新增不记额收支
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 34s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
Docker Build & Deploy / WeChat Notification (push) Successful in 3s

This commit is contained in:
孙诚
2026-01-15 10:53:05 +08:00
parent 12cf1b6323
commit 65f7316c82
9 changed files with 456 additions and 123 deletions

View File

@@ -58,6 +58,11 @@ public record BudgetArchiveContent
/// </summary>
public string[] SelectedCategories { get; set; } = [];
/// <summary>
/// 不记额预算
/// </summary>
public bool NoLimit { get; set; } = false;
/// <summary>
/// 描述说明
/// </summary>

View File

@@ -34,6 +34,11 @@ public class BudgetRecord : BaseEntity
/// 开始日期
/// </summary>
public DateTime StartDate { get; set; } = DateTime.Now;
/// <summary>
/// 不记额预算(选中后该预算没有预算金额,发生的收入或支出直接在存款中加减)
/// </summary>
public bool NoLimit { get; set; } = false;
}
public enum BudgetPeriodType

View File

@@ -54,6 +54,7 @@ public class BudgetService(
Current = c.Actual,
Category = c.Category,
SelectedCategories = c.SelectedCategories,
NoLimit = c.NoLimit,
Description = c.Description,
PeriodStart = periodRange.start,
PeriodEnd = periodRange.end,
@@ -172,9 +173,9 @@ public class BudgetService(
Count = 0
};
// 获取当前分类下所有预算
// 获取当前分类下所有预算,排除不记额预算
var relevant = budgets
.Where(b => b.Category == category)
.Where(b => b.Category == category && !b.NoLimit)
.ToList();
if (relevant.Count == 0)
@@ -249,6 +250,7 @@ public class BudgetService(
Actual = b.Current,
Category = b.Category,
SelectedCategories = b.SelectedCategories,
NoLimit = b.NoLimit,
Description = b.Description
}).ToArray();
@@ -471,9 +473,13 @@ public class BudgetService(
decimal incomeLimitAtPeriod = 0;
decimal expenseLimitAtPeriod = 0;
decimal noLimitIncomeAtPeriod = 0; // 新增:不记额收入汇总
decimal noLimitExpenseAtPeriod = 0; // 新增:不记额支出汇总
var incomeItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
var expenseItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
var noLimitIncomeItems = new List<(string Name, decimal Amount)>(); // 新增
var noLimitExpenseItems = new List<(string Name, decimal Amount)>(); // 新增
foreach (var b in allBudgets)
{
@@ -507,16 +513,36 @@ public class BudgetService(
if (factor <= 0) continue;
var subtotal = b.Limit * factor;
if (b.Category == BudgetCategory.Income)
// 新增:处理不记额预算
if (b.NoLimit)
{
incomeLimitAtPeriod += subtotal;
incomeItems.Add((b.Name, b.Limit, factor, subtotal));
// 不记额预算:计算实际发生的金额
var actualAmount = await CalculateCurrentAmountAsync(b, date);
if (b.Category == BudgetCategory.Income)
{
noLimitIncomeAtPeriod += actualAmount;
noLimitIncomeItems.Add((b.Name, actualAmount));
}
else if (b.Category == BudgetCategory.Expense)
{
noLimitExpenseAtPeriod += actualAmount;
noLimitExpenseItems.Add((b.Name, actualAmount));
}
}
else if (b.Category == BudgetCategory.Expense)
else
{
expenseLimitAtPeriod += subtotal;
expenseItems.Add((b.Name, b.Limit, factor, subtotal));
// 普通预算:按限额计算
var subtotal = b.Limit * factor;
if (b.Category == BudgetCategory.Income)
{
incomeLimitAtPeriod += subtotal;
incomeItems.Add((b.Name, b.Limit, factor, subtotal));
}
else if (b.Category == BudgetCategory.Expense)
{
expenseLimitAtPeriod += subtotal;
expenseItems.Add((b.Name, b.Limit, factor, subtotal));
}
}
}
@@ -552,6 +578,34 @@ public class BudgetService(
}
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
// 新增:显示不记额收入明细
description.Append("<h3>不记额收入明细</h3>");
if (noLimitIncomeItems.Count == 0) description.Append("<p>无不记额收入</p>");
else
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in noLimitIncomeItems)
{
description.Append($"""
<tr>
<td>{item.Name}</td>
<td><span class='income-value'>{item.Amount:N0}</span></td>
</tr>
""");
}
description.Append("</tbody></table>");
}
description.Append($"<p>不记额收入合计: <span class='income-value'><strong>{noLimitIncomeAtPeriod:N0}</strong></span></p>");
description.Append("<h3>预算支出明细</h3>");
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
else
@@ -583,14 +637,46 @@ public class BudgetService(
}
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
// 新增:显示不记额支出明细
description.Append("<h3>不记额支出明细</h3>");
if (noLimitExpenseItems.Count == 0) description.Append("<p>无不记额支出</p>");
else
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in noLimitExpenseItems)
{
description.Append($"""
<tr>
<td>{item.Name}</td>
<td><span class='expense-value'>{item.Amount:N0}</span></td>
</tr>
""");
}
description.Append("</tbody></table>");
}
description.Append($"<p>不记额支出合计: <span class='expense-value'><strong>{noLimitExpenseAtPeriod:N0}</strong></span></p>");
description.Append("<h3>存款计划结论</h3>");
description.Append($"<p>计划存款 = 收入 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> - 支出 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span></p>");
description.Append($"<p>最终目标:<span class='highlight'><strong>{incomeLimitAtPeriod - expenseLimitAtPeriod:N0}</strong></span></p>");
// 修改计算公式:包含不记额收入和支出
var totalIncome = incomeLimitAtPeriod + noLimitIncomeAtPeriod;
var totalExpense = expenseLimitAtPeriod + noLimitExpenseAtPeriod;
description.Append($"<p>计划收入 = 预算 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> + 不记额 <span class='income-value'>{noLimitIncomeAtPeriod:N0}</span> = <span class='income-value'><strong>{totalIncome:N0}</strong></span></p>");
description.Append($"<p>计划支出 = 预算 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span> + 不记额 <span class='expense-value'>{noLimitExpenseAtPeriod:N0}</span> = <span class='expense-value'><strong>{totalExpense:N0}</strong></span></p>");
description.Append($"<p>最终目标:<span class='highlight'><strong>{totalIncome - totalExpense:N0}</strong></span></p>");
var virtualBudget = await BuildVirtualSavingsBudgetRecordAsync(
periodType == BudgetPeriodType.Year ? -1 : -2,
date,
incomeLimitAtPeriod - expenseLimitAtPeriod);
totalIncome - totalExpense); // 修改:使用总金额
// 计算实际发生的 收入 - 支出
var current = await CalculateCurrentAmountAsync(new BudgetRecord
@@ -638,7 +724,7 @@ public record BudgetResult
public string Period { get; set; } = string.Empty;
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
public bool NoLimit { get; set; } = false;
public string Description { get; set; } = string.Empty;
public static BudgetResult FromEntity(
@@ -670,6 +756,7 @@ public record BudgetResult
},
PeriodStart = start,
PeriodEnd = end,
NoLimit = entity.NoLimit,
Description = description
};
}

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +1,26 @@
<template>
<div class="common-card budget-card" @click="toggleExpand">
<!-- eslint-disable vue/no-v-html -->
<template>
<!-- 普通预算卡片 -->
<div v-if="!budget.noLimit" class="common-card budget-card" @click="toggleExpand">
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div v-if="!isExpanded" class="budget-collapsed">
<div class="collapsed-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>
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
({{ budget.selectedCategories.join('') }})
</span>
</div>
<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>
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
({{ budget.selectedCategories.join('') }})
</span>
</div>
<van-icon name="arrow-down" class="expand-icon" />
</div>
@@ -44,95 +46,233 @@
<!-- 展开状态 -->
<div v-else class="budget-inner-card">
<div class="card-header" style="margin-bottom: 0;">
<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" style="max-width: 120px;">{{ budget.name }}</h3>
</div>
<div class="header-actions">
<slot name="actions">
<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" style="max-width: 120px;">{{ budget.name }}</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
icon="edit"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
@click.stop="$emit('click', budget)"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags">
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="amount-info">
<slot name="amount-info"></slot>
</div>
<div class="progress-section">
<slot name="progress-info">
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
<van-progress
:percentage="Math.min(percentage, 100)"
stroke-width="8"
:color="progressColor"
:show-pivot="false"
/>
<span class="percent" :class="percentClass">{{ percentage }}%</span>
</slot>
</div>
<div class="progress-section time-progress">
<span class="period-type">时间进度</span>
<van-progress
:percentage="timePercentage"
stroke-width="4"
color="var(--van-gray-6)"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
<van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description"></div>
</div>
</van-collapse-transition>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags">
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="amount-info">
<slot name="amount-info"></slot>
</div>
<div class="progress-section">
<slot name="progress-info">
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
<van-progress
:percentage="Math.min(percentage, 100)"
stroke-width="8"
:color="progressColor"
:show-pivot="false"
/>
<span class="percent" :class="percentClass">{{ percentage }}%</span>
</slot>
</div>
<div class="progress-section time-progress">
<span class="period-type">时间进度</span>
<van-progress
:percentage="timePercentage"
stroke-width="4"
color="var(--van-gray-6)"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
<van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description"></div>
</div>
</van-collapse-transition>
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
<!-- 不记额预算卡片 -->
<div v-else class="common-card budget-card no-limit-card" @click="toggleExpand">
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div v-if="!isExpanded" class="budget-collapsed">
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title">{{ budget.name }}</h3>
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
({{ budget.selectedCategories.join('') }})
</span>
</div>
<van-icon name="arrow-down" class="expand-icon" />
</div>
<div class="collapsed-footer no-limit-footer">
<div class="collapsed-item">
<span class="compact-label">实际</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{ budget.current !== undefined
? `¥${budget.current?.toFixed(0) || 0}`
: '--' }}
</slot>
</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<div v-else class="budget-inner-card">
<div class="card-header" style="margin-bottom: 0;">
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title" style="max-width: 120px;">{{ budget.name }}</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags">
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="no-limit-amount-info">
<div class="amount-item">
<span>
<span class="label">实际</span>
<span class="value" style="margin-left: 12px;">¥{{ budget.current?.toFixed(0) || 0 }}</span>
</span>
</div>
</div>
<div class="no-limit-notice">
<span>
<van-icon name="info-o" style="margin-right: 4px;" />
不记额预算 - 直接计入存款明细
</span>
</div>
<van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description"></div>
</div>
</van-collapse-transition>
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
@@ -261,6 +401,14 @@ const timePercentage = computed(() => {
cursor: pointer;
}
.no-limit-card {
border-left: 3px solid var(--van-success-color);
}
.collapsed-footer.no-limit-footer {
justify-content: flex-start;
}
.budget-content-wrapper {
position: relative;
width: 100%;
@@ -481,6 +629,39 @@ const timePercentage = computed(() => {
font-size: 11px;
}
.no-limit-notice {
text-align: center;
font-size: 12px;
color: var(--van-text-color-2);
background-color: var(--van-light-gray);
border-radius: 4px;
margin-top: 8px;
}
.no-limit-amount-info {
display: flex;
justify-content: center;
margin: 0px 0;
}
.amount-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.amount-item .label {
font-size: 12px;
color: var(--van-text-color-2);
}
.amount-item .value {
font-size: 20px;
font-weight: 600;
color: var(--van-success-color);
}
.budget-description {
margin-top: 8px;
background-color: var(--van-background);

View File

@@ -14,19 +14,27 @@
placeholder="例如:每月餐饮、年度奖金"
:rules="[{ required: true, message: '请填写预算名称' }]"
/>
<!-- 新增不记额预算复选框 -->
<van-field label="不记额预算">
<template #input>
<van-checkbox v-model="form.noLimit" @update:model-value="onNoLimitChange">不记额预算仅限年度</van-checkbox>
</template>
</van-field>
<van-field name="type" label="统计周期">
<template #input>
<van-radio-group
v-model="form.type"
direction="horizontal"
:disabled="isEdit"
:disabled="isEdit || form.noLimit"
>
<van-radio :name="BudgetPeriodType.Month"></van-radio>
<van-radio :name="BudgetPeriodType.Year"></van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 仅当未选中"不记额预算"时显示预算金额 -->
<van-field
v-if="!form.noLimit"
v-model="form.limit"
type="number"
name="limit"
@@ -83,7 +91,8 @@ const form = reactive({
type: BudgetPeriodType.Month,
category: BudgetCategory.Expense,
limit: '',
selectedCategories: []
selectedCategories: [],
noLimit: false // 新增字段
})
const open = ({
@@ -104,7 +113,8 @@ const open = ({
type: data.type,
category: category,
limit: data.limit,
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : []
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : [],
noLimit: data.noLimit || false // 新增
})
} else {
Object.assign(form, {
@@ -113,7 +123,8 @@ const open = ({
type: BudgetPeriodType.Month,
category: category,
limit: '',
selectedCategories: []
selectedCategories: [],
noLimit: false // 新增
})
}
visible.value = true
@@ -131,8 +142,9 @@ const onSubmit = async () => {
try {
const data = {
...form,
limit: parseFloat(form.limit),
selectedCategories: form.selectedCategories
limit: form.noLimit ? 0 : parseFloat(form.limit), // 不记额时金额为0
selectedCategories: form.selectedCategories,
noLimit: form.noLimit // 新增
}
const res = form.id ? await updateBudget(data) : await createBudget(data)
@@ -159,6 +171,13 @@ const getCategoryName = (category) => {
return ''
}
}
const onNoLimitChange = (value) => {
if (value) {
// 选中不记额时,自动设为年度预算
form.type = BudgetPeriodType.Year
}
}
</script>
<style scoped>

View File

@@ -465,6 +465,28 @@ const handleSaveSummary = async () => {
isSavingSummary.value = false
}
}
const handleDelete = async (budget) => {
try {
await showConfirmDialog({
title: '删除预算',
message: `确定要删除预算 "${budget.name}" `
})
const res = await deleteBudget(budget.id)
if (res.success) {
showToast('删除成功')
await fetchBudgetList()
} else {
showToast(res.message || '删除失败')
}
} catch (err) {
if (err.message !== 'cancel') {
console.error('删除预算失败', err)
showToast('删除预算失败')
}
}
}
</script>
<style scoped>

View File

@@ -128,14 +128,18 @@ public class BudgetController(
{
try
{
// 不记额预算的金额强制设为0
var limit = dto.NoLimit ? 0 : dto.Limit;
var budget = new BudgetRecord
{
Name = dto.Name,
Type = dto.Type,
Limit = dto.Limit,
Limit = limit,
Category = dto.Category,
SelectedCategories = dto.SelectedCategories != null ? string.Join(",", dto.SelectedCategories) : string.Empty,
StartDate = dto.StartDate ?? DateTime.Now
StartDate = dto.StartDate ?? DateTime.Now,
NoLimit = dto.NoLimit
};
var varidationError = await ValidateBudgetSelectedCategoriesAsync(budget);
@@ -169,11 +173,15 @@ public class BudgetController(
var budget = await budgetRepository.GetByIdAsync(dto.Id);
if (budget == null) return "预算不存在".Fail();
// 不记额预算的金额强制设为0
var limit = dto.NoLimit ? 0 : dto.Limit;
budget.Name = dto.Name;
budget.Type = dto.Type;
budget.Limit = dto.Limit;
budget.Limit = limit;
budget.Category = dto.Category;
budget.SelectedCategories = dto.SelectedCategories != null ? string.Join(",", dto.SelectedCategories) : string.Empty;
budget.NoLimit = dto.NoLimit;
if (dto.StartDate.HasValue)
{
budget.StartDate = dto.StartDate.Value;
@@ -197,6 +205,12 @@ public class BudgetController(
private async Task<string> ValidateBudgetSelectedCategoriesAsync(BudgetRecord record)
{
// 验证不记额预算必须是年度预算
if (record.NoLimit && record.Type != BudgetPeriodType.Year)
{
return "不记额预算只能设置为年度预算。";
}
var allBudgets = await budgetRepository.GetAllAsync();
var recordSelectedCategories = record.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);

View File

@@ -8,6 +8,7 @@ public class CreateBudgetDto
public BudgetCategory Category { get; set; }
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
public DateTime? StartDate { get; set; }
public bool NoLimit { get; set; } = false;
}
public class UpdateBudgetDto : CreateBudgetDto