feat: 添加分类统计功能,支持获取月度和年度预算统计信息
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
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

This commit is contained in:
孙诚
2026-01-09 15:42:59 +08:00
parent b41121400d
commit 2244d08b43
9 changed files with 524 additions and 177 deletions

View File

@@ -17,8 +17,6 @@
</template>
</van-nav-bar>
<van-tabs v-model:active="activeTab" type="card" class="budget-tabs">
<van-tab title="支出" :name="BudgetCategory.Expense">
<BudgetSummary
@@ -46,7 +44,7 @@
>
<template #amount-info>
<div class="info-item">
<div class="label">当前</div>
<div class="label">已支出</div>
<div class="value expense">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
@@ -54,7 +52,7 @@
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">余</div>
<div class="label">余</div>
<div class="value" :class="budget.limit - budget.current >= 0 ? 'income' : 'expense'">
¥{{ formatMoney(budget.limit - budget.current) }}
</div>
@@ -68,20 +66,19 @@
</template>
<van-empty v-else description="暂无支出预算" />
</div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
</van-tab>
</van-tab>
<van-tab title="收入" :name="BudgetCategory.Income">
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<div class="scroll-content">
<div class="budget-list">
<van-tab title="收入" :name="BudgetCategory.Income">
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<div class="scroll-content">
<div class="budget-list">
<template v-if="incomeBudgets?.length > 0">
<van-swipe-cell v-for="budget in incomeBudgets" :key="budget.id">
<BudgetCard
@@ -99,11 +96,11 @@
>
<template #amount-info>
<div class="info-item">
<div class="label">当前已收</div>
<div class="label">已收</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">目标收入</div>
<div class="label">目标</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
@@ -121,52 +118,49 @@
</template>
<van-empty v-else description="暂无收入预算" />
</div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
</van-tab>
</van-tab>
<van-tab title="存款" :name="BudgetCategory.Savings">
<div class="scroll-content" style="padding-top:4px">
<div class="budget-list">
<van-tab title="存款" :name="BudgetCategory.Savings">
<div class="scroll-content" style="padding-top:4px">
<div class="budget-list">
<template v-if="savingsBudgets?.length > 0">
<BudgetCard
v-for="budget in savingsBudgets"
:key="budget.id"
:budget="budget"
progress-color="#07c160"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
style="margin: 0 12px 12px;"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
>
<template #amount-info>
<div class="info-item">
<div class="label">已存</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
v-for="budget in savingsBudgets"
:key="budget.id"
:budget="budget"
progress-color="#07c160"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
style="margin: 0 12px 12px;"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
>
<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 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>
</div>
</template>
</BudgetCard>
</template>
<van-empty v-else description="暂无存款计划" />
</div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
</van-tab>
</van-tabs>
</van-tab>
</van-tabs>
<!-- 添加/编辑预算弹窗 -->
<BudgetEditPopup
ref="budgetEditRef"
@success="fetchBudgetList"
@@ -181,7 +175,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import { getBudgetList, deleteBudget, toggleStopBudget, getBudgetStatistics } from '@/api/budget'
import { getBudgetList, deleteBudget, toggleStopBudget, getBudgetStatistics, getCategoryStats } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
@@ -195,65 +189,18 @@ const savingsConfigRef = ref(null)
const expenseBudgets = ref([])
const incomeBudgets = ref([])
const savingsBudgets = ref([])
const overallStats = ref({
month: { rate: '0.0', current: 0, limit: 0, count: 0 },
year: { rate: '0.0', current: 0, limit: 0, count: 0 }
})
const activeTabTitle = computed(() => {
if (activeTab.value === BudgetCategory.Expense) return '使用'
return '达成'
})
const overallStats = computed(() => {
const allBudgetsList = [...expenseBudgets.value, ...incomeBudgets.value, ...savingsBudgets.value]
const getStats = (statType) => {
const category = activeTab.value
// 获取当前 tab 类别下所有未停止的预算
const relevant = allBudgetsList.filter(b => b.category === category && !b.isStopped)
if (relevant.length === 0) return { rate: '0.0', current: 0, limit: 0, count: 0 }
let totalC = 0
let totalL = 0
relevant.forEach(b => {
// 限额折算
let itemLimit = b.limit || 0
if (statType === BudgetPeriodType.Month && b.type === BudgetPeriodType.Year) {
itemLimit = b.limit / 12
} else if (statType === BudgetPeriodType.Year && b.type === BudgetPeriodType.Month) {
itemLimit = b.limit * 12
}
totalL += itemLimit
// 当前值累加
// 注意:由于前端 items 只有当前周期的 current如果是跨周期统计这里只能视为一种“参考”或“当前进度”
if (b.type === statType) {
totalC += (b.current || 0)
} else {
// 如果周期不匹配(例如在年度统计中统计月度预算),
// 只有在当前是 1 月的情况下,月度支出才等同于年度累计。
// 为保持统计的严谨性,这里仅在类型匹配时计入 Current或者根据业务需求进行估计。
// 但为了解决用户反馈的“统计不对”,我们需要把所有的匹配项都算进来。
if (statType === BudgetPeriodType.Year) {
// 在年度视图下,月度预算我们也计入它当前的 current作为它对年度目前的贡献
totalC += (b.current || 0)
}
// 月度视图下,年度预算的 current 无法直接折算,此处暂不计入支出。
}
})
const rate = totalL > 0 ? (totalC / totalL) * 100 : 0
return {
rate: rate.toFixed(1),
current: totalC,
limit: totalL,
count: relevant.length
}
}
return {
month: getStats(BudgetPeriodType.Month),
year: getStats(BudgetPeriodType.Year)
}
watch(activeTab, async () => {
await fetchCategoryStats()
})
const getValueClass = (rate) => {
@@ -284,9 +231,36 @@ const fetchBudgetList = async () => {
}
}
const fetchCategoryStats = async () => {
try {
const res = await getCategoryStats(activeTab.value)
if (res.success) {
// 转换后端返回的数据格式为前端需要的格式
const data = res.data
overallStats.value = {
month: {
rate: data.month?.rate?.toFixed(1) || '0.0',
current: data.month?.current || 0,
limit: data.month?.limit || 0,
count: data.month?.count || 0
},
year: {
rate: data.year?.rate?.toFixed(1) || '0.0',
current: data.year?.current || 0,
limit: data.year?.limit || 0,
count: data.year?.count || 0
}
}
}
} catch (err) {
console.error('加载分类统计失败', err)
}
}
onMounted(async () => {
try {
await fetchBudgetList()
await fetchCategoryStats()
} catch (err) {
console.error('获取初始化数据失败', err)
}
@@ -306,21 +280,20 @@ const getPeriodLabel = (type) => {
const getProgressColor = (budget) => {
const ratio = budget.current / budget.limit
if (ratio >= 1) return '#ee0a24' // 危险红色
if (ratio > 0.8) return '#ff976a' // 警告橙色
return '#1989fa' // 正常蓝色
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' // 蓝色
if (ratio >= 1) return '#07c160'
return '#1989fa'
}
const refDateMap = {}
const handleSwitchPeriod = async (budget, direction) => {
// 获取或初始化该预算的参考日期
let currentRefDate = refDateMap[budget.id] || new Date()
const date = new Date(currentRefDate)
@@ -334,7 +307,6 @@ const handleSwitchPeriod = async (budget, direction) => {
const res = await getBudgetStatistics(budget.id, date.toISOString())
if (res.success) {
refDateMap[budget.id] = date
// 更新当前列表中的预算对象信息
Object.assign(budget, res.data)
}
} catch (err) {
@@ -366,7 +338,6 @@ const handleToggleStop = async (budget) => {
const res = await toggleStopBudget(budget.id)
if (res.success) {
showToast(budget.isStopped ? '已恢复' : '已停止')
// 切换停止状态后刷新列表
fetchBudgetList()
}
} catch (err) {