From 312e70d0bc983fb15e446cce55fb7f2af816dc29 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 20:09:29 +0800 Subject: [PATCH] feat(web): DataTable groupBy with collapsible counted group headers Groups keep first-seen order (or a custom order), headers show counts and toggle with aria-expanded; collapsed groups persist per storageKey in localStorage (read after mount, failures ignored). The trash list groups by registered directory when items span more than one. Co-Authored-By: Claude Opus 5.5 --- .../components/console/data-table-groups.ts | 58 +++++++ .../app/components/console/data-table.test.ts | 90 +++++++++++ .../web/app/components/console/data-table.tsx | 153 +++++++++++++----- apps/web/app/routes/console/files.trash.tsx | 27 ++-- 4 files changed, 276 insertions(+), 52 deletions(-) create mode 100644 apps/web/app/components/console/data-table-groups.ts create mode 100644 apps/web/app/components/console/data-table.test.ts diff --git a/apps/web/app/components/console/data-table-groups.ts b/apps/web/app/components/console/data-table-groups.ts new file mode 100644 index 00000000..f52b04a6 --- /dev/null +++ b/apps/web/app/components/console/data-table-groups.ts @@ -0,0 +1,58 @@ +// DataTable groupBy 的纯逻辑:按键分组(保持首次出现顺序或自定义顺序)与展开态持久化。 + +export type RowGroup = { key: string; rows: T[] }; + +/** 按 key 分组;缺省按首次出现顺序,给 order 时按 order 排组(组内保持原顺序) */ +export function groupRows( + rows: readonly T[], + keyOf: (row: T) => string, + order?: (a: string, b: string) => number, +): RowGroup[] { + const map = new Map(); + for (const r of rows) { + const k = keyOf(r); + const list = map.get(k); + if (list) list.push(r); + else map.set(k, [r]); + } + const groups = [...map].map(([key, rs]) => ({ key, rows: rs })); + return order ? groups.sort((a, b) => order(a.key, b.key)) : groups; +} + +const PREFIX = "umt.table-groups:"; +type Store = Pick; +const defaultStore = (): Store | undefined => globalThis.localStorage; + +/** 读折叠的组(localStorage 不可用或内容损坏时为 null,由调用方取缺省) */ +export function readCollapsed(storageKey: string | undefined, store = defaultStore): Set | null { + if (!storageKey) return null; + try { + const raw = store()?.getItem(PREFIX + storageKey); + if (!raw) return null; + const v = JSON.parse(raw) as unknown; + return Array.isArray(v) ? new Set(v.filter((x): x is string => typeof x === "string")) : null; + } catch { + return null; + } +} + +export function writeCollapsed( + storageKey: string | undefined, + collapsed: ReadonlySet, + store = defaultStore, +): void { + if (!storageKey) return; + try { + store()?.setItem(PREFIX + storageKey, JSON.stringify([...collapsed])); + } catch { + // 私密窗口 / 配额满:只是不记住 + } +} + +/** 切换一个组的折叠态,返回新集合 */ +export function toggleCollapsed(collapsed: ReadonlySet, key: string): Set { + const next = new Set(collapsed); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; +} diff --git a/apps/web/app/components/console/data-table.test.ts b/apps/web/app/components/console/data-table.test.ts new file mode 100644 index 00000000..a21a88a3 --- /dev/null +++ b/apps/web/app/components/console/data-table.test.ts @@ -0,0 +1,90 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { DataTable } from "./data-table.tsx"; +import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts"; + +type Row = { id: string; status: string; name: string }; +const rows: Row[] = [ + { id: "1", status: "待识别", name: "a.mkv" }, + { id: "2", status: "已登记", name: "b.mkv" }, + { id: "3", status: "待识别", name: "c.mkv" }, +]; + +describe("DataTable groupBy:分组逻辑", () => { + it("按首次出现顺序分组,组内保持原顺序", () => { + expect(groupRows(rows, (r) => r.status).map((g) => [g.key, g.rows.map((r) => r.id)])).toEqual([ + ["待识别", ["1", "3"]], + ["已登记", ["2"]], + ]); + }); + it("给 order 时按 order 排组", () => { + const g = groupRows( + rows, + (r) => r.status, + (a, b) => a.localeCompare(b, "zh-CN"), + ); + expect(g.map((x) => x.key)).toEqual(["待识别", "已登记"].sort((a, b) => a.localeCompare(b, "zh-CN"))); + }); + it("折叠切换返回新集合", () => { + const a = new Set(); + const b = toggleCollapsed(a, "x"); + expect([...b]).toEqual(["x"]); + expect(a.size).toBe(0); + expect([...toggleCollapsed(b, "x")]).toEqual([]); + }); +}); + +describe("DataTable groupBy:记住折叠态", () => { + it("写入后能读回;没有 storageKey、内容损坏或存储不可用时为 null", () => { + const map = new Map(); + const store = () => ({ + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + }); + writeCollapsed("t", new Set(["已登记"]), store); + expect([...(readCollapsed("t", store) ?? [])]).toEqual(["已登记"]); + expect(readCollapsed(undefined, store)).toBeNull(); + map.set("umt.table-groups:bad", "{oops"); + expect(readCollapsed("bad", store)).toBeNull(); + const blocked = () => ({ + getItem: (): string | null => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }); + expect(readCollapsed("t", blocked)).toBeNull(); + expect(() => writeCollapsed("t", new Set(), blocked)).not.toThrow(); + expect(readCollapsed("t", () => undefined)).toBeNull(); + }); +}); + +describe("DataTable groupBy:渲染", () => { + it("每组一个可折叠组头,带计数;首屏全部展开", () => { + const html = renderToStaticMarkup( + createElement(DataTable, { + columns: [{ key: "name", header: "名称", cell: (r: Row) => r.name }], + rows, + rowKey: (r: Row) => r.id, + groupBy: { key: (r: Row) => r.status }, + }), + ); + expect(html.match(/aria-expanded="true"/g)).toHaveLength(2); + expect(html).toContain("待识别"); + expect(html).toMatch(/待识别<\/span>]*>2<\/span>/); + expect(html).toMatch(/已登记<\/span>]*>1<\/span>/); + for (const n of ["a.mkv", "b.mkv", "c.mkv"]) expect(html).toContain(n); + }); + it("不给 groupBy 时没有组头", () => { + const html = renderToStaticMarkup( + createElement(DataTable, { + columns: [{ key: "name", header: "名称", cell: (r: Row) => r.name }], + rows, + rowKey: (r: Row) => r.id, + }), + ); + expect(html).not.toContain("aria-expanded"); + }); +}); diff --git a/apps/web/app/components/console/data-table.tsx b/apps/web/app/components/console/data-table.tsx index 47ea7f83..b105edb3 100644 --- a/apps/web/app/components/console/data-table.tsx +++ b/apps/web/app/components/console/data-table.tsx @@ -1,7 +1,8 @@ -import type { LucideIcon } from "lucide-react"; -import type { ReactNode } from "react"; +import { ChevronRight, type LucideIcon } from "lucide-react"; +import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react"; import { Button } from "~/components/ui/button.tsx"; import { cn } from "~/lib/utils.ts"; +import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts"; import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx"; export type DataColumn = { @@ -15,6 +16,16 @@ export type DataColumn = { className?: string; }; +/** 分组:按键把行归组,组头可折叠并显示计数;给 storageKey 时在本浏览器记住折叠的组 */ +export type DataGroupBy = { + key: (row: T) => string; + /** 组头文字,缺省为 key */ + label?: (key: string, rows: T[]) => ReactNode; + /** 组顺序,缺省按首次出现 */ + order?: (a: string, b: string) => number; + storageKey?: string; +}; + const HIDE: Record["hideBelow"]>, string> = { sm: "hidden sm:table-cell", md: "hidden md:table-cell", @@ -44,6 +55,7 @@ export function DataTable({ onLoadMore, loadingMore = false, plain = false, + groupBy, className, }: { columns: DataColumn[]; @@ -59,8 +71,70 @@ export function DataTable({ onLoadMore?: () => void; loadingMore?: boolean; plain?: boolean; + groupBy?: DataGroupBy; className?: string; }) { + const storageKey = groupBy?.storageKey; + const [collapsed, setCollapsed] = useState>(() => new Set()); + // 折叠态在挂载后读(服务端渲染时没有 localStorage,首屏一律展开) + useEffect(() => { + const saved = readCollapsed(storageKey); + if (saved) setCollapsed(saved); + }, [storageKey]); + const groupKey = groupBy?.key; + const groupOrder = groupBy?.order; + const groups = useMemo( + () => (groupKey && rows ? groupRows(rows, groupKey, groupOrder) : null), + [rows, groupKey, groupOrder], + ); + const toggleGroup = (k: string) => + setCollapsed((prev) => { + const next = toggleCollapsed(prev, k); + writeCollapsed(storageKey, next); + return next; + }); + + const renderRow = (row: T) => { + const key = rowKey(row); + return ( + onRowClick(row) : undefined} + onKeyDown={ + onRowClick + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick(row); + } + } + : undefined + } + tabIndex={onRowClick ? 0 : undefined} + aria-selected={selectedKey != null ? selectedKey === key : undefined} + className={cn( + "border-b border-border/60 transition-colors duration-micro last:border-b-0", + onRowClick && "cursor-pointer outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]", + selectedKey === key && "bg-foreground/[0.05]", + )} + > + {columns.map((c) => ( + + {c.cell(row)} + + ))} + + ); + }; + let body: ReactNode; if (loading && !rows) { body = ; @@ -99,49 +173,42 @@ export function DataTable({ ))} - - {rows.map((row) => { - const key = rowKey(row); + {groups ? ( + groups.map((g) => { + const open = !collapsed.has(g.key); return ( - onRowClick(row) : undefined} - onKeyDown={ - onRowClick - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick(row); - } - } - : undefined - } - tabIndex={onRowClick ? 0 : undefined} - aria-selected={selectedKey != null ? selectedKey === key : undefined} - className={cn( - "border-b border-border/60 transition-colors duration-micro last:border-b-0", - onRowClick && - "cursor-pointer outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]", - selectedKey === key && "bg-foreground/[0.05]", - )} - > - {columns.map((c) => ( - - {c.cell(row)} - - ))} - + + + + + + + + + {open ? {g.rows.map(renderRow)} : null} + ); - })} - + }) + ) : ( + {rows.map(renderRow)} + )} ); diff --git a/apps/web/app/routes/console/files.trash.tsx b/apps/web/app/routes/console/files.trash.tsx index b165b5be..74bac6bc 100644 --- a/apps/web/app/routes/console/files.trash.tsx +++ b/apps/web/app/routes/console/files.trash.tsx @@ -94,7 +94,7 @@ export default function FilesTrashPage() { } : undefined, ); - if (r.failed.length) toast.error(`${r.failed.length} 项没有还原,原因见列表`); + if (r.failed.length) toast.error(`${r.failed.length} 项没有还原,原因见列表`); }, onError: (e) => toast.error(filesErrorText(e, "还原失败")), }, @@ -219,7 +219,7 @@ export default function FilesTrashPage() { if (!snap?.title) return —; const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`; return ( - + {snap.mediaEntityId ? ( {label} @@ -274,7 +274,7 @@ export default function FilesTrashPage() { <> {filesMockEnabled() ? 演示数据 : null} @@ -308,8 +308,8 @@ export default function FilesTrashPage() { 清除已到期 {expired.length || ""} } - title="清除已到期的项?" - description={`${expired.length} 项已过保留期,清除后不可恢复。`} + title="清除已到期的项?" + description={`${expired.length} 项已过保留期,清除后不可恢复。`} confirmLabel="清除" destructive onConfirm={() => doPurge(undefined)} @@ -340,12 +340,21 @@ export default function FilesTrashPage() { onLoadMore={() => void trash.fetchNextPage()} loadingMore={trash.isFetchingNextPage} empty={{ icon: Trash2, title: "回收站是空的", description: "在文件管理里删除的项会出现在这里。" }} + groupBy={ + new Set(rows?.map((r) => r.rootId)).size > 1 + ? { + key: (r) => r.rootId, + label: (k) => rootName.get(k)?.name ?? k, + storageKey: "files-trash-by-root", + } + : undefined + } /> !o && setPurgeIds(null)} - title={`立即清除 ${purgeIds?.length ?? 0} 项?`} + title={`立即清除 ${purgeIds?.length ?? 0} 项?`} description="清除后不可恢复。" confirmLabel="清除" destructive @@ -359,14 +368,14 @@ export default function FilesTrashPage() { setPurgeAll(o); if (!o) setConfirmText(""); }} - title="清空回收站?" - description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`} + title="清空回收站?" + description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`} confirmLabel="清空" confirmDisabled={confirmText.trim() !== "清空"} destructive onConfirm={() => { if (confirmText.trim() !== "清空") { - toast.error("输入“清空”以确认"); + toast.error("输入「清空」以确认"); return Promise.reject(new Error("unconfirmed")); } return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));