Commit 250995f5 by luoqi

Merge host action link fixes from main into test

parents a167b9ec 99bee3e2
Pipeline #3818 failed in 0 seconds
...@@ -93,15 +93,17 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。 ...@@ -93,15 +93,17 @@ PAC 出站 API 统一 ISO 8601 带时区 + 整数分。
```jsonc ```jsonc
{ {
"VIEW_PATIENT": "https://host.example.com/patient/{patientId}", "VIEW_PATIENT": "https://host.example.com/patient/{patientId}",
"VIEW_EMR": "https://host.example.com/emr?patientId={patientId}", "VIEW_MEDICAL_RECORD": "https://host.example.com/emr?patientId={patientId}",
"CREATE_APPOINTMENT": "https://host.example.com/appointment/new?patientId={patientId}", "CREATE_APPOINTMENT": "https://host.example.com/appointment/new?patientId={patientId}&phone={phone}",
"VIEW_XRAY": null, // null = 不支持,按钮不渲染 "VIEW_IMAGE": "https://host.example.com/patients/{patientId}/images",
"CALL_PATIENT": null "CALL_PATIENT": null
} }
``` ```
**只提供宿主方能支持的**,不写或填 `null` 都行。占位符 `{patientId}` / `{appointmentId}` / `{emrId}` / `{clinicId}` / `{userId}` 按字面替换。 **只提供宿主方能支持的**,不写或填 `null` 都行。占位符 `{patientId}` / `{appointmentId}` / `{emrId}` / `{clinicId}` / `{userId}` 按字面替换。
`{patientId}` 使用宿主患者 ID;新建预约可用 `{phone}` 带入患者联系电话。查看影像可按 `{patientId}` 打开患者影像中心,或按 `{imageId}` 打开单张影像。已配置影像模板时优先使用模板;未配置时,影像记录中的完整文件链接仍可打开。
**打开方式**:PAC 前端 `window.open(url)`(新标签)或 `_top`(整页跳宿主页)。患者上下文经 URL 参数带过去,宿主页预填。 **打开方式**:PAC 前端 `window.open(url)`(新标签)或 `_top`(整页跳宿主页)。患者上下文经 URL 参数带过去,宿主页预填。
**动作没有独立页(是弹窗 / 组件)**:宿主给一个能触发弹窗的**深链参数**即可,仍是链接 —— 无需 postMessage: **动作没有独立页(是弹窗 / 组件)**:宿主给一个能触发弹窗的**深链参数**即可,仍是链接 —— 无需 postMessage:
......
...@@ -6,6 +6,8 @@ import { Chip } from './shared'; ...@@ -6,6 +6,8 @@ import { Chip } from './shared';
import { ChainDetailView } from './chain-viz'; import { ChainDetailView } from './chain-viz';
import { EmrSoapView } from './emr-soap-view'; import { EmrSoapView } from './emr-soap-view';
import { FactsTimeline } from './facts-timeline'; import { FactsTimeline } from './facts-timeline';
import { HostImageLink } from './host-image-link';
import type { HostActionContext } from '@/lib/image-action-url';
import { ToothTimeline } from './tooth-timeline'; import { ToothTimeline } from './tooth-timeline';
import { PersonaDetailList } from './persona-detail-list'; import { PersonaDetailList } from './persona-detail-list';
import type { Chain, PersonaFeature, PlanReason } from './mock-data'; import type { Chain, PersonaFeature, PlanReason } from './mock-data';
...@@ -25,6 +27,8 @@ export function Drawer({ ...@@ -25,6 +27,8 @@ export function Drawer({
reason, reason,
reasons, reasons,
facts, facts,
hostActionContext,
sourceUnit,
fmtRel, fmtRel,
returnVisits, returnVisits,
summaryOverride, summaryOverride,
...@@ -47,6 +51,8 @@ export function Drawer({ ...@@ -47,6 +51,8 @@ export function Drawer({
reasons: PlanReason[]; reasons: PlanReason[];
/// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用) /// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用)
facts: AdaptedFact[]; facts: AdaptedFact[];
hostActionContext: HostActionContext;
sourceUnit?: string | null;
fmtRel: (d: Date) => string; fmtRel: (d: Date) => string;
/// 历史联系(诊所回访)结构化记录 — 「历史联系」卡片「详情」抽屉用 /// 历史联系(诊所回访)结构化记录 — 「历史联系」卡片「详情」抽屉用
returnVisits: ReturnVisitItem[]; returnVisits: ReturnVisitItem[];
...@@ -91,19 +97,19 @@ export function Drawer({ ...@@ -91,19 +97,19 @@ export function Drawer({
subtitle = imageFacts.length > 0 subtitle = imageFacts.length > 0
? `最早 ${imageFacts[imageFacts.length - 1]?.occurredAt?.slice(0, 10) ?? '—'} ~ 最近 ${imageFacts[0]?.occurredAt?.slice(0, 10) ?? '—'}` ? `最早 ${imageFacts[imageFacts.length - 1]?.occurredAt?.slice(0, 10) ?? '—'} ~ 最近 ${imageFacts[0]?.occurredAt?.slice(0, 10) ?? '—'}`
: '无影像 fact'; : '无影像 fact';
body = <ImageFactsView facts={imageFacts} />; body = <ImageFactsView facts={imageFacts} hostActionContext={hostActionContext} sourceUnit={sourceUnit} />;
width = 'w-[640px]'; width = 'w-[640px]';
} else if (kind === 'facts') { } else if (kind === 'facts') {
title = `患者事实时间轴(${facts.length})`; title = `患者事实时间轴(${facts.length})`;
subtitle = '按时间倒序'; subtitle = '按时间倒序';
body = <FactsTimeline facts={facts} />; body = <FactsTimeline facts={facts} hostActionContext={hostActionContext} sourceUnit={sourceUnit} />;
width = 'w-[640px]'; width = 'w-[640px]';
} else if (kind === 'treatments') { } else if (kind === 'treatments') {
// 治疗历史卡「详情」:同 facts 时间轴,默认只勾选治疗(可在 chip 里再放开看其余) // 治疗历史卡「详情」:同 facts 时间轴,默认只勾选治疗(可在 chip 里再放开看其余)
const txCount = facts.filter((f) => f.type === 'treatment_record').length; const txCount = facts.filter((f) => f.type === 'treatment_record').length;
title = `治疗时间轴(${txCount})`; title = `治疗时间轴(${txCount})`;
subtitle = '仅治疗 · 时间倒序(治疗计划可在上方筛选打开)'; subtitle = '仅治疗 · 时间倒序(治疗计划可在上方筛选打开)';
body = <FactsTimeline facts={facts} initialTypes={['treatment_record']} />; body = <FactsTimeline facts={facts} initialTypes={['treatment_record']} hostActionContext={hostActionContext} sourceUnit={sourceUnit} />;
width = 'w-[640px]'; width = 'w-[640px]';
} else if (kind === 'return-visits') { } else if (kind === 'return-visits') {
title = `历史联系(${returnVisits.length})`; title = `历史联系(${returnVisits.length})`;
...@@ -207,14 +213,15 @@ function StreamingHint() { ...@@ -207,14 +213,15 @@ function StreamingHint() {
// ImageFactsView — image_record fact 列表(真实数据) // ImageFactsView — image_record fact 列表(真实数据)
// 影像原图走宿主系统(actionUrls.VIEW_IMAGE);此处只展示 metadata + finding // 影像原图走宿主系统(actionUrls.VIEW_IMAGE);此处只展示 metadata + finding
// ────────────────────────────────────────── // ──────────────────────────────────────────
function ImageFactsView({ facts }: { facts: AdaptedFact[] }) { function ImageFactsView({ facts, hostActionContext, sourceUnit }: {
facts: AdaptedFact[];
hostActionContext: HostActionContext;
sourceUnit?: string | null;
}) {
if (facts.length === 0) { if (facts.length === 0) {
return ( return (
<div className="text-center py-12 text-sm text-slate-400"> <div className="text-center py-12 text-sm text-slate-400">
无影像 fact 暂无已同步的影像资料
<div className="text-[10.5px] mt-1.5">
(DW 给的是 metadata + URL,原图走宿主影像系统跳转)
</div>
</div> </div>
); );
} }
...@@ -255,11 +262,14 @@ function ImageFactsView({ facts }: { facts: AdaptedFact[] }) { ...@@ -255,11 +262,14 @@ function ImageFactsView({ facts }: { facts: AdaptedFact[] }) {
source encounter: {encId} source encounter: {encId}
</div> </div>
)} )}
<div className="mt-1.5">
<HostImageLink context={hostActionContext} sourceUnit={sourceUnit} fact={f} />
</div>
</div> </div>
); );
})} })}
<div className="text-[10.5px] text-slate-400 pt-2 border-t"> <div className="text-[10.5px] text-slate-400 pt-2 border-t">
如需查看原始 DICOM,请跳转宿主影像系统(deep-link 待 host actionUrls 接入) 影像原件由宿主影像系统提供
</div> </div>
</div> </div>
); );
......
...@@ -21,6 +21,8 @@ import { ...@@ -21,6 +21,8 @@ import {
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types'; import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
import { HostImageLink } from './host-image-link';
import type { HostActionContext } from '@/lib/image-action-url';
/** /**
* FactsTimeline — 患者事实时间轴(drawer 用) * FactsTimeline — 患者事实时间轴(drawer 用)
...@@ -34,10 +36,14 @@ import type { AdaptedFact } from './adapt-data'; ...@@ -34,10 +36,14 @@ import type { AdaptedFact } from './adapt-data';
export function FactsTimeline({ export function FactsTimeline({
facts, facts,
initialTypes, initialTypes,
hostActionContext,
sourceUnit,
}: { }: {
facts: AdaptedFact[]; facts: AdaptedFact[];
/// 初始勾选的 fact_type(缺省全选);如治疗历史卡「详情」传 ['treatment_record'] → 默认只看治疗时间轴 /// 初始勾选的 fact_type(缺省全选);如治疗历史卡「详情」传 ['treatment_record'] → 默认只看治疗时间轴
initialTypes?: string[]; initialTypes?: string[];
hostActionContext: HostActionContext;
sourceUnit?: string | null;
}) { }) {
if (facts.length === 0) { if (facts.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>; return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>;
...@@ -155,7 +161,7 @@ export function FactsTimeline({ ...@@ -155,7 +161,7 @@ export function FactsTimeline({
<div className="relative pl-6"> <div className="relative pl-6">
<div className="absolute left-[10px] top-1 bottom-1 w-px bg-slate-200" /> <div className="absolute left-[10px] top-1 bottom-1 w-px bg-slate-200" />
{sorted.map((f) => ( {sorted.map((f) => (
<TimelineRow key={f.id} fact={f} /> <TimelineRow key={f.id} fact={f} hostActionContext={hostActionContext} sourceUnit={sourceUnit} />
))} ))}
</div> </div>
)} )}
...@@ -292,7 +298,11 @@ function StatBlock({ label, children }: { label: string; children: React.ReactNo ...@@ -292,7 +298,11 @@ function StatBlock({ label, children }: { label: string; children: React.ReactNo
// 单个 fact 时间轴行 // 单个 fact 时间轴行
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
function TimelineRow({ fact }: { fact: AdaptedFact }) { function TimelineRow({ fact, hostActionContext, sourceUnit }: {
fact: AdaptedFact;
hostActionContext: HostActionContext;
sourceUnit?: string | null;
}) {
const meta = FACT_META[fact.type] ?? FACT_META_FALLBACK; const meta = FACT_META[fact.type] ?? FACT_META_FALLBACK;
const Icon = meta.Icon; const Icon = meta.Icon;
...@@ -314,8 +324,6 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) { ...@@ -314,8 +324,6 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
const { title, note } = factSummary(fact); const { title, note } = factSummary(fact);
const rightLines = rightColumn(fact); const rightLines = rightColumn(fact);
const imageUrl = fact.content?.image_file_url ??
(fact.type === 'image_record' ? fact.content?.file_url : null);
return ( return (
<div className="relative pb-3"> <div className="relative pb-3">
...@@ -340,10 +348,7 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) { ...@@ -340,10 +348,7 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
{note && ( {note && (
<div className="text-[11px] text-slate-500 leading-snug mt-0.5 line-clamp-2">{note}</div> <div className="text-[11px] text-slate-500 leading-snug mt-0.5 line-clamp-2">{note}</div>
)} )}
{typeof imageUrl === 'string' && /^https?:\/\//i.test(imageUrl) && ( <HostImageLink context={hostActionContext} sourceUnit={sourceUnit} fact={fact} />
<a href={imageUrl} target="_blank" rel="noopener noreferrer"
className="text-[11px] text-brand-600 hover:underline">查看影像</a>
)}
</div> </div>
{rightLines.length > 0 && ( {rightLines.length > 0 && (
<div className="flex flex-col items-end gap-0.5 text-[11.5px] text-slate-600 tabular-nums whitespace-nowrap"> <div className="flex flex-col items-end gap-0.5 text-[11.5px] text-slate-600 tabular-nums whitespace-nowrap">
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import { HOST_ACTION_META } from '@pac/types';
import { resolveActionUrl } from '@/lib/action-url';
import { resolveImageActionUrl, type ImageActionFact } from '@/lib/image-action-url';
import { HostImageLink } from './host-image-link';
import { FactsTimeline } from './facts-timeline';
import type { AdaptedFact } from './adapt-data';
const state = vi.hoisted(() => ({ actionUrls: {} as Record<string, string | null> }));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: { getState: () => ({ user: { actionUrls: state.actionUrls } }) },
}));
const context = {
patientId: 'host-patient-123',
phone: '+86 13800000000',
brandId: 'brand-1',
clinicId: 'target-clinic',
medicalRecordNumber: 'MR-123',
};
const fact: AdaptedFact = {
id: 'pac-fact-uuid', type: 'image_record', kind: 'actual', status: 'active',
occurredAt: '2026-10-15T06:40:00Z', plannedFor: null,
clinicId: 'image-clinic', title: null, summary: null,
content: { image_external_id: 'host-image-456', file_url: 'oss/relative.jpg' },
};
beforeEach(() => { state.actionUrls = {}; });
describe('宿主预约与影像跳转', () => {
it('预约模板同时填入宿主患者 ID 和 URL 编码后的原始电话', () => {
state.actionUrls.CREATE_APPOINTMENT = 'https://host.example/appointment?patientId={patientId}&phone={phone}';
const result = new URL(resolveActionUrl('CREATE_APPOINTMENT', context, '普尔')!);
expect(result.searchParams.get('patientId')).toBe('host-patient-123');
expect(result.searchParams.get('phone')).toBe('+86 13800000000');
expect(HOST_ACTION_META.CREATE_APPOINTMENT.placeholders).toContain('{phone}');
});
it('患者级影像中心不依赖已同步影像或 imageId', () => {
state.actionUrls.VIEW_IMAGE = 'https://host.example/patients/{patientId}?tabKey=12';
expect(renderToStaticMarkup(<HostImageLink context={context} />))
.toContain('href="https://host.example/patients/host-patient-123?tabKey=12"');
expect(HOST_ACTION_META.VIEW_IMAGE.placeholders).toContain('{patientId}');
});
it('患者卡片仅在配置了影像插槽时显示紧凑的「影像 →」链接', () => {
const link = <HostImageLink context={context} label="影像 →" className="text-[10.5px] text-brand-700 hover:underline" />;
expect(renderToStaticMarkup(link)).toBe('');
state.actionUrls.VIEW_IMAGE = 'https://host.example/patients/{patientId}?tabKey=12';
const html = renderToStaticMarkup(link);
expect(html).toContain('影像 →');
expect(html).toContain('text-[10.5px] text-brand-700 hover:underline');
expect(html).toContain('/patients/host-patient-123?tabKey=12');
});
it('影像事实优先用宿主模板,传源 imageId、该影像诊所及患者电话', () => {
state.actionUrls.VIEW_IMAGE = 'https://host.example/images/{imageId}?patientId={patientId}&clinicId={clinicId}&phone={phone}';
const result = new URL(resolveImageActionUrl(context, '普尔', fact)!);
expect(result.pathname).toBe('/images/host-image-456');
expect(result.searchParams.get('patientId')).toBe('host-patient-123');
expect(result.searchParams.get('clinicId')).toBe('image-clinic');
expect(result.searchParams.get('phone')).toBe(context.phone);
});
it('未配置模板时保留完整文件链接', () => {
expect(resolveImageActionUrl(context, null, {
...fact, content: { ...fact.content, file_url: 'https://files.example/image.jpg?signature=abc' },
})).toBe('https://files.example/image.jpg?signature=abc');
});
it('相对文件路径不能直接打开', () => {
expect(resolveImageActionUrl(context, null, fact)).toBeUndefined();
});
it('没有 imageId 时不渲染单张影像模板的空链接', () => {
state.actionUrls.VIEW_IMAGE = 'https://host.example/images/{imageId}';
expect(renderToStaticMarkup(<HostImageLink context={context} />)).toBe('');
});
it('缺少模板所需的患者 ID 时回退到文件链接', () => {
state.actionUrls.VIEW_IMAGE = 'https://host.example/patients/{patientId}';
expect(resolveImageActionUrl({}, null, {
...fact, content: { file_url: 'https://files.example/image.jpg' },
})).toBe('https://files.example/image.jpg');
});
it('沿用患者品牌换域规则,不把瑞泰影像带去瑞尔门户', () => {
state.actionUrls.VIEW_IMAGE = 'https://arrail-test.5i5ya.com/images/{imageId}';
expect(resolveImageActionUrl(context, '瑞泰', fact))
.toBe('https://rytime-test.5i5ya.com/images/host-image-456');
});
it('影像分析关联诊断使用对应的源影像 ID', () => {
state.actionUrls.VIEW_IMAGE = 'https://host.example/images/{imageId}';
const linked: ImageActionFact = {
type: 'diagnosis_record', content: { source_image_external_id: 'linked-image' },
};
expect(resolveImageActionUrl(context, null, linked)).toBe('https://host.example/images/linked-image');
expect(resolveImageActionUrl(context, null, { type: 'diagnosis_record', content: { code: 'K02' } }))
.toBeUndefined();
});
it('真实时间轴的影像入口使用患者模板,按新标签页打开', () => {
state.actionUrls.VIEW_IMAGE = 'https://host.example/patients/{patientId}?tabKey=12';
const html = renderToStaticMarkup(
<FactsTimeline facts={[fact]} hostActionContext={context} sourceUnit="普尔" />,
);
expect(html).toContain('href="https://host.example/patients/host-patient-123?tabKey=12"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
});
'use client';
import { HOST_LINK_PROPS, openHostUrl } from '@/lib/action-url';
import { resolveImageActionUrl, type HostActionContext, type ImageActionFact } from '@/lib/image-action-url';
export function HostImageLink({
context,
sourceUnit,
fact,
label = '查看影像',
className = 'text-[11px] text-brand-600 hover:underline',
}: {
context: HostActionContext;
sourceUnit?: string | null;
fact?: ImageActionFact;
label?: string;
className?: string;
}) {
const url = resolveImageActionUrl(context, sourceUnit, fact);
if (!url) return null;
return (
<a
href={url}
{...HOST_LINK_PROPS}
className={className}
onClick={(e) => {
e.preventDefault();
openHostUrl(url);
}}
>
{label}
</a>
);
}
...@@ -33,6 +33,7 @@ import { isEmbedded } from '@/lib/embed'; ...@@ -33,6 +33,7 @@ import { isEmbedded } from '@/lib/embed';
import { hostActionMode, openHostAction } from '@/lib/host-message'; import { hostActionMode, openHostAction } from '@/lib/host-message';
import { CloseOpportunityDialog } from './close-opportunity-dialog'; import { CloseOpportunityDialog } from './close-opportunity-dialog';
import { resolveActionUrl, openHostUrl, HOST_LINK_PROPS } from '@/lib/action-url'; import { resolveActionUrl, openHostUrl, HOST_LINK_PROPS } from '@/lib/action-url';
import { HostImageLink } from './host-image-link';
import { import {
cn, cn,
formatGender, formatGender,
...@@ -464,6 +465,7 @@ export function PlanDetailApp({ ...@@ -464,6 +465,7 @@ export function PlanDetailApp({
// 未配置 → 回调传 undefined → 按钮不渲染(PAC 默认无此动作)。预约:CREATE_APPOINTMENT 跳转。 // 未配置 → 回调传 undefined → 按钮不渲染(PAC 默认无此动作)。预约:CREATE_APPOINTMENT 跳转。
const hostActionCtx = { const hostActionCtx = {
patientId: patient.externalId, patientId: patient.externalId,
phone: patient.phone,
brandId: patient.brandId, brandId: patient.brandId,
clinicId: plan?.targetClinicId, clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber, medicalRecordNumber: patient.medicalRecordNumber,
...@@ -586,12 +588,7 @@ export function PlanDetailApp({ ...@@ -586,12 +588,7 @@ export function PlanDetailApp({
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。 // 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。缺失键清空。 // 未配置则提示去配,不写死任何 URL。缺失键清空。
// ⚠️ URL 先解析:没配的话**不该先把单结了**再告诉他跳不过去。 // ⚠️ URL 先解析:没配的话**不该先把单结了**再告诉他跳不过去。
const url = resolveActionUrl('CREATE_APPOINTMENT', { const url = resolveActionUrl('CREATE_APPOINTMENT', hostActionCtx, patient.sourceUnit);
patientId: patient.externalId,
brandId: patient.brandId,
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
}, patient.sourceUnit);
if (!url) { if (!url) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT'); showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return false; return false;
...@@ -713,9 +710,7 @@ export function PlanDetailApp({ ...@@ -713,9 +710,7 @@ export function PlanDetailApp({
setPersonaFocusKey(key); setPersonaFocusKey(key);
setDrawerOpen('persona'); setDrawerOpen('persona');
}} }}
onOpenImage={() => hostActionContext={hostActionCtx}
showToast('slate', '影像调阅', '跳转宿主页面')
}
onOpenProfile={() => onOpenProfile={() =>
showToast('slate', '患者档案', '跳转宿主页面') showToast('slate', '患者档案', '跳转宿主页面')
} }
...@@ -994,6 +989,8 @@ export function PlanDetailApp({ ...@@ -994,6 +989,8 @@ export function PlanDetailApp({
reason={focusedReason ?? reasons[0]!} reason={focusedReason ?? reasons[0]!}
reasons={reasons} reasons={reasons}
facts={facts} facts={facts}
hostActionContext={hostActionCtx}
sourceUnit={patient.sourceUnit}
fmtRel={fmtRel} fmtRel={fmtRel}
returnVisits={returnVisits} returnVisits={returnVisits}
summaryOverride={summaryOverride} summaryOverride={summaryOverride}
...@@ -1629,7 +1626,7 @@ function IdentityCard({ ...@@ -1629,7 +1626,7 @@ function IdentityCard({
patient, patient,
features, features,
onOpenPersonaFeature, onOpenPersonaFeature,
onOpenImage, hostActionContext,
onOpenProfile, onOpenProfile,
}: { }: {
patient: typeof mockPatient; patient: typeof mockPatient;
...@@ -1637,7 +1634,7 @@ function IdentityCard({ ...@@ -1637,7 +1634,7 @@ function IdentityCard({
features: typeof mockPersona.features; features: typeof mockPersona.features;
/** 点关键标签 → 开画像详情抽屉并滚到那一条 */ /** 点关键标签 → 开画像详情抽屉并滚到那一条 */
onOpenPersonaFeature: (featureKey: string) => void; onOpenPersonaFeature: (featureKey: string) => void;
onOpenImage: () => void; hostActionContext: Record<string, string | null | undefined>;
onOpenProfile: () => void; onOpenProfile: () => void;
}) { }) {
// 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。 // 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。
...@@ -1699,6 +1696,12 @@ function IdentityCard({ ...@@ -1699,6 +1696,12 @@ function IdentityCard({
</span> </span>
</div> </div>
<div className="flex items-center gap-2 flex-none"> <div className="flex items-center gap-2 flex-none">
<HostImageLink
context={hostActionContext}
sourceUnit={patient.sourceUnit}
label="影像 →"
className="text-[10.5px] text-brand-700 hover:underline whitespace-nowrap"
/>
{/* 潜在治疗入口已上移到顶栏「潜在」按钮(与 预约 / 回访 同处右上角动作区)。 */} {/* 潜在治疗入口已上移到顶栏「潜在」按钮(与 预约 / 回访 同处右上角动作区)。 */}
{/* 原始档案 —— 宿主 VIEW_PATIENT 跳转(占位 {patientId}/{medicalRecordNumber});未配则不渲染 */} {/* 原始档案 —— 宿主 VIEW_PATIENT 跳转(占位 {patientId}/{medicalRecordNumber});未配则不渲染 */}
{originalArchiveUrl ? ( {originalArchiveUrl ? (
......
import { actionTemplate, resolveActionUrl } from './action-url';
export type HostActionContext = Record<string, string | null | undefined>;
export interface ImageActionFact {
type: string;
clinicId?: string | null;
content?: Record<string, unknown> | null;
}
/** 影像入口统一用宿主模板;没有可用模板时保留完整文件链接。 */
export function resolveImageActionUrl(
context: HostActionContext,
sourceUnit?: string | null,
fact?: ImageActionFact,
): string | undefined {
const content = fact?.content ?? {};
const imageId = content.image_external_id ?? content.source_image_external_id;
if (fact && fact.type !== 'image_record' && fact.type !== 'image_analysis_record'
&& !imageId && !content.image_file_url) return undefined;
const ctx: HostActionContext = {
...context,
clinicId: fact?.clinicId ?? context.clinicId,
imageId: typeof imageId === 'string' ? imageId : undefined,
};
const template = actionTemplate('VIEW_IMAGE');
// 患者级入口没有 imageId,不能把单张影像模板里的标识清空后跳走。
const missingId = ['patientId', 'imageId'].some((key) =>
template?.includes(`{${key}}`) && !ctx[key]);
if (template?.trim() && !missingId) {
return resolveActionUrl('VIEW_IMAGE', ctx, sourceUnit);
}
const fileUrl = content.image_file_url ?? (fact?.type === 'image_record' ? content.file_url : null);
return typeof fileUrl === 'string' && /^https?:\/\//i.test(fileUrl) ? fileUrl : undefined;
}
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
esbuild: { jsx: 'automatic' },
test: { maxWorkers: 1 },
});
...@@ -127,11 +127,11 @@ export type HostActionKey = (typeof HostActionKey)[keyof typeof HostActionKey]; ...@@ -127,11 +127,11 @@ export type HostActionKey = (typeof HostActionKey)[keyof typeof HostActionKey];
export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholders: string[] }> = { export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholders: string[] }> = {
VIEW_PATIENT: { label: '查看患者档案(原始档案)', placeholders: ['{patientId}', '{medicalRecordNumber}'] }, VIEW_PATIENT: { label: '查看患者档案(原始档案)', placeholders: ['{patientId}', '{medicalRecordNumber}'] },
EDIT_PATIENT: { label: '编辑患者信息', placeholders: ['{patientId}'] }, EDIT_PATIENT: { label: '编辑患者信息', placeholders: ['{patientId}'] },
CREATE_APPOINTMENT: { label: '新建预约', placeholders: ['{patientId}', '{doctorId}'] }, CREATE_APPOINTMENT: { label: '新建预约', placeholders: ['{patientId}', '{phone}', '{doctorId}'] },
VIEW_APPOINTMENT: { label: '查看预约', placeholders: ['{appointmentId}'] }, VIEW_APPOINTMENT: { label: '查看预约', placeholders: ['{appointmentId}'] },
VIEW_ENCOUNTER: { label: '查看接诊', placeholders: ['{encounterId}'] }, VIEW_ENCOUNTER: { label: '查看接诊', placeholders: ['{encounterId}'] },
VIEW_MEDICAL_RECORD: { label: '查看病历(电子病历)', placeholders: ['{brandId}', '{clinicId}', '{medicalRecordNumber}', '{patientId}'] }, VIEW_MEDICAL_RECORD: { label: '查看病历(电子病历)', placeholders: ['{brandId}', '{clinicId}', '{medicalRecordNumber}', '{patientId}'] },
VIEW_IMAGE: { label: '查看影像', placeholders: ['{imageId}'] }, VIEW_IMAGE: { label: '查看影像', placeholders: ['{patientId}', '{imageId}', '{phone}', '{brandId}', '{clinicId}', '{medicalRecordNumber}'] },
VIEW_PAYMENT: { label: '查看账单/收款', placeholders: ['{paymentId}', '{orderId}'] }, VIEW_PAYMENT: { label: '查看账单/收款', placeholders: ['{paymentId}', '{orderId}'] },
ADD_NOTE: { label: '添加备注', placeholders: ['{patientId}'] }, ADD_NOTE: { label: '添加备注', placeholders: ['{patientId}'] },
OPEN_WECOM_CHAT: { label: '打开企微会话', placeholders: ['{patientId}', '{wecomExternalUserId}'] }, OPEN_WECOM_CHAT: { label: '打开企微会话', placeholders: ['{patientId}', '{wecomExternalUserId}'] },
......
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