Commit b8836580 by luoqi

merge: fix/host-embed-popup-and-copy → test

- 嵌宿主 sandbox iframe 时新标签页被拦 → 三级兜底(新页 → 顶层 → 本 frame)
- 去掉「已在新标签页打开宿主预约页」toast;顶栏「回访」按钮改「跟进」
- 打开潜在治疗的 postMessage 补 desc / treatments / stage 三个字段
- 新增交付文档 docs/integration/postmessage-actions.mdx(可直接给对接方)
parents 9750bd3e 2b179c7c
Pipeline #3486 failed in 0 seconds
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
"channel-push", "channel-push",
"friday-push-payload", "friday-push-payload",
"auth-login", "auth-login",
"postmessage-actions",
"execution-callback", "execution-callback",
"runbook" "runbook"
] ]
......
---
title: postMessage 动作契约(宿主侧接收)
description: PAC 嵌在宿主 iframe 里时,「打开潜在治疗」等动作如何以 postMessage 通知宿主 —— 信封、字段、示例监听代码、安全要点。
icon: MessageSquareShare
---
本页是给**宿主开发**看的交付文档:PAC 作为 iframe 嵌在你们系统里,某些动作按钮点下去需要**你们弹自己的组件**,
PAC 通过 `window.parent.postMessage` 把患者上下文推给你们。
<Callout type="info">
**优先用 URL,不是 postMessage**。动作只要有独立页面(或能用深链参数触发弹窗),就配 URL 模板 —— 见
[接入总览 §4](/docs/integration/overview)。只有「动作是弹窗/组件、拿不到 URL」时才用本页这条通道。
</Callout>
---
## 1. 怎么开启
在宿主管理页(或让 PAC 侧配)把动作键的值配成**哨兵字符串** `postMessage`,并且**必须同时配 `HOST_ORIGIN`**:
```json
{
"OPEN_POTENTIAL_TREATMENT": "postMessage",
"HOST_ORIGIN": "https://host.example.com"
}
```
- `HOST_ORIGIN` = 承载 PAC iframe 的**父页 origin**,用作 `postMessage` 的 `targetOrigin`。
- **不接受 `*`** —— 载荷含患者信息,不能广播给任意父页。没配 `HOST_ORIGIN` 时 PAC 判定该动作不可用、按钮直接不渲染。
- 哨兵值大小写不敏感(`postMessage` / `POSTMESSAGE` 都认)。
---
## 2. 消息信封
```ts
{
source: 'pac', // 固定
type: 'action', // 固定
action: 'OPEN_POTENTIAL_TREATMENT', // 动作键,同 actionUrls 的 key
payload: { /* 见下 */ }
}
```
**请用 `source === 'pac' && type === 'action'` 过滤** —— 同一个父页可能还嵌着别的 iframe,别按 `action` 裸判。
信封**没有** `version` 字段。兼容纪律由 PAC 保证:**只增字段、不改已有字段语义、不删字段**。
你们按需要读字段即可,多出来的忽略。
---
## 3. `OPEN_POTENTIAL_TREATMENT` 载荷
| 字段 | 类型 | 说明 |
|---|---|---|
| `patientId` | `string` | 宿主侧患者 id —— 就是你们摄入时给 PAC 的那个 id,可直接用来定位患者 |
| `desc` | `string` | **待治疗描述**。取客服此刻在页面顶部读到的那句 AI 召回简报(「这通电话的由头」);简报还没生成好时退回结构化召回原因文本。两者都没有 → 空串(**字段一定在,不会是 undefined**) |
| `treatments` | `string[]` | **关联治疗项目**,项目名数组(不带「治疗」后缀,如 `"种植"`)。无潜在治疗 → 空数组 |
| `stage` | `string` | **病例阶段**,PAC 恒发 `"已咨询"` |
### 完整示例
```json
{
"source": "pac",
"type": "action",
"action": "OPEN_POTENTIAL_TREATMENT",
"payload": {
"patientId": "1499498",
"desc": "多颗缺牙拖 3 个月易致邻牙移位;高价值老客,医生医嘱交代过「半年定期检查」,可约复查顺带评估种植修复。",
"treatments": ["种植", "修复"],
"stage": "已咨询"
}
}
```
### `treatments` 取值范围(共 8 类)
**只有下面 8 个值**,不带「治疗」后缀。中文是 PAC 的措辞口径、可能随业务调整;
要**稳定键**请按下表自己映射一次。
| 取值 | 稳定 code | 含义 |
|---|---|---|
| 种植 | `implant` | 缺牙待种 |
| 正畸 | `ortho` | 成人正畸 |
| 早矫 | `early_ortho` | 儿童早矫(替牙期)。界面上这一项叫「早期矫治」,项目名简称「早矫」 |
| 根管 | `endo` | 牙髓 |
| 牙周 | `perio` | 牙周 |
| 充填 | `filling` | 龋齿 |
| 修复 | `restoration` | 冠桥 / 贴面 / 嵌体 |
| 拔牙 | `extraction` | 残根残冠等需拔 |
<Callout type="info">
PAC 界面上客服看到的是「种植**治疗**」「根管**治疗**」,发给你们的是去掉后缀的项目名 ——
**两处措辞不同是有意的**(那边是给人读的标签,这边是给系统落单的项目名),
且由同一张表推导,不会各自漂。
</Callout>
### `stage` 为什么恒为「已咨询」
PAC 侧的"潜在治疗"是从**诊断 / 医生建议**推出来的客观缺口,还没进你们的病例流程;
客服点这个按钮的语义就是"我已经跟患者聊过、请在宿主侧建单",所以阶段恒为已咨询。
PAC **不推断**「已确诊 / 已排期」这类流程内状态 —— 那是宿主的真理源,猜错比不给更糟。
---
## 4. 宿主侧监听示例
```js
window.addEventListener('message', (e) => {
// ① 校验来源 origin —— 只信你自己嵌的那个 PAC 域名,别用 e.origin 之外的东西判
if (e.origin !== 'https://pac.example.com') return;
const msg = e.data;
if (!msg || msg.source !== 'pac' || msg.type !== 'action') return;
if (msg.action === 'OPEN_POTENTIAL_TREATMENT') {
const { patientId, desc, treatments, stage } = msg.payload;
openPotentialTreatmentDialog({
patientId,
desc, // 待治疗描述
items: treatments, // ["种植", "修复", ...]
stage, // "已咨询"
});
}
});
```
<Callout type="warn">
**必须校验 `e.origin`**。`message` 事件任何页面都能发,不校验 origin 等于给自己开了个后门。
PAC 侧已经用 `targetOrigin` 定向发送(不广播),但那只防"发错人",防不了"别人冒充 PAC 发给你"。
</Callout>
---
## 5. 注意事项
- **URL 模式不带这三个字段**。`desc` 是长文本、`treatments` 是数组,
塞 query string 会撞长度上限,还会把病情描述写进浏览器历史和你们的 access log。
URL 模式只带 `{patientId}` `{brandId}` `{clinicId}` `{medicalRecordNumber}` 这类占位。
要 URL 模式也拿到全量字段,得改成宿主提供 POST 接口 —— 需要就提。
- **单向、无回调**。PAC 不等你们的响应、也不接收回执。动作结果通过数据摄入回流(如新建的治疗单 → 治疗事实)
+ 客服在 PAC 记通话结果来体现。
- **iframe sandbox 别漏权限**。若你们给 iframe 加了 `sandbox`,至少要有
`allow-scripts allow-same-origin`;**动作要开新标签页则必须加 `allow-popups`**
(否则浏览器直接拦掉,控制台报 `Blocked opening ... 'allow-popups' permission is not set`,
PAC 会退化成整页跳转)。想让新页不继承沙箱再加 `allow-popups-to-escape-sandbox`。
- **认领前点不到**。这些按钮在 PAC 侧过「认领闸」——工单没被客服认领时点了会被拦,
是有意为之(没有归属人后面对不上账),不是 bug。
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
HOST_ACTION_MESSAGE_SOURCE,
HOST_ACTION_MESSAGE_TYPE,
HOST_CASE_STAGE_CONSULTED,
POTENTIAL_TREATMENT_CARD_LABEL,
potentialTreatmentItemName,
} from '@pac/types';
/**
* postMessage 动作契约 ↔ 交付文档 防漂移。
*
* 这份契约是**发给宿主开发照着写监听**的,漂了不会在 PAC 里报错 —— 只会让对方按文档写完发现
* 收不到 / 值对不上,而且要等到联调才暴露。所以拿测试盯住三件事:
* ① 信封固定字段(source/type)别改字面 —— 宿主是按它过滤的
* ② 病例阶段恒「已咨询」—— 文档承诺了"恒为",代码里改成别的值就是骗人
* ③ 8 类治疗项目的**项目名和稳定 code 都要在文档里列全** ——
* 改措辞(labels.ts)或加一类,交付文档必须同步,否则宿主的映射表会缺项。
* 注意查的是**项目名**(去「治疗」后缀)而不是卡片标签:发出去的是前者,
* 文档要跟载荷一致,不是跟界面一致
*/
const DOC = readFileSync(
join(__dirname, '../../pac-docs/content/docs/integration/postmessage-actions.mdx'),
'utf-8',
);
describe('postMessage 动作契约 ↔ 交付文档', () => {
test('⭐ 信封固定字段不许改字面(宿主按 source+type 过滤)', () => {
expect(HOST_ACTION_MESSAGE_SOURCE).toBe('pac');
expect(HOST_ACTION_MESSAGE_TYPE).toBe('action');
expect(DOC).toContain("source: 'pac'");
expect(DOC).toContain("type: 'action'");
});
test('⭐ 病例阶段恒「已咨询」—— 文档承诺了"恒为",代码不能偷偷换值', () => {
expect(HOST_CASE_STAGE_CONSULTED).toBe('已咨询');
expect(DOC).toContain('已咨询');
});
test('⭐ 8 类治疗项目的项目名 + 稳定 code 都要在交付文档里列全', () => {
const missing: string[] = [];
for (const code of Object.keys(POTENTIAL_TREATMENT_CARD_LABEL)) {
const item = potentialTreatmentItemName(code);
if (!DOC.includes(`| ${item} |`)) missing.push(`项目名「${item}」`);
if (!DOC.includes(`\`${code}\``)) missing.push(`code \`${code}\``);
}
expect(missing).toEqual([]);
});
test('⭐ 项目名不带「治疗」后缀、不出空串', () => {
for (const code of Object.keys(POTENTIAL_TREATMENT_CARD_LABEL)) {
const item = potentialTreatmentItemName(code);
expect(item.length).toBeGreaterThan(0);
expect(item.endsWith('治疗')).toBe(false);
}
expect(potentialTreatmentItemName('implant')).toBe('种植');
// 早矫是例外(卡片叫「早期矫治」,砍后缀砍不出来)—— 锁住,别哪天被"统一成推导"改回去
expect(potentialTreatmentItemName('early_ortho')).toBe('早矫');
});
test('⭐ 载荷字段名就是 desc / treatments / stage —— 改名等于毁约,宿主的解构会拿到 undefined', () => {
for (const f of ['`desc`', '`treatments`', '`stage`']) expect(DOC).toContain(f);
// 旧名字不许还留在交付文档里(留着对方会照旧名写)
for (const old of ['pendingTreatmentDesc', 'potentialTreatments', 'caseStage']) {
expect(DOC).not.toContain(old);
}
});
test('文档给出了 origin 校验示例 —— 少这一句宿主就等于开后门', () => {
expect(DOC).toContain('e.origin');
});
});
...@@ -46,6 +46,9 @@ import { ...@@ -46,6 +46,9 @@ import {
ABANDON_REASON_META, ABANDON_REASON_META,
personaFeatureSortKey, personaFeatureSortKey,
isKeyPersonaFeature, isKeyPersonaFeature,
potentialTreatmentItemName,
HOST_CASE_STAGE_CONSULTED,
type HostPotentialTreatmentPayload,
personaKeyFeatureOrder, personaKeyFeatureOrder,
type AbandonReason, type AbandonReason,
type ExecutionOutcome, type ExecutionOutcome,
...@@ -161,6 +164,9 @@ export function PlanDetailApp({ ...@@ -161,6 +164,9 @@ export function PlanDetailApp({
const recallHistory = data.recallHistory ?? []; const recallHistory = data.recallHistory ?? [];
const returnVisits = data.returnVisits ?? []; const returnVisits = data.returnVisits ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null); const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
// 顶部那句 AI 召回简报 —— 由 RecallBriefLine 拿到后回报(它负责 get-or-generate)。
// 「打开潜在治疗」的 postMessage 要发同一句话给宿主,所以提到这层存。
const [recallBrief, setRecallBrief] = useState<string | null>(null);
// 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null // 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null
const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null); const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
...@@ -370,10 +376,28 @@ export function PlanDetailApp({ ...@@ -370,10 +376,28 @@ export function PlanDetailApp({
// 顶栏跳宿主的三个动作(潜在 / 预约 / 回访)**全部过认领闸**: // 顶栏跳宿主的三个动作(潜在 / 预约 / 回访)**全部过认领闸**:
// 它们都会把人带进宿主系统对这个患者动手(哪怕「潜在」只是看,看完顺手就在宿主侧操作了), // 它们都会把人带进宿主系统对这个患者动手(哪怕「潜在」只是看,看完顺手就在宿主侧操作了),
// 而 PAC 这边没认领 = 没有归属人,回头对不上账。口径统一比"哪个算查看"的细分更好维护。 // 而 PAC 这边没认领 = 没有归属人,回头对不上账。口径统一比"哪个算查看"的细分更好维护。
// 「打开潜在治疗」的附加载荷(仅 postMessage 模式带上;契约见 @pac/types/host-action-message)。
// 三个字段一律取**客服此刻在页上看到的东西**,不另算一套 —— 宿主建出来的单要和客服
// 刚读的那句话对得上,否则对账时说不清是谁改的。
// · 描述:顶部那句 AI 召回简报;还没生成好 → 退回结构化召回原因文本(跟 UI 的回退口径一致)
// · 治疗项目:画像 potential_treatment 的 code → **项目名**(卡片措辞去掉「治疗」后缀,
// 宿主拿它当项目名落单;界面上客服看的仍是「种植治疗」。见 labels.ts)
// · 阶段:恒「已咨询」,PAC 不猜宿主流程内的状态
const potentialTreatmentPayload = (): HostPotentialTreatmentPayload => {
const codes = ((persona.features.find((f) => f.key === PersonaFeatureKey.POTENTIAL_TREATMENT)
?.data ?? null) as { types?: unknown } | null)?.types;
return {
desc: recallBrief ?? visibleReasons[0]?.reason ?? '',
treatments: Array.isArray(codes)
? codes.filter((c): c is string => typeof c === 'string').map(potentialTreatmentItemName)
: [],
stage: HOST_CASE_STAGE_CONSULTED,
};
};
const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT') const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
? () => { ? () => {
if (!gateCheck()) return; if (!gateCheck()) return;
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx); openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload());
} }
: undefined; : undefined;
const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT') const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
...@@ -437,8 +461,9 @@ export function PlanDetailApp({ ...@@ -437,8 +461,9 @@ export function PlanDetailApp({
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT'); showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return; return;
} }
// 不弹成功 toast —— 新标签页开出来用户自己看得见,再报一句是噪音(业务 2026-07-30)。
// 失败路径仍有提示:未配置 → 上面那条 amber toast;被 sandbox 拦 → openHostUrl 里降级跳转。
openHostUrl(url); openHostUrl(url);
showToast('emerald', '已在新标签页打开宿主预约页', '带上患者 id');
}; };
// ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ── // ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ──
...@@ -666,7 +691,11 @@ export function PlanDetailApp({ ...@@ -666,7 +691,11 @@ export function PlanDetailApp({
</div> </div>
{/* 第二行:本次召回一句话简报(LLM:谁/解决什么/到诊做什么;生成中 shimmer,失败回退结构化原因)*/} {/* 第二行:本次召回一句话简报(LLM:谁/解决什么/到诊做什么;生成中 shimmer,失败回退结构化原因)*/}
<div className="text-[11.5px] text-slate-600 leading-snug mt-1.5"> <div className="text-[11.5px] text-slate-600 leading-snug mt-1.5">
<RecallBriefLine planId={plan.id} visibleReasons={visibleReasons} /> <RecallBriefLine
planId={plan.id}
visibleReasons={visibleReasons}
onSummary={setRecallBrief}
/>
</div> </div>
</header> </header>
<div className="flex-1 min-h-0 overflow-y-auto p-4"> <div className="flex-1 min-h-0 overflow-y-auto p-4">
...@@ -1169,11 +1198,15 @@ function TopBar({ ...@@ -1169,11 +1198,15 @@ function TopBar({
type="button" type="button"
onClick={onOpenReturnVisit} onClick={onOpenReturnVisit}
onMouseEnter={onHoverReturnVisit} 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" 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" /> <CalendarClock className="h-3.5 w-3.5" />
<span className="hidden sm:inline">回访</span> <span className="hidden sm:inline">跟进</span>
</button> </button>
)} )}
{onCloseOpportunity && ( {onCloseOpportunity && (
...@@ -1373,6 +1406,12 @@ function IdentityCard({ ...@@ -1373,6 +1406,12 @@ function IdentityCard({
<a <a
href={originalArchiveUrl} href={originalArchiveUrl}
{...HOST_LINK_PROPS} {...HOST_LINK_PROPS}
// 走 JS 才有兜底:宿主 sandbox iframe 会把声明式 target="_blank" 直接拦掉,
// 而声明式那条路拦了我们既拦不到也补不了。href 留着让右键"复制链接"可用。
onClick={(e) => {
e.preventDefault();
openHostUrl(originalArchiveUrl);
}}
className="text-[10.5px] text-brand-700 hover:underline" className="text-[10.5px] text-brand-700 hover:underline"
> >
原始档案 → 原始档案 →
...@@ -1568,9 +1607,13 @@ function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] }) ...@@ -1568,9 +1607,13 @@ function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] })
function RecallBriefLine({ function RecallBriefLine({
planId, planId,
visibleReasons, visibleReasons,
onSummary,
}: { }: {
planId: string; planId: string;
visibleReasons: PlanReason[]; visibleReasons: PlanReason[];
/** 简报拿到后报给父层 —— 「打开潜在治疗」的 postMessage 要发同一句话给宿主,
* 不能各查一次(那会出现"页面显示 A、发过去 B")。 */
onSummary?: (summary: string | null) => void;
}) { }) {
const [summary, setSummary] = useState<string | null>(null); const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
...@@ -1582,7 +1625,10 @@ function RecallBriefLine({ ...@@ -1582,7 +1625,10 @@ function RecallBriefLine({
plansApi plansApi
.getRecallBrief(planId) .getRecallBrief(planId)
.then((r) => { .then((r) => {
if (alive) setSummary(r.status === 'ready' ? r.summary : null); if (!alive) return;
const s = r.status === 'ready' ? r.summary : null;
setSummary(s);
onSummary?.(s);
}) })
.catch(() => { .catch(() => {
/* 生成失败:静默,回退结构化原因 */ /* 生成失败:静默,回退结构化原因 */
...@@ -1955,6 +2001,11 @@ function TreatmentHistoryCard({ ...@@ -1955,6 +2001,11 @@ function TreatmentHistoryCard({
<a <a
href={emrUrl} href={emrUrl}
{...HOST_LINK_PROPS} {...HOST_LINK_PROPS}
// 同「原始档案」:点击走 openHostUrl 才有 sandbox 兜底(见 action-url.ts)
onClick={(e) => {
e.preventDefault();
openHostUrl(emrUrl);
}}
className="text-[10.5px] text-brand-700 hover:underline" className="text-[10.5px] text-brand-700 hover:underline"
> >
原始病历 → 原始病历 →
......
...@@ -47,9 +47,62 @@ export function resolveActionUrl( ...@@ -47,9 +47,62 @@ export function resolveActionUrl(
* *
* 用 a 标签的地方用 HOST_LINK_PROPS,用 JS 派发的地方用 openHostUrl(),两条路同一口径。 * 用 a 标签的地方用 HOST_LINK_PROPS,用 JS 派发的地方用 openHostUrl(),两条路同一口径。
* `noopener`:新页拿不到 window.opener,防宿主页被反向导航(tabnabbing)。 * `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; 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 { 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;
} }
...@@ -3,6 +3,12 @@ ...@@ -3,6 +3,12 @@
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { fillActionUrl, openHostUrl } from '@/lib/action-url'; import { fillActionUrl, openHostUrl } from '@/lib/action-url';
import {
HOST_ACTION_MESSAGE_SOURCE,
HOST_ACTION_MESSAGE_TYPE,
type HostActionMessage,
type HostPotentialTreatmentPayload,
} from '@pac/types';
/** /**
* 宿主动作派发(潜在治疗 / 回访)—— 模式由 actionUrls[key] 的值形态决定: * 宿主动作派发(潜在治疗 / 回访)—— 模式由 actionUrls[key] 的值形态决定:
...@@ -55,6 +61,13 @@ export function hostActionMode(key: HostAction): 'url' | 'postMessage' | undefin ...@@ -55,6 +61,13 @@ export function hostActionMode(key: HostAction): 'url' | 'postMessage' | undefin
export function openHostAction( export function openHostAction(
key: HostAction, key: HostAction,
ctx: Record<string, string | null | undefined>, ctx: Record<string, string | null | undefined>,
/**
* 附加载荷(仅 postMessage 模式带上)—— 目前只有「打开潜在治疗」用:
* 待治疗描述 / 关联治疗项目 / 病例阶段。契约见 @pac/types/host-action-message。
* ⚠️ 刻意**不进 URL 模式**:这三个字段是长文本 + 数组,塞 query string 会撞长度上限、
* 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。
*/
extraPayload?: HostPotentialTreatmentPayload,
): boolean { ): boolean {
const raw = rawValue(key); const raw = rawValue(key);
const mode = hostActionMode(key); const mode = hostActionMode(key);
...@@ -67,9 +80,12 @@ export function openHostAction( ...@@ -67,9 +80,12 @@ export function openHostAction(
return true; return true;
} }
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta) // 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
window.parent.postMessage( const message: HostActionMessage = {
{ source: 'pac', type: 'action', action: key, payload: { patientId: ctx.patientId ?? '' } }, source: HOST_ACTION_MESSAGE_SOURCE,
hostOrigin()!, type: HOST_ACTION_MESSAGE_TYPE,
); action: key,
payload: { patientId: ctx.patientId ?? '', ...(extraPayload ?? {}) },
};
window.parent.postMessage(message, hostOrigin()!);
return true; return true;
} }
/**
* 宿主动作 postMessage 契约 —— PAC(iframe 内)→ 宿主父页。
*
* 什么时候走 postMessage:宿主的动作**是弹窗/组件、没有独立 URL**。
* 有 URL 就配 URL 模板(`actionUrls[key]` 填链接),那是推荐姿势;
* 配成哨兵字符串 `postMessage` 才走本文件这条通道,并且必须同时配 `actionUrls.HOST_ORIGIN`
* 作 targetOrigin(不允许 `*` —— 患者信息不能广播给任意父页)。
*
* ⭐ 契约放 @pac/types 而不是前端:它是**对外接口**,宿主按它写监听。
* 放在这里意味着改字段会经过类型检查 + 文档(docs/integration/postmessage-actions.mdx 同源),
* 而不是某个组件里悄悄多塞一个 key。
*
* 兼容纪律:只增字段、不改已有字段语义、不删字段。宿主按 `type + source` 过滤,
* 多出来的字段它读不到也不会坏。
*/
/** 信封固定字段 —— 宿主用 source+type 过滤,别的 iframe 消息不会误入 */
export const HOST_ACTION_MESSAGE_SOURCE = 'pac' as const;
export const HOST_ACTION_MESSAGE_TYPE = 'action' as const;
/**
* 病例阶段 —— **PAC 固定发「已咨询」**。
*
* 为什么固定:PAC 侧的"潜在治疗"是从诊断/建议推出来的**客观缺口**,还没进宿主的病例流程;
* 客服点这个按钮的动作语义就是"我已经跟患者聊过、请在宿主侧建单",所以阶段恒为已咨询。
* PAC 不去推断"已确诊/已排期"这类宿主流程内的状态 —— 那是宿主的真理源,猜错比不给更糟。
*/
export const HOST_CASE_STAGE_CONSULTED = '已咨询' as const;
/**
* 「打开潜在治疗」的附加载荷(OPEN_POTENTIAL_TREATMENT 专有)。
*
* 三个字段都取**客服此刻在页面上看到的东西**,不另算一套 —— 宿主侧建出来的单
* 要和客服刚才读的那句话对得上,否则对账时说不清。
*/
export interface HostPotentialTreatmentPayload {
/**
* 待治疗描述 —— 取页面顶部那句 AI 召回简报(「这通电话的由头」),
* 简报还没生成好时退回结构化召回原因文本。两者都拿不到 → 空串(字段仍在,别让宿主判 undefined)。
*/
desc: string;
/**
* 关联治疗项目 —— **项目名**数组,共 8 类:
* 种植 / 正畸 / 早矫 / 根管 / 牙周 / 充填 / 修复 / 拔牙(见 potentialTreatmentItemName)。
* 无潜在治疗 → 空数组。
*
* ⚠️ 不带「治疗」后缀:宿主拿它当项目名落单,「种植治疗」在那边读起来像句子、不像项目。
* 界面上客服看到的仍是「种植治疗」(业务 2026-07-29 定的展示措辞)—— 两处措辞不同是有意的,
* 且由同一张表推导(砍后缀),不会各自漂。
*
* 为什么发中文不发 code:宿主拿去直接显示/落单最省事;要稳定键按文档里的对照表映一次。
*/
treatments: string[];
/** 病例阶段 —— 恒为 {@link HOST_CASE_STAGE_CONSULTED} */
stage: typeof HOST_CASE_STAGE_CONSULTED;
}
/** 所有动作共有的载荷字段 */
export interface HostActionBasePayload {
/** 宿主侧患者 id(PAC 的 patients.external_id,即摄入时宿主给的那个 id) */
patientId: string;
}
/** 完整消息体 —— 宿主 `window.addEventListener('message', ...)` 里 e.data 的形状 */
export interface HostActionMessage {
source: typeof HOST_ACTION_MESSAGE_SOURCE;
type: typeof HOST_ACTION_MESSAGE_TYPE;
/** 动作键,同 actionUrls 的 key(HostActionKey),如 OPEN_POTENTIAL_TREATMENT */
action: string;
payload: HostActionBasePayload & Partial<HostPotentialTreatmentPayload>;
}
...@@ -7,3 +7,4 @@ export * from './persona-feature-specs'; ...@@ -7,3 +7,4 @@ export * from './persona-feature-specs';
export * from './clinical-signals'; export * from './clinical-signals';
export * from './visit-recency'; export * from './visit-recency';
export * from './persona-tag-filters'; export * from './persona-tag-filters';
export * from './host-action-message';
...@@ -105,3 +105,27 @@ export const POTENTIAL_TREATMENT_CARD_LABEL: Record<string, string> = { ...@@ -105,3 +105,27 @@ export const POTENTIAL_TREATMENT_CARD_LABEL: Record<string, string> = {
export function potentialTreatmentCardLabel(code: string): string { export function potentialTreatmentCardLabel(code: string): string {
return POTENTIAL_TREATMENT_CARD_LABEL[code] ?? code; return POTENTIAL_TREATMENT_CARD_LABEL[code] ?? code;
} }
/**
* 项目名的例外 —— 砍后缀砍不出来的那几个,在这里显式给。
* early_ortho 的卡片措辞是「早期矫治」(没有「治疗」后缀可砍),业务要的项目名是「早矫」。
* ⚠️ 只放**真的推不出来**的;能靠砍后缀得到的别往这里堆,否则又变成两张手维护的表。
*/
const POTENTIAL_TREATMENT_ITEM_OVERRIDE: Record<string, string> = {
early_ortho: '早矫',
};
/**
* code → **项目名**:种植 / 正畸 / 早矫 / 根管 / 牙周 / 充填 / 修复 / 拔牙。
*
* 用途只有一个:发给宿主的 postMessage 载荷(`treatments`)。宿主拿它当**项目名**落单,
* 「种植治疗」在那边读起来像句子、不像项目;界面上客服看的仍是「种植治疗」(业务 2026-07-29 定的措辞)。
*
* ⭐ 默认从卡片措辞**推导**(砍掉结尾的「治疗」)而非另立一张全表 —— 两张手维护的表必然漂;
* 推不出来的走上面那张例外表(目前只有早矫一个)。
*/
export function potentialTreatmentItemName(code: string): string {
return (
POTENTIAL_TREATMENT_ITEM_OVERRIDE[code] ?? potentialTreatmentCardLabel(code).replace(/治疗$/, '')
);
}
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