Files
billai/web/src/lib/components/analysis/DailyTrendChart.svelte
cheliangzhao e2e1beb6f7 feat: implement cross-batch Alipay refund reconciliation
When a refund row in an uploaded Alipay bill has no matching expense
row in the same batch (because the original purchase was uploaded in a
prior batch), the refund is now reconciled against the stored record in
bills_cleaned rather than being silently discarded.

Changes:
- analyzer/cleaners/base.py: add unresolved_refunds list to BaseCleaner
- analyzer/cleaners/alipay.py: _aggregate_refunds stores full refund
  metadata (dict); _process_expenses tracks matched keys and populates
  self.unresolved_refunds for unmatched refunds
- analyzer/server.py: thread unresolved_refunds through do_clean,
  CleanResponse, and both /clean endpoints
- server/adapter/adapter.go: add UnresolvedRefund type and field to CleanResult
- server/adapter/http/cleaner.go: deserialize unresolved_refunds from
  Python response and populate CleanResult
- server/repository/repository.go: add ReconcileRefund to BillRepository interface
- server/repository/mongo/repository.go: implement ReconcileRefund —
  full refund soft-deletes the bill, partial refund reduces amount and
  appends remark with original amount and refund order number
- server/handler/upload.go: capture clean result and call ReconcileRefund
  for each unresolved refund after saving cleaned bills
- server/model/response.go: add ReconciledRefundCount to UploadData

Also: add CLAUDE.md (@AGENTS.md), update AGENTS.md, fix DailyTrendChart
missing-date gap by filling zero-expense dates in daily map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:29:47 +08:00

