Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
250995f5
Commit
250995f5
authored
Oct 15, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge host action link fixes from main into test
parents
a167b9ec
99bee3e2
Pipeline
#3818
failed in 0 seconds
Changes
9
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
245 additions
and
33 deletions
+245
-33
apps/pac-docs/content/docs/integration/overview.mdx
+5
-3
apps/pac-web/src/components/plan-detail/drawer.tsx
+19
-9
apps/pac-web/src/components/plan-detail/facts-timeline.tsx
+13
-8
apps/pac-web/src/components/plan-detail/host-image-link.test.tsx
+113
-0
apps/pac-web/src/components/plan-detail/host-image-link.tsx
+34
-0
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
+14
-11
apps/pac-web/src/lib/image-action-url.ts
+37
-0
apps/pac-web/vitest.config.ts
+8
-0
packages/types/src/enums/index.ts
+2
-2
No files found.
apps/pac-docs/content/docs/integration/overview.mdx
View file @
250995f5
...
@@ -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:
...
...
apps/pac-web/src/components/plan-detail/drawer.tsx
View file @
250995f5
...
@@ -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>
);
);
...
...
apps/pac-web/src/components/plan-detail/facts-timeline.tsx
View file @
250995f5
...
@@ -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"
>
...
...
apps/pac-web/src/components/plan-detail/host-image-link.test.tsx
0 → 100644
View file @
250995f5
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"'
);
});
});
apps/pac-web/src/components/plan-detail/host-image-link.tsx
0 → 100644
View file @
250995f5
'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
>
);
}
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
View file @
250995f5
...
@@ -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
?
(
...
...
apps/pac-web/src/lib/image-action-url.ts
0 → 100644
View file @
250995f5
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
;
}
apps/pac-web/vitest.config.ts
0 → 100644
View file @
250995f5
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
},
});
packages/types/src/enums/index.ts
View file @
250995f5
...
@@ -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}'
]
},
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment