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

@@ -101,3 +101,21 @@ export const getDailyStatisticsRange = (params) => {
params params
}) })
} }
/**
* 获取累积余额统计数据(用于余额卡片)
* @param {Object} params - 查询参数
* @param {number} params.year - 年份
* @param {number} params.month - 月份
* @returns {Promise<{success: boolean, data: Array}>}
* @returns {Array} data - 每日累积余额列表
* @returns {string} data[].date - 日期
* @returns {number} data[].cumulativeBalance - 累积余额
*/
export const getBalanceStatistics = (params) => {
return request({
url: '/TransactionRecord/GetBalanceStatistics',
method: 'get',
params
})
}

View File

@@ -31,7 +31,7 @@
--chart-color-10: #aab7b8; --chart-color-10: #aab7b8;
--chart-color-11: #ff8ed4; --chart-color-11: #ff8ed4;
--chart-color-12: #67e6dc; --chart-color-12: #67e6dc;
--chart-color-13: #ffab73; --chart-color-13: #5b8dee;
--chart-color-14: #c9b1ff; --chart-color-14: #c9b1ff;
--chart-color-15: #7bdff2; --chart-color-15: #7bdff2;
@@ -87,7 +87,6 @@ body {
background-color 0.5s; background-color 0.5s;
line-height: 1.6; line-height: 1.6;
font-family: font-family:
Inter,
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
'Segoe UI', 'Segoe UI',

View File

@@ -11,11 +11,25 @@
(月度) (月度)
</div> </div>
</div> </div>
<div class="gauge-wrapper">
<div <div
ref="monthGaugeRef" ref="monthGaugeRef"
class="chart-body gauge-chart" class="chart-body gauge-chart"
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }" :style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/> />
<div class="gauge-text-overlay">
<div
class="remaining-label"
>
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
</div>
<div
class="remaining-value"
>
¥{{ formatMoney(Math.max(0, overallStats.month.limit - overallStats.month.current)) }}
</div>
</div>
</div>
<div class="gauge-footer compact"> <div class="gauge-footer compact">
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">
@@ -40,11 +54,25 @@
(年度) (年度)
</div> </div>
</div> </div>
<div class="gauge-wrapper">
<div <div
ref="yearGaugeRef" ref="yearGaugeRef"
class="chart-body gauge-chart" class="chart-body gauge-chart"
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }" :style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/> />
<div class="gauge-text-overlay">
<div
class="remaining-label"
>
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
</div>
<div
class="remaining-value"
>
¥{{ formatMoney(Math.max(0, overallStats.year.limit - overallStats.year.current)) }}
</div>
</div>
</div>
<div class="gauge-footer compact"> <div class="gauge-footer compact">
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">
@@ -255,12 +283,13 @@ const updateSingleGauge = (chart, data, isExpense) => {
valueAnimation: true, valueAnimation: true,
fontSize: 24, // 字体调小 fontSize: 24, // 字体调小
offsetCenter: [0, -5], offsetCenter: [0, -5],
color: 'var(--van-text-color)', color: getCssVar('--van-text-color'),
formatter: '{value}%', formatter: '{value}%',
fontWeight: 'bold', fontWeight: 'bold',
fontFamily: 'DIN Alternate, system-ui' fontFamily: 'DIN Alternate, system-ui',
show: false
}, },
data: [{ value: displayRate }] data: [{ value: displayRate.toFixed(0) }]
} }
] ]
} }
@@ -872,6 +901,42 @@ onUnmounted(() => {
/* 减小内边距 */ /* 减小内边距 */
} }
.gauge-wrapper {
position: relative;
width: 100%;
height: 120px;
}
.gauge-text-overlay {
position: absolute;
bottom: 20%;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
}
.remaining-value {
font-size: 24px;
font-weight: bold;
font-family: DIN Alternate, system-ui;
color: var(--van-text-color);
line-height: 1;
transform-origin: center;
}
.remaining-label {
font-size: 10px;
color: var(--van-text-color-2);
margin-top: 4px;
font-family: system-ui;
transform-origin: center;
}
.chart-header { .chart-header {
margin-bottom: 12px; margin-bottom: 12px;
} }

View File