972 lines
32 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as Drawer from '$lib/components/ui/drawer';
import Activity from '@lucide/svelte/icons/activity';
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 { UIBill } from '$lib/models/bill';
import { pieColors } from '$lib/constants/chart';
import { formatLocalDate } from '$lib/utils';
import BillRecordsTable from './BillRecordsTable.svelte';
interface Props {
records: UIBill[];
categories?: string[];
onUpdate?: (updated: UIBill, original: UIBill) => void;
onDelete?: (deleted: UIBill) => void;
}
let { records = $bindable(), categories = [], onUpdate, onDelete }: Props = $props();
function handleRecordUpdated(updated: UIBill, original: UIBill) {
// 更新 records 数组
const idx = records.findIndex(r =>
r === original ||
(r.time === original.time && r.merchant === original.merchant && r.amount === original.amount)
);
if (idx !== -1) {
records[idx] = updated;
records = [...records];
}
// 更新 selectedDateRecords如果账单在当前选中的日期记录中
const dateIdx = selectedDateRecords.findIndex(r =>
r === original ||
(r.time === original.time && r.merchant === original.merchant && r.amount === original.amount)
);
if (dateIdx !== -1) {
selectedDateRecords[dateIdx] = updated;
selectedDateRecords = [...selectedDateRecords];
}
// 传播到父组件
onUpdate?.(updated, original);
}
function handleRecordDeleted(deleted: UIBill) {
const idx = records.findIndex(r =>
r === deleted ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (idx !== -1) {
records.splice(idx, 1);
records = [...records];
}
const dateIdx = selectedDateRecords.findIndex(r =>
r === deleted ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (dateIdx !== -1) {
selectedDateRecords.splice(dateIdx, 1);
selectedDateRecords = [...selectedDateRecords];
}
onDelete?.(deleted);
}
// Dialog 状态
let dialogOpen = $state(false);
let selectedDate = $state<Date | null>(null);
let selectedDateRecords = $state<UIBill[]>([]);
// 时间范围选项
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());
function toggleCategory(category: string) {
const newSet = new Set(hiddenCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
hiddenCategories = newSet;
}
// 提取日期字符串 (YYYY-MM-DD) - 兼容多种格式
function extractDateStr(timeStr: string): string {
// 处理 ISO 格式 (2025-12-29T10:30:00Z) 或空格格式 (2025-12-29 10:30:00)
return timeStr.split('T')[0].split(' ')[0];
}
const timeRangeOptions = [
{ value: '7d', label: '最近 7 天' },
{ value: 'week', label: '本周' },
{ value: '30d', label: '最近 30 天' },
{ value: 'month', label: '本月' },
{ value: '3m', label: '最近 3 个月' },
{ value: 'year', label: '本年' }
];
// 获取截止日期
function getCutoffDate(range: TimeRange): Date {
const now = new Date();
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':
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 formatLocalDate(d);
}
// 获取月份标识
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);
// 过滤支出记录
const expenseRecords = records.filter(r => {
if (r.incomeExpense !== '支出') return false;
const recordDate = new Date(extractDateStr(r.time));
return recordDate >= cutoffDate;
});
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0, aggregationType: 'day' as AggregationType };
// 先按天分组,计算天数
const dailyMap = new Map<string, Map<string, number>>();
const categoryTotals: Record<string, number> = {};
expenseRecords.forEach(record => {
const dateStr = extractDateStr(record.time);
const category = record.category || '其他';
const amount = record.amount || 0;
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
if (!dailyMap.has(dateStr)) {
dailyMap.set(dateStr, new Map());
}
const dayData = dailyMap.get(dateStr)!;
dayData.set(category, (dayData.get(category) || 0) + amount);
});
// 填充缺失的日期(零支出日期)
const now = new Date();
const allDates: string[] = [];
const cursor = new Date(cutoffDate);
while (cursor <= now) {
allDates.push(formatLocalDate(cursor));
cursor.setDate(cursor.getDate() + 1);
}
allDates.forEach(dateStr => {
if (!dailyMap.has(dateStr)) {
dailyMap.set(dateStr, new Map());
}
});
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])
.slice(0, 5)
.map(([cat]) => cat);
// 转换为数组格式并计算堆叠值
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,
dateStr: key,
label: formatAggregationLabel(key, aggregationType)
};
let cumulative = 0;
sortedCategories.forEach(cat => {
const value = aggData.get(cat) || 0;
result[cat] = value;
// 只有未隐藏的类别参与堆叠
if (!hiddenCategories.has(cat)) {
result[`${cat}_y0`] = cumulative;
result[`${cat}_y1`] = cumulative + value;
cumulative += value;
} else {
result[`${cat}_y0`] = 0;
result[`${cat}_y1`] = 0;
}
});
// 其他分类汇总
let otherSum = 0;
aggData.forEach((amount, cat) => {
if (!sortedCategories.includes(cat)) {
otherSum += amount;
}
});
if (otherSum > 0) {
result['其他'] = otherSum;
if (!hiddenCategories.has('其他')) {
result['其他_y0'] = cumulative;
result['其他_y1'] = cumulative + otherSum;
cumulative += otherSum;
} else {
result['其他_y0'] = 0;
result['其他_y1'] = 0;
}
}
result.total = cumulative;
return result;
})
.sort((a, b) => a.date.getTime() - b.date.getTime());
const finalCategories = [...sortedCategories];
if (data.some(d => d['其他'] > 0)) {
finalCategories.push('其他');
}
const maxValue = Math.max(...data.map(d => d.total || 0), 1);
return { data, categories: finalCategories, maxValue, aggregationType, dayCount };
});
// 获取颜色
function getColor(index: number): string {
return pieColors[index % pieColors.length];
}
// 趋势计算
let trendInfo = $derived(() => {
const { data } = processedData();
if (data.length < 2) return null;
const lastTotal = data[data.length - 1].total || 0;
const prevTotal = data[data.length - 2].total || 0;
const change = lastTotal - prevTotal;
const changePercent = prevTotal > 0 ? (change / prevTotal) * 100 : 0;
return { change, changePercent, lastTotal };
});
// 获取描述文本
let descriptionText = $derived(() => {
const { data, aggregationType, dayCount } = processedData();
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月';
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', 'week', '30d', 'month', '3m', 'year'].includes(value)) {
timeRange = value as TimeRange;
}
}
// SVG 尺寸
const chartWidth = 800;
const chartHeight = 250;
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
const innerWidth = chartWidth - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
// 坐标转换
function xScale(date: Date, data: any[]): number {
if (data.length <= 1) return padding.left;
const minDate = data[0].date.getTime();
const maxDate = data[data.length - 1].date.getTime();
const range = maxDate - minDate || 1;
return padding.left + ((date.getTime() - minDate) / range) * innerWidth;
}
function yScale(value: number, maxValue: number): number {
if (maxValue === 0) return padding.top + innerHeight;
return padding.top + innerHeight - (value / maxValue) * innerHeight;
}
// 生成平滑曲线的控制点 (Catmull-Rom to Bezier)
function getCurveControlPoints(
p0: { x: number; y: number },
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
tension: number = 0.3
): { cp1: { x: number; y: number }; cp2: { x: number; y: number } } {
const d1 = Math.sqrt(Math.pow(p1.x - p0.x, 2) + Math.pow(p1.y - p0.y, 2));
const d2 = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
const d3 = Math.sqrt(Math.pow(p3.x - p2.x, 2) + Math.pow(p3.y - p2.y, 2));
const d1a = Math.pow(d1, tension);
const d2a = Math.pow(d2, tension);
const d3a = Math.pow(d3, tension);
const cp1 = {
x: p1.x + (d1a !== 0 ? (p2.x - p0.x) * d2a / (d1a + d2a) / 6 * tension * 6 : 0),
y: p1.y + (d1a !== 0 ? (p2.y - p0.y) * d2a / (d1a + d2a) / 6 * tension * 6 : 0)
};
const cp2 = {
x: p2.x - (d3a !== 0 ? (p3.x - p1.x) * d2a / (d2a + d3a) / 6 * tension * 6 : 0),
y: p2.y - (d3a !== 0 ? (p3.y - p1.y) * d2a / (d2a + d3a) / 6 * tension * 6 : 0)
};
return { cp1, cp2 };
}
// 生成平滑曲线路径
function generateSmoothPath(points: { x: number; y: number }[]): string {
if (points.length < 2) return '';
if (points.length === 2) {
return `L ${points[1].x},${points[1].y}`;
}
let path = '';
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
const { cp1, cp2 } = getCurveControlPoints(p0, p1, p2, p3);
path += ` C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${p2.x},${p2.y}`;
}
return path;
}
// 生成面积路径(平滑曲线版本)
function generateAreaPath(category: string, data: any[], maxValue: number): string {
if (data.length === 0) return '';
const topPoints: { x: number; y: number }[] = [];
const bottomPoints: { x: number; y: number }[] = [];
data.forEach((d) => {
const x = xScale(d.date, data);
const y1 = yScale(d[`${category}_y1`] || 0, maxValue);
const y0 = yScale(d[`${category}_y0`] || 0, maxValue);
topPoints.push({ x, y: y1 });
bottomPoints.unshift({ x, y: y0 });
});
// 起始点
const startPoint = topPoints[0];
let path = `M ${startPoint.x},${startPoint.y}`;
// 顶部曲线
path += generateSmoothPath(topPoints);
// 连接到底部起点
const bottomStart = bottomPoints[0];
path += ` L ${bottomStart.x},${bottomStart.y}`;
// 底部曲线
path += generateSmoothPath(bottomPoints);
// 闭合路径
path += ' Z';
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.label || `${d.date.getMonth() + 1}/${d.date.getDate()}`
}));
}
// 生成 Y 轴刻度
function getYTicks(maxValue: number): { y: number; label: string }[] {
if (maxValue === 0) return [];
const ticks = [];
const step = Math.ceil(maxValue / 4 / 100) * 100;
for (let v = 0; v <= maxValue; v += step) {
ticks.push({
y: yScale(v, maxValue),
label: ${v}`
});
}
return ticks;
}
// Tooltip 状态
let tooltipData = $state<any>(null);
let tooltipX = $state(0);
let tooltipY = $state(0);
function handleMouseMove(event: MouseEvent, data: any[], maxValue: number) {
const svg = event.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
// 将像素坐标转换为 viewBox 坐标
const pixelX = event.clientX - rect.left;
const scaleRatio = chartWidth / rect.width;
const viewBoxX = pixelX * scaleRatio;
// 找到最近的数据点
let closestIdx = 0;
let closestDist = Infinity;
data.forEach((d, i) => {
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
if (dx < closestDist) {
closestDist = dx;
closestIdx = i;
}
});
// 阈值也需要按比例调整
if (closestDist < 50 * scaleRatio) {
tooltipData = data[closestIdx];
tooltipX = xScale(data[closestIdx].date, data);
tooltipY = event.clientY - rect.top;
} else {
tooltipData = null;
}
}
function handleMouseLeave() {
tooltipData = null;
}
function openDateDetails(clickedDate: Date) {
const dateStr = formatLocalDate(clickedDate);
selectedDate = clickedDate;
selectedDateRecords = records.filter(r => {
if (r.incomeExpense !== '支出') return false;
const recordDateStr = extractDateStr(r.time);
return recordDateStr === dateStr;
});
dialogOpen = true;
}
// 点击打开 Dialog
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
if (data.length === 0) return;
const svg = event.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
// 将像素坐标转换为 viewBox 坐标
const pixelX = event.clientX - rect.left;
const scaleRatio = chartWidth / rect.width;
const viewBoxX = pixelX * scaleRatio;
// 找到最近的数据点
let closestIdx = 0;
let closestDist = Infinity;
data.forEach((d, i) => {
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
if (dx < closestDist) {
closestDist = dx;
closestIdx = i;
}
});
// 点击图表任意位置都触发,选择最近的日期
const clickedDate = data[closestIdx].date;
openDateDetails(clickedDate);
}
// 计算选中日期的统计
let selectedDateStats = $derived.by(() => {
if (!selectedDate || selectedDateRecords.length === 0) return null;
const categoryMap = new Map<string, { amount: number; count: number }>();
let total = 0;
selectedDateRecords.forEach(r => {
const cat = r.category || '其他';
const amount = r.amount || 0;
total += amount;
if (!categoryMap.has(cat)) {
categoryMap.set(cat, { amount: 0, count: 0 });
}
const stat = categoryMap.get(cat)!;
stat.amount += amount;
stat.count += 1;
});
const categories = Array.from(categoryMap.entries())
.map(([category, stat]) => ({ category, ...stat }))
.sort((a, b) => b.amount - a.amount);
return { total, categories, count: selectedDateRecords.length };
});
</script>
{#if processedData().data.length > 1}
{@const { data, categories, maxValue } = processedData()}
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div class="space-y-1.5">
<Card.Title class="flex items-center gap-2">
<Activity class="h-5 w-5" />
每日支出趋势
</Card.Title>
<Card.Description>
{descriptionText()}
</Card.Description>
</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.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">
{#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-full"
style="background-color: oklch(0.65 0.2 25)"
></div>
<span class="text-xs text-muted-foreground">总支出</span>
</div>
{/if}
</div>
<!-- 趋势图 (自定义 SVG) -->
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
<svg
viewBox="0 0 {chartWidth} {chartHeight}"
class="w-full h-full cursor-pointer outline-none focus:outline-none"
role="button"
aria-label="每日支出趋势图表,点击可查看当日详情"
tabindex="0"
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
onmouseleave={handleMouseLeave}
onclick={(e) => handleClick(e, data, maxValue)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const last = data[data.length - 1];
if (last?.date) openDateDetails(last.date);
}
}}
>
<!-- Y 轴 -->
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.2"
/>
<!-- Y 轴刻度 -->
{#each getYTicks(maxValue) as tick}
<line
x1={padding.left}
y1={tick.y}
x2={padding.left + innerWidth}
y2={tick.y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4"
/>
<text
x={padding.left - 8}
y={tick.y + 4}
text-anchor="end"
class="fill-muted-foreground text-[10px]"
>
{tick.label}
</text>
{/each}
<!-- X 轴 -->
<line
x1={padding.left}
y1={padding.top + innerHeight}
x2={padding.left + innerWidth}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.2"
/>
<!-- X 轴刻度 -->
{#each getXTicks(data) as tick}
<text
x={tick.x}
y={padding.top + innerHeight + 20}
text-anchor="middle"
class="fill-muted-foreground text-[10px]"
>
{tick.label}
</text>
{/each}
{#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={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 辅助线 -->
{#if tooltipData}
<line
x1={tooltipX}
y1={padding.top}
x2={tooltipX}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.3"
stroke-dasharray="4"
/>
<!-- 数据点 -->
{#each categories as category, i}
{#if tooltipData[category] > 0}
<circle
cx={tooltipX}
cy={yScale(tooltipData[`${category}_y1`] || 0, maxValue)}
r="4"
fill={getColor(i)}
stroke="white"
stroke-width="2"
/>
{/if}
{/each}
{/if}
</svg>
<!-- Tooltip -->
{#if tooltipData}
{@const tooltipLeftPercent = (tooltipX / chartWidth) * 100}
{@const adjustedLeft = tooltipLeftPercent > 75 ? tooltipLeftPercent - 25 : tooltipLeftPercent + 2}
<div
class="absolute pointer-events-none z-10 border-border/50 bg-background rounded-lg border px-3 py-2 text-xs shadow-xl min-w-[160px]"
style="left: {adjustedLeft}%; top: 15%;"
>
<div class="font-medium text-foreground mb-2">
{tooltipData.label || tooltipData.date.toLocaleDateString('zh-CN')}
</div>
<div class="space-y-1">
{#each categories as category, i}
{#if tooltipData[category] > 0}
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
style="background-color: {getColor(i)}"
></div>
<span class="text-muted-foreground">{category}</span>
</div>
<span class="font-mono font-medium">¥{tooltipData[category].toFixed(2)}</span>
</div>
{/if}
{/each}
</div>
<div class="border-t border-border mt-2 pt-2 flex justify-between">
<span class="text-muted-foreground">合计</span>
<span class="font-mono font-bold">¥{tooltipData.total.toFixed(2)}</span>
</div>
</div>
{/if}
</div>
<!-- 趋势指标 -->
<div class="flex items-center justify-center gap-2 mt-4 text-sm">
{#if trendInfo()}
{@const info = trendInfo()}
{#if info!.change >= 0}
<TrendingUp class="h-4 w-4 text-red-500" />
<span class="text-red-500">+{Math.abs(info!.changePercent).toFixed(1)}%</span>
{:else}
<TrendingDown class="h-4 w-4 text-green-500" />
<span class="text-green-500">-{Math.abs(info!.changePercent).toFixed(1)}%</span>
{/if}
<span class="text-muted-foreground">较前一天</span>
{/if}
</div>
<!-- 提示文字 -->
<p class="text-center text-xs text-muted-foreground mt-2">
点击图表查看当日详情
</p>
</Card.Content>
</Card.Root>
{/if}
<!-- 当日详情 Drawer -->
<Drawer.Root bind:open={dialogOpen}>
<Drawer.Content class="md:max-w-4xl">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<Calendar class="h-5 w-5" />
{#if selectedDate}
{selectedDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
{/if}
</Drawer.Title>
<Drawer.Description>
{#if selectedDateStats}
{@const stats = selectedDateStats}
{stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
{/if}
</Drawer.Description>
</Drawer.Header>
<div class="flex-1 overflow-auto py-4 px-4 md:px-0">
{#if selectedDateStats}
{@const stats = selectedDateStats}
<!-- 分类汇总 -->
<div class="mb-4 space-y-2">
<h4 class="text-sm font-medium text-muted-foreground">分类汇总</h4>
<div class="grid grid-cols-2 gap-2">
{#each stats!.categories as cat, i}
<div class="flex items-center justify-between p-2 rounded-lg bg-muted/50">
<div class="flex items-center gap-2">
<div
class="w-2.5 h-2.5 rounded-full"
style="background-color: {getColor(i)}"
></div>
<span class="text-sm">{cat.category}</span>
<span class="text-xs text-muted-foreground">({cat.count}笔)</span>
</div>
<span class="font-mono text-sm font-medium text-red-600 dark:text-red-400">
¥{cat.amount.toFixed(2)}
</span>
</div>
{/each}
</div>
</div>
<!-- 详细记录 -->
<div>
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录</h4>
<BillRecordsTable
bind:records={selectedDateRecords}
showCategory={true}
showDescription={false}
pageSize={8}
{categories}
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
/>
</div>
{:else}
<p class="text-center text-muted-foreground py-8">暂无数据</p>
{/if}
</div>
</Drawer.Content>
</Drawer.Root>