release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import { RotateCcw, Trash2 } from "lucide-react";
import { Button, Dialog, Space, Table } from "tdesign-react";
import { api } from "../../services/api";
import AccessibleInput from "../../components/AccessibleInput";
import type { Notify } from "../expenses/types";
import type { Expense } from "../expenses/types";
import { dateText, money } from "../expenses/date";
import { AsyncState, BusyIcon, ErrorBanner, Page, StatusTag } from "../common";
export default function TrashPage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
const [items, setItems] = useState<Expense[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [purgeTarget, setPurgeTarget] = useState<Expense | null>(null);
const [password, setPassword] = useState("");
const [purgeError, setPurgeError] = useState("");
const loadSequence = useRef(0);
const load = async () => {
const sequence = ++loadSequence.current;
setLoading(true); setError("");
try {
const result = await api<{ items: Expense[] }>("/api/trash");
if (sequence === loadSequence.current) setItems(result.items);
}
catch (e) { if (sequence === loadSequence.current) setError((e as Error).message); }
finally { if (sequence === loadSequence.current) setLoading(false); }
};
useEffect(() => { void load(); }, []);
const restore = async (item: Expense) => {
setBusy(true); setError("");
try { await api(`/api/trash/${item.id}/restore`, { method: "POST", body: JSON.stringify({ version: item.version }) }); notify?.("账目已恢复", "success"); await load(); }
catch (e) { setError((e as Error).message); }
finally { setBusy(false); }
};
const purge = async () => {
if (!purgeTarget || !password) { setPurgeError("请输入当前管理员密码"); return; }
setBusy(true); setPurgeError("");
try { await api(`/api/trash/${purgeTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: purgeTarget.version, password }) }); setPurgeTarget(null); setPassword(""); notify?.("账目已永久删除,审计历史仍保留", "success"); await load(); }
catch (e) { setPurgeError((e as Error).message); }
finally { setBusy(false); }
};
const columns = [
{ colKey: "deletedAt", title: "删除时间", cell: ({ row }: any) => row.deletedAt ? dateText(row.deletedAt, timezone) : "-" },
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <span className="tn-amount">{money(row.amountCents)}</span> },
{ colKey: "note", title: "备注", cell: ({ row }: any) => <span className="tn-note">{row.note || "无备注"}</span> },
{ colKey: "status", title: "原状态", cell: ({ row }: any) => <StatusTag status={row.status} /> },
{ colKey: "actions", title: "操作", width: 190, cell: ({ row }: any) => <Space className="tn-action-group"><Button variant="outline" onClick={() => void restore(row)} disabled={busy} icon={busy ? <BusyIcon /> : <RotateCcw size={15} />}>恢复</Button><Button theme="danger" variant="outline" onClick={() => { setPurgeTarget(row); setPassword(""); setPurgeError(""); }} disabled={busy} icon={<Trash2 size={15} />}>永久删除</Button></Space> },
];
return <Page title="回收站" subtitle="已删除的账目会保留附件,可恢复或经过密码确认后永久删除。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || busy} icon={<RotateCcw size={15} />}>刷新</Button>}>
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Trash2 size={30} /><p>回收站为空</p></div> : undefined} onRetry={() => void load()}>
<div className="tn-table-wrap" role="region" aria-label="回收站账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>
</AsyncState>
<Dialog visible={Boolean(purgeTarget)} header="永久删除账目" confirmBtn={{ content: "永久删除", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }} onConfirm={() => void purge()} onCancel={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }}>
<p>此操作会移除账目和附件字节,完整审计内容仍会保留,且无法恢复。</p>
<p className="tn-dialog-note">请输入当前管理员密码确认。</p>
<AccessibleInput inputAriaLabel="当前管理员密码" inputAriaInvalid={Boolean(purgeError)} inputAriaDescribedby={purgeError ? "trash-purge-error" : undefined} type="password" value={password} onChange={value => { setPassword(value); setPurgeError(""); }} placeholder="当前管理员密码" autocomplete="current-password" />
{purgeError && <div id="trash-purge-error" className="tn-inline-error" role="alert">{purgeError}</div>}
</Dialog>
</Page>;
}