Commit d98dcbd4 by luoqi

feat(embed): 患者卡片「潜在治疗」/ 通话结果「回访」走 postMessage 通知宿主

- 新增两个 postMessage 动作 OPEN_POTENTIAL_TREATMENT / OPEN_RETURN_VISIT(入 HostActionKey
  + HOST_ACTION_META,admin 下拉可选);HOST_ORIGIN 作 postMessage targetOrigin,走 host 配置
  (会话下发的 actionUrls.HOST_ORIGIN),禁用 '*'
- 患者卡片右上角「影像/档案」→「潜在治疗」;通话结果「拨打」→「回访」,固定就是这套(不判断 iframe)
- 信封只含 { source:'pac', action, payload:{patientId} };patientId=external_id
- postToHost 只在未配 HOST_ORIGIN 时提示;宿主侧监听校验 origin + source 后弹自己的组件

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 1506e2fa
Pipeline #3343 failed in 0 seconds
'use client'; 'use client';
import { Phone } from 'lucide-react'; import { CalendarClock } from 'lucide-react';
import { toast } from 'sonner'; import { postToHost } from '@/lib/host-message';
/** /**
* CallWidget — 网页拨号占位按钮。 * 通话结果区右上角「回访」按钮 —— postMessage 通知宿主打开它自己的回访组件(带 patientId)。
* * 固定就是这套(不分嵌入与否);未配置 HOST_ORIGIN 时点击会提示去配。
* 外呼真正落地需国内合规外呼线路(企业资质认证后开通);
* Twilio 等海外线路无法拨打中国大陆。现阶段点击仅提示,链路待接国内 provider。
*/ */
export function CallWidget() { export function CallWidget({ patientId }: { patientId?: string }) {
return ( return (
<button <button
onClick={() => type="button"
toast('外呼功能待开通', { onClick={() => postToHost('OPEN_RETURN_VISIT', patientId ?? '')}
description: '需完成企业资质认证 + 接入国内合规外呼线路后开放',
})
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-teal-600 text-white text-[12px] font-medium hover:bg-teal-700 transition-colors" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-teal-600 text-white text-[12px] font-medium hover:bg-teal-700 transition-colors"
> >
<Phone className="w-3.5 h-3.5" /> <CalendarClock className="w-3.5 h-3.5" />
拨打 回访
</button> </button>
); );
} }
...@@ -24,6 +24,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store'; ...@@ -24,6 +24,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { IdentityCluster } from '@/components/identity-cluster'; import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed'; import { isEmbedded } from '@/lib/embed';
import { postToHost } from '@/lib/host-message';
import { import {
cn, cn,
formatGender, formatGender,
...@@ -478,7 +479,7 @@ export function PlanDetailApp({ ...@@ -478,7 +479,7 @@ export function PlanDetailApp({
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">通话结果</h2> <h2 className="text-[14px] font-semibold text-slate-900 leading-tight">通话结果</h2>
<p className="text-[10.5px] text-slate-500 mt-0.5">提交后本次任务自动归档</p> <p className="text-[10.5px] text-slate-500 mt-0.5">提交后本次任务自动归档</p>
</div> </div>
<CallWidget /> <CallWidget patientId={patient.externalId} />
</header> </header>
<div className="flex-1 min-h-0 overflow-y-auto p-3"> <div className="flex-1 min-h-0 overflow-y-auto p-3">
<OutcomeForm <OutcomeForm
...@@ -1032,11 +1033,13 @@ function IdentityCard({ ...@@ -1032,11 +1033,13 @@ function IdentityCard({
</span> </span>
</div> </div>
<div className="flex items-center gap-2 flex-none"> <div className="flex items-center gap-2 flex-none">
<button onClick={onOpenImage} className="text-[10.5px] text-teal-700 hover:underline"> {/* 潜在治疗 —— postMessage 通知宿主打开其组件(固定就是这套,不分嵌入) */}
影像 → <button
</button> type="button"
<button onClick={onOpenProfile} className="text-[10.5px] text-teal-700 hover:underline"> onClick={() => postToHost('OPEN_POTENTIAL_TREATMENT', patient.externalId ?? '')}
档案 → className="text-[10.5px] text-teal-700 hover:underline"
>
潜在治疗 →
</button> </button>
</div> </div>
</div> </div>
......
'use client';
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/auth-store';
/**
* PAC → 宿主 的动作事件(postMessage)。单向,信封只含 source / action / payload。
*
* 用于"宿主动作是弹窗/组件、没有独立 URL"的场景:PAC 只喊一声 + 带 patientId,
* 宿主父页监听后弹自己的组件。targetOrigin 走 host 配置 actionUrls.HOST_ORIGIN(不允许 '*')。
* 不做 iframe 判断:按钮固定就是这套;非嵌入时 targetOrigin 与当前窗口不符,浏览器自动丢弃(无副作用)。
*/
export type HostAction = 'OPEN_POTENTIAL_TREATMENT' | 'OPEN_RETURN_VISIT';
/** 宿主 origin(postMessage targetOrigin)—— 来自会话下发的 actionUrls.HOST_ORIGIN。 */
export function hostOrigin(): string | undefined {
return useAuthStore.getState().user?.actionUrls?.HOST_ORIGIN || undefined;
}
/** 向宿主父窗发一条动作事件。未配置 origin 时提示并返 false。 */
export function postToHost(action: HostAction, patientId: string): boolean {
const origin = hostOrigin();
if (!origin) {
toast('未配置宿主 Origin', { description: '请在宿主管理页配置 actionUrls.HOST_ORIGIN' });
return false;
}
// 信封:source + action + payload(无 version / doctorId / meta)
window.parent.postMessage({ source: 'pac', action, payload: { patientId } }, origin);
return true;
}
...@@ -81,6 +81,12 @@ export const HostActionKey = { ...@@ -81,6 +81,12 @@ export const HostActionKey = {
ADD_NOTE: 'ADD_NOTE', // 加备注 ADD_NOTE: 'ADD_NOTE', // 加备注
OPEN_WECOM_CHAT: 'OPEN_WECOM_CHAT', // 打开企微会话 OPEN_WECOM_CHAT: 'OPEN_WECOM_CHAT', // 打开企微会话
CALL_PATIENT: 'CALL_PATIENT', // 拨号 CALL_PATIENT: 'CALL_PATIENT', // 拨号
// postMessage 动作(无 URL,PAC 通过 postMessage 通知宿主打开其组件,payload 带 patientId)
OPEN_POTENTIAL_TREATMENT: 'OPEN_POTENTIAL_TREATMENT', // 打开潜在治疗
OPEN_RETURN_VISIT: 'OPEN_RETURN_VISIT', // 打开回访
// 非跳转 URL,而是 postMessage 目标源(embed 配置)—— 上面两个 postMessage 动作的 targetOrigin;
// 值填宿主承载 iframe 的页面 origin(如 https://host.example.com)。
HOST_ORIGIN: 'HOST_ORIGIN',
} as const; } as const;
export type HostActionKey = (typeof HostActionKey)[keyof typeof HostActionKey]; export type HostActionKey = (typeof HostActionKey)[keyof typeof HostActionKey];
...@@ -97,6 +103,9 @@ export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholde ...@@ -97,6 +103,9 @@ export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholde
ADD_NOTE: { label: '添加备注', placeholders: ['{patientId}'] }, ADD_NOTE: { label: '添加备注', placeholders: ['{patientId}'] },
OPEN_WECOM_CHAT: { label: '打开企微会话', placeholders: ['{patientId}', '{wecomExternalUserId}'] }, OPEN_WECOM_CHAT: { label: '打开企微会话', placeholders: ['{patientId}', '{wecomExternalUserId}'] },
CALL_PATIENT: { label: '拨号', placeholders: ['{phone}'] }, CALL_PATIENT: { label: '拨号', placeholders: ['{phone}'] },
OPEN_POTENTIAL_TREATMENT: { label: '打开潜在治疗(postMessage)', placeholders: ['{patientId}'] },
OPEN_RETURN_VISIT: { label: '打开回访(postMessage)', placeholders: ['{patientId}'] },
HOST_ORIGIN: { label: '宿主 Origin(postMessage 目标源)', placeholders: [] },
}; };
export const PatientStatus = { export const PatientStatus = {
......
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