This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/HTML报告生成规范.md
T
ChaiHaoandClaude Opus 5 ade0fa5a94 A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了
活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:28:31 +08:00

20 KiB

HTML 报告生成规范(活动视图模版)

2026-08-02 初版,从《活动开发规范》拆出成册。基准:activities/quote-intake@42 的三张模版 (intake-overview / intake-classified / intake-release)—— 用户验收通过的那套。 渲染引擎:apps/api/src/modules/interaction/internal/domain/view-render.ts; 容器:packages/ui-kit/src/ViewContainer.tsx;保存链路:apps/api/src/app/routes/desk.ts。 本文与代码冲突时以代码为准。


1. 报告是什么:同一份 HTML 的三个身份

身份 在哪 状态
件(给人看) 工作区 · 待处理 锁定态 —— 进屏先是一份能读的报告,不是一张待填的表
表单(给人改) 工作区 · 待处理,按「编辑」后 可改格子现出输入件,能增删行
归档件(存档) 工作区 · 工作成果 同一份 HTML 原样定稿,零输入框零外壳

为什么必须是同一份:渲成两三套就是存两三份,存多份就会分叉,分叉之后没人看得出 以哪份为准。看与改是两件事(整份件一进屏就是编辑态,人还没决定改什么, 眼前已经是 54 个亮着的输入框 —— 踩过),但看的与改的必须是同一份东西。

三条硬规矩:

  1. 每份件两态 1:1,不同工作各有各的件(2026-08-02/03 用户两次校准): 「1:1」的落点是每份件与它的办结快照(在办可编辑 ↔ 回执收下那一刻存 finalHtml,成果页静态重放,数据/内容/布局一个字不差)—— 不是全程只许一张。 作业的件(如 A2 选型 One Pager)与收口的件(整合清单)是两件工作两份件: 「清单是所有选型完成后收口才整合的东西」。别再把两态统一误执行成一张到底 (踩过:验收工单打开还是选型页,用户当场指出)。
  2. 剧本每步的 view 定推哪张,写清单时定死,不归模型临场挑;放行那一步的 view 必须指向验收目标件 —— 最后摆上去的那张就是归档件。
  3. 数据变了基座重渲(repush):人在件上保存、在卡上回填,落草稿层后服务端拿 新数据重渲同一张 —— 由数据算出来的东西(类徽、分布条、去向)跟着一起变。 手改的全部意义就是纠正 AI 的错,改了数而画面不动,这个兜底功能就是假的(用户原话级教训)。

2. 报告与卡(表单)的关系 —— 这套里最容易做错的一章

三个区各归各位(2026-08-01 用户定,别再混):

区 放什么 不放什么
工作区 · 待处理 只放那份件(活动的 HTML 模版),整篇铺满工作区宽度、可就地改 题头 / 编号带 / 依据带 / 判据表 / 指导书 / 提交钮 —— 一样都不要
工作区 · 工作成果 同一份件,定稿归档 同上
对话区的卡 作业指导书(steps/standard)· 依据 · 判据 · 整单级表单字段 · 提交与裁决 · 落地结果 件本身(给一枚「执行工单/验收工单」四字钮跳过去就够)

分工的原则:逐行数据归件,整单级归卡;编辑在件,提交在卡。

  • 一张 9 行的清单,人要逐格核、逐格改 —— 那是件的事(报告即表单);
  • 「结果说明」这种整单一格的字段、「先受理还是退回」这种裁决 —— 那是卡的事;
  • 卡上那枚提交钮是唯一的交卷动作;件上的保存只落草稿层,工单不办结、 放行门的位置不动。

同一个数管三处(同源纪律):台账声明 required 产出的 域_对象_missing——

件上的缺项红带(gapBar)  ←┐
卡上的提交闸(gate)      ←┼── 同一个基座读数、同一套算法
放行门的 L1 机检         ←┘

三处各算各的,就会出现「件上说齐了、卡上说不能交、放行门又说行」—— 那种矛盾人是判不出对错的。所以模版与卡都不算这个数,只摆基座算好的。

保存链路(件 → 库 → 屏):

