feat(web): W3-1 login, session shell, home dashboard, settings, engine panel and audit pages

- /login: setup/login/TOTP states driven by server error codes, fixed-height error slot
- console layout: GET /auth/session gate, redirect to /login, account popover (change password, logout)
- home: six dashboard sections with SSE-driven refresh, automation switch and read-only reconcile toggle
- settings tabs: downloaders, media servers, library roots, path mappings, site credentials,
  API keys (one-time plaintext), Pi upstream, cache/organize/retention/budget, automation
- engine: runs list (status/name filters, cursor paging, stats tiles), run detail (step timeline,
  json payloads, signals, cancel)
- audit: filterable list with detail sheet
- vite: allow repo root in server.fs so deno-installed deps under node_modules/.deno load in dev

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:40:41 +08:00
1 parent ef73abe3df
commit 0dec0854df
21 files changed
+3905 -57

No files matched your search

+216 -4
View File
@@ -1,6 +1,218 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import { useQueryClient } from "@tanstack/react-query";
import type { EngineRun } from "@umt/contracts";
import { Ban, CheckCircle2, Clock, Cpu, LoaderCircle, RotateCw, XCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useEngineRuns, useEngineStats } from "~/api/engine.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
export default function Page() {
return <WorkspacePlaceholder />;
export const meta = () => [{ title: "工作流引擎 · UHD 媒体自动化控制台" }];
const STAT_ORDER = [
{ status: "pending", label: "待执行", icon: Clock, tone: "neutral" },
{ status: "running", label: "运行中", icon: LoaderCircle, tone: "neutral" },
{ status: "completed", label: "完成", icon: CheckCircle2, tone: "success" },
{ status: "failed", label: "失败", icon: XCircle, tone: "destructive" },
{ status: "canceled", label: "已取消", icon: Ban, tone: "warning" },
] as const;
const STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled", "sleeping", "succeeded"].map((s) => ({
value: s,
label: engineRunLabel(s).label,
}));
/** 持续任务事件到达时节流刷新引擎视图(引擎本身不发事件,业务任务事件足以代表它在动) */
const ENGINE_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"organization.changed",
"agent.run_status",
];
function useDebounced<T>(value: T, ms = 350): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export default function EnginePage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const status = params.get("status") ?? "";
const [name, setName] = useState(params.get("workflow") ?? "");
const workflowName = useDebounced(name.trim());
useEffect(() => {
setParams(
(p) => {
if (workflowName) p.set("workflow", workflowName);
else p.delete("workflow");
return p;
},
{ replace: true },
);
}, [workflowName, setParams]);
const setStatus = (s: string) =>
setParams(
(p) => {
if (s) p.set("status", s);
else p.delete("status");
return p;
},
{ replace: true },
);
const runs = useEngineRuns({ status: status || undefined, workflowName: workflowName || undefined });
const stats = useEngineStats();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(ENGINE_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
const rows = runs.data?.pages.flatMap((p) => p.items);
const s = stats.data;
return (
<>
<PageHeader
title="工作流引擎"
description="持久任务引擎的运行记录,只读诊断用;业务进度请看「下载与任务」。"
actions={
<Button
variant="outline"
size="sm"
onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}
disabled={runs.isFetching}
>
<RotateCw className={runs.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
/>
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{STAT_ORDER.map((t) => (
<StatTile
key={t.status}
label={t.label}
icon={t.icon}
tone={(s?.byStatus[t.status] ?? 0) > 0 ? t.tone : "neutral"}
value={stats.isPending ? "…" : stats.isError ? "—" : (s?.byStatus[t.status] ?? 0)}
active={status === t.status}
onClick={() => setStatus(status === t.status ? "" : t.status)}
/>
))}
</div>
<div className="mb-5 flex flex-wrap gap-x-5 gap-y-1 text-meta text-muted-foreground">
<span>
最老待执行:
{s?.oldestPending ? (
<button
type="button"
className="cursor-pointer text-foreground underline-offset-2 hover:underline"
onClick={() => navigate(consolePath("engine", s.oldestPending?.id ?? ""))}
>
{s.oldestPending.workflowName} · 已等 {formatDuration(s.oldestPending.ageSec)}
</button>
) : (
"无"
)}
</span>
<span className="inline-flex items-center gap-1.5">
<Cpu className="h-3.5 w-3.5" />
持有租约的 worker:
{s && s.activeWorkers.length > 0
? s.activeWorkers.map((w) => `${w.workerId}(${w.running})`).join("、")
: "无"}
<span className="text-muted-foreground/80">(快照,非权威注册表)</span>
</span>
</div>
<FilterBar
search={{ value: name, onChange: setName, placeholder: "按工作流名称筛选" }}
onReset={
status || name
? () => {
setName("");
setStatus("");
}
: undefined
}
>
<FilterSelect label="状态" value={status} onChange={setStatus} options={STATUS_OPTIONS} />
</FilterBar>
<DataTable<EngineRun>
rows={rows}
rowKey={(r) => r.id}
loading={runs.isPending}
error={runs.error}
onRetry={() => runs.refetch()}
empty={{
title: status || workflowName ? "没有符合条件的 run" : "引擎还没有运行记录",
description: status || workflowName ? "换个筛选条件试试。" : "有任务入队后会出现在这里。",
}}
onRowClick={(r) => navigate(consolePath("engine", r.id))}
hasMore={!!runs.hasNextPage}
onLoadMore={() => runs.fetchNextPage()}
loadingMore={runs.isFetchingNextPage}
columns={[
{
key: "status",
header: "状态",
cell: (r) => {
const l = engineRunLabel(r.status);
return <StatusBadge tone={l.tone}>{l.label}</StatusBadge>;
},
},
{
key: "name",
header: "工作流",
cell: (r) => (
<div className="min-w-0 max-w-[16rem] sm:max-w-none">
<p className="truncate font-medium">
{r.workflowName}
{r.version ? <span className="ml-1.5 text-meta text-muted-foreground">{r.version}</span> : null}
</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.id}</p>
</div>
),
},
{ key: "attempts", header: "尝试", hideBelow: "md", align: "right", cell: (r) => r.attempts },
{
key: "dur",
header: "耗时",
hideBelow: "sm",
cell: (r) => <span className="tabular-nums">{formatDuration(runDurationSec(r))}</span>,
},
{
key: "created",
header: "创建",
hideBelow: "md",
cell: (r) => <span className="whitespace-nowrap text-muted-foreground">{formatRelative(r.createdAt)}</span>,
},
]}
/>
</>
);
}