feat(organize): 整理记录默认按作品分组,行内显示整理方式标签与分类

学 MP 历史记录页:整理方式改为中性小标签(移动 / 复制 / 硬链接 / 原地入库),标题下显示分类(二级分类,没有时退回作品类型)。
列表缺省按作品分组:一季多集收成一组并缺省折叠,组头显示海报、作品、季集范围(S01E01–E10)、分类、条数与非成功状态;只有一条的作品和未识别记录不画组头。切换后本浏览器记住,URL ?group=entity|none 优先。
DataTable 分组追加可选项 meta / defaultCollapsed / flat(不改既有调用的表现)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 12:10:02 +08:00
1 parent 67f05895d3
commit e41a5d1f16
4 files changed
+273 -17

No files matched your search

+13 -1
View File
@@ -25,6 +25,12 @@ export type DataGroupBy<T> = {
/** 组顺序,缺省按首次出现 */
order?: (a: string, b: string) => number;
storageKey?: string;
/** 组头右侧的计数位,缺省为行数 */
meta?: (key: string, rows: T[]) => ReactNode;
/** 组缺省折叠(点开的组才记住);缺省展开 */
defaultCollapsed?: boolean;
/** 返回 true 的组不画组头、直接平铺行(如只有一行的组) */
flat?: (key: string, rows: T[]) => boolean;
};
const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
@@ -189,7 +195,9 @@ export function DataTable<T>({
</thead>
{groups ? (
groups.map((g) => {
const open = !collapsed.has(g.key);
if (groupBy?.flat?.(g.key, g.rows)) return <tbody key={`g:${g.key}`}>{g.rows.map(renderRow)}</tbody>;
// defaultCollapsed 时集合里存的是点开的组
const open = groupBy?.defaultCollapsed ? collapsed.has(g.key) : !collapsed.has(g.key);
return (
<Fragment key={`g:${g.key}`}>
<tbody>
@@ -211,7 +219,11 @@ export function DataTable<T>({
<span className="min-w-0 truncate font-medium text-foreground">
{groupBy?.label ? groupBy.label(g.key, g.rows) : g.key}
</span>
{groupBy?.meta ? (
groupBy.meta(g.key, g.rows)
) : (
<span className="tabular-nums">{g.rows.length}</span>
)}
</button>
</th>
</tr>
@@ -1,5 +1,21 @@
import { OrganizationAction, type PublicOrganizationRecord } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { canReorganize, episodeTag, hasFiles, isRunning, recordFileProgress, recordTitle } from "./record-parts.ts";
import { ACTION_LABEL } from "~/api/records.ts";
import {
canReorganize,
episodeRange,
episodeTag,
GROUPING_STORAGE_KEY,
groupIssues,
hasFiles,
isRunning,
readGroupingPref,
recordCategory,
recordFileProgress,
recordTitle,
resolveGrouping,
writeGroupingPref,
} from "./record-parts.ts";
// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除
@@ -65,3 +81,72 @@ describe("文件级进度", () => {
expect(recordFileProgress({ files })).toEqual({ completed: 3, total: 5, current: "/source/3.mkv" });
});
});
describe("整理记录列表:整理方式、分类、分组", () => {
it("整理方式文案覆盖全部取值,常用的四种照 glossary", () => {
for (const a of OrganizationAction.options) expect(ACTION_LABEL[a]).toBeTruthy();
expect([ACTION_LABEL.move, ACTION_LABEL.copy, ACTION_LABEL.hardlink, ACTION_LABEL.in_place]).toEqual([
"移动",
"复制",
"硬链接",
"原地入库",
]);
});
it("分类:有二级分类用分类,没有退回作品类型,未识别为空", () => {
expect(recordCategory({ media: { ...media(), category: "纪录片" } })).toBe("纪录片");
expect(recordCategory({ media: { ...media(), category: null } })).toBe("剧集");
expect(recordCategory({ media: { ...media(), kind: "movie" as const } })).toBe("电影");
expect(recordCategory({ media: null })).toBe("");
});
it("季集范围:单集、同季连续、同季有缺、跨季、非剧集", () => {
const ep = (s: number | null, e: number | null) => media({ seasonNumber: s, episodeNumber: e });
expect(episodeRange([ep(1, 5)])).toBe("S01E05");
expect(episodeRange([3, 1, 2, 4, 5, 6, 7, 8, 9, 10].map((e) => ep(1, e)))).toBe("S01E01–E10");
expect(episodeRange([1, 2, 3, 5, 5].map((e) => ep(1, e)))).toBe("S01E01–E03、E05");
expect(episodeRange([ep(1, 9), ep(2, 1)])).toBe("S01–S02");
expect(episodeRange([ep(2, null)])).toBe("S02");
expect(episodeRange([media(), null])).toBe("");
});
it("组汇总只列非成功的非零项", () => {
const st = (...xs: PublicOrganizationRecord["status"][]) => xs.map((status) => ({ status }));
expect(groupIssues(st("succeeded", "succeeded"))).toEqual([]);
expect(groupIssues(st("succeeded", "failed", "partial", "running", "queued"))).toEqual([
{ label: "失败", n: 1, tone: "destructive" },
{ label: "部分成功", n: 1, tone: "warning" },
{ label: "进行中", n: 2, tone: "neutral" },
]);
});
it("分组缺省按作品;URL 优先,其次本浏览器记住的选择", () => {
expect(resolveGrouping(null, null)).toBe("entity");
expect(resolveGrouping("", "none")).toBe("none");
expect(resolveGrouping("entity", "none")).toBe("entity");
expect(resolveGrouping("none", null)).toBe("none");
expect(resolveGrouping("bogus", "bogus")).toBe("entity");
});
it("记住分组选择;存储不可用时不报错", () => {
const mem = new Map<string, string>();
const store = () => ({
getItem: (k: string) => mem.get(k) ?? null,
setItem: (k: string, v: string) => void mem.set(k, v),
});
expect(readGroupingPref(store)).toBeNull();
writeGroupingPref("none", store);
expect(mem.get(GROUPING_STORAGE_KEY)).toBe("none");
expect(resolveGrouping(null, readGroupingPref(store))).toBe("none");
const broken = () => ({
getItem: () => {
throw new Error("denied");
},
setItem: () => {
throw new Error("denied");
},
});
expect(readGroupingPref(broken)).toBeNull();
expect(() => writeGroupingPref("entity", broken)).not.toThrow();
});
});
@@ -47,3 +47,85 @@ export function recordFileProgress(
current: current?.source.path ?? current?.target.path ?? null,
};
}
const pad = (n: number) => String(n).padStart(2, "0");
/**
* 一组记录的季集范围(组头用):单集 S01E05;同季连续 S01E01–E10;同季有缺 S01E01–E03、E05;
* 跨季 S01–S02;只有季 S01;都不是剧集返回空串。
*/
export function episodeRange(media: ReadonlyArray<PublicOrganizationRecord["media"]>): string {
const pairs = media.filter((m): m is NonNullable<typeof m> => m?.seasonNumber != null);
if (!pairs.length) return "";
const seasons = [...new Set(pairs.map((m) => m.seasonNumber as number))].sort((a, b) => a - b);
if (seasons.length > 1) return `S${pad(seasons[0] as number)}–S${pad(seasons[seasons.length - 1] as number)}`;
const s = `S${pad(seasons[0] as number)}`;
const eps = [...new Set(pairs.map((m) => m.episodeNumber).filter((e): e is number => e != null))].sort(
(a, b) => a - b,
);
if (!eps.length) return s;
// 连续段:[1,2,3,5] → 1–3、5
const runs: [number, number][] = [];
for (const e of eps) {
const last = runs[runs.length - 1];
if (last && e === last[1] + 1) last[1] = e;
else runs.push([e, e]);
}
return `${s}${runs.map(([a, b]) => (a === b ? `E${pad(a)}` : `E${pad(a)}–E${pad(b)}`)).join("、")}`;
}
/** 分组汇总里要提示的状态(成功是常态不列,只列非零项) */
export function groupIssues(
rows: ReadonlyArray<Pick<PublicOrganizationRecord, "status">>,
): { label: string; n: number; tone: "destructive" | "warning" | "neutral" }[] {
const n = (f: (s: PublicOrganizationRecord["status"]) => boolean) => rows.filter((r) => f(r.status)).length;
return [
{ label: "失败", n: n((s) => s === "failed"), tone: "destructive" as const },
{ label: "部分成功", n: n((s) => s === "partial"), tone: "warning" as const },
{ label: "进行中", n: n((s) => s === "queued" || s === "running"), tone: "neutral" as const },
{ label: "已取消", n: n((s) => s === "cancelled"), tone: "neutral" as const },
].filter((x) => x.n > 0);
}
const MEDIA_KIND_LABEL: Record<NonNullable<PublicOrganizationRecord["media"]>["kind"], string> = {
movie: "电影",
series: "剧集",
variety: "综艺",
collection: "合集",
other: "其他",
};
/** 分类(照 MP 历史记录的「纪录片 / 欧美电影」):整理时的二级分类,没有时退回作品类型;未识别为空串 */
export function recordCategory(r: Pick<PublicOrganizationRecord, "media">): string {
if (!r.media) return "";
return r.media.category || MEDIA_KIND_LABEL[r.media.kind];
}
// ── 列表分组偏好:URL ?group= 优先(entity / none),没有时用本浏览器记住的选择,缺省按作品分组 ──
export type RecordGrouping = "entity" | "none";
export const GROUPING_STORAGE_KEY = "umt.organize.records-group";
export function resolveGrouping(param: string | null | undefined, stored: string | null | undefined): RecordGrouping {
if (param === "entity" || param === "none") return param;
return stored === "none" ? "none" : "entity";
}
type PrefStore = Pick<Storage, "getItem" | "setItem">;
const prefStore = (): PrefStore | undefined => globalThis.localStorage;
export function readGroupingPref(store = prefStore): string | null {
try {
return store()?.getItem(GROUPING_STORAGE_KEY) ?? null;
} catch {
return null;
}
}
export function writeGroupingPref(v: RecordGrouping, store = prefStore): void {
try {
store()?.setItem(GROUPING_STORAGE_KEY, v);
} catch {
// 私密窗口 / 配额满:只是不记住
}
}
+92 -15
View File
@@ -36,7 +36,19 @@ import { Checkbox } from "~/components/files/file-list.tsx";
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
import { canReorganize, isRunning, recordFileProgress, recordTitle } from "~/components/organize/record-parts.ts";
import {
canReorganize,
episodeRange,
groupIssues,
isRunning,
type RecordGrouping,
readGroupingPref,
recordCategory,
recordFileProgress,
recordTitle,
resolveGrouping,
writeGroupingPref,
} from "~/components/organize/record-parts.ts";
import { SourceSelection } from "~/components/organize/source-selection.tsx";
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -46,8 +58,9 @@ import { reasonLine } from "~/lib/reasons.ts";
import { useSse } from "~/lib/sse.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
// 整理记录(HIS-1 对照 MP 历史记录页):海报 + 作品 / 季集、源 → 目标路径、整理方式与大小、状态与原因、来源(下载器)、时间;
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;按作品分组;
// 整理记录(HIS-1 对照 MP 历史记录页):海报 + 作品 / 季集 + 分类、源 → 目标路径、整理方式标签与大小、状态与原因、来源(下载器)、时间;
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;
// 缺省按作品分组(一季十集收成一组,组头写季集范围、条数和非成功状态;只有一条的不分组),切换后本浏览器记住;
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
type Rec = PublicOrganizationRecord;
@@ -90,6 +103,52 @@ function StatusCell({ r }: { r: Rec }) {
);
}
/** 整理方式(移动 / 复制 / 硬链接 / 原地入库…):中性小标签,不用状态色 */
function ActionTag({ action }: { action: Rec["action"] }) {
return (
<span className="inline-flex items-center rounded-tight border border-border px-1.5 py-px text-meta whitespace-nowrap text-muted-foreground">
{ACTION_LABEL[action]}
</span>
);
}
/** 组头:海报 + 作品 (年) + 季集范围 */
function GroupLabel({ rows }: { rows: Rec[] }) {
const m = rows[0]?.media;
if (!m) return <>未识别</>;
const range = episodeRange(rows.map((r) => r.media));
return (
<span className="inline-flex min-w-0 items-center gap-2.5 align-middle">
<CoverPlaceholder small title={m.title} kind={m.kind} posterPath={m.posterPath} className="h-9 w-6 rounded-md" />
<span className="truncate">
{m.title}
{m.year ? ` (${m.year})` : ""}
{range ? <span className="ml-2 font-normal tabular-nums">{range}</span> : null}
</span>
</span>
);
}
/** 组头右侧:分类 · n 条 · 非成功状态(只列非零项,成功是常态不写) */
function GroupMeta({ rows }: { rows: Rec[] }) {
const cat = rows[0] ? recordCategory(rows[0]) : "";
const issues = groupIssues(rows);
return (
<span className="flex shrink-0 items-center gap-2 tabular-nums">
{cat ? <span className="hidden sm:inline">{cat}</span> : null}
<span>{rows.length} 条</span>
{issues.map((x) => (
<span
key={x.label}
className={cn(x.tone === "destructive" && "text-destructive", x.tone === "warning" && "text-warning")}
>
{x.label} {x.n}
</span>
))}
</span>
);
}
/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 title */
function shortPath(p: string | null): string | null {
if (!p) return p;
@@ -324,7 +383,15 @@ export default function OrganizePage() {
const [choosingSource, setChoosingSource] = useState(false);
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
const [toDelete, setToDelete] = useState<Rec[] | null>(null);
const grouped = v.group === "entity";
// 分组:URL ?group= 优先,否则用本浏览器记住的选择,缺省按作品分组(挂载后读,服务端渲染时一律按缺省)
const [storedGrouping, setStoredGrouping] = useState<string | null>(null);
useEffect(() => setStoredGrouping(readGroupingPref()), []);
const grouped = resolveGrouping(v.group, storedGrouping) === "entity";
const setGrouping = (next: RecordGrouping) => {
writeGroupingPref(next);
setStoredGrouping(next);
setFilter("group", next);
};
const selRows = (allRows ?? []).filter((r) => sel.has(r.id));
const allPicked = !!rows?.length && rows.every((r) => sel.has(r.id));
@@ -397,8 +464,16 @@ export default function OrganizePage() {
<p className="truncate text-foreground" title={recordTitle(r)}>
{r.media ? recordTitle(r) : <span className="text-muted-foreground">未识别 · {recordTitle(r)}</span>}
</p>
{recordCategory(r) ? (
<p className="hidden truncate text-meta text-muted-foreground sm:block">{recordCategory(r)}</p>
) : null}
<p className="truncate text-meta text-muted-foreground sm:hidden">
{[ACTION_LABEL[r.action], r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, relTime(when(r))]
{[
recordCategory(r),
ACTION_LABEL[r.action],
r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null,
relTime(when(r)),
]
.filter(Boolean)
.join(" · ")}
</p>
@@ -414,13 +489,13 @@ export default function OrganizePage() {
{ key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => <PathLines r={r} /> },
{
key: "action",
header: "方式",
header: "整理方式",
hideBelow: "sm",
cell: (r) => (
<div className="whitespace-nowrap">
<p>{ACTION_LABEL[r.action]}</p>
<div className="flex flex-col items-start gap-1 whitespace-nowrap">
<ActionTag action={r.action} />
{r.sizeBytes ? (
<p className="text-meta text-muted-foreground tabular-nums">{formatBytes(r.sizeBytes, 1)}</p>
<span className="text-meta text-muted-foreground tabular-nums">{formatBytes(r.sizeBytes, 1)}</span>
) : null}
</div>
),
@@ -517,7 +592,7 @@ export default function OrganizePage() {
variant={grouped ? "secondary" : "ghost"}
size="sm"
aria-pressed={grouped}
onClick={() => setFilter("group", grouped ? "" : "entity")}
onClick={() => setGrouping(grouped ? "none" : "entity")}
>
<ListTree />
按作品分组
@@ -605,11 +680,13 @@ export default function OrganizePage() {
groupBy={
grouped
? {
key: (r) => r.media?.entityId ?? "",
label: (k, g) => {
const m = g[0]?.media;
return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别";
},
// 未识别的记录互不相干,各自一行
key: (r) => r.media?.entityId ?? `none:${r.id}`,
label: (_k, g) => <GroupLabel rows={g} />,
meta: (_k, g) => <GroupMeta rows={g} />,
flat: (_k, g) => g.length === 1,
defaultCollapsed: true,
storageKey: "organize-records-entity",
}
: undefined
}