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>
......
......@@ -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