人按「保存」→ 前端按件自己说的话收值(data-edit 地址 + data-col 列名 + drops)
           → POST /tasks/:id/table {tables, drops}
           → 服务端按【活动清单的台账声明】过授权门,归型,只写真改了的行,
             自增主键补号,draftDrop 撤显式点名的行
           → 留一条人痕(改了几行/新增几行/撤掉哪几行 —— 那条痕是下一拍模型的行动指南)
           → repush 重渲 → 闸、判据、类徽、分布条、去向全部跟着变

三条实测教训,每条都别再犯:

  • 授权面是台账声明,不是「当下那张卡长什么样」 —— 从前保存借道工单 blocks[i] 取列名,熔断卡(没有 blocks)开着时,人补的那一格一个字都送不出去, 而他要补的恰恰是把这一单卡住的必填格;
  • 删要说出来(载荷 drops 点名主键),不靠「没回传就是删了」—— 那种口径会把 一次不完整的回传变成一次删表;
  • 只写真改了的行 —— 件上按一次保存整张表都回传,照单全写会留「修订 4 行」的痕, 而人只改了一格;日志是给人看的,那句话就成了假的。

卡侧还有一条老路(与件侧并存,别混):卡面 blocks 里绑了 ledger+fields 的表, 由基座铺行(发卡那一刻从草稿层铺,活动别自己抄行 —— 回复有长度上限,铺长了截断 整拍作废)、人提交时由基座写回(table:N 载荷)。验收卡的 editableAtRelease 成果表走的就是它。两条路的授权面是同一份台账声明。

3. 沙箱与安全

  • 容器:sandbox="allow-same-origin" 的 iframe,永不给 allow-scripts (两者同给才是破沙箱)。父文档读得到也改得动里面的 DOM:开编辑 = 加 class、 收值 = 读 input,全在父文档侧;iframe 里始终一行脚本都没有。
  • 模版含 <script 或外链,安装期即拒。样式全部自带(内联 <style>)。
  • ⚠️ 跨 realm 陷阱:父文档里 event.target instanceof Element 判 iframe 元素 恒为 false(Element 是父文档的构造器)。症状:点「编辑」毫无反应而 class/CSS/ 选择器逐个查都对。认能力不认血统(有 closest 就当元素);监听挂载写成幂等的, 每次有机会就挂一遍(srcDoc 换文档的时机抓不准)。
  • 内容更新就地换 body.innerHTML,不换 srcDoc —— srcDoc 一变整个文档重载, 高度掉回兜底再涨回来,人刚按下保存件就「闪黑」一下。

4. 模版语言参考(绑定全表)

渲染 = 纯取值绑定。基座三遍扫描:槽位填值 → 输入件填 value → 独立 class 投影。

属性 写在哪 语义
data-slot="键" 任意元素 取值填为文本。数值整数原样、小数截 4 位;数组给长度
data-rows="mine:域.对象" 行容器(tbody 等) 按本单草稿行逐行复制内部结构;行内 data-slot 相对该行取值;序号交 CSS 计数器
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-edit="域.对象" 可改表的行容器 这组行可就地改;保存按它认地址
data-value <input> 值进 value 属性(孪生件用,模版一般不手写)

取数约定:mine:域.对象 = 本单草稿行(成果表只用它 —— 别单放行过的记录不混进来); 裸键 = 摊平字段 + 基座读数(域_对象_rows / _missing / _missing_keys / _<列>_<值>…);case:openedAt / case:origin / case:owner / case:routeKey / routePort / routeTo / routeWhy = 引擎注入的案情与算出的去向。

语言纪律:报告上不摆系统内英文名 —— 状态名、表名、列名、口名一律人读名; 数值列与表头同侧左对齐(右对齐是给要竖着相加的数用的;一行一个料、彼此不相加的表, 右对齐只让数和自己的标题分家),等宽字体留着。

5. 模版不许算,但基座算过的可以摆

