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 { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
|
import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx";
|
||||||
|
|
||||||
export type DataColumn<T> = {
|
export type DataColumn<T> = {
|
||||||
@@ -15,6 +16,16 @@ export type DataColumn<T> = {
|
|||||||
className?: string;
|
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> = {
|
const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
|
||||||
sm: "hidden sm:table-cell",
|
sm: "hidden sm:table-cell",
|
||||||
md: "hidden md:table-cell",
|
md: "hidden md:table-cell",
|
||||||
@@ -44,6 +55,7 @@ export function DataTable<T>({
|
|||||||
onLoadMore,
|
onLoadMore,
|
||||||
loadingMore = false,
|
loadingMore = false,
|
||||||
plain = false,
|
plain = false,
|
||||||
|
groupBy,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
columns: DataColumn<T>[];
|
columns: DataColumn<T>[];
|
||||||
@@ -59,8 +71,70 @@ export function DataTable<T>({
|
|||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
loadingMore?: boolean;
|
loadingMore?: boolean;
|
||||||
plain?: boolean;
|
plain?: boolean;
|
||||||
|
groupBy?: DataGroupBy<T>;
|
||||||
className?: string;
|
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;
|
let body: ReactNode;
|
||||||
if (loading && !rows) {
|
if (loading && !rows) {
|
||||||
body = <LoadingState rows={5} className="p-4" />;
|
body = <LoadingState rows={5} className="p-4" />;
|
||||||
@@ -99,49 +173,42 @@ export function DataTable<T>({
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
{groups ? (
|
||||||
{rows.map((row) => {
|
groups.map((g) => {
|
||||||
const key = rowKey(row);
|
const open = !collapsed.has(g.key);
|
||||||
return (
|
return (
|
||||||
<tr
|
<Fragment key={`g:${g.key}`}>
|
||||||
key={key}
|
<tbody>
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
<tr className="border-b border-border/60 bg-foreground/[0.02]">
|
||||||
onKeyDown={
|
<th scope="colgroup" colSpan={columns.length} className="p-0 text-left font-normal">
|
||||||
onRowClick
|
<button
|
||||||
? (e) => {
|
type="button"
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
aria-expanded={open}
|
||||||
e.preventDefault();
|
onClick={() => toggleGroup(g.key)}
|
||||||
onRowClick(row);
|
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
|
||||||
: undefined
|
aria-hidden
|
||||||
}
|
className={cn(
|
||||||
tabIndex={onRowClick ? 0 : undefined}
|
"h-3.5 w-3.5 shrink-0 transition-transform duration-micro",
|
||||||
aria-selected={selectedKey != null ? selectedKey === key : undefined}
|
open && "rotate-90",
|
||||||
className={cn(
|
)}
|
||||||
"border-b border-border/60 transition-colors duration-micro last:border-b-0",
|
/>
|
||||||
onRowClick &&
|
<span className="min-w-0 truncate font-medium text-foreground">
|
||||||
"cursor-pointer outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
{groupBy?.label ? groupBy.label(g.key, g.rows) : g.key}
|
||||||
selectedKey === key && "bg-foreground/[0.05]",
|
</span>
|
||||||
)}
|
<span className="tabular-nums">{g.rows.length}</span>
|
||||||
>
|
</button>
|
||||||
{columns.map((c) => (
|
</th>
|
||||||
<td
|
</tr>
|
||||||
key={c.key}
|
</tbody>
|
||||||
className={cn(
|
{open ? <tbody>{g.rows.map(renderRow)}</tbody> : null}
|
||||||
"px-4 py-2.5 align-middle",
|
</Fragment>
|
||||||
c.align === "right" && "text-right",
|
|
||||||
c.hideBelow && HIDE[c.hideBelow],
|
|
||||||
c.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{c.cell(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</tbody>
|
) : (
|
||||||
|
<tbody>{rows.map(renderRow)}</tbody>
|
||||||
|
)}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function FilesTrashPage() {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: 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, "还原失败")),
|
onError: (e) => toast.error(filesErrorText(e, "还原失败")),
|
||||||
},
|
},
|
||||||
@@ -219,7 +219,7 @@ export default function FilesTrashPage() {
|
|||||||
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
|
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
|
||||||
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
|
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
|
||||||
return (
|
return (
|
||||||
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
|
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
|
||||||
{snap.mediaEntityId ? (
|
{snap.mediaEntityId ? (
|
||||||
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
|
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
|
||||||
{label}
|
{label}
|
||||||
@@ -274,7 +274,7 @@ export default function FilesTrashPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="回收站"
|
title="回收站"
|
||||||
description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
|
description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||||
@@ -308,8 +308,8 @@ export default function FilesTrashPage() {
|
|||||||
清除已到期 {expired.length || ""}
|
清除已到期 {expired.length || ""}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title="清除已到期的项?"
|
title="清除已到期的项?"
|
||||||
description={`${expired.length} 项已过保留期,清除后不可恢复。`}
|
description={`${expired.length} 项已过保留期,清除后不可恢复。`}
|
||||||
confirmLabel="清除"
|
confirmLabel="清除"
|
||||||
destructive
|
destructive
|
||||||
onConfirm={() => doPurge(undefined)}
|
onConfirm={() => doPurge(undefined)}
|
||||||
@@ -340,12 +340,21 @@ export default function FilesTrashPage() {
|
|||||||
onLoadMore={() => void trash.fetchNextPage()}
|
onLoadMore={() => void trash.fetchNextPage()}
|
||||||
loadingMore={trash.isFetchingNextPage}
|
loadingMore={trash.isFetchingNextPage}
|
||||||
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件管理里删除的项会出现在这里。" }}
|
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
|
<ConfirmDialog
|
||||||
open={!!purgeIds}
|
open={!!purgeIds}
|
||||||
onOpenChange={(o) => !o && setPurgeIds(null)}
|
onOpenChange={(o) => !o && setPurgeIds(null)}
|
||||||
title={`立即清除 ${purgeIds?.length ?? 0} 项?`}
|
title={`立即清除 ${purgeIds?.length ?? 0} 项?`}
|
||||||
description="清除后不可恢复。"
|
description="清除后不可恢复。"
|
||||||
confirmLabel="清除"
|
confirmLabel="清除"
|
||||||
destructive
|
destructive
|
||||||
@@ -359,14 +368,14 @@ export default function FilesTrashPage() {
|
|||||||
setPurgeAll(o);
|
setPurgeAll(o);
|
||||||
if (!o) setConfirmText("");
|
if (!o) setConfirmText("");
|
||||||
}}
|
}}
|
||||||
title="清空回收站?"
|
title="清空回收站?"
|
||||||
description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`}
|
description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`}
|
||||||
confirmLabel="清空"
|
confirmLabel="清空"
|
||||||
confirmDisabled={confirmText.trim() !== "清空"}
|
confirmDisabled={confirmText.trim() !== "清空"}
|
||||||
destructive
|
destructive
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (confirmText.trim() !== "清空") {
|
if (confirmText.trim() !== "清空") {
|
||||||
toast.error("输入“清空”以确认");
|
toast.error("输入「清空」以确认");
|
||||||
return Promise.reject(new Error("unconfirmed"));
|
return Promise.reject(new Error("unconfirmed"));
|
||||||
}
|
}
|
||||||
return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));
|
return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));
|
||||||
|
|||||||
Reference in new issue
Block a user