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

@@ -7,6 +7,11 @@ public interface IBudgetService
Task<BudgetResult?> GetStatisticsAsync(long id, DateTime? referenceDate = null);
Task<string> ArchiveBudgetsAsync(int year, int month);
/// <summary>
/// 获取指定分类的统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null);
}
public class BudgetService(
@@ -64,6 +69,92 @@ public class BudgetService(
return BudgetResult.FromEntity(budget, currentAmount, referenceDate);
}
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null)
{
var budgets = (await budgetRepository.GetAllAsync()).ToList();
var refDate = referenceDate ?? DateTime.Now;
var result = new BudgetCategoryStats();
// 获取月度统计
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, refDate);
// 获取年度统计
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, refDate);
return result;
}
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
List<BudgetRecord> budgets,
BudgetCategory category,
BudgetPeriodType statType,
DateTime referenceDate)
{
var result = new BudgetStatsDto
{
PeriodType = statType,
Rate = 0,
Current = 0,
Limit = 0,
Count = 0
};
// 获取当前分类下所有未停止的预算
var relevant = budgets
.Where(b => b.Category == category && !b.IsStopped)
.ToList();
if (relevant.Count == 0)
{
return result;
}
result.Count = relevant.Count;
decimal totalCurrent = 0;
decimal totalLimit = 0;
foreach (var budget in relevant)
{
// 限额折算
var itemLimit = budget.Limit;
if (statType == BudgetPeriodType.Month && budget.Type == BudgetPeriodType.Year)
{
// 月度视图下,年度预算不参与限额计算
itemLimit = 0;
}
else if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 年度视图下,月度预算折算为年度
itemLimit = budget.Limit * 12;
}
totalLimit += itemLimit;
// 当前值累加
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
if (budget.Type == statType)
{
totalCurrent += currentAmount;
}
else
{
// 如果周期不匹配
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 在年度视图下,月度预算计入其当前值(作为对年度目前的贡献)
totalCurrent += currentAmount;
}
// 月度视图下,年度预算的 current 不计入
}
}
result.Limit = totalLimit;
result.Current = totalCurrent;
result.Rate = totalLimit > 0 ? (totalCurrent / totalLimit) * 100 : 0;
return result;
}
public async Task<string> ArchiveBudgetsAsync(int year, int month)
{
var referenceDate = new DateTime(year, month, 1);
@@ -393,18 +484,22 @@ public class BudgetService(
Category = BudgetCategory.Savings,
Type = periodType,
Limit = incomeLimitAtPeriod - expenseLimitAtPeriod,
StartDate = periodType == BudgetPeriodType.Year ? new DateTime(date.Year, 1, 1) : new DateTime(date.Year, date.Month, 1),
StartDate = periodType == BudgetPeriodType.Year
? new DateTime(date.Year, 1, 1)
: new DateTime(date.Year, date.Month, 1),
SelectedCategories = savingsCategories
};
// 计算实际发生的 收入 - 支出
var incomeHelper = new BudgetRecord { Category = BudgetCategory.Income, Type = periodType, StartDate = virtualBudget.StartDate, SelectedCategories = savingsCategories };
var expenseHelper = new BudgetRecord { Category = BudgetCategory.Expense, Type = periodType, StartDate = virtualBudget.StartDate, SelectedCategories = savingsCategories };
var current = await CalculateCurrentAmountAsync(new BudgetRecord
{
Category = virtualBudget.Category,
Type = virtualBudget.Type,
SelectedCategories = virtualBudget.SelectedCategories,
StartDate = virtualBudget.StartDate,
}, date);
var actualIncome = await CalculateCurrentAmountAsync(incomeHelper, date);
var actualExpense = await CalculateCurrentAmountAsync(expenseHelper, date);
return BudgetResult.FromEntity(virtualBudget, actualIncome - actualExpense, date, description.ToString());
return BudgetResult.FromEntity(virtualBudget, current, date, description.ToString());
}
}
@@ -459,3 +554,49 @@ public record BudgetResult
};
}
}
/// <summary>
/// 预算统计结果 DTO
/// </summary>
public class BudgetStatsDto
{
/// <summary>
/// 统计周期类型Month/Year
/// </summary>
public BudgetPeriodType PeriodType { get; set; }
/// <summary>
/// 使用率百分比0-100
/// </summary>
public decimal Rate { get; set; }
/// <summary>
/// 实际金额
/// </summary>
public decimal Current { get; set; }
/// <summary>
/// 目标/限额金额
/// </summary>
public decimal Limit { get; set; }
/// <summary>
/// 预算项数量
/// </summary>
public int Count { get; set; }
}
/// <summary>
/// 分类统计结果
/// </summary>
public class BudgetCategoryStats
{
/// <summary>
/// 月度统计
/// </summary>
public BudgetStatsDto Month { get; set; } = new();
/// <summary>
/// 年度统计
/// </summary>
public BudgetStatsDto Year { get; set; } = new();
}

View File

@@ -82,8 +82,6 @@ public class EmailHandleService(
{
logger.LogInformation("处理交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type);
var record = await SaveTransactionRecordAsync(
card,
reason,
@@ -104,7 +102,9 @@ public class EmailHandleService(
records.Add(record);
}
_ = await AnalyzeClassifyAsync(records.ToArray());
// var analysisResult = await AnalyzeClassifyAsync(records.ToArray());
// TODO 不应该直接保存 应该保存在备用字段上,前端确认后再更新到正式字段
return allSuccess;
}

View File

@@ -17,7 +17,7 @@ public class BudgetArchiveJob(
logger.LogInformation("开始执行预算归档任务");
// 每个月1号执行归档上个月的数据
var targetDate = DateTime.Now.AddMonths(0); // TODO 调试期间使用
var targetDate = DateTime.Now.AddMonths(-1);
var year = targetDate.Year;
var month = targetDate.Month;

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

View File

@@ -15,7 +15,14 @@ public class BudgetController(
{
try
{
return (await budgetService.GetListAsync(referenceDate)).Ok();
return (await budgetService.GetListAsync(referenceDate))
.OrderBy(b => b.IsStopped)
.OrderBy(b => b.Category)
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Current / b.Limit)
.ThenBy(b => b.Name)
.ToList()
.Ok();
}
catch (Exception ex)
{
@@ -48,6 +55,24 @@ public class BudgetController(
}
}
/// <summary>
/// 获取分类统计信息(月度和年度)
/// </summary>
[HttpGet]
public async Task<BaseResponse<BudgetCategoryStats>> GetCategoryStatsAsync([FromQuery] BudgetCategory category, [FromQuery] DateTime? referenceDate = null)
{
try
{
var result = await budgetService.GetCategoryStatsAsync(category, referenceDate);
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取分类统计失败, Category: {Category}", category);
return $"获取分类统计失败: {ex.Message}".Fail<BudgetCategoryStats>();
}
}
/// <summary>
/// 删除预算
/// </summary>