更新预算归档功能,添加归档总结和更新归档总结接口,优化预算统计逻辑,调整相关样式
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 34s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
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-12 22:29:39 +08:00
parent 03115a04ec
commit 556fc5af20
14 changed files with 337 additions and 408 deletions

View File

@@ -1,7 +1,8 @@
<Project> <Project>
<ItemGroup> <ItemGroup>
<!-- Email & MIME Libraries --> <!-- Email & MIME Libraries -->
<PackageVersion Include="FreeSql" Version="3.5.304" /> <PackageVersion Include="FreeSql" Version="3.5.305" />
<PackageVersion Include="FreeSql.Extensions.JsonMap" Version="3.5.305" />
<PackageVersion Include="MailKit" Version="4.14.1" /> <PackageVersion Include="MailKit" Version="4.14.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageVersion Include="MimeKit" Version="4.14.0" /> <PackageVersion Include="MimeKit" Version="4.14.0" />
@@ -20,7 +21,7 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
<!-- Database --> <!-- Database -->
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" /> <PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.305" />
<PackageVersion Include="WebPush" Version="1.0.12" /> <PackageVersion Include="WebPush" Version="1.0.12" />
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" /> <PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
<!-- File Processing --> <!-- File Processing -->

View File

@@ -2,31 +2,6 @@
public class BudgetArchive : BaseEntity public class BudgetArchive : BaseEntity
{ {
/// <summary>
/// 预算Id
/// </summary>
public long BudgetId { get; set; }
/// <summary>
/// 预算周期类型
/// </summary>
public BudgetPeriodType BudgetType { get; set; }
/// <summary>
/// 预算金额
/// </summary>
public decimal BudgetedAmount { get; set; }
/// <summary>
/// 周期内实际发生金额
/// </summary>
public decimal RealizedAmount { get; set; }
/// <summary>
/// 详细描述
/// </summary>
public string? Description { get; set; }
/// <summary> /// <summary>
/// 归档目标年份 /// 归档目标年份
/// </summary> /// </summary>
@@ -37,8 +12,54 @@ public class BudgetArchive : BaseEntity
/// </summary> /// </summary>
public int Month { get; set; } public int Month { get; set; }
/// <summary>
/// 归档内容
/// </summary>
[JsonMap]
public BudgetArchiveContent[] Content { get; set; } = [];
/// <summary> /// <summary>
/// 归档日期 /// 归档日期
/// </summary> /// </summary>
public DateTime ArchiveDate { get; set; } = DateTime.Now; public DateTime ArchiveDate { get; set; } = DateTime.Now;
public string? Summary { get; set; }
}
public record BudgetArchiveContent
{
/// <summary>
/// 预算名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 统计周期
/// </summary>
public BudgetPeriodType Type { get; set; } = BudgetPeriodType.Month;
/// <summary>
/// 预算金额
/// </summary>
public decimal Limit { get; set; }
/// <summary>
/// 实际金额
/// </summary>
public decimal Actual { get; set; }
/// <summary>
/// 预算类别
/// </summary>
public BudgetCategory Category { get; set; }
/// <summary>
/// 相关分类 (逗号分隔的分类名称)
/// </summary>
public string[] SelectedCategories { get; set; } = [];
/// <summary>
/// 描述说明
/// </summary>
public string Description { get; set; } = string.Empty;
} }

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="FreeSql" /> <PackageReference Include="FreeSql" />
<PackageReference Include="FreeSql.Extensions.JsonMap" />
<PackageReference Include="Yitter.IdGenerator" /> <PackageReference Include="Yitter.IdGenerator" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -2,7 +2,8 @@
public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive> public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive>
{ {
Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month); Task<BudgetArchive?> GetArchiveAsync(int year, int month);
Task<List<BudgetArchive>> GetListAsync(int year, int month); Task<List<BudgetArchive>> GetListAsync(int year, int month);
} }
@@ -10,11 +11,10 @@ public class BudgetArchiveRepository(
IFreeSql freeSql IFreeSql freeSql
) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository ) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository
{ {
public async Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month) public async Task<BudgetArchive?> GetArchiveAsync(int year, int month)
{ {
return await FreeSql.Select<BudgetArchive>() return await FreeSql.Select<BudgetArchive>()
.Where(a => a.BudgetId == budgetId && .Where(a => a.Year == year &&
a.Year == year &&
a.Month == month) a.Month == month)
.ToOneAsync(); .ToOneAsync();
} }
@@ -22,13 +22,7 @@ public class BudgetArchiveRepository(
public async Task<List<BudgetArchive>> GetListAsync(int year, int month) public async Task<List<BudgetArchive>> GetListAsync(int year, int month)
{ {
return await FreeSql.Select<BudgetArchive>() return await FreeSql.Select<BudgetArchive>()
.Where( .Where(a => a.Year == year && a.Month == month)
a => a.BudgetType == BudgetPeriodType.Month &&
a.Year == year &&
a.Month == month ||
a.BudgetType == BudgetPeriodType.Year &&
a.Year == year
)
.ToListAsync(); .ToListAsync();
} }
} }

