npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 模式(默认):基于 antd Table,支持列宽拖动、字段显示/隐藏、分组表头、粘性表头等
  • TableView 模式:基于 @kne/table-view CSS Grid,适合移动端或卡片式表格场景

通过 loaderurl 配置数据源,通过 dataFormat 适配不同的接口数据结构。分页器渲染在表格外侧,翻页默认采用 reload 方式(不显示全屏 loading)。在 pagination 上同时传入 searchParamssetSearchParams 可将当前页、每页条数同步到 URL(参数名复用 currentName / pageSizeName)。

同时内置了顶部工具栏(TableToolbar),整合筛选、搜索、Tab 分类、批量操作等能力:

  • 筛选(filter):基于 @kne/react-filterFilterLines,支持多行多字段组合筛选,筛选值变化时自动 reload 并回到第 1 页;可通过 filter.searchParamsValue(与 useSearchParamsValue 同参)从 URL 平铺参数合并初始筛选并保证首包请求带上对应参数
  • 分页 URL 状态pagination.searchParams + pagination.setSearchParams 开启后,当前页与每页条数与 URL 双向同步(筛选重置页码时也会写回;loadMore 不写回)
  • 搜索(search):基于 @kne/react-filterSearchInput,支持关键词搜索与防抖自动提交,与筛选器共享筛选值状态;移动端开启 renderMobile 时,SearchInput 与下方卡片列表之间保留间距
  • 操作按钮(buttonGroup):透传 @kne/button-group 参数;桌面端显示在 SearchInput 右侧(small、至少 1 个外露),移动端与筛选同行两端对齐(筛选靠左、按钮组靠右,small、外露 1 个),批量操作显示在「全选/排序」行的排序后面
  • Tab(tab):顶部分类切换,默认「全部」,选中值写入 filter value 参与请求但不在已选标签中重复展示;桌面端在表格边框外侧,移动端显示在 SearchInput 下方;可通过 tabProps 透传 antd Tabs 属性
  • 批量操作(batchActions):配合 rowSelectionuseSelectedRow,提供下拉菜单形式的批量操作(如批量导出、批量通知),未选中时自动禁用
  • PC 卡片视图(renderCard):取值同 renderMobile(true / function / preset 字符串),生效后 PC 端可切换表格/卡片(状态按 name 存 localStorage);forceCard 强制卡片并不显示切换按钮;卡片模式下外框透明、默认触底下拉加载;移动端忽略
  • 树形数据:透传 dataTypetree / treeList)、expandedKeysonLoadChildrenrowSelection.checkRelation 等给内部 Table / TableView
  • 已选筛选值展示:工具栏下方展示当前生效的筛选条件标签,支持快速清除

Table

基于 antd Table 的表格组件,与 TableView 共享相同的 columnsrowSelection 等 API。额外提供以下增强能力:

  • 列宽拖动:悬停表头列右侧拖动手柄可调整列宽,支持 min/max 限制
  • 列配置面板:通过表头最后一列的设置图标,可显示/隐藏字段、拖拽排序
  • 配置持久化:设置 name 后,列宽和显示状态自动保存到 localStorage
  • 分组表头:通过 groupHeader 配置实现多级表头结构
  • 浮动横向滚动条:当表格宽度超出容器时,底部自动显示横向滚动条(通过 horizontalScroller 控制)
  • 树形数据:支持与 TableView 相同的 dataTypetree / 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 配置,可直接传入 TableTableView
  • selectedRowKeysselectedRows 追踪选中状态
  • setSelectedRowKeys(keys, dataSource) 从 key 列表反查完整行数据
  • clearSelectedRows() 一键清空选择

适用于批量操作(批量删除、批量导出等)和单选场景(详情查看、关联选择等)。

useSort

排序 Hook,配合 Table/TableViewsortRender 实现表头排序交互。支持:

  • 单列排序sort: truesort: { single: true }):切换列时自动清除其他列的排序
  • 多列排序sort: { single: false }):允许多列同时排序
  • 排序状态循环切换:DESC → ASC → 取消
  • sortDataSource(dataSource, sort, columns) 工具函数,支持本地排序(包含中文排序)

渲染逻辑

双模式:Table / TableView

TablePage 通过 type 切换底层表格实现:

| 模式 | 底层 | 适用场景 | |------|------|----------| | Table(默认) | antd Table | 桌面端完整表格能力:列宽拖动、列配置、分组表头、粘性表头、总结栏 | | TableView | @kne/table-view CSS Grid | 轻量栅格表格、移动端、卡片式展示 |

两种模式共享 columnsrowSelectionsortRenderrenderType 等 API,列渲染管线统一来自 @kne/table-view

列单元格渲染管线

