Archived
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:
@@ -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('<b>坏</b>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user