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

@@ -72,6 +72,19 @@ export function toggleStopBudget(id) {
})
}
/**
* 获取分类统计信息(月度和年度)
* @param {string} category 分类 (Expense/Income/Savings)
* @param {string} referenceDate 参考日期 (可选)
*/
export function getCategoryStats(category, referenceDate) {
return request({
url: '/Budget/GetCategoryStats',
method: 'get',
params: { category, referenceDate }
})
}
/**
* 归档预算
* @param {number} year 年份

View File

@@ -1,11 +1,10 @@
<template>
<div class="common-card budget-card" @click="$emit('click')">
<div class="common-card budget-card" @click="toggleExpand">
<div class="budget-content-wrapper">
<Transition :name="transitionName">
<div :key="budget.period" class="budget-inner-card">
<div class="card-header">
<!-- 折叠状态 -->
<div v-if="!isExpanded" class="budget-collapsed">
<div class="collapsed-header">
<div class="budget-info">
<h3 class="card-title">{{ budget.name }}</h3>
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
@@ -15,6 +14,45 @@
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title">{{ budget.name }}</h3>
</div>
<van-icon name="arrow-down" class="expand-icon" />
</div>
<div class="collapsed-footer">
<div class="collapsed-item">
<span class="compact-label">实际/目标</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{ budget.current !== undefined && budget.limit !== undefined
? `¥${budget.current?.toFixed(2) || 0} / ¥${budget.limit?.toFixed(2) || 0}`
: '--' }}
</slot>
</span>
</div>
<div class="collapsed-item">
<span class="compact-label">达成率</span>
<span class="compact-value" :class="percentClass">{{ percentage }}%</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<Transition v-else :name="transitionName">
<div :key="budget.period" class="budget-inner-card">
<div class="card-header">
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title">{{ budget.name }}</h3>
</div>
<div class="header-actions">
<slot name="actions">
@@ -25,17 +63,23 @@
:type="showDescription ? 'primary' : 'default'"
plain
round
style="margin-right: 4px;"
@click.stop="showDescription = !showDescription"
/>
<van-button
v-if="budget.category !== 2"
:icon="budget.isStopped ? 'play' : 'pause'"
size="mini"
plain
round
@click.stop="$emit('toggle-stop', budget)"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="mini"
plain
@click.stop="$emit('click', budget)"
/>
<van-button
:icon="budget.isStopped ? 'play' : 'pause'"
size="mini"
plain
@click.stop="$emit('toggle-stop', budget)"
/>
</template>
</slot>
</div>
</div>
@@ -128,9 +172,14 @@ const props = defineProps({
const emit = defineEmits(['toggle-stop', 'switch-period', 'click'])
const isExpanded = ref(props.budget.category === 2)
const transitionName = ref('slide-left')
const showDescription = ref(false)
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
const isNextDisabled = computed(() => {
if (!props.budget.periodEnd) return false
return new Date(props.budget.periodEnd) > new Date()
@@ -167,6 +216,7 @@ const timePercentage = computed(() => {
padding-bottom: 8px;
overflow: hidden;
position: relative;
cursor: pointer;
}
.budget-content-wrapper {
@@ -178,6 +228,102 @@ const timePercentage = computed(() => {
width: 100%;
}
/* 折叠状态样式 */
.budget-collapsed {
display: flex;
flex-direction: column;
gap: 6px;
}
.collapsed-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.collapsed-left {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.card-title-compact {
margin: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.status-tag-compact) {
padding: 2px 6px !important;
font-size: 11px !important;
height: auto !important;
flex-shrink: 0;
}
.collapsed-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.collapsed-item {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.collapsed-item:first-child {
flex: 2;
}
.collapsed-item:last-child {
flex: 1;
}
.compact-label {
font-size: 12px;
color: #969799;
line-height: 1.2;
}
.compact-value {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-value.warning {
color: #ff976a;
}
.compact-value.income {
color: #07c160;
}
.expand-icon {
color: #1989fa;
font-size: 14px;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.collapse-icon {
color: #1989fa;
font-size: 16px;
cursor: pointer;
}
/* 切换动画 */
.slide-left-enter-active,
.slide-left-leave-active,
@@ -354,5 +500,8 @@ const timePercentage = computed(() => {
.description-content {
color: #969799;
}
.collapsed-row .value {
color: #f5f5f5;
}
}
</style>

View File

@@ -1,10 +1,15 @@
<template>
<div class="summary-card common-card">
<div v-if="stats && (stats.month || stats.year)" class="summary-card common-card">
<template v-for="(config, key) in periodConfigs" :key="key">
<div class="summary-item">
<div class="label">{{ config.label }}{{ title }}</div>
<div class="value" :class="getValueClass(stats[key].rate)">
{{ stats[key].rate }}<span class="unit">%</span>
<div class="value" :class="getValueClass(stats[key]?.rate || '0.0')">
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span>
</div>
<div class="sub-info">
<span class="amount">¥{{ formatMoney(stats[key]?.current || 0) }}</span>
<span class="separator">/</span>
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
</div>
</div>
<div v-if="config.showDivider" class="divider"></div>
@@ -32,6 +37,10 @@ const periodConfigs = {
month: { label: '本月', showDivider: true },
year: { label: '年度', showDivider: false }
}
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
</script>
<style scoped>
@@ -58,7 +67,6 @@ const periodConfigs = {
.summary-item .value {
font-size: 20px;
font-weight: bold;
margin-bottom: 2px;
color: #323233;
}
@@ -80,8 +88,20 @@ const periodConfigs = {
font-weight: normal;
}
.summary-item .sub-label {
font-size: 11px;
.summary-item .sub-info {
font-size: 12px;
color: #c8c9cc;
display: flex;
justify-content: center;
align-items: center;
gap: 3px;
}
.summary-item .amount {
color: #646566;
}
.summary-item .separator {
color: #c8c9cc;
}
@@ -96,6 +116,9 @@ const periodConfigs = {
.summary-item .value {
color: #f5f5f5;
}
.summary-item .amount {
color: #c8c9cc;
}
.divider {
background-color: #2c2c2c;
}

View File

@@ -1,7 +1,7 @@
/* 后端返回的 HTML 富文本内容样式 */
.rich-html-content {
font-size: 14px;
line-height: 1.6;
font-size: 13px;
line-height: 1.5;
color: var(--van-text-color);
white-space: normal; /* 重置可能存在的 pre-wrap */
word-break: break-all;
@@ -10,77 +10,98 @@
.rich-html-content h1,
.rich-html-content h2,
.rich-html-content h3 {
margin: 16px 0 8px;
margin: 10px 0 4px;
color: var(--van-text-color);
font-weight: 600;
line-height: 1.25;
line-height: 1.2;
}
.rich-html-content h1 {
font-size: 1.5em;
font-size: 1.7em;
text-align: center;
border-bottom: 1px solid var(--van-border-color);
padding-bottom: 10px;
padding-bottom: 6px;
}
.rich-html-content h2 {
font-size: 1.25em;
margin-top: 20px;
font-size: 1.5em;
margin-top: 14px;
}
.rich-html-content h3 {
font-size: 1.1em;
border-left: 4px solid #1989fa;
padding-left: 10px;
margin-top: 15px;
padding-top: 4px;
padding-bottom: 4px;
font-size: 1.2em;
border-left: 3px solid #1989fa;
padding-left: 8px;
margin-top: 10px;
padding-top: 2px;
padding-bottom: 2px;
}
.rich-html-content p {
margin: 12px 0;
margin: 6px 0;
}
.rich-html-content ul,
.rich-html-content ol {
padding-left: 20px;
margin: 14px 0;
padding-left: 18px;
margin: 8px 0;
}
.rich-html-content li {
margin: 8px 0;
margin: 4px 0;
}
.rich-html-content strong {
font-weight: 600;
}
/* 表格样式优化 - 移动端适配滑动 */
/* 表格样式优化 - 撑满宽度并支持独立横向和纵向滚动 */
.rich-html-content table {
display: block;
width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 16px 0;
margin: 8px 0;
background: var(--van-background-2);
border-radius: 4px;
border: 1px solid var(--van-border-color);
border: none;
overflow-x: auto;
overflow-y: auto;
max-height: 50vh;
-webkit-overflow-scrolling: touch;
}
.rich-html-content th,
.rich-html-content td {
padding: 10px 12px;
padding: 6px 8px;
text-align: left;
border: 1px solid var(--van-border-color);
min-width: 80px; /* 防止内容过于拥挤 */
border: none;
border-bottom: 1px solid var(--van-border-color-light);
min-width: 70px; /* 防止内容过于拥挤 */
font-size: 12px;
white-space: nowrap; /* 防止文字换行 */
flex: 1; /* 让单元格按比例撑满宽度 */
}
/* 针对第一列预算项增加最小宽度 */
.rich-html-content td:first-child,
.rich-html-content th:first-child {
min-width: 100px;
/* 表格行确保100%撑满 */
.rich-html-content tbody,
.rich-html-content thead {
display: table;
width: 100%;
table-layout: auto;
}
/* 针对第一列预算项增加最小宽度 - 确保在滑动时有背景遮挡 */
.rich-html-content td:first-child {
min-width: 80px;
position: sticky;
left: 0;
background: inherit;
background: var(--van-background-2);
z-index: 1;
}
.rich-html-content th:first-child {
min-width: 80px;
position: sticky;
left: 0;
background: var(--van-gray-1);
z-index: 1;
}
@@ -88,7 +109,7 @@
background: var(--van-gray-1);
color: var(--van-text-color);
font-weight: 600;
font-size: 13px;
font-size: 12px;
white-space: nowrap;
}
@@ -130,12 +151,16 @@
color: #f5f5f5;
}
.rich-html-content td:first-child,
.rich-html-content th:first-child {
.rich-html-content td:first-child {
background-color: #1a1a1a;
}
.rich-html-content th:first-child {
background-color: #242424;
}
.rich-html-content th,
.rich-html-content td {
border-bottom: 1px solid #2c2c2c;
}
}

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) {