数据展示
在线示例:/design-system/data。这一页是模板里最重的部分:表格、树、表格树、描述列表、时间线、分页与布局容器。
DataTable
基于 @tanstack/react-table 的列定义,外层包一层后台常用能力。
const columns: ColumnDef<Row>[] = [
{ accessorKey: 'id', header: 'ID', size: 120 },
{
accessorKey: 'status',
header: '状态',
size: 120,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
]
<DataTable
data={rows}
columns={columns}
caption="组织单元"
sortable
storageId="design-data"
pinned={{ first: true, last: true }}
density={density}
columnVisibility={visibility}
onColumnVisibilityChange={setVisibility}
onRowClick={(row) => navigate(`/customers/${row.id}`)}
toolbar={<TableToolbar><ColumnManager … /></TableToolbar>}
footer={<PagePager page={page} pageCount={12} onPageChange={setPage} />}
/>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| prop | 类型 | 说明 |
|---|---|---|
data / columns | T[] / ColumnDef<T>[] | 数据与列定义 |
caption | string | 必填,表格标题(同时作为 aria-label) |
loading | boolean | 加载态 |
emptyTitle / emptyDescription | string | 空态文案 |
striped | boolean | 斑马纹,默认开启 |
sortable | boolean | 表头排序,点击在升序 → 降序 → 取消之间循环 |
defaultSorting | SortingState | 初始排序,支持多列与优先级 |
density | compact | default | relaxed | 行高密度 |
onRowClick | (row: T) => void | 行点击;行内的按钮/链接/复选框等交互元素不触发 |
pinned | { first?, last? } | 固定第一列 / 操作列 |
storageId | string | 列宽与列顺序按表名持久化 |
expandable | { content, label? } | 行内展开;与 virtual 互斥 |
virtual | { height?, rowHeight?, overscan? } | 大数据量虚拟滚动 |
columnOrder / onColumnOrderChange | string[] | 列顺序(受控);不传时用浏览器保存的顺序 |
columnSizes / onColumnSizesChange | Record<string, number> | 列宽(受控);受控后视图预设才能连列宽一起存 |
toolbar / footer | ReactNode | 工具栏与底部(分页、合计) |
虚拟滚动
<DataTable data={largeRows} columns={columns} caption="大数据量记录" virtual={{ height: 460 }} />只渲染可视窗口内的行,上下用占位行撑起滚动高度,表头固定。注意两点:
- 行高固定(实测首行高度),因此与行内展开互斥;
- 滚动容器必须是表格外层,别给内层
.table-container加overflow-x-auto,否则表头不再吸顶。
ColumnManager
<ColumnManager
columns={columns}
storageId="design-data"
visibility={visibility}
onVisibilityChange={setVisibility}
/>2
3
4
5
6
「恢复默认」在传了 storageId 时会连列宽一起复位(resetTableLayout),否则只复位显示 / 隐藏。
拖拽调整列顺序
两处都能拖:
- 表头:拖动列标题到目标列上;拖动时目标列会出现一条 2px 主色边框,指明会插到哪一侧。
- 列管理(「列」下拉):每项前面是拖拽手柄、行尾是勾选框,拖到目标项上即可。
const [order, setOrder] = useState<string[]>(() => readColumnOrder('customers'))
<DataTable … columnOrder={order} onColumnOrderChange={setOrder} />
<ColumnManager … order={order} onOrderChange={setOrder} pinned={{ first: true, last: true }} />2
3
4
- 顺序与列宽存在同一个浏览器键里(按
storageId),刷新后保留;「列 → 恢复默认」会连列宽一起复位。 - 固定列(
pinned.first/pinned.last)不参与拖拽:被拖到中间会让吸附位置错乱,所以直接禁止;ColumnManager传同一个pinned即可保持一致。 - 页面初始化
order时要写useState(() => readColumnOrder(storageId)):传空数组会被当成"默认顺序",把保存的顺序覆盖掉。
EditableTable(可编辑表格)
行内编辑 + 逐行新增的数据录入表:只读行显示文本与图标操作,进入编辑态后变成控件, 底部整行是「新增一行」。用 onChange 把已保存的行交给业务。
<EditableTable
columns={[
{ key: 'name', label: '客户名称', placeholder: '请输入客户名称' },
{ key: 'owner', label: '负责人', type: 'select', options: owners },
{ key: 'startedAt', label: '签约日期', type: 'date' },
{ key: 'amount', label: '合同金额', type: 'number' },
{ key: 'vip', label: '重点客户', type: 'switch' },
]}
defaultRows={rows}
onChange={setRows}
maxRows={7} // 不传就不限高,也不出现纵向滚动条
/>2
3
4
5
6
7
8
9
10
11
12
| prop | 类型 | 说明 |
|---|---|---|
columns | EditableTableColumn[] | 列定义;type 支持 text / number / date / select / switch |
defaultRows | { id, values }[] | 初始行(非受控),values 按列的 key 取值 |
onChange | (rows) => void | 已保存行的回调,编辑过程中不触发 |
stickyActions | boolean | 操作列固定在右侧,默认开启;列多时表格内部横向滚动 |
maxRows | number | 行数上限,超过才内部纵向滚动 + 表头吸顶;不传即不限高 |
配合约定:
- 操作列一律图标按钮(编辑 / 保存 / 取消 / 删除),删除用
bg-danger-soft+ 淡红描边,悬停加深; - 新行默认处于编辑态,
取消直接丢弃该行;取消非新行时只丢掉本次改动; - 行数多时不要自己给表格套
max-height,统一用maxRows,表头吸顶与横向滚动都由组件负责。
Tree(列表树)
<Tree
nodes={nodes}
label="组织单元"
defaultExpanded={rootIds}
filterable
showExpandControls
guides
checkable
defaultChecked={['OU-111']}
onCheckedChange={setChecked}
onMove={(sourceId, targetId) => moveNode(sourceId, targetId)}
loadChildren={(id) => fetchChildren(id)}
/>2
3
4
5
6
7
8
9
10
11
12
13
| prop | 类型 | 默认 | 说明 |
|---|---|---|---|
nodes | TreeNode<TreeItem>[] | — | { id, parentId, sortOrder, data: { label, description?, disabled? } } |
label | string | — | 必填,树的访问名称 |
defaultExpanded | string[] | [] | 默认展开 |
guides | boolean | true | 层级虚线;缩进始终保留 |
filterable | boolean | false | 显示节点搜索,命中自动展开祖先 |
showExpandControls | boolean | false | 展开全部 / 收起全部按钮 |
checkable | boolean | false | 勾选是属性开关,默认关闭;父节点级联子节点,孙节点被勾选时祖先显示半选 |
defaultChecked / onCheckedChange | string[] | [] | 勾选状态 |
onMove | (sourceId, targetId | null) => void | — | 拖拽排序,落地交给业务 |
loadChildren | (id) => void | — | 懒加载子节点 |
virtual | boolean | false | 节点 > 200 时按 36px 估算窗口 |
键盘:↑/↓ 在可见节点间移动,→ 展开或进入子节点,← 收起或回到父节点,Enter / Space 选中。
TreeTable(表格树)
表格树在标准表格样式上实现:层级数据拍平成行,第一列补缩进与展开按钮, 列宽拖拽、固定列、行点击、溢出提示、空态全部复用 DataTable。
<TreeTable
nodes={tableTree}
caption="组织单元(表格)"
defaultExpanded={rootIds}
rowLabel={(node) => node.data.name}
columns={[
{
id: 'name',
header: '名称',
cell: (row) => <span className="font-medium">{row.node.data.name}</span>,
},
{ id: 'kind', header: '类型', width: 110, cell: (row) => row.node.data.kind },
{
id: 'status',
header: '状态',
width: 120,
cell: (row) => <StatusBadge status={row.node.data.status} />,
},
]}
/>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| prop | 说明 |
|---|---|
nodes | TreeNode<T>[] |
columns | { id, header, width?, cell(row: FlatTreeRow<T>) }[],第一列自动获得缩进 |
caption | 表格标题 |
rowLabel | 展开 / 收起按钮的提示文本 |
defaultExpanded | 默认展开的节点 id |
Descriptions
<Descriptions
columns={3}
items={[
{ key: 'id', label: '客户编号', value: 'CUS-1001' },
{ key: 'email', label: '邮箱', value: '[email protected]', span: 2 },
]}
/>2
3
4
5
6
7
bordered 可切换描边样式;span 让长内容跨列。
Timeline
<Timeline
items={[
{ key: '1', title: '提交注册资料', time: '2026-09-16 09:20', tone: 'success' },
{ key: '2', title: '补充营业执照', description: '上传扫描件并完成人工比对。' },
{ key: '3', title: '审核退回', tone: 'danger', time: '2026-09-14 11:05' },
]}
/>2
3
4
5
6
7
tone 取 default | success | warning | danger,按时间倒序展示。
分页:两种模式
后台分页只有两种形态,选错了会在数据量大时出现"翻到第 500 页"的性能问题:
| 模式 | 组件 | 适用 | 说明 |
|---|---|---|---|
| 固定分页(offset / 页码) | PagePager + PaginationBar mode="fixed" | 数据量可控、需要跳页与总数 | 已知总页数,可以输入页码跳转 |
| 偏移分页(cursor) | CursorPagination + PaginationBar mode="cursor" | 大数据量、实时流、无限滚动 | 只知道"有没有下一页",提供首页 / 上一页 / 下一页 |
// 固定分页
<PagePager page={page} pageCount={12} onPageChange={setPage} />
// 偏移分页(CursorPagination 内部已用 PaginationBar mode="cursor" 包装,直接渲染即可)
<CursorPagination
count={rows.length}
page={page}
pending={query.isFetching}
hasNext={hasNext}
hasPrevious={hasPrevious}
first={goFirst}
previous={goPrevious}
next={goNext}
/>2
3
4
5
6
7
8
9
10
11
12
13
14
需要自定义底部布局时再用 PaginationBar:mode 取 fixed 或 cursor, summary / pageSize 放左侧信息,分页控件作为 children。
页码计算(省略号、边界)在 src/lib/pager.ts:pagerItems(page, pageCount) 返回带 null(省略号)的数组, parsePageInput() 负责把输入框内容解析成合法页码。
LayoutContainer
主内容 + 可折叠、可拖拽调宽的详情侧栏;宽度按 storageId 保存在浏览器。
<LayoutContainer asideLabel="详情侧栏" storageId="design-aside" aside={<Timeline items={events} />}>
<Descriptions items={detailItems} />
</LayoutContainer>2
3
| prop | 默认 | 说明 |
|---|---|---|
aside | — | 侧栏内容,不传则只有主区 |
asideLabel | — | 侧栏访问名称 |
defaultCollapsed | false | 初始折叠 |
storageId | — | 传入后持久化宽度,并通过 onWidthChange 共享 |
拖左边缘调整宽度(键盘 ←/→ 也可),双击复位。
工具栏与行操作零件
| 组件 | 用途 |
|---|---|
TableToolbar | 表格上方工具条容器(列管理、密度、批量操作) |
BulkActions | 批量操作下拉,label + 子项 |
RowActions | 行尾操作容器,固定右列时使用 |
TableCellOverflow | 超长单元格一行截断 + title 提示 |
SearchFilters | 列表页筛选区(见下) |
FilterButton | 筛选区按钮,action 自动获得提交语义 |
SearchFilters
<SearchFilters
presets={{ storageId: 'customers', value: filters, onApply: restore }}
columns={3}
collapsedCount={3}
description="筛选条件不会自动保存,常用组合可以存成预设。"
>
<SearchField as="label">客户名称<Input … /></SearchField>
<SearchField as="label">状态<NativeSelect … /></SearchField>
<SearchActions>
<Button type="reset" variant="outline">重置</Button>
<Button type="submit">查询</Button>
</SearchActions>
</SearchFilters>2
3
4
5
6
7
8
9
10
11
12
13
| prop | 默认 | 说明 |
|---|---|---|
presets | — | 配置开关:传入 { storageId, value, onApply } 就启用"筛选预设"(底层是 StorageBox) |
labelPosition | left | left 会按列对齐标签宽度 |
columns | 3 | 列数 |
collapsedCount | = columns | 折叠后保留的字段数;没有可折叠字段时不显示禁用「展开」 |
description / descriptionPosition | — | 说明文案,bottom 或 inline |
togglePosition | inline | 展开 / 收起按钮位置 |
collapsible / defaultExpanded | true / false | 折叠行为 |
StorageBox(储物箱)也可以单独使用:保存 / 恢复 / 重命名 / 删除任意状态快照, 数据存在浏览器,用 storageId 区分业务。
视图预设存的是整份列布局:筛选条件 + 列显隐 + 列顺序 + 列宽,恢复时四项一起回来。 要做到这点,页面要把列宽与列顺序也作为受控状态交给表格:
const [order, setOrder] = useState(() => readColumnOrder('customers'))
const [sizes, setSizes] = useState(() => readTablePrefs('customers').sizes)
const [visibility, setVisibility] = useState<VisibilityState>({})
<DataTable
columnVisibility={visibility} onColumnVisibilityChange={setVisibility}
columnOrder={order} onColumnOrderChange={setOrder}
columnSizes={sizes} onColumnSizesChange={setSizes}
/>
<StorageBox
snapshot={{ query, visibility, order, sizes }}
onRestore={(saved) => {
setQuery(saved.query)
setVisibility(saved.visibility)
setOrder(saved.order ?? order) // 旧预设没有这两个字段时保持当前值
setSizes(saved.sizes ?? {})
}}
/>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
只把列显隐放进快照的话,用户改了列宽 / 列顺序再保存,恢复时那两项不会回来——这正是"预设看起来没生效"的常见原因。
命名规则:
- 回车不会保存:输入框里按 Enter 不做任何事,必须点按钮,避免想改备注时手滑存下一条。
- 名称唯一:重名时不会静默覆盖,输入框下方给出提示,主按钮变成「覆盖」,点它才覆盖;重命名撞名时「确定」直接禁用。
- 保存上限 20 条(
upsertPreset的limit),满了会挤掉最旧的一条。 - 摘要可读:列表里那行说明默认用
describePresetValue()拼成关键字: 云和 · 状态: 正常/待处理,不再是一串 JSON;需要业务自己的说法时给presets.summary传函数。
视图预设(sample.viewPreset)与筛选预设在同一个组件上,行为一致。 筛选预设保存的是当前筛选栏里的条件(关键字 / 状态 / 日期区间),不必先点「搜索」。