重构预算管理模块,添加预算记录和服务,更新相关API,优化预算统计逻辑
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s

This commit is contained in:
孙诚
2026-01-06 21:15:02 +08:00
parent 0ca7f44e37
commit 343c754431
10 changed files with 654 additions and 221 deletions

View File

@@ -9,9 +9,6 @@
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')">
统计
</van-tabbar-item>
<van-tabbar-item name="budget" icon="bill-o" to="/budget" @click="handleTabClick('/budget')">
预算
</van-tabbar-item>
<van-tabbar-item
name="balance"
icon="balance-list"
@@ -21,6 +18,9 @@
>
账单
</van-tabbar-item>
<van-tabbar-item name="budget" icon="bill-o" to="/budget" @click="handleTabClick('/budget')">
预算
</van-tabbar-item>
<van-tabbar-item name="setting" icon="setting" to="/setting">
设置
</van-tabbar-item>

74
Web/src/api/budget.js Normal file
View File

@@ -0,0 +1,74 @@
import request from './request'
/**
* 获取预算列表
* @param {string} referenceDate 参考日期 (可选)
*/
export function getBudgetList(referenceDate) {
return request({
url: '/Budget/GetList',
method: 'get',
params: { referenceDate }
})
}
/**
* 获取单个预算统计
* @param {number} id 预算ID
* @param {string} referenceDate 参考日期
*/
export function getBudgetStatistics(id, referenceDate) {
return request({
url: '/Budget/GetStatistics',
method: 'get',
params: { id, referenceDate }
})
}
/**
* 创建预算
* @param {object} data 预算数据
*/
export function createBudget(data) {
return request({
url: '/Budget/Create',
method: 'post',
data
})
}
/**
* 删除预算
* @param {number} id 预算ID
*/
export function deleteBudget(id) {
return request({
url: `/Budget/DeleteById/${id}`,
method: 'delete'
})
}
/**
* 切换预算状态 (停止/恢复)
* @param {number} id 预算ID
*/
export function toggleStopBudget(id) {
return request({
url: '/Budget/ToggleStop',
method: 'post',
params: { id }
})
}
/**
* 同步预算进度
* @param {number} id 预算ID
* @param {string} referenceDate 参考日期 (可选)
*/
export function syncBudget(id, referenceDate) {
return request({
url: '/Budget/Sync',
method: 'post',
params: { id, referenceDate }
})
}

View File

@@ -0,0 +1,27 @@
/**
* 预算周期类型
*/
export const BudgetPeriodType = {
Week: 0,
Month: 1,
Year: 2,
Longterm: 3
}
/**
* 预算类别
*/
export const BudgetCategory = {
Expense: 0,
Income: 1,
Savings: 2
}
/**
* 交易类型 (与后端 TransactionType 对应)
*/
export const TransactionType = {
Expense: 0,
Income: 1,
None: 2
}

View File

