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
ec5fb5e5
Commit
ec5fb5e5
authored
Aug 23, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: fix/ruitai-host-domain → main(瑞泰患者跳 rytime 门户)
parents
8f79c94e
eb5d8b9f
Pipeline
#3593
failed in 0 seconds
Changes
9
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
245 additions
and
15 deletions
+245
-15
apps/pac-service/src/modules/plan-aggregate/plan-aggregate.service.ts
+17
-0
apps/pac-web/src/components/plan-detail/adapt-data.ts
+1
-0
apps/pac-web/src/components/plan-detail/mock-data.ts
+4
-0
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
+20
-12
apps/pac-web/src/components/plan-detail/plan-detail-types.ts
+5
-0
apps/pac-web/src/lib/action-url.ts
+8
-1
apps/pac-web/src/lib/host-brand-url.test.ts
+108
-0
apps/pac-web/src/lib/host-brand-url.ts
+73
-0
apps/pac-web/src/lib/host-message.ts
+9
-2
No files found.
apps/pac-service/src/modules/plan-aggregate/plan-aggregate.service.ts
View file @
ec5fb5e5
...
@@ -78,6 +78,8 @@ export class PlanAggregateService {
...
@@ -78,6 +78,8 @@ export class PlanAggregateService {
select
:
{
select
:
{
id
:
true
,
id
:
true
,
externalId
:
true
,
externalId
:
true
,
// 关系人可能跨品牌(概率低但存在)—— 判宿主域用,见 web 的 host-brand-url
sourceUnit
:
true
,
medicalRecordNumber
:
true
,
medicalRecordNumber
:
true
,
name
:
true
,
name
:
true
,
phone
:
true
,
phone
:
true
,
...
@@ -152,6 +154,7 @@ export class PlanAggregateService {
...
@@ -152,6 +154,7 @@ export class PlanAggregateService {
select
:
{
select
:
{
id
:
true
;
id
:
true
;
externalId
:
true
;
externalId
:
true
;
sourceUnit
:
true
;
medicalRecordNumber
:
true
;
medicalRecordNumber
:
true
;
name
:
true
;
name
:
true
;
phone
:
true
;
phone
:
true
;
...
@@ -410,6 +413,7 @@ function extractDedicatedCs(prefs: unknown): { id: string; name: string } | null
...
@@ -410,6 +413,7 @@ function extractDedicatedCs(prefs: unknown): { id: string; name: string } | null
function
serializePatient
(
patient
:
{
function
serializePatient
(
patient
:
{
id
:
string
;
id
:
string
;
externalId
:
string
;
externalId
:
string
;
sourceUnit
:
string
|
null
;
medicalRecordNumber
:
string
|
null
;
medicalRecordNumber
:
string
|
null
;
name
:
string
|
null
;
name
:
string
|
null
;
gender
:
string
|
null
;
gender
:
string
|
null
;
...
@@ -423,6 +427,15 @@ function serializePatient(patient: {
...
@@ -423,6 +427,15 @@ function serializePatient(patient: {
return
{
return
{
id
:
patient
.
id
,
id
:
patient
.
id
,
externalId
:
patient
.
externalId
,
externalId
:
patient
.
externalId
,
/**
* 品牌名(`patients.source_unit`,如「瑞尔」/「瑞泰」)—— **前端按它判宿主域**。
*
* ⚠️ 与 `brandId` 分工:`brandId` 是品牌 GUID(经 host.orgAliases 反解),给跳转链接的
* `{brandId}` 占位用;要**判断**是哪个品牌得用这个名字 —— 拿 GUID 判等于在前端
* 写死一串 uuid,换个环境就不对了。
* ⚠️ 宿主没有品牌概念时为 null(单品牌宿主),前端据此不做任何替换。
*/
sourceUnit
:
patient
.
sourceUnit
,
/// 病历号(纸质档案 / 客服核对身份用的可读编号,如 "SH0Q011691");≠ externalId(host patient_id)
/// 病历号(纸质档案 / 客服核对身份用的可读编号,如 "SH0Q011691");≠ externalId(host patient_id)
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
name
:
patient
.
name
,
name
:
patient
.
name
,
...
@@ -456,6 +469,8 @@ function serializeProfile(
...
@@ -456,6 +469,8 @@ function serializeProfile(
relatedPatient
:
{
relatedPatient
:
{
id
:
string
;
id
:
string
;
externalId
:
string
;
externalId
:
string
;
/// 关系人自己的品牌 —— 判宿主域用(亲属可能跨品牌),见 web 的 host-brand-url
sourceUnit
:
string
|
null
;
medicalRecordNumber
:
string
|
null
;
medicalRecordNumber
:
string
|
null
;
name
:
string
|
null
;
name
:
string
|
null
;
phone
:
string
|
null
;
phone
:
string
|
null
;
...
@@ -532,6 +547,8 @@ function serializeProfile(
...
@@ -532,6 +547,8 @@ function serializeProfile(
// 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页
// 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页
externalId
:
r
.
relatedPatient
?.
externalId
??
r
.
relatedExternalId
,
externalId
:
r
.
relatedPatient
?.
externalId
??
r
.
relatedExternalId
,
medicalRecordNumber
:
r
.
relatedPatient
?.
medicalRecordNumber
??
null
,
medicalRecordNumber
:
r
.
relatedPatient
?.
medicalRecordNumber
??
null
,
/// 关系人自己的品牌 —— ⛔ 别拿本人的顶替:亲属挂在别的品牌时链接会跳错门户
sourceUnit
:
r
.
relatedPatient
?.
sourceUnit
??
null
,
planId
:
r
.
relatedPatientId
?
(
relatedPlanIdByPatient
.
get
(
r
.
relatedPatientId
)
??
null
)
:
null
,
planId
:
r
.
relatedPatientId
?
(
relatedPlanIdByPatient
.
get
(
r
.
relatedPatientId
)
??
null
)
:
null
,
};
};
})
})
...
...
apps/pac-web/src/components/plan-detail/adapt-data.ts
View file @
ec5fb5e5
...
@@ -40,6 +40,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
...
@@ -40,6 +40,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
externalId
:
real
.
patient
.
externalId
,
externalId
:
real
.
patient
.
externalId
,
medicalRecordNumber
:
real
.
patient
.
medicalRecordNumber
??
null
,
medicalRecordNumber
:
real
.
patient
.
medicalRecordNumber
??
null
,
brandId
:
real
.
patient
.
brandId
??
null
,
// 品牌 GUID(EMR 跳转 {brandId} 用)
brandId
:
real
.
patient
.
brandId
??
null
,
// 品牌 GUID(EMR 跳转 {brandId} 用)
sourceUnit
:
real
.
patient
.
sourceUnit
??
null
,
// 品牌名 —— 判宿主域用(见 lib/host-brand-url)
dedicatedCs
:
real
.
patient
.
dedicatedCs
??
null
,
dedicatedCs
:
real
.
patient
.
dedicatedCs
??
null
,
name
:
real
.
patient
.
name
??
'(未知)'
,
name
:
real
.
patient
.
name
??
'(未知)'
,
nameMasked
:
real
.
patient
.
nameMasked
??
'*'
,
nameMasked
:
real
.
patient
.
nameMasked
??
'*'
,
...
...
apps/pac-web/src/components/plan-detail/mock-data.ts
View file @
ec5fb5e5
...
@@ -33,6 +33,7 @@ export const mockPatient = {
...
@@ -33,6 +33,7 @@ export const mockPatient = {
externalId
:
'5i5ya_PA00284917'
,
externalId
:
'5i5ya_PA00284917'
,
medicalRecordNumber
:
'SH0Q011691'
as
string
|
null
,
medicalRecordNumber
:
'SH0Q011691'
as
string
|
null
,
brandId
:
null
as
string
|
null
,
// 品牌 GUID(EMR 跳转 {brandId} 用)
brandId
:
null
as
string
|
null
,
// 品牌 GUID(EMR 跳转 {brandId} 用)
sourceUnit
:
null
as
string
|
null
,
// 品牌名(判宿主域用,见 lib/host-brand-url)
dedicatedCs
:
{
id
:
'5499'
,
name
:
'姜莹'
}
as
{
id
:
string
;
name
:
string
}
|
null
,
dedicatedCs
:
{
id
:
'5499'
,
name
:
'姜莹'
}
as
{
id
:
string
;
name
:
string
}
|
null
,
name
:
'张志远'
,
name
:
'张志远'
,
nameMasked
:
'张志*'
,
nameMasked
:
'张志*'
,
...
@@ -67,6 +68,7 @@ export const mockPatient = {
...
@@ -67,6 +68,7 @@ export const mockPatient = {
planId
:
null
,
planId
:
null
,
externalId
:
'5i5ya_PA00999001'
,
externalId
:
'5i5ya_PA00999001'
,
medicalRecordNumber
:
'SH0Q099001'
,
medicalRecordNumber
:
'SH0Q099001'
,
sourceUnit
:
null
,
relationshipRaw
:
'mother'
,
relationshipRaw
:
'mother'
,
ageCorrected
:
false
,
ageCorrected
:
false
,
},
},
...
@@ -85,6 +87,8 @@ export const mockPatient = {
...
@@ -85,6 +87,8 @@ export const mockPatient = {
/** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */
/** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */
externalId
:
string
;
externalId
:
string
;
medicalRecordNumber
:
string
|
null
;
medicalRecordNumber
:
string
|
null
;
/** 关系人**自己**的品牌名 —— 判宿主域用(见 lib/host-brand-url) */
sourceUnit
:
string
|
null
;
/** 源数据原样关系码 + 是否被年龄纠正过 */
/** 源数据原样关系码 + 是否被年龄纠正过 */
relationshipRaw
:
string
;
relationshipRaw
:
string
;
ageCorrected
:
boolean
;
ageCorrected
:
boolean
;
...
...
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
View file @
ec5fb5e5
...
@@ -486,7 +486,12 @@ export function PlanDetailApp({
...
@@ -486,7 +486,12 @@ export function PlanDetailApp({
const
openPotential
=
hostActionMode
(
'OPEN_POTENTIAL_TREATMENT'
)
const
openPotential
=
hostActionMode
(
'OPEN_POTENTIAL_TREATMENT'
)
?
()
=>
{
?
()
=>
{
if
(
!
gateCheck
())
return
;
if
(
!
gateCheck
())
return
;
openHostAction
(
'OPEN_POTENTIAL_TREATMENT'
,
hostActionCtx
,
potentialTreatmentPayload
());
openHostAction
(
'OPEN_POTENTIAL_TREATMENT'
,
hostActionCtx
,
potentialTreatmentPayload
(),
patient
.
sourceUnit
,
);
}
}
:
undefined
;
:
undefined
;
/**
/**
...
@@ -506,7 +511,7 @@ export function PlanDetailApp({
...
@@ -506,7 +511,7 @@ export function PlanDetailApp({
abandonReasons
:
[],
abandonReasons
:
[],
});
});
if
(
!
ok
)
return
false
;
if
(
!
ok
)
return
false
;
openHostAction
(
'OPEN_RETURN_VISIT'
,
hostActionCtx
);
openHostAction
(
'OPEN_RETURN_VISIT'
,
hostActionCtx
,
undefined
,
patient
.
sourceUnit
);
return
true
;
return
true
;
}
}
:
undefined
;
:
undefined
;
...
@@ -573,7 +578,7 @@ export function PlanDetailApp({
...
@@ -573,7 +578,7 @@ export function PlanDetailApp({
brandId
:
patient
.
brandId
,
brandId
:
patient
.
brandId
,
clinicId
:
plan
?.
targetClinicId
,
clinicId
:
plan
?.
targetClinicId
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
});
}
,
patient
.
sourceUnit
);
if
(
!
url
)
{
if
(
!
url
)
{
showToast
(
'amber'
,
'未配置新建预约'
,
'请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT'
);
showToast
(
'amber'
,
'未配置新建预约'
,
'请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT'
);
return
false
;
return
false
;
...
@@ -728,7 +733,7 @@ export function PlanDetailApp({
...
@@ -728,7 +733,7 @@ export function PlanDetailApp({
clinicId
:
plan
?.
targetClinicId
,
clinicId
:
plan
?.
targetClinicId
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
patientId
:
patient
.
externalId
,
patientId
:
patient
.
externalId
,
})
}
}
,
patient
.
sourceUnit
)
}
/>
/>
{
/* 召回建议 — 暂时隐藏(SuggestionCard) */
}
{
/* 召回建议 — 暂时隐藏(SuggestionCard) */
}
{
recallHistory
.
length
>
0
&&
(
{
recallHistory
.
length
>
0
&&
(
...
@@ -1654,10 +1659,11 @@ function IdentityCard({
...
@@ -1654,10 +1659,11 @@ function IdentityCard({
};
};
// 原始档案跳转 URL(宿主 VIEW_PATIENT 配了才有)。有原始档案入口时隐藏手机号行:
// 原始档案跳转 URL(宿主 VIEW_PATIENT 配了才有)。有原始档案入口时隐藏手机号行:
// PAC 侧号码是宿主同步的造数假号,真号在宿主档案页,避免客服误拨假号。
// PAC 侧号码是宿主同步的造数假号,真号在宿主档案页,避免客服误拨假号。
const
originalArchiveUrl
=
resolveActionUrl
(
'VIEW_PATIENT'
,
{
const
originalArchiveUrl
=
resolveActionUrl
(
patientId
:
patient
.
externalId
,
'VIEW_PATIENT'
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
{
patientId
:
patient
.
externalId
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
},
});
patient
.
sourceUnit
,
);
return
(
return
(
<
section
className=
"bg-white rounded-lg border shadow-sm flex-none"
>
<
section
className=
"bg-white rounded-lg border shadow-sm flex-none"
>
<
div
className=
"p-3 flex items-start gap-2.5"
>
<
div
className=
"p-3 flex items-start gap-2.5"
>
...
@@ -1919,10 +1925,12 @@ function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.cont
...
@@ -1919,10 +1925,12 @@ function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.cont
<
span
className=
"ml-auto flex-none"
>
<
span
className=
"ml-auto flex-none"
>
{
(()
=>
{
{
(()
=>
{
// 宿主档案优先,PAC 工单页兜底(见组件头注释)
// 宿主档案优先,PAC 工单页兜底(见组件头注释)
const
hostUrl
=
resolveActionUrl
(
'VIEW_PATIENT'
,
{
const
hostUrl
=
resolveActionUrl
(
patientId
:
c
.
externalId
,
'VIEW_PATIENT'
,
medicalRecordNumber
:
c
.
medicalRecordNumber
,
{
patientId
:
c
.
externalId
,
medicalRecordNumber
:
c
.
medicalRecordNumber
},
});
// ⛔ 用关系人自己的品牌,不是本人的:亲属挂在别的品牌时会跳错门户
c
.
sourceUnit
,
);
const
url
=
hostUrl
??
(
c
.
planId
?
`/plans/${c.planId}`
:
null
);
const
url
=
hostUrl
??
(
c
.
planId
?
`/plans/${c.planId}`
:
null
);
if
(
!
url
)
{
if
(
!
url
)
{
return
(
return
(
...
...
apps/pac-web/src/components/plan-detail/plan-detail-types.ts
View file @
ec5fb5e5
...
@@ -14,6 +14,9 @@ export type PlanDetailData = {
...
@@ -14,6 +14,9 @@ export type PlanDetailData = {
medicalRecordNumber
:
string
|
null
;
medicalRecordNumber
:
string
|
null
;
/// 品牌 GUID(source_unit 经 host.orgAliases 反解)—— EMR 跳转链接 {brandId} 用;无别名则 null
/// 品牌 GUID(source_unit 经 host.orgAliases 反解)—— EMR 跳转链接 {brandId} 用;无别名则 null
brandId
:
string
|
null
;
brandId
:
string
|
null
;
/// 品牌**名**(「瑞尔」/「瑞泰」)—— 判宿主域用(见 lib/host-brand-url)。⛔ 别拿 brandId 判:
/// 那是 uuid,在前端写死一串 uuid 换个环境就不对了。单品牌宿主为 null。
sourceUnit
:
string
|
null
;
dedicatedCs
:
{
id
:
string
;
name
:
string
}
|
null
;
dedicatedCs
:
{
id
:
string
;
name
:
string
}
|
null
;
name
:
string
|
null
;
name
:
string
|
null
;
nameMasked
:
string
|
null
;
nameMasked
:
string
|
null
;
...
@@ -54,6 +57,8 @@ export type PlanDetailData = {
...
@@ -54,6 +57,8 @@ export type PlanDetailData = {
/// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案)
/// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案)
externalId
:
string
;
externalId
:
string
;
medicalRecordNumber
:
string
|
null
;
medicalRecordNumber
:
string
|
null
;
/// 关系人**自己**的品牌名 —— 判宿主域用。⛔ 别拿本人的顶替(亲属可能跨品牌)
sourceUnit
:
string
|
null
;
/// 源数据原样的关系码;与 relationship 不同 = 被年龄纠正过
/// 源数据原样的关系码;与 relationship 不同 = 被年龄纠正过
relationshipRaw
:
string
;
relationshipRaw
:
string
;
/// 关系方向被年龄纠正过 —— UI 给一句 hover 说明,让客服知道这不是源数据原文
/// 关系方向被年龄纠正过 —— UI 给一句 hover 说明,让客服知道这不是源数据原文
...
...
apps/pac-web/src/lib/action-url.ts
View file @
ec5fb5e5
'use client'
;
'use client'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
applyBrandHost
}
from
'@/lib/host-brand-url'
;
import
type
{
HostActionKey
}
from
'@pac/types'
;
import
type
{
HostActionKey
}
from
'@pac/types'
;
/**
/**
...
@@ -33,9 +34,15 @@ export function actionTemplate(key: HostActionKey): string | undefined {
...
@@ -33,9 +34,15 @@ export function actionTemplate(key: HostActionKey): string | undefined {
export
function
resolveActionUrl
(
export
function
resolveActionUrl
(
key
:
HostActionKey
,
key
:
HostActionKey
,
ctx
:
Record
<
string
,
string
|
null
|
undefined
>
,
ctx
:
Record
<
string
,
string
|
null
|
undefined
>
,
/**
* 患者所属品牌(`patient.sourceUnit`)—— 用于把宿主域换成该品牌那套(见 host-brand-url)。
* ⚠️ 不传 = 不换。⛔ 别把它塞进 `ctx`:ctx 是**占位符**字典,模板里并没有 {sourceUnit}
* 这个占位,混在一起会让人以为可以在模板里写它。
*/
sourceUnit
?:
string
|
null
,
):
string
|
undefined
{
):
string
|
undefined
{
const
tpl
=
actionTemplate
(
key
);
const
tpl
=
actionTemplate
(
key
);
return
tpl
?
fillActionUrl
(
tpl
,
ctx
)
:
undefined
;
return
tpl
?
applyBrandHost
(
fillActionUrl
(
tpl
,
ctx
),
sourceUnit
)
:
undefined
;
}
}
/**
/**
...
...
apps/pac-web/src/lib/host-brand-url.test.ts
0 → 100644
View file @
ec5fb5e5
import
{
describe
,
expect
,
test
}
from
'vitest'
;
import
{
applyBrandHost
}
from
'./host-brand-url'
;
/**
* 品牌 → 宿主域替换。判据是**患者**的 `sourceUnit`(不是登录人 —— 见 host-brand-url 头注:
* 诊所级用户的 sourceUnits 是哨兵 `__pac_deny_no_brand__`,判不出品牌)。
*/
describe
(
'瑞泰患者:arrail → rytime'
,
()
=>
{
test
(
'⭐⭐ 普通跳转链接换域名'
,
()
=>
{
expect
(
applyBrandHost
(
'https://arrail.5i5ya.com/customerInfo?userId=123&fileNumber=SH01'
,
'瑞泰'
),
).
toBe
(
'https://rytime.5i5ya.com/customerInfo?userId=123&fileNumber=SH01'
);
});
test
(
'⭐⭐ HOST_ORIGIN(postMessage 的 targetOrigin)一样要换'
,
()
=>
{
// 🔴 不换的话浏览器**静默丢弃**这条消息:按钮点了没反应,控制台连个错都没有
expect
(
applyBrandHost
(
'https://arrail.5i5ya.com'
,
'瑞泰'
)).
toBe
(
'https://rytime.5i5ya.com/'
);
});
test
(
'瑞尔患者原样不动'
,
()
=>
{
const
u
=
'https://arrail.5i5ya.com/followup/create/1?organizationId=c1'
;
expect
(
applyBrandHost
(
u
,
'瑞尔'
)).
toBe
(
u
);
});
test
(
'已经是 rytime 的再换一次也不变(幂等)'
,
()
=>
{
const
u
=
'https://rytime.5i5ya.com/customerInfo?userId=1'
;
expect
(
applyBrandHost
(
u
,
'瑞泰'
)).
toBe
(
u
);
});
});
describe
(
'⛔ 不许越界'
,
()
=>
{
test
(
'⭐⭐ 只换域名,查询串里的同名参数不动'
,
()
=>
{
expect
(
applyBrandHost
(
'https://arrail.5i5ya.com/x?from=arrail'
,
'瑞泰'
)).
toBe
(
'https://rytime.5i5ya.com/x?from=arrail'
,
);
});
test
(
'⭐ 路径里的同名片段不动'
,
()
=>
{
expect
(
applyBrandHost
(
'https://arrail.5i5ya.com/arrail/page'
,
'瑞泰'
)).
toBe
(
'https://rytime.5i5ya.com/arrail/page'
,
);
});
test
(
'哨兵值 postMessage 不是 URL —— 原样返回,⛔ 别把它弄坏'
,
()
=>
{
expect
(
applyBrandHost
(
'postMessage'
,
'瑞泰'
)).
toBe
(
'postMessage'
);
});
test
(
'相对路径没有域名可换'
,
()
=>
{
expect
(
applyBrandHost
(
'/emr/full?tenantId=x'
,
'瑞泰'
)).
toBe
(
'/emr/full?tenantId=x'
);
});
});
describe
(
'拿不准就别动 —— ⛔ 这条补丁不许把能用的链接变没'
,
()
=>
{
test
(
'品牌为空(单品牌宿主)'
,
()
=>
{
const
u
=
'https://arrail.5i5ya.com/x'
;
expect
(
applyBrandHost
(
u
,
null
)).
toBe
(
u
);
expect
(
applyBrandHost
(
u
,
undefined
)).
toBe
(
u
);
});
test
(
'不认得的品牌'
,
()
=>
{
const
u
=
'https://arrail.5i5ya.com/x'
;
expect
(
applyBrandHost
(
u
,
'某新品牌'
)).
toBe
(
u
);
});
test
(
'域名里没有任何已知品牌片段'
,
()
=>
{
const
u
=
'https://pac.friday.tech/x'
;
expect
(
applyBrandHost
(
u
,
'瑞泰'
)).
toBe
(
u
);
});
test
(
'url 本身为空'
,
()
=>
{
expect
(
applyBrandHost
(
undefined
,
'瑞泰'
)).
toBeUndefined
();
expect
(
applyBrandHost
(
''
,
'瑞泰'
)).
toBe
(
''
);
});
});
/**
* 🔴 **每个宿主跳转的调用点都必须带上品牌参数** —— 漏一个不会报错(参数可选),
* 只会让瑞泰患者在那个按钮上被带去瑞尔门户,查不到人。tsc 拦不住,只能在这里锁。
* ⚠️ 判据是**源码**:这条测不了运行时,它防的是"新加一个调用点时忘了传"。
*/
describe
(
'调用点不许漏传品牌'
,
()
=>
{
const
readSrc
=
(
p
:
string
)
=>
// eslint-disable-next-line @typescript-eslint/no-var-requires
(
require
(
'node:fs'
)
as
typeof
import
(
'node:fs'
)).
readFileSync
(
(
require
(
'node:path'
)
as
typeof
import
(
'node:path'
)).
join
(
__dirname
,
p
),
'utf8'
,
);
test
(
'⭐⭐ plan-detail-app 里每个 resolveActionUrl / openHostAction 都带 sourceUnit'
,
()
=>
{
const
src
=
readSrc
(
'../components/plan-detail/plan-detail-app.tsx'
);
// 每个调用的右括号之前必须出现 sourceUnit
const
calls
=
src
.
split
(
/resolveActionUrl
\(
|openHostAction
\(
/
).
slice
(
1
);
expect
(
calls
.
length
).
toBeGreaterThanOrEqual
(
6
);
// 现有 4 + 2;新增的也会被本条覆盖
for
(
const
[
i
,
c
]
of
calls
.
entries
())
{
// 取到该调用的结束(下一个 `);` 或 `)}`),比对是否带了品牌
const
end
=
Math
.
min
(...[
c
.
indexOf
(
');'
),
c
.
indexOf
(
')}'
)].
filter
((
x
)
=>
x
>=
0
));
expect
(
c
.
slice
(
0
,
end
)).
toMatch
(
/sourceUnit/
);
expect
(
i
).
toBeGreaterThanOrEqual
(
0
);
}
});
test
(
'⭐ 两条入口本身接得住品牌参数'
,
()
=>
{
expect
(
readSrc
(
'./action-url.ts'
)).
toMatch
(
/sourceUnit
\?
: string
\|
null/
);
expect
(
readSrc
(
'./host-message.ts'
)).
toMatch
(
/sourceUnit
\?
: string
\|
null/
);
// postMessage 的 targetOrigin 也走了替换
expect
(
readSrc
(
'./host-message.ts'
)).
toContain
(
'applyBrandHost(hostOrigin(), sourceUnit)'
);
});
});
apps/pac-web/src/lib/host-brand-url.ts
0 → 100644
View file @
ec5fb5e5
'use client'
;
/**
* 品牌 → 宿主域替换(**特殊处理**,不是通用机制)。
*
* ── 为什么需要 ────────────────────────────────────────────────
* jvs-dw 这个宿主底下有两个品牌,各有一套自己的门户:
* 瑞尔 → arrail.5i5ya.com
* 瑞泰 → rytime.5i5ya.com
* 两边**只差域名这一段**,路径、参数、占位符完全一样。而 `host.actionUrls` 是
* **host 级**配置、只有一套 —— 现在配的是瑞尔那套。于是瑞泰的患者点「查看档案 / 病历 /
* 新建预约 / 回访」,会被带到瑞尔的门户,查不到这个人。
*
* ⇒ 打开的那一刻按**患者所属品牌**把域名换掉。
*
* ── 为什么按患者判、不按登录人判 ──────────────────────────────
* 登录人的品牌在前端**拿不到**:`/auth/session` 下发的 `sourceUnits` 对诊所级用户是
* 哨兵 `__pac_deny_no_brand__`(那条路按 clinicIds 过滤,不走品牌维,2026-08-23 实测
* 三家诊所的 token 全是这个值)。而患者的 `sourceUnit` 是实打实的品牌名。
* 语义上也是患者对:这些链接要打开的是**那个患者**在宿主里的页面。
*
* ── 纪律 ──────────────────────────────────────────────────────
* ⛔ **别把它做成通用的"多品牌路由"**。它是一条写死的补丁,成立的前提是"两个门户只差
* 一个域名片段"。真要支持多品牌,正解是 actionUrls 按品牌分组配置(host 管理页加一层),
* 那时删掉这个文件。
* ⚠️ 只在**域名**里换,⛔ 不碰路径和查询串:路径里出现同名片段(如 `?from=arrail`)是宿主
* 自己的业务参数,替换它属于越界。URL 解不出来(相对路径/哨兵值)则原样返回。
* ⚠️ `HOST_ORIGIN` 也要走这条替换 —— 它是 postMessage 的 `targetOrigin`。PAC 嵌在
* rytime 里而 targetOrigin 写着 arrail,浏览器会**静默丢弃**这条消息:按钮点了没反应,
* 控制台连个错都没有。
*/
/** 品牌名 → 该品牌宿主域里的那一段。⚠️ key 是 `patients.source_unit` 的原值。 */
const
BRAND_HOST_SEGMENT
:
Record
<
string
,
string
>
=
{
瑞尔
:
'arrail'
,
瑞泰
:
'rytime'
,
};
/** 本替换认得的所有品牌片段(用于识别"这个域名是别的品牌的")。 */
const
KNOWN_SEGMENTS
=
Object
.
values
(
BRAND_HOST_SEGMENT
);
/**
* 按患者品牌改写宿主 URL / origin。
*
* @param url 已填好占位符的宿主地址,或 `HOST_ORIGIN` 那样的裸 origin
* @param sourceUnit 患者所属品牌名(`patient.sourceUnit`);null / 未知品牌 → 原样返回
*
* 返回值恒非空(拿不准就返回原值)—— ⛔ 这条补丁不许把一个本来能用的链接变没。
*/
export
function
applyBrandHost
(
url
:
string
|
undefined
,
sourceUnit
:
string
|
null
|
undefined
,
):
string
|
undefined
{
if
(
!
url
)
return
url
;
const
want
=
sourceUnit
?
BRAND_HOST_SEGMENT
[
sourceUnit
]
:
undefined
;
if
(
!
want
)
return
url
;
// 没有品牌 / 不认得的品牌 → 不动
let
u
:
URL
;
try
{
u
=
new
URL
(
url
);
}
catch
{
return
url
;
// 相对路径、哨兵值('postMessage')等 —— 没有域名可换
}
// 域名里已经是目标品牌 → 不动(幂等)
const
parts
=
u
.
hostname
.
split
(
'.'
);
const
idx
=
parts
.
findIndex
((
p
)
=>
KNOWN_SEGMENTS
.
includes
(
p
));
if
(
idx
<
0
||
parts
[
idx
]
===
want
)
return
url
;
parts
[
idx
]
=
want
;
u
.
hostname
=
parts
.
join
(
'.'
);
return
u
.
toString
();
}
apps/pac-web/src/lib/host-message.ts
View file @
ec5fb5e5
...
@@ -3,6 +3,7 @@
...
@@ -3,6 +3,7 @@
import
{
toast
}
from
'sonner'
;
import
{
toast
}
from
'sonner'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
fillActionUrl
,
openHostUrl
}
from
'@/lib/action-url'
;
import
{
fillActionUrl
,
openHostUrl
}
from
'@/lib/action-url'
;
import
{
applyBrandHost
}
from
'@/lib/host-brand-url'
;
import
{
import
{
HOST_ACTION_MESSAGE_SOURCE
,
HOST_ACTION_MESSAGE_SOURCE
,
HOST_ACTION_MESSAGE_TYPE
,
HOST_ACTION_MESSAGE_TYPE
,
...
@@ -68,6 +69,12 @@ export function openHostAction(
...
@@ -68,6 +69,12 @@ export function openHostAction(
* 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。
* 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。
*/
*/
extraPayload
?:
HostPotentialTreatmentPayload
,
extraPayload
?:
HostPotentialTreatmentPayload
,
/**
* 患者所属品牌 —— URL 模式换域名;postMessage 模式换 `targetOrigin`。
* 🔴 targetOrigin 必须一起换:PAC 嵌在 rytime 里而 targetOrigin 写着 arrail,
* 浏览器**静默丢弃**这条消息 —— 按钮点了没反应,控制台连个错都没有。
*/
sourceUnit
?:
string
|
null
,
):
boolean
{
):
boolean
{
const
raw
=
rawValue
(
key
);
const
raw
=
rawValue
(
key
);
const
mode
=
hostActionMode
(
key
);
const
mode
=
hostActionMode
(
key
);
...
@@ -76,7 +83,7 @@ export function openHostAction(
...
@@ -76,7 +83,7 @@ export function openHostAction(
return
false
;
return
false
;
}
}
if
(
mode
===
'url'
)
{
if
(
mode
===
'url'
)
{
openHostUrl
(
fillActionUrl
(
raw
,
ctx
)
);
openHostUrl
(
applyBrandHost
(
fillActionUrl
(
raw
,
ctx
),
sourceUnit
)
!
);
return
true
;
return
true
;
}
}
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
...
@@ -86,6 +93,6 @@ export function openHostAction(
...
@@ -86,6 +93,6 @@ export function openHostAction(
action
:
key
,
action
:
key
,
payload
:
{
patientId
:
ctx
.
patientId
??
''
,
...(
extraPayload
??
{})
},
payload
:
{
patientId
:
ctx
.
patientId
??
''
,
...(
extraPayload
??
{})
},
};
};
window
.
parent
.
postMessage
(
message
,
hostOrigin
(
)
!
);
window
.
parent
.
postMessage
(
message
,
applyBrandHost
(
hostOrigin
(),
sourceUnit
)
!
);
return
true
;
return
true
;
}
}
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