两件事别混:

  • 报告自己算(把判据抄成静态文字、拿宽度骗人、JS 拼 class)—— 不行。 抄本与判据是两份东西,改了一处另一处就一直指着旧规则。
  • 摆基座算好的那一次 —— 该做,而且要做足。三个惯用法:
    • 按值上色:data-class-of="type_tag" → .v-a/.v-b/.v-c 三色徽;
    • 按条数分宽(分布条):data-class-of="quote_intake_record_type_tag_a" → 样式表备一把 v-1…v-20 的宽度梯子,flex 按档取;零条那段自己消失;
    • 按数开关(缺项红带):data-class-of="quote_intake_record_missing" → v-0 那档 display:none ——「一格不缺就别出这条带」这个判断在样式表里, 模版仍只做取值。
  • 去向那行:基座按契约各出口 when 序求值后注入 case:route*(与放行发口是 同一次判定),报告只摆;算不出来键就是空的,一行都不高亮,不冒充判过。
  • 搬不过来的如实空着:编数据是撒谎,画空轨像组件坏了 —— 那一格不画, 把「为什么没有」写在卡脚。

6. 编辑态(报告即表单)

默认锁着,按「编辑」才开。 每张可改的表配三样(A1 report.ts 有现成实现):

  1. 闸(editGate):[data-edit-for="域.对象"] + 三钮 data-edit-open / -cancel / -save。锁着只出「编辑」;开了换「取消/保存」; 闸只在这一屏可改时现身(根节点 editable),归档件里一个字都没有。

  2. 孪生输入件:挂了 data-col 的格子渲染时自动配一个 <input>, 只读那份与输入那份由样式表按表上的 on 二选一 —— 同一份 HTML 两态复用。 编辑态下必填空格红框(:placeholder-shown)、整行浅红打底。

  3. 增删行:序号格编辑态换成 ×(一列两用);表尾一条「加一行」 (照末行克隆、值清空,主键不填不落库)。删是挂记号不当场抹掉 (jc-row-gone 划线摆着 —— 看得见、可后悔);「取消」把加的行撤掉、 把删记号摘掉、把值放回快照,三样一起才叫还原。

    为什么必须有增删:手改是兜 AI 的错的那一手,而错不止「这一格填错了」—— 「这一行根本不该在」(客户点名跳过的料被写进有效清单)与「漏了一行」是同一类错, 只给改格子就没有出口(实测那一单 13 次拒回、234k token 才熔断)。

  4. 缺项红带(gapBar):表上方一条红带点名「还差 N 格必填,分别是哪一行哪一列」 (_missing + _missing_keys,与提交闸、放行门同源),并告诉人怎么办 (「按右上角编辑开表,红框那几格就是要填的」)。只在可改屏出、缺口为 0 自动收 (§5 的按数开关)。小胶囊淹在九行表里,人得逐行扫 —— 而他打开这一屏就是来填的。

7. 有目标件(设计稿)时:搬,不要照着做

A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(prd-style.ts 的 PRD_CSS + PRD_SPRITE,一个数值都没改),标记逐块搬,只把写死的数据换成绑定。 不用拼装件复述 —— 那套外壳有自己的版面语言,「照着做」出来的是第二种版面。

搬的时候四样替换(原型是带脚本的演示页,这里一行脚本跑不了):

原型怎么做 搬过来怎么做
JS 造表、写死的行 data-rows 行模版 + CSS 计数器序号
JS 算的计数 台账汇总列或基座读数,data-slot 取
JS 判空插胶囊 data-blank 真空 + CSS :empty::after
JS 拼 class / style="flex:N" data-class-of 投影 + 样式表分档(§5)

验收只认并排截图比对 —— 「25 块到位」是结构断言,对版面零约束(栽过三轮)。

