feat(web): 刮削设置分区、作品详情刮削状态与文件管理「刮削」(SCR-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 22:36:14 +08:00
1 parent f38eca419e
commit df033130f5
10 files changed
+313 -23

No files matched your search

+36 -13
View File
@@ -986,13 +986,21 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
);
}
// ─── 刷新元数据(MP 的"刮削") ─────────────────────────────────────
// ─── 刷新元数据 / 刮削 ─────────────────────────────────────
/**
* 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。
* NFO / 图片投影还没实现,不显示这两项。
* write = 刮削(SCR-1):同一接口 write=true,按作品排 scrapeMedia,把 NFO 与图片写进媒体库目录(覆盖方式按设置)。
*/
export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) {
export function RefreshMetadataDialog({
items,
write = false,
onClose,
}: {
items: FileEntry[];
write?: boolean | undefined;
onClose: () => void;
}) {
const batch = useScrapeBatch();
const key = useMemo(() => newIdempotencyKey(), []);
const [result, setResult] = useState<FilesScrapeBatchResult | null>(null);
@@ -1000,12 +1008,15 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
const single = entities.length === 1 ? entities[0] : null;
const run = () =>
batch.mutate(
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) },
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string), write },
{
onSuccess: (r) => {
setResult(r);
const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : "";
(r.skipped.length ? toast.warning : toast.success)(
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
write
? `已提交 ${r.submitted.length} 个作品的刮削${skipped}`
: `已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
);
},
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
@@ -1015,8 +1026,15 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}</DialogTitle>
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的「刮削」。</DialogDescription>
<DialogTitle>
{write ? "刮削" : "刷新元数据"}
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
</DialogTitle>
<DialogDescription>
{write
? "把 NFO 和图片写进媒体库目录,已有文件按设置只补缺失或全部覆盖。结果在作品详情的「刮削」里。"
: "按字段从 TMDB 刷新,锁定的字段和路径不变。不写媒体库目录里的文件。"}
</DialogDescription>
</DialogHeader>
{!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
@@ -1034,13 +1052,18 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<div key={s.mediaEntityId} className="rounded-lg border border-border/60 px-3 py-2">
<p className="flex items-center justify-between gap-2">
<span className="truncate">{s.title}</span>
<Link className="shrink-0 text-meta underline" to={`/console/engine/${s.taskId}`}>
查看运行
<Link
className="shrink-0 text-meta underline"
to={write ? `/console/library/${s.mediaEntityId}` : `/console/engine/${s.taskId}`}
>
{write ? "查看结果" : "查看运行"}
</Link>
</p>
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
{write ? null : (
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
)}
</div>
))}
{result.skipped.map((s) => (
@@ -1064,7 +1087,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
{result ? null : (
<Button onClick={run} disabled={batch.isPending || !entities.length}>
{batch.isPending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
刷新
{write ? "刮削" : "刷新"}
</Button>
)}
</DialogFooter>
+3 -2
View File
@@ -1123,8 +1123,9 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
mediaEntityId: b.mediaEntityId,
taskId: `run-${++seq}`,
lockedFields: ["title"],
nfo: b.writeNfo ? "not_implemented" : "not_requested",
images: b.images ? "not_implemented" : "not_requested",
nfo: b.writeNfo ? "queued" : "not_requested",
images: b.images ? "queued" : "not_requested",
scrapeTaskId: b.writeNfo || b.images ? `run-${++seq}` : null,
};
case "/files/probe":
await delay(600);
+2 -2
View File
@@ -196,14 +196,14 @@ describe("v2 菜单与副行", () => {
it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,顺序固定", () => {
expect(keys(menuModel([video], lib))).toBe("open | organize identify rename move copy | probe | copyPath | delete");
expect(keys(menuModel([registered], lib))).toBe(
"open | identify rename move copy | scrape probe | copyPath openMedia records | delete",
"open | identify rename move copy | scrape writeNfo probe | copyPath openMedia records | delete",
);
expect(opApplies("organize", registered)).toBe(false);
});
it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据", () => {
expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe | delete");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape writeNfo");
});
it("被状态阻止的项灰显带原因", () => {
+12 -3
View File
@@ -64,6 +64,7 @@ export type FileOp =
| "organize"
| "identify"
| "scrape"
| "writeNfo"
| "probe"
| "delete"
| "copyPath"
@@ -97,6 +98,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null;
case "scrape":
case "writeNfo":
return e.asset ? null : "未登记,先整理入库";
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
@@ -334,6 +336,7 @@ export type MenuKey =
| "move"
| "copy"
| "scrape"
| "writeNfo"
| "probe"
| "copyPath"
| "openMedia"
@@ -353,6 +356,7 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
case "identify":
return isVideo(e) || e.type === "dir";
case "scrape":
case "writeNfo":
case "openMedia":
return !!e.asset;
case "records":
@@ -366,8 +370,8 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
/**
* 单项 / 多选菜单的项与顺序(规格 §5.2):
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 ─ 删除 n 项
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 刮削 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 刮削 ─ 删除 n 项
*/
export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writable">): MenuModelItem[] {
const multi = list.length > 1;
@@ -391,6 +395,7 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
add("copy", e.escape ? "链接指向登记目录之外" : null);
sep();
if (opApplies("scrape", e)) add("scrape", why("scrape"));
if (opApplies("writeNfo", e)) add("writeNfo", why("writeNfo"));
if (opApplies("probe", e)) add("probe", null);
sep();
add("copyPath", null);
@@ -412,7 +417,10 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
add("move", why("move"));
add("copy", list.some((x) => x.escape) ? "含指向登记目录之外的链接" : null);
if (any("probe")) add("probe", null);
if (list.every((x) => !!x.asset)) add("scrape", null);
if (list.every((x) => !!x.asset)) {
add("scrape", null);
add("writeNfo", null);
}
sep();
add("delete", why("delete"));
return out;
@@ -433,6 +441,7 @@ export const OP_LABEL: Record<MenuKey, string> = {
move: "移动到…",
copy: "复制到…",
scrape: "刷新元数据",
writeNfo: "刮削",
probe: "探测",
copyPath: "复制路径…",
openMedia: "打开作品",