feat(interaction): data-options —— 下拉的选项从台账现读

芯片类型是人在件上现造的,写死在模版里等于「只能选出包那天就有的那几种」。
与 data-rows 同一层机制:只取值不判断,选中哪一个仍由 selectCurrent 按行内的值定。
空选项一律留着 ——「还没选」得说得出来。

brief 给的实现有两处会让测试落空,已按 TDD 修正并留痕在代码注释里:
①产出的 <select> 若留着 data-slot,renderTemplate 的 SLOT 规则会把整段 <option>
  当文本槽冲掉(<select>2</select>)—— fillOptions 需要把 data-slot 一并摘掉;
②selectCurrent 只在 renderRows 的逐行流程里被调用,不在任何 data-rows 里的下拉
  永远等不到人给它标 selected —— renderView 收尾再兜底调一遍(用外层作用域),
  已经被行内定过的整块原样放过(靠判断块内是否已有 selected,不靠字符串层面
  分不清的「是不是在行里」)。

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:03:45 +08:00
co-authored by Claude Opus 5
parent 9ab2f4b695
commit 81fb8179c2
5 changed files with 107 additions and 4 deletions
+1 -1
View File
@@ -7,6 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
@@ -435,3 +435,46 @@ describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
expect(all.split('使能逻辑').length - 1).toBe(2);
});
});
describe('data-options:下拉的选项从台账现读', () => {
const scope = {
'parts.chip_type': [
{ type_id: 1, type_name: 'DC-DC 降压稳压器' },
{ type_id: 2, type_name: 'LDO 线性稳压器' },
],
chip_type_id: 2,
};
it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => {
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=""></option>');
expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
});
it('选项文字要转义 —— 台账里的值是人填的', () => {
const out = renderView(
'<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
{ 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
);
expect(out).toContain('&lt;b&gt;坏&lt;/b&gt;');
expect(out).not.toContain('<b>坏</b>');
});
it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
expect(out).toContain('<option value=""></option>');
expect(out).not.toContain('undefined');
});
it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
const html =
'<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>';
const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
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);
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ export {
outputControl,
templateIsSafe,
} from './internal/domain/control-handover.ts';
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
@@ -283,9 +283,17 @@ const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/se
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
*
* **已经标过 `selected` 的整块原样放过**(2026-08-29,`data-options` 逼出来的):
* `renderRows` 逐行展开时会用**这一行自己的作用域**先调一遍这个函数;`renderView`
* 收尾时还会再拿**外层作用域**兜底调一遍,专为处理那些根本不在任何 `data-rows`
* 里的下拉(选项从台账现读但压根没有行可展开)。行内那一遍已经按这一行自己的值
* 定过了,收尾那一遍不该拿外层作用域覆盖它 —— 两次调用谁先谁后靠这一条区分,
* 不靠「是不是在行里」这种字符串层面分不清的信息。
*/
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;
const raw = resolveSlot(scope, col);
if (raw === undefined || raw === null || raw === '') return block;
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -312,7 +320,58 @@ function textareaCurrent(inner: string, scope: Readonly<Record<string, unknown>>
});
}
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
/** `<select … data-options="域.对象" data-option-value="列" data-option-label="列">` */
const OPTIONS_SELECT = /<select\b([^>]*?)\bdata-options\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/select>/g;
/**
* **下拉的选项从台账现读**(2026-08-29,spec §5.3)。
*
* 与 `data-rows` 同一层机制:**只取值,不判断**。地址写法也一致
* (`域.对象` = 任务视图,`mine:域.对象` = 本单草稿)。
*
* 为什么必须是现读而不是写死在模版里:芯片类型是**人在件上现造**的,
* 写死等于「只能选我出包那天就有的那几种」。
*
* **在行展开之前跑一遍**(`renderView` 的最内层):选项与行无关,一次铺好,
* 行展开时整段复制进每一行;哪一个 `selected` 由 `selectCurrent` 按各行自己的值定。
* 空选项一律留着 ——「还没选」得说得出来。
*
* `data-slot` 若与 `data-options` 同时出现在同一个 `<select>` 上,这里要把它摘掉:
* `renderTemplate` 的 `SLOT` 规则认标签名不认标签种类,凡带 `data-slot` 的元素一律
* 把**整段内容**换成取值的文本 —— `<select>` 的内容是一串 `<option>`,真被当成文本槽
* 处理,选项会被冲成一行数字/文字(踩过一次:`<select>2</select>`)。
*/
export function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string {
return html.replace(OPTIONS_SELECT, (_all, before: string, address: string, after: string) => {
const raw = `${before}${after}`;
const valueCol = /\bdata-option-value\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? 'value';
const labelCol = /\bdata-option-label\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? valueCol;
const attrs = raw
.replace(/\s*\bdata-option-value\s*=\s*"[^"]*"/, '')
.replace(/\s*\bdata-option-label\s*=\s*"[^"]*"/, '')
.replace(/\s*\bdata-slot\s*=\s*"[^"]*"/, '')
.replace(/\s+/g, ' ')
.trim();
const rows = resolveSlot(scope, address);
const list = Array.isArray(rows) ? rows : [];
const options = list
.map((row) => {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const value = escapeHtml(displayValue(one[valueCol]));
const label = escapeHtml(displayValue(one[labelCol]));
return `<option value="${value}">${label}</option>`;
})
.join('');
return `<select${attrs === '' ? '' : ` ${attrs}`}><option value=""></option>${options}</select>`;
});
}
/**
* 先铺下拉选项(行展开之前,一次铺好)→ 展开行(行内的下拉照各行自己的值定 `selected`)
* → **收尾再兜底调一遍 `selectCurrent`**(那些压根不在任何 `data-rows` 里的下拉,
* 没有「行」这一步替它调,靠这里用外层作用域补上;已经被行内那一遍定过的原样放过,
* 见 `selectCurrent` 内部的判重)→ 最后填标量槽。顺序不能反。
*/
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
return renderTemplate(renderRows(html, scope), scope);
return renderTemplate(selectCurrent(renderRows(fillOptions(html, scope), scope), scope), scope);
}
+1
View File
@@ -112,6 +112,7 @@
| `data-blank` | 与 `data-slot` 同元素 | 空值**真空**(不写「无」)—— 空格子的胶囊、占位交给 CSS `:empty::after` |
| `data-class-of="键"` | 任意元素 | 把值消毒成 `v-<小写值>` 追加进 class(数值就是 `v-0`/`v-1`/…)。**判断留在样式表** |
| `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) |
| `data-options="域.对象"`(配 `data-option-value` / `data-option-label`) | `<select>` | **选项从台账现读**:一行一个 `<option>`,`value` 取值列、显示取名列,前面固定留一个空选项(「还没选」得说得出来)。地址写法与 `data-rows` 一致。选中哪一个仍由 `selectCurrent` 按这一行的值定 —— 模版这一侧照旧只取值不判断 |
| `data-edit="域.对象"` | 可改表的行容器 | 这组行可就地改;保存按它认地址 |
| `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) |
| `data-file-of="主键列"` | 行内任意元素 | 这一格**点开是一份资料文件**:渲成 `data-file="域.对象/主键值"`,外层接住就跳到查看面。那一列空着就整个属性去掉 —— 没有文件的行不该长得像能点开 |