fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒
- SignalR Hub消息持久化+防重复+跨端广播 - 报告VLM+LLM真实AI流程(验证→提取→解读) - 医生审核页重构(严重程度/建议模板/综合评语) - 健康概览五合一趋势图(fl_chart+指标切换) - 用药提醒时区修复+跨午夜+过期提醒 - 运动打卡DayOfWeek映射修复+计划覆盖查询 - 体重与其他四指标同级(Abnormal检查/确认卡牌) - AI Agent支持多种类多时段批量录入 - 删除硬编码假数据(张三/假医生/假AI解读) - 随访/报告审核数据链路全部打通
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user