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
9d063987
Commit
9d063987
authored
Jul 21, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/return-visit-entry → main(宿主回访模式 + 关闭机会闭环)
# Conflicts: # apps/pac-service/tests/recall-suppression.spec.ts
parents
dbc1223d
a96424b5
Pipeline
#3415
failed in 0 seconds
Changes
13
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
518 additions
and
40 deletions
+518
-40
apps/pac-service/src/modules/plan/execution.service.ts
+3
-0
apps/pac-service/src/modules/plan/recall-suppression.ts
+17
-4
apps/pac-service/tests/recall-suppression.spec.ts
+78
-0
apps/pac-web/src/app/globals.css
+25
-0
apps/pac-web/src/components/host-admin/host-admin-app.tsx
+7
-0
apps/pac-web/src/components/plan-detail/close-opportunity-dialog.tsx
+142
-0
apps/pac-web/src/components/plan-detail/execution-api.ts
+2
-0
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
+115
-21
apps/pac-web/src/components/plans/patient-picker-rail.tsx
+8
-1
apps/pac-web/src/components/plans/use-patient-picker.ts
+4
-0
apps/pac-web/src/lib/host-message.ts
+55
-12
packages/types/src/enums/index.ts
+58
-2
packages/types/src/schemas/plan.ts
+4
-0
No files found.
apps/pac-service/src/modules/plan/execution.service.ts
View file @
9d063987
...
...
@@ -55,6 +55,8 @@ export interface SubmitExecutionInput {
abandonOther
?:
string
;
scheduledNextAt
?:
string
;
invalidReason
?:
string
;
/** 关闭机会来源(宿主回访模式)—— 据 CLOSE_REASON_META 覆写抑制窗 */
closeReason
?:
string
;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId
?:
string
;
}
...
...
@@ -145,6 +147,7 @@ export class ExecutionService {
breakerTripped
,
scheduledNextAt
:
input
.
scheduledNextAt
,
now
,
closeReason
:
input
.
closeReason
,
});
// ─── 4. 事务:写 execution + 更新 plan ───
...
...
apps/pac-service/src/modules/plan/recall-suppression.ts
View file @
9d063987
import
{
CLOSE_REASON_META
,
CloseReason
,
EXECUTION_OUTCOME_META
,
ExecutionOutcome
,
BREAKER_SUPPRESS_DAYS
,
...
...
@@ -12,10 +14,13 @@ const DAY_MS = 86_400_000;
* 优先级(高 → 低):
* 1. 熔断(breakerTripped):连续未接通累计达上限强制 abandoned → 固定短冷静期(30d),
* 不是"拒绝",换天再试 / 后续可接换渠道。优先于 outcome 自身策略。
* 2. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays):
* 2. 关闭机会覆写(closeReason → CLOSE_REASON_META.suppressDaysOverride):
* 弱语义关闭(识别不准/已治/其他)统一 14d 短档 —— 等 DW 同步 + 排除闸接手,
* 不套 outcome 的重抑制(如 marked_invalid 自带永久,识别不准是 PAC 的错,不罚患者)。
* 3. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays):
* 明确拒绝/近期不考虑=90d、成功转化/客服放弃=60d、外院/无效=永久。
*
3
. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。
*
4
. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。
*
4
. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。
*
5
. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。
*
* @returns snoozedUntil Date,或 null(不抑制)
*/
...
...
@@ -24,13 +29,21 @@ export function resolveSnoozedUntil(params: {
breakerTripped
:
boolean
;
scheduledNextAt
?:
string
|
null
;
now
:
Date
;
closeReason
?:
string
|
null
;
}):
Date
|
null
{
const
{
outcome
,
breakerTripped
,
scheduledNextAt
,
now
}
=
params
;
const
{
outcome
,
breakerTripped
,
scheduledNextAt
,
now
,
closeReason
}
=
params
;
if
(
breakerTripped
)
{
return
new
Date
(
now
.
getTime
()
+
BREAKER_SUPPRESS_DAYS
*
DAY_MS
);
}
const
closeOverride
=
closeReason
?
CLOSE_REASON_META
[
closeReason
as
CloseReason
]?.
suppressDaysOverride
:
undefined
;
if
(
closeOverride
!=
null
)
{
return
new
Date
(
now
.
getTime
()
+
closeOverride
*
DAY_MS
);
}
const
meta
=
EXECUTION_OUTCOME_META
[
outcome
as
ExecutionOutcome
];
if
(
meta
?.
suppressDays
!=
null
)
{
return
new
Date
(
now
.
getTime
()
+
meta
.
suppressDays
*
DAY_MS
);
...
...
apps/pac-service/tests/recall-suppression.spec.ts
View file @
9d063987
...
...
@@ -2,6 +2,8 @@ import {
EXECUTION_OUTCOME_META
,
SUPPRESS_PERMANENT_DAYS
,
BREAKER_SUPPRESS_DAYS
,
CLOSE_REASON_META
,
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
,
}
from
'@pac/types'
;
import
{
resolveSnoozedUntil
}
from
'../src/modules/plan/recall-suppression'
;
import
{
...
...
@@ -194,3 +196,79 @@ describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () =
}
});
});
describe
(
'关闭机会 closeReason 覆写(CLOSE_REASON_META.suppressDaysOverride)'
,
()
=>
{
const
NOW
=
new
Date
(
'2026-06-01T00:00:00Z'
);
const
daysFromNow
=
(
d
:
Date
|
null
)
=>
d
==
null
?
null
:
Math
.
round
((
d
.
getTime
()
-
NOW
.
getTime
())
/
86
_400_000
);
test
(
'识别不准确 → marked_invalid 自带永久被覆写成 14d 默认档'
,
()
=>
{
const
meta
=
CLOSE_REASON_META
.
inaccurate
;
const
r
=
resolveSnoozedUntil
({
outcome
:
meta
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'inaccurate'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
);
});
test
(
'已完成治疗 / 其他原因 → abandoned 自带 60d 被覆写成 14d'
,
()
=>
{
for
(
const
reason
of
[
'treated'
,
'other'
]
as
const
)
{
const
r
=
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
[
reason
].
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
reason
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
);
}
});
test
(
'无法联系客户 → 覆写成熔断口径 30d'
,
()
=>
{
const
r
=
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
.
unreachable
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'unreachable'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
30
);
});
test
(
'强语义原因无覆写 → 沿用 outcome 自带窗(拒绝 90d / 竞品永久)'
,
()
=>
{
expect
(
daysFromNow
(
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
.
no_intent
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'no_intent'
,
})),
).
toBe
(
90
);
expect
(
daysFromNow
(
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
.
competitor
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'competitor'
,
})),
).
toBe
(
SUPPRESS_PERMANENT_DAYS
);
});
test
(
'未知 / 空 closeReason → 不影响原有路径'
,
()
=>
{
const
r
=
resolveSnoozedUntil
({
outcome
:
'refused'
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'bogus'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
90
);
const
r2
=
resolveSnoozedUntil
({
outcome
:
'refused'
,
breakerTripped
:
false
,
now
:
NOW
});
expect
(
daysFromNow
(
r2
)).
toBe
(
90
);
});
test
(
'熔断优先级仍高于 closeReason 覆写'
,
()
=>
{
const
r
=
resolveSnoozedUntil
({
outcome
:
'abandoned'
,
breakerTripped
:
true
,
now
:
NOW
,
closeReason
:
'treated'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
BREAKER_SUPPRESS_DAYS
);
});
test
(
'每个 closeReason 的 outcome 都是合法 EXECUTION_OUTCOME_META 键'
,
()
=>
{
for
(
const
meta
of
Object
.
values
(
CLOSE_REASON_META
))
{
expect
(
EXECUTION_OUTCOME_META
[
meta
.
outcome
]).
toBeDefined
();
}
});
});
apps/pac-web/src/app/globals.css
View file @
9d063987
...
...
@@ -180,3 +180,28 @@ body {
to
{
opacity
:
1
;
transform
:
translateY
(
0
)
scale
(
1
);
}
}
.animate-lyricIn
{
animation
:
lyricIn
0.32s
cubic-bezier
(
0.22
,
1
,
0.36
,
1
);
}
/* ── 统一滚动条 —— Windows 默认滚动条过粗突兀;细轨 + slate 拇指,贴 PAC 中性色。
现代 Chrome/Edge/Firefox 走标准属性(scrollbar-width/color);
Safari 及旧 Chromium 走 ::-webkit-scrollbar(标准属性生效时自动忽略 webkit 版)。 */
*
{
scrollbar-width
:
thin
;
scrollbar-color
:
rgb
(
203
213
225
)
transparent
;
/* slate-300 / 轨道透明 */
}
::-webkit-scrollbar
{
width
:
8px
;
height
:
8px
;
}
::-webkit-scrollbar-track
{
background
:
transparent
;
}
::-webkit-scrollbar-thumb
{
background
:
rgb
(
203
213
225
);
border-radius
:
4px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
rgb
(
148
163
184
);
/* slate-400 */
}
::-webkit-scrollbar-corner
{
background
:
transparent
;
}
apps/pac-web/src/components/host-admin/host-admin-app.tsx
View file @
9d063987
...
...
@@ -20,6 +20,7 @@ import {
SelectTrigger
,
SelectValue
,
}
from
'@/components/ui/select'
;
import
{
isPostMessageSentinel
}
from
'@/lib/host-message'
;
import
{
hostApi
}
from
'./host-api'
;
import
{
useConfirm
}
from
'./use-confirm'
;
import
{
SecretRevealDialog
}
from
'./secret-reveal-dialog'
;
...
...
@@ -630,6 +631,12 @@ function ActionUrlsCard({
))
}
</
div
>
)
}
{
/* 哨兵 postMessage 依赖 HOST_ORIGIN 作 targetOrigin —— 缺配时动作按钮不会渲染 */
}
{
isPostMessageSentinel
(
val
)
&&
!
(
draft
.
HOST_ORIGIN
??
''
).
trim
()
&&
(
<
div
className=
"text-[10.5px] text-amber-600"
>
配了 postMessage 但未配 HOST_ORIGIN,该动作按钮不会显示
</
div
>
)
}
</
div
>
);
})
}
...
...
apps/pac-web/src/components/plan-detail/close-opportunity-dialog.tsx
0 → 100644
View file @
9d063987
'use client'
;
import
{
useState
}
from
'react'
;
import
{
CLOSE_REASON_META
,
CloseReason
}
from
'@pac/types'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Dialog
,
DialogContent
,
DialogHeader
,
DialogTitle
,
DialogFooter
,
}
from
'@/components/ui/dialog'
;
/**
* 关闭机会弹窗 —— 宿主回访模式下(顶栏有「回访」按钮)替代通话结果区的关闭入口。
* 原因清单 / 说明必填 / outcome 映射 全部来自 CLOSE_REASON_META(@pac/types 单一真理源);
* 提交行为由调用方 onConfirm 承接(execution 通道 + 需要时双写召回反馈)。
*/
const
CLOSE_REASONS
=
(
Object
.
keys
(
CLOSE_REASON_META
)
as
CloseReason
[]).
map
((
key
)
=>
({
key
,
...
CLOSE_REASON_META
[
key
],
}));
export
type
CloseReasonKey
=
CloseReason
;
export
function
CloseOpportunityDialog
({
open
,
onOpenChange
,
onConfirm
,
}:
{
open
:
boolean
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
/** 确认关闭(reason + 其他原因时的说明)。未接后端前由调用方决定提示行为。 */
onConfirm
?:
(
reason
:
CloseReasonKey
,
note
?:
string
)
=>
void
;
})
{
const
[
reason
,
setReason
]
=
useState
<
CloseReasonKey
|
null
>
(
null
);
const
[
note
,
setNote
]
=
useState
(
''
);
const
selected
=
CLOSE_REASONS
.
find
((
r
)
=>
r
.
key
===
reason
);
const
needNote
=
selected
?.
needNote
===
true
;
const
canConfirm
=
!!
reason
&&
(
!
needNote
||
note
.
trim
().
length
>
0
);
const
reset
=
()
=>
{
setReason
(
null
);
setNote
(
''
);
};
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
(
v
)
=>
{
onOpenChange
(
v
);
if
(
!
v
)
reset
();
}
}
>
<
DialogContent
className=
"sm:max-w-[520px]"
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[15px]"
>
关闭机会
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"space-y-3"
>
<
div
className=
"text-[12.5px] font-medium text-slate-900"
>
选择关闭原因
<
span
className=
"text-rose-600"
>
*
</
span
>
</
div
>
{
/* 原因单选卡:两列;「其他原因」独占整行 */
}
<
div
className=
"grid grid-cols-2 gap-2"
>
{
CLOSE_REASONS
.
map
((
r
)
=>
{
const
active
=
reason
===
r
.
key
;
return
(
<
button
key=
{
r
.
key
}
type=
"button"
onClick=
{
()
=>
setReason
(
r
.
key
)
}
className=
{
cn
(
'flex items-center gap-2 rounded-md border px-3 py-2.5 text-left text-[12.5px] transition-colors'
,
r
.
key
===
'other'
&&
'col-span-2'
,
active
?
'border-teal-400 bg-teal-50 font-medium text-teal-800'
:
'border-slate-200 bg-white text-slate-700 hover:border-teal-300 hover:bg-teal-50/50'
,
)
}
>
<
span
className=
{
cn
(
'flex h-3.5 w-3.5 flex-none items-center justify-center rounded-full border'
,
active
?
'border-teal-600'
:
'border-slate-300'
,
)
}
>
{
active
&&
<
span
className=
"h-2 w-2 rounded-full bg-teal-600"
/>
}
</
span
>
{
r
.
labelZh
}
</
button
>
);
})
}
</
div
>
{
needNote
&&
(
<
div
className=
"space-y-1.5"
>
<
div
className=
"text-[12.5px] font-medium text-slate-900"
>
请描述具体原因
<
span
className=
"text-rose-600"
>
*
</
span
>
</
div
>
<
textarea
value=
{
note
}
onChange=
{
(
e
)
=>
setNote
(
e
.
target
.
value
)
}
rows=
{
3
}
placeholder=
"请详细描述关闭机会的具体原因,以便后续分析和改进…"
className=
"w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-200 focus:border-teal-400"
/>
</
div
>
)
}
{
selected
&&
(
<
div
className=
"rounded-md border border-teal-100 bg-teal-50/60 px-3 py-2"
>
<
div
className=
"text-[11.5px] font-semibold text-teal-800"
>
已选关闭原因
</
div
>
<
div
className=
"mt-0.5 text-[12px] text-slate-700"
>
{
selected
.
labelZh
}
·
{
selected
.
desc
}
</
div
>
</
div
>
)
}
</
div
>
<
DialogFooter
>
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
()
=>
onOpenChange
(
false
)
}
>
取消
</
Button
>
<
Button
size=
"sm"
disabled=
{
!
canConfirm
}
onClick=
{
()
=>
{
if
(
!
reason
)
return
;
onConfirm
?.(
reason
,
needNote
?
note
.
trim
()
:
undefined
);
onOpenChange
(
false
);
reset
();
}
}
>
确认关闭
</
Button
>
</
DialogFooter
>
</
DialogContent
>
</
Dialog
>
);
}
apps/pac-web/src/components/plan-detail/execution-api.ts
View file @
9d063987
...
...
@@ -16,6 +16,8 @@ export interface SubmitExecutionBody {
abandonReasons
?:
string
[];
abandonOther
?:
string
;
scheduledNextAt
?:
string
;
/** 关闭机会来源(宿主回访模式弹窗)—— 服务端据此覆写抑制窗 */
closeReason
?:
string
;
}
/**
...
...
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
View file @
9d063987
'use client'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
,
type
ReactNode
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
createPortal
}
from
'react-dom'
;
import
{
toast
}
from
'sonner'
;
import
{
RefreshCw
,
ChevronDown
,
ThumbsUp
,
ThumbsDown
,
Sparkles
,
CalendarPlus
,
CalendarClock
}
from
'lucide-react'
;
import
{
RefreshCw
,
ChevronDown
,
ThumbsUp
,
ThumbsDown
,
Sparkles
,
CalendarPlus
,
CalendarClock
,
XCircle
}
from
'lucide-react'
;
import
{
Dialog
,
DialogContent
,
...
...
@@ -24,7 +25,8 @@ import { emitPetEvent } from '@/lib/pet-events';
import
{
usePlanSyncStore
}
from
'@/stores/plan-sync-store'
;
import
{
IdentityCluster
}
from
'@/components/identity-cluster'
;
import
{
isEmbedded
}
from
'@/lib/embed'
;
import
{
postToHost
}
from
'@/lib/host-message'
;
import
{
hostActionMode
,
openHostAction
}
from
'@/lib/host-message'
;
import
{
CloseOpportunityDialog
}
from
'./close-opportunity-dialog'
;
import
{
resolveActionUrl
}
from
'@/lib/action-url'
;
import
{
cn
,
...
...
@@ -39,6 +41,8 @@ import {
diagnosisCodeNameZh
,
EXECUTION_OUTCOME_META
,
RECALL_FEEDBACK_OPTIONS
,
CLOSE_REASON_META
,
type
CloseReason
,
type
ExecutionOutcome
,
}
from
'@pac/types'
;
import
{
AIStamp
,
Chip
,
PriorityBar
,
SidebarCard
,
tone
}
from
'./shared'
;
...
...
@@ -293,9 +297,64 @@ export function PlanDetailApp({
};
// 顶栏三动作(潜在 / 预约 / 回访)—— 原分散在身份卡、通话结果头、执行表单,统一收口到右上角。
// 潜在 / 回访:postMessage 通知宿主打开其组件;预约:宿主 CREATE_APPOINTMENT 跳转(占位替换)。
const
openPotential
=
()
=>
postToHost
(
'OPEN_POTENTIAL_TREATMENT'
,
patient
.
externalId
??
''
);
const
openReturnVisit
=
()
=>
postToHost
(
'OPEN_RETURN_VISIT'
,
patient
.
externalId
??
''
);
// 潜在 / 回访:actionUrls.OPEN_* 值形态决定模式(URL 模板 → 跳转;哨兵 postMessage → 喊宿主父页);
// 未配置 → 回调传 undefined → 按钮不渲染(PAC 默认无此动作)。预约:CREATE_APPOINTMENT 跳转。
const
hostActionCtx
=
{
patientId
:
patient
.
externalId
,
brandId
:
patient
.
brandId
,
clinicId
:
plan
?.
targetClinicId
,
medicalRecordNumber
:
patient
.
medicalRecordNumber
,
};
const
openPotential
=
hostActionMode
(
'OPEN_POTENTIAL_TREATMENT'
)
?
()
=>
openHostAction
(
'OPEN_POTENTIAL_TREATMENT'
,
hostActionCtx
)
:
undefined
;
const
openReturnVisit
=
hostActionMode
(
'OPEN_RETURN_VISIT'
)
?
()
=>
openHostAction
(
'OPEN_RETURN_VISIT'
,
hostActionCtx
)
:
undefined
;
// 宿主回访模式:回访动作在宿主侧完成 → 隐藏 PAC 通话结果区,顶栏补「关闭」入口(关闭机会弹窗)。
const
hasHostReturnVisit
=
!!
openReturnVisit
;
const
[
closeOpen
,
setCloseOpen
]
=
useState
(
false
);
// 关闭机会提交 —— 复用 execution 通道(channel='other'):outcome 按 CLOSE_REASON_META 映射,
// closeReason 让服务端覆写抑制窗;「识别不准确」额外双写召回反馈(down),补上拇指控件隐藏后的数据流。
// 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。
const
router
=
useRouter
();
const
submitClose
=
async
(
reason
:
CloseReason
,
note
?:
string
)
=>
{
const
meta
=
CLOSE_REASON_META
[
reason
];
const
notes
=
`[关闭机会]
${
meta
.
labelZh
}${
note
?
`:
${
note
}
`
:
''
}
`
;
try
{
const
result
=
await
submitExecution
(
plan
.
id
,
{
channel
:
'other'
,
outcome
:
meta
.
outcome
,
notes
,
closeReason
:
reason
,
});
setPlanOverride
({
status
:
result
.
planStatus
,
contactAttempts
:
result
.
contactAttempts
});
usePlanSyncStore
.
getState
().
notify
(
plan
.
id
,
result
.
planStatus
,
meta
.
outcome
);
if
(
meta
.
recallFeedbackDown
)
{
// 反馈失败不阻断关闭主流程(数据流尽力而为)
await
plansApi
.
submitRecallFeedback
(
plan
.
id
,
'down'
,
`机会识别不准确(关闭机会)
${
note
?
`:
${
note
}
`
:
''
}
`
)
.
catch
(()
=>
undefined
);
}
showToast
(
'emerald'
,
'机会已关闭'
,
meta
.
labelZh
);
// pageSize 2 + 排除本单:防状态回写与列表查询的竞态把刚关的单又选回来
try
{
const
mine
=
await
plansApi
.
list
({
view
:
'mine'
,
status
:
'assigned'
,
sort
:
'priority_desc'
,
page
:
1
,
pageSize
:
2
});
const
next
=
mine
.
items
.
find
((
i
)
=>
i
.
id
!==
plan
.
id
)
??
(
await
plansApi
.
list
({
view
:
'pool'
,
sort
:
'priority_desc'
,
page
:
1
,
pageSize
:
2
})).
items
.
find
(
(
i
)
=>
i
.
id
!==
plan
.
id
,
);
router
.
replace
(
next
?
`/plans/
${
next
.
id
}
`
:
'/plans'
);
}
catch
{
router
.
replace
(
'/plans'
);
}
}
catch
(
err
)
{
const
msg
=
err
instanceof
Error
?
err
.
message
:
String
(
err
);
showToast
(
'rose'
,
'关闭失败'
,
msg
.
slice
(
0
,
80
));
}
};
const
createAppointment
=
()
=>
{
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。缺失键清空。
...
...
@@ -371,10 +430,19 @@ export function PlanDetailApp({
onOpenPotential=
{
openPotential
}
onCreateAppointment=
{
createAppointment
}
onOpenReturnVisit=
{
openReturnVisit
}
onHoverReturnVisit=
{
hintRecallFeedback
}
// 宿主回访模式下「召回反馈」控件隐藏,宠物引导跟着停(否则指向不存在的控件)
onHoverReturnVisit=
{
hasHostReturnVisit
?
undefined
:
hintRecallFeedback
}
onCloseOpportunity=
{
hasHostReturnVisit
?
()
=>
setCloseOpen
(
true
)
:
undefined
}
/>
</
HeaderSlotPortal
>
{
/* 关闭机会弹窗 —— 原因映射 / 抑制覆写见 CLOSE_REASON_META(@pac/types) */
}
<
CloseOpportunityDialog
open=
{
closeOpen
}
onOpenChange=
{
setCloseOpen
}
onConfirm=
{
(
reason
,
note
)
=>
void
submitClose
(
reason
,
note
)
}
/>
{
/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
抽出 leftPane/centerPane/rightPane 单实例,grid 和 tabs 共用,避免 state desync */
}
<
ResponsiveDetail
...
...
@@ -546,6 +614,8 @@ export function PlanDetailApp({
</
main
>
}
rightPane=
{
// 宿主回访模式:回访结果在宿主侧记录 → 整列隐藏(顶栏「关闭」承接机会关闭)
hasHostReturnVisit
?
null
:
(
<
aside
className=
"min-h-0 flex flex-col gap-2.5 overflow-hidden h-full"
onMouseEnter=
{
hintRecallFeedback
}
...
...
@@ -565,6 +635,7 @@ export function PlanDetailApp({
</
div
>
</
section
>
</
aside
>
)
}
/>
...
...
@@ -602,13 +673,17 @@ function ResponsiveDetail({
isXl
:
boolean
;
leftPane
:
ReactNode
;
centerPane
:
ReactNode
;
rightPane
:
ReactNode
;
/** null = 无操作列(宿主回访模式)→ grid 收成 2 列,窄屏隐藏「操作」tab */
rightPane
:
ReactNode
|
null
;
})
{
return
(
<
div
className=
"flex-1 min-h-0"
>
<
div
className=
"h-full mx-auto px-5 py-3"
>
{
isXl
?
(
<
div
className=
"grid h-full gap-3"
style=
{
{
gridTemplateColumns
:
'300px 1fr 380px'
}
}
>
<
div
className=
"grid h-full gap-3"
style=
{
{
gridTemplateColumns
:
rightPane
?
'300px 1fr 380px'
:
'300px 1fr'
}
}
>
{
leftPane
}
{
centerPane
}
{
rightPane
}
...
...
@@ -619,7 +694,7 @@ function ResponsiveDetail({
<
TabsList
>
<
TabsTrigger
value=
"why"
>
原因 · 画像
</
TabsTrigger
>
<
TabsTrigger
value=
"script"
>
话术
</
TabsTrigger
>
<
TabsTrigger
value=
"outcome"
>
操作
</
TabsTrigger
>
{
rightPane
&&
<
TabsTrigger
value=
"outcome"
>
操作
</
TabsTrigger
>
}
</
TabsList
>
</
div
>
<
TabsContent
value=
"why"
className=
"flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
>
...
...
@@ -628,9 +703,11 @@ function ResponsiveDetail({
<
TabsContent
value=
"script"
className=
"flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
>
{
centerPane
}
</
TabsContent
>
<
TabsContent
value=
"outcome"
className=
"flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
>
{
rightPane
}
</
TabsContent
>
{
rightPane
&&
(
<
TabsContent
value=
"outcome"
className=
"flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
>
{
rightPane
}
</
TabsContent
>
)
}
</
Tabs
>
)
}
</
div
>
...
...
@@ -810,6 +887,7 @@ function TopBar({
onCreateAppointment
,
onOpenReturnVisit
,
onHoverReturnVisit
,
onCloseOpportunity
,
}:
{
plan
:
typeof
mockPlan
;
reason
:
typeof
mockPlan
.
reasons
[
0
];
...
...
@@ -817,12 +895,14 @@ function TopBar({
onRefreshAggregate
?:
()
=>
void
|
Promise
<
void
>
;
showToast
?:
(
kind
:
string
,
title
:
string
,
msg
:
string
)
=>
void
;
fmtRel
?:
(
d
:
Date
)
=>
string
;
/** 右上角动作:潜在治疗机会(
postMessage 宿主
) */
/** 右上角动作:潜在治疗机会(
actionUrls.OPEN_POTENTIAL_TREATMENT;未配置传 undefined → 不渲染
) */
onOpenPotential
?:
()
=>
void
;
/** 右上角动作:新建预约(宿主 CREATE_APPOINTMENT 跳转) */
onCreateAppointment
?:
()
=>
void
;
/** 右上角动作:回访(
postMessage 宿主
),主按钮样式 */
/** 右上角动作:回访(
actionUrls.OPEN_RETURN_VISIT;未配置传 undefined → 不渲染
),主按钮样式 */
onOpenReturnVisit
?:
()
=>
void
;
/** 右上角动作:关闭机会(危险级;仅宿主回访模式下出现,替代通话结果区的关闭路径) */
onCloseOpportunity
?:
()
=>
void
;
/** hover「回访」按钮时的引导回调(宠物发话引导做召回反馈) */
onHoverReturnVisit
?:
()
=>
void
;
})
{
...
...
@@ -889,13 +969,16 @@ function TopBar({
<
PriorityBar
score=
{
plan
.
priorityScore
}
label=
"优先级"
/>
</
span
>
</
PriorityHover
>
{
/* 召回反馈(plan 级)— 拇指 + 弹窗收集;放优先级右边 */
}
<
RecallFeedbackControl
planId=
{
plan
.
id
}
initialFeedback=
{
plan
.
recallFeedback
}
initialNote=
{
plan
.
recallFeedbackNote
}
showToast=
{
showToast
}
/>
{
/* 召回反馈(plan 级)— 拇指 + 弹窗收集;放优先级右边。
宿主回访模式(有「关闭」入口)下隐藏:召回好坏由关闭原因表达,不再单独收拇指 */
}
{
!
onCloseOpportunity
&&
(
<
RecallFeedbackControl
planId=
{
plan
.
id
}
initialFeedback=
{
plan
.
recallFeedback
}
initialNote=
{
plan
.
recallFeedbackNote
}
showToast=
{
showToast
}
/>
)
}
</
div
>
<
div
className=
"flex flex-none items-center gap-1.5 sm:gap-3 text-[11px] text-slate-600"
>
{
/* 数据新鲜度 — 该 plan 版本重算时间;宽屏才显,跟刷新按钮成对 */
}
...
...
@@ -962,6 +1045,17 @@ function TopBar({
<
span
className=
"hidden sm:inline"
>
回访
</
span
>
</
button
>
)
}
{
onCloseOpportunity
&&
(
<
button
type=
"button"
onClick=
{
onCloseOpportunity
}
title=
"关闭机会"
className=
"inline-flex items-center gap-1.5 rounded-md border border-rose-200 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-rose-600 transition-colors hover:border-rose-300 hover:bg-rose-50"
>
<
XCircle
className=
"h-3.5 w-3.5"
/>
<
span
className=
"hidden sm:inline"
>
关闭
</
span
>
</
button
>
)
}
</
div
>
)
}
{
/* 回收倒计时已隐藏 —— 暂无自动回收机制(返池改为手动)。RecycleCountdown 组件保留,需要时再挂回。 */
}
...
...
apps/pac-web/src/components/plans/patient-picker-rail.tsx
View file @
9d063987
...
...
@@ -116,10 +116,17 @@ export function PatientPickerRail({
}
},
[
view
,
loading
,
total
]);
// 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)
// 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)。
// 终态(completed/abandoned)行已不属于 pool/mine 视图(服务端过滤口径)→ 原地剔除,
// 与认领/返池同款处理;all 视图仍显示全部 → 只改状态角标。
const
sync
=
usePlanSyncStore
();
useEffect
(()
=>
{
if
(
!
sync
.
planId
)
return
;
const
terminal
=
sync
.
status
===
'completed'
||
sync
.
status
===
'abandoned'
;
if
(
terminal
&&
view
!==
'all'
)
{
removeItem
(
sync
.
planId
);
return
;
}
const
patch
:
Partial
<
PlanListItem
>
=
{};
if
(
sync
.
status
)
patch
.
status
=
sync
.
status
as
PlanListItem
[
'status'
];
if
(
sync
.
outcome
)
patch
.
lastOutcome
=
sync
.
outcome
as
ExecutionOutcome
;
...
...
apps/pac-web/src/components/plans/use-patient-picker.ts
View file @
9d063987
...
...
@@ -55,6 +55,10 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
try
{
const
q
:
Partial
<
ListPlansQuery
>
=
{
view
:
filters
.
view
,
// 「我的」= 我手头进行中的活(assigned)。服务端 view=mine 返回我名下全部状态
// (为旧列表页子 tab 设计),不显式过滤会把已结案/已放弃也列出来 —— 与召回池
// (服务端硬过滤 active)口径对齐,终态单不进工作台左栏。
status
:
filters
.
view
===
'mine'
?
'assigned'
:
undefined
,
keyword
:
filters
.
keyword
||
undefined
,
sort
:
filters
.
sort
,
phoneVerified
:
filters
.
phoneVerified
,
...
...
apps/pac-web/src/lib/host-message.ts
View file @
9d063987
...
...
@@ -2,31 +2,74 @@
import
{
toast
}
from
'sonner'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
fillActionUrl
}
from
'@/lib/action-url'
;
/**
* PAC → 宿主 的动作事件(postMessage)。单向,信封:source / type / action / payload。
* { source: 'pac', type: 'action', action, payload: { patientId } }
* (与对接方约定的契约一致 —— type 固定 'action',宿主据 type+source 过滤本类消息。)
* 宿主动作派发(潜在治疗 / 回访)—— 模式由 actionUrls[key] 的值形态决定:
*
* 未配置 → 动作不可用,按钮不渲染(PAC 默认无此动作)
* URL 模板 → 占位替换后打开宿主页(推荐姿势,同 CREATE_APPOINTMENT)
* 哨兵 'postMessage' → 向宿主父窗发动作事件,宿主监听后弹自己的组件
* (兜底通道,限"宿主动作是弹窗/组件、没有独立 URL"的场景;
* 需同时配 HOST_ORIGIN 作 targetOrigin,否则视为配置错误 → 不可用)
*
* 用于"宿主动作是弹窗/组件、没有独立 URL"的场景:PAC 只喊一声 + 带 patientId,
* 宿主父页监听后弹自己的组件。targetOrigin 走 host 配置 actionUrls.HOST_ORIGIN(不允许 '*')。
* 不做 iframe 判断:按钮固定就是这套;非嵌入时 targetOrigin 与当前窗口不符,浏览器自动丢弃(无副作用)。
* postMessage 信封(与对接方约定的契约一致,宿主据 type+source 过滤):
* { source: 'pac', type: 'action', action, payload: { patientId } }
* targetOrigin 走 actionUrls.HOST_ORIGIN(不允许 '*')。不做 iframe 判断:
* 非嵌入时 targetOrigin 与当前窗口不符,浏览器自动丢弃(无副作用)。
*/
export
type
HostAction
=
'OPEN_POTENTIAL_TREATMENT'
|
'OPEN_RETURN_VISIT'
;
/** 哨兵值:配在 actionUrls[key] 上表示该动作走 postMessage 而非 URL 跳转(比较不区分大小写)。 */
export
const
POST_MESSAGE_SENTINEL
=
'postMessage'
;
/** 值是否哨兵 —— 大小写不敏感:裸词不可能是合法 URL 模板,宽容拼写不会误伤 URL 配置。 */
export
function
isPostMessageSentinel
(
value
:
string
|
null
|
undefined
):
boolean
{
return
value
?.
trim
().
toLowerCase
()
===
POST_MESSAGE_SENTINEL
.
toLowerCase
();
}
/** 宿主 origin(postMessage targetOrigin)—— 来自会话下发的 actionUrls.HOST_ORIGIN。 */
export
function
hostOrigin
():
string
|
undefined
{
return
useAuthStore
.
getState
().
user
?.
actionUrls
?.
HOST_ORIGIN
||
undefined
;
}
/** 向宿主父窗发一条动作事件。未配置 origin 时提示并返 false。 */
export
function
postToHost
(
action
:
HostAction
,
patientId
:
string
):
boolean
{
const
origin
=
hostOrigin
();
if
(
!
origin
)
{
toast
(
'未配置宿主 Origin'
,
{
description
:
'请在宿主管理页配置 actionUrls.HOST_ORIGIN'
});
function
rawValue
(
key
:
HostAction
):
string
|
undefined
{
return
useAuthStore
.
getState
().
user
?.
actionUrls
?.[
key
]?.
trim
()
||
undefined
;
}
/**
* 宿主动作可用性(按钮渲染依据)。
* 返回 undefined = 不可用(未配置,或配了哨兵却没配 HOST_ORIGIN)。
*/
export
function
hostActionMode
(
key
:
HostAction
):
'url'
|
'postMessage'
|
undefined
{
const
raw
=
rawValue
(
key
);
if
(
!
raw
)
return
undefined
;
if
(
isPostMessageSentinel
(
raw
))
return
hostOrigin
()
?
'postMessage'
:
undefined
;
return
'url'
;
}
/**
* 按配置模式派发宿主动作。不可用时提示并返 false(正常按钮已按 hostActionMode 隐藏,
* 此处兜底会话中途配置变化的竞态)。
*/
export
function
openHostAction
(
key
:
HostAction
,
ctx
:
Record
<
string
,
string
|
null
|
undefined
>
,
):
boolean
{
const
raw
=
rawValue
(
key
);
const
mode
=
hostActionMode
(
key
);
if
(
!
raw
||
!
mode
)
{
toast
(
'宿主动作未配置'
,
{
description
:
`请在宿主管理页配置 actionUrls.
${
key
}
`
});
return
false
;
}
if
(
mode
===
'url'
)
{
window
.
open
(
fillActionUrl
(
raw
,
ctx
),
'_top'
);
return
true
;
}
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
window
.
parent
.
postMessage
({
source
:
'pac'
,
type
:
'action'
,
action
,
payload
:
{
patientId
}
},
origin
);
window
.
parent
.
postMessage
(
{
source
:
'pac'
,
type
:
'action'
,
action
:
key
,
payload
:
{
patientId
:
ctx
.
patientId
??
''
}
},
hostOrigin
()
!
,
);
return
true
;
}
packages/types/src/enums/index.ts
View file @
9d063987
...
...
@@ -103,8 +103,9 @@ export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholde
ADD_NOTE
:
{
label
:
'添加备注'
,
placeholders
:
[
'{patientId}'
]
},
OPEN_WECOM_CHAT
:
{
label
:
'打开企微会话'
,
placeholders
:
[
'{patientId}'
,
'{wecomExternalUserId}'
]
},
CALL_PATIENT
:
{
label
:
'拨号'
,
placeholders
:
[
'{phone}'
]
},
OPEN_POTENTIAL_TREATMENT
:
{
label
:
'打开潜在治疗(postMessage)'
,
placeholders
:
[
'{patientId}'
]
},
OPEN_RETURN_VISIT
:
{
label
:
'打开回访(postMessage)'
,
placeholders
:
[
'{patientId}'
]
},
// OPEN_* 两键:配 URL 模板走跳转(推荐);宿主无独立页时配哨兵值 postMessage 走消息通道(需 HOST_ORIGIN)。未配置则按钮不渲染。
OPEN_POTENTIAL_TREATMENT
:
{
label
:
'打开潜在治疗(URL 模板;或配 postMessage)'
,
placeholders
:
[
'{patientId}'
,
'{brandId}'
,
'{clinicId}'
,
'{medicalRecordNumber}'
]
},
OPEN_RETURN_VISIT
:
{
label
:
'打开回访(URL 模板;或配 postMessage)'
,
placeholders
:
[
'{patientId}'
,
'{brandId}'
,
'{clinicId}'
,
'{medicalRecordNumber}'
]
},
HOST_ORIGIN
:
{
label
:
'宿主 Origin(postMessage 目标源)'
,
placeholders
:
[]
},
};
...
...
@@ -612,6 +613,61 @@ export const RECALL_FEEDBACK_OPTIONS = [
{
value
:
'not_worth'
,
labelZh
:
'不值得召'
,
hint
:
'价值低 / 不是真机会'
},
]
as
const
;
// =============================================================
// 关闭机会(宿主回访模式)— 关闭原因封闭集 + 到执行结果的映射
// =============================================================
/// 弱语义关闭原因的默认抑制档:一个 DW 同步周期 + 排除闸接手绰绰有余,
/// 又不至于把真机会闷死(介于「再考虑 7d」与「拒绝 90d」之间)。
export
const
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
=
14
;
export
const
CloseReason
=
{
NO_INTENT
:
'no_intent'
,
INACCURATE
:
'inaccurate'
,
COMPETITOR
:
'competitor'
,
PRICE
:
'price'
,
TREATED
:
'treated'
,
UNREACHABLE
:
'unreachable'
,
OTHER
:
'other'
,
}
as
const
;
export
type
CloseReason
=
(
typeof
CloseReason
)[
keyof
typeof
CloseReason
];
export
const
CloseReasonSchema
=
z
.
enum
([
'no_intent'
,
'inaccurate'
,
'competitor'
,
'price'
,
'treated'
,
'unreachable'
,
'other'
,
]);
/// 关闭原因单一真理源 —— 关闭走现有 execution 通道(channel='other'),不另起机制:
/// outcome — 映射到的执行结果(状态机 / 触达账本 / 熔断口径全继承)
/// suppressDaysOverride — 覆写 outcome 自带抑制窗;未设则用 EXECUTION_OUTCOME_META.suppressDays。
/// 弱语义项(识别不准/已治/其他)统一 14d 默认档;无法联系取熔断口径 30d
/// recallFeedbackDown — 该原因本质是「召回不准」→ 前端提交时双写 recall-feedback(down),
/// 宿主模式下反馈数据流不因拇指控件隐藏而断
/// needNote — 必填文字说明
export
const
CLOSE_REASON_META
:
Record
<
CloseReason
,
{
labelZh
:
string
;
desc
:
string
;
outcome
:
ExecutionOutcome
;
suppressDaysOverride
?:
number
;
recallFeedbackDown
?:
boolean
;
needNote
?:
boolean
;
}
>
=
{
no_intent
:
{
labelZh
:
'客户无意愿'
,
desc
:
'客户明确表示没有治疗意愿'
,
outcome
:
'refused'
},
inaccurate
:
{
labelZh
:
'机会识别不准确'
,
desc
:
'识别的治疗机会与患者实际情况不符'
,
outcome
:
'marked_invalid'
,
suppressDaysOverride
:
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
,
recallFeedbackDown
:
true
},
competitor
:
{
labelZh
:
'选择竞品机构'
,
desc
:
'客户已选择或倾向其他机构治疗'
,
outcome
:
'external_treatment'
},
price
:
{
labelZh
:
'价格因素'
,
desc
:
'客户因价格原因暂不考虑治疗'
,
outcome
:
'refused'
},
treated
:
{
labelZh
:
'已完成治疗'
,
desc
:
'客户已完成该机会对应的治疗'
,
outcome
:
'abandoned'
,
suppressDaysOverride
:
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
},
unreachable
:
{
labelZh
:
'无法联系客户'
,
desc
:
'多次尝试后仍无法联系到客户'
,
outcome
:
'abandoned'
,
suppressDaysOverride
:
30
},
other
:
{
labelZh
:
'其他原因(需填写说明)'
,
desc
:
'需要进一步说明具体原因'
,
outcome
:
'abandoned'
,
suppressDaysOverride
:
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
,
needNote
:
true
},
};
export
const
ExecutionChannel
=
{
PHONE
:
'phone'
,
WECOM
:
'wecom'
,
...
...
packages/types/src/schemas/plan.ts
View file @
9d063987
...
...
@@ -3,6 +3,7 @@ import {
AbandonReasonSchema
,
AssetSourceSchema
,
AssetStatusSchema
,
CloseReasonSchema
,
ExecutionChannelSchema
,
ExecutionOutcomeSchema
,
PlanStatusSchema
,
...
...
@@ -236,6 +237,9 @@ export const SubmitExecutionRequestSchema = z.object({
abandonReasons
:
z
.
array
(
AbandonReasonSchema
).
max
(
20
).
optional
(),
abandonOther
:
z
.
string
().
optional
(),
scheduledNextAt
:
z
.
string
().
optional
(),
/// 关闭机会来源(宿主回访模式弹窗):服务端据 CLOSE_REASON_META 覆写抑制窗;
/// outcome 仍由前端按同一张表派生提交(两端同源,无漂移面)
closeReason
:
CloseReasonSchema
.
optional
(),
});
export
type
SubmitExecutionRequest
=
z
.
infer
<
typeof
SubmitExecutionRequestSchema
>
;
...
...
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