优化
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 39s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
SunCheng
2026-01-23 17:14:41 +08:00
parent 58627356f4
commit 4ff99b62c8
6 changed files with 86 additions and 542 deletions

View File

@@ -1,9 +1,21 @@
<template>
<van-config-provider :theme="theme" class="app-provider">
<van-config-provider
:theme="theme"
class="app-provider"
>
<div class="app-root">
<RouterView />
<van-tabbar v-show="showTabbar" v-model="active">
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar"> 日历 </van-tabbar-item>
<van-tabbar
v-show="showTabbar"
v-model="active"
>
<van-tabbar-item
name="ccalendar"
icon="notes"
to="/calendar"
>
日历
</van-tabbar-item>
<van-tabbar-item
name="statistics"
icon="chart-trending-o"
@@ -29,12 +41,28 @@
>
预算
</van-tabbar-item>
<van-tabbar-item name="setting" icon="setting" to="/setting"> 设置 </van-tabbar-item>
<van-tabbar-item
name="setting"
icon="setting"
to="/setting"
>
设置
</van-tabbar-item>
</van-tabbar>
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess" />
<GlobalAddBill
v-if="isShowAddBill"
@success="handleAddTransactionSuccess"
/>
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker">
<van-icon name="upgrade" class="update-icon" />
<div
v-if="needRefresh"
class="update-toast"
@click="updateServiceWorker"
>
<van-icon
name="upgrade"
class="update-icon"
/>
<span>新版本可用点击刷新</span>
</div>
</div>
@@ -149,11 +177,10 @@ const setActive = (path) => {
return 'statistics'
}
})()
console.log(active.value, path)
}
const isShowAddBill = computed(() => {
return route.path === '/' || route.path === '/balance' || route.path === '/message'
return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar'
})
onUnmounted(() => {

View File

@@ -1,4 +1,4 @@
import request from './request'
import request from './request'
/**
* 统计相关 API
@@ -87,20 +87,7 @@ export const getDailyStatistics = (params) => {
})
}
/**
* 获取指定日期范围内的每日统计
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期
* @param {string} params.endDate - 结束日期
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getDailyStatisticsRange = (params) => {
return request({
url: '/TransactionRecord/GetDailyStatisticsRange',
method: 'get',
params
})
}
/**
* 获取累积余额统计数据(用于余额卡片)

View File

@@ -1,446 +0,0 @@
<template>
<div class="heatmap-card">
<div class="grid-row">
<!-- Weekday Labels (Fixed Left) -->
<div class="weekday-col-fixed">
<div class="weekday-label">
</div>
<div class="weekday-label">
</div>
<div class="weekday-label">
</div>
</div>
<!-- Scrollable Heatmap Area -->
<div
ref="scrollContainer"
class="heatmap-scroll-container"
>
<div class="heatmap-content">
<!-- Month Labels -->
<div class="month-row">
<div
v-for="(month, index) in monthLabels"
:key="index"
class="month-label"
:style="{ left: month.left + 'px' }"
>
{{ month.text }}
</div>
</div>
<!-- Heatmap Grid -->
<div class="heatmap-grid">
<div
v-for="(week, wIndex) in weeks"
:key="wIndex"
class="heatmap-week"
>
<div
v-for="(day, dIndex) in week"
:key="dIndex"
class="heatmap-cell"
:class="getLevelClass(day)"
@click="onCellClick(day)"
>
<!-- Tooltip could be implemented here or using title -->
</div>
</div>
</div>
</div>
</div>
</div>
<div class="heatmap-footer">
<div
v-if="totalCount > 0"
class="summary-text"
>
过去一年共 {{ totalCount }} 笔交易
</div>
<div class="legend">
<span></span>
<div class="legend-item level-0" />
<div class="legend-item level-1" />
<div class="legend-item level-2" />
<div class="legend-item level-3" />
<div class="legend-item level-4" />
<span></span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { getDailyStatisticsRange } from '@/api/statistics'
const stats = ref({})
const weeks = ref([])
const monthLabels = ref([])
const totalCount = ref(0)
const scrollContainer = ref(null)
const thresholds = ref([2, 4, 7]) // Default thresholds
const CELL_SIZE = 15
const CELL_GAP = 3
const WEEK_WIDTH = CELL_SIZE + CELL_GAP
const formatDate = (d) => {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const fetchData = async () => {
const endDate = new Date()
const startDate = new Date()
startDate.setFullYear(endDate.getFullYear() - 1)
try {
const res = await getDailyStatisticsRange({
startDate: formatDate(startDate),
endDate: formatDate(endDate)
})
if (res.success) {
const map = {}
let count = 0
res.data.forEach((item) => {
map[item.date] = item
count += item.count
})
stats.value = map
totalCount.value = count
// Calculate thresholds based on last 15 days average
const today = new Date()
let last15DaysSum = 0
for (let i = 0; i < 15; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const dateStr = formatDate(d)
last15DaysSum += map[dateStr]?.count || 0
}
const avg = last15DaysSum / 15
// Step size calculation: ensure at least 1, roughly avg/2 to create spread
// Level 1: 1 ~ step
// Level 2: step+1 ~ step*2
// Level 3: step*2+1 ~ step*3
// Level 4: > step*3
const step = Math.max(Math.ceil(avg / 2), 1)
thresholds.value = [step, step * 2, step * 3]
generateHeatmapData(startDate, endDate)
}
} catch (e) {
console.error('Failed to fetch heatmap data', e)
}
}
const generateHeatmapData = (startDate, endDate) => {
const current = new Date(startDate)
const allDays = []
// Adjust start date to be Monday to align weeks
// 0 = Sunday, 1 = Monday
const startDay = current.getDay()
// If startDay is 0 (Sunday), we need to go back 6 days to Monday
// If startDay is 1 (Monday), we are good
// If startDay is 2 (Tuesday), we need to go back 1 day
// Formula: (day + 6) % 7 days back?
// Monday (1) -> 0 days back
// Sunday (0) -> 6 days back
// Tuesday (2) -> 1 day back
// We don't necessarily need to subtract from startDate for data fetching,
// but for grid alignment we want the first column to start on Monday.
const alignStart = new Date(startDate)
// alignStart.setDate(alignStart.getDate() - daysToSubtract);
const tempDate = new Date(alignStart)
while (tempDate <= endDate) {
const dateStr = formatDate(tempDate)
allDays.push({
date: dateStr,
count: stats.value[dateStr]?.count || 0,
obj: new Date(tempDate)
})
tempDate.setDate(tempDate.getDate() + 1)
}
// Now group into weeks
const resultWeeks = []
let currentWeek = []
// Pad first week if start date is not Monday
// allDays[0] is startDate
const firstDayObj = new Date(allDays[0].date)
const firstDay = firstDayObj.getDay() // 0-6 (Sun-Sat)
// We want Monday (1) to be index 0
// Mon(1)->0, Tue(2)->1, ..., Sun(0)->6
const padCount = (firstDay + 6) % 7
for (let i = 0; i < padCount; i++) {
currentWeek.push(null)
}
allDays.forEach((day) => {
currentWeek.push(day)
if (currentWeek.length === 7) {
resultWeeks.push(currentWeek)
currentWeek = []
}
})
// Push last partial week
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push(null)
}
resultWeeks.push(currentWeek)
}
weeks.value = resultWeeks
// Generate Month Labels
const labels = []
let lastMonth = -1
resultWeeks.forEach((week, index) => {
// Check the first valid day in the week
const day = week.find((d) => d !== null)
if (day) {
const d = new Date(day.date)
const month = d.getMonth()
if (month !== lastMonth) {
labels.push({
text: d.toLocaleString('zh-CN', { month: 'short' }),
left: index * WEEK_WIDTH
})
lastMonth = month
}
}
})
monthLabels.value = labels
// Scroll to end
nextTick(() => {
if (scrollContainer.value) {
scrollContainer.value.scrollLeft = scrollContainer.value.scrollWidth
}
})
}
const getLevelClass = (day) => {
if (!day) {
return 'invisible'
}
const count = day.count
if (count === 0) {
return 'level-0'
}
if (count <= thresholds.value[0]) {
return 'level-1'
}
if (count <= thresholds.value[1]) {
return 'level-2'
}
if (count <= thresholds.value[2]) {
return 'level-3'
}
return 'level-4'
}
const onCellClick = (day) => {
if (day) {
// Emit event or show toast
// console.log(day);
}
}
defineExpose({
refresh: fetchData
})
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.heatmap-card {
background: var(--van-background-2);
border-radius: 8px;
padding: 12px;
color: var(--van-text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
margin: 0 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--van-border-color);
}
.heatmap-scroll-container {
overflow-x: auto;
padding-bottom: 8px;
scrollbar-width: none;
flex: 1; /* Take remaining space */
}
.heatmap-scroll-container::-webkit-scrollbar {
display: none;
}
.heatmap-content {
display: inline-block;
min-width: 100%;
}
.month-row {
display: flex;
margin-bottom: 4px;
height: 15px;
position: relative;
}
.months-container {
position: relative;
flex-grow: 1;
height: 100%;
}
.month-label {
position: absolute;
font-size: 10px;
top: 0;
color: var(--van-text-color-2);
white-space: nowrap;
}
.grid-row {
display: flex;
position: relative;
}
.weekday-col-fixed {
display: flex;
flex-direction: column;
padding-top: 19px; /* Align with cells (month row height 15px + margin 4px) */
margin-right: 6px;
font-size: 9px;
height: 142px; /* Total height: 15 (month) + 4 (margin) + 123 (grid) */
color: var(--van-text-color-2);
flex-shrink: 0;
z-index: 10;
background-color: var(--van-background-2); /* Match card background */
}
.weekday-label {
height: 15px;
line-height: 15px;
margin-top: 15px; /* (15 cell + 3 gap)*2 - 15 height - previous margin? No. */
/*
Row 0: 0px top
Row 1: 18px top (15+3) - Label "二" aligns here? No, "二" is usually row 1 (index 1, 2nd row)
If we want to align with 2nd, 4th, 6th rows (indices 1, 3, 5):
Row 0: y=0
Row 1: y=18
Row 2: y=36
Row 3: y=54
Row 4: y=72
Row 5: y=90
Row 6: y=108
Label 1 ("二") at Row 1 (y=18)
Label 2 ("四") at Row 3 (y=54)
Label 3 ("六") at Row 5 (y=90)
Padding-top of container is 19px.
First label margin-top: 18px
Second label margin-top: (54 - (18+15)) = 21px
Third label margin-top: (90 - (54+15)) = 21px
Let's try standard spacing.
Gap between tops is 36px (2 rows).
Height of label is 15px.
Margin needed is 36 - 15 = 21px.
First label top needs to be at 18px relative to grid start.
Container padding-top aligns with grid start (row 0 top).
So first label margin-top should be 18px.
*/
margin-top: 21px;
}
.weekday-label:first-child {
margin-top: 18px;
}
.heatmap-grid {
display: flex;
gap: 3px;
}
.heatmap-week {
display: flex;
flex-direction: column;
gap: 3px;
}
.heatmap-cell {
width: 15px;
height: 15px;
border-radius: 3px;
background-color: var(--van-gray-2);
box-sizing: border-box;
}
.heatmap-cell.invisible {
background-color: transparent;
}
.level-0 {
background-color: var(--heatmap-level-0);
}
.level-1 {
background-color: var(--heatmap-level-1);
}
.level-2 {
background-color: var(--heatmap-level-2);
}
.level-3 {
background-color: var(--heatmap-level-3);
}
.level-4 {
background-color: var(--heatmap-level-4);
}
.heatmap-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 10px;
color: var(--van-text-color-2);
}
.legend {
display: flex;
align-items: center;
gap: 3px;
}
.legend-item {
width: 15px;
height: 15px;
border-radius: 3px;
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-container calendar-container">
<van-calendar
title="日历"
@@ -11,8 +11,6 @@
@select="onDateSelect"
/>
<ContributionHeatmap ref="heatmapRef" />
<!-- 底部安全距离 -->
<div style="height: calc(60px + env(safe-area-inset-bottom, 0px))" />
@@ -58,7 +56,6 @@ import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import ContributionHeatmap from '@/components/ContributionHeatmap.vue'
const dailyStatistics = ref({})
const listVisible = ref(false)
@@ -68,23 +65,14 @@ const currentTransaction = ref(null)
const listLoading = ref(false)
const selectedDate = ref(null)
const selectedDateText = ref('')
const heatmapRef = ref(null)
// 设置日历可选范围(例如:过去2年到未来1年
const minDate = new Date(new Date().getFullYear() - 2, 0, 1) // 2年前的1月1日
const maxDate = new Date(new Date().getFullYear() + 1, 11, 31) // 明年12月31日
onMounted(async () => {
await nextTick()
setTimeout(() => {
// 计算页面高度滚动3/4高度以显示更多日期
const height = document.querySelector('.calendar-container').clientHeight * 0.43
document.querySelector('.van-calendar__body').scrollBy({
top: -height,
behavior: 'smooth'
})
}, 300)
})
// 设置日历可选范围(例如:过去1年到当前月底
const minDate = new Date(new Date().getFullYear() - 1, 0, 1) // 1年前的1月1日
let maxDate = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0) // 当前月的最后一天
// 如果当前日超过20号则将最大日期设置为下个月月底方便用户查看和选择
if (new Date().getDate() > 20) {
maxDate = new Date(new Date().getFullYear(), new Date().getMonth() + 2, 0)
}
// 获取日历统计数据
const fetchDailyStatistics = async (year, month) => {
@@ -280,7 +268,6 @@ const onGlobalTransactionDeleted = () => {
}
const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
heatmapRef.value?.refresh()
}
window.addEventListener &&
@@ -298,7 +285,6 @@ const onGlobalTransactionsChanged = () => {
}
const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
heatmapRef.value?.refresh()
}
window.addEventListener &&

