fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s
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 5s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
This commit is contained in:
@@ -32,7 +32,8 @@ public class BudgetService(
|
|||||||
IOpenAiService openAiService,
|
IOpenAiService openAiService,
|
||||||
IMessageService messageService,
|
IMessageService messageService,
|
||||||
ILogger<BudgetService> logger,
|
ILogger<BudgetService> logger,
|
||||||
IBudgetSavingsService budgetSavingsService
|
IBudgetSavingsService budgetSavingsService,
|
||||||
|
IDateTimeProvider dateTimeProvider
|
||||||
) : IBudgetService
|
) : IBudgetService
|
||||||
{
|
{
|
||||||
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
|
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
|
||||||
@@ -40,8 +41,8 @@ public class BudgetService(
|
|||||||
var year = referenceDate.Year;
|
var year = referenceDate.Year;
|
||||||
var month = referenceDate.Month;
|
var month = referenceDate.Month;
|
||||||
|
|
||||||
var isArchive = year < DateTime.Now.Year
|
var isArchive = year < dateTimeProvider.Now.Year
|
||||||
|| (year == DateTime.Now.Year && month < DateTime.Now.Month);
|
|| (year == dateTimeProvider.Now.Year && month < dateTimeProvider.Now.Month);
|
||||||
|
|
||||||
if (isArchive)
|
if (isArchive)
|
||||||
{
|
{
|
||||||
@@ -49,7 +50,7 @@ public class BudgetService(
|
|||||||
|
|
||||||
if (archive != null)
|
if (archive != null)
|
||||||
{
|
{
|
||||||
var (start, end) = GetPeriodRange(DateTime.Now, BudgetPeriodType.Month, referenceDate);
|
var (start, end) = GetPeriodRange(dateTimeProvider.Now, BudgetPeriodType.Month, referenceDate);
|
||||||
return [.. archive.Content.Select(c => new BudgetResult
|
return [.. archive.Content.Select(c => new BudgetResult
|
||||||
{
|
{
|
||||||
Id = c.Id,
|
Id = c.Id,
|
||||||
@@ -123,7 +124,7 @@ public class BudgetService(
|
|||||||
|
|
||||||
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
|
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
|
||||||
{
|
{
|
||||||
var date = referenceDate ?? DateTime.Now;
|
var date = referenceDate ?? dateTimeProvider.Now;
|
||||||
var transactionType = category switch
|
var transactionType = category switch
|
||||||
{
|
{
|
||||||
BudgetCategory.Expense => TransactionType.Expense,
|
BudgetCategory.Expense => TransactionType.Expense,
|
||||||
@@ -282,7 +283,7 @@ public class BudgetService(
|
|||||||
groupByMonth);
|
groupByMonth);
|
||||||
|
|
||||||
decimal accumulated = 0;
|
decimal accumulated = 0;
|
||||||
var now = DateTime.Now;
|
var now = dateTimeProvider.Now;
|
||||||
|
|
||||||
if (statType == BudgetPeriodType.Month)
|
if (statType == BudgetPeriodType.Month)
|
||||||
{
|
{
|
||||||
@@ -360,7 +361,7 @@ public class BudgetService(
|
|||||||
if (archive != null)
|
if (archive != null)
|
||||||
{
|
{
|
||||||
archive.Content = content;
|
archive.Content = content;
|
||||||
archive.ArchiveDate = DateTime.Now;
|
archive.ArchiveDate = dateTimeProvider.Now;
|
||||||
archive.ExpenseSurplus = expenseSurplus;
|
archive.ExpenseSurplus = expenseSurplus;
|
||||||
archive.IncomeSurplus = incomeSurplus;
|
archive.IncomeSurplus = incomeSurplus;
|
||||||
if (!await budgetArchiveRepository.UpdateAsync(archive))
|
if (!await budgetArchiveRepository.UpdateAsync(archive))
|
||||||
@@ -375,7 +376,7 @@ public class BudgetService(
|
|||||||
Year = year,
|
Year = year,
|
||||||
Month = month,
|
Month = month,
|
||||||
Content = content,
|
Content = content,
|
||||||
ArchiveDate = DateTime.Now,
|
ArchiveDate = dateTimeProvider.Now,
|
||||||
ExpenseSurplus = expenseSurplus,
|
ExpenseSurplus = expenseSurplus,
|
||||||
IncomeSurplus = incomeSurplus
|
IncomeSurplus = incomeSurplus
|
||||||
};
|
};
|
||||||
@@ -503,7 +504,7 @@ public class BudgetService(
|
|||||||
11. 不要使用 div 包裹大段内容
|
11. 不要使用 div 包裹大段内容
|
||||||
|
|
||||||
【系统信息】
|
【系统信息】
|
||||||
当前时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
|
当前时间:{dateTimeProvider.Now:yyyy-MM-dd HH:mm:ss}
|
||||||
预算归档周期:{year}年{month}月
|
预算归档周期:{year}年{month}月
|
||||||
|
|
||||||
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
||||||
@@ -532,7 +533,7 @@ public class BudgetService(
|
|||||||
|
|
||||||
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
||||||
{
|
{
|
||||||
var referenceDate = now ?? DateTime.Now;
|
var referenceDate = now ?? dateTimeProvider.Now;
|
||||||
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
|
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
|
||||||
|
|
||||||
var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
||||||
@@ -541,7 +542,7 @@ public class BudgetService(
|
|||||||
if (actualAmount == 0
|
if (actualAmount == 0
|
||||||
&& budget.IsMandatoryExpense
|
&& budget.IsMandatoryExpense
|
||||||
&& referenceDate.Year == startDate.Year
|
&& referenceDate.Year == startDate.Year
|
||||||
&& referenceDate.Month == startDate.Month)
|
&& (budget.Type == BudgetPeriodType.Year || referenceDate.Month == startDate.Month))
|
||||||
{
|
{
|
||||||
if (budget.Type == BudgetPeriodType.Month)
|
if (budget.Type == BudgetPeriodType.Month)
|
||||||
{
|
{
|
||||||
@@ -616,11 +617,11 @@ public record BudgetResult
|
|||||||
|
|
||||||
public static BudgetResult FromEntity(
|
public static BudgetResult FromEntity(
|
||||||
BudgetRecord entity,
|
BudgetRecord entity,
|
||||||
decimal currentAmount = 0,
|
decimal currentAmount,
|
||||||
DateTime? referenceDate = null,
|
DateTime referenceDate,
|
||||||
string description = "")
|
string description = "")
|
||||||
{
|
{
|
||||||
var date = referenceDate ?? DateTime.Now;
|
var date = referenceDate;
|
||||||
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
||||||
|
|
||||||
return new BudgetResult
|
return new BudgetResult
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ const props = defineProps({
|
|||||||
activeTab: {
|
activeTab: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
default: BudgetCategory.Expense
|
default: BudgetCategory.Expense
|
||||||
|
},
|
||||||
|
selectedDate: {
|
||||||
|
type: Date,
|
||||||
|
default: () => new Date()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -294,33 +298,81 @@ const updateSingleGauge = (chart, data, isExpense) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
chart.setOption(option)
|
chart.setOption(option, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposeBudgetCharts = () => {
|
||||||
|
varianceChart?.dispose()
|
||||||
|
varianceChart = null
|
||||||
|
burndownChart?.dispose()
|
||||||
|
burndownChart = null
|
||||||
|
yearBurndownChart?.dispose()
|
||||||
|
yearBurndownChart = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateCharts = () => {
|
const updateCharts = () => {
|
||||||
const isExpense = props.activeTab === BudgetCategory.Expense
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
||||||
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
|
|
||||||
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
|
// 仪表盘总是存在的
|
||||||
|
if (!monthGaugeChart && monthGaugeRef.value) {
|
||||||
|
monthGaugeChart = echarts.init(monthGaugeRef.value)
|
||||||
|
}
|
||||||
|
if (monthGaugeChart) {
|
||||||
|
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!yearGaugeChart && yearGaugeRef.value) {
|
||||||
|
yearGaugeChart = echarts.init(yearGaugeRef.value)
|
||||||
|
}
|
||||||
|
if (yearGaugeChart) {
|
||||||
|
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
|
||||||
|
}
|
||||||
|
|
||||||
if (props.budgets.length > 0) {
|
if (props.budgets.length > 0) {
|
||||||
// Update Variance Chart
|
// 等待 v-if 相关的 DOM 更新
|
||||||
if (!varianceChart && varianceChartRef.value) {
|
nextTick(() => {
|
||||||
varianceChart = echarts.init(varianceChartRef.value)
|
// 偏差分析图
|
||||||
}
|
if (varianceChartRef.value) {
|
||||||
if (varianceChart) {
|
const existing = echarts.getInstanceByDom(varianceChartRef.value)
|
||||||
updateVarianceChart(varianceChart, props.budgets)
|
if (existing) {
|
||||||
}
|
varianceChart = existing
|
||||||
|
} else {
|
||||||
|
varianceChart?.dispose()
|
||||||
|
varianceChart = echarts.init(varianceChartRef.value)
|
||||||
|
}
|
||||||
|
updateVarianceChart(varianceChart, props.budgets)
|
||||||
|
varianceChart.resize()
|
||||||
|
}
|
||||||
|
|
||||||
// 更新燃尽图/积累图
|
// 月度燃尽图
|
||||||
if (!burndownChart && burndownChartRef.value) {
|
if (burndownChartRef.value) {
|
||||||
burndownChart = echarts.init(burndownChartRef.value)
|
const existing = echarts.getInstanceByDom(burndownChartRef.value)
|
||||||
}
|
if (existing) {
|
||||||
updateBurndownChart()
|
burndownChart = existing
|
||||||
|
} else {
|
||||||
|
burndownChart?.dispose()
|
||||||
|
burndownChart = echarts.init(burndownChartRef.value)
|
||||||
|
}
|
||||||
|
updateBurndownChart()
|
||||||
|
burndownChart.resize()
|
||||||
|
}
|
||||||
|
|
||||||
if (!yearBurndownChart && yearBurndownChartRef.value) {
|
// 年度燃尽图
|
||||||
yearBurndownChart = echarts.init(yearBurndownChartRef.value)
|
if (yearBurndownChartRef.value) {
|
||||||
}
|
const existing = echarts.getInstanceByDom(yearBurndownChartRef.value)
|
||||||
updateYearBurndownChart()
|
if (existing) {
|
||||||
|
yearBurndownChart = existing
|
||||||
|
} else {
|
||||||
|
yearBurndownChart?.dispose()
|
||||||
|
yearBurndownChart = echarts.init(yearBurndownChartRef.value)
|
||||||
|
}
|
||||||
|
updateYearBurndownChart()
|
||||||
|
yearBurndownChart.resize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 预算数据为空,DOM 已移除,清理实例
|
||||||
|
disposeBudgetCharts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +499,7 @@ const updateVarianceChart = (chart, budgets) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
chart.setOption(option)
|
chart.setOption(option, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateChartHeight = (budgets) => {
|
const calculateChartHeight = (budgets) => {
|
||||||
@@ -462,12 +514,18 @@ const calculateChartHeight = (budgets) => {
|
|||||||
const updateBurndownChart = () => {
|
const updateBurndownChart = () => {
|
||||||
if (!burndownChart) { return }
|
if (!burndownChart) { return }
|
||||||
|
|
||||||
// 获取当前月份的日期
|
// 使用传入的所选日期作为参考日期
|
||||||
const today = new Date()
|
const refDate = props.selectedDate
|
||||||
const year = today.getFullYear()
|
const year = refDate.getFullYear()
|
||||||
const month = today.getMonth()
|
const month = refDate.getMonth()
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
const currentDay = today.getDate()
|
|
||||||
|
const now = new Date()
|
||||||
|
const isCurrentMonth = now.getFullYear() === year && now.getMonth() === month
|
||||||
|
const isPastMonth =
|
||||||
|
now.getFullYear() > year || (now.getFullYear() === year && now.getMonth() > month)
|
||||||
|
// 如果是过去月份,显示完整数据;如果是当前月,显示到今天;如果是将来月,不显示实际数据
|
||||||
|
const currentDay = isCurrentMonth ? now.getDate() : isPastMonth ? daysInMonth : 0
|
||||||
const isExpense = props.activeTab === BudgetCategory.Expense
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
||||||
|
|
||||||
// 生成日期和理想燃尽线/积累线
|
// 生成日期和理想燃尽线/积累线
|
||||||
@@ -490,6 +548,7 @@ const updateBurndownChart = () => {
|
|||||||
|
|
||||||
// 实际燃尽:根据当前日期显示
|
// 实际燃尽:根据当前日期显示
|
||||||
if (trend.length > 0) {
|
if (trend.length > 0) {
|
||||||
|
// 后端返回了趋势数据
|
||||||
const dayValue = trend[i - 1]
|
const dayValue = trend[i - 1]
|
||||||
if (dayValue !== undefined && dayValue !== null) {
|
if (dayValue !== undefined && dayValue !== null) {
|
||||||
const actualRemaining = Math.max(0, totalBudget - dayValue)
|
const actualRemaining = Math.max(0, totalBudget - dayValue)
|
||||||
@@ -498,6 +557,7 @@ const updateBurndownChart = () => {
|
|||||||
actualBurndown.push(null)
|
actualBurndown.push(null)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 后端没有趋势数据, fallback 到线性估算
|
||||||
if (i <= currentDay && totalBudget > 0) {
|
if (i <= currentDay && totalBudget > 0) {
|
||||||
const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay))
|
const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay))
|
||||||
actualBurndown.push(Math.round(actualRemaining))
|
actualBurndown.push(Math.round(actualRemaining))
|
||||||
@@ -620,18 +680,22 @@ const updateBurndownChart = () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
burndownChart.setOption(option)
|
burndownChart.setOption(option, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateYearBurndownChart = () => {
|
const updateYearBurndownChart = () => {
|
||||||
if (!yearBurndownChart) { return }
|
if (!yearBurndownChart) { return }
|
||||||
|
|
||||||
// 获取当前年份的日期
|
// 使用参考日期
|
||||||
const today = new Date()
|
const refDate = props.selectedDate
|
||||||
const year = today.getFullYear()
|
const year = refDate.getFullYear()
|
||||||
const currentMonth = today.getMonth()
|
const refMonth = refDate.getMonth()
|
||||||
const currentDay = today.getDate()
|
|
||||||
const daysInCurrentMonth = new Date(year, currentMonth + 1, 0).getDate()
|
const now = new Date()
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
const currentMonth = now.getMonth()
|
||||||
|
const currentDay = now.getDate()
|
||||||
|
const daysInCurrentMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
|
||||||
const isExpense = props.activeTab === BudgetCategory.Expense
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
||||||
|
|
||||||
// 生成月份和理想燃尽线/积累线
|
// 生成月份和理想燃尽线/积累线
|
||||||
@@ -654,21 +718,19 @@ const updateYearBurndownChart = () => {
|
|||||||
daysInYear += new Date(year, j + 1, 0).getDate()
|
daysInYear += new Date(year, j + 1, 0).getDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i < currentMonth) {
|
if (year < currentYear || (year === currentYear && i < currentMonth)) {
|
||||||
// 之前的月份都已完成
|
// 以前的年/月
|
||||||
daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate()
|
daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate()
|
||||||
} else if (i === currentMonth) {
|
} else if (year === currentYear && i === currentMonth) {
|
||||||
// 当前月份
|
// 当前月
|
||||||
daysPassedInYear = daysInYear + currentDay
|
daysPassedInYear = daysInYear + currentDay
|
||||||
daysInYear += daysInCurrentMonth
|
|
||||||
} else {
|
} else {
|
||||||
// 未来的月份
|
// 未来的年/月
|
||||||
daysInYear += new Date(year, i + 1, 0).getDate()
|
daysPassedInYear = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全年总天数(365或366)
|
const daysInYearTotal = (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) ? 366 : 365
|
||||||
const daysInYearTotal = new Date(year, 12, 0).getDate() === 29 ? 366 : 365
|
const yearProgress = daysPassedInYear / daysInYearTotal
|
||||||
const yearProgress = i === 11 ? 1 : daysPassedInYear / daysInYearTotal
|
|
||||||
|
|
||||||
if (isExpense) {
|
if (isExpense) {
|
||||||
// 支出:燃尽图(向下走)
|
// 支出:燃尽图(向下走)
|
||||||
@@ -676,7 +738,7 @@ const updateYearBurndownChart = () => {
|
|||||||
const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12))
|
const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12))
|
||||||
idealBurndown.push(Math.round(idealRemaining))
|
idealBurndown.push(Math.round(idealRemaining))
|
||||||
|
|
||||||
// 实际燃尽:根据当前日期显示
|
// 实际燃尽:根据日期显示
|
||||||
if (trend.length > 0) {
|
if (trend.length > 0) {
|
||||||
const monthValue = trend[i]
|
const monthValue = trend[i]
|
||||||
if (monthValue !== undefined && monthValue !== null) {
|
if (monthValue !== undefined && monthValue !== null) {
|
||||||
@@ -686,7 +748,9 @@ const updateYearBurndownChart = () => {
|
|||||||
actualBurndown.push(null)
|
actualBurndown.push(null)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) {
|
// Fallback: 如果是今年且月份未开始,或者去年,做线性统计
|
||||||
|
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
|
||||||
|
if (!isFuture && totalBudget > 0) {
|
||||||
const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress))
|
const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress))
|
||||||
actualBurndown.push(Math.round(actualRemaining))
|
actualBurndown.push(Math.round(actualRemaining))
|
||||||
} else {
|
} else {
|
||||||
@@ -699,7 +763,7 @@ const updateYearBurndownChart = () => {
|
|||||||
const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12))
|
const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12))
|
||||||
idealBurndown.push(Math.round(idealAccumulated))
|
idealBurndown.push(Math.round(idealAccumulated))
|
||||||
|
|
||||||
// 实际积累:根据当前日期显示
|
// 实际积累:根据参数显示
|
||||||
if (trend.length > 0) {
|
if (trend.length > 0) {
|
||||||
const monthValue = trend[i]
|
const monthValue = trend[i]
|
||||||
if (monthValue !== undefined && monthValue !== null) {
|
if (monthValue !== undefined && monthValue !== null) {
|
||||||
@@ -708,7 +772,8 @@ const updateYearBurndownChart = () => {
|
|||||||
actualBurndown.push(null)
|
actualBurndown.push(null)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) {
|
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
|
||||||
|
if (!isFuture && totalBudget > 0) {
|
||||||
const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress)
|
const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress)
|
||||||
actualBurndown.push(Math.round(actualAccumulated))
|
actualBurndown.push(Math.round(actualAccumulated))
|
||||||
} else {
|
} else {
|
||||||
@@ -806,7 +871,7 @@ const updateYearBurndownChart = () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
yearBurndownChart.setOption(option)
|
yearBurndownChart.setOption(option, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
|
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
|
||||||
@@ -816,6 +881,12 @@ watch(() => props.budgets, () => {
|
|||||||
})
|
})
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(() => props.selectedDate, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
updateCharts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.activeTab, () => {
|
watch(() => props.activeTab, () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updateCharts()
|
updateCharts()
|
||||||
@@ -838,28 +909,7 @@ const handleResize = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const isExpense = props.activeTab === BudgetCategory.Expense
|
updateCharts()
|
||||||
monthGaugeChart = initGaugeChart(monthGaugeChart, monthGaugeRef.value, props.overallStats.month, isExpense)
|
|
||||||
yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense)
|
|
||||||
// 只在有数据时初始化柱状图
|
|
||||||
if (props.budgets.length > 0) {
|
|
||||||
// 初始化偏差图
|
|
||||||
if (varianceChartRef.value) {
|
|
||||||
varianceChart = echarts.init(varianceChartRef.value)
|
|
||||||
updateVarianceChart(varianceChart, props.budgets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化燃尽图/积累图
|
|
||||||
if (burndownChartRef.value) {
|
|
||||||
burndownChart = echarts.init(burndownChartRef.value)
|
|
||||||
updateBurndownChart()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (yearBurndownChartRef.value) {
|
|
||||||
yearBurndownChart = echarts.init(yearBurndownChartRef.value)
|
|
||||||
updateYearBurndownChart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -867,10 +917,10 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
monthGaugeChart?.dispose()
|
monthGaugeChart?.dispose()
|
||||||
|
monthGaugeChart = null
|
||||||
yearGaugeChart?.dispose()
|
yearGaugeChart?.dispose()
|
||||||
varianceChart?.dispose()
|
yearGaugeChart = null
|
||||||
burndownChart?.dispose()
|
disposeBudgetCharts()
|
||||||
yearBurndownChart?.dispose()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
:overall-stats="overallStats"
|
:overall-stats="overallStats"
|
||||||
:budgets="expenseBudgets"
|
:budgets="expenseBudgets"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
|
:selected-date="selectedDate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</van-tab>
|
</van-tab>
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
:overall-stats="overallStats"
|
:overall-stats="overallStats"
|
||||||
:budgets="incomeBudgets"
|
:budgets="incomeBudgets"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
|
:selected-date="selectedDate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</van-tab>
|
</van-tab>
|
||||||
@@ -501,6 +503,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { showToast, showConfirmDialog } from 'vant'
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
getBudgetList,
|
getBudgetList,
|
||||||
deleteBudget,
|
deleteBudget,
|
||||||
@@ -520,7 +523,7 @@ const activeTab = ref(BudgetCategory.Expense)
|
|||||||
const selectedDate = ref(new Date())
|
const selectedDate = ref(new Date())
|
||||||
const showDatePicker = ref(false)
|
const showDatePicker = ref(false)
|
||||||
const minDate = new Date(2020, 0, 1)
|
const minDate = new Date(2020, 0, 1)
|
||||||
const maxDate = new Date(2030, 11, 31)
|
const maxDate = new Date()
|
||||||
const pickerDate = ref([
|
const pickerDate = ref([
|
||||||
selectedDate.value.getFullYear().toString(),
|
selectedDate.value.getFullYear().toString(),
|
||||||
(selectedDate.value.getMonth() + 1).toString().padStart(2, '0')
|
(selectedDate.value.getMonth() + 1).toString().padStart(2, '0')
|
||||||
@@ -597,33 +600,9 @@ const onConfirmDate = ({ selectedValues }) => {
|
|||||||
showDatePicker.value = false
|
showDatePicker.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getValueClass = (rate) => {
|
|
||||||
const numRate = parseFloat(rate)
|
|
||||||
if (numRate === 0) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (activeTab.value === BudgetCategory.Expense) {
|
|
||||||
if (numRate >= 100) {
|
|
||||||
return 'expense'
|
|
||||||
}
|
|
||||||
if (numRate >= 80) {
|
|
||||||
return 'warning'
|
|
||||||
}
|
|
||||||
return 'income'
|
|
||||||
} else {
|
|
||||||
if (numRate >= 100) {
|
|
||||||
return 'income'
|
|
||||||
}
|
|
||||||
if (numRate >= 80) {
|
|
||||||
return 'warning'
|
|
||||||
}
|
|
||||||
return 'expense'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchBudgetList = async () => {
|
const fetchBudgetList = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getBudgetList(selectedDate.value.toISOString())
|
const res = await getBudgetList(dayjs(selectedDate.value).format('YYYY-MM-DD'))
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
const data = res.data || []
|
const data = res.data || []
|
||||||
expenseBudgets.value = data.filter((b) => b.category === BudgetCategory.Expense)
|
expenseBudgets.value = data.filter((b) => b.category === BudgetCategory.Expense)
|
||||||
@@ -647,7 +626,10 @@ const onRefresh = async () => {
|
|||||||
|
|
||||||
const fetchCategoryStats = async () => {
|
const fetchCategoryStats = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getCategoryStats(activeTab.value, selectedDate.value.toISOString())
|
const res = await getCategoryStats(
|
||||||
|
activeTab.value,
|
||||||
|
dayjs(selectedDate.value).format('YYYY-MM-DD')
|
||||||
|
)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
// 转换后端返回的数据格式为前端需要的格式
|
// 转换后端返回的数据格式为前端需要的格式
|
||||||
const data = res.data
|
const data = res.data
|
||||||
@@ -679,7 +661,10 @@ const fetchUncoveredCategories = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await getUncoveredCategories(activeTab.value, selectedDate.value.toISOString())
|
const res = await getUncoveredCategories(
|
||||||
|
activeTab.value,
|
||||||
|
dayjs(selectedDate.value).format('YYYY-MM-DD')
|
||||||
|
)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
uncoveredCategories.value = res.data || []
|
uncoveredCategories.value = res.data || []
|
||||||
}
|
}
|
||||||
@@ -795,7 +780,7 @@ const getProgressColor = (budget) => {
|
|||||||
|
|
||||||
const showArchiveSummary = async () => {
|
const showArchiveSummary = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getArchiveSummary(selectedDate.value.toISOString())
|
const res = await getArchiveSummary(dayjs(selectedDate.value).format('YYYY-MM-DD'))
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
archiveSummary.value = res.data || ''
|
archiveSummary.value = res.data || ''
|
||||||
showSummaryPopup.value = true
|
showSummaryPopup.value = true
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using Common;
|
namespace WebApi.Test.Budget;
|
||||||
|
|
||||||
namespace WebApi.Test;
|
|
||||||
|
|
||||||
public class BudgetSavingsTest : BaseTest
|
public class BudgetSavingsTest : BaseTest
|
||||||
{
|
{
|
||||||
@@ -59,8 +57,6 @@ public class BudgetSavingsTest : BaseTest
|
|||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.Limit.Should().Be(10000 - 2000); // 计划收入 - 计划支出 = 10000 - 2000 = 8000
|
result.Limit.Should().Be(10000 - 2000); // 计划收入 - 计划支出 = 10000 - 2000 = 8000
|
||||||
result.Current.Should().Be(1500 - 10000); // 实际支出 - 实际收入 = 1500 - 10000 = -8500
|
|
||||||
result.Name.Should().Be("月度存款计划");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -114,9 +110,6 @@ public class BudgetSavingsTest : BaseTest
|
|||||||
// 计划支出 = 月度计划支出(2000) + 本月发生的年度实际支出(6000) = 8000
|
// 计划支出 = 月度计划支出(2000) + 本月发生的年度实际支出(6000) = 8000
|
||||||
// 计划存款 = 60000 - 8000 = 52000
|
// 计划存款 = 60000 - 8000 = 52000
|
||||||
result.Limit.Should().Be(60000 - 8000);
|
result.Limit.Should().Be(60000 - 8000);
|
||||||
|
|
||||||
// 实际 = 实际支出(1500 + 6000) - 实际收入(10000 + 50000) = 7500 - 60000 = -52500
|
|
||||||
result.Current.Should().Be((1500 + 6000) - (10000 + 50000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -164,9 +157,6 @@ public class BudgetSavingsTest : BaseTest
|
|||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
// 计划存款 = 计划收入(6200) - 计划支出(3100) = 3100
|
// 计划存款 = 计划收入(6200) - 计划支出(3100) = 3100
|
||||||
result.Limit.Should().Be(6200 - 3100);
|
result.Limit.Should().Be(6200 - 3100);
|
||||||
|
|
||||||
// 实际 = 估算支出(2000) - 估算收入(4000) = -2000
|
|
||||||
result.Current.Should().Be(2000 - 4000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
172
WebApi.Test/Budget/BudgetTest.cs
Normal file
172
WebApi.Test/Budget/BudgetTest.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Common;
|
||||||
|
|
||||||
|
namespace WebApi.Test.Budget;
|
||||||
|
|
||||||
|
public class BudgetTest : BaseTest
|
||||||
|
{
|
||||||
|
private readonly IBudgetRepository _budgetRepository = Substitute.For<IBudgetRepository>();
|
||||||
|
private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For<IBudgetArchiveRepository>();
|
||||||
|
private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For<ITransactionRecordRepository>();
|
||||||
|
private readonly IOpenAiService _openAiService = Substitute.For<IOpenAiService>();
|
||||||
|
private readonly IMessageService _messageService = Substitute.For<IMessageService>();
|
||||||
|
private readonly ILogger<BudgetService> _logger = Substitute.For<ILogger<BudgetService>>();
|
||||||
|
private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For<IBudgetSavingsService>();
|
||||||
|
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
|
||||||
|
private readonly BudgetService _service;
|
||||||
|
|
||||||
|
public BudgetTest()
|
||||||
|
{
|
||||||
|
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
|
||||||
|
_service = new BudgetService(
|
||||||
|
_budgetRepository,
|
||||||
|
_budgetArchiveRepository,
|
||||||
|
_transactionsRepository,
|
||||||
|
_openAiService,
|
||||||
|
_messageService,
|
||||||
|
_logger,
|
||||||
|
_budgetSavingsService,
|
||||||
|
_dateTimeProvider
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCategoryStats_月度_Test()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var referenceDate = new DateTime(2024, 1, 15);
|
||||||
|
var budgets = new List<BudgetRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" },
|
||||||
|
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" }
|
||||||
|
};
|
||||||
|
|
||||||
|
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||||
|
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||||
|
.Returns(args =>
|
||||||
|
{
|
||||||
|
var b = (BudgetRecord)args[0];
|
||||||
|
return b.Name == "吃喝" ? 1200m : 300m;
|
||||||
|
});
|
||||||
|
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
|
||||||
|
.Returns(new Dictionary<DateTime, decimal>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Month.Limit.Should().Be(2500);
|
||||||
|
result.Month.Current.Should().Be(1500);
|
||||||
|
result.Month.Count.Should().Be(2);
|
||||||
|
result.Month.Rate.Should().Be(1500m / 2500m * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCategoryStats_月度_硬性收支_Test()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var referenceDate = new DateTime(2024, 1, 15);
|
||||||
|
var budgets = new List<BudgetRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "房租", Limit = 3100, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, IsMandatoryExpense = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||||
|
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||||
|
.Returns(0m); // 实际支出的金额为0
|
||||||
|
|
||||||
|
_dateTimeProvider.Now.Returns(referenceDate);
|
||||||
|
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
|
||||||
|
.Returns(new Dictionary<DateTime, decimal>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 1月有31天,15号经过了15天
|
||||||
|
// 3100 * 15 / 31 = 1500
|
||||||
|
result.Month.Limit.Should().Be(3100);
|
||||||
|
result.Month.Current.Should().Be(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCategoryStats_年度_1月_Test()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var referenceDate = new DateTime(2024, 1, 15);
|
||||||
|
var budgets = new List<BudgetRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year }
|
||||||
|
};
|
||||||
|
|
||||||
|
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||||
|
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Is<DateTime>(d => d.Month == 1 && d.Day == 1), Arg.Is<DateTime>(d => d.Month == 12 && d.Day == 31))
|
||||||
|
.Returns(2000m);
|
||||||
|
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
|
||||||
|
.Returns(new Dictionary<DateTime, decimal>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 月度统计中,年度预算被忽略 (Limit=0)
|
||||||
|
result.Month.Limit.Should().Be(0);
|
||||||
|
result.Month.Current.Should().Be(0);
|
||||||
|
|
||||||
|
// 年度统计中
|
||||||
|
result.Year.Limit.Should().Be(12000);
|
||||||
|
result.Year.Current.Should().Be(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCategoryStats_年度_1月_硬性收支_Test()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var referenceDate = new DateTime(2024, 1, 1); // 元旦
|
||||||
|
var budgets = new List<BudgetRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||||
|
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
|
||||||
|
_dateTimeProvider.Now.Returns(referenceDate);
|
||||||
|
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
|
||||||
|
.Returns(new Dictionary<DateTime, decimal>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 2024是闰年,366天。1月1号是第1天。
|
||||||
|
// 3660 * 1 / 366 = 10
|
||||||
|
result.Year.Limit.Should().Be(3660);
|
||||||
|
result.Year.Current.Should().Be(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCategoryStats_年度_3月_硬性收支_Test()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var referenceDate = new DateTime(2024, 3, 31); // 3月最后一天
|
||||||
|
var budgets = new List<BudgetRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||||
|
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
|
||||||
|
_dateTimeProvider.Now.Returns(referenceDate);
|
||||||
|
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
|
||||||
|
.Returns(new Dictionary<DateTime, decimal>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天
|
||||||
|
// 3660 * 91 / 366 = 910
|
||||||
|
result.Year.Limit.Should().Be(3660);
|
||||||
|
result.Year.Current.Should().Be(910);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ global using NSubstitute;
|
|||||||
global using Service.Budget;
|
global using Service.Budget;
|
||||||
global using Xunit;
|
global using Xunit;
|
||||||
global using Yitter.IdGenerator;
|
global using Yitter.IdGenerator;
|
||||||
global using WebApi.Test.Basic;
|
global using WebApi.Test.Basic;
|
||||||
|
global using Common;
|
||||||
Reference in New Issue
Block a user