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
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:
@@ -57,16 +57,16 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="year">年份</param>
|
/// <param name="year">年份</param>
|
||||||
/// <param name="month">月份</param>
|
/// <param name="month">月份</param>
|
||||||
/// <returns>每天的消费笔数和金额</returns>
|
/// <returns>每天的消费笔数和金额详情</returns>
|
||||||
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month);
|
Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsAsync(int year, int month);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定日期范围内的每日统计
|
/// 获取指定日期范围内的每日统计
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="startDate">开始日期</param>
|
/// <param name="startDate">开始日期</param>
|
||||||
/// <param name="endDate">结束日期</param>
|
/// <param name="endDate">结束日期</param>
|
||||||
/// <returns>每天的消费笔数和金额</returns>
|
/// <returns>每天的消费笔数和金额详情</returns>
|
||||||
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate);
|
Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定日期范围内的交易记录
|
/// 获取指定日期范围内的交易记录
|
||||||
@@ -345,7 +345,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.ToListAsync(t => t.Classify);
|
.ToListAsync(t => t.Classify);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month)
|
public async Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsAsync(int year, int month)
|
||||||
{
|
{
|
||||||
var startDate = new DateTime(year, month, 1);
|
var startDate = new DateTime(year, month, 1);
|
||||||
var endDate = startDate.AddMonths(1);
|
var endDate = startDate.AddMonths(1);
|
||||||
@@ -353,7 +353,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
return await GetDailyStatisticsByRangeAsync(startDate, endDate);
|
return await GetDailyStatisticsByRangeAsync(startDate, endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate)
|
public async Task<Dictionary<string, (int count, decimal expense, decimal income)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate)
|
||||||
{
|
{
|
||||||
var records = await FreeSql.Select<TransactionRecord>()
|
var records = await FreeSql.Select<TransactionRecord>()
|
||||||
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
|
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
|
||||||
@@ -366,11 +366,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
g =>
|
g =>
|
||||||
{
|
{
|
||||||
// 分别统计收入和支出
|
// 分别统计收入和支出
|
||||||
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
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 => 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);
|
||||||
var netAmount = income - expense;
|
|
||||||
return (count: g.Count(), amount: netAmount);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vant": "^4.9.22",
|
"vant": "^4.9.22",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
|
|||||||
23
Web/pnpm-lock.yaml
generated
23
Web/pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(vue@3.5.26)
|
version: 3.0.4(vue@3.5.26)
|
||||||
@@ -787,6 +790,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267:
|
electron-to-chromium@1.5.267:
|
||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
|
|
||||||
@@ -1296,6 +1302,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1435,6 +1444,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/code-frame@7.27.1':
|
'@babel/code-frame@7.27.1':
|
||||||
@@ -2131,6 +2143,11 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.0.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
entities@7.0.0: {}
|
entities@7.0.0: {}
|
||||||
@@ -2611,6 +2628,8 @@ snapshots:
|
|||||||
|
|
||||||
totalist@3.0.1: {}
|
totalist@3.0.1: {}
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -2744,3 +2763,7 @@ snapshots:
|
|||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|||||||
@@ -69,36 +69,28 @@
|
|||||||
<div v-if="!firstLoading" class="statistics-content">
|
<div v-if="!firstLoading" class="statistics-content">
|
||||||
<transition :name="transitionName" mode="out-in">
|
<transition :name="transitionName" mode="out-in">
|
||||||
<div :key="dateKey">
|
<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="common-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">支出分类统计</h3>
|
<h3 class="card-title">支出分类统计</h3>
|
||||||
<van-tag type="primary" size="medium">{{ expenseCategoriesView.length }}类</van-tag>
|
<van-tag type="primary" size="medium">{{ expenseCategoriesView.length }}类</van-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 环形图区域 -->
|
<!-- 环形图区域 -->
|
||||||
<div v-if="expenseCategoriesView.length > 0" class="chart-container">
|
<div v-if="expenseCategoriesView.length > 0" class="chart-container">
|
||||||
<div class="ring-chart">
|
<div class="ring-chart">
|
||||||
<svg viewBox="0 0 200 200" class="ring-svg">
|
<div ref="pieChartRef" style="width: 100%; height: 100%;"></div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,144 +122,96 @@
|
|||||||
description="本月暂无支出记录"
|
description="本月暂无支出记录"
|
||||||
image="search"
|
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>
|
|
||||||
|
|
||||||
<!-- 不计收支分类统计 -->
|
<!-- 收入分类统计 -->
|
||||||
<div v-if="noneCategoriesView.length > 0" class="common-card">
|
<div v-if="incomeCategoriesView.length > 0" class="common-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">不计收支分类统计</h3>
|
<h3 class="card-title">收入分类统计</h3>
|
||||||
<van-tag type="info" size="medium">{{ noneCategoriesView.length }}类</van-tag>
|
<van-tag type="success" size="medium">{{ incomeCategoriesView.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>
|
||||||
<div class="category-stats">
|
|
||||||
<div class="category-amount none-text">¥{{ formatMoney(category.amount) }}</div>
|
<div class="category-list">
|
||||||
<div class="category-percent">{{ category.percent }}%</div>
|
<div
|
||||||
</div>
|
v-for="category in incomeCategoriesView"
|
||||||
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
|
:key="category.isOther ? 'other' : category.classify"
|
||||||
</div>
|
class="category-item clickable"
|
||||||
</div>
|
@click="category.isOther ? (showAllIncome = true) : goToCategoryBills(category.classify, 1)"
|
||||||
</div>
|
>
|
||||||
|
<div class="category-info">
|
||||||
<!-- 趋势统计 -->
|
<div class="category-color income-color"></div>
|
||||||
<div class="common-card">
|
<div class="category-name-with-count">
|
||||||
<div class="card-header">
|
<span class="category-name">{{ category.classify || '未分类' }}</span>
|
||||||
<h3 class="card-title">近6个月趋势</h3>
|
<span class="category-count">{{ category.count }}笔</span>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="category-stats">
|
||||||
class="bar income-bar"
|
<div class="category-amount income-text">¥{{ formatMoney(category.amount) }}</div>
|
||||||
:style="{ height: getBarHeight(item.income, maxTrendValue) }"
|
<div class="category-percent">{{ category.percent }}%</div>
|
||||||
>
|
</div>
|
||||||
<div v-if="item.income > 0" class="bar-value">
|
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
|
||||||
{{ formatShortMoney(item.income) }}
|
</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>
|
</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 class="bar-label">{{ item.label }}</div>
|
|
||||||
</div>
|
</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="common-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">其他统计</h3>
|
<h3 class="card-title">其他统计</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="other-stats">
|
<div class="other-stats">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-label">日均支出</div>
|
<div class="stat-label">日均支出</div>
|
||||||
<div class="stat-value">¥{{ formatMoney(dailyAverage.expense) }}</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>
|
||||||
<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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,7 +274,8 @@ import { ref, computed, onMounted, onActivated, nextTick } 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, getTrendStatistics } from '@/api/statistics'
|
import { getMonthlyStatistics, getCategoryStatistics, getDailyStatistics } from '@/api/statistics'
|
||||||
|
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'
|
||||||
import TransactionDetail from '@/components/TransactionDetail.vue'
|
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||||
@@ -462,6 +407,11 @@ const noneCategoriesView = computed(() => {
|
|||||||
|
|
||||||
// 趋势数据
|
// 趋势数据
|
||||||
const trendData = ref([])
|
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)
|
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 isCurrentMonth = computed(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1
|
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, ',')
|
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) => {
|
const changeMonth = (offset) => {
|
||||||
transitionName.value = offset > 0 ? 'slide-left' : 'slide-right'
|
transitionName.value = offset > 0 ? 'slide-left' : 'slide-right'
|
||||||
@@ -639,7 +564,7 @@ const fetchStatistics = async (showLoading = true) => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchMonthlyData(),
|
fetchMonthlyData(),
|
||||||
fetchCategoryData(),
|
fetchCategoryData(),
|
||||||
fetchTrendData()
|
fetchDailyData()
|
||||||
])
|
])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取统计数据失败:', error)
|
console.error('获取统计数据失败:', error)
|
||||||
@@ -647,6 +572,11 @@ const fetchStatistics = async (showLoading = true) => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
firstLoading.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 {
|
try {
|
||||||
// 计算开始年月(当前月往前推5个月)
|
const response = await getDailyStatistics({
|
||||||
let startYear = currentYear.value
|
year: currentYear.value,
|
||||||
let startMonth = currentMonth.value - 5
|
month: currentMonth.value
|
||||||
|
|
||||||
if (startMonth <= 0) {
|
|
||||||
startMonth += 12
|
|
||||||
startYear--
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getTrendStatistics({
|
|
||||||
startYear,
|
|
||||||
startMonth,
|
|
||||||
monthCount: 6
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
trendData.value = response.data.map(item => ({
|
dailyData.value = response.data
|
||||||
year: item.year,
|
// 如果不是首次加载(即DOM已存在),直接渲染
|
||||||
month: item.month,
|
if (!firstLoading.value) {
|
||||||
label: `${item.month}月`,
|
nextTick(() => {
|
||||||
expense: item.expense,
|
renderChart(response.data)
|
||||||
income: item.income
|
})
|
||||||
}))
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取趋势数据失败:', error)
|
console.error('获取每日统计数据失败:', error)
|
||||||
showToast('获取趋势数据失败')
|
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 = () => {
|
const goToAnalysis = () => {
|
||||||
router.push('/bill-analysis')
|
router.push('/bill-analysis')
|
||||||
@@ -944,8 +1108,14 @@ const handleNotifiedTransactionId = async (transactionId) => {
|
|||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchStatistics()
|
fetchStatistics()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
chartInstance && chartInstance.resize()
|
||||||
|
pieChartInstance && pieChartInstance.resize()
|
||||||
|
}
|
||||||
|
|
||||||
// 页面激活时刷新数据(从其他页面返回时)
|
// 页面激活时刷新数据(从其他页面返回时)
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
fetchStatistics()
|
fetchStatistics()
|
||||||
@@ -961,6 +1131,9 @@ window.addEventListener && window.addEventListener('transaction-deleted', onGlob
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chartInstance && chartInstance.dispose()
|
||||||
|
pieChartInstance && pieChartInstance.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
const onGlobalTransactionsChanged = () => {
|
const onGlobalTransactionsChanged = () => {
|
||||||
@@ -1157,12 +1330,12 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
/* 环形图 */
|
/* 环形图 */
|
||||||
.chart-container {
|
.chart-container {
|
||||||
padding: 20px;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring-chart {
|
.ring-chart {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 200px;
|
width: 100%;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,13 @@ public class TransactionRecordController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month);
|
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();
|
return result.Ok();
|
||||||
}
|
}
|
||||||
@@ -300,7 +306,13 @@ public class TransactionRecordController(
|
|||||||
var effectiveStartDate = startDate.Date;
|
var effectiveStartDate = startDate.Date;
|
||||||
|
|
||||||
var statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(effectiveStartDate, effectiveEndDate);
|
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();
|
return result.Ok();
|
||||||
}
|
}
|
||||||
@@ -755,7 +767,9 @@ public record UpdateTransactionDto(
|
|||||||
public record DailyStatisticsDto(
|
public record DailyStatisticsDto(
|
||||||
string Date,
|
string Date,
|
||||||
int Count,
|
int Count,
|
||||||
decimal Amount
|
decimal Expense,
|
||||||
|
decimal Income,
|
||||||
|
decimal Balance
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user