Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46a7ddc87 | ||
|
|
1ecb783d0c | ||
|
|
60c0519ac7 | ||
|
|
32a73c5b50 | ||
|
|
45de0ef759 |
@@ -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
@@ -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.29",
|
||||
"version": "1.1.34",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "取消失败,任务状态可能已改变" };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"}
|
||||
<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) {
|
||||
|
||||
@@ -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={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>>) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user