无论 Table 还是 TableView,单元格内容均走同一套流程(Table 在 antd columns[].render 内调用):

  1. resolveColumns:解析 renderType,注入内置 renderwidth / min / max / ellipsis
  2. computeColumnsValuegetValueOf 取值 → format 格式化 → 按 display / 空值规则过滤
  3. computeDisplay:空值占位;非空调用列 render
  4. renderCellContent:按 ellipsis / cellFullWidth 输出最终节点

列渲染优先级:column.render(最高)> renderType 内置渲染 > 原始格式化值。renderrenderType 共存时,后者仅提供列宽等布局维度。

桌面端:antd Table

Table 将解析后的列映射为 antd columns,在 render 回调中复用上述管线。额外能力:

  • useTableConfig 管理列宽拖动、显示/隐藏、localStorage 持久化
  • useGroupHeader 生成分组表头
  • rowSelection 映射为 antd 行选择(含 allowSelectedAll 全选;树形下支持 checkRelation
  • dataTypetree / treeList 时归一化为嵌套数据并接入 antd expandable
  • render={({ header, renderBody }) => ...} 可自定义表格外层,renderBody() 返回完整 antd Table

移动端:renderMobile

TableTableView 均支持 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 全局定制。

默认导出 getTagColorrenderTagItemrenderTagListgetStatusTyperenderStatusItem 工具函数,用于 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(&#96;查看 ${item.name}&#96;)}>
        查看
      </Button>
      <Button type="link" size="small" onClick={() => message.info(&#96;编辑 ${item.name}&#96;)}>
        编辑
      </Button>
      {item.status !== 'resigned' && (
        <Button type="link" size="small" danger onClick={() => message.warning(&#96;办理离职 ${item.name}&#96;)}>
          离职办理
        </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(&#96;正在导出 ${selectedRowKeys.length} 名员工&#96;);
            }
          },
          {
            key: 'notify',
            label: '批量通知',
            onClick: ({ selectedRowKeys }) => {
              message.success(&#96;已通知 ${selectedRowKeys.length} 名员工&#96;);
            }
          }
        ]}
        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: &#96;确定删除 ${item.name} 吗?&#96;, 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={&#96;mobile-gap-with-toolbar-${enableSelectAll}-${enableSort}&#96;}
          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(&#96;查看 ${item.name}&#96;) },
      { children: '编辑', onClick: () => message.info(&#96;编辑 ${item.name}&#96;) },
      {
        children: '删除',
        isDelete: true,
        message: &#96;确定删除 ${item.name} 吗?&#96;,
        onClick: () => message.warning(&#96;已删除 ${item.name}&#96;)
      }
    ]
  }
];

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(&#96;查看 ${item.name}&#96;)}>
        查看
      </Button>
      <Button type="link" size="small" onClick={() => message.info(&#96;编辑 ${item.name}&#96;)}>
        编辑
      </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(&#96;正在导出 ${keys.length} 个节点&#96;);
              }
            },
            {
              key: 'enable',
              label: '批量启用',
              onClick: ({ selectedRowKeys: keys }) => {
                message.success(&#96;已启用 ${keys.length} 个节点&#96;);
              }
            },
            {
              key: 'disable',
              label: '批量停用',
              danger: true,
              onClick: ({ selectedRowKeys: keys }) => {
                message.warning(&#96;已停用 ${keys.length} 个节点&#96;);
              }
            }
          ]}
          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=&quot;tree&quot;(嵌套 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: &#96;员工${index + 1}&#96;,
  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: &#96;1px solid ${hasFilterInQuery ? '#b7eb8f' : '#ffd591'}&#96;,
          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(&#96;已将 ${keys.length} 人设为在职并 reload&#96;);
            }
          },
          {
            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(&#96;已将 ${keys.length} 人设为休假并 reload&#96;);
            }
          },
          {
            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(&#96;已删除 ${keys.length} 人并 reload&#96;);
            }
          },
          {
            key: 'reload-only',
            label: '仅 reload(不改数据)',
            onClick: ({ selectedRowKeys: keys, reload }) => {
              message.info(&#96;已选 ${keys.length} 条,仅触发 reload&#96;);
              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: &#96;用户 ${index + 1}&#96;,
            no: &#96;NO-${String(index + 1).padStart(3, '0')}&#96;
          }));
          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: &#96;EMP${String(index + 1).padStart(4, '0')}&#96;,
    employeeNo: &#96;EMP-2024-${String(index + 1).padStart(4, '0')}&#96;,
    name: &#96;员工${index + 1}&#96;,
    department: departments[index % departments.length],
    position: ['工程师', '经理', '专员'][index % 3],
    status: statusKeys[index % statusKeys.length],
    joinDate: &#96;2024-${String((index % 12) + 1).padStart(2, '0')}-15&#96;
  };
};

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