文案(整理向导):按 human-copy 重写整理对话框、预览与作品选择

- 「交给待处理」改「标为需要处理」,「转移后的路径」改「目标目录」,「刷新作品资料」改「刷新元数据」(术语表)
- 「来源配置」统一为「整理来源」;按钮与队列入口统一叫「队列」
- 去掉「请…」「本次 / 本批」等套话;只列非零计数;预览原因的括号与分隔符改全角

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 01:51:01 +08:00
1 parent a15d81360b
commit 804f40cd36
8 files changed
+70 -70

No files matched your search

@@ -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,14 +392,14 @@ 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) : "待识别 / 手动指定作品"} {r.entity ? entityLabel(r.entity) : "未识别"}
</p> </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 +448,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 +475,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 +488,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 +512,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 +524,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 +551,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 +572,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 +590,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 +610,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 +638,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 +647,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 +667,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 +676,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: "核验新文件后替换" },
]} ]}
/> />
@@ -712,7 +712,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
<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 和封面 整理完成后写 NFO 和图片
</span> </span>
</span> </span>
<Switch <Switch
@@ -725,15 +725,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 +755,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 +778,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 +797,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 +835,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 +848,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 +865,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 +884,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>
); );
} }