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:
1 parent
ef73abe3df
commit
0dec0854df
21 files changed
+3905
-57
No files matched your search
@@ -1,6 +1,219 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { AuditAction, type AuditEvent } from "@umt/contracts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useAuditEvents } from "~/api/audit.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
export const meta = () => [{ title: "审计日志 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const ACTOR_TYPES = [
|
||||
{ value: "user", label: "管理员" },
|
||||
{ value: "api_key", label: "API Key" },
|
||||
{ value: "agent", label: "Agent" },
|
||||
{ value: "system", label: "系统" },
|
||||
];
|
||||
const actorLabel = (t: string) => ACTOR_TYPES.find((a) => a.value === t)?.label ?? t;
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
"admin.setup": "初始化管理员",
|
||||
"admin.login_success": "登录",
|
||||
"admin.logout": "登出",
|
||||
"admin.password_change": "修改密码",
|
||||
"admin.totp_enable": "启用二次验证",
|
||||
"admin.totp_disable": "停用二次验证",
|
||||
"api_key.create": "创建 API Key",
|
||||
"api_key.update": "修改 API Key",
|
||||
"api_key.revoke": "撤销 API Key",
|
||||
"settings.update": "修改设置",
|
||||
"secret.create": "新建凭证",
|
||||
"secret.rotate": "轮换凭证",
|
||||
"secret.delete": "删除凭证",
|
||||
"site_account.credentials_set": "录入站点凭证",
|
||||
"engine.run_cancel": "取消引擎 run",
|
||||
};
|
||||
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
|
||||
const NOUN: Record<string, string> = {
|
||||
library_root: "库根",
|
||||
downloader_instance: "下载器",
|
||||
media_server_instance: "媒体服务器",
|
||||
path_mapping: "路径映射",
|
||||
};
|
||||
function actionLabel(a: string): string {
|
||||
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
|
||||
const [noun, verb] = a.split(".");
|
||||
if (noun && verb && NOUN[noun] && VERB[verb]) return `${VERB[verb]}${NOUN[noun]}`;
|
||||
return a;
|
||||
}
|
||||
|
||||
// 前缀组 + 具体动作
|
||||
const ACTION_OPTIONS = [
|
||||
...[
|
||||
"admin",
|
||||
"api_key",
|
||||
"settings",
|
||||
"secret",
|
||||
"library_root",
|
||||
"downloader_instance",
|
||||
"media_server_instance",
|
||||
"path_mapping",
|
||||
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
|
||||
...AuditAction.options.map((a) => ({ value: a, label: `${actionLabel(a)} · ${a}` })),
|
||||
];
|
||||
|
||||
export default function AuditPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const actorType = params.get("actorType") ?? "";
|
||||
const action = params.get("action") ?? "";
|
||||
const [target, setTarget] = useState(params.get("targetType") ?? "");
|
||||
const [targetType, setTargetType] = useState(target);
|
||||
const [selected, setSelected] = useState<AuditEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
const v = target.trim();
|
||||
setTargetType(v);
|
||||
setParams(
|
||||
(p) => {
|
||||
if (v) p.set("targetType", v);
|
||||
else p.delete("targetType");
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [target, setParams]);
|
||||
|
||||
const setParam = (k: string, v: string) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
if (v) p.set(k, v);
|
||||
else p.delete(k);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const q = useAuditEvents({
|
||||
actorType: actorType || undefined,
|
||||
action: action || undefined,
|
||||
targetType: targetType || undefined,
|
||||
});
|
||||
const rows = q.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = !!(actorType || action || targetType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="审计日志"
|
||||
description="登录、设置、密钥与实例变更的操作留痕,新的在前。凭证内容不会写进审计。"
|
||||
/>
|
||||
<FilterBar
|
||||
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setTarget("");
|
||||
setParams({}, { replace: true });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="操作者"
|
||||
value={actorType}
|
||||
onChange={(v) => setParam("actorType", v)}
|
||||
options={ACTOR_TYPES}
|
||||
/>
|
||||
<FilterSelect label="动作" value={action} onChange={(v) => setParam("action", v)} options={ACTION_OPTIONS} />
|
||||
</FilterBar>
|
||||
<DataTable<AuditEvent>
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={q.isPending}
|
||||
error={q.error}
|
||||
onRetry={() => q.refetch()}
|
||||
empty={{
|
||||
title: filtered ? "没有符合条件的审计事件" : "还没有审计事件",
|
||||
description: filtered ? "换个筛选条件试试。" : undefined,
|
||||
}}
|
||||
onRowClick={setSelected}
|
||||
selectedKey={selected?.id ?? null}
|
||||
hasMore={!!q.hasNextPage}
|
||||
onLoadMore={() => q.fetchNextPage()}
|
||||
loadingMore={q.isFetchingNextPage}
|
||||
columns={[
|
||||
{
|
||||
key: "at",
|
||||
header: "时间",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap tabular-nums" title={formatDateTime(r.at)}>
|
||||
{formatRelative(r.at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: "动作",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{actionLabel(r.action)}</p>
|
||||
<p className="font-mono text-meta text-muted-foreground">{r.action}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "actor", header: "操作者", hideBelow: "sm", cell: (r) => actorLabel(r.actorType) },
|
||||
{
|
||||
key: "target",
|
||||
header: "对象",
|
||||
hideBelow: "md",
|
||||
cell: (r) =>
|
||||
r.targetType ? (
|
||||
<div className="min-w-0 max-w-[18rem]">
|
||||
<p className="truncate">{r.targetType}</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground">{r.targetId}</p>
|
||||
</div>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DetailSheet
|
||||
open={!!selected}
|
||||
onOpenChange={(o) => !o && setSelected(null)}
|
||||
title={selected ? actionLabel(selected.action) : ""}
|
||||
description={selected ? formatDateTime(selected.at) : undefined}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<DetailSection title="事件">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "动作", value: selected.action, mono: true },
|
||||
{
|
||||
label: "操作者",
|
||||
value: `${actorLabel(selected.actorType)}${selected.actorId ? ` · ${selected.actorId}` : ""}`,
|
||||
wide: true,
|
||||
},
|
||||
{ label: "对象类型", value: selected.targetType },
|
||||
{ label: "对象 ID", value: selected.targetId, mono: true },
|
||||
{ label: "Trace", value: selected.traceId, mono: true },
|
||||
{ label: "事件 ID", value: selected.id, mono: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<JsonView label="负载" value={selected.payload} />
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,233 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { EngineStepAttempt } from "@umt/contracts";
|
||||
import { ArrowLeft, Ban, RotateCw } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { ENGINE_TERMINAL, useCancelEngineRun, useEngineRun } from "~/api/engine.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
export const meta = () => [{ title: "Run 详情 · 工作流引擎" }];
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
function: "函数",
|
||||
sleep: "休眠",
|
||||
workflow: "子工作流",
|
||||
"signal-send": "发送信号",
|
||||
"signal-wait": "等待信号",
|
||||
};
|
||||
|
||||
function stepTone(s: string) {
|
||||
if (s === "completed" || s === "succeeded") return "success" as const;
|
||||
if (s === "failed") return "destructive" as const;
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
function stepItems(steps: EngineStepAttempt[]): TimelineItem[] {
|
||||
const seen = new Map<string, number>();
|
||||
return steps.map((st) => {
|
||||
const n = (seen.get(st.stepName) ?? 0) + 1;
|
||||
seen.set(st.stepName, n);
|
||||
const status =
|
||||
st.status === "running"
|
||||
? "运行中"
|
||||
: st.status === "failed"
|
||||
? "失败"
|
||||
: st.status === "completed" || st.status === "succeeded"
|
||||
? "完成"
|
||||
: st.status;
|
||||
const hasError = st.error !== null && st.error !== undefined;
|
||||
return {
|
||||
id: st.id,
|
||||
at: st.startedAt ?? st.createdAt,
|
||||
tone: stepTone(st.status),
|
||||
title: (
|
||||
<span>
|
||||
{st.stepName}
|
||||
<span className="ml-2 text-meta font-normal text-muted-foreground">
|
||||
{KIND_LABEL[st.kind] ?? st.kind} · {status}
|
||||
{n > 1 ? ` · 第 ${n} 次` : ""}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
meta: formatDuration(runDurationSec(st)),
|
||||
description: (
|
||||
<>
|
||||
{st.waitSignal ? (
|
||||
<p>
|
||||
等待信号 <code className="font-mono">{st.waitSignal}</code>
|
||||
{st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{st.childWorkflowRunId ? (
|
||||
<p>
|
||||
子工作流{" "}
|
||||
<Link to={consolePath("engine", st.childWorkflowRunId)} className="font-mono underline">
|
||||
{st.childWorkflowRunId}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="font-mono text-meta">{st.id}</p>
|
||||
</>
|
||||
),
|
||||
children: hasError ? (
|
||||
<JsonView label="错误" value={st.error} tone="destructive" defaultOpen />
|
||||
) : st.output !== null && st.output !== undefined ? (
|
||||
<JsonView label="输出" value={st.output} defaultOpen={false} maxHeight="14rem" />
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function EngineRunPage() {
|
||||
const { runId } = useParams();
|
||||
const q = useEngineRun(runId);
|
||||
const cancel = useCancelEngineRun();
|
||||
|
||||
const back = (
|
||||
<Link
|
||||
to={consolePath("engine")}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm", className: "-ml-2 mb-2" })}
|
||||
>
|
||||
<ArrowLeft />
|
||||
返回 runs
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (q.isPending) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<LoadingState rows={8} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (q.isError) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<ErrorState title="加载 run 失败" error={q.error} onRetry={() => q.refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const r = q.data;
|
||||
const st = engineRunLabel(r.status);
|
||||
const terminal = ENGINE_TERMINAL.has(r.status);
|
||||
const hasError = r.error !== null && r.error !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<PageHeader
|
||||
title={r.workflowName}
|
||||
description={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
|
||||
{r.version ? <span>{r.version}</span> : null}
|
||||
<span className="font-mono text-meta break-all">{r.id}</span>
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => q.refetch()} disabled={q.isFetching}>
|
||||
<RotateCw className={q.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
{!terminal ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm">
|
||||
<Ban />
|
||||
取消 run
|
||||
</Button>
|
||||
}
|
||||
title="取消这个 run?"
|
||||
description="先标记业务侧取消,再通知引擎停止;正在执行的步骤会在下一个检查点退出。"
|
||||
confirmLabel="取消 run"
|
||||
cancelLabel="不取消"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" });
|
||||
if (res.canceled) toast.success("已取消");
|
||||
else toast.warning("run 已结束,无需取消");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "取消失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<section className="pg-slab rounded-xl p-5">
|
||||
<DetailSection title="概览">
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
label: "开始",
|
||||
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
|
||||
},
|
||||
{ label: "耗时", value: formatDuration(runDurationSec(r)) },
|
||||
{ label: "结束", value: formatDateTime(r.finishedAt) },
|
||||
{ label: "尝试次数", value: r.attempts },
|
||||
{ label: "可执行于", value: formatDateTime(r.availableAt) },
|
||||
{ label: "截止", value: formatDateTime(r.deadlineAt) },
|
||||
{ label: "Worker", value: r.workerId, mono: true },
|
||||
{ label: "父步骤", value: r.parentStepAttemptId, mono: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
</section>
|
||||
<section className="pg-slab space-y-4 rounded-xl p-5">
|
||||
{hasError ? <JsonView label="错误" value={r.error} tone="destructive" /> : null}
|
||||
<JsonView label="输入" value={r.input} defaultOpen={!hasError} />
|
||||
{!hasError ? <JsonView label="输出" value={r.output} /> : null}
|
||||
</section>
|
||||
<section className="pg-slab rounded-xl p-5">
|
||||
<DetailSection title={`信号(${r.signals.length})`}>
|
||||
{r.signals.length === 0 ? (
|
||||
<p className="text-caption text-muted-foreground">没有投递到这个 run 的信号。</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{r.signals.map((sg) => (
|
||||
<li key={sg.id} className="min-w-0">
|
||||
<p className="text-sm">
|
||||
<code className="font-mono">{sg.signal}</code>
|
||||
<span className="ml-2 text-meta text-muted-foreground">{formatDateTime(sg.createdAt)}</span>
|
||||
</p>
|
||||
<JsonView value={sg.data} maxHeight="10rem" className="mt-1" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DetailSection>
|
||||
</section>
|
||||
</div>
|
||||
<section className="pg-slab min-w-0 rounded-xl p-5">
|
||||
<DetailSection title={`步骤尝试(${r.stepAttempts.length})`}>
|
||||
{r.stepAttempts.length === 0 ? (
|
||||
<EmptyState plain title="还没有执行任何步骤" className="py-8" />
|
||||
) : (
|
||||
<Timeline items={stepItems(r.stepAttempts)} />
|
||||
)}
|
||||
</DetailSection>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,515 @@
|
||||
import { Activity, AlertTriangle, CircleCheck, HardDrive, ListTodo, Radio, Sparkles } from "lucide-react";
|
||||
import { useHealth } from "~/api/health.ts";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Dashboard } from "@umt/contracts";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CircleCheck,
|
||||
HardDrive,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
Pause,
|
||||
Play,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDashboard } from "~/api/dashboard.ts";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 { formatDateTime } from "~/lib/utils.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import {
|
||||
agentRunLabel,
|
||||
decisionKindLabel,
|
||||
downloadLabel,
|
||||
formatRelative,
|
||||
healthLabel,
|
||||
organizationActionLabel,
|
||||
organizationLabel,
|
||||
} from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
// 首页六区块(REQ §13.4.3),W3 接入正式状态投影
|
||||
const SECTIONS = [
|
||||
{ title: "自动化概览", icon: Sparkles, hint: "今日新增、升级与已核验入库" },
|
||||
{ title: "当前工作", icon: Activity, hint: "正在搜索/下载/整理/核验的目标" },
|
||||
{ title: "需要我处理", icon: AlertTriangle, hint: "根因级事项与剩余决策" },
|
||||
{ title: "缓存与保留", icon: HardDrive, hint: "使用率、在途预留与可清理量" },
|
||||
{ title: "来源健康", icon: Radio, hint: "站点、qB、Emby、模型上游" },
|
||||
{ title: "最近产出", icon: ListTodo, hint: "新增的集与版本、整理前后路径" },
|
||||
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
||||
const DASHBOARD_EVENTS = [
|
||||
"task.created",
|
||||
"task.status_changed",
|
||||
"download.submitted",
|
||||
"download.status_changed",
|
||||
"download.threshold",
|
||||
"organization.changed",
|
||||
"agent.run_status",
|
||||
"feed.fetched",
|
||||
"feed.health_changed",
|
||||
"decision.created",
|
||||
"decision.updated",
|
||||
"settings.changed",
|
||||
];
|
||||
|
||||
function ServiceStatus() {
|
||||
const { data, isPending, isError, error } = useHealth();
|
||||
if (isPending) return <StatusBadge>检查中</StatusBadge>;
|
||||
if (isError) return <StatusBadge tone="destructive">服务不可达:{error.message}</StatusBadge>;
|
||||
function useLiveDashboard() {
|
||||
const qc = useQueryClient();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useSse(DASHBOARD_EVENTS, () => {
|
||||
if (timer.current) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
qc.invalidateQueries({ queryKey: qk.dashboard });
|
||||
}, 1500);
|
||||
});
|
||||
return useDashboard();
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon: Icon,
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
to?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={data.status === "ok" ? "success" : "warning"}>
|
||||
服务{data.status === "ok" ? "正常" : "降级"}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={data.db === "ok" ? "success" : data.db === "down" ? "destructive" : "neutral"}>
|
||||
数据库 {data.db === "ok" ? "正常" : data.db === "down" ? "不可用" : "未知"}
|
||||
</StatusBadge>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
v{data.version} · {data.role} · {formatDateTime(data.time)}
|
||||
</span>
|
||||
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
||||
{to ? (
|
||||
<Link
|
||||
to={to}
|
||||
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
查看
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ children }: { children: ReactNode }) {
|
||||
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
|
||||
}
|
||||
|
||||
// ─── 自动化总开关:显眼状态 + 切换 ───
|
||||
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
||||
const update = useUpdateSettings();
|
||||
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
|
||||
try {
|
||||
await update.mutateAsync({ automation: patch });
|
||||
toast.success(ok);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "切换失败"));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const paused = !a.enabled;
|
||||
const readOnly = a.readOnlyReconcile;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
|
||||
(paused || readOnly) && "border border-warning/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
|
||||
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
|
||||
)}
|
||||
>
|
||||
{readOnly ? (
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
) : paused ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-lead font-semibold text-foreground">
|
||||
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
|
||||
</p>
|
||||
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
|
||||
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-caption text-muted-foreground">
|
||||
{paused
|
||||
? "自动收录、下载、整理都已停下;查询与手动操作仍可用。"
|
||||
: readOnly
|
||||
? "恢复后的只读对账:只核对不写入,确认无误后再关掉。"
|
||||
: `${a.activeSubscriptions} 个订阅在追踪,按规则自动收录、下载与入库。`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<div className="flex items-center gap-2 text-caption text-foreground">
|
||||
<label htmlFor="home-readonly">只读对账</label>
|
||||
<Switch
|
||||
id="home-readonly"
|
||||
checked={readOnly}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={(v) =>
|
||||
setAutomation({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式").catch(() => {})
|
||||
}
|
||||
aria-label="只读对账模式"
|
||||
/>
|
||||
</div>
|
||||
{paused ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
|
||||
>
|
||||
<Play />
|
||||
恢复自动化
|
||||
</Button>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={update.isPending}>
|
||||
<Pause />
|
||||
暂停自动化
|
||||
</Button>
|
||||
}
|
||||
title="暂停全部自动化?"
|
||||
description="自动收录、下载、整理会停下,已提交的下载任务不受影响。随时可以恢复。"
|
||||
confirmLabel="暂停"
|
||||
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview({ a }: { a: Dashboard["automation"] }) {
|
||||
const t = a.today;
|
||||
return (
|
||||
<Section title="自动化概览" icon={Sparkles} to={consolePath("subscriptions")}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatTile plain label="今日新计划" value={t.plansCreated} />
|
||||
<StatTile plain label="今日提交下载" value={t.downloadsSubmitted} />
|
||||
<StatTile plain label="今日入库文件" value={t.filesImported} />
|
||||
<StatTile plain label="今日升级" value={t.upgrades} />
|
||||
</div>
|
||||
<p className="mt-3 text-meta text-muted-foreground">
|
||||
整理成功 {t.recordsSucceeded} · 失败 {t.recordsFailed} · 满足的版本目标 {a.slotCounts.satisfied} · 需关注{" "}
|
||||
{a.slotCounts.attention}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
|
||||
const counts = [
|
||||
{ label: "下载中", v: w.downloading },
|
||||
{ label: "已暂停", v: w.paused },
|
||||
{ label: "无进展", v: w.stalled },
|
||||
{ label: "入库中", v: w.importing },
|
||||
];
|
||||
const idle = counts.every((c) => c.v === 0) && w.items.length === 0 && w.agentRuns.length === 0;
|
||||
return (
|
||||
<Section title="当前工作" icon={Activity} to={consolePath("tasks")}>
|
||||
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
|
||||
{counts.map((c) => (
|
||||
<span key={c.label}>
|
||||
{c.label} <span className="font-medium text-foreground tabular-nums">{c.v}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{idle ? (
|
||||
<EmptyState plain icon={CircleCheck} title="没有进行中的工作" className="py-6" />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{w.items.slice(0, 5).map((it) => {
|
||||
const s = downloadLabel(it.status);
|
||||
return (
|
||||
<Row key={it.downloadTaskId}>
|
||||
<Link to={consolePath("tasks", it.downloadTaskId)} className="min-w-0 flex-1 truncate hover:underline">
|
||||
{it.name}
|
||||
</Link>
|
||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||
{Math.round(it.progress * 100)}%
|
||||
</span>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
{w.agentRuns.slice(0, 3).map((r) => {
|
||||
const s = agentRunLabel(r.status);
|
||||
return (
|
||||
<Row key={r.id}>
|
||||
<Link to={consolePath("agent", r.id)} className="min-w-0 flex-1 truncate hover:underline">
|
||||
Agent:{r.goal}
|
||||
</Link>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
return (
|
||||
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||
{n.total === 0 ? (
|
||||
<EmptyState plain icon={CircleCheck} title="没有待你决定的事" className="py-6" />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
||||
{n.total}
|
||||
<span className="ml-1.5 text-caption font-normal text-muted-foreground">项待处理</span>
|
||||
</p>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{n.byKind.map((k) => (
|
||||
<Row key={k.kind}>
|
||||
<span className="truncate">{decisionKindLabel(k.kind)}</span>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">{k.count}</span>
|
||||
</Row>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
||||
return (
|
||||
<Section title="缓存与保留" icon={HardDrive} to={consolePath("settings")}>
|
||||
{c.volumes.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={HardDrive}
|
||||
title="尚未登记下载器"
|
||||
description="添加下载器实例后显示缓存卷水位。"
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{c.volumes.map((v) => {
|
||||
const pct = v.usedPercent;
|
||||
const over = pct !== null && pct >= v.hardThresholdPercent;
|
||||
const near = pct !== null && pct >= v.resumePercent;
|
||||
return (
|
||||
<li key={v.volumeKey} className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-medium">{v.volumeKey}</span>
|
||||
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
|
||||
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${pct ?? 0}%` }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 w-px bg-destructive"
|
||||
style={{ left: `${v.hardThresholdPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-meta text-muted-foreground">
|
||||
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
|
||||
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
|
||||
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
|
||||
</p>
|
||||
{v.notes.map((n) => (
|
||||
<p key={n} className="text-meta text-muted-foreground">
|
||||
{n}
|
||||
</p>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
|
||||
const rows = [
|
||||
...s.sites.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "站点",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
at: x.lastSnapshotAt,
|
||||
err: null as string | null,
|
||||
})),
|
||||
...s.feeds.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "Feed",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
at: x.lastSuccessAt,
|
||||
err: x.lastError,
|
||||
})),
|
||||
...s.downloaders.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "下载器",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
at: x.lastSeenAt,
|
||||
err: null,
|
||||
})),
|
||||
...s.mediaServers.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "媒体服务器",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
at: x.lastSeenAt,
|
||||
err: null,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Section title="来源健康" icon={Radio} to={consolePath("sources")}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Radio}
|
||||
title="还没有来源"
|
||||
description="在系统设置或来源页登记站点、Feed 与实例。"
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.slice(0, 8).map((r) => {
|
||||
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
||||
return (
|
||||
<Row key={`${r.kind}-${r.id}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate">
|
||||
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
||||
{r.name}
|
||||
</p>
|
||||
{r.err ? <p className="truncate text-meta text-destructive">{r.err}</p> : null}
|
||||
</div>
|
||||
<span className="hidden shrink-0 text-meta text-muted-foreground sm:inline">
|
||||
{formatRelative(r.at)}
|
||||
</span>
|
||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) {
|
||||
return (
|
||||
<Section title="最近产出" icon={ListTodo} to={consolePath("organize")}>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState plain icon={ListTodo} title="还没有入库记录" className="py-6" />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{items.slice(0, 6).map((r) => {
|
||||
const s = organizationLabel(r.status);
|
||||
return (
|
||||
<li key={r.recordId} className="min-w-0 py-2">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<Link to={consolePath("organize", r.recordId)} className="min-w-0 truncate hover:underline">
|
||||
{r.title ?? "未命名"}
|
||||
</Link>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
</div>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{organizationActionLabel(r.action)} · {r.counts.succeeded}/{r.counts.total} 个文件 ·{" "}
|
||||
{formatRelative(r.finishedAt)}
|
||||
{r.targetPath ? ` · ${r.targetPath}` : ""}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIndicator() {
|
||||
const s = useSseStatus();
|
||||
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
|
||||
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
|
||||
return <StatusBadge tone={tone}>{label}</StatusBadge>;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const q = useLiveDashboard();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="首页"
|
||||
description="系统是否在推进目标、完成了什么、还有什么缺口、是否需要你。"
|
||||
actions={<ServiceStatus />}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<LiveIndicator />
|
||||
{q.data ? (
|
||||
<span className="hidden text-meta text-muted-foreground sm:inline">
|
||||
更新于 {formatRelative(q.data.generatedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{SECTIONS.map((s) => (
|
||||
<section key={s.title} className="pg-slab rounded-xl p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<s.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-body font-semibold text-foreground">{s.title}</h2>
|
||||
{q.isPending ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{["a", "b", "c", "d", "e", "f"].map((k) => (
|
||||
<div key={k} className="pg-slab rounded-xl p-5">
|
||||
<LoadingState rows={3} />
|
||||
</div>
|
||||
<EmptyState icon={CircleCheck} title="暂无数据" description={s.hint} plain className="py-8" />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : q.isError ? (
|
||||
<ErrorState title="首页数据加载失败" error={q.error} onRetry={() => q.refetch()} />
|
||||
) : (
|
||||
<>
|
||||
<AutomationBanner a={q.data.automation} />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Overview a={q.data.automation} />
|
||||
<CurrentWork w={q.data.currentWork} />
|
||||
<NeedsAttention n={q.data.needsAttention} />
|
||||
<CacheSection c={q.data.cache} />
|
||||
<SourcesSection s={q.data.sources} />
|
||||
<RecentOutput items={q.data.recentOutput} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { ChevronLeft, ChevronRight, Clapperboard, Menu } from "lucide-react";
|
||||
import type { SessionInfo } from "@umt/contracts";
|
||||
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
import { ApiKeyDialog } from "~/components/console/api-key-dialog.tsx";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
||||
import { ErrorState } from "~/components/console/empty-state.tsx";
|
||||
import { CONSOLE_BASE, findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
|
||||
import PrismLeak from "~/components/prism-leak.tsx";
|
||||
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
|
||||
import { redirectToLogin } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
const PORTAL_NAME = "媒体自动化";
|
||||
@@ -128,7 +132,33 @@ function Brand({ collapsed = false }: { collapsed?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话探测:未登录跳登录页;探测中/失败给全屏占位 */
|
||||
export default function ConsoleLayout() {
|
||||
const session = useSession();
|
||||
const unauthenticated = session.data && !session.data.authenticated;
|
||||
|
||||
useEffect(() => {
|
||||
if (unauthenticated) redirectToLogin();
|
||||
}, [unauthenticated]);
|
||||
|
||||
if (session.isError) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<ErrorState title="无法获取登录状态" error={session.error} onRetry={() => session.refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!session.data || unauthenticated) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background" role="status" aria-label="正在确认登录状态">
|
||||
<LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <ConsoleShell session={session.data} />;
|
||||
}
|
||||
|
||||
function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
@@ -192,8 +222,8 @@ export default function ConsoleLayout() {
|
||||
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ApiKeyDialog />
|
||||
<ThemeToggle />
|
||||
<AccountMenu session={session} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { Bot, FolderTree, HardDrive, KeyRound, Link2, Server, Tv, UserCog, Zap } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import {
|
||||
DownloadersSection,
|
||||
LibraryRootsSection,
|
||||
MediaServersSection,
|
||||
PathMappingsSection,
|
||||
} from "./settings/instances-tab.tsx";
|
||||
import { ApiKeysSection, SiteCredentialsSection } from "./settings/keys-tab.tsx";
|
||||
import { AutomationTab, PiTab, StorageTab } from "./settings/prefs-tab.tsx";
|
||||
|
||||
export const meta = () => [{ title: "系统设置 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const TABS = [
|
||||
{ value: "automation", label: "自动化", icon: Zap, render: () => <AutomationTab /> },
|
||||
{ value: "downloaders", label: "下载器", icon: Server, render: () => <DownloadersSection /> },
|
||||
{ value: "media-servers", label: "媒体服务器", icon: Tv, render: () => <MediaServersSection /> },
|
||||
{ value: "library-roots", label: "库根", icon: FolderTree, render: () => <LibraryRootsSection /> },
|
||||
{ value: "path-mappings", label: "路径映射", icon: Link2, render: () => <PathMappingsSection /> },
|
||||
{ value: "site-credentials", label: "站点凭证", icon: UserCog, render: () => <SiteCredentialsSection /> },
|
||||
{ value: "api-keys", label: "API Key", icon: KeyRound, render: () => <ApiKeysSection /> },
|
||||
{ value: "pi", label: "Pi 上游", icon: Bot, render: () => <PiTab /> },
|
||||
{ value: "storage", label: "缓存与入库", icon: HardDrive, render: () => <StorageTab /> },
|
||||
] as const;
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SettingsPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab");
|
||||
const tab = TABS.some((t) => t.value === raw) ? (raw as string) : "automation";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="系统设置" description="实例、路径、凭证、密钥、模型上游与自动化开关。凭证一律只写,不回显。" />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
p.set("tab", v);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<TabsList aria-label="设置分节">
|
||||
{TABS.map((t) => (
|
||||
<TabsTrigger key={t.value} value={t.value}>
|
||||
<t.icon />
|
||||
{t.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{TABS.map((t) => (
|
||||
<TabsContent key={t.value} value={t.value}>
|
||||
{tab === t.value ? t.render() : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import {
|
||||
type DownloaderInstance,
|
||||
DownloaderInstanceCreate,
|
||||
DownloaderInstanceUpdate,
|
||||
type LibraryRoot,
|
||||
LibraryRootCreate,
|
||||
LibraryRootUpdate,
|
||||
type MediaServerInstance,
|
||||
MediaServerInstanceCreate,
|
||||
MediaServerInstanceUpdate,
|
||||
type PathMapping,
|
||||
PathMappingCreate,
|
||||
PathMappingUpdate,
|
||||
} from "@umt/contracts";
|
||||
import { PlugZap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { downloaders, libraryRoots, mediaServers, pathMappings } from "~/api/instances.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative, healthLabel } from "~/lib/labels.ts";
|
||||
import { type FormValues, nullStr, num, optStr, ResourceSection, str } from "./resource-section.tsx";
|
||||
|
||||
function Health({ status, at }: { status: string; at?: string | null }) {
|
||||
const h = healthLabel(status);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||||
{at ? <span className="text-meta text-muted-foreground">{formatRelative(at)}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Enabled({ on }: { on: boolean }) {
|
||||
return <StatusBadge tone={on ? "success" : "neutral"}>{on ? "启用" : "停用"}</StatusBadge>;
|
||||
}
|
||||
|
||||
/** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */
|
||||
function TestButton({
|
||||
run,
|
||||
pending,
|
||||
label = "测试连接",
|
||||
}: {
|
||||
run: () => Promise<{ ok: boolean; message: string | null; version?: string | null }>;
|
||||
pending: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await run();
|
||||
if (r.ok) toast.success(`${label}成功${r.version ? ` · ${r.version}` : ""}`);
|
||||
else toast.error(r.message || `${label}失败`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, `${label}失败`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlugZap />
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const credentialHint = { editHint: "留空表示不修改已保存的凭证" };
|
||||
|
||||
export function DownloadersSection() {
|
||||
const list = downloaders.useList();
|
||||
const create = downloaders.useCreate();
|
||||
const update = downloaders.useUpdate();
|
||||
const remove = downloaders.useRemove();
|
||||
const test = downloaders.useTest();
|
||||
return (
|
||||
<ResourceSection<DownloaderInstance>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "下载器实例",
|
||||
description:
|
||||
"qBittorrent WebUI。系统只管理带管理标签的任务;共享同一缓存卷的多个实例填相同的缓存卷标识以合并水位。",
|
||||
emptyHint: "添加 qBittorrent 后才能提交下载。",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tag",
|
||||
header: "管理标签",
|
||||
hideBelow: "md",
|
||||
cell: (r) => <code className="text-meta">{r.managedTag}</code>,
|
||||
},
|
||||
{ key: "vol", header: "缓存卷", hideBelow: "lg", cell: (r) => r.cacheVolumeKey ?? "—" },
|
||||
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
|
||||
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
|
||||
],
|
||||
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "家里的 qB" },
|
||||
{
|
||||
name: "baseUrl",
|
||||
label: "WebUI 地址",
|
||||
kind: "url",
|
||||
required: true,
|
||||
placeholder: "http://192.168.1.10:8080",
|
||||
},
|
||||
{ name: "username", label: "用户名", kind: "text", hint: "留空则把凭证当 WebUI API Key 使用" },
|
||||
{ name: "credential", label: "密码 / API Key", kind: "password", ...credentialHint },
|
||||
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
|
||||
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
initial: {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
credential: "",
|
||||
managedTag: "umt",
|
||||
cacheVolumeKey: "",
|
||||
enabled: true,
|
||||
},
|
||||
fromRow: (r) => ({
|
||||
name: r.name,
|
||||
baseUrl: r.baseUrl,
|
||||
username: r.username ?? "",
|
||||
credential: "",
|
||||
managedTag: r.managedTag,
|
||||
cacheVolumeKey: r.cacheVolumeKey ?? "",
|
||||
enabled: r.enabled,
|
||||
}),
|
||||
toBody: (v: FormValues, mode) => ({
|
||||
name: str(v.name),
|
||||
kind: mode === "create" ? "qbittorrent" : undefined,
|
||||
baseUrl: str(v.baseUrl),
|
||||
username: nullStr(v.username),
|
||||
credential: optStr(v.credential),
|
||||
managedTag: optStr(v.managedTag),
|
||||
cacheVolumeKey: nullStr(v.cacheVolumeKey),
|
||||
enabled: v.enabled === true,
|
||||
}),
|
||||
createSchema: DownloaderInstanceCreate,
|
||||
updateSchema: DownloaderInstanceUpdate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaServersSection() {
|
||||
const list = mediaServers.useList();
|
||||
const create = mediaServers.useCreate();
|
||||
const update = mediaServers.useUpdate();
|
||||
const remove = mediaServers.useRemove();
|
||||
const test = mediaServers.useTest();
|
||||
return (
|
||||
<ResourceSection<MediaServerInstance>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "媒体服务器",
|
||||
description: "Emby / Jellyfin 只用于查询有无、季集与版本,查不到不等于缺失。",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "kind", header: "类型", hideBelow: "md", cell: (r) => (r.kind === "emby" ? "Emby" : "Jellyfin") },
|
||||
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
|
||||
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
|
||||
],
|
||||
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true },
|
||||
{
|
||||
name: "kind",
|
||||
label: "类型",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "emby", label: "Emby" },
|
||||
{ value: "jellyfin", label: "Jellyfin" },
|
||||
],
|
||||
},
|
||||
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
|
||||
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选;按该用户视角查询" },
|
||||
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
initial: { name: "", kind: "emby", baseUrl: "", userId: "", credential: "", enabled: true },
|
||||
fromRow: (r) => ({
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
baseUrl: r.baseUrl,
|
||||
userId: r.userId ?? "",
|
||||
credential: "",
|
||||
enabled: r.enabled,
|
||||
}),
|
||||
toBody: (v) => ({
|
||||
name: str(v.name),
|
||||
kind: str(v.kind),
|
||||
baseUrl: str(v.baseUrl),
|
||||
userId: nullStr(v.userId),
|
||||
credential: optStr(v.credential),
|
||||
enabled: v.enabled === true,
|
||||
}),
|
||||
createSchema: MediaServerInstanceCreate,
|
||||
updateSchema: MediaServerInstanceUpdate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MEDIA_KIND = [
|
||||
{ value: "movie", label: "电影" },
|
||||
{ value: "series", label: "剧集" },
|
||||
{ value: "variety", label: "综艺" },
|
||||
{ value: "mixed", label: "混合" },
|
||||
{ value: "other", label: "其它" },
|
||||
];
|
||||
|
||||
export function LibraryRootsSection() {
|
||||
const list = libraryRoots.useList();
|
||||
const create = libraryRoots.useCreate();
|
||||
const update = libraryRoots.useUpdate();
|
||||
const remove = libraryRoots.useRemove();
|
||||
const test = libraryRoots.useTest();
|
||||
return (
|
||||
<ResourceSection<LibraryRoot>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "库根",
|
||||
description:
|
||||
"登记或修改路径时会在目录里写入哨兵文件 .uhd-root,用来判断库根是否在线;目录不存在或不可写会被拒绝。",
|
||||
deleteHint: "只删除登记,不会删除磁盘上的任何文件。",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground">{r.localPath}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
header: "类型",
|
||||
hideBelow: "md",
|
||||
cell: (r) => MEDIA_KIND.find((k) => k.value === r.mediaKind)?.label ?? r.mediaKind,
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
header: "管理方式",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => (r.managementMode === "managed" ? "自动维护" : "只登记"),
|
||||
},
|
||||
{ key: "health", header: "在线", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
|
||||
],
|
||||
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "电影库" },
|
||||
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
|
||||
{
|
||||
name: "localPath",
|
||||
label: "本地路径",
|
||||
kind: "text",
|
||||
required: true,
|
||||
placeholder: "/media/movies",
|
||||
hint: "执行进程可访问的绝对路径",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "managementMode",
|
||||
label: "管理方式",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "register_only", label: "只登记(不自动维护)" },
|
||||
{ value: "managed", label: "自动维护(补齐/升级)" },
|
||||
],
|
||||
},
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
{ name: "notes", label: "备注", kind: "textarea" },
|
||||
],
|
||||
initial: {
|
||||
name: "",
|
||||
mediaKind: "movie",
|
||||
localPath: "",
|
||||
managementMode: "register_only",
|
||||
enabled: true,
|
||||
notes: "",
|
||||
},
|
||||
fromRow: (r) => ({
|
||||
name: r.name,
|
||||
mediaKind: r.mediaKind,
|
||||
localPath: r.localPath,
|
||||
managementMode: r.managementMode,
|
||||
enabled: r.enabled,
|
||||
notes: r.notes ?? "",
|
||||
}),
|
||||
toBody: (v) => ({
|
||||
name: str(v.name),
|
||||
mediaKind: str(v.mediaKind),
|
||||
localPath: str(v.localPath),
|
||||
managementMode: str(v.managementMode),
|
||||
enabled: v.enabled === true,
|
||||
notes: nullStr(v.notes),
|
||||
}),
|
||||
createSchema: LibraryRootCreate,
|
||||
updateSchema: LibraryRootUpdate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PathMappingsSection() {
|
||||
const list = pathMappings.useList();
|
||||
const create = pathMappings.useCreate();
|
||||
const update = pathMappings.useUpdate();
|
||||
const remove = pathMappings.useRemove();
|
||||
const verify = pathMappings.useVerify();
|
||||
const dls = downloaders.useList();
|
||||
const mss = mediaServers.useList();
|
||||
const roots = libraryRoots.useList();
|
||||
const dlOpts = (dls.data?.items ?? []).map((d) => ({ value: d.id, label: d.name }));
|
||||
const msOpts = (mss.data?.items ?? []).map((d) => ({ value: d.id, label: d.name }));
|
||||
const rootOpts = [
|
||||
{ value: "none", label: "不关联" },
|
||||
...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })),
|
||||
];
|
||||
const nameOf = (r: PathMapping) =>
|
||||
r.viewKind === "downloader"
|
||||
? (dls.data?.items.find((d) => d.id === r.downloaderInstanceId)?.name ?? "下载器")
|
||||
: (mss.data?.items.find((d) => d.id === r.mediaServerInstanceId)?.name ?? "媒体服务器");
|
||||
|
||||
return (
|
||||
<ResourceSection<PathMapping>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "路径映射",
|
||||
description: "把下载器或媒体服务器看到的路径前缀换成本进程可访问的路径。前缀越长越优先,同长按优先级。",
|
||||
columns: [
|
||||
{
|
||||
key: "map",
|
||||
header: "映射",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 font-mono text-meta">
|
||||
<p className="truncate">{r.remotePrefix}</p>
|
||||
<p className="truncate text-muted-foreground">→ {r.localPrefix}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "view",
|
||||
header: "视图",
|
||||
hideBelow: "md",
|
||||
cell: (r) => (
|
||||
<span className="text-caption">
|
||||
{r.viewKind === "downloader" ? "下载器" : "媒体服务器"} · {nameOf(r)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "prio", header: "优先级", hideBelow: "lg", cell: (r) => r.priority },
|
||||
{ key: "verify", header: "校验", cell: (r) => <Health status={r.verifyStatus} at={r.verifiedAt} /> },
|
||||
],
|
||||
rowActions: (r) => (
|
||||
<TestButton
|
||||
label="校验"
|
||||
pending={verify.isPending}
|
||||
run={async () => {
|
||||
const m = await verify.mutateAsync(r.id);
|
||||
return { ok: m.verifyStatus === "ok", message: String(m.verifyDetail?.message ?? "本地前缀不可达") };
|
||||
}}
|
||||
/>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
name: "viewKind",
|
||||
label: "路径视图",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "downloader", label: "下载器看到的路径" },
|
||||
{ value: "media_server", label: "媒体服务器看到的路径" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "downloaderInstanceId",
|
||||
label: "下载器实例",
|
||||
kind: "select",
|
||||
options: dlOpts,
|
||||
placeholder: dlOpts.length ? "请选择" : "先添加下载器实例",
|
||||
visible: (v) => v.viewKind === "downloader",
|
||||
},
|
||||
{
|
||||
name: "mediaServerInstanceId",
|
||||
label: "媒体服务器",
|
||||
kind: "select",
|
||||
options: msOpts,
|
||||
placeholder: msOpts.length ? "请选择" : "先添加媒体服务器",
|
||||
visible: (v) => v.viewKind === "media_server",
|
||||
},
|
||||
{
|
||||
name: "remotePrefix",
|
||||
label: "对方路径前缀",
|
||||
kind: "text",
|
||||
required: true,
|
||||
placeholder: "/downloads",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "localPrefix",
|
||||
label: "本地路径前缀",
|
||||
kind: "text",
|
||||
required: true,
|
||||
placeholder: "/mnt/cache/downloads",
|
||||
wide: true,
|
||||
},
|
||||
{ name: "libraryRootId", label: "关联库根", kind: "select", options: rootOpts },
|
||||
{ name: "priority", label: "优先级", kind: "number", hint: "数字越大越优先" },
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
initial: {
|
||||
viewKind: "downloader",
|
||||
downloaderInstanceId: "",
|
||||
mediaServerInstanceId: "",
|
||||
remotePrefix: "",
|
||||
localPrefix: "",
|
||||
libraryRootId: "none",
|
||||
priority: "0",
|
||||
enabled: true,
|
||||
},
|
||||
fromRow: (r) => ({
|
||||
viewKind: r.viewKind,
|
||||
downloaderInstanceId: r.downloaderInstanceId ?? "",
|
||||
mediaServerInstanceId: r.mediaServerInstanceId ?? "",
|
||||
remotePrefix: r.remotePrefix,
|
||||
localPrefix: r.localPrefix,
|
||||
libraryRootId: r.libraryRootId ?? "none",
|
||||
priority: String(r.priority),
|
||||
enabled: r.enabled,
|
||||
}),
|
||||
toBody: (v) => {
|
||||
const dl = v.viewKind === "downloader";
|
||||
const root = str(v.libraryRootId);
|
||||
return {
|
||||
viewKind: str(v.viewKind),
|
||||
downloaderInstanceId: dl ? nullStr(v.downloaderInstanceId) : null,
|
||||
mediaServerInstanceId: dl ? null : nullStr(v.mediaServerInstanceId),
|
||||
remotePrefix: str(v.remotePrefix),
|
||||
localPrefix: str(v.localPrefix),
|
||||
libraryRootId: root && root !== "none" ? root : null,
|
||||
priority: num(v.priority) ?? 0,
|
||||
enabled: v.enabled === true,
|
||||
};
|
||||
},
|
||||
createSchema: PathMappingCreate,
|
||||
updateSchema: PathMappingUpdate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { type ApiKey, ApiKeyCreate, type ApiScope, SiteCredentialsPut } from "@umt/contracts";
|
||||
import { Check, Copy, KeyRound, LoaderCircle, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "~/api/api-keys.ts";
|
||||
import { usePutSiteCredentials, useSiteAccounts, useSiteOptions } from "~/api/instances.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { FieldControl, type FormValues, str } from "./resource-section.tsx";
|
||||
|
||||
// ─── 站点账号凭证 ───
|
||||
|
||||
const AUTH_KINDS = [
|
||||
{ value: "cookie", label: "Cookie" },
|
||||
{ value: "passkey", label: "Passkey" },
|
||||
{ value: "api_key", label: "API Key" },
|
||||
{ value: "password", label: "账号密码" },
|
||||
];
|
||||
|
||||
export function SiteCredentialsSection() {
|
||||
const sites = useSiteOptions();
|
||||
const accounts = useSiteAccounts();
|
||||
const put = usePutSiteCredentials();
|
||||
const [v, setV] = useState<FormValues>({
|
||||
siteId: "",
|
||||
label: "default",
|
||||
authKind: "cookie",
|
||||
username: "",
|
||||
credential: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const siteOpts = (sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }));
|
||||
const siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const fe: Record<string, string> = {};
|
||||
if (!str(v.siteId)) fe.siteId = "请选择站点";
|
||||
const parsed = SiteCredentialsPut.safeParse({
|
||||
label: str(v.label) || "default",
|
||||
authKind: str(v.authKind),
|
||||
credential: str(v.credential),
|
||||
username: v.authKind === "password" ? str(v.username) || undefined : undefined,
|
||||
});
|
||||
if (!parsed.success) Object.assign(fe, zodFieldErrors(parsed.error.issues));
|
||||
if (fe.credential) fe.credential = "请填写凭证";
|
||||
if (Object.keys(fe).length || !parsed.success) {
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const r = await put.mutateAsync({ siteId: str(v.siteId), body: parsed.data });
|
||||
toast.success(r.created ? "已添加站点账号" : "已替换站点凭证");
|
||||
setV((s) => ({ ...s, credential: "" }));
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
className="pg-slab grid content-start gap-x-4 gap-y-1 rounded-xl p-5 sm:grid-cols-2"
|
||||
>
|
||||
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
|
||||
按「站点 + 标签」新建或替换账号凭证。凭证加密保存,提交后不会回显;站点本身在
|
||||
<Link to={consolePath("sites")} className="mx-1 underline">
|
||||
PT 站点工作台
|
||||
</Link>
|
||||
登记。
|
||||
</p>
|
||||
<FieldControl
|
||||
spec={{
|
||||
name: "siteId",
|
||||
label: "站点",
|
||||
kind: "select",
|
||||
options: siteOpts,
|
||||
placeholder: sites.isPending ? "加载中…" : siteOpts.length ? "请选择" : "还没有登记站点",
|
||||
required: true,
|
||||
}}
|
||||
value={v.siteId}
|
||||
error={errors.siteId}
|
||||
onChange={(x) => setV((s) => ({ ...s, siteId: x }))}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "label", label: "标签", kind: "text", hint: "同一站点多个账号时区分" }}
|
||||
value={v.label}
|
||||
error={errors.label}
|
||||
onChange={(x) => setV((s) => ({ ...s, label: x }))}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "authKind", label: "凭证类型", kind: "select", options: AUTH_KINDS }}
|
||||
value={v.authKind}
|
||||
error={errors.authKind}
|
||||
onChange={(x) => setV((s) => ({ ...s, authKind: x }))}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
{v.authKind === "password" ? (
|
||||
<FieldControl
|
||||
spec={{ name: "username", label: "登录名", kind: "text" }}
|
||||
value={v.username}
|
||||
error={errors.username}
|
||||
onChange={(x) => setV((s) => ({ ...s, username: x }))}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
) : null}
|
||||
<FieldControl
|
||||
spec={{
|
||||
name: "credential",
|
||||
label: v.authKind === "password" ? "密码" : "凭证",
|
||||
kind: "password",
|
||||
required: true,
|
||||
wide: true,
|
||||
placeholder: v.authKind === "cookie" ? "粘贴整段 Cookie" : undefined,
|
||||
}}
|
||||
value={v.credential}
|
||||
error={errors.credential}
|
||||
onChange={(x) => setV((s) => ({ ...s, credential: x }))}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<Button type="submit" size="sm" disabled={put.isPending}>
|
||||
{put.isPending ? <LoaderCircle className="animate-spin" /> : <KeyRound />}
|
||||
保存凭证
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<DetailSection title="已录入的账号">
|
||||
<DataTable
|
||||
rows={accounts.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={accounts.isPending}
|
||||
error={accounts.error}
|
||||
onRetry={() => accounts.refetch()}
|
||||
empty={{ title: "还没有站点账号" }}
|
||||
columns={[
|
||||
{
|
||||
key: "site",
|
||||
header: "站点 / 标签",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{siteName(r.siteId)}</p>
|
||||
<p className="text-meta text-muted-foreground">{r.label}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
header: "类型",
|
||||
cell: (r) => AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<StatusBadge tone={!r.enabled ? "neutral" : r.secretRef ? "success" : "warning"}>
|
||||
{!r.enabled ? "停用" : r.secretRef ? "已录入" : "缺凭证"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── API Key ───
|
||||
|
||||
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
|
||||
{ value: "read", label: "read", hint: "只读查询" },
|
||||
{ value: "organize.read", label: "organize.read", hint: "读取整理记录(增量同步)" },
|
||||
{ value: "organize.execute", label: "organize.execute", hint: "触发整理" },
|
||||
{ value: "admin", label: "admin", hint: "全部管理权限" },
|
||||
];
|
||||
|
||||
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Dialog open={!!plaintext} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key 已创建</DialogTitle>
|
||||
<DialogDescription>明文只显示这一次,关闭后无法再查看。请现在复制保存。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
|
||||
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="复制"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plaintext ?? "");
|
||||
setCopied(true);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动选中复制");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysSection() {
|
||||
const keys = useApiKeys();
|
||||
const create = useCreateApiKey();
|
||||
const revoke = useRevokeApiKey();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [scopes, setScopes] = useState<ApiScope[]>(["read"]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [plaintext, setPlaintext] = useState<string | null>(null);
|
||||
const [revoking, setRevoking] = useState<ApiKey | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
if (fe.name) fe.name = "请填写名称(80 字以内)";
|
||||
if (fe.scopes) fe.scopes = "至少选择一个权限范围";
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const r = await create.mutateAsync(parsed.data);
|
||||
setOpen(false);
|
||||
setName("");
|
||||
setScopes(["read"]);
|
||||
setPlaintext(r.plaintextKey);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "创建失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<p className="max-w-2xl text-caption text-muted-foreground">
|
||||
给外部程序(媒体服务器插件、脚本)用的密钥,按权限范围授权。控制台本身走登录会话,不需要 Key。
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Plus />
|
||||
创建 API Key
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
rows={keys.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={keys.isPending}
|
||||
error={keys.error}
|
||||
onRetry={() => keys.refetch()}
|
||||
empty={{ title: "还没有 API Key" }}
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className={cn("font-medium", r.revokedAt && "text-muted-foreground line-through")}>{r.name}</p>
|
||||
<p className="text-meta text-muted-foreground">创建于 {formatDateTime(r.createdAt)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "scopes",
|
||||
header: "权限",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.scopes.map((s) => (
|
||||
<code key={s} className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5 text-meta">
|
||||
{s}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "used", header: "最近使用", hideBelow: "md", cell: (r) => formatRelative(r.lastUsedAt) },
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<StatusBadge tone={r.revokedAt ? "neutral" : "success"}>{r.revokedAt ? "已撤销" : "有效"}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "act",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (r) =>
|
||||
r.revokedAt ? null : (
|
||||
<Button variant="ghost" size="sm" className="hover:text-destructive" onClick={() => setRevoking(r)}>
|
||||
撤销
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Dialog open={open} onOpenChange={(o) => !create.isPending && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建 API Key</DialogTitle>
|
||||
<DialogDescription>创建后明文只显示一次。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id="apikey-form" onSubmit={submit} noValidate className="grid gap-1">
|
||||
<FormField label="名称" htmlFor="ak-name" error={errors.name} required>
|
||||
<Input
|
||||
id="ak-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-invalid={!!errors.name}
|
||||
placeholder="emby-plugin"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="权限范围" error={errors.scopes}>
|
||||
<div className="grid gap-1.5">
|
||||
{SCOPES.map((s) => {
|
||||
const on = scopes.includes(s.value);
|
||||
return (
|
||||
<label
|
||||
key={s.value}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-current"
|
||||
checked={on}
|
||||
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
|
||||
/>
|
||||
<code className="text-caption">{s.label}</code>
|
||||
<span className="text-meta text-muted-foreground">{s.hint}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={create.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="apikey-form" disabled={create.isPending}>
|
||||
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreatedKeyDialog plaintext={plaintext} onClose={() => setPlaintext(null)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!revoking}
|
||||
onOpenChange={(o) => !o && setRevoking(null)}
|
||||
title={`撤销「${revoking?.name ?? ""}」?`}
|
||||
description="撤销后使用这个 Key 的程序会立即失去访问权限,无法恢复。"
|
||||
confirmLabel="撤销"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!revoking) return;
|
||||
try {
|
||||
await revoke.mutateAsync(revoking.id);
|
||||
toast.success("已撤销");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "撤销失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
type GlobalSettings,
|
||||
GlobalSettings as GlobalSettingsSchema,
|
||||
type GlobalSettingsUpdate,
|
||||
mergeSettings,
|
||||
} from "@umt/contracts";
|
||||
import { LoaderCircle, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSettings, useUpdateSettings } from "~/api/settings.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { FieldControl, type FieldSpec, type FormValues, num, str } from "./resource-section.tsx";
|
||||
|
||||
// 全局设置表单:每张卡片一组字段,保存时 PATCH 对应分组;
|
||||
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
|
||||
|
||||
type CardSpec = {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
fields: FieldSpec[];
|
||||
fromSettings: (s: GlobalSettings) => FormValues;
|
||||
toPatch: (v: FormValues, s: GlobalSettings) => GlobalSettingsUpdate;
|
||||
extra?: (s: GlobalSettings) => ReactNode;
|
||||
};
|
||||
|
||||
function SettingsCard({ spec, settings }: { spec: CardSpec; settings: GlobalSettings }) {
|
||||
const update = useUpdateSettings();
|
||||
const [values, setValues] = useState<FormValues>(() => spec.fromSettings(settings));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const initial = spec.fromSettings(settings);
|
||||
const dirty = Object.keys(values).some((k) => values[k] !== initial[k]);
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const patch = spec.toPatch(values, settings);
|
||||
const parsed = GlobalSettingsSchema.safeParse(mergeSettings(settings, patch));
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
// 交叉校验(refine)落在分组上:挂到本卡第一个字段
|
||||
for (const [k, m] of Object.entries(fe)) {
|
||||
if (!spec.fields.some((f) => f.name === k))
|
||||
fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m;
|
||||
}
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const next = await update.mutateAsync(patch);
|
||||
setValues(spec.fromSettings(next));
|
||||
toast.success(`已保存${spec.title}`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={save} noValidate className="pg-slab min-w-0 rounded-xl p-5">
|
||||
<h3 className="text-body font-semibold text-foreground">{spec.title}</h3>
|
||||
{spec.description ? (
|
||||
<p className="mt-0.5 mb-4 text-caption text-muted-foreground">{spec.description}</p>
|
||||
) : (
|
||||
<div className="mb-4" />
|
||||
)}
|
||||
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
{spec.fields
|
||||
.filter((f) => !f.visible || f.visible(values))
|
||||
.map((f) => (
|
||||
<FieldControl
|
||||
key={f.name}
|
||||
spec={f}
|
||||
value={values[f.name]}
|
||||
error={errors[f.name]}
|
||||
hint={f.hint}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{spec.extra?.(settings)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={update.isPending || !dirty}>
|
||||
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
{dirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setValues(spec.fromSettings(settings));
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
还原
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function useSettingsGate(render: (s: GlobalSettings) => ReactNode) {
|
||||
const q = useSettings();
|
||||
if (q.isPending) return <LoadingState rows={6} />;
|
||||
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
|
||||
return render(q.data);
|
||||
}
|
||||
|
||||
const n = (v: number | null | undefined) => (v === null || v === undefined ? "" : String(v));
|
||||
|
||||
// ─── Pi 上游 ───
|
||||
|
||||
const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh"].map((v) => ({ value: v, label: v }));
|
||||
|
||||
const PI_CARD: CardSpec = {
|
||||
title: "Pi 上游与模型",
|
||||
description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。",
|
||||
fields: [
|
||||
{ name: "pi.provider", label: "Provider", kind: "text", required: true, placeholder: "anthropic" },
|
||||
{ name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" },
|
||||
{ name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true },
|
||||
{ name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true },
|
||||
{ name: "pi.thinkingLevel", label: "思考档位", kind: "select", options: THINKING },
|
||||
{ name: "pi.contextBudget", label: "上下文预算(token)", kind: "number", hint: "4096 – 2000000" },
|
||||
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
|
||||
{
|
||||
name: "pi.compaction.thresholdRatio",
|
||||
label: "压缩触发比例",
|
||||
kind: "number",
|
||||
hint: "0.3 – 0.95,达到预算的该比例时压缩",
|
||||
visible: (v) => v["pi.compaction.enabled"] === true,
|
||||
},
|
||||
{ name: "pi.subagentsEnabled", label: "允许子代理", kind: "switch" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"pi.provider": s.pi.provider,
|
||||
"pi.model": s.pi.model ?? "",
|
||||
"pi.baseUrl": s.pi.baseUrl ?? "",
|
||||
"pi.apiKey": "",
|
||||
"pi.thinkingLevel": s.pi.thinkingLevel,
|
||||
"pi.contextBudget": n(s.pi.contextBudget),
|
||||
"pi.compaction.enabled": s.pi.compaction.enabled,
|
||||
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
|
||||
"pi.subagentsEnabled": s.pi.subagentsEnabled,
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
pi: {
|
||||
provider: str(v["pi.provider"]),
|
||||
model: str(v["pi.model"]) || null,
|
||||
baseUrl: str(v["pi.baseUrl"]) || null,
|
||||
...(str(v["pi.apiKey"]) ? { apiKey: str(v["pi.apiKey"]) } : {}),
|
||||
thinkingLevel: str(v["pi.thinkingLevel"]) as GlobalSettings["pi"]["thinkingLevel"],
|
||||
contextBudget: num(v["pi.contextBudget"]),
|
||||
compaction: {
|
||||
enabled: v["pi.compaction.enabled"] === true,
|
||||
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
|
||||
},
|
||||
subagentsEnabled: v["pi.subagentsEnabled"] === true,
|
||||
},
|
||||
}),
|
||||
extra: (s) => <PiKeyStatus set={!!s.pi.apiKeySecretRef} />,
|
||||
};
|
||||
|
||||
function PiKeyStatus({ set }: { set: boolean }) {
|
||||
const update = useUpdateSettings();
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
|
||||
<StatusBadge tone={set ? "success" : "warning"}>{set ? "上游密钥已保存" : "未设置上游密钥"}</StatusBadge>
|
||||
{set ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="hover:text-destructive"
|
||||
disabled={update.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await update.mutateAsync({ pi: { apiKey: null } });
|
||||
toast.success("已清除上游密钥");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "清除失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
清除密钥
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AGENT_BUDGET_CARD: CardSpec = {
|
||||
title: "Agent 预算",
|
||||
description: "超过预算的 Agent 运行会停在「超预算」状态,等你放行。",
|
||||
fields: [
|
||||
{ name: "budget.agentMaxRunsPerDay", label: "每日最多运行次数", kind: "number" },
|
||||
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用上限(USD)", kind: "number" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay),
|
||||
"budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay),
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
budget: {
|
||||
agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]),
|
||||
agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export function PiTab() {
|
||||
return useSettingsGate((s) => (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||
<SettingsCard spec={PI_CARD} settings={s} />
|
||||
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
// ─── 缓存 / 入库 / 保留 / 下载预算 ───
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
const CACHE_CARD: CardSpec = {
|
||||
title: "缓存阈值与恢复水位",
|
||||
description: "缓存卷使用率达到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下再恢复(只恢复因阈值暂停的)。",
|
||||
fields: [
|
||||
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
|
||||
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "必须低于硬阈值" },
|
||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不得高于恢复水位" },
|
||||
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10 – 3600" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
|
||||
"cache.resumePercent": n(s.cache.resumePercent),
|
||||
"cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent),
|
||||
"cache.checkIntervalSec": n(s.cache.checkIntervalSec),
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
cache: {
|
||||
hardThresholdPercent: num(v["cache.hardThresholdPercent"]),
|
||||
resumePercent: num(v["cache.resumePercent"]),
|
||||
cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]),
|
||||
checkIntervalSec: num(v["cache.checkIntervalSec"]),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const ORGANIZE_CARD: CardSpec = {
|
||||
title: "入库方式与保留",
|
||||
description: "默认 Copy 入库,下载源在缓存里保留到做种期满;跨卷 Move 走 Copy→核验→删源。",
|
||||
fields: [
|
||||
{
|
||||
name: "organize.defaultMode",
|
||||
label: "默认入库方式",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "copy", label: "Copy(保留源文件做种)" },
|
||||
{ value: "move", label: "Move(移走源文件)" },
|
||||
],
|
||||
},
|
||||
{ name: "organize.allowCrossVolumeMove", label: "允许跨卷 Move", kind: "switch" },
|
||||
{
|
||||
name: "retention.seedRetentionDays",
|
||||
label: "源文件保留天数",
|
||||
kind: "number",
|
||||
hint: "Copy 入库后至少保留;0 – 365",
|
||||
},
|
||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"organize.defaultMode": s.organize.defaultMode,
|
||||
"organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
|
||||
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
|
||||
"retention.logRetentionDays": n(s.retention.logRetentionDays),
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
organize: {
|
||||
defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
|
||||
allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
|
||||
},
|
||||
retention: {
|
||||
seedRetentionDays: num(v["retention.seedRetentionDays"]),
|
||||
logRetentionDays: num(v["retention.logRetentionDays"]),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const DOWNLOAD_BUDGET_CARD: CardSpec = {
|
||||
title: "下载预算",
|
||||
fields: [
|
||||
{ name: "budget.maxNewTitlesPerDay", label: "每日最多新作品", kind: "number" },
|
||||
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量上限(GiB)", kind: "number" },
|
||||
{ name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1 – 100" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay),
|
||||
"budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100),
|
||||
"budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads),
|
||||
}),
|
||||
toPatch: (v) => {
|
||||
const gib = num(v["budget.maxDownloadGiBPerDay"]);
|
||||
return {
|
||||
budget: {
|
||||
maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]),
|
||||
maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB),
|
||||
maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function StorageTab() {
|
||||
return useSettingsGate((s) => (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SettingsCard spec={CACHE_CARD} settings={s} />
|
||||
<SettingsCard spec={ORGANIZE_CARD} settings={s} />
|
||||
<SettingsCard spec={DOWNLOAD_BUDGET_CARD} settings={s} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
// ─── 自动化开关 ───
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
tone,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled: boolean;
|
||||
tone: "success" | "warning";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 py-4">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-9 w-9 shrink-0 place-items-center rounded-full",
|
||||
tone === "success" ? "bg-success/10 text-success" : "bg-warning/10 text-warning",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<label htmlFor={`tg-${title}`} className="block cursor-pointer text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</label>
|
||||
<span className="block text-caption text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
<Switch id={`tg-${title}`} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={title} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutomationTab() {
|
||||
const update = useUpdateSettings();
|
||||
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
|
||||
try {
|
||||
await update.mutateAsync({ automation: patch });
|
||||
toast.success(msg);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "切换失败"));
|
||||
}
|
||||
};
|
||||
return useSettingsGate((s) => (
|
||||
<div className="pg-slab divide-y divide-border/60 rounded-xl px-5">
|
||||
<ToggleRow
|
||||
icon={s.automation.enabled ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
tone={s.automation.enabled ? "success" : "warning"}
|
||||
title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`}
|
||||
description="关闭后所有自动收录、下载、整理停止;只读查询与手动操作仍可用。"
|
||||
checked={s.automation.enabled}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
|
||||
/>
|
||||
<ToggleRow
|
||||
icon={<ShieldAlert className="h-4 w-4" />}
|
||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
||||
title={`只读对账模式 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
||||
description="从备份恢复后先开启:只核对库存与路径,不做任何自动写入;确认无误后关闭。"
|
||||
checked={s.automation.readOnlyReconcile}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式")}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { CursorPage } from "@umt/contracts";
|
||||
import { LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑对话框 + 删除 + 行内动作。
|
||||
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
|
||||
|
||||
export type FieldValue = string | boolean;
|
||||
export type FormValues = Record<string, FieldValue>;
|
||||
|
||||
export type FieldSpec = {
|
||||
name: string;
|
||||
label: string;
|
||||
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
/** 编辑时的说明(如"留空表示不修改") */
|
||||
editHint?: string;
|
||||
required?: boolean;
|
||||
/** 编辑时不可改 */
|
||||
createOnly?: boolean;
|
||||
/** 条件显示 */
|
||||
visible?: (v: FormValues) => boolean;
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
export type ResourceConfig<Row extends { id: string }> = {
|
||||
/** 单数名,用于标题与提示:"下载器实例" */
|
||||
noun: string;
|
||||
description?: ReactNode;
|
||||
columns: DataColumn<Row>[];
|
||||
fields: FieldSpec[];
|
||||
/** 新建时的初始值 */
|
||||
initial: FormValues;
|
||||
/** 编辑时从行取表单值(凭证字段给空串) */
|
||||
fromRow: (row: Row) => FormValues;
|
||||
/** 表单值 → 请求体;mode=edit 时凭证空串应省略 */
|
||||
toBody: (v: FormValues, mode: "create" | "edit") => unknown;
|
||||
createSchema: z.ZodType;
|
||||
updateSchema: z.ZodType;
|
||||
/** 行内额外动作(测试连接等) */
|
||||
rowActions?: (row: Row) => ReactNode;
|
||||
emptyHint?: string;
|
||||
/** 删除确认的额外说明 */
|
||||
deleteHint?: string;
|
||||
};
|
||||
|
||||
type Hooks<Row> = {
|
||||
list: UseQueryResult<CursorPage<Row>>;
|
||||
create: UseMutationResult<Row, Error, never>;
|
||||
update: UseMutationResult<Row, Error, { id: string; body: never }>;
|
||||
remove: UseMutationResult<unknown, Error, string>;
|
||||
};
|
||||
|
||||
export function ResourceSection<Row extends { id: string }>({
|
||||
config,
|
||||
hooks,
|
||||
}: {
|
||||
config: ResourceConfig<Row>;
|
||||
hooks: Hooks<Row>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<Row | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Row | null>(null);
|
||||
const { list, remove } = hooks;
|
||||
|
||||
const columns: DataColumn<Row>[] = [
|
||||
...config.columns,
|
||||
{
|
||||
key: "__actions",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{config.rowActions?.(row)}
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑${config.noun}`} onClick={() => setEditing(row)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`删除${config.noun}`}
|
||||
className="hover:text-destructive"
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
{config.description ? (
|
||||
<p className="max-w-2xl text-caption text-muted-foreground">{config.description}</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
添加{config.noun}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={list.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
|
||||
/>
|
||||
<ResourceFormDialog
|
||||
key={editing === "new" ? "new" : (editing?.id ?? "none")}
|
||||
config={config}
|
||||
hooks={hooks}
|
||||
row={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title={`删除${config.noun}?`}
|
||||
description={config.deleteHint ?? "删除后无法恢复。"}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await remove.mutateAsync(deleting.id);
|
||||
toast.success(`已删除${config.noun}`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "删除失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceFormDialog<Row extends { id: string }>({
|
||||
config,
|
||||
hooks,
|
||||
row,
|
||||
onClose,
|
||||
}: {
|
||||
config: ResourceConfig<Row>;
|
||||
hooks: Hooks<Row>;
|
||||
row: Row | "new" | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const mode = row === "new" ? "create" : "edit";
|
||||
const [values, setValues] = useState<FormValues>(() =>
|
||||
row && row !== "new" ? config.fromRow(row) : { ...config.initial },
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const pending = hooks.create.isPending || hooks.update.isPending;
|
||||
|
||||
const set = (name: string, v: FieldValue) => setValues((s) => ({ ...s, [name]: v }));
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const body = config.toBody(values, mode);
|
||||
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
// 顶层 refine 错误(path 为空)挂到第一个字段下
|
||||
if (fe[""]) {
|
||||
fe[config.fields[0]?.name ?? ""] ??= fe[""];
|
||||
}
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
if (mode === "create") await hooks.create.mutateAsync(parsed.data as never);
|
||||
else if (row && row !== "new") await hooks.update.mutateAsync({ id: row.id, body: parsed.data as never });
|
||||
toast.success(mode === "create" ? `已添加${config.noun}` : `已保存${config.noun}`);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!row} onOpenChange={(o) => !o && !pending && onClose()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "添加" : "编辑"}
|
||||
{config.noun}
|
||||
</DialogTitle>
|
||||
<DialogDescription>凭证只写入、加密保存,任何页面都不会回显。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id="resource-form" onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
{config.fields
|
||||
.filter((f) => !f.visible || f.visible(values))
|
||||
.map((f) => (
|
||||
<FieldControl
|
||||
key={f.name}
|
||||
spec={f}
|
||||
value={values[f.name]}
|
||||
error={errors[f.name]}
|
||||
onChange={(v) => set(f.name, v)}
|
||||
disabled={pending || (mode === "edit" && !!f.createOnly)}
|
||||
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="resource-form" disabled={pending}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldControl({
|
||||
spec,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled,
|
||||
hint,
|
||||
}: {
|
||||
spec: FieldSpec;
|
||||
value: FieldValue | undefined;
|
||||
error?: string;
|
||||
onChange: (v: FieldValue) => void;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
}) {
|
||||
const id = `f-${spec.name}`;
|
||||
const wide = spec.wide || spec.kind === "textarea";
|
||||
if (spec.kind === "switch") {
|
||||
return (
|
||||
<div className={wide ? "sm:col-span-2" : undefined}>
|
||||
<label htmlFor={id} className="flex min-h-9 items-center justify-between gap-3 text-caption font-medium">
|
||||
{spec.label}
|
||||
<Switch id={id} checked={value === true} onCheckedChange={onChange} disabled={disabled} />
|
||||
</label>
|
||||
<p className="min-h-4 text-meta text-muted-foreground">{hint ?? ""}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let control: ReactNode;
|
||||
if (spec.kind === "select") {
|
||||
control = (
|
||||
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
|
||||
<SelectValue placeholder={spec.placeholder ?? "请选择"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(spec.options ?? []).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (spec.kind === "textarea") {
|
||||
control = (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={spec.placeholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Input
|
||||
id={id}
|
||||
type={
|
||||
spec.kind === "password"
|
||||
? "password"
|
||||
: spec.kind === "number"
|
||||
? "number"
|
||||
: spec.kind === "url"
|
||||
? "url"
|
||||
: "text"
|
||||
}
|
||||
autoComplete={spec.kind === "password" ? "new-password" : "off"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={spec.placeholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormField
|
||||
label={spec.label}
|
||||
htmlFor={id}
|
||||
error={error}
|
||||
hint={hint}
|
||||
required={spec.required}
|
||||
className={wide ? "sm:col-span-2" : undefined}
|
||||
>
|
||||
{control}
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表单值工具 */
|
||||
export const str = (v: FieldValue | undefined) => (typeof v === "string" ? v.trim() : "");
|
||||
export const optStr = (v: FieldValue | undefined) => str(v) || undefined;
|
||||
export const nullStr = (v: FieldValue | undefined) => str(v) || null;
|
||||
export const num = (v: FieldValue | undefined) => (str(v) === "" ? undefined : Number(str(v)));
|
||||
Reference in new issue
Block a user