Files
UHD_Media_Tool/apps/web/app/components/settings/instances.tsx
T
rootandClaude Opus 5.5 ea42018d3f docs(copy): 设置页接入、媒体库、整理、系统分区文案按 human-copy 重写
- 下载器说明去掉「接管」,选下载器顺序写成一句;缓存盘统一为下载盘
- 「凭据」统一为「凭证」;去掉说明里的「同 MP」「待处理」等旧称
- 整理来源的基线、存量说法改成用户能懂的话,确认按钮写动作
- 数据保留里「站点快照」「待识别条目」按术语表改名

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:45 +08:00

491 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, storages } 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 { secretPatch } from "~/lib/secret.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 dlSecretSet = (v: FormValues) => v["secret:set"] === true && v["secret:authKind"] === v.authKind;
const AUTH_KINDS = [
{ value: "password", label: "用户名和密码" },
{ value: "api_key", label: "API Key" },
{ value: "none", label: "无认证(内网白名单)" },
];
/** 已存认证方式;旧数据没有 settings.authKind 时按用户名 / 凭证推断(与服务端一致) */
function authKindOf(r: DownloaderInstance): string {
const k = (r.settings as { authKind?: unknown }).authKind;
if (k === "password" || k === "api_key" || k === "none") return k;
return r.username ? "password" : r.secretRef ? "api_key" : "none";
}
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用「下载器」分区的缺省值 */
const POLICY_FIELDS = [
["hardPct", "hardThresholdPercent"],
["resumePct", "resumePercent"],
["targetPct", "cleanupTargetPercent"],
["seedDays", "seedRetentionDays"],
] as const;
function policyForm(r: DownloaderInstance): Record<string, string | boolean> {
const p = ((r.settings as { policy?: Record<string, unknown> }).policy ?? {}) as Record<string, unknown>;
return {
...Object.fromEntries(POLICY_FIELDS.map(([f, k]) => [f, typeof p[k] === "number" ? String(p[k]) : ""])),
hnrUnknownCleanable: p.hnrUnknownCleanable === true,
};
}
function policyBody(v: FormValues): Record<string, number | boolean> {
const out: Record<string, number | boolean> = {};
for (const [f, k] of POLICY_FIELDS) {
const raw = String(v[f] ?? "").trim();
if (raw !== "" && Number.isFinite(Number(raw))) out[k] = Number(raw);
}
if (v.hnrUnknownCleanable === true) out.hnrUnknownCleanable = true;
return out;
}
export 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}
{r.isDefault ? (
<span className="ml-1.5 font-normal text-meta text-muted-foreground">默认</span>
) : null}
</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: "authKind",
label: "认证方式",
kind: "select",
options: AUTH_KINDS,
hint: "API Key 需要 qBittorrent 5.2 及以上,在 WebUI 设置里生成",
},
{ name: "username", label: "用户名", kind: "text", visible: (v) => v.authKind === "password" },
{
name: "password",
label: "密码",
kind: "secret",
secret: { set: dlSecretSet, clearable: true },
visible: (v) => v.authKind === "password",
},
{
name: "apiKey",
label: "API Key",
kind: "secret",
secret: { set: dlSecretSet },
placeholder: "qbt_…",
visible: (v) => v.authKind === "api_key",
},
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只管理带这个标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{
name: "hardPct",
label: "暂停线(%)",
kind: "number",
placeholder: "留空用缺省值",
hint: "这个下载器的下载盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
},
{ name: "resumePct", label: "恢复线(%)", kind: "number", placeholder: "留空用缺省值" },
{ name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用缺省值" },
{
name: "seedDays",
label: "保种天数",
kind: "number",
placeholder: "留空用缺省值",
hint: "整理后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
},
{
name: "hnrUnknownCleanable",
label: "H&R 未知也可清理",
kind: "switch",
hint: "保种天数到期后,没给出 H&R 要求的任务(如 M-Team、接管的任务、站点没配 H&R)也允许清理;默认不清理",
},
{
name: "isDefault",
label: "设为默认下载器",
kind: "switch",
hint: "订阅、手动下载和站点都没指定下载器时用它;只能有一个默认",
},
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: {
name: "",
baseUrl: "",
authKind: "password",
username: "",
password: "",
apiKey: "",
managedTag: "umt",
cacheVolumeKey: "",
hardPct: "",
resumePct: "",
targetPct: "",
seedDays: "",
hnrUnknownCleanable: false,
isDefault: false,
enabled: true,
},
fromRow: (r) => ({
name: r.name,
baseUrl: r.baseUrl,
authKind: authKindOf(r),
username: r.username ?? "",
password: "",
apiKey: "",
"secret:set": !!r.secretRef,
"secret:authKind": authKindOf(r),
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
...policyForm(r),
isDefault: r.isDefault,
enabled: r.enabled,
}),
toBody: (v: FormValues, mode) => ({
name: str(v.name),
kind: mode === "create" ? "qbittorrent" : undefined,
baseUrl: str(v.baseUrl),
authKind: str(v.authKind),
username: v.authKind === "password" ? nullStr(v.username) : null,
credential:
v.authKind === "password"
? secretPatch(v.password)
: v.authKind === "api_key"
? secretPatch(v.apiKey)
: undefined,
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
settings: { policy: policyBody(v) },
isDefault: v.isDefault === true,
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: "secret",
secret: { set: (v) => v["secret:set"] === true, clearable: true },
},
{ 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: "",
"secret:set": !!r.secretRef,
enabled: r.enabled,
}),
toBody: (v) => ({
name: str(v.name),
kind: str(v.kind),
baseUrl: str(v.baseUrl),
userId: nullStr(v.userId),
credential: secretPatch(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: "其他" },
];
/** 库根路径 ↔ 存储 + 路径:Alist 上的库根存成 /@alist/<存储 id>/<Alist 内路径> */
const ALIST_PATH = /^\/@alist\/([0-9a-f-]{36})(\/.*)?$/i;
export function splitRootPath(p: string): { storage: string; path: string } {
const m = ALIST_PATH.exec(p);
return m ? { storage: m[1]!.toLowerCase(), path: m[2] || "/" } : { storage: "local", path: p };
}
export function joinRootPath(storage: string, path: string): string {
if (storage === "local") return path;
const p = `/${path.trim()}`.replace(/\/+/g, "/").replace(/\/$/, "");
return `/@alist/${storage}${p}`;
}
export function LibraryRootsTable() {
const storageList = storages.useList();
const storageOptions = [
{ value: "local", label: "本机" },
...(storageList.data?.items ?? []).map((s) => ({ value: s.id, label: `${s.name}(Alist)` })),
];
const storageName = (id: string) => storageList.data?.items.find((s) => s.id === id)?.name ?? "Alist";
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:
"本机目录保存时会放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。也可以选登记好的 Alist 存储,Alist 上的目录要先建好。",
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">
{(() => {
const sp = splitRootPath(r.localPath);
return sp.storage === "local" ? r.localPath : `${storageName(sp.storage)}:${sp.path}`;
})()}
</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: "storage",
label: "存储",
kind: "select",
options: storageOptions,
hint: "本机目录,或在「存储」里登记的 Alist / OpenList",
},
{
name: "localPath",
label: "路径",
kind: "text",
required: true,
placeholder: "/media/movies",
hint: "本机:服务进程能访问的绝对路径;Alist:Alist 里的路径,如 /115/电影",
wide: true,
},
{
name: "managementMode",
label: "管理方式",
kind: "select",
options: [
{ value: "register_only", label: "只登记" },
{ value: "managed", label: "自动维护(补齐、升级)" },
],
hint: "自动维护:自动整理可把新下载放进来,文件管理可在其中改名、移动、删除;只登记:只扫描登记已有文件,不自动写入也不可改动。",
wide: true,
},
{ name: "enabled", label: "启用", kind: "switch" },
{ name: "notes", label: "备注", kind: "textarea" },
],
initial: {
name: "",
mediaKind: "movie",
storage: "local",
localPath: "",
managementMode: "register_only",
enabled: true,
notes: "",
},
fromRow: (r) => ({
name: r.name,
mediaKind: r.mediaKind,
storage: splitRootPath(r.localPath).storage,
localPath: splitRootPath(r.localPath).path,
managementMode: r.managementMode,
enabled: r.enabled,
notes: r.notes ?? "",
}),
toBody: (v) => ({
name: str(v.name),
mediaKind: str(v.mediaKind),
localPath: joinRootPath(str(v.storage) || "local", str(v.localPath)),
managementMode: str(v.managementMode),
enabled: v.enabled === true,
notes: nullStr(v.notes),
}),
createSchema: LibraryRootCreate,
updateSchema: LibraryRootUpdate,
}}
/>
);
}