merge: copy/c5

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 02:06:02 +08:00
commit 9bc9fdea6d
27 files changed
+233 -241

No files matched your search

+50 -60
View File
@@ -145,7 +145,7 @@ export function UploadButton({ root, path }: { root: FileRoot; path: string }) {
setBusy(null); setBusy(null);
if (input.current) input.current.value = ""; if (input.current) input.current.value = "";
if (ok) toast.success(`已上传 ${ok} 个文件到 ${displayPath(root, path)}`); if (ok) toast.success(`已上传 ${ok} 个文件到 ${displayPath(root, path)}`);
if (failed.length) toast.error(`${failed.length} 个文件没有上传`, { description: failed.join("\n") }); if (failed.length) toast.error(`${failed.length} 个文件上传失败`, { description: failed.join("\n") });
}; };
return ( return (
<span title={root.writable ? undefined : "只读目录,不能上传"}> <span title={root.writable ? undefined : "只读目录,不能上传"}>
@@ -190,7 +190,7 @@ export function MkdirDialog({
onOpenChange(false); onOpenChange(false);
onCreated?.(r.path); onCreated?.(r.path);
}, },
onError: (e) => setErr(filesErrorReason(e) === "exists" ? "同名项已存在" : filesErrorText(e)), onError: (e) => setErr(filesErrorReason(e) === "exists" ? "同名已存在" : filesErrorText(e)),
}, },
); );
}; };
@@ -232,13 +232,13 @@ export function MkdirDialog({
// ─── 重命名 ───────────────────────────────────────────────────── // ─── 重命名 ─────────────────────────────────────────────────────
const SUGGEST_REASON: Record<string, string> = { const SUGGEST_REASON: Record<string, string> = {
identity_uncertain: "识别不可信,没有推荐名", identity_uncertain: "认不准作品,没有推荐名",
}; };
const TEMPLATE_SOURCE: Record<string, string> = { const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖", entity: "作品覆盖",
root: "媒体库目录", root: "媒体库目录",
global: "全局", global: "全局",
override: "覆写", override: "保留原文件名",
}; };
function suggestReasonText(r: string | null): string { function suggestReasonText(r: string | null): string {
@@ -418,17 +418,17 @@ export function RenameDialog({
<div className="-mt-2 space-y-1 text-meta text-muted-foreground"> <div className="-mt-2 space-y-1 text-meta text-muted-foreground">
{suggest.isPending && !suggested ? ( {suggest.isPending && !suggested ? (
<p className="flex items-center gap-1.5"> <p className="flex items-center gap-1.5">
<LoaderCircle className="h-3 w-3 animate-spin" /> 取推荐名… <LoaderCircle className="h-3 w-3 animate-spin" /> 生成推荐名…
</p> </p>
) : suggest.error ? ( ) : suggest.error ? (
<p className="text-destructive">{filesErrorText(suggest.error, "取推荐名失败")}</p> <p className="text-destructive">{filesErrorText(suggest.error, "生成推荐名失败")}</p>
) : suggested && !suggested.name ? ( ) : suggested && !suggested.name ? (
<p>{suggestReasonText(suggested.reason)}</p> <p>{suggestReasonText(suggested.reason)}</p>
) : suggested?.name ? ( ) : suggested?.name ? (
<> <>
<p className="flex flex-wrap items-center gap-x-2"> <p className="flex flex-wrap items-center gap-x-2">
{suggested.name === entry.name {suggested.name === entry.name
? "当前名称与命名模板一致" ? "已符合命名模板"
: usingSuggestion : usingSuggestion
? "已填入推荐名" ? "已填入推荐名"
: `推荐名:${suggested.name}`} : `推荐名:${suggested.name}`}
@@ -460,17 +460,17 @@ export function RenameDialog({
{!isDir ? ( {!isDir ? (
<div className="flex items-center justify-between gap-3 text-caption"> <div className="flex items-center justify-between gap-3 text-caption">
<span> <span>
同名附属文件一起改 附属文件一起改名
<span className="block text-meta text-muted-foreground">主干相同的字幕、NFO、图片</span> <span className="block text-meta text-muted-foreground">主文件名相同的字幕、NFO 和图片</span>
</span> </span>
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" /> <Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="附属文件一起改名" />
</div> </div>
) : null} ) : null}
{changed && !problem && !taken ? ( {changed && !problem && !taken ? (
<div className="space-y-2"> <div className="space-y-2">
{preview.isPending ? ( {preview.isPending ? (
<p className="flex items-center gap-2 text-meta text-muted-foreground"> <p className="flex items-center gap-2 text-meta text-muted-foreground">
<LoaderCircle className="h-3 w-3 animate-spin" /> 预览中 <LoaderCircle className="h-3 w-3 animate-spin" /> 预览中…
</p> </p>
) : null} ) : null}
{pvErr ? <Notice tone="destructive">{filesErrorText(pvErr)}</Notice> : null} {pvErr ? <Notice tone="destructive">{filesErrorText(pvErr)}</Notice> : null}
@@ -487,10 +487,10 @@ export function RenameDialog({
))} ))}
</ul> </ul>
{pv.assetLocations > 0 ? ( {pv.assetLocations > 0 ? (
<Notice tone="neutral">同步 {pv.assetLocations} 条文件位置,写一条整理记录。</Notice> <Notice tone="neutral">会同步 {pv.assetLocations} 条文件位置,并写一条整理记录。</Notice>
) : null} ) : null}
{pv.bindings.length > 0 ? ( {pv.bindings.length > 0 ? (
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后整理用新目录。</Notice> <Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定作废,之后整理用新目录。</Notice>
) : null} ) : null}
</> </>
) : null} ) : null}
@@ -570,11 +570,11 @@ export function TransferDialog({
const ok = r.executed?.succeeded.length ?? 0; const ok = r.executed?.succeeded.length ?? 0;
const failed = r.executed?.failed ?? []; const failed = r.executed?.failed ?? [];
const recordId = r.executed?.recordId; const recordId = r.executed?.recordId;
const msg = `${mode === "move" ? "已移动" : "已复制"} ${ok} 项${failed.length ? `,${failed.length} 项失败` : ""}`; const msg = `${mode === "move" ? "已移动" : "已复制"} ${ok} 项${failed.length ? `,${failed.length} 项失败` : ""}`;
(failed.length ? toast.warning : toast.success)(msg, { (failed.length ? toast.warning : toast.success)(msg, {
description: failed description: failed
.slice(0, 3) .slice(0, 3)
.map((f) => `${baseName(f.path)}:${f.message}`) .map((f) => `${baseName(f.path)}:${f.message}`)
.join("\n"), .join("\n"),
action: recordId action: recordId
? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) } ? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) }
@@ -599,8 +599,8 @@ export function TransferDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle> <DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
<DialogDescription> <DialogDescription>
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 先预览再执行。跨卷移动按复制 {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} ·
→ 核验 → 删源。 跨卷移动会先复制、核验,再删除源文件。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]"> <div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]">
@@ -636,9 +636,7 @@ export function TransferDialog({
}} }}
className="max-h-72 md:max-h-96" className="max-h-72 md:max-h-96"
/> />
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">目标可以是可写的媒体库目录或 Alist 存储。</p>
可选可写的媒体库目录或 Alist 存储;本机和 Alist 之间会上传或下载。
</p>
</div> </div>
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
{target && targetRoot ? ( {target && targetRoot ? (
@@ -647,11 +645,11 @@ export function TransferDialog({
<p className="font-mono text-caption break-all">{displayPath(targetRoot, target.path)}</p> <p className="font-mono text-caption break-all">{displayPath(targetRoot, target.path)}</p>
</div> </div>
) : ( ) : (
<Notice tone="neutral">在左侧选择目标目录。</Notice> <Notice tone="neutral">选择目标目录。</Notice>
)} )}
{dry.isPending ? ( {dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground"> <p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览 <LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览…
</p> </p>
) : null} ) : null}
{err ? <Notice tone="destructive">{err}</Notice> : null} {err ? <Notice tone="destructive">{err}</Notice> : null}
@@ -666,15 +664,15 @@ export function TransferDialog({
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""} {plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
</StatusBadge> </StatusBadge>
{conflicts.length ? ( {conflicts.length ? (
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge> <StatusBadge tone="warning">{conflicts.length} 项目标已存在 · 跳过</StatusBadge>
) : null} ) : null}
{plan.pairs.some((p) => p.registered) ? ( {plan.pairs.some((p) => p.registered) ? (
<StatusBadge>含已整理文件,执行后同步位置</StatusBadge> <StatusBadge>含已整理文件,会同步文件位置</StatusBadge>
) : null} ) : null}
</div> </div>
{hnrBlocked ? ( {hnrBlocked ? (
<Notice tone="destructive"> <Notice tone="destructive">
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。 {plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。可以改用复制。
<button <button
type="button" type="button"
className="ml-1 underline" className="ml-1 underline"
@@ -688,9 +686,7 @@ export function TransferDialog({
</button> </button>
</Notice> </Notice>
) : null} ) : null}
{plan.truncated ? ( {plan.truncated ? <Notice tone="warning">文件太多,只列出前一部分。执行时全部处理。</Notice> : null}
<Notice tone="warning">源文件过多,预览只列出前一部分,执行时处理全部。</Notice>
) : null}
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{plan.pairs.map((p) => { {plan.pairs.map((p) => {
const sr = rootById.get(p.source.root); const sr = rootById.get(p.source.root);
@@ -762,7 +758,7 @@ export function DeleteDialog({
useEffect(() => { useEffect(() => {
checkMutate( checkMutate(
{ idempotencyKey: `${key}-check`, items, mode: "trash", dryRun: true, retireAssets: false, unbind: false }, { idempotencyKey: `${key}-check`, items, mode: "trash", dryRun: true, retireAssets: false, unbind: false },
{ onError: (e) => setErr(filesErrorText(e, "引用检查失败")) }, { onError: (e) => setErr(filesErrorText(e, "删除前检查失败")) },
); );
}, [checkMutate, items, key]); }, [checkMutate, items, key]);
@@ -803,7 +799,7 @@ export function DeleteDialog({
(r.failed.length ? toast.warning : toast.success)( (r.failed.length ? toast.warning : toast.success)(
mode === "trash" ? `已移入回收站 ${n} 项` : `已永久删除 ${n} 项`, mode === "trash" ? `已移入回收站 ${n} 项` : `已永久删除 ${n} 项`,
{ {
description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined, description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined,
action: action:
mode === "trash" mode === "trash"
? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") } ? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") }
@@ -816,7 +812,7 @@ export function DeleteDialog({
const reason = filesErrorReason(e); const reason = filesErrorReason(e);
setErr( setErr(
reason === "permanent_disabled" reason === "permanent_disabled"
? "设置里未开启永久删除(files.allowPermanentDelete)" ? "永久删除没有开启。改用回收站"
: reason === "approval_required" : reason === "approval_required"
? "用 API Key 删除,每次都要审批" ? "用 API Key 删除,每次都要审批"
: filesErrorText(e), : filesErrorText(e),
@@ -829,13 +825,13 @@ export function DeleteDialog({
<Dialog open onOpenChange={(o) => !o && onClose()}> <Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl"> <DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle> <DialogTitle>{single ? `删除「${single}」?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogDescription>已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。</DialogDescription> <DialogDescription>已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto"> <div className="min-h-0 space-y-3 overflow-y-auto">
{check.isPending ? ( {check.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground"> <p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 引用检查中… <LoaderCircle className="h-4 w-4 animate-spin" /> 检查中…
</p> </p>
) : null} ) : null}
{err ? <Notice tone="destructive">{err}</Notice> : null} {err ? <Notice tone="destructive">{err}</Notice> : null}
@@ -876,16 +872,14 @@ export function DeleteDialog({
{d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"} {d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}
</p> </p>
))} ))}
{c.assets.length ? ( {c.assets.length ? <p>含 {c.assets.length} 个已整理文件</p> : null}
<p>已整理文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)</p>
) : null}
{c.bindings.map((bd) => ( {c.bindings.map((bd) => (
<p key={bd.bindingId}> <p key={bd.bindingId}>
路径绑定:{bd.title} 路径绑定:{bd.title}
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""} {bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
</p> </p>
))} ))}
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null} {c.sharedInode ? <p>硬链接:只删这一处,其他位置的文件不受影响</p> : null}
</div> </div>
</li> </li>
); );
@@ -898,7 +892,7 @@ export function DeleteDialog({
<span> <span>
同时从媒体库移除 同时从媒体库移除
<span className="block text-meta text-muted-foreground"> <span className="block text-meta text-muted-foreground">
这些文件从媒体库记录中移除;已满足的收录版本退回待处理。 已整理文件从媒体库移除,用到它们的收录版本改为需处理。
</span> </span>
</span> </span>
</label> </label>
@@ -908,9 +902,7 @@ export function DeleteDialog({
<input type="checkbox" className="mt-1" checked={unbind} onChange={(e) => setUnbind(e.target.checked)} /> <input type="checkbox" className="mt-1" checked={unbind} onChange={(e) => setUnbind(e.target.checked)} />
<span> <span>
解除路径绑定 解除路径绑定
<span className="block text-meta text-muted-foreground"> <span className="block text-meta text-muted-foreground">绑定作废,这个作品下次整理时重新选目录。</span>
绑定标为已替代,该作品下次整理重新选目录。
</span>
</span> </span>
</label> </label>
) : null} ) : null}
@@ -931,7 +923,7 @@ export function DeleteDialog({
</label> </label>
<label <label
className={cn("flex items-start gap-2.5 text-caption", !isAdmin && "opacity-50")} className={cn("flex items-start gap-2.5 text-caption", !isAdmin && "opacity-50")}
title={isAdmin ? undefined : "仅管理员会话可永久删除"} title={isAdmin ? undefined : "只有管理员会话可以永久删除"}
> >
<input <input
type="radio" type="radio"
@@ -944,7 +936,9 @@ export function DeleteDialog({
<span> <span>
永久删除 永久删除
<span className="block text-meta text-muted-foreground"> <span className="block text-meta text-muted-foreground">
{isAdmin ? "不可恢复。需要在设置里开启。" : "仅管理员会话可用。"} {isAdmin
? "无法恢复。部署时设置 FILES_ALLOW_PERMANENT_DELETE=true 才能用。"
: "只有管理员会话可用。"}
</span> </span>
</span> </span>
</label> </label>
@@ -952,7 +946,7 @@ export function DeleteDialog({
<FormField <FormField
label={ label={
<> <>
输入 <span className="font-mono">{confirmPhrase}</span> 以确认 输入 <span className="font-mono">{confirmPhrase}</span> 确认
</> </>
} }
htmlFor="del-confirm" htmlFor="del-confirm"
@@ -998,7 +992,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
await navigator.clipboard.writeText(v); await navigator.clipboard.writeText(v);
toast.success("已复制"); toast.success("已复制");
} catch { } catch {
toast.error("浏览器不允许写剪贴板"); toast.error("复制失败,浏览器不允许访问剪贴板");
} }
}; };
return ( return (
@@ -1051,10 +1045,8 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
{ {
onSuccess: (r) => { onSuccess: (r) => {
setResult(r); setResult(r);
const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : ""; const skipped = r.skipped.length ? `,${r.skipped.length} 个跳过` : "";
(r.skipped.length ? toast.warning : toast.success)( (r.skipped.length ? toast.warning : toast.success)(`已提交 ${r.submitted.length} 个作品${skipped}`);
`已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
);
}, },
onError: (e) => toast.error(filesErrorText(e, "提交失败")), onError: (e) => toast.error(filesErrorText(e, "提交失败")),
}, },
@@ -1067,7 +1059,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
刷新元数据 刷新元数据
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`} {single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
</DialogTitle> </DialogTitle>
<DialogDescription>按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。</DialogDescription> <DialogDescription>从 TMDB 刷新,锁定的字段和路径不变,不写目录里的文件。</DialogDescription>
</DialogHeader> </DialogHeader>
{!single && !result ? ( {!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption"> <ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
@@ -1121,7 +1113,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
// ─── 刮削(SCR-2) ───────────────────────────────────────── // ─── 刮削(SCR-2) ─────────────────────────────────────────
const SKIP_TEXT: Record<string, string> = { const SKIP_TEXT: Record<string, string> = {
scrape_root_read_only: "只登记模式的目录,不写文件", scrape_root_read_only: "只读目录,不写文件",
scrape_not_video: "不是视频文件或目录", scrape_not_video: "不是视频文件或目录",
scrape_no_videos: "目录里没有视频", scrape_no_videos: "目录里没有视频",
}; };
@@ -1166,7 +1158,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
setSkipped((cur) => [...cur, ...r.skipped.map((x) => ({ path: x.path, reason: x.reason }))]); setSkipped((cur) => [...cur, ...r.skipped.map((x) => ({ path: x.path, reason: x.reason }))]);
if (r.entities.length) toast.success(`已加入媒体库:${r.entities.map((e) => e.title).join("、")}`); if (r.entities.length) toast.success(`已加入媒体库:${r.entities.map((e) => e.title).join("、")}`);
}, },
onError: (e) => toast.error(filesErrorText(e, "刮削没有提交")), onError: (e) => toast.error(filesErrorText(e, "刮削提交失败")),
}, },
); );
const ready = choices.filter((c) => picked[c.path]); const ready = choices.filter((c) => picked[c.path]);
@@ -1178,9 +1170,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle> <DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
<DialogDescription> <DialogDescription>按路径识别作品,在文件所在目录写 NFO 和图片。没整理过的文件也能刮削。</DialogDescription>
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没整理的也能刮削。
</DialogDescription>
</DialogHeader> </DialogHeader>
{!started ? ( {!started ? (
<div className="grid gap-3"> <div className="grid gap-3">
@@ -1193,8 +1183,8 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
))} ))}
</ul> </ul>
) : null} ) : null}
<FormField label="已有文件"> <FormField label="覆盖方式">
<fieldset aria-label="已有文件" className="m-0 flex flex-wrap gap-1.5 border-0 p-0"> <fieldset aria-label="覆盖方式" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
{( {(
[ [
["update", "更新"], ["update", "更新"],
@@ -1220,7 +1210,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
</fieldset> </fieldset>
</FormField> </FormField>
{curMode === "overwrite" ? ( {curMode === "overwrite" ? (
<p className="text-meta text-warning">目录里已有的 NFO 和图片会被替换,包括其他工具写的。</p> <p className="text-meta text-warning">目录里已有的 NFO 和图片都会被替换,包括其他工具写的。</p>
) : null} ) : null}
</div> </div>
) : ( ) : (
@@ -1230,7 +1220,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
<p className="text-sm font-medium">认不准作品,选一个再刮削</p> <p className="text-sm font-medium">认不准作品,选一个再刮削</p>
{choices.map((c) => ( {choices.map((c) => (
<div key={c.path} className="grid gap-1.5 rounded-lg border border-border/60 px-3 py-2"> <div key={c.path} className="grid gap-1.5 rounded-lg border border-border/60 px-3 py-2">
<p className="text-caption break-all">{c.path || "目录根"}</p> <p className="text-caption break-all">{c.path || "(登记目录本身)"}</p>
<EntityPicker <EntityPicker
candidates={c.candidates} candidates={c.candidates}
value={picked[c.path] ?? null} value={picked[c.path] ?? null}
@@ -1263,7 +1253,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
{recordIds.length && !recs.length ? <p className="text-caption text-muted-foreground">加载中…</p> : null} {recordIds.length && !recs.length ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
{skipped.map((x) => ( {skipped.map((x) => (
<p key={x.path} className="text-meta text-warning break-all"> <p key={x.path} className="text-meta text-warning break-all">
{x.path || "目录根"}:{SKIP_TEXT[x.reason] ?? x.reason} {x.path || "(登记目录本身)"}:{SKIP_TEXT[x.reason] ?? x.reason}
</p> </p>
))} ))}
</div> </div>
+3 -3
View File
@@ -230,7 +230,7 @@ function Children({
className="flex items-center gap-2 py-1 text-meta text-muted-foreground" className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
style={{ paddingLeft: depth * 14 + 28 }} style={{ paddingLeft: depth * 14 + 28 }}
> >
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中 <LoaderCircle className="h-3 w-3 animate-spin" /> 加载中…
</div> </div>
); );
if (q.error) if (q.error)
@@ -242,7 +242,7 @@ function Children({
if (!dirs.length) if (!dirs.length)
return ( return (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}> <p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
无子目录 没有子目录
</p> </p>
); );
return ( return (
@@ -293,7 +293,7 @@ function Children({
{paginate && dirs.length > 0 ? <Pagination {...paged.pagination} /> : null} {paginate && dirs.length > 0 ? <Pagination {...paged.pagination} /> : null}
{q.data?.truncated ? ( {q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}> <p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
子目录过多,只列出一部分 子目录太多,只列出一部分
</p> </p>
) : null} ) : null}
</div> </div>
@@ -43,8 +43,10 @@ export function DirectoryDialog({
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-3xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === "source" ? "选择来源目录" : "选择媒体库目标目录"}</DialogTitle> <DialogTitle>{mode === "source" ? "选择来源目录" : "选择目标目录"}</DialogTitle>
<DialogDescription>展开登记目录,选择一个目录后确认。</DialogDescription> <DialogDescription>
{mode === "source" ? "可以选下载目录或媒体库目录。" : "只能选可写的媒体库目录。"}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="max-h-80 overflow-auto rounded-xl border p-2"> <div className="max-h-80 overflow-auto rounded-xl border p-2">
@@ -56,7 +58,7 @@ export function DirectoryDialog({
paginate paginate
/> />
{roots.isPending ? ( {roots.isPending ? (
<p>读取目录中…</p> <p className="text-caption text-muted-foreground">加载中…</p>
) : !available.length ? ( ) : !available.length ? (
<p className="text-caption text-muted-foreground">没有可用的登记目录。</p> <p className="text-caption text-muted-foreground">没有可用的登记目录。</p>
) : null} ) : null}
@@ -75,12 +77,12 @@ export function DirectoryDialog({
</Button> </Button>
) : null} ) : null}
{!selected ? ( {!selected ? (
<p className="text-caption text-muted-foreground">在左侧选择登记目录,查看内容。</p> <p className="text-caption text-muted-foreground">选择一个登记目录。</p>
) : listing.isPending ? ( ) : listing.isPending ? (
<p className="text-caption">读取目录中…</p> <p className="text-caption">加载中…</p>
) : listing.error ? ( ) : listing.error ? (
<p role="alert" className="text-caption text-destructive"> <p role="alert" className="text-caption text-destructive">
{filesErrorText(listing.error, "目录不可读取")} {filesErrorText(listing.error, "读取目录失败")}
</p> </p>
) : !entries.length ? ( ) : !entries.length ? (
<p className="text-caption text-muted-foreground">目录为空。</p> <p className="text-caption text-muted-foreground">目录为空。</p>
@@ -110,15 +112,15 @@ export function DirectoryDialog({
</div> </div>
{roots.error ? ( {roots.error ? (
<p role="alert" className="text-destructive"> <p role="alert" className="text-destructive">
读取目录失败,请关闭后重试。 读取登记目录失败。关闭后重试
</p> </p>
) : null} ) : null}
<p className="break-all font-mono text-meta"> <p className="break-all font-mono text-meta">
{root ? `${root.name} / ${selected?.path || "(根目录)"}` : "尚未选择"} {root ? `${root.name} / ${selected?.path || "(登记目录本身)"}` : "未选择目录"}
</p> </p>
{!allowRoot && selected && !selected.path ? ( {!allowRoot && selected && !selected.path ? (
<p role="status" className="text-caption text-warning"> <p role="status" className="text-caption text-warning">
请选择子目录,或先新增来源配置,再整理根目录存量。 不能直接整理登记目录本身。选一个子目录,或在设置里新增整理来源后用「整理存量」。
</p> </p>
) : null} ) : null}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -129,7 +131,7 @@ export function DirectoryDialog({
disabled={!root || !directorySelectionAllowed(selected, allowRoot) || listing.isPending || !!listing.error} disabled={!root || !directorySelectionAllowed(selected, allowRoot) || listing.isPending || !!listing.error}
onClick={() => selected && directorySelectionAllowed(selected, allowRoot) && onSelect(selected)} onClick={() => selected && directorySelectionAllowed(selected, allowRoot) && onSelect(selected)}
> >
使用此目录 使用这个目录
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -119,7 +119,7 @@ export function FileDetail({
label: "链接数", label: "链接数",
value: value:
stat.data?.nlink != null stat.data?.nlink != null
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}` ? `${stat.data.nlink}${stat.data.sharedInode ? "(硬链接,不能原地改写)" : ""}`
: null, : null,
}, },
{ {
@@ -163,7 +163,7 @@ export function FileDetail({
{e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"} {e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"}
</Link> </Link>
) : ( ) : (
"未满足任何收录版本" "未关联收录版本"
), ),
}, },
{ label: "主文件", value: e.asset.isPrimary ? "是" : "否" }, { label: "主文件", value: e.asset.isPrimary ? "是" : "否" },
@@ -215,7 +215,7 @@ export function FileDetail({
</li> </li>
))} ))}
</ul> </ul>
<p className="mt-1 text-meta text-muted-foreground">改名或移动此目录会同步更新绑定;刷新元数据不改路径。</p> <p className="mt-1 text-meta text-muted-foreground">改名或移动这个目录会同步更新绑定。刷新元数据不改路径。</p>
</DetailSection> </DetailSection>
) : null} ) : null}
@@ -245,7 +245,7 @@ export function FileDetail({
{ {
onSuccess: (r) => { onSuccess: (r) => {
const it = r.items[0]; const it = r.items[0];
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`); if (it?.status !== "ok") toast.warning(`没有探测到媒体信息${it?.reason ? `:${it.reason}` : ""}`);
}, },
onError: (err) => toast.error(filesErrorText(err, "探测失败")), onError: (err) => toast.error(filesErrorText(err, "探测失败")),
}, },
@@ -278,7 +278,7 @@ export function FileDetail({
/> />
) : ( ) : (
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
未登记文件不自动探测。点「探测」读取 ffprobe 摘要,结果缓存 24 小时。 未整理的文件不自动探测。点「探测」用 ffprobe 读取,结果缓存 24 小时。
</p> </p>
)} )}
</DetailSection> </DetailSection>
+1 -1
View File
@@ -562,7 +562,7 @@ export function FileList({
</div> </div>
{loadingMore ? ( {loadingMore ? (
<div className="flex items-center justify-center gap-2 py-3 text-meta text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-3 text-meta text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 加载下一页 <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 加载中…
</div> </div>
) : null} ) : null}
{footer} {footer}
@@ -190,7 +190,7 @@ function ImageView({
className="max-h-[28rem] rounded-xl border border-border object-contain" className="max-h-[28rem] rounded-xl border border-border object-contain"
/> />
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground"> <span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口看原图 <ExternalLink className="h-3 w-3" /> 在新窗口打开原图
</span> </span>
</a> </a>
</> </>
@@ -52,12 +52,12 @@ export function IdentifyPanel({
> >
{identify.isPending ? ( {identify.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground"> <p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 匹配本地作品与 TMDB… <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 匹配本地作品和 TMDB…
</p> </p>
) : identify.error ? ( ) : identify.error ? (
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p> <p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
) : !identify.data ? ( ) : !identify.data ? (
<p className="text-caption text-muted-foreground">按文件名解析,匹配本地作品和 TMDB。只读,不改文件。</p> <p className="text-caption text-muted-foreground">按文件名匹配本地作品和 TMDB,不改文件。</p>
) : !it ? ( ) : !it ? (
<p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p> <p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p>
) : ( ) : (
@@ -98,7 +98,7 @@ export function IdentifyPanel({
</p> </p>
) : null} ) : null}
{it.skipped === "below_min_size" ? ( {it.skipped === "below_min_size" ? (
<p className="text-caption text-muted-foreground">低于最小体积,没有查候选。</p> <p className="text-caption text-muted-foreground">文件小于最小体积,没有查找候选。</p>
) : null} ) : null}
{it.warnings.map((w) => ( {it.warnings.map((w) => (
<p key={w} className="text-caption text-warning"> <p key={w} className="text-caption text-warning">
@@ -132,7 +132,7 @@ export function IdentifyPanel({
{c.year ? ` (${c.year})` : ""} {c.year ? ` (${c.year})` : ""}
</span> </span>
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge> <StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
{c.mediaEntityId ? null : <StatusBadge tone="warning">仅 TMDB</StatusBadge>} {c.mediaEntityId ? null : <StatusBadge tone="warning">不在媒体库</StatusBadge>}
</p> </p>
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
{[ {[
+1 -1
View File
@@ -951,7 +951,7 @@ function organize(body: {
recordId: body.dryRun || conflict ? null : `rec-${++seq}`, recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
taskId: body.dryRun || conflict ? null : `task-${seq}`, taskId: body.dryRun || conflict ? null : `task-${seq}`,
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [], warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按「交给待处理」整组不执行" } : null, error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,整组不执行" } : null,
action: body.action, action: body.action,
entity: items.some((x) => x.external) entity: items.some((x) => x.external)
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" } ? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
+2 -2
View File
@@ -312,7 +312,7 @@ describe("transferTargetProblem(U13)", () => {
]; ];
it("目标就是源所在目录 / 在源目录之内时拦下", () => { it("目标就是源所在目录 / 在源目录之内时拦下", () => {
expect(transferTargetProblem(items, { root: "dl", path: "a" })).toMatch(/已在这个目录里/); expect(transferTargetProblem(items, { root: "dl", path: "a" })).toMatch(/已在这个目录里/);
expect(transferTargetProblem(items, { root: "dl", path: "pack/sub" })).toMatch(/目标在源目录之内/); expect(transferTargetProblem(items, { root: "dl", path: "pack/sub" })).toMatch(/目标不能是 pack 或它的子目录/);
expect(transferTargetProblem([{ root: "dl", path: "x.mkv" }], { root: "dl", path: "" })).toMatch(/已在/); expect(transferTargetProblem([{ root: "dl", path: "x.mkv" }], { root: "dl", path: "" })).toMatch(/已在/);
}); });
it("其他目录、其他根放行", () => { it("其他目录、其他根放行", () => {
@@ -370,7 +370,7 @@ describe("识别远端回填(S-7)", () => {
{ ...other, taskId: "run-1", status: "failed", error: "超时" } as never, { ...other, taskId: "run-1", status: "failed", error: "超时" } as never,
); );
expect(f.remoteTaskId).toBeNull(); expect(f.remoteTaskId).toBeNull();
expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]); expect(f.items[0]?.warnings).toEqual(["TMDB 查询失败,只显示本地结果:超时"]);
}); });
}); });
+9 -9
View File
@@ -15,11 +15,11 @@ export type StatusView = { label: string; tone: StatusTone; hint?: string };
/** 行主状态 → 徽标(文字必带,不只靠颜色) */ /** 行主状态 → 徽标(文字必带,不只靠颜色) */
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null { export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" }; if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,不能操作" };
const s: FileStatus = e.status; const s: FileStatus = e.status;
switch (s) { switch (s) {
case "temp": case "temp":
return { label: "未完成", tone: "neutral", hint: "下载器或整理过程中的未完成文件" }; return { label: "未完成", tone: "neutral", hint: "下载或整理还没完成的临时文件" };
case "downloading": case "downloading":
return { return {
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中", label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
@@ -44,7 +44,7 @@ export const FILTER_CHIPS = [
{ value: "unidentified", label: "未识别" }, { value: "unidentified", label: "未识别" },
{ value: "registered", label: "已整理" }, { value: "registered", label: "已整理" },
{ value: "downloading", label: "下载中" }, { value: "downloading", label: "下载中" },
{ value: "temp", label: "未完成下载" }, { value: "temp", label: "未完成" },
] as const; ] as const;
export const SORT_OPTIONS = [ export const SORT_OPTIONS = [
@@ -100,7 +100,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
case "scrape": case "scrape":
return e.asset ? null : "整理后才能刷新元数据"; return e.asset ? null : "整理后才能刷新元数据";
case "writeNfo": case "writeNfo":
if (root.kind === "library" && !root.writable) return "只读目录,不写 NFO"; if (root.kind === "library" && !root.writable) return "只读目录,不能刮削";
if (e.status === "temp") return "未完成的文件不能刮削"; if (e.status === "temp") return "未完成的文件不能刮削";
return null; return null;
case "probe": case "probe":
@@ -201,7 +201,7 @@ export function transferTargetProblem(
for (const it of items) { for (const it of items) {
if (it.root !== target.root) continue; if (it.root !== target.root) continue;
if (target.path === it.path || target.path.startsWith(`${it.path}/`)) if (target.path === it.path || target.path.startsWith(`${it.path}/`))
return `目标在源目录之内(${baseName(it.path)}),换一个目录`; return `目标不能是 ${baseName(it.path)} 或它的子目录,换一个目录`;
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`; if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
} }
return null; return null;
@@ -226,11 +226,11 @@ export function collapseCrumbs<T>(items: T[], max: number): (T | null)[] {
export function nameProblem(name: string): string | null { export function nameProblem(name: string): string | null {
const n = name.trim(); const n = name.trim();
if (!n) return "名称不能为空"; if (!n) return "名称不能为空";
if (n === "." || n === "..") return "不能用 . 或 .."; if (n === "." || n === "..") return "名称不能是 . 或 ..";
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\"; if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符 // biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符"; if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
if (n.length > 255) return "名称过长"; if (n.length > 255) return "名称不能超过 255 个字符";
return null; return null;
} }
@@ -318,7 +318,7 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
case "needs_unbind": case "needs_unbind":
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true }; return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
case "root_unavailable": case "root_unavailable":
return { text: "目录离线,不能确认文件状态", soft: false }; return { text: "目录离线,无法确认文件状态", soft: false };
case "not_found": case "not_found":
return { text: "文件已不存在", soft: false }; return { text: "文件已不存在", soft: false };
case "escape": case "escape":
@@ -557,7 +557,7 @@ const REMOTE_PENDING_WARNING = /^远端候选在后台查询/;
export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult { export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult {
if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r; if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r;
if (p.status === "failed") { if (p.status === "failed") {
const note = `远端查询失败,只有本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`; const note = `TMDB 查询失败,只显示本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`;
return { return {
...r, ...r,
remoteTaskId: null, remoteTaskId: null,
@@ -9,11 +9,11 @@ const KEYS: [string, string][] = [
["Ctrl/⌘+A", "全选"], ["Ctrl/⌘+A", "全选"],
["Ctrl/⌘+Enter", "整理选中项"], ["Ctrl/⌘+Enter", "整理选中项"],
["F2", "重命名"], ["F2", "重命名"],
["Del", "删除(先进回收站)"], ["Del", "删除(移入回收站)"],
["/", "搜索当前目录"], ["/", "搜索当前目录"],
["Esc", "清除选择"], ["Esc", "清除选择"],
["Shift+F10 / 菜单键", "打开操作菜单"], ["Shift+F10 / 菜单键", "打开操作菜单"],
["拖到目录", "移动到(按住 Alt 复制),先出预览"], ["拖到目录", "移动(按住 Alt 复制),先预览"],
["?", "显示这张表"], ["?", "显示这张表"],
]; ];
@@ -177,7 +177,7 @@ export function EntityPicker({
) : null} ) : null}
{remote.length ? ( {remote.length ? (
<> <>
<SectionTitle>TMDB 候选 · 选中后执行时新建作品</SectionTitle> <SectionTitle>TMDB 候选 · 选中后整理时新建作品</SectionTitle>
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{remote.map(({ c, e }) => ( {remote.map(({ c, e }) => (
<Option <Option
@@ -269,7 +269,7 @@ export function EntityPicker({
</ul> </ul>
{!hits.local.length && !hits.tmdb.length && !search.isFetching ? ( {!hits.local.length && !hits.tmdb.length && !search.isFetching ? (
<p className="px-2 py-1.5 text-meta text-muted-foreground"> <p className="px-2 py-1.5 text-meta text-muted-foreground">
没有匹配的作品{hits.tmdbConfigured ? "" : ";TMDB 未配置,只搜了本地"} 没有匹配的作品{hits.tmdbConfigured ? "" : "。TMDB 未配置,只搜了本地作品"}
</p> </p>
) : null} ) : null}
</> </>
@@ -130,7 +130,7 @@ describe("识别结果 → 行", () => {
it("只允许单集或连续集号,不让人工覆写被后端忽略", () => { it("只允许单集或连续集号,不让人工覆写被后端忽略", () => {
const r = { ...(rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow), episodeTouched: true }; const r = { ...(rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow), episodeTouched: true };
expect(rowProblem({ ...r, episodes: "3,5" })).toBe("集号需要连续,如 3-5"); expect(rowProblem({ ...r, episodes: "3,5" })).toBe("集号必须连续,例如 3-5");
expect(wizardStats([{ ...r, episodes: "3,5" }]).pending).toBe(1); expect(wizardStats([{ ...r, episodes: "3,5" }]).pending).toBe(1);
expect(rowProblem({ ...r, episodes: "4,3,4" })).toBeNull(); expect(rowProblem({ ...r, episodes: "4,3,4" })).toBeNull();
expect(rowProblem({ ...r, episodes: "3-5" })).toBeNull(); expect(rowProblem({ ...r, episodes: "3-5" })).toBeNull();
@@ -63,12 +63,12 @@ export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null; if (r.excluded) return null;
if (!r.entity) return "未选择作品"; if (!r.entity) return "未选择作品";
if (isMovieKind(r.entity.kind)) return null; if (isMovieKind(r.entity.kind)) return null;
if (!r.episodeTouched) return episodeUnresolved(r) ? "集数待确认,请填写季集或排除" : null; if (!r.episodeTouched) return episodeUnresolved(r) ? "集数待确认,填写季集或排除" : null;
const eps = parseEpisodes(r.episodes); const eps = parseEpisodes(r.episodes);
if (eps === null) return "集号格式不对,请填写单集或连续区间,如 3、3-5"; if (eps === null) return "集号格式不对,填单集或连续区间,例如 3 或 3-5";
const unique = [...new Set(eps)].sort((a, b) => a - b); const unique = [...new Set(eps)].sort((a, b) => a - b);
if (eps.length > 50) return "单个文件最多指定 50 个集号"; if (eps.length > 50) return "单个文件最多指定 50 个集号";
if (unique.some((n, i) => i > 0 && n !== unique[i - 1]! + 1)) return "集号需要连续,如 3-5"; if (unique.some((n, i) => i > 0 && n !== unique[i - 1]! + 1)) return "集号必须连续,例如 3-5";
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字"; if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (!r.season.trim()) return "剧集需要季号"; if (!r.season.trim()) return "剧集需要季号";
return null; return null;
@@ -439,12 +439,12 @@ export const EXCLUDE_REASON: Record<string, string> = {
extra_content: "额外内容", extra_content: "额外内容",
no_target_dir: "没有目标目录", no_target_dir: "没有目标目录",
naming_failed: "命名失败", naming_failed: "命名失败",
title_mismatch: "标题不是本作品", title_mismatch: "标题与作品不符",
}; };
export const SKIP_REASON: Record<string, string> = { export const SKIP_REASON: Record<string, string> = {
successful_record: "已有成功整理记录", successful_record: "已有成功整理记录",
already_in_library: "已在库中", already_in_library: "已在媒体库",
target_exists: "目标已存在", target_exists: "目标已存在",
main_not_transferred: "主文件未整理", main_not_transferred: "主文件未整理",
duplicate_target: "改名后与其他文件同名", duplicate_target: "改名后与其他文件同名",
@@ -139,7 +139,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
const remote = useRef({ waiting: new Set<string>(), early: new Map<string, IdentifyCompletedPayload>() }); const remote = useRef({ waiting: new Set<string>(), early: new Map<string, IdentifyCompletedPayload>() });
const applyRemote = useCallback((p: IdentifyCompletedPayload) => { const applyRemote = useCallback((p: IdentifyCompletedPayload) => {
if (p.status === "succeeded") setRows((rs) => mergeIdentified(rs, p.items)); if (p.status === "succeeded") setRows((rs) => mergeIdentified(rs, p.items));
else toast.error(`远端候选查询失败${p.error ? `:${p.error.slice(0, 120)}` : ""}`); else toast.error(`TMDB 查询失败,只显示本地结果${p.error ? `:${p.error.slice(0, 120)}` : ""}`);
}, []); }, []);
const expectRemote = useCallback( const expectRemote = useCallback(
(taskId: string | null | undefined) => { (taskId: string | null | undefined) => {
@@ -251,7 +251,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
setRows((rs) => mergeIdentified(rs, r.items)); setRows((rs) => mergeIdentified(rs, r.items));
expectRemote(r.remoteTaskId); expectRemote(r.remoteTaskId);
}, },
onError: (e) => toast.error(filesErrorText(e, "补充识别失败")), onError: (e) => toast.error(filesErrorText(e, "重新识别失败")),
}, },
); );
}; };
@@ -282,9 +282,9 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
year: m.year, year: m.year,
kind: m.kind, kind: m.kind,
}); });
toast.success(`已定位 ${m.title}`); toast.success(`已找到「${m.title}」`);
} catch (e) { } catch (e) {
if (mounted.current && sequence === lookupSequence.current) toast.error(filesErrorText(e, "TMDB 定位失败")); if (mounted.current && sequence === lookupSequence.current) toast.error(filesErrorText(e, "按 TMDB ID 查找失败"));
} finally { } finally {
if (mounted.current && sequence === lookupSequence.current) setLookupPending(false); if (mounted.current && sequence === lookupSequence.current) setLookupPending(false);
} }
@@ -323,7 +323,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
{ {
onSuccess: (r) => { onSuccess: (r) => {
if (signatureRef.current !== snapshot) { if (signatureRef.current !== snapshot) {
setNotice("选项或识别结果已变化,请重新生成预览。"); setNotice("选项或识别结果有变化,重新生成预览");
return; return;
} }
setPreview({ groups: r.groups, rows: effectiveRows, key, signature: snapshot }); setPreview({ groups: r.groups, rows: effectiveRows, key, signature: snapshot });
@@ -355,9 +355,9 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
onSuccess: (r) => { onSuccess: (r) => {
const failed = r.groups.filter((g) => !!g.error); const failed = r.groups.filter((g) => !!g.error);
const accepted = r.groups.filter((g) => !!g.recordId && !g.error).length; const accepted = r.groups.filter((g) => !!g.recordId && !g.error).length;
if (accepted) toast.success(`已加入整理队列:${accepted} 组`); if (accepted) toast.success(`${accepted} 组已加入队列`);
if (failed.length) { if (failed.length) {
setNotice(`${accepted} 组已受理,${failed.length} 组未加入队列;请检查下方原因,修改后重新预览。`); setNotice(`${accepted} 组已加入队列,${failed.length} 组没有加入。修改后重新生成预览`);
const acceptedSources = new Set( const acceptedSources = new Set(
r.groups.filter((g) => !!g.recordId && !g.error).flatMap((g) => g.files.map((f) => f.source)), r.groups.filter((g) => !!g.recordId && !g.error).flatMap((g) => g.files.map((f) => f.source)),
); );
@@ -368,7 +368,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
}), }),
); );
setPreview(null); setPreview(null);
toast.error(failed[0]?.error?.message ?? "部分任务未能加入队列"); toast.error(failed[0]?.error?.message ?? `${failed.length} 组没有加入队列`);
} else { } else {
onClose(); onClose();
} }
@@ -376,7 +376,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
onError: (e) => { onError: (e) => {
if (filesErrorReason(e) === "plan_stale") { if (filesErrorReason(e) === "plan_stale") {
setPreview(null); setPreview(null);
setNotice("源文件已变化,请重新生成预览。"); setNotice("源文件有变化,重新生成预览");
} }
toast.error(filesErrorText(e, "加入队列失败")); toast.error(filesErrorText(e, "加入队列失败"));
}, },
@@ -392,15 +392,13 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
const columns: DataColumn<WizardRow>[] = [ const columns: DataColumn<WizardRow>[] = [
{ {
key: "file", key: "file",
header: "本次文件", header: "文件",
cell: (r) => ( cell: (r) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-mono text-caption" title={r.path}> <p className="truncate font-mono text-caption" title={r.path}>
{baseName(r.path)} {baseName(r.path)}
</p> </p>
<p className="truncate text-meta text-muted-foreground"> <p className="truncate text-meta text-muted-foreground">{r.entity ? entityLabel(r.entity) : "未识别"}</p>
{r.entity ? entityLabel(r.entity) : "待识别 / 手动指定作品"}
</p>
{rowProblem(r) ? <p className="text-meta text-warning">{rowProblem(r)}</p> : null} {rowProblem(r) ? <p className="text-meta text-warning">{rowProblem(r)}</p> : null}
</div> </div>
), ),
@@ -448,7 +446,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
header: "范围", header: "范围",
cell: (r) => cell: (r) =>
r.excluded === "successful_record" ? ( r.excluded === "successful_record" ? (
<StatusBadge>成功历史 · 跳过</StatusBadge> <StatusBadge>整理过 · 跳过</StatusBadge>
) : ( ) : (
<Button <Button
size="sm" size="sm"
@@ -475,7 +473,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
> >
<DialogHeader className="shrink-0 border-b border-border px-5 py-4 pr-14"> <DialogHeader className="shrink-0 border-b border-border px-5 py-4 pr-14">
<DialogTitle>整理</DialogTitle> <DialogTitle>整理</DialogTitle>
<DialogDescription className="break-all">{src.label} · 本次设置作用于整个所选范围</DialogDescription> <DialogDescription className="break-all">{src.label} · 设置对所选的全部文件生效</DialogDescription>
</DialogHeader> </DialogHeader>
<div <div
className={cn( className={cn(
@@ -488,12 +486,12 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
<p className="text-meta text-muted-foreground">{source.scanSummary}</p> <p className="text-meta text-muted-foreground">{source.scanSummary}</p>
) : null} ) : null}
<FormSelect <FormSelect
label={inPlace ? "整理位置" : "来源配置"} label={inPlace ? "整理位置" : "整理来源"}
value={inPlace ? "none" : (profile?.id ?? (ambiguous ? "choose" : "none"))} value={inPlace ? "none" : (profile?.id ?? (ambiguous ? "choose" : "none"))}
onChange={pickProfile} onChange={pickProfile}
choices={[ choices={[
...(ambiguous ? [{ value: "choose", label: "请选择匹配的来源配置", disabled: true }] : []), ...(ambiguous ? [{ value: "choose", label: "选择整理来源", disabled: true }] : []),
{ value: "none", label: inPlace ? "原地 · 使用文件所在媒体库" : "手动指定 · 使用全局默认" }, { value: "none", label: inPlace ? "原地整理 · 文件所在的媒体库目录" : "不用整理来源 · 按全局设置" },
...matches.map((p) => ({ ...matches.map((p) => ({
value: p.id, value: p.id,
label: `${p.name} · ${p.action === "copy" ? "复制" : "移动"} · ${p.category ?? "自动分类"}`, label: `${p.name} · ${p.action === "copy" ? "复制" : "移动"} · ${p.category ?? "自动分类"}`,
@@ -512,7 +510,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
) : null} ) : null}
{profiles.error ? ( {profiles.error ? (
<p className="text-meta text-warning"> <p className="text-meta text-warning">
来源配置读取失败,可手动指定选项。 读取整理来源失败,可以手动设置。
<button type="button" className="ml-2 underline" onClick={() => void profiles.refetch()}> <button type="button" className="ml-2 underline" onClick={() => void profiles.refetch()}>
重试 重试
</button> </button>
@@ -524,7 +522,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
to="/console/settings#library/sources" to="/console/settings#library/sources"
onClick={onClose} onClick={onClose}
> >
去设置新增整理来源 新增整理来源
</Link> </Link>
) : null} ) : null}
<fieldset disabled={exec.isPending} className="space-y-4"> <fieldset disabled={exec.isPending} className="space-y-4">
@@ -551,7 +549,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
setRows((rs) => mergeIdentified(rs, r.items)); setRows((rs) => mergeIdentified(rs, r.items));
expectRemote(r.remoteTaskId); expectRemote(r.remoteTaskId);
}, },
onError: (e) => toast.error(filesErrorText(e, "按媒体类型识别失败,可手动指定作品")), onError: (e) => toast.error(filesErrorText(e, "按媒体类型重新识别失败。可以手动指定作品")),
}, },
); );
} }
@@ -572,7 +570,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
cancelLookup(); cancelLookup();
pickEntity(e); pickEntity(e);
}} }}
placeholder="自动识别 · 或搜索 TMDB 选择作品" placeholder="自动识别,或搜索作品"
className="h-9" className="h-9"
/> />
</div> </div>
@@ -590,14 +588,14 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
setTmdbId(e.target.value); setTmdbId(e.target.value);
}} }}
inputMode="numeric" inputMode="numeric"
placeholder={mediaType === "auto" ? "先选择电影或剧集" : "输入 TMDB ID"} placeholder={mediaType === "auto" ? "先选择电影或剧集" : "例如:693134"}
/> />
<Button <Button
variant="outline" variant="outline"
disabled={mediaType === "auto" || !/^\d{1,12}$/.test(tmdbId.trim()) || lookupPending} disabled={mediaType === "auto" || !/^\d{1,12}$/.test(tmdbId.trim()) || lookupPending}
onClick={() => void lookup()} onClick={() => void lookup()}
> >
{lookupPending ? <LoaderCircle className="animate-spin" /> : <Search />}定位 {lookupPending ? <LoaderCircle className="animate-spin" /> : <Search />}查找
</Button> </Button>
</div> </div>
</label> </label>
@@ -610,14 +608,14 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
value={season} value={season}
disabled={mediaType === "movie"} disabled={mediaType === "movie"}
onChange={(e) => setSeason(e.target.value)} onChange={(e) => setSeason(e.target.value)}
placeholder="自动解析" placeholder="自动"
/> />
</label> </label>
</div> </div>
{manual ? ( {manual ? (
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
本批统一使用:{entityLabel(manual)} 全部文件按 {entityLabel(manual)}
{manual.external ? ` · TMDB ${manual.external.externalId}` : ""}。清除作品选择可恢复逐文件自动识别。 {manual.external ? ` · TMDB ${manual.external.externalId}` : ""} 整理。清除作品后恢复逐个识别。
</p> </p>
) : null} ) : null}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@@ -638,7 +636,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
/> />
{!inPlace ? ( {!inPlace ? (
<FormSelect <FormSelect
label="转移后的路径" label="目标目录"
value={targetValue} value={targetValue}
onChange={(v) => { onChange={(v) => {
if (v === "dir") setTargetPicker(true); if (v === "dir") setTargetPicker(true);
@@ -647,12 +645,12 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
choices={[ choices={[
{ value: "auto", label: profile ? `自动 · ${profile.name}` : "自动 · 按作品类型与已有目录" }, { value: "auto", label: profile ? `自动 · ${profile.name}` : "自动 · 按作品类型与已有目录" },
...libRoots.map((r) => ({ value: r.id, label: r.name })), ...libRoots.map((r) => ({ value: r.id, label: r.name })),
{ value: "dir", label: "手动选择目标目录…" }, { value: "dir", label: "选择目录…" },
]} ]}
/> />
) : ( ) : (
<p className="self-end rounded-xl border border-border p-3 text-meta text-muted-foreground"> <p className="self-end rounded-xl border border-border p-3 text-meta text-muted-foreground">
在文件所在媒体库登记,不搬运、不改名;可选在原目录写入 NFO 和图片。 文件留在原位置,不搬运、不改名,只登记到媒体库。可以在原目录写 NFO 和图片。
</p> </p>
)} )}
</div> </div>
@@ -667,7 +665,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
</Button> </Button>
) : null} ) : null}
{hnrCount && !inPlace ? ( {hnrCount && !inPlace ? (
<p className="text-meta text-warning">{hnrCount} 个文件仍有做种义务,本次使用复制。</p> <p className="text-meta text-warning">{hnrCount} 个文件还要做种,只能复制。</p>
) : null} ) : null}
{!inPlace ? ( {!inPlace ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@@ -676,8 +674,8 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
value={options.onExisting} value={options.onExisting}
onChange={(v) => set({ onExisting: v as WizardOptions["onExisting"] })} onChange={(v) => set({ onExisting: v as WizardOptions["onExisting"] })}
choices={[ choices={[
{ value: "conflict", label: "交给待处理" }, { value: "conflict", label: "标为需要处理" },
{ value: "skip", label: "跳过已存在的文件" }, { value: "skip", label: "跳过" },
{ value: "replace_with_verify", label: "核验新文件后替换" }, { value: "replace_with_verify", label: "核验新文件后替换" },
]} ]}
/> />
@@ -711,9 +709,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2"> <div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-caption"> <span className="text-caption">
整理后刮削 整理后刮削
<span className="block text-meta text-muted-foreground"> <span className="block text-meta text-muted-foreground">整理完成后写 NFO 和图片</span>
成功整理的文件进入刮削任务,生成 NFO 和封面
</span>
</span> </span>
<Switch <Switch
checked={options.scrapeAfterImport} checked={options.scrapeAfterImport}
@@ -725,15 +721,15 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
{!isHistory && successfulCount > 0 ? ( {!isHistory && successfulCount > 0 ? (
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2"> <div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-caption"> <span className="text-caption">
跳过成功记录 跳过整理过的文件
<span className="block text-meta text-muted-foreground"> <span className="block text-meta text-muted-foreground">
{successfulCount} 个文件有成功历史,仅对本次生效 {successfulCount} 个文件以前整理成功过。只对这次生效
</span> </span>
</span> </span>
<Switch <Switch
checked={options.skipSuccessfulRecords} checked={options.skipSuccessfulRecords}
onCheckedChange={(v) => set({ skipSuccessfulRecords: v })} onCheckedChange={(v) => set({ skipSuccessfulRecords: v })}
aria-label="跳过成功记录" aria-label="跳过整理过的文件"
/> />
</div> </div>
) : null} ) : null}
@@ -755,19 +751,19 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
</label> </label>
{!minSizeValid ? ( {!minSizeValid ? (
<p role="alert" className="text-meta text-destructive"> <p role="alert" className="text-meta text-destructive">
最小体积需要填写有效的非负数。 填一个不小于 0 的数
</p> </p>
) : null} ) : null}
<div className="flex items-center justify-between gap-3 text-caption"> <div className="flex items-center justify-between gap-3 text-caption">
刷新作品资料 刷新元数据
<Switch <Switch
checked={options.refreshMetadata} checked={options.refreshMetadata}
onCheckedChange={(v) => set({ refreshMetadata: v })} onCheckedChange={(v) => set({ refreshMetadata: v })}
aria-label="刷新作品资料" aria-label="刷新元数据"
/> />
</div> </div>
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
同名字幕、外挂音轨随视频整理;NFO 与图片由刮削生成。 同名字幕和外挂音轨随视频一起整理。NFO 和图片由刮削生成。
<Link to="/console/settings#library/naming" className="underline" onClick={onClose}> <Link to="/console/settings#library/naming" className="underline" onClick={onClose}>
修改命名模板 修改命名模板
</Link> </Link>
@@ -778,13 +774,13 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
{loading ? ( {loading ? (
<p role="status" className="flex items-center gap-2 text-caption text-muted-foreground"> <p role="status" className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> <LoaderCircle className="h-4 w-4 animate-spin" />
定位源文件并识别作品… 读取文件并识别作品…
</p> </p>
) : src.error || (!src.items.length && !src.pending) ? ( ) : src.error || (!src.items.length && !src.pending) ? (
<p role="alert" className="text-caption text-destructive"> <p role="alert" className="text-caption text-destructive">
{src.error {src.error
? filesErrorText(src.error, "读取来源失败") ? filesErrorText(src.error, "读取来源失败")
: "源文件不存在或不在登记目录中,请在文件管理器重新选择。"} : "源文件不存在或不在登记目录里。回到文件管理重新选择"}
</p> </p>
) : identify.error ? ( ) : identify.error ? (
<div> <div>
@@ -797,19 +793,19 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-caption"> <p className="text-caption">
本次整理 {active.length} 个视频 · 跳过 {stats.excluded} 个 整理 {active.length} 个视频{stats.excluded ? ` · 跳过 ${stats.excluded} 个` : ""}
{stats.pending ? ` · ${stats.pending} 个待确认` : ""} {stats.pending ? ` · ${stats.pending} 个待确认` : ""}
</p> </p>
{!rows.length && started ? ( {!rows.length && started ? (
<p className="text-meta text-muted-foreground">所选范围没有视频文件。</p> <p className="text-meta text-muted-foreground">所选范围没有视频文件。</p>
) : null} ) : null}
<Button variant="ghost" size="sm" onClick={() => setFilesOpen((v) => !v)}> <Button variant="ghost" size="sm" onClick={() => setFilesOpen((v) => !v)}>
{filesOpen ? "收起文件明细" : "查看文件明细与季集"} {filesOpen ? "收起文件列表" : "查看文件和季集"}
</Button> </Button>
</div> </div>
)} )}
{truncated ? ( {truncated ? (
<p className="text-meta text-warning">本批达到 200 个视频上限,请进入子目录分批整理。</p> <p className="text-meta text-warning">一次最多 200 个视频,超出的没有列出。进入子目录分批整理。</p>
) : null} ) : null}
{src.unresolved.length ? ( {src.unresolved.length ? (
<p className="break-all text-meta text-warning">以下路径不可用:{src.unresolved.join("、")}</p> <p className="break-all text-meta text-warning">以下路径不可用:{src.unresolved.join("、")}</p>
@@ -835,7 +831,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
{dry.isPending ? ( {dry.isPending ? (
<p role="status" className="flex gap-2 text-caption text-muted-foreground"> <p role="status" className="flex gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> <LoaderCircle className="h-4 w-4 animate-spin" />
生成整理计划… 生成预览…
</p> </p>
) : preview ? ( ) : preview ? (
<> <>
@@ -848,7 +844,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
</> </>
) : ( ) : (
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
{dry.error ? filesErrorText(dry.error, "预览失败") : "请生成预览,查看最终路径和处理结果。"} {dry.error ? filesErrorText(dry.error, "预览失败") : "生成预览后在这里查看目标路径和处理方式。"}
</p> </p>
)} )}
</section> </section>
@@ -865,7 +861,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
}} }}
> >
<FileStack /> <FileStack />
整理队列 队列
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={runPreview} disabled={!canPreview || dry.isPending || exec.isPending}> <Button variant="outline" onClick={runPreview} disabled={!canPreview || dry.isPending || exec.isPending}>
@@ -884,7 +880,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
} }
> >
{exec.isPending ? <LoaderCircle className="animate-spin" /> : null} {exec.isPending ? <LoaderCircle className="animate-spin" /> : null}
{exec.isPending ? "提交中…" : "加入整理队列"} {exec.isPending ? "提交中…" : "加入队列"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -145,7 +145,7 @@ function RowMarks({ row }: { row: WizardRow }) {
<span className="flex flex-wrap gap-1"> <span className="flex flex-wrap gap-1">
{it.existing.assetId ? ( {it.existing.assetId ? (
<StatusBadge tone="neutral" className="whitespace-nowrap"> <StatusBadge tone="neutral" className="whitespace-nowrap">
已登记 已整理
</StatusBadge> </StatusBadge>
) : null} ) : null}
{it.records?.count ? ( {it.records?.count ? (
@@ -109,7 +109,7 @@ export function StepOptions({
onSelect={() => set({ action: "move" })} onSelect={() => set({ action: "move" })}
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null} disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
title="移动" title="移动"
hint="整理后删源;跨卷时按复制 → 核验 → 删源" hint="整理后删除源文件;跨卷移动会先复制、核验,再删除源文件"
/> />
</div> </div>
</Section> </Section>
@@ -119,8 +119,8 @@ export function StepOptions({
<Choice <Choice
checked={options.onExisting === "conflict"} checked={options.onExisting === "conflict"}
onSelect={() => set({ onExisting: "conflict" })} onSelect={() => set({ onExisting: "conflict" })}
title="交给待处理" title="标为需要处理"
hint="该组不执行,冲突进待处理" hint="这组不执行,在整理记录里标为需要处理"
/> />
<Choice <Choice
checked={options.onExisting === "skip"} checked={options.onExisting === "skip"}
@@ -28,7 +28,7 @@ import { PathSegments } from "./path-segments.tsx";
export type GroupTitle = (g: FilesOrganizeGroup) => string; export type GroupTitle = (g: FilesOrganizeGroup) => string;
const ERROR_TEXT: Record<string, string> = { const ERROR_TEXT: Record<string, string> = {
plan_blocked: "目标已存在,按「交给待处理」不执行", plan_blocked: "目标已存在,这组不执行",
plan_stale: "源文件在预览后有变化", plan_stale: "源文件在预览后有变化",
approval_required: "移动需要审批或持续授权", approval_required: "移动需要审批或持续授权",
download_active: "做种中且 H&R 未满足,不能移动", download_active: "做种中且 H&R 未满足,不能移动",
@@ -38,14 +38,14 @@ const ERROR_TEXT: Record<string, string> = {
const REASON_PREFIX: Record<string, string> = { const REASON_PREFIX: Record<string, string> = {
library_root_offline: "目标媒体库目录离线", library_root_offline: "目标媒体库目录离线",
root_unavailable: "目录离线", root_unavailable: "目录离线",
not_writable: "目标不可写", not_writable: "目标目录不可写",
path_escape: "路径越界", path_escape: "路径指向登记目录之外",
source_missing: "源文件不存在", source_missing: "源文件不存在",
}; };
function errorText(e: { code: string; message: string }): string { function errorText(e: { code: string; message: string }): string {
const m = /^([a-z_]+):\s*(.*)$/.exec(e.message ?? ""); const m = /^([a-z_]+):\s*(.*)$/.exec(e.message ?? "");
if (m && REASON_PREFIX[m[1] as string]) return `${REASON_PREFIX[m[1] as string]}(${m[2] || m[1]})`; if (m && REASON_PREFIX[m[1] as string]) return `${REASON_PREFIX[m[1] as string]}:${m[2] || m[1]}`;
return e.message || ERROR_TEXT[e.code] || e.code; return e.message || ERROR_TEXT[e.code] || e.code;
} }
@@ -110,7 +110,7 @@ function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; neste
) : f.action === "replace" ? ( ) : f.action === "replace" ? (
<StatusBadge tone="warning">替换</StatusBadge> <StatusBadge tone="warning">替换</StatusBadge>
) : f.target === f.source ? ( ) : f.target === f.source ? (
<StatusBadge>原地登记</StatusBadge> <StatusBadge>原地整理</StatusBadge>
) : !f.target ? ( ) : !f.target ? (
<StatusBadge>不整理</StatusBadge> <StatusBadge>不整理</StatusBadge>
) : null; ) : null;
@@ -173,7 +173,7 @@ export function PreviewGroupCard({
); );
} else { } else {
status = groupExecutable(g) ? ( status = groupExecutable(g) ? (
<StatusBadge tone="success">计划就绪</StatusBadge> <StatusBadge tone="success">可执行</StatusBadge>
) : groupAllSkipped(g) && !g.error ? ( ) : groupAllSkipped(g) && !g.error ? (
<StatusBadge>全部跳过</StatusBadge> <StatusBadge>全部跳过</StatusBadge>
) : ( ) : (
@@ -228,7 +228,7 @@ export function PreviewGroupCard({
</p> </p>
))} ))}
{!result ? <TargetLine g={g} roots={roots} /> : null} {!result ? <TargetLine g={g} roots={roots} /> : null}
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null} {g.truncated ? <p className="text-caption text-warning">文件太多,只列出前一部分。</p> : null}
{!result && nested.length ? ( {!result && nested.length ? (
<ul className="divide-y divide-border/50"> <ul className="divide-y divide-border/50">
{nested.map((n) => ( {nested.map((n) => (
@@ -248,8 +248,8 @@ export function PreviewGroupCard({
排除 {g.excluded.length} 个: 排除 {g.excluded.length} 个:
{g.excluded {g.excluded
.slice(0, 6) .slice(0, 6)
.map((e) => `${baseName(e.source)}(${EXCLUDE_REASON[e.reason] ?? e.reason})`) .map((e) => `${baseName(e.source)}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
.join(",")} .join("、")}
{g.excluded.length > 6 ? "…" : ""} {g.excluded.length > 6 ? "…" : ""}
</p> </p>
) : null} ) : null}
@@ -64,7 +64,7 @@ export function StructurePicker({
/> />
</div> </div>
) : null} ) : null}
{list.error ? <p className="text-meta text-warning">剧集组列表暂时取不到,只能跟随作品。</p> : null} {list.error ? <p className="text-meta text-warning">读取剧集组失败,只能跟随作品</p> : null}
</div> </div>
); );
} }
@@ -86,7 +86,7 @@ export function DeleteRecordsDialog({
<input type="checkbox" className="mt-1" checked={dst} onChange={(e) => setDst(e.target.checked)} /> <input type="checkbox" className="mt-1" checked={dst} onChange={(e) => setDst(e.target.checked)} />
<span> <span>
同时删除媒体库里的文件 同时删除媒体库里的文件
<span className="block text-meta text-muted-foreground">整理后放进媒体库的文件,会从媒体库移除。</span> <span className="block text-meta text-muted-foreground">整理到媒体库目录里的文件。</span>
</span> </span>
</label> </label>
</div> </div>
@@ -127,8 +127,8 @@ function Summary({
src: boolean; src: boolean;
withFiles: boolean; withFiles: boolean;
}) { }) {
if (error) return <p className="text-destructive">{errorMessage(error, "核对失败")}</p>; if (error) return <p className="text-destructive">{errorMessage(error, "检查失败")}</p>;
if (!plan) return <p className="text-muted-foreground">{pending ? "正在核对要删的文件…" : null}</p>; if (!plan) return <p className="text-muted-foreground">{pending ? "检查要删的文件…" : null}</p>;
const files = [ const files = [
plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null, plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null,
plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null, plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null,
@@ -137,15 +137,15 @@ function Summary({
<div className="space-y-1 text-muted-foreground"> <div className="space-y-1 text-muted-foreground">
{files.length ? ( {files.length ? (
<p> <p>
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。 {files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以还原。
{plan.assets ? `其中 ${plan.assets} 个是已整理文件,会从媒体库移除。` : ""} {plan.assets ? `其中 ${plan.assets} 个是已整理文件,会从媒体库移除。` : ""}
</p> </p>
) : ( ) : (
<p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p> <p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p>
)} )}
{src && plan.sourceFiles ? <p>下载器里的任务不会删除。</p> : null} {src && plan.sourceFiles ? <p>下载器里的任务不受影响。</p> : null}
{plan.autoRetry ? ( {plan.autoRetry ? (
<p className="text-foreground">{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会重新自动整理。</p> <p className="text-foreground">{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会自动重新整理。</p>
) : null} ) : null}
</div> </div>
); );
@@ -24,7 +24,7 @@ export function FileSourceSelection({
const profile = matches[0]; const profile = matches[0];
onSelect( onSelect(
profile profile
? { ...source, sourceConfigId: profile.id, defaultAction: profile.action, label: `来源 · ${profile.name}` } ? { ...source, sourceConfigId: profile.id, defaultAction: profile.action, label: `整理来源 · ${profile.name}` }
: source, : source,
); );
}, [profiles.isSuccess, matches, source, onSelect]); }, [profiles.isSuccess, matches, source, onSelect]);
@@ -32,14 +32,12 @@ export function FileSourceSelection({
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{profiles.isPending ? "读取整理来源…" : "选择整理策略"}</DialogTitle> <DialogTitle>{profiles.isPending ? "读取整理来源…" : "选择整理来源"}</DialogTitle>
<DialogDescription> <DialogDescription>整理时沿用所选整理来源的整理方式、目标目录和分类。</DialogDescription>
匹配来源配置后,使用其整理方式、目标目录与分类规则。媒体类型将在整理预览时核对。
</DialogDescription>
</DialogHeader> </DialogHeader>
{profiles.isPending ? ( {profiles.isPending ? (
<p role="status" className="text-caption text-muted-foreground"> <p role="status" className="text-caption text-muted-foreground">
正在检查所选文件对应的来源配置… 匹配整理来源…
</p> </p>
) : profiles.error ? ( ) : profiles.error ? (
<> <>
@@ -62,19 +60,19 @@ export function FileSourceSelection({
...source, ...source,
sourceConfigId: profile.id, sourceConfigId: profile.id,
defaultAction: profile.action, defaultAction: profile.action,
label: `来源 · ${profile.name}`, label: `整理来源 · ${profile.name}`,
}) })
} }
> >
{profile.name} · {profile.action === "copy" ? "复制" : "移动"} · {profile.category ?? "自动分类"} {profile.name} · {profile.action === "copy" ? "复制" : "移动"} · {profile.category ?? "自动分类"}
{!profile.enabled ? " · 自动监控已停用" : ""} {!profile.enabled ? " · 已停用" : ""}
</Button> </Button>
))} ))}
</div> </div>
) : null} ) : null}
{!profiles.isPending && (profiles.error || matches.length > 1) ? ( {!profiles.isPending && (profiles.error || matches.length > 1) ? (
<Button variant="outline" onClick={() => onSelect(source)}> <Button variant="outline" onClick={() => onSelect(source)}>
不使用来源配置,手动指定选项 不用整理来源,手动设置
</Button> </Button>
) : null} ) : null}
</DialogContent> </DialogContent>
@@ -30,15 +30,15 @@ function ScrapeQueueItem({ run }: { run: EngineRun }) {
return ( return (
<li className="pg-slab space-y-2 rounded-xl p-4"> <li className="pg-slab space-y-2 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<strong className="min-w-0 flex-1 break-words text-body">{record?.match?.title ?? "媒体库刮削"}</strong> <strong className="min-w-0 flex-1 break-words text-body">{record?.match?.title ?? "刮削"}</strong>
<StatusBadge>刮削</StatusBadge> <StatusBadge>刮削</StatusBadge>
<StatusBadge tone={run.status === "sleeping" ? "warning" : "neutral"}>{status}</StatusBadge> <StatusBadge tone={run.status === "sleeping" ? "warning" : "neutral"}>{status}</StatusBadge>
<span className="text-meta text-muted-foreground">{relTime(run.createdAt)}</span> <span className="text-meta text-muted-foreground">{relTime(run.createdAt)}</span>
</div> </div>
<div className="min-w-0 text-caption"> <div className="min-w-0 text-caption">
<span className="text-muted-foreground">目标目录 / 文件</span> <span className="text-muted-foreground">位置</span>
<p className="break-all font-mono text-meta"> <p className="break-all font-mono text-meta">
{record ? `${record.root} / ${record.path || "(根目录)"}` : "读取刮削目标中…"} {record ? `${record.root} / ${record.path || "(登记目录本身)"}` : "加载中…"}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
@@ -59,12 +59,12 @@ function ScrapeQueueItem({ run }: { run: EngineRun }) {
className="hover:text-foreground hover:underline" className="hover:text-foreground hover:underline"
to={`/console/organize/${record.organizationRecordId}`} to={`/console/organize/${record.organizationRecordId}`}
> >
关联整理记录 整理记录
</Link> </Link>
) : null} ) : null}
</div> </div>
{detail.error || scrape.error ? ( {detail.error || scrape.error ? (
<p className="text-meta text-warning">刮削详细信息暂时无法读取,任务状态仍会自动刷新。</p> <p className="text-meta text-warning">刮削详情读取失败,状态仍会自动刷新。</p>
) : null} ) : null}
</li> </li>
); );
@@ -83,7 +83,7 @@ function QueueItem({ run }: { run: EngineRun }) {
const download = useDownloadTask(downloadId); const download = useDownloadTask(downloadId);
const record = recordQuery.data ?? matchingRecord; const record = recordQuery.data ?? matchingRecord;
const progress = record ? recordFileProgress(record) : null; const progress = record ? recordFileProgress(record) : null;
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验整理任务"); const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "整理任务");
const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath; const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath;
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中"; const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
return ( return (
@@ -98,14 +98,12 @@ function QueueItem({ run }: { run: EngineRun }) {
<span className="text-muted-foreground">来源</span> <span className="text-muted-foreground">来源</span>
<p className="break-all font-mono text-meta"> <p className="break-all font-mono text-meta">
{source ?? {source ??
(detail.isFetching || download.isFetching || recordQuery.isFetching (detail.isFetching || download.isFetching || recordQuery.isFetching ? "加载中…" : "读取不到来源")}
? "读取来源信息中…"
: "来源信息暂不可用")}
</p> </p>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<span className="text-muted-foreground">目标</span> <span className="text-muted-foreground">目标</span>
<p className="break-all font-mono text-meta">{record?.target.path ?? "核验与生成整理计划后确定"}</p> <p className="break-all font-mono text-meta">{record?.target.path ?? "生成整理计划后确定"}</p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
@@ -131,7 +129,7 @@ function QueueItem({ run }: { run: EngineRun }) {
) : null} ) : null}
</div> </div>
{detail.error || recordQuery.error || related.error || download.error ? ( {detail.error || recordQuery.error || related.error || download.error ? (
<p className="text-meta text-warning">部分详细信息暂时无法读取,任务状态仍会自动刷新。</p> <p className="text-meta text-warning">部分详情读取失败,状态仍会自动刷新。</p>
) : null} ) : null}
</li> </li>
); );
@@ -159,7 +157,7 @@ export function OrganizeQueue() {
resetKey: "organize-queue", resetKey: "organize-queue",
}); });
return ( return (
<section aria-label="排队与执行中的整理任务" className="space-y-3"> <section aria-label="排队和执行中的任务" className="space-y-3">
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}> <Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}>
<RefreshCw /> <RefreshCw />
@@ -171,7 +169,7 @@ export function OrganizeQueue() {
) : runs.error && !rows ? ( ) : runs.error && !rows ? (
<ErrorState error={runs.error} onRetry={() => runs.refetch()} /> <ErrorState error={runs.error} onRetry={() => runs.refetch()} />
) : !rows?.length ? ( ) : !rows?.length ? (
<EmptyState icon={FileStack} title="没有排队或执行中的整理任务" /> <EmptyState icon={FileStack} title="没有排队或执行中的任务" />
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{paged.items?.map((run) => {paged.items?.map((run) =>
@@ -207,7 +205,7 @@ export function OrganizeQueueButton({ open, onOpenChange }: { open: boolean; onO
size="sm" size="sm"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={open} aria-expanded={open}
aria-label={count ? `整理队列:${count} 个任务` : "整理队列"} aria-label={count ? `队列:${count} 个任务` : "队列"}
onClick={() => onOpenChange(true)} onClick={() => onOpenChange(true)}
> >
{running ? <Loader2 className="animate-spin" /> : <ListOrdered />} {running ? <Loader2 className="animate-spin" /> : <ListOrdered />}
@@ -217,9 +215,9 @@ export function OrganizeQueueButton({ open, onOpenChange }: { open: boolean; onO
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 sm:max-w-xl"> <SheetContent side="right" className="w-full gap-0 sm:max-w-xl">
<SheetHeader className="pr-12"> <SheetHeader className="pr-12">
<SheetTitle>整理队列</SheetTitle> <SheetTitle>队列</SheetTitle>
<SheetDescription className="text-meta"> <SheetDescription className="text-meta">
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在这里排队。整理完成后进入整理记录,刮削显示到任务结束。 排队和执行中的整理、刮削任务。整理完成后移到整理记录。
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{open ? <OrganizeQueue /> : null}</div> <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{open ? <OrganizeQueue /> : null}</div>
@@ -32,7 +32,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
) : query.error ? ( ) : query.error ? (
<ErrorState error={query.error} onRetry={() => query.refetch()} /> <ErrorState error={query.error} onRetry={() => query.refetch()} />
) : query.data?.records.length ? ( ) : query.data?.records.length ? (
<PaginatedList rows={query.data.records} label="关联刮削记录" resetKey={recordId} as="ul" className="space-y-3"> <PaginatedList rows={query.data.records} label="刮削记录" resetKey={recordId} as="ul" className="space-y-3">
{(record) => ( {(record) => (
<li key={record.id} className="space-y-1.5 rounded-xl border border-border p-3"> <li key={record.id} className="space-y-1.5 rounded-xl border border-border p-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -44,7 +44,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
{record.match?.title ?? record.path} · {record.path || "/"} {record.match?.title ?? record.path} · {record.path || "/"}
</p> </p>
{record.status === "skipped" ? null : ( {record.status === "skipped" ? null : (
<p className="text-meta text-muted-foreground">{scrapeCounts(record) || "等待写入 NFO 与图片"}</p> <p className="text-meta text-muted-foreground">{scrapeCounts(record) || "等待写入"}</p>
)} )}
{record.reason ? ( {record.reason ? (
<p <p
@@ -57,7 +57,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
) : null} ) : null}
{unfinished(record.files).length ? ( {unfinished(record.files).length ? (
<details className="text-meta"> <details className="text-meta">
<summary className="cursor-pointer text-muted-foreground">失败或没写的文件</summary> <summary className="cursor-pointer text-muted-foreground">失败或跳过的文件</summary>
<PaginatedList <PaginatedList
rows={unfinished(record.files)} rows={unfinished(record.files)}
label="刮削失败或跳过的文件" label="刮削失败或跳过的文件"
@@ -90,7 +90,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
)} )}
</PaginatedList> </PaginatedList>
) : ( ) : (
<p className="text-caption text-muted-foreground">没有关联的刮削记录。</p> <p className="text-caption text-muted-foreground">没有刮削记录。</p>
)} )}
</section> </section>
); );
@@ -8,24 +8,22 @@ describe("organize queue", () => {
recordId: undefined, recordId: undefined,
}); });
expect(organizeQueueStage({ status: "pending" })).toBe("等待执行"); expect(organizeQueueStage({ status: "pending" })).toBe("等待执行");
expect(organizeQueueStage({ status: "running" })).toBe("核验文件与生成整理计划"); expect(organizeQueueStage({ status: "running" })).toBe("核验文件,生成整理计划");
}); });
it("distinguishes file transfer, plan waiting, and workflow waiting", () => { it("distinguishes file transfer, plan waiting, and workflow waiting", () => {
expect(organizeRunInput({ recordId: "record" }).recordId).toBe("record"); expect(organizeRunInput({ recordId: "record" }).recordId).toBe("record");
expect(organizeQueueStage({ status: "running" }, { status: "queued" })).toContain("等待搬运"); expect(organizeQueueStage({ status: "running" }, { status: "queued" })).toContain("等待搬运");
expect(organizeQueueStage({ status: "running" }, { status: "running" })).toContain("搬运文件"); expect(organizeQueueStage({ status: "running" }, { status: "running" })).toContain("搬运文件");
expect(organizeQueueStage({ status: "sleeping" }, { status: "running" })).toBe("等待继续执行"); expect(organizeQueueStage({ status: "sleeping" }, { status: "running" })).toBe("等待继续");
}); });
it("keeps the independent scrape stage distinct after importing finishes", () => { it("keeps the independent scrape stage distinct after importing finishes", () => {
expect(organizeRunInput({ input: { scrapeRecordId: "scrape" } }).scrapeRecordId).toBe("scrape"); expect(organizeRunInput({ input: { scrapeRecordId: "scrape" } }).scrapeRecordId).toBe("scrape");
expect(scrapeQueueStage({ status: "running" })).toBe("刮削 NFO / 图片"); expect(scrapeQueueStage({ status: "running" })).toBe("刮削中");
expect(scrapeQueueStage({ status: "pending" })).toContain("等待刮削"); expect(scrapeQueueStage({ status: "pending" })).toContain("等待刮削");
}); });
it("原地入库显示核验与登记,不显示搬运", () => { it("原地整理不显示搬运", () => {
expect(organizeQueueStage({ status: "running" }, { status: "running", action: "in_place" })).toBe( expect(organizeQueueStage({ status: "running" }, { status: "running", action: "in_place" })).toBe("原地整理中");
"核验原文件与登记媒体库", expect(organizeQueueStage({ status: "running" }, { status: "queued", action: "in_place" })).toBe("等待原地整理");
);
expect(organizeQueueStage({ status: "running" }, { status: "queued", action: "in_place" })).toBe("等待原地登记");
}); });
it("does not describe a finished business record as transferring", () => { it("does not describe a finished business record as transferring", () => {
expect(organizeQueueStage({ status: "running" }, { status: "succeeded" })).toContain("收尾"); expect(organizeQueueStage({ status: "running" }, { status: "succeeded" })).toContain("收尾");
@@ -20,18 +20,18 @@ export function organizeQueueStage(
run: Pick<EngineRun, "status">, run: Pick<EngineRun, "status">,
record?: Pick<PublicOrganizationRecord, "status"> & Partial<Pick<PublicOrganizationRecord, "action">>, record?: Pick<PublicOrganizationRecord, "status"> & Partial<Pick<PublicOrganizationRecord, "action">>,
) { ) {
if (run.status === "sleeping") return "等待继续执行"; if (run.status === "sleeping") return "等待继续";
if (run.status === "pending") return "等待执行"; if (run.status === "pending") return "等待执行";
if (!record) return "核验文件与生成整理计划"; if (!record) return "核验文件,生成整理计划";
if (record.action === "in_place" && (record.status === "queued" || record.status === "running")) if (record.action === "in_place" && (record.status === "queued" || record.status === "running"))
return record.status === "queued" ? "等待原地登记" : "核验原文件与登记媒体库"; return record.status === "queued" ? "等待原地整理" : "原地整理中";
if (record.status === "queued") return "整理计划已生成,等待搬运"; if (record.status === "queued") return "等待搬运文件";
if (record.status === "running") return "搬运文件与登记媒体库"; if (record.status === "running") return "搬运文件中";
return "整理已结束,完成任务收尾"; return "收尾中";
} }
export function scrapeQueueStage(run: Pick<EngineRun, "status">) { export function scrapeQueueStage(run: Pick<EngineRun, "status">) {
if (run.status === "pending") return "等待刮削 NFO / 图片"; if (run.status === "pending") return "等待刮削";
if (run.status === "sleeping") return "等待继续刮削 NFO / 图片"; if (run.status === "sleeping") return "等待继续刮削";
return "刮削 NFO / 图片"; return "刮削中";
} }
@@ -27,7 +27,7 @@ type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attentio
export function attentionLine(r: Rec): string | null { export function attentionLine(r: Rec): string | null {
const a = r.attention; const a = r.attention;
if (!a) return null; if (!a) return null;
if (a.decision?.status === "investigating") return "智能体正在识别作品,认出来会自动重新整理"; if (a.decision?.status === "investigating") return "智能体正在识别作品,识别出来后自动重新整理";
if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别"; if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别";
return a.decision?.summary ? humanizeReason(a.decision.summary) : null; return a.decision?.summary ? humanizeReason(a.decision.summary) : null;
} }
@@ -96,8 +96,8 @@ export function RecordAttentionActions({
disabled={identify.isPending} disabled={identify.isPending}
onClick={() => onClick={() =>
identify.mutateAsync(r.id).then( identify.mutateAsync(r.id).then(
(x) => toast.success(x.queued ? "已让智能体识别,认出来会自动重新整理" : "智能体已经在识别了"), (x) => toast.success(x.queued ? "已交给智能体识别" : "智能体正在识别"),
(err) => toast.error(errorMessage(err, "操作失败")), (err) => toast.error(errorMessage(err, "交给智能体识别失败")),
) )
} }
> >
@@ -112,14 +112,14 @@ export function RecordAttentionActions({
忽略 忽略
</Button> </Button>
} }
title="忽略这条记录的问题?" title="忽略这个问题?"
description="不再算需要处理,文件和记录都不动;之后可以随时重新整理。" description="这条记录不再算需要处理,文件和记录不变。之后仍可重新整理。"
confirmLabel="忽略" confirmLabel="忽略"
onConfirm={() => onConfirm={() =>
dismiss.mutateAsync(r.id).then( dismiss.mutateAsync(r.id).then(
() => toast.success("已忽略"), () => toast.success("已忽略"),
(err) => { (err) => {
toast.error(errorMessage(err, "操作失败")); toast.error(errorMessage(err, "忽略失败"));
throw err; throw err;
}, },
) )
@@ -17,9 +17,21 @@ export function stockWizardSource(profile: OrganizeSourceView, stock: OrganizeSt
sourceConfigId: profile.id, sourceConfigId: profile.id,
defaultAction: profile.action, defaultAction: profile.action,
label: `整理存量 · ${profile.name}`, label: `整理存量 · ${profile.name}`,
scanSummary: `找到 ${stock.items.length} 个文件,跳过 ${stock.skipped} 个,不可读取 ${stock.unavailable} 个${stock.truncated ? ";扫描已达到上限,仅显示本批结果" : ""}。本次扫描不改变自动整理基线,请先预览再执行。`, scanSummary: `找到 ${stock.items.length} 个文件${stockNotes(stock)}。`,
}; };
} }
/** 扫描结果的补充说明:只写非零项 */
function stockNotes(r: Pick<OrganizeStockResult, "skipped" | "unavailable" | "truncated">): string {
return [
r.skipped ? `跳过 ${r.skipped} 个` : "",
r.unavailable ? `${r.unavailable} 个读取失败` : "",
r.truncated ? "达到扫描上限,只列出这一批" : "",
]
.filter(Boolean)
.map((x) => `,${x}`)
.join("");
}
export function SourceSelection({ export function SourceSelection({
onClose, onClose,
onSelect, onSelect,
@@ -36,7 +48,7 @@ export function SourceSelection({
mode="source" mode="source"
allowRoot={false} allowRoot={false}
onClose={() => setManual(false)} onClose={() => setManual(false)}
onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择来源目录" })} onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择的目录" })}
/> />
); );
return ( return (
@@ -44,19 +56,19 @@ export function SourceSelection({
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>选择整理来源</DialogTitle> <DialogTitle>选择整理来源</DialogTitle>
<DialogDescription>扫描来源存量后,进入文件识别、预览与执行。扫描不会改变自动整理基线。</DialogDescription> <DialogDescription>扫描来源目录里已有的文件,预览后再整理。扫描不改变自动整理的基线。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-72 space-y-2 overflow-auto"> <div className="max-h-72 space-y-2 overflow-auto">
{sources.isPending ? <p>读取来源中…</p> : null} {sources.isPending ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
{sources.error ? ( {sources.error ? (
<p role="alert" className="text-destructive"> <p role="alert" className="text-destructive">
来源配置读取失败,仍可手动选择目录。 读取整理来源失败。可以手动选择目录
</p> </p>
) : null} ) : null}
{sources.data?.items.length ? ( {sources.data?.items.length ? (
<PaginatedList <PaginatedList
rows={sources.data.items} rows={sources.data.items}
label="整理来源配置" label="整理来源"
resetKey="organize-source-selection" resetKey="organize-source-selection"
className="space-y-2" className="space-y-2"
> >
@@ -73,9 +85,7 @@ export function SourceSelection({
onSuccess: (r) => onSuccess: (r) =>
r.items.length r.items.length
? onSelect(stockWizardSource(p, r)) ? onSelect(stockWizardSource(p, r))
: toast.info( : toast.info(`没有可整理的文件${stockNotes(r)}`),
`没有可整理文件;跳过 ${r.skipped},不可读取 ${r.unavailable}${r.truncated ? ",扫描已达到上限" : ""}`,
),
onError: (e) => toast.error(filesErrorText(e, "扫描存量失败")), onError: (e) => toast.error(filesErrorText(e, "扫描存量失败")),
}, },
) )
@@ -89,7 +99,7 @@ export function SourceSelection({
) : null} ) : null}
</div> </div>
<Button variant="outline" disabled={stock.isPending} onClick={() => setManual(true)}> <Button variant="outline" disabled={stock.isPending} onClick={() => setManual(true)}>
手动选择其他来源目录 手动选择目录
</Button> </Button>
{sources.isSuccess && !sources.data.items.length ? ( {sources.isSuccess && !sources.data.items.length ? (
<Link <Link
@@ -97,7 +107,7 @@ export function SourceSelection({
onClick={onClose} onClick={onClose}
className="text-caption text-muted-foreground underline" className="text-caption text-muted-foreground underline"
> >
去设置新增整理来源 新增整理来源
</Link> </Link>
) : null} ) : null}
</DialogContent> </DialogContent>