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:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
type DownloaderInstance,
|
||||
DownloaderInstanceCreate,
|
||||
DownloaderInstanceUpdate,
|
||||
type LibraryRoot,
|
||||
LibraryRootCreate,
|
||||
LibraryRootUpdate,
|
||||
type MediaServerInstance,
|
||||
MediaServerInstanceCreate,
|
||||
MediaServerInstanceUpdate,
|
||||
} from "@umt/contracts";
|
||||
import { PlugZap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { downloaders, libraryRoots, mediaServers } 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 { PathMappingsPanel } from "./path-mappings.tsx";
|
||||
import { type FormValues, nullStr, optStr, ResourceSection, str } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
export 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 写回后列表自动刷新 */
|
||||
export 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() {
|
||||
return (
|
||||
<SettingsBlock section="downloaders" anchor="instances" title="qBittorrent 实例" plain>
|
||||
<DownloadersTable />
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadersTable() {
|
||||
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:
|
||||
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和它的路径映射。",
|
||||
emptyHint: "添加 qBittorrent 后才能提交下载。",
|
||||
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
|
||||
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() {
|
||||
return (
|
||||
<SettingsBlock section="media-servers" anchor="instances" title="Emby / Jellyfin 实例" plain>
|
||||
<MediaServersTable />
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaServersTable() {
|
||||
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: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和它的路径映射。",
|
||||
drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />,
|
||||
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 LibraryRootsTable() {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user