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 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent abc69886ea
commit 312e70d0bc
4 files changed
+276 -52

No files matched your search

@@ -0,0 +1,58 @@
// DataTable groupBy 的纯逻辑:按键分组(保持首次出现顺序或自定义顺序)与展开态持久化。
export type RowGroup<T> = { key: string; rows: T[] };
/** 按 key 分组;缺省按首次出现顺序,给 order 时按 order 排组(组内保持原顺序) */
export function groupRows<T>(
rows: readonly T[],
keyOf: (row: T) => string,
order?: (a: string, b: string) => number,
): RowGroup<T>[] {
const map = new Map<string, T[]>();
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<Storage, "getItem" | "setItem">;
const defaultStore = (): Store | undefined => globalThis.localStorage;
/** 读折叠的组(localStorage 不可用或内容损坏时为 null,由调用方取缺省) */
export function readCollapsed(storageKey: string | undefined, store = defaultStore): Set<string> | 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<string>,
store = defaultStore,
): void {
if (!storageKey) return;
try {
store()?.setItem(PREFIX + storageKey, JSON.stringify([...collapsed]));
} catch {
// 私密窗口 / 配额满:只是不记住
}
}
/** 切换一个组的折叠态,返回新集合 */
export function toggleCollapsed(collapsed: ReadonlySet<string>, key: string): Set<string> {
const next = new Set(collapsed);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
}
@@ -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<string>();
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<string, string>();
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<Row>, {
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><span[^>]*>2<\/span>/);
expect(html).toMatch(/已登记<\/span><span[^>]*>1<\/span>/);
for (const n of ["a.mkv", "b.mkv", "c.mkv"]) expect(html).toContain(n);
});
it("不给 groupBy 时没有组头", () => {
const html = renderToStaticMarkup(
createElement(DataTable<Row>, {
columns: [{ key: "name", header: "名称", cell: (r: Row) => r.name }],
rows,
rowKey: (r: Row) => r.id,
}),
);
expect(html).not.toContain("aria-expanded");
});
});
+110 -43
View File
@@ -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<T> = {
@@ -15,6 +16,16 @@ export type DataColumn<T> = {
className?: string;
};
/** 分组:按键把行归组,组头可折叠并显示计数;给 storageKey 时在本浏览器记住折叠的组 */
export type DataGroupBy<T> = {
key: (row: T) => string;
/** 组头文字,缺省为 key */
label?: (key: string, rows: T[]) => ReactNode;
/** 组顺序,缺省按首次出现 */
order?: (a: string, b: string) => number;
storageKey?: string;
};
const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
sm: "hidden sm:table-cell",
md: "hidden md:table-cell",
@@ -44,6 +55,7 @@ export function DataTable<T>({
onLoadMore,
loadingMore = false,
plain = false,
groupBy,
className,
}: {
columns: DataColumn<T>[];
@@ -59,8 +71,70 @@ export function DataTable<T>({
onLoadMore?: () => void;
loadingMore?: boolean;
plain?: boolean;
groupBy?: DataGroupBy<T>;
className?: string;
}) {
const storageKey = groupBy?.storageKey;
const [collapsed, setCollapsed] = useState<Set<string>>(() => 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 (
<tr
key={key}
onClick={onRowClick ? () => 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) => (
<td
key={c.key}
className={cn(
"px-4 py-2.5 align-middle",
c.align === "right" && "text-right",
c.hideBelow && HIDE[c.hideBelow],
c.className,
)}
>
{c.cell(row)}
</td>
))}
</tr>
);
};
let body: ReactNode;
if (loading && !rows) {
body = <LoadingState rows={5} className="p-4" />;
@@ -99,49 +173,42 @@ export function DataTable<T>({
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const key = rowKey(row);
{groups ? (
groups.map((g) => {
const open = !collapsed.has(g.key);
return (
<tr
key={key}
onClick={onRowClick ? () => 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) => (
<td
key={c.key}
className={cn(
"px-4 py-2.5 align-middle",
c.align === "right" && "text-right",
c.hideBelow && HIDE[c.hideBelow],
c.className,
)}
>
{c.cell(row)}
</td>
))}
</tr>
<Fragment key={`g:${g.key}`}>
<tbody>
<tr className="border-b border-border/60 bg-foreground/[0.02]">
<th scope="colgroup" colSpan={columns.length} className="p-0 text-left font-normal">
<button
type="button"
aria-expanded={open}
onClick={() => toggleGroup(g.key)}
className="flex w-full items-center gap-2 px-4 py-2 text-meta text-muted-foreground outline-none hover:text-foreground focus-visible:bg-foreground/[0.06]"
>
<ChevronRight
aria-hidden
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform duration-micro",
open && "rotate-90",
)}
/>
<span className="min-w-0 truncate font-medium text-foreground">
{groupBy?.label ? groupBy.label(g.key, g.rows) : g.key}
</span>
<span className="tabular-nums">{g.rows.length}</span>
</button>
</th>
</tr>
</tbody>
{open ? <tbody>{g.rows.map(renderRow)}</tbody> : null}
</Fragment>
);
})}
</tbody>
})
) : (
<tbody>{rows.map(renderRow)}</tbody>
)}
</table>
</div>
);
+18 -9
View File
@@ -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 <span className="text-muted-foreground">—</span>;
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
return (
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
{snap.mediaEntityId ? (
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
{label}
@@ -274,7 +274,7 @@ export default function FilesTrashPage() {
<>
<PageHeader
title="回收站"
description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
actions={
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
@@ -308,8 +308,8 @@ export default function FilesTrashPage() {
清除已到期 {expired.length || ""}
</Button>
}
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
}
/>
<ConfirmDialog
open={!!purgeIds}
onOpenChange={(o) => !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(""));