feat: unify scope settings and catalog table interactions
Deploy production / deploy (push) Successful in 54s

This commit is contained in:
yuxuanhui
2026-09-12 12:11:53 +08:00
parent 9dee2de039
commit 8981c24fda
29 changed files with 2521 additions and 796 deletions
@@ -0,0 +1,264 @@
import {
useEffect,
useId,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import { Button, CheckboxGroup, Popover, Tooltip } from "@douyinfe/semi-ui-19";
import {
IconClose,
IconFilter,
IconSetting,
IconSort,
} from "@douyinfe/semi-icons";
import { ResearchSelect } from "../research/ResearchSelect";
import "./catalog-table-toolbar.css";
export type ColumnChoice = { key: string; label: string; required?: boolean };
/** Each catalog keeps its own columns. Ignore stale or malformed saved keys. */
export function useCatalogColumns(storageKey: string, choices: ColumnChoice[]) {
const [visible, setVisible] = useState<string[]>(() => {
try {
const saved: unknown = JSON.parse(
localStorage.getItem(storageKey) ?? "null",
);
if (Array.isArray(saved))
return choices
.filter((c) => c.required || saved.includes(c.key))
.map((c) => c.key);
} catch {
/* A storage restriction must not prevent reading the catalog. */
}
return choices.map((c) => c.key);
});
return {
choices,
visible,
onChange: (keys: string[]) => {
const next = choices
.filter((c) => c.required || keys.includes(c.key))
.map((c) => c.key);
setVisible(next);
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch {
/* Session-only preferences remain usable. */
}
},
};
}
export function CatalogIconAction({
label,
...props
}: ComponentProps<typeof Button> & { label: string }) {
return (
<Tooltip content={props.title ?? label}>
<span className="catalog-icon-action">
<Button
theme="borderless"
type="tertiary"
{...props}
aria-label={label}
/>
</span>
</Tooltip>
);
}
/** Shared interaction shell; callers own draft filters, remote sorting and pagination. */
export function CatalogTableToolbar({
label,
active = true,
filterCount = 0,
filters,
onApply,
onReset,
canApply = true,
columns,
sort,
actions,
selection,
}: {
label: string;
active?: boolean;
filterCount?: number;
filters: ReactNode;
onApply: () => void;
onReset: () => void;
canApply?: boolean;
columns: ReturnType<typeof useCatalogColumns>;
sort?: {
value: string;
direction: string;
options: { value: string; label: string }[];
onChange: (value: string, direction: string) => void;
};
actions?: ReactNode;
selection?: ReactNode;
}) {
const [open, setOpen] = useState<"filter" | "columns" | "sort" | null>(null);
const triggerId = useId();
useEffect(() => {
if (!active) setOpen(null);
}, [active]);
const close = () => {
setOpen(null);
document.getElementById(`${triggerId}-${open}`)?.focus();
};
const popup = (
kind: "filter" | "columns" | "sort",
content: ReactNode,
icon: ReactNode,
name: string,
) => (
<Popover
trigger="custom"
position="bottomLeft"
visible={active && open === kind}
onClickOutSide={() => setOpen(null)}
onEscKeyDown={close}
content={content}
>
<Button
id={`${triggerId}-${kind}`}
icon={icon}
aria-label={name}
title={name}
type={
open === kind || (kind === "filter" && filterCount)
? "primary"
: "tertiary"
}
theme={
open === kind || (kind === "filter" && filterCount)
? "light"
: "borderless"
}
onClick={() => setOpen(open === kind ? null : kind)}
>
{kind === "filter" && filterCount > 0 && (
<span className="catalog-filter-count">{filterCount}</span>
)}
</Button>
</Popover>
);
return (
<>
<div
className="catalog-table-toolbar"
role="toolbar"
aria-label={`${label}表格工具栏`}
>
<div className="catalog-toolbar-tools">
{popup(
"filter",
<form
className="catalog-filter-panel"
aria-label={`${label}筛选条件`}
onSubmit={(e) => {
e.preventDefault();
if (canApply) {
onApply();
close();
}
}}
>
<header>
<div>
<h2>筛选{label}</h2>
<p>设置条件后点击查询,应用到当前列表。</p>
</div>
<Button
icon={<IconClose aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="关闭筛选"
onClick={close}
/>
</header>
<div className="catalog-filter-body">{filters}</div>
<footer>
<Button
onClick={() => {
onReset();
close();
}}
>
重置
</Button>
<Button onClick={close}>取消</Button>
<Button htmlType="submit" theme="solid" disabled={!canApply}>
查询
</Button>
</footer>
</form>,
<IconFilter aria-hidden="true" />,
`筛选${label}`,
)}
{popup(
"columns",
<div className="catalog-column-panel">
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={columns.visible}
options={columns.choices.map((c) => ({
value: c.key,
label: c.label,
disabled: c.required,
}))}
onChange={(keys) => columns.onChange(keys.map(String))}
/>
<Button
theme="borderless"
onClick={() =>
columns.onChange(columns.choices.map((c) => c.key))
}
>
恢复默认列
</Button>
</div>,
<IconSetting aria-hidden="true" />,
"显示列设置",
)}
{sort ? (
popup(
"sort",
<div className="catalog-sort-panel">
<h2>排序</h2>
<ResearchSelect
label="排序字段"
value={sort.value}
optionList={sort.options}
onChange={(v) => sort.onChange(String(v), sort.direction)}
/>
<ResearchSelect
label="排序方向"
value={sort.direction}
optionList={[
{ value: "asc", label: "升序" },
{ value: "desc", label: "降序" },
]}
onChange={(v) => sort.onChange(sort.value, String(v))}
/>
</div>,
<IconSort aria-hidden="true" />,
"排序设置",
)
) : (
<CatalogIconAction
label="排序设置"
title="平台接口未提供排序,按平台返回顺序展示"
icon={<IconSort aria-hidden="true" />}
disabled
/>
)}
</div>
<div className="catalog-toolbar-actions">{actions}</div>
</div>
{selection && (
<div className="catalog-selection-actions">{selection}</div>
)}
</>
);
}
@@ -0,0 +1,128 @@
.catalog-table-toolbar {
display: flex;
align-items: center;
gap: 8px 16px;
flex-wrap: wrap;
padding: 4px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--semi-color-border);
}
.catalog-toolbar-tools,
.catalog-toolbar-actions,
.catalog-selection-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.catalog-toolbar-tools {
gap: 4px;
}
.catalog-toolbar-actions {
margin-left: auto;
}
.catalog-icon-action {
display: inline-flex;
}
.catalog-selection-actions {
padding: 4px 12px;
flex-shrink: 0;
background: var(--semi-color-primary-light-default);
border-bottom: 1px solid var(--semi-color-border);
}
.catalog-filter-count {
margin-left: 4px;
font-variant-numeric: tabular-nums;
}
.catalog-filter-panel {
display: flex;
flex-direction: column;
width: min(640px, calc(100vw - 32px));
max-height: min(700px, calc(100dvh - 120px));
color: var(--semi-color-text-0);
}
.catalog-filter-panel > header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 20px 16px;
}
.catalog-filter-panel h2,
.catalog-sort-panel h2 {
margin: 0;
font-size: 16px;
}
.catalog-filter-panel header p {
margin: 4px 0 0;
color: var(--semi-color-text-2);
font-size: 13px;
}
.catalog-filter-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 20px 20px;
}
.catalog-filter-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.catalog-filter-grid > label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.catalog-filter-grid .semi-select {
width: 100%;
}
.catalog-filter-wide {
grid-column: 1 / -1;
}
.catalog-filter-body details {
margin-top: 16px;
}
.catalog-filter-body details > summary {
cursor: pointer;
margin-bottom: 12px;
}
.catalog-filter-panel > footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--semi-color-border);
}
.catalog-filter-panel > footer > :first-child {
margin-right: auto;
}
.catalog-column-panel {
padding: 16px;
min-width: 192px;
max-height: min(480px, 60dvh);
overflow-y: auto;
}
.catalog-column-panel > strong {
display: block;
margin-bottom: 12px;
}
.catalog-sort-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) 100px;
gap: 12px;
padding: 16px;
width: min(360px, calc(100vw - 32px));
}
.catalog-sort-panel > h2 {
grid-column: 1 / -1;
}
.catalog-sort-panel .semi-select {
width: 100%;
}
@media (max-width: 600px) {
.catalog-filter-grid {
grid-template-columns: minmax(0, 1fr);
}
}