rentease-app/pages/properties/properties.vue

761 lines
18 KiB
Vue
Raw Normal View History

2026-04-20 06:23:11 +00:00
<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" @click="showFilter">
<uni-icons type="settings-filled" size="22" color="#1E293B"></uni-icons>
</view>
<view class="nav-btn primary" @click="addProperty">
<uni-icons type="plus" size="22" color="#FFFFFF"></uni-icons>
</view>
</view>
</view>
</view>
<!-- 公寓选择器 -->
<view class="apartment-tabs" v-if="apartments.length > 0">
<scroll-view scroll-x class="tabs-scroll" 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 v-if="selectedApartmentId === apt.id" class="tab-indicator"></view>
</view>
</scroll-view>
</view>
<!-- 状态筛选 -->
<view class="status-filter">
<view
v-for="(filter, index) in statusFilters"
:key="index"
class="filter-item"
:class="{ active: currentFilter === filter.value }"
@click="setFilter(filter.value)"
>
<view class="filter-dot" :style="{ background: filter.color }"></view>
<text>{{filter.label}}</text>
<text class="filter-count">({{getStatusCount(filter.value)}})</text>
</view>
</view>
<!-- 房间网格 -->
<scroll-view scroll-y class="room-grid" @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="grid-content">
<view
v-for="(room, index) in filteredRooms"
:key="index"
class="room-card"
:class="room.status"
@click="handleRoomClick(room)"
>
<view class="room-header">
<text class="room-number">{{room.roomNumber}}</text>
<view class="room-status-badge" :style="{ background: getStatusColor(room.status) }">
{{getStatusText(room.status)}}
</view>
</view>
<view class="room-info">
<view class="info-row">
<text class="info-label">面积</text>
<text class="info-value">{{room.area || '--'}} </text>
</view>
<view class="info-row">
<text class="info-label">租金</text>
<text class="info-value price">¥{{room.rent || 0}}/</text>
</view>
</view>
<view class="room-footer" v-if="room.renterName">
<view class="renter-info">
<uni-icons type="person" size="14" color="#64748B"></uni-icons>
<text class="renter-name">{{room.renterName}}</text>
</view>
<view class="expire-tag" v-if="room.isExpiringSoon">
<uni-icons type="clock" size="12" color="#F56C6C"></uni-icons>
<text>{{room.remainingDays}}天到期</text>
</view>
</view>
<view class="room-actions" @click.stop>
<view v-if="room.status === 'empty'" class="action-btn rent" @click="rentRoom(room)">
<text>出租</text>
</view>
<view v-if="room.status === 'empty'" class="action-btn reserve" @click="reserveRoom(room)">
<text>预订</text>
</view>
<view v-if="room.status === 'reserved'" class="action-btn confirm" @click="confirmRent(room)">
<text>确认入住</text>
</view>
<view v-if="room.status === 'rented'" class="action-btn bill" @click="createBill(room)">
<text>记账</text>
</view>
</view>
</view>
</view>
<uni-load-more v-if="filteredRooms.length > 0" :status="loadStatus"></uni-load-more>
<view class="safe-area-bottom" style="height: 40rpx;"></view>
</scroll-view>
<!-- 底部统计栏 -->
<view class="bottom-stats">
<view class="stat-item">
<text class="stat-value">{{currentApartmentStats.total}}</text>
<text class="stat-label">总房间</text>
</view>
<view class="stat-item">
<text class="stat-value" style="color: #67C23A;">{{currentApartmentStats.rented}}</text>
<text class="stat-label">在租</text>
</view>
<view class="stat-item">
<text class="stat-value" style="color: #909399;">{{currentApartmentStats.empty}}</text>
<text class="stat-label">空房</text>
</view>
<view class="stat-item">
<text class="stat-value" style="color: #409EFF;">{{currentApartmentStats.occupancyRate}}%</text>
<text class="stat-label">出租率</text>
</view>
</view>
</view>
</template>
<script>
import apartmentApi from '@/api/apartment.js'
import roomApi from '@/api/room.js'
export default {
data() {
return {
isLoading: false,
isRefreshing: false,
apartments: [],
selectedApartmentId: null,
rooms: [],
currentFilter: 'all',
loadStatus: 'more',
page: 1,
pageSize: 20,
statusFilters: [
{ label: '全部', value: 'all', color: '#409EFF' },
{ label: '空房', value: 'empty', color: '#909399' },
{ label: '已预订', value: 'reserved', color: '#E6A23C' },
{ label: '在租', value: 'rented', color: '#67C23A' },
{ label: '即将到期', value: 'soon_expire', color: '#F56C6C' },
{ label: '已到期', value: 'expired', color: '#F56C6C' }
]
}
},
computed: {
filteredRooms() {
let list = this.rooms
if (this.currentFilter !== 'all') {
list = list.filter(room => room.status === this.currentFilter)
}
return list
},
currentApartmentStats() {
const apt = this.apartments.find(a => a.id === this.selectedApartmentId)
if (!apt) return { total: 0, rented: 0, empty: 0, occupancyRate: 0 }
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
return {
total,
rented,
empty,
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 apartmentApi.getList({ pageSize: 999 })
if (res.data) {
this.apartments = res.data.map(apt => ({
id: apt.id,
name: apt.name,
roomCount: apt.roomCount || 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.getList({
apartmentId: this.selectedApartmentId,
page: this.page,
pageSize: this.pageSize
})
if (res.data) {
const list = res.data.map(room => ({
id: room.id,
roomNumber: room.roomNumber,
area: room.area,
rent: room.rent,
status: this.getRoomStatus(room),
renterName: room.Renter?.name,
renterPhone: room.Renter?.phone,
rentalId: room.Rental?.id,
endDate: room.Rental?.endDate,
isExpiringSoon: this.isExpiringSoon(room.Rental?.endDate),
remainingDays: this.getRemainingDays(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) {
if (!room.Rental) return 'empty'
const status = room.Rental.status
const endDate = new Date(room.Rental.endDate)
const now = new Date()
const daysUntilExpire = Math.ceil((endDate - now) / (1000 * 60 * 60 * 24))
if (status === 'active') {
if (daysUntilExpire < 0) return 'expired'
if (daysUntilExpire <= 7) return 'soon_expire'
return 'rented'
}
return status
},
isExpiringSoon(endDate) {
if (!endDate) return false
const days = this.getRemainingDays(endDate)
return days >= 0 && days <= 7
},
getRemainingDays(endDate) {
if (!endDate) return 0
const end = new Date(endDate)
const now = new Date()
return Math.ceil((end - now) / (1000 * 60 * 60 * 24))
},
selectApartment(id) {
this.selectedApartmentId = id
this.page = 1
this.loadRooms()
},
setFilter(value) {
this.currentFilter = value
},
getStatusCount(status) {
if (status === 'all') return this.rooms.length
return this.rooms.filter(r => r.status === status).length
},
getStatusColor(status) {
const colors = {
empty: '#909399',
reserved: '#E6A23C',
rented: '#67C23A',
soon_expire: '#F56C6C',
expired: '#F56C6C'
}
return colors[status] || '#909399'
},
getStatusText(status) {
const texts = {
empty: '空房',
reserved: '已预订',
rented: '在租',
soon_expire: '即将到期',
expired: '已到期'
}
return texts[status] || 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() {
uni.showActionSheet({
itemList: ['添加公寓', '添加房间'],
success: (res) => {
if (res.tapIndex === 0) {
uni.navigateTo({ url: '/pages/property-add/property-add' })
} else {
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' })
}
}
}
</script>
<style scoped>
.properties-page {
min-height: 100vh;
background: #F8FAFC;
display: flex;
flex-direction: column;
padding-bottom: 120rpx;
}
/* 导航栏 */
.custom-nav {
background: #FFFFFF;
border-bottom: 2rpx solid #F1F5F9;
}
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
}
.nav-title {
font-size: 36rpx;
font-weight: 700;
color: #1E293B;
}
.nav-actions {
display: flex;
gap: 16rpx;
}
.nav-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #F8FAFC;
}
.nav-btn.primary {
background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%);
}
/* 公寓标签 */
.apartment-tabs {
background: #FFFFFF;
padding: 20rpx 0;
border-bottom: 2rpx solid #F1F5F9;
}
.tabs-scroll {
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: 16rpx;
background: #F8FAFC;
position: relative;
}
.tab-item.active {
background: #EFF6FF;
}
.tab-name {
font-size: 28rpx;
font-weight: 600;
color: #1E293B;
}
.tab-count {
font-size: 22rpx;
color: #64748B;
margin-top: 4rpx;
}
.tab-indicator {
position: absolute;
bottom: -20rpx;
width: 40rpx;
height: 4rpx;
background: #409EFF;
border-radius: 2rpx;
}
/* 状态筛选 */
.status-filter {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 24rpx 32rpx;
background: #FFFFFF;
}
.filter-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: #F8FAFC;
border-radius: 8rpx;
font-size: 24rpx;
color: #64748B;
}
.filter-item.active {
background: #EFF6FF;
color: #409EFF;
}
.filter-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
.filter-count {
font-size: 22rpx;
color: #94A3B8;
}
/* 房间网格 */
.room-grid {
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: #1E293B;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94A3B8;
margin-bottom: 40rpx;
}
.empty-btn {
background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%);
color: #FFFFFF;
font-size: 30rpx;
font-weight: 600;
padding: 24rpx 60rpx;
border-radius: 16rpx;
border: none;
}
.grid-content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.room-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border: 2rpx solid transparent;
}
.room-card.empty {
border-color: #E4E7ED;
}
.room-card.reserved {
border-color: #FDF6EC;
background: #FDF6EC;
}
.room-card.rented {
border-color: #F0F9EB;
background: #F0F9EB;
}
.room-card.soon_expire,
.room-card.expired {
border-color: #FEF0F0;
background: #FEF0F0;
}
.room-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.room-number {
font-size: 36rpx;
font-weight: 700;
color: #1E293B;
}
.room-status-badge {
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-size: 20rpx;
color: #FFFFFF;
font-weight: 500;
}
.room-info {
margin-bottom: 16rpx;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 8rpx;
}
.info-label {
font-size: 24rpx;
color: #909399;
}
.info-value {
font-size: 24rpx;
color: #1E293B;
}
.info-value.price {
color: #F56C6C;
font-weight: 600;
}
.room-footer {
padding-top: 16rpx;
border-top: 2rpx solid #F1F5F9;
margin-bottom: 16rpx;
}
.renter-info {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
}
.renter-name {
font-size: 26rpx;
color: #1E293B;
}
.expire-tag {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 22rpx;
color: #F56C6C;
}
.room-actions {
display: flex;
gap: 12rpx;
}
.action-btn {
flex: 1;
height: 56rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn text {
font-size: 24rpx;
font-weight: 500;
}
.action-btn.rent {
background: #409EFF;
}
.action-btn.rent text {
color: #FFFFFF;
}
.action-btn.reserve {
background: #E6A23C;
}
.action-btn.reserve text {
color: #FFFFFF;
}
.action-btn.confirm {
background: #67C23A;
}
.action-btn.confirm text {
color: #FFFFFF;
}
.action-btn.bill {
background: #909399;
}
.action-btn.bill text {
color: #FFFFFF;
}
/* 底部统计 */
.bottom-stats {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
background: #FFFFFF;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 2rpx solid #F1F5F9;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #1E293B;
margin-bottom: 4rpx;
}
.stat-label {
font-size: 22rpx;
color: #909399;
}
</style>