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
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:
@@ -101,3 +101,21 @@ export const getDailyStatisticsRange = (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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
--chart-color-10: #aab7b8;
|
||||
--chart-color-11: #ff8ed4;
|
||||
--chart-color-12: #67e6dc;
|
||||
--chart-color-13: #ffab73;
|
||||
--chart-color-13: #5b8dee;
|
||||
--chart-color-14: #c9b1ff;
|
||||
--chart-color-15: #7bdff2;
|
||||
|
||||
@@ -87,7 +87,6 @@ body {
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
|
||||
@@ -11,11 +11,25 @@
|
||||
(月度)
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="monthGaugeRef"
|
||||
class="chart-body gauge-chart"
|
||||
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
|
||||
/>
|
||||
<div class="gauge-wrapper">
|
||||
<div
|
||||
ref="monthGaugeRef"
|
||||
class="chart-body gauge-chart"
|
||||
: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-item">
|
||||
<span class="label">
|
||||
@@ -40,11 +54,25 @@
|
||||
(年度)
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="yearGaugeRef"
|
||||
class="chart-body gauge-chart"
|
||||
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
|
||||
/>
|
||||
<div class="gauge-wrapper">
|
||||
<div
|
||||
ref="yearGaugeRef"
|
||||
class="chart-body gauge-chart"
|
||||
: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-item">
|
||||
<span class="label">
|
||||
@@ -255,12 +283,13 @@ const updateSingleGauge = (chart, data, isExpense) => {
|
||||
valueAnimation: true,
|
||||
fontSize: 24, // 字体调小
|
||||
offsetCenter: [0, -5],
|
||||
color: 'var(--van-text-color)',
|
||||
color: getCssVar('--van-text-color'),
|
||||
formatter: '{value}%',
|
||||
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 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</van-tab>
|
||||
|
||||
<van-tab
|
||||
title="存款"
|
||||
title="计划"
|
||||
:name="BudgetCategory.Savings"
|
||||
>
|
||||
<van-pull-refresh
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">
|
||||
目标
|
||||
计划存款
|
||||
</div>
|
||||
<div class="value">
|
||||
¥{{ formatMoney(budget.limit) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,10 +30,18 @@
|
||||
class="periodic-list"
|
||||
@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>
|
||||
<div @click="editPeriodic(item)">
|
||||
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)">
|
||||
<van-cell
|
||||
:title="item.reason || '无摘要'"
|
||||
:label="getPeriodicTypeText(item)"
|
||||
>
|
||||
<template #value>
|
||||
<div class="amount-info">
|
||||
<span :class="['amount', item.type === 1 ? 'income' : 'expense']">
|
||||
@@ -35,7 +50,10 @@
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="分类" :value="item.classify || '未分类'" />
|
||||
<van-cell
|
||||
title="分类"
|
||||
:value="item.classify || '未分类'"
|
||||
/>
|
||||
<van-cell
|
||||
title="下次执行时间"
|
||||
:value="formatDateTime(item.nextExecuteTime) || '未设置'"
|
||||
@@ -77,7 +95,13 @@
|
||||
|
||||
<!-- 底部新增按钮 -->
|
||||
<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>
|
||||
</div>
|
||||
@@ -89,7 +113,10 @@
|
||||
height="75%"
|
||||
>
|
||||
<van-form>
|
||||
<van-cell-group inset title="周期设置">
|
||||
<van-cell-group
|
||||
inset
|
||||
title="周期设置"
|
||||
>
|
||||
<van-field
|
||||
v-model="form.periodicTypeText"
|
||||
is-link
|
||||
@@ -150,7 +177,10 @@
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group inset title="基本信息">
|
||||
<van-cell-group
|
||||
inset
|
||||
title="基本信息"
|
||||
>
|
||||
<van-field
|
||||
v-model="form.reason"
|
||||
name="reason"
|
||||
@@ -170,35 +200,68 @@
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入金额' }]"
|
||||
/>
|
||||
<van-field v-model="form.type" name="type" label="类型">
|
||||
<van-field
|
||||
v-model="form.type"
|
||||
name="type"
|
||||
label="类型"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group v-model="form.type" direction="horizontal">
|
||||
<van-radio :name="0"> 支出 </van-radio>
|
||||
<van-radio :name="1"> 收入 </van-radio>
|
||||
<van-radio :name="2"> 不计 </van-radio>
|
||||
<van-radio-group
|
||||
v-model="form.type"
|
||||
direction="horizontal"
|
||||
>
|
||||
<van-radio :value="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :value="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :value="2">
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field name="classify" label="分类">
|
||||
<van-field
|
||||
name="classify"
|
||||
label="分类"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector v-model="form.classify" :type="form.type" />
|
||||
<ClassifySelector
|
||||
v-model="form.classify"
|
||||
:type="form.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
<template #footer>
|
||||
<van-button round block type="primary" :loading="submitting" @click="submit">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
{{ isEdit ? '更新' : '确认添加' }}
|
||||
</van-button>
|
||||
</template>
|
||||
</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
|
||||
:columns="periodicTypeColumns"
|
||||
@confirm="onPeriodicTypeConfirm"
|
||||
@@ -207,7 +270,12 @@
|
||||
</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
|
||||
:columns="weekdaysColumns"
|
||||
@confirm="onWeekdaysConfirm"
|
||||
@@ -216,7 +284,12 @@
|
||||
</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
|
||||
:columns="monthDaysColumns"
|
||||
@confirm="onMonthDaysConfirm"
|
||||
@@ -233,7 +306,9 @@ import { showToast, showConfirmDialog } from 'vant'
|
||||
import {
|
||||
getPeriodicList,
|
||||
deletePeriodic as deletePeriodicApi,
|
||||
togglePeriodicEnabled
|
||||
togglePeriodicEnabled,
|
||||
createPeriodic,
|
||||
updatePeriodic
|
||||
} from '@/api/transactionPeriodic'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
@@ -429,10 +504,10 @@ const editPeriodic = (item) => {
|
||||
form.id = item.id
|
||||
form.reason = item.reason
|
||||
form.amount = item.amount.toString()
|
||||
form.type = item.type
|
||||
form.type = parseInt(item.type)
|
||||
form.classify = item.classify
|
||||
form.periodicType = item.periodicType
|
||||
form.periodicTypeText = periodicTypeColumns.find((t) => t.value === item.periodicType)?.text || ''
|
||||
form.periodicType = parseInt(item.periodicType)
|
||||
form.periodicTypeText = periodicTypeColumns.find((t) => t.value === parseInt(item.periodicType))?.text || ''
|
||||
|
||||
// 解析周期配置
|
||||
if (item.periodicConfig) {
|
||||
@@ -557,6 +632,105 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
|
||||
form.monthDaysText = selectedOptions[0].text
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -311,7 +353,7 @@ public class TransactionRecordController(
|
||||
s.Value.count,
|
||||
s.Value.expense,
|
||||
s.Value.income,
|
||||
s.Value.income - s.Value.expense
|
||||
s.Value.saving
|
||||
)).ToList();
|
||||
|
||||
return result.Ok();
|
||||
@@ -778,6 +820,14 @@ public record DailyStatisticsDto(
|
||||
decimal Balance
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 累积余额统计DTO
|
||||
/// </summary>
|
||||
public record BalanceStatisticsDto(
|
||||
string Date,
|
||||
decimal CumulativeBalance
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 智能分类请求DTO
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user