@@ -8,7 +8,7 @@
<div class="page-content">
<van-tabs v-model:active="activeTab" sticky offset-top="46" type="card">
<van-tab title="支出" name="expense">
<van-tab title="支出" :name="BudgetCategory.Expense">
<div class="budget-list">
<template v-if="expenseBudgets?.length > 0">
<van-swipe-cell v-for="budget in expenseBudgets" :key="budget.id">
@@ -77,7 +77,7 @@
</div>
</van-tab>
<van-tab title="收入" name="income">
<van-tab title="收入" :name="BudgetCategory.Income">
<div class="budget-list">
<template v-if="incomeBudgets?.length > 0">
<van-swipe-cell v-for="budget in incomeBudgets" :key="budget.id">
@@ -146,7 +146,7 @@
</div>
</van-tab>
<van-tab title="存款" name="savings">
<van-tab title="存款" :name="BudgetCategory.Savings">
<div class="budget-list">
<template v-if="savingsBudgets?.length > 0">
<van-swipe-cell v-for="budget in savingsBudgets" :key="budget.id">
@@ -222,7 +222,7 @@
<!-- 添加预算弹窗 -->
<PopupContainer v-model="showAddPopup" title="新增预算" height="70%">
<div class="add-budget-form">
<van-form @submit="onSubmit">
<van-form>
<van-cell-group inset>
<van-field
v-model="form.name"
@@ -234,10 +234,10 @@
<van-field name="type" label="统计周期">
<template #input>
<van-radio-group v-model="form.type" direction="horizontal">
<van-radio name="week"></van-radio>
<van-radio name="month"></van-radio>
<van-radio name="year"></van-radio>
<van-radio name="longterm">长期</van-radio>
<van-radio :name="BudgetPeriodType.Week"></van-radio>
<van-radio :name="BudgetPeriodType.Month"></van-radio>
<van-radio :name="BudgetPeriodType.Year"></van-radio>
<van-radio :name="BudgetPeriodType.Longterm">长期</van-radio>
</van-radio-group>
</template>
</van-field>
@@ -256,9 +256,9 @@
<van-field name="category" label="类型">
<template #input>
<van-radio-group v-model="form.category" direction="horizontal">
<van-radio name="expense">支出</van-radio>
<van-radio name="income">收入</van-radio>
<van-radio name="savings">存款</van-radio>
<van-radio :name="BudgetCategory.Expense">支出</van-radio>
<van-radio :name="BudgetCategory.Income">收入</van-radio>
<van-radio :name="BudgetCategory.Savings">存款</van-radio>
</van-radio-group>
</template>
</van-field>
@@ -296,137 +296,41 @@
</div>
</van-cell-group>
<div style="margin: 32px 16px;">
<van-button round block type="primary" native-type="submit">
</van-button>
</div>
</van-form>
</div>
<template #footer>
<van-button block type="primary" @click="onSubmit">保存预算</van-button>
</template>
</PopupContainer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import PopupContainer from '@/components/PopupContainer.vue'
import { getCategoryList } from '@/api/transactionCategory'
import { getBudgetList, createBudget, deleteBudget, toggleStopBudget, syncBudget, getBudgetStatistics } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
const activeTab = ref('expense')
const activeTab = ref(BudgetCategory.Expense)
const showAddPopup = ref(false)
const categories = ref([])
// 模拟数据 (提前定义以防止模板渲染时 undefined)
const expenseBudgets = ref([
{
id: 1,
name: '总支出预算',
type: 'month',
limit: 5000,
current: 3250.5,
isStopped: false,
startDate: '2026-01-01',
period: '2026-01-01 ~ 2026-01-31',
lastSync: '2026-01-06 10:00',
syncing: false
},
{
id: 2,
name: '餐饮美食',
type: 'month',
limit: 1500,
current: 1420,
isStopped: false,
startDate: '2026-01-01',
period: '2026-01-01 ~ 2026-01-31',
lastSync: '2026-01-06 09:30',
syncing: false
},
{
id: 3,
name: '年度旅游',
type: 'year',
limit: 10000,
current: 0,
isStopped: true,
startDate: '2026-01-01',
period: '2026-01-01 ~ 2026-12-31',
lastSync: '2026-01-01 00:00',
syncing: false
}
])
const incomeBudgets = ref([
{
id: 101,
name: '月度薪资',
type: 'month',
limit: 10000,
current: 10000,
isStopped: false,
startDate: '2026-01-01',
period: '2026-01-01 ~ 2026-01-31',
lastSync: '2026-01-06 10:05',
syncing: false
},
{
id: 102,
name: '理财收益',
type: 'year',
limit: 2000,
current: 450.8,
isStopped: false,
startDate: '2026-01-01',
period: '2026-01-01 ~ 2026-12-31',
lastSync: '2026-01-06 10:05',
syncing: false
}
])
const savingsBudgets = ref([
{
id: 201,
name: '买房基金',
type: 'year',
limit: 500000,
current: 125000,
isStopped: false,
startDate: '2025-01-01',
period: '2025-01-01 ~ 2030-12-31',
lastSync: '2026-01-06 11:00',
syncing: false
},
{
id: 202,
name: '养老金',
type: 'year',
limit: 1000000,
current: 50000,
isStopped: false,
startDate: '2026-01-01',
period: '长期',
lastSync: '2026-01-06 11:00',
syncing: false
}
])
const expenseBudgets = ref([])
const incomeBudgets = ref([])
const savingsBudgets = ref([])
const form = reactive({
name: '',
type: 'month',
category: 'expense',
type: BudgetPeriodType.Month,
category: BudgetCategory.Expense,
limit: '',
selectedCategories: []
})
const categoryTypeMap = {
expense: 0,
income: 1,
savings: 2
}
const filteredCategories = computed(() => {
const targetType = categoryTypeMap[form.category]
const targetType = form.category === BudgetCategory.Expense ? 0 : (form.category === BudgetCategory.Income ? 1 : 2)
return categories.value.filter(c => c.type === targetType)
})
@@ -452,14 +356,31 @@ const toggleAll = () => {
}
}
onMounted(async () => {
const fetchBudgetList = async () => {
try {
const res = await getCategoryList()
const res = await getBudgetList()
if (res.success) {
categories.value = res.data || []
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)
console.error('加载预算列表失败', err)
}
}
onMounted(async () => {
try {
const [catRes] = await Promise.all([
getCategoryList(),
fetchBudgetList()
])
if (catRes.success) {
categories.value = catRes.data || []
}
} catch (err) {
console.error('获取初始化数据失败', err)
}
})
@@ -468,14 +389,15 @@ watch(() => form.category, () => {
})
const formatMoney = (val) => {
return parseFloat(val).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const getPeriodLabel = (type) => {
const map = {
week: '本周',
month: '本月',
year: '本年'
[BudgetPeriodType.Week]: '本周',
[BudgetPeriodType.Month]: '本月',
[BudgetPeriodType.Year]: '本年',
[BudgetPeriodType.Longterm]: '长期'
}
return map[type] || '周期'
}
@@ -493,113 +415,109 @@ const getIncomeProgressColor = (budget) => {
return '#1989fa' // 蓝色
}
const getPeriodRange = (startDate, type) => {
if (!startDate || startDate === '长期') return startDate
const start = new Date(startDate)
let end = new Date(startDate)
if (type === 'week') {
end.setDate(start.getDate() + 6)
} else if (type === 'month') {
end = new Date(start.getFullYear(), start.getMonth() + 1, 0)
} else if (type === 'year') {
end = new Date(start.getFullYear(), 11, 31)
}
const format = (d) => d.toISOString().split('T')[0]
return `${format(start)} ~ ${format(end)}`
}
const refDateMap = {}
const handleSwitchPeriod = (budget, direction) => {
if (!budget.startDate || budget.period === '长期') return
const currentStart = new Date(budget.startDate)
if (budget.type === 'week') {
currentStart.setDate(currentStart.getDate() + direction * 7)
} else if (budget.type === 'month') {
currentStart.setMonth(currentStart.getMonth() + direction)
currentStart.setDate(1)
} else if (budget.type === 'year') {
currentStart.setFullYear(currentStart.getFullYear() + direction)
currentStart.setMonth(0)
currentStart.setDate(1)
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)
}
budget.startDate = currentStart.toISOString().split('T')[0]
budget.period = getPeriodRange(budget.startDate, budget.type)
// 模拟数据更新
budget.current = Math.floor(Math.random() * budget.limit * 1.2 * 100) / 100
budget.lastSync = new Date().toLocaleString()
}
const handleDelete = (budget) => {
showConfirmDialog({
title: '确认删除',
message: `确定要删除预算 "${budget.name}" 吗?`,
}).then(() => {
expenseBudgets.value = expenseBudgets.value.filter(b => b.id !== budget.id)
incomeBudgets.value = incomeBudgets.value.filter(b => b.id !== budget.id)
savingsBudgets.value = savingsBudgets.value.filter(b => b.id !== budget.id)
showToast('已删除')
}).then(async () => {
try {
const res = await deleteBudget(budget.id)
if (res.success) {
showToast('已删除')
delete refDateMap[budget.id]
fetchBudgetList()
}
} catch (err) {
showToast('删除失败')
console.error('删除预算失败', err)
}
}).catch(() => {})
}
const handleSync = (budget) => {
const handleSync = async (budget) => {
budget.syncing = true
setTimeout(() => {
try {
const refDate = refDateMap[budget.id] ? refDateMap[budget.id].toISOString() : null
const res = await syncBudget(budget.id, refDate)
if (res.success) {
showToast('同步成功')
Object.assign(budget, res.data)
}
} catch (err) {
showToast('同步失败')
console.error('同步预算失败', err)
} finally {
budget.syncing = false
budget.lastSync = new Date().toLocaleString()
showToast('同步成功')
}, 1000)
}
}
const handleToggleStop = (budget) => {
budget.isStopped = !budget.isStopped
showToast(budget.isStopped ? '已停止' : '已恢复')
const handleToggleStop = async (budget) => {
try {
const res = await toggleStopBudget(budget.id)
if (res.success) {
showToast(budget.isStopped ? '已恢复' : '已停止')
// 切换停止状态后刷新列表
fetchBudgetList()
}
} catch (err) {
showToast('操作失败')
console.error('切换预算状态失败', err)
}
}
const onSubmit = () => {
const startDate = new Date()
if (form.type === 'month') startDate.setDate(1)
if (form.type === 'year') {
startDate.setMonth(0)
startDate.setDate(1)
const onSubmit = async () => {
try {
const res = await createBudget({
...form,
limit: parseFloat(form.limit),
categoryNames: form.selectedCategories
})
if (res.success) {
showToast('保存成功')
showAddPopup.value = false
fetchBudgetList()
// 重置表单
form.name = ''
form.limit = ''
form.selectedCategories = []
}
} catch (err) {
showToast('保存失败')
console.error('保存预算失败', err)
}
const startDateStr = startDate.toISOString().split('T')[0]
const newBudget = {
id: Date.now(),
name: form.name,
type: form.type,
limit: parseFloat(form.limit),
categories: [...form.selectedCategories],
current: 0,
isStopped: false,
startDate: startDateStr,
period: getPeriodRange(startDateStr, form.type),
lastSync: '刚刚',
syncing: false
}
if (form.category === 'expense') {
expenseBudgets.value.unshift(newBudget)
activeTab.value = 'expense'
} else if (form.category === 'income') {
incomeBudgets.value.unshift(newBudget)
activeTab.value = 'income'
} else {
savingsBudgets.value.unshift(newBudget)
activeTab.value = 'savings'
}
showAddPopup.value = false
showToast('添加成功')
// 重置表单
form.name = ''
form.limit = ''
form.selectedCategories = []
}
</script>