Archived
2026-08-31 用户四连: ① 「各自编辑各自的部分,不要做联动」—— 平台新增 scopedTargets(ui-kit):开合作用域 跟着触发钮走,三档 —— 钮在本地址宿主的行里→只这一行(A 卡的笔只开 A 卡);钮在别的 宿主的行里→行内属于本地址的宿主(这张卡的卡背);外部闸行→全宿主(老行为,② 的闸 不受影响)。收值与保存不随作用域收窄(collect 仍按地址全量,「只写真改了的行」在 服务端把关)。编辑态/锁面/笔盘显隐 CSS 全部改成按卡(.dossier.on);同地址同时只开 一张卡(笔在别卡编辑中自动隐去,快照键按地址,双开会互相踩)。2 测 + 变异验证。 ② 逐块「还差 N 行必填」红带撤下,点名并进顶部「还差什么」—— 同一批基座读数 (_missing/_missing_keys)在顶条出两行子行,数值 0 自己消失;gate_reason 改口。 ③ 比对基准与芯片档案区序换回(基准在前)。 ④ 比对基准换「层叠双封装」SVG(后面那枚淡出成剪影 —— 一类而非一颗),不再与芯片卡 共用同一枚封装图。 测:ui-kit 35(scoped 2 新,变异红过)+ 包内 105 + 2528 全仓;2.11.0 装库五探针核过 (合并条/无旧红带/类型图/按卡 CSS/基准在前);浏览器实测 XL 卡独自翻背进编辑、LM 卡纹丝不动。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
697 lines
35 KiB
TypeScript
697 lines
35 KiB
TypeScript
/**
|
|
* 视图区容器 —— **净化渲染,无脚本执行权**(编码规范 §9.2;语义出处 AM 07)。
|
|
*
|
|
* 「视图区容器渲染活动推送的 HTML:沙箱 iframe(srcdoc + sandbox)或净化后注入,
|
|
* 二选一由 `ui-kit` 的 `ViewContainer` 统一实现,**feature 不得自行 dangerouslySetInnerHTML`。」
|
|
*
|
|
* 这里选沙箱 iframe:结构上就没有脚本执行权,不依赖净化算法的完备性。
|
|
*
|
|
* **高度随内容长**: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;
|
|
/**
|
|
* **件上的资料名被点了** —— 收到 `data-file="域.对象/主键值"` 那一下。
|
|
*
|
|
* 容器只报「点的是哪一份」,取文件、渲文件都不在这儿:这份组件的本分是
|
|
* 把沙箱里发生的事如实交出去,至于跳到哪一屏是工作区的事。
|
|
*/
|
|
readonly onOpenFile?: (ref: { address: string; key: string }) => 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;
|
|
// textarea 的拟值在正文:拟值真变了,活值跟上(与 input 的 value 同一条规矩)
|
|
const owner = o.parentElement;
|
|
if (owner?.tagName === 'TEXTAREA') (owner as HTMLTextAreaElement).value = 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` 地址找(多宿主时取文档序第一个,老调用方语义不变) */
|
|
export function bodyOf(doc: Document, address: string): HTMLElement | null {
|
|
return hostsOf(doc, address)[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* **同一个地址的全部宿主**(2026-08-31,W05 芯片卡翻面逼出的多宿主收值)。
|
|
*
|
|
* 从前一个地址恰一个宿主(`bodyOf` 单 querySelector)——「参数值印在每张芯片卡的背面、
|
|
* 就地可改」这种版面下,同一张表(`parts.parameter`)的行天然分居两张卡,一个宿主装不下。
|
|
* 现在收值 / 开合 / 镜像都按**宿主清单**走:每个宿主各自归行(行仍是宿主的直接子元素),
|
|
* 清单合起来就是这张表。加行(`addRow`)仍只认第一个宿主 —— 会加行的表(类型字典 /
|
|
* 字段清单)从来都是单宿主,多宿主的表行由配方铺、人不加行。
|
|
*/
|
|
export function hostsOf(doc: Document, address: string): HTMLElement[] {
|
|
return Array.from(doc.querySelectorAll<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`));
|
|
}
|
|
|
|
/**
|
|
* 这个格子归这个宿主管吗 —— **认最近的 `data-edit` 祖先**。
|
|
* 宿主可以嵌套(芯片卡是 `parts.profile` 的行,卡背里又住着 `parts.parameter` 的宿主):
|
|
* 外层收值时不认里层宿主的格子,不然参数值会被当成主数据的列并进主数据的行
|
|
* (`view-container-collect-nested.test.ts` 钉过的那种静默丢数据,换一副面孔回来)。
|
|
* 嵌套的 `data-rows` **不带** `data-edit` 时行为原样(照旧并行 —— 那两条钉测不动)。
|
|
*/
|
|
function ownsCell(host: HTMLElement, cell: Element): boolean {
|
|
return cell.closest('[data-edit]') === host;
|
|
}
|
|
|
|
/**
|
|
* 一个格子所在的那一行 —— **host 的直接子元素**,不管格子本身嵌了几层。
|
|
*
|
|
* 自动孪生的格子(`twinInputs` 写出来的)是 `<td><input></td>`,往上两层正好是行;
|
|
* 手写的三选一(`.jc-radiogrp` 套一层 `<label>`)是 `<td><div><label><input></label></div></td>`,
|
|
* 往上两层只到得了那个 radio 组的 `<div>` —— **每个 radio 组各算一行**,同一张表格里
|
|
* 三组三行、真正的行反倒被拆成了 N 个碎片,主键那一列(在别的 td 里)一个也进不去这些碎片。
|
|
* 一路往上走到 host 的直接子元素为止,不管中间嵌了几层都能对上真正的那一行。
|
|
*/
|
|
function rowOf(host: Element, cell: Element): Element | null {
|
|
let node: Element | null = cell;
|
|
while (node !== null && node.parentElement !== host) node = node.parentElement;
|
|
return node;
|
|
}
|
|
|
|
/** 可改格子的三种控件 —— 文本输入 / 下拉 / 多行文本,同源同待 */
|
|
type EditCell = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
|
|
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内。下拉与多行文本(textarea)与文本输入同源同待 */
|
|
function cellsOf(doc: Document, address: string): EditCell[] {
|
|
return hostsOf(doc, address).flatMap((host) =>
|
|
Array.from(host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]')).filter(
|
|
(i) => ownsCell(host, i) && i.closest('.jc-row-gone') === null,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* **「没答」= 「不改」**——radio 一组都没点、下拉停在空占位,是同一句话的两种写法。
|
|
* 三选一没有第四态可以表达「维持原值」,只能靠「没有一个是选中的」;下拉同理,靠
|
|
* `<option value="">` 这个空占位表达「还没选」。跳过这一格,原值在台账里原样留着。
|
|
*/
|
|
export function unanswered(cell: EditCell): boolean {
|
|
if (cell.type === 'radio') return !(cell as HTMLInputElement).checked;
|
|
if (cell.type === 'select-one') return cell.value === '';
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 把一张表读成 `[{列: 值}]` —— **按行元素归行**,不按 `data-row` 序号归行。
|
|
*
|
|
* 序号是渲染那一刻钉上去的;人删一行、加一行之后,它就不再是「第几行」了
|
|
* (删掉第 2 行,余下的仍是 0/2/3 —— 拿它当下标会在中间留一个洞)。
|
|
* 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。
|
|
*/
|
|
export function collect(doc: Document, address: string): Array<Record<string, string>> {
|
|
const byRow = new Map<Element, Record<string, string>>();
|
|
for (const host of hostsOf(doc, address)) {
|
|
for (const cell of Array.from(
|
|
host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
|
|
)) {
|
|
if (!ownsCell(host, cell) || cell.closest('.jc-row-gone') !== null) continue;
|
|
const col = cell.dataset['col'];
|
|
const row = rowOf(host, cell);
|
|
if (col === undefined || row == null) continue;
|
|
/*
|
|
* **radio 选项 / 下拉:没答的不算话,没答 = 这一列不动**(2026-08-02,
|
|
* A2 替代等级三选;2026-08-20 扩到下拉)。radio 的 `value` 是选项值,恒有;
|
|
* 下拉一旦渲染也恒有一个 `.value`(哪怕是空占位)—— 都照 text 那样读的话,
|
|
* 没人碰过也会交出一个值,把台账里的原值抹掉;跳过则原值保留。
|
|
*/
|
|
if (unanswered(cell)) continue;
|
|
const one = byRow.get(row) ?? {};
|
|
one[col] = cell.value;
|
|
byRow.set(row, one);
|
|
}
|
|
}
|
|
return [...byRow.values()];
|
|
}
|
|
|
|
/**
|
|
* **保存那一下,新值就地写进只读孪生件**(2026-08-03 用户:「编辑→保存的时候
|
|
* 旧的状态还是会持续一会儿」)。
|
|
*
|
|
* 保存这条链是:交出去 → POST 落草稿层 → 失效缓存 → 重取任务详情 → 新件推回来。
|
|
* 一个来回几百毫秒到一秒多,而闸在按下那一刻就关了 —— 于是这一段时间里屏上摆的是
|
|
* **只读那一份的旧值**:人刚敲完 36000,一按保存,格子跳回原来的空白/旧数,
|
|
* 看着像这一下没生效(有人会再改一遍、再按一次)。
|
|
*
|
|
* 值本来就在手上(正是要交出去的那一份),不必等服务端告诉我们。所以按下保存先把
|
|
* 每个格子的值写进它自己的只读孪生件,回声到达时 morph 一比对已经一样 —— 一次重绘都没有。
|
|
*
|
|
* **只镜像格子自己的值**:红带缺几格、分布条、去向这些是**基座算出来的数**,
|
|
* 一律等回声(界面不撒谎:自己算一个摆上去,就成了没有依据的画面)。
|
|
*
|
|
* 认孪生件**按位置不按 `data-slot`**:渲染那一遍会把 `data-slot` 连同槽位一起**消费掉**,
|
|
* 活 DOM 里两份谁都没有这个属性(拿它去找,找到的是空,这一段就静默失效)——
|
|
* 而 `twinInputs` 是把 input **紧接着**只读那份写出来的,前一个兄弟就是它的孪生件。
|
|
* 这条是在真单的 DOM 上查出来的:合成 fixture 里我两份都写了 data-slot,于是照样绿。
|
|
*/
|
|
export function mirrorTwins(doc: Document, address: string): void {
|
|
for (const cell of cellsOf(doc, address)) {
|
|
// 与 collect 同一条规矩:没答的不算话(「没点/没选」= 不改)
|
|
if (unanswered(cell)) continue;
|
|
const twin = cell.previousElementSibling;
|
|
if (twin === null || twin.tagName === 'INPUT' || twin.tagName === 'SELECT' || twin.tagName === 'TEXTAREA') continue;
|
|
/*
|
|
* **下拉镜像的是选项文案,不是 value**(2026-08-31 用户:「保存的一瞬间芯片类型
|
|
* 下拉框的值会变成数字」)—— 下拉的 value 是机器码(type_id、tally 值),
|
|
* 摆给人看的向来是人读名;顺手把 selected 属性拨到人选的那一项:守卫类 CSS
|
|
* (:has(option[selected]))与「取消后再编辑」的回显都认它,不拨的话保存后到
|
|
* 重推回来那一段,屏上仍按旧选择说话。
|
|
*/
|
|
if (cell.tagName === 'SELECT') {
|
|
const sel = cell as HTMLSelectElement;
|
|
for (const o of Array.from(sel.options)) o.toggleAttribute('selected', o.value === sel.value && sel.value !== '');
|
|
twin.textContent = sel.selectedOptions[0]?.textContent ?? sel.value;
|
|
continue;
|
|
}
|
|
twin.textContent = cell.value;
|
|
}
|
|
}
|
|
|
|
/** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */
|
|
function collectGone(doc: Document, address: string): Array<Record<string, string>> {
|
|
return hostsOf(doc, address).flatMap((host) =>
|
|
Array.from(host.querySelectorAll<HTMLElement>('.jc-row-gone'))
|
|
.filter((row) => row.closest('[data-edit]') === host)
|
|
.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;
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* **按下某一行的 ×**:两种行两种下场。
|
|
* · 本轮「加一行」加出来的(`jc-row-new`)—— 它从没存在过,直接拿掉;挂个记号再保存,
|
|
* 送出去的是一条「撤掉一行草稿里没有的行」,服务端只能回「认不出」,人看着像没删成;
|
|
* · 原本就有的行 —— 挂记号(`jc-row-gone`),不当场抹掉:人得看得见自己删了哪一行,
|
|
* 也才有得后悔(「取消」把记号摘了就回来了);再按一次 × 记号摘掉。
|
|
* 抽成函数是为了钉测:2026-08-27 用户在 W05 上按 × 「没有反应」—— 类是切了,件里没有那条样式。
|
|
*/
|
|
export function dropRow(row: HTMLElement): 'removed' | 'marked' | 'unmarked' {
|
|
if (row.classList.contains('jc-row-new')) {
|
|
row.remove();
|
|
return 'removed';
|
|
}
|
|
return row.classList.toggle('jc-row-gone') ? 'marked' : 'unmarked';
|
|
}
|
|
|
|
/**
|
|
* **在表尾添一行空的** —— 照末行的样子克隆,值清空。
|
|
*
|
|
* 克隆的是**渲染出来的那一行**,不是模版:模版在服务端,浏览器这边看不见。
|
|
* 一张空表(一行都没有)加不了行 —— 没有可照的样子;那种情形本来也不该由人开头
|
|
* (行由活动写下,人改的是它写错的地方)。
|
|
*/
|
|
export 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<EditCell>('input[data-col], select[data-col], textarea[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;
|
|
}
|
|
|
|
/**
|
|
* 那块内容与它的闸一起开合。`.on` 落在哪:行容器是表格的落在 `<table>`(老件,样式按
|
|
* `table.on` 写);不是表格的落在 `data-edit` 元素自身(2026-08-02 A2 对决卡立的规矩)。
|
|
* 这一版是**全宿主**开合(外部闸行用它);按了卡上钮的走 `scopedTargets`(见下)。
|
|
*/
|
|
export function setOpenIn(doc: Document, address: string, on: boolean): void {
|
|
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
|
|
for (const host of hostsOf(doc, address)) (host.closest('table') ?? host).classList.toggle('on', on);
|
|
}
|
|
|
|
/**
|
|
* **开合的作用域跟着触发钮走**(2026-08-31 用户:「芯片档案编辑就不能各自编辑各自的部分吗,
|
|
* 不要做联动」)。三档,从近到远:
|
|
* ① 钮长在这张表**自己的宿主**里 —— 落到钮所在的**那一行**(宿主的直接子元素;
|
|
* W05 芯片卡的笔落在这张卡的行上,只这张卡进编辑);行认不出就整个宿主;
|
|
* ② 钮长在**别的**宿主的某一行里 —— 落到**这一行内部**属于本地址的宿主们
|
|
* (W05 卡背的参数宿主住在主数据宿主的行里:点这张卡的笔,只这张卡的卡背开);
|
|
* ③ 都不是(外部闸行,如差异审核件的编辑闸)—— 全部宿主一起,老行为。
|
|
* 收值与保存**不随作用域收窄**(collect 仍按地址全量;「只写真改了的行」在服务端把关,
|
|
* 没动的行是 no-op)—— 作用域只管「屏上哪一块进编辑态」这一件事。
|
|
*/
|
|
export function scopedTargets(doc: Document, address: string, trigger: Element): HTMLElement[] {
|
|
const hosts = hostsOf(doc, address);
|
|
for (const host of hosts) {
|
|
if (host.contains(trigger)) {
|
|
const row = rowOf(host, trigger);
|
|
return [(row as HTMLElement | null) ?? host];
|
|
}
|
|
}
|
|
/* 钮在别的宿主的行里:找出包住钮的那一行(任意 data-edit 宿主的直接子元素),取行内的本地址宿主 */
|
|
for (let n: Element | null = trigger; n !== null; n = n.parentElement) {
|
|
const parent = n.parentElement;
|
|
if (parent !== null && parent.hasAttribute('data-edit')) {
|
|
const inner = hosts.filter((h) => n !== null && n.contains(h));
|
|
if (inner.length > 0) return inner;
|
|
}
|
|
}
|
|
return hosts.map((h) => (h.closest('table') ?? h) as HTMLElement);
|
|
}
|
|
|
|
/**
|
|
* `data-file="域.对象/主键值"` → 取文件要的那两样。
|
|
*
|
|
* 认第一个斜杠:主键值里再有斜杠也照样归主键(文件名常带 `/`,地址永远不带)。
|
|
* 缺一半的一律不认 —— 宁可点不动,也不发一个取不回来的请求。
|
|
*/
|
|
export function fileRefOf(raw: string | null): { address: string; key: string } | undefined {
|
|
const ref = raw ?? '';
|
|
const at = ref.indexOf('/');
|
|
if (at <= 0 || at === ref.length - 1) return undefined;
|
|
const address = ref.slice(0, at);
|
|
// 地址必须是「域.对象」两段 —— 形状不对就不是一份资料的地址
|
|
if (!/^[a-z][\w]*\.[a-z][\w]*$/i.test(address)) return undefined;
|
|
return { address, key: ref.slice(at + 1) };
|
|
}
|
|
|
|
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits, onOpenFile, 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');
|
|
/*
|
|
* **一份不分页的件是「一页」,不是「零页」**(2026-08-23 用户:「这个 0/0 显示的
|
|
* 不正常」)。绝大多数件压根没写 `data-page`(整篇铺开就是全部内容)——
|
|
* 从前这里如实回报 0,外层拿它去写页码,屏上就出「0/0」。
|
|
* 页数报的是**这份件有几页**,件在手上,再少也是一页。
|
|
*/
|
|
onPagesRef.current?.(Math.max(1, 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 用户报的「保存时闪黑」)。
|
|
*
|
|
* `srcDoc` 一变浏览器就**重载整个文档**:那一瞬 iframe 里什么都没有,
|
|
* 高度也从量好的五千多掉回兜底的 420 再涨回来 —— 底下的深色画布露出来一下,
|
|
* 看起来就是「闪黑」。而人刚按下保存,件在他眼皮底下抖一下,像是出错了。
|
|
*
|
|
* 容器是同源的,换内容不必换文档:直接写 `body.innerHTML`。
|
|
* 文档不重载,于是高度不跳、滚动位置不丢、挂在文档上的监听也还在。
|
|
*/
|
|
const [seed] = useState(html);
|
|
const shown = useRef(html);
|
|
|
|
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);
|
|
}, []);
|
|
|
|
/*
|
|
* 回调放 ref —— 外层多半是行内箭头函数,每次渲染都是新的一个。
|
|
* 让它进 effect 的依赖,等于每渲染一次就拆装一次监听;而真正要重来的时机只有一个:
|
|
* **文档换了**(srcdoc 一变就是一份新文档,挂在旧文档上的东西随之作废)。
|
|
*/
|
|
const sink = useRef(onEdits);
|
|
sink.current = onEdits;
|
|
const fileSink = useRef(onOpenFile);
|
|
fileSink.current = onOpenFile;
|
|
/** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */
|
|
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;
|
|
if (typeof target?.closest !== 'function') return;
|
|
|
|
/*
|
|
* **资料名:点开那一份文件** —— 先看这一条,再看编辑闸。
|
|
* 两者不会同时命中(一个在表格格子里、一个在闸上),先后只是省一次匹配。
|
|
*/
|
|
const fileHit = target.closest('[data-file]');
|
|
if (fileHit !== null && fileHit !== undefined) {
|
|
const ref = fileRefOf(fileHit.getAttribute('data-file'));
|
|
if (ref !== undefined) {
|
|
event.preventDefault();
|
|
fileSink.current?.(ref);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const hit = target.closest('[data-edit-open],[data-edit-cancel],[data-edit-save],[data-edit-add],[data-edit-del]');
|
|
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);
|
|
for (const target of scopedTargets(doc, address, hit)) (target.closest('table') ?? target).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) {
|
|
// 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原
|
|
// (radio 的 value 是恒定的选项值,快照与还原认的是 checked)
|
|
snapshot.current.set(open, {
|
|
values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? ((c as HTMLInputElement).checked ? '1' : '') : c.value)),
|
|
/* 行数逐宿主记 —— 多宿主时「取消」要把每个宿主各自加出来的行裁回去 */
|
|
rows: hostsOf(doc, open).map((h) => h.children.length),
|
|
});
|
|
setOpen(open, true);
|
|
return;
|
|
}
|
|
if (cancel !== undefined) {
|
|
const was = snapshot.current.get(cancel);
|
|
if (was !== undefined) {
|
|
// 加出来的那几行整个撤掉,删掉的标记摘掉,再把值放回去 —— 三样一起才叫还原
|
|
hostsOf(doc, cancel).forEach((host, i) => {
|
|
const keep = was.rows[i];
|
|
if (keep !== undefined) while (host.children.length > keep) 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) => {
|
|
const saved = was.values[i];
|
|
if (saved === undefined) return;
|
|
if (c.type === 'radio') (c as HTMLInputElement).checked = saved === '1';
|
|
else c.value = saved;
|
|
});
|
|
}
|
|
setOpen(cancel, false);
|
|
return;
|
|
}
|
|
/*
|
|
* **增删只在编辑态里发生,保存那一下才落库** —— 与改格子同一条路子:
|
|
* 删是给那一行挂个记号(`jc-row-gone`),不是当场从屏上抹掉 ——
|
|
* 人得看得见自己删了哪一行,也才有得后悔(「取消」把记号摘了就回来了)。
|
|
*/
|
|
if (del !== undefined) {
|
|
const row = target?.closest?.('tr') ?? null;
|
|
if (row !== null) dropRow(row);
|
|
measureRef.current();
|
|
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);
|
|
// 关闸之前先把新值摆到只读那一份上 —— 不然人对着旧值等一个来回(见 mirrorTwins)
|
|
mirrorTwins(doc, 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;
|
|
|
|
/*
|
|
* 图片 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;
|
|
replaceInPlace(doc, html);
|
|
watchBody();
|
|
}
|
|
// 每次渲染都补一次分页、监听与量高(幂等):翻页/editable 开合都会改高度
|
|
paginate();
|
|
measure();
|
|
}, [bind, html, measure, watchBody, paginate, page]);
|
|
|
|
const onLoad = useCallback(() => {
|
|
watchBody();
|
|
paginate();
|
|
measure();
|
|
}, [watchBody, paginate, measure]);
|
|
|
|
return (
|
|
<iframe
|
|
ref={ref}
|
|
className="jc-view-frame"
|
|
title={title}
|
|
/*
|
|
* `allow-same-origin` 让外层量得到高度、挂得上事件、收得回值。
|
|
* 关键在于**始终不给 `allow-scripts`** —— 里面一行脚本也跑不了,
|
|
* 「无脚本执行权」这条不变。两者同时给才是破沙箱,这里永远不会。
|
|
*/
|
|
sandbox="allow-same-origin"
|
|
srcDoc={seed}
|
|
style={{ height }}
|
|
onLoad={onLoad}
|
|
scrolling="no"
|
|
/>
|
|
);
|
|
}
|