fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
SunCheng
2026-02-20 22:07:09 +08:00
parent 3c3172fc81
commit a7414c792e
11 changed files with 498 additions and 201 deletions

View File

@@ -224,7 +224,51 @@ public class BudgetApplication(
StartDate = startDate,
NoLimit = result.NoLimit,
IsMandatoryExpense = result.IsMandatoryExpense,
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0,
Details = result.Details != null ? MapToSavingsDetailDto(result.Details) : null
};
}
/// <summary>
/// 映射存款明细数据到DTO
/// </summary>
private static SavingsDetailDto MapToSavingsDetailDto(Service.Budget.SavingsDetail details)
{
return new SavingsDetailDto
{
IncomeItems = details.IncomeItems.Select(item => new BudgetDetailItemDto
{
Id = item.Id,
Name = item.Name,
Type = item.Type,
BudgetLimit = item.BudgetLimit,
ActualAmount = item.ActualAmount,
EffectiveAmount = item.EffectiveAmount,
CalculationNote = item.CalculationNote,
IsOverBudget = item.IsOverBudget,
IsArchived = item.IsArchived,
ArchivedMonths = item.ArchivedMonths
}).ToList(),
ExpenseItems = details.ExpenseItems.Select(item => new BudgetDetailItemDto
{
Id = item.Id,
Name = item.Name,
Type = item.Type,
BudgetLimit = item.BudgetLimit,
ActualAmount = item.ActualAmount,
EffectiveAmount = item.EffectiveAmount,
CalculationNote = item.CalculationNote,
IsOverBudget = item.IsOverBudget,
IsArchived = item.IsArchived,
ArchivedMonths = item.ArchivedMonths
}).ToList(),
Summary = new SavingsCalculationSummaryDto
{
TotalIncomeBudget = details.Summary.TotalIncomeBudget,
TotalExpenseBudget = details.Summary.TotalExpenseBudget,
PlannedSavings = details.Summary.PlannedSavings,
CalculationFormula = details.Summary.CalculationFormula
}
};
}

View File

@@ -16,8 +16,52 @@ public record BudgetResponse
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
public decimal UsagePercentage { get; init; }
/// <summary>
/// 存款明细数据(仅存款预算返回)
/// </summary>
public SavingsDetailDto? Details { get; init; }
}
/// <summary>
/// 存款明细数据 DTO
/// </summary>
public record SavingsDetailDto
{
public List<BudgetDetailItemDto> IncomeItems { get; init; } = new();
public List<BudgetDetailItemDto> ExpenseItems { get; init; } = new();
public SavingsCalculationSummaryDto Summary { get; init; } = new();
}
/// <summary>
/// 预算明细项 DTO
/// </summary>
public record BudgetDetailItemDto
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; }
public decimal BudgetLimit { get; init; }
public decimal ActualAmount { get; init; }
public decimal EffectiveAmount { get; init; }
public string CalculationNote { get; init; } = string.Empty;
public bool IsOverBudget { get; init; }
public bool IsArchived { get; init; }
public int[]? ArchivedMonths { get; init; }
}
/// <summary>
/// 存款计算汇总 DTO
/// </summary>
public record SavingsCalculationSummaryDto
{
public decimal TotalIncomeBudget { get; init; }
public decimal TotalExpenseBudget { get; init; }
public decimal PlannedSavings { get; init; }
public string CalculationFormula { get; init; } = string.Empty;
}
/// <summary>
/// 创建预算请求
/// </summary>

View File

