feat: 完善项目架构并增强分析页面功能

- 新增项目文档和 Docker 配置
  - 添加 README.md 和 TODO.md 项目文档
  - 为各服务添加 Dockerfile 和 docker-compose 配置

- 重构后端架构
  - 新增 adapter 层(HTTP/Python 适配器)
  - 新增 repository 层(数据访问抽象)
  - 新增 router 模块统一管理路由
  - 新增账单处理 handler

- 扩展前端 UI 组件库
  - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件
  - 集成 shadcn-svelte 组件库

- 增强分析页面功能
  - 添加时间范围筛选器(支持本月默认值)
  - 修复 DateRangePicker 默认值显示问题
  - 优化数据获取和展示逻辑

- 完善分析器服务
  - 新增 FastAPI 服务接口
  - 改进账单清理器实现
This commit is contained in:
2026-01-10 01:15:52 +08:00
parent 94f8ea12e6
commit 087ae027cc
96 changed files with 4301 additions and 482 deletions

View File

@@ -1,11 +1,29 @@
// API 配置
const API_BASE = 'http://localhost:8080';
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
const API_BASE = '';
// 健康检查
export async function checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE}/health`, {
method: 'GET',
signal: AbortSignal.timeout(3000) // 3秒超时
});
return response.ok;
} catch {
return false;
}
}
// 类型定义
export type BillType = 'alipay' | 'wechat';
export interface UploadData {
bill_type: 'alipay' | 'wechat';
bill_type: BillType;
file_url: string;
file_name: string;
raw_count: number;
cleaned_count: number;
duplicate_count?: number;
}
export interface UploadResponse {
@@ -52,9 +70,14 @@ export interface BillRecord {
}
// 上传账单
export async function uploadBill(file: File, options?: { year?: number; month?: number }): Promise<UploadResponse> {
export async function uploadBill(
file: File,
type: BillType,
options?: { year?: number; month?: number }
): Promise<UploadResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
if (options?.year) {
formData.append('year', options.year.toString());
@@ -108,23 +131,23 @@ function parseCSV(text: string): BillRecord[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',');
const records: BillRecord[] = [];
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length >= headers.length) {
if (values.length >= 7) {
records.push({
time: values[0] || '',
category: values[1] || '',
merchant: values[2] || '',
description: values[3] || '',
income_expense: values[4] || '',
amount: values[5] || '',
payment_method: values[6] || '',
status: values[7] || '',
remark: values[8] || '',
needs_review: values[9] || '',
description: values[4] || '', // 跳过 values[3] (对方账号)
income_expense: values[5] || '',
amount: values[6] || '',
payment_method: values[7] || '',
status: values[8] || '',
remark: values[11] || '',
needs_review: values[13] || '', // 复核等级在第14列
});
}
}
@@ -160,5 +183,71 @@ function parseCSVLine(line: string): string[] {
return result;
}
// 清洗后的账单记录
export interface CleanedBill {
id: string;
bill_type: string;
time: string;
category: string;
merchant: string;
description: string;
income_expense: string;
amount: number;
pay_method: string;
status: string;
remark: string;
review_level: string;
}
// 账单列表请求参数
export interface FetchBillsParams {
page?: number;
page_size?: number;
start_date?: string;
end_date?: string;
category?: string;
type?: string; // 账单来源 alipay/wechat
income_expense?: string; // 收支类型 收入/支出
}
// 账单列表响应
export interface BillsResponse {
result: boolean;
message?: string;
data?: {
total: number;
total_expense: number; // 筛选条件下的总支出
total_income: number; // 筛选条件下的总收入
page: number;
page_size: number;
pages: number;
bills: CleanedBill[];
};
}
// 获取账单列表(支持分页和筛选)
export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', params.page.toString());
if (params.page_size) searchParams.set('page_size', params.page_size.toString());
if (params.start_date) searchParams.set('start_date', params.start_date);
if (params.end_date) searchParams.set('end_date', params.end_date);
if (params.category) searchParams.set('category', params.category);
if (params.type) searchParams.set('type', params.type);
if (params.income_expense) searchParams.set('income_expense', params.income_expense);
const queryString = searchParams.toString();
const url = `${API_BASE}/api/bills${queryString ? '?' + queryString : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
import * as Drawer from '$lib/components/ui/drawer';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -334,22 +334,22 @@
{/if}
<!-- 详情/编辑弹窗 -->
<Dialog.Root bind:open={detailDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Drawer.Root bind:open={detailDialogOpen}>
<Drawer.Content class="sm:max-w-md">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" />
{isEditing ? '编辑账单' : '账单详情'}
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑表单 -->
<div class="space-y-4 py-4">
<div class="space-y-4 py-4 px-4 md:px-0">
<div class="space-y-2">
<Label>金额</Label>
<div class="relative">
@@ -400,7 +400,7 @@
</div>
{:else}
<!-- 详情展示 -->
<div class="py-4">
<div class="py-4 px-4 md:px-0">
<div class="text-center mb-6">
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
¥{selectedRecord.amount}
@@ -459,7 +459,7 @@
{/if}
{/if}
<Dialog.Footer>
<Drawer.Footer>
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
@@ -478,6 +478,6 @@
编辑
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as Drawer from '$lib/components/ui/drawer';
import { Button } from '$lib/components/ui/button';
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
import ListIcon from '@lucide/svelte/icons/list';
@@ -147,6 +147,7 @@
{@const x4 = Math.cos(startAngle) * innerRadius}
{@const y4 = Math.sin(startAngle) * innerRadius}
{@const largeArc = (endAngle - startAngle) > Math.PI ? 1 : 0}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<path
d="M {x1} {y1} A {outerRadius} {outerRadius} 0 {largeArc} 1 {x2} {y2} L {x3} {y3} A {innerRadius} {innerRadius} 0 {largeArc} 0 {x4} {y4} Z"
fill={item.color}
@@ -197,28 +198,28 @@
</Card.Root>
<!-- 分类详情弹窗 -->
<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">
<Drawer.Root bind:open={dialogOpen}>
<Drawer.Content class="sm:max-w-4xl">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<PieChartIcon class="h-5 w-5" />
{selectedCategory} - 账单明细
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{#if selectedStat}
{selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
{/if}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
<div class="flex-1 overflow-auto mt-4">
<div class="flex-1 overflow-auto px-4 md:px-0">
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
</div>
<Dialog.Footer class="mt-4">
<Drawer.Footer>
<Button variant="outline" onclick={() => dialogOpen = false}>
关闭
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -1,7 +1,7 @@
<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 * 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';
@@ -46,6 +46,12 @@
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: '本周' },
@@ -119,7 +125,7 @@
// 过滤支出记录
const expenseRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDate = new Date(r.time.split(' ')[0]);
const recordDate = new Date(extractDateStr(r.time));
return recordDate >= cutoffDate;
});
@@ -130,7 +136,7 @@
const categoryTotals: Record<string, number> = {};
expenseRecords.forEach(record => {
const dateStr = record.time.split(' ')[0];
const dateStr = extractDateStr(record.time);
const category = record.category || '其他';
const amount = parseFloat(record.amount) || 0;
@@ -526,7 +532,7 @@
selectedDate = clickedDate;
selectedDateRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDateStr = r.time.split(' ')[0];
const recordDateStr = extractDateStr(r.time);
return recordDateStr === dateStr;
});
@@ -641,7 +647,7 @@
<!-- 趋势图 (自定义 SVG) -->
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<svg
viewBox="0 0 {chartWidth} {chartHeight}"
class="w-full h-full cursor-pointer outline-none focus:outline-none"
@@ -827,25 +833,25 @@
</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">
<!-- 当日详情 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}
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{#if selectedDateStats}
{@const stats = selectedDateStats}
{stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
{/if}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
<div class="flex-1 overflow-auto py-4">
<div class="flex-1 overflow-auto py-4 px-4 md:px-0">
{#if selectedDateStats}
{@const stats = selectedDateStats}
@@ -886,5 +892,5 @@
<p class="text-center text-muted-foreground py-8">暂无数据</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
</Drawer.Content>
</Drawer.Root>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as Drawer from '$lib/components/ui/drawer';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -134,10 +134,10 @@
</Card.Root>
<!-- 账单详情弹窗 -->
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="sm:max-w-[450px]">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Drawer.Root bind:open={dialogOpen}>
<Drawer.Content class="sm:max-w-[450px]">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" />
{isEditing ? '编辑账单' : '账单详情'}
{#if selectedRank <= 3 && !isEditing}
@@ -149,16 +149,16 @@
Top {selectedRank}
</span>
{/if}
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑模式 -->
<div class="py-4 space-y-4">
<div class="py-4 space-y-4 px-4 md:px-0">
<div class="space-y-2">
<Label for="amount">金额</Label>
<div class="relative">
@@ -206,7 +206,7 @@
</div>
{:else}
<!-- 查看模式 -->
<div class="py-4 space-y-4">
<div class="py-4 space-y-4 px-4 md:px-0">
<!-- 金额 -->
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
@@ -265,7 +265,7 @@
{/if}
{/if}
<Dialog.Footer class="flex gap-2">
<Drawer.Footer class="flex gap-2">
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
@@ -284,6 +284,6 @@
编辑
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type Calendar from "./calendar.svelte";
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
months: ComponentProps<typeof CalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof CalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
monthIndex: number;
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridProps = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeaderProps = $props();
</script>
<CalendarPrimitive.Header
bind:ref
class={cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadingProps = $props();
</script>
<CalendarPrimitive.Heading
bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
onchange,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, monthItems, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
<option
value={monthItem.value}
selected={value !== undefined
? monthItem.value === value
: monthItem.value === selectedMonthItem.value}
>
{monthItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.MonthSelect>
</span>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
{...restProps}
bind:this={ref}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
>
{@render children?.()}
</nav>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class="size-4" />
{/snippet}
<CalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
{/snippet}
<CalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, yearItems, selectedYearItem })}
<select {...props} {value}>
{#each yearItems as yearItem (yearItem.value)}
<option
value={yearItem.value}
selected={value !== undefined
? yearItem.value === value
: yearItem.value === selectedYearItem.value}
>
{yearItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.YearSelect>
</span>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import * as Calendar from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ButtonVariant } from "../button/button.svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = "short",
buttonVariant = "ghost",
captionLayout = "label",
locale = "en-US",
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = "numeric",
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
months?: CalendarPrimitive.MonthSelectProps["months"];
years?: CalendarPrimitive.YearSelectProps["years"];
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith("dropdown")) return "short";
return "long";
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value as never}
bind:ref
bind:placeholder
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Months>
<Calendar.Nav>
<Calendar.PrevButton variant={buttonVariant} />
<Calendar.NextButton variant={buttonVariant} />
</Calendar.Nav>
{#each months as month, monthIndex (month)}
<Calendar.Month>
<Calendar.Header>
<Calendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="select-none">
{#each weekdays as weekday (weekday)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value),
})}
{:else}
<Calendar.Day />
{/if}
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Month>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>

View File

@@ -0,0 +1,40 @@
import Root from "./calendar.svelte";
import Cell from "./calendar-cell.svelte";
import Day from "./calendar-day.svelte";
import Grid from "./calendar-grid.svelte";
import Header from "./calendar-header.svelte";
import Months from "./calendar-months.svelte";
import GridRow from "./calendar-grid-row.svelte";
import Heading from "./calendar-heading.svelte";
import GridBody from "./calendar-grid-body.svelte";
import GridHead from "./calendar-grid-head.svelte";
import HeadCell from "./calendar-head-cell.svelte";
import NextButton from "./calendar-next-button.svelte";
import PrevButton from "./calendar-prev-button.svelte";
import MonthSelect from "./calendar-month-select.svelte";
import YearSelect from "./calendar-year-select.svelte";
import Month from "./calendar-month.svelte";
import Nav from "./calendar-nav.svelte";
import Caption from "./calendar-caption.svelte";
export {
Day,
Cell,
Grid,
Header,
Months,
GridRow,
Heading,
GridBody,
GridHead,
HeadCell,
NextButton,
PrevButton,
Nav,
Month,
YearSelect,
MonthSelect,
Caption,
//
Root as Calendar,
};

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { CalendarDate, type DateValue } from "@internationalized/date";
import CalendarIcon from "@lucide/svelte/icons/calendar";
import * as Popover from "$lib/components/ui/popover";
import { RangeCalendar } from "$lib/components/ui/range-calendar";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
import type { DateRange } from "bits-ui";
interface Props {
startDate?: string;
endDate?: string;
onchange?: (start: string, end: string) => void;
class?: string;
}
let { startDate = $bindable(), endDate = $bindable(), onchange, class: className }: Props = $props();
// 将 YYYY-MM-DD 字符串转换为 CalendarDate
function parseDate(dateStr: string): DateValue | undefined {
if (!dateStr) return undefined;
const [year, month, day] = dateStr.split('-').map(Number);
return new CalendarDate(year, month, day);
}
// 将 CalendarDate 转换为 YYYY-MM-DD 字符串
function formatDate(date: DateValue | undefined): string {
if (!date) return '';
return `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
}
// 内部日期范围状态,使用 $derived 响应 props 变化
let value: DateRange = $derived({
start: parseDate(startDate),
end: parseDate(endDate)
});
// 格式化显示文本
let displayText = $derived(() => {
if (value.start && value.end) {
return `${formatDate(value.start)} ~ ${formatDate(value.end)}`;
}
if (value.start) {
return `${formatDate(value.start)} ~ `;
}
return "选择日期范围";
});
// 当日期变化时通知父组件
function handleValueChange(newValue: DateRange) {
if (newValue.start && newValue.end && onchange) {
onchange(formatDate(newValue.start), formatDate(newValue.end));
}
}
</script>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
class={cn(
"w-[260px] justify-start text-left font-normal",
!value.start && "text-muted-foreground",
className
)}
{...props}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{displayText()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<RangeCalendar
{value}
onValueChange={handleValueChange}
numberOfMonths={2}
locale="zh-CN"
weekStartsOn={1}
/>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,3 @@
import DateRangePicker from "./date-range-picker.svelte";
export { DateRangePicker };

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Close class={className}>
{@render children?.()}
</Sheet.Close>
{:else}
<Dialog.Close class={className}>
{@render children?.()}
</Dialog.Close>
{/if}

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
/** 移动端 Sheet 的方向,默认 bottom */
side?: 'top' | 'bottom' | 'left' | 'right';
}
let { children, class: className, side = 'bottom' }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Content
{side}
class={cn('max-h-[90vh] overflow-hidden flex flex-col', className)}
>
<!-- 拖拽指示器 (移动端抽屉常见设计) -->
<div class="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted"></div>
{@render children?.()}
</Sheet.Content>
{:else}
<Dialog.Content class={cn('max-h-[85vh] overflow-hidden flex flex-col', className)}>
{@render children?.()}
</Dialog.Content>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Description class={className}>
{@render children?.()}
</Sheet.Description>
{:else}
<Dialog.Description class={className}>
{@render children?.()}
</Dialog.Description>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Footer class={cn('pt-2', className)}>
{@render children?.()}
</Sheet.Footer>
{:else}
<Dialog.Footer class={className}>
{@render children?.()}
</Dialog.Footer>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Header class={cn('text-left', className)}>
{@render children?.()}
</Sheet.Header>
{:else}
<Dialog.Header class={className}>
{@render children?.()}
</Dialog.Header>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Title class={className}>
{@render children?.()}
</Sheet.Title>
{:else}
<Dialog.Title class={className}>
{@render children?.()}
</Dialog.Title>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: Snippet;
}
let { open = $bindable(false), onOpenChange, children }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Root bind:open {onOpenChange}>
{@render children?.()}
</Sheet.Root>
{:else}
<Dialog.Root bind:open {onOpenChange}>
{@render children?.()}
</Dialog.Root>
{/if}

View File

@@ -0,0 +1,25 @@
import Root from './drawer.svelte';
import Content from './drawer-content.svelte';
import Header from './drawer-header.svelte';
import Footer from './drawer-footer.svelte';
import Title from './drawer-title.svelte';
import Description from './drawer-description.svelte';
import Close from './drawer-close.svelte';
export {
Root,
Content,
Header,
Footer,
Title,
Description,
Close,
//
Root as Drawer,
Content as DrawerContent,
Header as DrawerHeader,
Footer as DrawerFooter,
Title as DrawerTitle,
Description as DrawerDescription,
Close as DrawerClose
};

View File

@@ -0,0 +1,19 @@
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
import Portal from "./popover-portal.svelte";
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...restProps}
/>
</PopoverPortal>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,40 @@
import Root from "./range-calendar.svelte";
import Cell from "./range-calendar-cell.svelte";
import Day from "./range-calendar-day.svelte";
import Grid from "./range-calendar-grid.svelte";
import Header from "./range-calendar-header.svelte";
import Months from "./range-calendar-months.svelte";
import GridRow from "./range-calendar-grid-row.svelte";
import Heading from "./range-calendar-heading.svelte";
import HeadCell from "./range-calendar-head-cell.svelte";
import NextButton from "./range-calendar-next-button.svelte";
import PrevButton from "./range-calendar-prev-button.svelte";
import MonthSelect from "./range-calendar-month-select.svelte";
import YearSelect from "./range-calendar-year-select.svelte";
import Caption from "./range-calendar-caption.svelte";
import Nav from "./range-calendar-nav.svelte";
import Month from "./range-calendar-month.svelte";
import GridBody from "./range-calendar-grid-body.svelte";
import GridHead from "./range-calendar-grid-head.svelte";
export {
Day,
Cell,
Grid,
Header,
Months,
GridRow,
Heading,
GridBody,
GridHead,
HeadCell,
NextButton,
PrevButton,
MonthSelect,
YearSelect,
Caption,
Nav,
Month,
//
Root as RangeCalendar,
};

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type RangeCalendar from "./range-calendar.svelte";
import RangeCalendarMonthSelect from "./range-calendar-month-select.svelte";
import RangeCalendarYearSelect from "./range-calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof RangeCalendar>["captionLayout"];
months: ComponentProps<typeof RangeCalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof RangeCalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof RangeCalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof RangeCalendarYearSelect>["yearFormat"];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
monthIndex: number;
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<RangeCalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<RangeCalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.CellProps = $props();
</script>
<RangeCalendarPrimitive.Cell
bind:ref
class={cn(
"dark:[&:has([data-range-start])]:hover:bg-accent dark:[&:has([data-range-end])]:hover:bg-accent [&:has([data-range-middle])]:bg-accent dark:[&:has([data-range-middle])]:hover:bg-accent/50 [&:has([data-selected])]:bg-accent relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 data-[range-middle]:rounded-e-md [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:has([data-range-end])]:rounded-e-md [&:has([data-range-middle])]:rounded-none first:[&:has([data-range-middle])]:rounded-s-md last:[&:has([data-range-middle])]:rounded-e-md [&:has([data-range-start])]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.DayProps = $props();
</script>
<RangeCalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground data-[range-middle]:rounded-none",
// range Start
"data-[range-start]:bg-primary dark:data-[range-start]:hover:bg-accent data-[range-start]:text-primary-foreground",
// range End
"data-[range-end]:bg-primary dark:data-[range-end]:hover:bg-accent data-[range-end]:text-primary-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:line-through",
"dark:data-[range-middle]:hover:bg-accent/0",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: RangeCalendarPrimitive.GridBodyProps = $props();
</script>
<RangeCalendarPrimitive.GridBody bind:ref {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: RangeCalendarPrimitive.GridHeadProps = $props();
</script>
<RangeCalendarPrimitive.GridHead bind:ref {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.GridRowProps = $props();
</script>
<RangeCalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.GridProps = $props();
</script>
<RangeCalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.HeadCellProps = $props();
</script>
<RangeCalendarPrimitive.HeadCell
bind:ref
class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.HeaderProps = $props();
</script>
<RangeCalendarPrimitive.Header
bind:ref
class={cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.HeadingProps = $props();
</script>
<RangeCalendarPrimitive.Heading
bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
onchange,
...restProps
}: WithoutChildrenOrChild<RangeCalendarPrimitive.MonthSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<RangeCalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, monthItems, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
<option
value={monthItem.value}
selected={value !== undefined
? monthItem.value === value
: monthItem.value === selectedMonthItem.value}
>
{monthItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</RangeCalendarPrimitive.MonthSelect>
</span>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
{...restProps}
bind:this={ref}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
>
{@render children?.()}
</nav>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: RangeCalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class="size-4" />
{/snippet}
<RangeCalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: RangeCalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
{/snippet}
<RangeCalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
...restProps
}: WithoutChildrenOrChild<RangeCalendarPrimitive.YearSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<RangeCalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, yearItems, selectedYearItem })}
<select {...props} {value}>
{#each yearItems as yearItem (yearItem.value)}
<option
value={yearItem.value}
selected={value !== undefined
? yearItem.value === value
: yearItem.value === selectedYearItem.value}
>
{yearItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</RangeCalendarPrimitive.YearSelect>
</span>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import * as RangeCalendar from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ButtonVariant } from "$lib/components/ui/button/index.js";
import type { Snippet } from "svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date";
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
weekdayFormat = "short",
class: className,
buttonVariant = "ghost",
captionLayout = "label",
locale = "en-US",
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = "numeric",
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<RangeCalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
months?: RangeCalendarPrimitive.MonthSelectProps["months"];
years?: RangeCalendarPrimitive.YearSelectProps["years"];
monthFormat?: RangeCalendarPrimitive.MonthSelectProps["monthFormat"];
yearFormat?: RangeCalendarPrimitive.YearSelectProps["yearFormat"];
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith("dropdown")) return "short";
return "long";
});
</script>
<RangeCalendarPrimitive.Root
bind:ref
bind:value
bind:placeholder
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Months>
<RangeCalendar.Nav>
<RangeCalendar.PrevButton variant={buttonVariant} />
<RangeCalendar.NextButton variant={buttonVariant} />
</RangeCalendar.Nav>
{#each months as month, monthIndex (month)}
<RangeCalendar.Month>
<RangeCalendar.Header>
<RangeCalendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</RangeCalendar.Header>
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<RangeCalendar.GridRow class="select-none">
{#each weekdays as weekday (weekday)}
<RangeCalendar.HeadCell>
{weekday.slice(0, 2)}
</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<RangeCalendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<RangeCalendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value),
})}
{:else}
<RangeCalendar.Day />
{/if}
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
</RangeCalendar.Month>
{/each}
</RangeCalendar.Months>
{/snippet}
</RangeCalendarPrimitive.Root>

View File

@@ -2,6 +2,7 @@
import '../app.css';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { checkHealth } from '$lib/api';
import * as Sidebar from '$lib/components/ui/sidebar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar';
@@ -14,7 +15,6 @@
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Settings from '@lucide/svelte/icons/settings';
import HelpCircle from '@lucide/svelte/icons/help-circle';
import Search from '@lucide/svelte/icons/search';
import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
import Wallet from '@lucide/svelte/icons/wallet';
import LogOut from '@lucide/svelte/icons/log-out';
@@ -35,16 +35,32 @@
let { children } = $props();
let themeMode = $state<ThemeMode>('system');
let serverOnline = $state(true);
let checkingHealth = $state(true);
async function checkServerHealth() {
checkingHealth = true;
serverOnline = await checkHealth();
checkingHealth = false;
}
onMount(() => {
themeMode = loadThemeFromStorage();
applyThemeToDocument(themeMode);
// 检查服务器状态
checkServerHealth();
// 每 30 秒检查一次
const healthInterval = setInterval(checkServerHealth, 30000);
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => applyThemeToDocument(themeMode);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
clearInterval(healthInterval);
};
});
function cycleTheme() {
@@ -78,6 +94,18 @@
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
}
// 根据路径获取页面标题
function getPageTitle(pathname: string): string {
const titles: Record<string, string> = {
'/': '上传账单',
'/review': '智能复核',
'/bills': '账单管理',
'/analysis': '数据分析',
'/settings': '设置',
'/help': '帮助'
};
return titles[pathname] || 'BillAI';
}
</script>
<Sidebar.Provider>
@@ -237,18 +265,32 @@
<header class="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 h-4" />
<div class="flex items-center gap-2">
<Search class="size-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">搜索...</span>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="flex items-center gap-1.5 text-sm">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-muted-foreground">服务运行中</span>
</div>
<h1 class="text-lg font-semibold">{getPageTitle($page.url.pathname)}</h1>
<div class="flex-1" />
<div class="flex items-center gap-3">
<button
class="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity"
onclick={checkServerHealth}
title="点击刷新状态"
>
{#if checkingHealth}
<span class="relative flex h-2 w-2">
<span class="relative inline-flex rounded-full h-2 w-2 bg-gray-400 animate-pulse"></span>
</span>
<span class="text-muted-foreground">检查中...</span>
{:else if serverOnline}
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-muted-foreground">服务运行中</span>
{:else}
<span class="relative flex h-2 w-2">
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span class="text-red-500">服务离线</span>
{/if}
</button>
</div>
</header>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { uploadBill, type UploadResponse } from '$lib/api';
import { uploadBill, type UploadResponse, type BillType } from '$lib/api';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@@ -16,6 +16,7 @@
let isDragOver = $state(false);
let selectedFile: File | null = $state(null);
let selectedType: BillType = $state('alipay');
let isUploading = $state(false);
let uploadResult: UploadResponse | null = $state(null);
let errorMessage = $state('');
@@ -86,6 +87,14 @@
selectedFile = file;
errorMessage = '';
uploadResult = null;
// 根据文件名自动识别账单类型
const fileName = file.name.toLowerCase();
if (fileName.includes('支付宝') || fileName.includes('alipay')) {
selectedType = 'alipay';
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
selectedType = 'wechat';
}
}
function clearFile() {
@@ -101,7 +110,7 @@
errorMessage = '';
try {
const result = await uploadBill(selectedFile);
const result = await uploadBill(selectedFile, selectedType);
if (result.result) {
uploadResult = result;
} else {
@@ -135,7 +144,7 @@
<!-- 统计卡片 -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each stats as stat}
<Card.Root>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">{stat.title}</Card.Title>
{#if stat.trend === 'up'}
@@ -226,6 +235,27 @@
</div>
{/if}
<!-- 账单类型选择 -->
<div class="flex items-center gap-3">
<span class="text-sm font-medium">账单类型:</span>
<div class="flex gap-2">
<Button
variant={selectedType === 'alipay' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'alipay'}
>
支付宝
</Button>
<Button
variant={selectedType === 'wechat' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'wechat'}
>
微信
</Button>
</div>
</div>
<!-- 上传按钮 -->
<Button
class="w-full"
@@ -256,7 +286,7 @@
<CheckCircle class="h-5 w-5 text-green-600 dark:text-green-400" />
<div>
<p class="font-medium text-green-800 dark:text-green-200">处理成功</p>
<p class="text-sm text-green-600 dark:text-green-400">账单已分析完成</p>
<p class="text-sm text-green-600 dark:text-green-400">{uploadResult.message}</p>
</div>
</div>
@@ -267,6 +297,14 @@
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
</Badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">原始记录数</span>
<span class="text-sm font-medium">{uploadResult.data?.raw_count ?? 0}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">清洗后记录数</span>
<span class="text-sm font-medium">{uploadResult.data?.cleaned_count ?? 0}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">输出文件</span>
<span class="text-sm font-medium">{uploadResult.data?.file_name}</span>
@@ -275,7 +313,7 @@
<div class="flex gap-3 pt-2">
<a
href={`http://localhost:8080${uploadResult.data?.file_url}`}
href={uploadResult.data?.file_url || '#'}
download
class="flex-1"
>

View File

@@ -1,11 +1,16 @@
<script lang="ts">
import { fetchBillContent, type BillRecord } from '$lib/api';
import { onMount } from 'svelte';
import { fetchBills, checkHealth, type CleanedBill } from '$lib/api';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Activity from '@lucide/svelte/icons/activity';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Calendar from '@lucide/svelte/icons/calendar';
// 分析组件
import {
@@ -14,7 +19,6 @@
CategoryRanking,
MonthlyTrend,
TopExpenses,
EmptyState
} from '$lib/components/analysis';
// 数据处理服务
@@ -32,61 +36,119 @@
// 分类数据
import { categories as allCategories } from '$lib/data/categories';
// 计算默认日期范围(本月)
function getDefaultDates() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = today.toISOString().split('T')[0];
return { startDate, endDate };
}
const defaultDates = getDefaultDates();
// 状态
let fileName = $state('');
let isLoading = $state(false);
let errorMessage = $state('');
let records: BillRecord[] = $state([]);
let records: CleanedBill[] = $state([]);
let isDemo = $state(false);
let serverAvailable = $state(true);
// 派生数据
let categoryStats = $derived(calculateCategoryStats(records));
let monthlyStats = $derived(calculateMonthlyStats(records));
let dailyExpenseData = $derived(calculateDailyExpenseData(records));
let totalStats = $derived(calculateTotalStats(records));
// 时间范围筛选 - 初始化为默认值
let startDate: string = $state(defaultDates.startDate);
let endDate: string = $state(defaultDates.endDate);
// 将 CleanedBill 转换为分析服务需要的格式
function toAnalysisRecords(bills: CleanedBill[]) {
return bills.map(bill => ({
time: bill.time,
category: bill.category,
merchant: bill.merchant,
description: bill.description,
income_expense: bill.income_expense,
amount: String(bill.amount),
payment_method: bill.pay_method,
status: bill.status,
remark: bill.remark,
needs_review: bill.review_level,
}));
}
// 派生分析数据
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
let monthlyStats = $derived(calculateMonthlyStats(analysisRecords));
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
let totalStats = $derived(calculateTotalStats(analysisRecords));
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
let topExpenses = $derived(getTopExpenses(records, 10));
let topExpenses = $derived(getTopExpenses(analysisRecords, 10));
// 分类列表按数据中出现次数排序(出现次数多的优先)
// 分类列表按数据中出现次数排序
let sortedCategories = $derived(() => {
// 统计每个分类的记录数量
const categoryCounts = new Map<string, number>();
for (const record of records) {
for (const record of analysisRecords) {
categoryCounts.set(record.category, (categoryCounts.get(record.category) || 0) + 1);
}
// 对分类进行排序:先按数据中的数量降序,未出现的分类按原顺序排在后面
return [...allCategories].sort((a, b) => {
const countA = categoryCounts.get(a) || 0;
const countB = categoryCounts.get(b) || 0;
// 数量大的排前面
if (countA !== countB) return countB - countA;
// 数量相同时保持原有顺序
return allCategories.indexOf(a) - allCategories.indexOf(b);
});
});
async function loadData() {
if (!fileName) return;
isLoading = true;
errorMessage = '';
isDemo = false;
try {
records = await fetchBillContent(fileName);
// 先检查服务器状态
serverAvailable = await checkHealth();
if (!serverAvailable) {
errorMessage = '服务器不可用';
return;
}
// 获取账单数据(带时间范围筛选)
const response = await fetchBills({
page_size: 10000,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
if (response.result && response.data) {
records = response.data.bills || [];
if (records.length === 0) {
errorMessage = '暂无账单数据';
}
} else {
errorMessage = response.message || '加载失败';
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
serverAvailable = false;
} finally {
isLoading = false;
}
}
// 日期变化时重新加载
function onDateChange() {
if (!isDemo) {
loadData();
}
}
function loadDemoData() {
isDemo = true;
errorMessage = '';
records = demoRecords;
}
// 页面加载时自动获取数据
onMount(() => {
loadData();
});
</script>
<svelte:head>
@@ -95,55 +157,52 @@
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">数据分析</h1>
<p class="text-muted-foreground">可视化你的消费数据,洞察消费习惯</p>
</div>
{#if isDemo}
<Badge variant="secondary" class="text-xs">
📊 示例数据
</Badge>
{/if}
</div>
<!-- 搜索栏 -->
<div class="flex gap-3">
<div class="relative flex-1">
<BarChart3 class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="输入文件名..."
class="pl-10"
bind:value={fileName}
onkeydown={(e) => e.key === 'Enter' && loadData()}
/>
</div>
<Button onclick={loadData} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
分析中
<div class="flex items-center gap-3">
{#if isDemo}
<Badge variant="secondary" class="text-xs">
📊 示例数据
</Badge>
{:else}
<BarChart3 class="mr-2 h-4 w-4" />
分析
<!-- 时间范围筛选 -->
<DateRangePicker
bind:startDate
bind:endDate
onchange={onDateChange}
/>
{/if}
</Button>
<Button variant="outline" size="icon" onclick={loadData} disabled={isLoading} title="刷新数据">
<RefreshCw class="h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
</Button>
</div>
</div>
<!-- 错误提示 -->
{#if errorMessage}
{#if errorMessage && !isDemo}
<div class="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{errorMessage}
</div>
{/if}
{#if records.length > 0}
<!-- 加载中 -->
{#if isLoading}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<Loader2 class="h-16 w-16 text-muted-foreground mb-4 animate-spin" />
<p class="text-lg font-medium">正在加载数据...</p>
</Card.Content>
</Card.Root>
{:else if analysisRecords.length > 0}
<!-- 总览卡片 -->
<OverviewCards {totalStats} {records} />
<OverviewCards {totalStats} records={analysisRecords} />
<!-- 每日支出趋势图(按分类堆叠) -->
<DailyTrendChart bind:records categories={sortedCategories()} />
<DailyTrendChart records={analysisRecords} categories={sortedCategories()} />
<div class="grid gap-6 lg:grid-cols-2">
<!-- 分类支出排行 -->
@@ -151,7 +210,7 @@
{categoryStats}
{pieChartData}
totalExpense={totalStats.expense}
bind:records
records={analysisRecords}
categories={sortedCategories()}
/>
@@ -161,7 +220,30 @@
<!-- Top 10 支出 -->
<TopExpenses records={topExpenses} categories={sortedCategories()} />
{:else if !isLoading}
<EmptyState onLoadDemo={loadDemoData} />
{:else}
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<BarChart3 class="h-16 w-16 text-muted-foreground mb-4" />
<p class="text-lg font-medium">
{#if !serverAvailable}
服务器不可用
{:else}
暂无账单数据
{/if}
</p>
<p class="text-sm text-muted-foreground mb-4">
{#if !serverAvailable}
请检查后端服务是否正常运行
{:else}
上传账单后可在此进行数据分析
{/if}
</p>
<Button variant="outline" onclick={loadDemoData}>
<Activity class="mr-2 h-4 w-4" />
查看示例数据
</Button>
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,39 @@
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
// 服务端使用 Docker 内部地址,默认使用 localhost
const API_URL = env.API_URL || 'http://localhost:8080';
export const GET: RequestHandler = async ({ params, url, fetch }) => {
const path = params.path;
const queryString = url.search;
const response = await fetch(`${API_URL}/api/${path}${queryString}`);
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/json',
},
});
};
export const POST: RequestHandler = async ({ params, request, fetch }) => {
const path = params.path;
// 转发原始请求体
const response = await fetch(`${API_URL}/api/${path}`, {
method: 'POST',
body: await request.arrayBuffer(),
headers: {
'Content-Type': request.headers.get('Content-Type') || 'application/octet-stream',
},
});
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/json',
},
});
};

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { fetchBillContent, type BillRecord } from '$lib/api';
import { onMount } from 'svelte';
import { fetchBills, type CleanedBill } from '$lib/api';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Search from '@lucide/svelte/icons/search';
@@ -15,56 +16,124 @@
import TrendingUp from '@lucide/svelte/icons/trending-up';
import FileText from '@lucide/svelte/icons/file-text';
import Filter from '@lucide/svelte/icons/filter';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let fileName = $state('');
// 状态
let isLoading = $state(false);
let errorMessage = $state('');
let records: BillRecord[] = $state([]);
let filterCategory = $state('all');
let filterType = $state<'all' | '支出' | '收入'>('all');
let records: CleanedBill[] = $state([]);
// 分页
let currentPage = $state(1);
let pageSize = $state(20);
let totalRecords = $state(0);
let totalPages = $state(0);
// 聚合统计(所有筛选条件下的数据)
let totalExpense = $state(0);
let totalIncome = $state(0);
// 计算默认日期(当前月)
function getDefaultDates() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = today.toISOString().split('T')[0];
return { startDate, endDate };
}
const defaultDates = getDefaultDates();
// 筛选
let filterCategory = $state('');
let filterIncomeExpense = $state(''); // 收支类型
let filterBillType = $state(''); // 账单来源
let startDate = $state(defaultDates.startDate);
let endDate = $state(defaultDates.endDate);
let searchText = $state('');
async function loadBillData() {
if (!fileName) return;
// 分类列表(硬编码常用分类)
const categories = [
'餐饮美食', '交通出行', '生活服务', '日用百货',
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
];
async function loadBills() {
isLoading = true;
errorMessage = '';
try {
records = await fetchBillContent(fileName);
const response = await fetchBills({
page: currentPage,
page_size: pageSize,
start_date: startDate || undefined,
end_date: endDate || undefined,
category: filterCategory || undefined,
type: filterBillType || undefined,
income_expense: filterIncomeExpense || undefined,
});
if (response.result && response.data) {
records = response.data.bills || [];
totalRecords = response.data.total;
totalPages = response.data.pages;
totalExpense = response.data.total_expense || 0;
totalIncome = response.data.total_income || 0;
} else {
errorMessage = response.message || '加载失败';
records = [];
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
records = [];
} finally {
isLoading = false;
}
}
// 获取所有分类
let categories = $derived([...new Set(records.map(r => r.category))].sort());
// 切换页面
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadBills();
}
}
// 过滤后的记录
let filteredRecords = $derived(
records.filter(r => {
if (filterCategory !== 'all' && r.category !== filterCategory) return false;
if (filterType !== 'all' && r.income_expense !== filterType) return false;
if (searchText) {
const text = searchText.toLowerCase();
return r.merchant.toLowerCase().includes(text) ||
r.description.toLowerCase().includes(text);
}
return true;
})
// 筛选变化时重置到第一页
function applyFilters() {
currentPage = 1;
loadBills();
}
// 清除筛选(恢复默认值)
function clearFilters() {
filterCategory = '';
filterIncomeExpense = '';
filterBillType = '';
startDate = defaultDates.startDate;
endDate = defaultDates.endDate;
searchText = '';
currentPage = 1;
loadBills();
}
// 本地搜索(在当前页数据中筛选)
let displayRecords = $derived(
searchText
? records.filter(r => {
const text = searchText.toLowerCase();
return r.merchant?.toLowerCase().includes(text) ||
r.description?.toLowerCase().includes(text);
})
: records
);
// 统计
let stats = $derived({
total: filteredRecords.length,
expense: filteredRecords
.filter(r => r.income_expense === '支出')
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
income: filteredRecords
.filter(r => r.income_expense === '收入')
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
// 页面加载时获取数据
onMount(() => {
loadBills();
});
</script>
@@ -74,31 +143,14 @@
<div class="space-y-6">
<!-- 页面标题 -->
<div>
<h1 class="text-2xl font-bold tracking-tight">账单列表</h1>
<p class="text-muted-foreground">查看和筛选已处理的账单记录</p>
</div>
<!-- 搜索栏 -->
<div class="flex gap-3">
<div class="relative flex-1">
<FolderOpen class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="输入文件名..."
class="pl-10"
bind:value={fileName}
onkeydown={(e) => e.key === 'Enter' && loadBillData()}
/>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">账单列表</h1>
<p class="text-muted-foreground">查看和筛选已处理的账单记录</p>
</div>
<Button onclick={loadBillData} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
加载中
{:else}
<FolderOpen class="mr-2 h-4 w-4" />
加载
{/if}
<Button variant="outline" onclick={loadBills} disabled={isLoading}>
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
刷新
</Button>
</div>
@@ -110,166 +162,235 @@
</div>
{/if}
{#if records.length > 0}
<!-- 统计概览 -->
<div class="grid gap-4 md:grid-cols-3">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">交易笔数</Card.Title>
<Receipt class="h-4 w-4 text-muted-foreground" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{stats.total}</div>
<p class="text-xs text-muted-foreground">符合筛选条件的记录</p>
</Card.Content>
</Card.Root>
<!-- 统计概览 -->
<div class="grid gap-4 md:grid-cols-3">
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总交易笔数</Card.Title>
<Receipt class="h-4 w-4 text-muted-foreground" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{totalRecords}</div>
<p class="text-xs text-muted-foreground">筛选条件下的账单总数</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-red-200 dark:border-red-900">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总支出</Card.Title>
<TrendingDown class="h-4 w-4 text-red-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
¥{stats.expense.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">支出金额汇总</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-red-200 dark:border-red-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-red-300 dark:hover:border-red-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总支出</Card.Title>
<TrendingDown class="h-4 w-4 text-red-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
¥{totalExpense.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">筛选条件下的支出汇总</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-green-200 dark:border-green-900">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总收入</Card.Title>
<TrendingUp class="h-4 w-4 text-green-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
¥{stats.income.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">收入金额汇总</p>
</Card.Content>
</Card.Root>
</div>
<Card.Root class="border-green-200 dark:border-green-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-green-300 dark:hover:border-green-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总收入</Card.Title>
<TrendingUp class="h-4 w-4 text-green-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
¥{totalIncome.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">筛选条件下的收入汇总</p>
</Card.Content>
</Card.Root>
</div>
<!-- 筛选和表格 -->
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<!-- 筛选和表格 -->
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<Card.Title class="flex items-center gap-2">
<Filter class="h-5 w-5" />
筛选条件
</Card.Title>
<div class="flex flex-wrap gap-4">
<div class="space-y-1.5">
<Label class="text-xs">分类</Label>
<select
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterCategory}
>
<option value="all">全部分类</option>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">类型</Label>
<select
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterType}
>
<option value="all">全部</option>
<option value="支出">支出</option>
<option value="收入">收入</option>
</select>
</div>
<div class="space-y-1.5 flex-1 min-w-[200px]">
<Label class="text-xs">搜索</Label>
<div class="relative">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="商家/商品..."
class="pl-8"
bind:value={searchText}
/>
</div>
{#if filterCategory || filterIncomeExpense || filterBillType || startDate || endDate}
<Button variant="ghost" size="sm" onclick={clearFilters}>
清除筛选
</Button>
{/if}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div class="space-y-1.5 col-span-2 sm:col-span-2">
<Label class="text-xs">日期范围</Label>
<DateRangePicker
{startDate}
{endDate}
onchange={(start, end) => {
startDate = start;
endDate = end;
applyFilters();
}}
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">分类</Label>
<select
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterCategory}
onchange={applyFilters}
>
<option value="">全部</option>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">收/支</Label>
<select
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterIncomeExpense}
onchange={applyFilters}
>
<option value="">全部</option>
<option value="支出">支出</option>
<option value="收入">收入</option>
</select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">来源</Label>
<select
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterBillType}
onchange={applyFilters}
>
<option value="">全部</option>
<option value="alipay">支付宝</option>
<option value="wechat">微信</option>
</select>
</div>
<div class="space-y-1.5 col-span-2 sm:col-span-1">
<Label class="text-xs">搜索</Label>
<div class="relative">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="商家/商品..."
class="pl-8"
bind:value={searchText}
/>
</div>
</div>
</div>
</Card.Header>
<Card.Content>
{#if filteredRecords.length > 0}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
</div>
</Card.Header>
<Card.Content>
{#if isLoading}
<div class="flex flex-col items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground mb-4" />
<p class="text-muted-foreground">加载中...</p>
</div>
{:else if displayRecords.length > 0}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px] lg:w-[160px]">时间</Table.Head>
<Table.Head class="hidden xl:table-cell">来源</Table.Head>
<Table.Head>分类</Table.Head>
<Table.Head class="hidden sm:table-cell">交易对方</Table.Head>
<Table.Head class="hidden lg:table-cell">商品说明</Table.Head>
<Table.Head class="hidden min-[480px]:table-cell">收/支</Table.Head>
<Table.Head class="text-right">金额</Table.Head>
<Table.Head class="hidden xl:table-cell">支付方式</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each displayRecords as record}
<Table.Row>
<Table.Head class="w-[160px]">时间</Table.Head>
<Table.Head>分类</Table.Head>
<Table.Head>交易对方</Table.Head>
<Table.Head>商品说明</Table.Head>
<Table.Head>收/支</Table.Head>
<Table.Head class="text-right">金额</Table.Head>
<Table.Head>支付方式</Table.Head>
<Table.Head>状态</Table.Head>
<Table.Cell class="text-muted-foreground text-sm">
{record.time}
</Table.Cell>
<Table.Cell class="hidden xl:table-cell">
<Badge variant={record.bill_type === 'alipay' ? 'default' : 'secondary'}>
{record.bill_type === 'alipay' ? '支付宝' : '微信'}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{record.category}</Badge>
</Table.Cell>
<Table.Cell class="hidden sm:table-cell max-w-[100px] md:max-w-[150px] truncate" title={record.merchant}>
{record.merchant}
</Table.Cell>
<Table.Cell class="hidden lg:table-cell max-w-[150px] truncate text-muted-foreground" title={record.description}>
{record.description || '-'}
</Table.Cell>
<Table.Cell class="hidden min-[480px]:table-cell">
<span class={record.income_expense === '支出' ? 'text-red-500' : 'text-green-500'}>
{record.income_expense}
</span>
</Table.Cell>
<Table.Cell class="text-right font-mono font-medium">
¥{record.amount.toFixed(2)}
</Table.Cell>
<Table.Cell class="hidden xl:table-cell text-muted-foreground text-sm">
{record.pay_method || '-'}
</Table.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filteredRecords.slice(0, 100) as record}
<Table.Row>
<Table.Cell class="text-muted-foreground text-sm">
{record.time}
</Table.Cell>
<Table.Cell>
<Badge variant="secondary">{record.category}</Badge>
</Table.Cell>
<Table.Cell class="max-w-[180px] truncate" title={record.merchant}>
{record.merchant}
</Table.Cell>
<Table.Cell class="max-w-[180px] truncate text-muted-foreground" title={record.description}>
{record.description || '-'}
</Table.Cell>
<Table.Cell>
<span class={record.income_expense === '支出' ? 'text-red-500' : 'text-green-500'}>
{record.income_expense}
</span>
</Table.Cell>
<Table.Cell class="text-right font-mono font-medium">
¥{record.amount}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{record.payment_method || '-'}
</Table.Cell>
<Table.Cell>
<Badge variant="outline" class="text-green-600 border-green-200">
{record.status || '已完成'}
</Badge>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/each}
</Table.Body>
</Table.Root>
</div>
<!-- 分页控件 -->
<div class="flex items-center justify-between mt-4">
<p class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalRecords)} 条,共 {totalRecords}
</p>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
上一页
</Button>
<div class="flex items-center gap-1">
{#each Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
// 计算显示的页码范围
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, start + 4);
start = Math.max(1, end - 4);
return start + i;
}).filter(p => p <= totalPages) as page}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
class="w-9"
onclick={() => goToPage(page)}
>
{page}
</Button>
{/each}
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onclick={() => goToPage(currentPage + 1)}
>
下一页
<ChevronRight class="h-4 w-4" />
</Button>
</div>
{#if filteredRecords.length > 100}
<p class="text-center text-sm text-muted-foreground mt-4">
显示前 100 条记录,共 {filteredRecords.length}
</p>
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<FileText class="h-12 w-12 text-muted-foreground mb-4" />
<p class="text-muted-foreground">没有匹配的记录</p>
</div>
{/if}
</Card.Content>
</Card.Root>
{:else if !isLoading}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<FileText class="h-16 w-16 text-muted-foreground mb-4" />
<p class="text-lg font-medium">输入文件名加载账单数据</p>
<p class="text-sm text-muted-foreground">上传账单后可在此查看完整记录</p>
</Card.Content>
</Card.Root>
{/if}
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<FileText class="h-12 w-12 text-muted-foreground mb-4" />
<p class="text-muted-foreground">没有找到账单记录</p>
<p class="text-sm text-muted-foreground mt-1">请先上传账单或调整筛选条件</p>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,19 @@
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
// 服务端使用 Docker 内部地址
const API_URL = env.API_URL || 'http://localhost:8080';
export const GET: RequestHandler = async ({ params, fetch }) => {
const path = params.path;
const response = await fetch(`${API_URL}/download/${path}`);
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'text/csv',
'Content-Disposition': response.headers.get('Content-Disposition') || '',
},
});
};