Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c791dc4915 | ||
|
|
b46a7ddc87 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.33",
|
||||
"version": "1.1.35",
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,10 +85,10 @@ export default function AdminsPage({ currentAdmin, timezone = "Asia/Shanghai", n
|
||||
<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); }}>
|
||||
<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) {
|
||||
|
||||
@@ -64,9 +64,9 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC
|
||||
{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>>) {
|
||||
|
||||
@@ -126,6 +126,6 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
|
||||
{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>;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function TrashPage({ timezone = "Asia/Shanghai", notify }: { time
|
||||
<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(""); } }}>
|
||||
<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" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+390
-30
@@ -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,
|
||||
@@ -639,35 +679,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tn-metric-card .t-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
min-height: 104px;
|
||||
}
|
||||
.tn-metric-content {
|
||||
width: 100%;
|
||||
}
|
||||
.tn-metric-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--tn-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tn-metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--tn-navy-900);
|
||||
font-family: "Plus Jakarta Sans Variable", sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.tn-metric-foot {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--tn-text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* Stepper Surface */
|
||||
.tn-stepper-surface {
|
||||
@@ -1107,3 +1119,351 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* System Update Console & Phase Styles */
|
||||
.tn-release-version-title {
|
||||
margin: 4px 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--tn-navy-900);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tn-release-nickname {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--tn-text-secondary);
|
||||
}
|
||||
.tn-release-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--tn-text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tn-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.tn-meta-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--td-brand-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tn-meta-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* In-Page Release Notes Container */
|
||||
.tn-release-notes-surface {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
.tn-release-notes-box {
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
background: var(--tn-surface-alt, #f8fafc);
|
||||
border: 1px solid var(--tn-border);
|
||||
border-left: 3px solid var(--td-brand-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tn-release-notes-content {
|
||||
font-size: 13.5px;
|
||||
color: var(--tn-text);
|
||||
line-height: 1.7;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--td-brand-color-3) transparent;
|
||||
}
|
||||
|
||||
/* Upgrade Modal Phases */
|
||||
.tn-upgrade-phase-confirm {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.tn-confirm-phase-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tn-confirm-phase-head h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--tn-navy-900);
|
||||
}
|
||||
.tn-confirm-phase-head p {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.tn-upgrade-safety-tips {
|
||||
background: rgba(23, 92, 211, 0.04);
|
||||
border: 1px solid rgba(23, 92, 211, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--tn-navy-900);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tn-modal-actions-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.tn-staged-apply-box {
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
.tn-staged-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.tn-upgrade-phase-completed {
|
||||
padding: 24px 0 12px;
|
||||
}
|
||||
.tn-completed-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
background: rgba(47, 125, 92, 0.06);
|
||||
border: 1px solid rgba(47, 125, 92, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 18px 22px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tn-completed-banner h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e6043;
|
||||
}
|
||||
.tn-completed-banner p {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--tn-text);
|
||||
}
|
||||
.tn-upgrade-phase-failed {
|
||||
padding: 16px 0;
|
||||
}
|
||||
.tn-modal-footer-close {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 18px;
|
||||
border-top: 1px dashed var(--tn-border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
ASCII Layout Accurate Styles for Update Center
|
||||
============================================================ */
|
||||
|
||||
/* 4 Metric Cards Grid - Strictly Fixed Height & Zero Layout Shift */
|
||||
.tn-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.tn-metric-card {
|
||||
height: 104px !important;
|
||||
min-height: 104px !important;
|
||||
max-height: 104px !important;
|
||||
box-sizing: border-box !important;
|
||||
padding: 0 !important;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--tn-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.tn-metric-card .t-card__body {
|
||||
height: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: space-between !important;
|
||||
padding: 14px 18px !important;
|
||||
}
|
||||
.tn-metric-label {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
color: var(--tn-text-secondary);
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.tn-metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--tn-navy-900);
|
||||
line-height: 1.2;
|
||||
margin: 4px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tn-metric-card.tn-metric-date .tn-metric-value {
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
.tn-metric-foot {
|
||||
font-size: 12px;
|
||||
color: var(--tn-text-secondary);
|
||||
height: 20px !important;
|
||||
min-height: 20px !important;
|
||||
max-height: 20px !important;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Container 5: Release Details (Exact ASCII: 2 rows, 3 columns) */
|
||||
.tn-ascii-release-container {
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--tn-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tn-ascii-release-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.tn-ascii-release-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--tn-navy-900);
|
||||
}
|
||||
.tn-ascii-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tn-ascii-info-item {
|
||||
font-size: 13.5px;
|
||||
color: var(--tn-text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.tn-ascii-info-label {
|
||||
color: var(--tn-text-secondary);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.tn-ascii-info-val {
|
||||
font-weight: 600;
|
||||
color: var(--tn-navy-900);
|
||||
}
|
||||
.tn-ascii-release-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid #edf1f5;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
/* Container 6: In-page Release Notes Card */
|
||||
.tn-ascii-notes-container {
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 24px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--tn-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tn-ascii-notes-head {
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.tn-ascii-notes-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--tn-navy-900);
|
||||
}
|
||||
.tn-ascii-notes-body {
|
||||
font-size: 13.5px;
|
||||
color: var(--tn-text);
|
||||
line-height: 1.75;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Modal Inner Card matching ASCII */
|
||||
.tn-modal-card-box {
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--tn-border);
|
||||
border-radius: 4px;
|
||||
padding: 20px 24px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.tn-modal-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--tn-navy-900);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.tn-modal-metrics-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--tn-text-secondary);
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eef2f6;
|
||||
}
|
||||
.tn-modal-metrics-item strong {
|
||||
color: var(--tn-navy-900);
|
||||
margin-left: 4px;
|
||||
}
|
||||
.tn-modal-info-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--tn-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 12px 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Compact Floating Mock Switcher at bottom-right instead of giant top banner */
|
||||
.tn-dev-mock-dock {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 999;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--td-brand-color);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user