diff --git a/Repository/TransactionRecordRepository.cs b/Repository/TransactionRecordRepository.cs index 905e383..403fa15 100644 --- a/Repository/TransactionRecordRepository.cs +++ b/Repository/TransactionRecordRepository.cs @@ -57,16 +57,16 @@ public interface ITransactionRecordRepository : IBaseRepository /// 年份 /// 月份 - /// 每天的消费笔数和金额 - Task> GetDailyStatisticsAsync(int year, int month); + /// 每天的消费笔数和金额详情 + Task> GetDailyStatisticsAsync(int year, int month); /// /// 获取指定日期范围内的每日统计 /// /// 开始日期 /// 结束日期 - /// 每天的消费笔数和金额 - Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate); + /// 每天的消费笔数和金额详情 + Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate); /// /// 获取指定日期范围内的交易记录 @@ -345,7 +345,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.Classify); } - public async Task> GetDailyStatisticsAsync(int year, int month) + public async Task> GetDailyStatisticsAsync(int year, int month) { var startDate = new DateTime(year, month, 1); var endDate = startDate.AddMonths(1); @@ -353,7 +353,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate) + public async Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate) { var records = await FreeSql.Select() .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) @@ -366,11 +366,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository { // 分别统计收入和支出 - var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount); - var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount); - // 净额 = 收入 - 支出(消费大于收入时为负数) - var netAmount = income - expense; - return (count: g.Count(), amount: netAmount); + var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); + var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); + return (count: g.Count(), expense: expense, income: income); } ); diff --git a/Web/package.json b/Web/package.json index 04bae05..96fa2ed 100644 --- a/Web/package.json +++ b/Web/package.json @@ -16,6 +16,7 @@ "dependencies": { "axios": "^1.13.2", "dayjs": "^1.11.19", + "echarts": "^6.0.0", "pinia": "^3.0.4", "vant": "^4.9.22", "vue": "^3.5.25", diff --git a/Web/pnpm-lock.yaml b/Web/pnpm-lock.yaml index 9b16b48..e98f482 100644 --- a/Web/pnpm-lock.yaml +++ b/Web/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: dayjs: specifier: ^1.11.19 version: 1.11.19 + echarts: + specifier: ^6.0.0 + version: 6.0.0 pinia: specifier: ^3.0.4 version: 3.0.4(vue@3.5.26) @@ -787,6 +790,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -1296,6 +1302,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1435,6 +1444,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + snapshots: '@babel/code-frame@7.27.1': @@ -2131,6 +2143,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + electron-to-chromium@1.5.267: {} entities@7.0.0: {} @@ -2611,6 +2628,8 @@ snapshots: totalist@3.0.1: {} + tslib@2.3.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2744,3 +2763,7 @@ snapshots: yallist@3.1.1: {} yocto-queue@0.1.0: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 diff --git a/Web/src/views/StatisticsView.vue b/Web/src/views/StatisticsView.vue index d7c6ac3..f758c16 100644 --- a/Web/src/views/StatisticsView.vue +++ b/Web/src/views/StatisticsView.vue @@ -69,36 +69,28 @@
+ +
+
+

每日收支趋势

+
+ +
+
+
+
+
-
-

支出分类统计

- {{ expenseCategoriesView.length }}类 -
+
+

支出分类统计

+ {{ expenseCategoriesView.length }}类 +
- - - -
-
¥{{ formatMoney(monthlyData.totalExpense) }}
-
总支出
-
+
@@ -130,144 +122,96 @@ description="本月暂无支出记录" image="search" /> -
- - -
-
-

收入分类统计

- {{ incomeCategoriesView.length }}类 -
- -
-
-
-
-
- {{ category.classify || '未分类' }} - {{ category.count }}笔 -
-
-
-
¥{{ formatMoney(category.amount) }}
-
{{ category.percent }}%
-
-
-
-
- -
-
-

不计收支分类统计

- {{ noneCategoriesView.length }}类 -
- -
-
-
-
-
- {{ category.classify || '未分类' }} - {{ category.count }}笔 -
+ +
+
+

收入分类统计

+ {{ incomeCategoriesView.length }}类
-
-
¥{{ formatMoney(category.amount) }}
-
{{ category.percent }}%
-
- -
-
-
- - -
-
-

近6个月趋势

-
- -
-
-
-
-
-
- {{ formatShortMoney(item.expense) }} + +
+
+
+
+
+ {{ category.classify || '未分类' }} + {{ category.count }}笔
-
-
- {{ formatShortMoney(item.income) }} +
+
¥{{ formatMoney(category.amount) }}
+
{{ category.percent }}%
+
+ +
+
+
+ + +
+
+

不计收支分类统计

+ {{ noneCategoriesView.length }}类 +
+ +
+
+
+
+
+ {{ category.classify || '未分类' }} + {{ category.count }}笔
+
+
¥{{ formatMoney(category.amount) }}
+
{{ category.percent }}%
+
+
-
{{ item.label }}
- -
-
-
- 支出 -
-
-
- 收入 -
-
-
-
- -
-
-

其他统计

-
- -
-
-
日均支出
-
¥{{ formatMoney(dailyAverage.expense) }}
+ +
+
+

其他统计

+
+ +
+
+
日均支出
+
¥{{ formatMoney(dailyAverage.expense) }}
+
+
+
日均收入
+
¥{{ formatMoney(dailyAverage.income) }}
+
+
+
最大单笔支出
+
¥{{ formatMoney(monthlyData.maxExpense) }}
+
+
+
最大单笔收入
+
¥{{ formatMoney(monthlyData.maxIncome) }}
+
+
-
-
日均收入
-
¥{{ formatMoney(dailyAverage.income) }}
-
-
-
最大单笔支出
-
¥{{ formatMoney(monthlyData.maxExpense) }}
-
-
-
最大单笔收入
-
¥{{ formatMoney(monthlyData.maxIncome) }}
-
-
-
- -
+ +
@@ -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 + '
'; + params.forEach(param => { + result += param.marker + param.seriesName + ': ' + formatMoney(param.value) + '
'; + }); + 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; } diff --git a/WebApi/Controllers/TransactionRecordController.cs b/WebApi/Controllers/TransactionRecordController.cs index a518496..85304b5 100644 --- a/WebApi/Controllers/TransactionRecordController.cs +++ b/WebApi/Controllers/TransactionRecordController.cs @@ -273,7 +273,13 @@ public class TransactionRecordController( try { var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month); - var result = statistics.Select(s => new DailyStatisticsDto(s.Key, s.Value.count, s.Value.amount)).ToList(); + var result = statistics.Select(s => new DailyStatisticsDto( + s.Key, + s.Value.count, + s.Value.expense, + s.Value.income, + s.Value.income - s.Value.expense // Balance = Income - Expense + )).ToList(); return result.Ok(); } @@ -300,7 +306,13 @@ public class TransactionRecordController( var effectiveStartDate = startDate.Date; var statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(effectiveStartDate, effectiveEndDate); - var result = statistics.Select(s => new DailyStatisticsDto(s.Key, s.Value.count, s.Value.amount)).ToList(); + var result = statistics.Select(s => new DailyStatisticsDto( + s.Key, + s.Value.count, + s.Value.expense, + s.Value.income, + s.Value.income - s.Value.expense + )).ToList(); return result.Ok(); } @@ -755,7 +767,9 @@ public record UpdateTransactionDto( public record DailyStatisticsDto( string Date, int Count, - decimal Amount + decimal Expense, + decimal Income, + decimal Balance ); ///