fix(interaction): data-options 收尾防重改认显式标记,不再靠「有没有 selected 这个词」

评审复现了一个真泄漏:selectCurrent 用 /\bselected\b/.test(block) 当「行内那一遍
处理过没有」的代理 —— 而一行被正确处理过、且合法地落在「没有选中项」上(值不在
选项里,或这一格还没填),跟「压根没被处理过」在字面上是同一个样子。renderView
收尾那一遍拿外层作用域重新处理这块,若外层恰好有个同名裸键命中某个选项,就会把
它错误地渗进这一行 —— 界面撒谎:显示一个这一行数据里根本不存在的选中态。

改记一个跟内容无关的标记属性 data-sel-scoped:处理完(不管有没有真标出 selected)
都打上,下一遍见到标记就跳过;renderView 收尾前把它整个剥掉,不留进交给沙箱的成品。
顺带治了评审点的 Minor #1:旧写法还会被 <option> 显示文字里恰好出现的 "selected"
误判(枚举标签写成 "Selected Value" 那种)。

三条新用例:行内下拉值不在选项里 / 新加的空行,均验证不再被外层同名值渗透
(RED 证实了泄漏确实发生过);行外独立下拉仍正确标好 selected(防重归防重,
不能把本该工作的那条也防没了)。

docs/HTML报告生成规范.md 补一句:data-options 与 data-slot 不能共存,后者会被
fillOptions 摘掉不起作用(评审 Minor #2)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 06:21:26 +08:00
co-authored by Claude Opus 5
parent 81fb8179c2
commit 6f721ffc6d
3 changed files with 75 additions and 13 deletions
@@ -477,4 +477,33 @@ describe('data-options:下拉的选项从台账现读', () => {
expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1); expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1); expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
}); });
/*
* 评审逮到的渗透:「块里有没有 selected 这个词」防不住重复处理 ——
* 一行被正确处理过、且合法地落在「没有选中项」上(值不在选项里 / 这一格还没填),
* 跟「压根没被处理过」在字面上是同一个样子:都没有 selected 这个词。
* `renderView` 收尾那一遍拿的是外层作用域,这里的外层 `chip_type_id: 2` 恰好命中
* 选项列表里的 LDO —— 若防重失灵,它会被错误地渗进这一行,而这一行自己的数据
* 说的明明是「没有选中」。
*/
const rowHtml =
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
'</tbody>';
it('行内下拉:该行的值不在选项里 —— 不许被外层同名的值渗进来标 selected', () => {
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{ chip_type_id: 99 }] });
expect(out).not.toContain('selected');
});
it('行内下拉:新加的空行(这一列还没填)—— 同样不许被外层同名的值渗进来', () => {
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{}] });
expect(out).not.toContain('selected');
});
it('行外的独立下拉仍按顶层作用域正确标好 selected(防重不能把这条也防没了)', () => {
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
const out = renderView(html, scope);
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
});
}); });
@@ -272,6 +272,28 @@ function twinInputs(inner: string, rowIndex: number): string {
/** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */ /** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */
const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g; const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g;
/**
* **收尾防重的记号**(2026-08-29,评审逮到:「有没有 `selected` 这个词」防不住重复处理)。
*
* `renderRows` 逐行展开时用**这一行自己的作用域**先调一遍 `selectCurrent`;`renderView`
* 收尾时还会再拿**外层作用域**兜底调一遍,专为处理那些根本不在任何 `data-rows` 里的下拉
* (见 `selectCurrent` 的文档注释)。两次谁先谁后要能分清「这块处理过没有」——
* **不能拿「块里有没有 `selected` 这个词」当代理**:一行被正确处理过、且合法地落在
* 「没有选中项」上(这一行的值不在选项里,或这一格还没填),跟「压根没被处理过」在字面上
* 是同一个样子,都没有这个词。若外层作用域里恰好有一个同名的裸键、且它的值命中了某个选项
* (「行内 `chip_type_id` 与外层同名」这种情况在这仓的用法下并不罕见),收尾那一遍会把它
* **错误地** 渗进这一行 —— 界面撒了谎:下拉显示一个这一行的数据里根本不存在的选中态,
* 值是从一个无关字段借来的。这个词也可能出现在 `<option>` 自己的**显示文字**里
* (枚举标签写成「Selected Value」照样会误判)。
*
* 所以改记一个**显式的、跟内容无关的标记属性**:处理过(不管有没有真标出选中项)就打上,
* 下一遍见到标记就跳过。它是渲染期的内部记号,不该出现在交给沙箱的成品里 ——
* `renderView` 收尾前把它整个剥掉(`stripSelScoped`)。
*/
const SEL_SCOPED = /\bdata-sel-scoped\b/;
/** 剥掉 `SEL_SCOPED` 标记 —— 连同它前面那个空格一起,免得成品里留一段多余空白 */
const SEL_SCOPED_ATTR = /\s*\bdata-sel-scoped\b/g;
/** /**
* **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。 * **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。
* *
@@ -284,24 +306,28 @@ const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/se
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义: * (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。 * 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
* *
* **已经标过 `selected` 的整块原样放过**(2026-08-29,`data-options` 逼出来的): * 处理完(不管有没有真标出 `selected`)都打上 `SEL_SCOPED` 标记 —— 已经打过标记的块原样放过,
* `renderRows` 逐行展开时会用**这一行自己的作用域**先调一遍这个函数;`renderView` * 见 `SEL_SCOPED` 的文档注释。
* 收尾时还会再拿**外层作用域**兜底调一遍,专为处理那些根本不在任何 `data-rows`
* 里的下拉(选项从台账现读但压根没有行可展开)。行内那一遍已经按这一行自己的值
* 定过了,收尾那一遍不该拿外层作用域覆盖它 —— 两次调用谁先谁后靠这一条区分,
* 不靠「是不是在行里」这种字符串层面分不清的信息。
*/ */
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string { function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
return inner.replace(SELECT_CELL, (block, col: string) => { return inner.replace(SELECT_CELL, (block, col: string) => {
if (/\bselected\b/.test(block)) return block; if (SEL_SCOPED.test(block)) return block;
const raw = resolveSlot(scope, col); const raw = resolveSlot(scope, col);
if (raw === undefined || raw === null || raw === '') return block; let marked = block;
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (raw !== undefined && raw !== null && raw !== '') {
const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`); const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return optionRe.test(block) ? block.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`) : block; const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`);
if (optionRe.test(marked)) marked = marked.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`);
}
return marked.replace(/^(<select\b[^>]*?)>/, (_m, open: string) => `${open} data-sel-scoped>`);
}); });
} }
/** `renderView` 收尾前剥掉 `selectCurrent` 留下的内部记号 —— 交出去的是干净 HTML */
function stripSelScoped(html: string): string {
return html.replace(SEL_SCOPED_ATTR, '');
}
/** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */ /** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */
const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g; const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g;
@@ -370,8 +396,9 @@ export function fillOptions(html: string, scope: Readonly<Record<string, unknown
* 先铺下拉选项(行展开之前,一次铺好)→ 展开行(行内的下拉照各行自己的值定 `selected`) * 先铺下拉选项(行展开之前,一次铺好)→ 展开行(行内的下拉照各行自己的值定 `selected`)
* → **收尾再兜底调一遍 `selectCurrent`**(那些压根不在任何 `data-rows` 里的下拉, * → **收尾再兜底调一遍 `selectCurrent`**(那些压根不在任何 `data-rows` 里的下拉,
* 没有「行」这一步替它调,靠这里用外层作用域补上;已经被行内那一遍定过的原样放过, * 没有「行」这一步替它调,靠这里用外层作用域补上;已经被行内那一遍定过的原样放过,
* 见 `selectCurrent` 内部的判重)→ 最后填标量槽。顺序不能反。 * 见 `SEL_SCOPED` 的文档注释)→ 填标量槽 → **剥掉 `selectCurrent` 留下的内部记号**
* (`stripSelScoped`,渲染期的判重记号,不该出现在交给沙箱的成品里)。顺序不能反。
*/ */
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string { export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
return renderTemplate(selectCurrent(renderRows(fillOptions(html, scope), scope), scope), scope); return stripSelScoped(renderTemplate(selectCurrent(renderRows(fillOptions(html, scope), scope), scope), scope));
} }
+6
View File
@@ -117,6 +117,12 @@
| `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) | | `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) |
| `data-file-of="主键列"` | 行内任意元素 | 这一格**点开是一份资料文件**:渲成 `data-file="域.对象/主键值"`,外层接住就跳到查看面。那一列空着就整个属性去掉 —— 没有文件的行不该长得像能点开 | | `data-file-of="主键列"` | 行内任意元素 | 这一格**点开是一份资料文件**:渲成 `data-file="域.对象/主键值"`,外层接住就跳到查看面。那一列空着就整个属性去掉 —— 没有文件的行不该长得像能点开 |
**`data-options` 与 `data-slot` 不能共存**(2026-08-29):同一个 `<select>` 上若还写了
`data-slot`,渲染时会被 `fillOptions` 一并摘掉、不起作用 —— `data-slot` 换的是**文本**,
而 `<select>` 的内容是一串 `<option>`,留着它会被后面的槽位填值当成普通文本槽,把刚铺好的
选项整段冲成一行数字/文字。**下拉的「当下值」由 `data-col` 决定**(`selectCurrent` 认它),
不必也不该再写 `data-slot`。
**资料文件**(2026-08-23):一行台账还可以是一份**真文件**。台账声明里写 **资料文件**(2026-08-23):一行台账还可以是一份**真文件**。台账声明里写
`file: { titleColumn, bodyColumn, formatColumn }`(它同时是授权面 —— 不声明就点不开), `file: { titleColumn, bodyColumn, formatColumn }`(它同时是授权面 —— 不声明就点不开),
正文一律 Markdown 原文写在 `bodyColumn`,格式列写 `md` / `html` / `docx` / `xlsx`; 正文一律 Markdown 原文写在 `bodyColumn`,格式列写 `md` / `html` / `docx` / `xlsx`;