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
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:
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 年份
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user