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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
Reference in new issue
Block a user