fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒

- SignalR Hub消息持久化+防重复+跨端广播
- 报告VLM+LLM真实AI流程(验证→提取→解读)
- 医生审核页重构(严重程度/建议模板/综合评语)
- 健康概览五合一趋势图(fl_chart+指标切换)
- 用药提醒时区修复+跨午夜+过期提醒
- 运动打卡DayOfWeek映射修复+计划覆盖查询
- 体重与其他四指标同级(Abnormal检查/确认卡牌)
- AI Agent支持多种类多时段批量录入
- 删除硬编码假数据(张三/假医生/假AI解读)
- 随访/报告审核数据链路全部打通
This commit is contained in:
MingNian
2026-06-07 23:04:23 +08:00
parent d0d7d8428d
commit 287eab80a9
67 changed files with 1765 additions and 680 deletions

View File

@@ -3,129 +3,252 @@ import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../../services/api-client';
import type { Report } from '../../types';
const SEVERITY_OPTIONS = [
{ value: 'Normal', label: '🟢 正常', desc: '各项指标均在参考范围内' },
{ value: 'Abnormal', label: '🟡 轻度异常', desc: '存在轻微偏离,无需紧急处理' },
{ value: 'Severe', label: '🟠 中度异常', desc: '需进一步检查或调整用药' },
{ value: 'Critical', label: '🔴 重度异常', desc: '需立即干预或就医' },
];
const RECOMMENDATION_TEMPLATES: Record<string, string[]> = {
'用药调整': ['建议继续当前用药方案,无需调整', '建议增加药量至____两周后复查', '建议减少药量至____观察不良反应', '建议更换为____原因____'],
'复查建议': ['建议1个月后复查血常规', '建议3个月后复查心电图', '建议半年后全面复查', '建议进行心脏彩超检查'],
'生活方式': ['建议低盐低脂饮食', '建议每日散步30分钟', '建议控制体重目标____kg', '建议戒烟限酒'],
'其他': ['指标正常,继续保持', '建议定期监测血压', '注意休息,避免劳累'],
};
const SEVERITY_COLORS: Record<string, string> = {
Normal: '#20C997', Abnormal: '#F59E0B', Severe: '#EF4444', Critical: '#DC2626',
};
export default function ReportDetailPage() {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const [report, setReport] = useState<Report | null>(null);
const [comment, setComment] = useState('');
const [severity, setSeverity] = useState('Normal');
const [selectedRecommendations, setSelectedRecommendations] = useState<string[]>([]);
const [customRecommendation, setCustomRecommendation] = useState('');
const [doctorComment, setDoctorComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (id) api.get<Report>(`/api/doctor/reports/${id}`).then(r => { setReport(r); setComment(r.doctorComment || ''); }).catch(() => {});
if (id) api.get<Report>(`/api/doctor/reports/${id}`).then(r => {
setReport(r);
setDoctorComment(r.doctorComment || '');
setSeverity(r.severity || 'Normal');
}).catch(() => {});
}, [id]);
function toggleRecommendation(item: string) {
setSelectedRecommendations(prev =>
prev.includes(item) ? prev.filter(i => i !== item) : [...prev, item]
);
}
async function submitReview() {
if (!id) return;
setSubmitting(true);
try {
await api.post(`/api/doctor/reports/${id}/review`, { comment });
const recommendation = [
...selectedRecommendations,
...(customRecommendation.trim() ? [customRecommendation.trim()] : []),
].join('\n');
await api.post(`/api/doctor/reports/${id}/review`, {
severity,
comment: doctorComment,
recommendation,
});
setSuccess(true);
// 刷新报告状态
const updated = await api.get<Report>(`/api/doctor/reports/${id}`);
setReport(updated);
} catch { alert('提交失败'); }
setSubmitting(false);
}
if (!report) return <div style={{ padding: 40, color: '#9BA0B4' }}>...</div>;
// 解析 AI 指标
let indicators: any[] = [];
try { indicators = JSON.parse(report.aiIndicators || '[]'); } catch {}
const isReviewed = report.status === 'DoctorReviewed';
const sevColor = SEVERITY_COLORS[severity] || '#9BA0B4';
return (
<div style={{ padding: 32, maxWidth: 900 }}>
<div style={{ padding: 32, maxWidth: 960 }}>
{/* 顶栏 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<button onClick={() => nav(-1)} style={{ color: '#4F6EF7', fontSize: 14, border: 'none', background: 'none', cursor: 'pointer' }}> </button>
<h1 style={{ fontSize: 24, fontWeight: 700 }}></h1>
<button onClick={() => nav(-1)} style={{ color: '#4F6EF7', fontSize: 14, border: 'none', background: 'none', cursor: 'pointer' }}> </button>
<h1 style={{ fontSize: 22, fontWeight: 700, flex: 1 }}></h1>
<span style={{ padding: '3px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: isReviewed ? '#E6F9F2' : '#FFF8E6',
color: isReviewed ? '#20C997' : '#F59E0B' }}>
{isReviewed ? '✓ 已审核' : '⏳ 待审核'}
</span>
</div>
{/* 基本信息 */}
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', fontSize: 14 }}>
<div><span style={{ color: '#9BA0B4' }}></span>{report.patientName || '-'}</div>
<div><span style={{ color: '#9BA0B4' }}></span>{report.category}</div>
<div><span style={{ color: '#9BA0B4' }}></span>{new Date(report.createdAt).toLocaleString('zh-CN')}</div>
<div>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ padding: '2px 10px', borderRadius: 10, fontSize: 12, background: report.status === 'PendingDoctor' ? '#FFF8E6' : '#E6F9F2', color: report.status === 'PendingDoctor' ? '#F59E0B' : '#20C997' }}>
{report.status === 'PendingDoctor' ? '待审核' : '已审核'}
</span>
{/* 患者 + 报告信息 */}
<div style={{ background: '#FFF', borderRadius: 14, padding: '16px 24px', marginBottom: 20, boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', gap: 32, fontSize: 14 }}>
<span>👤 <b>{report.patientName || '-'}</b></span>
<span>📋 {report.category}</span>
<span>📅 {new Date(report.createdAt).toLocaleDateString('zh-CN')}</span>
{report.fileUrl && (
<a href={`http://localhost:5000${report.fileUrl}`} target="_blank" rel="noopener noreferrer"
style={{ color: '#4F6EF7', marginLeft: 'auto' }}>
📎
</a>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
{/* 左列AI 预解读 */}
<div>
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🤖</span> AI
<span style={{ fontSize: 11, color: '#F59E0B', background: '#FFF8E6', padding: '2px 8px', borderRadius: 6, fontWeight: 400 }}></span>
</div>
{report.aiSummary ? (
<div style={{ fontSize: 13, lineHeight: 1.8, color: '#5A6072', marginBottom: 16, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10, whiteSpace: 'pre-wrap' }}>
{report.aiSummary}
</div>
) : (
<div style={{ fontSize: 13, color: '#CCC', marginBottom: 16 }}>AI ...</div>
)}
{indicators.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '2px solid #EDF0F7' }}>
{['指标', '结果', '单位', '参考范围', '状态'].map(h => (
<th key={h} style={{ padding: '6px 8px', textAlign: 'left', color: '#9BA0B4', fontWeight: 500, fontSize: 11 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{indicators.map((ind: any, i: number) => {
const abnormal = ind.status === 'high';
const low = ind.status === 'low';
return (
<tr key={i} style={{ borderBottom: '1px solid #F5F5F5' }}>
<td style={{ padding: '7px 8px', fontSize: 13 }}>{ind.name}</td>
<td style={{ padding: '7px 8px', fontWeight: 600, fontSize: 13, color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>{ind.value}</td>
<td style={{ padding: '7px 8px', color: '#9BA0B4', fontSize: 12 }}>{ind.unit || '-'}</td>
<td style={{ padding: '7px 8px', color: '#9BA0B4', fontSize: 12 }}>{ind.referenceRange || '-'}</td>
<td style={{ padding: '7px 8px' }}>
<span style={{ padding: '1px 6px', borderRadius: 6, fontSize: 10, fontWeight: 500,
background: abnormal ? '#FEE2E2' : low ? '#FFF8E6' : '#E6F9F2',
color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>
{abnormal ? '↑高' : low ? '↓低' : '正常'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{report.fileUrl && (
<div style={{ marginTop: 16 }}>
<a href={`http://localhost:5000${report.fileUrl}`} target="_blank" rel="noopener noreferrer"
style={{ color: '#4F6EF7', fontSize: 13, textDecoration: 'underline' }}>
📎
</a>
</div>
)}
</div>
{/* AI 预解读 */}
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>🤖 AI </h2>
{report.aiSummary && (
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#5A6072', marginBottom: 16, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10 }}>
{report.aiSummary}
</div>
)}
{indicators.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '2px solid #EDF0F7' }}>
{['指标', '结果', '单位', '参考范围', '状态'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', color: '#9BA0B4', fontWeight: 500 }}>{h}</th>
{/* 右列:医生审核 */}
<div>
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 20, display: 'flex', alignItems: 'center', gap: 8 }}>
<span>👨</span>
</div>
{success && (
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#E6F9F2', borderRadius: 10, color: '#20C997', fontWeight: 500, fontSize: 13 }}>
</div>
)}
{/* 严重程度 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{SEVERITY_OPTIONS.map(opt => (
<button key={opt.value} onClick={() => setSeverity(opt.value)} disabled={isReviewed}
style={{
padding: '10px 12px', borderRadius: 8, border: severity === opt.value ? `2px solid ${sevColor}` : '2px solid #EDF0F7',
background: severity === opt.value ? `${sevColor}10` : '#FFF',
textAlign: 'left', cursor: isReviewed ? 'default' : 'pointer', opacity: isReviewed ? 0.7 : 1,
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{opt.label}</div>
<div style={{ fontSize: 10, color: '#9BA0B4', marginTop: 2 }}>{opt.desc}</div>
</button>
))}
</tr>
</thead>
<tbody>
{indicators.map((ind: any, i: number) => {
const abnormal = ind.status === 'high' || ind.status === 'abnormal';
const low = ind.status === 'low';
return (
<tr key={i} style={{ borderBottom: '1px solid #F2F3F7' }}>
<td style={{ padding: '8px 12px' }}>{ind.name}</td>
<td style={{ padding: '8px 12px', fontWeight: 500, color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>{ind.value}</td>
<td style={{ padding: '8px 12px', color: '#5A6072' }}>{ind.unit || '-'}</td>
<td style={{ padding: '8px 12px', color: '#5A6072' }}>{ind.range || '-'}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ padding: '2px 8px', borderRadius: 8, fontSize: 11, background: abnormal ? '#FEE2E2' : low ? '#FFF8E6' : '#E6F9F2', color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>
{abnormal ? '偏高' : low ? '偏低' : '正常'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
{/* 医生审核 */}
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>👨 </h2>
{success && (
<div style={{ marginBottom: 16, padding: '12px 18px', background: '#E6F9F2', borderRadius: 10, color: '#20C997', fontWeight: 500, fontSize: 14 }}>
{/* 快速建议模板 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}></div>
{Object.entries(RECOMMENDATION_TEMPLATES).map(([category, items]) => (
<div key={category} style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: '#9BA0B4', marginBottom: 4, fontWeight: 500 }}>{category}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{items.map(item => {
const selected = selectedRecommendations.includes(item);
return (
<button key={item} onClick={() => !isReviewed && toggleRecommendation(item)} disabled={isReviewed}
style={{
padding: '4px 10px', borderRadius: 6, border: selected ? '1px solid #4F6EF7' : '1px solid #E1E5ED',
background: selected ? '#EDF2FF' : '#FFF', color: selected ? '#4F6EF7' : '#5A6072',
fontSize: 12, cursor: isReviewed ? 'default' : 'pointer',
}}>
{item.length > 20 ? item.slice(0, 20) + '...' : item}
</button>
);
})}
</div>
</div>
))}
</div>
{/* 自定义建议 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 6 }}></div>
<textarea value={customRecommendation} onChange={e => setCustomRecommendation(e.target.value)} disabled={isReviewed}
placeholder="输入其他建议..."
rows={2}
style={{ width: '100%', padding: 10, border: '1px solid #E1E5ED', borderRadius: 8, fontSize: 13, outline: 'none', resize: 'vertical' }} />
</div>
{/* 医生评语 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 6 }}></div>
<textarea value={doctorComment} onChange={e => setDoctorComment(e.target.value)} disabled={isReviewed}
placeholder="输入对患者的综合评语..."
rows={3}
style={{ width: '100%', padding: 10, border: '1px solid #E1E5ED', borderRadius: 8, fontSize: 13, outline: 'none', resize: 'vertical' }} />
</div>
{/* 提交按钮 */}
{!isReviewed && (
<button onClick={submitReview} disabled={submitting}
style={{
width: '100%', padding: '12px 0', borderRadius: 10, border: 'none', fontSize: 15, fontWeight: 600,
background: submitting ? '#E1E5ED' : 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
color: submitting ? '#9BA0B4' : '#FFF', cursor: submitting ? 'default' : 'pointer',
}}>
{submitting ? '提交中...' : '✓ 提交审核意见'}
</button>
)}
{/* 已审核时展示历史记录 */}
{isReviewed && (
<div style={{ marginTop: 12, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10, fontSize: 13 }}>
<div style={{ color: '#9BA0B4', marginBottom: 6 }}></div>
{report.severity && <div>📊 {SEVERITY_OPTIONS.find(o => o.value === report.severity)?.label || report.severity}</div>}
{report.doctorComment && <div style={{ marginTop: 6 }}>💬 {report.doctorComment}</div>}
{report.doctorRecommendation && <div style={{ marginTop: 4, whiteSpace: 'pre-wrap' }}>📝 {report.doctorRecommendation}</div>}
<div style={{ color: '#9BA0B4', marginTop: 4, fontSize: 11 }}>
{report.doctorName} · {report.reviewedAt ? new Date(report.reviewedAt).toLocaleString('zh-CN') : ''}
</div>
</div>
)}
</div>
)}
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="请输入您的专业解读意见..."
rows={5}
style={{ width: '100%', padding: 14, border: '1px solid #E1E5ED', borderRadius: 12, fontSize: 14, outline: 'none', resize: 'vertical' }}
/>
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={submitReview} disabled={submitting || !comment.trim()}
style={{
padding: '12px 32px',
background: submitting || !comment.trim() ? '#E1E5ED' : 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
color: submitting || !comment.trim() ? '#9BA0B4' : '#FFF',
border: 'none', borderRadius: 12, fontSize: 15, fontWeight: 600,
}}>
{submitting ? '提交中...' : '提交审核意见'}
</button>
</div>
</div>
</div>

View File

@@ -103,9 +103,11 @@ export interface Report {
fileType: string;
category: string;
status: string;
severity: string | null;
aiSummary: string | null;
aiIndicators: string | null;
doctorComment: string | null;
doctorRecommendation: string | null;
doctorName: string | null;
reviewedAt: string | null;
createdAt: string;