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