**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。
**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算
**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」
**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。
验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
289 lines
16 KiB
TypeScript
289 lines
16 KiB
TypeScript
/**
|
|
* 视图模版填数 —— 纯函数、零 IO(编码规范 §10.2;语义出处 AM 07)。
|
|
*
|
|
* 「**模版是内容不是代码**:AI 按局面选模版、填数据、决定下一屏呈现什么。」
|
|
* 所以填数只做**取值与转义**两件事:没有表达式、没有分支、没有循环 ——
|
|
* 一旦模版能算,它就成了代码,容器的「无脚本执行权」也就形同虚设。
|
|
*
|
|
* 约定:`data-slot="路径"` 的元素,内容替换为作用域上该路径的值。
|
|
* 路径按点号下行、数组下标按数字段(`records.0.pn`),与步声明的取值写法一致。
|
|
*/
|
|
|
|
/** 只在**文本位置**插值,故只需转义这四个字符;属性位不开放插值 */
|
|
export function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
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-blank`:这一格空着的时候**真的留空**,不填「—」 */
|
|
const BLANK = /\s*\bdata-blank(?:\s*=\s*"[^"]*")?/g;
|
|
/** `data-class-of="路径"`:把该路径的值消毒成一个 class 后缀 —— **按值上色**用 */
|
|
const CLASS_OF = /\s*\bdata-class-of\s*=\s*"([^"]+)"/;
|
|
|
|
/**
|
|
* 值 → class 后缀。**只留 ASCII 字母数字与横杠,全部小写,封顶 24 字**;
|
|
* 一个字符都不合规就不给 class(宁可没有颜色,不给一个错的钩子)。
|
|
*
|
|
* 为什么要有它:模版不许算,于是「A 绿 / B 橙 / C 紫」这类**按值分档的表现**
|
|
* 从前一概做不了 —— 只能三个值一个色,那等于假装读懂了值。
|
|
* 而把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
|
* 消毒是硬的:属性位不开放插值,这里是唯一一处值进属性,所以只放行白名单字符。
|
|
*/
|
|
export function classToken(value: unknown): string {
|
|
if (value === undefined || value === null) return '';
|
|
const text = String(typeof value === 'boolean' ? (value ? 'true' : 'false') : value)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]/g, '');
|
|
return text.slice(0, 24);
|
|
}
|
|
|
|
/**
|
|
* 把作用域填进模版的槽位。
|
|
*
|
|
* 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域
|
|
* 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。
|
|
* 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。
|
|
*
|
|
* ── 例外:`data-blank` ────────────────────────────────────────────────
|
|
* 表格里「这一格客户没给」和「这一格填了个破折号」是两件事。带 `data-blank`
|
|
* 的槽位空值就**留空**,交给模版自己的 CSS 去标(`td:empty::after` 出「必填 / 选填」
|
|
* 胶囊、`tr:has(td.req:empty)` 整行标底)—— 判断仍在样式里,不在这里:
|
|
* 填数还是只做取值与转义两件事,没有表达式、没有分支。
|
|
*/
|
|
export function renderTemplate(html: string, scope: Readonly<Record<string, unknown>>): string {
|
|
const filled = html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
|
const raw = `${before}${after}`;
|
|
const blank = /\bdata-blank\b/.test(raw);
|
|
const attrs = paintClass(raw.replace(BLANK, ''), scope).replace(/\s+/g, ' ').trimEnd();
|
|
const value = resolveSlot(scope, path);
|
|
const empty = value === undefined || value === null || value === '';
|
|
const text = blank && empty ? '' : escapeHtml(displayValue(value));
|
|
return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}</${tag}>`;
|
|
});
|
|
/*
|
|
* **值进 value 属性的那一种** —— `<input … data-slot="pn" data-value/>`。
|
|
*
|
|
* 上一遍认的是「开合标签之间填字」,而 input 是空元素,没有「之间」。
|
|
* 这一份是可改格子的输入孪生件(见 `twinInputs`):值得进 `value=`,
|
|
* 空值就真空着 —— 空的 input 会显示自己的 placeholder,那正是「这一格还没有」。
|
|
* 属性位插值只此一处,所以转义照文本位那四个字符来,引号也在其中。
|
|
*/
|
|
const withValues = filled.replace(
|
|
/<input\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*?)\bdata-value\b([^>]*)>/g,
|
|
(_all, before: string, path: string, mid: string, after: string) => {
|
|
const value = resolveSlot(scope, path);
|
|
const empty = value === undefined || value === null || value === '';
|
|
// 末尾那个自闭合斜杠会被 `after` 一并捞进来 —— 不摘掉就写出 `/ value="…"`
|
|
const attrs = `${before}${mid}${after}`.replace(/\/\s*$/, '').replace(/\s+/g, ' ').trim();
|
|
return `<input ${attrs} value="${empty ? '' : escapeHtml(displayValue(value))}"/>`;
|
|
},
|
|
);
|
|
/*
|
|
* **只上色、不填字的那些格子** —— 剩下的 `data-class-of` 单独走一遍。
|
|
*
|
|
* 上一遍只认「带 data-slot 的元素」,于是分布条那种**纯色块**
|
|
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
|
* 属性还在,一个 class 都没长出来,屏上是一排灰格子 —— 而灰格子看着像
|
|
* 「还没算出来」,不像「没上色」。两遍都栽过同一个坑,所以把两件事分开:
|
|
* 取值填字是一件事,按值上色是另一件事,后者不该以前者为条件。
|
|
*/
|
|
return withValues.replace(
|
|
/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-class-of\s*=\s*"[^"]+"[^>]*)>/g,
|
|
(_all, tag: string, attrs: string) => {
|
|
const painted = paintClass(attrs, scope).replace(/\s+/g, ' ').trimEnd();
|
|
return `<${tag}${painted === '' ? '' : ` ${painted.trim()}`}>`;
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `data-class-of="路径"` → 把该路径的值消毒后追加成 `v-<值>`,并**消费掉属性本身**。
|
|
* 消费是要紧的:交出去的是干净 HTML,行模版复制第二遍时也不会再追加一次。
|
|
*/
|
|
function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): string {
|
|
const classOf = CLASS_OF.exec(attrs);
|
|
if (classOf === null) return attrs;
|
|
const rest = attrs.replace(CLASS_OF, '');
|
|
const token = classToken(resolveSlot(scope, classOf[1] ?? ''));
|
|
if (token === '') return rest;
|
|
return /\bclass\s*=\s*"[^"]*"/.test(rest)
|
|
? rest.replace(/\bclass\s*=\s*"([^"]*)"/, (_m, cls: string) => `class="${cls} v-${token}"`)
|
|
: `${rest} class="v-${token}"`;
|
|
}
|
|
|
|
/**
|
|
* 行模版:`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 raw = `${before}${after}`;
|
|
const attrs = raw.replace(/\s+/g, ' ').trim();
|
|
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
|
|
const value = resolveSlot(scope, path);
|
|
const rows = Array.isArray(value) ? value : [];
|
|
/*
|
|
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
|
|
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
|
*/
|
|
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
|
|
/*
|
|
* 这一组行还是**一份份资料文件**吗 —— 由行路径决定地址(`mine:content.asset`
|
|
* → `content.asset`)。行里带 `data-file-of="列名"` 的元素,渲染时换成
|
|
* `data-file="地址/主键值"`:点它就是点开那一份文件(外层接住,见 ViewContainer)。
|
|
* 模版这一侧照旧只做取值,「点了去哪」不在这份 HTML 里。
|
|
*/
|
|
const address = path.replace(/^mine:/, '');
|
|
const body = rows
|
|
.map((row, index) => {
|
|
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
|
const twinned = editable ? twinInputs(inner, index) : inner;
|
|
return renderTemplate(uniqueNames(textareaCurrent(selectCurrent(fileLinks(twinned, one, address), one), one), index), one);
|
|
})
|
|
.join('');
|
|
return `${open}${body}</${tag}>`;
|
|
});
|
|
}
|
|
|
|
/** `data-file-of="列名"` —— 这一格点开是一份资料文件,文件认哪一行由该列的值定 */
|
|
const FILE_OF = /\s*\bdata-file-of\s*=\s*"([^"]+)"/;
|
|
|
|
/**
|
|
* 把 `data-file-of` 换成一个**认得出行的** `data-file="地址/主键值"`,并消费掉原属性。
|
|
*
|
|
* 值取不到(那一列还空着)就**整个属性去掉** —— 没有文件的行不该长得像能点开,
|
|
* 点下去落一句「打不开」比一开始就不给点更糟(界面不撒谎)。
|
|
*/
|
|
export function fileLinks(inner: string, row: Readonly<Record<string, unknown>>, address: string): string {
|
|
return inner.replace(/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-file-of\s*=\s*"[^"]+"[^>]*)>/g, (all, tag: string, attrs: string) => {
|
|
const col = FILE_OF.exec(attrs)?.[1];
|
|
if (col === undefined) return all;
|
|
const value = displayValue(row[col]);
|
|
const rest = attrs.replace(FILE_OF, '');
|
|
if (value === '' || value === '—') return `<${tag}${rest}>`;
|
|
return `<${tag}${rest} data-file="${escapeHtml(address)}/${escapeHtml(value)}">`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着
|
|
* 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下
|
|
* 会把第 1 页已选的顶掉。每行的 name 缀上行号,组内互斥只在本行内发生。
|
|
*/
|
|
const uniqueNames = (inner: string, rowIndex: number): string =>
|
|
inner.replace(/\bname\s*=\s*"([^"]+)"/g, (_m, n: string) => `name="${n}__r${rowIndex}"`);
|
|
|
|
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
|
|
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
|
|
|
|
/**
|
|
* **给可改的格子配一个输入孪生件** —— 只读那一份原样留着,旁边多一个 `<input>`,
|
|
* 两者由样式表二选一显示(根节点带不带 `editing`)。
|
|
*
|
|
* 为什么是「都渲出来」而不是「渲成输入」:同一份件既要在待处理页给人改,
|
|
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉;
|
|
* 而容器是 `sandbox="allow-same-origin"` 的无脚本 iframe,外层**读得到也改得动**
|
|
* 里面的 DOM:开编辑就是给根节点加个 class,收值就是把 input 读出来。
|
|
* 模版这一侧仍然只做取值 —— 判断(显示哪一份)留在样式表。
|
|
*
|
|
* `data-col` 写的是**台账列名**,不是表头文字:写回时基座按列名对格,
|
|
* 表头改个说法不该让写回错位。
|
|
*/
|
|
function twinInputs(inner: string, rowIndex: number): string {
|
|
return inner.replace(EDITABLE_CELL, (all, _tag: string, attrs: string, col: string) => {
|
|
const slot = /\bdata-slot\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
|
if (slot === undefined) return all;
|
|
// 只读那一份的排版类(mono / num)照搬给输入件 —— 同一列两态字体要一致
|
|
const style = (/\bclass\s*=\s*"([^"]*)"/.exec(attrs)?.[1] ?? '')
|
|
.split(/\s+/)
|
|
.filter((c) => c === 'mono' || c === 'num')
|
|
.join(' ');
|
|
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`;
|
|
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
|
|
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
|
|
});
|
|
}
|
|
|
|
/** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */
|
|
const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g;
|
|
|
|
/**
|
|
* **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。
|
|
*
|
|
* 文本格子的孪生件(`twinInputs`)一开始就带着 `value=`,人一进编辑态看到的是
|
|
* 「AI 已经填了什么」;手写的 `<select>`(完成状态 / 关键人变动 / 覆盖深度这类枚举格)
|
|
* 从前一直是净的 `<option value="">`,没人标 `selected`——同一行里,文本格子回显着
|
|
* 原值,紧挨着的下拉却空白一片,像是这一格的数据丢了。
|
|
*
|
|
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
|
|
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
|
|
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
|
|
*/
|
|
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
|
return inner.replace(SELECT_CELL, (block, col: string) => {
|
|
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;
|
|
});
|
|
}
|
|
|
|
/** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */
|
|
const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g;
|
|
|
|
/**
|
|
* **多行文本的孪生件要带上当下的值**(2026-08-22,E04 执行回执):「结果摘要 / 客户反馈」
|
|
* 照目标件是 textarea(一句话装不下「见到的关键人、谈了什么、达成什么」),
|
|
* 而 textarea 的值在**正文**不在 `value=` 属性 —— `twinInputs` 那条 input 路填不了它。
|
|
* 与 `selectCurrent` 同款:值填进正文,没有值就真空着(placeholder 才显得出来)。
|
|
* 正文位插值照文本位那四个字符转义 —— 混进 `</textarea>` 就是逃逸。
|
|
*/
|
|
function textareaCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
|
return inner.replace(TEXTAREA_CELL, (all, attrs: string, col: string) => {
|
|
const raw = resolveSlot(scope, col);
|
|
if (raw === undefined || raw === null || raw === '') return all;
|
|
return `<textarea${attrs}>${escapeHtml(displayValue(raw))}</textarea>`;
|
|
});
|
|
}
|
|
|
|
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
|
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
|
return renderTemplate(renderRows(html, scope), scope);
|
|
}
|