Files
EmailBill/Web/src/views/budgetV2/Index.vue

886 lines
23 KiB
Vue
Raw Normal View History

2026-02-13 22:49:07 +08:00
<!-- 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
2026-02-15 10:10:28 +08:00
v-if="
activeTab !== BudgetCategory.Savings && uncoveredCategories.length > 0 && !isArchive
"
2026-02-13 22:49:07 +08:00
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"
2026-02-13 22:49:07 +08:00
@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"
2026-02-20 14:57:19 +08:00
@change="loadBudgetData"
2026-02-13 22:49:07 +08:00
/>
<!-- 未覆盖分类弹窗 -->
2026-02-20 14:57:19 +08:00
<PopupContainerV2
v-model:show="showUncoveredDetails"
2026-02-13 22:49:07 +08:00
title="未覆盖预算的分类"
2026-02-20 14:57:19 +08:00
:height="'60%'"
2026-02-13 22:49:07 +08:00
>
2026-02-20 14:57:19 +08:00
<div style="padding: 0">
<!-- subtitle 作为内容区域顶部 -->
2026-02-13 22:49:07 +08:00
<div
2026-02-20 14:57:19 +08:00
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>
2026-02-13 22:49:07 +08:00
</div>
2026-02-20 14:57:19 +08:00
<div class="item-right">
<div
class="item-amount"
:class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'"
>
¥{{ formatMoney(item.totalAmount) }}
</div>
2026-02-13 22:49:07 +08:00
</div>
</div>
</div>
</div>
<template #footer>
<van-button
block
round
type="primary"
@click="showUncoveredDetails = false"
>
我知道了
</van-button>
</template>
2026-02-20 14:57:19 +08:00
</PopupContainerV2>
2026-02-13 22:49:07 +08:00
<!-- 归档总结弹窗 -->
2026-02-20 14:57:19 +08:00
<PopupContainerV2
v-model:show="showSummaryPopup"
2026-02-13 22:49:07 +08:00
title="月份归档总结"
2026-02-20 14:57:19 +08:00
:height="'70%'"
2026-02-13 22:49:07 +08:00
>
2026-02-20 14:57:19 +08:00
<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>
2026-02-13 22:49:07 +08:00
</div>
2026-02-20 14:57:19 +08:00
</PopupContainerV2>
2026-02-13 22:49:07 +08:00
<!-- 日期选择器 -->
<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'
2026-02-20 14:57:19 +08:00
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
2026-02-13 22:49:07 +08:00
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()
2026-02-15 10:10:28 +08:00
const theme = computed(() => (messageStore.isDarkMode ? 'dark' : 'light'))
2026-02-13 22:49:07 +08:00
// 日期状态
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 {
// 并发加载多个数据源
2026-02-15 10:10:28 +08:00
await Promise.allSettled([loadMonthlyData(), loadCategoryStats(), loadUncoveredCategories()])
2026-02-13 22:49:07 +08:00
} 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 {
2026-02-15 10:10:28 +08:00
padding: var(--spacing-md);
padding-top: 0;
2026-02-13 22:49:07 +08:00
}
.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;
2026-02-15 10:10:28 +08:00
font-family:
DIN Alternate,
system-ui;
2026-02-13 22:49:07 +08:00
}
.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;
2026-02-15 10:10:28 +08:00
font-family:
DIN Alternate,
system-ui;
2026-02-13 22:49:07 +08:00
}
.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>