资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑

**资料文件全链**(用户:审核资料这一步要真产出 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
This commit is contained in:
xcs
2026-08-24 10:26:45 +08:00
co-authored by Claude Opus 5
parent 70e83d0b09
commit 0aa2fc11b0
92 changed files with 13984 additions and 370 deletions
@@ -162,16 +162,44 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
*/
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>;
return renderTemplate(uniqueNames(editable ? twinInputs(inner, index) : inner, index), one);
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 页点一下
@@ -211,6 +239,49 @@ 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-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);