汇报流:活动按步序位置推视图,容器沙箱渲染

「flow:按**步序位置**推送声明的模版」(AM 07)。原先这条链只有写面没有读面,
活动的 viewTemplates 是一行占位字符串,工作台视图区永远只显示一份硬编码概览。

补齐整条链:
  · 步声明新增 `view`:这一步呈现哪张模版 —— 推什么由声明给定,不由临场决定。
  · 新增模版填数的纯函数(interaction/domain):`data-slot` 取值、`data-rows` 逐条展开。
    **填数只做取值与转义**,没有表达式、没有分支、没有循环 —— 一旦模版能算,
    它就成了代码,容器的「无脚本执行权」也就形同虚设。填充**消费槽位**,
    交出去的是干净 HTML,不是带模板属性的半成品。
  · interaction 补读面 `viewOf`;desk 路由把推送的 HTML 交给前端;
    ViewRegion 经 `@ui` 的 ViewContainer(sandbox="" 的 iframe)渲染 —— feature 不碰 innerHTML。
  · A1 写了三张真模版(事态全景 / 打标判据 / 成果预览),内联自带样式
    (沙箱页面不继承工作台 CSS),按步序位置挂在 S3 / S5 / S6 上。

沿路两处修:
① 取值路径遇到**本身带点号的键**(台账地址 `域.对象`)会被拆成两级、永远取不到。
   改为整键优先、找不到再下行;`view-render` 与 `step-sequence` 两处同修。
② 「本单交付」不能画成台账全表:任务视图是正式版 + 我的草稿的合并结果,
   直接拿来列会把**别单**放行过的记录一并列出。本单自己写下的行单独放在
   `mine:域.对象` 下(整键,取值不歧义)。

另:视图推送失败原先静默吞掉 —— 会让「视图区一直空着」变成查不出原因的怪事;
改为留痕(仍不影响步序推进,呈现是投影)。

实测:一封 RFQ 入站 → 放行前那一屏列出本单交付 LM358DR / TI / 12000 / 8.2 / 10周,
以及 Type A「直接快答」的分类结论与四项打标判据。

gates: tsc / eslint / depcruise(321 模块)/ api-report / 423 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 10:47:20 +08:00
co-authored by Claude Opus 5
parent abd371b51c
commit ac2fecd005
29 changed files with 574 additions and 10 deletions
@@ -0,0 +1,91 @@
/**
* 视图模版填数 —— 纯函数、零 IO(编码规范 §10.2;语义出处 AM 07)。
*
* 「**模版是内容不是代码**:AI 按局面选模版、填数据、决定下一屏呈现什么。」
* 所以填数只做**取值与转义**两件事:没有表达式、没有分支、没有循环 ——
* 一旦模版能算,它就成了代码,容器的「无脚本执行权」也就形同虚设。
*
* 约定:`data-slot="路径"` 的元素,内容替换为作用域上该路径的值。
* 路径按点号下行、数组下标按数字段(`records.0.pn`),与步声明的取值写法一致。
*/
/** 只在**文本位置**插值,故只需转义这四个字符;属性位不开放插值 */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function resolveSlot(scope: unknown, path: string): unknown {
/*
* 整键优先:作用域里有些键**本身带点号**(台账地址 `域.对象`)。
* 先按字面整键找,找不到再按点号下行 —— 反过来会把地址键拆成两级、永远找不到。
*/
if (typeof scope === 'object' && scope !== null && !Array.isArray(scope)) {
const direct = (scope as Record<string, unknown>)[path];
if (direct !== undefined) return direct;
}
return path.split('.').reduce<unknown>((acc, seg) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
if (Array.isArray(acc)) {
const index = Number(seg);
return Number.isInteger(index) ? acc[index] : undefined;
}
return (acc as Record<string, unknown>)[seg];
}, scope);
}
/** 值 → 展示文本:空值显示为「—」,免得画面上留一串空洞 */
export function displayValue(value: unknown): string {
if (value === undefined || value === null || value === '') return '—';
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
if (typeof value === 'boolean') return value ? '是' : '否';
if (Array.isArray(value)) return String(value.length);
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
/**
* 把作用域填进模版的槽位。
*
* 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域
* 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。
* 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。
*/
export function renderTemplate(html: string, scope: Readonly<Record<string, unknown>>): string {
return html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trimEnd();
const text = escapeHtml(displayValue(resolveSlot(scope, path)));
return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}</${tag}>`;
});
}
/**
* 行模版:`data-rows="路径"` 的元素,按数组逐项复制其内部结构。
* 每行内部的 `data-slot` 相对**该行对象**取值 —— 这样「逐条清单」不必写死条数。
*/
const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trim();
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
const value = resolveSlot(scope, path);
const rows = Array.isArray(value) ? value : [];
const body = rows
.map((row) =>
renderTemplate(inner, (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>),
)
.join('');
return `${open}${body}</${tag}>`;
});
}
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
return renderTemplate(renderRows(html, scope), scope);
}