feat(web): settings with 9 sidebar sections and Agent upstream management

- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader
- SETTINGS_SECTIONS in contracts with field-ownership test
- Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default),
  edit drawer with OAuth browser/device login, test, set default, delete
- sites section with site registration, snapshot/rate-limit drawer and account credentials
- path mappings moved into downloader / media-server drawers; metadata and account sections

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 03:05:51 +08:00
1 parent 459991421e
commit c67f6a9682
25 files changed
+3842 -942

No files matched your search

+108 -53
View File
@@ -1,64 +1,119 @@
import { Bot, FolderTree, HardDrive, KeyRound, Link2, Server, Tv, UserCog, Zap } from "lucide-react";
import { useSearchParams } from "react-router";
import { settingsSectionForLegacyTab } from "@umt/contracts";
import { useEffect } from "react";
import { Link, redirect, useLocation, useNavigate } 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";
import { parseSettingsHash, SECTIONS, settingsHash } from "~/components/settings/sections.tsx";
import { anchorId } from "~/components/settings/settings-block.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { cn } from "~/lib/utils.ts";
export const meta = () => [{ title: "系统设置 · UHD 媒体自动化控制台" }];
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;
/** 旧链接 `?tab=xxx` → `#分区[/节]`(console-ia.md §3.3) */
export function clientLoader({ request }: { request: Request }) {
const url = new URL(request.url);
const tab = url.searchParams.get("tab");
if (!tab) return null;
const hit = settingsSectionForLegacyTab(tab);
url.searchParams.delete("tab");
const rest = url.searchParams.toString();
return redirect(`${url.pathname}${rest ? `?${rest}` : ""}${hit ? settingsHash(hit.section, hit.anchor) : ""}`);
}
export default function SettingsPage() {
const [params, setParams] = useSearchParams();
const raw = params.get("tab");
const tab = TABS.some((t) => t.value === raw) ? (raw as string) : "automation";
const location = useLocation();
const navigate = useNavigate();
const { section, anchor } = parseSettingsHash(location.hash);
// 切分区回到顶部;带节锚点时滚到该节(内容可能还在加载,稍后再试一次)
useEffect(() => {
if (!anchor) {
window.scrollTo({ top: 0 });
return;
}
let tries = 0;
const go = () => {
const el = document.getElementById(anchorId(section.id, anchor));
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
else if (tries++ < 10) timer = window.setTimeout(go, 150);
};
let timer = window.setTimeout(go, 0);
return () => window.clearTimeout(timer);
}, [section.id, anchor]);
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>
<PageHeader title="设置" description="全局参数、接入的实例与凭证。凭证只写入,保存后不回显。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */}
<div className="lg:hidden">
<Select value={section.id} onValueChange={(id) => navigate({ hash: settingsHash(id) })}>
<SelectTrigger className="w-full" aria-label="设置分区">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SECTIONS.map((s) => (
<SelectItem key={s.id} value={s.id}>
<s.icon />
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-2 text-caption text-muted-foreground">{section.description}</p>
</div>
{/* 宽屏:左侧分区导航 */}
<nav aria-label="设置分区" className="hidden lg:block">
<ul className="sticky top-20 grid gap-0.5">
{SECTIONS.map((s) => {
const active = s.id === section.id;
return (
<li key={s.id}>
<Link
to={{ hash: settingsHash(s.id) }}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors duration-micro",
active
? "bg-foreground/[0.07] font-medium text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
<s.icon className="size-4 shrink-0" aria-hidden />
{s.label}
</Link>
{active && s.anchors.length > 1 ? (
<ul className="mt-0.5 mb-1.5 ml-[1.375rem] grid gap-0.5 border-l border-border/60 pl-3">
{s.anchors.map((a) => (
<li key={a.id}>
<Link
to={{ hash: settingsHash(s.id, a.id) }}
className={cn(
"block rounded-md px-2 py-1 text-caption transition-colors duration-micro hover:text-foreground",
anchor === a.id ? "text-foreground" : "text-muted-foreground",
)}
>
{a.label}
</Link>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
</nav>
<div className="min-w-0">
<div className="mb-6 hidden lg:block">
<h2 className="flex items-center gap-2 text-title font-semibold text-foreground">{section.label}</h2>
<p className="mt-0.5 text-caption text-muted-foreground">{section.description}</p>
</div>
<div key={section.id}>{section.render()}</div>
</div>
</div>
</>
);
}
@@ -1,473 +0,0 @@
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,
}}
/>
);
}
@@ -1,407 +0,0 @@
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>
);
}
@@ -1,494 +0,0 @@
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: "select",
required: true,
options: [
{ value: "anthropic", label: "anthropic" },
{ value: "openai-compatible", label: "openai-compatible(baseUrl 带 /v1)" },
{ value: "fake", label: "fake(脚本化假模型,不联网,演示用)" },
],
},
{ 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"]),
},
}),
};
const AGENT_RUNTIME_CARD: CardSpec = {
title: "Agent 运行",
description:
"单次运行预算、后台自动调查(只读调查或授权内恢复);provider 选 fake 时按下面的脚本回放工具调用(留空用内置演示脚本)。",
fields: [
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1 – 500" },
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
{ name: "agent.autoInvestigate", label: "待处理事项自动调查", kind: "switch" },
{
name: "agent.autoInvestigateMode",
label: "自动调查模式",
kind: "select",
options: [
{ value: "investigate", label: "只读调查(结论交还给你)" },
{ value: "recover", label: "授权内恢复(可重试任务 / 重新校验 / 确认识别)" },
],
hint: "恢复只在已发布策略或持续授权覆盖时执行,换源、删除、清理始终排除;没有覆盖的授权时自动退回只读调查",
wide: true,
visible: (v) => v["agent.autoInvestigate"] === true,
},
{
name: "agent.investigateMaxToolCalls",
label: "自动调查工具调用上限",
kind: "number",
visible: (v) => v["agent.autoInvestigate"] === true,
},
{
name: "agent.investigateMaxAttempts",
label: "同一根因最多自动恢复",
kind: "number",
hint: "1 – 10 次,用尽后只交还给你",
visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover",
},
{ name: "agent.goalPollSec", label: "目标落定轮询(秒)", kind: "number" },
{ name: "agent.goalWaitHours", label: "目标最长等待(小时)", kind: "number" },
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: '{"match":"收进来","steps":[{"toolCalls":[{"capability":"inventory.query","input":{"query":"沙丘2"}}]}],"finalText":"…"},格式见 docs/runbook/agent.md',
},
{
name: "agent.sessionDir",
label: "会话目录",
kind: "text",
wide: true,
placeholder: "留空用部署默认:DATA_DIR/agent-sessions(容器内 /data/agent-sessions)",
hint: "绝对路径,api 与 worker 进程都要可读写;有运行中的会话时不要改(续跑会找不到旧会话文件)",
},
],
fromSettings: (s) => ({
"agent.maxToolCalls": n(s.agent.maxToolCalls),
"agent.maxDurationSec": n(s.agent.maxDurationSec),
"agent.autoInvestigate": s.agent.autoInvestigate,
"agent.autoInvestigateMode": s.agent.autoInvestigateMode,
"agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls),
"agent.investigateMaxAttempts": n(s.agent.investigateMaxAttempts),
"agent.goalPollSec": n(s.agent.goalPollSec),
"agent.goalWaitHours": n(s.agent.goalWaitHours),
"agent.fakeScript": s.agent.fakeScript ?? "",
"agent.sessionDir": s.agent.sessionDir ?? "",
}),
toPatch: (v) => ({
agent: {
maxToolCalls: num(v["agent.maxToolCalls"]),
maxDurationSec: num(v["agent.maxDurationSec"]),
autoInvestigate: v["agent.autoInvestigate"] === true,
autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate",
investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]),
investigateMaxAttempts: num(v["agent.investigateMaxAttempts"]),
goalPollSec: num(v["agent.goalPollSec"]),
goalWaitHours: num(v["agent.goalWaitHours"]),
fakeScript: str(v["agent.fakeScript"]).trim() || null,
sessionDir: str(v["agent.sessionDir"]).trim() || null,
},
}),
};
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} />
<div className="grid content-start gap-4">
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
<SettingsCard spec={AGENT_RUNTIME_CARD} settings={s} />
</div>
</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>
));
}
@@ -1,343 +0,0 @@
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)));