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