Commit 7dc4ed0f by luoqi

feat(plan-detail ui): 参考话术 + 关键事实 + 治疗历史 + 病历 + 登录人名

参考话术:
- 去默认 demo 话术,未生成显示空态;原文模式默认全展开;诊断·目标治疗标签
- 4 段 id/标题对齐(opening/informMissed/reviewAdvice/closing),去时长显示

侧栏:
- 患者画像移到「为什么召回」下;关键事实默认展开,改 4 行(主治医生/专属客服/累计消费/保险客户-显示保险名)
- 为什么召回:只显示一条 + "+N" hover card 看其余(对齐列表页)
- 新增「治疗历史」卡(治疗计划置顶 + actual 倒序,标在右,4 行高滚动)+「历史联系」占位;隐藏召回建议

头部 + 病历:
- 去"普通患者"占位;病历号显示在名字后(括号)
- 头部登录用户显示人名(dictionary)+ 中文角色(userDisplayName/roleNameZh)
- 模拟登录按钮显示岗位·人名
- 病历快读 SOAP:P 段加「本次治疗」(actual 治疗,同次接诊)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 7115d68c
......@@ -39,6 +39,12 @@ const ROLES: { key: UserRole; nameZh: string; desc: string }[] = [
{ key: 'admin', nameZh: '管理员', desc: '全部权限 · 含后台管理' },
];
// 模拟身份的演示人名(需与后端 auth.service.MOCK_NAMES 保持一致)— 话术自报家门会用到
const MOCK_NAMES: Record<TenantSlug, Record<UserRole, string>> = {
ruier: { staff: '小王', leader: '李莉', admin: '张敏' },
ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' },
};
export function MockLoginDialog({ open }: { open: boolean }) {
const setTokens = useAuthStore((s) => s.setTokens);
const [busy, setBusy] = useState<string | null>(null); // 'ruier:staff' 等
......@@ -122,7 +128,7 @@ export function MockLoginDialog({ open }: { open: boolean }) {
)}
>
<div className="text-[12.5px] font-semibold text-slate-800">
{loading ? '登录中…' : r.nameZh}
{loading ? '登录中…' : `${r.nameZh} · ${MOCK_NAMES[t.slug][r.key]}`}
</div>
<div className="text-[10.5px] leading-tight text-slate-500">{r.desc}</div>
</button>
......
......@@ -8,6 +8,19 @@ import { ExecutionChannel, UserRole, personaFeatureMeta, planScenarioLabel } fro
import { fmtRel as sharedFmtRel } from '@pac/utils/format';
import type { Chain, PersonaFeature, PlanReason } from './mock-data';
import { mockScript, mockSummaries } from './mock-data';
/// 未生成话术时的空态(不再用 mockScript demo 兜底):4 段空 markdown,前端显示"尚未生成"。
const EMPTY_SCRIPT = {
status: 'empty',
source: null,
generatedAt: null,
sections: [
{ id: 'opening', label: '开场白', durationHint: '', markdown: '' },
{ id: 'informMissed', label: '告知应治未治', durationHint: '', markdown: '' },
{ id: 'reviewAdvice', label: '复查建议', durationHint: '', markdown: '' },
{ id: 'closing', label: '结束回访语', durationHint: '', markdown: '' },
],
} as unknown as typeof mockScript;
import type { PlanDetailData } from './plan-detail-types';
import type { TokenDictionary } from '@pac/types';
......@@ -25,6 +38,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
const patient = {
id: real.patient.id,
externalId: real.patient.externalId,
medicalRecordNumber: real.patient.medicalRecordNumber ?? null,
dedicatedCs: real.patient.dedicatedCs ?? null,
name: real.patient.name ?? '(未知)',
nameMasked: real.patient.nameMasked ?? '*',
gender: real.patient.gender ?? '—',
......@@ -32,7 +47,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
birthDate: real.patient.birthDate ?? '',
phone: real.patient.phone ?? '',
phoneMasked: real.patient.phoneMasked ?? '',
tags: real.patient.tags.length > 0 ? real.patient.tags : ['普通患者'],
tags: real.patient.tags, // 无运营标签时为空 — 不再硬塞"普通患者"占位
preferences: {
contactWindow: '19:00–21:30', // 还没数据源
preferredChannel: ExecutionChannel.PHONE,
......@@ -96,6 +111,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
value: f.description,
tone: meta.tone,
evidence: [],
data: f.data ?? null,
} as PersonaFeature;
}),
};
......@@ -180,7 +196,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
generatedAt: new Date(real.script.generatedAt),
sections: real.script.sections,
} as typeof mockScript)
: mockScript,
: EMPTY_SCRIPT,
// 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组
recallHistory: real.recallHistory ?? [],
// outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import
......
......@@ -159,6 +159,8 @@ function EmrSection({
String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') === emrId;
const diagnoses = facts.filter((f) => f.type === 'diagnosis_record' && sameEncounter(f));
const plannedTx = facts.filter((f) => f.type === 'treatment_record' && f.kind === 'planned' && sameEncounter(f));
// 本次实际做的治疗(actual,同次接诊)— 病历里体现"这次到底做了什么"
const actualTx = facts.filter((f) => f.type === 'treatment_record' && f.kind === 'actual' && sameEncounter(f));
const images = facts.filter((f) => f.type === 'image_record' && sameEncounter(f));
// S 主观 — 自由文本
......@@ -269,7 +271,7 @@ function EmrSection({
)}
{/* P */}
{(disposals.length > 0 || plannedTx.length > 0 || doctorAdvice) && (
{(disposals.length > 0 || actualTx.length > 0 || plannedTx.length > 0 || doctorAdvice) && (
<SoapBlock letter="P" tone="emerald" title="计划(Plan)">
{disposals.length > 0 && (
<div className="mb-2">
......@@ -288,6 +290,29 @@ function EmrSection({
</ul>
</div>
)}
{actualTx.length > 0 && (
<div className="mb-2">
<div className="text-[11px] text-slate-500 mb-1">本次治疗:</div>
<ul className="space-y-1">
{actualTx.map((tx) => {
const tc = tx.content as Record<string, unknown>;
const subtype = String(tc.subtype ?? '');
const cat = String(tc.category ?? '');
const badgeText = cat ? treatmentCategoryNameZh(cat) : '治疗';
const tooth = String(tc.tooth_position ?? '');
return (
<li key={tx.id} className="text-[12px] text-slate-700 leading-relaxed">
<span className="px-1.5 py-px mr-1.5 bg-teal-50 text-teal-700 rounded text-[10.5px] font-medium">
{badgeText}
</span>
<span className="font-medium">{subtype}</span>
{tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth)}</span>}
</li>
);
})}
</ul>
</div>
)}
{plannedTx.length > 0 && (
<div className="mb-2">
<div className="text-[11px] text-slate-500 mb-1">治疗计划:</div>
......
......@@ -31,6 +31,8 @@ export const fmtDate = sharedFmtDate;
export const mockPatient = {
id: 'ptn_8f3c1a2b',
externalId: '5i5ya_PA00284917',
medicalRecordNumber: 'SH0Q011691' as string | null,
dedicatedCs: { id: '5499', name: '姜莹' } as { id: string; name: string } | null,
name: '张志远',
nameMasked: '张志*',
gender: '男',
......@@ -179,6 +181,8 @@ export type PersonaFeature = {
value: string;
tone: string;
evidence: string[];
/// 结构化 payload(如 entitlement_status 的 {commercialInsured, commercialInsurers, medicalInsured...});多数特征为 null
data?: unknown;
};
export const mockPersona = {
......
......@@ -16,15 +16,20 @@ import {
cn,
formatGender,
formatDaysReadable,
formatToothPosition,
userDisplayName,
roleNameZh,
} from '@/lib/utils';
import {
PersonaFeatureKey,
treatmentCategoryNameZh,
diagnosisCodeNameZh,
EXECUTION_OUTCOME_META,
type ExecutionOutcome,
} from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display';
import { ReasonLine } from './reason-line';
import { ChainSidebar } from './chain-viz';
......@@ -183,6 +188,22 @@ export function PlanDetailApp({
}, [streamState, script.sections]);
const isStreaming = streamState.status === 'streaming';
// 是否已有话术内容(没生成过 → 空态提示,不显示默认 demo / 空标题)
const hasScriptContent = displayedSections.some((s) => s.markdown.trim().length > 0);
// 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签
const focusedReason = useMemo(
() => [...reasons].sort((a, b) => b.priorityScore - a.priorityScore)[0],
[reasons],
);
const focusDiagnosis = (() => {
const code = focusedReason?.signals?.triggers?.find((t) => /^K\d/i.test(t.code ?? ''))?.code;
return code ? diagnosisCodeNameZh(code) : null;
})();
const focusTreatment = (() => {
const cat = focusedReason?.signals?.expectedCategories?.[0];
return cat ? treatmentCategoryNameZh(cat) : null;
})();
const submitOutcome = async (formData: {
channel: string;
......@@ -265,18 +286,32 @@ export function PlanDetailApp({
chains={chains}
onOpenMedical={() => setDrawerOpen('medical')}
/>
<SidebarCard
title="患者画像"
meta={`更新于 ${fmtRel(persona.computedAt)}`}
action={
<button
onClick={() => setDrawerOpen('persona')}
className="text-[10.5px] text-teal-700 hover:underline"
>
详情 →
</button>
}
>
<PersonaQuickList features={persona.features.slice(0, 4)} />
</SidebarCard>
<KeyFactsCard
patient={patient}
persona={persona}
facts={facts}
onOpenDetail={() => setDrawerOpen('facts')}
/>
<SuggestionCard
plan={effectivePlan}
patient={patient}
persona={persona}
facts={facts}
/>
<TreatmentHistoryCard facts={facts} />
{/* 历史联系 — 预留卡片(以后接客服联系记录) */}
<SidebarCard title="历史联系">
<div className="text-[11.5px] italic text-slate-400">暂无联系记录(后续接入客服联系记录)</div>
</SidebarCard>
{/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && (
<RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
)}
......@@ -294,20 +329,6 @@ export function PlanDetailApp({
>
<ChainSidebar chains={chains} />
</SidebarCard>
<SidebarCard
title="患者画像"
meta={`更新于 ${fmtRel(persona.computedAt)}`}
action={
<button
onClick={() => setDrawerOpen('persona')}
className="text-[10.5px] text-teal-700 hover:underline"
>
详情 →
</button>
}
>
<PersonaQuickList features={persona.features.slice(0, 4)} />
</SidebarCard>
</aside>
}
centerPane={
......@@ -316,9 +337,21 @@ export function PlanDetailApp({
{/* 窄屏 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>
<div className="flex flex-wrap items-center gap-1.5">
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2>
{focusDiagnosis && (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10.5px] font-medium bg-rose-50 text-rose-700 border border-indigo-100">
{focusDiagnosis}
</span>
)}
{focusTreatment && (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10.5px] font-medium bg-teal-50 text-teal-700 border border-teal-100">
目标 · {focusTreatment}
</span>
)}
</div>
<p className="text-[10.5px] text-slate-500 mt-0.5">
{displayedSections.length}
{hasScriptContent ? `共 ${displayedSections.length} 步` : '点右上「生成」实时生成'}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
......@@ -355,8 +388,9 @@ export function PlanDetailApp({
void regenerate(plan.id, { model: m });
}}
/>
{/* AI 时间戳 — 窄屏隐藏(信息不关键,腾空间) */}
{/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */}
<span className="hidden md:inline-flex">
{(streamState.status === 'done' || (hasScriptContent && script.generatedAt)) && (
<AIStamp
relative={
streamState.status === 'done'
......@@ -367,11 +401,22 @@ export function PlanDetailApp({
streamState.status === 'done' ? streamState.source : script.source
}
/>
)}
</span>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-4">
<ScriptView mode={scriptMode} sections={displayedSections} streaming={isStreaming} />
{!isStreaming && !hasScriptContent ? (
<div className="h-full min-h-[160px] flex flex-col items-center justify-center text-center gap-2 text-slate-400">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-8 h-8">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
<p className="text-[13px] text-slate-500">尚未生成参考话术</p>
<p className="text-[12px] text-slate-400">点击右上角「生成」按本患者实时生成</p>
</div>
) : (
<ScriptView mode={scriptMode} sections={displayedSections} streaming={isStreaming} />
)}
</div>
<AIDisclaimerFooter
onFeedback={async (v) => {
......@@ -599,11 +644,11 @@ function TopBar({
<RecycleCountdown recycleAt={plan.recycleAt} />
{/* 用户名块 — 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 className="font-medium text-slate-700">{userDisplayName(user)}</div>
<div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {roleNameZh(user?.role)}</div>
</div>
<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()}
{userDisplayName(user).charAt(0).toUpperCase()}
</span>
</div>
</header>
......@@ -771,6 +816,11 @@ function IdentityCard({
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-[15px] font-semibold text-slate-900 leading-tight">{patient.name}</span>
{patient.medicalRecordNumber && (
<span className="text-[11px] text-slate-400">
<span className="font-mono text-slate-500">{patient.medicalRecordNumber}</span>
</span>
)}
<span className="text-[10.5px] text-slate-500">
{formatGender(patient.gender)}·{patient.age ?? '?'}
</span>
......@@ -784,13 +834,15 @@ function IdentityCard({
</button>
</div>
</div>
<div className="mt-1 flex items-center gap-1 flex-wrap">
{patient.tags.map((tag) => (
<Chip key={tag} tone={tag.includes('VIP') ? 'amber' : 'slate'} size="xs">
{tag}
</Chip>
))}
</div>
{patient.tags.length > 0 && (
<div className="mt-1 flex items-center gap-1 flex-wrap">
{patient.tags.map((tag) => (
<Chip key={tag} tone={tag.includes('VIP') ? 'amber' : 'slate'} size="xs">
{tag}
</Chip>
))}
</div>
)}
<div className="mt-1 flex items-center gap-1.5">
<span className="text-[12px] tabular-nums font-mono text-slate-700">
{revealed && revealedPhone ? revealedPhone : patient.phoneMasked}
......@@ -894,26 +946,44 @@ function WhyCard({
</button>
}
>
<ul className="space-y-2 text-[12.5px] text-slate-700 leading-relaxed">
{visibleReasons.map((r) => (
<li key={r.id} className="flex gap-1.5">
{visibleReasons.length > 1 && <span className="text-rose-500 flex-none mt-[2px]"></span>}
<div className="flex-1 min-w-0">
<ReasonLine reason={r} />
</div>
</li>
))}
</ul>
{/* 跟列表页一致:只显示 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>
</SidebarCard>
);
}
// ──────────────────────────────────────────
// KeyFactsCard — 关键事实(5 行)
// 主医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人
// 主医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人
//
// 数据源全走真实 PAC 数据(无 mock 兜底):
// - 主医生:从 facts 算 doctor_id 出现频次 top 1(同 chain-composer doctorMap),host 缺值 → '—'
// - 主医生:从 facts 算 doctor_id 出现频次 top 1(同 chain-composer doctorMap),host 缺值 → '—'
// - 累计消费:profile.ltv(LTV cents 已扣 refund);右侧 hint 显示 persona.value description(画像评级)
// - 上次/首次到诊:profile.lastVisit / firstVisit
// - 联系人:profile.primaryContactType(host 暂无数据,统一 '—' 占位)
......@@ -930,7 +1000,7 @@ function KeyFactsCard({
facts: AdaptedFact[];
onOpenDetail: () => void;
}) {
// ─ 主医生 ─ 取 facts 中 doctor_id 出现频次最高的,并解析 doctor_name
// ─ 主医生 ─ 取 facts 中 doctor_id 出现频次最高的,并解析 doctor_name
// 同 chain-composer.buildDoctorMap 逻辑同源:同 patient (id,name) 双全的 fact 学一遍 map
const attendingDoctor = (() => {
const idCount = new Map<string, number>();
......@@ -956,7 +1026,7 @@ function KeyFactsCard({
const valueFeature = persona.features.find((f) => f.key === PersonaFeatureKey.VALUE);
const valueHint = shortPersonaValueLabel(valueFeature?.value);
// ─ 治疗类目 top 1-2 ─ facts 里 treatment_record.category 出现频次 top(用于主医生右侧 hint)
// ─ 治疗类目 top 1-2 ─ facts 里 treatment_record.category 出现频次 top(用于主医生右侧 hint)
const mainCategories = (() => {
const counter = new Map<string, number>();
for (const f of facts) {
......@@ -969,70 +1039,32 @@ function KeyFactsCard({
return top.length ? top.map(([c]) => treatmentCategoryNameZh(c)).join(' · ') : '';
})();
// ─ 某次就诊"做了什么"摘要(上次到诊 / 首次就诊 hint)─
// 窗口 = visit day ±1 day(EMR/治疗结算常延后一天,纳入)
// 优先级:
// ① actual treatment 治疗类目(去重,如 "牙周 · 预防")
// ② 诊断码 + 中文名(如 "K05 慢性牙周炎")
// ③ EMR 主诉(illness_desc / pre_illness 截 12 字,如 "下前牙区牙龈肿痛…")
// ④ 兜底 "到诊"
const visitPurpose = (day: string | undefined): string => {
if (!day) return '';
const within1Day = (occurredAt: string | null): boolean => {
if (!occurredAt) return false;
const d = occurredAt.slice(0, 10);
// d ∈ [day, day+1](正向 1 日窗口,容忍 EMR/治疗结算延后)
if (d === day) return true;
const dDate = new Date(d).getTime();
const dayDate = new Date(day).getTime();
const diff = (dDate - dayDate) / 86400_000;
return diff > 0 && diff <= 1;
};
// ─ 专属客服 ─ 摄入时从 host 落 preferences.dedicatedCs({id,name})
const dedicatedCs = patient.dedicatedCs?.name ?? '-';
// ① 实际治疗类(去重)
const cats = new Set<string>();
for (const f of facts) {
if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
if (!within1Day(f.occurredAt)) continue;
const c = String((f.content as Record<string, unknown> | null)?.category ?? '');
if (c && c !== 'review') cats.add(c);
// ─ 保险客户 ─ entitlement_status 画像特征的结构化 data(商保保司名 / 医保 / 自费)
const entData = (persona.features.find((f) => f.key === PersonaFeatureKey.ENTITLEMENT_STATUS)?.data ?? null) as
| { commercialInsured?: boolean; commercialInsurers?: string[]; medicalInsured?: boolean }
| null;
const insurance = (() => {
if (entData?.commercialInsured) {
const names = (entData.commercialInsurers ?? []).filter(Boolean);
return { value: names.length ? names.join('、') : '商保客户', hint: '商保' };
}
if (cats.size > 0) return [...cats].map(treatmentCategoryNameZh).join(' · ');
// ② 诊断名(取第一条非空)
for (const f of facts) {
if (f.type !== 'diagnosis_record') continue;
if (!within1Day(f.occurredAt)) continue;
const c = f.content as Record<string, unknown> | null;
const name = String(c?.name_zh ?? c?.name ?? '');
if (name) return `${name ? ' ' : ''}${name}`.trim();
}
// ③ EMR 主诉(illness_desc 优先,fallback pre_illness)
for (const f of facts) {
if (f.type !== 'emr_record') continue;
if (!within1Day(f.occurredAt)) continue;
const c = f.content as Record<string, unknown> | null;
const text = String(c?.illness_desc ?? c?.pre_illness ?? '').trim();
if (text) return text.length > 12 ? text.slice(0, 12) + '…' : text;
}
// ④ 兜底
return '到诊';
};
if (entData?.medicalInsured) return { value: '医保', hint: '' };
return { value: '-', hint: '' };
})();
// 顺序:累计消费 → 主诊医生 → 上次到诊 → 首次就诊 → 联系人(user 反馈调整)
// 4 行(user 指定):主治医生 / 专属客服 / 累计消费 / 保险客户(显示保险名称)
const rows = [
{ label: '主治医生', value: attendingDoctor, hint: mainCategories || '' },
{ label: '专属客服', value: dedicatedCs, hint: '' },
{ label: '累计消费', value: ${ltvYuan}`, hint: valueHint },
{ label: '主诊医生', value: attendingDoctor, hint: mainCategories || '' },
{ label: '上次到诊', value: patient.profile.lastVisit || '—', hint: visitPurpose(patient.profile.lastVisit) },
{ label: '首次就诊', value: patient.profile.firstVisit || '—', hint: visitPurpose(patient.profile.firstVisit) },
{ label: '联系人', value: patient.profile.primaryContactType || '—', hint: '' },
{ label: '保险客户', value: insurance.value, hint: insurance.hint },
];
return (
<SidebarCard
title="关键事实"
defaultOpen={false}
action={
<button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
详情 →
......@@ -1057,6 +1089,71 @@ function KeyFactsCard({
}
// ──────────────────────────────────────────
// TreatmentHistoryCard — 治疗历史(时间倒序,4 行高度超出滚动)
// 顶部:治疗计划(最新一条 planned treatment_record,按 plannedFor 倒序取第一条)
// 列表:已做治疗(actual treatment_record),每行 时间 + 治疗名(有牙位带牙位)
// ──────────────────────────────────────────
function treatmentLabel(f: AdaptedFact): string {
const c = (f.content as Record<string, unknown> | null) ?? {};
const name =
String(c.subtype ?? '').trim() ||
(c.category ? treatmentCategoryNameZh(String(c.category)) : '') ||
'治疗';
const tooth = String(c.tooth_position ?? '').trim();
return tooth ? `${name} ${formatToothPosition(tooth)}` : name;
}
function TreatmentHistoryCard({ facts }: { facts: AdaptedFact[] }) {
const latestPlan = useMemo(() => {
const planned = facts
.filter((f) => f.type === 'treatment_record' && f.kind === 'planned')
.sort((a, b) => (b.plannedFor ?? '').localeCompare(a.plannedFor ?? ''));
return planned[0] ?? null;
}, [facts]);
const history = useMemo(
() =>
facts
.filter((f) => f.type === 'treatment_record' && f.kind === 'actual')
.sort((a, b) => (b.occurredAt ?? '').localeCompare(a.occurredAt ?? '')),
[facts],
);
return (
<SidebarCard title="治疗历史" meta={history.length > 0 ? `${history.length} 项` : undefined}>
{/* 治疗计划(最新一条)— 置顶,与历史同一行格式,"治疗计划"标在最右 */}
{latestPlan && (
<div className="mb-1 flex items-baseline gap-2 text-[11px]">
<span className="w-[78px] flex-none whitespace-nowrap tabular-nums text-slate-400">
{latestPlan.plannedFor?.slice(0, 10) ?? '—'}
</span>
<span className="flex-1 min-w-0 truncate text-slate-800" title={treatmentLabel(latestPlan)}>
{treatmentLabel(latestPlan)}
</span>
<span className="flex-none rounded bg-teal-100 px-1 text-[10px] font-medium text-teal-700">治疗计划</span>
</div>
)}
{history.length === 0 ? (
<div className="text-[11.5px] italic text-slate-400">暂无治疗记录</div>
) : (
<div className="max-h-[108px] space-y-1 overflow-y-auto pr-1">
{history.map((f) => (
<div key={f.id} className="flex items-baseline gap-2 text-[11px]">
<span className="w-[78px] flex-none whitespace-nowrap tabular-nums text-slate-400">
{f.occurredAt?.slice(0, 10) ?? '—'}
</span>
<span className="flex-1 min-w-0 truncate text-slate-800" title={treatmentLabel(f)}>
{treatmentLabel(f)}
</span>
</div>
))}
</div>
)}
</SidebarCard>
);
}
// ──────────────────────────────────────────
// SuggestionCard
// ──────────────────────────────────────────
function SuggestionCard({
......
......@@ -11,6 +11,8 @@ export type PlanDetailData = {
patient: {
id: string;
externalId: string;
medicalRecordNumber: string | null;
dedicatedCs: { id: string; name: string } | null;
name: string | null;
nameMasked: string | null;
gender: string | null;
......@@ -108,6 +110,7 @@ export type PlanDetailData = {
key: string;
description: string;
score: number | null;
data: unknown;
}>;
} | null;
chains: Array<{
......
......@@ -12,8 +12,9 @@ export type ScriptViewMode = 'copilot' | 'cards' | 'markdown';
// 原文 — Markdown 全文(折叠分段)
// ──────────────────────────────────────────
export function ScriptMarkdown({ sections, streaming = false }: { sections: ScriptSection[]; streaming?: boolean }) {
// 原文模式默认全部展开(客服要一眼看全)
const [open, setOpen] = useState<Record<string, boolean>>(() =>
Object.fromEntries(sections.map((s, i) => [s.id, i === 0 || i === 1])),
Object.fromEntries(sections.map((s) => [s.id, true])),
);
return (
<div className="space-y-2">
......
......@@ -30,7 +30,7 @@ import {
import { useAuthStore } from '@/stores/auth-store';
import { useHasPermission } from '@/hooks/use-permission';
import { ApiError } from '@/lib/api-client';
import { cn, formatGender } from '@/lib/utils';
import { cn, formatGender, userDisplayName, roleNameZh } from '@/lib/utils';
import { plansApi } from './plans-api';
import { usePlansList } from './use-plans-list';
import { usePlanCounts } from './use-plan-counts';
......@@ -360,11 +360,11 @@ function PageHeader({
<RefreshCw className="h-3.5 w-3.5" /> 刷新
</Button>
<div className="hidden text-right text-[11.5px] leading-tight text-slate-500 md:block">
<div className="font-medium text-slate-700">{user?.sub ?? '—'}</div>
<div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {user?.role ?? '—'}</div>
<div className="font-medium text-slate-700">{userDisplayName(user)}</div>
<div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {roleNameZh(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">
{(user?.sub ?? '?').charAt(0).toUpperCase()}
{userDisplayName(user).charAt(0).toUpperCase()}
</span>
</div>
</header>
......
......@@ -62,3 +62,16 @@ export function formatGender(raw: string | null | undefined): string {
if (['f', 'female', '女', '2'].includes(g)) return '女';
return '—';
}
/// 当前登录用户的展示名:优先 dictionary.users[sub](人名,如"李莉"),否则回落 sub。
export function userDisplayName(
user: { sub: string; dictionary?: { users?: Record<string, string> } } | null | undefined,
): string {
if (!user) return '—';
return user.dictionary?.users?.[user.sub]?.trim() || user.sub;
}
/// 系统角色 → 中文(staff/leader/admin → 员工/主管/管理员)。
export function roleNameZh(role: string | null | undefined): string {
return ({ staff: '员工', leader: '主管', admin: '管理员' } as Record<string, string>)[role ?? ''] ?? (role ?? '—');
}
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