Files
EmailBill/Web/src/views/statisticsV2/Index.vue
SunCheng 045158730f
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 4m47s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
refactor: 整理组件目录结构
- TransactionDetail, CategoryBillPopup 移入 Transaction/
- BudgetTypeTabs 移入 Budget/
- GlassBottomNav, ModernEmpty 移入 Global/
- Icon, IconSelector, ClassifySelector 等 8 个通用组件移入 Common/
- 更新所有相关引用路径
2026-02-21 10:10:16 +08:00

816 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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/Common/DateSelectHeader.vue'
import TimePeriodTabs from '@/components/Common/TimePeriodTabs.vue'
import MonthlyExpenseCard from './modules/MonthlyExpenseCard.vue'
import ExpenseCategoryCard from './modules/ExpenseCategoryCard.vue'
import IncomeNoneCategoryCard from './modules/IncomeNoneCategoryCard.vue'
import CategoryBillPopup from '@/components/Transaction/CategoryBillPopup.vue'
import {
// 新统一接口
getDailyStatisticsByRange,
getSummaryByRange,
getCategoryStatisticsByRange,
getTrendStatistics,
// 旧接口(兼容性保留)
getMonthlyStatistics,
getCategoryStatistics,
getDailyStatistics
} from '@/api/statistics'
import { useMessageStore } from '@/stores/message'
import { getCssVar } from '@/utils/theme'
// 为组件缓存设置名称
defineOptions({
name: 'StatisticsV2View'
})
const router = useRouter()
const messageStore = useMessageStore()
// 主题
const theme = computed(() => (messageStore.isDarkMode ? 'dark' : 'light'))
// 状态管理
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 = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9'
]
// 计算属性
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) {
// 转换数据格式:添加完整的 date 字段
trendStats.value = dailyResult.data
.filter((item) => item != null)
.map((item) => ({
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
}))
}
} 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) {
// 计算年度汇总
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 }
)
monthlyStats.value = {
...yearTotal,
expenseCount: 0,
incomeCount: 0
}
trendStats.value = trendResult.data.map((item) => ({
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)
weekEnd.setDate(weekStart.getDate() + 7) // 修改:+7 天,因为 endDate 是不包含的
const startDateStr = formatDateToString(weekStart)
const endDateStr = formatDateToString(weekEnd)
// 使用新的统一接口获取周统计汇总
const weekSummaryResult = await getSummaryByRange({
startDate: startDateStr,
endDate: endDateStr
})
if (weekSummaryResult?.success && weekSummaryResult.data) {
monthlyStats.value = {
totalExpense: weekSummaryResult.data.totalExpense || 0,
totalIncome: weekSummaryResult.data.totalIncome || 0,
balance:
(weekSummaryResult.data.totalIncome || 0) - (weekSummaryResult.data.totalExpense || 0),
expenseCount: weekSummaryResult.data.expenseCount || 0,
incomeCount: weekSummaryResult.data.incomeCount || 0
}
}
// 使用新的统一接口获取周内每日统计
const dailyResult = await getDailyStatisticsByRange({
startDate: startDateStr,
endDate: endDateStr
})
if (dailyResult?.success && dailyResult.data) {
// ⚠️ 注意: 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
}
})
}
} catch (error) {
console.error('加载周度数据失败:', error)
}
}
// 加载分类统计
const loadCategoryStatistics = async (year, month) => {
try {
const categoryYear = year
// 如果是年度统计month应该传0表示查询全年
const categoryMonth = currentPeriod.value === 'year' ? 0 : month
// 对于周统计,使用日期范围进行分类统计
if (currentPeriod.value === 'week') {
const weekStart = getWeekStartDate(currentDate.value)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 7) // 修改:+7 天,因为 endDate 是不包含的
const startDateStr = formatDateToString(weekStart)
const endDateStr = formatDateToString(weekEnd)
// 使用新的统一接口并发加载支出、收入和不计收支分类
const [expenseResult, incomeResult, noneResult] = await Promise.allSettled([
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 0 }),
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 1 }),
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, 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()
// 处理支出分类结果
if (
expenseResult.status === 'fulfilled' &&
expenseResult.value?.success &&
expenseResult.value.data
) {
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]
}))
}
// 处理收入分类结果
if (
incomeResult.status === 'fulfilled' &&
incomeResult.value?.success &&
incomeResult.value.data
) {
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()
// 处理支出分类结果
if (
expenseResult.status === 'fulfilled' &&
expenseResult.value?.success &&
expenseResult.value.data
) {
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]
}))
}
// 处理收入分类结果
if (
incomeResult.status === 'fulfilled' &&
incomeResult.value?.success &&
incomeResult.value.data
) {
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': {
// 比较年月
return current.getFullYear() === now.getFullYear() && current.getMonth() === now.getMonth()
}
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
// 重置 touchEnd 值,防止使用上次的残留值
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 = (e) => {
// 如果 touchEnd 事件中还有 changedTouches,使用它来获取最终位置
if (e.changedTouches && e.changedTouches.length > 0) {
touchEndX.value = e.changedTouches[0].clientX
touchEndY.value = e.changedTouches[0].clientY
}
const deltaX = touchEndX.value - touchStartX.value
const deltaY = touchEndY.value - touchStartY.value
// 最小滑动距离阈值(像素)
const MIN_SWIPE_DISTANCE = 50
// 判断是否是水平滑动(水平距离大于垂直距离且超过阈值)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > MIN_SWIPE_DISTANCE) {
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 {
selectedDate.value = [currentDate.value.getFullYear(), currentDate.value.getMonth() + 1]
}
})
// 初始化
onMounted(() => {
// 设置默认选中日期
selectedDate.value = [currentDate.value.getFullYear(), currentDate.value.getMonth() + 1]
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;
background-color: var(--bg-primary);
/* 改善滚动性能 */
will-change: scroll-position;
/* 防止滚动卡顿 */
scroll-behavior: smooth;
}
.statistics-content {
padding: var(--spacing-md);
padding-bottom: calc(95px + env(safe-area-inset-bottom, 0px));
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);
padding-bottom: calc(95px + env(safe-area-inset-bottom, 0px));
}
}
</style>