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>
This commit is contained in:
@@ -38,11 +38,117 @@ export type ViewContainerProps = {
|
||||
readonly editable?: boolean;
|
||||
/** 人按下「保存」时把那张表收上来;不传就是纯呈现 */
|
||||
readonly onEdits?: (save: ViewSave) => void;
|
||||
/**
|
||||
* **多页件**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告,
|
||||
* 合理利用右上角的翻页」):件里带 `data-page` 的可见段各算一页,
|
||||
* 外层用这两个口翻 —— `page` 显示第几页(0 起),`onPages` 回报共几页。
|
||||
* 不传 `page` 或只有一页时全部照常显示。
|
||||
*/
|
||||
readonly page?: number;
|
||||
readonly onPages?: (total: number) => void;
|
||||
};
|
||||
|
||||
/** 量不到高度时的兜底,避免塌成 0 高 */
|
||||
const FALLBACK_HEIGHT = 420;
|
||||
|
||||
/**
|
||||
* **就地换内容的第二层:只换真变了的节点**(2026-08-03 用户:「保存的时候 HTML 会闪一下」)。
|
||||
*
|
||||
* 2026-08-02 那一刀把 srcDoc 重载换成了 `body.innerHTML = html` —— 文档不再重载,
|
||||
* 可整棵 DOM 仍是**推倒重建**:47KB 样式表在 body 里再解析一遍(head 那份还在,从此一文档
|
||||
* 两份同样的表)、二十来个 SVG 全部重建、CSS 动画一律回到第 0 帧重新起跳 —— 人眼里就是
|
||||
* 保存那一下整张件「闪一下」。同一个症状,病根在下一层。
|
||||
*
|
||||
* 治法:新旧两棵树**逐节点对齐**,一样的一根手指都不碰 —— 样式表不重解析、精灵不重建、
|
||||
* 没变的段落连重绘都没有;保存的涟漪只落在真改了的那几个格子上。
|
||||
*
|
||||
* 细则:
|
||||
* · 顶层 `<style>` 单独归位:srcDoc 首帧解析会把它提进 head,而 innerHTML 不会 ——
|
||||
* 不归位的话新旧顶层节点错一位,逐个对齐全部失配。同文归 head 去重,变了才换文;
|
||||
* · 输入件的**活值**只在拟值(value 属性)真变时跟上:人刚敲进去的字不因一次
|
||||
* 无关的重推被擦掉;radio 的 checked 是编辑态的临时物,不由这里管;
|
||||
* · 任何一步栽了就整体退回 innerHTML 重建 —— 宁可闪一下,不可摆错件。
|
||||
*/
|
||||
function morphNode(oldEl: Element, newEl: Element): void {
|
||||
if (oldEl.outerHTML === newEl.outerHTML) return;
|
||||
if (oldEl.tagName !== newEl.tagName) {
|
||||
oldEl.replaceWith(newEl);
|
||||
return;
|
||||
}
|
||||
for (const name of oldEl.getAttributeNames()) {
|
||||
if (!newEl.hasAttribute(name)) oldEl.removeAttribute(name);
|
||||
}
|
||||
for (const name of newEl.getAttributeNames()) {
|
||||
const value = newEl.getAttribute(name);
|
||||
if (value !== null && oldEl.getAttribute(name) !== value) {
|
||||
oldEl.setAttribute(name, value);
|
||||
// 拟值真变了,活值跟上(平时人敲了一半的字不动)
|
||||
if (name === 'value' && oldEl.tagName === 'INPUT') (oldEl as HTMLInputElement).value = value;
|
||||
}
|
||||
}
|
||||
const oldKids = Array.from(oldEl.childNodes);
|
||||
const newKids = Array.from(newEl.childNodes);
|
||||
const count = Math.max(oldKids.length, newKids.length);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const o = oldKids[i];
|
||||
const n = newKids[i];
|
||||
if (o === undefined) {
|
||||
if (n !== undefined) oldEl.appendChild(n);
|
||||
continue;
|
||||
}
|
||||
if (n === undefined) {
|
||||
(o as ChildNode).remove();
|
||||
continue;
|
||||
}
|
||||
const bothElements = o.nodeType === Node.ELEMENT_NODE && n.nodeType === Node.ELEMENT_NODE;
|
||||
if (!bothElements) {
|
||||
if (o.nodeType !== n.nodeType) (o as ChildNode).replaceWith(n);
|
||||
else if (o.textContent !== n.textContent) o.textContent = n.textContent;
|
||||
continue;
|
||||
}
|
||||
morphNode(o as Element, n as Element);
|
||||
}
|
||||
}
|
||||
|
||||
/** 内容变了:样式表归位 + 逐节点对齐;失败退回整体重建(闪但不错) */
|
||||
function replaceInPlace(doc: Document, html: string): void {
|
||||
try {
|
||||
const tpl = doc.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
// 顶层样式挑出来用 children 过滤 —— :scope 选择器在 DocumentFragment 上实测不命中,
|
||||
// 样式留在树里会让新旧顶层错一位,逐个对齐全部失配(整树重建,闪照旧)
|
||||
for (const style of Array.from(tpl.content.children).filter((el) => el.tagName === 'STYLE')) {
|
||||
const twin = Array.from(doc.head.querySelectorAll('style')).find((s) => s.textContent === style.textContent);
|
||||
if (twin === undefined) {
|
||||
const head = doc.head.querySelector('style');
|
||||
if (head === null) doc.head.appendChild(style);
|
||||
else head.textContent = style.textContent;
|
||||
}
|
||||
style.remove();
|
||||
}
|
||||
// 历史现场:上一版 innerHTML 换进 body 的那份样式表,见到就摘(head 已有同文)
|
||||
for (const stray of Array.from(doc.body.children).filter((el) => el.tagName === 'STYLE')) {
|
||||
if (Array.from(doc.head.querySelectorAll('style')).some((s) => s.textContent === stray.textContent)) stray.remove();
|
||||
}
|
||||
const oldKids = Array.from(doc.body.children);
|
||||
const newKids = Array.from(tpl.content.children);
|
||||
const count = Math.max(oldKids.length, newKids.length);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const o = oldKids[i];
|
||||
const n = newKids[i];
|
||||
if (o === undefined) {
|
||||
if (n !== undefined) doc.body.appendChild(n);
|
||||
} else if (n === undefined) {
|
||||
o.remove();
|
||||
} else {
|
||||
morphNode(o, n);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
doc.body.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */
|
||||
function bodyOf(doc: Document, address: string): HTMLElement | null {
|
||||
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
|
||||
@@ -70,6 +176,13 @@ function collect(doc: Document, address: string): Array<Record<string, string>>
|
||||
const col = cell.dataset['col'];
|
||||
const row = cell.parentElement?.parentElement ?? cell.parentElement;
|
||||
if (col === undefined || row == null) continue;
|
||||
/*
|
||||
* **radio 选项:没选中的不算话,一组都没选 = 这一列不动**(2026-08-02,
|
||||
* A2 替代等级三选)。radio 的 `value` 是选项值,恒有 —— 照 text 那样读,
|
||||
* 三个选项互相覆盖、没人点过也会交出一个值,把台账里的原值抹掉;
|
||||
* 跳过整列则原值保留:「没点」的意思是「不改」,不是「清空」。
|
||||
*/
|
||||
if (cell.type === 'radio' && !cell.checked) continue;
|
||||
const one = byRow.get(row) ?? {};
|
||||
one[col] = cell.value;
|
||||
byRow.set(row, one);
|
||||
@@ -77,6 +190,36 @@ function collect(doc: Document, address: string): Array<Record<string, string>>
|
||||
return [...byRow.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* **保存那一下,新值就地写进只读孪生件**(2026-08-03 用户:「编辑→保存的时候
|
||||
* 旧的状态还是会持续一会儿」)。
|
||||
*
|
||||
* 保存这条链是:交出去 → POST 落草稿层 → 失效缓存 → 重取任务详情 → 新件推回来。
|
||||
* 一个来回几百毫秒到一秒多,而闸在按下那一刻就关了 —— 于是这一段时间里屏上摆的是
|
||||
* **只读那一份的旧值**:人刚敲完 36000,一按保存,格子跳回原来的空白/旧数,
|
||||
* 看着像这一下没生效(有人会再改一遍、再按一次)。
|
||||
*
|
||||
* 值本来就在手上(正是要交出去的那一份),不必等服务端告诉我们。所以按下保存先把
|
||||
* 每个格子的值写进它自己的只读孪生件,回声到达时 morph 一比对已经一样 —— 一次重绘都没有。
|
||||
*
|
||||
* **只镜像格子自己的值**:红带缺几格、分布条、去向这些是**基座算出来的数**,
|
||||
* 一律等回声(界面不撒谎:自己算一个摆上去,就成了没有依据的画面)。
|
||||
*
|
||||
* 认孪生件**按位置不按 `data-slot`**:渲染那一遍会把 `data-slot` 连同槽位一起**消费掉**,
|
||||
* 活 DOM 里两份谁都没有这个属性(拿它去找,找到的是空,这一段就静默失效)——
|
||||
* 而 `twinInputs` 是把 input **紧接着**只读那份写出来的,前一个兄弟就是它的孪生件。
|
||||
* 这条是在真单的 DOM 上查出来的:合成 fixture 里我两份都写了 data-slot,于是照样绿。
|
||||
*/
|
||||
function mirrorTwins(doc: Document, address: string): void {
|
||||
for (const cell of cellsOf(doc, address)) {
|
||||
// 与 collect 同一条规矩:没选中的 radio 不算话(「没点」= 不改)
|
||||
if (cell.type === 'radio' && !cell.checked) continue;
|
||||
const twin = cell.previousElementSibling;
|
||||
if (twin === null || twin.tagName === 'INPUT') continue;
|
||||
twin.textContent = cell.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */
|
||||
function collectGone(doc: Document, address: string): Array<Record<string, string>> {
|
||||
const host = bodyOf(doc, address);
|
||||
@@ -118,9 +261,32 @@ function addRow(doc: Document, address: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits }: ViewContainerProps) {
|
||||
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits, page, onPages }: ViewContainerProps) {
|
||||
const ref = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(FALLBACK_HEIGHT);
|
||||
/*
|
||||
* 多页件的翻页:`[data-page]` 的**可见**段各算一页(模版里被 CSS 藏掉的行
|
||||
* 不算 —— A/B 行同样带着标记,但这份报告只翻待选型那几页)。
|
||||
* 页的显隐用内联 display 盖:非当前页 none,当前页清掉内联让 CSS 接管。
|
||||
*/
|
||||
const pageRef = useRef(page);
|
||||
pageRef.current = page;
|
||||
const onPagesRef = useRef(onPages);
|
||||
onPagesRef.current = onPages;
|
||||
const paginate = useCallback(() => {
|
||||
const doc = ref.current?.contentDocument;
|
||||
if (doc?.body == null) return;
|
||||
const marked = Array.from(doc.body.querySelectorAll<HTMLElement>('[data-page]'));
|
||||
for (const el of marked) el.style.removeProperty('display');
|
||||
const visible = marked.filter((el) => doc.defaultView?.getComputedStyle(el).display !== 'none');
|
||||
onPagesRef.current?.(visible.length);
|
||||
// 不传 page(如工作成果的静态归档)= 不分页,全部照常纵排
|
||||
if (pageRef.current === undefined || visible.length <= 1) return;
|
||||
const current = Math.min(pageRef.current, visible.length - 1);
|
||||
visible.forEach((el, i) => {
|
||||
if (i !== current) el.style.display = 'none';
|
||||
});
|
||||
}, []);
|
||||
/*
|
||||
* **`srcDoc` 只用来开第一帧,之后就地换内容**(2026-08-02 用户报的「保存时闪黑」)。
|
||||
*
|
||||
@@ -137,10 +303,17 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
const measure = useCallback(() => {
|
||||
const doc = ref.current?.contentDocument;
|
||||
if (doc?.documentElement == null) return;
|
||||
/*
|
||||
* **先挂监听、先落 `editable` class,再量高**(2026-08-02 用户:「工作区的
|
||||
* 滚轴都拉不到底下的填写区」)。从前这里先量后 bind:量的是编辑闸与红带
|
||||
* **还没现身**时的高度;bind 一加 class,闸/红带/编辑条冒出来把内容撑高
|
||||
* 一百多像素,而那之后没有人再量 —— 底部正好裁掉一截,外层滚到底也看不见
|
||||
* 填回表的尾巴(实测 A2 工作单:量得 2402、实际 2524)。
|
||||
*/
|
||||
bindRef.current();
|
||||
// 取 body 与 documentElement 的较大者:两者谁撑开高度随文档结构而异
|
||||
const next = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0);
|
||||
if (next > 0) setHeight(next);
|
||||
bindRef.current();
|
||||
}, []);
|
||||
|
||||
/*
|
||||
@@ -184,13 +357,17 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
if (hit === null || hit === undefined) return;
|
||||
event.preventDefault();
|
||||
|
||||
/** 那张表与它的闸一起开合 */
|
||||
/**
|
||||
* 那块内容与它的闸一起开合。`.on` 落在哪:行容器是表格的落在 `<table>`
|
||||
* (老件,样式按 `.jc-tbl-in.on` 写);**不是表格的落在 `data-edit` 元素自身**
|
||||
* (2026-08-02 A2 对决卡:渲染态即结构,编辑只是点亮其中的输入件 ——
|
||||
* 容器是 `<section>`,从前这里写死 `closest('table')`,非表格容器永远拿不到
|
||||
* `.on`,编辑态整个打不开)。
|
||||
*/
|
||||
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 host = doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
|
||||
(host?.closest('table') ?? host)?.classList.toggle('on', on);
|
||||
};
|
||||
|
||||
const open = hit.dataset['editOpen'];
|
||||
@@ -201,8 +378,9 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
|
||||
if (open !== undefined) {
|
||||
// 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原
|
||||
// (radio 的 value 是恒定的选项值,快照与还原认的是 checked)
|
||||
snapshot.current.set(open, {
|
||||
values: cellsOf(doc, open).map((c) => c.value),
|
||||
values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? (c.checked ? '1' : '') : c.value)),
|
||||
rows: Array.from(bodyOf(doc, open)?.children ?? []).length,
|
||||
});
|
||||
setOpen(open, true);
|
||||
@@ -217,7 +395,12 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
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));
|
||||
cellsOf(doc, cancel).forEach((c, i) => {
|
||||
const saved = was.values[i];
|
||||
if (saved === undefined) return;
|
||||
if (c.type === 'radio') c.checked = saved === '1';
|
||||
else c.value = saved;
|
||||
});
|
||||
}
|
||||
setOpen(cancel, false);
|
||||
return;
|
||||
@@ -243,6 +426,8 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
*/
|
||||
sink.current?.({ tables: { [save]: collect(doc, save) }, drops: { [save]: collectGone(doc, save) } });
|
||||
snapshot.current.delete(save);
|
||||
// 关闸之前先把新值摆到只读那一份上 —— 不然人对着旧值等一个来回(见 mirrorTwins)
|
||||
mirrorTwins(doc, save);
|
||||
setOpen(save, false);
|
||||
}
|
||||
});
|
||||
@@ -266,34 +451,48 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
}, [editable]);
|
||||
|
||||
bindRef.current = bind;
|
||||
|
||||
/*
|
||||
* 内容变了 —— **就地换,不重载**(见 `seed` 那段注释)。
|
||||
* 图片 late-load、字体回流、编辑闸开合都会改高度 —— 盯着 body 尺寸变化重量一次。
|
||||
*
|
||||
* **observer 与点击监听同一条纪律:每次有机会就重挂到当前真 body**。
|
||||
* 从前它由 effect 挂一次:挂载那一刻 `contentDocument.body` 还是 srcdoc 之前
|
||||
* 那份空白文档的 body,监听盯的是一具尸体 —— 之后内容再怎么长高都无人重量,
|
||||
* 高度停在第一次量的值(实测 A2 工作单:2402 对 2524,底部 122px 在容器外,
|
||||
* 外层滚轴拉到底也够不着填回区)。
|
||||
*/
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const watchBody = useCallback(() => {
|
||||
const body = ref.current?.contentDocument?.body;
|
||||
if (body == null) return;
|
||||
observerRef.current ??= new ResizeObserver(() => measureRef.current());
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current.observe(body);
|
||||
}, []);
|
||||
useEffect(() => () => observerRef.current?.disconnect(), []);
|
||||
|
||||
/*
|
||||
* 内容变了 —— **就地换,不重载**(见 `seed` 那段注释);换的那一层也只换
|
||||
* 真变了的节点(见 `morphNode` —— 保存不闪的关键在那儿)。
|
||||
* 换完立刻重量一次高度:新件长短不同,不重量会留一截空白或被截断。
|
||||
*/
|
||||
useEffect(() => {
|
||||
const doc = ref.current?.contentDocument;
|
||||
if (doc?.body != null && shown.current !== html) {
|
||||
shown.current = html;
|
||||
doc.body.innerHTML = html;
|
||||
measure();
|
||||
replaceInPlace(doc, html);
|
||||
watchBody();
|
||||
}
|
||||
bind();
|
||||
}, [bind, html, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = ref.current;
|
||||
const doc = frame?.contentDocument;
|
||||
if (doc?.body == null) return undefined;
|
||||
// 图片late-load、字体回流都会改高度 —— 盯着 body 尺寸变化重量一次
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(doc.body);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, html]);
|
||||
// 每次渲染都补一次分页、监听与量高(幂等):翻页/editable 开合都会改高度
|
||||
paginate();
|
||||
measure();
|
||||
}, [bind, html, measure, watchBody, paginate, page]);
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
watchBody();
|
||||
paginate();
|
||||
measure();
|
||||
bind();
|
||||
}, [measure, bind]);
|
||||
}, [watchBody, paginate, measure]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
|
||||
Reference in New Issue
Block a user