feat(日历): 添加交易热力图组件展示每日交易统计
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
Docker Build & Deploy / Deploy to Production (push) Successful in 10s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
Docker Build & Deploy / Deploy to Production (push) Successful in 10s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
- 新增 ContributionHeatmap 组件实现类似 GitHub 贡献热力图的可视化 - 添加 getDailyStatisticsRange API 接口获取日期范围内的每日统计数据 - 调整日历页面布局以容纳热力图组件 - 热力图支持动态阈值计算和暗黑模式适配 - 交易变更时自动刷新热力图数据
This commit is contained in:
@@ -60,6 +60,14 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <returns>每天的消费笔数和金额</returns>
|
/// <returns>每天的消费笔数和金额</returns>
|
||||||
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month);
|
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定日期范围内的每日统计
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="startDate">开始日期</param>
|
||||||
|
/// <param name="endDate">结束日期</param>
|
||||||
|
/// <returns>每天的消费笔数和金额</returns>
|
||||||
|
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定日期范围内的交易记录
|
/// 获取指定日期范围内的交易记录
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -342,6 +350,11 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
var startDate = new DateTime(year, month, 1);
|
var startDate = new DateTime(year, month, 1);
|
||||||
var endDate = startDate.AddMonths(1);
|
var endDate = startDate.AddMonths(1);
|
||||||
|
|
||||||
|
return await GetDailyStatisticsByRangeAsync(startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, (int count, decimal amount)>> 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)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ const setActive = (path) => {
|
|||||||
|
|
||||||
const isShowAddBill = computed(() => {
|
const isShowAddBill = computed(() => {
|
||||||
return route.path === '/'
|
return route.path === '/'
|
||||||
|| route.path === '/calendar'
|
|
||||||
|| route.path === '/balance'
|
|| route.path === '/balance'
|
||||||
|| route.path === '/message'
|
|| route.path === '/message'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -88,3 +88,18 @@ export const getDailyStatistics = (params) => {
|
|||||||
params
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
415
Web/src/components/ContributionHeatmap.vue
Normal file
415
Web/src/components/ContributionHeatmap.vue
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
<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>
|
||||||
|
<div class="legend-item level-1"></div>
|
||||||
|
<div class="legend-item level-2"></div>
|
||||||
|
<div class="legend-item level-3"></div>
|
||||||
|
<div class="legend-item level-4"></div>
|
||||||
|
<span>多</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, 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 = 10;
|
||||||
|
const CELL_GAP = 2;
|
||||||
|
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;
|
||||||
|
console.log("avg", avg)
|
||||||
|
// 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 data = [];
|
||||||
|
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
|
||||||
|
|
||||||
|
const daysToSubtract = (startDay + 6) % 7;
|
||||||
|
// 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: 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;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 19px; /* Align with cells (month row height 15px + margin 4px) */
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
height: 101px; /* Total height: 15 (month) + 4 (margin) + 82 (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: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.weekday-label:first-child { margin-top: 14px; }
|
||||||
|
|
||||||
|
.heatmap-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-week {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--van-gray-2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell.invisible {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-0 { background-color: var(--van-gray-2); }
|
||||||
|
/* Default (Light Mode) - Light to Deep Green */
|
||||||
|
.level-1 { background-color: #9be9a8; }
|
||||||
|
.level-2 { background-color: #40c463; }
|
||||||
|
.level-3 { background-color: #30a14e; }
|
||||||
|
.level-4 { background-color: #216e39; }
|
||||||
|
|
||||||
|
/* Dark Mode - Dark to Light/Bright Green (GitHub Dark Mode Style) */
|
||||||
|
/* The user requested "From Light to Deep" (浅至深) which usually means standard heatmap logic (darker = more).
|
||||||
|
HOWEVER, in dark interfaces, usually "Brighter = More".
|
||||||
|
If the user explicitly says "colors are wrong, should be from light to deep", and they are referring to the visual gradient:
|
||||||
|
|
||||||
|
If they mean visual brightness:
|
||||||
|
Light (Dim) -> Deep (Bright)
|
||||||
|
|
||||||
|
Let's stick to the GitHub Dark Mode palette which is scientifically designed for dark backgrounds:
|
||||||
|
L1 (Less): Dark Green (#0e4429)
|
||||||
|
L4 (More): Neon Green (#39d353)
|
||||||
|
This is visually "Dim to Bright".
|
||||||
|
|
||||||
|
If the user meant "Light color to Dark color" literally (like white -> black green), that would look bad on dark mode.
|
||||||
|
"浅至深" in color context usually implies saturation/intensity.
|
||||||
|
|
||||||
|
Let's restore the GitHub Dark Mode colors for dark mode, as my previous change might have inverted them incorrectly or caused confusion.
|
||||||
|
|
||||||
|
GitHub Dark Mode:
|
||||||
|
L0: #161b22
|
||||||
|
L1: #0e4429
|
||||||
|
L2: #006d32
|
||||||
|
L3: #26a641
|
||||||
|
L4: #39d353
|
||||||
|
|
||||||
|
This goes from Dark Green -> Bright Green.
|
||||||
|
*/
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.level-1 { background-color: #9be9a8; }
|
||||||
|
.level-2 { background-color: #40c463; }
|
||||||
|
.level-3 { background-color: #30a14e; }
|
||||||
|
.level-4 { background-color: #216e39; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
@month-show="onMonthShow"
|
@month-show="onMonthShow"
|
||||||
@select="onDateSelect"
|
@select="onDateSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ContributionHeatmap ref="heatmapRef" />
|
||||||
|
|
||||||
<!-- 日期交易列表弹出层 -->
|
<!-- 日期交易列表弹出层 -->
|
||||||
<PopupContainer
|
<PopupContainer
|
||||||
@@ -53,6 +55,7 @@ import TransactionList from "@/components/TransactionList.vue";
|
|||||||
import TransactionDetail from "@/components/TransactionDetail.vue";
|
import TransactionDetail from "@/components/TransactionDetail.vue";
|
||||||
import SmartClassifyButton from "@/components/SmartClassifyButton.vue";
|
import SmartClassifyButton from "@/components/SmartClassifyButton.vue";
|
||||||
import PopupContainer from "@/components/PopupContainer.vue";
|
import PopupContainer from "@/components/PopupContainer.vue";
|
||||||
|
import ContributionHeatmap from "@/components/ContributionHeatmap.vue";
|
||||||
|
|
||||||
const dailyStatistics = ref({});
|
const dailyStatistics = ref({});
|
||||||
const listVisible = ref(false);
|
const listVisible = ref(false);
|
||||||
@@ -62,6 +65,7 @@ const currentTransaction = ref(null);
|
|||||||
const listLoading = ref(false);
|
const listLoading = ref(false);
|
||||||
const selectedDate = ref(null);
|
const selectedDate = ref(null);
|
||||||
const selectedDateText = ref("");
|
const selectedDateText = ref("");
|
||||||
|
const heatmapRef = ref(null);
|
||||||
|
|
||||||
// 设置日历可选范围(例如:过去2年到未来1年)
|
// 设置日历可选范围(例如:过去2年到未来1年)
|
||||||
const minDate = new Date(new Date().getFullYear() - 2, 0, 1); // 2年前的1月1日
|
const minDate = new Date(new Date().getFullYear() - 2, 0, 1); // 2年前的1月1日
|
||||||
@@ -71,7 +75,7 @@ onMounted(async () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 计算页面高度,滚动3/4高度以显示更多日期
|
// 计算页面高度,滚动3/4高度以显示更多日期
|
||||||
const height = document.querySelector(".calendar-container").clientHeight * 0.45;
|
const height = document.querySelector(".calendar-container").clientHeight * 0.55;
|
||||||
document.querySelector(".van-calendar__body").scrollBy({
|
document.querySelector(".van-calendar__body").scrollBy({
|
||||||
top: -height,
|
top: -height,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
@@ -266,6 +270,7 @@ const onGlobalTransactionDeleted = () => {
|
|||||||
}
|
}
|
||||||
const now = selectedDate.value || new Date()
|
const now = selectedDate.value || new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
|
heatmapRef.value?.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
@@ -281,6 +286,7 @@ const onGlobalTransactionsChanged = () => {
|
|||||||
}
|
}
|
||||||
const now = selectedDate.value || new Date()
|
const now = selectedDate.value || new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
|
heatmapRef.value?.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
@@ -304,10 +310,11 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
background-color: var(--van-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-container :deep(.van-calendar) {
|
.calendar-container :deep(.van-calendar) {
|
||||||
height: 100% !important;
|
height: auto !important;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -284,6 +284,33 @@ 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 statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(effectiveStartDate, effectiveEndDate);
|
||||||
|
var result = statistics.Select(s => new DailyStatisticsDto(s.Key, s.Value.count, s.Value.amount)).ToList();
|
||||||
|
|
||||||
|
return result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取日历统计数据失败,开始: {StartDate}, 结束: {EndDate}", startDate, endDate);
|
||||||
|
return $"获取日历统计数据失败: {ex.Message}".Fail<List<DailyStatisticsDto>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取月度统计数据
|
/// 获取月度统计数据
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user