- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
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: "保存时会在目录里放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。",
|
|
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,
|
|
}}
|
|
/>
|
|
);
|
|
}
|