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"
|
2026-02-20 16:26:04 +08:00
|
|
|
|
: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>
|