iter(g247): 表格支持整体换数据源,dataTable 可接信号数据

This commit is contained in:
HIsheR
2026-10-26 22:03:37 +08:00
parent 19741b52b6
commit 42ea31d5b5
11 changed files with 103 additions and 27 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "ui-arch-registry-index/0.1",
"generatedAt": "2026-10-26T13:59:39.013Z",
"generatedAt": "2026-10-26T14:02:07.910Z",
"repoRoot": ".",
"packages": [
{
@@ -2016,9 +2016,9 @@
"props": [
{
"n": "data",
"t": "T[]",
"t": "T[] | Signal<T[]>",
"r": 1,
"d": "全量行数据,每行须含 id 字段作 rowKey"
"d": "全量行数据(行须含 id 作 rowKey);传信号或 computed 时数据变化自动换源,排序 / 筛选 / 页码保持用户原意"
},
{
"n": "columns",
@@ -2080,6 +2080,7 @@
"准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)",
"定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕",
"列展示细节用皮肤字段:align 控制表头与单元格对齐,format(value, row) 返回纯文本(走 textContent,不需要转义)",
"数据源可以给信号或 computed(如筛选结果):变化时内核整体换源,选择状态按 rowKey 剔除已消失的行,排序与页码不被打断",
"列宽用内核字段 width:数字(像素)固定该列,'flex' 或不设则按内容自适应",
"selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数",
"选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失",
+1 -1
View File
@@ -27,7 +27,7 @@
- `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html
- `apps/docs`:文档站,纯产物(ua docs 生成)
**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual + audit:contrast,提交前必过。其中 `smoke` 是两段内核断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 21 条(排序 / 筛选 / 分页 / 选择的派生管线);改内核语义前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 16 组「文字色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `<!-- ua:components -->` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。
**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual + audit:contrast,提交前必过。其中 `smoke` 是两段内核断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 26 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除);改内核语义前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 16 组「文字色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `<!-- ua:components -->` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。
`ua verify` 另外校验 `ITERATION.md` 的《视觉回归抽查清单》:button / textField / select / modal / dataTable / toast 六段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥18 条且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。
+18
View File
@@ -260,6 +260,12 @@ const resendCd = countdown({ seconds: 90, running: false, format: (s) => `重发
const highlightText = signal('数据不出端、预测低于 50ms、习惯本地学习是输入法增强系统的三条底线'); // 高亮演示原文
const highlightKw = signal('本地'); // 高亮演示关键词(与搜索框信号联动)
const demoTags = signal<string[]>(['本地推理', '隐私优先']); // 标签输入演示集合(成标签 / 删 chip 双向写回)
const tagRows = [
{ id: 'r1', name: '本地 ASR 推理', tags: ['本地推理', '隐私优先'] },
{ id: 'r2', name: '云端补全兜底', tags: ['云端'] },
{ id: 'r3', name: '习惯冷启动加速', tags: ['本地推理'] },
]; // 标签筛选演示数据:每行带 tags 供「且」关系匹配
const tagMatched = computed(() => tagRows.filter((row) => demoTags().every((tag) => row.tags.includes(tag)))); // 派生行集合:标签变化即重算,喂给 dataTable 的信号数据源
const treeChecked = signal<string[]>(['core']); // 多选树勾选集合(外部受控演示)
const treePick = signal('core'); // 树形选中节点 key
const transferKeys = signal<string[]>(['a11y']); // 穿梭框右侧集合(外部受控演示)
@@ -1168,6 +1174,18 @@ const content = h(
inputTag({ value: demoTags, max: 5, onChange: (list) => toast.info(`当前 ${list.length} 个标签`) }),
h('span', { class: 'out' }, () => `已选:${demoTags().join(' / ') || '(空)'}`),
),
h(
'div',
{ class: 'row' },
dataTable({
data: tagMatched, // 信号数据源:删标签后表格自动换源,排序 / 每页行数不被重置
columns: [
{ key: 'name', header: '能力' },
{ key: 'tags', header: '标签', format: (value) => (value as string[]).join(' / ') },
],
pageSize: 3,
}),
),
),
section(
'sec-affix',
+14 -13
View File
@@ -1,6 +1,6 @@
# @ui-arch 组件库 Agent 文档(无图形版)
> 自动生成于 2026-10-26T13:59:39.013Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 自动生成于 2026-10-26T14:02:07.910Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
## 快速上手
@@ -1005,7 +1005,7 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label:
参数:
- data(必填):T[] —— 全量行数据,每行须含 id 字段作 rowKey
- data(必填):T[] | Signal<T[]> —— 全量行数据(行须含 id 作 rowKey);传信号或 computed 时数据变化自动换源,排序 / 筛选 / 页码保持用户原意
- columns(必填):ColumnDef<T>[] —— 列定义:内核字段 { key, header?, sortable?, width? } + 皮肤字段 { align?, format? };width 数字为固定像素,'flex' 自适应
- filterKey:string —— 工具栏筛选的列;缺省取第一列
- pageSize(默认 5):number —— 每页行数;缺省 5
@@ -1028,17 +1028,18 @@ dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓
1. 准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)
2. 定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕
3. 列展示细节用皮肤字段:align 控制表头与单元格对齐,format(value, row) 返回纯文本(走 textContent,不需要转义)
4. 列宽用内核字段 width:数字(像素)固定该列,'flex' 或不设则按内容自适应
5. selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数
6. 选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失
7. 工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行
8. 表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供
9. 数据在途时传 loading(布尔或信号):表格体换成骨架行、筛选与翻页禁用,返回后置回 false 即恢复
10. 需要独立分页器联动时传 page 信号:const page = signal(1),dataTable({ page }) 与 pagination({ page }) 共用
11. 需要批量操作时传 selected 信号:内部勾选实时写回,外部置空或赋值即同步整表(跨页保留,按 rowKey 存)
12. 可排序表头的 aria-sort 跟随排序态同步(ascending / descending / none),读屏能直接播报当前列序,箭头只是视觉补充
13. 需要导出时开 exportable:按钮导的是筛选+排序后的全量行,与当前页无关,单元格按 format 输出
14. 整行可点(进详情、选中一行做后续操作)传 onRowClick:行会变成可聚焦项,Enter / 空格与点击等价;不传时行不进 Tab 序列,纯展示表不会被读屏当成按钮
4. 数据源可以给信号或 computed(如筛选结果):变化时内核整体换源,选择状态按 rowKey 剔除已消失的行,排序与页码不被打断
5. 列宽用内核字段 width:数字(像素)固定该列,'flex' 或不设则按内容自适应
6. selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数
7. 选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失
8. 工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行
9. 表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供
10. 数据在途时传 loading(布尔或信号):表格体换成骨架行、筛选与翻页禁用,返回后置回 false 即恢复
11. 需要独立分页器联动时传 page 信号:const page = signal(1),dataTable({ page }) 与 pagination({ page }) 共用
12. 需要批量操作时传 selected 信号:内部勾选实时写回,外部置空或赋值即同步整表(跨页保留,按 rowKey 存)
13. 可排序表头的 aria-sort 跟随排序态同步(ascending / descending / none),读屏能直接播报当前列序,箭头只是视觉补充
14. 需要导出时开 exportable:按钮导的是筛选+排序后的全量行,与当前页无关,单元格按 format 输出
15. 整行可点(进详情、选中一行做后续操作)传 onRowClick:行会变成可聚焦项,Enter / 空格与点击等价;不传时行不进 Tab 序列,纯展示表不会被读屏当成按钮
现场演示:apps/docs/demo.html#sec-table
@@ -172,4 +172,25 @@ assert(
);
assert(all.getPageInfo().totalPages === 1, 'getAllRows 不影响分页快照(2 行 / 每页 2 = 1 页)');
// setRows:整体换数据源,选择状态按 rowKey 剔除失效行,页码越界由插件收敛
const swapped = createTable({
data: users,
columns: [{ key: 'name', header: 'Name' }],
rowKey: 'id',
plugins: [selectionPlugin(), paginationPlugin()],
initialState: { pagination: { page: 1, pageSize: 2 } },
});
swapped.dispatch({ type: 'toggleSelect', key: 1 }); // 选中两行,后面验证失效行被剔除
swapped.dispatch({ type: 'toggleSelect', key: 4 });
assert(swapped.isRowSelected(1) && swapped.isRowSelected(4), 'setRows 前:两行已选');
swapped.setRows(users.filter((u) => u.id !== 4));
assert(swapped.getVisibleRows().total === 4, 'setRows 后:total 跟随新数据(5 行 → 4 行)');
assert(swapped.isRowSelected(1) && !swapped.isRowSelected(4), 'setRows 后:失效行键从选择里剔除,有效行保留');
assert(swapped.getState().pagination.page === 1, 'setRows 不动页码(用户翻页意图不被打断)');
const sameRows = users.filter((u) => u.id !== 4);
let notified = 0;
swapped.subscribe(() => (notified += 1));
swapped.setRows(sameRows);
assert(notified === 1 && swapped.getVisibleRows().total === 4, 'setRows 换引用:通知一次并重算 total');
console.log('SMOKE OK');
+17 -1
View File
@@ -13,7 +13,8 @@ import type {
export function createTable<T>(
options: TableOptions<T>,
): TableInstance<T> & Record<string, unknown> {
const { data, columns, plugins = [], onStateChange } = options;
let data = options.data; // 可变:setRows 整体换数据源(筛选结果、轮询刷新),派生管线照旧跑
const { columns, plugins = [], onStateChange } = options;
const rowKey = options.rowKey ?? ('id' as keyof T & string); // 默认行标识
const getKey: (row: T) => RowKey =
@@ -91,10 +92,25 @@ export function createTable<T>(
return model;
}
/*** 整体替换数据源:引用未变或内容长度与行键全同则不动(避免无意义重算),变了就通知订阅方
* 选择状态按 rowKey 存活:新数据里不再存在的行键会从 selection 里剔除,避免批量操作命中幽灵行。 ***/
function setRows(next: T[]): void {
if (next === data) return;
data = next;
const keys = new Set(next.map(getKey));
const kept = state.selection.filter((key) => keys.has(key));
if (kept.length !== state.selection.length) {
state = { ...state, selection: kept }; // 只动选择:排序 / 筛选 / 页码保持用户原意
}
onStateChange?.(state);
for (const fn of listeners) fn();
}
const instance: TableInstance<T> = {
getState: () => state,
dispatch,
subscribe,
setRows,
getVisibleRows,
getAllRows: () => getVisibleRows(false), // 筛选 + 排序后、未分页的全量行(导出等场景)
getColumns: () => columns,
+1
View File
@@ -89,6 +89,7 @@ export interface TableInstance<T = unknown> {
getState(): TableState; // 当前状态快照
dispatch(action: Action): void; // 派发动作,唯一变更入口
subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数
setRows(rows: T[]): void; // 整体换数据源(筛选结果 / 轮询刷新),选择状态按 rowKey 剔除失效行
getVisibleRows(): RowModel<T>; // 可见行模型(过完整管线)
getAllRows(): RowModel<T>; // 筛选 + 排序后、未分页的全量行(导出用)
getColumns(): ColumnDef<T>[]; // 列定义
+6 -6
View File
@@ -4,8 +4,8 @@ import {
paginationPlugin,
selectionPlugin,
sortPlugin,
type RowKey,
} from '@ui-arch/table-core';
import type { RowKey } from '@ui-arch/table-core';
import { iconSearch } from './icons.js'; import { computed, effect, h, renderList, signal } from '@ui-arch/frame';
import { attachResize } from './columnResize.js';
import { exportButton } from './csv.js';
@@ -13,23 +13,23 @@ import { button } from './button.js';
import { skeleton } from './skeleton.js';
import { textField } from './textField.js';
import { emptyRow } from './tableEmpty.js';
import { attachDataSource, readRows } from './tableDataSource.js';
import type { DataTableProps, TableApi } from './types.js';
/*** 数据表格:粘性表头 + 行选择 + 排序 + 筛选 + 分页 + 空状态;状态由 table-core 持有,信号只做桥接渲染 ***/
export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
// —— 状态集中声明 ——
const selectable = props.selectable ?? false; // 行选择开关
const selectable = props.selectable ?? false; // 状态集中声明:行选择开关
const keyword = signal(''); // 筛选框关键字:受控信号,空状态里的「清除筛选」靠它一键还原
const table = createTable({
data: props.data,
data: readRows(props.data), // 信号数据源先取当前值,后续靠 attachDataSource 换
columns: props.columns,
rowKey: 'id',
plugins: [filterPlugin<T>(), sortPlugin<T>(), paginationPlugin<T>(), selectionPlugin<T>()],
initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } },
});
const api = table as unknown as TableApi<T>; // 用到的两个取数方法在类型别名里声明
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增触发信号系统
table.subscribe(() => version(version() + 1));
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增 → 派生统一重算
table.subscribe(() => version(version() + 1)); attachDataSource(table, props.data); // 信号数据源变化即换源(静态数组直接返回)
// —— 派生:全部由 version 单一来源推导,语义与内核一致 ——
const visible = computed(() => {
+17
View File
@@ -0,0 +1,17 @@
import { effect, type Signal } from '@ui-arch/frame';
import type { RowKey, TableInstance } from '@ui-arch/table-core';
/*** 作用:取数据源当前值——传数组直接返回,传信号 / computed 则调用取值(表格创建时用) ***/
export function readRows<T>(data: T[] | Signal<T[]>): T[] {
return typeof data === 'function' ? data() : data;
}
/*** 作用:给表格挂换源 effect——数据源是信号时信号变化即整体换源,静态数组直接返回(不多挂 effect、不多算一次派生) ***/
export function attachDataSource<T extends { id: RowKey }>(
table: TableInstance<T>,
data: T[] | Signal<T[]>,
): void {
if (typeof data !== 'function') return; // 卫语句:静态数组无需响应式换源
const source = data;
effect(() => table.setRows(source())); // setRows 内部按 rowKey 剔除失效选择项,页码与排序保持原意
}
+1 -1
View File
@@ -61,7 +61,7 @@ export interface TableColumn<T = unknown> extends ColumnDef<T> {
/*** 数据表格入参 ***/
export interface DataTableProps<T> {
data: T[]; // 全量行数据,行须含 id 字段作 rowKey
data: T[] | Signal<T[]>; // 全量行数据(行须含 id 作 rowKey);传信号 / computed 则数据变化时自动换源
columns: TableColumn<T>[]; // 列定义:内核字段 + align / format
filterKey?: string; // 工具栏筛选的列;缺省取第一列
pageSize?: number; // 每页行数;缺省 5
+3 -2
View File
@@ -1887,9 +1887,9 @@
"props": [
{
"n": "data",
"t": "T[]",
"t": "T[] | Signal<T[]>",
"r": 1,
"d": "全量行数据,每行须含 id 字段作 rowKey"
"d": "全量行数据(行须含 id 作 rowKey);传信号或 computed 时数据变化自动换源,排序 / 筛选 / 页码保持用户原意"
},
{
"n": "columns",
@@ -1951,6 +1951,7 @@
"准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)",
"定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕",
"列展示细节用皮肤字段:align 控制表头与单元格对齐,format(value, row) 返回纯文本(走 textContent,不需要转义)",
"数据源可以给信号或 computed(如筛选结果):变化时内核整体换源,选择状态按 rowKey 剔除已消失的行,排序与页码不被打断",
"列宽用内核字段 width:数字(像素)固定该列,'flex' 或不设则按内容自适应",
"selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数",
"选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失",