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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user