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" 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>` */
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 用户实测截图:「点了编辑后直接就变白了」)。
*
@@ -284,24 +306,28 @@ const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/se
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
*
* **已经标过 `selected` 的整块原样放过**(2026-08-29,`data-options` 逼出来的):
* `renderRows` 逐行展开时会用**这一行自己的作用域**先调一遍这个函数;`renderView`
* 收尾时还会再拿**外层作用域**兜底调一遍,专为处理那些根本不在任何 `data-rows`
* 里的下拉(选项从台账现读但压根没有行可展开)。行内那一遍已经按这一行自己的值
* 定过了,收尾那一遍不该拿外层作用域覆盖它 —— 两次调用谁先谁后靠这一条区分,
* 不靠「是不是在行里」这种字符串层面分不清的信息。
* 处理完(不管有没有真标出 `selected`)都打上 `SEL_SCOPED` 标记 —— 已经打过标记的块原样放过,
* 见 `SEL_SCOPED` 的文档注释。
*/
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): 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);
if (raw === undefined || raw === null || raw === '') return block;
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`);
return optionRe.test(block) ? block.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`) : block;
let marked = block;
if (raw !== undefined && raw !== null && raw !== '') {
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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>` */
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`)
* → **收尾再兜底调一遍 `selectCurrent`**(那些压根不在任何 `data-rows` 里的下拉,
* 没有「行」这一步替它调,靠这里用外层作用域补上;已经被行内那一遍定过的原样放过,
* 见 `selectCurrent` 内部的判重)→ 最后填标量槽。顺序不能反。
* 见 `SEL_SCOPED` 的文档注释)→ 填标量槽 → **剥掉 `selectCurrent` 留下的内部记号**
* (`stripSelScoped`,渲染期的判重记号,不该出现在交给沙箱的成品里)。顺序不能反。
*/
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));
}