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

This commit is contained in:
SunCheng
2026-01-21 16:09:38 +08:00
parent c2a27abcac
commit b2e903e968
7 changed files with 626 additions and 48 deletions

View File

@@ -42,12 +42,51 @@
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">
<div
class="card-header"
style="padding-bottom: 0;"
>
<h3 class="card-title">
收支趋势
</h3>
@@ -288,7 +327,7 @@ 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 } from '@/api/statistics'
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'
@@ -392,10 +431,14 @@ const noneCategoriesView = computed(() => {
// 趋势数据
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)
@@ -425,6 +468,50 @@ 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) {
@@ -463,7 +550,7 @@ const fetchStatistics = async (showLoading = true) => {
}
try {
await Promise.all([fetchMonthlyData(), fetchCategoryData(), fetchDailyData()])
await Promise.all([fetchMonthlyData(), fetchCategoryData(), fetchDailyData(), fetchBalanceData()])
} catch (error) {
console.error('获取统计数据失败:', error)
showToast('获取统计数据失败')
@@ -474,6 +561,7 @@ const fetchStatistics = async (showLoading = true) => {
nextTick(() => {
renderChart(dailyData.value)
renderPieChart()
renderBalanceChart()
})
}
}
@@ -576,6 +664,23 @@ const fetchDailyData = async () => {
}
}
// 获取余额统计数据(独立接口)
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
@@ -670,6 +775,12 @@ const renderChart = (data) => {
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',
@@ -682,10 +793,14 @@ const renderChart = (data) => {
}
},
legend: {
data: ['支出', '收入', '存款'],
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: {
@@ -876,6 +991,121 @@ const renderPieChart = () => {
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')
@@ -1061,6 +1291,7 @@ onMounted(() => {
const handleResize = () => {
chartInstance && chartInstance.resize()
pieChartInstance && pieChartInstance.resize()
balanceChartInstance && balanceChartInstance.resize()
}
// 监听DOM引用变化确保在月份切换DOM重建后重新渲染图表
@@ -1085,6 +1316,15 @@ watch(pieChartRef, (newVal) => {
}
})
watch(balanceChartRef, (newVal) => {
if (newVal) {
setTimeout(() => {
renderBalanceChart()
balanceChartInstance && balanceChartInstance.resize()
}, 50)
}
})
// 页面激活时刷新数据(从其他页面返回时)
onActivated(() => {
fetchStatistics()
@@ -1105,6 +1345,7 @@ onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
chartInstance && chartInstance.dispose()
pieChartInstance && pieChartInstance.dispose()
balanceChartInstance && balanceChartInstance.dispose()
})
const onGlobalTransactionsChanged = () => {
@@ -1145,6 +1386,37 @@ onBeforeUnmount(() => {
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;
background: rgba(0, 0, 0, 0.02);
padding: 10px 0;
margin: 0 -12px;
}
/* 环形图 */
.chart-container {
padding: 0;
@@ -1411,8 +1683,8 @@ onBeforeUnmount(() => {
padding: 12px;
}
.side-by-side-cards .card-header {
margin-bottom: 12px;
.card-header {
margin-bottom: 0;
}
.text-ellipsis {