Files
EmailBill/Web/src/views/statisticsV1/Index.vue
SunCheng 3e18283e52 1
2026-02-09 19:25:51 +08:00

1703 lines
43 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>
<div class="page-container-flex">
<!-- 顶部导航栏 -->
<van-nav-bar
placeholder
>
<template #title>
<div
class="nav-date-picker"
@click="showMonthPicker = true"
>
<span>{{ currentMonth === 0 ? `${currentYear}` : `${currentYear}${currentMonth}` }}</span>
<van-icon name="arrow-down" />
</div>
</template>
<template #right>
<div class="nav-right-buttons">
<van-icon
name="upgrade"
size="18"
style="margin-right: 12px;"
@click="goToStatisticsV2"
/>
<van-icon
name="chat-o"
size="20"
@click="goToAnalysis"
/>
</div>
</template>
</van-nav-bar>
<!-- 下拉刷新 -->
<van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 初始加载中 -->
<van-loading
v-if="loading && firstLoading"
vertical
style="padding: 100px 0"
>
加载统计数据中...
</van-loading>
<!-- 统计内容可滚动部分 -->
<div
v-if="!firstLoading"
class="statistics-content"
>
<div>
<!-- 余额卡片 -->
<div
class="common-card"
style="margin-top: 12px;"
>
<div
class="card-header"
style="padding-bottom: 0;"
>
<h3 class="card-title">
余额
</h3>
</div>
<!-- 余额金额 -->
<div class="balance-amount">
<span
class="balance-value"
:class="{ 'balance-positive': displayBalance >= 0, 'balance-negative': displayBalance < 0 }"
>
¥{{ formatMoney(displayBalance) }}
</span>
</div>
<!-- 余额变化图表融合收支趋势 -->
<div
class="balance-chart"
style="height: 190px; padding: 0"
>
<div
ref="balanceChartRef"
style="width: 100%; height: 100%"
/>
</div>
</div>
<!-- 分类统计 -->
<div
class="common-card"
style="padding-bottom: 10px;"
>
<div class="card-header">
<h3 class="card-title">
支出分类
</h3>
<van-tag
type="primary"
size="medium"
>
{{ expenseCategoriesView.length }}
</van-tag>
</div>
<!-- 环形图区域 -->
<div
v-if="expenseCategoriesView.length > 0"
class="chart-container"
>
<div class="ring-chart">
<div
ref="pieChartRef"
style="width: 100%; height: 100%"
/>
</div>
</div>
<!-- 分类列表 -->
<div
class="category-list"
>
<div
v-for="category in expenseCategoriesSimpView"
:key="category.classify"
class="category-item clickable"
@click="goToCategoryBills(category.classify, 0)"
>
<div class="category-info">
<div
class="category-color"
:style="{ backgroundColor: category.color }"
/>
<div class="category-name-with-count">
<span class="category-name">{{ category.classify || '未分类' }}</span>
<span class="category-count">{{ category.count }}</span>
</div>
</div>
<div class="category-stats">
<div class="category-amount">
¥{{ formatMoney(category.amount) }}
</div>
<div class="category-percent">
{{ category.percent }}%
</div>
</div>
<van-icon
name="arrow"
class="category-arrow"
/>
</div>
<!-- 展开/收起按钮 -->
<div
v-if="expenseCategoriesView.length > 1"
class="expand-toggle"
@click="showAllExpense = !showAllExpense"
>
<van-icon :name="showAllExpense ? 'arrow-up' : 'arrow-down'" />
</div>
</div>
<van-empty
v-if="!expenseCategoriesView || !expenseCategoriesView.length"
description="本月暂无支出记录"
image="search"
/>
</div>
<!-- 收支和不计收支并列显示 -->
<div
v-if="incomeCategoriesView.length > 0 || noneCategoriesView.length > 0"
class="side-by-side-cards"
>
<!-- 收入分类统计 -->
<div
v-if="incomeCategoriesView.length > 0"
class="common-card half-card"
>
<div class="card-header">
<h3 class="card-title">
收入
<span
class="income-text"
style="font-size: 13px; margin-left: 4px"
>
¥{{ formatMoney(monthlyData.totalIncome) }}
</span>
</h3>
<van-tag
type="success"
size="medium"
>
{{ incomeCategoriesView.length }}
</van-tag>
</div>
<div class="category-list">
<div
v-for="category in incomeCategoriesView"
:key="category.classify"
class="category-item clickable"
@click="goToCategoryBills(category.classify, 1)"
>
<div class="category-info">
<div class="category-color income-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount income-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
</div>
<!-- 不计收支分类统计 -->
<div
v-if="noneCategoriesView.length > 0"
class="common-card half-card"
>
<div class="card-header">
<h3 class="card-title">
不计收支
</h3>
<van-tag
type="warning"
size="medium"
>
{{ noneCategoriesView.length }}
</van-tag>
</div>
<div class="category-list">
<div
v-for="category in noneCategoriesView"
:key="category.classify"
class="category-item clickable"
@click="goToCategoryBills(category.classify, 2)"
>
<div class="category-info">
<div class="category-color none-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount none-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
</div>
</div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</div>
</div>
</van-pull-refresh>
<!-- 日期选择器 -->
<van-popup
v-model:show="showMonthPicker"
position="bottom"
round
teleport="body"
>
<div class="date-picker-header">
<van-tabs
v-model:active="dateSelectionMode"
line-width="20px"
:ellipsis="false"
>
<van-tab
title="按月"
name="month"
/>
<van-tab
title="按年"
name="year"
/>
</van-tabs>
</div>
<van-date-picker
v-model="selectedDate"
:title="dateSelectionMode === 'year' ? '选择年份' : '选择月份'"
:min-date="minDate"
:max-date="maxDate"
:columns-type="dateSelectionMode === 'year' ? ['year'] : ['year', 'month']"
@confirm="onMonthConfirm"
@cancel="showMonthPicker = false"
/>
</van-popup>
<!-- 分类账单列表弹出层 -->
<PopupContainer
v-model="billListVisible"
:title="selectedCategoryTitle"
:subtitle="categoryBillsTotal ? `共 ${categoryBillsTotal} 笔交易` : ''"
height="75%"
>
<template #header-actions>
<SmartClassifyButton
v-if="isUnclassified"
ref="smartClassifyButtonRef"
:transactions="categoryBills"
:on-before-classify="beforeSmartClassify"
@save="onSmartClassifySave"
@notify-doned-transaction-id="handleNotifiedTransactionId"
/>
</template>
<TransactionList
ref="transactionListRef"
:transactions="categoryBills"
:loading="billListLoading"
:finished="billListFinished"
:show-delete="true"
@load="loadCategoryBills"
@click="viewBillDetail"
@delete="handleCategoryBillsDelete"
/>
</PopupContainer>
<!-- 交易详情编辑组件 -->
<TransactionDetail
v-model:show="detailVisible"
:transaction="currentTransaction"
@save="onBillSave"
/>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeTab"
@tab-click="handleTabClick"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onActivated, nextTick, watch } from 'vue'
import { onBeforeUnmount } from 'vue'
import { showToast } from 'vant'
import { useRouter } from 'vue-router'
import { getMonthlyStatistics, getCategoryStatistics, getDailyStatistics, getBalanceStatistics } from '@/api/statistics'
import * as echarts from 'echarts'
import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
import { getCssVar } from '@/utils/theme'
const router = useRouter()
// 底部导航栏
const activeTab = ref('statistics')
const handleTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
// 导航逻辑已在组件内部处理
}
// 响应式数据
const loading = ref(true)
const firstLoading = ref(true)
const refreshing = ref(false)
const showMonthPicker = ref(false)
const showAllExpense = ref(false)
const dateSelectionMode = ref('month')
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth() + 1)
const selectedDate = ref([
new Date().getFullYear().toString(),
(new Date().getMonth() + 1).toString().padStart(2, '0')
])
const billListVisible = ref(false)
const billListLoading = ref(false)
const billListFinished = ref(false)
const categoryBills = ref([])
const categoryBillsTotal = ref(0)
const selectedCategoryTitle = ref('')
const selectedClassify = ref('')
const selectedType = ref(null)
const billPageIndex = ref(1)
const billPageSize = 20
// 详情编辑相关
const detailVisible = ref(false)
const currentTransaction = ref(null)
// 月度数据
const monthlyData = ref({
totalExpense: 0,
totalIncome: 0,
balance: 0,
expenseCount: 0,
incomeCount: 0,
totalCount: 0
})
// 分类数据
const expenseCategories = ref([])
const incomeCategories = ref([])
const noneCategories = ref([])
const expenseCategoriesSimpView = computed(() => {
const list = expenseCategoriesView.value
if (showAllExpense.value) {
return list
}
// 只展示未分类
const unclassified = list.filter((c) => c.classify === '未分类' || !c.classify)
if (unclassified.length > 0) {
return [...unclassified]
}
return []
})
const expenseCategoriesView = computed(() => {
const list = [...expenseCategories.value]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const incomeCategoriesView = computed(() => {
const list = [...incomeCategories.value]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const noneCategoriesView = computed(() => {
const list = [...noneCategories.value]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
// 趋势数据
const dailyData = ref([])
// 余额数据(独立)
const balanceData = ref([])
const pieChartRef = ref(null)
const balanceChartRef = ref(null)
let pieChartInstance = null
let balanceChartInstance = null
// 日期范围
const minDate = new Date(2020, 0, 1)
const maxDate = new Date()
// 颜色配置
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 isUnclassified = computed(() => {
return selectedClassify.value === '未分类' || selectedClassify.value === ''
})
// 当月累积余额
const currentMonthBalance = computed(() => {
if (balanceData.value.length === 0) {
return 0
}
// 获取最后一天的累积余额
return balanceData.value[balanceData.value.length - 1].cumulativeBalance || 0
})
// 显示的动画余额
const displayBalance = ref(0)
// 监听余额变化,执行动画
watch(currentMonthBalance, (newVal, oldVal) => {
if (oldVal === undefined) {
// 初始加载,直接设置,不需要动画
displayBalance.value = newVal
return
}
// 数字跳动动画
const duration = 800 // 动画持续时间(毫秒)
const startValue = oldVal
const endValue = newVal
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// 使用缓动函数easeOutQuad
const easeProgress = 1 - (1 - progress) * (1 - progress)
displayBalance.value = Math.round(startValue + (endValue - startValue) * easeProgress)
if (progress < 1) {
requestAnimationFrame(animate)
} else {
displayBalance.value = endValue
}
}
animate()
})
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 下拉刷新
const onRefresh = async () => {
await fetchStatistics(false)
refreshing.value = false
}
// 确认月份选择
const onMonthConfirm = ({ selectedValues }) => {
const newYear = parseInt(selectedValues[0])
const newMonth = dateSelectionMode.value === 'year' ? 0 : parseInt(selectedValues[1])
currentYear.value = newYear
currentMonth.value = newMonth
showMonthPicker.value = false
// 重置展开状态
showAllExpense.value = false
fetchStatistics()
}
// 获取统计数据
const fetchStatistics = async (showLoading = true) => {
if (showLoading && firstLoading.value) {
loading.value = true
}
try {
await Promise.all([fetchMonthlyData(), fetchCategoryData(), fetchDailyData(), fetchBalanceData()])
} catch (error) {
console.error('获取统计数据失败:', error)
showToast('获取统计数据失败')
} finally {
loading.value = false
firstLoading.value = false
// DOM 更新后渲染图表
nextTick(() => {
renderPieChart()
renderBalanceChart()
})
}
}
// 获取月度数据
const fetchMonthlyData = async () => {
try {
const response = await getMonthlyStatistics({
year: currentYear.value,
month: currentMonth.value || 0
})
if (response.success && response.data) {
monthlyData.value = response.data
}
} catch (error) {
console.error('获取月度数据失败:', error)
showToast('获取月度数据失败')
}
}
// 获取分类数据
const fetchCategoryData = async () => {
try {
// 获取支出分类
const expenseResponse = await getCategoryStatistics({
year: currentYear.value,
month: currentMonth.value || 0,
type: 0 // 支出
})
if (expenseResponse.success && expenseResponse.data) {
const currentColors = getChartColors()
expenseCategories.value = expenseResponse.data.map((item, index) => ({
classify: item.classify,
amount: item.amount,
count: item.count,
percent: item.percent,
color: currentColors[index % currentColors.length]
}))
}
// 获取收入分类
const incomeResponse = await getCategoryStatistics({
year: currentYear.value,
month: currentMonth.value || 0,
type: 1 // 收入
})
if (incomeResponse.success && incomeResponse.data) {
incomeCategories.value = incomeResponse.data.map((item) => ({
classify: item.classify,
amount: item.amount,
count: item.count,
percent: item.percent
}))
}
// 获取不计收支分类
const noneResponse = await getCategoryStatistics({
year: currentYear.value,
month: currentMonth.value || 0,
type: 2 // 不计收支
})
if (noneResponse.success && noneResponse.data) {
noneCategories.value = noneResponse.data.map((item) => ({
classify: item.classify,
amount: item.amount,
count: item.count,
percent: item.percent
}))
}
} catch (error) {
console.error('获取分类数据失败:', error)
showToast('获取分类数据失败')
}
}
// 获取每日统计数据并渲染图表
const fetchDailyData = async () => {
try {
const response = await getDailyStatistics({
year: currentYear.value,
month: currentMonth.value || 0
})
if (response.success && response.data) {
dailyData.value = response.data
// 如果不是首次加载即DOM已存在直接渲染
if (!firstLoading.value) {
nextTick(() => {
renderBalanceChart()
})
}
}
} catch (error) {
console.error('获取每日统计数据失败:', error)
showToast('获取每日统计数据失败')
}
}
// 获取余额统计数据(独立接口)
const fetchBalanceData = async () => {
try {
const response = await getBalanceStatistics({
year: currentYear.value,
month: currentMonth.value || 0
})
if (response.success && response.data) {
balanceData.value = response.data
// 如果不是首次加载,重新渲染余额图表
if (!firstLoading.value) {
nextTick(() => {
renderBalanceChart()
})
}
}
} catch (error) {
console.error('获取余额统计数据失败:', error)
showToast('获取余额统计数据失败')
}
}
const renderPieChart = () => {
if (!pieChartRef.value) {
return
}
if (expenseCategoriesView.value.length === 0) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(pieChartRef.value)
if (pieChartInstance && pieChartInstance !== existingInstance) {
if (!pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
pieChartInstance = null
}
if (pieChartInstance && pieChartInstance.getDom() !== pieChartRef.value) {
pieChartInstance.dispose()
pieChartInstance = null
}
if (!pieChartInstance && existingInstance) {
pieChartInstance = existingInstance
}
if (!pieChartInstance) {
pieChartInstance = echarts.init(pieChartRef.value)
}
// 使用 Top N + Other 的数据逻辑,确保图表不会太拥挤
const list = [...expenseCategoriesView.value]
let chartData = []
// 按照金额排序
list.sort((a, b) => b.amount - a.amount)
const MAX_SLICES = 8 // 最大显示扇区数,其余合并为"其他"
const currentColors = getChartColors()
if (list.length > MAX_SLICES) {
const topList = list.slice(0, MAX_SLICES - 1)
const otherList = list.slice(MAX_SLICES - 1)
const otherAmount = otherList.reduce((sum, item) => sum + item.amount, 0)
chartData = topList.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: currentColors[index % currentColors.length] }
}))
chartData.push({
value: otherAmount,
name: '其他',
itemStyle: { color: getCssVar('--van-gray-6') } // 使用灰色表示其他
})
} else {
chartData = list.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: currentColors[index % currentColors.length] }
}))
}
const option = {
title: {
text: '¥' + formatMoney(monthlyData.value.totalExpense),
subtext: '总支出',
left: 'center',
top: 'center',
textStyle: {
color: getCssVar('--chart-text-muted'), // 适配深色模式
fontSize: 20,
fontWeight: 'bold'
},
subtextStyle: {
color: getCssVar('--chart-text-muted'),
fontSize: 13
}
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent.toFixed(1)}%)`
}
},
series: [
{
name: '支出分类',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: true,
minAngle: 5, // 最小扇区角度,防止扇区太小看不见
itemStyle: {
borderRadius: 5,
borderColor: getCssVar('--van-background-2'),
borderWidth: 2
},
label: {
show: true,
position: 'outside',
formatter: '{b}',
color: getCssVar('--van-text-color-2') // 适配深色模式
},
labelLayout: {
hideOverlap: true // 隐藏重叠标签
},
labelLine: {
show: true,
lineStyle: {
color: getCssVar('--van-text-color-3')
},
smooth: 0.2,
length: 10,
length2: 20
},
data: chartData
}
]
}
pieChartInstance.setOption(option)
}
// 渲染余额变化图表(融合支出、收入、余额三条线)
const renderBalanceChart = () => {
if (!balanceChartRef.value) {
return
}
if (balanceData.value.length === 0 && dailyData.value.length === 0) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(balanceChartRef.value)
if (balanceChartInstance && balanceChartInstance !== existingInstance) {
if (!balanceChartInstance.isDisposed()) {
balanceChartInstance.dispose()
}
balanceChartInstance = null
}
if (balanceChartInstance && balanceChartInstance.getDom() !== balanceChartRef.value) {
balanceChartInstance.dispose()
balanceChartInstance = null
}
if (!balanceChartInstance && existingInstance) {
balanceChartInstance = existingInstance
}
if (!balanceChartInstance) {
balanceChartInstance = echarts.init(balanceChartRef.value)
}
// 判断是年度统计还是月度统计
const isYearlyView = currentMonth.value === 0
let dates, expenses, incomes, balances
if (isYearlyView) {
// 按年统计:按月聚合数据
const monthlyMap = new Map()
const balanceMonthlyMap = new Map()
// 聚合 dailyData 按月
dailyData.value.forEach((item) => {
const date = new Date(item.date)
const month = date.getMonth() + 1 // 1-12
if (!monthlyMap.has(month)) {
monthlyMap.set(month, { expense: 0, income: 0 })
}
const data = monthlyMap.get(month)
data.expense += item.expense
data.income += item.income
})
// 聚合 balanceData 按月(取每月最后一天的余额)
balanceData.value.forEach((item) => {
const date = new Date(item.date)
const month = date.getMonth() + 1
const day = date.getDate()
if (!balanceMonthlyMap.has(month) || day > balanceMonthlyMap.get(month).day) {
balanceMonthlyMap.set(month, { balance: item.cumulativeBalance, day })
}
})
// 构建12个月的完整数据
const now = new Date()
const currentMonthNum = now.getFullYear() === currentYear.value ? now.getMonth() + 1 : 12
dates = []
const monthlyExpenses = []
const monthlyIncomes = []
const monthlyBalances = []
let accumulatedExpense = 0
let accumulatedIncome = 0
for (let m = 1; m <= currentMonthNum; m++) {
dates.push(`${m}`)
const data = monthlyMap.get(m) || { expense: 0, income: 0 }
accumulatedExpense += data.expense
accumulatedIncome += data.income
monthlyExpenses.push(accumulatedExpense)
monthlyIncomes.push(accumulatedIncome)
const balanceData = balanceMonthlyMap.get(m)
monthlyBalances.push(balanceData ? balanceData.balance : 0)
}
expenses = monthlyExpenses
incomes = monthlyIncomes
balances = monthlyBalances
} else {
// 按月统计:按日显示
const now = new Date()
let daysInMonth
if (currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1) {
daysInMonth = now.getDate()
} else {
daysInMonth = new Date(currentYear.value, currentMonth.value, 0).getDate()
}
const fullData = []
const dataMap = new Map()
dailyData.value.forEach((item) => {
const day = new Date(item.date).getDate()
dataMap.set(day, item)
})
// 创建余额映射
const balanceMap = new Map()
if (balanceData.value && balanceData.value.length > 0) {
balanceData.value.forEach((item) => {
const day = new Date(item.date).getDate()
balanceMap.set(day, item.cumulativeBalance)
})
}
for (let i = 1; i <= daysInMonth; i++) {
const item = dataMap.get(i)
if (item) {
fullData.push(item)
} else {
fullData.push({
date: `${currentYear.value}-${String(currentMonth.value).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
count: 0,
expense: 0,
income: 0,
balance: 0
})
}
}
dates = fullData.map((item) => {
const date = new Date(item.date)
return `${date.getDate()}`
})
// 计算累计支出和收入
let accumulatedExpense = 0
let accumulatedIncome = 0
expenses = fullData.map((item) => {
accumulatedExpense += item.expense
return accumulatedExpense
})
incomes = fullData.map((item) => {
accumulatedIncome += item.income
return accumulatedIncome
})
// 使用余额接口数据
balances = fullData.map((item, index) => {
const day = index + 1
return balanceMap.get(day) || 0
})
}
const legendData = [
{ name: '支出', value: '¥' + formatMoney(expenses[expenses.length - 1]) },
{ name: '收入', value: '¥' + formatMoney(incomes[incomes.length - 1]) },
{ name: '余额', value: '¥' + formatMoney(balances[balances.length - 1]) }
]
const option = {
tooltip: {
trigger: 'axis',
formatter: function (params) {
let result = params[0].name + '<br/>'
params.forEach((param) => {
result += param.marker + param.seriesName + ': ¥' + formatMoney(param.value) + '<br/>'
})
return result
}
},
legend: {
data: legendData.map((item) => item.name),
bottom: 0,
textStyle: {
color: getCssVar('--chart-text-muted')
},
formatter: function (name) {
const item = legendData.find((d) => d.name === name)
return item ? `${name} ${item.value}` : name
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '5%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
color: getCssVar('--chart-text-muted'),
fontSize: 11
}
},
yAxis: {
type: 'value',
splitNumber: 4,
axisLabel: {
color: getCssVar('--chart-text-muted'),
fontSize: 11,
formatter: (value) => {
return value / 1000 + 'k'
}
},
splitLine: {
lineStyle: {
type: 'dashed',
color: getCssVar('--van-border-color')
}
}
},
series: [
{
name: '支出',
type: 'line',
data: expenses,
itemStyle: { color: '#ff6b6b' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '收入',
type: 'line',
data: incomes,
itemStyle: { color: '#51cf66' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '余额',
type: 'line',
data: balances,
itemStyle: { color: '#4c9cf1' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
}
]
}
balanceChartInstance.setOption(option)
}
// 跳转到智能分析页面
const goToAnalysis = () => {
router.push('/bill-analysis')
}
// 切换到统计V2页面
const goToStatisticsV2 = () => {
router.push('/statistics-v2')
}
// 打开分类账单列表
const goToCategoryBills = (classify, type) => {
selectedClassify.value = classify || '未分类' // TODO 如果是未分类的 添加智能分类按钮
selectedType.value = type
selectedCategoryTitle.value = `${classify || '未分类'} - ${type === 0 ? '支出' : '收入'}`
// 重置分页状态
categoryBills.value = []
categoryBillsTotal.value = 0
billPageIndex.value = 1
billListFinished.value = false
billListVisible.value = true
// 打开弹窗后加载数据
loadCategoryBills()
}
const smartClassifyButtonRef = ref(null)
const transactionListRef = ref(null)
// 加载分类账单数据
const loadCategoryBills = async (customIndex = null, customSize = null) => {
if (billListLoading.value || billListFinished.value) {
return
}
billListLoading.value = true
try {
const params = {
pageIndex: customIndex || billPageIndex.value,
pageSize: customSize || billPageSize,
type: selectedType.value,
year: currentYear.value,
month: currentMonth.value || 0,
sortByAmount: true
}
// 仅当选择了分类时才添加classify参数
if (selectedClassify.value !== null) {
params.classify = selectedClassify.value
}
const response = await getTransactionList(params)
if (response.success) {
const newList = response.data || []
categoryBills.value = [...categoryBills.value, ...newList]
categoryBillsTotal.value = response.total
if (newList.length === 0 || newList.length < billPageSize) {
billListFinished.value = true
} else {
billListFinished.value = false
billPageIndex.value++
}
smartClassifyButtonRef.value?.reset()
} else {
showToast(response.message || '加载账单失败')
billListFinished.value = true
}
} catch (error) {
console.error('加载分类账单失败:', error)
showToast('加载账单失败')
billListFinished.value = true
} finally {
billListLoading.value = false
}
}
// 查看账单详情
const viewBillDetail = async (transaction) => {
try {
const response = await getTransactionDetail(transaction.id)
if (response.success) {
currentTransaction.value = response.data
detailVisible.value = true
} else {
showToast(response.message || '获取详情失败')
}
} catch (error) {
console.error('获取详情出错:', error)
showToast('获取详情失败')
}
}
const handleCategoryBillsDelete = (deletedId) => {
categoryBills.value = categoryBills.value.filter((t) => t.id !== deletedId)
categoryBillsTotal.value--
// 被删除后刷新统计数据和账单列表
fetchStatistics()
}
// 账单保存后的回调
const onBillSave = async (updatedTransaction) => {
// 刷新统计数据
await fetchStatistics()
// 只刷新列表中指定的账单项
const item = categoryBills.value.find((t) => t.id === updatedTransaction.id)
if (!item) {
return
}
// 如果分类发生了变化
if (item.classify !== updatedTransaction.classify) {
// 从列表中移除该项
categoryBills.value = categoryBills.value.filter((t) => t.id !== updatedTransaction.id)
categoryBillsTotal.value--
// 通知智能分类按钮组件移除指定项
smartClassifyButtonRef.value?.removeClassifiedTransaction(updatedTransaction.id)
return
}
Object.assign(item, updatedTransaction)
showToast('保存成功')
}
const beforeSmartClassify = async () => {
showToast({
message: '加载完整账单列表,请稍候...',
duration: 0,
forbidClick: true
})
await loadCategoryBills(1, categoryBillsTotal.value || 1000)
}
// 智能分类保存后的回调
const onSmartClassifySave = async () => {
// 关闭账单列表弹窗
billListVisible.value = false
// 刷新统计数据
await fetchStatistics()
try {
window.dispatchEvent(
new CustomEvent('transactions-changed', {
detail: { reason: selectedClassify.value, type: selectedType.value }
})
)
} catch (e) {
console.error('触发 transactions-changed 事件失败:', e)
}
showToast('智能分类已保存')
}
const handleNotifiedTransactionId = async (transactionId) => {
console.info('收到已处理交易ID通知:', transactionId)
// 滚动到指定的交易项
const index = categoryBills.value.findIndex((item) => String(item.id) === String(transactionId))
if (index !== -1) {
// 等待 DOM 更新
await nextTick()
// 允许一丁点延迟让浏览器响应渲染
await new Promise((resolve) => setTimeout(resolve, 0))
const listElement = transactionListRef.value?.$el
if (listElement) {
const items = listElement.querySelectorAll('.transaction-item')
const itemElement = items[index]
if (itemElement) {
itemElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
}
// 初始化
onMounted(() => {
fetchStatistics()
window.addEventListener('resize', handleResize)
})
const handleResize = () => {
pieChartInstance && pieChartInstance.resize()
balanceChartInstance && balanceChartInstance.resize()
}
// 监听DOM引用变化确保在月份切换DOM重建后重新渲染图表
watch(pieChartRef, (newVal) => {
if (newVal) {
setTimeout(() => {
renderPieChart()
pieChartInstance && pieChartInstance.resize()
}, 50)
}
})
watch(balanceChartRef, (newVal) => {
if (newVal) {
setTimeout(() => {
renderBalanceChart()
balanceChartInstance && balanceChartInstance.resize()
}, 50)
}
})
// 页面激活时刷新数据(从其他页面返回时)
onActivated(() => {
fetchStatistics()
})
// 全局监听交易删除事件,确保统计数据一致
const onGlobalTransactionDeleted = () => {
// e.detail contains transaction id
fetchStatistics()
}
window.addEventListener &&
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => {
window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
window.removeEventListener('resize', handleResize)
pieChartInstance && pieChartInstance.dispose()
balanceChartInstance && balanceChartInstance.dispose()
})
const onGlobalTransactionsChanged = () => {
fetchStatistics()
}
window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => {
window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
})
// 监听日期选择模式变化更新selectedDate数组
watch(dateSelectionMode, (newMode) => {
if (newMode === 'year') {
// 切换到年份模式:只保留年份
selectedDate.value = [currentYear.value.toString()]
} else {
// 切换到月份模式:添加当前月份
selectedDate.value = [
currentYear.value.toString(),
(currentMonth.value || new Date().getMonth() + 1).toString().padStart(2, '0')
]
}
})
</script>
<style scoped>
.page-container-flex {
background: transparent !important;
}
.statistics-content {
padding: 0;
overflow-x: hidden; /* 防止滑动动画出现横向滚动条 */
}
:deep(.van-pull-refresh) {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* 导航栏日期选择器 */
.nav-date-picker {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: var(--van-text-color);
}
/* 导航栏右侧按钮组 */
.nav-right-buttons {
display: flex;
align-items: center;
}
/* 余额卡片 */
.balance-amount {
text-align: center;
padding: 9px 0 8px 0;
font-size: 32px;
font-weight: bold;
color: var(--van-text-color);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.balance-value {
color: var(--chart-color-13);
transition: color 0.3s ease;
}
.balance-value.balance-positive {
color: var(--van-success-color);
}
.balance-value.balance-negative {
color: var(--van-danger-color);
}
.balance-chart {
border-radius: 8px;
padding: 10px 0;
margin: 0 -12px;
}
/* 环形图 */
.chart-container {
padding: 0;
}
.ring-chart {
position: relative;
width: 100%;
height: 200px;
margin: 0 auto;
}
.ring-svg {
width: 100%;
height: 100%;
transform: scale(1);
}
.ring-segment {
transition: all 0.3s ease;
}
.ring-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.center-value {
font-size: 20px;
font-weight: 700;
color: var(--van-text-color);
margin-bottom: 4px;
}
.center-label {
font-size: 13px;
color: var(--van-text-color-2);
}
/* 分类列表 */
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-name-with-count {
display: flex;
align-items: center;
gap: 8px;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-count {
font-size: 12px;
color: var(--van-text-color-3);
}
.category-stats {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.category-arrow {
margin-left: 8px;
color: var(--van-text-color-3);
font-size: 16px;
flex-shrink: 0;
}
.expand-toggle {
display: flex;
justify-content: center;
align-items: center;
padding-top: 0;
color: var(--van-text-color-3);
font-size: 20px;
cursor: pointer;
}
.expand-toggle:active {
opacity: 0.7;
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
}
.category-percent {
font-size: 12px;
color: var(--van-text-color-3);
background: var(--van-background);
padding: 2px 8px;
border-radius: 10px;
}
.income-color {
background-color: var(--van-success-color);
}
.income-text {
color: var(--van-success-color);
}
/* 不计收支颜色 */
.none-color {
background-color: var(--van-gray-6);
}
.none-text {
color: var(--van-gray-6);
}
.expense-color {
background-color: var(--van-danger-color);
}
/* 趋势图 */
.trend-chart {
padding: 20px 16px;
}
.trend-bars {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 180px;
margin-bottom: 16px;
padding: 0 4px;
}
.trend-bar-group {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bar-container {
width: 100%;
height: 150px;
display: flex;
justify-content: center;
align-items: flex-end;
gap: 4px;
padding: 0 2px;
}
.bar {
flex: 1;
max-width: 20px;
min-height: 4px;
border-radius: 4px 4px 0 0;
position: relative;
transition: all 0.3s ease;
display: flex;
align-items: flex-start;
justify-content: center;
}
.expense-bar {
background: linear-gradient(180deg, #ff6b6b 0%, #ff8787 100%);
}
.income-bar {
background: linear-gradient(180deg, #51cf66 0%, #69db7c 100%);
}
.bar-value {
font-size: 10px;
color: var(--van-text-color-2);
white-space: nowrap;
margin-top: -18px;
}
.bar-label {
font-size: 11px;
color: var(--van-text-color-3);
}
.trend-legend {
display: flex;
justify-content: space-center;
gap: 24px;
padding-top: 12px;
border-top: 1px solid var(--van-border-color);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--van-text-color-2);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
/* 并列显示卡片 */
.side-by-side-cards {
display: flex;
gap: 12px;
margin: 0 12px 16px;
}
.side-by-side-cards .common-card {
margin: 0;
flex: 1;
min-width: 0; /* 允许内部元素缩小 */
padding: 12px;
}
.card-header {
margin-bottom: 0;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
/* 日期选择器头部 */
.date-picker-header {
padding: 12px 16px 0;
background: var(--van-background-2);
border-bottom: 1px solid var(--van-border-color);
}
.date-picker-header :deep(.van-tabs) {
background: transparent;
}
.date-picker-header :deep(.van-tabs__nav) {
background: transparent;
padding-bottom: 0;
}
.date-picker-header :deep(.van-tab) {
font-size: 15px;
font-weight: 500;
}
</style>