@@ -876,12 +876,26 @@ public class BudgetSavingsService(
UpdateTime = dateTimeProvider.Now
};
return BudgetResult.FromEntity(
// 生成明细数据
var details = GenerateYearlyDetails(
currentMonthlyIncomeItems,
currentYearlyIncomeItems,
currentMonthlyExpenseItems,
currentYearlyExpenseItems,
archiveIncomeItems,
archiveExpenseItems,
new DateTime(year, 1, 1)
);
var result = BudgetResult.FromEntity(
record,
currentActual,
new DateTime(year, 1, 1),
description.ToString()
);
result.Details = details;
return result;
void AddOrIncCurrentItem(
long id,
@@ -1116,4 +1130,166 @@ public class BudgetSavingsService(
}
};
}
/// <summary>
/// 生成年度存款明细数据
/// </summary>
private SavingsDetail GenerateYearlyDetails(
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyIncomeItems,
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyIncomeItems,
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyExpenseItems,
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyExpenseItems,
List<(long id, string name, int[] months, decimal limit, decimal current)> archiveIncomeItems,
List<(long id, string name, int[] months, decimal limit, decimal current)> archiveExpenseItems,
DateTime referenceDate)
{
var incomeDetails = new List<BudgetDetailItem>();
var expenseDetails = new List<BudgetDetailItem>();
// 处理已归档的收入预算
foreach (var item in archiveIncomeItems)
{
incomeDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current,
CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)",
IsOverBudget = false,
IsArchived = true,
ArchivedMonths = item.months
});
}
// 处理当前月度收入预算
foreach (var item in currentMonthlyIncomeItems)
{
// 年度预算中,月度预算按 factor 倍率计算有效金额
var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor;
var note = item.limit == 0
? "不记额(使用实际)"
: $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}";
incomeDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > 0 && item.current < item.limit,
IsArchived = false
});
}
// 处理当前年度收入预算
foreach (var item in currentYearlyIncomeItems)
{
// 年度预算:硬性预算或不记额预算使用实际值,否则使用预算值
var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit;
var note = item.isMandatory
? "硬性(使用实际)"
: (item.limit == 0 ? "不记额(使用实际)" : "使用预算");
incomeDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = false,
IsArchived = false
});
}
// 处理已归档的支出预算
foreach (var item in archiveExpenseItems)
{
expenseDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current,
CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)",
IsOverBudget = false,
IsArchived = true,
ArchivedMonths = item.months
});
}
// 处理当前月度支出预算
foreach (var item in currentMonthlyExpenseItems)
{
var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor;
var note = item.limit == 0
? "不记额(使用实际)"
: $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}";
expenseDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 处理当前年度支出预算
foreach (var item in currentYearlyExpenseItems)
{
var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit;
var note = item.isMandatory
? "硬性(使用实际)"
: (item.limit == 0 ? "不记额(使用实际)" : "使用预算");
expenseDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 计算汇总
var totalIncome = incomeDetails.Sum(i => i.EffectiveAmount);
var totalExpense = expenseDetails.Sum(e => e.EffectiveAmount);
var plannedSavings = totalIncome - totalExpense;
var formula = $"收入 {totalIncome:N0} - 支出 {totalExpense:N0} = {plannedSavings:N0}";
return new SavingsDetail
{
IncomeItems = incomeDetails,
ExpenseItems = expenseDetails,
Summary = new SavingsCalculationSummary
{
TotalIncomeBudget = totalIncome,
TotalExpenseBudget = totalExpense,
PlannedSavings = plannedSavings,
CalculationFormula = formula
}
};
}
}

View File

