diff --git a/.ua/backlog.json b/.ua/backlog.json index 40ff9b4..6d5b7aa 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3365,6 +3365,15 @@ "motivation": "dataTable 的单元格只有 format 一条路(纯文本),状态列只能是「已通过」四个字而不是 tag,头像列只能是 URL,editable 刚做出来却放不进表格——写实业务里表格是主战场,这一格塞不进组件,整套组件库在详情页能用、在列表页就退化成纯文本。", "acceptance": "① TableColumn 增可选 render: (row) => unknown:传了即取代 format 决定这一格画什么,排序 / 筛选 / 导出仍按原值走;② 自定义单元格拦下点击冒泡,点格里的按钮不会顺带触发 onRowClick(整行可点时尤其重要);③ 不传 render 的列行为零变化(format 走 textContent,天然免注入);④ manifest 的列定义与步骤同步说明,demo 城市列改成标签渲染并在段落里点明格内点击不触发整行;⑤ npm run check 全绿。", "notes": "" + }, + { + "id": "g331", + "domain": "tooling", + "status": "open", + "title": "配方 22:表格里就地改数据(editable + dataTable)", + "motivation": "editable 与列的 render 都刚落地,但 Agent 拿到两个组件未必拼得对:把草稿信号当数据源(Esc 取消后表格显示未保存的值)、提交后整表换源冲掉别的格草稿、用 format 塞 DOM、在格内点击里又触发整行回调——这四条都是拼装顺序问题,不是组件问题,正好该由配方承接。", + "acceptance": "① recipes.md 新增配方 22(编号连续、含最小骨架与要点),骨架用 dataTable({ data: rows }) + 列 render 包 editable,草稿信号与数据源分离;② 要点覆盖「唯一数据源 / render 只决定画什么 / 整表换源的副作用 / 校验口径与 form 一致 / 格内点击被拦 / 纯文本列仍用 format」六条;③ ua recipes 22 能取出骨架与组件清单,MCP get_recipe 同源可见;④ npm run check 全绿。", + "notes": "" } ] } diff --git a/llms-full.txt b/llms-full.txt index 90c6baf..05c3519 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -3441,6 +3441,45 @@ card({ title: '消息中心' }, - `relativeTime` 默认每 30 秒自刷新,「3 分钟前」自己会走字;列表重建后新实例接着算,不用手动更新时间文案。条数极多时把 `list` 换 `virtualList`,并把 `refresh` 调大或传 0。 - 通知条目要可操作时在 list 的 `onItemClick` 里跳详情或调 `notification`(右下角)补一条带按钮的强提醒;轻提示走 `toast`,别在同一屏堆两层浮层。 +## 配方 22:表格里就地改数据(editable + dataTable) + +场景:后台列表要改一个预算、改一个区域名,不想让用户点进详情页再回来。 + +```ts +type Row = { id: string; name: string; budget: string }; +const rows = signal([ + { id: 'r1', name: '华东区', budget: '120' }, + { id: 'r2', name: '华南区', budget: '95' }, +]); + +/** 回写单格:只有 editable 的 onCommit 会走到这里,Esc 取消根本不触发,所以取消不会污染数据 */ +const patch = (id: string, key: 'name' | 'budget', value: string): void => { rows(rows().map((r) => (r.id === id ? { ...r, [key]: value } : r))); }; // 只改这一行这一列 + +/** 一格的草稿信号:初值取自行数据,与 rows 解耦——编辑中不会被别处的刷新冲掉 */ +const cell = (row: Row, key: 'name' | 'budget', title: string) => editable({ + value: signal(row[key]), // 草稿缓冲,不是数据源 + label: title, + validate: (v) => (v.trim() ? null : `${title}不能为空`), // 与 form 同口径:返回文案即不提交 + onCommit: (v) => { patch(row.id, key, v); toast.success(`${title}已保存`); }, +}); + +dataTable({ + data: rows, // 传信号:提交回写后内核整表换源,重建出的格子拿到新草稿 + columns: [ + { key: 'name', header: '区域', width: 200, render: (row) => cell(row, 'name', '区域') }, // render 决定这一格画什么 + { key: 'budget', header: '预算(万)', align: 'right', render: (row) => cell(row, 'budget', '预算') }, + ], + pageSize: 5, +}); +``` + +要点: +- 唯一数据源仍是 `rows`:`editable` 里的信号只是草稿缓冲,提交才回写。反过来把草稿当数据源,Esc 取消后表格显示的就是「没保存的值」。 +- 列的 `render` 只负责画什么,排序 / 筛选 / 导出仍按 `data` 里的原值走——所以行数据保持原始字符串,别为了显示把数据换成 DOM。 +- 提交会让内核整表换源,正在编辑的另一格草稿会被重建冲掉:一次只编辑一格是行内编辑的合理约束,要并行编辑就把草稿提到 `rows` 之外的状态里自己管。 +- 校验口径与 `form` 一致:`validate` 返回文案则不提交、保持编辑态并标红(`aria-invalid` + `role=alert`),用户改对再提交即可,不要在回调里弹 toast 了事。 +- 格内的点击会被拦下,不会触发 `onRowClick`:点输入框不该等于「打开详情」。反过来说,想整行进详情就别把可交互控件铺满整行。 +- 纯文本列继续用 `format`:它走 `textContent`,天然免注入;`render` 只在真要塞组件(tag / avatar / progress / editable)时才用。 ## 通用约定 - 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。 diff --git a/llms.txt b/llms.txt index 37427e1..81ddf98 100644 --- a/llms.txt +++ b/llms.txt @@ -196,3 +196,4 @@ - 配方 19:可排序的检查单(拖拽 + 键盘搬位 + 顺序回写)(5 条要点) - 配方 20:分步表单向导(步进条 + 按步校验 + 完成反馈)(7 条要点) - 配方 21:消息中心(铃铛角标 + 抽屉通知流)(6 条要点) +- 配方 22:表格里就地改数据(editable + dataTable)(6 条要点) diff --git a/packages/tools/cli/recipes.md b/packages/tools/cli/recipes.md index 13a9490..0c8b2c5 100644 --- a/packages/tools/cli/recipes.md +++ b/packages/tools/cli/recipes.md @@ -509,6 +509,45 @@ card({ title: '消息中心' }, - `relativeTime` 默认每 30 秒自刷新,「3 分钟前」自己会走字;列表重建后新实例接着算,不用手动更新时间文案。条数极多时把 `list` 换 `virtualList`,并把 `refresh` 调大或传 0。 - 通知条目要可操作时在 list 的 `onItemClick` 里跳详情或调 `notification`(右下角)补一条带按钮的强提醒;轻提示走 `toast`,别在同一屏堆两层浮层。 +## 配方 22:表格里就地改数据(editable + dataTable) + +场景:后台列表要改一个预算、改一个区域名,不想让用户点进详情页再回来。 + +```ts +type Row = { id: string; name: string; budget: string }; +const rows = signal([ + { id: 'r1', name: '华东区', budget: '120' }, + { id: 'r2', name: '华南区', budget: '95' }, +]); + +/** 回写单格:只有 editable 的 onCommit 会走到这里,Esc 取消根本不触发,所以取消不会污染数据 */ +const patch = (id: string, key: 'name' | 'budget', value: string): void => { rows(rows().map((r) => (r.id === id ? { ...r, [key]: value } : r))); }; // 只改这一行这一列 + +/** 一格的草稿信号:初值取自行数据,与 rows 解耦——编辑中不会被别处的刷新冲掉 */ +const cell = (row: Row, key: 'name' | 'budget', title: string) => editable({ + value: signal(row[key]), // 草稿缓冲,不是数据源 + label: title, + validate: (v) => (v.trim() ? null : `${title}不能为空`), // 与 form 同口径:返回文案即不提交 + onCommit: (v) => { patch(row.id, key, v); toast.success(`${title}已保存`); }, +}); + +dataTable({ + data: rows, // 传信号:提交回写后内核整表换源,重建出的格子拿到新草稿 + columns: [ + { key: 'name', header: '区域', width: 200, render: (row) => cell(row, 'name', '区域') }, // render 决定这一格画什么 + { key: 'budget', header: '预算(万)', align: 'right', render: (row) => cell(row, 'budget', '预算') }, + ], + pageSize: 5, +}); +``` + +要点: +- 唯一数据源仍是 `rows`:`editable` 里的信号只是草稿缓冲,提交才回写。反过来把草稿当数据源,Esc 取消后表格显示的就是「没保存的值」。 +- 列的 `render` 只负责画什么,排序 / 筛选 / 导出仍按 `data` 里的原值走——所以行数据保持原始字符串,别为了显示把数据换成 DOM。 +- 提交会让内核整表换源,正在编辑的另一格草稿会被重建冲掉:一次只编辑一格是行内编辑的合理约束,要并行编辑就把草稿提到 `rows` 之外的状态里自己管。 +- 校验口径与 `form` 一致:`validate` 返回文案则不提交、保持编辑态并标红(`aria-invalid` + `role=alert`),用户改对再提交即可,不要在回调里弹 toast 了事。 +- 格内的点击会被拦下,不会触发 `onRowClick`:点输入框不该等于「打开详情」。反过来说,想整行进详情就别把可交互控件铺满整行。 +- 纯文本列继续用 `format`:它走 `textContent`,天然免注入;`render` 只在真要塞组件(tag / avatar / progress / editable)时才用。 ## 通用约定 - 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。