真正入库取决于媒体服务器有没有扫到,本工具只负责整理(元数据、NFO 与图片、按规则归到目录)。 界面与面向用户的服务端文案里「已入库 / 未入库 / 部分入库 / 入库失败 / 入库中 / 入库后 / 原地入库…」 改为「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中 / 整理后 / 原地整理…」;文件管理快速筛选「已整理」、 目录副行「n 个已整理 / 未整理」、季卡片「已整理 x/y」。代码标识符不改,「Emby 已有」这类媒体服务器状态保留。 glossary 加「整理」口径条目;check:copy 拦处理状态语境的「入库」搭配,其它线待替换的文件暂列 TERM_PENDING。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
670 lines
24 KiB
TypeScript
670 lines
24 KiB
TypeScript
import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts";
|
||
import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, X } from "lucide-react";
|
||
import { useEffect, useId, useState } from "react";
|
||
import { Link } from "react-router";
|
||
import { toast } from "sonner";
|
||
import { useDownloadSavePaths } from "~/api/downloads.ts";
|
||
import { useEntityDetail } from "~/api/library.ts";
|
||
import { useLibraryVersions } from "~/api/library-versions.ts";
|
||
import { useTmdbMedia } from "~/api/search.ts";
|
||
import { useSite } from "~/api/sites.ts";
|
||
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||
import { FormField } from "~/components/console/form-field.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { MEDIA_KIND } from "~/components/library/labels.ts";
|
||
import { CoverPlaceholder, posterPathOf } from "~/components/library/parts.tsx";
|
||
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.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 { absTime, relTime } from "~/components/workbench/release-fields.ts";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||
import {
|
||
buildManualOptions,
|
||
CUSTOM_DIR,
|
||
existingVersions,
|
||
type IdentityChoice,
|
||
identityChanged,
|
||
type ManualDownloadOptions,
|
||
resolveSavePath,
|
||
savePathText,
|
||
type TmdbType,
|
||
targetOf,
|
||
tmdbTypeOf,
|
||
versionSummaryText,
|
||
} from "./manual-download-model.ts";
|
||
|
||
export type { ManualDownloadOptions } from "./manual-download-model.ts";
|
||
|
||
/** 调用方传入的资源摘要;缺的字段不显示 */
|
||
export interface ManualDownloadResource {
|
||
title: string;
|
||
subtitle?: string | null;
|
||
sizeBytes: number | null;
|
||
/** 已识别到的作品 */
|
||
entityId?: string | undefined;
|
||
entity?: { title: string; year: number | null; kind: string } | null;
|
||
siteId?: string;
|
||
/** 没给时按 siteId 查站点名 */
|
||
siteName?: string | null;
|
||
seeders?: number | null;
|
||
leechers?: number | null;
|
||
promotion?: { label: string; free: boolean } | null;
|
||
hr?: "yes" | "no" | "unknown";
|
||
publishedAt?: string | null;
|
||
/** 未识别时身份区的类型缺省值(按标题解析出的季集猜) */
|
||
kindHint?: TmdbType;
|
||
}
|
||
|
||
const AUTO = "__auto__";
|
||
|
||
/** 搜索、工作台和原站视图共用的一次性下载选项。 */
|
||
export function ManualDownloadDialog({
|
||
resource,
|
||
open,
|
||
onOpenChange,
|
||
submit,
|
||
initialUnrecognized = false,
|
||
onDelegate,
|
||
}: {
|
||
resource: ManualDownloadResource;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
|
||
initialUnrecognized?: boolean;
|
||
onDelegate?: () => void;
|
||
}) {
|
||
const formId = useId();
|
||
const entityId = resource.entityId;
|
||
const paths = useDownloadSavePaths(resource.siteId, open);
|
||
const detail = useEntityDetail(open ? entityId : undefined);
|
||
const inventory = useLibraryVersions(open ? entityId : undefined);
|
||
const site = useSite(open && !resource.siteName ? resource.siteId : undefined);
|
||
const [instanceId, setInstanceId] = useState("");
|
||
const [dir, setDir] = useState("");
|
||
const [custom, setCustom] = useState("");
|
||
const [mode, setMode] = useState<"normal" | "replace">("normal");
|
||
const [selected, setSelected] = useState<string[]>([]);
|
||
const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
|
||
const [identityOpen, setIdentityOpen] = useState(!entityId);
|
||
// null = 没动过,显示作品当前的值
|
||
const [tmdbType, setTmdbType] = useState<TmdbType | null>(null);
|
||
const [tmdbId, setTmdbId] = useState<string | null>(null);
|
||
const [picked, setPicked] = useState<{ id: string; title: string } | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [result, setResult] = useState<ManualAcquisitionResult | null>(null);
|
||
|
||
const entity = detail.data?.entity;
|
||
const tmdbIdentity = detail.data?.identities.find(
|
||
(i) => i.provider === "tmdb" && i.status === "accepted" && !i.contentUnitId,
|
||
);
|
||
const currentTmdb = tmdbIdentity
|
||
? { type: (tmdbIdentity.entityType === "movie" ? "movie" : "tv") as TmdbType, id: tmdbIdentity.externalId }
|
||
: null;
|
||
const shownType =
|
||
tmdbType ?? currentTmdb?.type ?? (entityId ? tmdbTypeOf(resource.entity?.kind) : (resource.kindHint ?? "movie"));
|
||
const shownId = tmdbId ?? currentTmdb?.id ?? "";
|
||
const choice: IdentityChoice | null = picked
|
||
? { kind: "entity", id: picked.id, title: picked.title }
|
||
: shownId
|
||
? { kind: "tmdb", type: shownType, id: shownId }
|
||
: null;
|
||
const changed = identityChanged(choice, { entityId, tmdb: currentTmdb });
|
||
|
||
const target = targetOf(paths.data, instanceId);
|
||
const existing = changed ? [] : existingVersions(inventory.data);
|
||
const single = existing.length === 1 ? existing[0]!.assetId : null;
|
||
const effectiveMode = changed ? "normal" : mode;
|
||
|
||
useEffect(() => {
|
||
if (mode === "replace" && single) setSelected([single]);
|
||
}, [mode, single]);
|
||
useEffect(() => {
|
||
if (open) {
|
||
setResult(null);
|
||
setUnrecognized(initialUnrecognized);
|
||
if (initialUnrecognized) setIdentityOpen(true);
|
||
}
|
||
}, [open, initialUnrecognized]);
|
||
|
||
const go = async () => {
|
||
setBusy(true);
|
||
try {
|
||
const r = await submit(
|
||
buildManualOptions({
|
||
entityId,
|
||
currentTmdb,
|
||
identity: choice,
|
||
instanceId,
|
||
target,
|
||
dir,
|
||
custom,
|
||
allowUnrecognized: unrecognized,
|
||
mode: effectiveMode,
|
||
selected,
|
||
}),
|
||
);
|
||
setResult(r);
|
||
if (r.requiresConfirmation) {
|
||
setUnrecognized(true);
|
||
setIdentityOpen(true);
|
||
}
|
||
if (r.submitted) toast.success("已提交下载");
|
||
} catch (e) {
|
||
toast.error(errorMessage(e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const work = resource.entity ?? (entity ? { title: entity.title, year: entity.year, kind: entity.kind } : null);
|
||
const siteName = resource.siteName ?? site.data?.name ?? null;
|
||
const missingCustom = dir === CUSTOM_DIR && !custom.trim();
|
||
const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<div className="flex min-w-0 items-start gap-3">
|
||
{work ? (
|
||
<CoverPlaceholder
|
||
title={work.title}
|
||
kind={work.kind}
|
||
posterPath={posterPathOf(entity?.metadata)}
|
||
small
|
||
className="h-20 w-14"
|
||
/>
|
||
) : (
|
||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full border border-border text-muted-foreground">
|
||
<Download className="h-4 w-4" />
|
||
</span>
|
||
)}
|
||
<div className="min-w-0 space-y-1">
|
||
<DialogTitle className="break-words">
|
||
{work ? `${work.title}${work.year ? ` (${work.year})` : ""}` : "下载这个资源"}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{[siteName, work ? (MEDIA_KIND[work.kind as keyof typeof MEDIA_KIND] ?? null) : "未识别作品"]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</DialogDescription>
|
||
</div>
|
||
</div>
|
||
</DialogHeader>
|
||
<div className="min-w-0 space-y-4">
|
||
<ResourceSummary resource={resource} />
|
||
<SaveTargetFields
|
||
formId={formId}
|
||
instances={paths.data?.instances ?? []}
|
||
autoInstanceId={paths.data?.autoInstanceId ?? null}
|
||
loading={paths.isPending}
|
||
error={paths.error ? errorMessage(paths.error) : null}
|
||
instanceId={instanceId}
|
||
onInstanceChange={(v) => {
|
||
setInstanceId(v);
|
||
if (dir !== CUSTOM_DIR) setDir("");
|
||
}}
|
||
target={target}
|
||
dir={dir}
|
||
onDirChange={setDir}
|
||
custom={custom}
|
||
onCustomChange={setCustom}
|
||
/>
|
||
<IdentitySection
|
||
formId={formId}
|
||
open={identityOpen}
|
||
onToggle={() => setIdentityOpen((v) => !v)}
|
||
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
|
||
unrecognized={!entityId || unrecognized}
|
||
type={shownType}
|
||
onTypeChange={(v) => {
|
||
setTmdbType(v);
|
||
setPicked(null);
|
||
}}
|
||
tmdbId={shownId}
|
||
onTmdbIdChange={(v) => {
|
||
setTmdbId(v.trim());
|
||
setPicked(null);
|
||
}}
|
||
picked={picked}
|
||
onPick={(w) => {
|
||
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
|
||
else {
|
||
setPicked(null);
|
||
setTmdbType(w.tmdb.type);
|
||
setTmdbId(w.tmdb.id);
|
||
}
|
||
}}
|
||
onClearPick={() => setPicked(null)}
|
||
changed={changed}
|
||
subject={resource.title}
|
||
/>
|
||
{entityId && !changed ? (
|
||
<ExistingVersionsSection
|
||
formId={formId}
|
||
loading={inventory.isPending}
|
||
error={!!inventory.error}
|
||
summary={versionSummaryText(inventory.data?.summary)}
|
||
versions={existing}
|
||
mode={mode}
|
||
onModeChange={setMode}
|
||
selected={selected}
|
||
onSelectedChange={setSelected}
|
||
/>
|
||
) : null}
|
||
{result && !result.submitted && !result.requiresConfirmation ? (
|
||
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
|
||
) : null}
|
||
{result?.submitted ? (
|
||
<Link className="text-caption underline" to={`/console/downloads?plan=${result.plan?.id ?? ""}`}>
|
||
查看下载任务
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
<DialogFooter>
|
||
{onDelegate ? (
|
||
<Button
|
||
variant="outline"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
onOpenChange(false);
|
||
onDelegate();
|
||
}}
|
||
>
|
||
<Bot />
|
||
委托智能体
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
onClick={() => void go()}
|
||
disabled={
|
||
busy ||
|
||
!!result?.submitted ||
|
||
missingCustom ||
|
||
badTmdb ||
|
||
(effectiveMode === "replace" && (!selected.length || inventory.isPending || !!inventory.error))
|
||
}
|
||
>
|
||
<Download />
|
||
{busy ? "提交中…" : unrecognized && !changed ? "继续下载" : "开始下载"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
/** 资源行:原始标题、促销、H&R、做种 / 下载、大小、发布时间 */
|
||
export function ResourceSummary({ resource: r }: { resource: ManualDownloadResource }) {
|
||
return (
|
||
<div className="min-w-0 space-y-1.5 rounded-xl border border-border p-3">
|
||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||
<p className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</p>
|
||
<span className="flex flex-wrap gap-1">
|
||
{r.promotion ? (
|
||
<StatusBadge tone={r.promotion.free ? "success" : "neutral"}>{r.promotion.label}</StatusBadge>
|
||
) : null}
|
||
{r.hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||
{r.hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||
</span>
|
||
</div>
|
||
{r.subtitle ? <p className="line-clamp-2 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||
{r.seeders !== undefined || r.leechers !== undefined ? (
|
||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||
<ArrowUp className="h-3 w-3" aria-label="做种" />
|
||
{r.seeders ?? "—"}
|
||
<ArrowDown className="ml-1 h-3 w-3" aria-label="下载" />
|
||
{r.leechers ?? "—"}
|
||
</span>
|
||
) : null}
|
||
{r.publishedAt ? <span title={absTime(r.publishedAt)}>发布 {relTime(r.publishedAt)}</span> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 下载器 + 保存目录并排;下方写明实际会保存到哪 */
|
||
export function SaveTargetFields({
|
||
formId,
|
||
instances,
|
||
autoInstanceId,
|
||
loading = false,
|
||
error = null,
|
||
instanceId,
|
||
onInstanceChange,
|
||
target,
|
||
dir,
|
||
onDirChange,
|
||
custom,
|
||
onCustomChange,
|
||
}: {
|
||
formId: string;
|
||
instances: DownloadSaveTarget[];
|
||
autoInstanceId: string | null;
|
||
loading?: boolean;
|
||
error?: string | null;
|
||
instanceId: string;
|
||
onInstanceChange: (v: string) => void;
|
||
target: DownloadSaveTarget | null;
|
||
dir: string;
|
||
onDirChange: (v: string) => void;
|
||
custom: string;
|
||
onCustomChange: (v: string) => void;
|
||
}) {
|
||
const auto = instances.find((t) => t.instanceId === autoInstanceId);
|
||
const resolved = resolveSavePath(target, dir, custom);
|
||
const pathHint = error
|
||
? error
|
||
: loading
|
||
? "加载中…"
|
||
: !target && dir !== CUSTOM_DIR
|
||
? "没有可用的下载器,先在设置里添加下载器和保存目录"
|
||
: resolved
|
||
? `将保存到:${savePathText(resolved)}`
|
||
: "填写下载器能访问的路径";
|
||
return (
|
||
<div className="grid items-start gap-x-3 sm:grid-cols-2">
|
||
<FormField label="下载器" htmlFor={`${formId}-dl`} hint={!instanceId && auto ? `自动:${auto.name}` : undefined}>
|
||
<Select value={instanceId || AUTO} onValueChange={(v) => onInstanceChange(v === AUTO ? "" : v)}>
|
||
<SelectTrigger id={`${formId}-dl`} className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper">
|
||
<SelectItem value={AUTO}>自动</SelectItem>
|
||
{instances.map((t) => (
|
||
<SelectItem key={t.instanceId} value={t.instanceId}>
|
||
{t.name}
|
||
{t.isDefault ? "(默认)" : ""}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="保存目录" htmlFor={`${formId}-dir`} hint={pathHint} error={error}>
|
||
<Select value={dir || AUTO} onValueChange={(v) => onDirChange(v === AUTO ? "" : v)}>
|
||
<SelectTrigger id={`${formId}-dir`} className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper">
|
||
<SelectItem value={AUTO}>自动</SelectItem>
|
||
{(target?.dirs ?? []).map((d) => (
|
||
<SelectItem key={d.path} value={d.path}>
|
||
{d.path}
|
||
{d.isDefault ? "(默认)" : ""}
|
||
</SelectItem>
|
||
))}
|
||
<SelectItem value={CUSTOM_DIR}>自定义路径…</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</FormField>
|
||
{dir === CUSTOM_DIR ? (
|
||
<FormField
|
||
label="自定义路径"
|
||
htmlFor={`${formId}-custom`}
|
||
hint="下载器路径或本机路径都行,本机路径按路径映射换算"
|
||
className="sm:col-span-2"
|
||
>
|
||
<Input
|
||
id={`${formId}-custom`}
|
||
value={custom}
|
||
onChange={(e) => onCustomChange(e.target.value)}
|
||
placeholder="/downloads/movies"
|
||
/>
|
||
</FormField>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 作品身份:类型 + TMDB 编号(可搜索);已识别时折叠,未识别时默认展开 */
|
||
export function IdentitySection({
|
||
formId,
|
||
open,
|
||
onToggle,
|
||
current,
|
||
unrecognized,
|
||
type,
|
||
onTypeChange,
|
||
tmdbId,
|
||
onTmdbIdChange,
|
||
picked,
|
||
onPick,
|
||
onClearPick,
|
||
changed,
|
||
subject,
|
||
}: {
|
||
formId: string;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
current: { title: string; year: number | null; kind: string; tmdbId: string | null } | null;
|
||
unrecognized: boolean;
|
||
type: TmdbType;
|
||
onTypeChange: (v: TmdbType) => void;
|
||
tmdbId: string;
|
||
onTmdbIdChange: (v: string) => void;
|
||
picked: { id: string; title: string } | null;
|
||
onPick: (w: AssignedWork) => void;
|
||
onClearPick: () => void;
|
||
changed: boolean;
|
||
subject: string;
|
||
}) {
|
||
const [searching, setSearching] = useState(false);
|
||
const valid = /^\d+$/.test(tmdbId);
|
||
const preview = useTmdbMedia(open && !picked && valid && changed ? type : undefined, tmdbId);
|
||
const summary = current
|
||
? [
|
||
`${current.title}${current.year ? ` (${current.year})` : ""}`,
|
||
MEDIA_KIND[current.kind as keyof typeof MEDIA_KIND],
|
||
current.tmdbId ? `TMDB ${current.tmdbId}` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")
|
||
: "未识别";
|
||
return (
|
||
<section className="min-w-0 rounded-xl border border-border">
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
aria-expanded={open}
|
||
aria-controls={`${formId}-identity`}
|
||
className="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-left transition-colors duration-micro hover:bg-wash/[0.06]"
|
||
>
|
||
<span className="shrink-0 text-caption font-medium text-foreground">作品</span>
|
||
<span
|
||
className={cn(
|
||
"min-w-0 flex-1 truncate text-caption",
|
||
changed ? "text-foreground" : current ? "text-muted-foreground" : "text-warning",
|
||
)}
|
||
>
|
||
{changed
|
||
? `改为:${picked ? picked.title : `${type === "movie" ? "电影" : "剧集"} · TMDB ${tmdbId}`}`
|
||
: summary}
|
||
</span>
|
||
<span className="shrink-0 text-meta text-muted-foreground">{open ? "收起" : "修改"}</span>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-base",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
</button>
|
||
{open ? (
|
||
<div id={`${formId}-identity`} className="space-y-3 border-t border-border px-3 pt-3 pb-1">
|
||
{unrecognized && !changed ? (
|
||
<p className="text-caption text-warning">
|
||
暂未识别到作品。指定后绑定到这个资源;不指定也能下载,整理时再补全识别和元数据。
|
||
</p>
|
||
) : null}
|
||
{picked ? (
|
||
<div className="flex min-w-0 items-center gap-2 pb-3 text-sm">
|
||
<span className="min-w-0 flex-1 truncate text-foreground">
|
||
{picked.title}
|
||
<span className="text-muted-foreground"> · 在媒体库</span>
|
||
</span>
|
||
<Button type="button" variant="ghost" size="icon-sm" aria-label="清除选择" onClick={onClearPick}>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="grid items-start gap-x-3 sm:grid-cols-[10rem_1fr]">
|
||
<FormField label="类型" htmlFor={`${formId}-kind`}>
|
||
<Select value={type} onValueChange={(v) => onTypeChange(v as TmdbType)}>
|
||
<SelectTrigger id={`${formId}-kind`} className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper">
|
||
<SelectItem value="movie">电影</SelectItem>
|
||
<SelectItem value="tv">剧集</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</FormField>
|
||
<FormField
|
||
label="TMDB 编号"
|
||
htmlFor={`${formId}-tmdb`}
|
||
error={tmdbId && !valid ? "TMDB 编号是数字" : preview.error ? errorMessage(preview.error) : null}
|
||
hint={
|
||
preview.data
|
||
? `${preview.data.title}${preview.data.year ? ` (${preview.data.year})` : ""}${preview.data.localEntityId ? " · 在媒体库" : ""}`
|
||
: preview.isFetching
|
||
? "查询中…"
|
||
: changed
|
||
? undefined
|
||
: "输入编号,或搜索作品名"
|
||
}
|
||
>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
id={`${formId}-tmdb`}
|
||
inputMode="numeric"
|
||
value={tmdbId}
|
||
onChange={(e) => onTmdbIdChange(e.target.value)}
|
||
placeholder="例如 969681"
|
||
aria-invalid={!!tmdbId && !valid}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="icon"
|
||
aria-label="搜索作品"
|
||
title="搜索作品"
|
||
onClick={() => setSearching(true)}
|
||
>
|
||
<Search />
|
||
</Button>
|
||
</div>
|
||
</FormField>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
<WorkAssignDialog
|
||
open={searching}
|
||
onOpenChange={setSearching}
|
||
initialQuery={current?.title ?? ""}
|
||
subject={subject}
|
||
onAssign={async (w) => onPick(w)}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 已有版本:先一行摘要;选一次性洗版时展开勾选要替换的文件 */
|
||
export function ExistingVersionsSection({
|
||
formId,
|
||
loading,
|
||
error,
|
||
summary,
|
||
versions,
|
||
mode,
|
||
onModeChange,
|
||
selected,
|
||
onSelectedChange,
|
||
}: {
|
||
formId: string;
|
||
loading: boolean;
|
||
error: boolean;
|
||
summary: string | null;
|
||
versions: AssetVersion[];
|
||
mode: "normal" | "replace";
|
||
onModeChange: (m: "normal" | "replace") => void;
|
||
selected: string[];
|
||
onSelectedChange: (ids: string[]) => void;
|
||
}) {
|
||
if (loading) return <p className="text-caption text-muted-foreground">正在读取已有版本…</p>;
|
||
if (error) return <p className="text-caption text-destructive">已有版本读取失败,重试后再选洗版</p>;
|
||
if (!versions.length) return null;
|
||
return (
|
||
<div className="min-w-0 space-y-2">
|
||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||
<p className="min-w-0 text-caption text-foreground">{summary ?? `媒体库已有 ${versions.length} 个版本`}</p>
|
||
<FilterSelect
|
||
label="已有版本"
|
||
value={mode === "normal" ? "" : mode}
|
||
onChange={(v) => onModeChange(v === "replace" ? "replace" : "normal")}
|
||
allLabel="保留"
|
||
options={[{ value: "replace", label: "一次性洗版" }]}
|
||
/>
|
||
</div>
|
||
{mode === "replace" ? (
|
||
<>
|
||
<fieldset className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-3">
|
||
<legend className="px-1 text-caption">选择要替换的已有文件</legend>
|
||
{versions.map((v) => (
|
||
<div key={v.assetId} className="flex items-start gap-2 text-caption">
|
||
<input
|
||
type="checkbox"
|
||
id={`${formId}-${v.assetId}`}
|
||
checked={selected.includes(v.assetId)}
|
||
onChange={(e) =>
|
||
onSelectedChange(
|
||
e.target.checked ? [...selected, v.assetId] : selected.filter((id) => id !== v.assetId),
|
||
)
|
||
}
|
||
/>
|
||
<label htmlFor={`${formId}-${v.assetId}`} className="min-w-0 break-all">
|
||
<VersionDescription version={v} />
|
||
</label>
|
||
</div>
|
||
))}
|
||
</fieldset>
|
||
<p className="text-meta text-muted-foreground">
|
||
本次新文件整理成功后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
|
||
</p>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function VersionDescription({ version }: { version: AssetVersion }) {
|
||
return (
|
||
<span className="min-w-0 break-all">
|
||
{version.fileName}
|
||
<span className="block text-meta text-muted-foreground">
|
||
{[
|
||
version.resolution,
|
||
version.source,
|
||
version.videoCodec,
|
||
...version.hdr,
|
||
version.edition,
|
||
version.sizeBytes ? formatBytes(version.sizeBytes, 1) : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|