Commit 8d708bac by luoqi

feat(web/plan-detail): responsive layout + algorithm hover cards + MD key fix

新组件:
  - priority-hover.tsx — 优先级数字悬浮展示 6 因子 breakdown
    (timeWindowFactor / valueBonus / urgencyBonus / signalQualityDiscount 等),
    无 breakdown 时 fallback 简版文案
  - persona-feature-hover.tsx — 患者画像卡片右上角悬浮算法说明
    (key → 中文映射 + 算法 evidence + 当前 score)

plan-detail-app.tsx:
  ResponsiveDetail wrapper — xl: 三栏 grid;<xl: shadcn Tabs 折叠
  TopBar 响应式隐藏/显示;PriorityHover 接入

chain-viz.tsx:
  加 "★ 再启新链" 状态(目标 + ongoing/entered)— 上条治疗链未闭环又再次诊断同
  类型时,以"再启新链"语义渲染,跟普通 discovered 区分

reason-line.tsx:
  多 trigger 来源标签:typeSet.size > 1 → "诊断+医生建议"(过去只显单一)

shared.tsx — MD parser 修 React 重复 key 警告:
  blockquote/bullet 内 while 消费多行后 i 已前移,push 时 key={i} 跟下一个
  paragraph push 的 key={i} 相同 → React reconciliation 报 duplicate key 3,
  视觉表现为 blockquote 被重复渲染。改用独立 key++ 计数,跟 line index i 解耦。

drawer.tsx / task-drawer.tsx:
  drawer cards 右上角接入 persona-feature-hover;英文 key 中文映射统一
