Compare commits

...
5 Commits
Author SHA1 Message Date
Qiufeng b46a7ddc87 fix: 优化电脑端弹窗尺寸并彻底修复系统更新排队调度卡死问题
TallyNote release / linux-x64 (push) Successful in 6m46s
2026-09-04 14:24:54 +08:00
Qiufeng 1ecb783d0c chore(release): 1.1.33 - 全面重塑系统商务与专业化文案
TallyNote release / linux-x64 (push) Successful in 7m1s
2026-09-04 14:01:58 +08:00
Qiufeng 60c0519ac7 fix: run release tests in one thread
TallyNote release / linux-x64 (push) Successful in 7m50s
2026-09-04 13:19:34 +08:00
Qiufeng 32a73c5b50 release: 1.1.31 with detailed release notes
TallyNote release / linux-x64 (push) Failing after 9m7s
2026-09-04 13:06:44 +08:00
Qiufeng 45de0ef759 fix: use stable vitest thread pool in releases
TallyNote release / linux-x64 (push) Failing after 8m51s
2026-09-04 12:55:17 +08:00
22 changed files with 243 additions and 77 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
# better-sqlite3 is a native addon; a single Vitest worker avoids a
# Node cleanup race observed on the hosted runner while preserving
# the complete test suite.
pnpm test -- --maxWorkers=1 --minWorkers=1
pnpm test -- --pool=threads --poolOptions.threads.singleThread=true
pnpm test:installer
- name: Build Linux release
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
+1 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.29",
"version": "1.1.34",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+12
View File
@@ -62,6 +62,7 @@ import {
reconcileOrphanedUpdateJobs,
readCachedRelease,
writeUpdateRequest,
cancelUpdateJob,
type UpdateRequest,
} from "./update-service.js";
@@ -1182,6 +1183,17 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } });
});
app.post("/api/update/cancel", { preHandler: guard(database, config) }, async (request, reply) => {
reconcileOrphanedUpdateJobs(database.sqlite, config);
const body = (request.body && typeof request.body === "object" ? request.body : {}) as { jobId?: string };
const result = cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId);
if (!result.cancelled) {
throw new AppError(409, "CANNOT_CANCEL", result.message || "无法取消当前更新任务");
}
reply.header("Cache-Control", "no-store");
return reply.send({ success: true, message: "已取消更新任务" });
});
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
reconcileOrphanedUpdateJobs(database.sqlite, config);
+50 -3
View File
@@ -38,6 +38,7 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
// a lease while doing long downloads/backups; only an expired lease permits
// the server to reclaim an active row.
export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000;
export const QUEUED_UPDATE_TIMEOUT_MS = 25 * 1000;
export type CachedRelease = {
checkedAt: number;
@@ -384,6 +385,14 @@ function markerMtime(filePath: string): number | null {
}
}
function forceRemoveRequest(filePath: string): void {
try {
const info = lstatSync(filePath);
if (!info.isFile() && !info.isSymbolicLink()) return;
unlinkSync(filePath);
} catch {}
}
function removeExpiredRequest(filePath: string, now: number): void {
try {
const info = lstatSync(filePath);
@@ -521,10 +530,48 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
&& !reconciledIds.has(queuedRequest.id)
&& !(queuedRequest.status === "staged" && queuedRequest.operation === "download"),
);
if (!stateFresh
&& (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS))
&& !requestStillNeeded) {
if (!stateFresh && !requestStillNeeded) {
forceRemoveRequest(config.updateRequestPath);
} else if (!stateFresh && (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS))) {
removeExpiredRequest(config.updateRequestPath, now);
}
return reconciled;
}
export function cancelUpdateJob(
database: Database.Database,
config: AppConfig,
adminId: string,
requestId: string,
jobId?: string,
): { cancelled: boolean; message?: string } {
const job = jobId
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null } | undefined
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId FROM update_jobs WHERE status='queued' ORDER BY created_at DESC LIMIT 1").get() as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null } | undefined;
if (!job) return { cancelled: false, message: "当前没有处于等待调度的更新任务" };
if (job.status !== "queued") return { cancelled: false, message: "任务已开始处理,无法取消" };
const now = Date.now();
const changed = database.transaction(() => {
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新排队', completed_at=?, updated_at=? WHERE id=? AND status='queued'").run(now, now, job.id);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId,
actorAdminId: adminId,
action: "update.cancelled",
targetType: "update",
targetId: job.id,
outcome: "success",
before: { status: job.status, operation: job.operation, version: job.version },
after: { status: "cancelled", version: job.version },
});
return true;
})();
if (changed) {
forceRemoveRequest(config.updateRequestPath);
return { cancelled: true };
}
return { cancelled: false, message: "取消失败,任务状态可能已改变" };
}
-3
View File
@@ -16,8 +16,6 @@ Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
# enough to finish or reach its own health-check/recovery path.
TimeoutStartSec=30min
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
# Keep the updater compatible with the same Node/libuv interface discovery
# path while retaining an explicit socket-family allowlist.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
@@ -28,7 +26,6 @@ ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
LockPersonality=true
RestrictRealtime=true
+18 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chmodSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
@@ -190,4 +190,21 @@ describe("更新 API", () => {
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
expect(audit?.outcome).toBe("failure");
});
it("管理员可主动取消排队中的更新任务并清理请求文件", async () => {
const session = await login("update-cancel");
mockRelease();
await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
expect(applied.statusCode).toBe(202);
expect(existsSync(config.updateRequestPath)).toBe(true);
const cancelRes = await app.inject({ method: "POST", url: "/api/update/cancel", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(cancelRes.statusCode).toBe(200);
expect(cancelRes.json().success).toBe(true);
expect(existsSync(config.updateRequestPath)).toBe(false);
const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(statusRes.statusCode).toBe(200);
expect(statusRes.json().job).toBeNull();
});
});
+4 -4
View File
@@ -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() {
+5 -5
View File
@@ -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" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"}
<Dialog width="540px" 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" ? "停用后该管理员将无法访问系统,已登录的会话会立即注销。" : "启用后该管理员可恢复系统访问并正常登录。"}
</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 width="540px" 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) {
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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={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>
<Dialog width="540px" 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 width="540px" 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 width="560px" 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 width="880px" className="tn-dialog-xlarge" 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 width="560px" 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>>) {
+4 -4
View File
@@ -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 width="540px" 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>;
}
+3 -3
View File
@@ -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>
<Dialog width="540px" 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>}
+81 -28
View File
@@ -274,6 +274,22 @@ export default function UpdatePage({
const [reloadReady, setReloadReady] = useState(false);
const [showNotesDialog, setShowNotesDialog] = useState(false);
const [showPipelineDialog, setShowPipelineDialog] = useState(false);
const [cancelling, setCancelling] = useState(false);
const cancelJob = async () => {
if (cancelling) return;
setCancelling(true);
try {
await api("/api/update/cancel", { method: "POST", body: JSON.stringify({ jobId: job?.id }) });
notify?.("已取消更新任务", "success");
setShowPipelineDialog(false);
await load();
} catch (e) {
notify?.((e as Error).message, "error");
} finally {
setCancelling(false);
}
};
const [now, setNow] = useState(() => Date.now());
const announced = useRef<string | null>(null);
@@ -585,9 +601,20 @@ export default function UpdatePage({
<Page
eyebrow="运维与维护"
title="系统更新中心"
subtitle="管理 TallyNote 本地账本版本升级、下载验证与安全平滑重启"
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
actions={
<div className="tn-update-page-actions">
{job?.status === "queued" && (
<Button
theme="default"
variant="outline"
onClick={() => void cancelJob()}
loading={cancelling}
disabled={cancelling}
>
取消等待
</Button>
)}
<Button
theme="default"
variant="outline"
@@ -710,7 +737,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 +749,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>
@@ -732,6 +759,8 @@ export default function UpdatePage({
<Dialog
visible={showPipelineDialog}
header="版本升级流水线"
className="tn-dialog-large"
width="820px"
confirmBtn={{ content: "关闭", theme: "default" }}
cancelBtn={null}
onConfirm={() => setShowPipelineDialog(false)}
@@ -741,7 +770,7 @@ export default function UpdatePage({
<div className="tn-stepper-head">
<div>
<small className="tn-section-subheading">
标准化发布流程:版本发现 → 校验签名与清单 → 安全暂存 → 原子切换并重启
标准升级流程:获取版本信息 → 校验完整性 → 下载更新包 → 自动应用并安全重启
</small>
</div>
{job && (
@@ -761,10 +790,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>
@@ -805,20 +834,40 @@ export default function UpdatePage({
{/* Status-specific descriptions */}
<div className="tn-job-runtime-desc">
{job.status === "queued" && (
<div className="tn-queued-runtime-notice">
<div className="tn-queued-header">
<span>正在等待后台系统更新守护进程认领任务…</span>
<Button
theme="default"
variant="outline"
size="small"
onClick={() => void cancelJob()}
loading={cancelling}
disabled={cancelling}
>
取消排队
</Button>
</div>
<small className="text-secondary">
若系统超过 20 秒仍未响应,通常是后台守护服务尚未就绪,您可以点击取消排队。
</small>
</div>
)}
{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 +875,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 +900,7 @@ export default function UpdatePage({
{job?.status === "completed" && (
<div className="tn-inline-info">
<CheckCircle2 size={16} />
<span>更新成功完成!新版本已通过内置端点健康检查,账本数据与附件完整无损。</span>
<span>系统已成功升级至最新版本,数据与附件均已完整保留。</span>
</div>
)}
</div>
@@ -885,7 +934,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 +957,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 +995,7 @@ export default function UpdatePage({
)}
{reloadReady && (
<Button theme="success" size="large" onClick={() => window.location.reload()}>
重新加载控制台
刷新页面
</Button>
)}
</div>
@@ -955,7 +1004,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 +1037,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 +1050,9 @@ export default function UpdatePage({
{/* Confirmation Dialog */}
<Dialog
visible={Boolean(confirmVersion)}
header={confirmAction === "download" ? "确认下载更新包" : "确认立即更新并平滑重启"}
className="tn-dialog-medium"
width="600px"
header={confirmAction === "download" ? "确认下载更新包" : "确认立即应用更新并重启服务"}
confirmBtn={{
content: confirmAction === "download" ? "确认下载" : "立即更新",
theme: "primary",
@@ -1021,21 +1072,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>
</>
)}
@@ -1045,6 +1096,8 @@ export default function UpdatePage({
{/* Full Release Notes Dialog */}
<Dialog
visible={showNotesDialog}
className="tn-dialog-large"
width="820px"
header={`${latest?.releaseName || latest?.tagName || `v${latest?.version}`} 更新日志`}
cancelBtn={null}
confirmBtn={{ content: "关闭", theme: "default" }}
+3 -3
View File
@@ -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",
+2 -2
View File
@@ -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);
+41 -1
View File
@@ -409,7 +409,47 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
@keyframes tn-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes tn-app-enter { from { opacity: 0; } to { opacity: 1; } }
@keyframes tn-auth-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.t-dialog { border: 1px solid var(--tn-border); border-radius: 4px; box-shadow: 0 18px 42px rgba(8, 47, 118, .18); }
.t-dialog {
border: 1px solid var(--tn-border);
border-radius: 4px;
box-shadow: 0 18px 42px rgba(8, 47, 118, .18);
width: 560px;
max-width: calc(100vw - 32px);
}
@media (min-width: 901px) {
.t-dialog {
width: 580px;
}
.t-dialog.tn-dialog-medium {
width: 680px !important;
}
.t-dialog.tn-dialog-large {
width: 820px !important;
}
.t-dialog.tn-dialog-xlarge {
width: 960px !important;
}
}
.tn-queued-runtime-notice {
background: var(--tn-surface);
border: 1px solid var(--tn-border);
border-radius: 4px;
padding: 12px 14px;
margin-top: 12px;
}
.tn-queued-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
font-weight: 500;
color: var(--tn-navy-900);
}
.tn-queued-header span {
font-size: 13.5px;
}
/* TDesign Dialog global no-icon & pure baseline typography normalization */
.t-dialog__header .t-icon:not(.t-icon-close),
.t-dialog__body .t-icon,