From 8e358c4faf043f7ad4f84d398ae8b96b11192a55 Mon Sep 17 00:00:00 2001 From: HIsheR Date: Tue, 27 Oct 2026 01:16:36 +0800 Subject: [PATCH] =?UTF-8?q?iter(g324):=20table-core=20=E8=A1=A5=20setColum?= =?UTF-8?q?ns=EF=BC=8C=E6=8D=A2=E5=88=97=E5=90=8E=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E4=B8=8E=E7=AD=9B=E9=80=89=E5=89=94=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 9 +++++ AGENTS.md | 2 +- packages/core/table-core/scripts/smoke.mjs | 38 ++++++++++++++++++++++ packages/core/table-core/src/core.ts | 22 +++++++++++-- packages/core/table-core/src/types.ts | 1 + 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/.ua/backlog.json b/.ua/backlog.json index d5e0038..823ca6b 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3302,6 +3302,15 @@ "motivation": "21 条配方此前只在 CLI / MCP / llms-full.txt 三条 Agent 通道可查,人类版文档站完全没有入口——浏览器里翻配方只能回 recipes.md 源文件;文档站已有组件/令牌/搜索/亮暗主题,配方是最后一块缺口。", "acceptance": "① apps/docs/index.html 生成「组合配方」卡:每条配方一个 details 折叠块(标题 + 组件 chips + 代码块 + 要点),导航栏补「配方」锚点;② 搜索态不渲染配方卡(不属于任何包,避免稀释过滤结果);③ 配方数据由 parseRecipes 生成而非手写,recipes.md 改动后 npm run docs 即同步;④ npm run check 通过。", "notes": "" + }, + { + "id": "g324", + "domain": "tune", + "status": "open", + "title": "table-core 补 setColumns:动态换列后失效排序 / 筛选剔除", + "motivation": "setRows 已支持整体换源并按 rowKey 剔除失效选择,但列定义仍是构造时写死:动态列表(按角色 / 视图切换列集)只能重建整个实例,排序与筛选状态全丢;setColumns 与 setRows 对称——换列后指向不存在列的 sorting / filters 失去语义,应剔除而不是静默残留。", + "acceptance": "① core 新增 setColumns(next):引用未变不动;换列后剔除 sorting / filters 里 key 不在新列集的条目,其余状态(分页 / 选择)保持;② getColumns 返回最新列集,getCellValue 按新列 accessor 取值;③ smoke 冒烟补断言(失效剔除 + 未变短路 + accessor 跟随);④ npm run check 通过。", + "notes": "" } ] } diff --git a/AGENTS.md b/AGENTS.md index 48975ce..eead145 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 + audit:motion + audit:surface,提交前必过。其中 `smoke` 是三段断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 31 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除 + 越界页码回写)+ `@ui-arch/ui` 8 条(皮肤层纯逻辑:行级差异的剪前后缀 / LCS 对齐 / 忽略空白 / 超限退化,跑的是 dist/diffKit.js);改内核语义或纯逻辑前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 18 组「前景色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档、焦点描边 × 页面底/表面)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua audit:motion` 守的是动效面,两类必查:① 带 `infinite` 的动画是否在 `prefers-reduced-motion: reduce` 块里被 `animation: none` 关掉——循环动画是前庭敏感用户的硬伤,漏一条整页就在抖(它抓出过转圈与不确定态进度两处漏网);② 悬停 / 按压的位移(`translate`)是否在减少动效下归零——位移最容易诱发前庭不适,且只提供手感、不表达状态(一条 `ua-` 类通配的 `transform: none !important` 兜住全站 14 处,新增组件不用各自补)。③ 旋转 / 缩放这类第三类空间动效(折叠箭头、按压缩放)是否有「只留颜色过渡」的收窄兜底——空间变换即时到位、状态仍看得见(一条通配 `transition-property` 收窄兜住全站 35 处)。`ua audit:surface` 补的是「底色 vs 底色」这条盲区:把表面阶梯(页面底 → 表面 → 悬停底 → 按下底,外加凹陷底)逐档按亮度比验,相邻档低于 1.04(凹陷档 1.02)即退出码 1;同一条阶梯还多验两档「半透明 accent 淡底压在表面上」——选中底(`--ua-color-tint-accent`)与悬停底(`-weak`)各要 ≥ 1.04,浓度调低到看不出选中时它会红(实测暗色弱档降到 2% 只有 1.026,正是这条门禁要拦的)。对比度门禁只管文字读不读得清,管不到「悬停有没有反馈」——实测过表面 → 二阶表面只有 1.008,悬停改了等于没改;`--json` 出逐档明细,`--list` 只列阶梯表。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、steps 至少 4 条(少于 4 条说明这组件还没想清楚边界)、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),反向也查孤儿演示(demo 里用 section() 定义的每个区块都必须被某个组件的 demo 字段认领,加了区块忘登记会直接报错),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 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 + audit:motion + audit:surface,提交前必过。其中 `smoke` 是三段断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 37 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除 + 越界页码回写 + setColumns 换列与失效规则剔除)+ `@ui-arch/ui` 8 条(皮肤层纯逻辑:行级差异的剪前后缀 / LCS 对齐 / 忽略空白 / 超限退化,跑的是 dist/diffKit.js);改内核语义或纯逻辑前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 18 组「前景色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档、焦点描边 × 页面底/表面)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua audit:motion` 守的是动效面,两类必查:① 带 `infinite` 的动画是否在 `prefers-reduced-motion: reduce` 块里被 `animation: none` 关掉——循环动画是前庭敏感用户的硬伤,漏一条整页就在抖(它抓出过转圈与不确定态进度两处漏网);② 悬停 / 按压的位移(`translate`)是否在减少动效下归零——位移最容易诱发前庭不适,且只提供手感、不表达状态(一条 `ua-` 类通配的 `transform: none !important` 兜住全站 14 处,新增组件不用各自补)。③ 旋转 / 缩放这类第三类空间动效(折叠箭头、按压缩放)是否有「只留颜色过渡」的收窄兜底——空间变换即时到位、状态仍看得见(一条通配 `transition-property` 收窄兜住全站 35 处)。`ua audit:surface` 补的是「底色 vs 底色」这条盲区:把表面阶梯(页面底 → 表面 → 悬停底 → 按下底,外加凹陷底)逐档按亮度比验,相邻档低于 1.04(凹陷档 1.02)即退出码 1;同一条阶梯还多验两档「半透明 accent 淡底压在表面上」——选中底(`--ua-color-tint-accent`)与悬停底(`-weak`)各要 ≥ 1.04,浓度调低到看不出选中时它会红(实测暗色弱档降到 2% 只有 1.026,正是这条门禁要拦的)。对比度门禁只管文字读不读得清,管不到「悬停有没有反馈」——实测过表面 → 二阶表面只有 1.008,悬停改了等于没改;`--json` 出逐档明细,`--list` 只列阶梯表。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、steps 至少 4 条(少于 4 条说明这组件还没想清楚边界)、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),反向也查孤儿演示(demo 里用 section() 定义的每个区块都必须被某个组件的 demo 字段认领,加了区块忘登记会直接报错),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 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 / layout / jsonViewer / diffView / codeBlock / signaturePad / heatmap / sparkline / commandPalette / noticeBar / progressRing / sortableList / masonry 十八段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥54 条(下限由段数 × 3 推导,加抽查位不必手改)且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。 diff --git a/packages/core/table-core/scripts/smoke.mjs b/packages/core/table-core/scripts/smoke.mjs index a3a6bec..a41b7f6 100644 --- a/packages/core/table-core/scripts/smoke.mjs +++ b/packages/core/table-core/scripts/smoke.mjs @@ -210,4 +210,42 @@ clamp.dispatch({ type: 'setPage', page: 9 }); // 显式跳到越界页 assert(clamp.getVisibleRows().rows.length === 1 && clamp.getState().pagination.page === 3, '跳越界页:派生钳制后回写为末页 3'); assert(clamp.getPageInfo().totalPages === 3, '收敛不改分页快照语义(totalPages 仍按 total 推算)'); +// setColumns:动态换列,失效的排序 / 筛选按 key 剔除,accessor 跟随新列集 +const colsTable = createTable({ + data: users, + columns: [ + { key: 'name', header: 'Name', sortable: true }, + { key: 'age', header: 'Age', sortable: true, accessor: (row) => row.age }, + { key: 'city', header: 'City' }, + ], + rowKey: 'id', + plugins: [filterPlugin(), sortPlugin(), paginationPlugin(), selectionPlugin()], + initialState: { + sorting: [{ key: 'age', dir: 'desc' }], + filters: [{ key: 'city', value: 'Beijing' }], + }, +}); +assert(colsTable.getVisibleRows().total === 2, 'setColumns 前:city 筛选生效(Beijing 2 行)'); +colsTable.setColumns([ + { key: 'name', header: 'Name', sortable: true }, + { key: 'score', header: 'Score', accessor: (row) => 100 - row.age }, // 换列集:city 列没了,age 列换成派生 score +]); +assert( + colsTable.getState().sorting.length === 0 && colsTable.getState().filters.length === 0, + 'setColumns 后:指向不存在列的排序 / 筛选整体剔除(age 列已换成 score)', +); +assert(colsTable.getVisibleRows().total === 5, 'setColumns 后:失效筛选被剔除,total 回到全量 5 行'); +assert(colsTable.getColumns().length === 2 && colsTable.getColumns()[1].key === 'score', 'setColumns 后:getColumns 返回新列集'); +assert( + colsTable.getCellValue(users[0], 'score') === 65, + 'setColumns 后:getCellValue 跟随新列 accessor(score = 100 - age)', +); +const sameCols = colsTable.getColumns(); +let colNotified = 0; +colsTable.subscribe(() => { + colNotified += 1; +}); +colsTable.setColumns(sameCols); +assert(colNotified === 0, 'setColumns 引用未变:短路不通知'); + console.log('SMOKE OK'); diff --git a/packages/core/table-core/src/core.ts b/packages/core/table-core/src/core.ts index 63cfddc..05b6000 100644 --- a/packages/core/table-core/src/core.ts +++ b/packages/core/table-core/src/core.ts @@ -14,7 +14,8 @@ export function createTable( options: TableOptions, ): TableInstance & Record { let data = options.data; // 可变:setRows 整体换数据源(筛选结果、轮询刷新),派生管线照旧跑 - const { columns, plugins = [], onStateChange } = options; + let cols = options.columns; // 可变:setColumns 动态换列(按角色 / 视图切换列集),失效的排序 / 筛选按 key 剔除 + const { plugins = [], onStateChange } = options; const rowKey = options.rowKey ?? ('id' as keyof T & string); // 默认行标识 const getKey: (row: T) => RowKey = @@ -64,7 +65,7 @@ export function createTable( /*** 取单元格值:优先列 accessor,缺省取 row[key] ***/ function getCellValue(row: T, key: string): unknown { - const col = columns.find((c) => c.key === key); + const col = cols.find((c) => c.key === key); if (col?.accessor) return col.accessor(row); return (row as Record)[key]; } @@ -115,14 +116,29 @@ export function createTable( for (const fn of listeners) fn(); } + /*** 整体替换列定义:引用未变则不动;换列后指向不存在列的排序 / 筛选剔除(列都没了,规则失去语义),分页 / 选择与列集无关、保持不动。 ***/ + function setColumns(next: typeof cols): void { + if (next === cols) return; + cols = next; + const keys = new Set(next.map((c) => c.key)); + const sorting = state.sorting.filter((s) => keys.has(s.key)); + const filters = state.filters.filter((f) => keys.has(f.key)); + if (sorting.length !== state.sorting.length || filters.length !== state.filters.length) { + state = { ...state, sorting, filters }; // 只动失效规则:页码 / 选择保持 + } + onStateChange?.(state); + for (const fn of listeners) fn(); + } + const instance: TableInstance = { getState: () => state, dispatch, subscribe, setRows, + setColumns, getVisibleRows, getAllRows: () => getVisibleRows(false), // 筛选 + 排序后、未分页的全量行(导出等场景) - getColumns: () => columns, + getColumns: () => cols, getCellValue, isRowSelected: (key) => state.selection.includes(key), }; diff --git a/packages/core/table-core/src/types.ts b/packages/core/table-core/src/types.ts index f969a48..014a637 100644 --- a/packages/core/table-core/src/types.ts +++ b/packages/core/table-core/src/types.ts @@ -91,6 +91,7 @@ export interface TableInstance { dispatch(action: Action): void; // 派发动作,唯一变更入口 subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数 setRows(rows: T[]): void; // 整体换数据源(筛选结果 / 轮询刷新),选择状态按 rowKey 剔除失效行 + setColumns(columns: ColumnDef[]): void; // 整体换列定义(按角色 / 视图切换列集),失效的排序 / 筛选按 key 剔除 getVisibleRows(): RowModel; // 可见行模型(过完整管线) getAllRows(): RowModel; // 筛选 + 排序后、未分页的全量行(导出用) getColumns(): ColumnDef[]; // 列定义