Commit 1e1d5ac9 by luoqi

feat(web): 详情页顶栏收口潜在/预约/回访三动作 + 宠物引导评价

- 潜在治疗/新建预约/回访 从身份卡·执行表单·通话结果头 上移到顶栏右上角动作区
  (潜在·预约=默认样式,回访=主按钮);删除已成死代码 call-widget.tsx。
- 宠物引导:话术生成后停留 15s→引导给话术打是否好用;hover 回访/通话结果→引导做召回反馈
  (60s 冷却、已反馈不扰),复用 emitPetEvent 气泡,不代评价。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent c1267e65
'use client';
import { CalendarClock } from 'lucide-react';
import { postToHost } from '@/lib/host-message';
/**
* 通话结果区右上角「回访」按钮 —— postMessage 通知宿主打开它自己的回访组件(带 patientId)。
* 固定就是这套(不分嵌入与否);未配置 HOST_ORIGIN 时点击会提示去配。
*/
export function CallWidget({ patientId }: { patientId?: string }) {
return (
<button
type="button"
onClick={() => postToHost('OPEN_RETURN_VISIT', patientId ?? '')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-teal-600 text-white text-[12px] font-medium hover:bg-teal-700 transition-colors"
>
<CalendarClock className="w-3.5 h-3.5" />
回访
</button>
);
}
......@@ -86,7 +86,6 @@ const ABANDON_REASONS = ['号码空号 / 错号', '已转介他人', '明确不
export function OutcomeForm({
plan,
onSubmit,
onCreateAppointment,
defaultChannel = 'phone',
}: {
plan: { contactAttempts: number; status?: string };
......@@ -97,7 +96,6 @@ export function OutcomeForm({
scheduledNextAt: string;
abandonReasons: string[];
}) => void;
onCreateAppointment: () => void;
defaultChannel?: string;
}) {
const [channel, setChannel] = useState(defaultChannel);
......@@ -312,25 +310,7 @@ export function OutcomeForm({
本次为第 <strong className="text-slate-600 tabular-nums">{plan.contactAttempts + 1}</strong> 次触达
</span>
<div className="flex items-center gap-1.5">
{/* 新建预约按钮常显(不再仅"成功"时出现)— 客服任何时候都能手动建预约 */}
<button
onClick={onCreateAppointment}
className="px-3 py-1.5 rounded text-[12px] border border-teal-300 text-teal-700 hover:bg-teal-50 inline-flex items-center gap-1 transition-colors"
>
<svg
viewBox="0 0 24 24"
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18M12 14v4M10 16h4" />
</svg>
新建预约
</button>
{/* 「新建预约」按钮已上移到详情页顶栏右上角(与 潜在 / 回访 同处动作区) */}
<button
disabled={!canSubmit}
onClick={handleSubmit}
......
'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { toast } from 'sonner';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown } from 'lucide-react';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown, Sparkles, CalendarPlus, CalendarClock } from 'lucide-react';
import {
Dialog,
DialogContent,
......@@ -20,8 +20,8 @@ import {
DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api';
import { emitPetEvent } from '@/lib/pet-events';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAuthStore } from '@/stores/auth-store';
import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed';
import { postToHost } from '@/lib/host-message';
......@@ -67,7 +67,6 @@ import type { AdaptedFact } from './adapt-data';
import { useScriptStream } from './use-script-stream';
import { useSummaryStream } from './use-summary-stream';
// import { RealtimeCoach } from '@/components/realtime-coach'; // 暂隐藏(功能未上线)
import { CallWidget } from './call-widget';
import { submitExecution, adaptAbandonReasons } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
......@@ -293,6 +292,68 @@ export function PlanDetailApp({
}
};
// 顶栏三动作(潜在 / 预约 / 回访)—— 原分散在身份卡、通话结果头、执行表单,统一收口到右上角。
// 潜在 / 回访:postMessage 通知宿主打开其组件;预约:宿主 CREATE_APPOINTMENT 跳转(占位替换)。
const openPotential = () => postToHost('OPEN_POTENTIAL_TREATMENT', patient.externalId ?? '');
const openReturnVisit = () => postToHost('OPEN_RETURN_VISIT', patient.externalId ?? '');
const createAppointment = () => {
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。缺失键清空。
const url = resolveActionUrl('CREATE_APPOINTMENT', {
patientId: patient.externalId,
brandId: patient.brandId,
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
});
if (!url) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return;
}
window.open(url, '_top');
showToast('emerald', '已打开宿主预约页', '带上患者 id');
};
// ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ──
// 目标 = 中栏话术底部 👍/👎(AIDisclaimerFooter)。每患者只提示一次,切患者/无话术不提示;
// 宠物只发话(director 气泡),不代评价。
const scriptHintShownRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!hasScriptContent) return;
const pid = plan.id;
if (scriptHintShownRef.current.has(pid)) return;
const t = setTimeout(() => {
scriptHintShownRef.current.add(pid);
emitPetEvent({
type: 'director',
script: {
gesture: 'greet',
bubble: '这套话术还顺手吗?点话术区右下角的「👍 / 👎」帮我评一下呗~',
ttlMs: 6_000,
},
});
}, 15_000);
return () => clearTimeout(t);
}, [plan.id, hasScriptContent]);
// ── 宠物引导 2:hover「回访」按钮 / 「通话结果」区 → 发话引导给「本次召回」打反馈 ──
// 目标 = 顶栏「召回反馈」👍/👎(RecallFeedbackControl,plan 级、正交于执行)。
// 冷却 60s(避免一 hover 就刷屏);本 plan 已给过召回反馈则不再打扰。
const recallHintAtRef = useRef(0);
const hintRecallFeedback = () => {
if (plan.recallFeedback) return;
const now = Date.now();
if (now - recallHintAtRef.current < 60_000) return;
recallHintAtRef.current = now;
emitPetEvent({
type: 'director',
script: {
gesture: 'greet',
bubble: '顺手评价下这次召回准不准?顶部「召回反馈」点个 👍 / 👎 就行~',
ttlMs: 5_500,
},
});
};
return (
<div
className="h-full overflow-hidden bg-slate-50 text-slate-900 flex flex-col"
......@@ -307,6 +368,10 @@ export function PlanDetailApp({
onRefreshAggregate={onRefreshAggregate}
showToast={showToast}
fmtRel={fmtRel}
onOpenPotential={openPotential}
onCreateAppointment={createAppointment}
onOpenReturnVisit={openReturnVisit}
onHoverReturnVisit={hintRecallFeedback}
/>
</HeaderSlotPortal>
......@@ -481,37 +546,21 @@ export function PlanDetailApp({
</main>
}
rightPane={
<aside className="min-h-0 flex flex-col gap-2.5 overflow-hidden h-full">
<aside
className="min-h-0 flex flex-col gap-2.5 overflow-hidden h-full"
onMouseEnter={hintRecallFeedback}
>
<section className="bg-white rounded-lg border border-slate-100 shadow-sm flex flex-col min-h-0 flex-1 overflow-hidden">
<header className="flex-none px-4 py-2.5 border-b border-slate-100 flex items-center justify-between gap-2">
<div>
<header className="flex-none px-4 py-2.5 border-b border-slate-100">
{/* 「回访」按钮已上移到顶栏右上角动作区(主按钮) */}
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">通话结果</h2>
<p className="text-[10.5px] text-slate-500 mt-0.5">提交后本次任务自动归档</p>
</div>
<CallWidget patientId={patient.externalId} />
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
<OutcomeForm
plan={effectivePlan}
onSubmit={submitOutcome}
defaultChannel={effectivePlan.recommendedChannel}
onCreateAppointment={() => {
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。通用引擎:支持 {patientId}/{brandId}/
// {clinicId}/{medicalRecordNumber} 等,缺失键清空
const url = resolveActionUrl('CREATE_APPOINTMENT', {
patientId: patient.externalId,
brandId: patient.brandId,
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
});
if (!url) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return;
}
window.open(url, '_top');
showToast('emerald', '已打开宿主预约页', '带上患者 id');
}}
/>
</div>
</section>
......@@ -757,6 +806,10 @@ function TopBar({
onRefreshAggregate,
showToast,
fmtRel,
onOpenPotential,
onCreateAppointment,
onOpenReturnVisit,
onHoverReturnVisit,
}: {
plan: typeof mockPlan;
reason: typeof mockPlan.reasons[0];
......@@ -764,6 +817,14 @@ function TopBar({
onRefreshAggregate?: () => void | Promise<void>;
showToast?: (kind: string, title: string, msg: string) => void;
fmtRel?: (d: Date) => string;
/** 右上角动作:潜在治疗机会(postMessage 宿主) */
onOpenPotential?: () => void;
/** 右上角动作:新建预约(宿主 CREATE_APPOINTMENT 跳转) */
onCreateAppointment?: () => void;
/** 右上角动作:回访(postMessage 宿主),主按钮样式 */
onOpenReturnVisit?: () => void;
/** hover「回访」按钮时的引导回调(宠物发话引导做召回反馈) */
onHoverReturnVisit?: () => void;
}) {
const [refreshing, setRefreshing] = useState(false);
// 嵌入宿主 iframe 时:隐藏 PAC 品牌 / 标题 / 用户三件套(宿主已提供),只留召回功能区。
......@@ -817,7 +878,7 @@ function TopBar({
</>
)}
{/* scenario chip + 优先级 — 始终显示,核心信息 */}
<Chip tone="rose" icon size="xs" className="ml-1 sm:ml-2 flex-none">
<Chip tone="rose" icon size="xs" className="flex-none">
{reason.scenarioLabel}
</Chip>
<PriorityHover
......@@ -864,6 +925,45 @@ function TopBar({
<span className="hidden sm:inline">{refreshing ? '同步中…' : '刷新'}</span>
</button>
)}
{/* 患者动作三连:潜在 / 预约(默认样式)+ 回访(主按钮)。原分散在身份卡 / 通话结果头 / 执行表单,统一收口到此。 */}
{(onOpenPotential || onCreateAppointment || onOpenReturnVisit) && (
<div className="flex flex-none items-center gap-1.5">
{onOpenPotential && (
<button
type="button"
onClick={onOpenPotential}
title="查看潜在治疗机会"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-100 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-slate-700 transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700"
>
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">潜在</span>
</button>
)}
{onCreateAppointment && (
<button
type="button"
onClick={onCreateAppointment}
title="新建预约"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-100 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-slate-700 transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700"
>
<CalendarPlus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">预约</span>
</button>
)}
{onOpenReturnVisit && (
<button
type="button"
onClick={onOpenReturnVisit}
onMouseEnter={onHoverReturnVisit}
title="回访"
className="inline-flex items-center gap-1.5 rounded-md bg-teal-600 px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-teal-700"
>
<CalendarClock className="h-3.5 w-3.5" />
<span className="hidden sm:inline">回访</span>
</button>
)}
</div>
)}
{/* 回收倒计时已隐藏 —— 暂无自动回收机制(返池改为手动)。RecycleCountdown 组件保留,需要时再挂回。 */}
{/* 用户名 + 头像 + 退出 —— 嵌入宿主时整体隐藏(见 IdentityCluster) */}
<IdentityCluster />
......@@ -1027,14 +1127,7 @@ function IdentityCard({
</span>
</div>
<div className="flex items-center gap-2 flex-none">
{/* 潜在治疗 —— postMessage 通知宿主打开其组件(固定就是这套,不分嵌入) */}
<button
type="button"
onClick={() => postToHost('OPEN_POTENTIAL_TREATMENT', patient.externalId ?? '')}
className="text-[10.5px] text-teal-700 hover:underline"
>
潜在治疗 →
</button>
{/* 潜在治疗入口已上移到顶栏「潜在」按钮(与 预约 / 回访 同处右上角动作区)。 */}
{/* 原始档案 —— 宿主 VIEW_PATIENT 跳转(占位 {patientId}/{medicalRecordNumber});未配则不渲染 */}
{originalArchiveUrl ? (
<a
......
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