Files
EmailBill/Web/src/views/BudgetView.vue
孙诚 69298c2ffa
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 12s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
色彩调整
2026-01-15 17:12:27 +08:00

641 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-container-flex">
<van-nav-bar title="预算管理" placeholder>
<template #right>
<van-icon
v-if="activeTab !== BudgetCategory.Savings
&& uncoveredCategories.length > 0
&& !isArchive"
name="warning-o"
size="20"
color="var(--van-danger-color)"
style="margin-right: 12px"
title="查看未覆盖预算的分类"
@click="showUncoveredDetails = true"
/>
<van-icon
v-if="isArchive"
name="records-o"
size="20"
title="已归档月份总结"
style="margin-right: 12px"
@click="showArchiveSummary()"
/>
<van-icon
v-if="activeTab !== BudgetCategory.Savings"
name="plus"
size="20"
title="添加预算"
@click="budgetEditRef.open({ category: activeTab })"
/>
<van-icon
v-else
name="setting-o"
size="20"
title="储蓄分类配置"
@click="savingsConfigRef.open()"
/>
</template>
</van-nav-bar>
<van-tabs v-model:active="activeTab" type="card" class="budget-tabs" style="margin: 12px 4px;">
<van-tab title="支出" :name="BudgetCategory.Expense">
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh">
<div class="budget-list">
<template v-if="expenseBudgets?.length > 0">
<van-swipe-cell v-for="budget in expenseBudgets" :key="budget.id">
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ 'warning': (budget.current / budget.limit) > 0.8 }"
:period-label="getPeriodLabel(budget.type)"
@click="budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})"
>
<template #amount-info>
<div class="info-item">
<div class="label">已支出</div>
<div class="value expense">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">预算</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">余额</div>
<div class="value" :class="budget.limit - budget.current >= 0 ? 'income' : 'expense'">
¥{{ formatMoney(budget.limit - budget.current) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
</template>
</van-swipe-cell>
</template>
<van-empty v-else description="暂无支出预算" />
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
</van-tab>
<van-tab title="收入" :name="BudgetCategory.Income">
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh">
<div class="budget-list">
<template v-if="incomeBudgets?.length > 0">
<van-swipe-cell v-for="budget in incomeBudgets" :key="budget.id">
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
@click="budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})"
>
<template #amount-info>
<div class="info-item">
<div class="label">已收入</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">目标</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">差额</div>
<div class="value" :class="budget.current >= budget.limit ? 'income' : 'expense'">
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
</template>
</van-swipe-cell>
</template>
<van-empty v-else description="暂无收入预算" />
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
</van-tab>
<van-tab title="存款" :name="BudgetCategory.Savings">
<van-pull-refresh v-model="isRefreshing" class="scroll-content" style="padding-top:4px" @refresh="onRefresh">
<div class="budget-list">
<template v-if="savingsBudgets?.length > 0">
<BudgetCard
v-for="budget in savingsBudgets"
:key="budget.id"
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
style="margin: 0 12px 12px;"
>
<template #amount-info>
<div class="info-item">
<div class="label">已存</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">目标</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">还差</div>
<div class="value expense">
¥{{ formatMoney(Math.max(0, budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
</template>
<van-empty v-else description="暂无存款计划" />
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
</van-tab>
</van-tabs>
<BudgetEditPopup
ref="budgetEditRef"
@success="fetchBudgetList"
/>
<SavingsConfigPopup
ref="savingsConfigRef"
@success="fetchBudgetList"
/>
<PopupContainer
v-model="showUncoveredDetails"
title="未覆盖预算的分类"
:subtitle="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
height="60%"
>
<div class="uncovered-list">
<div v-for="item in uncoveredCategories" :key="item.category" class="uncovered-item">
<div class="item-left">
<div class="category-name">{{ item.category }}</div>
<div class="transaction-count">{{ item.transactionCount }} 笔记录</div>
</div>
<div class="item-right">
<div class="item-amount" :class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'">
¥{{ formatMoney(item.totalAmount) }}
</div>
</div>
</div>
</div>
<template #footer>
<van-button block round type="primary" @click="showUncoveredDetails = false">
我知道了
</van-button>
</template>
</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>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import { getBudgetList, deleteBudget, getCategoryStats, getUncoveredCategories, getArchiveSummary, updateArchiveSummary } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import PopupContainer from '@/components/PopupContainer.vue'
const activeTab = ref(BudgetCategory.Expense)
const selectedDate = ref(new Date())
const budgetEditRef = ref(null)
const savingsConfigRef = ref(null)
const isRefreshing = ref(false)
const showUncoveredDetails = ref(false)
const uncoveredCategories = ref([])
const showSummaryPopup = ref(false)
const archiveSummary = ref('')
const isSavingSummary = ref(false)
const expenseBudgets = ref([])
const incomeBudgets = ref([])
const savingsBudgets = ref([])
const overallStats = ref({
month: { rate: '0.0', current: 0, limit: 0, count: 0 },
year: { rate: '0.0', current: 0, limit: 0, count: 0 }
})
const activeTabTitle = computed(() => {
if (activeTab.value === BudgetCategory.Expense) 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 () => {
await Promise.all([fetchCategoryStats(), fetchUncoveredCategories()])
})
watch(selectedDate, async () => {
await Promise.all([
fetchBudgetList(),
fetchCategoryStats(),
fetchUncoveredCategories()
])
})
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 () => {
try {
const res = await getBudgetList(selectedDate.value.toISOString())
if (res.success) {
const data = res.data || []
expenseBudgets.value = data.filter(b => b.category === BudgetCategory.Expense)
incomeBudgets.value = data.filter(b => b.category === BudgetCategory.Income)
savingsBudgets.value = data.filter(b => b.category === BudgetCategory.Savings)
}
} catch (err) {
console.error('加载预算列表失败', err)
}
}
const onRefresh = async () => {
try {
await Promise.all([fetchBudgetList(), fetchCategoryStats(), fetchUncoveredCategories()])
} catch (err) {
console.error('刷新失败', err)
} finally {
isRefreshing.value = false
}
}
const fetchCategoryStats = async () => {
try {
const res = await getCategoryStats(activeTab.value, selectedDate.value.toISOString())
if (res.success) {
// 转换后端返回的数据格式为前端需要的格式
const data = res.data
overallStats.value = {
month: {
rate: data.month?.rate?.toFixed(1) || '0.0',
current: data.month?.current || 0,
limit: data.month?.limit || 0,
count: data.month?.count || 0
},
year: {
rate: data.year?.rate?.toFixed(1) || '0.0',
current: data.year?.current || 0,
limit: data.year?.limit || 0,
count: data.year?.count || 0
}
}
}
} catch (err) {
console.error('加载分类统计失败', err)
}
}
const fetchUncoveredCategories = async () => {
if (activeTab.value === BudgetCategory.Savings) {
uncoveredCategories.value = []
return
}
try {
const res = await getUncoveredCategories(activeTab.value, selectedDate.value.toISOString())
if (res.success) {
uncoveredCategories.value = res.data || []
}
} catch (err) {
console.error('获取未覆盖分类失败', err)
}
}
onMounted(async () => {
try {
await Promise.all([
fetchBudgetList(),
fetchCategoryStats(),
fetchUncoveredCategories()
])
} catch (err) {
console.error('获取初始化数据失败', err)
}
})
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
const getPeriodLabel = (type) => {
const isCurrent = (date) => {
const now = new Date()
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()
}
const isCurrentYear = (date) => {
const now = new Date()
return date.getFullYear() === now.getFullYear()
}
if (type === BudgetPeriodType.Month) {
return isCurrent(selectedDate.value) ? '本月' : `${selectedDate.value.getMonth() + 1}月`
}
if (type === BudgetPeriodType.Year) {
return isCurrentYear(selectedDate.value) ? '本年' : `${selectedDate.value.getFullYear()}年`
}
return '周期'
}
const getProgressColor = (budget) => {
if (!budget.limit || budget.limit === 0) return 'var(--van-primary-color)'
const ratio = Math.min(Math.max(budget.current / budget.limit, 0), 1)
// 颜色插值辅助函数
const interpolate = (start, end, t) => {
return Math.round(start + (end - start) * t)
}
// 多段颜色渐变计算
const getGradientColor = (value, stops) => {
// 找到当前值所在的区间
let startStop = stops[0]
let endStop = stops[stops.length - 1]
for (let i = 0; i < stops.length - 1; i++) {
if (value >= stops[i].p && value <= stops[i + 1].p) {
startStop = stops[i]
endStop = stops[i + 1]
break
}
}
// 计算区间内的相对比例
const range = endStop.p - startStop.p
const t = (value - startStop.p) / range
const r = interpolate(startStop.c.r, endStop.c.r, t)
const g = interpolate(startStop.c.g, endStop.c.g, t)
const b = interpolate(startStop.c.b, endStop.c.b, t)
return `rgb(${r}, ${g}, ${b})`
}
let stops
if (budget.category === BudgetCategory.Expense) {
// 支出: 这是一个"安全 -> 警示 -> 危险"的过程
// 使用 蓝绿色 -> 黄色 -> 橙红色的渐变,更加自然且具有高级感
stops = [
{ p: 0, c: { r: 64, g: 169, b: 255 } }, // 0% 清新的蓝色 (Safe/Fresh)
{ p: 0.4, c: { r: 54, g: 207, b: 201 } }, // 40% 青色过渡
{ p: 0.7, c: { r: 250, g: 173, b: 20 } }, // 70% 温暖的黄色 (Warning)
{ p: 1, c: { r: 255, g: 77, b: 79 } } // 100% 柔和的红色 (Danger)
]
} else {
// 收入/存款: 这是一个"开始 -> 积累 -> 达成"的过程
// 采用 红色(未开始) -> 橙色(进行中) -> 绿色(达成) 的经典逻辑,但调整了色值使其更现代
// stops = [
// { p: 0, c: { r: 255, g: 120, b: 117 } }, // 0% 淡红 (Start)
// { p: 0.3, c: { r: 255, g: 197, b: 61 } }, // 30% 金黄 (Progress)
// { p: 0.7, c: { r: 115, g: 209, b: 61 } }, // 70% 草绿 (Good)
// { p: 1, c: { r: 35, g: 120, b: 4 } } // 100% 深绿 (Excellent)
// ]
// 如果用户喜欢"红->蓝"的逻辑,可以尝试这种"红->白->蓝"的冷暖过渡:
stops = [
{ p: 0, c: { r: 245, g: 34, b: 45 } }, // 深红
{ p: 0.45, c: { r: 255, g: 204, b: 204 } }, // 浅红
{ p: 0.5, c: { r: 240, g: 242, b: 245 } }, // 中性灰白
{ p: 0.55, c: { r: 186, g: 231, b: 255 } }, // 浅蓝
{ p: 1, c: { r: 24, g: 144, b: 255 } } // 深蓝
]
}
return getGradientColor(ratio, stops)
}
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
}
}
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>
.budget-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin-top: 12px;
min-height: 0;
}
:deep(.van-tabs__content) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.van-tab__panel) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.budget-list {
padding-top: 8px;
padding-bottom: 20px;
}
.budget-list :deep(.van-swipe-cell) {
margin: 0 12px 12px;
}
.scroll-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.delete-button {
height: 100%;
}
:deep(.van-tabs__nav--card) {
margin: 0 12px;
}
.uncovered-list {
padding: 12px 16px;
}
.uncovered-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background-color: var(--van-background-2);
border-radius: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.item-left {
display: flex;
flex-direction: column;
}
.category-name {
font-size: 16px;
font-weight: 500;
color: var(--van-text-color);
margin-bottom: 4px;
}
.transaction-count {
font-size: 12px;
color: var(--van-text-color-2);
}
.item-right {
text-align: right;
}
.item-amount {
font-size: 18px;
font-weight: 600;
font-family: DIN Alternate, system-ui;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>