Commit 9ba57bc9 by luoqi

feat(embed): 打开潜在治疗的 postMessage 补三个字段 + 出一份宿主侧交付文档

载荷从 `{patientId}` 扩到:
  pendingTreatmentDesc  待治疗描述 —— 取页面顶部那句 AI 召回简报;还没生成好则退回
                        结构化召回原因文本;都没有 → 空串(字段一定在,别让宿主判 undefined)
  potentialTreatments   关联治疗项目 —— 中文标签数组,与召回池卡片同一套措辞(8 类)
  caseStage             病例阶段 —— 恒「已咨询」

 三个字段一律取**客服此刻在页上看到的东西**,不另算一套:宿主建出来的单要和客服刚读的
那句话对得上,否则对账时说不清是谁改的。所以简报由 RecallBriefLine 拿到后回报给父层
(它本来就负责 get-or-generate),不再单独查一次 —— 否则会出现"页面显示 A、发过去 B"。

 契约收在 @pac/types/host-action-message.ts 而不是组件里:它是**对外接口**,宿主照它写监听。
放在 types 意味着改字段过类型检查 + 有文档同源,而不是某个组件里悄悄多塞一个 key。
兼容纪律写进注释:只增字段、不改已有语义、不删字段。

️ 这三个字段**刻意不进 URL 模式**:长文本 + 数组塞 query string 会撞长度上限,
还会把病情描述写进浏览器历史和宿主 access log。要 URL 模式也带得宿主改成 POST。

交付文档 docs/integration/postmessage-actions.mdx(可直接给对接方):
信封 / 字段表 / 完整示例 JSON / 8 类治疗项目的中文code 对照 / 宿主监听示例
(含必须校验 e.origin)/ 注意事项(URL 模式不带、单向无回调、sandbox 别漏 allow-popups)。

加防漂移测试:信封字面、caseStage 恒值、8 类标签与 code 必须在交付文档里列全 ——
这份文档是发给外部照着写的,漂了要等联调才暴露。

