Commit a96424b5 by luoqi

feat(recall): 宿主回访模式 — 回访 URL 插槽三态 + 关闭机会闭环

宿主动作派发(host-message 重写):
- actionUrls.OPEN_RETURN_VISIT / OPEN_POTENTIAL_TREATMENT 值形态定模式:
  未配置=按钮不渲染 / URL 模板=跳转(推荐) / 哨兵 postMessage=喊宿主父页(需 HOST_ORIGIN)
- 哨兵比较大小写不敏感(裸词不可能是合法 URL,宽容拼写零误伤)
- 管理页:配哨兵缺 HOST_ORIGIN 时黄条提示

宿主回访模式布局(有回访按钮时):
- 通话结果整列隐藏(xl 收 2 列,窄屏去「操作」tab);召回反馈拇指隐藏,宠物引导同停
- 顶栏「关闭」危险按钮 → 关闭机会弹窗(7 原因,PAC UI 标准)

关闭机会闭环(CLOSE_REASON_META 单一真理源,前后端同表):
- 原因→outcome 映射:无意愿/价格=refused(90d)、竞品=external_treatment(永久)、
  无法联系=abandoned(30d 熔断口径)、已治/其他=abandoned(14d 默认档)、
  识别不准=marked_invalid(覆写永久→14d)+ 双写召回反馈 down
- resolveSnoozedUntil 优先级链插入 closeReason 覆写档(熔断 > 覆写 > outcome 自带)
- 复用 execution 通道(channel=other),状态机/触达账本/跨栏同步全继承
- 关闭后自动跳下一位(我的进行中 → 召回池,与 /plans 入口解析器同规则)

列表口径对齐:
- 终态单(completed/abandoned)在 pool/mine 视图原地剔除(与服务端过滤一致)
- 「我的」显式 status=assigned(服务端 view=mine 返全状态是旧列表页设计)

其他:全局细滚动条(Windows 默认过粗);删 spec 中引用已重构掉的
computeLikelihoodBonus 的过时测试块(main 上即已编译失败);新增 7 个
closeReason 抑制覆写测试(19/19 过)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent e1697a0d
Pipeline #3399 failed in 0 seconds
......@@ -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 ───
......
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);
......
......@@ -2,9 +2,10 @@ 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 { computeLikelihoodBonus } from '../src/modules/plan/engine/priority-scorer';
/**
* 召回闭环:熔断 / 拒绝 / 回访冷静期(snoozedUntil)。
......@@ -82,21 +83,8 @@ describe('召回抑制窗 snoozedUntil 计算', () => {
});
});
describe('优先级:considering 不再加权(GAP2)', () => {
test('考虑中不应把患者顶到列表最前(冷静期内 + 不加权)', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'considering' }])).toBe(0);
});
test('真实正向进展(成功转化 / 改约)仍 +4', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'success_appointed' }])).toBe(4);
expect(computeLikelihoodBonus(3, [{ outcome: 'rescheduled' }])).toBe(4);
});
test('无近期正向执行 → 仅 riskBonus', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'no_answer' }])).toBe(0); // risk=3 → riskBonus 0
expect(computeLikelihoodBonus(0, [])).toBe(6); // risk=0 → (3-0)*2=6
});
});
// 「优先级:considering 不再加权(GAP2)」测试块已删 —— computeLikelihoodBonus 在
// 7eb8dbe(三源信号统一标定)重构中移除,likelihood 并入 calcPriority,该行为由引擎侧覆盖。
describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () => {
test('每个 outcome 都声明了 suppressDays(number 或 null),无遗漏', () => {
......@@ -110,3 +98,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();
}
});
});
......@@ -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;
}
......@@ -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>
);
})}
......
'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>
);
}
......@@ -16,6 +16,8 @@ export interface SubmitExecutionBody {
abandonReasons?: string[];
abandonOther?: string;
scheduledNextAt?: string;
/** 关闭机会来源(宿主回访模式弹窗)—— 服务端据此覆写抑制窗 */
closeReason?: string;
}
/**
......
'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 组件保留,需要时再挂回。 */}
......
......@@ -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;
......
......@@ -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,
......
......@@ -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;
}
......@@ -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',
......
......@@ -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>;
......
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