Files
akmon/pages/ec/finance/management.uvue
2026-01-20 08:04:15 +08:00

892 lines
22 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 养老管理系统 - 财务管理 -->
<template>
<view class="finance-management">
<view class="header">
<text class="title">财务管理</text>
<button class="add-btn" @click="showAddBill">添加账单</button>
</view>
<!-- 统计卡片 -->
<view class="stats-section">
<view class="stat-card">
<view class="stat-icon"></view>
<view class="stat-content">
<text class="stat-number">{{ stats.total_amount.toFixed(2) }}</text>
<text class="stat-label">总金额</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon">✅</view>
<view class="stat-content">
<text class="stat-number">{{ stats.paid_amount.toFixed(2) }}</text>
<text class="stat-label">已收金额</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon">⏰</view>
<view class="stat-content">
<text class="stat-number">{{ stats.pending_amount.toFixed(2) }}</text>
<text class="stat-label">待收金额</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon">⚠️</view>
<view class="stat-content">
<text class="stat-number">{{ stats.overdue_amount.toFixed(2) }}</text>
<text class="stat-label">逾期金额</text>
</view>
</view>
</view>
<!-- 筛选区域 -->
<view class="filter-section">
<view class="filter-item">
<text class="filter-label">老人:</text>
<picker-view class="picker" :value="selectedElderIndex" @change="onElderChange">
<picker-view-column>
<view v-for="(elder, index) in elderOptions" :key="elder.id" class="picker-item">
{{ elder.name }}
</view>
</picker-view-column>
</picker-view>
</view>
<view class="filter-item">
<text class="filter-label">类型:</text>
<picker-view class="picker" :value="selectedTypeIndex" @change="onTypeChange">
<picker-view-column>
<view v-for="(type, index) in typeOptions" :key="index" class="picker-item">
{{ type.label }}
</view>
</picker-view-column>
</picker-view>
</view>
<view class="filter-item">
<text class="filter-label">状态:</text>
<picker-view class="picker" :value="selectedStatusIndex" @change="onStatusChange">
<picker-view-column>
<view v-for="(status, index) in statusOptions" :key="index" class="picker-item">
{{ status.label }}
</view>
</picker-view-column>
</picker-view>
</view>
<button class="search-btn" @click="searchBills">搜索</button>
</view>
<!-- 账单列表 -->
<view class="bills-list">
<view v-for="bill in bills" :key="bill.id" class="bill-item" @click="viewBillDetail(bill)">
<view class="bill-header">
<text class="bill-type">{{ getTypeText(bill.bill_type) }}</text>
<view class="status-badge" :class="getStatusClass(bill.status)">
<text class="status-text">{{ getStatusText(bill.status) }}</text>
</view>
</view>
<view class="bill-info">
<text class="elder-name">{{ getElderName(bill.elder_id) }}</text>
<text class="bill-amount">金额: ¥{{ bill.amount.toFixed(2) }}</text>
<text class="bill-description">{{ bill.description ?? '无描述' }}</text>
</view>
<view class="bill-dates">
<text class="date-text">到期: {{ formatDate(bill.due_date) }}</text>
<text class="date-text" v-if="bill.paid_date">支付: {{ formatDate(bill.paid_date) }}</text>
</view>
<view class="bill-actions">
<button class="action-btn edit-btn" @click.stop="editBill(bill)">编辑</button>
<button class="action-btn pay-btn" v-if="bill.status === 'pending'" @click.stop="markAsPaid(bill)">标记已付</button>
<button class="action-btn print-btn" @click.stop="printBill(bill)">打印</button>
</view>
</view>
</view>
<!-- 添加/编辑账单弹窗 -->
<view v-if="showBillModal" class="modal-overlay" @click="closeBillModal">
<view class="modal-content" @click.stop="">
<view class="modal-header">
<text class="modal-title">{{ isEditMode ? '编辑账单' : '添加账单' }}</text>
<button class="close-btn" @click="closeBillModal">×</button>
</view>
<view class="modal-body">
<view class="form-group">
<text class="form-label">老人:</text>
<picker-view class="form-picker" :value="formData.elderIndex" @change="onFormElderChange">
<picker-view-column>
<view v-for="(elder, index) in elderOptions" :key="elder.id" class="picker-item">
{{ elder.name }}
</view>
</picker-view-column>
</picker-view>
</view>
<view class="form-group">
<text class="form-label">账单类型:</text>
<picker-view class="form-picker" :value="formData.typeIndex" @change="onFormTypeChange">
<picker-view-column>
<view v-for="(type, index) in billTypes" :key="index" class="picker-item">
{{ type.label }}
</view>
</picker-view-column>
</picker-view>
</view>
<view class="form-group">
<text class="form-label">金额:</text>
<input class="form-input" v-model="formData.amount" type="number" placeholder="请输入金额" />
</view>
<view class="form-group">
<text class="form-label">描述:</text>
<textarea class="form-textarea" v-model="formData.description" placeholder="请输入账单描述"></textarea>
</view>
<view class="form-group">
<text class="form-label">到期日期:</text>
<lime-date-time-picker v-model="formData.due_date" type="date" :placeholder="'选择日期'" class="date-picker" />
</view>
<view class="form-group">
<text class="form-label">支付方式:</text>
<picker-view class="form-picker" :value="formData.paymentMethodIndex" @change="onFormPaymentMethodChange">
<picker-view-column>
<view v-for="(method, index) in paymentMethods" :key="index" class="picker-item">
{{ method.label }}
</view>
</picker-view-column>
</picker-view>
</view>
<view class="form-group">
<text class="form-label">备注:</text>
<textarea class="form-textarea" v-model="formData.notes" placeholder="请输入备注"></textarea>
</view>
</view>
<view class="modal-footer">
<button class="cancel-btn" @click="closeBillModal">取消</button>
<button class="save-btn" @click="saveBill">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { Bill, Elder } from '../types.uts'
import { formatDate, getStatusClass } from '../types.uts'
import supa from '@/components/supadb/aksupainstance.uts'
// 统计数据类型
type FinanceStats = {
total_amount: number
paid_amount: number
pending_amount: number
overdue_amount: number
}
// 响应式数据
const bills = ref<Bill[]>([])
const elderOptions = ref<Elder[]>([])
const eldersMap = ref<Map<string, string>>(new Map())
const stats = ref<FinanceStats>({
total_amount: 0,
paid_amount: 0,
pending_amount: 0,
overdue_amount: 0
})
// 筛选相关
const selectedElderIndex = ref([0])
const selectedTypeIndex = ref([0])
const selectedStatusIndex = ref([0])
const typeOptions = [
{ value: 'all', label: '全部类型' },
{ value: 'monthly_fee', label: '月费' },
{ value: 'medical', label: '医疗费' },
{ value: 'nursing', label: '护理费' },
{ value: 'meal', label: '餐费' },
{ value: 'activity', label: '活动费' },
{ value: 'other', label: '其他费用' }
]
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '已支付' },
{ value: 'overdue', label: '已逾期' },
{ value: 'cancelled', label: '已取消' }
]
const billTypes = [
{ value: 'monthly_fee', label: '月费' },
{ value: 'medical', label: '医疗费' },
{ value: 'nursing', label: '护理费' },
{ value: 'meal', label: '餐费' },
{ value: 'activity', label: '活动费' },
{ value: 'other', label: '其他费用' }
]
const paymentMethods = [
{ value: 'cash', label: '现金' },
{ value: 'card', label: '银行卡' },
{ value: 'transfer', label: '转账' },
{ value: 'wechat', label: '微信支付' },
{ value: 'alipay', label: '支付宝' }
]
// 弹窗相关
const showBillModal = ref(false)
const isEditMode = ref(false)
const currentBillId = ref<string | null>(null)
// 表单数据
const formData = ref({
elderIndex: [0],
typeIndex: [0],
amount: '',
description: '',
due_date: '',
paymentMethodIndex: [0],
notes: ''
})
// 页面加载
onLoad(() => {
loadData()
})
// 加载数据
async function loadData(): Promise<void> {
try {
await Promise.all([
loadElders(),
loadBills(),
loadStats()
])
} catch (error) {
console.error('加载数据失败:', error)
uni.showToast({
title: '加载数据失败',
icon: 'error'
})
}
}
// 加载老人列表
async function loadElders(): Promise<void> {
const result = await supa.executeAs<Elder>('eldercare_admin', `
SELECT id, name FROM ec_elders
WHERE status = 'active'
ORDER BY name
`)
elderOptions.value = [{ id: '', name: '全部老人' } as Elder, ...result]
// 建立映射
const map = new Map<string, string>()
for (let i: Int = 0; i < result.length; i++) {
const elder = result[i]
map.set(elder.id, elder.name)
}
eldersMap.value = map
}
// 加载账单列表
async function loadBills(): Promise<void> {
let whereClause = "WHERE 1=1"
// 老人筛选
if (selectedElderIndex.value[0] > 0) {
const selectedElder = elderOptions.value[selectedElderIndex.value[0]]
whereClause += ` AND elder_id = '${selectedElder.id}'`
}
// 类型筛选
if (selectedTypeIndex.value[0] > 0) {
const selectedType = typeOptions[selectedTypeIndex.value[0]]
whereClause += ` AND bill_type = '${selectedType.value}'`
}
// 状态筛选
if (selectedStatusIndex.value[0] > 0) {
const selectedStatus = statusOptions[selectedStatusIndex.value[0]]
whereClause += ` AND status = '${selectedStatus.value}'`
}
const result = await supa.executeAs<Bill>('eldercare_admin', `
SELECT * FROM ec_bills
${whereClause}
ORDER BY created_at DESC
`)
bills.value = result
}
// 加载统计数据
async function loadStats(): Promise<void> {
const result = await supa.executeAs<FinanceStats>('eldercare_admin', `
SELECT
COALESCE(SUM(amount), 0) as total_amount,
COALESCE(SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END), 0) as paid_amount,
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) as pending_amount,
COALESCE(SUM(CASE WHEN status = 'overdue' THEN amount ELSE 0 END), 0) as overdue_amount
FROM ec_bills
`)
if (result.length > 0) {
stats.value = result[0]
}
}
// 获取老人姓名
function getElderName(elderId: string): string {
return eldersMap.value.get(elderId) ?? '未知老人'
}
// 获取类型文本
function getTypeText(type: string): string {
const typeMap: Record<string, string> = {
'monthly_fee': '月费',
'medical': '医疗费',
'nursing': '护理费',
'meal': '餐费',
'activity': '活动费',
'other': '其他费用'
}
return typeMap[type] ?? type
}
// 获取状态文本
function getStatusText(status: string): string {
const statusMap: Record<string, string> = {
'pending': '待支付',
'paid': '已支付',
'overdue': '已逾期',
'cancelled': '已取消'
}
return statusMap[status] ?? status
}
// 筛选事件
function onElderChange(e: any): void {
selectedElderIndex.value = e.detail.value
}
function onTypeChange(e: any): void {
selectedTypeIndex.value = e.detail.value
}
function onStatusChange(e: any): void {
selectedStatusIndex.value = e.detail.value
}
// 搜索账单
function searchBills(): void {
loadBills()
loadStats()
}
// 查看账单详情
function viewBillDetail(bill: Bill): void {
uni.navigateTo({
url: `/pages/ec/finance/detail?id=${bill.id}`
})
}
// 编辑账单
function editBill(bill: Bill): void {
isEditMode.value = true
currentBillId.value = bill.id
// 填充表单数据
const elderIndex = elderOptions.value.findIndex(elder => elder.id === bill.elder_id)
const typeIndex = billTypes.findIndex(type => type.value === bill.bill_type)
const paymentMethodIndex = paymentMethods.findIndex(method => method.value === bill.payment_method)
formData.value = {
elderIndex: [elderIndex > 0 ? elderIndex : 0],
typeIndex: [typeIndex > 0 ? typeIndex : 0],
amount: bill.amount.toString(),
description: bill.description ?? '',
due_date: bill.due_date ?? '',
paymentMethodIndex: [paymentMethodIndex > 0 ? paymentMethodIndex : 0],
notes: bill.notes ?? ''
}
showBillModal.value = true
}
// 标记为已支付
async function markAsPaid(bill: Bill): Promise<void> {
uni.showModal({
title: '确认支付',
content: '确定标记此账单为已支付吗?',
success: async (res) => {
if (res.confirm) {
try {
await supa.executeAs('eldercare_admin', `
UPDATE ec_bills
SET status = 'paid', paid_date = CURRENT_DATE, updated_at = NOW()
WHERE id = '${bill.id}'
`)
uni.showToast({
title: '支付成功',
icon: 'success'
})
loadBills()
loadStats()
} catch (error) {
console.error('标记支付失败:', error)
uni.showToast({
title: '操作失败',
icon: 'error'
})
}
}
}
})
}
// 打印账单
function printBill(bill: Bill): void {
// 这里可以实现打印功能
uni.showToast({
title: '打印功能待实现',
icon: 'none'
})
}
// 显示添加账单弹窗
function showAddBill(): void {
isEditMode.value = false
currentBillId.value = null
// 重置表单
const today = new Date()
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, today.getDate())
formData.value = {
elderIndex: [0],
typeIndex: [0],
amount: '',
description: '',
due_date: formatDate(nextMonth.toISOString()),
paymentMethodIndex: [0],
notes: ''
}
showBillModal.value = true
}
// 关闭弹窗
function closeBillModal(): void {
showBillModal.value = false
}
// 表单事件
function onFormElderChange(e: any): void {
formData.value.elderIndex = e.detail.value
}
function onFormTypeChange(e: any): void {
formData.value.typeIndex = e.detail.value
}
function onFormPaymentMethodChange(e: any): void {
formData.value.paymentMethodIndex = e.detail.value
}
function onDueDateChange(date: string): void {
formData.value.due_date = date
}
// 保存账单
async function saveBill(): Promise<void> {
// 验证表单
if (formData.value.elderIndex[0] === 0) {
uni.showToast({
title: '请选择老人',
icon: 'error'
})
return
}
if (formData.value.amount.trim() === '' || parseFloat(formData.value.amount) <= 0) {
uni.showToast({
title: '请输入有效金额',
icon: 'error'
})
return
}
try {
const selectedElder = elderOptions.value[formData.value.elderIndex[0]]
const selectedType = billTypes[formData.value.typeIndex[0]]
const selectedPaymentMethod = paymentMethods[formData.value.paymentMethodIndex[0]]
if (isEditMode.value && currentBillId.value !== null) {
// 更新账单
await supa.executeAs('eldercare_admin', `
UPDATE ec_bills SET
elder_id = '${selectedElder.id}',
bill_type = '${selectedType.value}',
amount = ${formData.value.amount},
description = '${formData.value.description}',
due_date = ${formData.value.due_date ? `'${formData.value.due_date}'` : 'NULL'},
payment_method = '${selectedPaymentMethod.value}',
notes = '${formData.value.notes}',
updated_at = NOW()
WHERE id = '${currentBillId.value}'
`)
} else {
// 新增账单
await supa.executeAs('eldercare_admin', `
INSERT INTO ec_bills (
elder_id, bill_type, amount, description, due_date,
payment_method, notes, status
) VALUES (
'${selectedElder.id}',
'${selectedType.value}',
${formData.value.amount},
'${formData.value.description}',
${formData.value.due_date ? `'${formData.value.due_date}'` : 'NULL'},
'${selectedPaymentMethod.value}',
'${formData.value.notes}',
'pending'
)
`)
}
uni.showToast({
title: '保存成功',
icon: 'success'
})
closeBillModal()
loadBills()
loadStats()
} catch (error) {
console.error('保存失败:', error)
uni.showToast({
title: '保存失败',
icon: 'error'
})
}
}
</script>
<style scoped>
/* uts-android 兼容性重构:
1. 移除所有嵌套选择器、伪类(如 :last-child、&.xxx全部 class 扁平化。
2. 所有间距用 margin-right/margin-bottom 控制,禁止 gap、flex-wrap、嵌套。
3. 所有布局 display: flex禁止 grid、gap、伪类。
4. 组件间距、分隔线全部用 border/margin 控制。
5. 新增.is-last、.is-active、.is-overdue 等辅助 class。
*/
.finance-management {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 24px;
font-weight: bold;
color: #333;
}
.add-btn {
padding: 8px 16px;
border-radius: 20px;
border: 1px solid #2196f3;
background-color: #2196f3;
color: white;
}
.stats-section {
display: flex;
flex-direction: row;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: #fff;
border-radius: 12px;
margin-right: 15px;
padding: 20px;
display: flex;
flex-direction: row;
align-items: center;
}
.stat-card.is-last {
margin-right: 0;
}
.stat-icon {
font-size: 24px;
margin-right: 10px;
}
.stat-content {
flex: 1;
}
.stat-number {
font-size: 20px;
font-weight: bold;
}
.stat-label {
font-size: 14px;
color: #666;
}
.filter-section {
display: flex;
flex-direction: row;
align-items: center;
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 15px;
}
.filter-item.is-last {
margin-right: 0;
}
.filter-label {
font-size: 14px;
color: #666;
margin-right: 8px;
}
.picker {
width: 150px;
}
.search-btn {
padding: 8px 16px;
border-radius: 8px;
background: #2196f3;
color: white;
border: none;
}
.bills-list {
background: #fff;
border-radius: 12px;
min-height: 300px;
margin-bottom: 20px;
}
.bill-item {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.bill-item.is-last {
border-bottom: none;
}
.bill-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.bill-type {
font-size: 16px;
font-weight: bold;
color: #333;
margin-right: 10px;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
background-color: #f5f5f5;
color: #999;
}
.status-badge.paid {
background-color: #f6ffed;
color: #52c41a;
}
.status-badge.pending {
background-color: #fffbe6;
color: #faad14;
}
.status-badge.overdue {
background-color: #fff2f0;
color: #ff4d4f;
}
.status-text {
font-weight: 500;
}
.bill-info {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.elder-name {
font-size: 14px;
color: #1890ff;
margin-right: 10px;
}
.bill-amount {
font-size: 14px;
color: #333;
margin-right: 10px;
}
.bill-description {
font-size: 14px;
color: #666;
}
.bill-dates {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.date-text {
font-size: 12px;
color: #999;
margin-right: 10px;
}
.bill-actions {
display: flex;
flex-direction: row;
align-items: center;
}
.action-btn {
padding: 6px 12px;
border-radius: 15px;
border: none;
font-size: 12px;
color: white;
margin-right: 8px;
}
.edit-btn {
background-color: #1890ff;
}
.pay-btn {
background-color: #52c41a;
}
.print-btn {
background-color: #faad14;
color: #fff;
}
.action-btn.is-last {
margin-right: 0;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
padding: 24px;
width: 500px;
max-width: 95vw;
}
.modal-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
color: #999;
}
.modal-body {
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.form-label {
font-size: 14px;
color: #666;
margin-right: 8px;
width: 80px;
flex-shrink: 0;
}
.form-picker {
width: 150px;
}
.form-input {
flex: 1;
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
}
.form-textarea {
flex: 1;
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
min-height: 60px;
}
.form-date-picker {
width: 150px;
}
.modal-footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.cancel-btn {
background-color: #f5f5f5;
color: #666;
margin-right: 10px;
}
.save-btn {
background-color: #2196f3;
color: white;
}
@media (max-width: 768px) {
.finance-management {
padding: 15px;
}
.stats-section {
flex-direction: column;
}
.stat-card {
margin: 5px 0;
min-width: auto;
}
.filter-section {
flex-direction: column;
align-items: stretch;
}
.filter-item {
margin-right: 0;
justify-content: space-between;
}
.picker {
width: 150px;
}
.modal-content {
width: 95%;
}
}
</style>