- 修复时区问题:使用本地时区格式化日期,避免 toISOString() 导致的日期偏移 - 优化日期范围选择器性能:使用 untrack 避免循环更新 - 统一日期格式化方法:在 utils.ts 中添加 formatLocalDate 工具函数 - 修复分页逻辑:优化页码计算和显示 - 更新相关页面:bills 和 analysis 页面使用统一的日期格式化方法
898 lines
30 KiB
Svelte
898 lines
30 KiB
Svelte
<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 { BillRecord } from '$lib/api';
|
|
import { pieColors } from '$lib/constants/chart';
|
|
import { formatLocalDate } from '$lib/utils';
|
|
import BillRecordsTable from './BillRecordsTable.svelte';
|
|
|
|
interface Props {
|
|
records: BillRecord[];
|
|
categories?: string[];
|
|
}
|
|
|
|
let { records = $bindable(), categories = [] }: Props = $props();
|
|
|
|
// Dialog 状态
|
|
let dialogOpen = $state(false);
|
|
let selectedDate = $state<Date | null>(null);
|
|
let selectedDateRecords = $state<BillRecord[]>([]);
|
|
|
|
// 时间范围选项
|
|
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.income_expense !== '支出') 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 = parseFloat(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 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;
|
|
}
|
|
|
|
// 点击打开 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;
|
|
const dateStr = formatLocalDate(clickedDate);
|
|
|
|
// 找出当天的所有支出记录
|
|
selectedDate = clickedDate;
|
|
selectedDateRecords = records.filter(r => {
|
|
if (r.income_expense !== '支出') return false;
|
|
const recordDateStr = extractDateStr(r.time);
|
|
return recordDateStr === dateStr;
|
|
});
|
|
|
|
dialogOpen = true;
|
|
}
|
|
|
|
// 计算选中日期的统计
|
|
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 = parseFloat(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};">
|
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
|
<svg
|
|
viewBox="0 0 {chartWidth} {chartHeight}"
|
|
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
|
role="application"
|
|
aria-label="每日支出趋势图表,点击可查看当日详情"
|
|
tabindex="-1"
|
|
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
|
|
onmouseleave={handleMouseLeave}
|
|
onclick={(e) => handleClick(e, data, maxValue)}
|
|
>
|
|
<!-- 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="sm: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}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<p class="text-center text-muted-foreground py-8">暂无数据</p>
|
|
{/if}
|
|
</div>
|
|
</Drawer.Content>
|
|
</Drawer.Root>
|