Commit e2bd5460 by luoqi

feat(plans): EMR 原文跳转(治疗历史「详情」槽)+ 通用 actionUrl 占位引擎;修左栏待认领换行

- 左栏 PatientRow:诊所名变长后「待认领」被挤换行 → StatusPill 加 whitespace-nowrap、
  诊所名 min-w-0 truncate、电话定宽,布局稳住
- 通用占位替换 lib/action-url.ts(fillActionUrl/resolveActionUrl):扫 {key} 从 ctx 取值
  URL 编码,缺失清空;替代原来写死的 {patientId} 单占位
- 治疗历史卡「详情」槽:配了 VIEW_MEDICAL_RECORD 就打开宿主电子病历原文(占位
  {brandId}/{clinicId}/{medicalRecordNumber}/{patientId}),隐藏原 PAC 时间轴抽屉;
  未配置回退原详情。CREATE_APPOINTMENT 也切到通用引擎(同样支持这些 id 变量)
- 后端 plan-aggregate 下发 patient.brandId(source_unit → host.orgAliases 反解品牌 GUID);
  前端补 patient.brandId / plan.targetClinicId 贯穿 raw→adapt→mock 类型链
- HOST_ACTION_META.VIEW_MEDICAL_RECORD 占位更新为 brandId/clinicId/medicalRecordNumber/patientId

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent d929aaee
......@@ -159,8 +159,17 @@ export class PlanAggregateService {
// 必须按 patient 取才看得到"上次为什么被压住 / 上次结果"。也顺带补上之前缺失的通话历史。
const recallHistory = await this.loadRecallHistory(scope, patient.id);
// 品牌 GUID:source_unit(名) → host.orgAliases[名]。给 EMR 跳转链接的 {brandId} 用
// (契约里品牌层传的是 GUID;PAC 内部存的是名字,靠 orgAliases 反解)。
const hostRow = await this.prisma.host.findUnique({
where: { id: scope.hostId },
select: { orgAliases: true },
});
const orgAliases = (hostRow?.orgAliases ?? {}) as Record<string, string>;
const brandId = patient.sourceUnit ? (orgAliases[patient.sourceUnit] ?? null) : null;
return {
patient: serializePatient(patient),
patient: { ...serializePatient(patient), brandId },
profile: serializeProfile(patient, facts, facts.filter((f) => f.type === 'encounter_record')),
plan: plan ? serializePlan(plan) : null,
persona: persona ? serializePersona(persona) : null,
......
......@@ -39,6 +39,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
id: real.patient.id,
externalId: real.patient.externalId,
medicalRecordNumber: real.patient.medicalRecordNumber ?? null,
brandId: real.patient.brandId ?? null, // 品牌 GUID(EMR 跳转 {brandId} 用)
dedicatedCs: real.patient.dedicatedCs ?? null,
name: real.patient.name ?? '(未知)',
nameMasked: real.patient.nameMasked ?? '*',
......@@ -153,6 +154,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
contactAttempts: real.plan?.contactAttempts ?? 0,
maxContactAttempts: real.plan?.maxContactAttempts ?? 4,
targetClinic: clinicName(real.plan?.targetClinicId, dict) ?? '(集团统一池)',
targetClinicId: real.plan?.targetClinicId ?? null, // 原始诊所 id(EMR 跳转 {clinicId} 用)
executorClinic: clinicName(real.plan?.targetClinicId, dict) ?? '(集团统一池)',
goal: real.plan?.goal ?? null,
assignee: {
......
......@@ -32,6 +32,7 @@ export const mockPatient = {
id: 'ptn_8f3c1a2b',
externalId: '5i5ya_PA00284917',
medicalRecordNumber: 'SH0Q011691' as string | null,
brandId: null as string | null, // 品牌 GUID(EMR 跳转 {brandId} 用)
dedicatedCs: { id: '5499', name: '姜莹' } as { id: string; name: string } | null,
name: '张志远',
nameMasked: '张志*',
......@@ -314,6 +315,7 @@ export const mockPlan = {
contactAttempts: 0,
maxContactAttempts: 4,
targetClinic: '望京旗舰店',
targetClinicId: null as string | null, // 原始诊所 id(EMR 跳转 {clinicId} 用)
executorClinic: '望京旗舰店',
goal: '邀约本周种植面诊,避免邻牙倾斜 / 对颌伸长' as string | null,
assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole },
......
......@@ -25,6 +25,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed';
import { postToHost } from '@/lib/host-message';
import { resolveActionUrl } from '@/lib/action-url';
import {
cn,
formatGender,
......@@ -341,6 +342,14 @@ export function PlanDetailApp({
facts={facts}
onOpenMedical={() => setDrawerOpen('medical')}
onOpenTimeline={() => setDrawerOpen('treatments')}
// 「详情」槽:配了 VIEW_MEDICAL_RECORD 就打开宿主电子病历原文(占位替换 4 个 id),
// 替换掉原来的 PAC 事实时间轴抽屉;未配置则回退原详情。
emrUrl={resolveActionUrl('VIEW_MEDICAL_RECORD', {
brandId: patient.brandId,
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
patientId: patient.externalId,
})}
/>
{/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && (
......@@ -487,17 +496,19 @@ export function PlanDetailApp({
onSubmit={submitOutcome}
defaultChannel={effectivePlan.recommendedChannel}
onCreateAppointment={() => {
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。
const tpl = useAuthStore.getState().user?.actionUrls?.CREATE_APPOINTMENT;
if (!tpl) {
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。通用引擎:支持 {patientId}/{brandId}/
// {clinicId}/{medicalRecordNumber} 等,缺失键清空
const url = resolveActionUrl('CREATE_APPOINTMENT', {
patientId: patient.externalId,
brandId: patient.brandId,
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
});
if (!url) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return;
}
const url = tpl
.replace(/\{patientId\}/g, encodeURIComponent(patient.externalId ?? ''))
.replace(/\{doctorId\}/g, '')
.replace(/\{[a-zA-Z]+\}/g, ''); // 其余未知占位清空
window.open(url, '_top');
showToast('emerald', '已打开宿主预约页', '带上患者 id');
}}
......@@ -1480,11 +1491,14 @@ function TreatmentHistoryCard({
facts,
onOpenMedical,
onOpenTimeline,
emrUrl,
}: {
facts: AdaptedFact[];
onOpenMedical?: () => void;
/// 「详情」→ 打开患者事实时间轴(默认只勾选治疗,见 Drawer kind='treatments')
onOpenTimeline?: () => void;
/// 配了 VIEW_MEDICAL_RECORD 时,「详情」槽改为打开宿主电子病历原文;此时隐藏原时间轴详情。
emrUrl?: string;
}) {
const history = useMemo(
() =>
......@@ -1517,10 +1531,22 @@ function TreatmentHistoryCard({
病历快读 →
</button>
)}
{onOpenTimeline && (
{emrUrl ? (
// 宿主电子病历原文(占位插槽)—— 替换原「详情」时间轴抽屉
<a
href={emrUrl}
target="_top"
rel="noopener"
className="text-[10.5px] text-teal-700 hover:underline"
>
病历详情 →
</a>
) : (
onOpenTimeline && (
<button onClick={onOpenTimeline} className="text-[10.5px] text-teal-700 hover:underline">
详情 →
</button>
)
)}
</div>
}
......
......@@ -12,6 +12,8 @@ export type PlanDetailData = {
id: string;
externalId: string;
medicalRecordNumber: string | null;
/// 品牌 GUID(source_unit 经 host.orgAliases 反解)—— EMR 跳转链接 {brandId} 用;无别名则 null
brandId: string | null;
dedicatedCs: { id: string; name: string } | null;
name: string | null;
nameMasked: string | null;
......
......@@ -394,7 +394,7 @@ const STATUS_META: Record<string, { label: string; tone: string }> = {
function StatusPill({ status }: { status: string }) {
const m = STATUS_META[status] ?? { label: status, tone: 'bg-slate-100 text-slate-700' };
return <span className={cn('inline-block flex-none rounded px-1.5 py-0.5 text-[10px] font-medium', m.tone)}>{m.label}</span>;
return <span className={cn('inline-block flex-none whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium', m.tone)}>{m.label}</span>;
}
// ── 回访结果角标(最近一次执行的分组:成功 / 不成功 / 保持)────────
......@@ -490,15 +490,15 @@ function PatientRow({
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<span className="truncate font-mono text-[10.5px] tabular-nums text-slate-400">
<span className="flex-none font-mono text-[10.5px] tabular-nums text-slate-400">
{p.patient.phoneMasked ?? ''}
</span>
{clinicName && (
<span className="truncate text-[10px] text-slate-400" title={clinicName}>
<span className="min-w-0 flex-1 truncate text-[10px] text-slate-400" title={clinicName}>
· {clinicName}
</span>
)}
<span className="ml-auto" />
<span className="ml-auto flex-none" />
{onClaim ? (
<>
<button
......
'use client';
import { useAuthStore } from '@/stores/auth-store';
import type { HostActionKey } from '@pac/types';
/**
* 宿主动作跳转 URL —— 通用占位符替换。
*
* 模板存于 host.actionUrls[key](会话下发到 user.actionUrls),形如
* `/emr/MedicalAdmin/full?tenantId={brandId}&emrId={medicalRecordNumber}&organizationId={clinicId}&patientId={patientId}`
* 把 `{xxx}` 逐个替换成 ctx[xxx](URL 编码);缺失的键替换成空串(不报错)。
*
* 约定占位符(驼峰,与 HOST_ACTION_META 一致):
* {patientId} {medicalRecordNumber} {brandId} {clinicId} {doctorId} {appointmentId} ...
*/
export function fillActionUrl(
template: string,
ctx: Record<string, string | null | undefined>,
): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
encodeURIComponent(ctx[key] ?? ''),
);
}
/** 取某个宿主动作的模板(会话下发的 actionUrls);未配置返回 undefined。 */
export function actionTemplate(key: HostActionKey): string | undefined {
return useAuthStore.getState().user?.actionUrls?.[key] || undefined;
}
/**
* 便捷:填好某动作的 URL。未配置模板 → 返回 undefined(调用方据此决定不渲染按钮)。
*/
export function resolveActionUrl(
key: HostActionKey,
ctx: Record<string, string | null | undefined>,
): string | undefined {
const tpl = actionTemplate(key);
return tpl ? fillActionUrl(tpl, ctx) : undefined;
}
......@@ -97,7 +97,7 @@ export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholde
CREATE_APPOINTMENT: { label: '新建预约', placeholders: ['{patientId}', '{doctorId}'] },
VIEW_APPOINTMENT: { label: '查看预约', placeholders: ['{appointmentId}'] },
VIEW_ENCOUNTER: { label: '查看接诊', placeholders: ['{encounterId}'] },
VIEW_MEDICAL_RECORD: { label: '查看病历', placeholders: ['{emrId}', '{patientId}'] },
VIEW_MEDICAL_RECORD: { label: '查看病历(电子病历)', placeholders: ['{brandId}', '{clinicId}', '{medicalRecordNumber}', '{patientId}'] },
VIEW_IMAGE: { label: '查看影像', placeholders: ['{imageId}'] },
VIEW_PAYMENT: { label: '查看账单/收款', placeholders: ['{paymentId}', '{orderId}'] },
ADD_NOTE: { label: '添加备注', placeholders: ['{patientId}'] },
......
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