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';
import { PersonaFeatureHover } from './persona-feature-hover';
import type { Chain, PersonaFeature, PlanReason } from './mock-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({
open,
......@@ -25,6 +26,7 @@ export function Drawer({
reasons,
facts,
fmtRel,
returnVisits,
summaryOverride,
summaryStreaming,
onRegenerateSummary,
......@@ -44,6 +46,8 @@ export function Drawer({
/// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用)
facts: AdaptedFact[];
fmtRel: (d: Date) => string;
/// 历史联系(诊所回访)结构化记录 — 「历史联系」卡片「详情」抽屉用
returnVisits: ReturnVisitItem[];
/** AI 流式重生成结果(部分或完成);存在时覆盖 mock summaries 显示 */
summaryOverride?: {
medicalRecord?: string;
......@@ -92,13 +96,25 @@ export function Drawer({
subtitle = '按时间倒序';
body = <FactsTimeline facts={facts} />;
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') {
title = '牙位事实';
subtitle = '每颗牙 / 全口治疗线 · 时间倒序';
body = <ToothTimeline facts={facts} />;
width = 'w-[560px]';
} else if (kind === 'persona') {
title = '患者画像';
title = '画像标签';
subtitle = `更新于 ${fmtRel(persona.computedAt)} · ${persona.features.length} 项画像`;
body = (
<div className="space-y-3">
......@@ -106,13 +122,20 @@ export function Drawer({
{persona.features.map((f) => {
const T = tone(f.tone);
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 (
<div key={f.key} className="relative rounded-md border border-slate-100 p-3">
{/* 右上角 ? hover 看算法说明 */}
<PersonaFeatureHover featureKey={f.key}>
<div key={f.key} className="relative rounded-md border border-slate-100 p-3 pr-8">
{/* 右上角 ? hover 看规则说明(算法口径) */}
<PersonaFeatureHover featureKey={f.key} value={f.value}>
<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"
aria-label="查看算法说明"
aria-label="查看规则说明"
>
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
......@@ -120,6 +143,7 @@ export function Drawer({
</svg>
</span>
</PersonaFeatureHover>
{/* 属性名 */}
<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)} />
{f.label}
......@@ -129,7 +153,21 @@ export function Drawer({
</span>
)}
</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>
);
})}
......@@ -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';
* ③ 图标走 lucide-react(替代 inline SVG path,更易维护)
* ④ 按 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) {
return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>;
}
......@@ -44,7 +51,9 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
}, [facts]);
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 toggleAll = () => setSelected(allOn ? new Set() : new Set(allTypes));
const toggleType = (t: string) =>
......
......@@ -28,7 +28,8 @@ export function PersonaFeatureHover({
<HoverCard openDelay={150} closeDelay={80}>
{/* 直接把 children 作为 trigger,radix 锚定 children 本身的位置(避免多包一层 0 尺寸 span)*/}
<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="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>
......
......@@ -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">
<IdentityCard
patient={patient}
features={persona.features}
onOpenImage={() =>
showToast('slate', '影像调阅', '跳转宿主页面')
}
......@@ -324,9 +323,10 @@ export function PlanDetailApp({
showToast('slate', '患者档案', '跳转宿主页面')
}
/>
<WhyCard
visibleReasons={visibleReasons}
onOpenMedical={() => setDrawerOpen('medical')}
<PersonaTagsCard
planId={plan.id}
features={persona.features}
onOpenDetail={() => setDrawerOpen('persona')}
/>
<KeyFactsCard
patient={patient}
......@@ -336,20 +336,32 @@ export function PlanDetailApp({
onOpenDetail={() => setDrawerOpen('facts')}
onOpenTeeth={() => setDrawerOpen('teeth')}
/>
<TreatmentHistoryCard facts={facts} />
<TreatmentHistoryCard
facts={facts}
onOpenMedical={() => setDrawerOpen('medical')}
onOpenTimeline={() => setDrawerOpen('treatments')}
/>
{/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && (
<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 备用)*/}
</aside>
}
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">
{/* 窄屏 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="flex flex-wrap items-center gap-1.5">
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2>
......@@ -364,9 +376,6 @@ export function PlanDetailApp({
</span>
)}
</div>
<p className="text-[10.5px] text-slate-500 mt-0.5">
{`共 ${displayedSections.length} 步`}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{/* 话术 3 模式切换:伴飞 / 卡片 / 原文 */}
......@@ -418,6 +427,11 @@ export function PlanDetailApp({
)}
</span>
</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>
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{deepSteps && deepSteps.length > 0 && (
......@@ -492,6 +506,7 @@ export function PlanDetailApp({
reasons={reasons}
facts={facts}
fmtRel={fmtRel}
returnVisits={returnVisits}
summaryOverride={summaryOverride}
summaryStreaming={summaryStreaming}
onRegenerateSummary={() => void regenerateSummary(plan.id)}
......@@ -947,12 +962,10 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) {
// ──────────────────────────────────────────
function IdentityCard({
patient,
features,
onOpenImage,
onOpenProfile,
}: {
patient: typeof mockPatient;
features: typeof mockPersona.features;
onOpenImage: () => void;
onOpenProfile: () => void;
}) {
......@@ -1102,86 +1115,213 @@ function IdentityCard({
</span>
)}
</div>
{/* 画像标签(并入身份卡)— hover 看"是什么 + 怎么算的";无标题/分割线/详情入口(内部细节不外露)*/}
{features.length > 0 && (
<div className="mt-2">
<PersonaTagCloud features={features} />
</div>
)}
</div>
</div>
</section>
);
}
// WhyCard — 召回原因列表(W3 末:plan_reasons 按 sub_key 拆分;
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染,关键字高亮。reason 文本仅作 signals 缺失时 fallback)
// ──────────────────────────────────────────
function WhyCard({
visibleReasons,
onOpenMedical,
// PersonaTagsCard — 画像标签(独立卡片,放身份卡下方)
// 交互跟「历史联系」一致:卡片只展示 LLM 一句话**画像重点**(一眼抓重点);
// 标签云 + 规则收进「详情」抽屉(Drawer kind='persona':标签属性 + 取值条目 + ? hover 规则)。
// 进卡片 get-or-generate:已生成秒回直接显示;未生成则 shimmer 占位,后端当场生成完替换。
// ──────────────────────────────────────────
function PersonaTagsCard({
planId,
features,
onOpenDetail,
}: {
// 召回算法产出的全部 reason(治疗链已弃用,不再做替代闭环二次过滤),这里只渲染。
// 跟下方 诊断/目标治疗 标签共用同一份,口径统一。
visibleReasons: PlanReason[];
onOpenMedical: () => void;
planId: string;
features: typeof mockPersona.features;
onOpenDetail: () => void;
}) {
if (visibleReasons.length === 0) {
return (
<SidebarCard
title="为什么召回"
action={
<button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline">
病历快读 →
</button>
}
>
<div className="text-[11.5px] text-slate-400 italic">所有召回原因均已被替代方案覆盖,可不召回</div>
</SidebarCard>
);
}
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 (
<SidebarCard
title="为什么召回"
title="画像标签"
meta={features.length > 0 ? `${features.length} 项` : undefined}
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>
}
>
{/* 跟列表页一致:只显示 primary(MAX priorityScore)一条,多的 +N;hover +N 看其余 */}
<div className="text-[12.5px] text-slate-700 leading-relaxed">
<ReasonLine reason={visibleReasons[0]!} />
{visibleReasons.length > 1 && (
<HoverCard openDelay={120} closeDelay={80}>
<HoverCardTrigger asChild>
<button className="ml-1.5 align-middle rounded px-1 text-[10.5px] text-slate-400 hover:text-teal-700 hover:bg-slate-100 cursor-default">
+{visibleReasons.length - 1}
</button>
</HoverCardTrigger>
<HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
<p className="text-[11px] font-medium text-slate-500 mb-1.5">
其余 {visibleReasons.length - 1} 项应治未治
</p>
<ul className="space-y-1.5 text-[12px] text-slate-700 leading-relaxed">
{visibleReasons.slice(1).map((r) => (
<li key={r.id} className="flex gap-1.5">
<span className="text-rose-400 flex-none mt-[2px]"></span>
<div className="flex-1 min-w-0">
<ReasonLine reason={r} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
)}
</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>
);
}
// ──────────────────────────────────────────
// RecallReasonLine — 本次召回原因(primary + N 其余 hover);
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染(reason 文本仅 signals 缺失时 fallback)。
// 原"为什么召回"卡片已去掉,此内容移到中栏「参考话术」标题下方(占掉原"共 N 步")。
// 召回算法产出的全部 reason(治疗链已弃用,不做替代闭环二次过滤),这里只渲染。
function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] }) {
if (visibleReasons.length === 0) {
return (
<span className="text-slate-400 italic">所有召回原因均已被替代方案覆盖,可不召回</span>
);
}
return (
<span className="text-slate-700">
<ReasonLine reason={visibleReasons[0]!} />
{visibleReasons.length > 1 && (
<HoverCard openDelay={120} closeDelay={80}>
<HoverCardTrigger asChild>
<button className="ml-1.5 align-middle rounded px-1 text-[10.5px] text-slate-400 hover:text-teal-700 hover:bg-slate-100 cursor-default">
+{visibleReasons.length - 1}
</button>
</HoverCardTrigger>
<HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
<p className="text-[11px] font-medium text-slate-500 mb-1.5">
其余 {visibleReasons.length - 1} 项应治未治
</p>
<ul className="space-y-1.5 text-[12px] text-slate-700 leading-relaxed">
{visibleReasons.slice(1).map((r) => (
<li key={r.id} className="flex gap-1.5">
<span className="text-rose-400 flex-none mt-[2px]"></span>
<div className="flex-1 min-w-0">
<ReasonLine reason={r} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</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>
)}
</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} />;
}
// ──────────────────────────────────────────
// KeyFactsCard — 关键事实(5 行)
// 主治医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人
//
......@@ -1331,7 +1471,16 @@ function treatmentLabel(f: AdaptedFact): string {
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(
() =>
facts
......@@ -1353,7 +1502,24 @@ function TreatmentHistoryCard({ facts }: { facts: AdaptedFact[] }) {
}, [facts, history]);
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 && (
<div className="mb-1 flex items-baseline gap-2 text-[11px]">
......@@ -1551,41 +1717,63 @@ function RecallHistoryCard({
);
}
/// ReturnVisitsCard — 诊所回访记录(5 试点,展示用)。标题"历史联系"(替代原占位卡)。
/// 价值:客服看得到诊所已做/已排的回访(术后/常规/咨询)→ 避免重复外呼 + 拿上下文。
function ReturnVisitsCard({ visits }: { visits: ReturnVisitItem[] }) {
/// ReturnVisitsCard — 历史联系(诊所回访)。标题"历史联系"。
/// 交互:卡片只展示 LLM 一句话**重点摘要**(一眼抓重点);结构化记录收进「详情」抽屉。
/// 进卡片 get-or-generate:已生成秒回直接显示;未生成则 shimmer 占位,后端当场生成完替换。
function ReturnVisitsCard({
planId,
visits,
onOpenDetail,
}: {
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 (
<SidebarCard title="历史联系" meta={`${visits.length} 条`}>
<div className="space-y-1.5 max-h-[260px] overflow-y-auto pr-1">
{visits.map((v, i) => {
const done = v.status === '已回访' || v.taskStatus === '已完成';
return (
<div key={i} className="flex items-start gap-2 text-[11px] py-0.5">
<span
className={`flex-none mt-[3px] 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-600">{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-500 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>
<SidebarCard
title="历史联系"
meta={`${visits.length} 条`}
action={
<button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
详情 →
</button>
}
>
{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>
) : (
// 兜底(生成失败/无摘要)
<p className="text-[12px] text-slate-400">{visits.length} 条历史联系 · 点「详情」查看</p>
)}
</SidebarCard>
);
}
......@@ -1697,10 +1885,8 @@ function PersonaTagCloud({ features }: { features: typeof mockPersona.features }
// 话术生成模型选项(具体型号,直传后端)
const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
{ key: 'deepseek-v4-pro', label: '慢 · 精细(DeepSeek V4 Pro)' },
{ key: 'deepseek-v4-flash', label: '快 · 均衡(DeepSeek V4 Flash)' },
{ key: 'gemini-3.5-flash', label: '快 · 流畅(Gemini 3.5 Flash)' },
{ key: 'qwen3.7-max', label: '极快 · 简洁(Qwen 3.7 Max)' },
{ key: 'deepseek-v4-flash', label: '快 · 均衡' },
{ key: 'qwen3.7-max', label: '极快 · 简洁' },
];
// 投入档选项(跟模型并列,直传后端 tier)
......
......@@ -102,7 +102,7 @@ function StepDetail({ step: s }: { step: DeepStep }) {
return (
<ul className="space-y-0.5 text-[11px] text-slate-500">
{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>
{o.intent && <span className="text-slate-400">{o.intent}</span>}
</li>
......
......@@ -38,6 +38,24 @@ export const plansApi = {
getAggregate: (planId: string) =>
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 } */
assign: (planId: string, assigneeUserId: string) =>
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