This commit is contained in:
wangxiaoxian 2026-04-22 14:47:04 +08:00
parent dc892c150b
commit 166480323c
21 changed files with 4461 additions and 3001 deletions

View File

@ -6,7 +6,7 @@ import { get, post, put, del } from '../utils/request.js'
export default {
/**
* 获取公寓列表
* 获取公寓列表分页
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
@ -17,6 +17,15 @@ export default {
return get('/apartments', params)
},
/**
* 获取公寓列表全部
* @param {Object} params - 查询参数
* @returns {Promise}
*/
list(params = {}) {
return get('/apartments/list', params)
},
/**
* 获取公寓详情
* @param {number} id - 公寓ID

View File

@ -10,11 +10,22 @@ export default {
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
* @param {string} params.month - 月份筛选
* @param {string} params.status - 状态筛选
* @param {string} params.type - 类型筛选 income/expense
* @param {string} params.status - 状态筛选 unpaid/partial/paid/cancelled
* @param {string} params.startDate - 开始日期
* @param {string} params.endDate - 结束日期
* @returns {Promise}
*/
getList(params = {}) {
return get('/bills/list', params)
},
/**
* 获取所有账单与Web端兼容的别名方法
* @param {Object} params - 查询参数
* @returns {Promise}
*/
getAll(params = {}) {
return get('/bills', params)
},

View File

@ -11,9 +11,10 @@ export default {
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
* @param {string} params.keyword - 搜索关键词
* @param {string} params.status - 状态
* @returns {Promise}
*/
getList(params = {}) {
getAll(params = {}) {
return get('/renters', params)
},

View File

@ -6,7 +6,7 @@ import { get, post, put, del } from '../utils/request.js'
export default {
/**
* 获取房间列表
* 获取房间列表分页
* @param {Object} params - 查询参数
* @param {number} params.apartmentId - 公寓ID
* @param {string} params.status - 房间状态
@ -16,6 +16,17 @@ export default {
return get('/rooms', params)
},
/**
* 获取房间列表全部
* @param {Object} params - 查询参数
* @param {number} params.apartmentId - 公寓ID
* @param {string} params.status - 房间状态
* @returns {Promise}
*/
list(params = {}) {
return get('/rooms/list', params)
},
/**
* 获取房间详情
* @param {number} id - 房间ID

File diff suppressed because it is too large Load Diff

View File

@ -30,85 +30,33 @@
/>
</view>
<view class="form-item">
<text class="item-label">公寓地址</text>
<text class="item-label">地址</text>
<input
type="text"
v-model="form.address"
placeholder="请输入公寓地址"
placeholder="请输入地址"
class="item-input"
/>
</view>
<view class="form-item">
<text class="item-label">负责人</text>
<input
type="text"
v-model="form.manager"
placeholder="请输入负责人姓名"
class="item-input"
/>
</view>
<view class="form-item">
<text class="item-label">联系电话</text>
<input
type="number"
v-model="form.phone"
placeholder="请输入联系电话"
class="item-input"
maxlength="11"
/>
</view>
</view>
</view>
<!-- 状态设置 -->
<view class="form-section">
<view class="section-title">状态设置</view>
<view class="form-card">
<view class="form-item">
<text class="item-label">运营状态</text>
<view class="status-options">
<view
v-for="(option, index) in statusOptions"
:key="index"
class="status-option"
:class="{ active: form.status === option.value }"
@click="form.status = option.value"
>
<text>{{option.label}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 备注 -->
<view class="form-section">
<view class="section-title">备注信息</view>
<view class="form-card">
<view class="form-item">
<view class="form-item textarea-item">
<text class="item-label">描述</text>
<textarea
v-model="form.remark"
placeholder="请输入备注信息(选填)"
v-model="form.description"
placeholder="请输入描述"
class="remark-input"
maxlength="200"
maxlength="500"
/>
<text class="word-count">{{form.remark.length}}/200</text>
</view>
</view>
</view>
<!-- 删除按钮编辑模式 -->
<view v-if="isEdit" class="delete-section">
<button class="delete-btn" @click="confirmDelete">删除公寓</button>
</view>
<view class="safe-area-bottom" style="height: 40rpx;"></view>
</scroll-view>
</view>
</template>
<script>
import { apartmentApi } from '../../api/index.js'
import { apartmentApi } from '@/api/index.js'
export default {
data() {
@ -118,38 +66,31 @@
form: {
name: '',
address: '',
manager: '',
phone: '',
status: 'active',
remark: ''
},
statusOptions: [
{ label: '运营中', value: 'active' },
{ label: '已停用', value: 'inactive' },
{ label: '装修中', value: 'renovating' }
]
description: ''
}
}
},
onLoad(options) {
if (options.id) {
//
if (options.mode === 'edit' && options.id) {
this.isEdit = true
this.apartmentId = options.id
this.loadApartmentDetail(options.id)
this.loadApartmentDetail()
}
},
methods: {
async loadApartmentDetail(id) {
//
async loadApartmentDetail() {
try {
uni.showLoading({ title: '加载中...' })
const res = await apartmentApi.getDetail(id)
const data = res.data || {}
const res = await apartmentApi.getDetail(this.apartmentId)
const data = res.data
//
this.form = {
name: data.name || '',
address: data.address || '',
manager: data.manager || '',
phone: data.phone || '',
status: data.status || 'active',
remark: data.remark || ''
description: data.description || ''
}
} catch (error) {
uni.showToast({ title: '加载失败', icon: 'none' })
@ -168,9 +109,11 @@
uni.showLoading({ title: '保存中...' })
if (this.isEdit) {
//
await apartmentApi.update(this.apartmentId, this.form)
uni.showToast({ title: '更新成功', icon: 'success' })
uni.showToast({ title: '修改成功', icon: 'success' })
} else {
//
await apartmentApi.create(this.form)
uni.showToast({ title: '添加成功', icon: 'success' })
}
@ -179,35 +122,8 @@
uni.navigateBack()
}, 1500)
} catch (error) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
uni.hideLoading()
}
},
confirmDelete() {
uni.showModal({
title: '确认删除',
content: '删除后无法恢复,确定要删除该公寓吗?',
confirmColor: '#EF4444',
success: (res) => {
if (res.confirm) {
this.deleteApartment()
}
}
})
},
async deleteApartment() {
try {
uni.showLoading({ title: '删除中...' })
await apartmentApi.delete(this.apartmentId)
uni.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
uni.showToast({ title: '删除失败', icon: 'none' })
const msg = error.data && error.data.message
uni.showToast({ title: msg || (this.isEdit ? '修改失败' : '添加失败'), icon: 'none' })
} finally {
uni.hideLoading()
}
@ -305,11 +221,21 @@
border-bottom: none;
}
.textarea-item {
flex-direction: column;
align-items: flex-start;
}
.item-label {
width: 160rpx;
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
flex-shrink: 0;
}
.textarea-item .item-label {
margin-bottom: 16rpx;
}
.item-label.required::after {
@ -325,26 +251,6 @@
text-align: right;
}
.status-options {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.status-option {
padding: 12rpx 24rpx;
border-radius: 8rpx;
background: #F1F5F9;
font-size: 26rpx;
color: #64748B;
}
.status-option.active {
background: #DBEAFE;
color: #2563EB;
}
.remark-input {
width: 100%;
height: 200rpx;
@ -352,31 +258,4 @@
color: #1E293B;
line-height: 1.6;
}
.word-count {
position: absolute;
right: 32rpx;
bottom: 24rpx;
font-size: 24rpx;
color: #94A3B8;
}
/* 删除区域 */
.delete-section {
margin-top: 48rpx;
}
.delete-btn {
width: 100%;
height: 96rpx;
background: #FEE2E2;
color: #EF4444;
font-size: 32rpx;
font-weight: 600;
border-radius: 16rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -84,8 +84,8 @@
<text class="stat-value rented">{{item.rentedCount || 0}}</text>
</view>
<view class="room-stat">
<text class="stat-label">月租</text>
<text class="stat-value">¥{{item.monthlyIncome || 0}}</text>
<text class="stat-label">预订</text>
<text class="stat-value">{{item.bookedCount || 0}}</text>
</view>
</view>
@ -100,6 +100,10 @@
<uni-icons type="home-filled" size="16" color="#64748B"></uni-icons>
<text>房源</text>
</view>
<view class="action-btn delete" @click.stop="deleteApartment(item)">
<uni-icons type="trash" size="16" color="#EF4444"></uni-icons>
<text class="delete-text">删除</text>
</view>
</view>
</view>
</view>
@ -123,7 +127,7 @@
</template>
<script>
import { apartmentApi, roomApi } from '../../api/index.js'
import { apartmentApi, statisticsApi } from '../../api/index.js'
export default {
data() {
@ -154,25 +158,45 @@
this.isLoading = true
try {
//
const res = await apartmentApi.list({
keyword: this.searchKeyword,
// - web使getAllgetList
const res = await apartmentApi.getList({
name: this.searchKeyword,
page: this.page,
pageSize: this.pageSize
})
const apartments = res.data || []
// : { data: { list: [], total: n, page: n, pageSize: n } }
const apartments = res.data.list || []
const total = res.data.total || 0
//
for (let apt of apartments) {
try {
const roomRes = await roomApi.list({ apartmentId: apt.id })
const rooms = roomRes.data || []
apt.roomCount = rooms.length
apt.emptyCount = rooms.filter(r => r.status === 'empty').length
apt.rentedCount = rooms.filter(r => r.status === 'rented').length
} catch (e) {
console.error('加载房间统计失败:', e)
//
try {
const statsRes = await statisticsApi.getApartmentRoomStatusStats()
const apartmentStats = statsRes.data || []
//
for (let apt of apartments) {
const stat = apartmentStats.find(s => s.apartmentId === apt.id)
if (stat) {
apt.roomCount = stat.total || 0
apt.emptyCount = stat.empty || 0
apt.rentedCount = stat.rented || 0
apt.bookedCount = stat.reserved || 0
} else {
apt.roomCount = 0
apt.emptyCount = 0
apt.rentedCount = 0
apt.bookedCount = 0
}
}
} catch (e) {
console.error('加载房间统计失败:', e)
//
for (let apt of apartments) {
apt.roomCount = 0
apt.emptyCount = 0
apt.rentedCount = 0
apt.bookedCount = 0
}
}
@ -182,11 +206,13 @@
this.apartmentList = [...this.apartmentList, ...apartments]
}
this.hasMore = apartments.length >= this.pageSize
// total
this.hasMore = this.apartmentList.length < total
//
this.updateStats()
} catch (error) {
console.error('加载公寓列表失败:', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.isLoading = false
@ -242,6 +268,31 @@
})
},
deleteApartment(item) {
uni.showModal({
title: '确认删除',
content: `确定要删除公寓"${item.name}"吗?`,
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '删除中...' })
await apartmentApi.delete(item.id)
uni.showToast({ title: '删除成功', icon: 'success' })
//
this.page = 1
this.loadData()
} catch (error) {
const msg = error.data && error.data.message
uni.showToast({ title: msg || '删除失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
}
})
},
goBack() {
uni.navigateBack()
},
@ -492,6 +543,14 @@
color: #64748B;
}
.action-btn.delete {
color: #EF4444;
}
.action-btn.delete .delete-text {
color: #EF4444;
}
/* 空状态 */
.empty-state {
display: flex;

View File

@ -18,10 +18,10 @@
<!-- 类型选择 -->
<view class="type-section">
<view class="type-tabs">
<view class="type-tab" :class="{ active: form.type === 'income' }" @click="form.type = 'income'">
<view class="type-tab" :class="{ active: form.type === 'income' }" @click="switchType('income')">
<text>收入</text>
</view>
<view class="type-tab" :class="{ active: form.type === 'expense' }" @click="form.type = 'expense'">
<view class="type-tab" :class="{ active: form.type === 'expense' }" @click="switchType('expense')">
<text>支出</text>
</view>
</view>
@ -39,13 +39,6 @@
<!-- 表单信息 -->
<view class="form-section">
<view class="form-card">
<view class="form-item" @click="selectRoom">
<text class="item-label">关联房间</text>
<view class="item-value">
<text :class="{ placeholder: !selectedRoom }">{{selectedRoom ? selectedRoom.roomNumber : '请选择房间'}}</text>
<uni-icons type="right" size="16" color="#94A3B8"></uni-icons>
</view>
</view>
<view class="form-item" @click="selectCategory">
<text class="item-label required">收支类目</text>
<view class="item-value">
@ -53,6 +46,20 @@
<uni-icons type="right" size="16" color="#94A3B8"></uni-icons>
</view>
</view>
<view class="form-item" @click="selectRoom">
<text class="item-label">关联房间</text>
<view class="item-value">
<text :class="{ placeholder: !selectedRoom }">{{selectedRoom ? selectedRoom.roomNumber : '请选择房间'}}</text>
<uni-icons type="right" size="16" color="#94A3B8"></uni-icons>
</view>
</view>
<view class="form-item" @click="selectRental" :class="{ disabled: !form.roomId }">
<text class="item-label">关联租约</text>
<view class="item-value">
<text :class="{ placeholder: !selectedRental }">{{selectedRental ? selectedRental.tenantName : (form.roomId ? '请选择租约' : '请先选择房间')}}</text>
<uni-icons type="right" size="16" color="#94A3B8"></uni-icons>
</view>
</view>
<view class="form-item" @click="selectDate">
<text class="item-label required">账单日期</text>
<view class="item-value">
@ -60,6 +67,13 @@
<uni-icons type="right" size="16" color="#94A3B8"></uni-icons>
</view>
</view>
<view class="form-item" @click="selectBillMonth">
<text class="item-label">账单月份</text>
<view class="item-value">
<text :class="{ placeholder: !form.billMonth }">{{form.billMonth || '请选择账单月份'}}</text>
<uni-icons type="right" size="16" color="#94A3B8"></uni-icons>
</view>
</view>
<view class="form-item">
<text class="item-label">备注</text>
<input type="text" v-model="form.remark" placeholder="请输入备注" class="item-input"/>
@ -73,7 +87,7 @@
</template>
<script>
import { billApi, roomApi } from '../../api/index.js'
import { billApi, roomApi, rentalApi, settingApi } from '../../api/index.js'
export default {
data() {
@ -81,54 +95,124 @@
form: {
type: 'income',
receivableAmount: '',
roomId: '',
roomId: null,
rentalId: null,
category: '',
billDate: this.formatDate(new Date()),
billMonth: '',
remark: ''
},
selectedRoom: null,
categories: [
{ code: 'rent', name: '租金', type: 'income' },
{ code: 'deposit', name: '押金', type: 'income' },
{ code: 'water', name: '水费', type: 'income' },
{ code: 'electricity', name: '电费', type: 'income' },
{ code: 'maintenance', name: '维修费', type: 'expense' },
{ code: 'property', name: '物业费', type: 'expense' },
{ code: 'other', name: '其他', type: 'all' }
]
selectedRental: null,
rooms: [],
roomRentals: [],
incomeCategories: [],
expenseCategories: []
}
},
onLoad() {
this.loadRooms()
this.loadCategories()
//
const today = new Date()
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0)
this.form.billDate = this.formatDate(lastDay)
//
this.form.billMonth = this.formatMonth(today)
},
methods: {
async selectRoom() {
//
switchType(type) {
this.form.type = type
this.form.category = ''
},
//
async loadCategories() {
try {
const res = await roomApi.list()
const rooms = res.data || []
if (rooms.length === 0) {
uni.showToast({ title: '暂无房间数据', icon: 'none' })
return
}
const roomList = rooms.map(r => r.roomNumber)
uni.showActionSheet({
itemList: roomList,
success: (res) => {
this.selectedRoom = rooms[res.tapIndex]
this.form.roomId = this.selectedRoom.id
}
})
const res = await settingApi.getCategories()
const categories = res.data || []
this.incomeCategories = categories.filter(c => c.type === 'income')
this.expenseCategories = categories.filter(c => c.type === 'expense')
} catch (error) {
uni.showToast({ title: '加载房间失败', icon: 'none' })
console.error('加载类目列表失败:', error)
}
},
selectCategory() {
const filteredCategories = this.categories.filter(c => c.type === this.form.type || c.type === 'all')
const categoryList = filteredCategories.map(c => c.name)
//
async loadRooms() {
try {
const res = await roomApi.list()
this.rooms = res.data || []
} catch (error) {
uni.showToast({ title: '加载房间列表失败', icon: 'none' })
}
},
//
async loadRoomRentals(roomId) {
try {
const res = await rentalApi.getAll({ roomId, status: 'active' })
this.roomRentals = res.data || []
} catch (error) {
console.error('加载租约列表失败:', error)
}
},
async selectRoom() {
if (this.rooms.length === 0) {
uni.showToast({ title: '暂无房间数据', icon: 'none' })
return
}
const roomList = this.rooms.map(r => (r.apartmentName || '') + ' - ' + r.roomNumber)
uni.showActionSheet({
itemList: categoryList,
itemList: roomList,
success: (res) => {
this.form.category = filteredCategories[res.tapIndex].code
this.selectedRoom = this.rooms[res.tapIndex]
this.form.roomId = this.selectedRoom.id
//
this.selectedRental = null
this.form.rentalId = null
//
this.loadRoomRentals(this.form.roomId)
}
})
},
selectCategory() {
const categories = this.form.type === 'income' ? this.incomeCategories : this.expenseCategories
if (categories.length === 0) {
uni.showToast({ title: '暂无类目数据', icon: 'none' })
return
}
const categoryList = categories.map(c => c.name)
uni.showActionSheet({
itemList: categoryList,
success: (res) => {
this.form.category = categories[res.tapIndex].code
}
})
},
selectRental() {
if (!this.form.roomId) {
uni.showToast({ title: '请先选择房间', icon: 'none' })
return
}
if (this.roomRentals.length === 0) {
uni.showToast({ title: '该房间暂无租约', icon: 'none' })
return
}
const rentalList = this.roomRentals.map(r => r.tenantName + ' (' + r.startDate + ' 至 ' + r.endDate + ')')
uni.showActionSheet({
itemList: rentalList,
success: (res) => {
this.selectedRental = this.roomRentals[res.tapIndex]
this.form.rentalId = this.selectedRental.id
}
})
},
selectDate() {
uni.showActionSheet({
itemList: ['今天', '昨天', '自定义'],
@ -150,7 +234,32 @@
}
})
},
selectBillMonth() {
// 使
const today = new Date()
const currentYear = today.getFullYear()
const years = [currentYear - 1, currentYear, currentYear + 1].map(String)
const months = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
uni.showActionSheet({
itemList: years,
title: '选择年份',
success: (yearRes) => {
const selectedYear = years[yearRes.tapIndex]
uni.showActionSheet({
itemList: months.map(m => selectedYear + '-' + m),
title: '选择月份',
success: (monthRes) => {
this.form.billMonth = selectedYear + '-' + months[monthRes.tapIndex]
}
})
}
})
},
async save() {
//
if (!this.form.receivableAmount || parseFloat(this.form.receivableAmount) <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' })
return
@ -159,9 +268,18 @@
uni.showToast({ title: '请选择类目', icon: 'none' })
return
}
if (!this.form.billDate) {
uni.showToast({ title: '请选择账单日期', icon: 'none' })
return
}
try {
uni.showLoading({ title: '保存中...' })
await billApi.create(this.form)
const data = {
...this.form,
receivableAmount: parseFloat(this.form.receivableAmount)
}
await billApi.create(data)
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 1500)
} catch (error) {
@ -170,12 +288,20 @@
uni.hideLoading()
}
},
goBack() { uni.navigateBack() },
formatDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
},
formatMonth(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
},
getCategoryText(code) {
const category = this.categories.find(c => c.code === code)
const allCategories = [...this.incomeCategories, ...this.expenseCategories]
const category = allCategories.find(c => c.code === code)
return category ? category.name : code
}
}
@ -204,6 +330,7 @@
.form-card { background: #FFFFFF; border-radius: 24rpx; overflow: hidden; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04); }
.form-item { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 32rpx; border-bottom: 2rpx solid #F8FAFC; }
.form-item:last-child { border-bottom: none; }
.form-item.disabled { opacity: 0.6; }
.item-label { font-size: 30rpx; color: #1E293B; font-weight: 500; }
.item-label.required::after { content: '*'; color: #EF4444; margin-left: 8rpx; }
.item-value { display: flex; align-items: center; gap: 16rpx; font-size: 30rpx; color: #1E293B; }

View File

@ -17,29 +17,32 @@
<scroll-view scroll-y class="page-content" @scrolltolower="loadMore">
<!-- 筛选栏 -->
<view class="filter-section">
<view class="filter-tabs">
<view v-for="(tab, index) in filterTabs" :key="index" class="filter-tab" :class="{ active: currentFilter === tab.value }" @click="switchFilter(tab.value)">
<text>{{tab.label}}</text>
<view class="filter-row">
<view class="filter-label">类型:</view>
<view class="filter-options">
<view v-for="(tab, index) in typeTabs" :key="index" class="filter-option" :class="{ active: searchForm.type === tab.value }" @click="switchTypeFilter(tab.value)">
<text>{{tab.label}}</text>
</view>
</view>
</view>
</view>
<!-- 统计概览 -->
<view class="stats-section">
<view class="stats-card">
<view class="stat-item">
<text class="stat-label">应收总额</text>
<text class="stat-value">¥{{stats.totalReceivable}}</text>
<view class="filter-row">
<view class="filter-label">状态:</view>
<view class="filter-options">
<view v-for="(tab, index) in statusTabs" :key="index" class="filter-option" :class="{ active: searchForm.status === tab.value }" @click="switchStatusFilter(tab.value)">
<text>{{tab.label}}</text>
</view>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">实收总额</text>
<text class="stat-value received">¥{{stats.totalReceived}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">待收金额</text>
<text class="stat-value pending">¥{{stats.totalPending}}</text>
</view>
<view class="filter-row">
<view class="filter-label">日期:</view>
<view class="date-picker-group">
<picker mode="date" :value="searchForm.startDate" @change="onStartDateChange">
<view class="date-picker">{{searchForm.startDate || '开始日期'}}</view>
</picker>
<text class="date-separator"></text>
<picker mode="date" :value="searchForm.endDate" @change="onEndDateChange">
<view class="date-picker">{{searchForm.endDate || '结束日期'}}</view>
</picker>
</view>
</view>
</view>
@ -63,14 +66,20 @@
</view>
<view class="type-info">
<text class="type-name">{{getCategoryText(item.category)}}</text>
<text class="type-room">{{item.roomNumber || '-'}} · {{item.renterName || '-'}}</text>
<text class="type-tag" :class="item.type">{{item.type === 'income' ? '收入' : '支出'}}</text>
</view>
</view>
<view class="bill-amount">
<text class="amount" :class="item.type">{{item.type === 'income' ? '+' : '-'}}¥{{item.receivableAmount}}</text>
<text class="received" v-if="item.receivedAmount > 0">实收: ¥{{item.receivedAmount}}</text>
</view>
</view>
<view class="bill-remark" v-if="item.remark">
<text class="remark-label">备注:</text>
<text class="remark-content">{{item.remark}}</text>
</view>
<view class="bill-footer">
<text class="create-time">创建时间: {{item.createTime}}</text>
</view>
</view>
</view>
@ -85,31 +94,45 @@
</template>
<script>
import { billApi } from '../../api/index.js'
import { billApi, settingApi } from '../../api/index.js'
export default {
data() {
return {
currentFilter: 'all',
filterTabs: [
{ label: '全部', value: 'all' },
typeTabs: [
{ label: '全部', value: '' },
{ label: '收入', value: 'income' },
{ label: '支出', value: 'expense' },
{ label: '未收', value: 'unpaid' }
{ label: '支出', value: 'expense' }
],
statusTabs: [
{ label: '全部', value: '' },
{ label: '未收', value: 'unpaid' },
{ label: '部分收款', value: 'partial' },
{ label: '已收清', value: 'paid' },
{ label: '已取消', value: 'cancelled' }
],
searchForm: {
type: '',
status: '',
startDate: '',
endDate: ''
},
billList: [],
isLoading: false,
page: 1,
pageSize: 10,
hasMore: true,
total: 0,
stats: {
totalReceivable: '0.00',
totalReceived: '0.00',
totalPending: '0.00'
}
},
categoryMap: {}
}
},
onLoad() {
this.loadCategories()
this.loadData()
},
onShow() {
@ -120,22 +143,24 @@
if (this.isLoading) return
this.isLoading = true
try {
const params = { page: this.page, pageSize: this.pageSize }
if (this.currentFilter !== 'all') {
if (this.currentFilter === 'unpaid') {
params.status = 'unpaid'
} else {
params.type = this.currentFilter
}
const params = {
page: this.page,
pageSize: this.pageSize,
type: this.searchForm.type,
status: this.searchForm.status,
startDate: this.searchForm.startDate,
endDate: this.searchForm.endDate
}
const res = await billApi.list(params)
const list = res.data || []
const res = await billApi.getAll(params)
// : { data: { list: [], total: n, page: n, pageSize: n } }
const list = res.data?.list || []
this.total = res.data?.total || 0
if (this.page === 1) {
this.billList = list
} else {
this.billList = [...this.billList, ...list]
}
this.hasMore = list.length >= this.pageSize
this.hasMore = this.billList.length < this.total
this.updateStats()
} catch (error) {
uni.showToast({ title: '加载失败', icon: 'none' })
@ -162,8 +187,33 @@
this.loadData()
}
},
switchFilter(value) {
this.currentFilter = value
switchTypeFilter(value) {
this.searchForm.type = value
this.page = 1
this.loadData()
},
switchStatusFilter(value) {
this.searchForm.status = value
this.page = 1
this.loadData()
},
onStartDateChange(e) {
this.searchForm.startDate = e.detail.value
this.page = 1
this.loadData()
},
onEndDateChange(e) {
this.searchForm.endDate = e.detail.value
this.page = 1
this.loadData()
},
resetSearch() {
this.searchForm = {
type: '',
status: '',
startDate: '',
endDate: ''
}
this.page = 1
this.loadData()
},
@ -175,12 +225,26 @@
},
goBack() { uni.navigateBack() },
getStatusText(status) {
const texts = { 'paid': '已付清', 'partial': '部分付款', 'unpaid': '未付款' }
const texts = { 'paid': '已收清', 'partial': '部分收款', 'unpaid': '未收', 'cancelled': '已取消' }
return texts[status] || status
},
//
async loadCategories() {
try {
const res = await settingApi.getCategories()
const categories = res.data || []
// code -> name
const map = {}
categories.forEach(cat => {
map[cat.code] = cat.name
})
this.categoryMap = map
} catch (error) {
console.error('加载类目列表失败:', error)
}
},
getCategoryText(category) {
const texts = { 'rent': '租金', 'deposit': '押金', 'water': '水费', 'electricity': '电费', 'maintenance': '维修费', 'property': '物业费', 'other': '其他' }
return texts[category] || category || '其他'
return this.categoryMap[category] || category || '其他'
}
}
}
@ -194,10 +258,16 @@
.nav-title { font-size: 36rpx; font-weight: 700; color: #1E293B; }
.nav-actions { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.page-content { flex: 1; padding: 24rpx 32rpx; }
.filter-section { margin-bottom: 24rpx; }
.filter-tabs { display: flex; gap: 16rpx; }
.filter-tab { padding: 16rpx 32rpx; background: #FFFFFF; border-radius: 12rpx; font-size: 28rpx; color: #64748B; }
.filter-tab.active { background: #2563EB; color: #FFFFFF; }
.filter-section { margin-bottom: 24rpx; background: #FFFFFF; border-radius: 24rpx; padding: 24rpx; }
.filter-row { display: flex; align-items: center; margin-bottom: 20rpx; }
.filter-row:last-child { margin-bottom: 0; }
.filter-label { font-size: 26rpx; color: #64748B; min-width: 80rpx; }
.filter-options { display: flex; flex-wrap: wrap; gap: 12rpx; flex: 1; }
.filter-option { padding: 12rpx 24rpx; background: #F1F5F9; border-radius: 8rpx; font-size: 24rpx; color: #64748B; }
.filter-option.active { background: #2563EB; color: #FFFFFF; }
.date-picker-group { display: flex; align-items: center; gap: 16rpx; flex: 1; }
.date-picker { padding: 12rpx 20rpx; background: #F1F5F9; border-radius: 8rpx; font-size: 24rpx; color: #64748B; min-width: 160rpx; text-align: center; }
.date-separator { font-size: 24rpx; color: #94A3B8; }
.stats-section { margin-bottom: 24rpx; }
.stats-card { background: #FFFFFF; border-radius: 24rpx; padding: 32rpx; display: flex; align-items: center; justify-content: space-around; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04); }
.stat-item { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
@ -223,13 +293,19 @@
.type-icon.expense { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
.type-info { display: flex; flex-direction: column; gap: 8rpx; }
.type-name { font-size: 30rpx; font-weight: 600; color: #1E293B; }
.type-room { font-size: 24rpx; color: #64748B; }
.type-tag { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 8rpx; margin-top: 4rpx; }
.type-tag.income { background: #DCFCE7; color: #16A34A; }
.type-tag.expense { background: #FEE2E2; color: #DC2626; }
.bill-amount { display: flex; flex-direction: column; align-items: flex-end; gap: 8rpx; }
.amount { font-size: 36rpx; font-weight: 700; }
.amount.income { color: #667eea; }
.amount.expense { color: #f5576c; }
.received { font-size: 24rpx; color: #94A3B8; }
.amount.income { color: #16A34A; }
.amount.expense { color: #DC2626; }
.bill-remark { margin-top: 20rpx; padding-top: 20rpx; border-top: 2rpx solid #F1F5F9; display: flex; gap: 8rpx; }
.remark-label { font-size: 24rpx; color: #64748B; flex-shrink: 0; }
.remark-content { font-size: 24rpx; color: #1E293B; flex: 1; word-break: break-all; }
.bill-footer { margin-top: 16rpx; }
.create-time { font-size: 22rpx; color: #94A3B8; }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 120rpx 0; }
.empty-text { font-size: 28rpx; color: #94A3B8; margin-bottom: 32rpx; }
.add-btn { background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: #FFFFFF; font-size: 28rpx; padding: 24rpx 64rpx; border-radius: 16rpx; border: none; }
.add-btn { background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: #FFFFFF; font-size: 26rpx; padding: 16rpx 40rpx; border-radius: 12rpx; border: none; }
</style>

View File

@ -3,24 +3,26 @@
<!-- 顶部欢迎区 -->
<view class="welcome-section">
<view class="welcome-content">
<text class="welcome-title">欢迎使用租房管理系统</text>
<text class="welcome-date">{{currentDate}}</text>
<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/property-add/property-add')">
<view class="action-btn" @click="navigateTo('/pages/apartment-add/apartment-add')">
<uni-icons type="plus-filled" size="20" color="#FFFFFF"></uni-icons>
<text>添加房源</text>
<text>添加公寓</text>
</view>
<view class="action-btn secondary" @click="navigateTo('/pages/bills/bills')">
<uni-icons type="wallet-filled" size="20" color="#2563EB"></uni-icons>
<text>账单管理</text>
<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/properties/properties')">
<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>
@ -58,6 +60,51 @@
</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">
@ -142,9 +189,12 @@
<!-- 预警提醒区 -->
<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="clock-filled" size="24" color="#E6A23C"></uni-icons>
<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">
@ -213,38 +263,7 @@
</view>
</view>
<!-- 快捷功能入口 -->
<view class="quick-menu">
<view class="menu-title">快捷功能</view>
<view class="menu-grid">
<view class="menu-item" @click="navigateTo('/pages/rental-add/rental-add')">
<view class="menu-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<uni-icons type="plus-filled" size="24" color="#FFFFFF"></uni-icons>
</view>
<text>办理入住</text>
</view>
<view class="menu-item" @click="navigateTo('/pages/tenants/tenants')">
<view class="menu-icon" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
<uni-icons type="person-filled" size="24" color="#FFFFFF"></uni-icons>
</view>
<text>租客管理</text>
</view>
<view class="menu-item" @click="navigateTo('/pages/rentals/rentals')">
<view class="menu-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<uni-icons type="list-filled" size="24" color="#FFFFFF"></uni-icons>
</view>
<text>租赁记录</text>
</view>
<view class="menu-item" @click="navigateTo('/pages/stats/stats')">
<view class="menu-icon" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<uni-icons type="chart-filled" size="24" color="#FFFFFF"></uni-icons>
</view>
<text>收支统计</text>
</view>
</view>
</view>
<view class="safe-area-bottom" style="height: 40rpx;"></view>
<view class="safe-area-bottom" style="height: 40rpx;"></view>
</view>
</template>
@ -266,7 +285,8 @@
monthlyReceivable: 0,
monthlyReceived: 0,
unpaidBillCount: 0,
apartmentHouseStats: []
apartmentHouseStats: [],
isLoading: false
}
},
computed: {
@ -280,7 +300,10 @@
this.loadData()
},
onShow() {
this.loadData()
// onLoad使onLoad
setTimeout(() => {
this.loadData()
}, 100)
},
onPullDownRefresh() {
this.loadData().finally(() => {
@ -299,6 +322,10 @@
},
async loadData() {
//
if (this.isLoading) return
this.isLoading = true
try {
await Promise.all([
this.loadDashboardStats(),
@ -307,6 +334,8 @@
])
} catch (error) {
console.error('加载数据失败:', error)
} finally {
this.isLoading = false
}
},
@ -321,8 +350,6 @@
this.rentedRoomCount = data.rentedRoomCount || 0
this.soonExpireRoomCount = data.soonExpireRoomCount || 0
this.expiredRoomCount = data.expiredRoomCount || 0
this.monthlyReceivable = data.monthlyReceivable || 0
this.monthlyReceived = data.monthlyReceived || 0
} catch (error) {
console.error('加载仪表盘数据失败:', error)
}
@ -341,13 +368,24 @@
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
}
},
@ -403,7 +441,7 @@
<style scoped>
.home-page {
min-height: 100vh;
background: #F8FAFC;
background: linear-gradient(180deg, #F8FAFC 0%, #F1F5F9 100%);
padding-bottom: 40rpx;
}
@ -411,13 +449,21 @@
.welcome-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40rpx 32rpx;
border-radius: 0 0 32rpx 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;
@ -429,7 +475,7 @@
.welcome-date {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
color: rgba(255, 255, 255, 0.85);
}
.quick-actions {
@ -443,15 +489,26 @@
align-items: center;
justify-content: center;
gap: 12rpx;
background: rgba(255, 255, 255, 0.2);
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;
@ -459,7 +516,7 @@
}
.action-btn.secondary text {
color: #2563EB;
color: #667eea;
}
/* KPI卡片区 */
@ -467,8 +524,8 @@
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
padding: 24rpx 32rpx;
margin-top: -20rpx;
padding: 32rpx;
margin-top: 0;
}
.kpi-card {
@ -477,7 +534,13 @@
padding: 28rpx;
border-radius: 20rpx;
background: #FFFFFF;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
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 {
@ -491,19 +554,19 @@
}
.kpi-primary .kpi-icon {
background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.kpi-success .kpi-icon {
background: linear-gradient(135deg, #67C23A 0%, #85ce61 100%);
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.kpi-warning .kpi-icon {
background: linear-gradient(135deg, #E6A23C 0%, #ebb563 100%);
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.kpi-info .kpi-icon {
background: linear-gradient(135deg, #909399 0%, #a6a9ad 100%);
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.kpi-content {
@ -524,15 +587,6 @@
color: #64748B;
}
/* 状态分布区 */
.status-section {
background: #FFFFFF;
margin: 0 32rpx 24rpx;
padding: 28rpx;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.section-header {
display: flex;
justify-content: space-between;
@ -551,12 +605,59 @@
color: #94A3B8;
}
.refresh-btn {
/* 功能模块网格 */
.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;
gap: 8rpx;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.12);
}
.module-name {
font-size: 26rpx;
color: #409EFF;
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 {
@ -569,6 +670,11 @@
display: flex;
align-items: center;
gap: 16rpx;
padding: 12rpx 0;
}
.status-item:active {
opacity: 0.7;
}
.status-info {
@ -611,7 +717,7 @@
.progress-fill {
height: 100%;
border-radius: 6rpx;
transition: width 0.3s ease;
transition: width 0.5s ease;
}
.status-count {
@ -624,8 +730,7 @@
/* 预警提醒区 */
.alert-section {
padding: 0 32rpx;
margin-bottom: 24rpx;
margin: 0 32rpx 24rpx;
}
.alert-card {
@ -637,6 +742,11 @@
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 {
@ -718,6 +828,18 @@
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;
@ -759,11 +881,16 @@
}
.stat-tag {
padding: 8rpx 16rpx;
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 {
@ -785,47 +912,4 @@
background: #FEE2E2;
color: #DC2626;
}
/* 快捷菜单 */
.quick-menu {
background: #FFFFFF;
margin: 0 32rpx;
padding: 28rpx;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.menu-title {
font-size: 32rpx;
font-weight: 700;
color: #1E293B;
margin-bottom: 24rpx;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
}
.menu-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.menu-icon {
width: 96rpx;
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.menu-item text {
font-size: 24rpx;
color: #64748B;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,12 @@
<!-- 自定义导航栏 -->
<view class="custom-nav safe-area-top">
<view class="nav-content">
<text class="nav-title">租赁记录</text>
<view class="nav-left">
<view class="nav-btn back-btn" @click="goBack">
<uni-icons type="left" size="22" color="#1E293B"></uni-icons>
</view>
<text class="nav-title">租赁记录</text>
</view>
<view class="nav-actions">
<view class="nav-btn" @click="showFilter">
<uni-icons type="settings-filled" size="22" color="#1E293B"></uni-icons>
@ -31,24 +36,6 @@
</view>
</view>
<!-- 统计卡片 -->
<view class="stats-bar">
<view class="stat-item">
<text class="stat-num">{{activeCount}}</text>
<text class="stat-label">在租</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-num warning">{{expiringCount}}</text>
<text class="stat-label">即将到期</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-num">{{expiredCount}}</text>
<text class="stat-label">已到期</text>
</view>
</view>
<!-- 筛选标签 -->
<scroll-view scroll-x class="filter-tabs" show-scrollbar="false">
<view
@ -211,12 +198,103 @@
</view>
</view>
</uni-popup>
<!-- 筛选弹窗 -->
<uni-popup ref="filterPopup" type="right">
<view class="filter-drawer">
<view class="filter-header">
<text class="filter-title">筛选条件</text>
<uni-icons type="close" size="20" color="#94A3B8" @click="closeFilterPopup"></uni-icons>
</view>
<scroll-view scroll-y class="filter-body">
<!-- 公寓筛选 -->
<view class="filter-section">
<text class="filter-label">公寓</text>
<view class="filter-options">
<view
v-for="apt in apartmentFilterOptions"
:key="apt.value"
class="filter-option"
:class="{ active: filterForm.apartmentId === apt.value }"
@click="selectApartment(apt.value)"
>
<text>{{apt.label}}</text>
</view>
</view>
</view>
<!-- 房间筛选 -->
<view class="filter-section" v-if="filterForm.apartmentId">
<text class="filter-label">房间</text>
<view class="filter-options">
<view
v-for="room in roomFilterOptions"
:key="room.value"
class="filter-option"
:class="{ active: filterForm.roomId === room.value }"
@click="selectRoom(room.value)"
>
<text>{{room.label}}</text>
</view>
</view>
</view>
<!-- 状态筛选 -->
<view class="filter-section">
<text class="filter-label">租赁状态</text>
<view class="filter-options">
<view
v-for="status in statusOptions"
:key="status.value"
class="filter-option"
:class="{ active: filterForm.status === status.value }"
@click="selectStatus(status.value)"
>
<text>{{status.label}}</text>
</view>
</view>
</view>
<!-- 付租方式筛选 -->
<view class="filter-section">
<text class="filter-label">付租方式</text>
<view class="filter-options">
<view
v-for="type in paymentTypeOptions"
:key="type.value"
class="filter-option"
:class="{ active: filterForm.paymentType === type.value }"
@click="selectPaymentType(type.value)"
>
<text>{{type.label}}</text>
</view>
</view>
</view>
<!-- 租客姓名 -->
<view class="filter-section">
<text class="filter-label">租客姓名</text>
<input
class="filter-input"
type="text"
placeholder="请输入租客姓名"
v-model="filterForm.renterName"
/>
</view>
</scroll-view>
<view class="filter-footer">
<button class="btn-reset" @click="resetFilter">重置</button>
<button class="btn-confirm" @click="confirmFilter">确定</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
import rentalApi from '@/api/rental.js'
import apartmentApi from '@/api/apartment.js'
import roomApi from '@/api/room.js'
export default {
data() {
@ -234,6 +312,7 @@
],
rentals: [],
apartments: [],
rooms: [],
selectedApartmentId: '',
loadStatus: 'more',
page: 1,
@ -245,19 +324,32 @@
electricityMeterEnd: '',
refundDeposit: '',
remark: ''
}
},
//
filterForm: {
apartmentId: '',
roomId: '',
status: '',
paymentType: '',
renterName: ''
},
//
statusOptions: [
{ label: '全部', value: '' },
{ label: '在租', value: 'active' },
{ label: '已到期', value: 'expired' },
{ label: '已终止', value: 'terminated' }
],
paymentTypeOptions: [
{ label: '全部', value: '' },
{ label: '月租', value: 'monthly' },
{ label: '季租', value: 'quarterly' },
{ label: '半年租', value: 'half_year' },
{ label: '年租', value: 'yearly' }
]
}
},
computed: {
activeCount() {
return this.rentals.filter(r => r.status === 'active').length
},
expiringCount() {
return this.rentals.filter(r => r.status === 'active' && this.isExpiringSoon(r.endDate)).length
},
expiredCount() {
return this.rentals.filter(r => r.status === 'expired').length
},
filteredRentals() {
let list = this.rentals
@ -285,10 +377,22 @@
if (!this.selectedApartmentId) return '全部公寓'
const apartment = this.apartments.find(a => a.id === this.selectedApartmentId)
return apartment ? apartment.name : '全部公寓'
},
//
apartmentFilterOptions() {
return [{ label: '全部', value: '' }, ...this.apartments.map(a => ({ label: a.name, value: a.id }))]
},
//
roomFilterOptions() {
const filteredRooms = this.filterForm.apartmentId
? this.rooms.filter(r => r.apartmentId === this.filterForm.apartmentId)
: this.rooms
return [{ label: '全部', value: '' }, ...filteredRooms.map(r => ({ label: r.roomNumber, value: r.id }))]
}
},
onLoad() {
this.loadApartments()
this.loadRooms()
this.loadRentals()
},
onShow() {
@ -297,7 +401,7 @@
methods: {
async loadApartments() {
try {
const res = await apartmentApi.getList({ pageSize: 999 })
const res = await apartmentApi.list()
if (res.data) {
this.apartments = res.data
}
@ -305,19 +409,41 @@
console.error('加载公寓列表失败:', error)
}
},
async loadRooms() {
try {
const res = await roomApi.list()
if (res.data) {
this.rooms = res.data
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
},
async loadRentals() {
//
if (this.isLoading) return
this.isLoading = true
try {
const params = {
page: this.page,
pageSize: this.pageSize,
renterName: this.searchKeyword
renterName: this.searchKeyword || this.filterForm.renterName,
apartmentId: this.filterForm.apartmentId,
roomId: this.filterForm.roomId,
status: this.filterForm.status,
paymentType: this.filterForm.paymentType
}
const res = await rentalApi.getList(params)
if (res.data) {
const list = res.data.map(item => ({
// : { data: { list: [], total: n, page: n, pageSize: n } }
const pageData = res.data || {}
const rawList = pageData.list || []
if (rawList.length > 0 || this.page === 1) {
const list = rawList.map(item => ({
id: item.id,
apartmentId: item.Room?.Apartment?.id,
apartmentName: item.Room?.Apartment?.name || '--',
@ -344,7 +470,7 @@
this.rentals = [...this.rentals, ...list]
}
this.total = res.total || 0
this.total = pageData.total || 0
this.loadStatus = this.rentals.length < this.total ? 'more' : 'noMore'
}
} catch (error) {
@ -390,11 +516,15 @@
},
addRental() {
uni.navigateTo({
url: '/pages/rental-add/rental-add'
uni.switchTab({
url: '/pages/add-record/add-record'
})
},
goBack() {
uni.navigateBack()
},
viewRentalDetail(item) {
uni.navigateTo({
url: `/pages/rental-detail/rental-detail?id=${item.id}`
@ -462,11 +592,53 @@
}
},
//
showFilter() {
uni.showToast({
title: '筛选功能',
icon: 'none'
})
//
this.loadRooms()
this.$refs.filterPopup.open()
},
closeFilterPopup() {
this.$refs.filterPopup.close()
},
selectApartment(apartmentId) {
this.filterForm.apartmentId = apartmentId
//
this.filterForm.roomId = ''
},
selectRoom(roomId) {
this.filterForm.roomId = roomId
},
selectStatus(status) {
this.filterForm.status = status
},
selectPaymentType(type) {
this.filterForm.paymentType = type
},
resetFilter() {
this.filterForm = {
apartmentId: '',
roomId: '',
status: '',
paymentType: '',
renterName: ''
}
this.searchKeyword = ''
this.page = 1
this.loadRentals()
this.closeFilterPopup()
},
confirmFilter() {
this.page = 1
this.loadRentals()
this.closeFilterPopup()
},
loadMore() {
@ -534,6 +706,12 @@
padding: 20rpx 32rpx;
}
.nav-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.nav-title {
font-size: 36rpx;
font-weight: 700;
@ -545,6 +723,10 @@
gap: 16rpx;
}
.back-btn {
margin-left: -8rpx;
}
.nav-btn {
width: 72rpx;
height: 72rpx;
@ -1034,4 +1216,105 @@
font-weight: 600;
border: none;
}
/* 筛选抽屉样式 */
.filter-drawer {
width: 600rpx;
height: 100vh;
background: #FFFFFF;
display: flex;
flex-direction: column;
}
.filter-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 2rpx solid #F1F5F9;
}
.filter-title {
font-size: 32rpx;
font-weight: 700;
color: #1E293B;
}
.filter-body {
flex: 1;
padding: 32rpx;
}
.filter-section {
margin-bottom: 40rpx;
}
.filter-label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 20rpx;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.filter-option {
padding: 16rpx 32rpx;
background: #F1F5F9;
border-radius: 12rpx;
font-size: 26rpx;
color: #64748B;
transition: all 0.2s;
}
.filter-option.active {
background: #DBEAFE;
color: #2563EB;
font-weight: 500;
}
.filter-input {
width: 100%;
height: 80rpx;
background: #F8FAFC;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1E293B;
border: 2rpx solid #E2E8F0;
}
.filter-footer {
display: flex;
gap: 20rpx;
padding: 24rpx 32rpx;
border-top: 2rpx solid #F1F5F9;
background: #FFFFFF;
}
.filter-footer .btn-reset {
flex: 1;
height: 80rpx;
background: #F3F4F6;
border-radius: 12rpx;
font-size: 28rpx;
color: #64748B;
border: none;
}
.filter-footer .btn-confirm {
flex: 1;
height: 80rpx;
background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%);
border-radius: 12rpx;
font-size: 28rpx;
color: #FFFFFF;
font-weight: 600;
border: none;
}
</style>

View File

@ -39,24 +39,6 @@
<text class="item-label">身份证号</text>
<text class="item-value">{{renter.idCard || '-'}}</text>
</view>
<view class="info-item">
<text class="item-label">紧急联系人</text>
<text class="item-value">{{renter.emergencyContact || '-'}}</text>
</view>
<view class="info-item">
<text class="item-label">紧急电话</text>
<view class="item-value">
<text>{{renter.emergencyPhone || '-'}}</text>
<uni-icons v-if="renter.emergencyPhone" type="phone-filled" size="18" color="#2563EB" @click="callPhone(renter.emergencyPhone)"></uni-icons>
</view>
</view>
</view>
</view>
<view class="info-section" v-if="renter.remark">
<view class="section-title">备注信息</view>
<view class="info-card">
<text class="remark-text">{{renter.remark}}</text>
</view>
</view>
@ -96,7 +78,7 @@
try {
uni.showLoading({ title: '加载中...' })
const res = await renterApi.getDetail(this.renterId)
this.renter = res.data || {}
this.renter = res.data
} catch (error) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
@ -110,14 +92,14 @@
uni.navigateTo({ url: `/pages/rentals/rentals?renterId=${this.renterId}` })
},
addRental() {
uni.navigateTo({ url: `/pages/add-record/add-record?renterId=${this.renterId}` })
uni.switchTab({ url: `/pages/add-record/add-record?renterId=${this.renterId}` })
},
callPhone(phone) {
uni.makePhoneCall({ phoneNumber: phone })
},
goBack() { uni.navigateBack() },
getStatusText(status) {
const texts = { 'active': '在租', 'inactive': '已退租' }
const texts = { 'active': '正常', 'inactive': '停用' }
return texts[status] || '未知'
}
}
@ -146,7 +128,6 @@
.info-item:last-child { border-bottom: none; }
.item-label { font-size: 30rpx; color: #64748B; }
.item-value { display: flex; align-items: center; gap: 16rpx; font-size: 30rpx; color: #1E293B; font-weight: 500; }
.remark-text { font-size: 30rpx; color: #1E293B; line-height: 1.6; padding: 28rpx 0; }
.action-section { display: flex; gap: 24rpx; margin-top: 48rpx; }
.action-btn { flex: 1; height: 96rpx; background: #F1F5F9; color: #64748B; font-size: 32rpx; font-weight: 600; border-radius: 16rpx; border: none; display: flex; align-items: center; justify-content: center; }
.action-btn.primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #FFFFFF; }

View File

@ -18,7 +18,15 @@
<view class="search-section">
<view class="search-box">
<uni-icons type="search" size="18" color="#94A3B8"></uni-icons>
<input type="text" v-model="searchKeyword" placeholder="搜索租客姓名或电话" @confirm="search"/>
<input type="text" v-model="searchForm.keyword" placeholder="搜索姓名/电话/身份证" @confirm="search"/>
</view>
<view class="filter-box">
<picker mode="selector" :range="statusOptions" range-key="label" :value="statusIndex" @change="onStatusChange">
<view class="filter-item">
<text>{{statusOptions[statusIndex].label}}</text>
<uni-icons type="down" size="14" color="#64748B"></uni-icons>
</view>
</picker>
</view>
</view>
@ -36,7 +44,7 @@
</view>
<view class="info-detail">
<text class="detail-item">{{item.phone || '暂无电话'}}</text>
<text class="detail-item" v-if="item.idCard">身份证: {{maskIdCard(item.idCard)}}</text>
<text class="detail-item" v-if="item.idCard">身份证: {{item.idCard || '-'}}</text>
</view>
</view>
<uni-icons type="right" size="16" color="#94A3B8"></uni-icons>
@ -59,8 +67,18 @@
export default {
data() {
return {
searchKeyword: '',
searchForm: {
keyword: '',
status: ''
},
statusOptions: [
{ label: '全部', value: '' },
{ label: '正常', value: 'active' },
{ label: '停用', value: 'inactive' }
],
statusIndex: 0,
renterList: [],
total: 0,
isLoading: false,
page: 1,
pageSize: 10,
@ -78,18 +96,22 @@
if (this.isLoading) return
this.isLoading = true
try {
const res = await renterApi.list({
keyword: this.searchKeyword,
const params = {
keyword: this.searchForm.keyword,
status: this.searchForm.status,
page: this.page,
pageSize: this.pageSize
})
const list = res.data || []
}
const res = await renterApi.getAll(params)
const list = res.data.list
const total = res.data.total
if (this.page === 1) {
this.renterList = list
} else {
this.renterList = [...this.renterList, ...list]
}
this.hasMore = list.length >= this.pageSize
this.total = total
this.hasMore = this.renterList.length < total
} catch (error) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
@ -106,6 +128,12 @@
this.page = 1
this.loadData()
},
onStatusChange(e) {
this.statusIndex = e.detail.value
this.searchForm.status = this.statusOptions[this.statusIndex].value
this.page = 1
this.loadData()
},
addRenter() {
uni.navigateTo({ url: '/pages/renter-add/renter-add' })
},
@ -116,12 +144,8 @@
uni.navigateBack()
},
getStatusText(status) {
const texts = { 'active': '在租', 'inactive': '已退租' }
const texts = { 'active': '正常', 'inactive': '停用' }
return texts[status] || '未知'
},
maskIdCard(idCard) {
if (!idCard || idCard.length < 8) return idCard
return idCard.slice(0, 4) + '****' + idCard.slice(-4)
}
}
}
@ -135,9 +159,12 @@
.nav-title { font-size: 36rpx; font-weight: 700; color: #1E293B; }
.nav-actions { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.page-content { flex: 1; padding: 24rpx 32rpx; }
.search-section { margin-bottom: 24rpx; }
.search-box { display: flex; align-items: center; background: #FFFFFF; border-radius: 16rpx; padding: 20rpx 24rpx; gap: 16rpx; }
.search-section { margin-bottom: 24rpx; display: flex; gap: 16rpx; }
.search-box { flex: 1; display: flex; align-items: center; background: #FFFFFF; border-radius: 16rpx; padding: 20rpx 24rpx; gap: 16rpx; }
.search-box input { flex: 1; font-size: 28rpx; color: #1E293B; }
.filter-box { background: #FFFFFF; border-radius: 16rpx; padding: 0 24rpx; display: flex; align-items: center; }
.filter-item { display: flex; align-items: center; gap: 8rpx; height: 72rpx; }
.filter-item text { font-size: 28rpx; color: #64748B; }
.renter-list { display: flex; flex-direction: column; gap: 24rpx; }
.renter-card { background: #FFFFFF; border-radius: 24rpx; padding: 32rpx; display: flex; align-items: center; gap: 24rpx; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04); }
.renter-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; }

View File

@ -16,63 +16,135 @@
<view class="form-card">
<view class="form-item">
<text class="form-label">所属公寓 <text class="required">*</text></text>
<picker mode="selector" :range="apartmentOptions" :value="selectedApartmentIndex" @change="onApartmentChange">
<view class="picker-value" :class="{ placeholder: !form.apartmentId }">
<text>{{selectedApartmentLabel}}</text>
<uni-icons type="arrowright" size="16" color="#94A3B8"></uni-icons>
</view>
</picker>
<view class="form-control">
<picker mode="selector" :range="apartmentOptions" :value="selectedApartmentIndex" @change="onApartmentChange">
<view class="picker-value" :class="{ placeholder: !form.apartmentId }">
<text>{{selectedApartmentLabel}}</text>
<uni-icons type="arrowright" size="16" color="#94A3B8"></uni-icons>
</view>
</picker>
</view>
</view>
<view class="form-item">
<text class="form-label">房间号 <text class="required">*</text></text>
<input
type="text"
v-model="form.roomNumber"
placeholder="请输入房间号101"
class="form-input"
maxlength="20"
/>
<view class="form-control">
<input
type="text"
v-model="form.roomNumber"
placeholder="请输入房间号101"
class="form-input"
maxlength="10"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">房间面积 ()</text>
<input
type="digit"
v-model="form.area"
placeholder="请输入房间面积"
class="form-input"
/>
<text class="form-label">楼层</text>
<view class="form-control">
<input
type="number"
v-model="form.floor"
placeholder="请输入楼层"
class="form-input"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">房间朝向</text>
<picker mode="selector" :range="orientationOptions" :value="orientationIndex" @change="onOrientationChange">
<view class="picker-value" :class="{ placeholder: !form.orientation }">
<text>{{form.orientation || '请选择朝向'}}</text>
<uni-icons type="arrowright" size="16" color="#94A3B8"></uni-icons>
</view>
</picker>
<text class="form-label">户型</text>
<view class="form-control">
<input
type="text"
v-model="form.roomType"
placeholder="请输入户型,如一室一厅"
class="form-input"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">面积 ()</text>
<view class="form-control">
<input
type="digit"
v-model="form.area"
placeholder="请输入面积"
class="form-input"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">月租金 ()</text>
<input
type="digit"
v-model="form.rent"
placeholder="请输入月租金"
class="form-input"
/>
<view class="form-control">
<input
type="digit"
v-model="form.monthlyPrice"
placeholder="请输入月租金"
class="form-input"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">房间描述</text>
<textarea
v-model="form.description"
placeholder="请输入房间描述(选填)"
class="form-textarea"
maxlength="200"
/>
<text class="form-label">年租金 ()</text>
<view class="form-control">
<input
type="digit"
v-model="form.yearlyPrice"
placeholder="请输入年租金"
class="form-input"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">押金 ()</text>
<view class="form-control">
<input
type="digit"
v-model="form.deposit"
placeholder="请输入押金"
class="form-input"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">排序</text>
<view class="form-control">
<input
type="number"
v-model="form.sortOrder"
placeholder="数字越小越靠前"
class="form-input"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">主状态</text>
<view class="form-control">
<picker mode="selector" :range="statusOptions" :value="statusIndex" @change="onStatusChange">
<view class="picker-value" :class="{ placeholder: !form.status }">
<text>{{getStatusLabel(form.status)}}</text>
<uni-icons type="arrowright" size="16" color="#94A3B8"></uni-icons>
</view>
</picker>
</view>
</view>
<view class="form-item" v-if="form.status === 'rented'">
<text class="form-label">租房标识</text>
<view class="form-control">
<picker mode="selector" :range="rentalStatusOptions" :value="rentalStatusIndex" @change="onRentalStatusChange">
<view class="picker-value" :class="{ placeholder: !form.rentalStatus }">
<text>{{getRentalStatusLabel(form.rentalStatus)}}</text>
<uni-icons type="arrowright" size="16" color="#94A3B8"></uni-icons>
</view>
</picker>
</view>
</view>
</view>
@ -85,7 +157,7 @@
<script>
import roomApi from '@/api/room.js'
import apartmentApi from '@/api/apartment.js'
export default {
data() {
return {
@ -93,14 +165,22 @@
roomId: null,
submitLoading: false,
apartments: [],
orientationOptions: ['东', '南', '西', '北', '东南', '东北', '西南', '西北'],
statusOptions: ['空房', '预定', '在租'],
statusValues: ['empty', 'reserved', 'rented'],
rentalStatusOptions: ['正常', '即将到期', '已到期'],
rentalStatusValues: ['normal', 'soon_expire', 'expired'],
form: {
apartmentId: '',
roomNumber: '',
floor: '',
roomType: '',
area: '',
orientation: '',
rent: '',
description: ''
monthlyPrice: '',
yearlyPrice: '',
deposit: '',
sortOrder: 0,
status: 'empty',
rentalStatus: 'normal'
}
}
},
@ -116,8 +196,11 @@
const apartment = this.apartments.find(a => a.id === this.form.apartmentId)
return apartment ? apartment.name : '请选择公寓'
},
orientationIndex() {
return this.orientationOptions.indexOf(this.form.orientation)
statusIndex() {
return this.statusValues.indexOf(this.form.status)
},
rentalStatusIndex() {
return this.rentalStatusValues.indexOf(this.form.rentalStatus)
}
},
onLoad(options) {
@ -133,7 +216,7 @@
methods: {
async loadApartments() {
try {
const res = await apartmentApi.getList({ pageSize: 999 })
const res = await apartmentApi.list()
if (res.data) {
this.apartments = res.data
}
@ -149,10 +232,15 @@
this.form = {
apartmentId: data.apartmentId,
roomNumber: data.roomNumber,
floor: data.floor ? String(data.floor) : '',
roomType: data.roomType || '',
area: data.area ? String(data.area) : '',
orientation: data.orientation || '',
rent: data.rent ? String(data.rent) : '',
description: data.description || ''
monthlyPrice: data.monthlyPrice ? String(data.monthlyPrice) : '',
yearlyPrice: data.yearlyPrice ? String(data.yearlyPrice) : '',
deposit: data.deposit ? String(data.deposit) : '',
sortOrder: data.sortOrder !== undefined ? data.sortOrder : 0,
status: data.status || 'empty',
rentalStatus: data.rentalStatus || 'normal'
}
}
} catch (error) {
@ -163,8 +251,19 @@
onApartmentChange(e) {
this.form.apartmentId = this.apartments[e.detail.value].id
},
onOrientationChange(e) {
this.form.orientation = this.orientationOptions[e.detail.value]
onStatusChange(e) {
this.form.status = this.statusValues[e.detail.value]
},
onRentalStatusChange(e) {
this.form.rentalStatus = this.rentalStatusValues[e.detail.value]
},
getStatusLabel(value) {
const index = this.statusValues.indexOf(value)
return index >= 0 ? this.statusOptions[index] : '请选择状态'
},
getRentalStatusLabel(value) {
const index = this.rentalStatusValues.indexOf(value)
return index >= 0 ? this.rentalStatusOptions[index] : '请选择租房标识'
},
async saveRoom() {
if (!this.form.apartmentId) {
@ -181,10 +280,15 @@
const data = {
apartmentId: this.form.apartmentId,
roomNumber: this.form.roomNumber.trim(),
floor: this.form.floor ? parseInt(this.form.floor) : null,
roomType: this.form.roomType.trim() || null,
area: this.form.area ? parseFloat(this.form.area) : null,
orientation: this.form.orientation || null,
rent: this.form.rent ? parseFloat(this.form.rent) : null,
description: this.form.description.trim() || null
monthlyPrice: this.form.monthlyPrice ? parseFloat(this.form.monthlyPrice) : null,
yearlyPrice: this.form.yearlyPrice ? parseFloat(this.form.yearlyPrice) : null,
deposit: this.form.deposit ? parseFloat(this.form.deposit) : null,
sortOrder: this.form.sortOrder !== '' ? parseInt(this.form.sortOrder) : 0,
status: this.form.status,
rentalStatus: this.form.rentalStatus
}
if (this.isEdit) {
@ -256,6 +360,8 @@
margin-bottom: 40rpx;
}
.form-item {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 2rpx solid #F1F5F9;
}
@ -263,10 +369,13 @@
border-bottom: none;
}
.form-label {
display: block;
font-size: 26rpx;
width: 180rpx;
font-size: 28rpx;
color: #64748B;
margin-bottom: 16rpx;
flex-shrink: 0;
}
.form-control {
flex: 1;
}
.required {
color: #EF4444;
@ -275,7 +384,7 @@
display: flex;
align-items: center;
justify-content: space-between;
font-size: 30rpx;
font-size: 28rpx;
color: #1E293B;
}
.picker-value.placeholder {
@ -283,23 +392,23 @@
}
.form-input {
width: 100%;
height: 60rpx;
font-size: 30rpx;
height: 48rpx;
font-size: 28rpx;
color: #1E293B;
text-align: right;
}
.form-textarea {
width: 100%;
height: 160rpx;
font-size: 30rpx;
color: #1E293B;
.form-input::placeholder {
color: #94A3B8;
}
.btn-submit {
background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%);
color: #FFFFFF;
font-size: 30rpx;
font-weight: 600;
padding: 28rpx;
height: 88rpx;
line-height: 88rpx;
border-radius: 16rpx;
border: none;
width: 100%;
}
</style>

View File

@ -27,18 +27,48 @@
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">房间面积</text>
<text class="label">房间号</text>
<text class="value">{{room.roomNumber}}</text>
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">楼层</text>
<text class="value">{{room.floor || '--'}}</text>
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">户型</text>
<text class="value">{{room.roomType || '--'}}</text>
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">面积</text>
<text class="value">{{room.area || '--'}} </text>
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">房间朝向</text>
<text class="value">{{room.orientation || '--'}}</text>
<text class="label">月租金</text>
<text class="value price">¥{{room.monthlyPrice || 0}}</text>
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">月租金</text>
<text class="value price">¥{{room.rent || 0}}</text>
<text class="label">年租金</text>
<text class="value price">¥{{room.yearlyPrice || 0}}</text>
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">押金</text>
<text class="value price">¥{{room.deposit || 0}}</text>
</view>
<view class="info-divider"></view>
<view class="info-item">
<text class="label">主状态</text>
<text class="value">{{room.statusText}}</text>
</view>
<view class="info-divider" v-if="room.status === 'rented' && room.rentalStatusText"></view>
<view class="info-item" v-if="room.status === 'rented' && room.rentalStatusText">
<text class="label">租房标识</text>
<text class="value">{{room.rentalStatusText}}</text>
</view>
</view>
</view>
@ -56,10 +86,34 @@
</view>
<view class="action-section">
<view class="action-btn primary" v-if="room.status === 'vacant'" @click="addRental">
<uni-icons type="plus" size="20" color="#FFFFFF"></uni-icons>
<text>办理入住</text>
<!-- 空房状态显示预定和办理入住按钮 -->
<template v-if="room.status === 'empty'">
<view class="action-btn warning" @click="reserveRoom">
<uni-icons type="compose" size="20" color="#FFFFFF"></uni-icons>
<text>预定</text>
</view>
<view class="action-btn primary" @click="addRental">
<uni-icons type="plus" size="20" color="#FFFFFF"></uni-icons>
<text>办理入住</text>
</view>
</template>
<!-- 预定状态显示取消预定和办理入住按钮 -->
<template v-if="room.status === 'reserved'">
<view class="action-btn warning" @click="cancelReserve">
<uni-icons type="close" size="20" color="#FFFFFF"></uni-icons>
<text>取消预定</text>
</view>
<view class="action-btn primary" @click="addRental">
<uni-icons type="plus" size="20" color="#FFFFFF"></uni-icons>
<text>办理入住</text>
</view>
</template>
<!-- 已租状态查看租赁 -->
<view class="action-btn primary" v-if="room.status === 'rented'" @click="viewRentalDetail">
<uni-icons type="eye" size="20" color="#FFFFFF"></uni-icons>
<text>查看租赁</text>
</view>
<!-- 删除房间按钮 -->
<view class="action-btn danger" @click="deleteRoom">
<uni-icons type="trash" size="20" color="#FFFFFF"></uni-icons>
<text>删除房间</text>
@ -108,20 +162,25 @@
this.room = {
id: data.id,
roomNumber: data.roomNumber,
floor: data.floor,
roomType: data.roomType,
area: data.area,
orientation: data.orientation,
rent: data.rent,
monthlyPrice: data.monthlyPrice,
yearlyPrice: data.yearlyPrice,
deposit: data.deposit,
status: data.status || 'vacant',
statusText: data.status === 'rented' ? '已出租' : '空置中',
apartmentName: data.Apartment?.name || '--',
statusText: this.getRoomStatusText(data.status),
rentalStatus: data.rentalStatus,
rentalStatusText: this.getRentalStatusText(data.rentalStatus),
apartmentName: data.Apartment && data.Apartment.name || '--',
rental: data.Rental ? {
id: data.Rental.id,
renterName: data.Rental.Renter?.name,
renterName: data.Rental.Renter && data.Rental.Renter.name,
startDate: data.Rental.startDate,
endDate: data.Rental.endDate,
rent: data.Rental.rent,
status: data.Rental.status,
statusText: this.getStatusText(data.Rental.status)
statusText: this.getRentalStatusText(data.Rental.status)
} : null
}
}
@ -138,6 +197,46 @@
addRental() {
uni.navigateTo({ url: `/pages/rental-add/rental-add?roomId=${this.roomId}` })
},
//
async reserveRoom() {
uni.showModal({
title: '确认预定',
content: '确定要预定这个房间吗?',
confirmColor: '#2563EB',
success: async (res) => {
if (res.confirm) {
try {
await roomApi.update(this.roomId, { status: 'reserved' })
uni.showToast({ title: '预定成功', icon: 'success' })
this.loadRoomDetail(this.roomId)
} catch (error) {
uni.showToast({ title: '预定失败', icon: 'none' })
console.error(error)
}
}
}
})
},
//
async cancelReserve() {
uni.showModal({
title: '确认取消',
content: '确定要取消预定吗?',
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
try {
await roomApi.update(this.roomId, { status: 'empty' })
uni.showToast({ title: '取消预定成功', icon: 'success' })
this.loadRoomDetail(this.roomId)
} catch (error) {
uni.showToast({ title: '取消预定失败', icon: 'none' })
console.error(error)
}
}
}
})
},
viewRentalDetail() {
if (this.room.rental) {
uni.navigateTo({ url: `/pages/rental-detail/rental-detail?id=${this.room.rental.id}` })
@ -164,8 +263,12 @@
goBack() {
uni.navigateBack()
},
getStatusText(status) {
const map = { active: '在租', expired: '已到期', terminated: '已终止' }
getRoomStatusText(status) {
const map = { empty: '空房', reserved: '预定', rented: '在租' }
return map[status] || status
},
getRentalStatusText(status) {
const map = { normal: '正常', soon_expire: '即将到期', expired: '已到期' }
return map[status] || status
}
}
@ -324,6 +427,10 @@
background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%);
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.3);
}
.action-btn.warning {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
box-shadow: 0 8rpx 24rpx rgba(245, 158, 11, 0.3);
}
.action-btn.danger {
background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%);
box-shadow: 0 8rpx 24rpx rgba(239, 68, 68, 0.3);

View File

@ -32,10 +32,10 @@
</view>
<view v-else class="room-grid">
<view v-for="(item, index) in rooms" :key="index" class="room-card" @click="viewRoomDetail(item)">
<view v-for="(item, index) in rooms" :key="index" class="room-card" :class="getRoomStatusClass(item)" @click="viewRoomDetail(item)">
<view class="room-header">
<text class="room-number">{{item.roomNumber}}</text>
<view class="room-status" :class="item.status">{{item.statusText}}</view>
<view class="room-status">{{item.statusText}}</view>
</view>
<view class="room-info">
<text class="info-item">面积: {{item.area || '--'}} </text>
@ -97,13 +97,13 @@
methods: {
async loadApartments() {
try {
const res = await apartmentApi.getList({ pageSize: 999 })
const res = await apartmentApi.list()
if (res.data) {
this.apartments = res.data
if (!this.selectedApartmentId && this.apartments.length > 0) {
this.selectedApartmentId = this.apartments[0].id
}
this.loadRooms()
// loadRooms onShow
}
} catch (error) {
console.error('加载公寓列表失败:', error)
@ -119,23 +119,24 @@
apartmentId: this.selectedApartmentId
}
const res = await roomApi.getList(params)
if (res.data) {
const list = res.data.map(item => ({
if (res.data && res.data.list) {
const list = res.data.list.map(item => ({
id: item.id,
roomNumber: item.roomNumber,
area: item.area,
orientation: item.orientation,
rent: item.rent,
status: item.status || 'vacant',
statusText: item.status === 'rented' ? '已出租' : '空置中',
renterName: item.Renter?.name
status: this.getRoomStatus(item),
rentalStatus: item.rentalStatus,
statusText: this.getRoomStatusText(item),
renterName: item.Renter && item.Renter.name
}))
if (this.page === 1) {
this.rooms = list
} else {
this.rooms = [...this.rooms, ...list]
}
this.loadStatus = this.rooms.length < (res.total || 0) ? 'more' : 'noMore'
this.loadStatus = this.rooms.length < (res.data.total || 0) ? 'more' : 'noMore'
}
} catch (error) {
console.error('加载房间列表失败:', error)
@ -173,6 +174,32 @@
},
goBack() {
uni.navigateBack()
},
getRoomStatus(room) {
// WebStatus.vue使room.status
return room.status || 'empty'
},
getRoomStatusText(room) {
// WebStatus.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
},
getRoomStatusClass(room) {
// statusrentalStatusWeb
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 ''
}
}
}
@ -249,11 +276,12 @@
.empty-btn {
background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%);
color: #FFFFFF;
font-size: 30rpx;
font-weight: 600;
padding: 24rpx 60rpx;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 500;
padding: 16rpx 40rpx;
border-radius: 12rpx;
border: none;
line-height: 1.5;
}
.room-grid {
display: grid;
@ -262,9 +290,37 @@
}
.room-card {
background: #FFFFFF;
border: 2rpx solid #EBEEF5;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.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-header {
display: flex;
@ -279,16 +335,11 @@
}
.room-status {
padding: 4rpx 12rpx;
border-radius: 6rpx;
border-radius: 20rpx;
font-size: 20rpx;
}
.room-status.vacant {
background: #D1FAE5;
color: #059669;
}
.room-status.rented {
background: #DBEAFE;
color: #2563EB;
background: rgba(255, 255, 255, 0.9);
color: #606266;
font-weight: 500;
}
.room-info {
margin-bottom: 12rpx;

View File

@ -39,7 +39,7 @@
<view class="overview-row">
<view class="overview-card pending">
<view class="card-icon">
<uni-icons type="time-filled" size="28" color="#FFFFFF"></uni-icons>
<uni-icons type="refresh-filled" size="28" color="#FFFFFF"></uni-icons>
</view>
<view class="card-content">
<text class="card-value">¥{{formatAmount(statistics.totalUnreceived)}}</text>
@ -226,13 +226,37 @@
<text>暂无明细数据</text>
</view>
</view>
<view v-if="hasMore" class="load-more" @click="loadMore">
<text>加载更多</text>
</view>
</view>
<view class="safe-area-bottom" style="height: 40rpx;"></view>
</scroll-view>
<!-- 自定义日期选择弹窗 -->
<view class="custom-date-mask" v-if="showCustomDatePicker" @click="cancelCustomDate">
<view class="date-picker-popup" @click.stop>
<view class="popup-header">
<text class="popup-title">选择日期范围</text>
<view class="popup-close" @click="cancelCustomDate">
<uni-icons type="close" size="20" color="#64748B"></uni-icons>
</view>
</view>
<view class="popup-content">
<uni-datetime-picker
v-model="tempDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:border="false"
:clear-icon="false"
/>
</view>
<view class="popup-footer">
<view class="btn btn-cancel" @click="cancelCustomDate">取消</view>
<view class="btn btn-confirm" @click="confirmCustomDate">确定</view>
</view>
</view>
</view>
</view>
</template>
@ -262,9 +286,9 @@
],
categoryMap: {},
isLoading: false,
page: 1,
pageSize: 10,
hasMore: true
//
showCustomDatePicker: false,
tempDateRange: []
}
},
computed: {
@ -278,8 +302,6 @@
this.loadData()
},
onPullDownRefresh() {
this.page = 1
this.hasMore = true
this.loadData().finally(() => {
uni.stopPullDownRefresh()
})
@ -317,15 +339,23 @@
this.isLoading = true
try {
console.log('开始加载数据,日期范围:', this.dateRange)
const billRes = await billApi.list({
startDate: this.dateRange[0],
endDate: this.dateRange[1],
page: this.page,
pageSize: this.pageSize
endDate: this.dateRange[1]
})
console.log('API返回结果:', billRes)
this.processData(billRes.data || [])
if (billRes.code === 200) {
this.processData(billRes.data || [])
} else {
uni.showToast({
title: billRes.message || '加载数据失败',
icon: 'none'
})
}
} catch (error) {
console.error('加载数据失败:', error)
uni.showToast({
title: '加载数据失败',
icon: 'none'
@ -432,15 +462,8 @@
//
this.processTrendData(trendData)
if (this.page === 1) {
this.incomeList = incomeList
this.expenseList = expenseList
} else {
this.incomeList = [...this.incomeList, ...incomeList]
this.expenseList = [...this.expenseList, ...expenseList]
}
this.hasMore = bills.length >= this.pageSize
this.incomeList = incomeList
this.expenseList = expenseList
},
//
@ -468,9 +491,15 @@
uni.showActionSheet({
itemList: ['本周', '本月', '本季度', '本年', '自定义'],
success: (res) => {
const periods = ['本周', '本月', '本季度', '本年', '自定义']
this.currentPeriod = periods[res.tapIndex]
this.updateDateRange(res.tapIndex)
if (res.tapIndex === 4) {
//
this.showCustomDatePicker = true
this.tempDateRange = this.dateRange
} else {
const periods = ['本周', '本月', '本季度', '本年', '自定义']
this.currentPeriod = periods[res.tapIndex]
this.updateDateRange(res.tapIndex)
}
}
})
},
@ -504,22 +533,38 @@
}
this.dateRange = [this.formatDate(start), this.formatDate(end)]
this.page = 1
this.hasMore = true
this.loadData()
},
//
confirmCustomDate() {
if (this.tempDateRange && this.tempDateRange.length === 2) {
this.dateRange = this.tempDateRange
this.currentPeriod = `${this.tempDateRange[0]}${this.tempDateRange[1]}`
this.showCustomDatePicker = false
this.loadData()
} else {
uni.showToast({
title: '请选择开始和结束日期',
icon: 'none'
})
}
},
//
cancelCustomDate() {
this.showCustomDatePicker = false
this.tempDateRange = []
},
//
switchTab(value) {
this.activeTab = value
},
//
//
loadMore() {
if (this.hasMore && !this.isLoading) {
this.page++
this.loadData()
}
// 使
},
//
@ -1074,4 +1119,79 @@
color: #64748B;
font-size: 26rpx;
}
/* 日期选择弹窗 */
.custom-date-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.date-picker-popup {
background: #FFFFFF;
border-radius: 24rpx;
width: 600rpx;
overflow: hidden;
margin: 0 40rpx;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 2rpx solid #F1F5F9;
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #1E293B;
}
.popup-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 24rpx;
background: #F8FAFC;
}
.popup-content {
padding: 48rpx 32rpx;
}
.popup-footer {
display: flex;
padding: 24rpx 32rpx;
gap: 24rpx;
border-top: 2rpx solid #F1F5F9;
}
.btn {
flex: 1;
text-align: center;
padding: 24rpx 0;
border-radius: 12rpx;
font-size: 28rpx;
}
.btn-cancel {
background: #F1F5F9;
color: #64748B;
}
.btn-confirm {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #FFFFFF;
}
</style>

View File

@ -70,18 +70,19 @@ const getHeaders = (needAuth = true) => {
/**
* 处理响应错误
* 优先使用后端返回的 message
* 根据后端统一响应格式处理错误
* 后端格式: { code: 400/401/403/404/500, message: 'xxx', data: null }
*/
const handleResponseError = (response) => {
const { statusCode: code, data } = response
const { statusCode: httpCode, data } = response
// 优先使用后端返回的 message
const message = data?.message
const message = data?.message || '请求失败'
switch (code) {
switch (httpCode) {
case 400:
uni.showToast({
title: message || '请求参数错误',
title: message,
icon: 'none'
})
break
@ -91,7 +92,7 @@ const handleResponseError = (response) => {
uni.removeStorageSync(getStorageKey('token'))
uni.removeStorageSync(getStorageKey('userInfo'))
uni.showToast({
title: message || '登录已过期,请重新登录',
title: message,
icon: 'none'
})
setTimeout(() => {
@ -103,28 +104,28 @@ const handleResponseError = (response) => {
case 403:
uni.showToast({
title: message || '没有权限执行此操作',
title: message,
icon: 'none'
})
break
case 404:
uni.showToast({
title: message || '请求的资源不存在',
title: message,
icon: 'none'
})
break
case 500:
uni.showToast({
title: message || '服务器错误,请稍后重试',
title: message,
icon: 'none'
})
break
default:
uni.showToast({
title: message || '请求失败',
title: message,
icon: 'none'
})
}
@ -160,23 +161,28 @@ const request = (options = {}, needAuth = true) => {
uni.request({
...config,
success: (response) => {
const { statusCode: code, data } = response
const { statusCode: httpCode, data } = response
// 请求成功
if (code >= 200 && code < 300) {
// 如果后端返回的数据没有 code 字段,直接视为成功
if (!data.code || data.code === 200) {
// HTTP 请求成功 (2xx)
if (httpCode >= 200 && httpCode < 300) {
// 根据后端统一响应格式处理
// 后端格式: { code: 200, message: 'xxx', data: xxx }
// 业务逻辑成功
if (data.code === 200) {
resolve(data)
} else {
// 业务逻辑失败
// 业务逻辑失败(如参数错误等)
// 后端格式: { code: 400/403/404/500, message: 'xxx', data: null }
const errorMessage = data.message || '操作失败'
uni.showToast({
title: data.message || '操作失败',
title: errorMessage,
icon: 'none'
})
reject(data)
}
} else {
// HTTP错误
// HTTP 错误4xx, 5xx
handleResponseError(response)
reject(response)
}
@ -262,11 +268,13 @@ export const upload = (url, filePath, formData = {}, needAuth = true) => {
header: needAuth && token ? { 'Authorization': `Bearer ${token}` } : {},
success: (response) => {
const data = JSON.parse(response.data)
// 根据后端统一响应格式处理
if (data.code === 200) {
resolve(data)
} else {
const errorMessage = data.message || '上传失败'
uni.showToast({
title: data.message || '上传失败',
title: errorMessage,
icon: 'none'
})
reject(data)