feat(web): organize records page parity with MP history (HIS-1)

Poster, source -> target, size, downloader; type/time filters; status
counts; group by work; row menu; batch select, delete and reorganize;
delete confirm dialog with dry-run summary; detail delete and download info.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 23:57:19 +08:00
1 parent 51e82f1cd9
commit 03c651085f
8 files changed
+898 -110

No files matched your search

@@ -0,0 +1,160 @@
import type { OrganizationDeleteResult, PublicOrganizationRecord } from "@umt/contracts";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDeleteRecords } from "~/api/records.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
import { hasFiles, recordTitle } from "./record-parts.ts";
// HIS-1 删除整理记录确认框(对照 MP 的四个删除选项:只删记录 / 连同源文件 / 连同媒体库文件 / 全部)。
// 勾选变化时先 dryRun 核对,把会删什么、哪些删不了、会不会被自动整理再处理写清楚。
type Rec = Pick<PublicOrganizationRecord, "id" | "media" | "source" | "target" | "action">;
export function DeleteRecordsDialog({
records,
open,
onOpenChange,
onDone,
}: {
records: Rec[];
open: boolean;
onOpenChange: (open: boolean) => void;
/** 删除完成(传回真正删掉的 id) */
onDone?: (removed: string[]) => void;
}) {
const [src, setSrc] = useState(false);
const [dst, setDst] = useState(false);
const dry = useDeleteRecords();
const exec = useDeleteRecords();
const ids = records.map((r) => r.id);
const idKey = ids.join(",");
const anyFiles = records.some(hasFiles);
const { mutate: check, reset } = dry;
useEffect(() => {
if (!open) {
setSrc(false);
setDst(false);
reset();
return;
}
if (!idKey) return;
check({ ids: idKey.split(","), deleteSource: src, deleteTarget: dst, dryRun: true });
}, [open, idKey, src, dst, check, reset]);
const plan = dry.data;
const byId = new Map(records.map((r) => [r.id, r]));
const blocked = plan?.items.filter((i) => i.blocked) ?? [];
const deletable = plan ? plan.items.length - blocked.length : 0;
const withFiles = src || dst;
const single = records.length === 1 ? records[0] : undefined;
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={single ? `删除「${recordTitle(single)}」的整理记录?` : `删除 ${records.length} 条整理记录?`}
confirmLabel={withFiles ? "删除记录和文件" : "删除记录"}
destructive
confirmDisabled={!plan || dry.isPending || deletable === 0}
onConfirm={() =>
exec
.mutateAsync({ ids, deleteSource: src, deleteTarget: dst, dryRun: false })
.then((r) => {
report(r);
onDone?.(r.removed);
})
.catch((e) => {
toast.error(errorMessage(e, "删除失败"));
throw e;
})
}
>
<div className="space-y-3 text-caption">
{anyFiles ? (
<div className="space-y-2">
<label className="flex items-start gap-2.5">
<input type="checkbox" className="mt-1" checked={src} onChange={(e) => setSrc(e.target.checked)} />
<span>
同时删除源文件
<span className="block text-meta text-muted-foreground">下载目录里整理前的文件。</span>
</span>
</label>
<label className="flex items-start gap-2.5">
<input type="checkbox" className="mt-1" checked={dst} onChange={(e) => setDst(e.target.checked)} />
<span>
同时删除媒体库里的文件
<span className="block text-meta text-muted-foreground">整理后放进媒体库的文件,会从媒体库移除。</span>
</span>
</label>
</div>
) : null}
<Summary plan={plan} pending={dry.isPending} error={dry.error} src={src} withFiles={withFiles} />
{blocked.length ? (
<div className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-warning">
<p>{blocked.length === records.length ? "这些记录都不能删:" : `${blocked.length} 条不能删,其余照删:`}</p>
<ul className="mt-1 space-y-0.5 text-meta">
{blocked.slice(0, 5).map((b) => {
const r = byId.get(b.id);
return (
<li key={b.id} className="break-all">
{r ? `${recordTitle(r)}:` : ""}
{b.message}
</li>
);
})}
{blocked.length > 5 ? <li>还有 {blocked.length - 5} 条</li> : null}
</ul>
</div>
) : null}
</div>
</ConfirmDialog>
);
}
function Summary({
plan,
pending,
error,
src,
withFiles,
}: {
plan: OrganizationDeleteResult | undefined;
pending: boolean;
error: unknown;
src: boolean;
withFiles: boolean;
}) {
if (error) return <p className="text-destructive">{errorMessage(error, "核对失败")}</p>;
if (!plan) return <p className="text-muted-foreground">{pending ? "正在核对要删的文件…" : null}</p>;
const files = [
plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null,
plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null,
].filter(Boolean);
return (
<div className="space-y-1 text-muted-foreground">
{files.length ? (
<p>
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。
{plan.assets ? `其中 ${plan.assets} 个是已入库文件,会从媒体库移除。` : ""}
</p>
) : (
<p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p>
)}
{src && plan.sourceFiles ? <p>下载器里的任务不会删除。</p> : null}
{plan.autoRetry ? (
<p className="text-foreground">{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会重新自动整理。</p>
) : null}
</div>
);
}
function report(r: OrganizationDeleteResult) {
const kept = r.items.filter((i) => i.blocked);
const files = r.sourceFiles + r.targetFiles;
const head = `已删除 ${r.removed.length} 条记录${files ? `,${files} 个文件移入回收站` : ""}`;
if (kept.length) toast.warning(head, { description: `${kept.length} 条没有删除:${kept[0]?.message ?? ""}` });
else toast.success(head);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { canReorganize, episodeTag, hasFiles, isRunning, recordTitle } from "./record-parts.ts";
// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除
const media = (
o: Partial<{ seasonNumber: number | null; episodeNumber: number | null; year: number | null }> = {},
) => ({
entityId: "00000000-0000-4000-8000-000000000001",
kind: "series" as const,
title: "Severance",
year: 2022,
contentUnitId: null,
seasonNumber: null,
episodeNumber: null,
externalIds: [],
...o,
});
const path = (p: string | null) => ({
path: p,
view: "local" as const,
downloaderInstanceId: null,
libraryRootId: null,
});
describe("整理记录展示", () => {
it("季集标记", () => {
expect(episodeTag(null)).toBe("");
expect(episodeTag(media())).toBe("");
expect(episodeTag(media({ seasonNumber: 1 }))).toBe("S01");
expect(episodeTag(media({ seasonNumber: 1, episodeNumber: 2 }))).toBe("S01E02");
});
it("标题:作品 (年) 季集;没关联作品时取源文件名", () => {
expect(
recordTitle({ media: media({ seasonNumber: 2, episodeNumber: 10 }), source: path(null), target: path(null) }),
).toBe("Severance (2022) S02E10");
expect(recordTitle({ media: media({ year: null }), source: path(null), target: path(null) })).toBe("Severance");
expect(recordTitle({ media: null, source: path("/dl/Some.Movie.2020/"), target: path(null) })).toBe(
"Some.Movie.2020",
);
expect(recordTitle({ media: null, source: path(null), target: path(null) })).toBe("未识别");
});
it("重新整理只对已结束的复制 / 移动 / 硬链接", () => {
expect(canReorganize({ action: "copy", status: "failed" })).toBe(true);
expect(canReorganize({ action: "hardlink", status: "succeeded" })).toBe(true);
expect(canReorganize({ action: "move", status: "running" })).toBe(false);
expect(canReorganize({ action: "trash", status: "succeeded" })).toBe(false);
expect(isRunning({ status: "queued" })).toBe(true);
expect(hasFiles({ action: "rename" })).toBe(false);
expect(hasFiles({ action: "move" })).toBe(true);
});
});
@@ -0,0 +1,35 @@
import type { PublicOrganizationRecord } from "@umt/contracts";
// 整理记录列表 / 详情 / 删除确认共用的展示小件(HIS-1)。
type Rec = Pick<PublicOrganizationRecord, "media" | "source" | "target">;
/** S01E02 / S01;不是剧集返回空串 */
export function episodeTag(m: PublicOrganizationRecord["media"]): string {
if (!m || m.seasonNumber == null) return "";
const s = `S${String(m.seasonNumber).padStart(2, "0")}`;
return m.episodeNumber != null ? `${s}E${String(m.episodeNumber).padStart(2, "0")}` : s;
}
/** 列表标题:作品 (年) S01E02;没关联作品时用源文件名 */
export function recordTitle(r: Rec): string {
if (r.media) {
const ep = episodeTag(r.media);
return `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}${ep ? ` ${ep}` : ""}`;
}
const p = r.source.path ?? r.target.path ?? "";
return p.split("/").filter(Boolean).pop() ?? "未识别";
}
/** 能重新整理的记录:复制 / 移动 / 硬链接,且已结束 */
export const canReorganize = (r: Pick<PublicOrganizationRecord, "action" | "status">) =>
(r.action === "copy" || r.action === "move" || r.action === "hardlink") &&
r.status !== "queued" &&
r.status !== "running";
/** 有源文件 / 媒体库文件可删的记录(改名、删除、还原类只删记录本身) */
export const hasFiles = (r: Pick<PublicOrganizationRecord, "action">) =>
r.action === "copy" || r.action === "move" || r.action === "hardlink";
export const isRunning = (r: Pick<PublicOrganizationRecord, "status">) =>
r.status === "queued" || r.status === "running";