Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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:
@@ -23,13 +23,29 @@ export type ViewBlocksProps = {
|
||||
/** 报告级「修改」态:表 / 邮件就地进入编辑(存与弃由外层报告统一管) */
|
||||
readonly editing?: boolean | undefined;
|
||||
readonly onOpenFile?: ((file: FileDoc) => void) | undefined;
|
||||
/**
|
||||
* 表被改了 —— **改完的行要交出去**(第几块、整张表的行)。
|
||||
*
|
||||
* 从前没有这个口子:可编辑表的修改只活在 `TableBlock` 自己的 `setRows` 里,
|
||||
* 人填完提交,回传载荷里一个格子都没有。实测后果最难看 ——
|
||||
* 活动派了一张「空的地方标红待填写」的执行单,人老老实实填了 13 个格子、提交,
|
||||
* Agent 收到的回传是空的,于是认为没人答,**又发了一张一模一样的单**。
|
||||
* 人以为自己白填了一遍,而系统看起来在原地打转。
|
||||
*/
|
||||
readonly onTableChange?: ((index: number, rows: readonly (readonly string[])[]) => void) | undefined;
|
||||
};
|
||||
|
||||
export function ViewBlocks({ blocks, editing = false, onOpenFile }: ViewBlocksProps) {
|
||||
export function ViewBlocks({ blocks, editing = false, onOpenFile, onTableChange }: ViewBlocksProps) {
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, i) => (
|
||||
<Block key={i} b={block} editing={editing} onOpenFile={onOpenFile} />
|
||||
<Block
|
||||
key={i}
|
||||
b={block}
|
||||
editing={editing}
|
||||
onOpenFile={onOpenFile}
|
||||
{...(onTableChange === undefined ? {} : { onTableChange: (rows: readonly (readonly string[])[]) => onTableChange(i, rows) })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -39,10 +55,12 @@ function Block({
|
||||
b,
|
||||
editing,
|
||||
onOpenFile,
|
||||
onTableChange,
|
||||
}: {
|
||||
b: ViewBlock;
|
||||
editing: boolean;
|
||||
onOpenFile?: ((file: FileDoc) => void) | undefined;
|
||||
onTableChange?: ((rows: readonly (readonly string[])[]) => void) | undefined;
|
||||
}) {
|
||||
switch (b.t) {
|
||||
case 'callout':
|
||||
@@ -82,7 +100,7 @@ function Block({
|
||||
);
|
||||
|
||||
case 'table':
|
||||
return <TableBlock b={b} editing={editing} />;
|
||||
return <TableBlock b={b} editing={editing} {...(onTableChange === undefined ? {} : { onChange: onTableChange })} />;
|
||||
|
||||
case 'bars':
|
||||
return (
|
||||
@@ -218,13 +236,26 @@ function Section({ title, children }: { title?: string | undefined; children: Re
|
||||
}
|
||||
|
||||
/** 可编辑表:报告「修改」态下就地编辑;数据源一换即复位(渲染期调整 state,不用 effect) */
|
||||
function TableBlock({ b, editing }: { b: Extract<ViewBlock, { t: 'table' }>; editing: boolean }) {
|
||||
function TableBlock({
|
||||
b,
|
||||
editing,
|
||||
onChange,
|
||||
}: {
|
||||
b: Extract<ViewBlock, { t: 'table' }>;
|
||||
editing: boolean;
|
||||
onChange?: ((rows: readonly (readonly string[])[]) => void) | undefined;
|
||||
}) {
|
||||
const [rows, setRows] = useState<readonly (readonly string[])[]>(b.rows);
|
||||
const [srcRows, setSrcRows] = useState(b.rows);
|
||||
if (srcRows !== b.rows) {
|
||||
setSrcRows(b.rows);
|
||||
setRows(b.rows);
|
||||
}
|
||||
/** 改一格就往外交一次 —— 外层报告拿它拼回传载荷,不必等「保存」那一下 */
|
||||
const put = (next: readonly (readonly string[])[]): void => {
|
||||
setRows(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const live = editing && b.editable === true;
|
||||
const canEditCol = (c: number) => b.editableCols === undefined || b.editableCols.includes(c);
|
||||
@@ -259,12 +290,26 @@ function TableBlock({ b, editing }: { b: Extract<ViewBlock, { t: 'table' }>; edi
|
||||
{r.map((cell, ci) => (
|
||||
<td key={ci} className={ci === 0 ? 'jc-mono' : ''}>
|
||||
{live && canEditCol(ci) ? (
|
||||
/*
|
||||
* 空格子**自己喊「待填写」**(红字占位)。
|
||||
*
|
||||
* 一张全可编辑的表摆在人面前,哪几格是空的、哪几格本来就没有,
|
||||
* 光看是分不出来的 —— 尤其这份表大半列都填着值,空的那两格
|
||||
* 在一片文字里根本不显眼。人于是签完了才发现漏填。
|
||||
* 占位字 + 红边把「这里等你」摆到脸上,填上就恢复常态。
|
||||
*/
|
||||
<input
|
||||
className={cell.trim() === '' ? 'jc-tbl-todo' : undefined}
|
||||
placeholder="待填写"
|
||||
aria-label={`${b.cols[ci] ?? ''}${cell.trim() === '' ? '(待填写)' : ''}`}
|
||||
value={cell}
|
||||
onChange={(e) =>
|
||||
setRows(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x)))
|
||||
put(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x)))
|
||||
}
|
||||
/>
|
||||
) : cell.trim() === '' ? (
|
||||
/* 只读态也别留一片空白:没填就是没填,如实说 */
|
||||
<em className="jc-tbl-empty">未填</em>
|
||||
) : (
|
||||
cell
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @ui —— 无业务语义展示组件(卡片原语 / 时间线 / **净化视图容器**)。
|
||||
*/
|
||||
export type { ViewContainerProps } from './ViewContainer.tsx';
|
||||
export type { ViewContainerProps, ViewEdits, ViewSave } from './ViewContainer.tsx';
|
||||
export { ViewContainer } from './ViewContainer.tsx';
|
||||
export { ViewBlocks } from './ViewBlocks.tsx';
|
||||
export { Ico, hasIco } from './Ico.tsx';
|
||||
|
||||
Reference in New Issue
Block a user