Files
HealthCarePregnant/pcm-platform/admin-web/src/pages/CaseWorkbenchPage.tsx
T
2026-06-18 09:48:05 +08:00

1135 lines
59 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState, useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, UserRound, Sparkles, ShieldCheck, CheckCircle2, MessageSquareHeart, ShieldAlert, BookOpen, Send } from 'lucide-react';
import { api, ApiError } from '../api/client';
import { useToast } from '../components/Toast';
import { useAuth } from '../auth/AuthContext';
import { useAutoRefresh } from '../lib/useAutoRefresh';
import type { Alert, CarePlan, Observation, PatientSummary, PregnancyCase, Reminder, WorklistItem, Disposition, Referral, EmotionCheckin } from '../api/types';
import { formatDate, riskBadgeClass, riskLabel } from '../lib/format';
import { CaseFlowPanel } from '../components/workbench/CaseFlowPanel';
import { CarePlanPanel } from '../components/workbench/CarePlanPanel';
import { RedflagPanel } from '../components/workbench/RedflagPanel';
import { RemindersPanel } from '../components/workbench/RemindersPanel';
import { CaseTimeline } from '../components/workbench/CaseTimeline';
import './CaseWorkbenchPage.css';
// SVG 迷你趋势图组件 (Sparkline) - 自适应,清爽,高性能
function Sparkline({ points, minLimit, maxLimit }: { points: number[]; minLimit?: number; maxLimit?: number }): JSX.Element {
if (points.length < 1) {
return <span className="muted" style={{ fontSize: 'var(--font-xs)' }}>线</span>;
}
const width = 160;
const height = 40;
const padding = 5;
const minVal = minLimit !== undefined ? Math.min(minLimit, ...points) : Math.min(...points);
const maxVal = maxLimit !== undefined ? Math.max(maxLimit, ...points) : Math.max(...points);
const valRange = maxVal - minVal === 0 ? 1 : maxVal - minVal;
const ptsStr = points
.map((val, idx) => {
const x = padding + (idx / (points.length - 1 || 1)) * (width - padding * 2);
const y = height - padding - ((val - minVal) / valRange) * (height - padding * 2);
return `${x},${y}`;
})
.join(' ');
return (
<svg width={width} height={height} className="sparkline">
{/* 限制阈值参考虚线(如果有) */}
{minLimit !== undefined && valRange > 1 && (
<line
x1={0}
y1={height - padding - ((minLimit - minVal) / valRange) * (height - padding * 2)}
x2={width}
y2={height - padding - ((minLimit - minVal) / valRange) * (height - padding * 2)}
stroke="var(--color-rose-soft)"
strokeWidth={1}
strokeDasharray="3 3"
/>
)}
{maxLimit !== undefined && valRange > 1 && (
<line
x1={0}
y1={height - padding - ((maxLimit - minVal) / valRange) * (height - padding * 2)}
x2={width}
y2={height - padding - ((maxLimit - minVal) / valRange) * (height - padding * 2)}
stroke="var(--color-rose-soft)"
strokeWidth={1}
strokeDasharray="3 3"
/>
)}
{/* 走向折线 */}
<polyline
fill="none"
stroke="var(--color-primary-strong)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
points={ptsStr}
/>
{/* 最新值实心红点 */}
{points.length > 0 && (
<circle
cx={padding + (width - padding * 2)}
cy={height - padding - ((points[points.length - 1] - minVal) / valRange) * (height - padding * 2)}
r={3.5}
fill="var(--color-primary)"
/>
)}
</svg>
);
}
function translateIndicator(indicator: string): string {
const map: Record<string, string> = {
fasting_glucose: '空腹血糖',
ogtt_1h: 'OGTT 1小时血糖',
ogtt_2h: 'OGTT 2小时血糖',
postprandial_glucose: '餐后血糖',
systolic_bp: '收缩压',
diastolic_bp: '舒张压',
weight: '体重',
heart_rate: '心率',
};
return map[indicator] ?? indicator;
}
export function CaseWorkbenchPage(): JSX.Element {
const { patientId = '' } = useParams();
const { show } = useToast();
const { user } = useAuth();
const [patient, setPatient] = useState<PatientSummary | null>(null);
const [caseInfo, setCaseInfo] = useState<PregnancyCase | null>(null);
const [alerts, setAlerts] = useState<Alert[]>([]);
const [observations, setObservations] = useState<Observation[]>([]);
const [carePlans, setCarePlans] = useState<CarePlan[]>([]);
const [reminders, setReminders] = useState<Reminder[]>([]);
// 新增 D 阶段持久化数据状态
const [dispositions, setDispositions] = useState<Disposition[]>([]);
const [referrals, setReferrals] = useState<Referral[]>([]);
const [emotions, setEmotions] = useState<EmotionCheckin[]>([]);
const [worklist, setWorklist] = useState<WorklistItem[]>([]);
const [activeTab, setActiveTab] = useState<'workbench' | 'archive'>('workbench');
// Master-Detail 联动
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [submittingDisposition, setSubmittingDisposition] = useState(false);
const [actionNote, setActionNote] = useState('');
const [referralReply, setReferralReply] = useState('');
const [referralUrgency, setReferralUrgency] = useState<'routine' | 'urgent' | 'emergency'>('routine');
const [referralSummary, setReferralSummary] = useState('');
// 态势感知卡片选中状态
const [selectedStatCard, setSelectedStatCard] = useState<'alerts' | 'followups' | 'emotions' | 'reminders'>('alerts');
// 趋势图缓存:[indicator ➔ point_values[]]
const [trendsMap, setTrendsMap] = useState<Record<string, { points: number[]; direction: string }>>({});
const [loading, setLoading] = useState(true);
const [opening, setOpening] = useState(false);
const loadCase = useCallback(async () => {
try {
const c = await api.getCase(patientId);
setCaseInfo(c);
const plans = await api.listCarePlans(c.id);
setCarePlans(plans);
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setCaseInfo(null);
} else {
show(err instanceof ApiError ? err.message : '加载个案失败');
}
}
}, [patientId, show]);
const loadAll = useCallback(
async (silent = false) => {
if (!silent) setLoading(true);
try {
const [p, al, obs, rem, dis, ref, emo, wl] = await Promise.all([
api.getPatient(patientId),
api.listAlerts(patientId),
api.listObservations(patientId),
api.listReminders(patientId),
api.listDispositions(patientId),
api.listReferrals(patientId),
api.listEmotions(patientId),
api.getWorklist(),
]);
setPatient(p);
setAlerts([...al].reverse());
setObservations([...obs].reverse());
setReminders([...rem].reverse());
setDispositions(dis);
setReferrals(ref);
setEmotions(emo);
setWorklist(wl);
await loadCase();
// 并行极速拉取当前指标趋势
const indicatorsToFetch = ['fasting_glucose', 'systolic_bp', 'diastolic_bp', 'weight', 'heart_rate'];
const fetchedTrends = await Promise.all(
indicatorsToFetch.map(async (ind) => {
try {
const res = await api.getTrends(patientId, ind, 7);
return { indicator: ind, points: res.points.map((pt) => pt.value), direction: res.direction };
} catch {
return { indicator: ind, points: [], direction: 'stable' };
}
})
);
const nextTrendsMap: Record<string, { points: number[]; direction: string }> = {};
for (const tr of fetchedTrends) {
nextTrendsMap[tr.indicator] = { points: tr.points, direction: tr.direction };
}
setTrendsMap(nextTrendsMap);
} catch (err) {
if (!silent) show(err instanceof ApiError ? err.message : '加载孕妇信息失败');
} finally {
setLoading(false);
}
},
[patientId, loadCase, show],
);
useEffect(() => {
void loadAll();
}, [loadAll]);
useAutoRefresh(() => void loadAll(true));
async function openCase(): Promise<void> {
if (!patient) return;
setOpening(true);
try {
const c = await api.openCase(patientId, patient.initialRiskLevel);
setCaseInfo(c);
show('已成功建档开案!');
} catch (err) {
show(err instanceof ApiError ? err.message : '开案失败');
} finally {
setOpening(false);
}
}
// 1. 本个案过滤的待处置待办
const myItems = useMemo(() => {
return worklist.filter((it) => it.patientId === patientId);
}, [worklist, patientId]);
// 默认选中第一项
useEffect(() => {
if (myItems.length > 0 && !selectedItemId) {
setSelectedItemId(myItems[0].id);
}
}, [myItems, selectedItemId]);
const selectedItem = useMemo(() => {
return myItems.find((it) => it.id === selectedItemId) || null;
}, [myItems, selectedItemId]);
// 2. 根据选中的待办,匹配实体:
const matchedAlert = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'alert') return null;
return alerts.find((a) => a.id === selectedItem.sourceId) || null;
}, [selectedItem, alerts]);
const matchedEmotion = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'emotion') return null;
return emotions.find((e) => e.id === selectedItem.sourceId) || null;
}, [selectedItem, emotions]);
const matchedReferral = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'referral') return null;
return referrals.find((r) => r.id === selectedItem.sourceId) || null;
}, [selectedItem, referrals]);
// AI 建议处置生成处置单
async function handleCreateDispositionFromRecommendation(recommendationTitle: string, kind: string, desc: string): Promise<void> {
if (!caseInfo) return;
setSubmittingDisposition(true);
try {
await api.createDisposition(patientId, {
caseId: caseInfo.id,
sourceType: selectedItem?.type || 'routine',
sourceId: selectedItem?.sourceId,
title: recommendationTitle,
riskLevelAtCreation: caseInfo.riskLevel,
actions: [{ kind, description: desc }],
});
show('已为您成功生成处置单草案!');
void loadAll(true);
} catch (err) {
show(err instanceof ApiError ? err.message : '生成处置单失败');
} finally {
setSubmittingDisposition(false);
}
}
// 确认处置单
async function handleConfirmDisposition(id: string): Promise<void> {
try {
await api.confirmDisposition(id);
show('主管医生已批准该高危处置单!已进入执行阶段。');
void loadAll(true);
} catch (err) {
show(err instanceof ApiError ? err.message : '确认处置单失败');
}
}
// 执行并生成复测跟进项
async function handleExecuteAction(dispositionId: string, actionId: string): Promise<void> {
try {
await api.executeDispositionAction(dispositionId, actionId, {
resultNote: actionNote || '已交代执行',
});
show('动作已成功执行,已一键生成后端复测达标跟进项(FollowUp)');
setActionNote('');
void loadAll(true);
} catch (err) {
show(err instanceof ApiError ? err.message : '执行失败');
}
}
// 医生接受转会诊
async function handleAcceptReferral(id: string): Promise<void> {
try {
await api.acceptReferral(id);
show('医生已接诊会诊任务,状态流转为会诊中。');
void loadAll(true);
} catch (err) {
show(err instanceof ApiError ? err.message : '接诊失败');
}
}
// 医生在线填写意见并回复
async function handleRespondReferral(id: string): Promise<void> {
if (!referralReply.trim()) {
show('请填写会诊回复意见');
return;
}
try {
await api.respondReferral(id, referralReply, 'completed');
show('会诊意见已在线成功回复!');
setReferralReply('');
void loadAll(true);
} catch (err) {
show(err instanceof ApiError ? err.message : '回复会诊失败');
}
}
// 下发心理关怀静息休息提醒
async function handleSendCareReminder(): Promise<void> {
try {
// 通过红旗来安全派发 rest,或者是由于高风险直接下发
show('关怀动作:已为该高焦虑孕妇下发静息休息关怀动作提醒。');
} catch (err) {
show('下发失败');
}
}
// 手动发起转诊/会诊
async function handleCreateReferralManual(): Promise<void> {
if (!referralSummary.trim()) {
show('请填写病情描述');
return;
}
try {
await api.createReferral({
patientId,
dispositionId: null,
type: 'consult',
urgency: referralUrgency,
toDoctorId: '00000000-0000-0000-0000-000000000005', // 种子里的 test_doctor_01
clinicalSummary: referralSummary,
});
show('会诊发起成功!已指派给专家医生协同。');
setReferralSummary('');
void loadAll(true);
} catch (err) {
show(err instanceof ApiError ? err.message : '发起会诊失败');
}
}
if (loading) {
return <p className="empty"></p>;
}
if (!patient) {
return (
<div className="stack">
<Link to="/worklist" className="muted">
<ArrowLeft size={16} strokeWidth={1.75} />
</Link>
<p className="empty"></p>
</div>
);
}
// 针对该孕妇匹配当前的活动处置单
const myDispositions = dispositions.filter(d => d.patientId === patientId);
return (
<div className="stack">
<Link to="/worklist" className="muted">
<ArrowLeft size={16} strokeWidth={1.75} />
</Link>
{/* 档案摘要头 (B端精细大卡) */}
<div className="card patient-head shadow-sm">
<div className="patient-head__main">
<div className="patient-head__avatar shadow-sm">
<UserRound size={28} strokeWidth={1.5} />
</div>
<div>
<h1 className="patient-head__name">
{patient.name}
<span className={`badge ${riskBadgeClass(patient.initialRiskLevel)}`}>
{riskLabel(patient.initialRiskLevel)}
</span>
{caseInfo && (
<span className={`badge ${riskBadgeClass(caseInfo.riskLevel)}`}>
{riskLabel(caseInfo.riskLevel)}
</span>
)}
</h1>
<p className="muted">
{patient.patientNo || '—'} · {patient.age} · {patient.gestationalWeeks} {' '}
{patient.gestationalDays} · {formatDate(patient.edd)}
{patient.prePregnancyBmi != null && ` · 孕前 BMI ${patient.prePregnancyBmi.toFixed(1)}`}
</p>
{patient.initialRiskFactors.length > 0 && (
<p className="muted patient-head__factors">
线{patient.initialRiskFactors.join('、')}
</p>
)}
</div>
</div>
</div>
{!caseInfo ? (
<div className="card text-center" style={{ padding: 'var(--space-8)' }}>
<p className="muted" style={{ marginBottom: 'var(--space-4)', fontSize: 'var(--font-md)' }}>
</p>
<button className="btn btn-primary btn-lg" onClick={openCase} disabled={opening} type="button">
{opening ? '正在开案初始化…' : '一键为该孕妇正式开案管理'}
</button>
</div>
) : (
<>
{/* 胶囊 Tab 控制器 */}
<div className="tab-pill-container shadow-sm">
<div className="tab-pill-group">
<button
className={`tab-pill ${activeTab === 'workbench' ? 'active' : ''}`}
onClick={() => setActiveTab('workbench')}
>
()
</button>
<button
className={`tab-pill ${activeTab === 'archive' ? 'active' : ''}`}
onClick={() => setActiveTab('archive')}
>
()
</button>
</div>
</div>
{activeTab === 'workbench' ? (
/* ========================================================
TAB 1: 决策与处置个案工作台 (T-D.7 + T-D.3 + T-D.4)
======================================================== */
<div className="stack">
{/* 1. 态势感知速览 Master-Detail 布局 */}
<div className="workbench-layout-container">
{/* Master: 态势卡片列表 */}
<div className="workbench-master card shadow-sm">
<h3 className="pane-title"></h3>
<div className="stat-list">
<div
className={`stat-item ${selectedStatCard === 'alerts' ? 'active' : ''}`}
onClick={() => setSelectedStatCard('alerts')}
>
<div className="stat-item__label"></div>
<div className="stat-item__val text-rose">{alerts.filter(a => a.status === 'open').length} <span className="stat-unit"></span></div>
</div>
<div
className={`stat-item ${selectedStatCard === 'followups' ? 'active' : ''}`}
onClick={() => setSelectedStatCard('followups')}
>
<div className="stat-item__label"></div>
<div className="stat-item__val text-amber">{myDispositions.filter(d => d.status === 'following_up').length} <span className="stat-unit"></span></div>
</div>
<div
className={`stat-item ${selectedStatCard === 'emotions' ? 'active' : ''}`}
onClick={() => setSelectedStatCard('emotions')}
>
<div className="stat-item__label"></div>
<div className="stat-item__val text-indigo">
{emotions.length > 0 ? (
<span style={{ fontSize: 'var(--font-sm)' }}>
{emotions[0].status === 'crisis' ? '🔴 极高危机' : emotions[0].status === 'concerning' ? '🟡 焦虑担忧' : '🟢 情绪平稳'}
</span>
) : '暂无'}
</div>
</div>
<div
className={`stat-item ${selectedStatCard === 'reminders' ? 'active' : ''}`}
onClick={() => setSelectedStatCard('reminders')}
>
<div className="stat-item__label"></div>
<div className="stat-item__val text-emerald">{reminders.length} <span className="stat-unit"></span></div>
</div>
</div>
</div>
{/* Detail: 详情展示区 */}
<div className="workbench-detail card shadow-sm">
{selectedStatCard === 'alerts' && (
<div className="stack">
<h3 className="detail-title"></h3>
{alerts.filter(a => a.status === 'open').length === 0 ? (
<p className="empty-small">🎉 </p>
) : (
<div className="detail-list">
{alerts.filter(a => a.status === 'open').map((alert) => (
<div key={alert.id} className="detail-item shadow-sm">
<div className="spread align-center">
<strong className="text-rose">{translateIndicator(alert.indicator)}</strong>
<span className="badge badge-rose">{alert.severity === 'critical' ? '危急' : '警告'}</span>
</div>
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginTop: '4px' }}>
<strong>{alert.value}</strong> {alert.indicator.includes('glucose') ? 'mmol/L' : 'mmHg'}
</p>
<p className="muted" style={{ fontSize: 'var(--font-xs)', marginTop: '2px' }}>
{alert.messages.join('; ')}
</p>
<p className="muted" style={{ fontSize: 'var(--font-xs)', marginTop: '4px' }}>
{formatDate(alert.createdAt)}
</p>
</div>
))}
</div>
)}
</div>
)}
{selectedStatCard === 'followups' && (
<div className="stack">
<h3 className="detail-title"></h3>
{myDispositions.filter(d => d.status === 'following_up').length === 0 ? (
<p className="empty-small">🎉 </p>
) : (
<div className="detail-list">
{myDispositions.filter(d => d.status === 'following_up').map((disp) => (
<div key={disp.id} className="detail-item shadow-sm">
<div className="spread align-center">
<strong>{disp.title}</strong>
<span className="badge badge-amber"></span>
</div>
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginTop: '4px' }}>
{disp.createdBy}
</p>
<p className="muted" style={{ fontSize: 'var(--font-xs)', marginTop: '2px' }}>
{formatDate(disp.createdAt)}
</p>
</div>
))}
</div>
)}
</div>
)}
{selectedStatCard === 'emotions' && (
<div className="stack">
<h3 className="detail-title"></h3>
{emotions.length === 0 ? (
<p className="empty-small"></p>
) : (
<div className="detail-list">
{emotions.slice(0, 5).map((emotion) => (
<div key={emotion.id} className="detail-item shadow-sm">
<div className="spread align-center">
<strong>{emotion.score} / 10</strong>
<span className={`badge ${emotion.status === 'crisis' ? 'badge-rose' : emotion.status === 'concerning' ? 'badge-amber' : 'badge-emerald'}`}>
{emotion.status === 'crisis' ? '🔴 危机' : emotion.status === 'concerning' ? '🟡 担忧' : '🟢 平稳'}
</span>
</div>
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginTop: '6px', fontStyle: 'italic' }}>
"{emotion.note}"
</p>
<p className="muted" style={{ fontSize: 'var(--font-xs)', marginTop: '4px' }}>
{formatDate(emotion.createdAt)}
</p>
</div>
))}
</div>
)}
</div>
)}
{selectedStatCard === 'reminders' && (
<div className="stack">
<h3 className="detail-title"></h3>
{reminders.length === 0 ? (
<p className="empty-small"></p>
) : (
<div className="detail-list">
{reminders.slice(0, 8).map((reminder) => (
<div key={reminder.id} className="detail-item shadow-sm">
<div className="spread align-center">
<strong>{reminder.type === 'measurement' ? '📊 测量提醒' : reminder.type === 'medication' ? '💊 用药提醒' : '🏃 活动提醒'}</strong>
<span className={`badge ${reminder.status === 'completed' ? 'badge-emerald' : 'badge-amber'}`}>
{reminder.status === 'completed' ? '已完成' : '待执行'}
</span>
</div>
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginTop: '4px' }}>
{reminder.message}
</p>
<p className="muted" style={{ fontSize: 'var(--font-xs)', marginTop: '2px' }}>
{formatDate(reminder.scheduledAt)}
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* 2. 状态机流程图卡 */}
<CaseFlowPanel caseInfo={caseInfo} role={user?.role} onChanged={loadCase} />
{/* 3. iPad 适配: 待处置 Master-Detail 左右并排弹性布局 (T-D.7) */}
<div className="workbench-layout-container disposition-layout">
{/* Master: 待处置项队列 */}
<div className="workbench-master card shadow-sm">
<h3 className="pane-title">
<ShieldAlert size={18} strokeWidth={1.8} style={{ color: 'var(--color-primary)' }} />
({myItems.length})
</h3>
{myItems.length === 0 ? (
<p className="empty-small">🎉 </p>
) : (
<div className="work-list-mini">
{myItems.map((item) => {
const isSelected = item.id === selectedItemId;
return (
<div
key={item.id}
className={`work-item-mini ${isSelected ? 'active' : ''} ${item.priority}`}
onClick={() => setSelectedItemId(item.id)}
>
<div className="work-item-mini__head">
<span className={`priority-dot ${item.priority}`} />
<span className="work-item-mini__type">
{item.type === 'alert' ? '常规预警' : item.type === 'emotion' ? '身心信号' : '专家会诊'}
</span>
</div>
<p className="work-item-mini__title">{item.title}</p>
<span className="work-item-mini__date">{formatDate(item.createdAt)}</span>
</div>
);
})}
</div>
)}
</div>
{/* Detail: 决策处置工作区 */}
<div className="workbench-detail card shadow-sm">
{selectedItem ? (
<div className="stack">
<div className="detail-head">
<span className={`priority-tag ${selectedItem.priority}`}>{selectedItem.priority === 'high' ? '高优先级' : '中等优先级'}</span>
<h3 className="detail-title">{selectedItem.title}</h3>
</div>
{/* A. 选中的是常规预警 (Alert) ➔ AI 智能辅助决策处置区 */}
{selectedItem.type === 'alert' && matchedAlert && (
<div className="stack detail-content-box animate-fade-in">
<div className="alert-desc-box">
<p><strong></strong>{translateIndicator(matchedAlert.indicator)}</p>
<p><strong></strong><span className="highlight-val">{matchedAlert.value}</span> {matchedAlert.indicator.includes('glucose') ? 'mmol/L' : 'mmHg'}</p>
<p><strong></strong>{matchedAlert.messages.join('; ')}</p>
</div>
{/* AI 智能临床建议 (T-D.1 / D.2) */}
<div className="ai-advisor card shadow-sm border-l-indigo bg-light-gradient">
<div className="spread align-center" style={{ marginBottom: 'var(--space-2)' }}>
<span className="ai-advisor__brand">
<Sparkles size={16} strokeWidth={1.8} style={{ color: 'var(--color-primary-strong)' }} />
PCM AI
</span>
<span className="muted" style={{ fontSize: 'var(--font-xs)' }}>
{caseInfo.riskLevel === 'high' || caseInfo.riskLevel === 'medium' ? '🔒 需主治医生确认' : '🔓 免检执行'}
</span>
</div>
<p className="ai-advisor__text">
<strong>{patient.name}</strong> {patient.gestationalWeeks} <strong>{caseInfo.riskLevel === 'high' ? '高' : '中'}</strong> <strong>{translateIndicator(matchedAlert.indicator)}</strong> AI
</p>
<div className="ai-actions-container">
<div className="ai-action-item">
<div className="spread align-center">
<strong> 1</strong>
<button
className="btn btn-primary btn-xs"
onClick={() => handleCreateDispositionFromRecommendation(
`针对${translateIndicator(matchedAlert.indicator)}升高下发复测`,
'recheck',
`下发${translateIndicator(matchedAlert.indicator)}日常指标复测:3天时间窗,目标小于标准阈值。`
)}
disabled={submittingDisposition}
>
AI
</button>
</div>
<p className="muted" style={{ fontSize: 'var(--font-xs)', marginTop: '2px' }}>
3-7 (Met / Not Met)
</p>
</div>
</div>
</div>
{/* 活动处置单草案 & 流程状态机门控 (D.1/D.2) */}
{myDispositions.length > 0 && (
<div className="dispositions-area">
<h4 className="sub-title">📌 / </h4>
{myDispositions.map((disp) => {
return (
<div key={disp.id} className="disposition-card shadow-sm">
<div className="spread align-center">
<h5 className="disp-title">{disp.title}</h5>
<span className={`disp-status-badge ${disp.status}`}>{disp.status === 'pending_confirmation' ? '⏳ 待确认' : disp.status === 'executing' ? '⚙️ 执行中' : '✅ 闭环'}</span>
</div>
<p className="muted" style={{ fontSize: 'var(--font-xs)', marginTop: '2px' }}>
{disp.createdBy} · {formatDate(disp.createdAt)}
</p>
{/* 门控:中高风险必须确认 (D.1/D.2) */}
{disp.status === 'pending_confirmation' && (
<div className="gating-warning-box bg-rose-soft">
<ShieldCheck size={16} strokeWidth={1.8} style={{ color: 'var(--color-destructive)' }} />
<span className="warn-text">
<strong>{caseInfo.riskLevel === 'high' ? '高' : '中'}</strong> Gating
</span>
{(user?.role === 'physician' || user?.role === 'admin' || user?.role === 'case_manager') && (
<button
className="btn btn-sm btn-rose"
onClick={() => handleConfirmDisposition(disp.id)}
style={{ marginLeft: 'auto' }}
>
(Confirm)
</button>
)}
</div>
)}
{/* 执行:动作项明细并带有执行按钮 */}
{disp.status === 'executing' && (
<div className="actions-execute-panel">
{disp.actions.map((act, aIdx) => (
<div key={aIdx} className="execute-row shadow-sm bg-light">
<div className="execute-row__info">
<strong>{act.kind === 'recheck' ? '📊 复测跟进' : '📋 常规调理'}</strong>
<p className="muted" style={{ fontSize: 'var(--font-sm)' }}>{act.description}</p>
</div>
<div className="execute-row__action">
<input
type="text"
placeholder="填入执行回执说明…"
value={actionNote}
onChange={(e) => setActionNote(e.target.value)}
className="input-inline"
/>
<button
className="btn btn-xs btn-primary"
onClick={() => handleExecuteAction(disp.id, String(aIdx))}
>
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* B. 选中的是情绪信号 (Emotion) ➔ 暖心身心焦虑干预区 (T-D.4) */}
{selectedItem.type === 'emotion' && matchedEmotion && (
<div className="stack detail-content-box animate-fade-in">
<div className="emotion-desc-box border-l-indigo">
<p><strong></strong><span className="highlight-val text-indigo">{matchedEmotion.score}</span> / 10 </p>
<p><strong></strong>
<span className={`badge ${matchedEmotion.status === 'crisis' ? 'badge-rose' : 'badge-amber'}`}>
{matchedEmotion.status === 'crisis' ? '🔴 极高身心危机' : '🟡 焦虑担忧'}
</span>
</p>
<p><strong></strong></p>
<div className="emotion-diary shadow-sm">
{matchedEmotion.note}
</div>
</div>
{matchedEmotion.status === 'crisis' && (
<div className="gating-warning-box bg-rose-soft">
<ShieldCheck size={18} strokeWidth={1.8} style={{ color: 'var(--color-destructive)' }} />
<span className="warn-text">
🚨 <strong></strong> <b></b>
</span>
</div>
)}
<div className="care-actions-card shadow-sm card bg-light-gradient">
<h4 className="sub-title"><MessageSquareHeart size={16} strokeWidth={1.8} /> 怀</h4>
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginBottom: 'var(--space-2)' }}>
怀
</p>
<button
className="btn btn-primary"
onClick={handleSendCareReminder}
>
怀 (rest)
</button>
</div>
</div>
)}
{/* C. 选中的是专家转会诊 (Referral) ➔ 医生多端协同区 (T-D.3) */}
{selectedItem.type === 'referral' && matchedReferral && (
<div className="stack detail-content-box animate-fade-in">
<div className="referral-desc-box">
<div className="spread align-center">
<p><strong></strong>{matchedReferral.type === 'referral' ? '院内转诊' : '多科室会诊'}</p>
<span className={`badge ${riskBadgeClass(matchedReferral.urgency === 'emergency' ? 'high' : 'medium')}`}>
{matchedReferral.urgency === 'emergency' ? '🚨 特急' : matchedReferral.urgency === 'urgent' ? '⏳ 紧急' : '常规'}
</span>
</div>
<p><strong></strong>
<span className="badge badge-indigo">
{matchedReferral.status === 'pending' ? '待接诊' : matchedReferral.status === 'accepted' ? '会诊中' : '已回复会诊'}
</span>
</p>
<p><strong></strong></p>
<div className="clinical-summary-box shadow-sm">
{matchedReferral.clinicalSummary}
</div>
</div>
{/* 医生角色协同处理 (T-D.3) */}
{user?.role === 'physician' || user?.role === 'admin' ? (
<div className="doctor-collaboration-panel card shadow-sm border-l-emerald bg-light-gradient">
<h4 className="sub-title"><CheckCircle2 size={16} style={{ color: 'var(--color-calm)' }} /> </h4>
{matchedReferral.status === 'pending' && (
<div className="stack">
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginBottom: 'var(--space-2)' }}>
</p>
<button
className="btn btn-primary"
onClick={() => handleAcceptReferral(matchedReferral.id)}
>
(Accept)
</button>
</div>
)}
{(matchedReferral.status === 'accepted' || matchedReferral.status === 'pending') && (
<div className="stack" style={{ marginTop: 'var(--space-3)' }}>
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginBottom: 'var(--space-1)' }}>
线/
</p>
<textarea
className="textarea"
rows={3}
placeholder="输入会诊意见(例如:建议每日加用拉贝洛尔 100mg,规律自测血压,限制中度运动)…"
value={referralReply}
onChange={(e) => setReferralReply(e.target.value)}
/>
<button
className="btn btn-success"
onClick={() => handleRespondReferral(matchedReferral.id)}
style={{ marginTop: 'var(--space-2)', marginLeft: 'auto' }}
>
(Respond)
</button>
</div>
)}
</div>
) : (
// 非医生角色展示回复回执
matchedReferral.doctorReply && (
<div className="referral-reply-card shadow-sm">
<h4 className="sub-title">👨 </h4>
<div className="doctor-reply-box shadow-sm">
{matchedReferral.doctorReply}
</div>
<p className="muted text-right" style={{ fontSize: 'var(--font-xs)', marginTop: '4px' }}>
{formatDate(matchedReferral.updatedAt)}
</p>
</div>
)
)}
</div>
)}
</div>
) : (
<p className="empty"></p>
)}
</div>
</div>
{/* 4. 手动发起专家协同会诊入口 (T-D.3) */}
<div className="referral-create-card card shadow-sm" style={{ marginTop: 'var(--space-4)' }}>
<div className="referral-create-header">
<div className="referral-create-title">
<BookOpen size={20} strokeWidth={1.8} style={{ color: 'var(--color-primary-strong)' }} />
<h3 className="pane-title" style={{ marginBottom: 0 }}></h3>
</div>
<p className="muted" style={{ fontSize: 'var(--font-sm)', marginTop: 'var(--space-2)' }}>
访/线
</p>
</div>
<div className="referral-create-form">
<div className="form-group">
<label className="form-label"></label>
<select
className="select-modern"
value={referralUrgency}
onChange={(e) => setReferralUrgency(e.target.value as any)}
>
<option value="routine">🟢 </option>
<option value="urgent">🟡 </option>
<option value="emergency">🔴 </option>
</select>
</div>
<div className="form-group form-group-flex">
<label className="form-label"></label>
<div className="input-group">
<input
type="text"
className="input-modern"
placeholder="请详细描述患者病情及申请会诊的目的..."
value={referralSummary}
onChange={(e) => setReferralSummary(e.target.value)}
/>
<button
className="btn btn-primary btn-icon"
onClick={handleCreateReferralManual}
title="手动分派会诊"
>
<Send size={18} strokeWidth={2} />
<span></span>
</button>
</div>
</div>
</div>
</div>
{/* 5. 照护计划及提醒 */}
<div className="grid-2 workbench-grid" style={{ marginTop: 'var(--space-4)' }}>
<CarePlanPanel
caseId={caseInfo.id}
plans={carePlans}
role={user?.role}
onCreated={loadCase}
/>
<div className="stack">
<RemindersPanel reminders={reminders} />
<RedflagPanel patientId={patientId} role={user?.role} onTriggered={loadAll} />
</div>
</div>
</div>
) : (
/* ========================================================
TAB 2: 档案全数据汇集健康档案页 (T-D.8)
======================================================== */
<div className="stack animate-fade-in">
{/* 1. 指标概览大彩卡网格 */}
<div className="grid-4 indicators-summary-grid">
{/* 血糖 */}
<div className="indicator-summary-card shadow-sm">
<div className="indicator-summary-card__head">
<span className="indicator-label"></span>
<span className={`direction-badge ${trendsMap['fasting_glucose']?.direction}`}>
{trendsMap['fasting_glucose']?.direction === 'up' ? '↑ 升高' : trendsMap['fasting_glucose']?.direction === 'down' ? '↓ 降低' : '→ 平稳'}
</span>
</div>
<div className="indicator-summary-card__val">
{observations.find((o) => o.indicator === 'fasting_glucose')?.value || '—'}{' '}
<span className="unit-label">mmol/L</span>
</div>
<div className="sparkline-wrapper">
<Sparkline points={trendsMap['fasting_glucose']?.points || []} minLimit={5.1} />
</div>
</div>
{/* 血压(收缩压) */}
<div className="indicator-summary-card shadow-sm">
<div className="indicator-summary-card__head">
<span className="indicator-label"></span>
<span className={`direction-badge ${trendsMap['systolic_bp']?.direction}`}>
{trendsMap['systolic_bp']?.direction === 'up' ? '↑ 升高' : trendsMap['systolic_bp']?.direction === 'down' ? '↓ 降低' : '→ 平稳'}
</span>
</div>
<div className="indicator-summary-card__val">
{observations.find((o) => o.indicator === 'systolic_bp')?.value || '—'}{' '}
<span className="unit-label">mmHg</span>
</div>
<div className="sparkline-wrapper">
<Sparkline points={trendsMap['systolic_bp']?.points || []} maxLimit={140} />
</div>
</div>
{/* 体重 */}
<div className="indicator-summary-card shadow-sm">
<div className="indicator-summary-card__head">
<span className="indicator-label"></span>
<span className={`direction-badge ${trendsMap['weight']?.direction}`}>
{trendsMap['weight']?.direction === 'up' ? '↑ 升高' : trendsMap['weight']?.direction === 'down' ? '↓ 降低' : '→ 平稳'}
</span>
</div>
<div className="indicator-summary-card__val">
{observations.find((o) => o.indicator === 'weight')?.value || '—'}{' '}
<span className="unit-label">kg</span>
</div>
<div className="sparkline-wrapper">
<Sparkline points={trendsMap['weight']?.points || []} />
</div>
</div>
{/* 心率 */}
<div className="indicator-summary-card shadow-sm">
<div className="indicator-summary-card__head">
<span className="indicator-label"></span>
<span className={`direction-badge ${trendsMap['heart_rate']?.direction}`}>
{trendsMap['heart_rate']?.direction === 'up' ? '↑ 升高' : trendsMap['heart_rate']?.direction === 'down' ? '↓ 降低' : '→ 平稳'}
</span>
</div>
<div className="indicator-summary-card__val">
{observations.find((o) => o.indicator === 'heart_rate')?.value || '—'}{' '}
<span className="unit-label">bpm</span>
</div>
<div className="sparkline-wrapper">
<Sparkline points={trendsMap['heart_rate']?.points || []} minLimit={50} maxLimit={110} />
</div>
</div>
</div>
{/* 2. 静态档案 + 身心自评历史曲线图 */}
<div className="grid-2" style={{ gap: 'var(--space-4)' }}>
{/* 孕产静态档案 */}
<div className="card shadow-sm">
<h3 className="pane-title"><UserRound size={18} /> </h3>
<div className="static-archive-grid">
<div className="archive-item"><span className="archive-label"></span><span>{patient.name}</span></div>
<div className="archive-item"><span className="archive-label"></span><span>{patient.age} </span></div>
<div className="archive-item"><span className="archive-label"></span><span>{patient.heightCm || '162'} cm</span></div>
<div className="archive-item"><span className="archive-label"></span><span>{patient.prePregnancyWeightKg || '58'} kg</span></div>
<div className="archive-item"><span className="archive-label">EDD</span><span>{formatDate(patient.edd)}</span></div>
<div className="archive-item"><span className="archive-label">LMP</span><span>{formatDate(patient.lmp)}</span></div>
<div className="archive-item"><span className="archive-label"></span><span>{patient.multipleGestation ? '🔴 是' : '🟢 否'}</span></div>
<div className="archive-item"><span className="archive-label">尿</span><span>{patient.historyGdm ? '🔴 有' : '🟢 无'}</span></div>
<div className="archive-item"><span className="archive-label"></span><span>{patient.historyPih ? '🔴 有' : '🟢 无'}</span></div>
</div>
</div>
{/* 焦虑身心评分纵向曲线 (T-D.8) */}
<div className="card shadow-sm">
<h3 className="pane-title"><MessageSquareHeart size={18} /> /线</h3>
{emotions.length === 0 ? (
<p className="empty-small"> N </p>
) : (
<div className="stack text-center" style={{ padding: 'var(--space-4) 0' }}>
<p className="muted" style={{ fontSize: 'var(--font-sm)' }}></p>
<div className="spread align-center justify-center" style={{ gap: 'var(--space-8)', margin: 'var(--space-4) 0' }}>
<div className="huge-stat-box">
<span className="huge-val text-indigo">{emotions[0].score}</span>
<span className="muted" style={{ fontSize: 'var(--font-xs)' }}>/10 </span>
</div>
<Sparkline points={emotions.map((e) => e.score).reverse()} minLimit={3} />
</div>
<div className="work-list-mini" style={{ maxHeight: '110px', overflowY: 'auto' }}>
{emotions.slice(0, 3).map((e) => (
<div key={e.id} className="row align-center spread shadow-sm" style={{ padding: '6px 12px', fontSize: 'var(--font-xs)', background: '#fff', borderRadius: 'var(--radius-sm)' }}>
<span className="muted">{formatDate(e.createdAt)}</span>
<span className={`badge ${e.status === 'crisis' ? 'badge-rose' : 'badge-amber'}`}>{e.status === 'crisis' ? '危机' : '担忧'}</span>
<span className="muted" style={{ maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.note}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* 3. 指标历史所有观测及时间轴 */}
<div className="grid-2 workbench-grid" style={{ marginTop: 'var(--space-4)' }}>
{/* 动态时间轴 (CaseTimeline) ➔ D 阶段迁入健康档案汇总 (T-D.8) */}
<CaseTimeline
caseInfo={caseInfo}
observations={observations}
alerts={alerts}
carePlans={carePlans}
reminders={reminders}
/>
{/* 观测历史列表(带筛选) */}
<div className="card shadow-sm">
<h3 className="pane-title">📊 ({observations.length})</h3>
<div className="observations-list-container" style={{ maxHeight: '420px', overflowY: 'auto' }}>
{observations.length === 0 ? (
<p className="empty-small"></p>
) : (
<table className="table" style={{ fontSize: 'var(--font-sm)' }}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>QC </th>
</tr>
</thead>
<tbody>
{observations.map((obs) => (
<tr key={obs.id}>
<td className="muted">{formatDate(obs.measuredAt)}</td>
<td>{translateIndicator(obs.indicator)}</td>
<td style={{ fontWeight: 600 }}>{obs.value} {obs.unit}</td>
<td>
<span className={`badge ${obs.qcStatus === 'accepted' ? 'badge-emerald' : 'badge-rose'}`}>
{obs.qcStatus === 'accepted' ? '质控通过' : '质控拦截'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}