feat(analysis): 趋势图增加本周选项、线性图简化为总金额曲线

- 添加本周时间范围选项
- 线性图模式只显示总支出曲线,不再显示分类曲线
- 图例根据图表类型动态切换(堆叠图显示分类,线性图显示总支出)
- 时间范围选项:7天、本周、30天、本月、3个月、本年
This commit is contained in:
CHE LIANG ZHAO
2026-01-08 11:33:30 +08:00
parent b226c85fa7
commit ccd2d0386a
2 changed files with 449 additions and 103 deletions

View File

@@ -6,6 +6,8 @@
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import Calendar from '@lucide/svelte/icons/calendar';
import AreaChart from '@lucide/svelte/icons/area-chart';
import LineChart from '@lucide/svelte/icons/line-chart';
import { Button } from '$lib/components/ui/button';
import type { BillRecord } from '$lib/api';
import { pieColors } from '$lib/constants/chart';
@@ -24,8 +26,12 @@
let selectedDateRecords = $state<BillRecord[]>([]);
// 时间范围选项
type TimeRange = '7d' | '30d' | '3m';
let timeRange = $state<TimeRange>('3m');
type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
let timeRange = $state<TimeRange>('month');
// 图表类型
type ChartType = 'area' | 'line';
let chartType = $state<ChartType>('area');
// 隐藏的类别
let hiddenCategories = $state<Set<string>>(new Set());
@@ -42,8 +48,11 @@
const timeRangeOptions = [
{ value: '7d', label: '最近 7 天' },
{ value: 'week', label: '本周' },
{ value: '30d', label: '最近 30 天' },
{ value: '3m', label: '最近 3 个月' }
{ value: 'month', label: '月' },
{ value: '3m', label: '最近 3 个月' },
{ value: 'year', label: '本年' }
];
// 获取截止日期
@@ -52,15 +61,58 @@
switch (range) {
case '7d':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case 'week':
// 本周一
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
return new Date(now.getFullYear(), now.getMonth(), diff);
case '30d':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case 'month':
// 本月第一天
return new Date(now.getFullYear(), now.getMonth(), 1);
case '3m':
default:
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
case 'year':
// 本年第一天
return new Date(now.getFullYear(), 0, 1);
default:
return new Date(now.getFullYear(), now.getMonth(), 1);
}
}
// 按日期和分类分组数据
// 聚合粒度类型
type AggregationType = 'day' | 'week' | 'month';
// 获取周的起始日期(周一)
function getWeekStart(date: Date): string {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // 调整到周一
d.setDate(diff);
return d.toISOString().split('T')[0];
}
// 获取月份标识
function getMonthKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
// 格式化聚合后的标签
function formatAggregationLabel(key: string, type: AggregationType): string {
if (type === 'day') {
const d = new Date(key);
return `${d.getMonth() + 1}/${d.getDate()}`;
} else if (type === 'week') {
const d = new Date(key);
return `${d.getMonth() + 1}/${d.getDate()}周`;
} else {
const [year, month] = key.split('-');
return `${month}月`;
}
}
// 按日期和分类分组数据(支持智能聚合)
let processedData = $derived(() => {
const cutoffDate = getCutoffDate(timeRange);
@@ -71,11 +123,10 @@
return recordDate >= cutoffDate;
});
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0 };
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0, aggregationType: 'day' as AggregationType };
// 按日期分组
const dateMap = new Map<string, Map<string, number>>();
const categorySet = new Set<string>();
// 先按天分组,计算天数
const dailyMap = new Map<string, Map<string, number>>();
const categoryTotals: Record<string, number> = {};
expenseRecords.forEach(record => {
@@ -83,16 +134,49 @@
const category = record.category || '其他';
const amount = parseFloat(record.amount) || 0;
categorySet.add(category);
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
if (!dateMap.has(dateStr)) {
dateMap.set(dateStr, new Map());
if (!dailyMap.has(dateStr)) {
dailyMap.set(dateStr, new Map());
}
const dayData = dateMap.get(dateStr)!;
const dayData = dailyMap.get(dateStr)!;
dayData.set(category, (dayData.get(category) || 0) + amount);
});
const dayCount = dailyMap.size;
// 根据天数决定聚合粒度
let aggregationType: AggregationType = 'day';
if (dayCount > 90) {
aggregationType = 'month';
} else if (dayCount > 30) {
aggregationType = 'week';
}
// 按聚合粒度重新分组
const aggregatedMap = new Map<string, Map<string, number>>();
dailyMap.forEach((dayData, dateStr) => {
const date = new Date(dateStr);
let key: string;
if (aggregationType === 'day') {
key = dateStr;
} else if (aggregationType === 'week') {
key = getWeekStart(date);
} else {
key = getMonthKey(date);
}
if (!aggregatedMap.has(key)) {
aggregatedMap.set(key, new Map());
}
const aggData = aggregatedMap.get(key)!;
dayData.forEach((amount, cat) => {
aggData.set(cat, (aggData.get(cat) || 0) + amount);
});
});
// 获取前5大分类
const sortedCategories = Object.entries(categoryTotals)
.sort((a, b) => b[1] - a[1])
@@ -100,16 +184,26 @@
.map(([cat]) => cat);
// 转换为数组格式并计算堆叠值
const data = Array.from(dateMap.entries())
.map(([dateStr, dayData]) => {
const data = Array.from(aggregatedMap.entries())
.map(([key, aggData]) => {
// 为不同聚合类型创建正确的日期对象
let date: Date;
if (aggregationType === 'month') {
const [year, month] = key.split('-');
date = new Date(parseInt(year), parseInt(month) - 1, 15); // 月中
} else {
date = new Date(key);
}
const result: Record<string, any> = {
date: new Date(dateStr),
dateStr: dateStr
date,
dateStr: key,
label: formatAggregationLabel(key, aggregationType)
};
let cumulative = 0;
sortedCategories.forEach(cat => {
const value = dayData.get(cat) || 0;
const value = aggData.get(cat) || 0;
result[cat] = value;
// 只有未隐藏的类别参与堆叠
if (!hiddenCategories.has(cat)) {
@@ -124,7 +218,7 @@
// 其他分类汇总
let otherSum = 0;
dayData.forEach((amount, cat) => {
aggData.forEach((amount, cat) => {
if (!sortedCategories.includes(cat)) {
otherSum += amount;
}
@@ -153,7 +247,7 @@
const maxValue = Math.max(...data.map(d => d.total || 0), 1);
return { data, categories: finalCategories, maxValue };
return { data, categories: finalCategories, maxValue, aggregationType, dayCount };
});
// 获取颜色
@@ -176,13 +270,23 @@
// 获取描述文本
let descriptionText = $derived(() => {
const { data } = processedData();
const { data, aggregationType, dayCount } = processedData();
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月';
return `${label}各分类支出趋势 (${data.length} 天)`;
let aggregationHint = '';
if (aggregationType === 'week') {
aggregationHint = `,按周聚合 (${data.length} 周)`;
} else if (aggregationType === 'month') {
aggregationHint = `,按月聚合 (${data.length} 月)`;
} else {
aggregationHint = ` (${dayCount || data.length} 天)`;
}
return `${label}各分类支出趋势${aggregationHint}`;
});
function handleTimeRangeChange(value: string | undefined) {
if (value && ['7d', '30d', '3m'].includes(value)) {
if (value && ['7d', 'week', '30d', 'month', '3m', 'year'].includes(value)) {
timeRange = value as TimeRange;
}
}
@@ -295,13 +399,46 @@
return path;
}
// 生成线性图路径(总金额曲线)
function generateTotalLinePath(data: any[], maxValue: number): string {
if (data.length === 0) return '';
// 计算每天的总支出(所有可见分类的总和)
const points: { x: number; y: number }[] = data.map((d) => {
// 使用 total 字段,这是所有可见分类的累计值
const total = d.total || 0;
return {
x: xScale(d.date, data),
y: yScale(total, maxValue)
};
});
// 起始点
const startPoint = points[0];
let path = `M ${startPoint.x},${startPoint.y}`;
// 平滑曲线
path += generateSmoothPath(points);
return path;
}
// 生成线性图的数据点坐标(总金额)
function getTotalLinePoints(data: any[], maxValue: number): { x: number; y: number; value: number }[] {
return data.map((d) => ({
x: xScale(d.date, data),
y: yScale(d.total || 0, maxValue),
value: d.total || 0
}));
}
// 生成 X 轴刻度
function getXTicks(data: any[]): { x: number; label: string }[] {
if (data.length === 0) return [];
const step = Math.max(1, Math.floor(data.length / 6));
return data.filter((_, i) => i % step === 0 || i === data.length - 1).map(d => ({
x: xScale(d.date, data),
label: `${d.date.getMonth() + 1}/${d.date.getDate()}`
label: d.label || `${d.date.getMonth() + 1}/${d.date.getDate()}`
}));
}
@@ -437,39 +574,72 @@
{descriptionText()}
</Card.Description>
</div>
<Select.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
<Select.Trigger class="w-[140px] h-8 text-xs">
{timeRangeOptions.find(o => o.value === timeRange)?.label}
</Select.Trigger>
<Select.Content>
{#each timeRangeOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex items-center gap-2">
<!-- 图表类型切换 -->
<div class="flex items-center rounded-md border bg-muted/50 p-0.5">
<button
class="flex items-center justify-center h-7 w-7 rounded transition-colors {chartType === 'area' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
onclick={() => chartType = 'area'}
title="堆叠面积图"
>
<AreaChart class="h-4 w-4 {chartType === 'area' ? 'text-primary' : 'text-muted-foreground'}" />
</button>
<button
class="flex items-center justify-center h-7 w-7 rounded transition-colors {chartType === 'line' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
onclick={() => chartType = 'line'}
title="线性图"
>
<LineChart class="h-4 w-4 {chartType === 'line' ? 'text-primary' : 'text-muted-foreground'}" />
</button>
</div>
<!-- 时间范围选择 -->
<Select.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
<Select.Trigger class="w-[140px] h-8 text-xs">
{timeRangeOptions.find(o => o.value === timeRange)?.label}
</Select.Trigger>
<Select.Content>
{#each timeRangeOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</Card.Header>
<Card.Content>
<!-- 分类图例 (点击可切换显示) -->
<!-- 图例 -->
<div class="flex flex-wrap gap-4 mb-4">
{#each categories as category, i}
{@const isHidden = hiddenCategories.has(category)}
<button
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity outline-none"
onclick={() => toggleCategory(category)}
title={isHidden ? '点击显示' : '点击隐藏'}
>
{#if chartType === 'area'}
<!-- 堆叠面积图:分类图例 (点击可切换显示) -->
{#each categories as category, i}
{@const isHidden = hiddenCategories.has(category)}
<button
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity outline-none"
onclick={() => toggleCategory(category)}
title={isHidden ? '点击显示' : '点击隐藏'}
>
<div
class="w-3 h-3 rounded-sm transition-opacity {isHidden ? 'opacity-30' : ''}"
style="background-color: {getColor(i)}"
></div>
<span
class="text-xs transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}"
>{category}</span>
</button>
{/each}
{:else}
<!-- 线性图:总支出图例 -->
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-sm transition-opacity {isHidden ? 'opacity-30' : ''}"
style="background-color: {getColor(i)}"
class="w-3 h-3 rounded-full"
style="background-color: oklch(0.65 0.2 25)"
></div>
<span
class="text-xs transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}"
>{category}</span>
</button>
{/each}
<span class="text-xs text-muted-foreground">总支出</span>
</div>
{/if}
</div>
<!-- 堆叠面积图 (自定义 SVG) -->
<!-- 趋势图 (自定义 SVG) -->
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<svg
@@ -535,16 +705,42 @@
</text>
{/each}
<!-- 堆叠面积 (从后向前渲染) -->
{#each [...categories].reverse() as category, i}
{@const colorIdx = categories.length - 1 - i}
{#if chartType === 'area'}
<!-- 堆叠面积图 (从后向前渲染) -->
{#each [...categories].reverse() as category, i}
{@const colorIdx = categories.length - 1 - i}
<path
d={generateAreaPath(category, data, maxValue)}
fill={getColor(colorIdx)}
fill-opacity="0.7"
class="transition-opacity hover:opacity-90"
/>
{/each}
{:else}
<!-- 线性图(总金额) -->
<!-- 曲线 -->
<path
d={generateAreaPath(category, data, maxValue)}
fill={getColor(colorIdx)}
fill-opacity="0.7"
class="transition-opacity hover:opacity-90"
d={generateTotalLinePath(data, maxValue)}
fill="none"
stroke="oklch(0.65 0.2 25)"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-opacity"
/>
{/each}
<!-- 数据点 -->
{#each getTotalLinePoints(data, maxValue) as point}
<circle
cx={point.x}
cy={point.y}
r="4"
fill="oklch(0.65 0.2 25)"
stroke="white"
stroke-width="2"
class="transition-all"
/>
{/each}
{/if}
<!-- Tooltip 辅助线 -->
{#if tooltipData}
@@ -582,7 +778,7 @@
style="left: {adjustedLeft}%; top: 15%;"
>
<div class="font-medium text-foreground mb-2">
{tooltipData.date.toLocaleDateString('zh-CN')}
{tooltipData.label || tooltipData.date.toLocaleDateString('zh-CN')}
</div>
<div class="space-y-1">
{#each categories as category, i}