人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
+257 -5
View File
@@ -9,20 +9,130 @@
* **高度随内容长**:HTML 报告既是独立文件,也是交互视图的组成部分 ——
* 它得像页面里的一段正文那样整篇铺开、跟着外层一起滚,而不是缩在一个定高小窗里自己滚。
* 定高会把长报告拦腰截断,人得在两层滚动条之间来回找。
*
* ── 就地改:默认锁着,按「编辑」才开(2026-08-01 用户定)────────────────
* 待处理那一屏摆的**只有这份件**,而件本身要能改 —— 于是编辑态长在件里面:
* 每个可改的格子在渲染时就配了一个 `<input class="jc-cell">` 孪生件
* (见 `view-render.ts` 的 `twinInputs`),平时收着,开了闸才现身。
*
* **看与改是两件事**:进这一屏整份件就是编辑态的话,人还没决定要改什么,
* 屏上已经是一张待填的表(A1 那份是 54 个输入框一起亮着)。所以每张可改的表
* 各带一道闸(`[data-edit-for]`):锁着 → 「编辑」;开了 → 「取消 / 保存」。
*
* 外层怎么开、怎么收:`allow-same-origin` 让父文档**读得到也改得动** iframe 的 DOM——
* · 挂事件、加 class、读 input,全在父文档这一侧;
* · **iframe 里始终一行脚本都没有**:`allow-scripts` 永远不给,两者同时给才是破沙箱。
*/
import { useCallback, useEffect, useRef, useState } from 'react';
/** 一张可改的表收上来的样子:`{ 台账地址: [{ 列名: 值 }] }` */
export type ViewEdits = Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
/** 按一次保存交出去的东西:现在这张表长什么样(`tables`)+ 人删掉了哪几行(`drops`) */
export type ViewSave = { readonly tables: ViewEdits; readonly drops: ViewEdits };
export type ViewContainerProps = {
readonly html: string;
readonly title?: string;
/** 这一屏的件**可以改** —— 闸出现(默认仍是锁着的,要按「编辑」才开) */
readonly editable?: boolean;
/** 人按下「保存」时把那张表收上来;不传就是纯呈现 */
readonly onEdits?: (save: ViewSave) => void;
};
/** 量不到高度时的兜底,避免塌成 0 高 */
const FALLBACK_HEIGHT = 420;
export function ViewContainer({ html, title = '活动视图' }: ViewContainerProps) {
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */
function bodyOf(doc: Document, address: string): HTMLElement | null {
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
}
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内 */
function cellsOf(doc: Document, address: string): HTMLInputElement[] {
const host = bodyOf(doc, address);
if (host === null) return [];
return Array.from(host.querySelectorAll<HTMLInputElement>('input[data-col]')).filter(
(i) => i.closest('.jc-row-gone') === null,
);
}
/**
* 把一张表读成 `[{列: 值}]` —— **按行元素归行**,不按 `data-row` 序号归行。
*
* 序号是渲染那一刻钉上去的;人删一行、加一行之后,它就不再是「第几行」了
* (删掉第 2 行,余下的仍是 0/2/3 —— 拿它当下标会在中间留一个洞)。
* 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。
*/
function collect(doc: Document, address: string): Array<Record<string, string>> {
const byRow = new Map<Element, Record<string, string>>();
for (const cell of cellsOf(doc, address)) {
const col = cell.dataset['col'];
const row = cell.parentElement?.parentElement ?? cell.parentElement;
if (col === undefined || row == null) continue;
const one = byRow.get(row) ?? {};
one[col] = cell.value;
byRow.set(row, one);
}
return [...byRow.values()];
}
/** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */
function collectGone(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address);
if (host === null) return [];
return Array.from(host.querySelectorAll<HTMLElement>('.jc-row-gone')).map((row) => {
const one: Record<string, string> = {};
for (const cell of Array.from(row.querySelectorAll<HTMLInputElement>('input[data-col]'))) {
const col = cell.dataset['col'];
if (col !== undefined) one[col] = cell.value;
}
return one;
});
}
/**
* **在表尾添一行空的** —— 照末行的样子克隆,值清空。
*
* 克隆的是**渲染出来的那一行**,不是模版:模版在服务端,浏览器这边看不见。
* 一张空表(一行都没有)加不了行 —— 没有可照的样子;那种情形本来也不该由人开头
* (行由活动写下,人改的是它写错的地方)。
*/
function addRow(doc: Document, address: string): boolean {
const host = bodyOf(doc, address);
const last = host?.querySelector<HTMLElement>('tr:last-of-type');
if (host === null || last == null) return false;
const fresh = last.cloneNode(true) as HTMLElement;
fresh.classList.remove('jc-row-gone');
fresh.classList.add('jc-row-new');
for (const cell of Array.from(fresh.querySelectorAll<HTMLInputElement>('input[data-col]'))) {
cell.value = '';
// 序号不再有意义(见 `collect` 的注释),清掉免得两行同号
delete cell.dataset['row'];
}
// 只读那一份也清空 —— 新行还没有值,摆着上一行的字就是在撒谎
for (const view of Array.from(fresh.querySelectorAll<HTMLElement>('[data-slot], .jc-vcell, .jc-tbadge'))) {
if (view.tagName !== 'INPUT') view.textContent = '';
}
host.appendChild(fresh);
return true;
}
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits }: ViewContainerProps) {
const ref = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(FALLBACK_HEIGHT);
/*
* **`srcDoc` 只用来开第一帧,之后就地换内容**(2026-08-02 用户报的「保存时闪黑」)。
*
* `srcDoc` 一变浏览器就**重载整个文档**:那一瞬 iframe 里什么都没有,
* 高度也从量好的五千多掉回兜底的 420 再涨回来 —— 底下的深色画布露出来一下,
* 看起来就是「闪黑」。而人刚按下保存,件在他眼皮底下抖一下,像是出错了。
*
* 容器是同源的,换内容不必换文档:直接写 `body.innerHTML`。
* 文档不重载,于是高度不跳、滚动位置不丢、挂在文档上的监听也还在。
*/
const [seed] = useState(html);
const shown = useRef(html);
const measure = useCallback(() => {
const doc = ref.current?.contentDocument;
@@ -30,8 +140,146 @@ export function ViewContainer({ html, title = '活动视图' }: ViewContainerPro
// 取 body 与 documentElement 的较大者:两者谁撑开高度随文档结构而异
const next = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0);
if (next > 0) setHeight(next);
bindRef.current();
}, []);
/*
* 回调放 ref —— 外层多半是行内箭头函数,每次渲染都是新的一个。
* 让它进 effect 的依赖,等于每渲染一次就拆装一次监听;而真正要重来的时机只有一个:
* **文档换了**(srcdoc 一变就是一份新文档,挂在旧文档上的东西随之作废)。
*/
const sink = useRef(onEdits);
sink.current = onEdits;
/** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */
const snapshot = useRef(new Map<string, { values: string[]; rows: number }>());
/*
* 挂事件 + 开关闸 —— 都在**父文档这一侧**做,iframe 里没有脚本。
*
* 点击处理器是**稳定引用**,于是「重复挂同一个」是幂等的(DOM 规范如此)——
* 这一条正是这里的关键:`srcDoc` 换文档的时机抓不准,
* 与其判断「挂过没有」,不如**每次有机会就挂一遍**,哪一次落在真文档上都算数。
*
* 从前用一个 `bound` ref 记「挂过哪一份文档」去重,实测点「编辑」毫无反应:
* 挂载那一刻 `contentDocument` 还是 srcdoc 之前的空白文档,监听挂在了它身上;
* 而 `onLoad` 已经错过、effect 的依赖又没变,于是**再也没有第二次机会**。
*/
const onClick = useRef((event: Event) => {
const doc = ref.current?.contentDocument;
if (doc == null) return;
/*
* **不能用 `instanceof Element`** —— 这是跨 realm 的经典陷阱。
*
* `Element` 是**父文档**的那个构造器,而 `event.target` 来自 iframe 的 realm:
* 它的原型链上挂的是 iframe 那份 `Element`,两者不是同一个对象,
* `instanceof` 恒为 false —— handler 挂上了、事件也收到了,却每次都在第一行 return。
* 实测症状:点「编辑」毫无反应,而根节点的 class、CSS、选择器逐个查都是对的。
* 认能力不认血统:有 `closest` 就当它是元素。
*/
const target = event.target as { closest?: (s: string) => HTMLElement | null } | null;
const hit =
typeof target?.closest === 'function'
? target.closest('[data-edit-open],[data-edit-cancel],[data-edit-save],[data-edit-add],[data-edit-del]')
: null;
if (hit === null || hit === undefined) return;
event.preventDefault();
/** 那张表与它的闸一起开合 */
const setOpen = (address: string, on: boolean): void => {
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
doc
.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`)
?.closest('table')
?.classList.toggle('on', on);
};
const open = hit.dataset['editOpen'];
const cancel = hit.dataset['editCancel'];
const save = hit.dataset['editSave'];
const add = hit.dataset['editAdd'];
const del = hit.dataset['editDel'];
if (open !== undefined) {
// 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原
snapshot.current.set(open, {
values: cellsOf(doc, open).map((c) => c.value),
rows: Array.from(bodyOf(doc, open)?.children ?? []).length,
});
setOpen(open, true);
return;
}
if (cancel !== undefined) {
const was = snapshot.current.get(cancel);
if (was !== undefined) {
// 加出来的那几行整个撤掉,删掉的标记摘掉,再把值放回去 —— 三样一起才叫还原
const host = bodyOf(doc, cancel);
if (host !== null) {
while (host.children.length > was.rows) host.lastElementChild?.remove();
for (const row of Array.from(host.querySelectorAll('.jc-row-gone'))) row.classList.remove('jc-row-gone');
}
cellsOf(doc, cancel).forEach((c, i) => (c.value = was.values[i] ?? c.value));
}
setOpen(cancel, false);
return;
}
/*
* **增删只在编辑态里发生,保存那一下才落库** —— 与改格子同一条路子:
* 删是给那一行挂个记号(`jc-row-gone`),不是当场从屏上抹掉 ——
* 人得看得见自己删了哪一行,也才有得后悔(「取消」把记号摘了就回来了)。
*/
if (del !== undefined) {
target?.closest?.('tr')?.classList.toggle('jc-row-gone');
return;
}
if (add !== undefined) {
addRow(doc, add);
measureRef.current();
return;
}
if (save !== undefined) {
/*
* **保存 = 把这张表现在的样子交出去**:留下的行 + 人删掉的行,两份分开说。
* 删要单独说、不靠「没回传就是删了」—— 那种口径会把一次不完整的回传变成一次删表。
*/
sink.current?.({ tables: { [save]: collect(doc, save) }, drops: { [save]: collectGone(doc, save) } });
snapshot.current.delete(save);
setOpen(save, false);
}
});
/*
* `measure` 由 `onLoad` 与 ResizeObserver 反复触发,而它们一定发生在**真文档**上 ——
* 所以顺手在那里也挂一次监听:这是抓不准 srcdoc 时机的可靠兜底。
* 走 ref 是为了不让 `measure` 依赖 `bind`(那会让 ResizeObserver 每次 editable 变就重挂)。
*/
const bindRef = useRef<() => void>(() => undefined);
/** 加一行之后件长高了 —— 重量一次,不然新行被容器裁在外面(走 ref,同 `bindRef` 的道理) */
const measureRef = useRef<() => void>(() => undefined);
measureRef.current = measure;
const bind = useCallback(() => {
const doc = ref.current?.contentDocument;
if (doc?.documentElement == null) return;
// `editable` 只让闸现身;开不开由人按 —— 件默认是锁着的
doc.documentElement.classList.toggle('editable', editable);
// 同一个 (type, listener) 重复 add 无副作用 —— 所以不去重,只求哪一次挂中了真文档
doc.addEventListener('click', onClick.current);
}, [editable]);
bindRef.current = bind;
/*
* 内容变了 —— **就地换,不重载**(见 `seed` 那段注释)。
* 换完立刻重量一次高度:新件长短不同,不重量会留一截空白或被截断。
*/
useEffect(() => {
const doc = ref.current?.contentDocument;
if (doc?.body != null && shown.current !== html) {
shown.current = html;
doc.body.innerHTML = html;
measure();
}
bind();
}, [bind, html, measure]);
useEffect(() => {
const frame = ref.current;
const doc = frame?.contentDocument;
@@ -42,21 +290,25 @@ export function ViewContainer({ html, title = '活动视图' }: ViewContainerPro
return () => observer.disconnect();
}, [measure, html]);
const onLoad = useCallback(() => {
measure();
bind();
}, [measure, bind]);
return (
<iframe
ref={ref}
className="jc-view-frame"
title={title}
/*
* `allow-same-origin` **只为让外层量到内容高度**(否则 iframe 是不透明源,
* contentDocument 读不到,高度无从得知)。
* `allow-same-origin` 让外层量得到高度、挂得上事件、收得回值。
* 关键在于**始终不给 `allow-scripts`** —— 里面一行脚本也跑不了,
* 「无脚本执行权」这条不变。两者同时给才是破沙箱,这里永远不会。
*/
sandbox="allow-same-origin"
srcDoc={html}
srcDoc={seed}
style={{ height }}
onLoad={measure}
onLoad={onLoad}
scrolling="no"
/>
);