Files
EmailBill/Web/src/views/budgetV2/Index.vue
SunCheng 4cc205fc25 feat(budget): 实现存款明细计算核心逻辑
- 添加 BudgetItemCalculator 辅助类,实现明细项计算规则
  - 收入:实际>0取实际,否则取预算
  - 支出:取MAX(预算, 实际)
  - 硬性支出未发生:按天数折算
  - 归档数据:直接使用实际值

- 实现月度和年度存款核心公式
  - 月度:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出
  - 年度:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算

- 定义存款明细数据结构
  - SavingsDetail: 包含收入/支出明细列表和汇总
  - BudgetDetailItem: 预算明细项(含计算用金额、计算说明等)
  - SavingsCalculationSummary: 计算汇总信息

- 新增单元测试
  - BudgetItemCalculatorTest: 11个测试覆盖所有计算规则
  - BudgetSavingsCalculationTest: 6个测试验证核心公式

测试结果:所有测试通过 (366 passed, 0 failed)
2026-02-20 16:26:04 +08:00

886 lines
23 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.
<!-- eslint-disable vue/no-v-html -->
<template>
<van-config-provider
:theme="theme"
class="config-provider-full-height"
>
<div class="page-container-flex budget-v2-wrapper">
<!-- 页头DateSelectHeader -->
<DateSelectHeader
type="month"
:current-date="currentDate"
@prev="handlePrevMonth"
@next="handleNextMonth"
@jump="showDatePicker = true"
>
<template #right>
<!-- 未覆盖分类警告图标支出和收入 tab -->
<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="comment-o"
size="20"
title="已归档月份总结"
style="margin-right: 12px"
@click="showArchiveSummary()"
/>
<!-- 储蓄配置图标(存款计划 tab -->
<van-icon
v-if="activeTab === BudgetCategory.Savings"
name="setting-o"
size="20"
title="储蓄分类配置"
@click="savingsConfigRef.open()"
/>
</template>
</DateSelectHeader>
<!-- 业务类型选择器 -->
<div>
<BudgetTypeTabs
:active-tab="activeTab"
@change="handleTabChange"
/>
</div>
<!-- 可滚动内容区域 -->
<div class="budget-scroll-content">
<!-- 下拉刷新 -->
<van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载状态 -->
<van-loading
v-if="loading"
vertical
style="padding: 100px 0"
>
加载预算数据中...
</van-loading>
<!-- 错误状态 -->
<div
v-else-if="hasError"
class="error-state"
>
<van-empty
image="error"
:description="errorMessage || '加载数据时出现错误'"
>
<van-button
type="primary"
size="small"
@click="retryLoad"
>
重试
</van-button>
</van-empty>
</div>
<!-- 预算内容 -->
<div
v-else
class="budget-content"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 支出内容 -->
<ExpenseBudgetContent
v-if="activeTab === BudgetCategory.Expense"
:budgets="expenseBudgets"
:stats="overallStats"
:uncovered-categories="uncoveredCategories"
:selected-date="selectedDate"
@delete="handleDelete"
@edit="handleEdit"
/>
<!-- 收入内容 -->
<IncomeBudgetContent
v-else-if="activeTab === BudgetCategory.Income"
:budgets="incomeBudgets"
:stats="overallStats"
:uncovered-categories="uncoveredCategories"
:selected-date="selectedDate"
@delete="handleDelete"
@edit="handleEdit"
/>
<!-- 计划内容 -->
<SavingsBudgetContent
v-else-if="activeTab === BudgetCategory.Savings"
:budgets="savingsBudgets"
:income-budgets="incomeBudgets"
:expense-budgets="expenseBudgets"
@savings-nav="handleSavingsNav"
/>
</div>
<!-- 底部安全距离 -->
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh>
</div>
<!-- 悬浮编辑按钮(支出和收入 tab -->
<van-floating-bubble
v-if="activeTab !== BudgetCategory.Savings"
v-model:offset="bubbleOffset"
icon="edit"
axis="xy"
magnetic="x"
@click="showListPopup = true"
/>
<!-- 预算编辑弹窗 -->
<BudgetEditPopup
ref="budgetEditRef"
@success="loadBudgetData"
/>
<!-- 储蓄配置弹窗 -->
<SavingsConfigPopup
ref="savingsConfigRef"
@change="loadBudgetData"
/>
<!-- 未覆盖分类弹窗 -->
<PopupContainerV2
v-model:show="showUncoveredDetails"
title="未覆盖预算的分类"
:height="'60%'"
>
<div style="padding: 0">
<!-- subtitle 作为内容区域顶部 -->
<div
style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)"
v-html="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
/>
<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>
</div>
<template #footer>
<van-button
block
round
type="primary"
@click="showUncoveredDetails = false"
>
我知道了
</van-button>
</template>
</PopupContainerV2>
<!-- 归档总结弹窗 -->
<PopupContainerV2
v-model:show="showSummaryPopup"
title="月份归档总结"
:height="'70%'"
>
<div style="padding: 0">
<!-- subtitle -->
<div style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)">
{{ selectedDate.getFullYear() }}年{{ selectedDate.getMonth() + 1 }}月
</div>
<div style="padding: 16px">
<div
class="rich-html-content"
v-html="
archiveSummary ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无总结</p>'
"
/>
</div>
</div>
</PopupContainerV2>
<!-- 日期选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-date-picker
v-model="pickerDate"
title="选择年月"
:min-date="minDate"
:max-date="maxDate"
:columns-type="['year', 'month']"
@confirm="onDatePickerConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
</div>
</van-config-provider>
</template>
<script setup>
import { ref, computed, onMounted, watch, onBeforeUnmount, onActivated } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import dayjs from 'dayjs'
import {
getBudgetList,
deleteBudget,
getCategoryStats,
getUncoveredCategories,
getArchiveSummary,
getSavingsBudget
} from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import DateSelectHeader from '@/components/DateSelectHeader.vue'
import BudgetTypeTabs from '@/components/BudgetTypeTabs.vue'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ExpenseBudgetContent from './modules/ExpenseBudgetContent.vue'
import IncomeBudgetContent from './modules/IncomeBudgetContent.vue'
import SavingsBudgetContent from './modules/SavingsBudgetContent.vue'
import { useMessageStore } from '@/stores/message'
// 为组件缓存设置名称
defineOptions({
name: 'BudgetV2View'
})
const messageStore = useMessageStore()
const theme = computed(() => (messageStore.isDarkMode ? 'dark' : 'light'))
// 日期状态
const currentDate = ref(new Date())
const selectedDate = ref(new Date())
const showDatePicker = ref(false)
const minDate = new Date(2020, 0, 1)
const maxDate = new Date()
const pickerDate = ref([
selectedDate.value.getFullYear().toString(),
(selectedDate.value.getMonth() + 1).toString().padStart(2, '0')
])
// 业务 tab 状态
const activeTab = ref(BudgetCategory.Expense)
// 数据状态
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 }
})
// UI 状态
const loading = ref(false)
const refreshing = ref(false)
const hasError = ref(false)
const errorMessage = ref('')
// 未覆盖分类状态
const uncoveredCategories = ref([])
const showUncoveredDetails = ref(false)
// 归档总结状态
const showSummaryPopup = ref(false)
const archiveSummary = ref('')
// 弹窗状态
const showListPopup = ref(false)
const budgetEditRef = ref(null)
const savingsConfigRef = ref(null)
// 悬浮按钮位置
const bubbleOffset = ref({
x: window.innerWidth - 48 - 24,
y: window.innerHeight - 48 - 100
})
// 触摸手势状态(用于左右滑动切换月份)
const touchStartX = ref(0)
const touchStartY = ref(0)
const touchEndX = ref(0)
const touchEndY = ref(0)
// 计算属性
const isArchive = computed(() => {
const now = new Date()
return (
selectedDate.value.getFullYear() < now.getFullYear() ||
(selectedDate.value.getFullYear() === now.getFullYear() &&
selectedDate.value.getMonth() < now.getMonth())
)
})
const popupTitle = computed(() => {
switch (activeTab.value) {
case BudgetCategory.Expense:
return '支出预算明细'
case BudgetCategory.Income:
return '收入预算明细'
default:
return '预算明细'
}
})
// 监听 activeTab 变化,触发数据刷新
watch(activeTab, async () => {
await Promise.allSettled([loadCategoryStats(), loadUncoveredCategories()])
})
// Tab 切换处理
const handleTabChange = (tab) => {
activeTab.value = tab
}
// 监听 selectedDate 变化,触发数据刷新
watch(selectedDate, async () => {
await loadBudgetData()
})
// 月份切换逻辑
const handlePrevMonth = async () => {
const newDate = new Date(currentDate.value)
newDate.setMonth(newDate.getMonth() - 1)
currentDate.value = newDate
selectedDate.value = newDate
}
const handleNextMonth = async () => {
if (isCurrentMonth()) {
return // 禁止切换到未来月份
}
const newDate = new Date(currentDate.value)
newDate.setMonth(newDate.getMonth() + 1)
currentDate.value = newDate
selectedDate.value = newDate
}
const isCurrentMonth = () => {
const now = new Date()
return (
currentDate.value.getFullYear() === now.getFullYear() &&
currentDate.value.getMonth() === now.getMonth()
)
}
// 左右滑动手势切换月份
const handleSwipeMonth = (offset) => {
if (offset > 0) {
handleNextMonth()
} else {
handlePrevMonth()
}
}
// 触摸事件处理(左右滑动切换月份)
const handleTouchStart = (e) => {
touchStartX.value = e.touches[0].clientX
touchStartY.value = e.touches[0].clientY
touchEndX.value = touchStartX.value
touchEndY.value = touchStartY.value
}
const handleTouchMove = (e) => {
touchEndX.value = e.touches[0].clientX
touchEndY.value = e.touches[0].clientY
}
const handleTouchEnd = () => {
const deltaX = touchEndX.value - touchStartX.value
const deltaY = touchEndY.value - touchStartY.value
const minSwipeDistance = 50
// 判断是否是水平滑动(水平距离大于垂直距离且超过阈值)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) {
if (deltaX > 0) {
// 右滑 - 上一个月
handlePrevMonth()
} else {
// 左滑 - 下一个月
handleNextMonth()
}
}
// 重置触摸位置
touchStartX.value = 0
touchStartY.value = 0
touchEndX.value = 0
touchEndY.value = 0
}
// 下拉刷新
const onRefresh = async () => {
hasError.value = false
errorMessage.value = ''
await loadBudgetData()
refreshing.value = false
showToast('刷新成功')
}
// 日期选择器
const onDatePickerConfirm = ({ selectedValues }) => {
const [year, month] = selectedValues
const newDate = new Date(parseInt(year), parseInt(month) - 1, 1)
// 检查是否超过当前月
const today = new Date()
if (
newDate.getFullYear() > today.getFullYear() ||
(newDate.getFullYear() === today.getFullYear() && newDate.getMonth() > today.getMonth())
) {
showToast('不能选择未来的月份')
showDatePicker.value = false
return
}
currentDate.value = newDate
selectedDate.value = newDate
showDatePicker.value = false
}
// 数据加载核心逻辑
const loadBudgetData = async () => {
if (loading.value && !refreshing.value) {
return // 防止重复加载
}
loading.value = !refreshing.value
hasError.value = false
errorMessage.value = ''
try {
// 并发加载多个数据源
await Promise.allSettled([loadMonthlyData(), loadCategoryStats(), loadUncoveredCategories()])
} catch (_error) {
console.error('加载预算数据失败:', _error)
hasError.value = true
errorMessage.value = _error.message || '网络连接异常,请检查网络后重试'
} finally {
loading.value = false
}
}
// 加载月度数据
const loadMonthlyData = async () => {
try {
const res = await getBudgetList(dayjs(selectedDate.value).format('YYYY-MM-DD'))
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 loadCategoryStats = async () => {
try {
const res = await getCategoryStats(
activeTab.value,
dayjs(selectedDate.value).format('YYYY-MM-DD')
)
if (res.success) {
const data = res.data
overallStats.value = {
month: {
rate: data.month?.usagePercentage?.toFixed(1) || '0.0',
current: data.month?.current || 0,
limit: data.month?.limit || 0,
count: data.month?.count || 0,
trend: data.month?.trend || [],
description: data.month?.description || ''
},
year: {
rate: data.year?.usagePercentage?.toFixed(1) || '0.0',
current: data.year?.current || 0,
limit: data.year?.limit || 0,
count: data.year?.count || 0,
trend: data.year?.trend || [],
description: data.year?.description || ''
}
}
}
} catch (err) {
console.error('加载分类统计失败', err)
}
}
// 加载未覆盖分类
const loadUncoveredCategories = async () => {
if (activeTab.value === BudgetCategory.Savings) {
uncoveredCategories.value = []
return
}
try {
const res = await getUncoveredCategories(
activeTab.value,
dayjs(selectedDate.value).format('YYYY-MM-DD')
)
if (res.success) {
uncoveredCategories.value = res.data || []
}
} catch (err) {
console.error('获取未覆盖分类失败', err)
}
}
// 加载归档总结
const showArchiveSummary = async () => {
try {
const res = await getArchiveSummary(dayjs(selectedDate.value).format('YYYY-MM-DD'))
if (res.success) {
archiveSummary.value = res.data || ''
showSummaryPopup.value = true
}
} catch (err) {
console.error('获取总结失败', err)
showToast('获取总结失败')
}
}
// 重试加载
const retryLoad = () => {
hasError.value = false
errorMessage.value = ''
loadBudgetData()
}
// 预算操作
const handleEdit = (budget) => {
budgetEditRef.value.open({
data: budget,
isEditFlag: true,
category: budget.category
})
}
const handleDelete = async (budget) => {
try {
await showConfirmDialog({
title: '删除预算',
message: `确定要删除预算 "${budget.name}" 吗?`
})
const res = await deleteBudget(budget.id)
if (res.success) {
showToast('删除成功')
await loadBudgetData()
} else {
showToast(res.message || '删除失败')
}
} catch (err) {
if (err.message !== 'cancel') {
console.error('删除预算失败', err)
showToast('删除预算失败')
}
}
}
// 存款计划日期切换
const handleSavingsNav = async (budget, offset) => {
if (!budget.periodStart) {
return
}
const date = new Date(budget.periodStart)
let year = date.getFullYear()
let month = date.getMonth() + 1
if (budget.type === BudgetPeriodType.Year) {
year += offset
} else {
month += offset
if (month > 12) {
month = 1
year++
} else if (month < 1) {
month = 12
year--
}
}
try {
const res = await getSavingsBudget(year, month, budget.type)
if (res.success && res.data) {
const index = savingsBudgets.value.findIndex((b) => b.id === budget.id)
if (index !== -1) {
savingsBudgets.value[index] = res.data
}
} else {
showToast('获取数据失败')
}
} catch (err) {
console.error('切换日期失败', err)
showToast('切换日期失败')
}
}
// 辅助函数
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 } },
{ p: 0.4, c: { r: 54, g: 207, b: 201 } },
{ p: 0.7, c: { r: 250, g: 173, b: 20 } },
{ p: 1, c: { r: 255, g: 77, b: 79 } }
]
} else {
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 handleTransactionsChanged = () => {
const temp = selectedDate.value
selectedDate.value = new Date(temp)
}
// 生命周期
onMounted(async () => {
try {
await loadBudgetData()
window.addEventListener('transactions-changed', handleTransactionsChanged)
} catch (err) {
console.error('获取初始化数据失败', err)
}
})
onActivated(() => {
// 从缓存恢复时刷新数据
})
onBeforeUnmount(() => {
window.removeEventListener('transactions-changed', handleTransactionsChanged)
})
</script>
<style scoped lang="scss">
.config-provider-full-height {
height: 100%;
}
.budget-v2-wrapper {
font-family: var(--font-primary);
color: var(--text-primary);
}
.budget-scroll-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background-color: var(--bg-primary);
/* 改善滚动性能 */
will-change: scroll-position;
/* 防止滚动卡顿 */
scroll-behavior: smooth;
}
.budget-content {
padding: var(--spacing-md);
padding-top: 0;
}
.error-state {
padding: 100px 16px;
text-align: center;
}
.budget-list {
padding-top: 8px;
padding-bottom: 20px;
}
.budget-list :deep(.van-swipe-cell) {
margin: 0 12px 12px;
}
.delete-button {
height: 100%;
}
.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;
}
.info-item {
display: flex;
flex-direction: column;
align-items: center;
}
.info-item .label {
font-size: 12px;
color: var(--van-text-color-2);
margin-bottom: 4px;
}
.info-item .value {
font-size: 16px;
font-weight: 600;
font-family:
DIN Alternate,
system-ui;
}
.info-item .value.expense {
color: var(--van-danger-color);
}
.info-item .value.income {
color: var(--van-success-color);
}
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>