feat: 优化预算管理界面,增强预算编辑功能,添加预算删除接口
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
孙诚
2026-01-07 19:19:53 +08:00
parent 851886a601
commit 35a856c6e3
5 changed files with 267 additions and 134 deletions

View File

@@ -1,5 +1,8 @@
<template>
<div class="common-card budget-card" @click="$emit('click')">
<div class="budget-content-wrapper">
<Transition :name="transitionName">
<div :key="budget.period" class="budget-inner-card">
<div class="card-header">
<div class="budget-info">
<h3 class="card-title">{{ budget.name }}</h3>
@@ -27,20 +30,26 @@
</div>
<div class="progress-section">
<div class="progress-info">
<slot name="progress-info">
<span class="period-type">{{ periodLabel }}进度</span>
<span class="percent" :class="percentClass">
{{ percentage }}%
</span>
</slot>
</div>
<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 v-if="budget.type !== BudgetPeriodType.Longterm" class="progress-section time-progress">
<span class="period-type">时间进度</span>
<van-progress
:percentage="timePercentage"
stroke-width="4"
color="#969799"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
</div>
@@ -52,7 +61,7 @@
plain
size="small"
style="width: 50px;"
@click="$emit('switch-period', -1)"
@click="handleSwitch(-1)"
/>
<span class="period-text">{{ budget.period }}</span>
<van-button
@@ -61,15 +70,19 @@
plain
size="small"
style="width: 50px;"
@click="$emit('switch-period', 1)"
@click="handleSwitch(1)"
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
const props = defineProps({
budget: {
@@ -98,12 +111,31 @@ const props = defineProps({
}
})
defineEmits(['toggle-stop', 'switch-period', 'click'])
const emit = defineEmits(['toggle-stop', 'switch-period', 'click'])
const transitionName = ref('slide-left')
const handleSwitch = (direction) => {
transitionName.value = direction > 0 ? 'slide-left' : 'slide-right'
emit('switch-period', direction)
}
const percentage = computed(() => {
if (!props.budget.limit) return 0
return Math.round((props.budget.current / props.budget.limit) * 100)
})
const timePercentage = computed(() => {
if (!props.budget.periodStart || !props.budget.periodEnd || props.budget.type === BudgetPeriodType.Longterm) return 0
const start = new Date(props.budget.periodStart).getTime()
const end = new Date(props.budget.periodEnd).getTime()
const now = new Date().getTime()
if (now <= start) return 0
if (now >= end) return 100
return Math.round(((now - start) / (end - start)) * 100)
})
</script>
<style scoped>
@@ -112,6 +144,51 @@ const percentage = computed(() => {
margin-right: 0;
margin-bottom: 0;
padding-bottom: 8px;
overflow: hidden;
position: relative;
}
.budget-content-wrapper {
position: relative;
width: 100%;
}
.budget-inner-card {
width: 100%;
}
/* 切换动画 */
.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 {
@@ -134,14 +211,14 @@ const percentage = computed(() => {
.amount-info {
display: flex;
justify-content: space-between;
margin: 16px 0;
margin: 12px 0;
text-align: center;
}
:deep(.info-item) .label {
font-size: 12px;
color: #969799;
margin-bottom: 4px;
margin-bottom: 2px;
}
:deep(.info-item) .value {
@@ -158,15 +235,27 @@ const percentage = computed(() => {
}
.progress-section {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
color: #646566;
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 6px;
color: #646566;
.progress-section :deep(.van-progress) {
flex: 1;
}
.period-type {
white-space: nowrap;
width: 65px;
}
.percent {
white-space: nowrap;
width: 35px;
text-align: right;
}
.percent.warning {
@@ -179,6 +268,16 @@ const percentage = computed(() => {
font-weight: bold;
}
.time-progress {
margin-top: -8px;
opacity: 0.8;
}
.time-progress .period-type,
.time-progress .percent {
font-size: 11px;
}
.card-footer {
display: flex;
justify-content: space-between;

View File

@@ -1,8 +1,8 @@
<template>
<PopupContainer
v-model="visible"
:title="isEdit ? '编辑预算' : '新增预算'"
height="70%"
:title="isEdit ? `编辑${getCategoryName(form.category)}预算` : `新增${getCategoryName(form.category)}预算`"
height="85%"
>
<div class="add-budget-form">
<van-form>
@@ -20,7 +20,6 @@
<van-radio :name="BudgetPeriodType.Week"></van-radio>
<van-radio :name="BudgetPeriodType.Month"></van-radio>
<van-radio :name="BudgetPeriodType.Year"></van-radio>
<van-radio :name="BudgetPeriodType.Longterm">长期</van-radio>
</van-radio-group>
</template>
</van-field>
@@ -36,15 +35,6 @@
<span></span>
</template>
</van-field>
<van-field name="category" label="类型">
<template #input>
<van-radio-group v-model="form.category" direction="horizontal" :disabled="isEdit">
<van-radio :name="BudgetCategory.Expense">支出</van-radio>
<van-radio :name="BudgetCategory.Income">收入</van-radio>
<van-radio :name="BudgetCategory.Savings">存款</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field label="相关分类">
<template #input>
<div v-if="form.selectedCategories.length === 0" style="color: #c8c9cc;">可多选分类</div>
@@ -95,17 +85,10 @@ import { createBudget, updateBudget } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
const props = defineProps({
editData: {
type: Object,
default: null
}
})
const emit = defineEmits(['success'])
const visible = ref(false)
const isEdit = computed(() => !!props.editData)
const isEdit = ref(false)
const categories = ref([])
const form = reactive({
@@ -117,13 +100,23 @@ const form = reactive({
selectedCategories: []
})
const open = (data = null) => {
const open = ({
data,
isEditFlag,
category
}) => {
if(category === undefined) {
showToast('缺少必要参数category')
return
}
isEdit.value = isEditFlag
if (data) {
Object.assign(form, {
id: data.id,
name: data.name,
type: data.type,
category: data.category,
category: category,
limit: data.limit,
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : []
})
@@ -132,7 +125,7 @@ const open = (data = null) => {
id: undefined,
name: '',
type: BudgetPeriodType.Month,
category: BudgetCategory.Expense,
category: category,
limit: '',
selectedCategories: []
})
@@ -199,18 +192,30 @@ const onSubmit = async () => {
}
const res = form.id ? await updateBudget(data) : await createBudget(data)
if (res.success) {
showToast('保存成功')
visible.value = false
emit('success')
}
} catch (err) {
showToast('保存失败')
showToast(err.message || '保存失败')
console.error('保存预算失败', err)
}
}
const getCategoryName = (category) => {
switch(category) {
case BudgetCategory.Expense:
return '支出'
case BudgetCategory.Income:
return '收入'
case BudgetCategory.Savings:
return '存款'
default:
return ''
}
}
onMounted(() => {
fetchCategories()
})
@@ -243,7 +248,6 @@ onMounted(() => {
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
max-height: 200px;
overflow-y: auto;
}

View File

@@ -2,7 +2,7 @@
<div class="page-container-flex">
<van-nav-bar title="预算管理" placeholder>
<template #right>
<van-icon name="plus" size="20" @click="budgetEditRef.open()" />
<van-icon name="plus" size="20" @click="budgetEditRef.open({ category: activeTab })" />
</template>
</van-nav-bar>
@@ -25,7 +25,11 @@
:period-label="getPeriodLabel(budget.type)"
@toggle-stop="handleToggleStop"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open(budget)"
@click="budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})"
>
<template #amount-info>
<div class="info-item">
@@ -88,12 +92,6 @@
</div>
</div>
</template>
<template #progress-info>
<span class="period-type">{{ getPeriodLabel(budget.type) }}达成度</span>
<span class="percent" :class="{ 'income': (budget.current / budget.limit) >= 1 }">
{{ Math.round((budget.current / budget.limit) * 100) }}%
</span>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
@@ -117,6 +115,7 @@
:budget="budget"
progress-color="#07c160"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
status-tag-text="积累中"
@toggle-stop="handleToggleStop"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@@ -138,12 +137,6 @@
</div>
</div>
</template>
<template #progress-info>
<span class="period-type">储蓄进度</span>
<span class="percent" :class="{ 'income': (budget.current / budget.limit) >= 1 }">
{{ Math.round((budget.current / budget.limit) * 100) }}%
</span>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
@@ -168,7 +161,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import { getBudgetList, deleteBudget, toggleStopBudget, getBudgetStatistics } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'

View File

@@ -53,6 +53,24 @@ public class BudgetController(
}
}
/// <summary>
/// 删除预算
/// </summary>
[HttpDelete("{id}")]
public async Task<BaseResponse> DeleteByIdAsync(long id)
{
try
{
var success = await budgetService.DeleteAsync(id);
return success ? BaseResponse.Done() : "删除预算失败".Fail();
}
catch (Exception ex)
{
logger.LogError(ex, "删除预算失败, Id: {Id}", id);
return $"删除预算失败: {ex.Message}".Fail();
}
}
/// <summary>
/// 创建预算
/// </summary>
@@ -71,6 +89,12 @@ public class BudgetController(
StartDate = dto.StartDate ?? DateTime.Now
};
var varidationError = await ValidateBudgetSelectedCategoriesAsync(budget);
if (!string.IsNullOrEmpty(varidationError))
{
return varidationError.Fail<long>();
}
var success = await budgetService.AddAsync(budget);
if (success)
{
@@ -85,24 +109,6 @@ public class BudgetController(
}
}
/// <summary>
/// 删除预算
/// </summary>
[HttpDelete("{id}")]
public async Task<BaseResponse> DeleteByIdAsync(long id)
{
try
{
var success = await budgetService.DeleteAsync(id);
return success ? BaseResponse.Done() : "删除预算失败".Fail();
}
catch (Exception ex)
{
logger.LogError(ex, "删除预算失败, Id: {Id}", id);
return $"删除预算失败: {ex.Message}".Fail();
}
}
/// <summary>
/// 更新预算
/// </summary>
@@ -125,6 +131,12 @@ public class BudgetController(
budget.StartDate = dto.StartDate.Value;
}
var varidationError = await ValidateBudgetSelectedCategoriesAsync(budget);
if (!string.IsNullOrEmpty(varidationError))
{
return varidationError.Fail();
}
var success = await budgetService.UpdateAsync(budget);
return success ? BaseResponse.Done() : "更新预算失败".Fail();
}
@@ -135,6 +147,27 @@ public class BudgetController(
}
}
private async Task<string> ValidateBudgetSelectedCategoriesAsync(BudgetRecord record)
{
var allBudgets = await budgetService.GetAllAsync();
var recordSelectedCategories = record.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var budget in allBudgets)
{
var selectedCategories = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (budget.Id != record.Id)
{
if (budget.Category == record.Category &&
recordSelectedCategories.Intersect(selectedCategories).Any())
{
return $"和 {budget.Name} 存在分类冲突,请调整相关分类。";
}
}
}
return string.Empty;
}
/// <summary>
/// 切换预算暂停状态
/// </summary>

View File

@@ -12,6 +12,8 @@ public class BudgetDto
public bool IsStopped { get; set; }
public string StartDate { get; set; } = string.Empty;
public string Period { get; set; } = string.Empty;
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
public static BudgetDto FromEntity(BudgetRecord entity, decimal currentAmount = 0, DateTime? referenceDate = null)
{
@@ -38,7 +40,9 @@ public class BudgetDto
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
BudgetPeriodType.Week => $"{start:yy}年第{System.Globalization.CultureInfo.CurrentCulture.Calendar.GetWeekOfYear(start, System.Globalization.CalendarWeekRule.FirstDay, DayOfWeek.Monday)}周",
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
}
},
PeriodStart = start,
PeriodEnd = end
};
}
}