Commit 1cf137ac by luoqi

feat(embed): 宿主嵌入体验 —— 隐藏 PAC 品牌/用户、会话失效兜底、动作走链接;org-tree 冷派生优化

- 嵌入检测(window.top):隐藏 PAC 品牌/用户/登出(IdentityCluster),会话失效显示
  「登录已过期+重试」而非 dev 快速登录(SessionExpired);模拟登录嵌入态不出现
- 动作插槽(新建预约等):PAC 直接 window.open 宿主链接,不 iframe 嵌、不 postMessage、
  无 returnUrl;患者上下文经 query 传,回跳靠 history.back()/宿主自己的召回页
- org-tree 派生:启动预热(OnApplicationBootstrap)+ loose index-scan 改写,
  冷派生 ~3.8s→~1ms,消除首个 scoped 用户登录尾延迟
- docs: overview §4/§5 补 actionUrls 打开方式/回跳、sandbox 权限、嵌入隐藏、会话失效

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 33a826e7
...@@ -102,6 +102,20 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。 ...@@ -102,6 +102,20 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。
**只提供宿主方能支持的**,不写或填 `null` 都行。占位符 `{patientId}` / `{appointmentId}` / `{emrId}` / `{clinicId}` / `{userId}` 按字面替换。 **只提供宿主方能支持的**,不写或填 `null` 都行。占位符 `{patientId}` / `{appointmentId}` / `{emrId}` / `{clinicId}` / `{userId}` 按字面替换。
**打开方式**:PAC 前端 `window.open(url)`(新标签)或 `_top`(整页跳宿主页)。患者上下文经 URL 参数带过去,宿主页预填。
**动作没有独立页(是弹窗 / 组件)**:宿主给一个能触发弹窗的**深链参数**即可,仍是链接 —— 无需 postMessage:
```
https://host.example.com/console?action=new-appointment&patientId={patientId}
```
宿主页加载识别 `action` 就弹自己的组件。
**回跳召回**:动作页「返回召回」跳**宿主自己承载召回 iframe 的那个页**(或 `history.back()`);**PAC 不提供 returnUrl**,宿主链自己的页面即可。
**单向、无回调**:动作结果由数据摄入(如新预约 → 预约事实回流)+ 客服在 PAC 记 outcome 反映,**不做 UI 回调 / postMessage**。
**跨域登录态**:推荐宿主方用 cookie 跨域共享(跳过去自动登录),或在跳转 URL 加短时 token。 **跨域登录态**:推荐宿主方用 cookie 跨域共享(跳过去自动登录),或在跳转 URL 加短时 token。
--- ---
...@@ -119,7 +133,7 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。 ...@@ -119,7 +133,7 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。
```html ```html
<iframe <iframe
src="https://pac.example.com/plans?code=XXX" src="https://pac.example.com/plans?code=XXX"
sandbox="allow-scripts allow-same-origin allow-top-navigation" sandbox="allow-scripts allow-same-origin allow-popups allow-top-navigation"
style="width:100%; height:100%; border:0;"> style="width:100%; height:100%; border:0;">
</iframe> </iframe>
``` ```
...@@ -139,10 +153,13 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。 ...@@ -139,10 +153,13 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。
### 5.3 嵌入约束 ### 5.3 嵌入约束
- 浏览器 CSP:宿主方配置 `frame-ancestors <宿主方 UI 域名>`(PAC 接入时约定) - 浏览器 CSP:宿主方配置 `frame-ancestors <宿主方 UI 域名>`(PAC 接入时约定)
- iframe `sandbox` 最小权限(上面示例已含) - iframe `sandbox` 最小权限(上面示例已含):`allow-popups` 供动作新标签打开、`allow-top-navigation` 供动作 `_top` 跳转,按 §4 用到再加
- **不依赖 postMessage** 跟 PAC 通信 — 状态全走 PAC 后端,iframe 是纯渲染容器 - **不依赖 postMessage** 跟 PAC 通信 — 状态全走 PAC 后端,iframe 是纯渲染容器
- **嵌入态自动隐藏** PAC 品牌 / 用户信息 / 登出(宿主已提供),只留召回功能区
- 客服界面响应式适配,移动端 / 桌面端均可 - 客服界面响应式适配,移动端 / 桌面端均可
**会话失效**:嵌入态 `code` / token 失效后,PAC 显示「登录已过期 + 重试」引导回宿主重新进入(§3.1 重新换票即恢复),**不弹内置登录**。
### 5.4 (可选)PAC 开放 REST API ### 5.4 (可选)PAC 开放 REST API
完整 API 仅作 **未来扩展 / 运维 / 第三方对接** 预留,现阶段宿主方不需要调。各 endpoint 见侧栏 **API 参考**(OpenAPI 自动生成、按模块分组:auth / plan / push / sync …),或线上 Swagger 同源:`https://<pac-host>/api/docs`。 完整 API 仅作 **未来扩展 / 运维 / 第三方对接** 预留,现阶段宿主方不需要调。各 endpoint 见侧栏 **API 参考**(OpenAPI 自动生成、按模块分组:auth / plan / push / sync …),或线上 Swagger 同源:`https://<pac-host>/api/docs`。
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { import {
buildOrgTree, buildOrgTree,
...@@ -19,11 +19,60 @@ import { ...@@ -19,11 +19,60 @@ import {
* 按 (hostId, tenantId) 缓存(TTL);数据日更,够鲜。无数据 → 退化树(根=tenantId,无子)。 * 按 (hostId, tenantId) 缓存(TTL);数据日更,够鲜。无数据 → 退化树(根=tenantId,无子)。
*/ */
@Injectable() @Injectable()
export class OrgTreeService { export class OrgTreeService implements OnApplicationBootstrap {
private readonly logger = new Logger(OrgTreeService.name); private readonly logger = new Logger(OrgTreeService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/**
* 启动预热 —— 补上"每日 sync 预热"覆盖不到的空窗。
*
* 冷派生要全扫 patient_transactions(生产 379 万行,实测约 3-5s)。每日 sync 完
* 会 forceRefresh 预热,但内存缓存**随进程重启/部署清空**:重启后到下次 sync 之间,
* 第一个非集团级(orgScope 非空)用户登录就撞冷派生 —— 正是"首次无缓存"卡顿的来源。
* 这里在应用启动就绪后(DB 已连)后台把各 (host,tenant) 树填进缓存,真实用户不再承担。
*
* 后台跑,不阻塞启动;单个 tenant 失败不影响其余。env 关:PAC_ORGTREE_WARM_ON_BOOT=false。
*/
onApplicationBootstrap(): void {
if (process.env.PAC_ORGTREE_WARM_ON_BOOT === 'false') return;
void this.warmAll();
}
private async warmAll(): Promise<void> {
const startedAt = Date.now();
try {
const hosts = await this.prisma.host.findMany({
where: { active: true },
select: { id: true },
});
let warmed = 0;
for (const h of hosts) {
const tenants = await this.prisma.patient.findMany({
where: { hostId: h.id },
distinct: ['tenantId'],
select: { tenantId: true },
});
for (const t of tenants) {
try {
await this.getTree(h.id, t.tenantId); // 非 force:填冷缓存即可
warmed++;
} catch (err) {
this.logger.warn(
`org-tree 启动预热失败 host=${h.id} tenant=${t.tenantId}: ${(err as Error).message}`,
);
}
}
}
this.logger.log(
`org-tree 启动预热完成:${warmed} 个 (host,tenant) elapsed=${Date.now() - startedAt}ms`,
);
} catch (err) {
// 预热是尽力而为的优化,查 host/tenant 失败不该拖垮启动(真实请求仍会按需派生)。
this.logger.warn(`org-tree 启动预热跳过: ${(err as Error).message}`);
}
}
private readonly cache = new Map<string, { tree: OrgTree; at: number }>(); private readonly cache = new Map<string, { tree: OrgTree; at: number }>();
/** 单飞:并发 miss 共享同一次派生,防惊群 —— 冷启动 bootstrap(loadSession + plans 列表) /** 单飞:并发 miss 共享同一次派生,防惊群 —— 冷启动 bootstrap(loadSession + plans 列表)
* 多个请求同时 miss 时,原本各自全扫一遍 patient_transactions(数十万行)导致登录长时间卡。 */ * 多个请求同时 miss 时,原本各自全扫一遍 patient_transactions(数十万行)导致登录长时间卡。 */
...@@ -63,23 +112,51 @@ export class OrgTreeService { ...@@ -63,23 +112,51 @@ export class OrgTreeService {
// clinic_id 立柱在 patient_transactions,source_unit 在 patients → join patient_id。 // clinic_id 立柱在 patient_transactions,source_unit 在 patients → join patient_id。
// 显式 host+tenant 过滤(此处在 ALS 之前跑,纵深防御扩展不兜,自带条件)。 // 显式 host+tenant 过滤(此处在 ALS 之前跑,纵深防御扩展不兜,自带条件)。
// //
// 结果恒个位数行,但 planner 按行数估算走 JIT——生产实测(379万行)占了近 20% 执行时间, // 朴素 `SELECT DISTINCT source_unit, clinic_id` 会全扫 patient_transactions(生产 379 万行、
// 纯浪费。SET LOCAL 只在本事务生效,COMMIT 后自动复原,不污染连接池里其它请求。 // ~470MB)就为拿个位数行的「品牌→诊所」映射 —— 冷缓存下这是登录尾延迟的根。改成两步、
const [, rows] = await this.prisma.$transaction([ // 全走索引(实测 470MB→~60 页,3.8s→~1ms):
// ① loose index-scan:靠 (host_id,tenant_id,clinic_id) 索引"跳"着取 distinct clinic_id
// (递归 CTE 每次 seek clinic_id > 上一个,N 家诊所只 N 次索引下探,不读全表);
// ② 每诊所 LIMIT 1 采样一条患者的 source_unit 定其品牌。
//
// **前提(诊所×品牌 1:1)**:一家诊所只属一个品牌(source_unit = 拥有该诊所的品牌)——
// 领域不变量,当前数据零反例。故 ① distinct 诊所 + ② 单条采样即可定归属;若脏数据让同一
// clinic_id 跨了品牌,② 只取其一(另一归属丢失),这类问题应在摄入侧治理,不在此兜。
// enable_seqscan=off:LATERAL 里 `clinic_id = c.clinic_id`(值来自 CTE)行数估不准,planner
// 会误选 seq scan;所需索引((host,tenant,clinic) + patients_pkey)都在,强制走索引安全。
// SET LOCAL 只在本事务生效,COMMIT 自动复原,不污染连接池其它请求。
const [, , rows] = await this.prisma.$transaction([
this.prisma.$executeRaw`SET LOCAL jit = off`, this.prisma.$executeRaw`SET LOCAL jit = off`,
this.prisma.$executeRaw`SET LOCAL enable_seqscan = off`,
this.prisma.$queryRaw<{ source_unit: string; clinic_id: string }[]>` this.prisma.$queryRaw<{ source_unit: string; clinic_id: string }[]>`
SELECT DISTINCT p.source_unit, pt.clinic_id WITH RECURSIVE clinics AS (
(SELECT clinic_id FROM patient_transactions
WHERE host_id = ${hostId}::uuid AND tenant_id = ${tenantId} AND clinic_id <> ''
ORDER BY clinic_id LIMIT 1)
UNION ALL
SELECT (SELECT pt.clinic_id FROM patient_transactions pt
WHERE pt.host_id = ${hostId}::uuid AND pt.tenant_id = ${tenantId}
AND pt.clinic_id <> '' AND pt.clinic_id > c.clinic_id
ORDER BY pt.clinic_id LIMIT 1)
FROM clinics c WHERE c.clinic_id IS NOT NULL
)
SELECT c.clinic_id AS clinic_id, su.source_unit AS source_unit
FROM clinics c
CROSS JOIN LATERAL (
SELECT p.source_unit
FROM patient_transactions pt FROM patient_transactions pt
JOIN patients p ON p.id = pt.patient_id JOIN patients p ON p.id = pt.patient_id
WHERE pt.host_id = ${hostId}::uuid AND pt.tenant_id = ${tenantId} WHERE pt.host_id = ${hostId}::uuid AND pt.tenant_id = ${tenantId}
AND p.source_unit <> '' AND pt.clinic_id <> '' AND pt.clinic_id = c.clinic_id AND p.source_unit <> ''
LIMIT 1
) su
WHERE c.clinic_id IS NOT NULL
`, `,
]); ]);
const elapsed = Date.now() - startedAt; const elapsed = Date.now() - startedAt;
// >1s 才 warn —— 冷派生本身预期慢(全表扫),日常预热命中不会看到这条; // 走索引后派生应恒 <100ms;>1s 说明索引没生效 / 数据异常,warn 留证据便于对号。
// 留证据是为了"用户说卡"时能在日志里对上号,而不是靠猜。
if (elapsed > 1000) { if (elapsed > 1000) {
this.logger.warn(`org-tree 派生慢:host=${hostId} tenant=${tenantId} rows=${rows.length} elapsed=${elapsed}ms`); this.logger.warn(`org-tree 派生慢:host=${hostId} tenant=${tenantId} rows=${rows.length} elapsed=${elapsed}ms`);
} else { } else {
this.logger.log(`org-tree 派生:host=${hostId} tenant=${tenantId} rows=${rows.length} elapsed=${elapsed}ms`); this.logger.log(`org-tree 派生:host=${hostId} tenant=${tenantId} rows=${rows.length} elapsed=${elapsed}ms`);
} }
......
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Inbox, LogOut, RefreshCw } from 'lucide-react'; import { Inbox, RefreshCw } from 'lucide-react';
import { Permission } from '@pac/types'; import { Permission } from '@pac/types';
import { Can } from '@/components/can'; import { Can } from '@/components/can';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail'; import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
import { useAuthStore, visibleClinics } from '@/stores/auth-store'; import { IdentityCluster } from '@/components/identity-cluster';
import { userDisplayName, roleNameZh } from '@/lib/utils'; import { isEmbedded } from '@/lib/embed';
/** /**
* /plans — 工作台入口解析器(原列表页已弃用,组件保留在 plans-list-app.tsx 未挂载)。 * /plans — 工作台入口解析器(原列表页已弃用,组件保留在 plans-list-app.tsx 未挂载)。
...@@ -30,7 +30,6 @@ export default function PlansEntryPage() { ...@@ -30,7 +30,6 @@ export default function PlansEntryPage() {
function EntryResolver() { function EntryResolver() {
const router = useRouter(); const router = useRouter();
const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving'); const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving');
const user = useAuthStore((s) => s.user);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -72,33 +71,18 @@ function EntryResolver() { ...@@ -72,33 +71,18 @@ function EntryResolver() {
// ③ 空工作台:左栏可用(调筛选自己找人),右侧整体空态 // ③ 空工作台:左栏可用(调筛选自己找人),右侧整体空态
return ( return (
<div className="flex h-screen flex-col overflow-hidden bg-slate-50"> <div className="flex h-screen flex-col overflow-hidden bg-slate-50">
{/* 空态 header —— 内容只有 PAC 品牌 + 用户三件套,无功能项;嵌入宿主时整体隐藏 */}
{!isEmbedded() && (
<header className="flex h-12 flex-none items-center gap-2 border-b border-slate-100 bg-white px-4"> <header className="flex h-12 flex-none items-center gap-2 border-b border-slate-100 bg-white px-4">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white"> <span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
PAC PAC
</span> </span>
<h1 className="text-[14px] font-semibold text-slate-900">召回工作台</h1> <h1 className="text-[14px] font-semibold text-slate-900">召回工作台</h1>
{/* 用户信息 + 退出 —— 空态也必须有(否则落到这里的用户无法登出)。与详情 TopBar 一致。 */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<div className="hidden text-right text-[11.5px] leading-tight text-slate-500 md:block"> <IdentityCluster />
<div className="font-medium text-slate-700">{userDisplayName(user)}</div>
<div className="nums">
{visibleClinics(user).length} 个诊所 · {roleNameZh(user?.role)}
</div>
</div>
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
{userDisplayName(user).charAt(0).toUpperCase()}
</span>
<button
type="button"
title="退出登录"
aria-label="退出登录"
onClick={() => useAuthStore.getState().clear()}
className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-4 w-4" />
</button>
</div> </div>
</header> </header>
)}
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<PatientPickerRail currentPlanId="" /> <PatientPickerRail currentPlanId="" />
<div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-3 text-center"> <div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-3 text-center">
......
...@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'; ...@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
import { useAuthBootstrap } from '@/hooks/use-auth-bootstrap'; import { useAuthBootstrap } from '@/hooks/use-auth-bootstrap';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { MockLoginDialog } from '@/components/mock-login-dialog'; import { MockLoginDialog } from '@/components/mock-login-dialog';
import { SessionExpired } from '@/components/session-expired';
import { isEmbedded } from '@/lib/embed';
const Placeholder = () => ( const Placeholder = () => (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground"> <div className="flex h-screen items-center justify-center text-sm text-muted-foreground">
...@@ -29,7 +31,10 @@ export function AuthGate({ children }: { children: React.ReactNode }) { ...@@ -29,7 +31,10 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
if (isAuthenticated) return <>{children}</>; if (isAuthenticated) return <>{children}</>;
if (status === 'authenticating' || status === 'idle') return <Placeholder />; if (status === 'authenticating' || status === 'idle') return <Placeholder />;
// 未鉴权 fallback —— 透明占位 + 强制 MockLoginDialog // 嵌入态(宿主 iframe):不弹 dev 快速登录 —— 会话失效只能回宿主重进,给友好引导 + 重试。
if (isEmbedded()) return <SessionExpired />;
// 独立打开(dev)未鉴权兜底 —— 透明占位 + 强制 MockLoginDialog
// 真生产把后端 PAC_ENABLE_MOCK_LOGIN=false,mockLogin 返 10107,dialog 会 toast 错误 // 真生产把后端 PAC_ENABLE_MOCK_LOGIN=false,mockLogin 返 10107,dialog 会 toast 错误
// 后用户能看到底层占位文案了解需要走宿主 SSO // 后用户能看到底层占位文案了解需要走宿主 SSO
return ( return (
......
'use client';
import { LogOut } from 'lucide-react';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { userDisplayName, roleNameZh } from '@/lib/utils';
import { isEmbedded } from '@/lib/embed';
/**
* 用户名 + 头像 + 退出三件套(各 header 共用,避免各改各的漂移)。
*
* 嵌入(iframe)时整体隐藏 —— 宿主系统已有用户身份与登出入口,PAC 不再重复;
* 且嵌入态点 PAC 登出只会清 PAC token、把人踹回登录兜底,体验很怪,故一并隐。
*/
export function IdentityCluster() {
const user = useAuthStore((s) => s.user);
if (isEmbedded()) return null;
const name = userDisplayName(user);
return (
<>
<div className="hidden md:block text-right text-[11.5px] leading-tight text-slate-500">
<div className="font-medium text-slate-700">{name}</div>
<div className="nums">
{visibleClinics(user).length} 个诊所 · {roleNameZh(user?.role)}
</div>
</div>
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
{name.charAt(0).toUpperCase()}
</span>
<button
type="button"
title="退出登录"
aria-label="退出登录"
onClick={() => useAuthStore.getState().clear()}
className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-4 w-4" />
</button>
</>
);
}
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown, LogOut } from 'lucide-react'; import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
...@@ -21,14 +21,13 @@ import { ...@@ -21,14 +21,13 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { usePlanSyncStore } from '@/stores/plan-sync-store'; import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAuthStore, visibleClinics } from '@/stores/auth-store'; import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed';
import { import {
cn, cn,
formatGender, formatGender,
formatDaysReadable, formatDaysReadable,
formatToothPosition, formatToothPosition,
userDisplayName,
roleNameZh,
} from '@/lib/utils'; } from '@/lib/utils';
import { import {
PersonaFeatureKey, PersonaFeatureKey,
...@@ -48,6 +47,7 @@ import { ReasonLine } from './reason-line'; ...@@ -48,6 +47,7 @@ import { ReasonLine } from './reason-line';
import { ScriptView, type ScriptViewMode } from './script-viewer'; import { ScriptView, type ScriptViewMode } from './script-viewer';
import { ScriptDeepProcess } from './script-deep-process'; import { ScriptDeepProcess } from './script-deep-process';
import { OutcomeForm } from './outcome-form'; import { OutcomeForm } from './outcome-form';
import { actionUrl } from '@/lib/action-urls';
import { Drawer, type DrawerKind } from './drawer'; import { Drawer, type DrawerKind } from './drawer';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useMediaQuery } from '@/hooks/use-media-query'; import { useMediaQuery } from '@/hooks/use-media-query';
...@@ -485,9 +485,18 @@ export function PlanDetailApp({ ...@@ -485,9 +485,18 @@ export function PlanDetailApp({
plan={effectivePlan} plan={effectivePlan}
onSubmit={submitOutcome} onSubmit={submitOutcome}
defaultChannel={effectivePlan.recommendedChannel} defaultChannel={effectivePlan.recommendedChannel}
onCreateAppointment={() => onCreateAppointment={() => {
showToast('emerald', '打开宿主预约页', '带上患者 + 医生 + 治疗项预填') // 新建预约发生在宿主系统:直接打开宿主预约页(新标签),带患者 + 项目预填。
} const url = actionUrl('createAppointment', {
patientId: patient.externalId,
name: patient.nameMasked ?? patient.name,
phone: patient.phoneMasked,
reason: reasons[0]?.scenarioLabel,
cs: patient.dedicatedCs?.name,
});
window.open(url, '_top');
showToast('emerald', '已打开宿主预约页', '已带患者 + 项目预填');
}}
/> />
</div> </div>
</section> </section>
...@@ -741,8 +750,9 @@ function TopBar({ ...@@ -741,8 +750,9 @@ function TopBar({
showToast?: (kind: string, title: string, msg: string) => void; showToast?: (kind: string, title: string, msg: string) => void;
fmtRel?: (d: Date) => string; fmtRel?: (d: Date) => string;
}) { }) {
const user = useAuthStore((s) => s.user);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// 嵌入宿主 iframe 时:隐藏 PAC 品牌 / 标题 / 用户三件套(宿主已提供),只留召回功能区。
const embedded = isEmbedded();
/// W4 末:从 DW 直连重拉该 patient 全量数据 → 触发 persona + plan 重算 /// W4 末:从 DW 直连重拉该 patient 全量数据 → 触发 persona + plan 重算
/// 完成后调外层 refresh() 拿新 plan;plan 没了(scenario 不再命中)由 loader 层 /// 完成后调外层 refresh() 拿新 plan;plan 没了(scenario 不再命中)由 loader 层
...@@ -774,6 +784,9 @@ function TopBar({ ...@@ -774,6 +784,9 @@ function TopBar({
return ( return (
<header className="flex flex-none items-center justify-between gap-2 border-b border-slate-100 bg-white px-3 py-2 sm:px-5 sm:py-3 sm:gap-3"> <header className="flex flex-none items-center justify-between gap-2 border-b border-slate-100 bg-white px-3 py-2 sm:px-5 sm:py-3 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3"> <div className="flex min-w-0 items-center gap-2 sm:gap-3">
{/* PAC 品牌 + 面包屑标题 —— 嵌入宿主时隐藏(宿主自有品牌与导航) */}
{!embedded && (
<>
<div className="inline-flex flex-none items-center gap-2"> <div className="inline-flex flex-none items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white"> <span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
PAC PAC
...@@ -786,6 +799,8 @@ function TopBar({ ...@@ -786,6 +799,8 @@ function TopBar({
<h1 className="truncate text-[14px] sm:text-[15px] font-semibold leading-tight text-slate-900">任务详情</h1> <h1 className="truncate text-[14px] sm:text-[15px] font-semibold leading-tight text-slate-900">任务详情</h1>
<p className="hidden sm:block mt-0.5 truncate text-[11px] text-slate-500">召回中心</p> <p className="hidden sm:block mt-0.5 truncate text-[11px] text-slate-500">召回中心</p>
</div> </div>
</>
)}
{/* scenario chip + 优先级 — 始终显示,核心信息 */} {/* scenario chip + 优先级 — 始终显示,核心信息 */}
<Chip tone="rose" icon size="xs" className="ml-1 sm:ml-2 flex-none"> <Chip tone="rose" icon size="xs" className="ml-1 sm:ml-2 flex-none">
{reason.scenarioLabel} {reason.scenarioLabel}
...@@ -836,24 +851,8 @@ function TopBar({ ...@@ -836,24 +851,8 @@ function TopBar({
)} )}
{/* 回收倒计时 — 核心信息,始终显示 */} {/* 回收倒计时 — 核心信息,始终显示 */}
<RecycleCountdown recycleAt={plan.recycleAt} /> <RecycleCountdown recycleAt={plan.recycleAt} />
{/* 用户名块 — md+ 才显,移动端只剩头像 */} {/* 用户名 + 头像 + 退出 —— 嵌入宿主时整体隐藏(见 IdentityCluster) */}
<div className="hidden md:block text-right text-[11.5px] leading-tight text-slate-500"> <IdentityCluster />
<div className="font-medium text-slate-700">{userDisplayName(user)}</div>
<div className="nums">{visibleClinics(user).length} 个诊所 · {roleNameZh(user?.role)}</div>
</div>
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
{userDisplayName(user).charAt(0).toUpperCase()}
</span>
{/* 退出登录(自原列表页迁来;清 token → AuthGate 自动弹回登录) */}
<button
type="button"
title="退出登录"
aria-label="退出登录"
onClick={() => useAuthStore.getState().clear()}
className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-4 w-4" />
</button>
</div> </div>
</header> </header>
); );
......
'use client';
import { useState } from 'react';
import { Clock, RefreshCw } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { refreshToken as apiRefresh } from '@/lib/auth-api';
/**
* 嵌入态(宿主 iframe)会话失效兜底 —— 替代 dev 的 MockLoginDialog。
*
* 嵌入时 PAC 的 code/token 都由宿主换票签发;access + refresh 都失效后 PAC **无法自助续期**
* (换票只能宿主发起),只能引导用户回宿主重新进入。给一个「重试」兜底暂时性刷新失败:
* 若 localStorage 里 refresh token 还在(偶发网络抖动导致的失败),重试即恢复,不必真回宿主。
*/
export function SessionExpired() {
const [retrying, setRetrying] = useState(false);
const [failed, setFailed] = useState(false);
const retry = async () => {
if (retrying) return;
setRetrying(true);
setFailed(false);
const rt = useAuthStore.getState().refreshToken;
// 有 refresh token → 试着静默续期(挡暂时性失败);没有就只能回宿主,直接标记失败。
if (rt) {
try {
const r = await apiRefresh(rt);
useAuthStore.getState().setTokens(r);
return; // 成功:store 更新 → AuthGate 自动放行,本组件卸载
} catch {
/* fall through */
}
}
setRetrying(false);
setFailed(true);
};
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 p-8 text-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-50 text-amber-500">
<Clock className="h-7 w-7" />
</span>
<p className="text-[15px] font-medium text-slate-700">登录已过期</p>
<p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500">
你在宿主系统里的登录会话已失效。请回到宿主系统重新进入召回工作台;
若只是短暂网络问题,可先点下方重试。
</p>
<button
type="button"
onClick={retry}
disabled={retrying}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-[12.5px] text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${retrying ? 'animate-spin' : ''}`} />
{retrying ? '重试中…' : '重试'}
</button>
{failed && (
<p className="text-[11.5px] text-rose-500">仍未恢复,请从宿主系统重新打开本页面。</p>
)}
</div>
);
}
'use client';
/**
* 宿主动作 URL(actionUrls)——「插槽」配置。
*
* PAC 工作台里有些动作(新建预约 / 看患者档案 / 看影像原图)发生在**宿主系统**里,
* PAC 不实现,只在对应插槽用宿主提供的 URL 打开宿主页面(iframe 嵌入或跳转)。
*
* 生产:这些 URL 由宿主在接入时配置下发(换票 token / 配置接口),按 host 不同而不同。
* 这里给**演示默认值**(指向模拟宿主的新建预约页),并允许 env 覆盖,方便本地联调。
*/
const DEFAULT_ACTION_URLS = {
/** 新建预约 —— 带患者/项目预填,打开宿主预约中心 */
createAppointment: 'http://localhost:4100/appointment',
} as const;
type ActionKey = keyof typeof DEFAULT_ACTION_URLS;
const ENV_OVERRIDES: Partial<Record<ActionKey, string | undefined>> = {
createAppointment: process.env.NEXT_PUBLIC_HOST_APPOINTMENT_URL,
};
/** 取某个插槽的宿主基础 URL。 */
export function actionBaseUrl(key: ActionKey): string {
return ENV_OVERRIDES[key] || DEFAULT_ACTION_URLS[key];
}
/** 拼上下文参数,生成插槽最终 URL(供 iframe src / 跳转用)。 */
export function actionUrl(key: ActionKey, params: Record<string, string | undefined>): string {
const url = new URL(actionBaseUrl(key));
for (const [k, v] of Object.entries(params)) {
if (v) url.searchParams.set(k, v);
}
return url.toString();
}
'use client';
/**
* 是否被嵌入(在别人的 iframe 里)。
*
* 自动检测:被跨源框住时 `window.top !== window.self`。跨源读 `window.top` 某些浏览器会抛
* SecurityError —— 抛本身就说明被跨源嵌入,按 `true`。SSR(无 window)按 `false`。
*
* 用途:嵌入宿主系统时隐藏 PAC 品牌 / 标题 / 用户身份 / 登出(宿主已提供这些),只留召回功能区。
* 状态在一次页面加载内不变,直接函数调用即可,无需做成响应式 store。
*/
export function isEmbedded(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.top !== window.self;
} catch {
return true;
}
}
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