1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 32s
Docker Build & Deploy / Deploy to Production (push) Successful in 13s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
孙诚
2026-01-15 22:10:57 +08:00
parent 71a8707241
commit 9069e3dbcf
5 changed files with 426 additions and 217 deletions

View File

@@ -69,36 +69,28 @@
<div v-if="!firstLoading" class="statistics-content">
<transition :name="transitionName" mode="out-in">
<div :key="dateKey">
<!-- 趋势统计 -->
<div class="common-card">
<div class="card-header">
<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>
<!-- 分类统计 -->
<div class="common-card">
<div class="card-header">
<h3 class="card-title">支出分类统计</h3>
<van-tag type="primary" size="medium">{{ expenseCategoriesView.length }}</van-tag>
</div>
<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">
<svg viewBox="0 0 200 200" class="ring-svg">
<circle
v-for="(segment, index) in chartSegments"
:key="index"
cx="100"
cy="100"
r="70"
fill="none"
:stroke="segment.color"
:stroke-width="35"
:stroke-dasharray="`${segment.length} ${circumference - segment.length}`"
:stroke-dashoffset="-segment.offset"
transform="rotate(-90 100 100)"
class="ring-segment"
/>
</svg>
<div class="ring-center">
<div class="center-value">¥{{ formatMoney(monthlyData.totalExpense) }}</div>
<div class="center-label">总支出</div>
</div>
<div ref="pieChartRef" style="width: 100%; height: 100%;"></div>
</div>
</div>
@@ -130,144 +122,96 @@
description="本月暂无支出记录"
image="search"
/>
</div>
<!-- 收入分类统计 -->
<div v-if="incomeCategoriesView.length > 0" class="common-card">
<div class="card-header">
<h3 class="card-title">收入分类统计</h3>
<van-tag type="success" size="medium">{{ incomeCategoriesView.length }}</van-tag>
</div>
<div class="category-list">
<div
v-for="category in incomeCategoriesView"
:key="category.isOther ? 'other' : category.classify"
class="category-item clickable"
@click="category.isOther ? (showAllIncome = true) : goToCategoryBills(category.classify, 1)"
>
<div class="category-info">
<div class="category-color income-color"></div>
<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 income-text">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
</div>
</div>
</div>
<!-- 不计收支分类统计 -->
<div v-if="noneCategoriesView.length > 0" class="common-card">
<div class="card-header">
<h3 class="card-title">不计收支分类统计</h3>
<van-tag type="info" size="medium">{{ noneCategoriesView.length }}</van-tag>
</div>
<div class="category-list">
<div
v-for="category in noneCategoriesView"
:key="category.isOther ? 'other' : category.classify"
class="category-item clickable"
@click="category.isOther ? (showAllNone = true) : goToCategoryBills(category.classify, 2)"
>
<div class="category-info">
<div class="category-color none-color"></div>
<div class="category-name-with-count">
<span class="category-name">{{ category.classify || '未分类' }}</span>
<span class="category-count">{{ category.count }}</span>
</div>
<!-- 收入分类统计 -->
<div v-if="incomeCategoriesView.length > 0" class="common-card">
<div class="card-header">
<h3 class="card-title">收入分类统计</h3>
<van-tag type="success" size="medium">{{ incomeCategoriesView.length }}</van-tag>
</div>
<div class="category-stats">
<div class="category-amount none-text">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
</div>
</div>
</div>
<!-- 趋势统计 -->
<div class="common-card">
<div class="card-header">
<h3 class="card-title">近6个月趋势</h3>
</div>
<div class="trend-chart">
<div class="trend-bars">
<div
v-for="item in trendData"
:key="item.month"
class="trend-bar-group"
>
<div class="bar-container">
<div
class="bar expense-bar"
:style="{ height: getBarHeight(item.expense, maxTrendValue) }"
>
<div v-if="item.expense > 0" class="bar-value">
{{ formatShortMoney(item.expense) }}
<div class="category-list">
<div
v-for="category in incomeCategoriesView"
:key="category.isOther ? 'other' : category.classify"
class="category-item clickable"
@click="category.isOther ? (showAllIncome = true) : goToCategoryBills(category.classify, 1)"
>
<div class="category-info">
<div class="category-color income-color"></div>
<div class="category-name-with-count">
<span class="category-name">{{ category.classify || '未分类' }}</span>
<span class="category-count">{{ category.count }}</span>
</div>
</div>
<div
class="bar income-bar"
:style="{ height: getBarHeight(item.income, maxTrendValue) }"
>
<div v-if="item.income > 0" class="bar-value">
{{ formatShortMoney(item.income) }}
<div class="category-stats">
<div class="category-amount income-text">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
</div>
</div>
</div>
<!-- 不计收支分类统计 -->
<div v-if="noneCategoriesView.length > 0" class="common-card">
<div class="card-header">
<h3 class="card-title">不计收支分类统计</h3>
<van-tag type="info" size="medium">{{ noneCategoriesView.length }}</van-tag>
</div>
<div class="category-list">
<div
v-for="category in noneCategoriesView"
:key="category.isOther ? 'other' : category.classify"
class="category-item clickable"
@click="category.isOther ? (showAllNone = true) : goToCategoryBills(category.classify, 2)"
>
<div class="category-info">
<div class="category-color none-color"></div>
<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 none-text">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
</div>
<div class="bar-label">{{ item.label }}</div>
</div>
</div>
<div class="trend-legend">
<div class="legend-item">
<div class="legend-color expense-color"></div>
<span>支出</span>
</div>
<div class="legend-item">
<div class="legend-color income-color"></div>
<span>收入</span>
</div>
</div>
</div>
</div>
<!-- 其他统计 -->
<div class="common-card">
<div class="card-header">
<h3 class="card-title">其他统计</h3>
</div>
<div class="other-stats">
<div class="stat-item">
<div class="stat-label">日均支出</div>
<div class="stat-value">¥{{ formatMoney(dailyAverage.expense) }}</div>
<!-- 其他统计 -->
<div class="common-card">
<div class="card-header">
<h3 class="card-title">其他统计</h3>
</div>
<div class="other-stats">
<div class="stat-item">
<div class="stat-label">日均支出</div>
<div class="stat-value">¥{{ formatMoney(dailyAverage.expense) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">日均收入</div>
<div class="stat-value income-text">¥{{ formatMoney(dailyAverage.income) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最大单笔支出</div>
<div class="stat-value">¥{{ formatMoney(monthlyData.maxExpense) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最大单笔收入</div>
<div class="stat-value income-text">¥{{ formatMoney(monthlyData.maxIncome) }}</div>
</div>
</div>
</div>
<div class="stat-item">
<div class="stat-label">日均收入</div>
<div class="stat-value income-text">¥{{ formatMoney(dailyAverage.income) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最大单笔支出</div>
<div class="stat-value">¥{{ formatMoney(monthlyData.maxExpense) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最大单笔收入</div>
<div class="stat-value income-text">¥{{ formatMoney(monthlyData.maxIncome) }}</div>
</div>
</div>
</div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
</transition>
</div>
@@ -330,7 +274,8 @@ import { ref, computed, onMounted, onActivated, nextTick } from 'vue'
import { onBeforeUnmount } from 'vue'
import { showToast } from 'vant'
import { useRouter } from 'vue-router'
import { getMonthlyStatistics, getCategoryStatistics, getTrendStatistics } from '@/api/statistics'
import { getMonthlyStatistics, getCategoryStatistics, getDailyStatistics } 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'
@@ -462,6 +407,11 @@ const noneCategoriesView = computed(() => {
// 趋势数据
const trendData = ref([])
const dailyData = ref([])
const chartRef = ref(null)
const pieChartRef = ref(null)
let chartInstance = null
let pieChartInstance = null
// 日期范围
const minDate = new Date(2020, 0, 1)
@@ -500,13 +450,7 @@ const dailyAverage = computed(() => {
}
})
// 趋势图最大值
const maxTrendValue = computed(() => {
const allValues = trendData.value.flatMap(item => [item.expense, item.income])
return Math.max(...allValues, 1)
})
// 是否是当前月
// 日均统计
const isCurrentMonth = computed(() => {
const now = new Date()
return currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1
@@ -548,25 +492,6 @@ const formatMoney = (value) => {
return Number(value).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 格式化短金额k为单位
const formatShortMoney = (value) => {
if (!value) return '0'
if (value >= 10000) {
return (value / 10000).toFixed(1) + 'w'
}
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'k'
}
return value.toFixed(0)
}
// 获取柱状图高度
const getBarHeight = (value, maxValue) => {
if (!value || !maxValue) return '0%'
const percent = (value / maxValue) * 100
return Math.max(percent, 5) + '%' // 最小5%以便显示
}
// 切换月份
const changeMonth = (offset) => {
transitionName.value = offset > 0 ? 'slide-left' : 'slide-right'
@@ -639,7 +564,7 @@ const fetchStatistics = async (showLoading = true) => {
await Promise.all([
fetchMonthlyData(),
fetchCategoryData(),
fetchTrendData()
fetchDailyData()
])
} catch (error) {
console.error('获取统计数据失败:', error)
@@ -647,6 +572,11 @@ const fetchStatistics = async (showLoading = true) => {
} finally {
loading.value = false
firstLoading.value = false
// DOM 更新后渲染图表
nextTick(() => {
renderChart(dailyData.value)
renderPieChart()
})
}
}
@@ -724,39 +654,273 @@ const fetchCategoryData = async () => {
}
}
// 获取趋势数据
const fetchTrendData = async () => {
// 获取每日统计数据并渲染图表
const fetchDailyData = async () => {
try {
// 计算开始年月当前月往前推5个月
let startYear = currentYear.value
let startMonth = currentMonth.value - 5
if (startMonth <= 0) {
startMonth += 12
startYear--
}
const response = await getTrendStatistics({
startYear,
startMonth,
monthCount: 6
const response = await getDailyStatistics({
year: currentYear.value,
month: currentMonth.value
})
if (response.success && response.data) {
trendData.value = response.data.map(item => ({
year: item.year,
month: item.month,
label: `${item.month}`,
expense: item.expense,
income: item.income
}))
dailyData.value = response.data
// 如果不是首次加载即DOM已存在直接渲染
if (!firstLoading.value) {
nextTick(() => {
renderChart(response.data)
})
}
}
} catch (error) {
console.error('获取趋势数据失败:', error)
showToast('获取趋势数据失败')
console.error('获取每日统计数据失败:', error)
showToast('获取每日统计数据失败')
}
}
const renderChart = (data) => {
if (!chartRef.value) return
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 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: ['支出', '收入', '存款'],
bottom: 0,
textStyle: {
color: '#999' // 适配深色模式
}
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
color: '#999' // 适配深色模式
}
},
yAxis: {
type: 'value',
interval: 1000, // 固定间隔1k
axisLabel: {
color: '#999', // 适配深色模式
formatter: (value) => {
return (value / 1000) + 'k'
}
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#333' // 深色分割线
}
}
},
series: [
{
name: '支出',
type: 'line',
data: expenses,
itemStyle: { color: '#FF6B6B' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '收入',
type: 'line',
data: incomes,
itemStyle: { color: '#4ECDC4' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '存款',
type: 'line',
data: balances,
itemStyle: { color: '#FFAB73' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
}
]
}
chartInstance.setOption(option)
}
const renderPieChart = () => {
if (!pieChartRef.value) return
if (expenseCategoriesView.value.length === 0) return
if (!pieChartInstance) {
pieChartInstance = echarts.init(pieChartRef.value)
}
// 使用 Top N + Other 的数据逻辑,确保图表不会太拥挤
const list = [...expenseCategoriesView.value]
let chartData = []
// 按照金额排序
list.sort((a, b) => b.amount - a.amount)
if (list.length <= 8) {
chartData = list.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: colors[index % colors.length] }
}))
} else {
const top = list.slice(0, 7)
const rest = list.slice(7)
chartData = top.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: colors[index % colors.length] }
}))
const otherAmount = rest.reduce((s, c) => s + c.amount, 0)
if (otherAmount > 0) {
chartData.push({
value: otherAmount,
name: '其他',
itemStyle: { color: '#AAB7B8' }
})
}
}
const option = {
title: {
text: '¥' + formatMoney(monthlyData.value.totalExpense),
subtext: '总支出',
left: 'center',
top: 'center',
textStyle: {
color: '#fff', // 适配深色模式
fontSize: 20,
fontWeight: 'bold'
},
subtextStyle: {
color: '#999',
fontSize: 13
}
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
series: [
{
name: '支出分类',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: true,
minAngle: 5, // 最小扇区角度,防止扇区太小看不见
itemStyle: {
borderRadius: 5,
borderColor: '#1a1a1a',
borderWidth: 2
},
label: {
show: true,
position: 'outside',
formatter: '{b}',
color: '#ccc', // 适配深色模式
overflow: 'none' // 禁止文本截断
},
labelLine: {
show: true,
lineStyle: {
color: '#666'
}
},
data: chartData
}
]
}
pieChartInstance.setOption(option)
}
// 跳转到智能分析页面
const goToAnalysis = () => {
router.push('/bill-analysis')
@@ -944,8 +1108,14 @@ const handleNotifiedTransactionId = async (transactionId) => {
// 初始化
onMounted(() => {
fetchStatistics()
window.addEventListener('resize', handleResize)
})
const handleResize = () => {
chartInstance && chartInstance.resize()
pieChartInstance && pieChartInstance.resize()
}
// 页面激活时刷新数据(从其他页面返回时)
onActivated(() => {
fetchStatistics()
@@ -961,6 +1131,9 @@ window.addEventListener && window.addEventListener('transaction-deleted', onGlob
onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
window.removeEventListener('resize', handleResize)
chartInstance && chartInstance.dispose()
pieChartInstance && pieChartInstance.dispose()
})
const onGlobalTransactionsChanged = () => {
@@ -1157,12 +1330,12 @@ onBeforeUnmount(() => {
/* 环形图 */
.chart-container {
padding: 20px;
padding: 12px 0;
}
.ring-chart {
position: relative;
width: 200px;
width: 100%;
height: 200px;
margin: 0 auto;
}