fix: 修复微信账单金额解析问题(半角¥符号支持)

- 修复 parse_amount 函数同时支持全角¥和半角¥
- 新增 MonthRangePicker 日期选择组件
- 新增 /api/monthly-stats 接口获取月度统计
- 分析页面月度趋势使用全量数据
- 新增健康检查路由
This commit is contained in:
2026-01-10 19:21:24 +08:00
parent 9247e1ec7f
commit eb76c3a8dc
20 changed files with 597 additions and 44 deletions

View File

@@ -0,0 +1,3 @@
import MonthRangePicker from './month-range-picker.svelte';
export { MonthRangePicker };

View File

@@ -0,0 +1,293 @@
<script lang="ts">
import CalendarIcon from "@lucide/svelte/icons/calendar";
import * as Popover from "$lib/components/ui/popover";
import * as Select from "$lib/components/ui/select";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
interface Props {
startYear?: number;
startMonth?: number;
startDay?: number;
endYear?: number;
endMonth?: number;
endDay?: number;
onchange?: (startYear: number, startMonth: number, startDay: number, endYear: number, endMonth: number, endDay: number) => void;
class?: string;
}
let {
startYear = $bindable(),
startMonth = $bindable(),
startDay = $bindable(),
endYear = $bindable(),
endMonth = $bindable(),
endDay = $bindable(),
onchange,
class: className
}: Props = $props();
// 生成年份列表 (当前年份前后5年)
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 11 }, (_, i) => currentYear - 5 + i);
// 月份列表
const months = Array.from({ length: 12 }, (_, i) => i + 1);
// 获取某月的天数
function getDaysInMonth(year: number | undefined, month: number | undefined): number[] {
if (!year || !month) return Array.from({ length: 31 }, (_, i) => i + 1);
const days = new Date(year, month, 0).getDate();
return Array.from({ length: days }, (_, i) => i + 1);
}
// 内部状态
let internalStartYear = $state(startYear);
let internalStartMonth = $state(startMonth);
let internalStartDay = $state(startDay);
let internalEndYear = $state(endYear);
let internalEndMonth = $state(endMonth);
let internalEndDay = $state(endDay);
// 动态计算每月天数
let startDays = $derived(getDaysInMonth(internalStartYear, internalStartMonth));
let endDays = $derived(getDaysInMonth(internalEndYear, internalEndMonth));
// 同步外部值变化到内部
$effect(() => {
if (startYear !== undefined) internalStartYear = startYear;
});
$effect(() => {
if (startMonth !== undefined) internalStartMonth = startMonth;
});
$effect(() => {
if (startDay !== undefined) internalStartDay = startDay;
});
$effect(() => {
if (endYear !== undefined) internalEndYear = endYear;
});
$effect(() => {
if (endMonth !== undefined) internalEndMonth = endMonth;
});
$effect(() => {
if (endDay !== undefined) internalEndDay = endDay;
});
// 格式化显示
function formatDate(year: number | undefined, month: number | undefined, day: number | undefined): string {
if (!year || !month || !day) return '';
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
let displayText = $derived(() => {
const start = formatDate(internalStartYear, internalStartMonth, internalStartDay);
const end = formatDate(internalEndYear, internalEndMonth, internalEndDay);
if (start && end) {
return `${start} ~ ${end}`;
}
if (start) {
return `${start} ~ `;
}
return "选择日期范围";
});
// 当选择改变时触发
function handleStartYearChange(value: string | undefined) {
if (value) {
internalStartYear = parseInt(value);
startYear = internalStartYear;
// 检查日期是否超出范围
validateStartDay();
notifyChange();
}
}
function handleStartMonthChange(value: string | undefined) {
if (value) {
internalStartMonth = parseInt(value);
startMonth = internalStartMonth;
// 检查日期是否超出范围
validateStartDay();
notifyChange();
}
}
function handleStartDayChange(value: string | undefined) {
if (value) {
internalStartDay = parseInt(value);
startDay = internalStartDay;
notifyChange();
}
}
function handleEndYearChange(value: string | undefined) {
if (value) {
internalEndYear = parseInt(value);
endYear = internalEndYear;
// 检查日期是否超出范围
validateEndDay();
notifyChange();
}
}
function handleEndMonthChange(value: string | undefined) {
if (value) {
internalEndMonth = parseInt(value);
endMonth = internalEndMonth;
// 检查日期是否超出范围
validateEndDay();
notifyChange();
}
}
function handleEndDayChange(value: string | undefined) {
if (value) {
internalEndDay = parseInt(value);
endDay = internalEndDay;
notifyChange();
}
}
// 验证并调整日期(当月份变化时)
function validateStartDay() {
const maxDay = getDaysInMonth(internalStartYear, internalStartMonth).length;
if (internalStartDay && internalStartDay > maxDay) {
internalStartDay = maxDay;
startDay = internalStartDay;
}
}
function validateEndDay() {
const maxDay = getDaysInMonth(internalEndYear, internalEndMonth).length;
if (internalEndDay && internalEndDay > maxDay) {
internalEndDay = maxDay;
endDay = internalEndDay;
}
}
function notifyChange() {
if (onchange && internalStartYear && internalStartMonth && internalStartDay && internalEndYear && internalEndMonth && internalEndDay) {
onchange(internalStartYear, internalStartMonth, internalStartDay, internalEndYear, internalEndMonth, internalEndDay);
}
}
</script>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
class={cn(
"w-[260px] justify-start text-left font-normal",
!internalStartYear && "text-muted-foreground",
className
)}
{...props}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{displayText()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-4" align="start">
<div class="space-y-4">
<!-- 开始日期 -->
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">开始日期</p>
<div class="flex gap-2">
<Select.Root
type="single"
value={internalStartYear?.toString()}
onValueChange={handleStartYearChange}
>
<Select.Trigger class="w-[90px]">
<span>{internalStartYear || 'YYYY'}</span>
</Select.Trigger>
<Select.Content>
{#each years as year}
<Select.Item value={year.toString()}>{year}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={internalStartMonth?.toString()}
onValueChange={handleStartMonthChange}
>
<Select.Trigger class="w-[70px]">
<span>{internalStartMonth ? String(internalStartMonth).padStart(2, '0') : 'MM'}</span>
</Select.Trigger>
<Select.Content>
{#each months as month}
<Select.Item value={month.toString()}>{String(month).padStart(2, '0')}月</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={internalStartDay?.toString()}
onValueChange={handleStartDayChange}
>
<Select.Trigger class="w-[70px]">
<span>{internalStartDay ? String(internalStartDay).padStart(2, '0') : 'DD'}</span>
</Select.Trigger>
<Select.Content>
{#each startDays as day}
<Select.Item value={day.toString()}>{String(day).padStart(2, '0')}日</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<!-- 结束日期 -->
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">结束日期</p>
<div class="flex gap-2">
<Select.Root
type="single"
value={internalEndYear?.toString()}
onValueChange={handleEndYearChange}
>
<Select.Trigger class="w-[90px]">
<span>{internalEndYear || 'YYYY'}</span>
</Select.Trigger>
<Select.Content>
{#each years as year}
<Select.Item value={year.toString()}>{year}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={internalEndMonth?.toString()}
onValueChange={handleEndMonthChange}
>
<Select.Trigger class="w-[70px]">
<span>{internalEndMonth ? String(internalEndMonth).padStart(2, '0') : 'MM'}</span>
</Select.Trigger>
<Select.Content>
{#each months as month}
<Select.Item value={month.toString()}>{String(month).padStart(2, '0')}月</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={internalEndDay?.toString()}
onValueChange={handleEndDayChange}
>
<Select.Trigger class="w-[70px]">
<span>{internalEndDay ? String(internalEndDay).padStart(2, '0') : 'DD'}</span>
</Select.Trigger>
<Select.Content>
{#each endDays as day}
<Select.Item value={day.toString()}>{String(day).padStart(2, '0')}日</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>