rentease-app/pages/home/home.vue

915 lines
25 KiB
Vue
Raw 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="home-page">
<!-- 顶部欢迎区 -->
<view class="welcome-section">
<view class="welcome-content">
<view class="user-info">
<text class="welcome-title">欢迎使用租房管理系统</text>
<text class="welcome-date">{{currentDate}}</text>
</view>
</view>
<view class="quick-actions">
<view class="action-btn" @click="navigateTo('/pages/apartment-add/apartment-add')">
<uni-icons type="plus-filled" size="20" color="#FFFFFF"></uni-icons>
<text>添加公寓</text>
</view>
<view class="action-btn secondary" @click="navigateTo('/pages/bill-add/bill-add')">
<uni-icons type="wallet-filled" size="20" color="#667eea"></uni-icons>
<text>添加账单</text>
</view>
</view>
</view>
<!-- 核心KPI卡片区 -->
<view class="kpi-section">
<view class="kpi-card kpi-primary" @click="navigateTo('/pages/apartments/apartments')">
<view class="kpi-icon">
<uni-icons type="home-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<view class="kpi-content">
<text class="kpi-value">{{apartmentCount}}</text>
<text class="kpi-label">公寓总数</text>
</view>
</view>
<view class="kpi-card kpi-success" @click="navigateTo('/pages/rooms/rooms')">
<view class="kpi-icon">
<uni-icons type="shop-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<view class="kpi-content">
<text class="kpi-value">{{roomCount}}</text>
<text class="kpi-label">房间总数</text>
</view>
</view>
<view class="kpi-card kpi-warning" @click="navigateTo('/pages/bills/bills')">
<view class="kpi-icon">
<uni-icons type="wallet-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<view class="kpi-content">
<text class="kpi-value">{{formatMoney(monthlyReceivable)}}</text>
<text class="kpi-label">本月应收</text>
</view>
</view>
<view class="kpi-card kpi-info" @click="navigateTo('/pages/bills/bills')">
<view class="kpi-icon">
<uni-icons type="checkbox-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<view class="kpi-content">
<text class="kpi-value">{{formatMoney(monthlyReceived)}}</text>
<text class="kpi-label">本月实收</text>
</view>
</view>
</view>
<!-- 功能模块网格 -->
<view class="module-section">
<view class="section-header">
<text class="section-title">功能模块</text>
</view>
<view class="module-grid">
<view class="module-item" @click="navigateTo('/pages/apartments/apartments')">
<view class="module-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<uni-icons type="home-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<text class="module-name">公寓管理</text>
</view>
<view class="module-item" @click="navigateTo('/pages/renters/renters')">
<view class="module-icon" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
<uni-icons type="person-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<text class="module-name">租客管理</text>
</view>
<view class="module-item" @click="navigateTo('/pages/rentals/rentals')">
<view class="module-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<uni-icons type="paperclip" size="28" color="#FFFFFF"></uni-icons>
</view>
<text class="module-name">租赁管理</text>
</view>
<view class="module-item" @click="navigateTo('/pages/bills/bills')">
<view class="module-icon" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<uni-icons type="wallet-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<text class="module-name">账单管理</text>
</view>
<view class="module-item" @click="navigateTo('/pages/meter-readings/meter-readings')">
<view class="module-icon" style="background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);">
<uni-icons type="settings-filled" size="28" color="#667eea"></uni-icons>
</view>
<text class="module-name">抄表管理</text>
</view>
<view class="module-item" @click="navigateTo('/pages/billing/billing-center')">
<view class="module-icon" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);">
<uni-icons type="vip-filled" size="28" color="#e67e22"></uni-icons>
</view>
<text class="module-name">计费中心</text>
</view>
</view>
</view>
<!-- 房间状态分布 -->
<view class="status-section">
<view class="section-header">
<text class="section-title">房间状态分布</text>
<text class="section-subtitle">总房间数: {{roomCount}}</text>
</view>
<view class="status-list">
<view class="status-item" @click="navigateToRoomStatus('empty')">
<view class="status-info">
<view class="status-dot dot-empty"></view>
<text class="status-name">空房</text>
</view>
<view class="status-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{width: getPercentage(emptyRoomCount) + '%', background: '#909399'}"></view>
</view>
</view>
<text class="status-count">{{emptyRoomCount}}</text>
</view>
<view class="status-item" @click="navigateToRoomStatus('reserved')">
<view class="status-info">
<view class="status-dot dot-reserved"></view>
<text class="status-name">已预订</text>
</view>
<view class="status-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{width: getPercentage(reservedRoomCount) + '%', background: '#E6A23C'}"></view>
</view>
</view>
<text class="status-count">{{reservedRoomCount}}</text>
</view>
<view class="status-item" @click="navigateToRoomStatus('rented')">
<view class="status-info">
<view class="status-dot dot-rented"></view>
<text class="status-name">在租中</text>
</view>
<view class="status-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{width: getPercentage(rentedRoomCount) + '%', background: '#67C23A'}"></view>
</view>
</view>
<text class="status-count">{{rentedRoomCount}}</text>
</view>
<view class="status-item" @click="navigateToRoomStatus('soon_expire')">
<view class="status-info">
<view class="status-dot dot-soon"></view>
<text class="status-name">即将到期</text>
</view>
<view class="status-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{width: getPercentage(soonExpireRoomCount) + '%', background: '#F56C6C'}"></view>
</view>
</view>
<text class="status-count">{{soonExpireRoomCount}}</text>
</view>
<view class="status-item" @click="navigateToRoomStatus('expired')">
<view class="status-info">
<view class="status-dot dot-expired"></view>
<text class="status-name">已到期</text>
</view>
<view class="status-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{width: getPercentage(expiredRoomCount) + '%', background: '#F56C6C'}"></view>
</view>
</view>
<text class="status-count">{{expiredRoomCount}}</text>
</view>
<view class="status-item">
<view class="status-info">
<view class="status-dot dot-rate"></view>
<text class="status-name">出租率</text>
</view>
<view class="status-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{width: occupancyRate + '%', background: '#409EFF'}"></view>
</view>
</view>
<text class="status-count">{{occupancyRate}}%</text>
</view>
</view>
</view>
<!-- 预警提醒区 -->
<view class="alert-section">
<view class="section-header">
<text class="section-title">预警提醒</text>
</view>
<view class="alert-card alert-warning" @click="navigateToRentals('soon_expire')">
<view class="alert-icon">
<uni-icons type="notification-filled" size="24" color="#E6A23C"></uni-icons>
<view v-if="soonExpireRoomCount > 0" class="badge">{{soonExpireRoomCount}}</view>
</view>
<view class="alert-content">
<text class="alert-title">即将到期</text>
<text class="alert-desc">{{soonExpireRoomCount}}个房间租约即将到期</text>
</view>
<uni-icons type="arrowright" size="18" color="#C0C4CC"></uni-icons>
</view>
<view class="alert-card alert-danger" @click="navigateToRentals('expired')">
<view class="alert-icon">
<uni-icons type="info-filled" size="24" color="#F56C6C"></uni-icons>
<view v-if="expiredRoomCount > 0" class="badge">{{expiredRoomCount}}</view>
</view>
<view class="alert-content">
<text class="alert-title">已到期</text>
<text class="alert-desc">{{expiredRoomCount}}个房间租约已到期</text>
</view>
<uni-icons type="arrowright" size="18" color="#C0C4CC"></uni-icons>
</view>
<view class="alert-card alert-info" @click="navigateToBills('unpaid')">
<view class="alert-icon">
<uni-icons type="compose" size="24" color="#409EFF"></uni-icons>
<view v-if="unpaidBillCount > 0" class="badge">{{unpaidBillCount}}</view>
</view>
<view class="alert-content">
<text class="alert-title">未收账单</text>
<text class="alert-desc">{{unpaidBillCount}}笔账单待收款</text>
</view>
<uni-icons type="arrowright" size="18" color="#C0C4CC"></uni-icons>
</view>
</view>
<!-- 公寓分布 -->
<view class="apartment-section" v-if="apartmentHouseStats.length > 0">
<view class="section-header">
<text class="section-title">公寓房间状态分布</text>
<text class="refresh-btn" @click="refreshData">
<uni-icons type="refresh" size="14" color="#409EFF"></uni-icons>
刷新
</text>
</view>
<view class="apartment-list">
<view v-for="(item, index) in apartmentHouseStats" :key="index" class="apartment-item">
<view class="apartment-header">
<text class="apartment-name">{{item.apartment}}</text>
<text class="apartment-total">共{{item.total}}间</text>
</view>
<view class="apartment-stats">
<view class="stat-tag" :class="{active: item.empty > 0}" @click="navigateToRoomStatusByApartment(item.apartmentId, 'empty')">
<text>空房 {{item.empty}}</text>
</view>
<view class="stat-tag" :class="{active: item.reserved > 0}" @click="navigateToRoomStatusByApartment(item.apartmentId, 'reserved')">
<text>预订 {{item.reserved}}</text>
</view>
<view class="stat-tag success" @click="navigateToRoomStatusByApartment(item.apartmentId, 'rented')">
<text>在租 {{item.rented}}</text>
</view>
<view class="stat-tag warning" :class="{active: item.soon_expire > 0}" @click="navigateToRoomStatusByApartment(item.apartmentId, 'soon_expire')">
<text>即将到期 {{item.soon_expire}}</text>
</view>
<view class="stat-tag danger" :class="{active: item.expired > 0}" @click="navigateToRoomStatusByApartment(item.apartmentId, 'expired')">
<text>已到期 {{item.expired}}</text>
</view>
</view>
</view>
</view>
</view>
<view class="safe-area-bottom" style="height: 40rpx;"></view>
</view>
</template>
<script>
import statisticsApi from '@/api/statistics.js'
import billApi from '@/api/bill.js'
export default {
data() {
return {
currentDate: '',
apartmentCount: 0,
roomCount: 0,
emptyRoomCount: 0,
reservedRoomCount: 0,
rentedRoomCount: 0,
soonExpireRoomCount: 0,
expiredRoomCount: 0,
monthlyReceivable: 0,
monthlyReceived: 0,
unpaidBillCount: 0,
apartmentHouseStats: [],
isLoading: false
}
},
computed: {
occupancyRate() {
if (this.roomCount === 0) return 0
return Math.round((this.rentedRoomCount / this.roomCount) * 100)
}
},
onLoad() {
this.updateCurrentDate()
this.loadData()
},
onShow() {
// 避免与onLoad重复加载使用延迟确保onLoad先执行
setTimeout(() => {
this.loadData()
}, 100)
},
onPullDownRefresh() {
this.loadData().finally(() => {
uni.stopPullDownRefresh()
})
},
methods: {
updateCurrentDate() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[now.getDay()]
this.currentDate = `${year}${month}${day}${weekday}`
},
async loadData() {
// 防止重复加载
if (this.isLoading) return
this.isLoading = true
try {
await Promise.all([
this.loadDashboardStats(),
this.loadApartmentStats(),
this.loadBillStats()
])
} catch (error) {
console.error('加载数据失败:', error)
} finally {
this.isLoading = false
}
},
async loadDashboardStats() {
try {
const res = await statisticsApi.getDashboardStats()
const data = res.data || res
this.apartmentCount = data.apartmentCount || 0
this.roomCount = data.roomCount || 0
this.emptyRoomCount = data.emptyRoomCount || 0
this.reservedRoomCount = data.reservedRoomCount || 0
this.rentedRoomCount = data.rentedRoomCount || 0
this.soonExpireRoomCount = data.soonExpireRoomCount || 0
this.expiredRoomCount = data.expiredRoomCount || 0
} catch (error) {
console.error('加载仪表盘数据失败:', error)
}
},
async loadApartmentStats() {
try {
const res = await statisticsApi.getApartmentRoomStatusStats()
this.apartmentHouseStats = res.data || res || []
} catch (error) {
console.error('加载公寓统计失败:', error)
}
},
async loadBillStats() {
try {
const res = await billApi.getStatistics()
const data = res.data || {}
// 适配API返回的数据结构
const summary = data.summary || {}
const statusList = data.status || []
// 计算未收账单数量unpaid + partial
this.unpaidBillCount = statusList
.filter(item => item.status === 'unpaid' || item.status === 'partial')
.reduce((sum, item) => sum + (item.count || 0), 0)
// 从summary获取应收实收数据
this.monthlyReceivable = parseFloat(summary.totalReceivable) || 0
this.monthlyReceived = parseFloat(summary.totalReceived) || 0
} catch (error) {
console.error('加载账单统计失败:', error)
this.unpaidBillCount = 0
this.monthlyReceivable = 0
this.monthlyReceived = 0
}
},
getPercentage(count) {
if (this.roomCount === 0) return 0
return Math.round((count / this.roomCount) * 100)
},
formatMoney(value) {
if (!value || value === 0) return '¥0'
if (value >= 10000) {
return '¥' + (value / 10000).toFixed(1) + '万'
}
return '¥' + value.toLocaleString()
},
navigateTo(url) {
uni.navigateTo({ url })
},
navigateToRoomStatus(status) {
uni.navigateTo({
url: `/pages/properties/properties?status=${status}`
})
},
navigateToRoomStatusByApartment(apartmentId, status) {
uni.navigateTo({
url: `/pages/rooms/rooms?apartmentId=${apartmentId}&status=${status}`
})
},
navigateToRentals(subStatus) {
uni.navigateTo({
url: `/pages/rentals/rentals?subStatus=${subStatus}`
})
},
navigateToBills(status) {
uni.navigateTo({
url: `/pages/bills/bills?status=${status}`
})
},
refreshData() {
this.loadData()
uni.showToast({ title: '数据已刷新', icon: 'success' })
}
}
}
</script>
<style scoped>
.home-page {
min-height: 100vh;
background: linear-gradient(180deg, #F8FAFC 0%, #F1F5F9 100%);
padding-bottom: 40rpx;
}
/* 欢迎区 */
.welcome-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40rpx 32rpx;
border-radius: 0 0 40rpx 40rpx;
box-shadow: 0 8rpx 32rpx rgba(102, 126, 234, 0.3);
}
.welcome-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32rpx;
}
.user-info {
flex: 1;
}
.welcome-title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #FFFFFF;
margin-bottom: 12rpx;
}
.welcome-date {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
}
.quick-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
background: rgba(255, 255, 255, 0.25);
padding: 24rpx;
border-radius: 16rpx;
backdrop-filter: blur(10rpx);
transition: all 0.3s ease;
}
.action-btn:active {
transform: scale(0.98);
background: rgba(255, 255, 255, 0.35);
}
.action-btn.secondary {
background: #FFFFFF;
}
.action-btn.secondary:active {
background: #F8FAFC;
}
.action-btn text {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 600;
}
.action-btn.secondary text {
color: #667eea;
}
/* KPI卡片区 */
.kpi-section {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
padding: 32rpx;
margin-top: 0;
}
.kpi-card {
display: flex;
align-items: center;
padding: 28rpx;
border-radius: 20rpx;
background: #FFFFFF;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.kpi-card:active {
transform: translateY(-4rpx);
box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.1);
}
.kpi-icon {
width: 72rpx;
height: 72rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.kpi-primary .kpi-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.kpi-success .kpi-icon {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.kpi-warning .kpi-icon {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.kpi-info .kpi-icon {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.kpi-content {
flex: 1;
}
.kpi-value {
display: block;
font-size: 36rpx;
font-weight: 700;
color: #1E293B;
margin-bottom: 8rpx;
}
.kpi-label {
display: block;
font-size: 24rpx;
color: #64748B;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #1E293B;
}
.section-subtitle {
font-size: 24rpx;
color: #94A3B8;
}
/* 功能模块网格 */
.module-section {
background: #FFFFFF;
margin: 0 32rpx 24rpx;
padding: 28rpx;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.module-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.module-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
padding: 20rpx 12rpx;
border-radius: 16rpx;
transition: all 0.3s ease;
}
.module-item:active {
background: #F8FAFC;
transform: scale(0.95);
}
.module-icon {
width: 100rpx;
height: 100rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.12);
}
.module-name {
font-size: 26rpx;
font-weight: 600;
color: #1E293B;
}
/* 状态分布区 */
.status-section {
background: #FFFFFF;
margin: 0 32rpx 24rpx;
padding: 28rpx;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.status-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.status-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 12rpx 0;
}
.status-item:active {
opacity: 0.7;
}
.status-info {
display: flex;
align-items: center;
gap: 12rpx;
width: 140rpx;
flex-shrink: 0;
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.dot-empty { background: #909399; }
.dot-reserved { background: #E6A23C; }
.dot-rented { background: #67C23A; }
.dot-soon { background: #F56C6C; }
.dot-expired { background: #F56C6C; }
.dot-rate { background: #409EFF; }
.status-name {
font-size: 26rpx;
color: #64748B;
}
.status-progress {
flex: 1;
}
.progress-bar {
height: 12rpx;
background: #F1F5F9;
border-radius: 6rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 6rpx;
transition: width 0.5s ease;
}
.status-count {
width: 100rpx;
text-align: right;
font-size: 26rpx;
font-weight: 600;
color: #1E293B;
}
/* 预警提醒区 */
.alert-section {
margin: 0 32rpx 24rpx;
}
.alert-card {
display: flex;
align-items: center;
background: #FFFFFF;
padding: 28rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border-left: 8rpx solid transparent;
transition: all 0.3s ease;
}
.alert-card:active {
transform: translateX(8rpx);
}
.alert-warning {
border-left-color: #E6A23C;
}
.alert-danger {
border-left-color: #F56C6C;
}
.alert-info {
border-left-color: #409EFF;
}
.alert-icon {
position: relative;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16rpx;
margin-right: 24rpx;
}
.alert-warning .alert-icon {
background: #fdf6ec;
}
.alert-danger .alert-icon {
background: #fef0f0;
}
.alert-info .alert-icon {
background: #f0f9ff;
}
.badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 32rpx;
height: 32rpx;
background: #F56C6C;
color: #FFFFFF;
font-size: 20rpx;
font-weight: 600;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
}
.alert-content {
flex: 1;
}
.alert-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 8rpx;
}
.alert-desc {
display: block;
font-size: 24rpx;
color: #909399;
}
/* 公寓分布 */
.apartment-section {
background: #FFFFFF;
margin: 0 32rpx 24rpx;
padding: 28rpx;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.refresh-btn {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
color: #409EFF;
}
.refresh-btn:active {
opacity: 0.7;
}
.apartment-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.apartment-item {
padding-bottom: 24rpx;
border-bottom: 2rpx solid #F1F5F9;
}
.apartment-item:last-child {
padding-bottom: 0;
border-bottom: none;
}
.apartment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.apartment-name {
font-size: 30rpx;
font-weight: 600;
color: #1E293B;
}
.apartment-total {
font-size: 24rpx;
color: #64748B;
}
.apartment-stats {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.stat-tag {
padding: 10rpx 18rpx;
background: #F3F4F6;
border-radius: 8rpx;
font-size: 22rpx;
color: #9CA3AF;
transition: all 0.2s ease;
}
.stat-tag:active {
transform: scale(0.95);
}
.stat-tag.active {
background: #FEF3C7;
color: #D97706;
}
.stat-tag.success {
background: #D1FAE5;
color: #059669;
}
.stat-tag.warning.active {
background: #FEF3C7;
color: #D97706;
}
.stat-tag.danger.active {
background: #FEE2E2;
color: #DC2626;
}
</style>