fix:优化数据

This commit is contained in:
丹尼尔
2026-03-15 16:38:59 +08:00
parent a609f81a36
commit 3aa1a586e5
43 changed files with 14565 additions and 294 deletions

View File

@@ -0,0 +1,503 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
apiBase,
financeApi,
type FinanceRecordRead,
type FinanceSyncResponse,
type FinanceSyncResult,
} from "@/lib/api/client";
import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react";
import { toast } from "sonner";
export default function FinancePage() {
const [months, setMonths] = useState<string[]>([]);
const [selectedMonth, setSelectedMonth] = useState<string>("");
const [records, setRecords] = useState<FinanceRecordRead[]>([]);
const [loadingMonths, setLoadingMonths] = useState(true);
const [loadingRecords, setLoadingRecords] = useState(false);
const [syncing, setSyncing] = useState(false);
const [lastSync, setLastSync] = useState<FinanceSyncResponse | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [reviewRecord, setReviewRecord] = useState<FinanceRecordRead | null>(null);
const [reviewAmount, setReviewAmount] = useState("");
const [reviewDate, setReviewDate] = useState("");
const [savingReview, setSavingReview] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewUrlRef = useRef<string | null>(null);
const loadMonths = useCallback(async () => {
try {
const list = await financeApi.listMonths();
setMonths(list);
if (list.length > 0 && !selectedMonth) setSelectedMonth(list[0]);
} catch {
toast.error("加载月份列表失败");
} finally {
setLoadingMonths(false);
}
}, [selectedMonth]);
const loadRecords = useCallback(async () => {
if (!selectedMonth) {
setRecords([]);
return;
}
setLoadingRecords(true);
try {
const list = await financeApi.listRecords(selectedMonth);
setRecords(list);
} catch {
toast.error("加载记录失败");
} finally {
setLoadingRecords(false);
}
}, [selectedMonth]);
useEffect(() => {
loadMonths();
}, [loadMonths]);
useEffect(() => {
loadRecords();
}, [loadRecords]);
const handleSync = async () => {
setSyncing(true);
toast.loading("正在同步邮箱…", { id: "finance-sync" });
try {
const res: FinanceSyncResponse = await financeApi.sync();
setLastSync(res);
toast.dismiss("finance-sync");
if (res.new_files > 0) {
toast.success(`发现 ${res.new_files} 个新文件`);
await loadMonths();
if (selectedMonth) await loadRecords();
} else {
toast.info("收件箱已是最新,无新文件");
}
} catch (e) {
toast.dismiss("finance-sync");
toast.error(e instanceof Error ? e.message : "同步失败");
} finally {
setSyncing(false);
}
};
const resetUploadDialog = useCallback(() => {
setUploadFile(null);
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPreviewUrl(null);
setReviewRecord(null);
setReviewAmount("");
setReviewDate("");
if (fileInputRef.current) fileInputRef.current.value = "";
}, []);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|jpg|jpeg|png|webp)$/i)) {
toast.error("仅支持 PDF、JPG、PNG、WEBP");
return;
}
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setUploadFile(file);
setReviewRecord(null);
setReviewAmount("");
setReviewDate("");
if (file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
previewUrlRef.current = url;
setPreviewUrl(url);
} else {
setPreviewUrl(null);
}
};
const handleUploadSubmit = async () => {
if (!uploadFile) return;
setUploading(true);
try {
const record = await financeApi.uploadInvoice(uploadFile);
setReviewRecord(record);
setReviewAmount(record.amount != null ? String(record.amount) : "");
setReviewDate(record.billing_date || "");
toast.success("已上传,请核对金额与日期");
await loadMonths();
if (selectedMonth === record.month) await loadRecords();
} catch (e) {
toast.error(e instanceof Error ? e.message : "上传失败");
} finally {
setUploading(false);
}
};
const handleReviewSave = async () => {
if (!reviewRecord) return;
const amount = reviewAmount.trim() ? parseFloat(reviewAmount) : null;
const billing_date = reviewDate.trim() || null;
setSavingReview(true);
try {
await financeApi.updateRecord(reviewRecord.id, { amount, billing_date });
toast.success("已保存");
setUploadDialogOpen(false);
resetUploadDialog();
if (selectedMonth) await loadRecords();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingReview(false);
}
};
const handleDownloadZip = async () => {
if (!selectedMonth) {
toast.error("请先选择月份");
return;
}
try {
await financeApi.downloadMonth(selectedMonth);
toast.success(`已下载 ${selectedMonth}.zip`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "下载失败");
}
};
const formatDate = (s: string) =>
new Date(s).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
const typeLabel: Record<string, string> = {
invoices: "发票",
bank_records: "流水",
statements: "流水",
receipts: "回执",
manual: "手动上传",
others: "其他",
};
const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0);
const totalInvoicesThisMonth = records.filter(
(r) => r.amount != null && (r.type === "manual" || r.type === "invoices")
).reduce((s, r) => s + (r.amount ?? 0), 0);
return (
<div className="p-6 max-w-5xl">
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground mt-0.5">
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{selectedMonth && (
<Card className="py-2 px-4">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-semibold">
¥{totalInvoicesThisMonth.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
</p>
</Card>
)}
<Button
variant="outline"
onClick={() => {
setUploadDialogOpen(true);
resetUploadDialog();
}}
>
<Upload className="h-4 w-4" />
<span className="ml-2"></span>
</Button>
<Button onClick={handleSync} disabled={syncing} size="default">
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
<span className="ml-2"></span>
</Button>
</div>
</div>
{/* Upload Invoice Dialog */}
<Dialog open={uploadDialogOpen} onOpenChange={(open) => {
setUploadDialogOpen(open);
if (!open) resetUploadDialog();
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
</DialogHeader>
{!reviewRecord ? (
<>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:bg-muted/50"
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={handleFileSelect}
/>
{previewUrl ? (
<img src={previewUrl} alt="预览" className="max-h-48 mx-auto object-contain" />
) : uploadFile ? (
<p className="text-sm font-medium">{uploadFile.name}</p>
) : (
<p className="text-sm text-muted-foreground"> PDF/</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
</Button>
<Button onClick={handleUploadSubmit} disabled={!uploadFile || uploading}>
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<span className="ml-2"></span>
</Button>
</DialogFooter>
</>
) : (
<>
{previewUrl && (
<img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />
)}
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
step="0.01"
value={reviewAmount}
onChange={(e) => setReviewAmount(e.target.value)}
placeholder="可手动修改"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setReviewRecord(null); setReviewAmount(""); setReviewDate(""); }}>
</Button>
<Button onClick={handleReviewSave} disabled={savingReview}>
{savingReview ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<span className="ml-2"></span>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* Sync History / Last sync */}
<Card>
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
<Inbox className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="py-2">
{lastSync !== null ? (
<>
<p className="text-sm text-muted-foreground">
<strong>{lastSync.new_files}</strong>
</p>
{lastSync.details && lastSync.details.length > 0 && (
<div className="mt-2 space-y-2">
{Object.entries(
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>(
(acc, item) => {
const t = item.type || "others";
if (!acc[t]) acc[t] = [];
acc[t].push(item);
return acc;
},
{},
),
).map(([t, items]) => (
<div key={t}>
<p className="text-xs font-medium text-muted-foreground">
{typeLabel[t] ?? t}{items.length}
</p>
<ul className="mt-1 ml-4 list-disc space-y-0.5 text-xs text-muted-foreground">
{items.map((it) => (
<li key={it.id}>
{it.file_name}
<span className="ml-1 text-[11px] text-muted-foreground/80">
[{it.month}]
</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
</p>
)}
</CardContent>
</Card>
{/* Month + Download */}
<Card>
<CardHeader className="py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base"></CardTitle>
<div className="flex items-center gap-2">
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={loadingMonths}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="选择月份" />
</SelectTrigger>
<SelectContent>
{months.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={handleDownloadZip}
disabled={!selectedMonth || records.length === 0}
>
<Download className="h-4 w-4" />
<span className="ml-1.5"> (.zip)</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loadingRecords ? (
<p className="text-sm text-muted-foreground flex items-center gap-1 py-4">
<Loader2 className="h-4 w-4 animate-spin" />
</p>
) : records.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}
</p>
) : (
<>
<p className="text-xs text-muted-foreground mb-2">
/ / _金额_原文件名
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell>
<span className="text-muted-foreground">
{typeLabel[r.type] ?? r.type}
</span>
</TableCell>
<TableCell className="font-medium">{r.file_name}</TableCell>
<TableCell>
{r.amount != null
? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`
: "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.billing_date || formatDate(r.created_at)}
</TableCell>
<TableCell>
<a
href={`${apiBase()}${r.file_path.startsWith("/") ? "" : "/"}${r.file_path}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
>
<FileText className="h-3.5 w-3.5" />
</a>
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/30 font-medium">
<TableCell colSpan={2}></TableCell>
<TableCell>
¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}