feat(web): file manager v2 and shared organize wizard (FE-4)

- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px
- 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多"
- 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB
- 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行),
  TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、
  plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录
- 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…"
- 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:26:41 +08:00
1 parent 09a6b0b5d3
commit c31df8b69a
24 files changed
+4820 -1607

No files matched your search

@@ -0,0 +1,284 @@
import { Check, ChevronDown, LoaderCircle, Search } from "lucide-react";
import { useState } from "react";
import { type FileIdentifyCandidate, useMediaSearch } from "~/api/files.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { cn } from "~/lib/utils.ts";
import { entityKey, entityLabel, type WizardEntity } from "./model.ts";
export const LEVEL: Record<string, { label: string; tone: "success" | "warning" | "neutral" }> = {
high: { label: "高", tone: "success" },
medium: { label: "中", tone: "neutral" },
low: { label: "低", tone: "warning" },
};
const REASON: Record<string, string | null> = {
"title:exact": "标题一致",
"title:alias": "别名一致",
"title:partial": "标题部分一致",
"title:none": "标题不一致",
"year:exact": "年份一致",
"year:season_year": "季年份一致",
"year:within_tolerance": "年份相差 1 年内",
"year:mismatch": "年份不同",
"year:unknown": null,
"type:match": "类型一致",
"type:mismatch": "类型不同",
"type:unknown": null,
explicit_id: "文件名带外部 ID",
local_entity: null,
};
/** 识别理由代码 → 文案;未知与冗余项不显示 */
export function reasonsText(reasons: string[]): string {
return reasons
.map((r) => (r in REASON ? REASON[r] : r))
.filter(Boolean)
.join(" · ");
}
const KIND_LABEL: Record<string, string> = { movie: "电影", series: "剧集", variety: "综艺", tv: "剧集" };
/** 识别候选 → 作品:有本地作品用本地,只有 TMDB 的记为外部候选 */
export function candidateEntity(c: FileIdentifyCandidate): WizardEntity | null {
if (c.mediaEntityId) return { id: c.mediaEntityId, external: null, title: c.title, year: c.year, kind: c.kind };
if (c.provider === "tmdb" && c.externalId && /^\d+$/.test(c.externalId))
return {
id: null,
external: { provider: "tmdb", externalId: c.externalId, kind: c.kind === "movie" ? "movie" : "tv" },
title: c.title,
year: c.year,
kind: c.kind === "movie" ? "movie" : "series",
};
return null;
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p className="px-2 pt-2 pb-1 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
{children}
</p>
);
}
function Option({
selected,
title,
sub,
badge,
onPick,
}: {
selected: boolean;
title: string;
sub?: string;
badge?: React.ReactNode;
onPick: () => void;
}) {
return (
<li>
<button
type="button"
onClick={onPick}
className="w-full rounded-lg px-2 py-1.5 text-left hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
>
<span className="flex items-center gap-2 text-caption">
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : <span className="w-3.5 shrink-0" />}
<span className="min-w-0 flex-1 truncate">{title}</span>
{badge}
</span>
{sub ? <span className="block truncate pl-5.5 text-meta text-muted-foreground">{sub}</span> : null}
</button>
</li>
);
}
/**
* 作品选择器(§14 EntityPicker):上半识别候选(本地 + TMDB),下半搜索本地作品与 TMDB。
* 选 TMDB 条目 = 行标"将新建作品",执行时由后端建作品(G1)。
*/
export function EntityPicker({
candidates = [],
value,
onChange,
placeholder = "选择作品…",
className,
invalid,
}: {
candidates?: FileIdentifyCandidate[];
value: WizardEntity | null;
onChange: (e: WizardEntity | null) => void;
placeholder?: string;
className?: string;
invalid?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const search = useMediaSearch(open ? q : "");
const cands = candidates
.map((c) => ({ c, e: candidateEntity(c) }))
.filter((x): x is { c: FileIdentifyCandidate; e: WizardEntity } => !!x.e);
const local = cands.filter((x) => x.e.id);
const remote = cands.filter((x) => !x.e.id);
const cur = entityKey(value);
const pick = (e: WizardEntity) => {
onChange(e);
setOpen(false);
setQ("");
};
const hits = search.data;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-invalid={invalid || undefined}
className={cn(
"flex h-8 w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-left text-caption",
value ? "border-input" : invalid ? "border-warning/60 text-warning" : "border-input text-muted-foreground",
className,
)}
>
<span className="min-w-0 flex-1 truncate">{value ? entityLabel(value) : placeholder}</span>
{value && !value.id ? <StatusBadge tone="warning">将新建</StatusBadge> : null}
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(28rem,calc(100vw-2rem))] p-1.5">
<div className="max-h-[min(26rem,60dvh)] overflow-y-auto">
{local.length ? (
<>
<SectionTitle>识别候选</SectionTitle>
<ul className="space-y-0.5">
{local.map(({ c, e }) => (
<Option
key={`l-${c.mediaEntityId}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={reasonsText(c.reasons) || undefined}
badge={
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
}
onPick={() => pick(e)}
/>
))}
</ul>
</>
) : null}
{remote.length ? (
<>
<SectionTitle>TMDB 候选 · 选中后执行时新建作品</SectionTitle>
<ul className="space-y-0.5">
{remote.map(({ c, e }) => (
<Option
key={`r-${c.externalId}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={[`TMDB ${c.externalId}`, KIND_LABEL[e.kind], reasonsText(c.reasons)]
.filter(Boolean)
.join(" · ")}
badge={
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
}
onPick={() => pick(e)}
/>
))}
</ul>
</>
) : null}
<div className="relative mt-2 px-1">
<Search className="pointer-events-none absolute top-1/2 left-3.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
autoFocus={!cands.length}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索本地作品或 TMDB"
aria-label="搜索作品"
className="h-8 pl-8 text-caption"
/>
{search.isFetching ? (
<LoaderCircle className="absolute top-1/2 right-3.5 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</div>
{q.trim() && hits ? (
<>
{hits.local.length ? <SectionTitle>本地作品</SectionTitle> : null}
<ul className="space-y-0.5">
{hits.local.map((h) => {
const e: WizardEntity = { id: h.id, external: null, title: h.title, year: h.year, kind: h.kind };
return (
<Option
key={`s-${h.id}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={[KIND_LABEL[e.kind], h.alias].filter(Boolean).join(" · ")}
onPick={() => pick(e)}
/>
);
})}
</ul>
{hits.tmdb.filter((t) => !t.localEntityId || !hits.local.some((l) => l.id === t.localEntityId)).length ? (
<SectionTitle>TMDB</SectionTitle>
) : null}
<ul className="space-y-0.5">
{hits.tmdb
.filter((t) => !t.localEntityId || !hits.local.some((l) => l.id === t.localEntityId))
.map((t) => {
const e: WizardEntity = t.localEntityId
? {
id: t.localEntityId,
external: null,
title: t.title,
year: t.year,
kind: t.entityType === "movie" ? "movie" : "series",
}
: {
id: null,
external: { provider: "tmdb", externalId: t.externalId, kind: t.entityType },
title: t.title,
year: t.year,
kind: t.entityType === "movie" ? "movie" : "series",
};
return (
<Option
key={`t-${t.externalId}-${t.entityType}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={[
`TMDB ${t.externalId}`,
KIND_LABEL[t.entityType],
t.localEntityId ? "已有本地作品" : "选中后新建作品",
t.originalTitle && t.originalTitle !== t.title ? t.originalTitle : null,
]
.filter(Boolean)
.join(" · ")}
onPick={() => pick(e)}
/>
);
})}
</ul>
{!hits.local.length && !hits.tmdb.length && !search.isFetching ? (
<p className="px-2 py-1.5 text-meta text-muted-foreground">
没有匹配的作品{hits.tmdbConfigured ? "" : ";TMDB 未配置,只搜了本地"}
</p>
) : null}
</>
) : null}
{search.error ? <p className="px-2 py-1.5 text-meta text-destructive">搜索失败</p> : null}
{value ? (
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className="mt-1 w-full rounded-lg px-2 py-1.5 text-left text-meta text-muted-foreground hover:bg-foreground/[0.06]"
>
清除选择
</button>
) : null}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,3 @@
export { EntityPicker } from "./entity-picker.tsx";
export { type OrganizeSource, OrganizeWizard } from "./organize-wizard.tsx";
export { PathSegments } from "./path-segments.tsx";
@@ -0,0 +1,284 @@
import { describe, expect, it } from "vitest";
import type { FileIdentifyItem, FilesOrganizeGroup } from "~/api/files.ts";
import {
applyGroupEntity,
applyMinSize,
applyOffsetTo,
defaultOptions,
displayPath,
downloadContentRef,
groupRows,
MIB,
nestSidecars,
organizeRequest,
pageOf,
previewStats,
rowProblem,
rowsFromIdentify,
setRowEntity,
type WizardEntity,
type WizardRow,
wizardStats,
} from "./model.ts";
const item = (
path: string,
over: Partial<{ id: string | null; level: "high" | "low"; size: number; skipped: "below_min_size" | null }> = {},
): FileIdentifyItem => ({
root: "lib:x",
path,
parsed: {
title: "T",
year: null,
season: 1,
episodes: [2],
resolution: null,
source: null,
releaseGroup: null,
mediaKind: "series",
},
candidates:
over.id === null
? []
: [
{
mediaEntityId: over.id ?? "e1",
provider: "local",
externalId: null,
title: "T",
year: 2020,
kind: "series",
level: over.level ?? "high",
score: 1,
reasons: [],
},
],
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
existing: { assetId: null },
warnings: [],
size: over.size ?? 2_000 * MIB,
skipped: over.skipped ?? null,
});
const series: WizardEntity = { id: "e9", external: null, title: "X", year: 2001, kind: "series" };
const tmdbMovie: WizardEntity = {
id: null,
external: { provider: "tmdb", externalId: "872585", kind: "movie" },
title: "奥本海默",
year: 2023,
kind: "movie",
};
describe("识别结果 → 行", () => {
it("高置信自动选中;低置信/无候选为未决,排除后放行", () => {
const rows = rowsFromIdentify(
[item("d/a.mkv"), item("d/b.mkv", { level: "low" }), item("e/c.mkv", { id: null })],
new Set(),
);
expect(rows[0]?.entity?.id).toBe("e1");
expect(rows[0]?.episodes).toBe("2");
expect(rowProblem(rows[0] as WizardRow)).toBeNull();
expect(rowProblem(rows[1] as WizardRow)).toMatch(/作品/);
expect(rowProblem({ ...(rows[2] as WizardRow), excluded: "manual" })).toBeNull();
});
it("服务端按体积跳过的项预先排除并计数", () => {
const rows = rowsFromIdentify(
[item("a.mkv"), item("sample.mkv", { size: 10 * MIB, skipped: "below_min_size" })],
new Set(),
);
expect(rows[1]?.excluded).toBe("below_min_size");
expect(wizardStats(rows)).toMatchObject({ videos: 2, excluded: 1, bySize: 1 });
});
it("改作品时同目录未手动改过的行跟随,别的目录不动", () => {
let rows = rowsFromIdentify(
[item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null }), item("e/c.mkv", { id: null })],
new Set(),
);
rows = setRowEntity(rows, rows[0]?.key as string, series);
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e9", "e9", null]);
rows = setRowEntity(rows, rows[1]?.key as string, { ...series, id: "e8" });
rows = setRowEntity(rows, rows[0]?.key as string, { ...series, id: "e7" });
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e7", "e8", null]);
});
it("应用到本组 = 组内第一项的作品复制到全组", () => {
let rows = rowsFromIdentify([item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null })], new Set());
rows = [
{ ...(rows[0] as WizardRow), entity: series, touched: true },
{ ...(rows[1] as WizardRow), touched: true },
];
rows = applyGroupEntity(rows, rows[0]?.group as string);
expect(rows.map((r) => r.entity?.id)).toEqual(["e9", "e9"]);
expect(groupRows(rows)).toHaveLength(1);
});
it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => {
const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"]));
expect(rows[0]?.hnrBlocked).toBe(true);
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
});
});
describe("批量设置", () => {
it("集号偏移支持负数,小于 1 的行不改并计数", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv")], new Set()).map((r, i) => ({
...r,
episodes: i === 0 ? "13-14" : "1",
}));
const r = applyOffsetTo(rows, new Set(rows.map((x) => x.key)), -12);
expect(r.rows.map((x) => x.episodes)).toEqual(["1-2", "1"]);
expect(r.failed).toBe(1);
});
it("最小体积:低于排除,回到阈值以上取消并要求补识别;手动排除不动", () => {
let rows = rowsFromIdentify(
[
item("big.mkv"),
item("small.mkv", { size: 40 * MIB, skipped: "below_min_size" }),
item("m.mkv", { size: 10 * MIB }),
],
new Set(),
);
rows = rows.map((r) => (r.path === "m.mkv" ? { ...r, excluded: "manual" as const } : r));
const up = applyMinSize(rows, 100 * MIB);
expect(up.rows.map((r) => r.excluded)).toEqual([null, "below_min_size", "manual"]);
const down = applyMinSize(up.rows, 20 * MIB);
expect(down.rows.map((r) => r.excluded)).toEqual([null, null, "manual"]);
expect(down.reidentify.map((r) => r.path)).toEqual(["small.mkv"]);
});
});
describe("请求体", () => {
it("本地作品带 mediaEntityId,TMDB 候选带 external;电影不带季集;排除项不提交", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv"), item("c.mkv")], new Set());
const r = [
rows[0] as WizardRow,
{ ...(rows[1] as WizardRow), entity: tmdbMovie },
{ ...(rows[2] as WizardRow), excluded: "manual" as const },
];
const body = organizeRequest(
r,
defaultOptions({ target: { mode: "root", rootId: "lib:0e0e0e0e-0000-0000-0000-000000000000" } }),
{ dryRun: true, idempotencyKey: "k-123456" },
);
expect(body.items).toEqual([
{ root: "lib:x", path: "a.mkv", mediaEntityId: "e1", season: 1, episodes: [2] },
{ root: "lib:x", path: "b.mkv", external: { provider: "tmdb", externalId: "872585", kind: "movie" } },
]);
expect(body.targetLibraryRootId).toBe("0e0e0e0e-0000-0000-0000-000000000000");
expect(body).toMatchObject({
onExisting: "conflict",
naming: "template",
refreshMetadata: true,
minSizeBytes: 50 * MIB,
});
});
it("指定目录写入每项 targetDir", () => {
const rows = rowsFromIdentify([item("a.mkv")], new Set());
const body = organizeRequest(rows, defaultOptions({ target: { mode: "dir", ref: { root: "lib:y", path: "A" } } }), {
dryRun: false,
idempotencyKey: "k-123456",
planIds: ["p"],
});
expect(body.items[0]?.targetDir).toEqual({ root: "lib:y", path: "A" });
expect(body.planIds).toEqual(["p"]);
});
});
const group = (over: Partial<FilesOrganizeGroup> = {}): FilesOrganizeGroup => ({
mediaEntityId: "e1",
contentUnitId: null,
planId: "p1",
files: [
{ source: "/dl/A/a.mkv", target: "/lib/T/a.mkv", kind: "main", size: 10, action: "transfer", existing: null },
{
source: "/dl/A/a.zh.srt",
target: "/lib/T/a.zh.srt",
kind: "subtitle",
size: 1,
action: "transfer",
existing: null,
},
{ source: "/dl/A/b.mkv", target: "/lib/T/b.mkv", kind: "main", size: 10, action: "hold", existing: "conflict" },
],
truncated: false,
recordId: null,
taskId: null,
warnings: [],
error: null,
...over,
});
describe("预览", () => {
it("统计:组、文件、可执行、冲突、失败", () => {
expect(previewStats([group(), group({ planId: null, error: { code: "plan_blocked", message: "x" } })])).toEqual({
groups: 2,
files: 6,
executable: 1,
conflicts: 2,
failed: 1,
});
});
it("全部跳过的组不算可执行", () => {
const skipAll = group({
files: [{ source: "/a.mkv", target: "/l/a.mkv", kind: "main", size: 1, action: "skip", existing: "same" }],
});
expect(previewStats([skipAll]).executable).toBe(0);
});
it("附属文件挂在同主干的主文件下", () => {
const n = nestSidecars(group().files);
expect(n.map((x) => [x.main.source, x.sidecars.length])).toEqual([
["/dl/A/a.mkv", 1],
["/dl/A/b.mkv", 0],
]);
});
it("超过 20 组分页", () => {
const p = pageOf(
Array.from({ length: 45 }, (_, i) => i),
2,
);
expect(p).toMatchObject({ pages: 3, page: 2 });
expect(p.items).toHaveLength(5);
});
it("绝对路径按登记目录显示", () => {
expect(displayPath("/srv/dl/A/a.mkv", [{ hostPath: "/srv/dl", name: "qB" }])).toBe("qB/A/a.mkv");
expect(displayPath("/x/a.mkv", [{ hostPath: "/srv/dl", name: "qB" }])).toBe("/x/a.mkv");
});
});
describe("来源换算", () => {
const roots = [
{ id: "dl:i:0", hostPath: "/srv/downloads" },
{ id: "lib:m", hostPath: "/mnt/media/movies" },
];
it("按下载器路径映射换成本机路径再落到登记目录", () => {
const maps = [
{
viewKind: "downloader",
downloaderInstanceId: "i",
remotePrefix: "/downloads",
localPrefix: "/srv/downloads",
enabled: true,
priority: 0,
},
];
expect(downloadContentRef("/downloads/Movie.2023/", "i", maps, roots)).toEqual({
root: "dl:i:0",
path: "Movie.2023",
});
expect(downloadContentRef("/downloads/Movie.2023", "other", maps, roots)).toBeNull();
});
it("没有映射按同机处理;不在登记目录返回 null", () => {
expect(downloadContentRef("/srv/downloads/a.mkv", "i", [], roots)).toEqual({ root: "dl:i:0", path: "a.mkv" });
expect(downloadContentRef("/tmp/a.mkv", "i", [], roots)).toBeNull();
expect(downloadContentRef(null, "i", [], roots)).toBeNull();
});
});
@@ -0,0 +1,422 @@
// 整理向导的纯逻辑(docs/design/file-manager-v2.md §6):识别结果 → 行、未决判断、批量设置、请求体、预览统计、
// 来源换算(下载任务内容路径 → 登记目录 FileRef)。无 React 依赖,单测见 model.test.ts。
import type {
FileIdentifyItem,
FileRef,
FileRoot,
FilesOnExisting,
FilesOrganizeGroup,
FilesOrganizeItem,
FilesOrganizeRequest,
} from "~/api/files.ts";
import { formatEpisodes, parentRel, parseEpisodes, shiftEpisodes } from "~/components/files/model.ts";
// ─── 作品 ────────────────────────────────────────────────────────
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,执行时由后端就地建作品) */
export type WizardEntity = {
id: string | null;
external: { provider: "tmdb"; externalId: string; kind: "movie" | "tv" } | null;
title: string;
year: number | null;
/** movie / series / variety …;TMDB tv 记为 series */
kind: string;
};
export const entityKey = (e: WizardEntity | null) =>
e ? (e.id ?? `tmdb:${e.external?.kind}:${e.external?.externalId}`) : "";
export const entityLabel = (e: Pick<WizardEntity, "title" | "year">) => `${e.title}${e.year ? ` (${e.year})` : ""}`;
export const isMovieKind = (k: string | null | undefined) => k === "movie";
// ─── 行 ──────────────────────────────────────────────────────────
export type ExcludeReason = "manual" | "below_min_size";
export type WizardRow = {
key: string;
root: string;
path: string;
item: FileIdentifyItem;
entity: WizardEntity | null;
/** 用户手动改过作品(同组联动时不覆盖) */
touched: boolean;
season: string;
episodes: string;
slotId: string;
excluded: ExcludeReason | null;
/** 同目录分组键 */
group: string;
hnrBlocked: boolean;
};
/** 行是否仍未决(必须处理或排除才能下一步) */
export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null;
if (!r.entity) return "未选择作品";
const eps = parseEpisodes(r.episodes);
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (!isMovieKind(r.entity.kind) && !r.season.trim()) return "剧集需要季号";
return null;
}
/** 识别项 → 行:高置信本地候选(或服务端建议)自动选中;低于最小体积的项预先排除 */
export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set<string>): WizardRow[] {
return items.map((it) => {
const top = it.candidates[0];
const auto: WizardEntity | null =
top?.mediaEntityId && (top.level === "high" || it.suggestion.mediaEntityId === top.mediaEntityId)
? { id: top.mediaEntityId, external: null, title: top.title, year: top.year, kind: top.kind }
: null;
return {
key: `${it.root}\u0000${it.path}`,
root: it.root,
path: it.path,
item: it,
entity: auto,
touched: false,
season: it.parsed.season != null ? String(it.parsed.season) : "",
episodes: formatEpisodes(it.parsed.episodes),
slotId: "",
excluded: it.skipped === "below_min_size" ? "below_min_size" : null,
group: `${it.root}\u0000${parentRel(it.path)}`,
hnrBlocked: [...hnrPaths].some((p) => it.path === p || it.path.startsWith(`${p}/`)),
};
});
}
/** 改一行的作品:同组中未手动改过、未排除的行跟随 */
export function setRowEntity(
rows: WizardRow[],
key: string,
entity: WizardEntity | null,
propagate = true,
): WizardRow[] {
const target = rows.find((r) => r.key === key);
if (!target) return rows;
return rows.map((r) => {
if (r.key === key) return { ...r, entity, touched: true, slotId: "" };
if (propagate && r.group === target.group && !r.touched && !r.excluded) return { ...r, entity, slotId: "" };
return r;
});
}
/** 组头"应用到本组":把组内第一项已选作品复制到全组(未排除的行) */
export function applyGroupEntity(rows: WizardRow[], group: string): WizardRow[] {
const first = rows.find((r) => r.group === group && !r.excluded && r.entity);
if (!first?.entity) return rows;
const e = first.entity;
return rows.map((r) => (r.group === group && !r.excluded ? { ...r, entity: e, touched: true, slotId: "" } : r));
}
/** 批量:作品应用到选中行 */
export function applyEntityTo(rows: WizardRow[], keys: Set<string>, e: WizardEntity): WizardRow[] {
return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, entity: e, touched: true, slotId: "" } : r));
}
/** 批量:季号应用到选中行 */
export function applySeasonTo(rows: WizardRow[], keys: Set<string>, season: string): WizardRow[] {
return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, season: season.trim() } : r));
}
/** 批量:集号偏移 ±n(选中行);偏移后小于 1 的行不改并计入 failed */
export function applyOffsetTo(rows: WizardRow[], keys: Set<string>, n: number): { rows: WizardRow[]; failed: number } {
let failed = 0;
const out = rows.map((r) => {
if (!keys.has(r.key) || r.excluded) return r;
const eps = parseEpisodes(r.episodes);
if (!eps?.length) return r;
const next = shiftEpisodes(eps, n);
if (!next) {
failed++;
return r;
}
return { ...r, episodes: formatEpisodes(next) };
});
return { rows: out, failed };
}
/**
* 最小体积变化:低于阈值的行排除(below_min_size),回到阈值以上的行取消这类排除;手动排除不动。
* 大小未知的行不动。返回需要补识别的行(之前因体积被服务端跳过、没有候选)。
*/
export function applyMinSize(rows: WizardRow[], minBytes: number): { rows: WizardRow[]; reidentify: WizardRow[] } {
const reidentify: WizardRow[] = [];
const out = rows.map((r) => {
const size = r.item.size;
if (size == null || r.excluded === "manual") return r;
if (size < minBytes) return r.excluded ? r : { ...r, excluded: "below_min_size" as const };
if (r.excluded === "below_min_size") {
if (r.item.skipped === "below_min_size") reidentify.push(r);
return { ...r, excluded: null };
}
return r;
});
return { rows: out, reidentify };
}
/** 用补识别的结果替换行的识别项(保留用户已改的字段) */
export function mergeIdentified(rows: WizardRow[], items: FileIdentifyItem[]): WizardRow[] {
const byKey = new Map(items.map((it) => [`${it.root}\u0000${it.path}`, it]));
const fresh = new Map(rowsFromIdentify(items, new Set()).map((r) => [r.key, r]));
return rows.map((r) => {
const it = byKey.get(r.key);
if (!it) return r;
const f = fresh.get(r.key);
return {
...r,
item: it,
entity: r.entity ?? f?.entity ?? null,
season: r.season || f?.season || "",
episodes: r.episodes || f?.episodes || "",
};
});
}
export type WizardStats = { videos: number; pending: number; excluded: number; bySize: number };
export function wizardStats(rows: WizardRow[]): WizardStats {
return {
videos: rows.length,
pending: rows.filter((r) => rowProblem(r)).length,
excluded: rows.filter((r) => r.excluded).length,
bySize: rows.filter((r) => r.excluded === "below_min_size").length,
};
}
/** 按源目录分组(保持首次出现顺序) */
export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows: WizardRow[] }[] {
const map = new Map<string, WizardRow[]>();
for (const r of rows) map.set(r.group, [...(map.get(r.group) ?? []), r]);
return [...map.entries()].map(([group, rs]) => ({ group, dir: parentRel(rs[0]?.path ?? ""), rows: rs }));
}
// ─── 选项与请求 ──────────────────────────────────────────────────
export type WizardTarget = { mode: "auto" } | { mode: "root"; rootId: string } | { mode: "dir"; ref: FileRef };
export type WizardOptions = {
action: "copy" | "move";
target: WizardTarget;
onExisting: FilesOnExisting;
naming: "template" | "keep";
refreshMetadata: boolean;
minSizeBytes: number;
};
export const MIB = 1024 * 1024;
export const DEFAULT_MIN_SIZE = 50 * MIB;
export const defaultOptions = (o: Partial<WizardOptions> = {}): WizardOptions => ({
action: "copy",
target: { mode: "auto" },
onExisting: "conflict",
naming: "template",
refreshMetadata: true,
minSizeBytes: DEFAULT_MIN_SIZE,
...o,
});
/** `lib:<uuid>` → library_root.id */
export const libraryRootIdOf = (rootId: string) => rootId.replace(/^lib:/, "");
export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganizeItem[] {
return rows
.filter((r) => !r.excluded && r.entity)
.map((r) => {
const e = r.entity as WizardEntity;
const movie = isMovieKind(e.kind);
const eps = parseEpisodes(r.episodes);
const item: FilesOrganizeItem = { root: r.root, path: r.path };
if (e.id) item.mediaEntityId = e.id;
else if (e.external) item.external = e.external;
if (!movie && r.season.trim()) item.season = Number(r.season.trim());
if (!movie && eps?.length) item.episodes = eps;
if (r.slotId && e.id) item.slotId = r.slotId;
if (o.target.mode === "dir") item.targetDir = o.target.ref;
return item;
});
}
/** dryRun 与执行必须同一请求体(服务端按请求指纹比对计划),只差 dryRun/planIds/幂等键 */
export function organizeRequest(
rows: WizardRow[],
o: WizardOptions,
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[] },
): FilesOrganizeRequest {
return {
idempotencyKey: x.idempotencyKey,
items: organizeItems(rows, o),
action: o.action,
targetLibraryRootId: o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
dryRun: x.dryRun,
planIds: x.planIds,
minSizeBytes: o.minSizeBytes,
onExisting: o.onExisting,
naming: o.naming,
refreshMetadata: o.refreshMetadata,
};
}
// ─── 预览 ────────────────────────────────────────────────────────
type GroupFile = FilesOrganizeGroup["files"][number];
export const fileHasProblem = (f: GroupFile) =>
f.action === "hold" || f.existing === "conflict" || (!f.target && f.action !== "skip");
/** 组里所有文件都会被跳过(如已在库中):不算可执行 */
export const groupAllSkipped = (g: FilesOrganizeGroup) =>
g.files.length > 0 && g.files.every((f) => f.action === "skip");
export const groupExecutable = (g: FilesOrganizeGroup) => !!g.planId && !g.error && !groupAllSkipped(g);
export function groupHasProblem(g: FilesOrganizeGroup): boolean {
return (
!!g.error ||
g.warnings.length > 0 ||
(g.excluded?.length ?? 0) > 0 ||
g.files.some((f) => fileHasProblem(f) || f.action === "skip")
);
}
export type PreviewStats = { groups: number; files: number; executable: number; conflicts: number; failed: number };
export function previewStats(groups: FilesOrganizeGroup[]): PreviewStats {
return {
groups: groups.length,
files: groups.reduce((s, g) => s + g.files.length, 0),
executable: groups.filter(groupExecutable).length,
conflicts: groups.reduce((s, g) => s + g.files.filter(fileHasProblem).length, 0),
failed: groups.filter((g) => !!g.error).length,
};
}
export const PAGE_GROUPS = 20;
export function pageOf<T>(list: T[], page: number, size = PAGE_GROUPS): { items: T[]; pages: number; page: number } {
const pages = Math.max(1, Math.ceil(list.length / size));
const p = Math.min(Math.max(0, page), pages - 1);
return { items: list.slice(p * size, p * size + size), pages, page: p };
}
const MAIN_KINDS = new Set(["main", "video", "bluray_dir"]);
/** 预览文件:主文件在前,同主干的附属文件缩进挂在主文件下 */
export function nestSidecars(files: GroupFile[]): { main: GroupFile; sidecars: GroupFile[] }[] {
const mains = files.filter((f) => MAIN_KINDS.has(f.kind));
const rest = files.filter((f) => !MAIN_KINDS.has(f.kind));
const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, "");
const out = mains.map((m) => ({ main: m, sidecars: [] as GroupFile[] }));
const orphans: GroupFile[] = [];
for (const f of rest) {
// 目标名按主文件改名(主干 + .zh.ass 之类),先按目标主干匹配,再按源主干匹配
const host =
out.find((o) => o.main.target && f.target?.startsWith(`${o.main.target.replace(/\.[^./]+$/, "")}.`)) ??
out.find((o) => {
const s = stem(o.main.source);
return (f.source.split("/").pop() ?? "").startsWith(s) && parentRel(f.source) === parentRel(o.main.source);
});
if (host) host.sidecars.push(f);
else orphans.push(f);
}
return [...out, ...orphans.map((f) => ({ main: f, sidecars: [] }))];
}
export const EXCLUDE_REASON: Record<string, string> = {
below_min_size: "低于最小体积",
sample: "样片",
too_small: "体积过小",
not_imported_kind: "不入库的文件类型",
orphan_sidecar: "孤立附属文件",
no_episode: "解析不出集号",
special_extra: "特典/花絮",
extra_content: "额外内容",
no_target_dir: "没有目标目录",
naming_failed: "命名失败",
};
export const SKIP_REASON: Record<string, string> = {
already_in_library: "已在库中",
target_exists: "目标已存在",
main_not_transferred: "主文件未整理",
duplicate_target: "改名后与其他文件同名",
};
export const FILE_KIND: Record<string, string> = {
main: "主视频",
video: "视频",
subtitle: "字幕",
audio: "音轨",
nfo: "NFO",
image: "图片",
bluray_dir: "原盘目录",
other: "其他",
};
export const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖",
root: "库根",
global: "全局",
override: "保留原文件名",
};
/** 绝对路径 → 登记目录下的相对显示(找最长的 hostPath 前缀) */
export function displayPath(abs: string, roots: Pick<FileRoot, "hostPath" | "name">[]): string {
const hit = roots
.filter((r) => abs === r.hostPath || abs.startsWith(`${r.hostPath}/`))
.sort((a, b) => b.hostPath.length - a.hostPath.length)[0];
return hit ? `${hit.name}/${abs.slice(hit.hostPath.length + 1)}` : abs;
}
// ─── 来源换算 ────────────────────────────────────────────────────
export type MappingLite = {
viewKind: string;
downloaderInstanceId: string | null;
remotePrefix: string;
localPrefix: string;
enabled: boolean;
priority: number;
};
const trimSlash = (p: string) => p.replace(/\/+$/, "") || "/";
const under = (p: string, prefix: string) => p === prefix || p.startsWith(`${prefix}/`);
/** 本机绝对路径 → 登记目录 FileRef(最长前缀) */
export function localToRef(local: string, roots: Pick<FileRoot, "id" | "hostPath">[]): FileRef | null {
const p = trimSlash(local);
const hit = roots
.filter((r) => under(p, trimSlash(r.hostPath)))
.sort((a, b) => b.hostPath.length - a.hostPath.length)[0];
if (!hit) return null;
const base = trimSlash(hit.hostPath);
return { root: hit.id, path: p === base ? "" : p.slice(base.length + 1) };
}
/**
* 下载任务内容路径(下载器视角)→ 登记目录 FileRef:先按该下载器的路径映射(优先级高者先)换成本机路径,
* 没有映射时按同机处理(内容路径即本机路径)。
*/
export function downloadContentRef(
contentPath: string | null,
instanceId: string,
mappings: MappingLite[],
roots: Pick<FileRoot, "id" | "hostPath">[],
): FileRef | null {
if (!contentPath) return null;
const cp = trimSlash(contentPath);
const own = mappings
.filter((m) => m.enabled && m.viewKind === "downloader" && m.downloaderInstanceId === instanceId)
.sort((a, b) => b.priority - a.priority || b.remotePrefix.length - a.remotePrefix.length);
for (const m of own) {
const rp = trimSlash(m.remotePrefix);
if (!under(cp, rp)) continue;
const local = `${trimSlash(m.localPrefix)}${cp.slice(rp.length)}`;
const ref = localToRef(local, roots);
if (ref) return ref;
}
return localToRef(cp, roots);
}
@@ -0,0 +1,520 @@
import { useQuery } from "@tanstack/react-query";
import type { CursorPage, PathMapping } from "@umt/contracts";
import { ArrowLeft, ArrowRight, Check, FileStack, LoaderCircle, RotateCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
type FileRef,
type FileRoot,
type FilesOrganizeGroup,
filesErrorReason,
filesErrorText,
filesMockEnabled,
useFileRecords,
useFileRoots,
useIdentify,
useOrganizeFiles,
useRecordSources,
} from "~/api/files.ts";
import { useRecord } from "~/api/records.ts";
import { useSettings } from "~/api/settings.ts";
import { baseName, newIdempotencyKey } from "~/components/files/model.ts";
import { useIsMobile } from "~/components/files/use-mobile.ts";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { apiData } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import {
applyEntityTo,
applyGroupEntity,
applyMinSize,
applyOffsetTo,
applySeasonTo,
DEFAULT_MIN_SIZE,
defaultOptions,
downloadContentRef,
entityLabel,
groupExecutable,
MIB,
mergeIdentified,
organizeRequest,
previewStats,
rowsFromIdentify,
setRowEntity,
type WizardEntity,
type WizardOptions,
type WizardRow,
wizardStats,
} from "./model.ts";
import { mbToBytes, StepFiles } from "./step-files.tsx";
import { StepOptions } from "./step-options.tsx";
import { PreviewGroupCard, PreviewList, PreviewStatsBar } from "./step-preview.tsx";
/** 向导来源(§6):文件管理选中项 / 下载任务内容路径 / 整理记录源路径 */
export type OrganizeSource =
| { kind: "files"; items: FileRef[]; hnrPaths?: string[]; label?: string; preset?: WizardEntity | null }
| {
kind: "download";
taskId: string;
instanceId: string;
contentPath: string | null;
name: string;
/** 做种中且 H&R 未满足:只能复制 */
hnrBlocked?: boolean;
}
| { kind: "record"; recordId: string };
const STEPS = ["文件与作品", "选项", "预览并执行"] as const;
type Resolved = {
items: FileRef[];
hnrPaths: string[];
unresolved: string[];
preset: WizardEntity | null;
label: string;
pending: boolean;
error: unknown;
};
/** 把三种来源换算成登记目录内的 FileRef */
function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined): Resolved {
const isDl = source.kind === "download";
const isRec = source.kind === "record";
const maps = useQuery({
queryKey: ["files", "wizard-path-mappings"],
enabled: isDl && !filesMockEnabled(),
retry: false,
queryFn: ({ signal }) => apiData<CursorPage<PathMapping>>("/path-mappings", { params: { limit: 200 }, signal }),
});
const recSrc = useRecordSources(isRec ? source.recordId : null);
const rec = useRecord(isRec ? source.recordId : undefined);
return useMemo((): Resolved => {
const base = { unresolved: [], preset: null, pending: false, error: null };
if (source.kind === "files")
return {
...base,
items: source.items,
hnrPaths: source.hnrPaths ?? [],
preset: source.preset ?? null,
label:
source.label ??
(source.items.length === 1 ? baseName(source.items[0]?.path ?? "") : `${source.items.length} 项`),
};
if (source.kind === "download") {
if (!roots || (maps.isLoading && !filesMockEnabled()))
return { ...base, items: [], hnrPaths: [], label: source.name, pending: true };
const ref = downloadContentRef(source.contentPath, source.instanceId, maps.data?.items ?? [], roots);
return {
...base,
items: ref ? [ref] : [],
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
label: `下载任务 · ${source.name}`,
};
}
if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
const m = rec.data?.media;
return {
...base,
items: recSrc.data?.items ?? [],
hnrPaths: [],
unresolved: recSrc.data?.unresolved ?? [],
preset:
m && recSrc.data?.mediaEntityId
? { id: recSrc.data.mediaEntityId, external: null, title: m.title, year: m.year, kind: m.kind }
: null,
label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`,
};
}, [source, roots, maps.isLoading, maps.data, recSrc.isPending, recSrc.error, recSrc.data, rec.data]);
}
/**
* 整理向导(唯一入口,§6):① 文件与作品 → ② 选项 → ③ 预览并执行。
* 文件管理、下载详情、整理记录"重新整理"共用。执行后原地显示每组结果与整理记录进度。
*/
export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; onClose: () => void }) {
const navigate = useNavigate();
const mobile = useIsMobile();
const rootsQ = useFileRoots();
const roots = rootsQ.data;
const settings = useSettings();
const src = useResolvedSource(source, roots);
const [step, setStep] = useState(0);
const [rows, setRows] = useState<WizardRow[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [options, setOptions] = useState<WizardOptions>(defaultOptions());
const [minSizeMb, setMinSizeMb] = useState(String(DEFAULT_MIN_SIZE / MIB));
const [truncated, setTruncated] = useState(false);
const [preview, setPreview] = useState<{ groups: FilesOrganizeGroup[]; rows: WizardRow[]; key: string } | null>(null);
const [results, setResults] = useState<FilesOrganizeGroup[]>([]);
const [recordIds, setRecordIds] = useState<string[]>([]);
const [notice, setNotice] = useState<string | null>(null);
const [onlyProblems, setOnlyProblems] = useState(false);
const [page, setPage] = useState(0);
const identify = useIdentify();
const reidentify = useIdentify();
const dry = useOrganizeFiles();
const exec = useOrganizeFiles();
const records = useFileRecords({ ids: recordIds }, recordIds.length > 0);
// 设置里的"整理默认"(方式 / 最小体积);读不到用默认值
const ready = settings.isFetched || settings.isError || filesMockEnabled();
const defaults = useMemo(() => {
const o = settings.data?.organize;
return defaultOptions({
action: o?.defaultMode === "move" ? "move" : "copy",
minSizeBytes: o?.minSizeBytes ?? DEFAULT_MIN_SIZE,
});
}, [settings.data]);
// 来源就绪 → 识别(一次)
const [started, setStarted] = useState(false);
const identifyMutate = identify.mutate;
useEffect(() => {
if (started || !ready || src.pending || !src.items.length) return;
setStarted(true);
setOptions(defaults);
setMinSizeMb(String(Math.round(defaults.minSizeBytes / MIB)));
identifyMutate(
{ items: src.items, minSizeBytes: defaults.minSizeBytes },
{
onSuccess: (r) => {
const hnr = new Set(src.hnrPaths);
let next = rowsFromIdentify(r.items, hnr);
if (src.preset) {
const p = src.preset;
next = next.map((x) => (x.excluded || x.entity?.id === p.id ? x : { ...x, entity: p }));
}
setRows(next);
setTruncated(r.truncated);
},
},
);
}, [started, ready, src, defaults, identifyMutate]);
const active = rows.filter((r) => !r.excluded);
const stats = wizardStats(rows);
const hnrCount = active.filter((r) => r.hnrBlocked).length;
const kinds = new Set(active.map((r) => r.entity?.kind).filter((k): k is string => !!k));
const canNext = active.length > 0 && stats.pending === 0;
useEffect(() => {
if (hnrCount && options.action === "move") setOptions((o) => ({ ...o, action: "copy" }));
}, [hnrCount, options.action]);
const rootName = useCallback((id: string) => roots?.find((r) => r.id === id)?.name ?? id, [roots]);
// ── ① 批量操作 ──
const onMinSizeMb = (v: string) => {
setMinSizeMb(v);
const bytes = mbToBytes(v);
if (bytes == null) return;
setOptions((o) => ({ ...o, minSizeBytes: bytes }));
const r = applyMinSize(rows, bytes);
setRows(r.rows);
if (r.reidentify.length)
reidentify.mutate(
{ items: r.reidentify.map((x) => ({ root: x.root, path: x.path })), minSizeBytes: bytes },
{
onSuccess: (res) => setRows((rs) => mergeIdentified(rs, res.items)),
onError: (e) => toast.error(filesErrorText(e, "补充识别失败")),
},
);
};
// ── ③ 预览 / 执行 ──
const titleOf = useCallback(
(g: FilesOrganizeGroup) => {
if (g.entity) return g.entity.title;
const r = rows.find((x) => x.entity?.id === g.mediaEntityId);
return r?.entity ? entityLabel(r.entity) : g.mediaEntityId.slice(0, 8);
},
[rows],
);
const runPreview = (useRows: WizardRow[], note: string | null = null) => {
const key = newIdempotencyKey();
setNotice(note);
setPage(0);
setStep(2);
setPreview(null);
dry.mutate(organizeRequest(useRows, options, { dryRun: true, idempotencyKey: `${key}-dry` }), {
onSuccess: (r) => setPreview({ groups: r.groups, rows: useRows, key }),
onError: (e) => toast.error(filesErrorText(e, "生成预览失败")),
});
};
const executable = preview?.groups.filter(groupExecutable) ?? [];
const runExec = () => {
if (!preview || !executable.length) return;
const body = organizeRequest(preview.rows, options, {
dryRun: false,
idempotencyKey: preview.key,
planIds: executable.map((g) => g.planId as string),
});
exec.mutate(body, {
onSuccess: (r) => {
const stale = r.groups.filter((g) => g.error?.code === "plan_stale");
const done = r.groups.filter((g) => g.error?.code !== "plan_stale");
setResults((prev) => [...prev, ...done]);
setRecordIds((prev) => [...new Set([...prev, ...r.recordIds])]);
if (stale.length) {
const ids = new Set(stale.map((g) => g.mediaEntityId));
const ext = new Set(stale.map((g) => g.entity?.externalId).filter(Boolean));
const again = preview.rows.filter(
(x) =>
(x.entity?.id && ids.has(x.entity.id)) || (x.entity?.external && ext.has(x.entity.external.externalId)),
);
runPreview(again.length ? again : preview.rows, `${stale.length} 组源文件在预览后有变化,已重新预览`);
} else setPreview(null);
},
onError: (e) => {
if (filesErrorReason(e) === "plan_stale") {
runPreview(preview.rows, "源文件在预览后有变化,已重新预览");
toast.warning("源文件在预览后有变化,已重新预览");
} else toast.error(filesErrorText(e, "执行失败"));
},
});
};
const recordsHref =
recordIds.length === 1 ? `/console/organize/${recordIds[0]}` : `/console/organize?ids=${recordIds.join(",")}`;
const recordById = new Map((records.data?.items ?? []).map((r) => [r.id, r]));
const executed = results.length > 0 && !preview && !dry.isPending;
// ── 渲染 ──
let body: React.ReactNode;
if (step === 0) {
if (src.pending || (!started && !src.error && src.items.length > 0) || identify.isPending) {
body = (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" />
{src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"}
</p>
);
} else if (src.error || (!src.items.length && src.unresolved.length) || !src.items.length) {
body = (
<div className="space-y-2 text-caption">
<p className="text-destructive">
{src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"}
</p>
{src.unresolved.length ? (
<ul className="space-y-0.5 font-mono text-meta text-muted-foreground">
{src.unresolved.map((p) => (
<li key={p} className="break-all">
{p}
</li>
))}
</ul>
) : null}
<p className="text-muted-foreground">
在设置里检查下载器路径映射,或到
<Link to="/console/files" className="mx-1 underline underline-offset-2" onClick={onClose}>
文件管理
</Link>
里选中文件后整理。
</p>
</div>
);
} else if (identify.error) {
body = (
<div className="space-y-2">
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
<Button size="sm" variant="outline" onClick={() => setStarted(false)}>
<RotateCw />
重试
</Button>
</div>
);
} else if (!rows.length) {
body = <p className="text-caption text-muted-foreground">选中的项里没有视频文件。</p>;
} else {
body = (
<div className="space-y-3">
{src.unresolved.length ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
{src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。
</p>
) : null}
<StepFiles
rows={rows}
stats={stats}
selected={selected}
onSelected={setSelected}
onEntity={(k, e) => setRows((rs) => setRowEntity(rs, k, e))}
onPatch={(k, p) => setRows((rs) => rs.map((r) => (r.key === k ? { ...r, ...p } : r)))}
onApplyGroup={(g) => setRows((rs) => applyGroupEntity(rs, g))}
onBulkEntity={(e) => setRows((rs) => applyEntityTo(rs, selected, e))}
onBulkSeason={(s) => setRows((rs) => applySeasonTo(rs, selected, s))}
onBulkOffset={(n) => {
const r = applyOffsetTo(rows, selected, n);
setRows(r.rows);
if (r.failed) toast.warning(`${r.failed} 项偏移后集号小于 1,没有改`);
}}
minSizeMb={minSizeMb}
onMinSizeMb={onMinSizeMb}
truncated={truncated}
mobile={mobile}
rootName={rootName}
/>
</div>
);
}
} else if (step === 1) {
body = (
<StepOptions options={options} onChange={setOptions} roots={roots ?? []} hnrCount={hnrCount} kinds={kinds} />
);
} else {
const groups = preview?.groups ?? [];
body = (
<div className="space-y-4">
{notice ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-caption text-warning">
{notice}
</p>
) : null}
{results.length ? (
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">执行结果</h3>
{results.map((g, i) => (
<PreviewGroupCard
key={`r-${g.recordId ?? i}`}
g={g}
title={titleOf(g)}
roots={roots ?? []}
onlyProblems={false}
result
record={g.recordId ? recordById.get(g.recordId) : undefined}
/>
))}
{executed ? (
<p className="text-caption text-muted-foreground">整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。</p>
) : null}
</section>
) : null}
{dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成整理计划…
</p>
) : preview ? (
<div className="space-y-3">
<PreviewStatsBar
stats={previewStats(groups)}
onlyProblems={onlyProblems}
onOnlyProblems={setOnlyProblems}
/>
<PreviewList
groups={groups}
titleOf={titleOf}
roots={roots ?? []}
onlyProblems={onlyProblems}
page={page}
onPage={setPage}
/>
</div>
) : !results.length ? (
<p className="text-caption text-destructive">{filesErrorText(dry.error, "生成预览失败")}</p>
) : null}
</div>
);
}
return (
<Sheet open onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-6xl">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="text-lead">整理</SheetTitle>
<SheetDescription className="truncate">{src.label}</SheetDescription>
<ol className="mt-3 flex flex-wrap items-center gap-1.5" aria-label="步骤">
{STEPS.map((s, i) => (
<li key={s} className="flex items-center gap-1.5">
{i > 0 ? <span aria-hidden className="h-px w-4 bg-border" /> : null}
<span
aria-current={step === i ? "step" : undefined}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-meta",
step === i
? "pill-selected text-foreground"
: i < step
? "border-border text-foreground"
: "border-border text-muted-foreground",
)}
>
{i < step ? <Check className="h-3 w-3" /> : <span className="tabular-nums">{i + 1}</span>}
{s}
</span>
</li>
))}
</ol>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{body}</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--shell-border)] px-5 py-3">
<div>
{step === 1 ? (
<Button variant="ghost" onClick={() => setStep(0)}>
<ArrowLeft />
上一步
</Button>
) : null}
{step === 2 && !executed ? (
<Button variant="ghost" onClick={() => setStep(0)} disabled={exec.isPending}>
<ArrowLeft />
返回修改
</Button>
) : null}
</div>
<div className="flex items-center gap-2">
{step === 0 ? (
<>
{rows.length && !canNext ? (
<span className="text-meta text-warning">
{stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"}
</span>
) : null}
<Button onClick={() => setStep(1)} disabled={!canNext}>
下一步
<ArrowRight />
</Button>
</>
) : null}
{step === 1 ? (
<Button
onClick={() => runPreview(rows)}
disabled={options.target.mode === "dir" && !options.target.ref.root}
>
生成预览
<ArrowRight />
</Button>
) : null}
{step === 2 && preview ? (
<Button onClick={runExec} disabled={!executable.length || exec.isPending}>
{exec.isPending ? <LoaderCircle className="animate-spin" /> : null}
执行 {executable.length} 组
</Button>
) : null}
{step === 2 && executed ? (
<>
<Button
variant="outline"
disabled={!recordIds.length}
onClick={() => {
onClose();
navigate(recordsHref);
}}
>
<FileStack />
查看整理记录
</Button>
<Button onClick={onClose}>完成</Button>
</>
) : null}
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,51 @@
import { ChevronRight } from "lucide-react";
import { cn } from "~/lib/utils.ts";
export type PathSegment = { text: string; role: string; bound?: boolean };
const ROLE: Record<string, { label: string; cls: string }> = {
category: { label: "分类", cls: "text-muted-foreground" },
root: { label: "作品目录", cls: "text-foreground font-medium" },
season: { label: "季目录", cls: "text-foreground" },
dir: { label: "子目录", cls: "text-muted-foreground" },
file: { label: "文件名", cls: "text-foreground" },
};
/**
* 目标路径分段着色(§6.3):作品目录 / 季目录 / 子目录 / 文件名,已沿用路径绑定的段加"沿用绑定"标签。
* 向导预览与重命名共用。
*/
export function PathSegments({
segments,
prefix,
className,
}: {
segments: PathSegment[];
/** 段前的固定部分(库根名或本机路径),淡显 */
prefix?: string | null;
className?: string;
}) {
return (
<p className={cn("flex min-w-0 flex-wrap items-center gap-x-0.5 gap-y-1 font-mono text-meta", className)}>
{prefix ? <span className="break-all text-muted-foreground/80">{prefix}</span> : null}
{segments.map((s, i) => {
const r = ROLE[s.role] ?? ROLE.dir;
return (
<span key={`${i}-${s.text}`} className="inline-flex min-w-0 items-center gap-0.5">
{prefix || i > 0 ? (
<ChevronRight aria-hidden className="h-3 w-3 shrink-0 text-muted-foreground/60" />
) : null}
<span className={cn("break-all", r?.cls)} title={r?.label}>
{s.text}
</span>
{s.bound ? (
<span className="ml-1 rounded-tight border border-border px-1 font-sans text-micro text-muted-foreground">
沿用绑定
</span>
) : null}
</span>
);
})}
</p>
);
}
@@ -0,0 +1,519 @@
import { ChevronDown, Undo2, X } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { filesMockEnabled } from "~/api/files.ts";
import { useSlots } from "~/api/slots.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Checkbox } from "~/components/files/file-list.tsx";
import { baseName, contentUnitLabel } from "~/components/files/model.ts";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { EntityPicker } from "./entity-picker.tsx";
import {
groupRows,
isMovieKind,
MIB,
rowProblem,
type WizardEntity,
type WizardRow,
type WizardStats,
} from "./model.ts";
type RowPatch = Partial<Pick<WizardRow, "season" | "episodes" | "slotId" | "excluded">>;
export type StepFilesProps = {
rows: WizardRow[];
stats: WizardStats;
selected: Set<string>;
onSelected: (s: Set<string>) => void;
onEntity: (key: string, e: WizardEntity | null) => void;
onPatch: (key: string, p: RowPatch) => void;
onApplyGroup: (group: string) => void;
onBulkEntity: (e: WizardEntity) => void;
onBulkSeason: (s: string) => void;
onBulkOffset: (n: number) => void;
minSizeMb: string;
onMinSizeMb: (v: string) => void;
truncated: boolean;
mobile: boolean;
rootName: (rootId: string) => string;
};
/** 向导 ① 批量条(§6.1 WizardBulkBar):作品 / 季 / 集号偏移 / 最小体积 + 统计 */
export function WizardBulkBar({
selectedCount,
onBulkEntity,
onBulkSeason,
onBulkOffset,
minSizeMb,
onMinSizeMb,
stats,
}: {
selectedCount: number;
onBulkEntity: (e: WizardEntity) => void;
onBulkSeason: (s: string) => void;
onBulkOffset: (n: number) => void;
minSizeMb: string;
onMinSizeMb: (v: string) => void;
stats: WizardStats;
}) {
const [season, setSeason] = useState("");
const [offset, setOffset] = useState("");
const off = Number(offset);
const none = selectedCount === 0;
const hint = none ? "先勾选要改的行" : undefined;
return (
<div className="space-y-2 rounded-xl border border-border bg-foreground/[0.02] p-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-caption">
<span className="text-muted-foreground">已选 {selectedCount} 项</span>
<div className="flex min-w-0 items-center gap-1.5" title={hint}>
<span className="shrink-0 text-muted-foreground">作品</span>
<div className={cn("w-52 min-w-0", none && "pointer-events-none opacity-50")}>
<EntityPicker value={null} onChange={(e) => e && onBulkEntity(e)} placeholder="应用到选中…" />
</div>
</div>
<div className="flex items-center gap-1.5" title={hint}>
<span className="text-muted-foreground">季</span>
<Input
value={season}
onChange={(e) => setSeason(e.target.value)}
inputMode="numeric"
aria-label="批量季号"
className="h-8 w-14 text-center"
/>
<Button
size="sm"
variant="outline"
disabled={none || !/^\d{1,4}$/.test(season.trim())}
onClick={() => onBulkSeason(season)}
>
应用
</Button>
</div>
<div className="flex items-center gap-1.5" title={hint}>
<span className="text-muted-foreground">集号偏移</span>
<Input
value={offset}
onChange={(e) => setOffset(e.target.value)}
placeholder="±N"
aria-label="集号偏移量(可为负)"
className="h-8 w-16 text-center"
/>
<Button
size="sm"
variant="outline"
disabled={none || !/^[+-]?\d{1,4}$/.test(offset.trim()) || !off}
onClick={() => onBulkOffset(off)}
>
应用
</Button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">最小体积</span>
<Input
value={minSizeMb}
onChange={(e) => onMinSizeMb(e.target.value)}
inputMode="numeric"
aria-label="最小体积(MB)"
className="h-8 w-16 text-center"
/>
<span className="text-muted-foreground">MB</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<StatusBadge>{stats.videos} 个视频</StatusBadge>
{stats.pending ? (
<StatusBadge tone="warning">{stats.pending} 项待定</StatusBadge>
) : (
<StatusBadge tone="success">没有待定项</StatusBadge>
)}
{stats.excluded ? (
<StatusBadge>
{stats.excluded} 项已排除{stats.bySize ? `(${stats.bySize} 项低于最小体积)` : ""}
</StatusBadge>
) : null}
</div>
</div>
);
}
function RowMarks({ row }: { row: WizardRow }) {
const it = row.item;
return (
<span className="flex flex-wrap gap-1">
{it.existing.assetId ? (
<StatusBadge tone="neutral" className="whitespace-nowrap">
已登记
</StatusBadge>
) : null}
{it.records?.count ? (
it.records.lastRecordId ? (
<Link to={`/console/organize/${it.records.lastRecordId}`} target="_blank" className="hover:underline">
<StatusBadge tone="neutral">有 {it.records.count} 条整理记录</StatusBadge>
</Link>
) : (
<StatusBadge tone="neutral">有 {it.records.count} 条整理记录</StatusBadge>
)
) : null}
{row.hnrBlocked ? <StatusBadge tone="warning">做种中·H&R 未满足</StatusBadge> : null}
{row.excluded === "below_min_size" ? <StatusBadge>低于最小体积</StatusBadge> : null}
{row.entity && !row.entity.id ? <StatusBadge tone="warning">将新建作品</StatusBadge> : null}
</span>
);
}
function parsedLine(row: WizardRow) {
const p = row.item.parsed;
return [
p.title ?? "解析不出标题",
p.year ? String(p.year) : null,
p.season != null ? contentUnitLabel(p.season, p.episodes) : null,
p.resolution,
p.source,
p.releaseGroup,
row.item.size != null ? formatBytes(row.item.size, 1) : null,
]
.filter(Boolean)
.join(" · ");
}
function SlotSelect({
entity,
value,
onChange,
}: {
entity: WizardEntity | null;
value: string;
onChange: (v: string) => void;
}) {
const mock = filesMockEnabled();
const id = entity?.id ?? null;
const slots = useSlots({ mediaEntityId: id ?? undefined }, !!id && !mock);
const items = slots.data?.items ?? [];
return (
<select
aria-label="版本目标"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={!id}
title={entity && !id ? "新建的作品还没有版本目标" : undefined}
className="h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
>
<option value="">自动</option>
{items.map((s) => (
<option key={s.id} value={s.id}>
{s.name} · {SLOT_STATUS[s.status]?.label ?? s.status}
</option>
))}
</select>
);
}
function ExcludeButton({ row, onPatch }: { row: WizardRow; onPatch: (p: RowPatch) => void }) {
return (
<Button
variant="ghost"
size="xs"
onClick={() => onPatch({ excluded: row.excluded ? null : "manual" })}
aria-label={row.excluded ? `恢复 ${baseName(row.path)}` : `排除 ${baseName(row.path)}`}
>
{row.excluded ? <Undo2 /> : <X />}
{row.excluded ? "恢复" : "排除"}
</Button>
);
}
/** 向导 ①:按源目录分组的表格(桌面)/ 紧凑卡片(手机) */
export function StepFiles(p: StepFilesProps) {
const groups = groupRows(p.rows);
const allKeys = p.rows.filter((r) => !r.excluded).map((r) => r.key);
const toggle = (k: string) => {
const n = new Set(p.selected);
if (n.has(k)) n.delete(k);
else n.add(k);
p.onSelected(n);
};
const [bulkOpen, setBulkOpen] = useState(false);
const bulk = (
<WizardBulkBar
selectedCount={p.selected.size}
onBulkEntity={p.onBulkEntity}
onBulkSeason={p.onBulkSeason}
onBulkOffset={p.onBulkOffset}
minSizeMb={p.minSizeMb}
onMinSizeMb={p.onMinSizeMb}
stats={p.stats}
/>
);
return (
<div className="space-y-3">
{p.mobile ? (
<div>
<button
type="button"
aria-expanded={bulkOpen}
onClick={() => setBulkOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-xl border border-border px-3 py-2 text-caption"
>
<span>
批量设置 · {p.stats.videos} 个视频{p.stats.pending ? ` · ${p.stats.pending} 项待定` : ""}
</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", bulkOpen && "rotate-180")} />
</button>
{bulkOpen ? <div className="mt-2">{bulk}</div> : null}
</div>
) : (
bulk
)}
{p.truncated ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录里视频超过 200 个,只识别了前 200 个。进入子目录分批整理。
</p>
) : null}
{p.mobile ? (
<div className="space-y-4">
{groups.map((g) => (
<section key={g.group} className="space-y-2">
<GroupHead
dir={g.dir}
rootName={p.rootName(g.rows[0]?.root ?? "")}
count={g.rows.length}
onApply={() => p.onApplyGroup(g.group)}
canApply={g.rows.length > 1 && g.rows.some((r) => r.entity && !r.excluded)}
/>
<ul className="space-y-2">
{g.rows.map((r) => {
const problem = rowProblem(r);
const movie = isMovieKind(r.entity?.kind);
return (
<li
key={r.key}
className={cn(
"rounded-xl border px-3 py-2",
r.excluded
? "border-border opacity-60"
: problem
? "border-warning/50 bg-warning/[0.06]"
: "border-border",
)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={p.selected.has(r.key)}
label={`选择 ${baseName(r.path)}`}
onToggle={() => toggle(r.key)}
/>
<p className="min-w-0 flex-1 truncate font-mono text-caption" title={r.path}>
{baseName(r.path)}
</p>
<ExcludeButton row={r} onPatch={(x) => p.onPatch(r.key, x)} />
</div>
{!r.excluded ? (
<div className="mt-1.5 grid grid-cols-[minmax(0,1fr)_3.25rem_4.5rem] gap-1.5">
<EntityPicker
candidates={r.item.candidates}
value={r.entity}
onChange={(e) => p.onEntity(r.key, e)}
invalid={!r.entity}
/>
<Input
aria-label="季"
placeholder="季"
value={r.season}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { season: e.target.value })}
className="h-8 px-1 text-center"
/>
<Input
aria-label="集"
placeholder="集"
value={r.episodes}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { episodes: e.target.value })}
className="h-8 px-1 text-center"
/>
</div>
) : null}
<div className="mt-1">
<RowMarks row={r} />
</div>
{problem ? <p className="mt-1 text-meta text-warning">{problem}</p> : null}
</li>
);
})}
</ul>
</section>
))}
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full min-w-[56rem] border-collapse text-caption">
<thead>
<tr className="border-b border-border text-left text-meta text-muted-foreground">
<th className="w-9 px-2 py-2">
<Checkbox
checked={allKeys.length > 0 && allKeys.every((k) => p.selected.has(k))}
partial={p.selected.size > 0 && !allKeys.every((k) => p.selected.has(k))}
label="全选"
onToggle={() =>
p.onSelected(allKeys.every((k) => p.selected.has(k)) ? new Set() : new Set(allKeys))
}
/>
</th>
<th className="px-2 py-2 font-medium">源文件</th>
<th className="w-[15rem] px-2 py-2 font-medium">作品</th>
<th className="w-16 px-2 py-2 font-medium">季</th>
<th className="w-24 px-2 py-2 font-medium">集</th>
<th className="w-40 px-2 py-2 font-medium">版本目标</th>
<th className="w-20 px-2 py-2">
<span className="sr-only">操作</span>
</th>
</tr>
</thead>
{groups.map((g) => (
<tbody key={g.group}>
<tr className="border-b border-border/60 bg-foreground/[0.03]">
<td colSpan={7} className="px-2 py-1.5">
<GroupHead
dir={g.dir}
rootName={p.rootName(g.rows[0]?.root ?? "")}
count={g.rows.length}
onApply={() => p.onApplyGroup(g.group)}
canApply={g.rows.length > 1 && g.rows.some((r) => r.entity && !r.excluded)}
/>
</td>
</tr>
{g.rows.map((r) => {
const problem = rowProblem(r);
const movie = isMovieKind(r.entity?.kind);
return (
<tr
key={r.key}
className={cn(
"border-b border-border/50 align-top",
r.excluded ? "opacity-55" : problem ? "bg-warning/[0.07]" : "",
)}
>
<td className="px-2 py-2">
<Checkbox
checked={p.selected.has(r.key)}
label={`选择 ${baseName(r.path)}`}
onToggle={() => toggle(r.key)}
/>
</td>
<td className="max-w-0 px-2 py-1.5">
<p className="truncate font-mono text-caption text-foreground" title={r.path}>
{baseName(r.path)}
</p>
<p className="truncate text-meta text-muted-foreground" title={parsedLine(r)}>
{parsedLine(r)}
</p>
<div className="mt-0.5 empty:hidden">
<RowMarks row={r} />
</div>
{problem ? <p className="text-meta text-warning">{problem}</p> : null}
{(r.item.skipped ? [] : r.item.warnings).map((w) => (
<p key={w} className="text-meta text-warning">
{w}
</p>
))}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<EntityPicker
candidates={r.item.candidates}
value={r.entity}
onChange={(e) => p.onEntity(r.key, e)}
invalid={!r.entity}
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<Input
aria-label={`${baseName(r.path)} 季`}
value={movie ? "" : r.season}
placeholder={movie ? "—" : ""}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { season: e.target.value })}
className="h-8 px-1 text-center"
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<Input
aria-label={`${baseName(r.path)} 集`}
value={movie ? "" : r.episodes}
placeholder={movie ? "—" : "如 3-5"}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { episodes: e.target.value })}
className="h-8 px-1 text-center"
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<SlotSelect
entity={r.entity}
value={r.slotId}
onChange={(v) => p.onPatch(r.key, { slotId: v })}
/>
)}
</td>
<td className="px-1 py-1.5 text-right">
<ExcludeButton row={r} onPatch={(x) => p.onPatch(r.key, x)} />
</td>
</tr>
);
})}
</tbody>
))}
</table>
</div>
)}
</div>
);
}
function GroupHead({
dir,
rootName,
count,
onApply,
canApply,
}: {
dir: string;
rootName: string;
count: number;
onApply: () => void;
canApply: boolean;
}) {
return (
<div className="flex min-w-0 items-center gap-2 text-meta">
<span className="min-w-0 truncate font-mono text-foreground" title={`${rootName}/${dir}`}>
{dir ? dir.split("/").pop() : rootName}
</span>
<span className="shrink-0 text-muted-foreground">{count} 项</span>
{canApply ? (
<button
type="button"
onClick={onApply}
className="ml-auto shrink-0 text-muted-foreground underline underline-offset-2 hover:text-foreground"
title="把本组第一项的作品应用到本组全部"
>
应用到本组
</button>
) : null}
</div>
);
}
export const mbToBytes = (v: string) => {
const n = Number(v.trim());
return Number.isFinite(n) && n >= 0 ? Math.round(n * MIB) : null;
};
@@ -0,0 +1,222 @@
import { Link } from "react-router";
import type { FileRoot } from "~/api/files.ts";
import { DirPicker } from "~/components/files/dir-picker.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import type { WizardOptions } from "./model.ts";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">{title}</h3>
{children}
</section>
);
}
function Choice({
checked,
disabled,
onSelect,
title,
hint,
}: {
checked: boolean;
disabled?: string | null;
onSelect: () => void;
title: string;
hint?: React.ReactNode;
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: 带说明文字的整卡单选,原生 radio 无法套卡片样式;键盘可达(button)
<button
type="button"
role="radio"
aria-checked={checked}
aria-disabled={!!disabled || undefined}
title={disabled ?? undefined}
onClick={() => !disabled && onSelect()}
className={cn(
"flex w-full items-start gap-2.5 rounded-xl border px-3 py-2 text-left transition-colors duration-micro",
checked ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
)}
>
<span
aria-hidden
className={cn(
"mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border",
checked ? "border-foreground" : "border-border",
)}
>
{checked ? <span className="h-2 w-2 rounded-full bg-foreground" /> : null}
</span>
<span className="min-w-0">
<span className="block text-body">{title}</span>
{disabled ? (
<span className="block text-meta text-muted-foreground">{disabled}</span>
) : hint ? (
<span className="block text-meta text-muted-foreground">{hint}</span>
) : null}
</span>
</button>
);
}
/** 向导 ②:MP 手动整理字段的映射(§6.2) */
export function StepOptions({
options,
onChange,
roots,
hnrCount,
kinds,
}: {
options: WizardOptions;
onChange: (o: WizardOptions) => void;
roots: FileRoot[];
/** 做种中且 H&R 未满足的行数:大于 0 时不能移动 */
hnrCount: number;
/** 本次涉及的作品类型(过滤库根) */
kinds: Set<string>;
}) {
const set = (p: Partial<WizardOptions>) => onChange({ ...options, ...p });
const libRoots = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
/** 库根类型与本次作品类型不符时的原因 */
const rootMismatch = (r: FileRoot): string | null => {
if (r.mediaKind === "mixed" || r.mediaKind === "other" || !r.mediaKind) return null;
const hasMovie = [...kinds].some((k) => k === "movie");
const hasShow = [...kinds].some((k) => k !== "movie");
if (r.mediaKind === "movie" && hasShow) return "电影库,本次含剧集";
if (r.mediaKind !== "movie" && hasMovie) return "剧集库,本次含电影";
return null;
};
const t = options.target;
return (
<div className="grid max-w-4xl gap-6 lg:grid-cols-2">
<div className="space-y-6">
<Section title="整理方式">
<div role="radiogroup" aria-label="整理方式" className="space-y-1.5">
<Choice
checked={options.action === "copy"}
onSelect={() => set({ action: "copy" })}
title="复制"
hint="保留源文件,不影响做种"
/>
<Choice
checked={options.action === "move"}
onSelect={() => set({ action: "move" })}
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
title="移动"
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
/>
<Choice checked={false} onSelect={() => {}} disabled="还不支持硬链接" title="硬链接" />
</div>
</Section>
<Section title="目标已存在">
<div role="radiogroup" aria-label="目标已存在" className="space-y-1.5">
<Choice
checked={options.onExisting === "conflict"}
onSelect={() => set({ onExisting: "conflict" })}
title="交给待处理"
hint="该组不执行,冲突进待处理"
/>
<Choice
checked={options.onExisting === "skip"}
onSelect={() => set({ onExisting: "skip" })}
title="跳过"
hint="只跳过已存在的文件,其余照常整理"
/>
<Choice
checked={options.onExisting === "replace_with_verify"}
onSelect={() => set({ onExisting: "replace_with_verify" })}
title="替换"
hint="新文件核验通过后,旧文件移入回收站"
/>
</div>
</Section>
</div>
<div className="space-y-6">
<Section title="目标">
<div role="radiogroup" aria-label="目标" className="space-y-1.5">
<Choice
checked={t.mode === "auto"}
onSelect={() => set({ target: { mode: "auto" } })}
title="自动"
hint="已绑定的作品沿用原目录,否则按作品类型选库根"
/>
{libRoots.map((r) => (
<Choice
key={r.id}
checked={t.mode === "root" && t.rootId === r.id}
disabled={rootMismatch(r)}
onSelect={() => set({ target: { mode: "root", rootId: r.id } })}
title={r.name}
hint={
<span className="font-mono">
{r.hostPath}
{r.usage ? ` · 剩余 ${formatBytes(r.usage.freeBytes, 1)}` : ""}
</span>
}
/>
))}
<Choice
checked={t.mode === "dir"}
onSelect={() => set({ target: { mode: "dir", ref: t.mode === "dir" ? t.ref : { root: "", path: "" } } })}
title="指定目录"
hint="文件按模板命名后直接放进这个目录,不建路径绑定"
/>
{t.mode === "dir" ? (
<DirPicker
roots={roots}
value={t.ref.root ? t.ref : null}
onChange={(ref) => set({ target: { mode: "dir", ref } })}
className="max-h-64"
/>
) : null}
</div>
</Section>
<Section title="命名">
<div role="radiogroup" aria-label="命名" className="space-y-1.5">
<Choice
checked={options.naming === "template"}
onSelect={() => set({ naming: "template" })}
title="按命名模板"
hint={
<>
模板按 作品覆盖 → 库根 → 全局 取第一个,预览里显示实际来源。
<Link to="/console/settings#library/naming" className="ml-1 underline underline-offset-2">
去设置
</Link>
</>
}
/>
<Choice
checked={options.naming === "keep"}
onSelect={() => set({ naming: "keep" })}
title="保留原文件名"
hint="目录仍按模板或已有绑定"
/>
</div>
</Section>
<Section title="入库后">
<div className="flex items-start justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-body">
刷新元数据
<span className="block text-meta text-muted-foreground">按字段刷新,锁定的字段不变</span>
</span>
<Switch
checked={options.refreshMetadata}
onCheckedChange={(v) => set({ refreshMetadata: v })}
aria-label="入库后刷新元数据"
/>
</div>
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
</Section>
</div>
</div>
);
}
@@ -0,0 +1,288 @@
import type { PublicOrganizationRecord } from "@umt/contracts";
import { ArrowRight, TriangleAlert } from "lucide-react";
import { Link } from "react-router";
import type { FileRoot, FilesOrganizeGroup } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { baseName, parentRel } from "~/components/files/model.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import {
displayPath,
EXCLUDE_REASON,
FILE_KIND,
fileHasProblem,
groupAllSkipped,
groupExecutable,
groupHasProblem,
nestSidecars,
type PreviewStats,
pageOf,
SKIP_REASON,
TEMPLATE_SOURCE,
} from "./model.ts";
import { PathSegments } from "./path-segments.tsx";
export type GroupTitle = (g: FilesOrganizeGroup) => string;
const ERROR_TEXT: Record<string, string> = {
plan_blocked: "目标已存在,按“交给待处理”不执行",
plan_stale: "源文件在预览后有变化",
approval_required: "移动需要审批或持续授权",
download_active: "做种中且 H&R 未满足,不能移动",
};
/** 服务端原因码(message 以 code: 开头)→ 文案 */
const REASON_PREFIX: Record<string, string> = {
library_root_offline: "目标库根离线",
root_unavailable: "目录离线",
not_writable: "目标不可写",
path_escape: "路径越界",
source_missing: "源文件不存在",
};
function errorText(e: { code: string; message: string }): string {
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]})`;
return e.message || ERROR_TEXT[e.code] || e.code;
}
export function PreviewStatsBar({
stats,
onlyProblems,
onOnlyProblems,
}: {
stats: PreviewStats;
onlyProblems: boolean;
onOnlyProblems: (v: boolean) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge>
{stats.groups} 组 · {stats.files} 个文件
</StatusBadge>
<StatusBadge tone={stats.executable ? "success" : "neutral"}>可执行 {stats.executable}</StatusBadge>
{stats.conflicts ? <StatusBadge tone="warning">冲突 {stats.conflicts}</StatusBadge> : null}
{stats.failed ? <StatusBadge tone="destructive">不能执行 {stats.failed}</StatusBadge> : null}
<div className="ml-auto flex items-center gap-2 text-caption text-muted-foreground">
<span aria-hidden>只看问题</span>
<Switch checked={onlyProblems} onCheckedChange={onOnlyProblems} aria-label="只看问题" />
</div>
</div>
);
}
function TargetLine({ g, roots }: { g: FilesOrganizeGroup; roots: FileRoot[] }) {
const first = g.files.find((f) => f.target)?.target;
if (!first) return null;
const dirAbs = parentRel(first);
const segs = g.targetSegments ?? [];
if (segs.length) {
const rel = segs.map((s) => s.text).join("/");
const prefixAbs = dirAbs.endsWith(`/${rel}`) ? dirAbs.slice(0, dirAbs.length - rel.length - 1) : null;
const root = prefixAbs ? roots.find((r) => r.hostPath === prefixAbs) : undefined;
return (
<PathSegments
prefix={root ? root.name : (prefixAbs ?? null)}
segments={segs}
className="rounded-lg bg-foreground/[0.03] px-2 py-1.5"
/>
);
}
return (
<p className="rounded-lg bg-foreground/[0.03] px-2 py-1.5 font-mono text-meta break-all text-foreground">
{displayPath(dirAbs, roots)}
</p>
);
}
type GroupFile = FilesOrganizeGroup["files"][number];
function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; nested?: boolean }) {
const problem = fileHasProblem(f);
const badge =
f.action === "hold" || f.existing === "conflict" ? (
<StatusBadge tone="warning">冲突</StatusBadge>
) : f.action === "skip" ? (
<StatusBadge>跳过{f.skipReason ? ` · ${SKIP_REASON[f.skipReason] ?? f.skipReason}` : ""}</StatusBadge>
) : f.action === "replace" ? (
<StatusBadge tone="warning">替换</StatusBadge>
) : !f.target ? (
<StatusBadge>不入库</StatusBadge>
) : null;
return (
<li className={cn("min-w-0 py-1", nested && "pl-5")}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-meta">
<span className="shrink-0 text-muted-foreground">{FILE_KIND[f.kind] ?? f.kind}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{formatBytes(f.size, 1)}</span>
{badge}
</div>
<p className="truncate font-mono text-meta text-muted-foreground" title={f.source}>
{displayPath(f.source, roots)}
</p>
{f.target ? (
<p
className={cn(
"flex min-w-0 items-center gap-1 font-mono text-meta",
problem ? "text-warning" : "text-foreground",
)}
title={f.target}
>
<ArrowRight aria-hidden className="h-3 w-3 shrink-0" />
<span className="truncate">{baseName(f.target)}</span>
</p>
) : null}
</li>
);
}
export function PreviewGroupCard({
g,
title,
roots,
onlyProblems,
result,
record,
}: {
g: FilesOrganizeGroup;
title: string;
roots: FileRoot[];
onlyProblems: boolean;
/** 执行后:显示结果而不是计划状态 */
result?: boolean;
record?: PublicOrganizationRecord;
}) {
const size = g.files.reduce((s, f) => s + f.size, 0);
const nested = nestSidecars(g.files).filter(
(n) => !onlyProblems || fileHasProblem(n.main) || n.main.action === "skip" || n.sidecars.some(fileHasProblem),
);
let status: React.ReactNode;
if (result) {
status = g.error ? (
<StatusBadge tone="destructive">失败</StatusBadge>
) : record ? (
<StatusBadge tone={RECORD_STATUS_TONE[record.status]}>{RECORD_STATUS_LABEL[record.status]}</StatusBadge>
) : g.recordId ? (
<StatusBadge tone="success">已提交</StatusBadge>
) : (
<StatusBadge>未执行</StatusBadge>
);
} else {
status = groupExecutable(g) ? (
<StatusBadge tone="success">计划就绪</StatusBadge>
) : groupAllSkipped(g) && !g.error ? (
<StatusBadge>全部跳过</StatusBadge>
) : (
<StatusBadge tone="destructive">不能执行</StatusBadge>
);
}
return (
<section className="rounded-2xl border border-border">
<header className="flex flex-wrap items-center gap-2 px-3 py-2 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-border/60">
<span className="min-w-0 truncate text-body font-medium">{title}</span>
{status}
{g.entity?.created ? <StatusBadge tone="warning">新建作品</StatusBadge> : null}
<span className="text-meta text-muted-foreground">
{result && record ? `${record.counts.succeeded}/${record.counts.total} 成功 · ` : ""}
{g.files.length} 个文件 · {formatBytes(size, 1)}
{g.action ? ` · ${g.action === "move" ? "移动" : "复制"}` : ""}
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
</span>
{result && g.recordId ? (
<Link className="ml-auto text-meta underline underline-offset-2" to={`/console/organize/${g.recordId}`}>
整理记录
</Link>
) : null}
</header>
<div className="space-y-2 px-3 py-2.5 empty:hidden">
{g.error ? (
<p className="flex items-start gap-1.5 text-caption text-destructive">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{errorText(g.error)}
</p>
) : null}
{record?.reason && result ? <p className="text-caption text-warning">{record.reason.message}</p> : null}
{g.warnings.map((w) => (
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{w}
</p>
))}
{!result ? <TargetLine g={g} roots={roots} /> : null}
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
{!result && nested.length ? (
<ul className="divide-y divide-border/50">
{nested.map((n) => (
<li key={n.main.source}>
<ul>
<FileLine f={n.main} roots={roots} />
{n.sidecars.map((s) => (
<FileLine key={s.source} f={s} roots={roots} nested />
))}
</ul>
</li>
))}
</ul>
) : null}
{!result && g.excluded?.length ? (
<p className="text-meta text-muted-foreground">
排除 {g.excluded.length} 个:
{g.excluded
.slice(0, 6)
.map((e) => `${baseName(e.source)}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
.join(",")}
{g.excluded.length > 6 ? "…" : ""}
</p>
) : null}
</div>
</section>
);
}
export function PreviewList({
groups,
titleOf,
roots,
onlyProblems,
page,
onPage,
}: {
groups: FilesOrganizeGroup[];
titleOf: GroupTitle;
roots: FileRoot[];
onlyProblems: boolean;
page: number;
onPage: (p: number) => void;
}) {
const shown = onlyProblems ? groups.filter(groupHasProblem) : groups;
const pg = pageOf(shown, page);
if (!shown.length)
return <p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭“只看问题”查看全部。</p>;
return (
<div className="space-y-3">
{pg.items.map((g, i) => (
<PreviewGroupCard
key={`${g.mediaEntityId}-${g.contentUnitId}-${g.planId ?? i}`}
g={g}
title={titleOf(g)}
roots={roots}
onlyProblems={onlyProblems}
/>
))}
{pg.pages > 1 ? (
<div className="flex items-center justify-center gap-2 text-caption">
<Button size="sm" variant="outline" disabled={pg.page === 0} onClick={() => onPage(pg.page - 1)}>
上一页
</Button>
<span className="text-muted-foreground tabular-nums">
{pg.page + 1} / {pg.pages}
</span>
<Button size="sm" variant="outline" disabled={pg.page >= pg.pages - 1} onClick={() => onPage(pg.page + 1)}>
下一页
</Button>
</div>
) : null}
</div>
);
}