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 TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down'; import TrendingDown from '@lucide/svelte/icons/trending-down';
import Calendar from '@lucide/svelte/icons/calendar'; 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 { Button } from '$lib/components/ui/button';
import type { BillRecord } from '$lib/api'; import type { BillRecord } from '$lib/api';
import { pieColors } from '$lib/constants/chart'; import { pieColors } from '$lib/constants/chart';
@@ -24,8 +26,12 @@
let selectedDateRecords = $state<BillRecord[]>([]); let selectedDateRecords = $state<BillRecord[]>([]);
// 时间范围选项 // 时间范围选项
type TimeRange = '7d' | '30d' | '3m'; type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
let timeRange = $state<TimeRange>('3m'); let timeRange = $state<TimeRange>('month');
// 图表类型
type ChartType = 'area' | 'line';
let chartType = $state<ChartType>('area');
// 隐藏的类别 // 隐藏的类别
let hiddenCategories = $state<Set<string>>(new Set()); let hiddenCategories = $state<Set<string>>(new Set());
@@ -42,8 +48,11 @@
const timeRangeOptions = [ const timeRangeOptions = [
{ value: '7d', label: '最近 7 天' }, { value: '7d', label: '最近 7 天' },
{ value: 'week', label: '本周' },
{ value: '30d', label: '最近 30 天' }, { value: '30d', label: '最近 30 天' },
{ value: '3m', label: '最近 3 个月' } { value: 'month', label: '月' },
{ value: '3m', label: '最近 3 个月' },
{ value: 'year', label: '本年' }
]; ];
// 获取截止日期 // 获取截止日期
@@ -52,15 +61,58 @@
switch (range) { switch (range) {
case '7d': case '7d':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); 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': case '30d':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case 'month':
// 本月第一天
return new Date(now.getFullYear(), now.getMonth(), 1);
case '3m': case '3m':
default:
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); 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(() => { let processedData = $derived(() => {
const cutoffDate = getCutoffDate(timeRange); const cutoffDate = getCutoffDate(timeRange);
@@ -71,11 +123,10 @@
return recordDate >= cutoffDate; 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 dailyMap = new Map<string, Map<string, number>>();
const categorySet = new Set<string>();
const categoryTotals: Record<string, number> = {}; const categoryTotals: Record<string, number> = {};
expenseRecords.forEach(record => { expenseRecords.forEach(record => {
@@ -83,16 +134,49 @@
const category = record.category || '其他'; const category = record.category || '其他';
const amount = parseFloat(record.amount) || 0; const amount = parseFloat(record.amount) || 0;
categorySet.add(category);
categoryTotals[category] = (categoryTotals[category] || 0) + amount; categoryTotals[category] = (categoryTotals[category] || 0) + amount;
if (!dateMap.has(dateStr)) { if (!dailyMap.has(dateStr)) {
dateMap.set(dateStr, new Map()); dailyMap.set(dateStr, new Map());
} }
const dayData = dateMap.get(dateStr)!; const dayData = dailyMap.get(dateStr)!;
dayData.set(category, (dayData.get(category) || 0) + amount); 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大分类 // 获取前5大分类
const sortedCategories = Object.entries(categoryTotals) const sortedCategories = Object.entries(categoryTotals)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
@@ -100,16 +184,26 @@
.map(([cat]) => cat); .map(([cat]) => cat);
// 转换为数组格式并计算堆叠值 // 转换为数组格式并计算堆叠值
const data = Array.from(dateMap.entries()) const data = Array.from(aggregatedMap.entries())
.map(([dateStr, dayData]) => { .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> = { const result: Record<string, any> = {
date: new Date(dateStr), date,
dateStr: dateStr dateStr: key,
label: formatAggregationLabel(key, aggregationType)
}; };
let cumulative = 0; let cumulative = 0;
sortedCategories.forEach(cat => { sortedCategories.forEach(cat => {
const value = dayData.get(cat) || 0; const value = aggData.get(cat) || 0;
result[cat] = value; result[cat] = value;
// 只有未隐藏的类别参与堆叠 // 只有未隐藏的类别参与堆叠
if (!hiddenCategories.has(cat)) { if (!hiddenCategories.has(cat)) {
@@ -124,7 +218,7 @@
// 其他分类汇总 // 其他分类汇总
let otherSum = 0; let otherSum = 0;
dayData.forEach((amount, cat) => { aggData.forEach((amount, cat) => {
if (!sortedCategories.includes(cat)) { if (!sortedCategories.includes(cat)) {
otherSum += amount; otherSum += amount;
} }
@@ -153,7 +247,7 @@
const maxValue = Math.max(...data.map(d => d.total || 0), 1); 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(() => { let descriptionText = $derived(() => {
const { data } = processedData(); const { data, aggregationType, dayCount } = processedData();
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月'; 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) { 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; timeRange = value as TimeRange;
} }
} }
@@ -295,13 +399,46 @@
return path; 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 轴刻度 // 生成 X 轴刻度
function getXTicks(data: any[]): { x: number; label: string }[] { function getXTicks(data: any[]): { x: number; label: string }[] {
if (data.length === 0) return []; if (data.length === 0) return [];
const step = Math.max(1, Math.floor(data.length / 6)); const step = Math.max(1, Math.floor(data.length / 6));
return data.filter((_, i) => i % step === 0 || i === data.length - 1).map(d => ({ return data.filter((_, i) => i % step === 0 || i === data.length - 1).map(d => ({
x: xScale(d.date, data), x: xScale(d.date, data),
label: `${d.date.getMonth() + 1}/${d.date.getDate()}` label: d.label || `${d.date.getMonth() + 1}/${d.date.getDate()}`
})); }));
} }
@@ -437,6 +574,26 @@
{descriptionText()} {descriptionText()}
</Card.Description> </Card.Description>
</div> </div>
<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.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
<Select.Trigger class="w-[140px] h-8 text-xs"> <Select.Trigger class="w-[140px] h-8 text-xs">
{timeRangeOptions.find(o => o.value === timeRange)?.label} {timeRangeOptions.find(o => o.value === timeRange)?.label}
@@ -447,10 +604,13 @@
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</div>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<!-- 分类图例 (点击可切换显示) --> <!-- 图例 -->
<div class="flex flex-wrap gap-4 mb-4"> <div class="flex flex-wrap gap-4 mb-4">
{#if chartType === 'area'}
<!-- 堆叠面积图:分类图例 (点击可切换显示) -->
{#each categories as category, i} {#each categories as category, i}
{@const isHidden = hiddenCategories.has(category)} {@const isHidden = hiddenCategories.has(category)}
<button <button
@@ -467,9 +627,19 @@
>{category}</span> >{category}</span>
</button> </button>
{/each} {/each}
{:else}
<!-- 线性图:总支出图例 -->
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
style="background-color: oklch(0.65 0.2 25)"
></div>
<span class="text-xs text-muted-foreground">总支出</span>
</div>
{/if}
</div> </div>
<!-- 堆叠面积图 (自定义 SVG) --> <!-- 趋势图 (自定义 SVG) -->
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};"> <div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<svg <svg
@@ -535,7 +705,8 @@
</text> </text>
{/each} {/each}
<!-- 堆叠面积 (从后向前渲染) --> {#if chartType === 'area'}
<!-- 堆叠面积图 (从后向前渲染) -->
{#each [...categories].reverse() as category, i} {#each [...categories].reverse() as category, i}
{@const colorIdx = categories.length - 1 - i} {@const colorIdx = categories.length - 1 - i}
<path <path
@@ -545,6 +716,31 @@
class="transition-opacity hover:opacity-90" class="transition-opacity hover:opacity-90"
/> />
{/each} {/each}
{:else}
<!-- 线性图(总金额) -->
<!-- 曲线 -->
<path
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 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 辅助线 --> <!-- Tooltip 辅助线 -->
{#if tooltipData} {#if tooltipData}
@@ -582,7 +778,7 @@
style="left: {adjustedLeft}%; top: 15%;" style="left: {adjustedLeft}%; top: 15%;"
> >
<div class="font-medium text-foreground mb-2"> <div class="font-medium text-foreground mb-2">
{tooltipData.date.toLocaleDateString('zh-CN')} {tooltipData.label || tooltipData.date.toLocaleDateString('zh-CN')}
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
{#each categories as category, i} {#each categories as category, i}

View File

@@ -1,48 +1,198 @@
import type { BillRecord } from '$lib/api'; import type { BillRecord } from '$lib/api';
/** 演示数据(基于支付宝真实数据格式) */ // 生成随机金额
export const demoRecords: BillRecord[] = [ function randomAmount(min: number, max: number): string {
{ time: '2026-01-07 12:01:02', category: '餐饮美食', merchant: '金山武汉食堂', description: '金山武汉食堂-烧腊', income_expense: '支出', amount: '23.80', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' }, return (Math.random() * (max - min) + min).toFixed(2);
{ time: '2026-01-06 15:54:53', category: '餐饮美食', merchant: '友宝', description: '智能货柜消费', income_expense: '支出', amount: '7.19', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' }, }
{ time: '2026-01-06 11:55:10', category: '餐饮美食', merchant: '金山武汉食堂', description: '金山武汉食堂-小碗菜', income_expense: '支出', amount: '12.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-06 09:35:09', category: '交通出行', merchant: '高德打车', description: '高德打车订单', income_expense: '支出', amount: '16.09', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-05 18:59:11', category: '餐饮美食', merchant: '板栗', description: '收钱码收款', income_expense: '支出', amount: '21.00', payment_method: '花呗', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-05 18:22:34', category: '日用百货', merchant: '金山便利店', description: '立码收收款', income_expense: '支出', amount: '40.69', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-05 15:16:38', category: '充值缴费', merchant: '武汉供电公司', description: '电费自动缴费', income_expense: '支出', amount: '50.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-05 13:49:13', category: '餐饮美食', merchant: '友宝', description: '维他柠檬茶', income_expense: '支出', amount: '2.40', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-05 11:59:45', category: '餐饮美食', merchant: '金山武汉食堂', description: '金山武汉食堂-小碗菜', income_expense: '支出', amount: '9.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-05 09:36:44', category: '交通出行', merchant: '高德打车', description: '高德打车订单', income_expense: '支出', amount: '13.43', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-04 19:27:50', category: '日用百货', merchant: '朴朴超市', description: '朴朴商品订单', income_expense: '支出', amount: '52.77', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-04 17:06:22', category: '餐饮美食', merchant: '友宝', description: '智能货柜消费', income_expense: '支出', amount: '2.55', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-04 12:03:39', category: '餐饮美食', merchant: '金山武汉食堂', description: '金山武汉食堂-烧腊', income_expense: '支出', amount: '23.80', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-04 09:29:28', category: '交通出行', merchant: '高德打车', description: '高德打车订单', income_expense: '支出', amount: '22.86', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-04 09:04:05', category: '餐饮美食', merchant: '巴比鲜包', description: '早餐', income_expense: '支出', amount: '8.00', payment_method: '花呗', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-03 22:30:01', category: '餐饮美食', merchant: '美团', description: '长沙臭豆腐', income_expense: '支出', amount: '20.88', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-03 17:04:37', category: '家居家装', merchant: '淘宝', description: '四件套', income_expense: '支出', amount: '156.35', payment_method: '招商银行信用卡', status: '支付成功', remark: '', needs_review: '' },
{ time: '2026-01-03 13:44:03', category: '餐饮美食', merchant: '淘宝闪购', description: '必胜客外卖', income_expense: '支出', amount: '55.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-03 10:16:31', category: '充值缴费', merchant: '湖北联通', description: '手机充值', income_expense: '支出', amount: '50.00', payment_method: '招商银行信用卡', status: '充值成功', remark: '', needs_review: '' },
{ time: '2026-01-03 00:17:12', category: '交通出行', merchant: '高德打车', description: '高德打车订单', income_expense: '支出', amount: '17.45', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-02 21:29:15', category: '交通出行', merchant: '高德打车', description: '高德打车订单', income_expense: '支出', amount: '20.65', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-02 15:39:08', category: '交通出行', merchant: '高德打车', description: '高德打车订单', income_expense: '支出', amount: '12.61', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-02 13:30:02', category: '充值缴费', merchant: '武汉燃气集团', description: '燃气费', income_expense: '支出', amount: '300.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-02 12:06:04', category: '餐饮美食', merchant: '淘宝闪购', description: '外卖订单', income_expense: '支出', amount: '17.38', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-02 12:04:27', category: '运动健身', merchant: '携程', description: '武汉冰雪中心', income_expense: '支出', amount: '390.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-02 11:05:33', category: '充值缴费', merchant: '中国移动', description: '话费充值', income_expense: '支出', amount: '50.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-02 01:46:12', category: '充值缴费', merchant: '中国移动', description: '话费自动充值', income_expense: '支出', amount: '50.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-01 21:42:18', category: '文化休闲', merchant: '雷神', description: '超级会员', income_expense: '支出', amount: '88.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-01 20:44:27', category: '餐饮美食', merchant: '美团', description: '茶百道', income_expense: '支出', amount: '6.85', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-01 20:42:34', category: '餐饮美食', merchant: '美团', description: '南膳房北京烤鸭', income_expense: '支出', amount: '20.20', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-01 15:38:40', category: '餐饮美食', merchant: '淘宝闪购', description: '米已成粥外卖', income_expense: '支出', amount: '19.90', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2026-01-01 14:58:20', category: '家居家装', merchant: '淘宝', description: '四件套', income_expense: '支出', amount: '137.85', payment_method: '招商银行信用卡', status: '支付成功', remark: '', needs_review: '' },
{ time: '2026-01-01 14:57:26', category: '数码电器', merchant: '天猫', description: '手机膜', income_expense: '支出', amount: '22.24', payment_method: '招商银行信用卡', status: '等待确认收货', remark: '', needs_review: '' },
{ time: '2026-01-01 14:29:47', category: '文化休闲', merchant: '南方新媒体', description: '超级大会员', income_expense: '支出', amount: '25.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2025-12-31 18:30:00', category: '餐饮美食', merchant: '火锅店', description: '跨年火锅', income_expense: '支出', amount: '288.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2025-12-30 12:00:00', category: '餐饮美食', merchant: '金山食堂', description: '午餐', income_expense: '支出', amount: '15.00', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2025-12-29 09:00:00', category: '交通出行', merchant: '高德打车', description: '打车', income_expense: '支出', amount: '18.50', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
{ time: '2025-12-28 20:00:00', category: '餐饮美食', merchant: '外卖', description: '晚餐外卖', income_expense: '支出', amount: '35.00', payment_method: '花呗', status: '交易成功', remark: '', needs_review: '' },
// 收入记录
{ time: '2026-01-05 10:00:00', category: '退款', merchant: '淘宝', description: '商品退款', income_expense: '收入', amount: '99.00', payment_method: '原路退回', status: '退款成功', remark: '', needs_review: '' },
{ time: '2026-01-01 09:00:00', category: '其他收入', merchant: '支付宝', description: '新年红包', income_expense: '收入', amount: '8.88', payment_method: '余额', status: '已到账', remark: '', needs_review: '' },
];
// 生成指定日期的时间字符串
function formatDateTime(date: Date, hour: number, minute: number): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(hour).padStart(2, '0');
const min = String(minute).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}:00`;
}
// 商家和消费场景配置
const merchants = {
: [
{ merchant: '金山武汉食堂', descriptions: ['午餐', '烧腊', '小碗菜'], amountRange: [12, 28] },
{ merchant: '瑞幸咖啡', descriptions: ['生椰拿铁', '美式', '拿铁'], amountRange: [9.9, 18] },
{ merchant: '星巴克', descriptions: ['美式咖啡', '拿铁', '星冰乐'], amountRange: [28, 45] },
{ merchant: '麦当劳', descriptions: ['早餐套餐', '午餐套餐', '汉堡'], amountRange: [20, 45] },
{ merchant: '肯德基', descriptions: ['套餐', '炸鸡', '汉堡'], amountRange: [25, 50] },
{ merchant: '海底捞', descriptions: ['火锅', '聚餐'], amountRange: [150, 300] },
{ merchant: '美团外卖', descriptions: ['外卖订单', '午餐外卖', '晚餐外卖'], amountRange: [20, 50] },
{ merchant: '饿了么', descriptions: ['外卖订单', '午餐', '晚餐'], amountRange: [18, 45] },
{ merchant: '便利店', descriptions: ['零食', '饮料', '早餐'], amountRange: [8, 30] },
{ merchant: '喜茶', descriptions: ['多肉葡萄', '芝芝莓莓'], amountRange: [18, 32] },
],
: [
{ merchant: '高德打车', descriptions: ['打车订单', '快车'], amountRange: [12, 35] },
{ merchant: '滴滴出行', descriptions: ['快车', '打车'], amountRange: [15, 40] },
{ merchant: '武汉地铁', descriptions: ['地铁充值', '乘车'], amountRange: [50, 100] },
{ merchant: '哈啰单车', descriptions: ['骑行', '单车'], amountRange: [1.5, 5] },
{ merchant: '中国石化', descriptions: ['加油', '油费'], amountRange: [200, 400] },
],
: [
{ merchant: '朴朴超市', descriptions: ['日用品', '商品订单'], amountRange: [30, 80] },
{ merchant: '盒马鲜生', descriptions: ['生鲜蔬果', '日用品'], amountRange: [50, 150] },
{ merchant: '沃尔玛', descriptions: ['日用品采购', '超市购物'], amountRange: [80, 200] },
{ merchant: '名创优品', descriptions: ['日用品', '小商品'], amountRange: [20, 60] },
{ merchant: '屈臣氏', descriptions: ['洗护用品', '化妆品'], amountRange: [50, 150] },
],
: [
{ merchant: '武汉供电公司', descriptions: ['电费', '电费缴费'], amountRange: [50, 200] },
{ merchant: '武汉燃气集团', descriptions: ['燃气费', '天然气'], amountRange: [100, 300] },
{ merchant: '中国移动', descriptions: ['话费充值', '手机充值'], amountRange: [50, 100] },
{ merchant: '中国联通', descriptions: ['话费充值', '手机充值'], amountRange: [50, 100] },
{ merchant: '中国电信', descriptions: ['宽带续费', '话费'], amountRange: [100, 200] },
],
: [
{ merchant: '优衣库', descriptions: ['衣服', '裤子', '外套'], amountRange: [100, 500] },
{ merchant: 'ZARA', descriptions: ['衣服', '外套'], amountRange: [200, 600] },
{ merchant: 'Nike', descriptions: ['运动鞋', '运动服'], amountRange: [300, 800] },
{ merchant: '淘宝', descriptions: ['服装', '鞋子'], amountRange: [80, 300] },
],
: [
{ merchant: '京东', descriptions: ['数码配件', '电子产品'], amountRange: [50, 500] },
{ merchant: '天猫', descriptions: ['手机配件', '电子产品'], amountRange: [30, 200] },
{ merchant: '苹果官网', descriptions: ['配件', '保护壳'], amountRange: [100, 300] },
],
: [
{ merchant: '腾讯视频', descriptions: ['VIP会员', '会员续费'], amountRange: [25, 30] },
{ merchant: '爱奇艺', descriptions: ['VIP会员', '会员'], amountRange: [25, 30] },
{ merchant: 'B站', descriptions: ['大会员', '会员'], amountRange: [25, 25] },
{ merchant: '万达影城', descriptions: ['电影票', '观影'], amountRange: [40, 100] },
{ merchant: '书店', descriptions: ['书籍', '购书'], amountRange: [30, 100] },
],
: [
{ merchant: '迪卡侬', descriptions: ['运动装备', '健身用品'], amountRange: [100, 300] },
{ merchant: '健身房', descriptions: ['月卡', '私教课'], amountRange: [200, 500] },
{ merchant: '携程', descriptions: ['滑雪', '运动场馆'], amountRange: [150, 400] },
],
: [
{ merchant: '药店', descriptions: ['药品', '保健品'], amountRange: [30, 100] },
{ merchant: '医院', descriptions: ['挂号费', '门诊'], amountRange: [20, 100] },
],
: [
{ merchant: '淘宝', descriptions: ['家居用品', '四件套', '收纳'], amountRange: [50, 200] },
{ merchant: '宜家', descriptions: ['家具', '家居'], amountRange: [100, 500] },
],
};
const paymentMethods = ['微信支付', '支付宝', '招商银行信用卡', '花呗', '工商银行储蓄卡'];
// 生成单条支出记录
function generateExpenseRecord(date: Date, category: string): BillRecord {
const categoryMerchants = merchants[category as keyof typeof merchants] || merchants['餐饮美食'];
const merchantInfo = categoryMerchants[Math.floor(Math.random() * categoryMerchants.length)];
const description = merchantInfo.descriptions[Math.floor(Math.random() * merchantInfo.descriptions.length)];
const amount = randomAmount(merchantInfo.amountRange[0], merchantInfo.amountRange[1]);
const paymentMethod = paymentMethods[Math.floor(Math.random() * paymentMethods.length)];
const hour = Math.floor(Math.random() * 14) + 8; // 8:00 - 22:00
const minute = Math.floor(Math.random() * 60);
return {
time: formatDateTime(date, hour, minute),
category,
merchant: merchantInfo.merchant,
description,
income_expense: '支出',
amount,
payment_method: paymentMethod,
status: '交易成功',
remark: '',
needs_review: '',
};
}
// 生成收入记录
function generateIncomeRecord(date: Date): BillRecord {
const incomeTypes = [
{ category: '退款', merchant: '淘宝', description: '商品退款', amountRange: [30, 200] },
{ category: '退款', merchant: '京东', description: '退货退款', amountRange: [50, 300] },
{ category: '其他收入', merchant: '微信红包', description: '红包', amountRange: [5, 100] },
{ category: '其他收入', merchant: '支付宝', description: '余额宝收益', amountRange: [1, 20] },
{ category: '其他收入', merchant: '微信转账', description: '朋友转账', amountRange: [100, 500] },
];
const incomeInfo = incomeTypes[Math.floor(Math.random() * incomeTypes.length)];
const amount = randomAmount(incomeInfo.amountRange[0], incomeInfo.amountRange[1]);
const hour = Math.floor(Math.random() * 14) + 8;
const minute = Math.floor(Math.random() * 60);
return {
time: formatDateTime(date, hour, minute),
category: incomeInfo.category,
merchant: incomeInfo.merchant,
description: incomeInfo.description,
income_expense: '收入',
amount,
payment_method: incomeInfo.category === '退款' ? '原路退回' : '微信零钱',
status: incomeInfo.category === '退款' ? '退款成功' : '已到账',
remark: '',
needs_review: '',
};
}
// 生成一天的记录
function generateDayRecords(date: Date): BillRecord[] {
const records: BillRecord[] = [];
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
// 每天固定的支出
const categories = Object.keys(merchants);
// 工作日:早餐、午餐、晚餐、交通
// 周末:可能有更多消费
const baseExpenses = isWeekend ?
Math.floor(Math.random() * 4) + 4 : // 周末 4-7 笔
Math.floor(Math.random() * 3) + 3; // 工作日 3-5 笔
// 必有餐饮
records.push(generateExpenseRecord(date, '餐饮美食'));
records.push(generateExpenseRecord(date, '餐饮美食'));
// 可能有交通
if (Math.random() > 0.3) {
records.push(generateExpenseRecord(date, '交通出行'));
}
// 随机其他消费
for (let i = 0; i < baseExpenses - 2; i++) {
const randomCategory = categories[Math.floor(Math.random() * categories.length)];
records.push(generateExpenseRecord(date, randomCategory));
}
// 10% 概率有收入
if (Math.random() < 0.1) {
records.push(generateIncomeRecord(date));
}
return records;
}
// 生成指定日期范围的所有记录
function generateRecords(startDate: Date, endDate: Date): BillRecord[] {
const records: BillRecord[] = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
records.push(...generateDayRecords(new Date(currentDate)));
currentDate.setDate(currentDate.getDate() + 1);
}
// 按时间排序(最新的在前)
return records.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
}
// 生成从2025年10月1日到2026年1月8日的数据
const startDate = new Date(2025, 9, 1); // 2025-10-01
const endDate = new Date(2026, 0, 8); // 2026-01-08
/** 演示数据(支付宝 + 微信支付混合数据覆盖约100天 */
export const demoRecords: BillRecord[] = generateRecords(startDate, endDate);