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

766 lines
22 KiB
Vue
Raw Normal View History

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>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeTab"
@tab-click="handleTabClick"
/>
<!-- 分类账单弹窗 -->
<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'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
import { getMonthlyStatistics, getCategoryStatistics, getCategoryStatisticsByDateRange, getDailyStatistics, getTrendStatistics, getWeeklyStatistics, getRangeStatistics } 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 activeTab = ref('statistics')
const handleTabClick = (_item, _index) => {
// 导航逻辑已在组件内部处理
}
// 状态管理
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) {
trendStats.value = dailyResult.data.filter(item => item != null)
}
} 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() + 6)
// 获取周统计汇总
const weekSummaryResult = await getRangeStatistics({
startDate: formatDateToString(weekStart),
endDate: formatDateToString(weekEnd)
})
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 getWeeklyStatistics({
startDate: formatDateToString(weekStart),
endDate: formatDateToString(weekEnd)
})
if (dailyResult?.success && dailyResult.data) {
// 转换数据格式以适配图表组件
trendStats.value = dailyResult.data.map(item => ({
date: item.date,
amount: (item.income || 0) - (item.expense || 0),
count: item.count || 0
}))
}
} catch (error) {
console.error('加载周度数据失败:', error)
}
}
// 加载分类统计
const loadCategoryStatistics = async (year, month) => {
try {
const categoryYear = year
const categoryMonth = month
// 对于周统计,使用日期范围进行分类统计
if (currentPeriod.value === 'week') {
const weekStart = getWeekStartDate(currentDate.value)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 6)
weekEnd.setHours(23, 59, 59, 999)
const startDateStr = formatDateToString(weekStart)
const endDateStr = formatDateToString(weekEnd)
// 并发加载支出、收入和不计收支分类(使用日期范围)
const [expenseResult, incomeResult, noneResult] = await Promise.allSettled([
getCategoryStatisticsByDateRange({ startDate: startDateStr, endDate: endDateStr, type: 0 }),
getCategoryStatisticsByDateRange({ startDate: startDateStr, endDate: endDateStr, type: 1 }),
getCategoryStatisticsByDateRange({ 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
}
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
// 判断是否是水平滑动(水平距离大于垂直距离)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
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-secondary);
/* 改善滚动性能 */
will-change: scroll-position;
/* 防止滚动卡顿 */
scroll-behavior: smooth;
}
.statistics-content {
padding: var(--spacing-md);
padding-bottom: calc(80px + 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(90px + env(safe-area-inset-bottom, 0px));
}
}
</style>