Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46a7ddc87 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.33",
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
@@ -588,6 +604,17 @@ export default function UpdatePage({
|
||||
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"
|
||||
@@ -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)}
|
||||
@@ -805,6 +834,26 @@ 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>正在从官方渠道下载更新安装包,后台安全传输中,不影响当前系统的正常使用。</span>
|
||||
)}
|
||||
@@ -1001,6 +1050,8 @@ export default function UpdatePage({
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog
|
||||
visible={Boolean(confirmVersion)}
|
||||
className="tn-dialog-medium"
|
||||
width="600px"
|
||||
header={confirmAction === "download" ? "确认下载更新包" : "确认立即应用更新并重启服务"}
|
||||
confirmBtn={{
|
||||
content: confirmAction === "download" ? "确认下载" : "立即更新",
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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