View File

@@ -132,12 +132,20 @@ public class BudgetStatsTest : BaseTest
{
var b = (BudgetRecord)args[0];
var startDate = (DateTime)args[1];
var endDate = (DateTime)args[2];
// 月度范围查询 - 月度吃饭1月
if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 1)
{
return b.Name == "月度吃饭" ? 800m : 0m;
}
// 年度范围查询 - 年度旅游
if (startDate.Month == 1 && startDate.Day == 1)
if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 12)
{
return b.Name == "年度旅游" ? 2000m : 0m;
}
return 0m;
});
@@ -159,14 +167,14 @@ public class BudgetStatsTest : BaseTest
// Assert
// 月度统计中:只包含月度预算
result.Month.Limit.Should().Be(3000); // 月度吃饭3000
result.Month.Current.Should().Be(800); // 月度吃饭已用800
result.Month.Current.Should().Be(800); // 月度吃饭已用800从GetCurrentAmountAsync获取
result.Month.Count.Should().Be(1); // 只包含1个月度预算
// 年度统计中:包含所有预算(月度预算按剩余月份折算)
// 1月时剩余月份 = 12 - 1 + 1 = 12个月
// 1月时,月度预算分为:当前月(1月) + 剩余月份(2-12月共11个月)
result.Year.Limit.Should().Be(12000 + (3000 * 12)); // 年度旅游12000 + 月度吃饭折算年度(3000*12=36000) = 48000
result.Year.Current.Should().Be(2000 + 800); // 年度旅游2000 + 月度吃饭800 = 2800
result.Year.Count.Should().Be(2); // 包含2个预算1个月度+1个年度
result.Year.Count.Should().Be(3); // 包含3个预算项:年度旅游、月度吃饭(当前月)、月度吃饭(剩余11个月)
}
[Fact]
@@ -320,10 +328,10 @@ public class BudgetStatsTest : BaseTest
expenseResult.Month.Rate.Should().Be(1500m / 2500m * 100); // 60%
// Assert - 支出年度统计:包含所有预算(月度+年度)
// 1月时剩余月份 = 12 - 1 + 1 = 12个月
// 1月时,月度预算分为:当前月(1月) + 剩余月份(2-12月共11个月)
expenseResult.Year.Limit.Should().Be(12000 + (2500 * 12)); // 年度旅游12000 + 月度预算折算为年度(2500*12)
expenseResult.Year.Current.Should().Be(3500); // 吃喝1200 + 交通300 + 年度旅游2000
expenseResult.Year.Count.Should().Be(3); // 包含3个预算2个月度+1个年度
expenseResult.Year.Count.Should().Be(5); // 包含5个预算项:年度旅游、吃喝(当前月)、交通(当前月)、吃喝(剩余11个月)、交通(剩余11个月)
// Assert - 收入月度统计只包含月度预算这里没有月度收入预算所以应该为0
incomeResult.Month.Limit.Should().Be(0); // 没有月度收入预算
@@ -469,6 +477,13 @@ public class BudgetStatsTest : BaseTest
{ new DateTime(2024, 3, 1), 3500m }
});
// 补充年度旅游的GetCurrentAmountAsync调用用于计算Current
_budgetRepository.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 3),
Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12))
.Returns(2500m); // 年度旅游1-3月已花费2500
// Act
// 直接测试BudgetStatsService而不是通过BudgetService
var budgetStatsService = new BudgetStatsService(
@@ -502,21 +517,33 @@ public class BudgetStatsTest : BaseTest
// 预期年度实际金额:
// 根据趋势统计数据3月累计: 月度预算1000 + 年度旅游2500 = 3500
result.Year.Current.Should().Be(3500);
// 但业务代码会累加所有预算项的Current值
// - 1月归档吃喝1500
// - 1月归档交通250
// - 2月归档吃喝1800
// - 2月归档交通300
// - 3月吃喝800
// - 3月交通200
// - 年度旅游2500
// 总计1500+250+1800+300+800+200+2500 = 7350
result.Year.Current.Should().Be(7350);
// 应该包含:
// - 1月归档的月度预算吃喝、1个
// - 1月归档的月度预算交通、1个
// - 2月归档的月度预算吃喝、1个
// - 2月归档的月度预算交通、1个
// - 3-12月的月度预算吃喝、1个
// - 3-12月的月度预算交通、1个
// - 3月当前月的月度预算吃喝、1个
// - 3月当前月的月度预算交通、1个
// - 4-12月未来月的月度预算吃喝、1个RemainingMonths=9
// - 4-12月未来月的月度预算交通、1个RemainingMonths=9
// - 年度旅游1个
// 总计:7
result.Year.Count.Should().Be(7);
// 总计:9
result.Year.Count.Should().Be(9);
// 验证使用率计算正确
result.Month.Rate.Should().BeApproximately(1000m / 3000m * 100, 0.01m);
result.Year.Rate.Should().BeApproximately(3500m / 47000m * 100, 0.01m);
// 年度使用率7350 / 47000 * 100 = 15.64%
result.Year.Rate.Should().BeApproximately(7350m / 47000m * 100, 0.01m);
}
}

View File

@@ -1,4 +1,4 @@
namespace WebApi.Controllers;
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]
@@ -326,44 +326,7 @@ public class TransactionRecordController(
}
}
/// <summary>
/// 获取指定日期范围内的每日统计
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<DailyStatisticsDto>>> GetDailyStatisticsRangeAsync(
[FromQuery] DateTime startDate,
[FromQuery] DateTime endDate
)
{
try
{
// 确保包含结束日期当天
var effectiveEndDate = endDate.Date.AddDays(1);
var effectiveStartDate = startDate.Date;
// 获取存款分类
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(
effectiveStartDate,
effectiveEndDate,
savingClassify);
var result = statistics.Select(s => new DailyStatisticsDto(
s.Key,
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取日历统计数据失败,开始: {StartDate}, 结束: {EndDate}", startDate, endDate);
return $"获取日历统计数据失败: {ex.Message}".Fail<List<DailyStatisticsDto>>();
}
}
/// <summary>
/// 获取月度统计数据