feat(analysis): 增强图表交互功能
- 分类支出排行: 饼图支持点击类别切换显示/隐藏,百分比动态重新计算 - 每日支出趋势: 图例支持点击切换类别显示,隐藏类别不参与堆叠计算 - Dialog列表: 添加列排序功能(时间/商家/描述/金额) - Dialog列表: 添加分页功能,每页10条(分类)/8条(每日) - 饼图hover效果: 扇形放大、阴影增强、中心显示详情
This commit is contained in:
788
web/src/lib/components/analysis/DailyTrendChart.svelte
Normal file
788
web/src/lib/components/analysis/DailyTrendChart.svelte
Normal file
@@ -0,0 +1,788 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
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 ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
}
|
||||
|
||||
let { records }: Props = $props();
|
||||
|
||||
// Dialog 状态
|
||||
let dialogOpen = $state(false);
|
||||
let selectedDate = $state<Date | null>(null);
|
||||
let selectedDateRecords = $state<BillRecord[]>([]);
|
||||
|
||||
// 排序状态
|
||||
type SortField = 'time' | 'category' | 'merchant' | 'amount';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
let sortField = $state<SortField>('amount');
|
||||
let sortOrder = $state<SortOrder>('desc');
|
||||
|
||||
// 分页状态
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 8; // 每页条目数
|
||||
|
||||
// 排序后的记录
|
||||
let sortedDateRecords = $derived.by(() => {
|
||||
return selectedDateRecords.toSorted((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case 'time':
|
||||
cmp = a.time.localeCompare(b.time);
|
||||
break;
|
||||
case 'category':
|
||||
cmp = a.category.localeCompare(b.category);
|
||||
break;
|
||||
case 'merchant':
|
||||
cmp = a.merchant.localeCompare(b.merchant);
|
||||
break;
|
||||
case 'amount':
|
||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
});
|
||||
|
||||
// 分页计算
|
||||
let totalPages = $derived(Math.ceil(sortedDateRecords.length / pageSize));
|
||||
let paginatedRecords = $derived(
|
||||
sortedDateRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField === field) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortOrder = field === 'amount' ? 'desc' : 'asc';
|
||||
}
|
||||
currentPage = 1; // 排序后重置到第一页
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
// 时间范围选项
|
||||
type TimeRange = '7d' | '30d' | '3m';
|
||||
let timeRange = $state<TimeRange>('3m');
|
||||
|
||||
// 隐藏的类别
|
||||
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;
|
||||
}
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '7d', label: '最近 7 天' },
|
||||
{ value: '30d', label: '最近 30 天' },
|
||||
{ value: '3m', label: '最近 3 个月' }
|
||||
];
|
||||
|
||||
// 获取截止日期
|
||||
function getCutoffDate(range: TimeRange): Date {
|
||||
const now = new Date();
|
||||
switch (range) {
|
||||
case '7d':
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case '30d':
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
case '3m':
|
||||
default:
|
||||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期和分类分组数据
|
||||
let processedData = $derived(() => {
|
||||
const cutoffDate = getCutoffDate(timeRange);
|
||||
|
||||
// 过滤支出记录
|
||||
const expenseRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDate = new Date(r.time.split(' ')[0]);
|
||||
return recordDate >= cutoffDate;
|
||||
});
|
||||
|
||||
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0 };
|
||||
|
||||
// 按日期分组
|
||||
const dateMap = new Map<string, Map<string, number>>();
|
||||
const categorySet = new Set<string>();
|
||||
const categoryTotals: Record<string, number> = {};
|
||||
|
||||
expenseRecords.forEach(record => {
|
||||
const dateStr = record.time.split(' ')[0];
|
||||
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());
|
||||
}
|
||||
const dayData = dateMap.get(dateStr)!;
|
||||
dayData.set(category, (dayData.get(category) || 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(dateMap.entries())
|
||||
.map(([dateStr, dayData]) => {
|
||||
const result: Record<string, any> = {
|
||||
date: new Date(dateStr),
|
||||
dateStr: dateStr
|
||||
};
|
||||
|
||||
let cumulative = 0;
|
||||
sortedCategories.forEach(cat => {
|
||||
const value = dayData.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;
|
||||
dayData.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 };
|
||||
});
|
||||
|
||||
// 获取颜色
|
||||
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 } = processedData();
|
||||
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月';
|
||||
return `${label}各分类支出趋势 (${data.length} 天)`;
|
||||
});
|
||||
|
||||
function handleTimeRangeChange(value: string | undefined) {
|
||||
if (value && ['7d', '30d', '3m'].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;
|
||||
}
|
||||
|
||||
// 生成面积路径
|
||||
function generateAreaPath(category: string, data: any[], maxValue: number): string {
|
||||
if (data.length === 0) return '';
|
||||
|
||||
const points: string[] = [];
|
||||
const bottomPoints: string[] = [];
|
||||
|
||||
data.forEach((d, i) => {
|
||||
const x = xScale(d.date, data);
|
||||
const y1 = yScale(d[`${category}_y1`] || 0, maxValue);
|
||||
const y0 = yScale(d[`${category}_y0`] || 0, maxValue);
|
||||
points.push(`${x},${y1}`);
|
||||
bottomPoints.unshift(`${x},${y0}`);
|
||||
});
|
||||
|
||||
return `M ${points.join(' L ')} L ${bottomPoints.join(' L ')} Z`;
|
||||
}
|
||||
|
||||
// 生成 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()}`
|
||||
}));
|
||||
}
|
||||
|
||||
// 生成 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 = clickedDate.toISOString().split('T')[0];
|
||||
|
||||
// 找出当天的所有支出记录
|
||||
selectedDate = clickedDate;
|
||||
selectedDateRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDateStr = r.time.split(' ')[0];
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
// 重置排序和分页状态
|
||||
sortField = 'amount';
|
||||
sortOrder = 'desc';
|
||||
currentPage = 1;
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
</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 ? '点击显示' : '点击隐藏'}
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<!-- 堆叠面积图 (自定义 SVG) -->
|
||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<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}
|
||||
|
||||
<!-- 堆叠面积 (从后向前渲染) -->
|
||||
{#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}
|
||||
|
||||
<!-- 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.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}
|
||||
|
||||
<!-- 当日详情 Dialog -->
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.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}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if selectedDateStats}
|
||||
{@const stats = selectedDateStats}
|
||||
共 {stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto py-4">
|
||||
{#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>
|
||||
<div class="rounded-md border overflow-auto max-h-[300px]">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b text-xs font-medium text-muted-foreground sticky top-0">
|
||||
<button
|
||||
class="w-[60px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('time')}
|
||||
>
|
||||
时间
|
||||
{#if sortField === 'time'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="w-[80px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('category')}
|
||||
>
|
||||
分类
|
||||
{#if sortField === 'category'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 min-w-[100px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('merchant')}
|
||||
>
|
||||
商家
|
||||
{#if sortField === 'merchant'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="w-[100px] flex items-center justify-end gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('amount')}
|
||||
>
|
||||
金额
|
||||
{#if sortField === 'amount'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 记录列表 -->
|
||||
{#each paginatedRecords as record}
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-muted/30">
|
||||
<div class="w-[60px] text-xs text-muted-foreground">
|
||||
{record.time.split(' ')[1] || '--:--'}
|
||||
</div>
|
||||
<div class="w-[80px] text-sm">{record.category}</div>
|
||||
<div class="flex-1 min-w-[100px] text-sm truncate" title="{record.merchant}">
|
||||
{record.merchant}
|
||||
</div>
|
||||
<div class="w-[100px] text-right font-mono text-sm font-medium text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-3 pt-3 border-t">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedDateRecords.length)} 条,共 {sortedDateRecords.length} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
||||
<Button
|
||||
variant={page === currentPage ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
class="h-7 w-7 text-xs"
|
||||
onclick={() => goToPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-muted-foreground text-xs">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-muted-foreground py-8">暂无数据</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user