Files
EmailBill/Web/src/views/BudgetView.vue
孙诚 35a856c6e3
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
feat: 优化预算管理界面,增强预算编辑功能,添加预算删除接口
2026-01-07 19:19:53 +08:00

359 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-container-flex">
<van-nav-bar title="预算管理" placeholder>
<template #right>
<van-icon name="plus" size="20" @click="budgetEditRef.open({ category: activeTab })" />
</template>
</van-nav-bar>
<div class="scroll-content">
<div class="page-content">
<van-tabs v-model:active="activeTab" sticky offset-top="0" type="card">
<van-tab title="支出" :name="BudgetCategory.Expense">
<BudgetSummary
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<div class="budget-list">
<template v-if="expenseBudgets?.length > 0">
<van-swipe-cell v-for="budget in expenseBudgets" :key="budget.id">
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ 'warning': (budget.current / budget.limit) > 0.8 }"
:period-label="getPeriodLabel(budget.type)"
@toggle-stop="handleToggleStop"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})"
>
<template #amount-info>
<div class="info-item">
<div class="label">当前</div>
<div class="value expense">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">预算</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">结余</div>
<div class="value" :class="budget.limit - budget.current >= 0 ? 'income' : 'expense'">
¥{{ formatMoney(budget.limit - budget.current) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
</template>
</van-swipe-cell>
</template>
<van-empty v-else description="暂无支出预算" />
</div>
</van-tab>
<van-tab title="收入" :name="BudgetCategory.Income">
<BudgetSummary
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<div class="budget-list">
<template v-if="incomeBudgets?.length > 0">
<van-swipe-cell v-for="budget in incomeBudgets" :key="budget.id">
<BudgetCard
:budget="budget"
:progress-color="getIncomeProgressColor(budget)"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
status-tag-text="进行中"
@toggle-stop="handleToggleStop"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open(budget)"
>
<template #amount-info>
<div class="info-item">
<div class="label">当前已收</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">目标收入</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">差额</div>
<div class="value" :class="budget.current >= budget.limit ? 'income' : 'expense'">
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
</template>
</van-swipe-cell>
</template>
<van-empty v-else description="暂无收入预算" />
</div>
</van-tab>
<van-tab title="存款" :name="BudgetCategory.Savings">
<BudgetSummary
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<div class="budget-list">
<template v-if="savingsBudgets?.length > 0">
<van-swipe-cell v-for="budget in savingsBudgets" :key="budget.id">
<BudgetCard
:budget="budget"
progress-color="#07c160"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
status-tag-text="积累中"
@toggle-stop="handleToggleStop"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open(budget)"
>
<template #amount-info>
<div class="info-item">
<div class="label">已存</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">目标</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">还差</div>
<div class="value expense">
¥{{ formatMoney(Math.max(0, budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
</template>
</van-swipe-cell>
</template>
<van-empty v-else description="暂无存款计划" />
</div>
</van-tab>
</van-tabs>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
<!-- 添加/编辑预算弹窗 -->
<BudgetEditPopup
ref="budgetEditRef"
@success="fetchBudgetList"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import { getBudgetList, deleteBudget, toggleStopBudget, getBudgetStatistics } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
const activeTab = ref(BudgetCategory.Expense)
const budgetEditRef = ref(null)
const expenseBudgets = ref([])
const incomeBudgets = ref([])
const savingsBudgets = ref([])
const activeTabTitle = computed(() => {
if (activeTab.value === BudgetCategory.Expense) return '使用'
return '达成'
})
const overallStats = computed(() => {
const allBudgets = [...expenseBudgets.value, ...incomeBudgets.value, ...savingsBudgets.value]
const getStatsForType = (type) => {
const category = activeTab.value
const filtered = allBudgets.filter(b => b.type === type && b.category === category && !b.isStopped)
if (filtered.length === 0) return { rate: '0.0', current: 0, limit: 0, count: 0 }
const current = filtered.reduce((sum, b) => sum + (b.current || 0), 0)
const limit = filtered.reduce((sum, b) => sum + (b.limit || 0), 0)
const rate = limit > 0 ? (current / limit) * 100 : 0
return {
rate: rate.toFixed(1),
current,
limit,
count: filtered.length
}
}
return {
week: getStatsForType(BudgetPeriodType.Week),
month: getStatsForType(BudgetPeriodType.Month),
year: getStatsForType(BudgetPeriodType.Year)
}
})
const getValueClass = (rate) => {
const numRate = parseFloat(rate)
if (numRate === 0) return ''
if (activeTab.value === BudgetCategory.Expense) {
if (numRate >= 100) return 'expense'
if (numRate >= 80) return 'warning'
return 'income'
} else {
if (numRate >= 100) return 'income'
if (numRate >= 80) return 'warning'
return 'expense'
}
}
const fetchBudgetList = async () => {
try {
const res = await getBudgetList()
if (res.success) {
const data = res.data || []
expenseBudgets.value = data.filter(b => b.category === BudgetCategory.Expense)
incomeBudgets.value = data.filter(b => b.category === BudgetCategory.Income)
savingsBudgets.value = data.filter(b => b.category === BudgetCategory.Savings)
}
} catch (err) {
console.error('加载预算列表失败', err)
}
}
onMounted(async () => {
try {
await fetchBudgetList()
} catch (err) {
console.error('获取初始化数据失败', err)
}
})
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const getPeriodLabel = (type) => {
const map = {
[BudgetPeriodType.Week]: '本周',
[BudgetPeriodType.Month]: '本月',
[BudgetPeriodType.Year]: '本年',
[BudgetPeriodType.Longterm]: '长期'
}
return map[type] || '周期'
}
const getProgressColor = (budget) => {
const ratio = budget.current / budget.limit
if (ratio >= 1) return '#ee0a24' // 危险红色
if (ratio > 0.8) return '#ff976a' // 警告橙色
return '#1989fa' // 正常蓝色
}
const getIncomeProgressColor = (budget) => {
const ratio = budget.current / budget.limit
if (ratio >= 1) return '#07c160' // 完成绿色
return '#1989fa' // 蓝色
}
const refDateMap = {}
const handleSwitchPeriod = async (budget, direction) => {
if (budget.type === BudgetPeriodType.Longterm) {
showToast('长期预算不支持切换周期')
return
}
// 获取或初始化该预算的参考日期
let currentRefDate = refDateMap[budget.id] || new Date()
const date = new Date(currentRefDate)
if (budget.type === BudgetPeriodType.Week) {
date.setDate(date.getDate() + direction * 7)
} else if (budget.type === BudgetPeriodType.Month) {
date.setMonth(date.getMonth() + direction)
} else if (budget.type === BudgetPeriodType.Year) {
date.setFullYear(date.getFullYear() + direction)
}
try {
const res = await getBudgetStatistics(budget.id, date.toISOString())
if (res.success) {
refDateMap[budget.id] = date
// 更新当前列表中的预算对象信息
Object.assign(budget, res.data)
}
} catch (err) {
showToast('加载历史统计失败')
console.error('加载预算历史统计失败', err)
}
}
const handleDelete = (budget) => {
showConfirmDialog({
title: '确认删除',
message: `确定要删除预算 "${budget.name}" `,
}).then(async () => {
try {
const res = await deleteBudget(budget.id)
if (res.success) {
showToast('已删除')
fetchBudgetList()
}
} catch (err) {
showToast('删除失败')
console.error('删除预算失败', err)
}
}).catch(() => {})
}
const handleToggleStop = async (budget) => {
try {
const res = await toggleStopBudget(budget.id)
if (res.success) {
showToast(budget.isStopped ? '已恢复' : '已停止')
// 切换停止状态后刷新列表
fetchBudgetList()
}
} catch (err) {
showToast('操作失败')
console.error('切换预算状态失败', err)
}
}
</script>
<style scoped>
.budget-list {
padding-top: 8px;
padding-bottom: 20px;
}
.budget-list :deep(.van-swipe-cell) {
margin: 0 12px 12px;
}
.delete-button {
height: 100%;
}
:deep(.van-tabs__nav--card) {
margin: 0 12px;
}
</style>