701 tests / 46 suites + web typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 1402bb9f
......@@ -10,7 +10,8 @@
"channel-push",
"friday-push-payload",
"auth-login",
"postmessage-actions",
"execution-callback",
"runbook"
]
}
\ No newline at end of file
}
---
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,可直接用来定位患者 |
| `pendingTreatmentDesc` | `string` | **待治疗描述**。取客服此刻在页面顶部读到的那句 AI 召回简报(「这通电话的由头」);简报还没生成好时退回结构化召回原因文本。两者都没有 → 空串(**字段一定在,不会是 undefined**) |
| `potentialTreatments` | `string[]` | **关联治疗项目**,中文标签数组,与召回池卡片上那排标签同一套措辞。无潜在治疗 → 空数组 |
| `caseStage` | `string` | **病例阶段**,PAC 恒发 `"已咨询"` |
### 完整示例
```json
{
"source": "pac",
"type": "action",
"action": "OPEN_POTENTIAL_TREATMENT",
"payload": {
"patientId": "1499498",
"pendingTreatmentDesc": "多颗缺牙拖 3 个月易致邻牙移位;高价值老客,医生医嘱交代过「半年定期检查」,可约复查顺带评估种植修复。",
"potentialTreatments": ["种植治疗", "修复治疗"],
"caseStage": "已咨询"
}
}
```
### `potentialTreatments` 取值范围(共 8 类)
中文标签是 PAC 的展示口径,可能随业务措辞调整;要**稳定键**请按下表自己映射一次。
| 中文标签 | 稳定 code | 含义 |
|---|---|---|
| 种植治疗 | `implant` | 缺牙待种 |
| 正畸治疗 | `ortho` | 成人正畸 |
| 早期矫治 | `early_ortho` | 儿童早矫(替牙期) |
| 根管治疗 | `endo` | 牙髓 |
| 牙周治疗 | `perio` | 牙周 |
| 充填治疗 | `filling` | 龋齿 |
| 修复治疗 | `restoration` | 冠桥 / 贴面 / 嵌体 |
| 拔牙治疗 | `extraction` | 残根残冠等需拔 |
### `caseStage` 为什么恒为「已咨询」
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, pendingTreatmentDesc, potentialTreatments, caseStage } = msg.payload;
openPotentialTreatmentDialog({
patientId,
desc: pendingTreatmentDesc,
items: potentialTreatments, // ["种植治疗", ...]
stage: caseStage, // "已咨询"
});
}
});
```
<Callout type="warn">
**必须校验 `e.origin`**。`message` 事件任何页面都能发,不校验 origin 等于给自己开了个后门。
PAC 侧已经用 `targetOrigin` 定向发送(不广播),但那只防"发错人",防不了"别人冒充 PAC 发给你"。
</Callout>
---
## 5. 注意事项
- **URL 模式不带这三个字段**。`pendingTreatmentDesc` 是长文本、`potentialTreatments` 是数组,
塞 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,
} 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, label] of Object.entries(POTENTIAL_TREATMENT_CARD_LABEL)) {
if (!DOC.includes(label)) missing.push(`标签「${label}」`);
if (!DOC.includes(`\`${code}\``)) missing.push(`code \`${code}\``);
}
expect(missing).toEqual([]);
});
test('文档给出了 origin 校验示例 —— 少这一句宿主就等于开后门', () => {
expect(DOC).toContain('e.origin');
});
});
......@@ -46,6 +46,9 @@ import {
ABANDON_REASON_META,
personaFeatureSortKey,
isKeyPersonaFeature,
potentialTreatmentCardLabel,
HOST_CASE_STAGE_CONSULTED,
type HostPotentialTreatmentPayload,
personaKeyFeatureOrder,
type AbandonReason,
type ExecutionOutcome,
......@@ -161,6 +164,9 @@ export function PlanDetailApp({
const recallHistory = data.recallHistory ?? [];
const returnVisits = data.returnVisits ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
// 顶部那句 AI 召回简报 —— 由 RecallBriefLine 拿到后回报(它负责 get-or-generate)。
// 「打开潜在治疗」的 postMessage 要发同一句话给宿主,所以提到这层存。
const [recallBrief, setRecallBrief] = useState<string | null>(null);
// 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null
const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
......@@ -370,10 +376,27 @@ export function PlanDetailApp({
// 顶栏跳宿主的三个动作(潜在 / 预约 / 回访)**全部过认领闸**:
// 它们都会把人带进宿主系统对这个患者动手(哪怕「潜在」只是看,看完顺手就在宿主侧操作了),
// 而 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 {
pendingTreatmentDesc: recallBrief ?? visibleReasons[0]?.reason ?? '',
potentialTreatments: Array.isArray(codes)
? codes.filter((c): c is string => typeof c === 'string').map(potentialTreatmentCardLabel)
: [],
caseStage: HOST_CASE_STAGE_CONSULTED,
};
};
const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
? () => {
if (!gateCheck()) return;
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx);
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload());
}
: undefined;
const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
......@@ -667,7 +690,11 @@ export function PlanDetailApp({
</div>
{/* 第二行:本次召回一句话简报(LLM:谁/解决什么/到诊做什么;生成中 shimmer,失败回退结构化原因)*/}
<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>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-4">
......@@ -1579,9 +1606,13 @@ function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] })
function RecallBriefLine({
planId,
visibleReasons,
onSummary,
}: {
planId: string;
visibleReasons: PlanReason[];
/** 简报拿到后报给父层 —— 「打开潜在治疗」的 postMessage 要发同一句话给宿主,
* 不能各查一次(那会出现"页面显示 A、发过去 B")。 */
onSummary?: (summary: string | null) => void;
}) {
const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
......@@ -1593,7 +1624,10 @@ function RecallBriefLine({
plansApi
.getRecallBrief(planId)
.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(() => {
/* 生成失败:静默,回退结构化原因 */
......
......@@ -3,6 +3,12 @@
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/auth-store';
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] 的值形态决定:
......@@ -55,6 +61,13 @@ export function hostActionMode(key: HostAction): 'url' | 'postMessage' | undefin
export function openHostAction(
key: HostAction,
ctx: Record<string, string | null | undefined>,
/**
* 附加载荷(仅 postMessage 模式带上)—— 目前只有「打开潜在治疗」用:
* 待治疗描述 / 关联治疗项目 / 病例阶段。契约见 @pac/types/host-action-message。
* ⚠️ 刻意**不进 URL 模式**:这三个字段是长文本 + 数组,塞 query string 会撞长度上限、
* 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。
*/
extraPayload?: HostPotentialTreatmentPayload,
): boolean {
const raw = rawValue(key);
const mode = hostActionMode(key);
......@@ -67,9 +80,12 @@ export function openHostAction(
return true;
}
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
window.parent.postMessage(
{ source: 'pac', type: 'action', action: key, payload: { patientId: ctx.patientId ?? '' } },
hostOrigin()!,
);
const message: HostActionMessage = {
source: HOST_ACTION_MESSAGE_SOURCE,
type: HOST_ACTION_MESSAGE_TYPE,
action: key,
payload: { patientId: ctx.patientId ?? '', ...(extraPayload ?? {}) },
};
window.parent.postMessage(message, hostOrigin()!);
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)。
*/
pendingTreatmentDesc: string;
/**
* 关联治疗项目 —— 中文标签数组,与召回池卡片上那排标签**同一套措辞**
* (查 POTENTIAL_TREATMENT_CARD_LABEL,共 8 类:种植治疗 / 正畸治疗 / 早期矫治 /
* 根管治疗 / 牙周治疗 / 充填治疗 / 修复治疗 / 拔牙治疗)。
* 无潜在治疗 → 空数组。
*
* 为什么发中文不发 code:宿主拿去直接显示/落单最省事;code 若要也可以加(见文档),
* 但目前对接方要的是"跟客服看到的一致"。
*/
potentialTreatments: string[];
/** 病例阶段 —— 恒为 {@link HOST_CASE_STAGE_CONSULTED} */
caseStage: 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';
export * from './clinical-signals';
export * from './visit-recency';
export * from './persona-tag-filters';
export * from './host-action-message';
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