feat: unify scope settings and catalog table interactions
Deploy production / deploy (push) Successful in 54s
Deploy production / deploy (push) Successful in 54s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user