Commit 1402bb9f by luoqi

fix(web): 嵌宿主 sandbox iframe 时新标签页被拦 → 逐级兜底;去掉预约成功 toast;回访→跟进

## ① 内嵌宿主时控制台报错、点了没反应
线上(嵌在宿主 iframe 里)实测:
  Blocked opening '<url>' in a new window because the request was made in a sandboxed
  frame whose 'allow-popups' permission is not set.
宿主的 <iframe sandbox> 没给 allow-popups → window.open 直接被浏览器拦掉。
上一版(7-29 改新标签页)只写了一行 window.open,**连"被拦了"都不知道**,表现就是点了没反应。

openHostUrl 改成三级兜底:新标签页 → 顶层跳转(老 _top 行为)→ 本 frame 内跳转。
能开新页就开,开不了也得让人到得了那个页面。

️ 检测"被拦"不能靠 window.open 的返回值配 noopener —— features 里带 noopener 时
**成功也返回 null**,永远分不清。改成先开同源 about:blank、拿到句柄手工断 opener 再导航,
效果同 noopener 但可检测。

️ 两个 a 标签(原始档案 / 原始病历)也改成 onClick 走 openHostUrl:声明式的
target="_blank" 在 sandbox 下同样被拦,而那条路我们既拦不到也补不了。href 保留让
右键"复制链接"仍可用。

根治仍在宿主侧 —— 注释和 console.warn 都写明了:请对接方给 iframe 加 allow-popups
(想让新页不继承沙箱再加 allow-popups-to-escape-sandbox)。

## ② 去掉「已在新标签页打开宿主预约页」toast
新标签页开出来用户自己看得见,再报一句是噪音。失败路径的提示都还在。
兜底路径也刻意不弹 toast:下一行就导航走了,提示根本来不及被看见。

## ③ 顶栏「回访」按钮文案改「跟进」
这个按钮跳的是宿主侧动作页,落到宿主那边不一定叫回访;而 PAC 里「回访」已被
"诊所回访记录 / 历史联系"占着,同一个词指两件事。
️ 只改按钮字面,槽位 key(OPEN_RETURN_VISIT)不动 —— 那是宿主配置里的键名。