View File

@@ -2,21 +2,23 @@
public interface IBudgetService public interface IBudgetService
{ {
Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null); Task<List<BudgetResult>> GetListAsync(DateTime referenceDate);
Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate);
Task<string> ArchiveBudgetsAsync(int year, int month); Task<string> ArchiveBudgetsAsync(int year, int month);
/// <summary> /// <summary>
/// 获取指定分类的统计信息(月度和年度) /// 获取指定分类的统计信息(月度和年度)
/// </summary> /// </summary>
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null); Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
/// <summary> /// <summary>
/// 获取未被预算覆盖的分类统计信息 /// 获取未被预算覆盖的分类统计信息
/// </summary> /// </summary>
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null); Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
Task<string?> GetArchiveSummaryAsync(int year, int month);
Task UpdateArchiveSummaryAsync(int year, int month, string? summary);
} }
public class BudgetService( public class BudgetService(
@@ -29,8 +31,38 @@ public class BudgetService(
ILogger<BudgetService> logger ILogger<BudgetService> logger
) : IBudgetService ) : IBudgetService
{ {
public async Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null) public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
{ {
var year = referenceDate.Year;
var month = referenceDate.Month;
var isArchive = year < DateTime.Now.Year
|| (year == DateTime.Now.Year && month < DateTime.Now.Month);
if (isArchive)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null)
{
var periodRange = GetPeriodRange(DateTime.Now, BudgetPeriodType.Month, referenceDate);
return archive.Content.Select(c => new BudgetResult
{
Name = c.Name,
Type = c.Type,
Limit = c.Limit,
Current = c.Actual,
Category = c.Category,
SelectedCategories = c.SelectedCategories,
Description = c.Description,
PeriodStart = periodRange.start,
PeriodEnd = periodRange.end,
}).ToList();
}
logger.LogWarning("获取预算列表时发现归档数据缺失Year: {Year}, Month: {Month}", year, month);
}
var budgets = await budgetRepository.GetAllAsync(); var budgets = await budgetRepository.GetAllAsync();
var dtos = new List<BudgetResult?>(); var dtos = new List<BudgetResult?>();
@@ -53,104 +85,17 @@ public class BudgetService(
return dtos.Where(dto => dto != null).Cast<BudgetResult>().ToList(); return dtos.Where(dto => dto != null).Cast<BudgetResult>().ToList();
} }
public async Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate) public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{ {
bool isArchive = false; var budgets = await GetListAsync(referenceDate);
BudgetRecord? budget = null;
if (id == -1)
{
if (isAcrhiveFunc(BudgetPeriodType.Year))
{
isArchive = true;
budget = await BuildVirtualSavingsBudgetRecordAsync(-1, referenceDate, 0);
}
}
else if (id == -2)
{
if (isAcrhiveFunc(BudgetPeriodType.Month))
{
isArchive = true;
budget = await BuildVirtualSavingsBudgetRecordAsync(-2, referenceDate, 0);
}
}
else
{
budget = await budgetRepository.GetByIdAsync(id);
if (budget == null)
{
return null;
}
isArchive = isAcrhiveFunc(budget.Type);
}
if (isArchive && budget != null)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(
id,
referenceDate.Year,
referenceDate.Month);
if (archive != null) // 存在归档 直接读取归档数据
{
budget.Limit = archive.BudgetedAmount;
return BudgetResult.FromEntity(
budget,
archive.RealizedAmount,
referenceDate,
archive.Description ?? string.Empty);
}
}
if (id == -1)
{
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Year, referenceDate);
}
if (id == -2)
{
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
}
budget = await budgetRepository.GetByIdAsync(id);
if (budget == null)
{
return null;
}
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
return BudgetResult.FromEntity(budget, currentAmount, referenceDate);
bool isAcrhiveFunc(BudgetPeriodType periodType)
{
if (periodType == BudgetPeriodType.Year)
{
return DateTime.Now.Year > referenceDate.Year;
}
else if (periodType == BudgetPeriodType.Month)
{
return DateTime.Now.Year > referenceDate.Year
|| (DateTime.Now.Year == referenceDate.Year
&& DateTime.Now.Month > referenceDate.Month);
}
return false;
}
}
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(); var result = new BudgetCategoryStats();
// 获取月度统计 // 获取月度统计
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, refDate); result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, referenceDate);
// 获取年度统计 // 获取年度统计
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, refDate); result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, referenceDate);
return result; return result;
} }
@@ -190,8 +135,30 @@ public class BudgetService(
.ToList(); .ToList();
} }
public async Task<string?> GetArchiveSummaryAsync(int year, int month)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
return archive?.Summary;
}
public async Task UpdateArchiveSummaryAsync(int year, int month, string? summary)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive == null)
{
await ArchiveBudgetsAsync(year, month);
archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
}
if (archive != null)
{
archive.Summary = summary;
await budgetArchiveRepository.UpdateAsync(archive);
}
}
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync( private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
List<BudgetRecord> budgets, List<BudgetResult> budgets,
BudgetCategory category, BudgetCategory category,
BudgetPeriodType statType, BudgetPeriodType statType,
DateTime referenceDate) DateTime referenceDate)
@@ -236,7 +203,15 @@ public class BudgetService(
totalLimit += itemLimit; totalLimit += itemLimit;
// 当前值累加 // 当前值累加
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate); var currentAmount = await CalculateCurrentAmountAsync(new()
{
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Category = budget.Category,
SelectedCategories = string.Join(',', budget.SelectedCategories),
StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1)
}, referenceDate);
if (budget.Type == statType) if (budget.Type == statType)
{ {
totalCurrent += currentAmount; totalCurrent += currentAmount;
@@ -263,55 +238,49 @@ public class BudgetService(
public async Task<string> ArchiveBudgetsAsync(int year, int month) public async Task<string> ArchiveBudgetsAsync(int year, int month)
{ {
var referenceDate = new DateTime(year, month, 1); var referenceDate = new DateTime(year, month, 1);
var budgets = await GetListAsync(referenceDate); var budgets = await GetListAsync(referenceDate);
var addArchives = new List<BudgetArchive>(); var content = budgets.Select(b => new BudgetArchiveContent
var updateArchives = new List<BudgetArchive>();
foreach (var budget in budgets)
{ {
var archive = await budgetArchiveRepository.GetArchiveAsync(budget.Id, year, month); Name = b.Name,
Type = b.Type,
Limit = b.Limit,
Actual = b.Current,
Category = b.Category,
SelectedCategories = b.SelectedCategories,
Description = b.Description
}).ToArray();
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null) if (archive != null)
{ {
archive.RealizedAmount = budget.Current; archive.Content = content;
archive.ArchiveDate = DateTime.Now; archive.ArchiveDate = DateTime.Now;
archive.Description = budget.Description; if (!await budgetArchiveRepository.UpdateAsync(archive))
updateArchives.Add(archive); {
return "更新预算归档失败";
}
} }
else else
{ {
archive = new BudgetArchive archive = new BudgetArchive
{ {
BudgetId = budget.Id,
BudgetType = budget.Type,
Year = year, Year = year,
Month = month, Month = month,
BudgetedAmount = budget.Limit, Content = content,
RealizedAmount = budget.Current,
Description = budget.Description,
ArchiveDate = DateTime.Now ArchiveDate = DateTime.Now
}; };
addArchives.Add(archive); if (!await budgetArchiveRepository.AddAsync(archive))
}
}
if (addArchives.Count > 0)
{
if (!await budgetArchiveRepository.AddRangeAsync(addArchives))
{ {
return "保存预算归档失败"; return "保存预算归档失败";
} }
} }
if (updateArchives.Count > 0)
{
if (!await budgetArchiveRepository.UpdateRangeAsync(updateArchives))
{
return "更新预算归档失败";
}
}
_ = NotifyAsync(year, month); _ = NotifyAsync(year, month);
return string.Empty; return string.Empty;
} }
@@ -320,22 +289,16 @@ public class BudgetService(
try try
{ {
var archives = await budgetArchiveRepository.GetListAsync(year, month); var archives = await budgetArchiveRepository.GetListAsync(year, month);
var budgets = await budgetRepository.GetAllAsync();
var budgetMap = budgets.ToDictionary(b => b.Id, b => b);
var archiveData = archives.Select(a => var archiveData = archives.SelectMany(a => a.Content.Select(c => new
{ {
budgetMap.TryGetValue(a.BudgetId, out var br); c.Name,
var name = br?.Name ?? (a.BudgetId == -1 ? "年度存款" : a.BudgetId == -2 ? "月度存款" : "未知"); Type = c.Type.ToString(),
return new c.Limit,
{ c.Actual,
Name = name, Category = c.Category.ToString(),
Type = a.BudgetType.ToString(), c.SelectedCategories
Limit = a.BudgetedAmount, })).ToList();
Actual = a.RealizedAmount,
Category = br?.Category.ToString() ?? (a.BudgetId < 0 ? "Savings" : "Unknown")
};
}).ToList();
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync( var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
$""" $"""
@@ -368,9 +331,9 @@ public class BudgetService(
); );
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0) // 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
var budgetedCategories = budgets var budgetedCategories = archiveData
.Where(b => !string.IsNullOrEmpty(b.SelectedCategories)) .SelectMany(b => b.SelectedCategories)
.SelectMany(b => b.SelectedCategories.Split(',')) .Where(c => !string.IsNullOrEmpty(c))
.Distinct() .Distinct()
.ToHashSet(); .ToHashSet();
@@ -567,10 +530,10 @@ public class BudgetService(
<table> <table>
<thead> <thead>
<tr> <tr>
<th">名称</th> <th></th>
<th">金额</th> <th></th>
<th">折算</th> <th></th>
<th">合计</th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -23,6 +23,8 @@ public class BudgetArchiveJob(
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>(); var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
// 归档月度数据
var result = await budgetService.ArchiveBudgetsAsync(year, month); var result = await budgetService.ArchiveBudgetsAsync(year, month);
if (string.IsNullOrEmpty(result)) if (string.IsNullOrEmpty(result))

View File

@@ -12,19 +12,6 @@ export function getBudgetList(referenceDate) {
}) })
} }
/**
* 获取单个预算统计
* @param {number} id 预算ID
* @param {string} referenceDate 参考日期
*/
export function getBudgetStatistics(id, referenceDate) {
return request({
url: '/Budget/GetStatistics',
method: 'get',
params: { id, referenceDate }
})
}
/** /**
* 创建预算 * 创建预算
* @param {object} data 预算数据 * @param {object} data 预算数据
@@ -84,15 +71,27 @@ export function getUncoveredCategories(category, referenceDate) {
params: { category, referenceDate } params: { category, referenceDate }
}) })
} }
/** /**
* 归档预算 * 获取归档总结
* @param {number} year 年份 * @param {string} referenceDate 参考日期
* @param {number} month 月份
*/ */
export function archiveBudgets(year, month) { export function getArchiveSummary(referenceDate) {
return request({ return request({
url: `/Budget/ArchiveBudgetsAsync/${year}/${month}`, url: '/Budget/GetArchiveSummary',
method: 'post' method: 'get',
params: { referenceDate }
}) })
} }
/**
* 更新归档总结
* @param {object} data 数据 { referenceDate, summary }
*/
export function updateArchiveSummary(data) {
return request({
url: '/Budget/UpdateArchiveSummary',
method: 'post',
data
})
}

