Commit 1d462aa1 by luoqi

feat(web): 详情页重构 — 画像标签独立卡 + 三处一句话摘要 + 抽屉/布局/展开

画像标签从身份卡拆出独立卡(详情抽屉:标签属性+取值,? hover 看规则,z-[70] 修被抽屉遮挡);
历史联系/画像标签/参考话术原因行均改为 LLM 一句话(shimmer 占位、失败回退结构化);
召回简报加主题色阶背景 + 箭头展开看原「为什么召回」;中栏 min-w-0 修长文撑爆右栏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 93a11bdd
...@@ -11,8 +11,9 @@ import { cleanPersonaValue } from './persona-display'; ...@@ -11,8 +11,9 @@ import { cleanPersonaValue } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover'; import { PersonaFeatureHover } from './persona-feature-hover';
import type { Chain, PersonaFeature, PlanReason } from './mock-data'; import type { Chain, PersonaFeature, PlanReason } from './mock-data';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
import type { ReturnVisitItem } from './plan-detail-app';
export type DrawerKind = 'chain-detail' | 'medical' | 'image' | 'facts' | 'teeth' | 'persona' | null; export type DrawerKind = 'chain-detail' | 'medical' | 'image' | 'facts' | 'treatments' | 'teeth' | 'persona' | 'return-visits' | null;
export function Drawer({ export function Drawer({
open, open,
...@@ -25,6 +26,7 @@ export function Drawer({ ...@@ -25,6 +26,7 @@ export function Drawer({
reasons, reasons,
facts, facts,
fmtRel, fmtRel,
returnVisits,
summaryOverride, summaryOverride,
summaryStreaming, summaryStreaming,
onRegenerateSummary, onRegenerateSummary,
...@@ -44,6 +46,8 @@ export function Drawer({ ...@@ -44,6 +46,8 @@ export function Drawer({
/// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用) /// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用)
facts: AdaptedFact[]; facts: AdaptedFact[];
fmtRel: (d: Date) => string; fmtRel: (d: Date) => string;
/// 历史联系(诊所回访)结构化记录 — 「历史联系」卡片「详情」抽屉用
returnVisits: ReturnVisitItem[];
/** AI 流式重生成结果(部分或完成);存在时覆盖 mock summaries 显示 */ /** AI 流式重生成结果(部分或完成);存在时覆盖 mock summaries 显示 */
summaryOverride?: { summaryOverride?: {
medicalRecord?: string; medicalRecord?: string;
...@@ -92,13 +96,25 @@ export function Drawer({ ...@@ -92,13 +96,25 @@ export function Drawer({
subtitle = '按时间倒序'; subtitle = '按时间倒序';
body = <FactsTimeline facts={facts} />; body = <FactsTimeline facts={facts} />;
width = 'w-[640px]'; width = 'w-[640px]';
} else if (kind === 'treatments') {
// 治疗历史卡「详情」:同 facts 时间轴,默认只勾选治疗(可在 chip 里再放开看其余)
const txCount = facts.filter((f) => f.type === 'treatment_record').length;
title = `治疗时间轴(${txCount})`;
subtitle = '仅治疗 · 时间倒序';
body = <FactsTimeline facts={facts} initialTypes={['treatment_record']} />;
width = 'w-[640px]';
} else if (kind === 'return-visits') {
title = `历史联系(${returnVisits.length})`;
subtitle = '诊所回访记录 · 时间倒序';
body = <ReturnVisitsList visits={returnVisits} />;
width = 'w-[520px]';
} else if (kind === 'teeth') { } else if (kind === 'teeth') {
title = '牙位事实'; title = '牙位事实';
subtitle = '每颗牙 / 全口治疗线 · 时间倒序'; subtitle = '每颗牙 / 全口治疗线 · 时间倒序';
body = <ToothTimeline facts={facts} />; body = <ToothTimeline facts={facts} />;
width = 'w-[560px]'; width = 'w-[560px]';
} else if (kind === 'persona') { } else if (kind === 'persona') {
title = '患者画像'; title = '画像标签';
subtitle = `更新于 ${fmtRel(persona.computedAt)} · ${persona.features.length} 项画像`; subtitle = `更新于 ${fmtRel(persona.computedAt)} · ${persona.features.length} 项画像`;
body = ( body = (
<div className="space-y-3"> <div className="space-y-3">
...@@ -106,13 +122,20 @@ export function Drawer({ ...@@ -106,13 +122,20 @@ export function Drawer({
{persona.features.map((f) => { {persona.features.map((f) => {
const T = tone(f.tone); const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value); const { tag, text } = cleanPersonaValue(f.value);
// 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开,
// 不像卡片那样截成 "+N"。单值特征回退到 cleanPersonaValue 的主描述文本。
const labels = (f.data as { labels?: unknown } | null | undefined)?.labels;
const valueLabels =
Array.isArray(labels) && labels.every((x) => typeof x === 'string')
? (labels as string[])
: null;
return ( return (
<div key={f.key} className="relative rounded-md border border-slate-100 p-3"> <div key={f.key} className="relative rounded-md border border-slate-100 p-3 pr-8">
{/* 右上角 ? hover 看算法说明 */} {/* 右上角 ? hover 看规则说明(算法口径) */}
<PersonaFeatureHover featureKey={f.key}> <PersonaFeatureHover featureKey={f.key} value={f.value}>
<span <span
className="absolute top-2 right-2 inline-flex items-center justify-center w-4 h-4 rounded-full text-slate-400 cursor-help hover:text-slate-700 hover:bg-slate-100" className="absolute top-2 right-2 inline-flex items-center justify-center w-4 h-4 rounded-full text-slate-400 cursor-help hover:text-slate-700 hover:bg-slate-100"
aria-label="查看算法说明" aria-label="查看规则说明"
> >
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
...@@ -120,6 +143,7 @@ export function Drawer({ ...@@ -120,6 +143,7 @@ export function Drawer({
</svg> </svg>
</span> </span>
</PersonaFeatureHover> </PersonaFeatureHover>
{/* 属性名 */}
<div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}> <div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}>
<span className={cn('w-1.5 h-1.5 rounded-full', T.dot)} /> <span className={cn('w-1.5 h-1.5 rounded-full', T.dot)} />
{f.label} {f.label}
...@@ -129,7 +153,21 @@ export function Drawer({ ...@@ -129,7 +153,21 @@ export function Drawer({
</span> </span>
)} )}
</div> </div>
{text && <div className="text-[13px] font-medium text-slate-900 mt-1">{text}</div>} {/* 取值条目 — 多值展开为 chip,单值显示文本 */}
{valueLabels && valueLabels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{valueLabels.map((l, i) => (
<span
key={i}
className="inline-flex items-center rounded bg-slate-50 px-1.5 py-0.5 text-[12px] font-medium text-slate-800 ring-1 ring-slate-100"
>
{l}
</span>
))}
</div>
) : (
text && <div className="text-[13px] font-medium text-slate-900 mt-1">{text}</div>
)}
</div> </div>
); );
})} })}
...@@ -326,3 +364,36 @@ function CBCTImageView() { ...@@ -326,3 +364,36 @@ function CBCTImageView() {
); );
} }
/** 历史联系(诊所回访)结构化列表 — 「历史联系」卡片「详情」抽屉内容。 */
function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
if (visits.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无历史联系记录</div>;
}
return (
<div className="space-y-2">
{visits.map((v, i) => {
const done = v.status === '已回访' || v.taskStatus === '已完成';
return (
<div key={i} className="flex items-start gap-2.5 text-[12.5px] py-1 border-b border-slate-50 last:border-0">
<span className={cn('flex-none mt-[5px] w-1.5 h-1.5 rounded-full', done ? 'bg-teal-400' : 'bg-slate-300')} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-slate-700">{v.type ?? '回访'}</span>
{v.status && <span className="text-slate-400">· {v.status}</span>}
{v.taskDate && <span className="text-slate-400 tabular-nums">{v.taskDate}</span>}
{v.treatmentItems && <span className="text-teal-700/70">· {v.treatmentItems}</span>}
</div>
{v.followContent && (
<div className="text-slate-600 leading-snug break-words mt-0.5">{v.followContent}</div>
)}
{v.result && (
<div className="text-slate-400 leading-snug break-words mt-0.5">结果:{v.result}</div>
)}
</div>
</div>
);
})}
</div>
);
}
...@@ -31,7 +31,14 @@ import type { AdaptedFact } from './adapt-data'; ...@@ -31,7 +31,14 @@ import type { AdaptedFact } from './adapt-data';
* ③ 图标走 lucide-react(替代 inline SVG path,更易维护) * ③ 图标走 lucide-react(替代 inline SVG path,更易维护)
* ④ 按 occurredAt ?? plannedFor 倒序;planned 加"约"前缀 * ④ 按 occurredAt ?? plannedFor 倒序;planned 加"约"前缀
*/ */
export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) { export function FactsTimeline({
facts,
initialTypes,
}: {
facts: AdaptedFact[];
/// 初始勾选的 fact_type(缺省全选);如治疗历史卡「详情」传 ['treatment_record'] → 默认只看治疗时间轴
initialTypes?: string[];
}) {
if (facts.length === 0) { if (facts.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>; return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>;
} }
...@@ -44,7 +51,9 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) { ...@@ -44,7 +51,9 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
}, [facts]); }, [facts]);
const allTypes = useMemo(() => [...typeCounts.keys()].sort(), [typeCounts]); const allTypes = useMemo(() => [...typeCounts.keys()].sort(), [typeCounts]);
const [selected, setSelected] = useState<Set<string>>(() => new Set(allTypes)); const [selected, setSelected] = useState<Set<string>>(() =>
initialTypes && initialTypes.length ? new Set(initialTypes) : new Set(allTypes),
);
const allOn = selected.size === allTypes.length; const allOn = selected.size === allTypes.length;
const toggleAll = () => setSelected(allOn ? new Set() : new Set(allTypes)); const toggleAll = () => setSelected(allOn ? new Set() : new Set(allTypes));
const toggleType = (t: string) => const toggleType = (t: string) =>
......
...@@ -28,7 +28,8 @@ export function PersonaFeatureHover({ ...@@ -28,7 +28,8 @@ export function PersonaFeatureHover({
<HoverCard openDelay={150} closeDelay={80}> <HoverCard openDelay={150} closeDelay={80}>
{/* 直接把 children 作为 trigger,radix 锚定 children 本身的位置(避免多包一层 0 尺寸 span)*/} {/* 直接把 children 作为 trigger,radix 锚定 children 本身的位置(避免多包一层 0 尺寸 span)*/}
<HoverCardTrigger asChild>{children}</HoverCardTrigger> <HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent align="end" sideOffset={6} className="w-80 p-3 text-[11.5px]"> {/* z-[70]:抽屉面板是 z-[60],默认 hovercard z-50 会被抽屉盖住(详情抽屉里 ? hover 不显的根因) */}
<HoverCardContent align="end" sideOffset={6} className="z-[70] w-80 p-3 text-[11.5px]">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-baseline justify-between border-b border-slate-100 pb-1.5"> <div className="flex items-baseline justify-between border-b border-slate-100 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">{meta?.title ?? featureKey}</span> <span className="text-[13px] font-semibold text-slate-900">{meta?.title ?? featureKey}</span>
......
...@@ -316,7 +316,6 @@ export function PlanDetailApp({ ...@@ -316,7 +316,6 @@ export function PlanDetailApp({
<aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1 h-full"> <aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1 h-full">
<IdentityCard <IdentityCard
patient={patient} patient={patient}
features={persona.features}
onOpenImage={() => onOpenImage={() =>
showToast('slate', '影像调阅', '跳转宿主页面') showToast('slate', '影像调阅', '跳转宿主页面')
} }
...@@ -324,9 +323,10 @@ export function PlanDetailApp({ ...@@ -324,9 +323,10 @@ export function PlanDetailApp({
showToast('slate', '患者档案', '跳转宿主页面') showToast('slate', '患者档案', '跳转宿主页面')
} }
/> />
<WhyCard <PersonaTagsCard
visibleReasons={visibleReasons} planId={plan.id}
onOpenMedical={() => setDrawerOpen('medical')} features={persona.features}
onOpenDetail={() => setDrawerOpen('persona')}
/> />
<KeyFactsCard <KeyFactsCard
patient={patient} patient={patient}
...@@ -336,20 +336,32 @@ export function PlanDetailApp({ ...@@ -336,20 +336,32 @@ export function PlanDetailApp({
onOpenDetail={() => setDrawerOpen('facts')} onOpenDetail={() => setDrawerOpen('facts')}
onOpenTeeth={() => setDrawerOpen('teeth')} onOpenTeeth={() => setDrawerOpen('teeth')}
/> />
<TreatmentHistoryCard facts={facts} /> <TreatmentHistoryCard
facts={facts}
onOpenMedical={() => setDrawerOpen('medical')}
onOpenTimeline={() => setDrawerOpen('treatments')}
/>
{/* 召回建议 — 暂时隐藏(SuggestionCard) */} {/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && ( {recallHistory.length > 0 && (
<RecallHistoryCard history={recallHistory} fmtRel={fmtRel} /> <RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
)} )}
{returnVisits.length > 0 && <ReturnVisitsCard visits={returnVisits} />} {returnVisits.length > 0 && (
<ReturnVisitsCard
planId={plan.id}
visits={returnVisits}
onOpenDetail={() => setDrawerOpen('return-visits')}
/>
)}
{/* 治疗链卡片已隐藏(链已弃用;chains 仍传 drawer 备用)*/} {/* 治疗链卡片已隐藏(链已弃用;chains 仍传 drawer 备用)*/}
</aside> </aside>
} }
centerPane={ centerPane={
<main className="relative min-h-0 flex flex-col h-full"> <main className="relative min-h-0 min-w-0 flex flex-col h-full">
<section className="bg-white rounded-lg border border-slate-100 shadow-sm flex flex-col min-h-0 flex-1 overflow-hidden"> <section className="bg-white rounded-lg border border-slate-100 shadow-sm flex flex-col min-h-0 flex-1 overflow-hidden">
{/* 窄屏 flex-wrap 自然换行,gap-y 给行间距 */} {/* 窄屏 flex-wrap 自然换行,gap-y 给行间距 */}
<header className="flex-none px-3 sm:px-4 py-2.5 border-b border-slate-100 flex flex-wrap items-center justify-between gap-x-2 gap-y-2"> <header className="flex-none px-3 sm:px-4 py-2.5 border-b border-slate-100">
{/* 第一行:标题 + 标签 ←→ 控件(模式切换 / 重新生成 / AI 时间戳)*/}
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2> <h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2>
...@@ -364,9 +376,6 @@ export function PlanDetailApp({ ...@@ -364,9 +376,6 @@ export function PlanDetailApp({
</span> </span>
)} )}
</div> </div>
<p className="text-[10.5px] text-slate-500 mt-0.5">
{`共 ${displayedSections.length} 步`}
</p>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2"> <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{/* 话术 3 模式切换:伴飞 / 卡片 / 原文 */} {/* 话术 3 模式切换:伴飞 / 卡片 / 原文 */}
...@@ -418,6 +427,11 @@ export function PlanDetailApp({ ...@@ -418,6 +427,11 @@ export function PlanDetailApp({
)} )}
</span> </span>
</div> </div>
</div>
{/* 第二行:本次召回一句话简报(LLM:谁/解决什么/到诊做什么;生成中 shimmer,失败回退结构化原因)*/}
<div className="text-[11.5px] text-slate-600 leading-snug mt-1.5">
<RecallBriefLine planId={plan.id} visibleReasons={visibleReasons} />
</div>
</header> </header>
<div className="flex-1 min-h-0 overflow-y-auto p-4"> <div className="flex-1 min-h-0 overflow-y-auto p-4">
{deepSteps && deepSteps.length > 0 && ( {deepSteps && deepSteps.length > 0 && (
...@@ -492,6 +506,7 @@ export function PlanDetailApp({ ...@@ -492,6 +506,7 @@ export function PlanDetailApp({
reasons={reasons} reasons={reasons}
facts={facts} facts={facts}
fmtRel={fmtRel} fmtRel={fmtRel}
returnVisits={returnVisits}
summaryOverride={summaryOverride} summaryOverride={summaryOverride}
summaryStreaming={summaryStreaming} summaryStreaming={summaryStreaming}
onRegenerateSummary={() => void regenerateSummary(plan.id)} onRegenerateSummary={() => void regenerateSummary(plan.id)}
...@@ -947,12 +962,10 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) { ...@@ -947,12 +962,10 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) {
// ────────────────────────────────────────── // ──────────────────────────────────────────
function IdentityCard({ function IdentityCard({
patient, patient,
features,
onOpenImage, onOpenImage,
onOpenProfile, onOpenProfile,
}: { }: {
patient: typeof mockPatient; patient: typeof mockPatient;
features: typeof mockPersona.features;
onOpenImage: () => void; onOpenImage: () => void;
onOpenProfile: () => void; onOpenProfile: () => void;
}) { }) {
...@@ -1102,55 +1115,91 @@ function IdentityCard({ ...@@ -1102,55 +1115,91 @@ function IdentityCard({
</span> </span>
)} )}
</div> </div>
{/* 画像标签(并入身份卡)— hover 看"是什么 + 怎么算的";无标题/分割线/详情入口(内部细节不外露)*/}
{features.length > 0 && (
<div className="mt-2">
<PersonaTagCloud features={features} />
</div>
)}
</div> </div>
</div> </div>
</section> </section>
); );
} }
// WhyCard — 召回原因列表(W3 末:plan_reasons 按 sub_key 拆分;
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染,关键字高亮。reason 文本仅作 signals 缺失时 fallback)
// ────────────────────────────────────────── // ──────────────────────────────────────────
function WhyCard({ // PersonaTagsCard — 画像标签(独立卡片,放身份卡下方)
visibleReasons, // 交互跟「历史联系」一致:卡片只展示 LLM 一句话**画像重点**(一眼抓重点);
onOpenMedical, // 标签云 + 规则收进「详情」抽屉(Drawer kind='persona':标签属性 + 取值条目 + ? hover 规则)。
// 进卡片 get-or-generate:已生成秒回直接显示;未生成则 shimmer 占位,后端当场生成完替换。
// ──────────────────────────────────────────
function PersonaTagsCard({
planId,
features,
onOpenDetail,
}: { }: {
// 召回算法产出的全部 reason(治疗链已弃用,不再做替代闭环二次过滤),这里只渲染。 planId: string;
// 跟下方 诊断/目标治疗 标签共用同一份,口径统一。 features: typeof mockPersona.features;
visibleReasons: PlanReason[]; onOpenDetail: () => void;
onOpenMedical: () => void;
}) { }) {
if (visibleReasons.length === 0) { const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
setLoading(true);
plansApi
.getPersonaSummary(planId)
.then((r) => {
if (alive) setSummary(r.status === 'ready' ? r.summary : null);
})
.catch(() => {
/* 生成失败:静默,显示兜底行 */
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [planId]);
return ( return (
<SidebarCard <SidebarCard
title="为什么召回" title="画像标签"
meta={features.length > 0 ? `${features.length} 项` : undefined}
action={ action={
<button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline"> <button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
病历快读 详情
</button> </button>
} }
> >
<div className="text-[11.5px] text-slate-400 italic">所有召回原因均已被替代方案覆盖,可不召回</div> {summary ? (
// 已生成 → 一句话画像重点
<p className="text-[12.5px] text-slate-700 leading-relaxed">{summary}</p>
) : loading ? (
// 生成中 → 文字闪烁占位(像 AI 话术生成)
<div className="animate-pulse space-y-1.5 py-0.5">
<div className="h-3 rounded bg-slate-100" />
<div className="h-3 w-3/4 rounded bg-slate-100" />
</div>
) : features.length > 0 ? (
// 兜底(生成失败/无摘要)→ 退回标签云,信息不丢
<PersonaTagCloud features={features} />
) : (
<p className="text-[12px] text-slate-400">暂无画像标签(数据不足)</p>
)}
</SidebarCard> </SidebarCard>
); );
} }
// ──────────────────────────────────────────
// RecallReasonLine — 本次召回原因(primary + N 其余 hover);
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染(reason 文本仅 signals 缺失时 fallback)。
// 原"为什么召回"卡片已去掉,此内容移到中栏「参考话术」标题下方(占掉原"共 N 步")。
// 召回算法产出的全部 reason(治疗链已弃用,不做替代闭环二次过滤),这里只渲染。
function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] }) {
if (visibleReasons.length === 0) {
return ( return (
<SidebarCard <span className="text-slate-400 italic">所有召回原因均已被替代方案覆盖,可不召回</span>
title="为什么召回" );
action={
<button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline">
病历快读 →
</button>
} }
> return (
{/* 跟列表页一致:只显示 primary(MAX priorityScore)一条,多的 +N;hover +N 看其余 */} <span className="text-slate-700">
<div className="text-[12.5px] text-slate-700 leading-relaxed">
<ReasonLine reason={visibleReasons[0]!} /> <ReasonLine reason={visibleReasons[0]!} />
{visibleReasons.length > 1 && ( {visibleReasons.length > 1 && (
<HoverCard openDelay={120} closeDelay={80}> <HoverCard openDelay={120} closeDelay={80}>
...@@ -1176,9 +1225,100 @@ function WhyCard({ ...@@ -1176,9 +1225,100 @@ function WhyCard({
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
)} )}
</span>
);
}
// ──────────────────────────────────────────
// RecallBriefLine — 本次召回一句话简报(LLM)
// 交互跟「历史联系 / 画像标签」一致:进来 get-or-generate;有则秒回显示一句话,
// 生成中 shimmer 占位,失败/空则回退到结构化 RecallReasonLine(信息不丢)。
// 一句话回答三问:患者是谁 / 帮其解决什么问题 / 邀约到诊做什么。
// ──────────────────────────────────────────
function RecallBriefLine({
planId,
visibleReasons,
}: {
planId: string;
visibleReasons: PlanReason[];
}) {
const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let alive = true;
setLoading(true);
plansApi
.getRecallBrief(planId)
.then((r) => {
if (alive) setSummary(r.status === 'ready' ? r.summary : null);
})
.catch(() => {
/* 生成失败:静默,回退结构化原因 */
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [planId]);
if (summary) {
// 主题色阶背景:左侧 teal 实条 + 向右渐隐的 teal 底,突出"这通电话的由头"。
// 右侧箭头展开 → 看摘要"压掉"的结构化召回原因(=以前的「为什么召回」,样式沿用 RecallReasonLine)。
const canExpand = visibleReasons.length > 0;
return (
<div>
<button
type="button"
onClick={canExpand ? () => setExpanded((v) => !v) : undefined}
aria-expanded={canExpand ? expanded : undefined}
className={cn(
'flex w-full items-start gap-2 rounded-md border-l-[3px] border-teal-400 bg-gradient-to-r from-teal-50 via-teal-50/60 to-transparent py-1.5 pl-2 pr-2 text-left',
canExpand ? 'cursor-pointer hover:from-teal-100/70' : 'cursor-default',
)}
>
<svg viewBox="0 0 24 24" className="mt-[1px] h-3.5 w-3.5 flex-none text-teal-500" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5M12 16h.01" strokeLinecap="round" />
</svg>
<span className="flex-1 min-w-0 font-medium text-teal-900 leading-snug">{summary}</span>
{canExpand && (
// 右指箭头(收起)→ 旋转 90° 朝下(展开)
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className={cn('mt-[1px] h-3.5 w-3.5 flex-none text-teal-500/70 transition-transform', expanded && 'rotate-90')}
>
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
{canExpand && expanded && (
// 展开 = 以前的「为什么召回」结构化原因(RecallReasonLine,样式与以前一致)
<div className="mt-1.5 rounded-md border border-slate-100 bg-white px-2.5 py-2">
<div className="text-[12.5px] text-slate-700 leading-relaxed">
<RecallReasonLine visibleReasons={visibleReasons} />
</div> </div>
</SidebarCard> </div>
)}
</div>
);
}
if (loading) {
return (
<span className="inline-flex w-full max-w-[420px] animate-pulse items-center gap-2 align-middle">
<span className="h-3 flex-1 rounded bg-slate-100" />
<span className="h-3 w-1/4 rounded bg-slate-100" />
</span>
); );
}
// 兜底:回退结构化召回原因
return <RecallReasonLine visibleReasons={visibleReasons} />;
} }
// ────────────────────────────────────────── // ──────────────────────────────────────────
...@@ -1331,7 +1471,16 @@ function treatmentLabel(f: AdaptedFact): string { ...@@ -1331,7 +1471,16 @@ function treatmentLabel(f: AdaptedFact): string {
return tooth ? `${name} ${formatToothPosition(tooth)}` : name; return tooth ? `${name} ${formatToothPosition(tooth)}` : name;
} }
function TreatmentHistoryCard({ facts }: { facts: AdaptedFact[] }) { function TreatmentHistoryCard({
facts,
onOpenMedical,
onOpenTimeline,
}: {
facts: AdaptedFact[];
onOpenMedical?: () => void;
/// 「详情」→ 打开患者事实时间轴(默认只勾选治疗,见 Drawer kind='treatments')
onOpenTimeline?: () => void;
}) {
const history = useMemo( const history = useMemo(
() => () =>
facts facts
...@@ -1353,7 +1502,24 @@ function TreatmentHistoryCard({ facts }: { facts: AdaptedFact[] }) { ...@@ -1353,7 +1502,24 @@ function TreatmentHistoryCard({ facts }: { facts: AdaptedFact[] }) {
}, [facts, history]); }, [facts, history]);
return ( return (
<SidebarCard title="治疗历史" meta={history.length > 0 ? `${history.length} 项` : undefined}> <SidebarCard
title="治疗历史"
meta={history.length > 0 ? `${history.length} 项` : undefined}
action={
<div className="flex items-center gap-2">
{onOpenMedical && (
<button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline">
病历快读 →
</button>
)}
{onOpenTimeline && (
<button onClick={onOpenTimeline} className="text-[10.5px] text-teal-700 hover:underline">
详情 →
</button>
)}
</div>
}
>
{/* 治疗计划(最新一条)— 置顶,与历史同一行格式,"治疗计划"标在最右 */} {/* 治疗计划(最新一条)— 置顶,与历史同一行格式,"治疗计划"标在最右 */}
{latestPlan && ( {latestPlan && (
<div className="mb-1 flex items-baseline gap-2 text-[11px]"> <div className="mb-1 flex items-baseline gap-2 text-[11px]">
...@@ -1551,41 +1717,63 @@ function RecallHistoryCard({ ...@@ -1551,41 +1717,63 @@ function RecallHistoryCard({
); );
} }
/// ReturnVisitsCard — 诊所回访记录(5 试点,展示用)。标题"历史联系"(替代原占位卡)。 /// ReturnVisitsCard — 历史联系(诊所回访)。标题"历史联系"。
/// 价值:客服看得到诊所已做/已排的回访(术后/常规/咨询)→ 避免重复外呼 + 拿上下文。 /// 交互:卡片只展示 LLM 一句话**重点摘要**(一眼抓重点);结构化记录收进「详情」抽屉。
function ReturnVisitsCard({ visits }: { visits: ReturnVisitItem[] }) { /// 进卡片 get-or-generate:已生成秒回直接显示;未生成则 shimmer 占位,后端当场生成完替换。
return ( function ReturnVisitsCard({
<SidebarCard title="历史联系" meta={`${visits.length} 条`}> planId,
<div className="space-y-1.5 max-h-[260px] overflow-y-auto pr-1"> visits,
{visits.map((v, i) => { onOpenDetail,
const done = v.status === '已回访' || v.taskStatus === '已完成'; }: {
planId: string;
visits: ReturnVisitItem[];
onOpenDetail: () => void;
}) {
const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
setLoading(true);
plansApi
.getRecallSummary(planId)
.then((r) => {
if (alive) setSummary(r.status === 'ready' ? r.summary : null);
})
.catch(() => {
/* 生成失败:静默,显示兜底行 */
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [planId]);
return ( return (
<div key={i} className="flex items-start gap-2 text-[11px] py-0.5"> <SidebarCard
<span title="历史联系"
className={`flex-none mt-[3px] w-1.5 h-1.5 rounded-full ${done ? 'bg-teal-400' : 'bg-slate-300'}`} meta={`${visits.length} 条`}
/> action={
<div className="flex-1 min-w-0"> <button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
<div className="flex items-center gap-1.5 flex-wrap"> 详情 →
<span className="font-medium text-slate-600">{v.type ?? '回访'}</span> </button>
{v.status && <span className="text-slate-400">· {v.status}</span>} }
{v.taskDate && <span className="text-slate-400 tabular-nums">{v.taskDate}</span>} >
{v.treatmentItems && ( {summary ? (
<span className="text-teal-700/70">· {v.treatmentItems}</span> // 已生成 → 一句话重点摘要
)} <p className="text-[12.5px] text-slate-700 leading-relaxed">{summary}</p>
</div> ) : loading ? (
{v.followContent && ( // 生成中 → 文字闪烁占位(像 AI 话术生成)
<div className="text-slate-500 leading-snug break-words mt-0.5"> <div className="animate-pulse space-y-1.5 py-0.5">
{v.followContent} <div className="h-3 rounded bg-slate-100" />
<div className="h-3 w-3/4 rounded bg-slate-100" />
</div> </div>
) : (
// 兜底(生成失败/无摘要)
<p className="text-[12px] text-slate-400">{visits.length} 条历史联系 · 点「详情」查看</p>
)} )}
{v.result && (
<div className="text-slate-400 leading-snug break-words mt-0.5">结果:{v.result}</div>
)}
</div>
</div>
);
})}
</div>
</SidebarCard> </SidebarCard>
); );
} }
...@@ -1697,10 +1885,8 @@ function PersonaTagCloud({ features }: { features: typeof mockPersona.features } ...@@ -1697,10 +1885,8 @@ function PersonaTagCloud({ features }: { features: typeof mockPersona.features }
// 话术生成模型选项(具体型号,直传后端) // 话术生成模型选项(具体型号,直传后端)
const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [ const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
{ key: 'deepseek-v4-pro', label: '慢 · 精细(DeepSeek V4 Pro)' }, { key: 'deepseek-v4-flash', label: '快 · 均衡' },
{ key: 'deepseek-v4-flash', label: '快 · 均衡(DeepSeek V4 Flash)' }, { key: 'qwen3.7-max', label: '极快 · 简洁' },
{ key: 'gemini-3.5-flash', label: '快 · 流畅(Gemini 3.5 Flash)' },
{ key: 'qwen3.7-max', label: '极快 · 简洁(Qwen 3.7 Max)' },
]; ];
// 投入档选项(跟模型并列,直传后端 tier) // 投入档选项(跟模型并列,直传后端 tier)
......
...@@ -102,7 +102,7 @@ function StepDetail({ step: s }: { step: DeepStep }) { ...@@ -102,7 +102,7 @@ function StepDetail({ step: s }: { step: DeepStep }) {
return ( return (
<ul className="space-y-0.5 text-[11px] text-slate-500"> <ul className="space-y-0.5 text-[11px] text-slate-500">
{d.outline.map((o, i) => ( {d.outline.map((o, i) => (
<li key={i} className="truncate"> <li key={i} className="break-words leading-snug">
<span className="text-slate-600">{o.title}</span> <span className="text-slate-600">{o.title}</span>
{o.intent && <span className="text-slate-400">{o.intent}</span>} {o.intent && <span className="text-slate-400">{o.intent}</span>}
</li> </li>
......
...@@ -38,6 +38,24 @@ export const plansApi = { ...@@ -38,6 +38,24 @@ export const plansApi = {
getAggregate: (planId: string) => getAggregate: (planId: string) =>
api.get<PlanDetailData>(`/pac/v1/plans/${encodeURIComponent(planId)}/full`), api.get<PlanDetailData>(`/pac/v1/plans/${encodeURIComponent(planId)}/full`),
/** 回访历史一句话摘要(有则取、无则当场生成;无回访记录 status='empty')*/
getRecallSummary: (planId: string) =>
api.get<{ summary: string | null; status: 'ready' | 'empty'; source?: string }>(
`/pac/v1/plans/${encodeURIComponent(planId)}/recall-summary`,
),
/** 画像标签一句话重点(有则取、无则当场生成;无 persona/feature status='empty')*/
getPersonaSummary: (planId: string) =>
api.get<{ summary: string | null; status: 'ready' | 'empty'; source?: string }>(
`/pac/v1/plans/${encodeURIComponent(planId)}/persona-summary`,
),
/** 本次召回一句话简报(谁/解决什么/到诊做什么;有则取、无则当场生成;无召回原因 status='empty')*/
getRecallBrief: (planId: string) =>
api.get<{ summary: string | null; status: 'ready' | 'empty'; source?: string }>(
`/pac/v1/plans/${encodeURIComponent(planId)}/recall-brief`,
),
/** 认领 / 指派 — POST /plans/{id}/assign { assigneeUserId } */ /** 认领 / 指派 — POST /plans/{id}/assign { assigneeUserId } */
assign: (planId: string, assigneeUserId: string) => assign: (planId: string, assigneeUserId: string) =>
api.post<PlanActionAck>(`/pac/v1/plans/${encodeURIComponent(planId)}/assign`, { api.post<PlanActionAck>(`/pac/v1/plans/${encodeURIComponent(planId)}/assign`, {
......
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