本地实测:打桩让 window.open 返 null(模拟 sandbox)→ 确实走兜底跳到了宿主页;
按钮 DOM 是 title="跟进" / 文案"跟进";预约不再弹成功 toast。
️ 未实测的:真 Chrome 里"正常开出新标签页"这一支 —— 内嵌浏览器面板本身拦弹窗,
两种 window.open 形式都返 null,证不了。697 tests / 45 suites + web typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent f23bfefe
......@@ -437,8 +437,9 @@ export function PlanDetailApp({
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return;
}
// 不弹成功 toast —— 新标签页开出来用户自己看得见,再报一句是噪音(业务 2026-07-30)。
// 失败路径仍有提示:未配置 → 上面那条 amber toast;被 sandbox 拦 → openHostUrl 里降级跳转。
openHostUrl(url);
showToast('emerald', '已在新标签页打开宿主预约页', '带上患者 id');
};
// ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ──
......@@ -1169,11 +1170,15 @@ function TopBar({
type="button"
onClick={onOpenReturnVisit}
onMouseEnter={onHoverReturnVisit}
title="回访"
// 按钮文案「跟进」而非「回访」(业务 2026-07-30):这个按钮跳的是宿主侧的动作页,
// 落到宿主那边不一定叫回访;而 PAC 里「回访」已被"诊所回访记录 / 历史联系"占着,
// 同一个词指两件事。⚠️ 只改按钮字面,槽位 key(OPEN_RETURN_VISIT)不动 ——
// 那是宿主配置里的键名,改了所有宿主都得重配。
title="跟进"
className="inline-flex items-center gap-1.5 rounded-md bg-brand-600 px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-brand-700"
>
<CalendarClock className="h-3.5 w-3.5" />
<span className="hidden sm:inline">回访</span>
<span className="hidden sm:inline">跟进</span>
</button>
)}
{onCloseOpportunity && (
......@@ -1373,6 +1378,12 @@ function IdentityCard({
<a
href={originalArchiveUrl}
{...HOST_LINK_PROPS}
// 走 JS 才有兜底:宿主 sandbox iframe 会把声明式 target="_blank" 直接拦掉,
// 而声明式那条路拦了我们既拦不到也补不了。href 留着让右键"复制链接"可用。
onClick={(e) => {
e.preventDefault();
openHostUrl(originalArchiveUrl);
}}
className="text-[10.5px] text-brand-700 hover:underline"
>
原始档案 →
......@@ -1955,6 +1966,11 @@ function TreatmentHistoryCard({
<a
href={emrUrl}
{...HOST_LINK_PROPS}
// 同「原始档案」:点击走 openHostUrl 才有 sandbox 兜底(见 action-url.ts)
onClick={(e) => {
e.preventDefault();
openHostUrl(emrUrl);
}}
className="text-[10.5px] text-brand-700 hover:underline"
>
原始病历 →
......
......@@ -47,9 +47,62 @@ export function resolveActionUrl(
*
* 用 a 标签的地方用 HOST_LINK_PROPS,用 JS 派发的地方用 openHostUrl(),两条路同一口径。
* `noopener`:新页拿不到 window.opener,防宿主页被反向导航(tabnabbing)。
*
* ⚠️ **a 标签必须同时挂 onClick={openHostUrl}**(见各调用点):宿主用 sandbox iframe 嵌 PAC 时,
* 声明式的 target="_blank" 会被浏览器直接拦掉、且拦不住也拦不到 —— 只有走 JS 才有兜底余地。
* href 保留是为了右键"复制链接地址"仍可用。
*/
export const HOST_LINK_PROPS = { target: '_blank', rel: 'noopener noreferrer' } as const;
/**
* ⭐ 打开宿主页 —— 新标签页优先,**被 sandbox 拦掉时逐级兜底**。
*
* 2026-07-30 线上(嵌宿主)实证,控制台报:
* `Blocked opening '<url>' in a new window because the request was made in a sandboxed
* frame whose 'allow-popups' permission is not set.`
* 宿主的 `<iframe sandbox>` 没给 `allow-popups` → window.open 直接被拦,**点了没反应**。
* 上一版只写了一行 window.open,连"被拦了"都不知道。
*
* 三级兜底(能开新页就开新页,开不了也要让人到得了那个页面):
* ① 新标签页(业务要的形态)
* ② 顶层跳转(老 `_top` 行为)—— 会顶掉宿主页,但比"点了没反应"好;
* sandbox 若也没给 allow-top-navigation,赋值会抛 SecurityError,继续降级
* ③ 本 frame 内跳转 —— 一定成立,PAC 自己被替换掉(客服可用宿主的返回回来)
*
* ⚠️ 检测被拦**不能靠 window.open 的返回值配 noopener**:features 里带 noopener 时
* 规范规定**成功也返回 null**,那样永远分不清是被拦还是开成功了。
* 所以改成先开同源 about:blank、拿到句柄后手工断 opener 再导航 —— 效果同 noopener,但可检测。
*
* 根治仍在宿主侧:请对接方给 iframe 的 sandbox 加上 `allow-popups`(想保留新页则再加
* `allow-popups-to-escape-sandbox`,否则新页会继承沙箱限制)。这里只是让 PAC 别装死。
*/
export function openHostUrl(url: string): void {
window.open(url, '_blank', 'noopener,noreferrer');
const opened = window.open('', '_blank');
if (opened) {
try {
opened.opener = null; // about:blank 同源,能设;等价于 noopener,防 tabnabbing
} catch {
/* 设不上就算了 —— 比开不出页面轻得多 */
}
opened.location.replace(url);
return;
}
// 被拦了 —— 只能退回"跳转"。⚠️ 这里**不弹 toast**:下一行就导航走了,提示根本来不及被看见
// (试过,页面卸载时 toast 刚挂上就消失)。要留痕就留给控制台,那是给对接方看的。
console.warn(
`[pac] 新标签页被宿主 iframe 的 sandbox 拦下(缺 allow-popups),降级为跳转:${url}\n` +
'根治:请宿主给 <iframe sandbox> 加 allow-popups(想让新页不继承沙箱再加 allow-popups-to-escape-sandbox)',
);
try {
// 顶层跳转(老 `_top` 行为):会顶掉宿主页,但比"点了没反应"好。
// sandbox 没给 allow-top-navigation 时赋值会抛 SecurityError → 落到本 frame 内跳。
if (window.top && window.top !== window.self) {
window.top.location.href = url;
return;
}
} catch {
/* 继续降级 */
}
window.location.href = url;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment