This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f5f7f5" />
|
||||
<title>TallyNote · 采购报销记录</title>
|
||||
<title>TallyNote · 采购报销协同管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.32",
|
||||
"version": "1.1.33",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -87,10 +87,10 @@ function App() {
|
||||
// Keep the login form mounted for those requests so the user sees the
|
||||
// button's busy state instead of losing the entire form to a bootstrap
|
||||
// spinner. `bootstrapRequestId` is only set by the initial session check.
|
||||
if (isSessionBootstrapping(session)) return <main className="tn-auth-shell" role="status" aria-live="polite"><Loading text="正在连接本地账本…" /></main>;
|
||||
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接 TallyNote</h1><p className="tn-page-subtitle">{session.error || "请确认本地服务正在运行。"}</p><Button theme="primary" onClick={() => void dispatch(bootstrapSession())}>重新连接</Button></div></main>;
|
||||
if (isSessionBootstrapping(session)) return <main className="tn-auth-shell" role="status" aria-live="polite"><Loading text="正在进入系统…" /></main>;
|
||||
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接服务</h1><p className="tn-page-subtitle">{session.error || "服务暂时无法连接,请稍后重试或检查网络状态。"}</p><Button theme="primary" onClick={() => void dispatch(bootstrapSession())}>重新连接</Button></div></main>;
|
||||
if (!session.admin) return <LoginPage
|
||||
notice={session.initialized ? undefined : "首次安装还差一步:请在服务器执行 sudo tallynote-admin-init 创建管理员账号。"}
|
||||
notice={session.initialized ? undefined : "系统尚未初始化管理员账号,请联系系统管理员完成初始配置后登录。"}
|
||||
onSuccess={() => setPasswordOpen(false)}
|
||||
/>;
|
||||
if (session.admin.mustChangePassword) return <ChangePasswordPage admin={session.admin} firstLogin onSuccess={() => notify("密码已更新", "success")} />;
|
||||
@@ -104,7 +104,7 @@ function App() {
|
||||
: page === "audit" ? <AuditPage timezone={session.timezone} />
|
||||
: <UpdatePage timezone={session.timezone} notify={notify} />;
|
||||
|
||||
return <AppLayout activeId={page} adminName={session.admin.displayName} adminUsername={session.admin.username} onNavigate={onNavigate} onLogout={onLogout} onOpenPassword={() => { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}><Suspense fallback={<main className="tn-page-loading" role="status" aria-live="polite"><Loading text="正在打开页面…" /></main>}><div key={passwordOpen ? "password" : page} className="tn-page-transition">{content}</div></Suspense></AppLayout>;
|
||||
return <AppLayout activeId={page} adminName={session.admin.displayName} adminUsername={session.admin.username} onNavigate={onNavigate} onLogout={onLogout} onOpenPassword={() => { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}><Suspense fallback={<main className="tn-page-loading" role="status" aria-live="polite"><Loading text="页面加载中…" /></main>}><div key={passwordOpen ? "password" : page} className="tn-page-transition">{content}</div></Suspense></AppLayout>;
|
||||
}
|
||||
|
||||
function RouteErrorPage() {
|
||||
|
||||
@@ -77,18 +77,18 @@ export default function AdminsPage({ currentAdmin, timezone = "Asia/Shanghai", n
|
||||
{ colKey: "status", title: "状态", cell: ({ row }: any) => <StatusTag status={row.status} /> },
|
||||
{ colKey: "lastLoginAt", title: "最近登录", cell: ({ row }: any) => row.lastLoginAt ? dateText(row.lastLoginAt, timezone) : "从未登录" },
|
||||
{ colKey: "version", title: "版本", cell: ({ row }: any) => <span className="tn-code">v{row.version}</span> },
|
||||
{ colKey: "actions", title: "操作", width: 250, cell: ({ row }: any) => <Space className="tn-action-group"><Tooltip content={row.id === currentAdmin.id ? "当前账号请使用右上角修改密码" : "生成一次性临时密码"}><Button variant="outline" onClick={() => setAction({ kind: "reset", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={<KeyRound size={15} />}>重置密码</Button></Tooltip><Button variant="outline" theme={row.status === "active" ? "danger" : "primary"} onClick={() => setAction({ kind: "toggle", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={row.status === "active" ? <UserRoundX size={15} /> : <UserRoundCheck size={15} />}>{row.status === "active" ? "停用" : "启用"}</Button></Space> },
|
||||
{ colKey: "actions", title: "操作", width: 250, cell: ({ row }: any) => <Space className="tn-action-group"><Tooltip content={row.id === currentAdmin.id ? "当前账号请在个人菜单中修改密码" : "重置并生成临时登录密码"}><Button variant="outline" onClick={() => setAction({ kind: "reset", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={<KeyRound size={15} />}>重置密码</Button></Tooltip><Button variant="outline" theme={row.status === "active" ? "danger" : "primary"} onClick={() => setAction({ kind: "toggle", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={row.status === "active" ? <UserRoundX size={15} /> : <UserRoundCheck size={15} />}>{row.status === "active" ? "停用" : "启用"}</Button></Space> },
|
||||
];
|
||||
|
||||
return <Page title="管理员" subtitle="多个等权管理员共享同一本地账目,停用会立即撤销该账号的现有会话。" actions={<Space><Button variant="outline" onClick={() => void load()} disabled={loading} icon={<RotateCcw size={15} />}>刷新</Button><Button theme="primary" onClick={() => { setForm({ username: "", displayName: "" }); setFormError(""); setFormFields({}); setShowCreate(true); }} icon={<Plus size={16} />}>新增管理员</Button></Space>}>
|
||||
return <Page title="管理员" subtitle="管理员协同维护团队账单与报销凭据,停用后将立即限制该账号访问并注销其登录会话。" actions={<Space><Button variant="outline" onClick={() => void load()} disabled={loading} icon={<RotateCcw size={15} />}>刷新</Button><Button theme="primary" onClick={() => { setForm({ username: "", displayName: "" }); setFormError(""); setFormFields({}); setShowCreate(true); }} icon={<Plus size={16} />}>新增管理员</Button></Space>}>
|
||||
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><UserRound 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>
|
||||
<Drawer className="tn-content-drawer tn-admin-drawer" visible={showCreate} destroyOnClose placement="right" size="440px" header="新增管理员" onClose={() => { if (!busy) requestDiscard(() => setShowCreate(false)); }} footer={<Space><Button variant="outline" onClick={() => requestDiscard(() => setShowCreate(false))} disabled={busy}>取消</Button><Button theme="primary" type="button" onClick={() => void create()} disabled={busy} icon={busy ? <BusyIcon /> : <ShieldCheck size={15} />}>创建并生成临时密码</Button></Space>}>
|
||||
<Form id="admin-create-form" layout="vertical" onSubmit={() => { void create(); }}><Form.FormItem label="用户名" help={formFields.username || "至少 3 个字符"} status={formFields.username ? "error" : undefined} rules={[{ required: true, min: 3, max: 64, message: "用户名至少需要 3 个字符" }]}><AccessibleInput disabled={busy} inputAriaLabel="用户名" inputAriaInvalid={Boolean(formFields.username)} value={form.username} onChange={value => { setForm({ ...form, username: value }); setFormFields(current => ({ ...current, username: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={64} autocomplete="off" /></Form.FormItem><Form.FormItem label="显示名" help={formFields.displayName} status={formFields.displayName ? "error" : undefined} rules={[{ required: true, max: 80, message: "请输入显示名" }]}><AccessibleInput disabled={busy} inputAriaLabel="显示名" inputAriaInvalid={Boolean(formFields.displayName)} value={form.displayName} onChange={value => { setForm({ ...form, displayName: value }); setFormFields(current => ({ ...current, displayName: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={80} /></Form.FormItem>{formError && <div className="tn-inline-error" role="alert">{formError}</div>}</Form>
|
||||
</Drawer>
|
||||
<Dialog visible={Boolean(action)} header={action?.kind === "reset" ? "重置管理员密码?" : action?.admin.status === "active" ? "停用管理员?" : "启用管理员?"} confirmBtn={{ content: action?.kind === "reset" ? "重置密码" : action?.admin.status === "active" ? "停用" : "启用", theme: action?.kind === "toggle" && action.admin.status === "active" ? "danger" : "primary", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void (action?.kind === "reset" ? reset() : toggle())} onCancel={() => { if (!busy) setAction(null); }}>
|
||||
{action?.kind === "reset" ? <>将生成一次性临时密码,并立即使“{action.admin.displayName}”的现有会话失效。</> : action?.admin.status === "active" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"}
|
||||
{action?.kind === "reset" ? <>将生成一次性临时密码,同时让管理员“{action.admin.displayName}”已登录的会话安全退出。</> : action?.admin.status === "active" ? "停用后该管理员将无法访问系统,已登录的会话会立即注销。" : "启用后该管理员可恢复系统访问并正常登录。"}
|
||||
</Dialog>
|
||||
<Dialog visible={Boolean(secret)} header="临时密码已生成" confirmBtn="关闭" cancelBtn={null} onClose={() => setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}><div className="tn-secret"><code>{secret}</code><Button variant="outline" icon={<Copy size={15} />} onClick={() => { void copySecret(secret); }}>复制</Button></div><p className="tn-dialog-note">请通过安全渠道交给管理员。首次登录必须修改密码。</p></Dialog>
|
||||
<Dialog visible={Boolean(secret)} header="临时密码已生成" confirmBtn="关闭" cancelBtn={null} onClose={() => setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}><div className="tn-secret"><code>{secret}</code><Button variant="outline" icon={<Copy size={15} />} onClick={() => { void copySecret(secret); }}>复制</Button></div><p className="tn-dialog-note">请妥善保管并将临时密码交付给管理员,该密码在首次登录时会被强制更新。</p></Dialog>
|
||||
</Page>;
|
||||
|
||||
async function copySecret(value: string) {
|
||||
|
||||
@@ -23,7 +23,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
"auth.login_failed": "登录失败",
|
||||
"expense.created": "创建账目",
|
||||
"expense.updated": "更新账目",
|
||||
"expense.status_changed": "切换报销状态",
|
||||
"expense.status_changed": "更新报销状态",
|
||||
"expense.trashed": "移入回收站",
|
||||
"expense.restored": "恢复账目",
|
||||
"expense.purged": "永久删除账目",
|
||||
@@ -133,8 +133,8 @@ export default function AuditPage({ timezone = "Asia/Shanghai" }: { timezone?: s
|
||||
{ colKey: "outcome", title: "结果", cell: ({ row }: any) => <Tag theme={row.outcome === "success" || !row.outcome ? "success" : row.outcome === "denied" ? "warning" : "danger"}>{outcomeLabel(row.outcome)}</Tag> },
|
||||
];
|
||||
|
||||
return <Page title="审计日志" subtitle="记录登录、账目、附件、导出、管理员和更新操作。日志只读,永久删除也不会清除它。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || loadingMore} icon={<RotateCcw size={15} />}>刷新</Button>}>
|
||||
<form className="tn-toolbar tn-audit-toolbar" onSubmit={e => { e.preventDefault(); applyFilters(); }}><AccessibleInput inputAriaLabel="动作筛选" value={actionDraft} onChange={setActionDraft} maxlength={100} placeholder="动作,例如 expense.created" prefixIcon={<Search size={16} />} /><Select aria-label="目标类型" value={targetDraft} onChange={v => setTargetDraft(String(v))} options={[{ label: "全部目标", value: "" }, { label: "账目", value: "expense" }, { label: "管理员", value: "admin" }, { label: "导出", value: "export" }, { label: "会话", value: "session" }, { label: "更新任务", value: "update" }, { label: "系统检查", value: "system" }]} /><Button theme="primary" type="submit" icon={<Search size={15} />}>筛选</Button></form>
|
||||
return <Page title="审计日志" subtitle="全量记录系统鉴权、账目变更、凭证管理、数据导出与维护行为,审计日志严格只读留存,确保财务追溯合规。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || loadingMore} icon={<RotateCcw size={15} />}>刷新</Button>}>
|
||||
<form className="tn-toolbar tn-audit-toolbar" onSubmit={e => { e.preventDefault(); applyFilters(); }}><AccessibleInput inputAriaLabel="动作筛选" value={actionDraft} onChange={setActionDraft} maxlength={100} placeholder="输入操作行为筛选" prefixIcon={<Search size={16} />} /><Select aria-label="目标类型" value={targetDraft} onChange={v => setTargetDraft(String(v))} options={[{ label: "全部目标", value: "" }, { label: "账目", value: "expense" }, { label: "管理员", value: "admin" }, { label: "导出", value: "export" }, { label: "会话", value: "session" }, { label: "更新任务", value: "update" }, { label: "系统检查", value: "system" }]} /><Button theme="primary" type="submit" icon={<Search size={15} />}>筛选</Button></form>
|
||||
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Archive size={30} /><p>{action || targetType ? "没有符合当前筛选条件的审计记录" : "暂无审计记录"}</p>{(action || targetType) && <Button variant="outline" onClick={() => setSearchParams(new URLSearchParams())}>清除筛选</Button>}</div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap tn-audit-table" role="region" aria-label="审计日志列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>{hasMore && <div className="tn-table-more"><Button variant="outline" onClick={() => void load(true)} disabled={loadingMore} icon={<RotateCcw size={15} />}>{loadingMore ? "加载中…" : "加载更早记录"}</Button></div>}</AsyncState>
|
||||
</Page>;
|
||||
}
|
||||
|
||||
@@ -108,14 +108,14 @@ export default function ChangePasswordPage({ admin, onSuccess, onCancel, returnL
|
||||
</section>;
|
||||
|
||||
if (!isFirstLogin) {
|
||||
return <Page title="修改密码" subtitle="更新当前管理员的登录凭据。" actions={onCancel ? <Button variant="text" type="button" onClick={onCancel} icon={<ArrowLeft size={16} />}>{returnLabel}</Button> : undefined} className="tn-password-page">{panel}</Page>;
|
||||
return <Page title="修改密码" subtitle="定期更新管理员账户登录密码,保障财务数据访问安全。" actions={onCancel ? <Button variant="text" type="button" onClick={onCancel} icon={<ArrowLeft size={16} />}>{returnLabel}</Button> : undefined} className="tn-password-page">{panel}</Page>;
|
||||
}
|
||||
|
||||
return <main className="tn-login-page" data-page="change-password">
|
||||
<section className="tn-login-container tn-password-container">
|
||||
<div className="tn-login-heading">
|
||||
<h1 id="password-title" className="tn-login-title">首次登录保护</h1>
|
||||
<p className="tn-login-subtitle">管理员 {displayName} 需要先设置新密码。</p>
|
||||
<h1 id="password-title" className="tn-login-title">初始安全设置</h1>
|
||||
<p className="tn-login-subtitle">欢迎使用系统,管理员 {displayName},为保障账户安全,首次登录请先设置新密码。</p>
|
||||
</div>
|
||||
{panel}
|
||||
</section>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function LoginPage({ notice, onSuccess }: LoginPageProps) {
|
||||
<main className="tn-login-page" data-page="login">
|
||||
<section className="tn-login-container" aria-labelledby="login-title">
|
||||
<div className="tn-login-heading">
|
||||
<h1 id="login-title" className="tn-login-title">登录到 <span className="tn-login-title-brand">TallyNote</span></h1>
|
||||
<h1 id="login-title" className="tn-login-title">登录 <span className="tn-login-title-brand">TallyNote</span> 工作台</h1>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
|
||||
@@ -29,7 +29,7 @@ export function AsyncState({ loading, error, empty, onRetry, children }: {
|
||||
if (loading && empty) return <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div>;
|
||||
if (error && empty) return <div className="tn-empty" role="alert"><AlertCircle size={28} /><p>{error}</p>{onRetry && <Button variant="outline" onClick={onRetry}>重试</Button>}</div>;
|
||||
return <>
|
||||
{loading && <div className="tn-inline-loading" role="status"><Loader2 size={15} className="tn-spin" aria-hidden="true" />正在更新…</div>}
|
||||
{loading && <div className="tn-inline-loading" role="status"><Loader2 size={15} className="tn-spin" aria-hidden="true" />正在同步…</div>}
|
||||
{error && <ErrorBanner message={error} onRetry={onRetry} />}
|
||||
{empty || children}
|
||||
</>;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }
|
||||
<div className="tn-page-actions"><div className="tn-dashboard-actions"><div className="tn-dashboard-month-control"><Tooltip content="上个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={16} />} /></Tooltip><DatePicker className="tn-dashboard-month" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) setDashboardMonth(next); }} inputProps={{ "aria-label": "仪表盘月份" } as any} /><Tooltip content="下个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={16} />} /></Tooltip></div><div className="tn-dashboard-secondary-actions"><Button className="tn-dashboard-refresh" variant="outline" onClick={() => void load()} disabled={loading} icon={<RefreshCw size={15} />}>刷新</Button><Button className="tn-dashboard-view" theme="primary" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看账目</Button></div></div></div>
|
||||
</div>
|
||||
{error && hasCurrentSnapshot && <Alert theme="error" icon={<AlertCircle size={16} />} message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />}
|
||||
{loading ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载仪表盘…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
|
||||
{loading ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="正在汇总本月数据…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
|
||||
<div className="tn-dashboard-stats">
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-total"><Statistic title="本月总额" value={totalCents / 100} prefix="¥" decimalPlaces={2} /></Card>
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-count"><Statistic title="账目笔数" value={totalCount} suffix="笔" /></Card>
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = { expense: Expense; timezone?: string; onClose: () => void; onUpdat
|
||||
const TIMELINE_LABELS: Record<string, string> = {
|
||||
"expense.created": "创建账目",
|
||||
"expense.updated": "更新账目",
|
||||
"expense.status_changed": "切换报销状态",
|
||||
"expense.status_changed": "更新报销状态",
|
||||
"expense.trashed": "移入回收站",
|
||||
"expense.restored": "从回收站恢复",
|
||||
"attachment.added": "添加附件",
|
||||
@@ -49,7 +49,7 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC
|
||||
const current = (caught.details as { current?: Expense } | undefined)?.current;
|
||||
if (!current) return false;
|
||||
setDetail(current);
|
||||
setMessage("这笔账目刚被其他管理员修改,已加载最新版本,请确认后重试。");
|
||||
setMessage("此笔账目已被其他管理员更新,已为您自动同步最新记录,请确认后重试。");
|
||||
return true;
|
||||
};
|
||||
const updateStatus = async () => { setBusy(true); try { const next = detail.status === "reimbursed" ? "unreimbursed" : "reimbursed"; const result = await api<{ expense: Expense }>(`/api/expenses/${detail.id}/status`, { method: "POST", body: JSON.stringify({ status: next, version: detail.version }) }); setDetail(result.expense); setAction(null); notify?.(next === "reimbursed" ? "已标记为已报销" : "已改回未报销", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
|
||||
@@ -57,16 +57,16 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC
|
||||
const remove = async () => { if (!removeTarget) return; const requires = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim(); if (requires && !removeReason.trim()) { setRemoveError("请填写无发票原因"); return; } setBusy(true); setRemoveError(""); try { const body: { version: number; invoiceMissingReason?: string } = { version: detail.version }; if (requires) body.invoiceMissingReason = removeReason.trim(); const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(body) }); setDetail(result.expense); setRemoveTarget(null); notify?.("附件已删除", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setRemoveError)) setRemoveError((caught as Error).message); } finally { setBusy(false); } };
|
||||
return <>
|
||||
<Drawer className="tn-content-drawer tn-detail-drawer" placement="right" size="520px" visible destroyOnClose header="账目详情" onClose={onClose} footer={<Space><Button onClick={() => setAction("status")} disabled={busy}>{detail.status === "reimbursed" ? "标记未报销" : "标记已报销"}</Button><Button theme="danger" onClick={() => setAction("trash")} disabled={busy}><Trash2 size={15} />移入回收站</Button></Space>}>
|
||||
{loading ? <div role="status" aria-live="polite"><Loading text="加载详情…" /></div> : error ? <div role="alert" className="expense-error"><AlertCircle size={16} />{error}<Button variant="text" onClick={load}>重试</Button></div> : <div className="expense-detail">
|
||||
{loading ? <div role="status" aria-live="polite"><Loading text="正在加载账目详情…" /></div> : error ? <div role="alert" className="expense-error"><AlertCircle size={16} />{error}<Button variant="text" onClick={load}>重试</Button></div> : <div className="expense-detail">
|
||||
<div className="expense-detail-head"><strong>{money(detail.amountCents)}</strong><Button variant="outline" onClick={() => onRequestEdit(detail)}><Settings size={15} />编辑</Button></div>
|
||||
<dl><div><dt>支付时间</dt><dd>{dateText(detail.paidAt, timezone)}</dd></div><div><dt>发票</dt><dd>{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <><Tag theme="warning">无发票</Tag>:{detail.invoiceMissingReason}</> : <Tag theme="danger">未说明</Tag>}</dd></div><div><dt>状态</dt><dd><Tag theme={detail.status === "reimbursed" ? "success" : "warning"}>{detail.status === "reimbursed" ? "已报销" : "未报销"}</Tag></dd></div><div><dt>备注</dt><dd>{detail.note || "无"}</dd></div></dl>
|
||||
<h3>附件 <small>{detail.attachments?.length || 0}</small></h3><div className="expense-attachments">{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return <div className="expense-attachment" key={a.id}><span title={a.originalName}>{a.mimeType.startsWith("image/") ? <ImageIcon size={16} /> : <FileText size={16} />} {a.originalName}<small>{formatBytes(a.sizeBytes)}</small></span><Space>{a.previewable && <Tooltip content="预览附件" placement="left"><Button variant="text" shape="circle" onClick={() => setPreview(a)} aria-label={`预览 ${a.originalName}`}><Search size={15} /></Button></Tooltip>}<Tooltip content="下载附件" placement="left"><Button variant="text" shape="circle" onClick={() => { window.location.href = `/api/attachments/${a.id}/content?download=1`; }} aria-label={`下载 ${a.originalName}`}><ArrowDownToLine size={15} /></Button></Tooltip><Tooltip content={protectsLastProof ? "至少保留一张付款凭证" : "删除附件"} placement="left"><Button variant="text" shape="circle" theme="danger" disabled={protectsLastProof} onClick={() => { setRemoveReason(""); setRemoveError(""); setRemoveTarget(a); }} aria-label={protectsLastProof ? `不可删除最后一张付款凭证 ${a.originalName}` : `删除 ${a.originalName}`}><Trash2 size={14} /></Button></Tooltip></Space></div>; })}</div>
|
||||
{timeline.length > 0 && <><h3>操作记录</h3><div className="expense-timeline">{timeline.slice(0, 12).map(t => <div key={t.id}><span>{dateText(t.occurredAt, timezone)}</span><strong title={t.action}>{TIMELINE_LABELS[t.action] || t.action}</strong><small>{t.actorUsername || "系统"}</small></div>)}</div></>}
|
||||
</div>}
|
||||
</Drawer>
|
||||
<Dialog visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"}</Dialog>
|
||||
<Dialog visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。</Dialog>
|
||||
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert">{removeError}</div>}</>}</Dialog>
|
||||
<Dialog visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "确认将该笔账目恢复为未报销状态?" : "确认该笔账目已完成报销审批与结算?"}</Dialog>
|
||||
<Dialog visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>移入回收站后将不在正常列表中展示,关联附件会完整保留,可随时前往回收站恢复。</Dialog>
|
||||
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "每笔账目至少需要保留一张有效的付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "当前为该账目唯一的发票附件,删除后请补充说明无发票原因。" : "确认删除该发票附件?"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert">{removeError}</div>}</>}</Dialog>
|
||||
<Dialog visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function ExpenseDrawer({ expense, timezone = "Asia/Shanghai", onC
|
||||
{error && <div role="alert" className="expense-error"><AlertCircle size={16} />{error}</div>}
|
||||
</form>
|
||||
</Drawer>
|
||||
<Dialog visible={Boolean(conflict)} header="记录已被更新" confirmBtn="保留当前内容" cancelBtn="加载服务器版本" onClose={() => { setConflict(null); setError("冲突提示已关闭,请再次保存以重新确认最新版本。"); }} onConfirm={() => { if (conflict) { setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); setError("当前内容已保留,请再次保存以覆盖服务器版本。"); } }} onCancel={() => { if (conflict) { setAmount((conflict.amountCents / 100).toFixed(2)); setNote(conflict.note); setPaidAt(dateInputValue(new Date(conflict.paidAt), timezone)); setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); } }}>另一位管理员刚刚修改了这笔账目。请选择如何处理,系统不会静默覆盖。</Dialog>
|
||||
<Dialog visible={Boolean(conflict)} header="记录已被更新" confirmBtn="保留当前内容" cancelBtn="加载服务器版本" onClose={() => { setConflict(null); setError("已取消冲突提示,再次点击保存将重新确认最新数据。"); }} onConfirm={() => { if (conflict) { setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); setError("已保留您当前编辑的内容,再次点击保存将更新此账目。"); } }} onCancel={() => { if (conflict) { setAmount((conflict.amountCents / 100).toFixed(2)); setNote(conflict.note); setPaidAt(dateInputValue(new Date(conflict.paidAt), timezone)); setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); } }}>此笔账目已被其他管理员更新。为保障财务数据准确,请选择保留您当前的编辑并覆盖,或同步加载最新版本。</Dialog>
|
||||
</>;
|
||||
}
|
||||
function focusExpenseField(errors: Partial<Record<ExpenseField, string>>) {
|
||||
|
||||
@@ -92,14 +92,14 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
failureCount += 1;
|
||||
setExportIssue("网络连接不稳定,导出仍在后台进行,正在重新查询状态…");
|
||||
setExportIssue("网络响应稍慢,数据导出仍在后台处理中,正在自动同步进度…");
|
||||
schedule(Math.min(1000 * (2 ** Math.min(failureCount, 3)), 8000));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
|
||||
}, [exporting, notify]);
|
||||
const moveToTrash = async () => { if (!trashTarget) return; setTrashing(true); try { await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) }); notify?.("账目已移入回收站,可在回收站恢复", "success"); setTrashTarget(null); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setTrashing(false); } };
|
||||
const moveToTrash = async () => { if (!trashTarget) return; setTrashing(true); try { await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) }); notify?.("账目已移入回收站,可随时在回收站恢复", "success"); setTrashTarget(null); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setTrashing(false); } };
|
||||
const columns = [
|
||||
{ colKey: "row-select", type: "multiple" },
|
||||
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
|
||||
@@ -122,10 +122,10 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
|
||||
return <div className="tn-page expenses-page"><div className="tn-page-head expenses-head"><div><h1 className="tn-page-title">账目列表</h1></div><div className="tn-page-actions"><Checkbox checked={includeManifest} onChange={setIncludeManifest}>包含 manifest.json</Checkbox><Button variant="outline" onClick={() => void exportAll()} disabled={Boolean(exporting) || (!selectedKeys.length && (!items.length || !dataReady))} icon={<FileDown size={16} />}>{exporting ? "导出中…" : selectedKeys.length ? `导出所选(${selectedKeys.length})` : "导出筛选结果"}</Button><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={16} />}>新增账目</Button></div></div>
|
||||
<div className="tn-toolbar expenses-toolbar"><div className="tn-expense-month-controls"><Tooltip content="上个月"><Button variant="text" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={18} />} /></Tooltip><DatePicker className="tn-month-picker" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) updateFilters({ month: next }); }} placeholder="选择月份" inputProps={{ "aria-label": "账目月份" } as any} /><Tooltip content="下个月"><Button variant="text" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={18} />} /></Tooltip></div><Radio.Group className="tn-segmented" theme="button" variant="primary-filled" value={status} onChange={(value: any) => updateFilters({ status: value as "unreimbursed" | "reimbursed" })} aria-label="报销状态"><Radio.Button value="unreimbursed">未报销</Radio.Button><Radio.Button value="reimbursed">已报销</Radio.Button></Radio.Group><div className="tn-expense-search-controls"><AccessibleInput inputAriaLabel="搜索备注" className="tn-expense-search" value={queryDraft} onChange={setQueryDraft} onEnter={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} maxlength={200} placeholder="搜索备注" prefixIcon={<Search size={16} />} suffix={queryDraft ? <Tooltip content="清除搜索"><Button variant="text" shape="square" onClick={() => { setQueryDraft(""); updateFilters({ query: "" }); }} aria-label="清除搜索" icon={<X size={14} />} /></Tooltip> : undefined} /><Button variant="outline" onClick={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} disabled={loading} icon={<Search size={15} />}>搜索</Button></div><div className="tn-expense-filter-actions"><Checkbox checked={missingInvoice} onChange={checked => updateFilters({ missingInvoice: checked })}>缺发票</Checkbox><Tooltip content="刷新当前结果"><Button variant="outline" shape="square" onClick={() => void load()} disabled={loading} aria-label="刷新当前结果" icon={<RefreshCw size={15} />} /></Tooltip></div></div>
|
||||
<div className="tn-summary expenses-summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong>{selectedKeys.length > 0 && <><span>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}</span><Button variant="text" onClick={() => setSelectedKeys([])}>清除选择</Button></>}</div>
|
||||
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>停止等待</Button></div>}
|
||||
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>关闭提示</Button></div>}
|
||||
{error && <div className="expense-error" role="alert"><AlertCircle size={16} />{error}<Button variant="text" onClick={() => void load()}>重试</Button></div>}
|
||||
{error ? null : !dataReady || (loading && !items.length) ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
|
||||
{drawer === "new" && <ExpenseDrawer timezone={timezone} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />}
|
||||
{trashTarget && <Dialog visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站。它会从普通列表和导出结果中隐藏,附件会保留,可随时恢复。</Dialog>}
|
||||
{trashTarget && <Dialog visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>确认将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站?移入后将不在正常列表中展示,关联附件将完整保留,可随时恢复。</Dialog>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@ export default function TrashPage({ timezone = "Asia/Shanghai", notify }: { time
|
||||
{ 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>}>
|
||||
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>此操作将永久清除该笔账目及其关联的所有凭证与发票附件,审计日志将予以留存,清除后不可恢复。</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>}
|
||||
|
||||
@@ -585,7 +585,7 @@ export default function UpdatePage({
|
||||
<Page
|
||||
eyebrow="运维与维护"
|
||||
title="系统更新中心"
|
||||
subtitle="管理 TallyNote 本地账本版本升级、下载验证与安全平滑重启"
|
||||
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
|
||||
actions={
|
||||
<div className="tn-update-page-actions">
|
||||
<Button
|
||||
@@ -710,7 +710,7 @@ export default function UpdatePage({
|
||||
<div className="tn-metric-content">
|
||||
<span className="tn-metric-label">更新守护方式</span>
|
||||
<div className="tn-metric-value">
|
||||
{info.strategy === "systemd" ? "systemd 守护" : "手动源码模式"}
|
||||
{info.strategy === "systemd" ? "系统守护进程" : "独立运行模式"}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -722,7 +722,7 @@ export default function UpdatePage({
|
||||
{info.checkedAt ? dateText(info.checkedAt, timezone) : "未检查"}
|
||||
</div>
|
||||
<div className="tn-metric-foot">
|
||||
<small>数据源:Gitea Releases</small>
|
||||
<small>数据源:官方发布渠道</small>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -741,7 +741,7 @@ export default function UpdatePage({
|
||||
<div className="tn-stepper-head">
|
||||
<div>
|
||||
<small className="tn-section-subheading">
|
||||
标准化发布流程:版本发现 → 校验签名与清单 → 安全暂存 → 原子切换并重启
|
||||
标准升级流程:获取版本信息 → 校验完整性 → 下载更新包 → 自动应用并安全重启
|
||||
</small>
|
||||
</div>
|
||||
{job && (
|
||||
@@ -761,10 +761,10 @@ export default function UpdatePage({
|
||||
|
||||
<div className="tn-stepper-wrap">
|
||||
<Steps current={currentStep} theme="default">
|
||||
<StepItem title="检查新版" content="获取 Release 元数据" />
|
||||
<StepItem title="下载更新包" content="安全暂存 tar.gz 归档" />
|
||||
<StepItem title="完整性校验" content="SHA-256 + 签名核验" />
|
||||
<StepItem title="立即应用" content="原子切换与平滑重启" />
|
||||
<StepItem title="检查新版" content="获取最新版本发布信息" />
|
||||
<StepItem title="下载更新包" content="安全暂存更新程序包" />
|
||||
<StepItem title="完整性校验" content="校验数字签名与文件指纹" />
|
||||
<StepItem title="立即应用" content="自动备份数据并平滑升级" />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
@@ -806,19 +806,19 @@ export default function UpdatePage({
|
||||
{/* Status-specific descriptions */}
|
||||
<div className="tn-job-runtime-desc">
|
||||
{job.status === "downloading" && (
|
||||
<span>正在从受信仓库高速拉取发布包,数据经过 TLS 1.3 传输加密,不影响前台正常记账。</span>
|
||||
<span>正在从官方渠道下载更新安装包,后台安全传输中,不影响当前系统的正常使用。</span>
|
||||
)}
|
||||
{job.status === "verifying" && (
|
||||
<span>已下载完成,正在对发布包进行 SHA-256 哈希比对与 Ed25519 签名真伪验证…</span>
|
||||
<span>更新包已下载完成,正在进行防篡改完整性核验与数字签名认证…</span>
|
||||
)}
|
||||
{job.status === "staged" && (
|
||||
<span className="text-success">
|
||||
<CheckCircle2 size={15} aria-hidden="true" />
|
||||
<span>更新包已下载并通过全部完整性校验。随时可以点击右上方【立即应用更新】。</span>
|
||||
<span>更新包已准备就绪并通过安全校验,可随时点击【立即应用更新】完成升级。</span>
|
||||
</span>
|
||||
)}
|
||||
{job.status === "backing_up" && (
|
||||
<span>升级前安全保护中:正在自动为 SQLite 数据库与凭据附件生成安全快照备份…</span>
|
||||
<span>正在为系统数据与附件凭据创建安全快照备份,确保升级过程数据无损…</span>
|
||||
)}
|
||||
{job.status === "applying" && (
|
||||
<div className="tn-restarting-banner">
|
||||
@@ -826,10 +826,10 @@ export default function UpdatePage({
|
||||
<RefreshCw size={18} className="tn-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<strong>服务正在平滑重启并执行健康检查…</strong>
|
||||
<strong>系统正在安全重启并检验服务可用性…</strong>
|
||||
{restartSeconds !== null && (
|
||||
<div className="tn-restart-countdown">
|
||||
预计 {restartSeconds} 秒后自动恢复,前端正保持心跳轮询,无需手动刷新。
|
||||
预计 {restartSeconds} 秒后自动恢复连接,系统将自动刷新页面,请稍候。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -851,7 +851,7 @@ export default function UpdatePage({
|
||||
{job?.status === "completed" && (
|
||||
<div className="tn-inline-info">
|
||||
<CheckCircle2 size={16} />
|
||||
<span>更新成功完成!新版本已通过内置端点健康检查,账本数据与附件完整无损。</span>
|
||||
<span>系统已成功升级至最新版本,数据与附件均已完整保留。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -885,7 +885,7 @@ export default function UpdatePage({
|
||||
<span className="tn-fact-label">目标平台发布包</span>
|
||||
<strong className="tn-fact-value">{latest.compatible ? latest.assetName : "无匹配架构包"}</strong>
|
||||
<small className="tn-fact-hint">
|
||||
{latest.compatible ? "与当前操作系统及 CPU 架构完全匹配" : "请在对应架构 runner 上单独构建"}
|
||||
{latest.compatible ? "与当前系统运行环境匹配" : "当前系统环境暂不支持此更新包"}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -908,7 +908,7 @@ export default function UpdatePage({
|
||||
<strong className="tn-fact-value">
|
||||
{latest.assetSize ? bytesText(latest.assetSize) : "-"}
|
||||
</strong>
|
||||
<small className="tn-fact-hint">包含独立运行时与全量生产静态资源</small>
|
||||
<small className="tn-fact-hint">完整生产环境更新程序包</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -946,7 +946,7 @@ export default function UpdatePage({
|
||||
)}
|
||||
{reloadReady && (
|
||||
<Button theme="success" size="large" onClick={() => window.location.reload()}>
|
||||
重新加载控制台
|
||||
刷新页面
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -955,7 +955,7 @@ export default function UpdatePage({
|
||||
<Surface className="tn-empty-surface">
|
||||
<div className="tn-empty-content">
|
||||
<RefreshCw size={28} className="text-muted" />
|
||||
<p>暂无待显示的 Release 信息,可点击右上角“检查新版本”主动拉取。</p>
|
||||
<p>暂无更新版本信息,可点击右上角“检查新版本”获取最新状态。</p>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
@@ -988,9 +988,9 @@ export default function UpdatePage({
|
||||
{!info.configured && (
|
||||
<div className="tn-update-explainer">
|
||||
<div>
|
||||
<strong>当前为手动开发/源码维护模式</strong>
|
||||
<strong>当前运行环境未启用后台自动更新服务</strong>
|
||||
<p>
|
||||
检测到当前以开发或无守护模式运行。生产服务器使用 curl / systemd 安装并部署后,将自动启用此处的一键全自动在线更新。
|
||||
系统当前处于独立运行模式。若需开启在线一键更新,请配置系统后台更新守护进程。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1001,7 +1001,7 @@ export default function UpdatePage({
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog
|
||||
visible={Boolean(confirmVersion)}
|
||||
header={confirmAction === "download" ? "确认下载更新包" : "确认立即更新并平滑重启"}
|
||||
header={confirmAction === "download" ? "确认下载更新包" : "确认立即应用更新并重启服务"}
|
||||
confirmBtn={{
|
||||
content: confirmAction === "download" ? "确认下载" : "立即更新",
|
||||
theme: "primary",
|
||||
@@ -1021,21 +1021,21 @@ export default function UpdatePage({
|
||||
{confirmAction === "download" ? (
|
||||
<>
|
||||
<p>
|
||||
即将从官方 Release 源拉取 <strong>v{confirmVersion}</strong> 安装包。
|
||||
即将从官方渠道下载 <strong>v{confirmVersion}</strong> 版本安装包。
|
||||
</p>
|
||||
<p className="text-secondary">
|
||||
下载将在后台独立执行,并自动校验 SHA-256 哈希清单与 Ed25519 签名,不会中断当前记账工作。
|
||||
安装包将在后台静默下载并完成安全性校验,期间不影响系统的正常使用。
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
即将应用已就绪的 <strong>v{confirmVersion}</strong> 更新包。
|
||||
即将应用 <strong>v{confirmVersion}</strong> 版本更新包并升级系统。
|
||||
</p>
|
||||
<div className="tn-update-safety-tips">
|
||||
<div>• 系统更新前将自动备份当前 SQLite 数据库与附件目录;</div>
|
||||
<div>• 服务将短暂重启约 15~30 秒,期间页面保持心跳重连并在完成后平滑刷新;</div>
|
||||
<div>• 切换操作具备原子性,若健康检查异常将自动回退至当前版本。</div>
|
||||
<div>• 更新前将自动创建数据与凭据附件快照,保障数据资产安全;</div>
|
||||
<div>• 服务将进行平滑重启(预计 15~30 秒),更新完成后页面将自动刷新恢复;</div>
|
||||
<div>• 系统具备自动容灾保护,若升级验证异常将自动回退至当前版本。</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -173,7 +173,7 @@ export const MOCK_SCENARIOS: Record<MockScenario, MockUpdateState> = {
|
||||
},
|
||||
backing_up: {
|
||||
title: "数据备份中(更新保护)",
|
||||
description: "正在为 /var/lib/tallynote 生成自动还原快照",
|
||||
description: "正在为系统数据生成安全快照备份",
|
||||
info: {
|
||||
configured: true,
|
||||
strategy: "systemd",
|
||||
@@ -194,7 +194,7 @@ export const MOCK_SCENARIOS: Record<MockScenario, MockUpdateState> = {
|
||||
},
|
||||
applying: {
|
||||
title: "服务平滑重启中(倒计时中)",
|
||||
description: "已原子切换版本,systemd 正在热重启,前端实时探活",
|
||||
description: "已安全切换版本,服务正在热重启并检验状态",
|
||||
info: {
|
||||
configured: true,
|
||||
strategy: "systemd",
|
||||
@@ -269,7 +269,7 @@ export const MOCK_SCENARIOS: Record<MockScenario, MockUpdateState> = {
|
||||
},
|
||||
disabled: {
|
||||
title: "手动源码模式",
|
||||
description: "未接入 systemd 时的只读说明与命令引导展示",
|
||||
description: "未启用后台守护时的更新提示与引导说明",
|
||||
info: {
|
||||
configured: false,
|
||||
strategy: "disabled",
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function api<T = unknown>(url: string, init: ApiRequestInit = {}):
|
||||
}
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
error?.message || `请求失败(${response.status})`,
|
||||
error?.message || (response.status >= 500 ? "服务器暂时繁忙,请稍后重试" : "操作未能完成,请稍后重试"),
|
||||
error?.code,
|
||||
error?.details,
|
||||
error?.requestId,
|
||||
@@ -108,7 +108,7 @@ export async function api<T = unknown>(url: string, init: ApiRequestInit = {}):
|
||||
if (caught instanceof ApiError) throw caught;
|
||||
if (timedOut) throw new ApiError(408, "请求超时,请稍后重试", "REQUEST_TIMEOUT");
|
||||
if (externalSignal?.aborted) throw new ApiError(0, "请求已取消", "REQUEST_CANCELED");
|
||||
throw new ApiError(0, "网络连接失败,请确认服务仍在运行");
|
||||
throw new ApiError(0, "网络连接异常,请检查网络后重试");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener("abort", abortFromCaller);
|
||||
|
||||
Reference in New Issue
Block a user