feat(analysis): 趋势图增加本周选项、线性图简化为总金额曲线
- 添加本周时间范围选项 - 线性图模式只显示总支出曲线,不再显示分类曲线 - 图例根据图表类型动态切换(堆叠图显示分类,线性图显示总支出) - 时间范围选项:7天、本周、30天、本月、3个月、本年
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -1,48 +1,198 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
|
||||
/** 演示数据(基于支付宝真实数据格式) */
|
||||
export const demoRecords: BillRecord[] = [
|
||||
{ time: '2026-01-07 12:01:02', category: '餐饮美食', merchant: '金山武汉食堂', description: '金山武汉食堂-烧腊', income_expense: '支出', amount: '23.80', payment_method: '招商银行信用卡', status: '交易成功', remark: '', needs_review: '' },
|
||||
{ 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 randomAmount(min: number, max: number): string {
|
||||
return (Math.random() * (max - min) + min).toFixed(2);
|
||||
}
|
||||
|
||||
// 生成指定日期的时间字符串
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user