diff --git a/apps/web/app/components/console/data-table.tsx b/apps/web/app/components/console/data-table.tsx index e550fde2..ac4d5e0e 100644 --- a/apps/web/app/components/console/data-table.tsx +++ b/apps/web/app/components/console/data-table.tsx @@ -25,6 +25,12 @@ export type DataGroupBy = { /** 组顺序,缺省按首次出现 */ 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["hideBelow"]>, string> = { @@ -189,7 +195,9 @@ export function DataTable({ {groups ? ( groups.map((g) => { - const open = !collapsed.has(g.key); + if (groupBy?.flat?.(g.key, g.rows)) return {g.rows.map(renderRow)}; + // defaultCollapsed 时集合里存的是点开的组 + const open = groupBy?.defaultCollapsed ? collapsed.has(g.key) : !collapsed.has(g.key); return ( @@ -211,7 +219,11 @@ export function DataTable({ {groupBy?.label ? groupBy.label(g.key, g.rows) : g.key} - {g.rows.length} + {groupBy?.meta ? ( + groupBy.meta(g.key, g.rows) + ) : ( + {g.rows.length} + )} diff --git a/apps/web/app/components/organize/record-parts.test.ts b/apps/web/app/components/organize/record-parts.test.ts index 1b4116d7..7cc3a789 100644 --- a/apps/web/app/components/organize/record-parts.test.ts +++ b/apps/web/app/components/organize/record-parts.test.ts @@ -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(); + 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(); + }); +}); diff --git a/apps/web/app/components/organize/record-parts.ts b/apps/web/app/components/organize/record-parts.ts index 20260ed1..4b24d3de 100644 --- a/apps/web/app/components/organize/record-parts.ts +++ b/apps/web/app/components/organize/record-parts.ts @@ -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): string { + const pairs = media.filter((m): m is NonNullable => 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>, +): { 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["kind"], string> = { + movie: "电影", + series: "剧集", + variety: "综艺", + collection: "合集", + other: "其他", +}; + +/** 分类(照 MP 历史记录的「纪录片 / 欧美电影」):整理时的二级分类,没有时退回作品类型;未识别为空串 */ +export function recordCategory(r: Pick): 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; +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 { + // 私密窗口 / 配额满:只是不记住 + } +} diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index 0f6ee24f..b5998d2e 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -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 ( + + {ACTION_LABEL[action]} + + ); +} + +/** 组头:海报 + 作品 (年) + 季集范围 */ +function GroupLabel({ rows }: { rows: Rec[] }) { + const m = rows[0]?.media; + if (!m) return <>未识别; + const range = episodeRange(rows.map((r) => r.media)); + return ( + + + + {m.title} + {m.year ? ` (${m.year})` : ""} + {range ? {range} : null} + + + ); +} + +/** 组头右侧:分类 · n 条 · 非成功状态(只列非零项,成功是常态不写) */ +function GroupMeta({ rows }: { rows: Rec[] }) { + const cat = rows[0] ? recordCategory(rows[0]) : ""; + const issues = groupIssues(rows); + return ( + + {cat ? {cat} : null} + {rows.length} 条 + {issues.map((x) => ( + + {x.label} {x.n} + + ))} + + ); +} + /** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 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(null); const [toDelete, setToDelete] = useState(null); - const grouped = v.group === "entity"; + // 分组:URL ?group= 优先,否则用本浏览器记住的选择,缺省按作品分组(挂载后读,服务端渲染时一律按缺省) + const [storedGrouping, setStoredGrouping] = useState(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() {

{r.media ? recordTitle(r) : 未识别 · {recordTitle(r)}}

+ {recordCategory(r) ? ( +

{recordCategory(r)}

+ ) : null}

- {[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(" · ")}

@@ -414,13 +489,13 @@ export default function OrganizePage() { { key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => }, { key: "action", - header: "方式", + header: "整理方式", hideBelow: "sm", cell: (r) => ( -
-

{ACTION_LABEL[r.action]}

+
+ {r.sizeBytes ? ( -

{formatBytes(r.sizeBytes, 1)}

+ {formatBytes(r.sizeBytes, 1)} ) : null}
), @@ -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")} > 按作品分组 @@ -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) => , + meta: (_k, g) => , + flat: (_k, g) => g.length === 1, + defaultCollapsed: true, + storageKey: "organize-records-entity", } : undefined }