fix: 修复微信账单金额解析问题(半角¥符号支持)
- 修复 parse_amount 函数同时支持全角¥和半角¥ - 新增 MonthRangePicker 日期选择组件 - 新增 /api/monthly-stats 接口获取月度统计 - 分析页面月度趋势使用全量数据 - 新增健康检查路由
This commit is contained in:
@@ -56,6 +56,19 @@ export interface ReviewResponse {
|
||||
data?: ReviewData;
|
||||
}
|
||||
|
||||
// 月度统计数据
|
||||
export interface MonthlyStat {
|
||||
month: string; // YYYY-MM
|
||||
expense: number;
|
||||
income: number;
|
||||
}
|
||||
|
||||
export interface MonthlyStatsResponse {
|
||||
result: boolean;
|
||||
message?: string;
|
||||
data?: MonthlyStat[];
|
||||
}
|
||||
|
||||
export interface BillRecord {
|
||||
time: string;
|
||||
category: string;
|
||||
@@ -109,6 +122,17 @@ export async function getReviewRecords(fileName: string): Promise<ReviewResponse
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取月度统计(全部数据,不受筛选条件影响)
|
||||
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/monthly-stats`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 下载文件 URL
|
||||
export function getDownloadUrl(fileUrl: string): string {
|
||||
return `${API_BASE}${fileUrl}`;
|
||||
|
||||
3
web/src/lib/components/ui/month-range-picker/index.ts
Normal file
3
web/src/lib/components/ui/month-range-picker/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import MonthRangePicker from './month-range-picker.svelte';
|
||||
|
||||
export { MonthRangePicker };
|
||||
@@ -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>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchBills, checkHealth, type CleanedBill } from '$lib/api';
|
||||
import { fetchBills, fetchMonthlyStats, checkHealth, type CleanedBill, type MonthlyStat } from '$lib/api';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
|
||||
import { formatLocalDate } from '$lib/utils';
|
||||
import { MonthRangePicker } from '$lib/components/ui/month-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';
|
||||
@@ -25,7 +24,6 @@
|
||||
// 数据处理服务
|
||||
import {
|
||||
calculateCategoryStats,
|
||||
calculateMonthlyStats,
|
||||
calculateDailyExpenseData,
|
||||
calculateTotalStats,
|
||||
calculatePieChartData,
|
||||
@@ -37,15 +35,17 @@
|
||||
// 分类数据
|
||||
import { categories as allCategories } from '$lib/data/categories';
|
||||
|
||||
// 计算默认日期范围(本月)
|
||||
// 计算默认日期范围(本月1日到今天)
|
||||
function getDefaultDates() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = today.getMonth();
|
||||
|
||||
const startDate = formatLocalDate(new Date(year, month, 1));
|
||||
const endDate = formatLocalDate(today);
|
||||
return { startDate, endDate };
|
||||
return {
|
||||
startYear: today.getFullYear(),
|
||||
startMonth: today.getMonth() + 1,
|
||||
startDay: 1,
|
||||
endYear: today.getFullYear(),
|
||||
endMonth: today.getMonth() + 1,
|
||||
endDay: today.getDate(),
|
||||
};
|
||||
}
|
||||
const defaultDates = getDefaultDates();
|
||||
|
||||
@@ -53,12 +53,22 @@
|
||||
let isLoading = $state(false);
|
||||
let errorMessage = $state('');
|
||||
let records: CleanedBill[] = $state([]);
|
||||
let monthlyStats: MonthlyStat[] = $state([]); // 月度统计(全部数据)
|
||||
let isDemo = $state(false);
|
||||
let serverAvailable = $state(true);
|
||||
|
||||
// 时间范围筛选 - 初始化为默认值
|
||||
let startDate: string = $state(defaultDates.startDate);
|
||||
let endDate: string = $state(defaultDates.endDate);
|
||||
// 日期范围筛选 - 初始化为默认值
|
||||
let startYear = $state(defaultDates.startYear);
|
||||
let startMonth = $state(defaultDates.startMonth);
|
||||
let startDay = $state(defaultDates.startDay);
|
||||
let endYear = $state(defaultDates.endYear);
|
||||
let endMonth = $state(defaultDates.endMonth);
|
||||
let endDay = $state(defaultDates.endDay);
|
||||
|
||||
// 辅助函数:将年月日转换为日期字符串
|
||||
function toDateString(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 将 CleanedBill 转换为分析服务需要的格式
|
||||
function toAnalysisRecords(bills: CleanedBill[]) {
|
||||
@@ -79,7 +89,6 @@
|
||||
// 派生分析数据
|
||||
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));
|
||||
@@ -113,19 +122,29 @@
|
||||
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 || [];
|
||||
// 并行获取数据:筛选后的账单 + 全部月度统计
|
||||
const [billsResponse, monthlyResponse] = await Promise.all([
|
||||
fetchBills({
|
||||
page_size: 10000,
|
||||
start_date: toDateString(startYear, startMonth, startDay),
|
||||
end_date: toDateString(endYear, endMonth, endDay),
|
||||
}),
|
||||
fetchMonthlyStats(),
|
||||
]);
|
||||
|
||||
// 处理账单数据
|
||||
if (billsResponse.result && billsResponse.data) {
|
||||
records = billsResponse.data.bills || [];
|
||||
if (records.length === 0) {
|
||||
errorMessage = '暂无账单数据';
|
||||
}
|
||||
} else {
|
||||
errorMessage = response.message || '加载失败';
|
||||
errorMessage = billsResponse.message || '加载失败';
|
||||
}
|
||||
|
||||
// 处理月度统计数据
|
||||
if (monthlyResponse.result && monthlyResponse.data) {
|
||||
monthlyStats = monthlyResponse.data;
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage = err instanceof Error ? err.message : '加载失败';
|
||||
@@ -135,10 +154,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 日期变化时重新加载
|
||||
function onDateChange() {
|
||||
// 日期变化时重新加载(只加载筛选后的数据,月度统计不变)
|
||||
async function onDateChange() {
|
||||
if (!isDemo) {
|
||||
loadData();
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
const response = await fetchBills({
|
||||
page_size: 10000,
|
||||
start_date: toDateString(startYear, startMonth, startDay),
|
||||
end_date: toDateString(endYear, endMonth, endDay),
|
||||
});
|
||||
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 : '加载失败';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,10 +209,14 @@
|
||||
📊 示例数据
|
||||
</Badge>
|
||||
{:else}
|
||||
<!-- 时间范围筛选 -->
|
||||
<DateRangePicker
|
||||
bind:startDate
|
||||
bind:endDate
|
||||
<!-- 日期范围筛选 -->
|
||||
<MonthRangePicker
|
||||
bind:startYear
|
||||
bind:startMonth
|
||||
bind:startDay
|
||||
bind:endYear
|
||||
bind:endMonth
|
||||
bind:endDay
|
||||
onchange={onDateChange}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
47
web/src/routes/health/+server.ts
Normal file
47
web/src/routes/health/+server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// 服务端使用 Docker 内部地址,默认使用 localhost
|
||||
const API_URL = env.API_URL || 'http://localhost:8080';
|
||||
|
||||
// 获取版本号(优先从环境变量,其次从 package.json)
|
||||
function getVersion(): string {
|
||||
// 优先使用环境变量
|
||||
if (process.env.npm_package_version) {
|
||||
return process.env.npm_package_version;
|
||||
}
|
||||
|
||||
// 回退到默认值
|
||||
return '0.0.1';
|
||||
}
|
||||
|
||||
const APP_VERSION = getVersion();
|
||||
|
||||
export const GET: RequestHandler = async ({ fetch }) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/health`);
|
||||
const data = await response.json();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: data.status || 'ok',
|
||||
version: APP_VERSION,
|
||||
}),
|
||||
{
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: 'error',
|
||||
version: APP_VERSION,
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user