View File

@@ -42,8 +42,7 @@
</div> </div>
<!-- 展开状态 --> <!-- 展开状态 -->
<Transition v-else :name="transitionName"> <div v-else class="budget-inner-card">
<div :key="budget.period" class="budget-inner-card">
<div class="card-header" style="margin-bottom: 0;"> <div class="card-header" style="margin-bottom: 0;">
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
@@ -133,32 +132,8 @@
</div> </div>
</van-collapse-transition> </van-collapse-transition>
</div> </div>
<div class="card-footer">
<div class="period-navigation" @click.stop>
<van-button
icon="arrow-left"
class="nav-icon"
plain
size="small"
style="width: 50px;"
@click="handleSwitch(-1)"
/>
<span class="period-text">{{ budget.period }}</span>
<van-button
icon="arrow"
class="nav-icon"
plain
size="small"
style="width: 50px;"
:disabled="isNextDisabled"
@click="handleSwitch(1)"
/>
</div> </div>
</div> </div>
</div>
</Transition>
</div>
<!-- 关联账单列表弹窗 --> <!-- 关联账单列表弹窗 -->
<PopupContainer <PopupContainer
@@ -205,10 +180,9 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['switch-period', 'click']) const emit = defineEmits(['click'])
const isExpanded = ref(props.budget.category === 2) const isExpanded = ref(props.budget.category === 2)
const transitionName = ref('slide-left')
const showDescription = ref(false) const showDescription = ref(false)
const showBillListModal = ref(false) const showBillListModal = ref(false)
const billList = ref([]) const billList = ref([])
@@ -218,16 +192,6 @@ const toggleExpand = () => {
isExpanded.value = !isExpanded.value isExpanded.value = !isExpanded.value
} }
const isNextDisabled = computed(() => {
if (!props.budget.periodEnd) return false
return new Date(props.budget.periodEnd) > new Date()
})
const handleSwitch = (direction) => {
transitionName.value = direction > 0 ? 'slide-left' : 'slide-right'
emit('switch-period', direction)
}
const handleQueryBills = async () => { const handleQueryBills = async () => {
showBillListModal.value = true showBillListModal.value = true
billLoading.value = true billLoading.value = true
@@ -402,40 +366,6 @@ const timePercentage = computed(() => {
cursor: pointer; cursor: pointer;
} }
/* 切换动画 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-leave-to {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-active,
.slide-right-leave-active {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.budget-info { .budget-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -564,42 +494,7 @@ const timePercentage = computed(() => {
line-height: 1.4; line-height: 1.4;
} }
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
color: #969799;
padding: 12px 12px 0;
padding-top: 8px;
border-top: 1px solid #ebedf0;
}
.period-navigation {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.period-text {
font-size: 14px;
font-weight: 500;
color: #323233;
}
.nav-icon {
padding: 4px;
font-size: 12px;
color: #1989fa;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.card-footer {
border-top-color: #2c2c2c;
}
.period-text {
color: #f5f5f5;
}
.budget-description { .budget-description {
background-color: #2c2c2c; background-color: #2c2c2c;
} }

View File

@@ -71,8 +71,9 @@
.rich-html-content thead, .rich-html-content thead,
.rich-html-content tbody { .rich-html-content tbody {
display: table; display: table;
min-width: 100%; width: 100%;
table-layout: fixed; /* 核心:强制列宽分配逻辑一致 */ table-layout: fixed; /* 核心:强制列宽分配逻辑一致 */
border-collapse: collapse;
} }
.rich-html-content tr { .rich-html-content tr {
@@ -86,10 +87,11 @@
text-align: left; text-align: left;
border: none; border: none;
border-bottom: 1px solid var(--van-border-color-light); border-bottom: 1px solid var(--van-border-color-light);
font-size: 12px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
box-sizing: border-box;
} }
.rich-html-content th { .rich-html-content th {

View File

@@ -3,23 +3,36 @@
<van-nav-bar title="预算管理" placeholder> <van-nav-bar title="预算管理" placeholder>
<template #right> <template #right>
<van-icon <van-icon
v-if="activeTab !== BudgetCategory.Savings && uncoveredCategories.length > 0" v-if="activeTab !== BudgetCategory.Savings
&& uncoveredCategories.length > 0
&& !isArchive"
name="warning-o" name="warning-o"
size="20" size="20"
color="#ee0a24" color="#ee0a24"
style="margin-right: 12px" style="margin-right: 12px"
title="查看未覆盖预算的分类"
@click="showUncoveredDetails = true" @click="showUncoveredDetails = true"
/> />
<van-icon
v-if="isArchive"
name="records-o"
size="20"
title="已归档月份总结"
style="margin-right: 12px"
@click="showArchiveSummary()"
/>
<van-icon <van-icon
v-if="activeTab !== BudgetCategory.Savings" v-if="activeTab !== BudgetCategory.Savings"
name="plus" name="plus"
size="20" size="20"
title="添加预算"
@click="budgetEditRef.open({ category: activeTab })" @click="budgetEditRef.open({ category: activeTab })"
/> />
<van-icon <van-icon
v-else v-else
name="setting-o" name="setting-o"
size="20" size="20"
title="储蓄分类配置"
@click="savingsConfigRef.open()" @click="savingsConfigRef.open()"
/> />
</template> </template>
@@ -43,7 +56,6 @@
:progress-color="getProgressColor(budget)" :progress-color="getProgressColor(budget)"
:percent-class="{ 'warning': (budget.current / budget.limit) > 0.8 }" :percent-class="{ 'warning': (budget.current / budget.limit) > 0.8 }"
:period-label="getPeriodLabel(budget.type)" :period-label="getPeriodLabel(budget.type)"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open({ @click="budgetEditRef.open({
data: budget, data: budget,
isEditFlag: true, isEditFlag: true,
@@ -95,7 +107,6 @@
:progress-color="getIncomeProgressColor(budget)" :progress-color="getIncomeProgressColor(budget)"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }" :percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)" :period-label="getPeriodLabel(budget.type)"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open({ @click="budgetEditRef.open({
data: budget, data: budget,
isEditFlag: true, isEditFlag: true,
@@ -142,7 +153,6 @@
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }" :percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)" :period-label="getPeriodLabel(budget.type)"
style="margin: 0 12px 12px;" style="margin: 0 12px 12px;"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
> >
<template #amount-info> <template #amount-info>
<div class="info-item"> <div class="info-item">
@@ -204,13 +214,44 @@
</van-button> </van-button>
</template> </template>
</PopupContainer> </PopupContainer>
<PopupContainer
v-model="showSummaryPopup"
title="月份归档总结"
:subtitle="`${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月`"
height="50%"
>
<div style="padding: 16px;">
<van-field
v-model="archiveSummary"
rows="6"
autosize
label="总结语"
type="textarea"
:placeholder="`请输入${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月预算执行的总结或感悟...`"
show-word-limit
maxlength="500"
/>
</div>
<template #footer>
<van-button
block
round
type="primary"
:loading="isSavingSummary"
@click="handleSaveSummary"
>
保存总结
</van-button>
</template>
</PopupContainer>
</div> </div>
</template> </template>
<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 { getBudgetList, deleteBudget, getBudgetStatistics, getCategoryStats, getUncoveredCategories } from '@/api/budget' import { getBudgetList, deleteBudget, getCategoryStats, getUncoveredCategories, getArchiveSummary, updateArchiveSummary } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums' import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import BudgetCard from '@/components/Budget/BudgetCard.vue' import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue' import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
@@ -226,6 +267,10 @@ const isRefreshing = ref(false)
const showUncoveredDetails = ref(false) const showUncoveredDetails = ref(false)
const uncoveredCategories = ref([]) const uncoveredCategories = ref([])
const showSummaryPopup = ref(false)
const archiveSummary = ref('')
const isSavingSummary = ref(false)
const expenseBudgets = ref([]) const expenseBudgets = ref([])
const incomeBudgets = ref([]) const incomeBudgets = ref([])
const savingsBudgets = ref([]) const savingsBudgets = ref([])
@@ -239,6 +284,12 @@ const activeTabTitle = computed(() => {
return '达成' return '达成'
}) })
const isArchive = computed(() => {
const now = new Date()
return selectedDate.value.getFullYear() < now.getFullYear() ||
(selectedDate.value.getFullYear() === now.getFullYear() && selectedDate.value.getMonth() < now.getMonth())
})
watch(activeTab, async () => { watch(activeTab, async () => {
await Promise.all([fetchCategoryStats(), fetchUncoveredCategories()]) await Promise.all([fetchCategoryStats(), fetchUncoveredCategories()])
}) })
@@ -378,30 +429,6 @@ const getIncomeProgressColor = (budget) => {
return '#1989fa' return '#1989fa'
} }
const refDateMap = {}
const handleSwitchPeriod = async (budget, direction) => {
let currentRefDate = refDateMap[budget.id] || new Date()
const date = new Date(currentRefDate)
if (budget.type === BudgetPeriodType.Month) {
date.setMonth(date.getMonth() + direction)
} else if (budget.type === BudgetPeriodType.Year) {
date.setFullYear(date.getFullYear() + direction)
}
try {
const res = await getBudgetStatistics(budget.id, date.toISOString())
if (res.success) {
refDateMap[budget.id] = date
Object.assign(budget, res.data)
}
} catch (err) {
showToast('加载历史统计失败')
console.error('加载预算历史统计失败', err)
}
}
const handleDelete = (budget) => { const handleDelete = (budget) => {
showConfirmDialog({ showConfirmDialog({
title: '确认删除', title: '确认删除',
@@ -419,6 +446,39 @@ const handleDelete = (budget) => {
} }
}).catch(() => {}) }).catch(() => {})
} }
const showArchiveSummary = async () => {
try {
const res = await getArchiveSummary(selectedDate.value.toISOString())
if (res.success) {
archiveSummary.value = res.data || ''
showSummaryPopup.value = true
}
} catch (err) {
console.error('获取总结失败', err)
showToast('获取总结失败')
}
}
const handleSaveSummary = async () => {
if (isSavingSummary.value) return
isSavingSummary.value = true
try {
const res = await updateArchiveSummary({
referenceDate: selectedDate.value.toISOString(),
summary: archiveSummary.value
})
if (res.success) {
showToast('已保存')
showSummaryPopup.value = false
}
} catch (err) {
console.error('保存总结失败', err)
showToast('保存总结失败')
} finally {
isSavingSummary.value = false
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -11,7 +11,7 @@ public class BudgetController(
/// 获取预算列表 /// 获取预算列表
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<BaseResponse<List<BudgetResult>>> GetListAsync([FromQuery] DateTime? referenceDate = null) public async Task<BaseResponse<List<BudgetResult>>> GetListAsync([FromQuery] DateTime referenceDate)
{ {
try try
{ {
@@ -30,41 +30,11 @@ public class BudgetController(
} }
} }
/// <summary>
/// 获取单个预算统计信息
/// </summary>
[HttpGet]
public async Task<BaseResponse<BudgetResult>> GetStatisticsAsync([FromQuery] long id, [FromQuery] DateTime referenceDate)
{
try
{
// 参数验证
if (id == 0)
{
return "预算 Id 无效".Fail<BudgetResult>();
}
var result = await budgetService.GetStatisticsAsync(id, referenceDate);
if (result == null)
{
return "预算不存在".Fail<BudgetResult>();
}
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取预算统计失败, Id: {Id}", id);
return $"获取预算统计失败: {ex.Message}".Fail<BudgetResult>();
}
}
/// <summary> /// <summary>
/// 获取分类统计信息(月度和年度) /// 获取分类统计信息(月度和年度)
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<BaseResponse<BudgetCategoryStats>> GetCategoryStatsAsync([FromQuery] BudgetCategory category, [FromQuery] DateTime? referenceDate = null) public async Task<BaseResponse<BudgetCategoryStats>> GetCategoryStatsAsync([FromQuery] BudgetCategory category, [FromQuery] DateTime referenceDate)
{ {
try try
{ {
@@ -96,6 +66,42 @@ public class BudgetController(
} }
} }
/// <summary>
/// 获取归档总结
/// </summary>
[HttpGet]
public async Task<BaseResponse<string?>> GetArchiveSummaryAsync([FromQuery] DateTime referenceDate)
{
try
{
var result = await budgetService.GetArchiveSummaryAsync(referenceDate.Year, referenceDate.Month);
return result.Ok<string?>();
}
catch (Exception ex)
{
logger.LogError(ex, "获取归档总结失败");
return $"获取归档总结失败: {ex.Message}".Fail<string?>();
}
}
/// <summary>
/// 更新归档总结
/// </summary>
[HttpPost]
public async Task<BaseResponse> UpdateArchiveSummaryAsync([FromBody] UpdateArchiveSummaryDto dto)
{
try
{
await budgetService.UpdateArchiveSummaryAsync(dto.ReferenceDate.Year, dto.ReferenceDate.Month, dto.Summary);
return BaseResponse.Done();
}
catch (Exception ex)
{
logger.LogError(ex, "更新归档总结失败");
return $"更新归档总结失败: {ex.Message}".Fail();
}
}
/// <summary> /// <summary>
/// 删除预算 /// 删除预算
/// </summary> /// </summary>
@@ -189,30 +195,6 @@ public class BudgetController(
} }
} }
/// <summary>
/// 归档预算
/// </summary>
[HttpPost("{year}/{month}")]
public async Task<BaseResponse> ArchiveBudgetsAsync(int year, int month)
{
try
{
var msg = await budgetService.ArchiveBudgetsAsync(year, month);
if (!string.IsNullOrEmpty(msg))
{
return msg.Fail();
}
return BaseResponse.Done();
}
catch (Exception ex)
{
logger.LogError(ex, "归档预算失败, 归档日期: {Year}-{Month}", year, month);
return $"归档预算失败: {ex.Message}".Fail();
}
}
private async Task<string> ValidateBudgetSelectedCategoriesAsync(BudgetRecord record) private async Task<string> ValidateBudgetSelectedCategoriesAsync(BudgetRecord record)
{ {
var allBudgets = await budgetRepository.GetAllAsync(); var allBudgets = await budgetRepository.GetAllAsync();

View File

@@ -15,3 +15,9 @@ public class UpdateBudgetDto : CreateBudgetDto
public long Id { get; set; } public long Id { get; set; }
} }
public class UpdateArchiveSummaryDto
{
public DateTime ReferenceDate { get; set; }
public string? Summary { get; set; }
}

View File

@@ -116,6 +116,8 @@ var fsql = new FreeSqlBuilder()
) )
.Build(); .Build();
fsql.UseJsonMap();
builder.Services.AddSingleton(fsql); builder.Services.AddSingleton(fsql);
// 自动扫描注册服务和仓储 // 自动扫描注册服务和仓储

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup> <ItemGroup>
<PackageReference Include="FreeSql.Extensions.JsonMap" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" /> <PackageReference Include="Scalar.AspNetCore" />