parent 140c9003
......@@ -48,23 +48,33 @@ type ChainStatusVisual = {
tone: 'rose' | 'amber' | 'sky' | 'emerald' | 'slate';
};
function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage' | 'target'>): ChainStatusVisual {
// W4 末:口径统一 — entered/ongoing/closed 都不该 ★(SQL ⑤d 已排除 entered,⑤a 排除 ongoing)
// chain 内部状态 = 视觉真理源,target=true 只对 discovered 才上 ★
// target+entered/ongoing 出现 = SQL 漏排或老 plan,按 chain 真实 status 显示更不误导
// closed 永不 ★(临床已结束)
if (chain.status === 'closed') return { short: '已闭环', long: '✓ 已闭环', icon: '✓', tone: 'emerald' };
// ⭐ target + entered/ongoing = 老链已在管,又来新诊断 → SQL 重新召回 →"再启新链"
// 语义:不是凭空新建,是同 chain 上"上次治疗后又有新事件,该再启一轮"
// 典型:张吴双 牙周 2024.04.27 洁牙后 + 2026.03.27 新 K05 诊断 → 该再做基础治疗
if (chain.target && (chain.status === 'entered' || chain.status === 'ongoing')) {
return { short: '再启新链', long: '★ 再启新链', icon: '★', tone: 'rose' };
}
if (chain.status === 'entered') return { short: '已进入', long: '⏵ 已进入', icon: '⏵', tone: 'amber' };
if (chain.target && chain.status === 'discovered') {
return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' };
}
// W4 末:discovered + target=false(SQL 未召回 — 如同牙位拔除 ⑤c 排除 / cooldown 未过等)
// discovered + target=false(SQL 未召回 — 同牙位拔除 ⑤c 排除 / cooldown 未过等)
// 不再 ★ 误导客服;改"已发现 · 暂不召回",中性灰色表示"已识别但 SQL 评估暂不进入主流程"
// 典型 case:季根财 K05 全口 — 所有牙位都被同期 surgical 拔除,牙周治疗已无意义
// 典型:季根财 K05 全口 — 所有牙位都被同期 surgical 拔除,牙周治疗已无意义
if (chain.status === 'discovered') return { short: '已发现', long: '已发现 · 暂不召回', icon: '⊙', tone: 'slate' };
// ongoing — 按 stage 区分"治疗中" / "复查中" (业务上是不同语义,客服需要知道病人在做啥)
// ongoing(无 target)— 按 stage 区分"治疗中" / "复查中"
const sub = chain.currentStage >= 4 ? '复查中' : '治疗中';
return { short: `在管 · ${sub}`, long: `↻ 在管 · ${sub}`, icon: '↻', tone: 'sky' };
}
/// corner badge 文案 — 跟 chainStatusVisual 同口径,但只输出 ★ 那两档(target=true 才用)
function chainTargetCornerLabel(chain: Pick<Chain, 'status' | 'target'>): string {
if (!chain.target) return '';
return chain.status === 'discovered' ? '★ 潜在新链' : '★ 再启新链';
}
// 5 状态徽章(W3 末从 3 态升级)— 后端 chain-composer 5 阶段引擎产物
function ChainStatusBadge({ status, currentStage, target }: { status: Chain['status']; currentStage: Chain['currentStage']; target?: boolean }) {
const v = chainStatusVisual({ status, currentStage, target });
......@@ -204,10 +214,9 @@ function TargetTimelineRow({ chain }: { chain: Chain }) {
const theme = themeOfStatus(chain.status);
return (
<div className="relative rounded-lg border border-rose-200 bg-gradient-to-br from-rose-50/50 to-white px-3 pt-3 pb-2 ring-1 ring-rose-100">
{/* 左上角 corner badge 已表达"★ 潜在新链 = discovered" 状态,
标题旁不再重复 ChainStatusBadge */}
{/* 左上角 corner badge 表达 ★ 状态(★ 潜在新链 / ★ 再启新链),标题旁不再重复 ChainStatusBadge */}
<span className="absolute -top-2 left-3 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-rose-600 text-white text-[9.5px] font-medium shadow-sm">
★ 潜在新链
{chainTargetCornerLabel(chain)}
</span>
<div className="flex items-center gap-2 mb-2 min-w-0">
<span className="text-[13.5px] font-semibold text-slate-900 truncate" title={chain.name}>
......@@ -238,8 +247,18 @@ function TargetTimelineRow({ chain }: { chain: Chain }) {
/>
</svg>
<div className="text-rose-800 leading-tight min-w-0 truncate">
<strong className="font-semibold">链断口</strong> · 已诊断 {chain.diagnosedAt}
<strong className="font-semibold tabular-nums"> · {formatDaysReadable(chain.gapDays)}未进入治疗链</strong>
{chain.status === 'discovered' ? (
<>
<strong className="font-semibold">链断口</strong> · 已诊断 {chain.diagnosedAt}
<strong className="font-semibold tabular-nums"> · {formatDaysReadable(chain.gapDays)}未进入治疗链</strong>
</>
) : (
// 再启新链:链已 ongoing/entered,gapDays 用最早诊断算误导(实际有 actual)
// 用相对说法,避免误用 diagnosedAt;后续后端给 latestDxAt + lastActualAt 再精化
<>
<strong className="font-semibold">再启信号</strong> · 在管治疗链上又被诊断 <strong className="font-semibold">建议再启一轮基础治疗</strong>
</>
)}
<span className="text-rose-600/80 ml-1">{chainTargetMeta(chain.category).window}</span>
</div>
</div>
......@@ -363,7 +382,7 @@ function ChainSidebarRow({ chain }: { chain: Chain }) {
return (
<div className="relative rounded-md border border-rose-300 bg-gradient-to-br from-rose-50 to-white px-2.5 pt-2 pb-2 ring-1 ring-rose-100">
<span className="absolute -top-1.5 left-2 inline-flex items-center px-1.5 py-px rounded-full bg-rose-600 text-white text-[9px] font-bold shadow-sm">
★ 潜在新链
{chainTargetCornerLabel(chain)}
</span>
<div className="flex items-center justify-between gap-2 mt-0.5">
<span className="text-[12px] font-semibold text-slate-900 truncate" title={chain.name}>
......@@ -371,9 +390,12 @@ function ChainSidebarRow({ chain }: { chain: Chain }) {
</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
<MiniDots reach={1} closed={false} target />
{/* discovered=只到 S1;ongoing/entered 用真实 currentStage 反映已走到第几步 */}
<MiniDots reach={chain.status === 'discovered' ? 1 : chain.currentStage} closed={false} target />
<span className="text-[10px] text-rose-600 font-semibold tabular-nums">
{formatDaysReadable(chain.gapDays)}断口
{chain.status === 'discovered'
? <>{formatDaysReadable(chain.gapDays)}断口</>
: <>↻ 在管再诊</>}
</span>
</div>
<div className="text-[10.5px] text-slate-600 mt-1 leading-tight truncate" title={`${evidence || '已诊断'} · ${chain.diagnosedAt}`}>
......
......@@ -7,6 +7,7 @@ import { ChainDetailView } from './chain-viz';
import { EmrSoapView } from './emr-soap-view';
import { FactsTimeline } from './facts-timeline';
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';
......@@ -97,7 +98,19 @@ export function Drawer({
const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value);
return (
<div key={f.key} className="rounded-md border border-slate-200 p-3">
<div key={f.key} className="relative rounded-md border border-slate-200 p-3">
{/* 右上角 ? hover 看算法说明 */}
<PersonaFeatureHover featureKey={f.key}>
<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="查看算法说明"
>
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01" strokeLinecap="round" strokeLinejoin="round" />
</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}
......
'use client';
import * as React from 'react';
import { PersonaFeatureKey } from '@pac/types';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
/**
* PersonaFeatureHover — 患者画像 4 个 feature 的算法说明 hover card。
*
* 数据来源:apps/pac-service/src/modules/persona/features/*.feature.ts 顶部 JSDoc。
* 算法 doc 改动时同步这里(后续可考虑后端返回 description,前端零硬编码)。
*
* 触发器(children)在 feature 卡片右上角放一个 ? 图标。
*/
export function PersonaFeatureHover({
featureKey,
children,
}: {
featureKey: string;
children: React.ReactNode;
}) {
const meta = ALGORITHMS[featureKey];
if (!meta) return <>{children}</>;
return (
<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]">
<div className="space-y-2">
<div className="flex items-baseline justify-between border-b border-slate-200 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">{meta.title}</span>
<span className="text-[10.5px] text-slate-500">{meta.subtitle}</span>
</div>
{meta.formula && (
<div className="rounded bg-slate-50 px-2 py-1.5 font-mono text-[10.5px] text-slate-700 leading-relaxed">
{meta.formula}
</div>
)}
<ul className="space-y-1 text-slate-600 leading-relaxed">
{meta.rules.map((r, i) => (
<li key={i} className="flex gap-2">
<span className="text-slate-400 flex-none">·</span>
<span>
{r.label && <strong className="text-slate-800 mr-1">{r.label}</strong>}
{r.body}
</span>
</li>
))}
</ul>
{meta.note && (
<p className="border-t border-slate-100 pt-1.5 text-[10.5px] text-slate-500 leading-relaxed">
{meta.note}
</p>
)}
</div>
</HoverCardContent>
</HoverCard>
);
}
interface AlgoMeta {
title: string;
subtitle: string;
formula?: string;
rules: { label?: string; body: string }[];
note?: string;
}
const ALGORITHMS: Record<string, AlgoMeta> = {
[PersonaFeatureKey.VALUE]: {
title: '患者价值',
subtitle: '生命周期付费档位',
formula: '累计付费 = 付款 + 充值 − 退款',
rules: [
{ label: 'VIP 钻卡', body: '累计 ≥ ¥30,000' },
{ label: 'VIP 金卡', body: '累计 ≥ ¥10,000' },
{ label: 'VIP 银卡', body: '累计 ≥ ¥3,000' },
{ label: '普通付费', body: '累计 ≥ ¥500' },
{ label: '新客 / 未消费', body: '< ¥500' },
],
note: '档位影响召回优先级"价值加分"(0 / 2 / 5 / 10 / 20)',
},
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: {
title: '治疗链状态',
subtitle: '诊断 vs 实际治疗对齐',
rules: [
{ label: '缺口', body: '有诊断但未做对应类别治疗 → 召回核心信号' },
{ label: '在管', body: '诊断后有同类治疗记录,治疗链推进中' },
{ label: '闭环', body: '同类治疗已完成、无后续风险(牙周等终身维护类除外)' },
],
note: '诊断→治疗类别用统一映射表(如 K02 龋齿 → 充填/嵌体)',
},
[PersonaFeatureKey.RECALL_RISK]: {
title: '流失风险',
subtitle: '复发 + 长期未触达',
rules: [
{ label: '高', body: '距上次临床事件 ≥ 540 天 + 治疗链有缺口' },
{ label: '中', body: '距上次 ≥ 360 天,或 治疗链有缺口' },
{ label: '低', body: '距上次 ≥ 180 天' },
{ label: '无', body: '< 180 天 且 链无缺口' },
],
note: '影响召回优先级"转化加分"(无=0 / 低=2 / 中=4 / 高=6)',
},
[PersonaFeatureKey.DO_NOT_CONTACT_STATUS]: {
title: '不打扰状态',
subtitle: '合规硬约束',
rules: [
{ label: '不打扰', body: '主档标记不打扰 / 患者已故 / 有未关闭投诉记录' },
{ label: '可触达', body: '上述全无' },
],
note: '不打扰时召回引擎硬拦截,不生成 / 已生成的不派单。电话缺失算"待补充电话",不算不打扰',
},
};
......@@ -33,7 +33,15 @@ export interface ReasonLineInput {
export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
if (!reason.signals) return <span>{reason.reason}</span>;
const s = reason.signals;
const trig = s.triggers[0]; // 当前单源;多源 W5+ 再调
const trig = s.triggers[0]; // 第一个 trigger 给 code 展示(后端把 diagnosis 排前)
// 多 trigger 合并显示 source:cluster 同时含诊断+建议两种 sig → "(诊断+医生建议)"
const typeSet = new Set(s.triggers.map((t) => t.type));
const sourceLabel =
typeSet.size > 1
? [...typeSet].map(triggerTypeLabelZh).join('+')
: trig
? triggerTypeLabelZh(trig.type)
: '';
const subLabel = subLabelZh(reason.scenario, s.subKey);
const cats = s.expectedCategories.map(treatmentCategoryNameZh).join(' / ');
return (
......@@ -41,7 +49,7 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
<strong className="text-slate-900">{subLabel}</strong>
{s.toothPosition && ` · 牙位 ${formatToothPosition(s.toothPosition)}`}
{trig?.code && ` — ${diagnosisCodeNameZh(trig.code)}`}
{trig && `(${triggerTypeLabelZh(trig.type)})`}{' '}
{sourceLabel && `(${sourceLabel})`}{' '}
<strong className="text-rose-600 tabular-nums">{formatDaysReadable(s.daysSince)}</strong>
{cats && `,未启动 ${cats}`}
</span>
......
......@@ -194,6 +194,7 @@ export function MD({ text, className }: { text: string; className?: string }) {
const lines = text.split('\n');
const out: ReactNode[] = [];
let i = 0;
let key = 0; // ⭐ 顶层独立计数:不能用 i,因 blockquote/bullet 消费多行后 i 可能跟下一个 push 的 i 重复
while (i < lines.length) {
const line = lines[i]!;
if (line.trim() === '') {
......@@ -202,14 +203,14 @@ export function MD({ text, className }: { text: string; className?: string }) {
}
if (line.startsWith('### ')) {
out.push(
<h4 key={i} className="text-[13px] font-semibold text-slate-900 mt-3">
<h4 key={key++} className="text-[13px] font-semibold text-slate-900 mt-3">
{line.slice(4)}
</h4>,
);
i++;
} else if (line.startsWith('## ')) {
out.push(
<h3 key={i} className="text-[14px] font-semibold text-slate-900 mt-3">
<h3 key={key++} className="text-[14px] font-semibold text-slate-900 mt-3">
{line.slice(3)}
</h3>,
);
......@@ -222,7 +223,7 @@ export function MD({ text, className }: { text: string; className?: string }) {
}
out.push(
<blockquote
key={i}
key={key++}
className="my-2 pl-3 border-l-2 border-teal-300 bg-teal-50/40 py-2 pr-2 rounded-r text-[13px] text-slate-800 leading-relaxed"
>
{block.map((b, k) => (
......@@ -237,7 +238,7 @@ export function MD({ text, className }: { text: string; className?: string }) {
i++;
}
out.push(
<ul key={i} className="list-disc pl-5 my-1.5 text-[13px] text-slate-700 space-y-0.5">
<ul key={key++} className="list-disc pl-5 my-1.5 text-[13px] text-slate-700 space-y-0.5">
{items.map((t, k) => (
<li key={k} dangerouslySetInnerHTML={{ __html: inlineMD(t) }} />
))}
......@@ -246,7 +247,7 @@ export function MD({ text, className }: { text: string; className?: string }) {
} else {
out.push(
<p
key={i}
key={key++}
className="text-[13px] text-slate-700 leading-relaxed my-1"
dangerouslySetInnerHTML={{ __html: inlineMD(line) }}
/>,
......
......@@ -85,8 +85,8 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
return (
<>
{/* 隐式 hover 触发条 — 左边缘全高 */}
<div className="fixed left-0 top-0 bottom-0 w-3 z-40" onMouseEnter={() => setHover(true)}>
{/* 隐式 hover 触发条 — 左边缘全高。移动端(<md)无 hover,直接隐藏 */}
<div className="hidden md:block fixed left-0 top-0 bottom-0 w-3 z-40" onMouseEnter={() => setHover(true)}>
{!open && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1">
<span className="block w-1 h-12 bg-teal-500/40 rounded-r-full hover:bg-teal-600 transition-colors" />
......
'use client';
import * as React from 'react';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
/**
* PriorityHover — 优先级数字旁悬停展示 6 因子拆解。
*
* 数据来源:plan.reasons[0].breakdown.priority(后端 priority-scorer 输出)
* raw = (clinicalBase × timeWindowFactor + valueBonus + likelihoodBonus + urgencyBonus) × confidenceFactor
*
* 列表 + 详情页共用。breakdown 缺失时回退最简文案。
*/
export interface PriorityBreakdown {
raw?: number;
main?: number; // = clinicalBase × timeWindowFactor
clinicalBase?: number; // 临床基线(sub_scenario 类别)
timeWindowFactor?: number; // 时间窗(0-1)
valueBonus?: number; // 价值(persona value)
likelihoodBonus?: number; // 转化(历史触达 + recall_risk)
urgencyBonus?: number; // 紧迫(超临界天)
confidenceFactor?: number; // 信号置信(0-1)
}
export function PriorityHover({
score,
breakdown,
children,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
children: React.ReactNode;
}) {
return (
<HoverCard openDelay={150} closeDelay={80}>
{/* 直接把 children 作为 trigger,radix 锚定 children 本身(避免多包一层 span 错位)*/}
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent align="end" sideOffset={6} className="w-72 p-3 text-[11.5px]">
<PriorityBreakdownTable score={score} breakdown={breakdown} />
</HoverCardContent>
</HoverCard>
);
}
function PriorityBreakdownTable({
score,
breakdown,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
}) {
const total = Math.round(score);
if (!breakdown) {
// 老数据 / 异常兜底
return (
<div className="space-y-1.5">
<Header total={total} />
<p className="text-slate-500 leading-relaxed">
由 6 因子算分:临床基线 × 时间窗 + 价值 + 转化 + 紧迫,× 信号置信。
<br />
(此条召回缺明细 — 重新计算后可恢复)
</p>
</div>
);
}
const base = breakdown.clinicalBase ?? 0;
const tw = breakdown.timeWindowFactor ?? 0;
const main = breakdown.main ?? Math.round(base * tw);
const value = breakdown.valueBonus ?? 0;
const likelihood = breakdown.likelihoodBonus ?? 0;
const urgency = breakdown.urgencyBonus ?? 0;
const conf = breakdown.confidenceFactor ?? 1;
const subtotal = main + value + likelihood + urgency;
return (
<div className="space-y-1.5">
<Header total={total} />
<table className="w-full tabular-nums">
<tbody>
<Row label="临床基线" value={base.toFixed(0)} hint="召回场景类别基线分" />
<Row label="× 时间窗" value={tw.toFixed(2)} hint="医嘱黄金窗内剩余比例" />
<Subtotal label="主体" value={main.toFixed(0)} />
<Row label="+ 价值" value={`+${value.toFixed(0)}`} hint="患者价值档位加权" tone="emerald" />
<Row label="+ 转化" value={`+${likelihood.toFixed(0)}`} hint="历史触达成功率 + 流失风险" tone="emerald" />
<Row label="+ 紧迫" value={`+${urgency.toFixed(0)}`} hint="过黄金窗临界点" tone="emerald" />
<Subtotal label="小计" value={subtotal.toFixed(0)} />
<Row label="× 置信" value={conf.toFixed(2)} hint="信号置信(诊断 1.0 / 建议 0.8)" tone={conf < 1 ? 'amber' : undefined} />
<Subtotal label="= 总分" value={total.toString()} bold />
</tbody>
</table>
</div>
);
}
function Header({ total }: { total: number }) {
return (
<div className="flex items-baseline justify-between border-b border-slate-200 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">优先级 {total} / 100</span>
<span className="text-[10.5px] text-slate-500">6 因子算分</span>
</div>
);
}
function Row({
label,
value,
hint,
tone,
}: {
label: string;
value: string;
hint?: string;
tone?: 'emerald' | 'amber';
}) {
const valueClass =
tone === 'emerald' ? 'text-emerald-700 font-medium'
: tone === 'amber' ? 'text-amber-700 font-medium'
: 'text-slate-700';
return (
<tr>
<td className="py-0.5 text-slate-600">{label}</td>
<td className={`py-0.5 text-right ${valueClass}`}>{value}</td>
<td className="py-0.5 pl-2 text-[10.5px] text-slate-400">{hint ?? ''}</td>
</tr>
);
}
function Subtotal({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<tr>
<td colSpan={3} className="border-t border-slate-100 pt-1 pb-0.5">
<span className="flex justify-between">
<span className={`text-slate-700 ${bold ? 'font-semibold' : ''}`}>{label}</span>
<span className={`tabular-nums ${bold ? 'text-rose-700 font-bold' : 'text-slate-800 font-medium'}`}>{value}</span>
</span>
</td>
</tr>
);
}
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