@@ -508,6 +508,11 @@ const handleQueryBills = async () => {
}
const percentage = computed(() => {
// 优先使用后端返回的 usagePercentage 字段
if (props.budget.usagePercentage !== undefined && props.budget.usagePercentage !== null) {
return Math.round(props.budget.usagePercentage)
}
// 降级方案:如果后端没有返回该字段,前端计算
if (!props.budget.limit) {
return 0
}

View File

@@ -92,50 +92,68 @@
<van-icon name="balance-o" />
收入明细
</div>
<div class="detail-table">
<div
<div class="rich-html-content">
<table>
<thead>
<tr>
<th>名称</th>
<th>预算额度</th>
<th>实际金额</th>
<th>计算用</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in currentBudget.details.incomeItems"
:key="item.id"
class="detail-item"
>
<div class="item-header">
<span class="item-name">{{ item.name }}</span>
<van-tag
size="mini"
:type="item.type === 1 ? 'default' : 'primary'"
>
{{ item.type === 1 ? '月度' : '年度' }}
</van-tag>
</div>
<div class="item-amounts">
<div class="amount-row">
<span class="amount-label">预算</span>
<span class="amount-value">¥{{ formatMoney(item.budgetLimit) }}</span>
</div>
<div class="amount-row">
<span class="amount-label">实际</span>
<span
class="amount-value"
:class="{ warning: item.isOverBudget }"
>
¥{{ formatMoney(item.actualAmount) }}
</span>
</div>
<div class="amount-row highlight">
<span class="amount-label">计算用</span>
<span class="amount-value income">¥{{ formatMoney(item.effectiveAmount) }}</span>
</div>
</div>
<div class="item-note">
<td>
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
<span>{{ item.name }}</span>
<van-tag
size="mini"
plain
:type="item.isOverBudget ? 'warning' : 'success'"
:type="item.type === 1 ? 'default' : 'primary'"
>
{{ item.calculationNote }}
{{ item.type === 1 ? '月' : '年' }}
</van-tag>
<van-tag
v-if="item.isArchived"
size="mini"
type="success"
>
已归档
</van-tag>
</div>
</div>
</td>
<td>{{ formatMoney(item.budgetLimit) }}</td>
<td>
<span
class="income-value"
:class="{ 'expense-value': item.isOverBudget }"
>
{{ formatMoney(item.actualAmount) }}
</span>
</td>
<td>
<span class="income-value">{{ formatMoney(item.effectiveAmount) }}</span>
</td>
</tr>
</tbody>
</table>
<p style="margin-top: 12px;">
<strong>收入预算合计:</strong>
<template v-if="hasArchivedIncome">
已归档 <span class="income-value"><strong>{{ formatMoney(archivedIncomeTotal) }}</strong></span>
+ 未来预算 <span class="income-value"><strong>{{ formatMoney(futureIncomeTotal) }}</strong></span>
= <span class="income-value"><strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong></span>
</template>
<template v-else>
<span class="income-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
</span>
</template>
</p>
</div>
</div>
@@ -145,48 +163,37 @@
<van-icon name="bill-o" />
支出明细
</div>
<div class="detail-table">
<div
<div class="rich-html-content">
<table>
<thead>
<tr>
<th>名称</th>
<th>预算额度</th>
<th>实际金额</th>
<th>计算用</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in currentBudget.details.expenseItems"
:key="item.id"
class="detail-item"
:class="{ overbudget: item.isOverBudget }"
>
<div class="item-header">
<span class="item-name">{{ item.name }}</span>
<van-tag
size="mini"
:type="item.type === 1 ? 'default' : 'primary'"
>
{{ item.type === 1 ? '月度' : '年度' }}
</van-tag>
</div>
<div class="item-amounts">
<div class="amount-row">
<span class="amount-label">预算</span>
<span class="amount-value">¥{{ formatMoney(item.budgetLimit) }}</span>
</div>
<div class="amount-row">
<span class="amount-label">实际</span>
<span
class="amount-value"
:class="{ danger: item.isOverBudget }"
>
¥{{ formatMoney(item.actualAmount) }}
</span>
</div>
<div class="amount-row highlight">
<span class="amount-label">计算用</span>
<span class="amount-value expense">¥{{ formatMoney(item.effectiveAmount) }}</span>
</div>
</div>
<div class="item-note">
<td>
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
<span>{{ item.name }}</span>
<van-tag
size="mini"
plain
:type="item.isOverBudget ? 'danger' : 'default'"
:type="item.type === 1 ? 'default' : 'primary'"
>
{{ item.calculationNote }}
{{ item.type === 1 ? '月' : '年' }}
</van-tag>
<van-tag
v-if="item.isArchived"
size="mini"
type="success"
>
已归档
</van-tag>
<van-tag
v-if="item.isOverBudget"
@@ -196,7 +203,30 @@
超支
</van-tag>
</div>
</div>
</td>
<td>{{ formatMoney(item.budgetLimit) }}</td>
<td>
<span class="expense-value">{{ formatMoney(item.actualAmount) }}</span>
</td>
<td>
<span class="expense-value">{{ formatMoney(item.effectiveAmount) }}</span>
</td>
</tr>
</tbody>
</table>
<p style="margin-top: 12px;">
<strong>支出预算合计:</strong>
<template v-if="hasArchivedExpense">
已归档 <span class="expense-value"><strong>{{ formatMoney(archivedExpenseTotal) }}</strong></span>
+ 未来预算 <span class="expense-value"><strong>{{ formatMoney(futureExpenseTotal) }}</strong></span>
= <span class="expense-value"><strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong></span>
</template>
<template v-else>
<span class="expense-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
</span>
</template>
</p>
</div>
</div>
@@ -206,28 +236,27 @@
<van-icon name="calculator-o" />
计算汇总
</div>
<div class="formula-box">
<div class="formula-row">
<span class="formula-label">收入合计</span>
<span class="formula-value income">
¥{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}
<div class="rich-html-content">
<h3>计算公式</h3>
<p>
<strong>收入预算合计:</strong>
<span class="income-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
</span>
</div>
<div class="formula-row">
<span class="formula-label">支出合计</span>
<span class="formula-value expense">
¥{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}
</p>
<p>
<strong>支出预算合计</strong>
<span class="expense-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
</span>
</div>
<div class="formula-row highlight">
<span class="formula-label">计划存款</span>
<span class="formula-value primary">
¥{{ formatMoney(currentBudget.details.summary.plannedSavings) }}
</span>
</div>
</div>
<div class="formula-text">
</p>
<p>
<strong>计划存款</strong>
{{ currentBudget.details.summary.calculationFormula }}
= <span class="highlight">
<strong>{{ formatMoney(currentBudget.details.summary.plannedSavings) }}</strong>
</span>
</p>
</div>
</div>
</div>
@@ -400,6 +429,45 @@ const incomeCurrent = computed(() => matchedIncomeBudget.value?.current || 0)
const expenseLimit = computed(() => matchedExpenseBudget.value?.limit || 0)
const expenseCurrent = computed(() => matchedExpenseBudget.value?.current || 0)
// 归档和未来预算的汇总 (仅用于年度存款计划)
const hasArchivedIncome = computed(() => {
if (!currentBudget.value?.details) return false
return currentBudget.value.details.incomeItems.some(item => item.isArchived)
})
const archivedIncomeTotal = computed(() => {
if (!currentBudget.value?.details) return 0
return currentBudget.value.details.incomeItems
.filter(item => item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const futureIncomeTotal = computed(() => {
if (!currentBudget.value?.details) return 0
return currentBudget.value.details.incomeItems
.filter(item => !item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const hasArchivedExpense = computed(() => {
if (!currentBudget.value?.details) return false
return currentBudget.value.details.expenseItems.some(item => item.isArchived)
})
const archivedExpenseTotal = computed(() => {
if (!currentBudget.value?.details) return 0
return currentBudget.value.details.expenseItems
.filter(item => item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const futureExpenseTotal = computed(() => {
if (!currentBudget.value?.details) return 0
return currentBudget.value.details.expenseItems
.filter(item => !item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
// 辅助函数
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, {
@@ -647,98 +715,13 @@ const getProgressColor = (budget) => {
padding: 0 8px;
}
/* 明细表格样式 */
/* 明细表格样式 - 使用 rich-html-content 统一样式 */
.detail-tables {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-table {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-item {
background-color: var(--van-light-gray);
border-radius: 8px;
padding: 12px;
border-left: 3px solid var(--van-gray-4);
}
.detail-item.overbudget {
border-left-color: var(--van-danger-color);
background-color: rgba(245, 34, 45, 0.05);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.item-name {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
}
.item-amounts {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.amount-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.amount-row.highlight {
padding-top: 6px;
margin-top: 4px;
border-top: 1px dashed var(--van-border-color);
font-weight: 600;
}
.amount-label {
color: var(--van-text-color-2);
}
.amount-value {
font-family: DIN Alternate, system-ui;
font-weight: 600;
color: var(--van-text-color);
}
.amount-value.income {
color: var(--van-success-color);
}
.amount-value.expense {
color: var(--van-danger-color);
}
.amount-value.warning {
color: var(--van-warning-color);
}
.amount-value.danger {
color: var(--van-danger-color);
}
.item-note {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.formula-row {
display: flex;
justify-content: space-between;

View File

@@ -0,0 +1,45 @@
## MODIFIED Requirements
### Requirement: Display income and expense budget in savings detail popup
The savings detail popup SHALL display the associated income budget and expense budget information for the selected savings plan, including both budget limits and current amounts.
#### Scenario: User opens savings detail popup with matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there exist income and expense budgets for the same period and type
- **THEN** the popup SHALL display the income budget limit and current amount
- **AND** the popup SHALL display the expense budget limit and current amount
- **AND** the popup SHALL display the savings formula (Income Limit - Expense Limit = Planned Savings)
- **AND** the popup SHALL display the savings result (Planned Savings, Actual Savings, Remaining)
#### Scenario: User opens savings detail popup without matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there are no income or expense budgets for the same period and type
- **THEN** the popup SHALL display 0 for income budget limit and current amount
- **AND** the popup SHALL display 0 for expense budget limit and current amount
- **AND** the popup SHALL still display the savings formula and result with these values
### Requirement: Pass budget data to savings component
The parent component (Index.vue) SHALL pass income budgets and expense budgets to the SavingsBudgetContent component to enable detail popup display.
#### Scenario: Budget data is loaded successfully
- **WHEN** the budget data is loaded from the API
- **THEN** the income budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the expense budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the savings budgets SHALL be passed to SavingsBudgetContent via props (existing behavior)
### Requirement: Match income and expense budgets to savings plan
The SavingsBudgetContent component SHALL match income and expense budgets to the current savings plan based on periodStart and type fields.
#### Scenario: Match budgets with same period and type
- **WHEN** displaying savings plan details
- **AND** the component searches for matching budgets
- **THEN** the component SHALL find income budgets where periodStart and type match the savings plan
- **AND** the component SHALL find expense budgets where periodStart and type match the savings plan
- **AND** if multiple matches exist, the component SHALL use the first match
#### Scenario: No matching budgets found
- **WHEN** displaying savings plan details
- **AND** no income budget matches the savings plan's periodStart and type
- **OR** no expense budget matches the savings plan's periodStart and type
- **THEN** the component SHALL use 0 as the default value for unmatched budget fields
- **AND** the popup SHALL still render without errors