rentease-app/pages/properties/properties.vue

860 lines
21 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="properties-page">
<!-- 自定义导航栏 -->
<view class="custom-nav safe-area-top">
<view class="nav-content">
<text class="nav-title">房态图</text>
<view class="nav-actions">
<view class="nav-btn primary" @click="addProperty">
<uni-icons type="plus" size="22" color="#FFFFFF"></uni-icons>
</view>
</view>
</view>
</view>
<!-- 公寓选择器 -->
<view class="apartment-section-header" v-if="apartments.length > 0">
<scroll-view scroll-x class="apartment-tabs" show-scrollbar="false">
<view
v-for="(apt, index) in apartments"
:key="index"
class="tab-item"
:class="{ active: selectedApartmentId === apt.id }"
@click="selectApartment(apt.id)"
>
<text class="tab-name">{{apt.name}}</text>
<text class="tab-count">{{apt.roomCount}}间</text>
</view>
</scroll-view>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input-wrapper">
<uni-icons type="search" size="18" color="#909399"></uni-icons>
<input
class="search-input"
type="text"
v-model="searchKeyword"
placeholder="搜索房间号"
confirm-type="search"
@confirm="onSearch"
/>
<uni-icons v-if="searchKeyword" type="clear" size="16" color="#909399" @click="clearSearch"></uni-icons>
</view>
</view>
<!-- 状态图例 -->
<view class="status-legend">
<view class="legend-scroll">
<view
v-for="(filter, index) in statusFilters"
:key="index"
class="legend-item"
:class="{ active: currentFilter === filter.value }"
@click="setFilter(filter.value)"
>
<view class="legend-dot" :style="{ background: filter.color }"></view>
<text class="legend-text">{{filter.label}}</text>
<text class="legend-count">{{getStatusCount(filter.value)}}</text>
</view>
</view>
</view>
<!-- 房间列表 -->
<scroll-view
scroll-y
class="room-list"
@scrolltolower="loadMore"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 空状态 -->
<view v-if="filteredRooms.length === 0 && !isLoading" class="empty-state">
<view class="empty-icon">
<uni-icons type="home-filled" size="80" color="#CBD5E1"></uni-icons>
</view>
<text class="empty-title">暂无房间</text>
<text class="empty-desc">点击右上角添加房间</text>
<button class="empty-btn" @click="addRoom">添加房间</button>
</view>
<!-- 房间卡片网格 -->
<view v-else class="room-grid">
<view
v-for="(room, index) in filteredRooms"
:key="index"
class="room-card"
:class="getRoomStatusClass(room)"
@click="handleRoomClick(room)"
>
<text class="room-number">{{room.roomNumber}}</text>
<view class="room-info">
<text class="room-price" v-if="room.monthlyPrice">¥{{room.monthlyPrice}}/月</text>
<text class="room-tenant" v-if="room.renterName">{{room.renterName}}</text>
<text class="room-date" v-if="room.endDate">{{formatDate(room.endDate)}}到期</text>
</view>
<view class="room-status-tag">{{getRoomStatusText(room)}}</view>
</view>
</view>
<uni-load-more v-if="filteredRooms.length > 0" :status="loadStatus"></uni-load-more>
<!-- 底部统计栏 -->
<view class="bottom-stats">
<view class="stats-content">
<view class="stat-item">
<text class="stat-value">{{currentApartmentStats.total}}</text>
<text class="stat-label">总房间</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value" style="color: #67C23A;">{{currentApartmentStats.rented}}</text>
<text class="stat-label">在租</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value" style="color: #909399;">{{currentApartmentStats.empty}}</text>
<text class="stat-label">空房</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item highlight">
<text class="stat-value" style="color: #409EFF;">{{currentApartmentStats.occupancyRate}}%</text>
<text class="stat-label">出租率</text>
</view>
</view>
</view>
<view class="safe-area-bottom" style="height: 120rpx;"></view>
</scroll-view>
</view>
</template>
<script>
import apartmentApi from '@/api/apartment.js'
import roomApi from '@/api/room.js'
import statisticsApi from '@/api/statistics.js'
export default {
data() {
return {
isLoading: false,
isRefreshing: false,
apartments: [],
selectedApartmentId: null,
rooms: [],
apartmentStats: {}, // 公寓统计数据
currentFilter: 'all',
loadStatus: 'more',
page: 1,
pageSize: 20,
searchKeyword: '',
statusFilters: [
{ label: '全部', value: 'all', color: '#409EFF' },
{ label: '空置', value: 'empty', color: '#67C23A' },
{ label: '已租', value: 'rented', color: '#409EFF' },
{ label: '即将到期', value: 'soon_expire', color: '#E6A23C' },
{ label: '已到期', value: 'expired', color: '#F56C6C' },
{ label: '预定', value: 'reserved', color: '#909399' }
]
}
},
computed: {
filteredRooms() {
let list = this.rooms
// 按状态筛选
if (this.currentFilter !== 'all') {
list = list.filter(room => room.status === this.currentFilter)
}
// 按关键词搜索
if (this.searchKeyword.trim()) {
const keyword = this.searchKeyword.trim().toLowerCase()
list = list.filter(room => room.roomNumber.toLowerCase().includes(keyword))
}
return list
},
currentApartmentStats() {
const total = this.rooms.length
const rented = this.rooms.filter(r => r.status === 'rented').length
const empty = this.rooms.filter(r => r.status === 'empty').length
const soonExpire = this.rooms.filter(r => r.status === 'soon_expire').length
return {
total,
rented,
empty,
soonExpire,
occupancyRate: total > 0 ? Math.round((rented / total) * 100) : 0
}
}
},
onLoad(options) {
this.loadApartments()
},
onShow() {
if (this.selectedApartmentId) {
this.loadRooms()
}
},
methods: {
async loadApartments() {
try {
// 使用统计接口获取公寓列表及统计数据
const res = await statisticsApi.getApartmentRoomStatusStats()
if (res.data) {
this.apartments = res.data.map(apt => ({
id: apt.apartmentId,
name: apt.apartment,
roomCount: apt.total || 0
}))
// 保存统计数据
this.apartmentStats = {}
res.data.forEach(apt => {
this.apartmentStats[apt.apartmentId] = {
total: apt.total || 0,
empty: apt.empty || 0,
rented: apt.rented || 0,
soonExpire: apt.soon_expire || 0,
expired: apt.expired || 0,
reserved: apt.reserved || 0
}
})
if (this.apartments.length > 0 && !this.selectedApartmentId) {
this.selectedApartmentId = this.apartments[0].id
this.loadRooms()
}
}
} catch (error) {
console.error('加载公寓列表失败:', error)
}
},
async loadRooms() {
if (!this.selectedApartmentId) return
this.isLoading = true
try {
const res = await roomApi.list({
apartmentId: this.selectedApartmentId
})
if (res.data) {
const list = res.data.map(room => ({
id: room.id,
roomNumber: room.roomNumber,
area: room.area,
monthlyPrice: room.rent || room.monthlyPrice,
status: this.getRoomStatus(room),
rentalStatus: room.rentalStatus,
renterName: room.Renter?.name,
renterPhone: room.Renter?.phone,
rentalId: room.Rental?.id,
endDate: room.Rental?.endDate
}))
if (this.page === 1) {
this.rooms = list
} else {
this.rooms = [...this.rooms, ...list]
}
this.loadStatus = this.rooms.length < (res.total || 0) ? 'more' : 'noMore'
}
} catch (error) {
console.error('加载房间列表失败:', error)
} finally {
this.isLoading = false
this.isRefreshing = false
}
},
getRoomStatus(room) {
// 与Web端Status.vue保持一致直接使用room.status
return room.status || 'empty'
},
getRoomStatusClass(room) {
// 根据status和rentalStatus确定样式类与Web端保持一致
if (room.status === 'empty') return 'status-empty'
if (room.status === 'rented') {
if (room.rentalStatus === 'expired') return 'status-expired'
if (room.rentalStatus === 'soon_expire') return 'status-soon-expire'
return 'status-rented'
}
if (room.status === 'reserved') return 'status-reserved'
return ''
},
formatDate(date) {
if (!date) return '-'
const d = new Date(date)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
},
selectApartment(id) {
this.selectedApartmentId = id
this.page = 1
this.loadRooms()
},
setFilter(value) {
this.currentFilter = value
},
getStatusCount(status) {
// 使用接口返回的统计数据
const stats = this.apartmentStats[this.selectedApartmentId]
if (!stats) return 0
if (status === 'all') return stats.total
const keyMap = {
'empty': 'empty',
'rented': 'rented',
'soon_expire': 'soonExpire',
'expired': 'expired',
'reserved': 'reserved'
}
return stats[keyMap[status]] || 0
},
getStatusColor(status) {
const colors = {
empty: '#67C23A',
rented: '#409EFF',
soon_expire: '#E6A23C',
expired: '#F56C6C',
reserved: '#909399'
}
return colors[status] || '#909399'
},
getStatusText(status) {
// 保持与Web端一致的文本映射
const texts = {
empty: '空置',
rented: '已租',
soon_expire: '即将到期',
expired: '已到期',
reserved: '预定'
}
return texts[status] || status
},
getRoomStatusText(room) {
// 与Web端Status.vue#getRoomStatusText保持一致
if (room.status === 'empty') return '空置'
if (room.status === 'rented') {
if (room.rentalStatus === 'expired') return '已到期'
if (room.rentalStatus === 'soon_expire') return '即将到期'
return '已租'
}
if (room.status === 'reserved') return '预定'
return room.status
},
handleRoomClick(room) {
uni.navigateTo({
url: `/pages/room-detail/room-detail?id=${room.id}`
})
},
rentRoom(room) {
uni.navigateTo({
url: `/pages/rental-add/rental-add?roomId=${room.id}`
})
},
reserveRoom(room) {
uni.showModal({
title: '预订房间',
content: `确定要预订房间 ${room.roomNumber} 吗?`,
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '预订功能开发中', icon: 'none' })
}
}
})
},
confirmRent(room) {
uni.navigateTo({
url: `/pages/rental-add/rental-add?roomId=${room.id}`
})
},
createBill(room) {
uni.navigateTo({
url: `/pages/bill-add/bill-add?roomId=${room.id}&renterId=${room.renterId}`
})
},
addProperty() {
// 直接跳转到添加房间页面
this.addRoom()
},
addRoom() {
if (!this.selectedApartmentId) {
uni.showToast({ title: '请先选择公寓', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/room-add/room-add?apartmentId=${this.selectedApartmentId}`
})
},
onRefresh() {
this.isRefreshing = true
this.page = 1
this.loadRooms()
},
loadMore() {
if (this.loadStatus === 'noMore') return
this.loadStatus = 'loading'
this.page++
this.loadRooms()
},
showFilter() {
uni.showToast({ title: '筛选功能', icon: 'none' })
},
onSearch() {
// 搜索时不需要重新加载数据computed 会自动过滤
uni.showToast({ title: `搜索: ${this.searchKeyword}`, icon: 'none', duration: 1000 })
},
clearSearch() {
this.searchKeyword = ''
}
}
}
</script>
<style scoped>
.properties-page {
min-height: 100vh;
background: #F5F7FA;
display: flex;
flex-direction: column;
}
/* 导航栏 */
.custom-nav {
background: #FFFFFF;
border-bottom: 1rpx solid #EBEEF5;
}
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
}
.nav-title {
font-size: 36rpx;
font-weight: 700;
color: #303133;
}
.nav-actions {
display: flex;
gap: 16rpx;
}
.nav-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #F5F7FA;
transition: all 0.2s;
}
.nav-btn:active {
transform: scale(0.95);
background: #EBEEF5;
}
.nav-btn.primary {
background: linear-gradient(135deg, #409EFF 0%, #66B1FF 100%);
box-shadow: 0 4rpx 16rpx rgba(64, 158, 255, 0.3);
}
/* 统计概览卡片 */
.statistics-overview {
padding: 24rpx 32rpx;
background: #FFFFFF;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
}
.stat-card {
border-radius: 16rpx;
padding: 20rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.1;
background: linear-gradient(135deg, rgba(255,255,255,0.3) 0%, transparent 100%);
}
.stat-card.total {
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.3);
}
.stat-card.empty {
background: linear-gradient(135deg, #11998E 0%, #38EF7D 100%);
box-shadow: 0 4rpx 16rpx rgba(17, 153, 142, 0.3);
}
.stat-card.rented {
background: linear-gradient(135deg, #4FACFE 0%, #00F2FE 100%);
box-shadow: 0 4rpx 16rpx rgba(79, 172, 254, 0.3);
}
.stat-card.soon-expire {
background: linear-gradient(135deg, #FA709A 0%, #FEE140 100%);
box-shadow: 0 4rpx 16rpx rgba(250, 112, 154, 0.3);
}
.stat-icon {
margin-bottom: 8rpx;
opacity: 0.9;
}
.stat-content {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-number {
font-size: 36rpx;
font-weight: 700;
color: #FFFFFF;
line-height: 1.2;
}
.stat-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 4rpx;
}
/* 搜索栏 */
.search-bar {
background: #FFFFFF;
padding: 20rpx 32rpx;
border-bottom: 1rpx solid #EBEEF5;
}
.search-input-wrapper {
display: flex;
align-items: center;
background: #F5F7FA;
border-radius: 32rpx;
padding: 16rpx 24rpx;
gap: 16rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #303133;
height: 40rpx;
}
.search-input::placeholder {
color: #909399;
}
/* 公寓选择器 */
.apartment-section-header {
background: #FFFFFF;
border-bottom: 1rpx solid #EBEEF5;
padding: 16rpx 0;
}
.apartment-tabs {
white-space: nowrap;
padding: 0 32rpx;
}
.tab-item {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 16rpx 32rpx;
margin-right: 16rpx;
border-radius: 12rpx;
background: #F5F7FA;
border: 2rpx solid transparent;
transition: all 0.2s;
}
.tab-item.active {
background: #ECF5FF;
border-color: #409EFF;
}
.tab-name {
font-size: 28rpx;
font-weight: 600;
color: #303133;
}
.tab-count {
font-size: 22rpx;
color: #909399;
margin-top: 4rpx;
}
/* 状态图例 */
.status-legend {
background: #FFFFFF;
padding: 20rpx 0;
border-bottom: 1rpx solid #EBEEF5;
}
.legend-scroll {
display: flex;
padding: 0 32rpx;
gap: 16rpx;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: #F5F7FA;
border-radius: 24rpx;
border: 2rpx solid transparent;
transition: all 0.2s;
}
.legend-item.active {
background: #ECF5FF;
border-color: #409EFF;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.legend-text {
font-size: 24rpx;
color: #606266;
}
.legend-count {
font-size: 22rpx;
color: #909399;
background: #FFFFFF;
padding: 2rpx 10rpx;
border-radius: 10rpx;
margin-left: 4rpx;
}
/* 房间列表 */
.room-list {
flex: 1;
padding: 24rpx 32rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 60rpx;
}
.empty-icon {
margin-bottom: 32rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #303133;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #909399;
margin-bottom: 40rpx;
}
.empty-btn {
background: linear-gradient(135deg, #409EFF 0%, #66B1FF 100%);
color: #FFFFFF;
font-size: 28rpx;
font-weight: 500;
padding: 16rpx 40rpx;
border-radius: 12rpx;
border: none;
box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.3);
line-height: 1.5;
}
/* 房间卡片网格 */
.room-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.room-card {
background: #FFFFFF;
border: 2rpx solid #EBEEF5;
border-radius: 16rpx;
padding: 24rpx;
transition: all 0.2s;
position: relative;
overflow: hidden;
min-height: 160rpx;
}
.room-card:active {
transform: scale(0.98);
}
/* 状态样式 */
.room-card.status-empty {
border-color: #67C23A;
background: #F0F9FF;
}
.room-card.status-rented {
border-color: #409EFF;
background: #ECF5FF;
}
.room-card.status-soon-expire {
border-color: #E6A23C;
background: #FDF6EC;
}
.room-card.status-expired {
border-color: #F56C6C;
background: #FEF0F0;
}
.room-card.status-reserved {
border-color: #909399;
background: #F4F4F5;
}
.room-number {
font-size: 32rpx;
font-weight: 700;
color: #303133;
margin-bottom: 12rpx;
display: block;
}
.room-info {
font-size: 24rpx;
color: #606266;
line-height: 1.6;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.room-price {
color: #F56C6C;
font-weight: 600;
}
.room-tenant {
color: #409EFF;
}
.room-date {
color: #909399;
}
.room-status-tag {
position: absolute;
top: 20rpx;
right: 20rpx;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.9);
color: #606266;
font-weight: 500;
}
/* 底部统计栏 */
.bottom-stats {
background: #FFFFFF;
border-top: 1rpx solid #EBEEF5;
border-radius: 16rpx;
margin: 24rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.stats-content {
display: flex;
justify-content: space-around;
align-items: center;
padding: 20rpx 32rpx;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-item.highlight {
background: linear-gradient(135deg, #ECF5FF 0%, #F5F7FA 100%);
border-radius: 12rpx;
padding: 12rpx 0;
margin: -12rpx 0;
}
.stat-divider {
width: 2rpx;
height: 40rpx;
background: #EBEEF5;
}
.bottom-stats .stat-value {
font-size: 32rpx;
font-weight: 700;
color: #303133;
margin-bottom: 4rpx;
}
.bottom-stats .stat-label {
font-size: 22rpx;
color: #909399;
}
</style>