From d98899a5f8510b0e0882fd6193a57bdbe3b1d169 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 11:59:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(organize):=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=97=E8=A1=A8=E5=B8=A6=E5=87=BA=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表与详情的 media.category 取自冻结计划里的 classification.category,供整理记录行显示分类(对照 MP 历史记录页);不需要迁移。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../acquisition-organize/services/organize-records.ts | 5 +++++ .../test/acquisition-organize/records-filter.test.ts | 10 ++++++++-- packages/contracts/src/organization.ts | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/server/src/modules/acquisition-organize/services/organize-records.ts b/apps/server/src/modules/acquisition-organize/services/organize-records.ts index 4f9658f8..3cfb0f77 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-records.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-records.ts @@ -70,6 +70,10 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ const v = (metadata as { posterPath?: { value?: unknown } } | null)?.posterPath?.value; return typeof v === "string" && v ? v : null; }; + const categoryOf = (plan: unknown) => { + const v = (plan as { classification?: { category?: unknown } } | null)?.classification?.category; + return typeof v === "string" && v ? v : null; + }; // blocked:* 的说明在 error.conflicts[].detail(中文),有就用它,不把内部串直接给人看 const conflictText = (err: unknown) => ((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? []) @@ -119,6 +123,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ externalId: i.external_id, })), posterPath: posterOf(e.metadata), + category: categoryOf(r.plan), } : null, trigger: r.trigger, diff --git a/apps/server/test/acquisition-organize/records-filter.test.ts b/apps/server/test/acquisition-organize/records-filter.test.ts index 07172472..f0ee3fa6 100644 --- a/apps/server/test/acquisition-organize/records-filter.test.ts +++ b/apps/server/test/acquisition-organize/records-filter.test.ts @@ -44,6 +44,10 @@ describe.skipIf(skip)("整理记录筛选与统计", () => { media_entity_id: r.entity, download_task_id: "task" in r ? r.task : null, source_path: r.src, + // 冻结计划里的二级分类(列表带出 media.category) + ...(r.k === "film" + ? { plan: JSON.stringify({ classification: { category: "欧美电影", matchedFields: [], fallback: false } }) } + : {}), action: r.action, status: r.status, ...("at" in r ? { created_at: r.at } : {}), @@ -135,13 +139,15 @@ describe.skipIf(skip)("整理记录筛选与统计", () => { } }); - it("列表项带海报、大小、下载器名称与 hash", async () => { + it("列表项带海报、分类、大小、下载器名称与 hash", async () => { const [film] = await list("q=Arrival.2016"); expect(film).toMatchObject({ - media: { title: "Arrival", posterPath: "/arrival.jpg" }, + media: { title: "Arrival", posterPath: "/arrival.jpg", category: "欧美电影" }, sizeBytes: 1000, download: { name: "Arrival.2016", hash: "abcdef0123456789abcdef0123456789abcdef01", downloaderName: "家里的 qB" }, }); + const [show] = await list("q=Severance"); + expect(show).toMatchObject({ media: { title: "Severance", category: null } }); const [unknown] = await list("kind=none"); expect(unknown).toMatchObject({ media: null, download: null, sizeBytes: 1000 }); }); diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index c90a4989..470cf93d 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -232,6 +232,8 @@ export const PublicOrganizationRecord = z.object({ externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })), /** HIS-1:作品海报(TMDB poster_path,如 /abc.jpg);没有为 null */ posterPath: z.string().nullable().optional(), + /** 整理时按二级分类策略得到的分类(如「纪录片」「欧美电影」,取自冻结计划);没有为 null */ + category: z.string().nullable().optional(), }) .nullable(), trigger: OrganizationTrigger, From 67f05895d34f19eb8154dd99c5194ee814701451 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 12:06:27 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(files):=20=E8=A1=8C=E5=B0=BE=E6=82=AC?= =?UTF-8?q?=E5=81=9C=E9=9C=B2=E5=87=BA=E5=B8=B8=E7=94=A8=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=EF=BC=8C=E9=9D=A2=E5=8C=85=E5=B1=91=E5=B8=A6?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E7=9B=AE=E5=BD=95=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学 MP 文件管理:鼠标悬停或键盘焦点所在行在行尾浮出 2–3 个图标按钮(未入库视频 / 目录:整理、识别、刮削;已入库:打开作品、刷新元数据、重命名;其余:重命名、移动到),带 tooltip 与 aria-label,被状态阻止的灰显写原因;浮出时让出修改时间列;焦点行可 Tab 进入、Esc 回列表;窄屏仍只用 ⋯ 菜单。 面包屑前加「下载目录 / 媒体库」说明段。顺带修正 Shift+F10 菜单定位按行键找行时被 CSS.escape 改坏的问题。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/components/files/file-list.tsx | 86 +++++++++++++++---- apps/web/app/components/files/model.test.ts | 43 +++++++++- apps/web/app/components/files/model.ts | 40 ++++++++- .../app/components/files/path-breadcrumb.tsx | 27 +++--- apps/web/app/routes/console/files.tsx | 4 +- docs/design/file-manager-v2.md | 4 +- 6 files changed, 165 insertions(+), 39 deletions(-) diff --git a/apps/web/app/components/files/file-list.tsx b/apps/web/app/components/files/file-list.tsx index 300878b0..fe4b9f74 100644 --- a/apps/web/app/components/files/file-list.tsx +++ b/apps/web/app/components/files/file-list.tsx @@ -13,6 +13,7 @@ import { MoreHorizontal, Music, PenLine, + RefreshCw, Sparkles, Subtitles, Wand2, @@ -28,10 +29,10 @@ import { clickSelect, entryStatusView, entrySubline, + hoverOpLabel, hoverOps, type MenuKey, moveFocus, - OP_LABEL, type Selection, selectAll, toggleSelect, @@ -40,6 +41,8 @@ import { const HOVER_ICON: Partial> = { organize: Wand2, identify: Sparkles, + writeNfo: FileText, + scrape: RefreshCw, rename: PenLine, move: FolderInput, openMedia: Library, @@ -228,7 +231,8 @@ export function FileList({ : []; const onKeyDown = (ev: React.KeyboardEvent) => { - if ((ev.target as HTMLElement).closest("input,textarea,[role=menu]")) return; + // 行内操作按钮获焦时(Tab 进入)Enter / Space 交给按钮本身 + if ((ev.target as HTMLElement).closest("input,textarea,[role=menu],[data-hover-op]")) return; const focused = selection.focus ? byKey.get(selection.focus) : undefined; const mod = ev.ctrlKey || ev.metaKey; const step = (delta: number) => { @@ -284,6 +288,17 @@ export function FileList({ } return; } + case "Tab": { + // Tab 进入焦点行的行内操作(列表里的链接不打断);Shift+Tab 照常离开列表 + if (ev.shiftKey || !focused) return; + // 按行 id 找(行键里有 \0,CSS.escape 会把它换成 U+FFFD,属性选择器匹配不上) + const op = ref.current?.querySelector(`#file-row-${focusIndex} [data-hover-op]`); + if (op) { + ev.preventDefault(); + op.focus(); + } + return; + } case "Escape": if (selection.keys.size) { ev.preventDefault(); @@ -294,7 +309,7 @@ export function FileList({ case "F10": if (focused && (ev.key === "ContextMenu" || ev.shiftKey)) { ev.preventDefault(); - const row = ref.current?.querySelector(`[data-row-key="${CSS.escape(keyOf(focused))}"]`); + const row = ref.current?.querySelector(`#file-row-${focusIndex}`); const r = row?.getBoundingClientRect(); onMenu(focused, { x: (r?.left ?? 0) + 48, y: (r?.bottom ?? 0) - 4 }); } @@ -355,7 +370,7 @@ export function FileList({ onFocus={() => { if (!selection.focus && order[0]) onSelection({ ...selection, focus: order[0] }); }} - className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset" + className="group/grid relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset" > {!mobile ? ( // biome-ignore lint/a11y/useSemanticElements: 同上,虚拟网格的表头行 @@ -401,6 +416,7 @@ export function FileList({ const selected = selection.keys.has(k); const focused = selection.focus === k; const isDropDir = e.type === "dir" && !e.escape; + const hasOps = !!onAction && !e.escape; const common = { id: `file-row-${i}`, role: "row", @@ -512,10 +528,31 @@ export function FileList({ - {sizeCell(e)} - {relTime(e.mtime)} + {/* 行内操作浮出时让出位置:lg 起盖在修改时间上,更窄时盖在大小上 */} + + {sizeCell(e)} + + + {relTime(e.mtime)} + - {onAction && !e.escape ? : null} + {hasOps && onAction ? ( + + ) : null} {menuBtn} @@ -559,13 +596,19 @@ export const DRAG_MIME = "application/x-umt-files"; const DESKTOP_COLS = "grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_6.5rem_32px]"; -/** 行内 hover 时浮出的 2 个主操作(§5.1),盖在大小/时间列上方 */ +/** + * 行内常用操作(§5.1,照 MP 悬停露出图标):鼠标悬停该行,或键盘焦点在该行(列表获焦且是键盘操作)时, + * 在行尾浮出 2–3 个图标按钮,盖在修改时间列上;每个带 tooltip 与 aria-label,被状态阻止的灰显并写原因。 + * 焦点行的按钮可用 Tab 进入。窄屏走卡片 + ⋯ 菜单,不依赖悬停。 + */ function HoverOps({ e, + active, onAction, opState, }: { e: FileEntry; + active: boolean; onAction: (e: FileEntry, op: MenuKey) => void; opState?: (e: FileEntry, op: MenuKey) => string | null; }) { @@ -573,34 +616,43 @@ function HoverOps({ return ( {ops.map((op) => { const Icon = HOVER_ICON[op]; + const label = hoverOpLabel(op); const why = opState?.(e, op) ?? null; return ( ); })} diff --git a/apps/web/app/components/files/model.test.ts b/apps/web/app/components/files/model.test.ts index 2d28510f..c279d5f3 100644 --- a/apps/web/app/components/files/model.test.ts +++ b/apps/web/app/components/files/model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveFilesMock } from "~/api/files.ts"; import { applyIdentifyCompleted, + breadcrumbItems, clickSelect, collapseCrumbs, cueTime, @@ -10,6 +11,7 @@ import { entryStatusView, entrySubline, formatEpisodes, + hoverOpLabel, hoverOps, initialLocation, menuModel, @@ -220,10 +222,45 @@ describe("v2 菜单与副行", () => { expect(m && !("separator" in m) ? m.disabled : null).toMatch(/H&R/); }); - it("hover 主操作按状态", () => { - expect(hoverOps(video)).toEqual(["organize", "identify"]); - expect(hoverOps(registered)).toEqual(["rename", "openMedia"]); + it("悬停常用操作按文件 / 目录类型选 2–3 个,都在菜单里能找到", () => { + expect(hoverOps(video)).toEqual(["organize", "identify", "writeNfo"]); + expect(hoverOps({ ...base, type: "dir" as const, fileKind: null })).toEqual(["organize", "identify", "writeNfo"]); + expect(hoverOps(registered)).toEqual(["openMedia", "scrape", "rename"]); + // 未完成的视频不能整理:退为识别 / 重命名 / 移动 + expect(hoverOps({ ...video, status: "temp" as const })).toEqual(["identify", "rename", "move"]); expect(hoverOps({ ...base, fileKind: "nfo" as const })).toEqual(["rename", "move"]); + expect(hoverOps({ ...base, fileKind: "subtitle" as const })).toEqual(["rename", "move"]); + for (const e of [video, registered, { ...base, fileKind: "nfo" as const }]) { + const ops = hoverOps(e); + expect(ops.length).toBeGreaterThanOrEqual(2); + expect(ops.length).toBeLessThanOrEqual(3); + const menu = menuModel([e], lib).flatMap((x) => ("separator" in x ? [] : [x.key])); + for (const op of ops) expect(menu).toContain(op); + } + }); + + it("悬停图标的 tooltip / aria-label 文字去掉菜单里的省略号", () => { + expect(hoverOpLabel("organize")).toBe("整理"); + expect(hoverOpLabel("writeNfo")).toBe("刮削"); + expect(hoverOpLabel("move")).toBe("移动到"); + expect(hoverOpLabel("scrape")).toBe("刷新元数据"); + expect(hoverOpLabel("openMedia")).toBe("打开作品"); + }); + + it("面包屑:登记目录类型 > 登记目录 > 各级子目录,每段带可跳转的相对路径", () => { + expect(breadcrumbItems({ name: "qBittorrent · 下载", kind: "download" }, "")).toEqual({ + group: "下载目录", + crumbs: [{ label: "qBittorrent · 下载", path: "" }], + }); + expect(breadcrumbItems({ name: "剧集库", kind: "library" }, "亚洲剧集/繁花 (2023)/Season 1/")).toEqual({ + group: "媒体库", + crumbs: [ + { label: "剧集库", path: "" }, + { label: "亚洲剧集", path: "亚洲剧集" }, + { label: "繁花 (2023)", path: "亚洲剧集/繁花 (2023)" }, + { label: "Season 1", path: "亚洲剧集/繁花 (2023)/Season 1" }, + ], + }); }); it("副行:已登记作品、待识别解析、目录计数", () => { diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index cd0ea344..68c4428f 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -429,13 +429,23 @@ export function menuModel(list: FileEntry[], root: Pick = { open: "打开", organize: "整理…", @@ -470,6 +480,28 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint return null; } +export const ROOT_KIND_LABEL: Record = { download: "下载目录", library: "媒体库" }; + +export type Crumb = { label: string; path: string }; + +/** + * 面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)+ 登记目录名 + 各级子目录。 + * group 不可点;crumbs 每段可点(path 为登记目录内相对路径,根为 "")。 + */ +export function breadcrumbItems( + root: Pick, + path: string, +): { group: string; crumbs: Crumb[] } { + const segs = segments(path); + return { + group: ROOT_KIND_LABEL[root.kind], + crumbs: [ + { label: root.name, path: "" }, + ...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })), + ], + }; +} + export type Loc = { root: string; path: string }; /** 初始位置(§2 #2):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */ diff --git a/apps/web/app/components/files/path-breadcrumb.tsx b/apps/web/app/components/files/path-breadcrumb.tsx index 6e49f1de..8a153fc5 100644 --- a/apps/web/app/components/files/path-breadcrumb.tsx +++ b/apps/web/app/components/files/path-breadcrumb.tsx @@ -1,35 +1,38 @@ import { ChevronRight, HardDrive } from "lucide-react"; import { useState } from "react"; +import type { FileRoot } from "~/api/files.ts"; import { cn } from "~/lib/utils.ts"; -import { collapseCrumbs, segments } from "./model.ts"; +import { breadcrumbItems, collapseCrumbs } from "./model.ts"; /** - * 路径面包屑:根名 + 各段,可点击跳转;每段也是拖放目标(拖入 = 移动到该目录)。 - * 窄屏折叠为"根 / … / 父 / 当前"。 + * 路径面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)> 登记目录 > 各级子目录。 + * 目录各段可点击跳转,也是拖放目标(拖入 = 移动到该目录)。窄屏去掉类型,折叠为"根 / … / 父 / 当前"。 */ export function PathBreadcrumb({ - rootName, + root, path, onNavigate, onDropTo, className, }: { - rootName: string; + root: Pick; path: string; onNavigate: (path: string) => void; /** 有值时每段可作为拖放目标 */ onDropTo?: (path: string, e: React.DragEvent) => void; className?: string; }) { - const segs = segments(path); - const crumbs = [ - { label: rootName, path: "" }, - ...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })), - ]; + const { group, crumbs } = breadcrumbItems(root, path); const [over, setOver] = useState(null); - const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string) => ( + const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string, withGroup = false) => (
    + {withGroup ? ( +
  1. + {group} + +
  2. + ) : null} {items.map((c, i) => { const last = i === items.length - 1; return ( @@ -85,7 +88,7 @@ export function PathBreadcrumb({ return ( ); diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index 27af4832..62c718e3 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -994,7 +994,7 @@ export default function FilesPage() {
    {searchBox}
    ) : root ? ( go({ root: root.id, path: p })} className="min-w-0 flex-1" @@ -1039,7 +1039,7 @@ export default function FilesPage() { {root ? ( go({ root: root.id, path: p })} onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)} diff --git a/docs/design/file-manager-v2.md b/docs/design/file-manager-v2.md index a36062c0..b6eed575 100644 --- a/docs/design/file-manager-v2.md +++ b/docs/design/file-manager-v2.md @@ -137,7 +137,9 @@ ### 5.1 行内 hover 操作(桌面) -按行状态只露 2 个 + ⋯:待识别视频/目录 → `整理` `识别`;已登记 → `重命名` `打开作品`;其他 → `重命名` `移动到`。 +鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未入库视频/目录 → `整理` `识别` `刮削`;已入库 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。 + +面包屑照 MP「本地 > MEDIA > 亚洲剧集」:`下载目录 / 媒体库`(说明,不可点)> 登记目录 > 各级子目录;窄屏去掉类型并折叠。 ### 5.2 右键 / ⋯ 菜单(单项,最终顺序) From e41a5d1f16dec67f9cafd4bc1719b4774746d94f Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 12:10:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(organize):=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=BB=98=E8=AE=A4=E6=8C=89=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E5=88=86=E7=BB=84=EF=BC=8C=E8=A1=8C=E5=86=85=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=95=B4=E7=90=86=E6=96=B9=E5=BC=8F=E6=A0=87=E7=AD=BE=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学 MP 历史记录页:整理方式改为中性小标签(移动 / 复制 / 硬链接 / 原地入库),标题下显示分类(二级分类,没有时退回作品类型)。 列表缺省按作品分组:一季多集收成一组并缺省折叠,组头显示海报、作品、季集范围(S01E01–E10)、分类、条数与非成功状态;只有一条的作品和未识别记录不画组头。切换后本浏览器记住,URL ?group=entity|none 优先。 DataTable 分组追加可选项 meta / defaultCollapsed / flat(不改既有调用的表现)。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../web/app/components/console/data-table.tsx | 16 ++- .../components/organize/record-parts.test.ts | 87 +++++++++++++- .../app/components/organize/record-parts.ts | 82 ++++++++++++++ apps/web/app/routes/console/organize.tsx | 107 +++++++++++++++--- 4 files changed, 274 insertions(+), 18 deletions(-) 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 }