@kne/table-page
v0.1.38
Published
A React table page component based on Ant Design, supporting column config, filter, sort and batch operations.
Downloads
782
Readme
table-page
描述
A React table page component based on Ant Design, supporting column config, filter, sort and batch operations.
安装
npm i --save @kne/table-page概述
@kne/table-page 是一个基于 React 和 Ant Design 的表格页面组件库,提供开箱即用的数据表格解决方案。组件库围绕表格的常见业务场景,封装了数据加载、分页、排序、行选择、列配置、筛选搜索、批量操作等能力,帮助开发者快速构建功能完善的表格管理页面。
核心组件
TablePage
表格页面主组件,基于 @kne/react-fetch 封装数据请求与分页逻辑。内置两种渲染模式:
Table模式(默认):基于 antdTable,支持列宽拖动、字段显示/隐藏、分组表头、粘性表头等TableView模式:基于@kne/table-viewCSS Grid,适合移动端或卡片式表格场景
通过 loader 或 url 配置数据源,通过 dataFormat 适配不同的接口数据结构。分页器渲染在表格外侧,翻页默认采用 reload 方式(不显示全屏 loading)。在 pagination 上同时传入 searchParams 与 setSearchParams 可将当前页、每页条数同步到 URL(参数名复用 currentName / pageSizeName)。
同时内置了顶部工具栏(TableToolbar),整合筛选、搜索、Tab 分类、批量操作等能力:
- 筛选(filter):基于
@kne/react-filter的FilterLines,支持多行多字段组合筛选,筛选值变化时自动reload并回到第 1 页;可通过filter.searchParamsValue(与useSearchParamsValue同参)从 URL 平铺参数合并初始筛选并保证首包请求带上对应参数 - 分页 URL 状态:
pagination.searchParams+pagination.setSearchParams开启后,当前页与每页条数与 URL 双向同步(筛选重置页码时也会写回;loadMore不写回) - 搜索(search):基于
@kne/react-filter的SearchInput,支持关键词搜索与防抖自动提交,与筛选器共享筛选值状态;移动端开启renderMobile时,SearchInput 与下方卡片列表之间保留间距 - 操作按钮(buttonGroup):透传
@kne/button-group参数;桌面端显示在 SearchInput 右侧(small、至少 1 个外露),移动端与筛选同行两端对齐(筛选靠左、按钮组靠右,small、外露 1 个),批量操作显示在「全选/排序」行的排序后面 - Tab(tab):顶部分类切换,默认「全部」,选中值写入 filter value 参与请求但不在已选标签中重复展示;桌面端在表格边框外侧,移动端显示在 SearchInput 下方;可通过
tabProps透传 antd Tabs 属性 - 批量操作(batchActions):配合
rowSelection和useSelectedRow,提供下拉菜单形式的批量操作(如批量导出、批量通知),未选中时自动禁用 - PC 卡片视图(renderCard):取值同
renderMobile(true / function / preset 字符串),生效后 PC 端可切换表格/卡片(状态按name存 localStorage);forceCard强制卡片并不显示切换按钮;卡片模式下外框透明、默认触底下拉加载;移动端忽略 - 树形数据:透传
dataType(tree/treeList)、expandedKeys、onLoadChildren、rowSelection.checkRelation等给内部 Table / TableView - 已选筛选值展示:工具栏下方展示当前生效的筛选条件标签,支持快速清除
Table
基于 antd Table 的表格组件,与 TableView 共享相同的 columns、rowSelection 等 API。额外提供以下增强能力:
- 列宽拖动:悬停表头列右侧拖动手柄可调整列宽,支持
min/max限制 - 列配置面板:通过表头最后一列的设置图标,可显示/隐藏字段、拖拽排序
- 配置持久化:设置
name后,列宽和显示状态自动保存到 localStorage - 分组表头:通过
groupHeader配置实现多级表头结构 - 浮动横向滚动条:当表格宽度超出容器时,底部自动显示横向滚动条(通过
horizontalScroller控制) - 树形数据:支持与
TableView相同的dataType(tree/treeList),映射为 antd Table 树形展开,含懒加载与checkRelation勾选关联
TableView
基于 @kne/table-view 的 CSS Grid 表格视图组件。相比于 Table,它更轻量灵活,适合需要自定义渲染、移动端卡片展示的场景。支持:
- 基于 24 栅格的列宽分配(
span属性) - CSS Grid 自动布局,内容超出时自动撑开
- 行选择(checkbox 多选 / radio 单选)
- 行点击事件
- 通过
render属性自定义渲染,可拆分表头和表体 - 树形数据(
dataType: 'tree' | 'treeList'):展开收起、懒加载、移动端面包屑卡片
核心 Hooks
useSelectedRow
行选择 Hook,支持多选(checkbox)和单选(radio)两种模式。提供:
getRowSelection(dataSource)生成标准rowSelection配置,可直接传入Table或TableViewselectedRowKeys和selectedRows追踪选中状态setSelectedRowKeys(keys, dataSource)从 key 列表反查完整行数据clearSelectedRows()一键清空选择
适用于批量操作(批量删除、批量导出等)和单选场景(详情查看、关联选择等)。
useSort
排序 Hook,配合 Table/TableView 的 sortRender 实现表头排序交互。支持:
- 单列排序(
sort: true或sort: { single: true }):切换列时自动清除其他列的排序 - 多列排序(
sort: { single: false }):允许多列同时排序 - 排序状态循环切换:DESC → ASC → 取消
sortDataSource(dataSource, sort, columns)工具函数,支持本地排序(包含中文排序)
渲染逻辑
双模式:Table / TableView
TablePage 通过 type 切换底层表格实现:
| 模式 | 底层 | 适用场景 |
|------|------|----------|
| Table(默认) | antd Table | 桌面端完整表格能力:列宽拖动、列配置、分组表头、粘性表头、总结栏 |
| TableView | @kne/table-view CSS Grid | 轻量栅格表格、移动端、卡片式展示 |
两种模式共享 columns、rowSelection、sortRender、renderType 等 API,列渲染管线统一来自 @kne/table-view。
列单元格渲染管线
无论 Table 还是 TableView,单元格内容均走同一套流程(Table 在 antd columns[].render 内调用):
resolveColumns:解析renderType,注入内置render与width/min/max/ellipsiscomputeColumnsValue:getValueOf取值 →format格式化 → 按display/ 空值规则过滤computeDisplay:空值占位;非空调用列renderrenderCellContent:按ellipsis/cellFullWidth输出最终节点
列渲染优先级:column.render(最高)> renderType 内置渲染 > 原始格式化值。render 与 renderType 共存时,后者仅提供列宽等布局维度。
桌面端:antd Table
Table 将解析后的列映射为 antd columns,在 render 回调中复用上述管线。额外能力:
useTableConfig管理列宽拖动、显示/隐藏、localStorage 持久化useGroupHeader生成分组表头rowSelection映射为 antd 行选择(含allowSelectedAll全选;树形下支持checkRelation)dataType为tree/treeList时归一化为嵌套数据并接入 antdexpandablerender={({ header, renderBody }) => ...}可自定义表格外层,renderBody()返回完整 antd Table
移动端:renderMobile
Table 与 TableView 均支持 renderMobile,移动端判断使用 useIsMobile()(768px)。激活后 Table 不再渲染 antd Table,委托 TableView 处理:
| renderMobile 值 | 行为 |
|-------------------|------|
| true | 默认卡片 List:每行一张卡片,字段列「标题 + 内容」纵向排列,options 操作列靠右(紧凑「⋯」入口) |
| function | 完全接管移动端渲染;回调含 renderToolbar / getSelectionProps / getRowKey 等,见 TableView API |
| string | 从 preset({ renderMobile: { [name]: fn } }) 查找;未注册则视为未开启,回退普通表格 |
桌面端不受 renderMobile 影响:Table 仍走 antd Table,TableView 仍走 CSS Grid 或 render。
列渲染类型系统
通过 renderType 属性,可以用声明式的方式定义列的渲染样式,无需手写 render 函数。内置以下 render 类型:
| 类型 | 说明 |
|------|------|
| main | 主要内容列,自动省略号,较大宽度 |
| options | 操作列,铺满单元格 |
| enum | 枚举值渲染,自动映射 color/text |
| tag | 标签渲染,单个 Tag 组件 |
| status | 状态渲染,antd Badge 组件 |
| tagList | 标签列表渲染,多个 Tag 组件 |
| amount | 金额列,右对齐,自动省略号 |
| list | 列表渲染,自动省略号 |
| description | 描述文本,大宽度,自动省略号 |
支持尺寸修饰符:
short:缩小宽度(约 120px)small:最小宽度(约 100px)large:放大宽度(约 300px)
例如 renderType: "enum-small" 表示枚举值 + 小尺寸列。维度(width、min、max、ellipsis)可通过 globalParams.renderTypeSize 全局定制。
默认导出 getTagColor、renderTagItem、renderTagList、getStatusType、renderStatusItem 工具函数,用于 Tag / Status 相关渲染。
其他导出
| 导出项 | 说明 |
|--------|------|
| tableLocalApis | 基于 localStorage 的列配置存取 API,可替换为服务端存储 |
| useTableConfig | 列配置 Hook,提供列宽、显示状态的管理能力 |
| preset / globalParams | 全局参数预设,用于设置 renderType 映射和标签颜色等全局配置 |
| Ellipsis | 超出省略组件,基于 antd Typography |
| label | 标签组件 |
| sortDataSource | 客户端排序工具函数 |
使用场景
- 后台管理系统:订单管理、用户列表、商品管理等 CRUD 页面
- 数据报表:配合排序、分页、总结栏展示统计数据
- 列表配置页:需要用户自定义列宽、显示字段的表格场景
- 移动端适配:
renderMobile启用卡片 List,或TableView模式做栅格式展示
示例
示例样式
@use '~@kne/responsive-utils/scss' as resp;
// 手机预览下给示例内容左右留白,便于观察表格/卡片边框
@include resp.mobile-container {
.example-driver-runner {
padding-inline: 16px;
box-sizing: border-box;
}
}示例代码
- TablePage
- 表格页面组件,基于 @kne/react-fetch 实现数据加载与分页,支持 sticky 固定表头、useSort 服务端排序、renderMobile 移动端卡片、renderCard PC 卡片视图切换、pagination.forceLoadMore(PC Table 触底下拉)、tab 分类切换、列配置、总结栏、树形 dataType(含筛选/批量/操作列/卡片切换/懒加载);空数据(total 为 0)时不显示分页器。文末对照仅 SearchInput 与 Search+全选/排序条两种移动端间距(开关可拆开全选/排序)
- _TablePage(@kne/current-lib_table-page)[import * as _TablePage from "@kne/table-page"],(@kne/current-lib_table-page/dist/index.css),antd(antd),_ReactFilter(@kne/react-filter)[import * as _ReactFilter from "@kne/react-filter"],(@kne/react-filter/dist/index.css)
const { default: TablePage, Table, mergeTreeChildren } = _TablePage;
const { fields } = _ReactFilter;
const { SuperSelectFilterItem } = fields;
const { Table: AntTable, Col, Flex, Row, Tag, Button, Space, Switch, Radio, message } = antd;
const { useMemo, useState, useRef } = React;
const TOTAL = 156;
const range = (start, end) => Array.from({ length: end - start }, (_, i) => start + i);
const surnames = ['张', '李', '王', '刘', '陈'];
const givenNames = ['伟', '强', '敏', '磊', '杰', '婷', '娜', '静', '丽', '娟'];
const departments = ['技术研发部', '产品设计部', '市场营销部', '人力资源部', '财务部'];
const positions = ['工程师', '高级工程师', '经理', '总监', '专员'];
const educations = ['本科', '硕士', '博士', '大专'];
const performances = ['A', 'B', 'C', 'S'];
const statusMap = {
active: { type: 'success', text: '在职' },
vacation: { type: 'warning', text: '休假' },
resigned: { type: 'default', text: '离职' },
probation: { type: 'processing', text: '试用期' }
};
const perfMap = {
S: { type: 'success', text: 'S' },
A: { type: 'processing', text: 'A' },
B: { type: 'warning', text: 'B' },
C: { type: 'error', text: 'C' }
};
const departmentOptions = departments.map(item => ({ value: item, label: item }));
const statusOptions = Object.entries(statusMap).map(([value, { text }]) => ({ value, label: text }));
const positionOptions = positions.map(item => ({ value: item, label: item }));
const educationOptions = educations.map(item => ({ value: item, label: item }));
const performanceOptions = performances.map(item => ({ value: item, label: item }));
const workYearsOptions = [
{ value: '1', label: '1年以内' },
{ value: '1-3', label: '1-3年' },
{ value: '3-5', label: '3-5年' },
{ value: '5+', label: '5年以上' }
];
const salaryOptions = [
{ value: '15-20', label: '15-20K' },
{ value: '20-30', label: '20-30K' },
{ value: '30-50', label: '30-50K' },
{ value: '50+', label: '50K以上' }
];
const locationOptions = [
{ value: 'beijing', label: '北京' },
{ value: 'shanghai', label: '上海' },
{ value: 'guangzhou', label: '广州' },
{ value: 'shenzhen', label: '深圳' },
{ value: 'hangzhou', label: '杭州' }
];
const contractOptions = [
{ value: 'fulltime', label: '全职' },
{ value: 'parttime', label: '兼职' },
{ value: 'intern', label: '实习' },
{ value: 'outsource', label: '外包' }
];
const genderOptions = [
{ value: 'male', label: '男' },
{ value: 'female', label: '女' }
];
const levelOptions = [
{ value: 'p5', label: 'P5' },
{ value: 'p6', label: 'P6' },
{ value: 'p7', label: 'P7' },
{ value: 'p8', label: 'P8' },
{ value: 'p9', label: 'P9' }
];
const buildEmployee = index => {
const statusKeys = ['active', 'vacation', 'resigned', 'probation'];
return {
id: `EMP${String(index + 1).padStart(4, '0')}`,
employeeNo: `EMP-2024-${String(index + 1).padStart(4, '0')}`,
name: `${surnames[index % surnames.length]}${givenNames[index % givenNames.length]}`,
department: departments[index % departments.length],
position: positions[index % positions.length],
status: statusKeys[index % statusKeys.length],
email: `employee${index + 1}@company.com`,
phone: `138${String(index).padStart(8, '0')}`,
joinDate: `2023-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 28) + 1).padStart(2, '0')}`,
workYears: Math.floor(index / 12) + 1,
salary: `${15 + (index % 20)}K-${20 + (index % 20)}K`,
education: educations[index % educations.length],
performance: performances[index % performances.length]
};
};
const columns = [
{
name: 'employeeNo',
title: '工号',
width: 180,
min: 120,
max: 240,
fixed: 'left',
sort: { single: true },
renderType: 'main',
primary: true,
onClick: ({ item, colItem }) => {
message.info(`查看员工:${colItem.name}(${item})`);
}
},
{
name: 'name',
title: '姓名',
width: 100,
min: 80,
max: 160,
sort: true,
renderType: 'main',
onClick: ({ item, colItem }) =>
new Promise(resolve => {
const hide = message.loading(`正在加载 ${item} 的详情…`, 0);
setTimeout(() => {
hide();
message.success(`${colItem.department} · ${colItem.position}`);
resolve();
}, 600);
})
},
{ name: 'department', title: '部门', width: 150, min: 120, max: 240, sort: true },
{ name: 'position', title: '职位', width: 120, min: 100, max: 200 },
{
name: 'status',
title: '状态',
renderType: 'status',
getValueOf: item => statusMap[item.status] || { type: 'default', text: item.status }
},
{ name: 'performance', title: '绩效', width: 80, min: 70, max: 120, renderType: 'tag', getValueOf: item => perfMap[item.performance] || { type: 'default', text: item.performance } },
{ name: 'phone', title: '手机号', width: 140, min: 120, max: 180, render: value => value.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3') },
{ name: 'email', title: '邮箱', width: 200, min: 160, max: 320, ellipsis: true },
{ name: 'joinDate', title: '入职日期', width: 120, min: 100, max: 160, format: 'date', sort: true },
{ name: 'workYears', title: '工龄', width: 90, min: 70, max: 120, sort: true, render: value => `${value}年` },
{ name: 'salary', title: '薪资范围', width: 120, min: 100, max: 180, hidden: true },
{ name: 'education', title: '学历', width: 90, min: 70, max: 120, hidden: true },
{
name: 'options',
title: '操作',
renderType: 'options',
fixed: 'right',
width: 160,
min: 120,
max: 200,
getValueOf: item => {
const actions = [
{ children: '查看', onClick: () => message.info(`查看 ${item.name}`) },
{ children: '编辑', onClick: () => message.info(`编辑 ${item.name}`) }
];
if (item.status !== 'resigned') {
actions.push({
children: '离职办理',
onClick: () => message.warning(`办理离职 ${item.name}`)
});
}
return actions;
}
}
];
const sortFieldLabels = {
employeeNo: '工号',
name: '姓名',
department: '部门',
joinDate: '入职日期',
workYears: '工龄'
};
const normalizeFilterValue = value => {
if (value == null) {
return value;
}
return Array.isArray(value) ? value[0] : value;
};
const applyFilters = (employees, data, requestParams) => {
const params = Object.assign({}, requestParams?.data, data);
let result = employees;
if (params.keyword) {
const keyword = String(params.keyword).toLowerCase();
result = result.filter(item => item.employeeNo.toLowerCase().includes(keyword) || item.name.includes(params.keyword));
}
const department = normalizeFilterValue(params.department);
if (department) {
result = result.filter(item => item.department === department);
}
const status = normalizeFilterValue(params.status);
if (status) {
result = result.filter(item => item.status === status);
}
const position = normalizeFilterValue(params.position);
if (position) {
result = result.filter(item => item.position === position);
}
return result;
};
const SortState = ({ sort }) => (
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 8, fontSize: 13 }}>
当前排序:
{sort.length ? (
sort.map(item => (
<Tag key={item.name} color="blue" style={{ marginLeft: 8 }}>
{sortFieldLabels[item.name] || item.name} {item.sort}
</Tag>
))
) : (
<span style={{ marginLeft: 8, color: '#999' }}>无</span>
)}
</div>
);
const TIP_TAG_STYLE = { marginRight: 8 };
const Tips = () => (
<div style={{ color: '#666', fontSize: 13, lineHeight: 1.8 }}>
<div>
<Tag style={TIP_TAG_STYLE} color="geekblue">渲染组件</Tag>
通过 <code>renderType</code> 在 <code>Table</code>(antd)与 <code>TableView</code>(纯 CSS Grid)间切换;点下方切换可对比表头、选择列与骨架屏表现。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="blue">数据加载</Tag>
通过 <code>loader</code> 模拟分页接口,请求参数为 <code>data.currentPage</code>、<code>data.perPage</code>。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="processing">蒙层 Loading</Tag>
<code>reload</code> 切换数据时保留旧表格,叠加半透明蒙层与 spinner,加载完成后再替换为新数据;<code>refresh</code> 会卸载表格并显示全屏 loading。点下方「演示蒙层 Loading」可观察效果。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="green">分页</Tag>
分页器渲染在表格外侧,翻页时以 <code>reload</code> 方式请求;<code>pageSize</code> 会持久化到 localStorage;当 <code>total</code> 为 0(无数据)时不显示分页器。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="gold">筛选</Tag>
顶部工具栏集成 <code>filter</code>、<code>search</code>、<code>batchActions</code>、<code>buttonGroup</code>;筛选变化自动 <code>reload</code> 并回到第 1 页;移动端 <code>buttonGroup</code> 与筛选同行两端对齐(筛选靠左、按钮组靠右),批量操作显示在「全选/排序」行的排序后面。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="lime">Tab</Tag>
通过 <code>tab</code> 配置顶部分类切换(默认「全部」),选中值写入 filter value 参与请求,但不在已选筛选标签中重复展示;桌面端在表格边框外,移动端在 SearchInput 下方;可用 <code>tabProps</code> 透传 Tabs 属性(如 <code>tabBarExtraContent</code>)。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="orange">列配置</Tag>
设置 <code>name</code> 开启列宽拖动与显示/隐藏,「薪资范围」「学历」默认隐藏;状态列使用 <code>renderType="status"</code>,绩效列使用 <code>renderType="tag"</code>,操作列使用 <code>renderType="options"</code> 且 <code>fixed="right"</code>。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="cyan">排序</Tag>
配合 <code>Table.useSort</code> 与 <code>sortRender</code>、<code>mobileSortToolbar</code>,在 <code>onSortChange</code> 中调用 <code>reload</code> 传排序参数,与翻页一样不闪烁。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="volcano">移动端</Tag>
设置 <code>renderMobile</code> 后,手机预览下启用卡片 List(含全选、排序工具栏);桌面端仍为 antd Table。文末对照「仅 SearchInput」与「Search + 全选/排序条」两种间距(情况 B 可用开关拆开全选/排序)。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="geekblue">固定表头</Tag>
设置 <code>sticky</code> 与 <code>scroll.y</code>,表体在固定高度内滚动时表头保持可见;横向滚动配合 <code>scroll.x</code>。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="magenta">单元格点击</Tag>
列配置 <code>onClick</code>(配合 <code>renderType="main"</code>、<code>primary</code> / <code>hover</code>),仅可点击单元格 hover 时显示手型;工号列同步演示异步点击 loading。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="purple">总结栏</Tag>
<code>summary</code> 回调可拿到 <code>data</code>、<code>requestParams</code> 等 fetch 上下文。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="red">PC 卡片</Tag>
传入 <code>renderCard</code>(签名同 <code>renderMobile</code>)后,工具栏 <code>buttonGroup</code> 前出现表格/卡片切换按钮,状态按 <code>name</code> 持久化到 localStorage;卡片模式下外框透明、默认触底下拉加载(<code>pagination.forcePagination</code> 可改回分页);<code>forceCard</code> 强制卡片并隐藏切换按钮;移动端忽略。
</div>
<div>
<Tag style={TIP_TAG_STYLE} color="lime">Table 下拉加载</Tag>
PC Table 默认底部分页;设置 <code>pagination.forceLoadMore</code> 可在不切换卡片的情况下触底下拉(与 <code>forcePagination</code> 同时为 true 时以后者为准)。开启后勿再设 Table <code>scroll.y</code>。
</div>
</div>
);
const EmployeeCard = ({ item }) => (
<div
style={{
boxSizing: 'border-box',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 16,
background: '#fff'
}}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 8 }}>
<strong>{item.name}</strong>
<Tag color={statusMap[item.status]?.type}>{statusMap[item.status]?.text || item.status}</Tag>
</Flex>
<Flex align="center" gap={8} wrap style={{ marginBottom: 4, fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>
<span>{item.department}</span>
<span style={{ color: 'rgba(0,0,0,0.25)' }}>·</span>
<span>{item.position}</span>
</Flex>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>
{item.employeeNo} · 入职 {item.joinDate} · 薪资 {item.salary}
</div>
<Flex justify="flex-end" gap={4} style={{ marginTop: 12, paddingTop: 8, borderTop: '1px solid #f0f0f0' }}>
<Button type="link" size="small" onClick={() => message.info(`查看 ${item.name}`)}>
查看
</Button>
<Button type="link" size="small" onClick={() => message.info(`编辑 ${item.name}`)}>
编辑
</Button>
{item.status !== 'resigned' && (
<Button type="link" size="small" danger onClick={() => message.warning(`办理离职 ${item.name}`)}>
离职办理
</Button>
)}
</Flex>
</div>
);
const renderEmployeeCard = ({ dataSource = [] }) => (
<Row gutter={[12, 12]}>
{dataSource.map(item => (
<Col span={12} key={item.id}>
<EmployeeCard item={item} />
</Col>
))}
</Row>
);
const BaseExample = () => {
const tableRef = React.useRef();
const [empty, setEmpty] = useState(false);
const [cardForcePagination, setCardForcePagination] = useState(false);
const [tableForceLoadMore, setTableForceLoadMore] = useState(false);
const [renderType, setRenderType] = useState('Table');
const emptyRef = React.useRef(false);
const slowReloadRef = React.useRef(false);
const allEmployees = useMemo(() => range(0, TOTAL).map(buildEmployee), []);
const { selectedRows, getRowSelection } = Table.useSelectedRow({ rowKey: 'id' });
const { sort, sortRender, mobileSortToolbar } = Table.useSort({
defaultSort: [{ name: 'joinDate', sort: 'DESC' }],
onSortChange: newSort => {
tableRef.current?.reload({
data: { currentPage: 1, sort: newSort }
});
}
});
return (
<Flex vertical gap={16}>
<Tips />
<SortState sort={sort} />
<Space wrap>
<Flex align="center" gap={8}>
<span>表格组件:</span>
<Radio.Group
optionType="button"
buttonStyle="solid"
size="small"
value={renderType}
onChange={e => setRenderType(e.target.value)}
options={[
{ label: 'Table', value: 'Table' },
{ label: 'TableView', value: 'TableView' }
]}
/>
</Flex>
<Flex align="center" gap={8}>
<Switch
checked={empty}
onChange={checked => {
emptyRef.current = checked;
setEmpty(checked);
tableRef.current?.reload({ data: { currentPage: 1 } });
}}
/>
<span>{empty ? '空数据(无分页)' : '有数据(显示分页)'}</span>
</Flex>
<Button
type="primary"
onClick={() => {
slowReloadRef.current = true;
Promise.resolve(
tableRef.current?.reload({
data: { currentPage: 1 }
})
).finally(() => {
slowReloadRef.current = false;
});
}}
>
演示蒙层 Loading
</Button>
<Button
onClick={() => {
tableRef.current?.reload({
data: { currentPage: 1 }
});
}}
>
重新加载(回到第 1 页)
</Button>
<Button
onClick={() => {
tableRef.current?.refresh();
}}
>
刷新当前页(全屏 loading)
</Button>
<Flex align="center" gap={8}>
<span>卡片模式数据加载:</span>
<Switch checkedChildren="分页" unCheckedChildren="下拉加载" checked={cardForcePagination} onChange={setCardForcePagination} />
</Flex>
<Flex align="center" gap={8}>
<span>Table 数据加载:</span>
<Switch
checkedChildren="下拉加载"
unCheckedChildren="分页"
checked={tableForceLoadMore}
onChange={setTableForceLoadMore}
/>
</Flex>
</Space>
<TablePage
ref={tableRef}
name="demo-employee-table"
renderType={renderType}
sticky={renderType === 'Table' && !tableForceLoadMore}
scroll={renderType === 'Table' ? (tableForceLoadMore ? { x: 1600 } : { x: 1600, y: 400 }) : undefined}
size="large"
renderMobile
renderCard={renderEmployeeCard}
sortRender={sortRender}
mobileSortToolbar={mobileSortToolbar}
rowSelection={getRowSelection(allEmployees)}
selectedRows={selectedRows}
search={{ name: 'keyword', label: '关键词', placeholder: '搜索工号/姓名', style: { width: 180 } }}
buttonGroup={{
list: [
{
type: 'primary',
children: '新建员工',
onClick: () => message.success('打开新建员工')
},
{
children: '导出全部',
onClick: () => message.info('正在导出全部员工')
}
]
}}
tab={{
name: 'position',
label: '职位',
list: positionOptions
}}
tabProps={{
tabBarExtraContent: (
<Button type="link" size="small" onClick={() => message.info('新增职位')}>
新增职位
</Button>
)
}}
filter={{
// 扁平 list:桌面端按容器宽度自动收起到「更多」
list: [
{
type: SuperSelectFilterItem,
props: { name: 'department', label: '部门', single: true, options: departmentOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'status', label: '状态', single: true, options: statusOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'education', label: '学历', single: true, options: educationOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'performance', label: '绩效', single: true, options: performanceOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'workYears', label: '工龄', single: true, options: workYearsOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'salary', label: '薪资', single: true, options: salaryOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'location', label: '工作城市', single: true, options: locationOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'contract', label: '合同类型', single: true, options: contractOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'gender', label: '性别', single: true, options: genderOptions }
},
{
type: SuperSelectFilterItem,
props: { name: 'level', label: '职级', single: true, options: levelOptions }
}
]
}}
batchActions={[
{
key: 'export',
label: '批量导出',
onClick: ({ selectedRowKeys }) => {
message.info(`正在导出 ${selectedRowKeys.length} 名员工`);
}
},
{
key: 'notify',
label: '批量通知',
onClick: ({ selectedRowKeys }) => {
message.success(`已通知 ${selectedRowKeys.length} 名员工`);
}
}
]}
pagination={{
open: true,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
forcePagination: cardForcePagination,
forceLoadMore: tableForceLoadMore
}}
dataFormat={data => ({
list: data.pageData,
total: data.totalCount,
data
})}
loader={({ data, requestParams }) => {
const delay = slowReloadRef.current ? 1600 : 400;
if (emptyRef.current) {
return new Promise(resolve => {
setTimeout(() => resolve({ pageData: [], totalCount: 0 }), delay);
});
}
const currentPage = Number(data?.currentPage ?? requestParams?.data?.currentPage) || 1;
const perPage = Number(data?.perPage ?? requestParams?.data?.perPage) || 20;
const sortParams = data?.sort ?? requestParams?.data?.sort ?? [{ name: 'joinDate', sort: 'DESC' }];
const filteredEmployees = applyFilters(allEmployees, data, requestParams);
const sortedEmployees = sortParams.length ? Table.sortDataSource(filteredEmployees, sortParams, columns) : filteredEmployees;
const startIndex = (currentPage - 1) * perPage;
return new Promise(resolve => {
setTimeout(() => {
resolve({
pageData: sortedEmployees.slice(startIndex, startIndex + perPage),
totalCount: filteredEmployees.length
});
}, delay);
});
}}
columns={columns}
summary={({ pageData, data }) => {
const totalCount = data?.totalCount || 0;
return (
<AntTable.Summary fixed>
<AntTable.Summary.Row>
<AntTable.Summary.Cell index={0} colSpan={5}>
<strong>当前页统计</strong>
</AntTable.Summary.Cell>
<AntTable.Summary.Cell index={5}>
<strong>{pageData.length} 人</strong>
</AntTable.Summary.Cell>
<AntTable.Summary.Cell index={6} colSpan={7}>
<strong>总员工数: {totalCount} 人</strong>
</AntTable.Summary.Cell>
</AntTable.Summary.Row>
</AntTable.Summary>
);
}}
/>
</Flex>
);
};
const sharedGroups = [
{
id: 1,
name: '华北销售共享组',
description: '覆盖华北区销售线索与客户跟进数据,成员可按只读或读写权限访问。',
members: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }],
dataSources: [{ id: 'd1' }, { id: 'd2' }],
sharedModules: [{ id: 'm1' }]
},
{
id: 2,
name: '产品研发协作组',
description: '产品与研发跨部门协作,共享需求池与缺陷跟踪模块。',
members: [{ id: 'u4' }, { id: 'u5' }],
dataSources: [{ id: 'd3' }],
sharedModules: [{ id: 'm2' }, { id: 'm3' }]
},
{
id: 3,
name: '财务审计只读组',
description: '审计人员只读访问财务相关模块与导出记录。',
members: [{ id: 'u6' }],
dataSources: [{ id: 'd4' }, { id: 'd5' }, { id: 'd6' }],
sharedModules: [{ id: 'm4' }]
}
];
const sharedGroupColumns = [
{ name: 'id', title: 'ID', width: 80, renderType: 'small' },
{ name: 'name', title: '共享组名称', width: 180, renderType: 'main', sort: true },
{ name: 'description', title: '描述', width: 320, renderType: 'description', ellipsis: true },
{
name: 'options',
title: '操作',
width: 140,
renderType: 'options',
getValueOf: item => [
{ children: '编辑', type: 'link', onClick: () => console.log('edit', item.id) },
{ children: '删除', type: 'link', isDelete: true, message: `确定删除 ${item.name} 吗?`, onClick: () => console.log('remove', item.id) }
]
}
];
/** 移动端间距:仅 Search vs Search + 全选/排序条(双情况对照,开关可拆开全选/排序) */
const SearchSelectSortMobileExample = () => {
const searchOnlyRef = useRef();
const withToolbarRef = useRef();
const [enableSelectAll, setEnableSelectAll] = useState(true);
const [enableSort, setEnableSort] = useState(true);
const { selectedRows, getRowSelection, clearSelectedRows } = Table.useSelectedRow({ rowKey: 'id' });
const { sort, sortRender, mobileSortToolbar } = Table.useSort({
onSortChange: newSort => {
withToolbarRef.current?.reload({ data: { sort: newSort } });
}
});
const loadSharedGroups = ({ data, requestParams }, { applySort = false } = {}) => {
const keyword = String(data?.keyword ?? requestParams?.data?.keyword ?? '').trim().toLowerCase();
const sortParams = data?.sort ?? requestParams?.data?.sort ?? sort;
let list = sharedGroups;
if (keyword) {
list = list.filter(
item =>
String(item.name).toLowerCase().includes(keyword) ||
String(item.description).toLowerCase().includes(keyword)
);
}
const sorted =
applySort && sortParams?.length ? Table.sortDataSource(list, sortParams, sharedGroupColumns) : list;
return Promise.resolve({
pageData: sorted,
totalCount: sorted.length
});
};
const dataFormat = data => ({
list: data.pageData,
total: data.totalCount,
data
});
return (
<Flex vertical gap={24}>
<div style={{ color: '#666', fontSize: 13, lineHeight: 1.7 }}>
<Tag color="blue" style={{ marginRight: 8 }}>
移动端 Search 间距
</Tag>
对照两种布局(请切手机预览):
<strong>仅 SearchInput</strong> → 与卡片列表有间距;
<strong>SearchInput + 全选/排序条</strong> → Search 与下方「全选 | 排序」行有间距。无 filter / tab / batch。
</div>
<Flex vertical gap={12} style={{ padding: 16, background: '#fafafa', borderRadius: 8 }}>
<div style={{ fontWeight: 600, color: 'rgba(0,0,0,0.88)' }}>情况 A:只有 SearchInput</div>
<div style={{ color: '#666', fontSize: 13, lineHeight: 1.7 }}>
只配 <code>search</code> + <code>renderMobile</code>,不配 <code>rowSelection</code> / <code>mobileSortToolbar</code>。
SearchInput 正下方应直接是卡片列表,中间保留间距。
</div>
<TablePage
ref={searchOnlyRef}
name="demo-mobile-gap-search-only"
pagination={{ open: false }}
search={{ name: 'keyword', label: '关键词', placeholder: '搜索共享组名称' }}
columns={sharedGroupColumns}
renderMobile
dataFormat={dataFormat}
loader={ctx => loadSharedGroups(ctx, { applySort: false })}
/>
</Flex>
<Flex vertical gap={12} style={{ padding: 16, background: '#fafafa', borderRadius: 8 }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
<div style={{ fontWeight: 600, color: 'rgba(0,0,0,0.88)' }}>情况 B:SearchInput + 全选 / 排序条</div>
<Space wrap>
<Flex align="center" gap={8}>
<Switch
checked={enableSelectAll}
onChange={checked => {
setEnableSelectAll(checked);
if (!checked) {
clearSelectedRows();
}
}}
/>
<span>全选条</span>
</Flex>
<Flex align="center" gap={8}>
<Switch
checked={enableSort}
onChange={checked => {
setEnableSort(checked);
withToolbarRef.current?.reload({ data: { sort: checked ? sort : [] } });
}}
/>
<span>排序条</span>
</Flex>
</Space>
</Flex>
<div style={{ color: '#666', fontSize: 13, lineHeight: 1.7 }}>
默认同时开启全选与排序(工具栏左侧全选、右侧排序)。可用开关单独关掉某一项;两项都开时,SearchInput 与「全选 | 排序」行之间不可紧贴。
</div>
<TablePage
key={`mobile-gap-with-toolbar-${enableSelectAll}-${enableSort}`}
ref={withToolbarRef}
name="demo-mobile-gap-search-select-sort"
pagination={{ open: false }}
search={{ name: 'keyword', label: '关键词', placeholder: '搜索共享组名称' }}
columns={sharedGroupColumns}
renderMobile
sortRender={enableSort ? sortRender : undefined}
mobileSortToolbar={enableSort ? mobileSortToolbar : undefined}
rowSelection={
enableSelectAll
? getRowSelection(sharedGroups, { type: 'checkbox', allowSelectedAll: true })
: undefined
}
selectedRows={enableSelectAll ? selectedRows : undefined}
dataFormat={dataFormat}
loader={ctx => loadSharedGroups(ctx, { applySort: enableSort })}
/>
</Flex>
</Flex>
);
};
const orgStatusMap = {
active: { type: 'success', text: '启用' },
paused: { type: 'warning', text: '停用' }
};
const orgRegionOptions = [
{ value: 'east', label: '华东' },
{ value: 'north', label: '华北' },
{ value: 'south', label: '华南' }
];
const orgStatusOptions = Object.entries(orgStatusMap).map(([value, { text }]) => ({ value, label: text }));
const treeColumns = [
{ name: 'name', title: '名称', width: 200, renderType: 'main' },
{ name: 'code', title: '编码', width: 120 },
{ name: 'owner', title: '负责人', width: 100 },
{
name: 'status',
title: '状态',
width: 100,
renderType: 'status',
getValueOf: item => orgStatusMap[item.status] || { type: 'default', text: item.status }
},
{
name: 'options',
title: '操作',
width: 160,
renderType: 'options',
fixed: 'right',
getValueOf: item => [
{ children: '查看', onClick: () => message.info(`查看 ${item.name}`) },
{ children: '编辑', onClick: () => message.info(`编辑 ${item.name}`) },
{
children: '删除',
isDelete: true,
message: `确定删除 ${item.name} 吗?`,
onClick: () => message.warning(`已删除 ${item.name}`)
}
]
}
];
const orgTreeData = [
{
id: '1',
name: '华东区',
code: 'EAST',
owner: '张三',
region: 'east',
status: 'active',
children: [
{
id: '1-1',
name: '上海',
code: 'SH',
owner: '李四',
region: 'east',
status: 'active',
children: [
{ id: '1-1-1', name: '浦东分部', code: 'SH-PD', owner: '王五', region: 'east', status: 'active' },
{ id: '1-1-2', name: '徐汇分部', code: 'SH-XH', owner: '赵六', region: 'east', status: 'paused' }
]
},
{ id: '1-2', name: '杭州', code: 'HZ', owner: '钱七', region: 'east', status: 'active' }
]
},
{
id: '2',
name: '华北区',
code: 'NORTH',
owner: '孙八',
region: 'north',
status: 'active',
children: [{ id: '2-1', name: '北京', code: 'BJ', owner: '周九', region: 'north', status: 'paused' }]
}
];
const orgTreeListData = [
{ id: '1', name: '华东区', code: 'EAST', owner: '张三', parentId: null, region: 'east', status: 'active' },
{ id: '1-1', name: '上海', code: 'SH', owner: '李四', parentId: '1', region: 'east', status: 'active' },
{ id: '1-1-1', name: '浦东分部', code: 'SH-PD', owner: '王五', parentId: '1-1', region: 'east', status: 'active' },
{ id: '1-1-2', name: '徐汇分部', code: 'SH-XH', owner: '赵六', parentId: '1-1', region: 'east', status: 'paused' },
{ id: '1-2', name: '杭州', code: 'HZ', owner: '钱七', parentId: '1', region: 'east', status: 'active' },
{ id: '2', name: '华北区', code: 'NORTH', owner: '孙八', parentId: '', region: 'north', status: 'active' },
{ id: '2-1', name: '北京', code: 'BJ', owner: '周九', parentId: '2', region: 'north', status: 'paused' }
];
const lazyOrgRoot = [
{ id: 'org-1', name: '集团总部', code: 'HQ', owner: '张三', parentId: null, region: 'east', status: 'active', hasChildren: true },
{ id: 'org-2', name: '分公司', code: 'BR', owner: '李四', parentId: null, region: 'south', status: 'active', hasChildren: true }
];
const lazyOrgChildrenMap = {
'org-1': [
{ id: 'org-1-1', name: '研发中心', code: 'RD', owner: '王五', region: 'east', status: 'active', hasChildren: true },
{ id: 'org-1-2', name: '市场部', code: 'MKT', owner: '赵六', region: 'east', status: 'paused', hasChildren: false }
],
'org-1-1': [
{ id: 'org-1-1-1', name: '前端组', code: 'FE', owner: '钱七', region: 'east', status: 'active', hasChildren: false },
{ id: 'org-1-1-2', name: '后端组', code: 'BE', owner: '孙八', region: 'east', status: 'active', hasChildren: false }
],
'org-2': [{ id: 'org-2-1', name: '华南办', code: 'SC', owner: '周九', region: 'south', status: 'active', hasChildren: false }]
};
const OrgTreeCard = ({ item }) => (
<div
style={{
boxSizing: 'border-box',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 16,
background: '#fff'
}}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 8 }}>
<strong>{item.name}</strong>
<Tag color={orgStatusMap[item.status]?.type}>{orgStatusMap[item.status]?.text || item.status}</Tag>
</Flex>
<Flex align="center" gap={8} wrap style={{ marginBottom: 4, fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>
<span>编码 {item.code}</span>
<span style={{ color: 'rgba(0,0,0,0.25)' }}>·</span>
<span>负责人 {item.owner}</span>
</Flex>
<Flex justify="flex-end" gap={4} style={{ marginTop: 12, paddingTop: 8, borderTop: '1px solid #f0f0f0' }}>
<Button type="link" size="small" onClick={() => message.info(`查看 ${item.name}`)}>
查看
</Button>
<Button type="link" size="small" onClick={() => message.info(`编辑 ${item.name}`)}>
编辑
</Button>
</Flex>
</div>
);
const renderOrgTreeCard = ({ displayDataSource, dataSource = [] }) => {
const list = displayDataSource || dataSource;
return (
<Row gutter={[12, 12]}>
{list.map(item => (
<Col span={12} key={item.id}>
<OrgTreeCard item={item} />
</Col>
))}
</Row>
);
};
const applyOrgTreeFilters = (list, data, requestParams) => {
const params = Object.assign({}, requestParams?.data, data);
const keyword = String(params.keyword || '').trim().toLowerCase();
const region = normalizeFilterValue(params.region);
const status = normalizeFilterValue(params.status);
return (list || []).filter(item => {
if (keyword) {
const hit = [item.name, item.code, item.owner].some(v => String(v || '').toLowerCase().includes(keyword));
if (!hit) return false;
}
if (region && item.region !== region) return false;
if (status && item.status !== status) return false;
return true;
});
};
/** TablePage 树形:筛选 / 搜索 / 批量 / 操作列 / 卡片切换 / 懒加载 */
const TreePageExample = () => {
const treePageRef = useRef(null);
const { selectedRowKeys, selectedRows, getRowSelection, clearSelectedRows } = Table.useSelectedRow({ rowKey: 'id' });
const [checkRelation, setCheckRelation] = useState('parent');
const lazyDataRef = useRef(lazyOrgRoot);
const lazyPageRef = useRef(null);
const handleLoadChildren = (item, { key }) =>
new Promise(resolve => {
setTimeout(() => {
lazyDataRef.current = mergeTreeChildren(lazyDataRef.current, lazyOrgChildrenMap[key] || [], {
parentKeyValue: key,
dataType: 'treeList',
rowKey: 'id',
parentKey: 'parentId',
hasChildrenKey: 'hasChildren'
});
lazyPageRef.current?.reload?.();
resolve();
}, 800);
});
return (
<Flex vertical gap={24}>
<div style={{ color: '#666', fontSize: 13, lineHeight: 1.7 }}>
<Tag color="blue" style={{ marginRight: 8 }}>
tree
</Tag>
树形 TablePage:支持 <code>dataType</code>、<code>checkRelation</code>,并演示筛选 / 搜索 / 批量操作 / 行操作 / <code>renderCard</code> 卡片切换 / 懒加载。
</div>
<div>
<div style={{ marginBottom: 8, color: '#666' }}>treeList + 工具栏(筛选 / 批量 / 操作 / 卡片切换)</div>
<Space style={{ marginBottom: 8 }} wrap>
<span style={{ color: '#666' }}>checkRelation:</span>
<Radio.Group
value={checkRelation}
optionType="button"
options={[
{ label: 'parent', value: 'parent' },
{ label: 'all', value: 'all' },
{ label: 'independent', value: 'independent' }
]}
onChange={e => {
setCheckRelation(e.target.value);
clearSelectedRows();
}}
/>
</Space>
<TablePage
ref={treePageRef}
name="demo-table-page-tree-list"
pagination={{ open: false }}
columns={treeColumns}
dataType="treeList"
defaultExpandedKeys
controllerOpen={false}
renderMobile
renderCard={renderOrgTreeCard}
rowSelection={getRowSelection(orgTreeListData, { type: 'checkbox', allowSelectedAll: true, checkRelation })}
selectedRows={selectedRows}
search={{ name: 'keyword', label: '关键词', placeholder: '搜索名称/编码/负责人', style: { width: 240 } }}
buttonGroup={{
list: [
{
type: 'primary',
children: '新建组织',
onClick: () => message.success('打开新建组织')
},
{
children: '导出组织树',
onClick: () => message.info('正在导出组织树')
}
]
}}
tab={{
name: 'region',
label: '大区',
list: orgRegionOptions
}}
filter={{
list: [
[
{
type: SuperSelectFilterItem,
props: { name: 'status', label: '状态', single: true, options: orgStatusOptions }
}
]
],
displayLine: 1
}}
batchActions={[
{
key: 'export',
label: '批量导出',
onClick: ({ selectedRowKeys: keys }) => {
message.info(`正在导出 ${keys.length} 个节点`);
}
},
{
key: 'enable',
label: '批量启用',
onClick: ({ selectedRowKeys: keys }) => {
message.success(`已启用 ${keys.length} 个节点`);
}
},
{
key: 'disable',
label: '批量停用',
danger: true,
onClick: ({ selectedRowKeys: keys }) => {
message.warning(`已停用 ${keys.length} 个节点`);
}
}
]}
loader={({ data, requestParams }) => {
const filtered = applyOrgTreeFilters(orgTreeListData, data, requestParams);
return Promise.resolve({
pageData: filtered,
totalCount: filtered.length
});
}}
/>
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>已选 key:{selectedRowKeys.join(', ') || '无'}</div>
</div>
<div>
<div style={{ marginBottom: 8, color: '#666' }}>dataType="tree"(嵌套 children)</div>
<TablePage
name="demo-table-page-tree"
pagination={{ open: false }}
columns={treeColumns}
dataType="tree"
defaultExpandedKeys
controllerOpen={false}
renderMobile
renderCard={renderOrgTreeCard}
buttonGroup={{
list: [{ type: 'primary', children: '新建', onClick: () => message.success('新建') }]
}}
loader={() =>
Promise.resolve({
pageData: orgTreeData,
totalCount: orgTreeData.length
})
}
/>
</div>
<div>
<div style={{ marginBottom: 8, color: '#666' }}>懒加载:hasChildren + onLoadChildren + mergeTreeChildren</div>
<TablePage
ref={lazyPageRef}
name="demo-table-page-tree-lazy"
pagination={{ open: false }}
columns={treeColumns}
dataType="treeList"
controllerOpen={false}
onLoadChildren={handleLoadChildren}
loader={() =>
Promise.resolve({
pageData: lazyDataRef.current,
totalCount: lazyDataRef.current.length
})
}
/>
</div>
</Flex>
);
};
render(
<Flex vertical gap={32}>
<BaseExample />
<TreePageExample />
<SearchSelectSortMobileExample />
</Flex>
);
- filter reload 一致性
- 验证已选筛选与 reload 查询条件一致:选筛选项后,批量操作 / ref 裸调 reload(含 reload({ data: { currentPage: 1 } }))仍必须带上当前筛选;页面展示最近一次请求参数便于核对
- _TablePage(@kne/current-lib_table-page)[import * as _TablePage from "@kne/table-page"],(@kne/current-lib_table-page/dist/index.css),antd(antd),_ReactFilter(@kne/react-filter)[import * as _ReactFilter from "@kne/react-filter"],(@kne/react-filter/dist/index.css)
const { default: TablePage, Table } = _TablePage;
const { fields } = _ReactFilter;
const { SuperSelectFilterItem } = fields;
const { Flex, Tag, Button, Space, message, Alert } = antd;
const { useMemo, useState, useRef, useCallback } = React;
const TOTAL = 80;
const departments = ['技术研发部', '产品设计部', '市场营销部', '人力资源部', '财务部'];
const statuses = [
{ value: 'active', label: '在职' },
{ value: 'vacation', label: '休假' },
{ value: 'probation', label: '试用期' },
{ value: 'resigned', label: '离职' }
];
const positions = ['工程师', '高级工程师', '经理', '总监', '专员'];
const statusMap = {
active: { type: 'success', text: '在职' },
vacation: { type: 'warning', text: '休假' },
probation: { type: 'processing', text: '试用期' },
resigned: { type: 'default', text: '离职' }
};
const normalizeFilterValue = value => {
if (value == null) {
return value;
}
return Array.isArray(value) ? value[0] : value;
};
const pickQueryFilters = data => {
const keys = ['department', 'status', 'position', 'keyword'];
const next = {};
keys.forEach(key => {
const value = normalizeFilterValue(data?.[key]);
if (value != null && value !== '') {
next[key] = value;
}
});
return next;
};
const buildEmployee = index => ({
id: index + 1,
name: `员工${index + 1}`,
department: departments[index % departments.length],
position: positions[index % positions.length],
status: statuses[index % statuses.length].value
});
const applyFilters = (list, data) => {
let result = list;
const department = normalizeFilterValue(data?.department);
if (department) {
result = result.filter(item => item.department === department);
}
const status = normalizeFilterValue(data?.status);
if (status) {
result = result.filter(item => item.status === status);
}
const position = normalizeFilterValue(data?.position);
if (position) {
result = result.filter(item => item.position === position);
}
if (data?.keyword) {
const keyword = String(data.keyword).toLowerCase();
result = result.filter(item => item.name.toLowerCase().includes(keyword));
}
return result;
};
/**
* 验证:已选筛选 UI 与 reload 查询条件必须一致。
*
* 复现步骤:
* 1. 选择「部门 / 状态」等筛选项,确认列表已过滤、上方已选标签有值
* 2. 勾选若干行,点「批量操作」完成业务后 reload
* 3. 期望:已选标签不变;列表仍按筛选过滤;「最近一次请求参数」仍含筛选字段
*/
const FilterReloadExample = () => {
const tableRef = useRef();
const employeesRef = useRef(Array.from({ length: TOTAL }, (_, i) => buildEmployee(i)));
const [dataVersion, setDataVersion] = useState(0);
const { selectedRowKeys, selectedRows, getRowSelection, clearSelectedRows } = Table.useSelectedRow({ rowKey: 'id' });
const [lastQuery, setLastQuery] = useState({});
const [lastTrigger, setLastTrigger] = useState('首包');
const triggerRef = useRef('首包');
const allEmployees = employeesRef.current;
const queryText = JSON.stringify(lastQuery, null, 2);
const hasFilterInQuery = Object.keys(lastQuery).length > 0;
const runAfterBatch = useCallback(
(label, mutate) => {
mutate(employeesRef.current);
setDataVersion(v => v + 1);
clearSelectedRows();
triggerRef.current = label;
// 故意裸调:只重置页码,不应丢掉当前已选筛选
tableRef.current?.reload({ data: { currentPage: 1 } });
},
[clearSelectedRows]
);
return (
<Flex vertical gap={12}>
<Alert
type="info"
showIcon
message="筛选 + 选择 + 批量操作 + reload 一致性"
description={
<div style={{ lineHeight: 1.7 }}>
1. 先选筛选项(部门/状态等)
<br />
2. 勾选若干行(支持表头全选当前可勾选行)
<br />
3. 点「批量操作」执行业务(会改本地数据并 <code>reload</code>)
<br />
通过标准:已选筛选标签不变;列表仍是过滤结果;下方请求参数仍含筛选字段
</div>
}
/>
<div
style={{
padding: 12,
background: hasFilterInQuery ? '#f6ffed' : '#fff7e6',
border: `1px solid ${hasFilterInQuery ? '#b7eb8f' : '#ffd591'}`,
borderRadius: 8,
fontSize: 13
}}
>
<Space wrap style={{ marginBottom: 8 }}>
<Tag color={hasFilterInQuery ? 'success' : 'warning'}>{hasFilterInQuery ? '请求含筛选' : '请求无筛选字段'}</Tag>
<Tag>触发方式:{lastTrigger}</Tag>
<Tag color="blue">已选 {selectedRowKeys.length} 条</Tag>
<Tag>数据版本 {dataVersion}</Tag>
</Space>
<div style={{ color: '#666', marginBottom: 4 }}>最近一次请求参数(从 loader 的 data 提取):</div>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{queryText === '{}' ? '{ }(无 department / status / position / keyword)' : queryText}</pre>
{selectedRows.length > 0 ? (
<div style={{ marginTop: 8, color: '#666' }}>
当前选中:{selectedRows.map(item => item.name).join('、')}
</div>
) : null}
</div>
<Space wrap>
<Button
onClick={() => {
triggerRef.current = 'ref.reload({ data: { currentPage: 1 } })';
tableRef.current?.reload({ data: { currentPage: 1 } });
}}
>
ref.reload(裸调)
</Button>
<Button
onClick={() => {
triggerRef.current = 'ref.reload()';
tableRef.current?.reload();
}}
>
ref.reload() 无参
</Button>
<Button disabled={!selectedRowKeys.length} onClick={() => clearSelectedRows()}>
清空选择
</Button>
</Space>
<TablePage
ref={tableRef}
name="demo-filter-reload"
rowKey="id"
rowSelection={getRowSelection(allEmployees)}
selectedRows={selectedRows}
search={{ name: 'keyword', label: '关键词', placeholder: '搜姓名' }}
filter={{
list: [
{
type: SuperSelectFilterItem,
props: {
name: 'department',
label: '部门',
single: true,
options: departments.map(item => ({ value: item, label: item }))
}
},
{
type: SuperSelectFilterItem,
props: {
name: 'status',
label: '状态',
single: true,
options: statuses
}
},
{
type: SuperSelectFilterItem,
props: {
name: 'position',
label: '职位',
single: true,
options: positions.map(item => ({ value: item, label: item }))
}
}
]
}}
batchActions={[
{
key: 'set-active',
label: '批量设为在职',
onClick: ({ selectedRowKeys: keys }) => {
const idSet = new Set(keys);
runAfterBatch('batch:设为在职 → reload', list => {
list.forEach(item => {
if (idSet.has(item.id)) {
item.status = 'active';
}
});
});
message.success(`已将 ${keys.length} 人设为在职并 reload`);
}
},
{
key: 'set-vacation',
label: '批量设为休假',
onClick: ({ selectedRowKeys: keys }) => {
const idSet = new Set(keys);
runAfterBatch('batch:设为休假 → reload', list => {
list.forEach(item => {
if (idSet.has(item.id)) {
item.status = 'vacation';
}
});
});
message.success(`已将 ${keys.length} 人设为休假并 reload`);
}
},
{
key: 'remove',
label: '批量删除',
danger: true,
onClick: ({ selectedRowKeys: keys }) => {
const idSet = new Set(keys);
runAfterBatch('batch:删除 → reload', list => {
employeesRef.current = list.filter(item => !idSet.has(item.id));
});
message.success(`已删除 ${keys.length} 人并 reload`);
}
},
{
key: 'reload-only',
label: '仅 reload(不改数据)',
onClick: ({ selectedRowKeys: keys, reload }) => {
message.info(`已选 ${keys.length} 条,仅触发 reload`);
triggerRef.current = 'batchActions.reload({ data: { currentPage: 1 } })';
reload({ data: { currentPage: 1 } });
}
}
]}
pagination={{
open: true,
pageSize: 10,
showSizeChanger: true
}}
dataFormat={data => ({
list: data.pageData,
total: data.totalCount,
data
})}
loader={({ data }) => {
const query = pickQueryFilters(data);
const trigger = triggerRef.current;
const currentPage = Number(data?.currentPage) || 1;
const perPage = Number(data?.perPage) || 10;
const source = employeesRef.current;
const filtered = applyFilters(source, data);
const start = (currentPage - 1) * perPage;
return new Promise(resolve => {
setTimeout(() => {
setLastQuery(query);
setLastTrigger(trigger);
triggerRef.current = '筛选/翻页等内部 reload';
resolve({
pageData: filtered.slice(start, start + perPage).map(item => ({ ...item })),
totalCount: filtered.length
});
}, 300);
});
}}
columns={[
{ name: 'id', title: 'ID', width: 70 },
{ name: 'name', title: '姓名', width: 120 },
{ name: 'department', title: '部门', width: 140 },
{ name: 'position', title: '职位', width: 120 },
{
name: 'status',
title: '状态',
width: 100,
renderType: 'status',
getValueOf: item => statusMap[item.status] || { type: 'default', text: item.status }
}
]}
/>
</Flex>
);
};
render(<FilterReloadExample />);
- searchParamsValue
- filter.searchParamsValue 与 useSearchParamsValue 同参:从 URL 平铺参数合并进 defaultValue(URL 同名覆盖),保证首包请求已带筛选;可选 setSearchParams 清理已消费 key
- _TablePage(@kne/current-lib_table-page)[import * as _TablePage from "@kne/table-page"],(@kne/current-lib_table-page/dist/index.css),antd(antd),_ReactFilter(@kne/react-filter)[import * as _ReactFilter from "@kne/react-filter"],(@kne/react-filter/dist/index.css)
const { default: TablePage } = _TablePage;
const { fields } = _ReactFilter;
const { InputFilterItem } = fields;
const { Flex, Typography, Card } = antd;
const { useMemo, useState } = React;
const mockUsers = [
{ id: '1', name: 'Alice', userId: 'u-1001', tenantId: 't-88' },
{ id: '2', name: 'Bob', userId: 'u-1002', tenantId: 't-88' },
{ id: '3', name: 'Carol', userId: 'u-2002', tenantId: 't-99' }
];
/**
* TablePage filter.searchParamsValue:与 useSearchParamsValue 同参。
* 非受控 defaultValue 与 URL 按 name 合并(URL 同名覆盖);首包请求参数已含合并结果。
*/
const BaseExample = () => {
const [lastRequest, setLastRequest] = useState(null);
const searchParams = useMemo(() => {
const params = new URLSearchParams();
params.set('userId', 'u-1001');
return params;
}, []);
const [liveSearch, setLiveSearch] = useState(searchParams);
return (
<Flex vertical gap={16}>
<Card size="small" title="说明">
<Typography.Paragraph style={{ marginBottom: 0 }}>
模拟 URL <Typography.Text code>?userId=u-1001</Typography.Text>,并配置 defaultValue 含 status。合并后首包应同时带上 status 与 userId。
</Typography.Paragraph>
</Card>
{lastRequest ? (
<Card size="small" title="首次/最近请求 data 参数">
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(lastRequest, null, 2)}</pre>
</Card>
) : null}
<TablePage
name="search-params-value-demo"
data={{ currentPage: 1, perPage: 10 }}
pagination={{ paramsType: 'data' }}
filter={{
defaultValue: [{ name: 'status', label: '状态', value: { label: '开启', value: 'open' } }],
searchParamsValue: {
searchParams: liveSearch,
setSearchParams: next => setLiveSearch(next),
fields: [
{ name: 'userId', label: '用户Id' },
{ name: 'tenantId', label: '租户Id' }
]
},
list: [
[
{ type: InputFilterItem, props: { name: 'userId', label: '用户Id' } },
{ type: InputFilterItem, props: { name: 'tenantId', label: '租户Id' } },
{ type: InputFilterItem, props: { name: 'status', label: '状态' } }
]
]
}}
loader={({ data }) => {
setLastRequest(data);
const list = mockUsers.filter(row => {
if (data.userId && row.userId !== data.userId) return false;
if (data.tenantId && row.tenantId !== data.tenantId) return false;
return true;
});
return Promise.resolve({
pageData: list,
totalCount: list.length
});
}}
columns={[
{ name: 'name', title: '姓名', type: 'main' },
{ name: 'userId', title: '用户Id' },
{ name: 'tenantId', title: '租户Id' }
]}
/>
<Typography.Text type="secondary">当前 search:?{liveSearch.toString() || '(已清理)'}</Typography.Text>
</Flex>
);
};
render(<BaseExample />);
- pagination searchParams
- pagination.searchParams + setSearchParams:当前页与每页条数写入 URL(默认 currentPage / perPage);落地 URL 决定首包分页,翻页与改 pageSize 以 replace 写回
- _TablePage(@kne/current-lib_table-page)[import * as _TablePage from "@kne/table-page"],(@kne/current-lib_table-page/dist/index.css),antd(antd)
const { default: TablePage } = _TablePage;
const { Flex, Typography, Card } = antd;
const { useMemo, useState } = React;
const TOTAL = 56;
const range = (start, end) => Array.from({ length: end - start }, (_, i) => start + i);
/**
* pagination.searchParams + setSearchParams:当前页 / 每页条数与 URL 双向同步。
* 模拟落地 ?currentPage=2&perPage=10,翻页或改 pageSize 后 URL 会 replace 更新。
*/
const BaseExample = () => {
const initialSearchParams = useMemo(() => {
const params = new URLSearchParams();
params.set('currentPage', '2');
params.set('perPage', '10');
return params;
}, []);
const [liveSearch, setLiveSearch] = useState(initialSearchParams);
const [lastRequest, setLastRequest] = useState(null);
return (
<Flex vertical gap={16}>
<Card size="small" title="说明">
<Typography.Paragraph style={{ marginBottom: 0 }}>
模拟 URL <Typography.Text code>?currentPage=2&perPage=10</Typography.Text>
。首包应请求第 2 页、每页 10 条;翻页或切换每页条数后下方 search 同步更新(replace)。
</Typography.Paragraph>
</Card>
{lastRequest ? (
<Card size="small" title="最近请求 data 参数">
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(lastRequest, null, 2)}</pre>
</Card>
) : null}
<TablePage
name="pagination-search-params-demo"
pagination={{
paramsType: 'data',
searchParams: liveSearch,
setSearchParams: next => setLiveSearch(next),
pageSizeOptions: ['10', '20', '50'],
showSizeChanger: true,
hideOnSinglePage: false,
cachePageSize: false
}}
loader={({ data }) => {
setLastRequest(data);
const currentPage = Number(data.currentPage) || 1;
const perPage = Number(data.perPage) || 10;
const start = (currentPage - 1) * perPage;
const pageData = range(start, Math.min(start + perPage, TOTAL)).map(index => ({
id: String(index + 1),
name: `用户 ${index + 1}`,
no: `NO-${String(index + 1).padStart(3, '0')}`
}));
return Promise.resolve({
pageData,
totalCount: TOTAL
});
}}
columns={[
{ name: 'no', title: '编号', type: 'main' },
{ name: 'name', title: '姓名' }
]}
/>
<Typography.Text type="secondary">当前 search:?{liveSearch.toString() || '(空)'}</Typography.Text>
</Flex>
);
};
render(<BaseExample />);
- TablePage sticky scroll
- 自包含演示区:sticky + getScrollContainer 由区内页面滚动触发表头吸顶(非 scroll.y);模拟导航在区内 sticky,不遮挡文档站顶栏
- _TablePage(@kne/current-lib_table-page)[import * as _TablePage from "@kne/table-page"],(@kne/current-lib_table-page/dist/index.css),antd(antd),_ReactFilter(@kne/react-filter)[import * as _ReactFilter from "@kne/react-filter"],(@kne/react-filter/dist/index.css)
const { default: TablePage } = _TablePage;
const { fields } = _ReactFilter;
const { SuperSelectFilterItem } = fields;
const { Flex, Tag } = antd;
const { useRef, useMemo } = React;
const NAV_HEIGHT = 56;
const DEMO_HEIGHT = 600;
const TOTAL = 80;
const statusMap = {
active: { type: 'success', text: '在职' },
vacation: { type: 'warning', text: '休假' },
resigned: { type: 'default', text: '离职' },
probation: { type: 'processing', text: '试用期' }
};
const departments = ['技术研发部', '产品设计部', '市场营销部', '人力资源部', '财务部'];
const departmentOptions = departments.map(item => ({ value: item, label: item }));
const statusOptions = Object.entries(statusMap).map(([value, { text }]) => ({ value, label: text }));
const normalizeFilterValue = value => {
if (value == null) {
return value;
}
return Array.isArray(value) ? value[0] : value;
};
const applyFilters = (employees, data, requestParams) => {
const params = Object.assign({}, requestParams?.data, data);
let result = employees;
if (params.keyword) {
const keyword = String(params.keyword).toLowerCase();
result = result.filter(item => item.employeeNo.toLowerCase().includes(keyword) || item.name.includes(params.keyword));
}
const department = normalizeFilterValue(params.department);
if (department) {
result = result.filter(item => item.department === department);
}
const status = normalizeFilterValue(params.status);
if (status) {
result = result.filter(item => item.status === status);
}
return result;
};
const buildEmployee = index => {
const statusKeys = ['active', 'vacation', 'resigned', 'probation'];
return {
id: `EMP${String(index + 1).padStart(4, '0')}`,
employeeNo: `EMP-2024-${String(index + 1).padStart(4, '0')}`,
name: `员工${index + 1}`,
department: departments[index % departments.length],
position: ['工程师', '经理', '专员'][index % 3],
status: statusKeys[index % statusKeys.length],
joinDate: `2024-${String((index % 12) + 1).padStart(2, '0')}-15`
};
};
const allEmployees = Array.from({ length: TOTAL }, (_, index) => buildEmployee(index));
const columns = [
{ name: 'employeeNo', title: '工号', width: 160, min: 120, max: 220, fixed: 'left', renderType: 'small' },
{ name: 'name', title: '姓名', width: 100, renderType: 'main' },
{ name: 'department', title: '部门', width: 150 },
{ name: 'position', title: '职位', width: 120 },
{
name: 'status',
title: '状态',
width: 100,
renderType: 'status',
getValueOf: item => statusMap[item.status] || { type: 'default', text: i