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
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user