8. 版式纪律

  • 空占位三级统一(2026-08-02 用户定,全局口径):必填(红)· 建议填(橘)· 选填(灰) —— 与声明的 required / expect / 其余一一对应,胶囊文案就写这三个词。 归档静态态它们如实呈现「没有」。

  • 选择列直接选(如 A2 替代等级):三枚自绘 radio(input[type=radio].jc-cell[data-col], radio 盖满选项格可点、:has(:checked) 高亮),不写 checked —— 当前值由 data-class-of 投影 v-* 描虚线;基座对未选中的 radio 整列不交(「没点」=「不改」)。 收值按「input 的祖父」归行:一张卡里所有输入格的祖父必须是同一个元素。

  • 铺满工作区:去掉外层宽度上限与横向留白、卸掉外框圆角边 —— 件就是这一屏的正文, 不是嵌在里面的另一个页面(jc-stage { max-width:none })。

  • 必填/选填胶囊只在在办态摆;归档件一律 dimempty(那时空格的意思不再是 「你还能填」而是「客户没给」,摆红胶囊是在催一件办完的事)。

  • 别摆恒常说「没有」的横幅(「本轮不报 0 行:—」),别把同一个数摆两处 (人得停下来对一遍两处一不一样);已在别处说过的结论不再复述一遍。

  • 标记里不写 ** 星号这类记号 —— 卡面与件都是纯文本/纯 HTML,记号不会变粗体。

  • 概念图形是报告的第一语言(A2 迭代周用户反复要「图形占比大一点」): 芯片小像、流光走线、引脚对照、漏斗 —— 内联线稿 SVG(stroke currentColor, 配色走样式表)把「这是件什么事」画出来;数据格是第二语言。一份让人愿意看的 报告先立视觉隐喻,再摆数。

  • 收值三铁律(件上就地改的结构约束,违者行互相覆盖或值丢失): ① 同一行所有输入格的祖父必须是同一个元素(collect 按祖父归行); ② 主键格必挂 data-col(行的钥匙,可以是隐藏格); ③ radio 不写 checked,「没点」=「不改」(当前值靠 data-class-of 投影描样式)。

8.5 一页汇报(slide 语言)—— 强交互全走纯 CSS

用户对 A2 选型件的要求(2026-08-02):「不够华丽,要强交互的汇报形式, 直接拿去当一页 PPT 的程度」。沙箱零脚本不变 —— 「强交互」全部由样式表承担, 数据仍只经 data-slot / data-class-of 投影,动效是呈现,不是判断。 参考实现:activities/quote-alternative/src/views/report.ts 的 ALT_WORKSHEET。

构图:主视觉(hero)+ 编号小节(slide part)推进。

  • hero 深空底:radial-gradient 两团光 + linear-gradient 深空 + ::before 电路点阵(radial-gradient 1px 点)+ ::after 顶部一道光。 hero 内配色自成一组(亮字/暗丸/信标),别把 PRD 亮色变量直接用在深底上。
  • slide 小节头(jc-sp):01/02/… 编号丸 + 题 + 一句注 + 延伸线 —— 编号是版面语言,不是数据。
  • 逐段入场(jc-rise + .d1…d6 延迟):opacity/transform 动画, 全部包在 @media (prefers-reduced-motion: no-preference) 里(连 @keyframes 一起包 —— 引用未定义的动画名等于没有动画,减弱设置下自然全静)。
  • 流光走线:两条同 path 的 SVG(wire 底线 + pulse 虚线), stroke-dasharray + stroke-dashoffset 动画 + drop-shadow 发光。
  • 悬停揭示:卡片 hover 浮起(transform + box-shadow 过渡); 等级卡的口径段 max-height:0 → hover/focus-within 展开,加 tabindex="0" 让键盘也进得去;触屏常开(@media (hover: none) 里展开、藏掉「悬停展开」hint)。
  • 空节自折:整节只在有数据时出现 —— 包一层 wrapper, :not(:has(.行.v-xxx)) { display:none }(如 nowwrap/donewrap)。 没有 NOW 行时「当前这一颗」整节(含小节头)不出,不留空壳。

三条工程账(都踩过):

  1. 媒体查询要排在被它覆盖的基础规则之后 —— 同权重下后定义胜。 竖排断点里写了 .jc-trace{display:none},而 .jc-trace{display:flex} 基础规则 定义在媒体块之后,于是竖排下走线照显:横着一根流光指向右边的空气。
  2. 动效只动 transform/opacity/box-shadow,别动布局属性; jc-rise 会在每次 srcdoc 重渲后重播(保存一次播一次)—— 半秒内是 slide 感, 长了就是折磨,入场动画别超过 0.6s。
  3. 队列丸/信标这类状态灯照旧走 data-class-of(v-now 信标呼吸、v-done 绿、 v-wait 暗)—— 「哪颗在办」是数据说的,动画只是把它说响一点。

