- 添加 BudgetItemCalculator 辅助类,实现明细项计算规则 - 收入:实际>0取实际,否则取预算 - 支出:取MAX(预算, 实际) - 硬性支出未发生:按天数折算 - 归档数据:直接使用实际值 - 实现月度和年度存款核心公式 - 月度:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出 - 年度:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算 - 定义存款明细数据结构 - SavingsDetail: 包含收入/支出明细列表和汇总 - BudgetDetailItem: 预算明细项(含计算用金额、计算说明等) - SavingsCalculationSummary: 计算汇总信息 - 新增单元测试 - BudgetItemCalculatorTest: 11个测试覆盖所有计算规则 - BudgetSavingsCalculationTest: 6个测试验证核心公式 测试结果:所有测试通过 (366 passed, 0 failed)
886 lines
23 KiB
Vue
886 lines
23 KiB
Vue
<!-- 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>
|