Files
EmailBill/Web/src/views/StatisticsView.vue
SunCheng 937e8db776
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 28s
Docker Build & Deploy / Deploy to Production (push) Successful in 11s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
fix bugs
2026-01-21 19:51:41 +08:00

1697 lines
42 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>{{ currentYear }}{{ currentMonth }}</span>
<van-icon name="arrow-down" />
</div>
</template>
<template #right>
<van-icon
name="chat-o"
size="20"
@click="goToAnalysis"
/>
</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: 130px; padding: 0"
>
<div
ref="balanceChartRef"
style="width: 100%; height: 100%"
/>
</div>
</div>
<!-- 趋势统计 -->
<div
class="common-card"
style="padding-bottom: 5px; margin-top: 12px;"
>
<div
class="card-header"
style="padding-bottom: 0;"
>
<h3 class="card-title">
收支趋势
</h3>
</div>
<div
class="trend-chart"
style="height: 240px; padding: 10px 0"
>
<div
ref="chartRef"
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"
>
<van-date-picker
v-model="selectedDate"
title="选择月份"
:min-date="minDate"
:max-date="maxDate"
:columns-type="['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"
/>
</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 { getCssVar } from '@/utils/theme'
const router = useRouter()
// 响应式数据
const loading = ref(true)
const firstLoading = ref(true)
const refreshing = ref(false)
const showMonthPicker = ref(false)
const showAllExpense = ref(false)
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 chartRef = ref(null)
const pieChartRef = ref(null)
const balanceChartRef = ref(null)
let chartInstance = 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 = 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(() => {
renderChart(dailyData.value)
renderPieChart()
renderBalanceChart()
})
}
}
// 获取月度数据
const fetchMonthlyData = async () => {
try {
const response = await getMonthlyStatistics({
year: currentYear.value,
month: currentMonth.value
})
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,
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,
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,
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
})
if (response.success && response.data) {
dailyData.value = response.data
// 如果不是首次加载即DOM已存在直接渲染
if (!firstLoading.value) {
nextTick(() => {
renderChart(response.data)
})
}
}
} catch (error) {
console.error('获取每日统计数据失败:', error)
showToast('获取每日统计数据失败')
}
}
// 获取余额统计数据(独立接口)
const fetchBalanceData = async () => {
try {
const response = await getBalanceStatistics({
year: currentYear.value,
month: currentMonth.value
})
if (response.success && response.data) {
balanceData.value = response.data
}
} catch (error) {
console.error('获取余额统计数据失败:', error)
showToast('获取余额统计数据失败')
}
}
const renderChart = (data) => {
if (!chartRef.value) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(chartRef.value)
// 如果当前保存的实例与DOM不一致或者DOM上已经有实例但我们没保存引用
if (chartInstance && chartInstance !== existingInstance) {
// 这种情况很少见,但为了保险,销毁旧的引用
if (!chartInstance.isDisposed()) {
chartInstance.dispose()
}
chartInstance = null
}
// 如果DOM变了transition导致的旧的chartInstance绑定的DOM已经不在了
// 这时 chartInstance.getDom() !== chartRef.value
if (chartInstance && chartInstance.getDom() !== chartRef.value) {
chartInstance.dispose()
chartInstance = null
}
// 如果DOM上已经有实例可能由其他途径创建复用它
if (!chartInstance && existingInstance) {
chartInstance = existingInstance
}
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value)
}
// 补全当月所有日期
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()
data.forEach((item) => {
const day = new Date(item.date).getDate()
dataMap.set(day, item)
})
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
})
}
}
const dates = fullData.map((item) => {
const date = new Date(item.date)
return `${date.getDate()}`
})
// Calculate cumulative values
let accumulatedExpense = 0
let accumulatedIncome = 0
let accumulatedBalance = 0
const expenses = fullData.map((item) => {
accumulatedExpense += item.expense
return accumulatedExpense
})
const incomes = fullData.map((item) => {
accumulatedIncome += item.income
return accumulatedIncome
})
const balances = fullData.map((item) => {
accumulatedBalance += item.balance
return accumulatedBalance
})
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') // 适配深色模式
}
},
yAxis: {
type: 'value',
splitNumber: 5,
axisLabel: {
color: getCssVar('--chart-text-muted'), // 适配深色模式
formatter: (value) => {
return value / 1000 + 'k'
}
},
splitLine: {
lineStyle: {
type: 'dashed',
color: getCssVar('--van-border-color') // 深色分割线
}
}
},
series: [
{
name: '支出',
type: 'line',
data: expenses,
itemStyle: { color: getCssVar('--chart-color-1') },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '收入',
type: 'line',
data: incomes,
itemStyle: { color: getCssVar('--chart-color-2') },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '存款',
type: 'line',
data: balances,
itemStyle: { color: getCssVar('--chart-color-13') },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
}
]
}
chartInstance.setOption(option)
}
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: '{b}: {c} ({d}%)'
},
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) {
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 dates = balanceData.value.map((item) => {
const date = new Date(item.date)
return `${date.getMonth() + 1}/${date.getDate()}`
})
const balances = balanceData.value.map((item) => item.cumulativeBalance)
const option = {
tooltip: {
trigger: 'axis',
formatter: function (params) {
if (params.length === 0) {
return ''
}
const param = params[0]
return `${param.name}<br/>余额: ¥${formatMoney(param.value)}`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '5%',
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: balances,
itemStyle: { color: getCssVar('--chart-color-13') },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: getCssVar('--chart-color-13')
},
{
offset: 1,
color: getCssVar('--chart-color-13')
}
])
}
}
]
}
balanceChartInstance.setOption(option)
// 设置图表透明度
if (balanceChartRef.value) {
balanceChartRef.value.style.opacity = '0.85'
}
}
// 跳转到智能分析页面
const goToAnalysis = () => {
router.push('/bill-analysis')
}
// 打开分类账单列表
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,
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 = () => {
chartInstance && chartInstance.resize()
pieChartInstance && pieChartInstance.resize()
balanceChartInstance && balanceChartInstance.resize()
}
// 监听DOM引用变化确保在月份切换DOM重建后重新渲染图表
watch(chartRef, (newVal) => {
// 无论有没有数据只要DOM变了就尝试渲染
// 如果没有数据renderChart 内部也应该处理(或者我们可以传空数据)
if (newVal) {
setTimeout(() => {
// 传入当前 dailyData即使是空的renderChart 应该能处理
renderChart(dailyData.value || [])
chartInstance && chartInstance.resize()
}, 50)
}
})
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)
chartInstance && chartInstance.dispose()
pieChartInstance && pieChartInstance.dispose()
balanceChartInstance && balanceChartInstance.dispose()
})
const onGlobalTransactionsChanged = () => {
fetchStatistics()
}
window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => {
window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
})
</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);
}
/* 余额卡片 */
.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;
}
</style>