9. 自测与浏览器走查

包内自测(结构层):模版无 <script 无外链;data-class-of 不以 data-slot 为前提 (纯色块曾整条静默失色);剧本每个 view 都在 viewTemplates 里;绑定键都是 声明列 / 基座读数 / case:*。

浏览器走查(版面与交互层,结构断言替代不了):

锁定态整篇可读 → 编辑 → 改一格 → 取消(还原) → 再改 + 删一行 + 加一行 → 保存
→ 件上新值现出、红带收起、类徽/分布条/去向重算 → 卡上提交闸开 → 提交 → 放行
→ 工作成果里同一份件零输入框零外壳 → 与目标件并排截图比对

10. 陷阱表

症状 根因 出路
点「编辑」毫无反应,一切选择器都对 instanceof Element 跨 realm 恒 false;或监听挂在 about:blank 上 认能力不认血统;幂等重挂(§3)
保存时件「闪黑」一下 srcDoc 换文档整页重载 就地换 body.innerHTML(§3)
就地换了还是「闪一下」(2026-08-03 再报) innerHTML 仍是整树重建:47KB 样式在 body 再解析一遍(head 那份还在,一文档两份)、SVG 全重建、CSS 动画回第 0 帧重起 第二层:逐节点 morph(ViewContainer.replaceInPlace)—— 一样的节点不碰,样式归位 head 去重,涟漪只落在真改了的格子上;任何一步栽了退回整树重建(闪但不错)
保存后旧值还摆着一会儿(真正让人以为没生效的那个) 保存要跑个来回(POST 落草稿 → 失效 → 重取详情 → 新件推回),而闸在按下那一刻就关了 —— 这段时间屏上是只读那份的旧值 按下保存先把新值写进只读孪生件再关闸(mirrorTwins),回声到达时 morph 一比已经一样;只镜像格子自己的值,红带/分布条/去向等基座算的数一律等回声
镜像/取值找不到孪生件而无声失效 渲染那一遍把 data-slot 连同槽位一起消费掉,活 DOM 里两份都没有这个属性 认位置不认属性:twinInputs 把 input 紧接着只读那份写出,previousElementSibling 就是孪生件;验证要在真单的 DOM 上做 —— 合成 fixture 里两份都写了 data-slot,照样绿
人保存了,一格都没落库 保存借道当下那张卡的 blocks 按 data-edit 地址收,授权=台账声明(§2)
改了类标,分布条/去向不动 汇总数是自报的没人重算;或没 repush 分类列标 tally 让基座数;保存后必 repush(§1)
纯色块整条灰着像没算出来 data-class-of 曾以 data-slot 为前提 已修;自测钉住
屏上多了一行删不掉 只给改格子没给删行 §6-3 增删行
空格子显示「无」/上一行的值 没挂 data-blank;或加行时只清了 input 没清只读份 §4;addRow 两份都清
报告与卡说的判据不一致 两处各算各的 全部读基座读数,同源(§2)
归档件上还有编辑闸/胶囊 闸没锁在 editable 根类下;胶囊没分态 §6-1、§8
版面「像又不像」目标件 用拼装件照着做而不是搬 §7;截图比对
人保存后一行的值被另一行盖掉 主键格没挂 data-col —— 收上去的行没有钥匙,服务端按「全表一致列」补键,多行对到同一行 主键列必挂 data-col(它不是编辑面,是行的钥匙);e2e 走 API 自带主键测不出,只有件上真按保存才现形(A2 走查逮过)
逐行卡片克隆成套娃(第二行长进第一行里) 渲染器按第一个同名闭合标签截行内容 —— <div data-rows> 的行模版里嵌了别的 <div>,行在半截被断开 行容器的标签名不得在行模版内部再出现:容器换 <section>/<tbody> 这类行内不会嵌的标签(A2 选型档案卡逮过)
空格胶囊比同行的数字往下坠几像素 裸 inline-flex 胶囊按 baseline 挂:盒(字 + 内距 + 边框)比字的基线深,行高也随之忽高忽低 空格子(:empty)转 flex + align-items:center + min-height 撑住文字那份盒高,胶囊中线对数字中线(A1 逮过,2026-08-03)