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

+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(""));