@@ -77,7 +77,7 @@
</van-tab> </van-tab>
<van-tab <van-tab
title="存款" title="计划"
:name="BudgetCategory.Savings" :name="BudgetCategory.Savings"
> >
<van-pull-refresh <van-pull-refresh
@@ -108,7 +108,7 @@
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label"> <div class="label">
目标 计划存款
</div> </div>
<div class="value"> <div class="value">
¥{{ formatMoney(budget.limit) }} ¥{{ formatMoney(budget.limit) }}

View File

@@ -9,9 +9,16 @@
/> />
<!-- 下拉刷新区域 --> <!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载提示 --> <!-- 加载提示 -->
<van-loading v-if="loading && !periodicList.length" vertical style="padding: 50px 0"> <van-loading
v-if="loading && !periodicList.length"
vertical
style="padding: 50px 0"
>
加载中... 加载中...
</van-loading> </van-loading>
@@ -23,10 +30,18 @@
class="periodic-list" class="periodic-list"
@load="onLoad" @load="onLoad"
> >
<van-cell-group v-for="item in periodicList" :key="item.id" inset class="periodic-item"> <van-cell-group
v-for="item in periodicList"
:key="item.id"
inset
class="periodic-item"
>
<van-swipe-cell> <van-swipe-cell>
<div @click="editPeriodic(item)"> <div @click="editPeriodic(item)">
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)"> <van-cell
:title="item.reason || '无摘要'"
:label="getPeriodicTypeText(item)"
>
<template #value> <template #value>
<div class="amount-info"> <div class="amount-info">
<span :class="['amount', item.type === 1 ? 'income' : 'expense']"> <span :class="['amount', item.type === 1 ? 'income' : 'expense']">
@@ -35,7 +50,10 @@
</div> </div>
</template> </template>
</van-cell> </van-cell>
<van-cell title="分类" :value="item.classify || '未分类'" /> <van-cell
title="分类"
:value="item.classify || '未分类'"
/>
<van-cell <van-cell
title="下次执行时间" title="下次执行时间"
:value="formatDateTime(item.nextExecuteTime) || '未设置'" :value="formatDateTime(item.nextExecuteTime) || '未设置'"
@@ -77,7 +95,13 @@
<!-- 底部新增按钮 --> <!-- 底部新增按钮 -->
<div class="bottom-button"> <div class="bottom-button">
<van-button type="primary" size="large" round icon="plus" @click="openAddDialog"> <van-button
type="primary"
size="large"
round
icon="plus"
@click="openAddDialog"
>
新增周期账单 新增周期账单
</van-button> </van-button>
</div> </div>
@@ -89,7 +113,10 @@
height="75%" height="75%"
> >
<van-form> <van-form>
<van-cell-group inset title="周期设置"> <van-cell-group
inset
title="周期设置"
>
<van-field <van-field
v-model="form.periodicTypeText" v-model="form.periodicTypeText"
is-link is-link
@@ -150,7 +177,10 @@
/> />
</van-cell-group> </van-cell-group>
<van-cell-group inset title="基本信息"> <van-cell-group
inset
title="基本信息"
>
<van-field <van-field
v-model="form.reason" v-model="form.reason"
name="reason" name="reason"
@@ -170,35 +200,68 @@
type="number" type="number"
:rules="[{ required: true, message: '请输入金额' }]" :rules="[{ required: true, message: '请输入金额' }]"
/> />
<van-field v-model="form.type" name="type" label="类型"> <van-field
v-model="form.type"
name="type"
label="类型"
>
<template #input> <template #input>
<van-radio-group v-model="form.type" direction="horizontal"> <van-radio-group
<van-radio :name="0"> 支出 </van-radio> v-model="form.type"
<van-radio :name="1"> 收入 </van-radio> direction="horizontal"
<van-radio :name="2"> 不计 </van-radio> >
<van-radio :value="0">
支出
</van-radio>
<van-radio :value="1">
收入
</van-radio>
<van-radio :value="2">
不计
</van-radio>
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
<van-field name="classify" label="分类"> <van-field
name="classify"
label="分类"
>
<template #input> <template #input>
<span v-if="!form.classify" style="color: var(--van-gray-5)">请选择交易分类</span> <span
v-if="!form.classify"
style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ form.classify }}</span> <span v-else>{{ form.classify }}</span>
</template> </template>
</van-field> </van-field>
<!-- 分类选择组件 --> <!-- 分类选择组件 -->
<ClassifySelector v-model="form.classify" :type="form.type" /> <ClassifySelector
v-model="form.classify"
:type="form.type"
/>
</van-cell-group> </van-cell-group>
</van-form> </van-form>
<template #footer> <template #footer>
<van-button round block type="primary" :loading="submitting" @click="submit"> <van-button
round
block
type="primary"
:loading="submitting"
@click="submit"
>
{{ isEdit ? '更新' : '确认添加' }} {{ isEdit ? '更新' : '确认添加' }}
</van-button> </van-button>
</template> </template>
</PopupContainer> </PopupContainer>
<!-- 周期类型选择器 --> <!-- 周期类型选择器 -->
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showPeriodicTypePicker"
position="bottom"
round
teleport="body"
>
<van-picker <van-picker
:columns="periodicTypeColumns" :columns="periodicTypeColumns"
@confirm="onPeriodicTypeConfirm" @confirm="onPeriodicTypeConfirm"
@@ -207,7 +270,12 @@
</van-popup> </van-popup>
<!-- 星期选择器 --> <!-- 星期选择器 -->
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showWeekdaysPicker"
position="bottom"
round
teleport="body"
>
<van-picker <van-picker
:columns="weekdaysColumns" :columns="weekdaysColumns"
@confirm="onWeekdaysConfirm" @confirm="onWeekdaysConfirm"
@@ -216,7 +284,12 @@
</van-popup> </van-popup>
<!-- 日期选择器 --> <!-- 日期选择器 -->
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showMonthDaysPicker"
position="bottom"
round
teleport="body"
>
<van-picker <van-picker
:columns="monthDaysColumns" :columns="monthDaysColumns"
@confirm="onMonthDaysConfirm" @confirm="onMonthDaysConfirm"
@@ -233,7 +306,9 @@ import { showToast, showConfirmDialog } from 'vant'
import { import {
getPeriodicList, getPeriodicList,
deletePeriodic as deletePeriodicApi, deletePeriodic as deletePeriodicApi,
togglePeriodicEnabled togglePeriodicEnabled,
createPeriodic,
updatePeriodic
} from '@/api/transactionPeriodic' } from '@/api/transactionPeriodic'
import PopupContainer from '@/components/PopupContainer.vue' import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue' import ClassifySelector from '@/components/ClassifySelector.vue'
@@ -429,10 +504,10 @@ const editPeriodic = (item) => {
form.id = item.id form.id = item.id
form.reason = item.reason form.reason = item.reason
form.amount = item.amount.toString() form.amount = item.amount.toString()
form.type = item.type form.type = parseInt(item.type)
form.classify = item.classify form.classify = item.classify
form.periodicType = item.periodicType form.periodicType = parseInt(item.periodicType)
form.periodicTypeText = periodicTypeColumns.find((t) => t.value === item.periodicType)?.text || '' form.periodicTypeText = periodicTypeColumns.find((t) => t.value === parseInt(item.periodicType))?.text || ''
// 解析周期配置 // 解析周期配置
if (item.periodicConfig) { if (item.periodicConfig) {
@@ -557,6 +632,105 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
form.monthDaysText = selectedOptions[0].text form.monthDaysText = selectedOptions[0].text
showMonthDaysPicker.value = false showMonthDaysPicker.value = false
} }
// 提交表单
const submit = async () => {
// 验证基本字段
if (!form.reason.trim()) {
showToast('请输入摘要')
return
}
if (!form.amount) {
showToast('请输入金额')
return
}
if (form.amount < 0) {
showToast('金额必须大于0')
return
}
if (!form.classify) {
showToast('请选择分类')
return
}
// 验证周期配置
if (!form.periodicTypeText) {
showToast('请选择周期类型')
return
}
let periodicConfig = ''
switch (form.periodicType) {
case 0: // 每天
periodicConfig = ''
break
case 1: // 每周
if (form.weekdays.length === 0) {
showToast('请选择星期几')
return
}
periodicConfig = form.weekdays.join(',')
break
case 2: // 每月
if (form.monthDays.length === 0) {
showToast('请选择日期')
return
}
periodicConfig = form.monthDays.join(',')
break
case 3: // 每季度
if (!form.quarterDay) {
showToast('请输入季度开始后第几天')
return
}
periodicConfig = form.quarterDay.toString()
break
case 4: // 每年
if (!form.yearDay) {
showToast('请输入年开始后第几天')
return
}
periodicConfig = form.yearDay.toString()
break
}
submitting.value = true
try {
const requestData = {
periodicType: parseInt(form.periodicType),
periodicConfig: periodicConfig,
amount: parseFloat(form.amount),
type: parseInt(form.type),
classify: form.classify,
reason: form.reason.trim()
}
let response
if (isEdit.value) {
// 更新
requestData.id = form.id
requestData.isEnabled = true // Update API 需要此字段
response = await updatePeriodic(requestData)
} else {
// 创建
response = await createPeriodic(requestData)
}
if (response.success) {
showToast(isEdit.value ? '更新成功' : '添加成功')
dialogVisible.value = false
resetForm()
loadData(true)
} else {
showToast(response.message || (isEdit.value ? '更新失败' : '添加失败'))
}
} catch (error) {
console.error('提交出错:', error)
showToast(isEdit.value ? '更新出错' : '添加出错')
} finally {
submitting.value = false
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -42,12 +42,51 @@
class="statistics-content" class="statistics-content"
> >
<div> <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 <div
class="common-card" class="common-card"
style="padding-bottom: 5px; margin-top: 12px;" style="padding-bottom: 5px; margin-top: 12px;"
> >
<div class="card-header"> <div
class="card-header"
style="padding-bottom: 0;"
>
<h3 class="card-title"> <h3 class="card-title">
收支趋势 收支趋势
</h3> </h3>
@@ -288,7 +327,7 @@ import { ref, computed, onMounted, onActivated, nextTick, watch } from 'vue'
import { onBeforeUnmount } from 'vue' import { onBeforeUnmount } from 'vue'
import { showToast } from 'vant' import { showToast } from 'vant'
import { useRouter } from 'vue-router' 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 * as echarts from 'echarts'
import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord' import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue' import TransactionList from '@/components/TransactionList.vue'
@@ -392,10 +431,14 @@ const noneCategoriesView = computed(() => {
// 趋势数据 // 趋势数据
const dailyData = ref([]) const dailyData = ref([])
// 余额数据(独立)
const balanceData = ref([])
const chartRef = ref(null) const chartRef = ref(null)
const pieChartRef = ref(null) const pieChartRef = ref(null)
const balanceChartRef = ref(null)
let chartInstance = null let chartInstance = null
let pieChartInstance = null let pieChartInstance = null
let balanceChartInstance = null
// 日期范围 // 日期范围
const minDate = new Date(2020, 0, 1) const minDate = new Date(2020, 0, 1)
@@ -425,6 +468,50 @@ const isUnclassified = computed(() => {
return selectedClassify.value === '未分类' || selectedClassify.value === '' 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) => { const formatMoney = (value) => {
if (!value && value !== 0) { if (!value && value !== 0) {
@@ -463,7 +550,7 @@ const fetchStatistics = async (showLoading = true) => {
} }
try { try {
await Promise.all([fetchMonthlyData(), fetchCategoryData(), fetchDailyData()]) await Promise.all([fetchMonthlyData(), fetchCategoryData(), fetchDailyData(), fetchBalanceData()])
} catch (error) { } catch (error) {
console.error('获取统计数据失败:', error) console.error('获取统计数据失败:', error)
showToast('获取统计数据失败') showToast('获取统计数据失败')
@@ -474,6 +561,7 @@ const fetchStatistics = async (showLoading = true) => {
nextTick(() => { nextTick(() => {
renderChart(dailyData.value) renderChart(dailyData.value)
renderPieChart() 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) => { const renderChart = (data) => {
if (!chartRef.value) { if (!chartRef.value) {
return return
@@ -670,6 +775,12 @@ const renderChart = (data) => {
return accumulatedBalance 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 = { const option = {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
@@ -682,10 +793,14 @@ const renderChart = (data) => {
} }
}, },
legend: { legend: {
data: ['支出', '收入', '存款'], data: legendData.map((item) => item.name),
bottom: 0, bottom: 0,
textStyle: { textStyle: {
color: getCssVar('--chart-text-muted') // 适配深色模式 color: getCssVar('--chart-text-muted') // 适配深色模式
},
formatter: function (name) {
const item = legendData.find((d) => d.name === name)
return item ? `${name} ${item.value}` : name
} }
}, },
grid: { grid: {
@@ -876,6 +991,121 @@ const renderPieChart = () => {
pieChartInstance.setOption(option) 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 = () => { const goToAnalysis = () => {
router.push('/bill-analysis') router.push('/bill-analysis')
@@ -1061,6 +1291,7 @@ onMounted(() => {
const handleResize = () => { const handleResize = () => {
chartInstance && chartInstance.resize() chartInstance && chartInstance.resize()
pieChartInstance && pieChartInstance.resize() pieChartInstance && pieChartInstance.resize()
balanceChartInstance && balanceChartInstance.resize()
} }
// 监听DOM引用变化确保在月份切换DOM重建后重新渲染图表 // 监听DOM引用变化确保在月份切换DOM重建后重新渲染图表
@@ -1085,6 +1316,15 @@ watch(pieChartRef, (newVal) => {
} }
}) })
watch(balanceChartRef, (newVal) => {
if (newVal) {
setTimeout(() => {
renderBalanceChart()
balanceChartInstance && balanceChartInstance.resize()
}, 50)
}
})
// 页面激活时刷新数据(从其他页面返回时) // 页面激活时刷新数据(从其他页面返回时)
onActivated(() => { onActivated(() => {
fetchStatistics() fetchStatistics()
@@ -1105,6 +1345,7 @@ onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
chartInstance && chartInstance.dispose() chartInstance && chartInstance.dispose()
pieChartInstance && pieChartInstance.dispose() pieChartInstance && pieChartInstance.dispose()
balanceChartInstance && balanceChartInstance.dispose()
}) })
const onGlobalTransactionsChanged = () => { const onGlobalTransactionsChanged = () => {
@@ -1145,6 +1386,37 @@ onBeforeUnmount(() => {
color: var(--van-text-color); 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 { .chart-container {
padding: 0; padding: 0;
@@ -1411,8 +1683,8 @@ onBeforeUnmount(() => {
padding: 12px; padding: 12px;
} }
.side-by-side-cards .card-header { .card-header {
margin-bottom: 12px; margin-bottom: 0;
} }
.text-ellipsis { .text-ellipsis {

View File

@@ -252,6 +252,48 @@ public class TransactionRecordController(
} }
} }
/// <summary>
/// 获取累积余额统计数据(用于余额卡片图表)
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<BalanceStatisticsDto>>> GetBalanceStatisticsAsync(
[FromQuery] int year,
[FromQuery] int month
)
{
try
{
// 获取存款分类
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
// 获取每日统计数据
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month, savingClassify);
// 按日期排序并计算累积余额
var sortedStats = statistics.OrderBy(s => s.Key).ToList();
var result = new List<BalanceStatisticsDto>();
decimal cumulativeBalance = 0;
foreach (var item in sortedStats)
{
decimal dailyBalance = item.Value.income - item.Value.expense;
cumulativeBalance += dailyBalance;
result.Add(new BalanceStatisticsDto(
item.Key,
cumulativeBalance
));
}
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取累积余额统计失败,年份: {Year}, 月份: {Month}", year, month);
return $"获取累积余额统计失败: {ex.Message}".Fail<List<BalanceStatisticsDto>>();
}
}
/// <summary> /// <summary>
/// 获取指定月份每天的消费统计 /// 获取指定月份每天的消费统计
/// </summary> /// </summary>
@@ -311,7 +353,7 @@ public class TransactionRecordController(
s.Value.count, s.Value.count,
s.Value.expense, s.Value.expense,
s.Value.income, s.Value.income,
s.Value.income - s.Value.expense s.Value.saving
)).ToList(); )).ToList();
return result.Ok(); return result.Ok();
@@ -778,6 +820,14 @@ public record DailyStatisticsDto(
decimal Balance decimal Balance
); );
/// <summary>
/// 累积余额统计DTO
/// </summary>
public record BalanceStatisticsDto(
string Date,
decimal CumulativeBalance
);
/// <summary> /// <summary>
/// 智能分类请求DTO /// 智能分类请求DTO
/// </summary> /// </summary>