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: '不打扰时召回引擎硬拦截,不生成 / 已生成的不派单。电话缺失算"待补充电话",不算不打扰',
},
};
......@@ -12,12 +12,15 @@ import {
} from '@/lib/utils';
import { PersonaFeatureKey, treatmentCategoryNameZh } from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display';
import { ReasonLine } from './reason-line';
import { ChainSidebar } from './chain-viz';
import { ScriptView, type ScriptViewMode } from './script-viewer';
import { OutcomeForm } from './outcome-form';
import { Drawer, type DrawerKind } from './drawer';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useMediaQuery } from '@/hooks/use-media-query';
import {
mockPatient,
mockChains,
......@@ -206,11 +209,12 @@ export function PlanDetailApp({
showToast={showToast}
/>
<div className="flex-1 min-h-0">
<div className="h-full mx-auto px-5 py-3">
<div className="grid h-full gap-3" style={{ gridTemplateColumns: '300px 1fr 380px' }}>
{/* ─── LEFT ─── */}
<aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1">
{/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
抽出 leftPane/centerPane/rightPane 单实例,grid 和 tabs 共用,避免 state desync */}
<ResponsiveDetail
isXl={useMediaQuery('(min-width: 1280px)')}
leftPane={
<aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1 h-full">
<IdentityCard
patient={patient}
onOpenImage={() =>
......@@ -266,12 +270,13 @@ export function PlanDetailApp({
<PersonaQuickList features={persona.features.slice(0, 4)} />
</SidebarCard>
</aside>
{/* ─── CENTER: SCRIPT ─── */}
<main className="min-h-0 flex flex-col">
}
centerPane={
<main className="min-h-0 flex flex-col h-full">
<section className="bg-white rounded-lg border border-slate-200 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-3">
<div>
{/* 窄屏 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">
<div className="min-w-0">
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2>
<p className="text-[10.5px] text-slate-500 mt-0.5">
{displayedSections.length}
......@@ -283,9 +288,9 @@ export function PlanDetailApp({
)}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{/* 话术 3 模式切换:伴飞 / 卡片 / 原文 */}
<div className="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11.5px]">
<div className="inline-flex flex-none items-center rounded-md bg-slate-100 p-0.5 text-[11.5px]">
{([
['copilot', '伴飞'],
['cards', '卡片'],
......@@ -295,7 +300,7 @@ export function PlanDetailApp({
key={m}
onClick={() => setScriptMode(m)}
className={cn(
'px-2.5 py-1 rounded transition-colors',
'px-2 sm:px-2.5 py-1 rounded transition-colors',
scriptMode === m
? 'bg-white font-semibold text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-800',
......@@ -316,16 +321,19 @@ export function PlanDetailApp({
void regenerate(plan.id);
}}
/>
<AIStamp
relative={
streamState.status === 'done'
? '刚刚'
: fmtRel(script.generatedAt)
}
source={
streamState.status === 'done' ? streamState.source : script.source
}
/>
{/* AI 时间戳 — 窄屏隐藏(信息不关键,腾空间) */}
<span className="hidden md:inline-flex">
<AIStamp
relative={
streamState.status === 'done'
? '刚刚'
: fmtRel(script.generatedAt)
}
source={
streamState.status === 'done' ? streamState.source : script.source
}
/>
</span>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-4">
......@@ -361,9 +369,9 @@ export function PlanDetailApp({
/>
</section>
</main>
{/* ─── RIGHT: OUTCOME ─── */}
<aside className="min-h-0 flex flex-col gap-2.5 overflow-hidden">
}
rightPane={
<aside className="min-h-0 flex flex-col gap-2.5 overflow-hidden h-full">
<section className="bg-white rounded-lg border border-slate-200 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">
<div>
......@@ -383,9 +391,8 @@ export function PlanDetailApp({
</div>
</section>
</aside>
</div>
</div>
</div>
}
/>
<Drawer
open={!!drawerOpen}
......@@ -406,6 +413,56 @@ export function PlanDetailApp({
}
// ──────────────────────────────────────────
// ResponsiveDetail — xl ≥1280 走 3 列 grid;<xl 走 shadcn Tabs(原因/话术/操作)
// Tabs 默认 "script" 优先(客服主战场是话术,先看话术再切原因/操作)
// 3 个 pane 是单实例 ReactNode(父组件创建),无 state desync
// ──────────────────────────────────────────
function ResponsiveDetail({
isXl,
leftPane,
centerPane,
rightPane,
}: {
isXl: boolean;
leftPane: ReactNode;
centerPane: ReactNode;
rightPane: ReactNode;
}) {
return (
<div className="flex-1 min-h-0">
<div className="h-full mx-auto px-5 py-3">
{isXl ? (
<div className="grid h-full gap-3" style={{ gridTemplateColumns: '300px 1fr 380px' }}>
{leftPane}
{centerPane}
{rightPane}
</div>
) : (
<Tabs defaultValue="script" className="h-full flex flex-col">
<div className="flex-none flex justify-center pb-2">
<TabsList>
<TabsTrigger value="why">原因 · 画像</TabsTrigger>
<TabsTrigger value="script">话术</TabsTrigger>
<TabsTrigger value="outcome">操作</TabsTrigger>
</TabsList>
</div>
<TabsContent value="why" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
{leftPane}
</TabsContent>
<TabsContent value="script" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
{centerPane}
</TabsContent>
<TabsContent value="outcome" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
{rightPane}
</TabsContent>
</Tabs>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────
// TopBar
// ──────────────────────────────────────────
function TopBar({
......@@ -452,29 +509,35 @@ function TopBar({
};
return (
<header className="flex flex-none items-center justify-between gap-3 border-b border-slate-200 bg-white px-5 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="inline-flex items-center gap-2">
<header className="flex flex-none items-center justify-between gap-2 border-b border-slate-200 bg-white px-3 py-2 sm:px-5 sm:py-3 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<div className="inline-flex flex-none items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
PAC
</span>
<span className="text-[13px] font-semibold text-slate-900">疗效保障</span>
{/* "疗效保障"标题字在窄屏隐藏,腾出空间给关键信息 */}
<span className="hidden lg:inline text-[13px] font-semibold text-slate-900">疗效保障</span>
</div>
<span className="text-slate-300">/</span>
<span className="hidden lg:inline text-slate-300">/</span>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-slate-900">任务详情</h1>
<p className="mt-0.5 truncate text-[11px] text-slate-500">
召回中心
</p>
<h1 className="truncate text-[14px] sm:text-[15px] font-semibold leading-tight text-slate-900">任务详情</h1>
<p className="hidden sm:block mt-0.5 truncate text-[11px] text-slate-500">召回中心</p>
</div>
{/* 保留要素:scenario chip + 优先级条 */}
<Chip tone="rose" icon size="xs" className="ml-2">
{/* scenario chip + 优先级 — 始终显示,核心信息 */}
<Chip tone="rose" icon size="xs" className="ml-1 sm:ml-2 flex-none">
{reason.scenarioLabel}
</Chip>
<PriorityBar score={plan.priorityScore} label="优先级" />
<PriorityHover
score={plan.priorityScore}
breakdown={(plan.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
>
<span className="hidden md:inline-flex cursor-help flex-none">
<PriorityBar score={plan.priorityScore} label="优先级" />
</span>
</PriorityHover>
</div>
<div className="flex flex-none items-center gap-3 text-[11px] text-slate-600">
{/* W4 末:从 DW 直连重拉该 patient 最新数据(单患者,跟 daily incremental cursor 隔离)*/}
<div className="flex flex-none items-center gap-1.5 sm:gap-3 text-[11px] text-slate-600">
{/* 刷新 — 窄屏只显图标节省空间 */}
{patientId && (
<button
type="button"
......@@ -482,24 +545,24 @@ function TopBar({
disabled={refreshing}
title={refreshing ? '正在从 DW 拉数据并重算…' : '从 DW 直连重拉该患者最新数据 → 触发画像+召回重算'}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11.5px] font-medium transition-colors',
'inline-flex items-center gap-1.5 rounded-md border px-2 sm:px-2.5 py-1 text-[11.5px] font-medium transition-colors',
refreshing
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-slate-200 bg-white text-slate-700 hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700',
)}
>
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
<span>{refreshing ? '同步中…' : '刷新'}</span>
<span className="hidden sm:inline">{refreshing ? '同步中…' : '刷新'}</span>
</button>
)}
{/* 保留要素:回收倒计时 */}
{/* 回收倒计时 — 核心信息,始终显示 */}
<RecycleCountdown recycleAt={plan.recycleAt} />
{/* 跟列表页一致:用户信息 + 头像 */}
<div className="hidden text-right text-[11.5px] leading-tight text-slate-500 md:block">
{/* 用户名块 — md+ 才显,移动端只剩头像 */}
<div className="hidden md:block text-right text-[11.5px] leading-tight text-slate-500">
<div className="font-medium text-slate-700">{user?.sub ?? '—'}</div>
<div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {user?.role ?? '—'}</div>
</div>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
{(user?.sub ?? '?').charAt(0).toUpperCase()}
</span>
</div>
......@@ -521,18 +584,22 @@ function AIDisclaimerFooter({
onFeedback(v);
};
return (
<div className="flex-none border-t border-slate-100 px-4 py-2 flex items-center justify-between gap-3">
<div className="text-[10.5px] text-slate-500 flex items-center gap-1.5 leading-tight">
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
<div className="flex-none border-t border-slate-100 px-3 sm:px-4 py-2 flex items-center justify-between gap-2 sm:gap-3">
<div className="min-w-0 text-[10.5px] text-slate-500 flex items-center gap-1.5 leading-tight">
<svg viewBox="0 0 24 24" className="w-3 h-3 flex-none" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" strokeLinecap="round" />
</svg>
<span>
{/* 移动端只显短版,腾空间给反馈按钮 */}
<span className="hidden sm:inline">
本话术由 <strong className="text-slate-700">AI 辅助生成</strong>,请核对后使用 · 涉及医疗建议以医生意见为准
</span>
<span className="sm:hidden">
<strong className="text-slate-700">AI 辅助生成</strong>,请核对
</span>
</div>
<div className="flex items-center gap-1.5 text-[10.5px] text-slate-500">
<span>本段是否好用?</span>
<div className="flex flex-none items-center gap-1.5 text-[10.5px] text-slate-500">
<span className="hidden sm:inline">本段是否好用?</span>
<button
onClick={() => click('up')}
disabled={chosen !== null}
......
......@@ -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