2026-02-09 19:25:51 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<van-config-provider :theme="theme">
|
|
|
|
|
|
<div class="page-container-flex statistics-v2-wrapper">
|
|
|
|
|
|
<!-- 头部年月选择器 -->
|
|
|
|
|
|
<CalendarHeader
|
|
|
|
|
|
:type="currentPeriod"
|
|
|
|
|
|
:current-date="currentDate"
|
|
|
|
|
|
:show-notification="true"
|
|
|
|
|
|
@prev="handlePrevPeriod"
|
|
|
|
|
|
@next="handleNextPeriod"
|
|
|
|
|
|
@jump="showDatePicker = true"
|
|
|
|
|
|
@notification="goToStatisticsV1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<!-- 时间段选择器 -->
|
|
|
|
|
|
<TimePeriodTabs
|
|
|
|
|
|
:active-tab="currentPeriod"
|
|
|
|
|
|
@change="handlePeriodChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 可滚动内容区域 -->
|
|
|
|
|
|
<div class="statistics-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="statistics-content"
|
|
|
|
|
|
@touchstart="handleTouchStart"
|
|
|
|
|
|
@touchmove="handleTouchMove"
|
|
|
|
|
|
@touchend="handleTouchEnd"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 收支结余和趋势卡片(合并) -->
|
|
|
|
|
|
<MonthlyExpenseCard
|
|
|
|
|
|
:amount="monthlyStats.totalExpense"
|
|
|
|
|
|
:income="monthlyStats.totalIncome"
|
|
|
|
|
|
:balance="monthlyStats.balance"
|
|
|
|
|
|
:trend-data="trendStats"
|
|
|
|
|
|
:period="currentPeriod"
|
|
|
|
|
|
:current-date="currentDate"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 支出分类卡片 -->
|
|
|
|
|
|
<ExpenseCategoryCard
|
|
|
|
|
|
:categories="expenseCategories"
|
|
|
|
|
|
:total-expense="monthlyStats.totalExpense"
|
|
|
|
|
|
:colors="categoryColors"
|
|
|
|
|
|
@category-click="goToCategoryBills"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 收入和不计收支分类卡片 -->
|
|
|
|
|
|
<IncomeNoneCategoryCard
|
|
|
|
|
|
:income-categories="incomeCategories"
|
|
|
|
|
|
:none-categories="noneCategories"
|
|
|
|
|
|
:total-income="monthlyStats.totalIncome"
|
|
|
|
|
|
@category-click="goToCategoryBills"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</van-pull-refresh>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 日期选择器 -->
|
|
|
|
|
|
<van-popup
|
|
|
|
|
|
v-model:show="showDatePicker"
|
|
|
|
|
|
position="bottom"
|
|
|
|
|
|
:style="{ height: '50%' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<van-date-picker
|
|
|
|
|
|
v-model="selectedDate"
|
|
|
|
|
|
:type="datePickerType"
|
|
|
|
|
|
:min-date="minDate"
|
|
|
|
|
|
:max-date="maxDate"
|
|
|
|
|
|
@confirm="onDateConfirm"
|
|
|
|
|
|
@cancel="showDatePicker = false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</van-popup>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 分类账单弹窗 -->
|
|
|
|
|
|
<CategoryBillPopup
|
|
|
|
|
|
v-model="billPopupVisible"
|
|
|
|
|
|
:classify="selectedClassify"
|
|
|
|
|
|
:type="selectedType"
|
|
|
|
|
|
:year="currentDate.getFullYear()"
|
|
|
|
|
|
:month="currentPeriod === 'year' ? 0 : currentDate.getMonth() + 1"
|
|
|
|
|
|
@refresh="loadStatistics"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</van-config-provider>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
import CalendarHeader from '@/components/DateSelectHeader.vue'
|
|
|
|
|
|
import TimePeriodTabs from '@/components/TimePeriodTabs.vue'
|
|
|
|
|
|
import MonthlyExpenseCard from './modules/MonthlyExpenseCard.vue'
|
|
|
|
|
|
import ExpenseCategoryCard from './modules/ExpenseCategoryCard.vue'
|
|
|
|
|
|
import IncomeNoneCategoryCard from './modules/IncomeNoneCategoryCard.vue'
|
|
|
|
|
|
import CategoryBillPopup from '@/components/CategoryBillPopup.vue'
|
2026-02-10 17:49:19 +08:00
|
|
|
|
import {
|
|
|
|
|
|
// 新统一接口
|
|
|
|
|
|
getDailyStatisticsByRange,
|
|
|
|
|
|
getSummaryByRange,
|
|
|
|
|
|
getCategoryStatisticsByRange,
|
|
|
|
|
|
getTrendStatistics,
|
|
|
|
|
|
// 旧接口(兼容性保留)
|
|
|
|
|
|
getMonthlyStatistics,
|
|
|
|
|
|
getCategoryStatistics,
|
|
|
|
|
|
getDailyStatistics
|
|
|
|
|
|
} from '@/api/statistics'
|
2026-02-09 19:25:51 +08:00
|
|
|
|
import { useMessageStore } from '@/stores/message'
|
|
|
|
|
|
import { getCssVar } from '@/utils/theme'
|
|
|
|
|
|
|
|
|
|
|
|
// 为组件缓存设置名称
|
|
|
|
|
|
defineOptions({
|
|
|
|
|
|
name: 'StatisticsV2View'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const messageStore = useMessageStore()
|
|
|
|
|
|
|
|
|
|
|
|
// 主题
|
2026-02-15 10:10:28 +08:00
|
|
|
|
const theme = computed(() => (messageStore.isDarkMode ? 'dark' : 'light'))
|
2026-02-09 19:25:51 +08:00
|
|
|
|
|
|
|
|
|
|
// 状态管理
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const refreshing = ref(false)
|
|
|
|
|
|
const showDatePicker = ref(false)
|
|
|
|
|
|
const errorMessage = ref('')
|
|
|
|
|
|
const hasError = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// 分类账单弹窗状态
|
|
|
|
|
|
const billPopupVisible = ref(false)
|
|
|
|
|
|
const selectedClassify = ref('')
|
|
|
|
|
|
const selectedType = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
// 触摸滑动相关状态
|
|
|
|
|
|
const touchStartX = ref(0)
|
|
|
|
|
|
const touchStartY = ref(0)
|
|
|
|
|
|
const touchEndX = ref(0)
|
|
|
|
|
|
const touchEndY = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
// 时间段选择
|
|
|
|
|
|
const currentPeriod = ref('month')
|
|
|
|
|
|
const currentDate = ref(new Date())
|
|
|
|
|
|
const selectedDate = ref([])
|
|
|
|
|
|
const minDate = new Date(2020, 0, 1)
|
|
|
|
|
|
const maxDate = new Date()
|
|
|
|
|
|
|
|
|
|
|
|
// 统计数据
|
|
|
|
|
|
const monthlyStats = ref({
|
|
|
|
|
|
totalExpense: 0,
|
|
|
|
|
|
totalIncome: 0,
|
|
|
|
|
|
balance: 0,
|
|
|
|
|
|
expenseCount: 0,
|
|
|
|
|
|
incomeCount: 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const trendStats = ref([])
|
|
|
|
|
|
const expenseCategories = ref([])
|
|
|
|
|
|
const incomeCategories = ref([])
|
|
|
|
|
|
const noneCategories = ref([])
|
|
|
|
|
|
|
|
|
|
|
|
// 颜色配置
|
|
|
|
|
|
const categoryColors = [
|
2026-02-15 10:10:28 +08:00
|
|
|
|
'#FF6B6B',
|
|
|
|
|
|
'#4ECDC4',
|
|
|
|
|
|
'#45B7D1',
|
|
|
|
|
|
'#96CEB4',
|
|
|
|
|
|
'#FFEAA7',
|
|
|
|
|
|
'#DDA0DD',
|
|
|
|
|
|
'#98D8C8',
|
|
|
|
|
|
'#F7DC6F',
|
|
|
|
|
|
'#BB8FCE',
|
|
|
|
|
|
'#85C1E9'
|
2026-02-09 19:25:51 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
// 计算属性
|
|
|
|
|
|
const datePickerType = computed(() => {
|
|
|
|
|
|
switch (currentPeriod.value) {
|
|
|
|
|
|
case 'week':
|
|
|
|
|
|
case 'month':
|
|
|
|
|
|
return 'year-month'
|
|
|
|
|
|
case 'year':
|
|
|
|
|
|
return 'year'
|
|
|
|
|
|
default:
|
|
|
|
|
|
return 'year-month'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 获取周的开始日期(周一)
|
|
|
|
|
|
const getWeekStartDate = (date) => {
|
|
|
|
|
|
const target = new Date(date.valueOf())
|
|
|
|
|
|
const dayNr = (date.getDay() + 6) % 7 // 周一为0,周日为6
|
|
|
|
|
|
target.setDate(target.getDate() - dayNr)
|
|
|
|
|
|
target.setHours(0, 0, 0, 0)
|
|
|
|
|
|
return target
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化日期为字符串
|
|
|
|
|
|
const formatDateToString = (date) => {
|
|
|
|
|
|
const year = date.getFullYear()
|
|
|
|
|
|
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
|
|
|
|
|
const day = date.getDate().toString().padStart(2, '0')
|
|
|
|
|
|
return `${year}-${month}-${day}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载统计数据
|
|
|
|
|
|
const loadStatistics = async () => {
|
|
|
|
|
|
if (loading.value && !refreshing.value) {
|
|
|
|
|
|
return // 防止重复加载
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loading.value = !refreshing.value
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const year = currentDate.value.getFullYear()
|
|
|
|
|
|
const month = currentDate.value.getMonth() + 1
|
|
|
|
|
|
|
|
|
|
|
|
// 重置数据
|
|
|
|
|
|
monthlyStats.value = {
|
|
|
|
|
|
totalExpense: 0,
|
|
|
|
|
|
totalIncome: 0,
|
|
|
|
|
|
balance: 0,
|
|
|
|
|
|
expenseCount: 0,
|
|
|
|
|
|
incomeCount: 0
|
|
|
|
|
|
}
|
|
|
|
|
|
trendStats.value = []
|
|
|
|
|
|
expenseCategories.value = []
|
|
|
|
|
|
incomeCategories.value = []
|
|
|
|
|
|
noneCategories.value = []
|
|
|
|
|
|
|
|
|
|
|
|
// 根据时间段加载不同的数据
|
|
|
|
|
|
if (currentPeriod.value === 'month') {
|
|
|
|
|
|
await loadMonthlyData(year, month)
|
|
|
|
|
|
} else if (currentPeriod.value === 'year') {
|
|
|
|
|
|
await loadYearlyData(year)
|
|
|
|
|
|
} else if (currentPeriod.value === 'week') {
|
|
|
|
|
|
await loadWeeklyData()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载分类统计
|
|
|
|
|
|
await loadCategoryStatistics(year, month)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载统计数据失败:', error)
|
|
|
|
|
|
hasError.value = true
|
|
|
|
|
|
errorMessage.value = error.message || '网络连接异常,请检查网络后重试'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重试加载
|
|
|
|
|
|
const retryLoad = () => {
|
|
|
|
|
|
hasError.value = false
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
loadStatistics()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载月度数据
|
|
|
|
|
|
const loadMonthlyData = async (year, month) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 月度统计
|
|
|
|
|
|
const monthlyResult = await getMonthlyStatistics({ year, month })
|
|
|
|
|
|
if (monthlyResult?.success && monthlyResult.data) {
|
|
|
|
|
|
monthlyStats.value = {
|
|
|
|
|
|
totalExpense: monthlyResult.data.totalExpense || 0,
|
|
|
|
|
|
totalIncome: monthlyResult.data.totalIncome || 0,
|
|
|
|
|
|
balance: (monthlyResult.data.totalIncome || 0) - (monthlyResult.data.totalExpense || 0),
|
|
|
|
|
|
expenseCount: monthlyResult.data.expenseCount || 0,
|
|
|
|
|
|
incomeCount: monthlyResult.data.incomeCount || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载每日统计
|
|
|
|
|
|
const dailyResult = await getDailyStatistics({ year, month })
|
|
|
|
|
|
if (dailyResult?.success && dailyResult.data) {
|
2026-02-10 19:47:55 +08:00
|
|
|
|
// 转换数据格式:添加完整的 date 字段
|
|
|
|
|
|
trendStats.value = dailyResult.data
|
2026-02-15 10:10:28 +08:00
|
|
|
|
.filter((item) => item != null)
|
|
|
|
|
|
.map((item) => ({
|
2026-02-10 19:47:55 +08:00
|
|
|
|
date: `${year}-${month.toString().padStart(2, '0')}-${item.day.toString().padStart(2, '0')}`,
|
|
|
|
|
|
expense: item.expense || 0,
|
|
|
|
|
|
income: item.income || 0,
|
|
|
|
|
|
count: item.count || 0
|
|
|
|
|
|
}))
|
2026-02-09 19:25:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载月度数据失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载年度数据
|
|
|
|
|
|
const loadYearlyData = async (year) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 年度统计 - 使用趋势接口获取12个月数据
|
|
|
|
|
|
const trendResult = await getTrendStatistics({ startYear: year, startMonth: 1, monthCount: 12 })
|
|
|
|
|
|
if (trendResult?.success && trendResult.data) {
|
|
|
|
|
|
// 计算年度汇总
|
2026-02-15 10:10:28 +08:00
|
|
|
|
const yearTotal = trendResult.data.reduce(
|
|
|
|
|
|
(acc, item) => {
|
|
|
|
|
|
const expense = item.expense || 0
|
|
|
|
|
|
const income = item.income || 0
|
|
|
|
|
|
return {
|
|
|
|
|
|
totalExpense: acc.totalExpense + expense,
|
|
|
|
|
|
totalIncome: acc.totalIncome + income,
|
|
|
|
|
|
balance: acc.balance + income - expense
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ totalExpense: 0, totalIncome: 0, balance: 0 }
|
|
|
|
|
|
)
|
2026-02-09 19:25:51 +08:00
|
|
|
|
|
|
|
|
|
|
monthlyStats.value = {
|
|
|
|
|
|
...yearTotal,
|
|
|
|
|
|
expenseCount: 0,
|
|
|
|
|
|
incomeCount: 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 10:10:28 +08:00
|
|
|
|
trendStats.value = trendResult.data.map((item) => ({
|
2026-02-09 19:25:51 +08:00
|
|
|
|
date: `${item.year}-${item.month.toString().padStart(2, '0')}-01`,
|
|
|
|
|
|
amount: (item.income || 0) - (item.expense || 0),
|
|
|
|
|
|
count: 1
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载年度数据失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载周度数据
|
|
|
|
|
|
const loadWeeklyData = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 周统计 - 计算当前周的开始和结束日期
|
|
|
|
|
|
const weekStart = getWeekStartDate(currentDate.value)
|
|
|
|
|
|
const weekEnd = new Date(weekStart)
|
2026-02-10 17:49:19 +08:00
|
|
|
|
weekEnd.setDate(weekStart.getDate() + 7) // 修改:+7 天,因为 endDate 是不包含的
|
2026-02-09 19:25:51 +08:00
|
|
|
|
|
2026-02-10 17:49:19 +08:00
|
|
|
|
const startDateStr = formatDateToString(weekStart)
|
|
|
|
|
|
const endDateStr = formatDateToString(weekEnd)
|
|
|
|
|
|
|
|
|
|
|
|
// 使用新的统一接口获取周统计汇总
|
|
|
|
|
|
const weekSummaryResult = await getSummaryByRange({
|
|
|
|
|
|
startDate: startDateStr,
|
|
|
|
|
|
endDate: endDateStr
|
2026-02-09 19:25:51 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (weekSummaryResult?.success && weekSummaryResult.data) {
|
|
|
|
|
|
monthlyStats.value = {
|
|
|
|
|
|
totalExpense: weekSummaryResult.data.totalExpense || 0,
|
|
|
|
|
|
totalIncome: weekSummaryResult.data.totalIncome || 0,
|
2026-02-15 10:10:28 +08:00
|
|
|
|
balance:
|
|
|
|
|
|
(weekSummaryResult.data.totalIncome || 0) - (weekSummaryResult.data.totalExpense || 0),
|
2026-02-09 19:25:51 +08:00
|
|
|
|
expenseCount: weekSummaryResult.data.expenseCount || 0,
|
|
|
|
|
|
incomeCount: weekSummaryResult.data.incomeCount || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 17:49:19 +08:00
|
|
|
|
// 使用新的统一接口获取周内每日统计
|
|
|
|
|
|
const dailyResult = await getDailyStatisticsByRange({
|
|
|
|
|
|
startDate: startDateStr,
|
|
|
|
|
|
endDate: endDateStr
|
2026-02-09 19:25:51 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (dailyResult?.success && dailyResult.data) {
|
2026-02-11 13:00:01 +08:00
|
|
|
|
// ⚠️ 注意: API 返回的 data 按日期顺序排列,但只有 day 字段(天数)
|
|
|
|
|
|
// 需要根据 weekStart 和索引重建完整日期
|
|
|
|
|
|
trendStats.value = dailyResult.data.map((item, index) => {
|
|
|
|
|
|
// 从 weekStart 开始,按索引递增天数
|
|
|
|
|
|
const date = new Date(weekStart)
|
|
|
|
|
|
date.setDate(weekStart.getDate() + index)
|
|
|
|
|
|
const dateStr = formatDateToString(date)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
date: dateStr,
|
|
|
|
|
|
expense: item.expense || 0,
|
|
|
|
|
|
income: item.income || 0,
|
|
|
|
|
|
count: item.count || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-02-09 19:25:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载周度数据失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载分类统计
|
|
|
|
|
|
const loadCategoryStatistics = async (year, month) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const categoryYear = year
|
2026-02-10 17:49:19 +08:00
|
|
|
|
// 如果是年度统计,month应该传0表示查询全年
|
|
|
|
|
|
const categoryMonth = currentPeriod.value === 'year' ? 0 : month
|
2026-02-09 19:25:51 +08:00
|
|
|
|
|
|
|
|
|
|
// 对于周统计,使用日期范围进行分类统计
|
|
|
|
|
|
if (currentPeriod.value === 'week') {
|
|
|
|
|
|
const weekStart = getWeekStartDate(currentDate.value)
|
|
|
|
|
|
const weekEnd = new Date(weekStart)
|
2026-02-10 17:49:19 +08:00
|
|
|
|
weekEnd.setDate(weekStart.getDate() + 7) // 修改:+7 天,因为 endDate 是不包含的
|
2026-02-09 19:25:51 +08:00
|
|
|
|
|
|
|
|
|
|
const startDateStr = formatDateToString(weekStart)
|
|
|
|
|
|
const endDateStr = formatDateToString(weekEnd)
|
|
|
|
|
|
|
2026-02-10 17:49:19 +08:00
|
|
|
|
// 使用新的统一接口并发加载支出、收入和不计收支分类
|
2026-02-09 19:25:51 +08:00
|
|
|
|
const [expenseResult, incomeResult, noneResult] = await Promise.allSettled([
|
2026-02-10 17:49:19 +08:00
|
|
|
|
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 0 }),
|
|
|
|
|
|
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 1 }),
|
|
|
|
|
|
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 2 })
|
2026-02-09 19:25:51 +08:00
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
// 获取图表颜色配置
|
|
|
|
|
|
const getChartColors = () => [
|
|
|
|
|
|
getCssVar('--chart-color-1'),
|
|
|
|
|
|
getCssVar('--chart-color-2'),
|
|
|
|
|
|
getCssVar('--chart-color-3'),
|
|
|
|
|
|
getCssVar('--chart-color-4'),
|
|
|
|
|
|
getCssVar('--chart-color-5'),
|
|
|
|
|
|
getCssVar('--chart-color-6'),
|
|
|
|
|
|
getCssVar('--chart-color-7'),
|
|
|
|
|
|
getCssVar('--chart-color-8'),
|
|
|
|
|
|
getCssVar('--chart-color-9'),
|
|
|
|
|
|
getCssVar('--chart-color-10'),
|
|
|
|
|
|
getCssVar('--chart-color-11'),
|
|
|
|
|
|
getCssVar('--chart-color-12'),
|
|
|
|
|
|
getCssVar('--chart-color-13'),
|
|
|
|
|
|
getCssVar('--chart-color-14'),
|
|
|
|
|
|
getCssVar('--chart-color-15')
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const currentColors = getChartColors()
|
|
|
|
|
|
|
|
|
|
|
|
// 处理支出分类结果
|
2026-02-15 10:10:28 +08:00
|
|
|
|
if (
|
|
|
|
|
|
expenseResult.status === 'fulfilled' &&
|
|
|
|
|
|
expenseResult.value?.success &&
|
|
|
|
|
|
expenseResult.value.data
|
|
|
|
|
|
) {
|
2026-02-09 19:25:51 +08:00
|
|
|
|
expenseCategories.value = expenseResult.value.data.map((item, index) => ({
|
|
|
|
|
|
classify: item.classify,
|
|
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
count: item.count || 0,
|
|
|
|
|
|
percent: item.percent || 0,
|
|
|
|
|
|
color: currentColors[index % currentColors.length]
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理收入分类结果
|
2026-02-15 10:10:28 +08:00
|
|
|
|
if (
|
|
|
|
|
|
incomeResult.status === 'fulfilled' &&
|
|
|
|
|
|
incomeResult.value?.success &&
|
|
|
|
|
|
incomeResult.value.data
|
|
|
|
|
|
) {
|
2026-02-09 19:25:51 +08:00
|
|
|
|
incomeCategories.value = incomeResult.value.data.map((item) => ({
|
|
|
|
|
|
classify: item.classify,
|
|
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
count: item.count || 0,
|
|
|
|
|
|
percent: item.percent || 0
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理不计收支分类结果
|
|
|
|
|
|
if (noneResult.status === 'fulfilled' && noneResult.value?.success && noneResult.value.data) {
|
|
|
|
|
|
noneCategories.value = noneResult.value.data.map((item) => ({
|
|
|
|
|
|
classify: item.classify,
|
|
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
count: item.count || 0,
|
|
|
|
|
|
percent: item.percent || 0
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 对于月度和年度统计,使用年月进行分类统计
|
|
|
|
|
|
// 并发加载支出、收入和不计收支分类
|
|
|
|
|
|
const [expenseResult, incomeResult, noneResult] = await Promise.allSettled([
|
|
|
|
|
|
getCategoryStatistics({ year: categoryYear, month: categoryMonth, type: 0 }),
|
|
|
|
|
|
getCategoryStatistics({ year: categoryYear, month: categoryMonth, type: 1 }),
|
|
|
|
|
|
getCategoryStatistics({ year: categoryYear, month: categoryMonth, type: 2 })
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
// 获取图表颜色配置
|
|
|
|
|
|
const getChartColors = () => [
|
|
|
|
|
|
getCssVar('--chart-color-1'),
|
|
|
|
|
|
getCssVar('--chart-color-2'),
|
|
|
|
|
|
getCssVar('--chart-color-3'),
|
|
|
|
|
|
getCssVar('--chart-color-4'),
|
|
|
|
|
|
getCssVar('--chart-color-5'),
|
|
|
|
|
|
getCssVar('--chart-color-6'),
|
|
|
|
|
|
getCssVar('--chart-color-7'),
|
|
|
|
|
|
getCssVar('--chart-color-8'),
|
|
|
|
|
|
getCssVar('--chart-color-9'),
|
|
|
|
|
|
getCssVar('--chart-color-10'),
|
|
|
|
|
|
getCssVar('--chart-color-11'),
|
|
|
|
|
|
getCssVar('--chart-color-12'),
|
|
|
|
|
|
getCssVar('--chart-color-13'),
|
|
|
|
|
|
getCssVar('--chart-color-14'),
|
|
|
|
|
|
getCssVar('--chart-color-15')
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const currentColors = getChartColors()
|
|
|
|
|
|
|
|
|
|
|
|
// 处理支出分类结果
|
2026-02-15 10:10:28 +08:00
|
|
|
|
if (
|
|
|
|
|
|
expenseResult.status === 'fulfilled' &&
|
|
|
|
|
|
expenseResult.value?.success &&
|
|
|
|
|
|
expenseResult.value.data
|
|
|
|
|
|
) {
|
2026-02-09 19:25:51 +08:00
|
|
|
|
expenseCategories.value = expenseResult.value.data.map((item, index) => ({
|
|
|
|
|
|
classify: item.classify,
|
|
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
count: item.count || 0,
|
|
|
|
|
|
percent: item.percent || 0,
|
|
|
|
|
|
color: currentColors[index % currentColors.length]
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理收入分类结果
|
2026-02-15 10:10:28 +08:00
|
|
|
|
if (
|
|
|
|
|
|
incomeResult.status === 'fulfilled' &&
|
|
|
|
|
|
incomeResult.value?.success &&
|
|
|
|
|
|
incomeResult.value.data
|
|
|
|
|
|
) {
|
2026-02-09 19:25:51 +08:00
|
|
|
|
incomeCategories.value = incomeResult.value.data.map((item) => ({
|
|
|
|
|
|
classify: item.classify,
|
|
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
count: item.count || 0,
|
|
|
|
|
|
percent: item.percent || 0
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理不计收支分类结果
|
|
|
|
|
|
if (noneResult.status === 'fulfilled' && noneResult.value?.success && noneResult.value.data) {
|
|
|
|
|
|
noneCategories.value = noneResult.value.data.map((item) => ({
|
|
|
|
|
|
classify: item.classify,
|
|
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
count: item.count || 0,
|
|
|
|
|
|
percent: item.percent || 0
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载分类统计失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理时间段切换
|
|
|
|
|
|
const handlePeriodChange = (period) => {
|
|
|
|
|
|
currentPeriod.value = period
|
|
|
|
|
|
// 清除错误状态
|
|
|
|
|
|
hasError.value = false
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
loadStatistics()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换时间周期
|
|
|
|
|
|
const handlePrevPeriod = () => {
|
|
|
|
|
|
const newDate = new Date(currentDate.value)
|
|
|
|
|
|
|
|
|
|
|
|
switch (currentPeriod.value) {
|
|
|
|
|
|
case 'week':
|
|
|
|
|
|
newDate.setDate(newDate.getDate() - 7)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'month':
|
|
|
|
|
|
newDate.setMonth(newDate.getMonth() - 1)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'year':
|
|
|
|
|
|
newDate.setFullYear(newDate.getFullYear() - 1)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentDate.value = newDate
|
|
|
|
|
|
// 清除错误状态
|
|
|
|
|
|
hasError.value = false
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
loadStatistics()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleNextPeriod = () => {
|
|
|
|
|
|
// 检查是否已经是最后一个周期(当前周期)
|
|
|
|
|
|
if (isLastPeriod()) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const newDate = new Date(currentDate.value)
|
|
|
|
|
|
|
|
|
|
|
|
switch (currentPeriod.value) {
|
|
|
|
|
|
case 'week':
|
|
|
|
|
|
newDate.setDate(newDate.getDate() + 7)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'month':
|
|
|
|
|
|
newDate.setMonth(newDate.getMonth() + 1)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'year':
|
|
|
|
|
|
newDate.setFullYear(newDate.getFullYear() + 1)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentDate.value = newDate
|
|
|
|
|
|
// 清除错误状态
|
|
|
|
|
|
hasError.value = false
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
loadStatistics()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 判断是否是最后一个周期(不能再往后切换)
|
|
|
|
|
|
const isLastPeriod = () => {
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
const current = new Date(currentDate.value)
|
|
|
|
|
|
|
|
|
|
|
|
switch (currentPeriod.value) {
|
|
|
|
|
|
case 'week': {
|
|
|
|
|
|
// 获取当前周的开始日期和当前时间所在周的开始日期
|
|
|
|
|
|
const currentWeekStart = getWeekStartDate(current)
|
|
|
|
|
|
const nowWeekStart = getWeekStartDate(now)
|
|
|
|
|
|
return currentWeekStart >= nowWeekStart
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'month': {
|
|
|
|
|
|
// 比较年月
|
2026-02-15 10:10:28 +08:00
|
|
|
|
return current.getFullYear() === now.getFullYear() && current.getMonth() === now.getMonth()
|
2026-02-09 19:25:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
case 'year': {
|
|
|
|
|
|
// 比较年份
|
|
|
|
|
|
return current.getFullYear() === now.getFullYear()
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 触摸事件处理
|
|
|
|
|
|
const handleTouchStart = (e) => {
|
|
|
|
|
|
touchStartX.value = e.touches[0].clientX
|
|
|
|
|
|
touchStartY.value = e.touches[0].clientY
|
2026-02-11 13:00:01 +08:00
|
|
|
|
// 重置 touchEnd 值,防止使用上次的残留值
|
|
|
|
|
|
touchEndX.value = touchStartX.value
|
|
|
|
|
|
touchEndY.value = touchStartY.value
|
2026-02-09 19:25:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleTouchMove = (e) => {
|
|
|
|
|
|
touchEndX.value = e.touches[0].clientX
|
|
|
|
|
|
touchEndY.value = e.touches[0].clientY
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 13:00:01 +08:00
|
|
|
|
const handleTouchEnd = (e) => {
|
|
|
|
|
|
// 如果 touchEnd 事件中还有 changedTouches,使用它来获取最终位置
|
|
|
|
|
|
if (e.changedTouches && e.changedTouches.length > 0) {
|
|
|
|
|
|
touchEndX.value = e.changedTouches[0].clientX
|
|
|
|
|
|
touchEndY.value = e.changedTouches[0].clientY
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 19:25:51 +08:00
|
|
|
|
const deltaX = touchEndX.value - touchStartX.value
|
|
|
|
|
|
const deltaY = touchEndY.value - touchStartY.value
|
|
|
|
|
|
|
2026-02-11 13:00:01 +08:00
|
|
|
|
// 最小滑动距离阈值(像素)
|
|
|
|
|
|
const MIN_SWIPE_DISTANCE = 50
|
|
|
|
|
|
|
|
|
|
|
|
// 判断是否是水平滑动(水平距离大于垂直距离且超过阈值)
|
|
|
|
|
|
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > MIN_SWIPE_DISTANCE) {
|
2026-02-09 19:25:51 +08:00
|
|
|
|
if (deltaX > 0) {
|
|
|
|
|
|
// 右滑 - 上一个周期
|
|
|
|
|
|
handlePrevPeriod()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 左滑 - 下一个周期
|
|
|
|
|
|
handleNextPeriod()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重置触摸位置
|
|
|
|
|
|
touchStartX.value = 0
|
|
|
|
|
|
touchStartY.value = 0
|
|
|
|
|
|
touchEndX.value = 0
|
|
|
|
|
|
touchEndY.value = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 下拉刷新
|
|
|
|
|
|
const onRefresh = async () => {
|
|
|
|
|
|
// 清除错误状态
|
|
|
|
|
|
hasError.value = false
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
await loadStatistics()
|
|
|
|
|
|
refreshing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 日期选择确认
|
|
|
|
|
|
const onDateConfirm = ({ selectedValues }) => {
|
|
|
|
|
|
if (currentPeriod.value === 'year') {
|
|
|
|
|
|
const [year] = selectedValues
|
|
|
|
|
|
currentDate.value = new Date(year, 0, 1)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const [year, month] = selectedValues
|
|
|
|
|
|
currentDate.value = new Date(year, month - 1, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
showDatePicker.value = false
|
|
|
|
|
|
// 清除错误状态
|
|
|
|
|
|
hasError.value = false
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
loadStatistics()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到分类账单
|
|
|
|
|
|
const goToCategoryBills = (classify, type) => {
|
|
|
|
|
|
selectedClassify.value = classify || ''
|
|
|
|
|
|
selectedType.value = type
|
|
|
|
|
|
billPopupVisible.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换到统计V1页面
|
|
|
|
|
|
const goToStatisticsV1 = () => {
|
|
|
|
|
|
router.push({ name: 'statistics' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听时间段变化,更新选中日期
|
|
|
|
|
|
watch(currentPeriod, () => {
|
|
|
|
|
|
if (currentPeriod.value === 'year') {
|
|
|
|
|
|
selectedDate.value = [currentDate.value.getFullYear()]
|
|
|
|
|
|
} else {
|
2026-02-15 10:10:28 +08:00
|
|
|
|
selectedDate.value = [currentDate.value.getFullYear(), currentDate.value.getMonth() + 1]
|
2026-02-09 19:25:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
// 设置默认选中日期
|
2026-02-15 10:10:28 +08:00
|
|
|
|
selectedDate.value = [currentDate.value.getFullYear(), currentDate.value.getMonth() + 1]
|
2026-02-09 19:25:51 +08:00
|
|
|
|
loadStatistics()
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
@import '@/assets/theme.css';
|
|
|
|
|
|
|
|
|
|
|
|
/* ========== 页面容器 ========== */
|
|
|
|
|
|
.statistics-v2-wrapper {
|
|
|
|
|
|
font-family: var(--font-primary);
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.statistics-scroll-content {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
overscroll-behavior: contain;
|
2026-02-11 13:00:01 +08:00
|
|
|
|
background-color: var(--bg-primary);
|
2026-02-09 19:25:51 +08:00
|
|
|
|
/* 改善滚动性能 */
|
|
|
|
|
|
will-change: scroll-position;
|
|
|
|
|
|
/* 防止滚动卡顿 */
|
|
|
|
|
|
scroll-behavior: smooth;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.statistics-content {
|
|
|
|
|
|
padding: var(--spacing-md);
|
2026-02-11 13:00:01 +08:00
|
|
|
|
padding-bottom: calc(95px + env(safe-area-inset-bottom, 0px));
|
2026-02-09 19:25:51 +08:00
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
/* 确保内容足够高以便滚动 */
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--spacing-md);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-state {
|
|
|
|
|
|
padding: var(--spacing-3xl) var(--spacing-md);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 在移动设备上优化滚动体验 */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.statistics-scroll-content {
|
|
|
|
|
|
/* iOS Safari 优化 */
|
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
/* 防止橡皮筋效果 */
|
|
|
|
|
|
overscroll-behavior-y: contain;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.statistics-content {
|
|
|
|
|
|
padding: var(--spacing-sm);
|
2026-02-11 13:00:01 +08:00
|
|
|
|
padding-bottom: calc(95px + env(safe-area-inset-bottom, 0px));
|
2026-02-09 19:25:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-15 10:10:28 +08:00
|
|
|
|
</style>
|