fix: patient report shows interpretation, medication daily tracking, followup bugs, home overview restored, doctor renamed
This commit is contained in:
@@ -6,7 +6,7 @@ const navItems = [
|
|||||||
{ to: '/patients', label: '患者管理', icon: '👥' },
|
{ to: '/patients', label: '患者管理', icon: '👥' },
|
||||||
{ to: '/consultations', label: '在线问诊', icon: '💬' },
|
{ to: '/consultations', label: '在线问诊', icon: '💬' },
|
||||||
{ to: '/reports', label: '报告审核', icon: '📋' },
|
{ to: '/reports', label: '报告审核', icon: '📋' },
|
||||||
{ to: '/follow-ups', label: '随访管理', icon: '📅' },
|
{ to: '/follow-ups', label: '复查管理', icon: '📅' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sidebarBg = '#0F1D3D';
|
const sidebarBg = '#0F1D3D';
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Empty } from '@/components/common/Empty';
|
|
||||||
import { Badge } from '@/components/common/Badge';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useNotificationStore } from '@/stores/notification.store';
|
import { useNotificationStore } from '@/stores/notification.store';
|
||||||
import * as healthService from '@/services/health.service';
|
import * as healthService from '@/services/health.service';
|
||||||
import { MEASUREMENT_TYPES, HEALTH_TIPS } from '@/utils/constants';
|
|
||||||
import { getBPRiskLevel } from '@/utils/format';
|
import { getBPRiskLevel } from '@/utils/format';
|
||||||
import type { HealthStats } from '@/types';
|
import type { HealthStats } from '@/types';
|
||||||
import styles from './HomePage.module.css';
|
import styles from './HomePage.module.css';
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ label: '测血压', icon: '💓', path: '/health/records?type=blood_pressure' },
|
{ key: 'bp', label: '血压', icon: '💓', path: '/health/records?type=blood_pressure', badge: false },
|
||||||
{ label: '记用药', icon: '💊', path: '/health/medications' },
|
{ key: 'med', label: '用药', icon: '💊', path: '/health/medications', badge: false },
|
||||||
{ label: '在线问诊', icon: '💬', path: '/services/consultation' },
|
{ key: 'chat', label: '问诊', icon: '💬', path: '/services/consultation', badge: true },
|
||||||
{ label: '报告解读', icon: '📋', path: '/services/reports' },
|
{ key: 'report', label: '报告', icon: '📋', path: '/services/reports', badge: true },
|
||||||
{ label: '健康日历', icon: '📅', path: '/health/calendar' },
|
{ key: 'calendar', label: '日历', icon: '📅', path: '/health/calendar', badge: false },
|
||||||
{ label: '运动饮食', icon: '🏃', path: '/health/exercise-diet' },
|
{ key: 'followup', label: '复查', icon: '🏥', path: '/services/follow-ups', badge: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
@@ -26,7 +22,6 @@ export function HomePage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { unreadCount, fetchNotifications } = useNotificationStore();
|
const { unreadCount, fetchNotifications } = useNotificationStore();
|
||||||
const [stats, setStats] = useState<HealthStats[]>([]);
|
const [stats, setStats] = useState<HealthStats[]>([]);
|
||||||
const [tipIndex, setTipIndex] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
healthService.getLatestStats().then(setStats);
|
healthService.getLatestStats().then(setStats);
|
||||||
@@ -35,6 +30,8 @@ export function HomePage() {
|
|||||||
|
|
||||||
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
||||||
const hrStats = stats.find((s) => s.type === 'heart_rate');
|
const hrStats = stats.find((s) => s.type === 'heart_rate');
|
||||||
|
const sugarStats = stats.find((s) => s.type === 'blood_sugar');
|
||||||
|
const spo2Stats = stats.find((s) => s.type === 'spo2');
|
||||||
|
|
||||||
const bpValue = bpStats?.latest?.value;
|
const bpValue = bpStats?.latest?.value;
|
||||||
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
|
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
|
||||||
@@ -43,76 +40,77 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
title={`你好,${user?.nickname || '用户'}`}
|
<div>
|
||||||
showBack={false}
|
<div style={{ fontSize: 20, fontWeight: 700 }}>你好,{user?.nickname || '用户'}</div>
|
||||||
rightAction={
|
<div style={{ fontSize: 12, color: '#9CA3AF', marginTop: 2 }}>今天感觉如何?</div>
|
||||||
<button className={styles.notifyBtn} onClick={() => navigate('/notifications')}>
|
</div>
|
||||||
🔔
|
<button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
|
||||||
{unreadCount > 0 && <Badge count={unreadCount} />}
|
🔔{unreadCount > 0 && <span style={{ position: 'absolute', top: -2, right: -2, width: 16, height: 16, borderRadius: 8, background: '#EF4444', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600 }}>{unreadCount}</span>}
|
||||||
</button>
|
</button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Health Overview */}
|
{/* Health Overview */}
|
||||||
{bpStats?.latest && hrStats?.latest ? (
|
<Card className={styles.overviewCard}>
|
||||||
<Card className={styles.overviewCard}>
|
<div className={styles.overviewHeader}>
|
||||||
<div className={styles.overviewHeader}>
|
<span className={styles.overviewTitle}>健康概览</span>
|
||||||
<span className={styles.overviewTitle}>健康概览</span>
|
<span className={styles.overviewTime}>最新记录</span>
|
||||||
<span className={styles.overviewTime}>最新记录</span>
|
</div>
|
||||||
</div>
|
<div className={styles.overviewData}>
|
||||||
<div className={styles.overviewData}>
|
<div className={styles.bpSection}>
|
||||||
<div className={styles.bpSection}>
|
<span className={styles.dataLabel}>血压</span>
|
||||||
<span className={styles.dataLabel}>血压</span>
|
{systolic ? (
|
||||||
<div className={styles.bpValues}>
|
<div className={styles.bpValues}>
|
||||||
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
|
<span className={`${styles.bpNum} ${riskLevel === 'normal' ? '' : riskLevel === 'borderline' ? styles.riskBp : styles.riskAbnormal}`}>
|
||||||
{systolic}
|
{systolic}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.bpSep}>/</span>
|
<span className={styles.bpSep}>/</span>
|
||||||
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
|
<span className={`${styles.bpNum} ${riskLevel === 'normal' ? '' : riskLevel === 'borderline' ? styles.riskBp : styles.riskAbnormal}`}>
|
||||||
{diastolic}
|
{diastolic}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.unit}>mmHg</span>
|
) : <span className={styles.bpNum} style={{ fontSize: 28, opacity: 0.4 }}>--/--</span>}
|
||||||
</div>
|
<span className={styles.unit}>mmHg</span>
|
||||||
<div className={styles.divider} />
|
|
||||||
<div className={styles.hrSection}>
|
|
||||||
<span className={styles.dataLabel}>心率</span>
|
|
||||||
<span className={styles.hrNum}>{Number(hrStats.latest.value)}</span>
|
|
||||||
<span className={styles.unit}>bpm</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div className={styles.divider} />
|
||||||
) : (
|
<div className={styles.hrSection}>
|
||||||
<Empty icon="💓" message="暂无健康数据" />
|
<span className={styles.dataLabel}>心率</span>
|
||||||
)}
|
<span className={styles.hrNum}>{hrStats?.latest ? Number(hrStats.latest.value) : '--'}</span>
|
||||||
|
<span className={styles.unit}>bpm</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<div className={styles.hrSection}>
|
||||||
|
<span className={styles.dataLabel}>血糖</span>
|
||||||
|
<span className={styles.hrNum}>{sugarStats?.latest ? Number(sugarStats.latest.value) : '--'}</span>
|
||||||
|
<span className={styles.unit}>mmol/L</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<div className={styles.hrSection}>
|
||||||
|
<span className={styles.dataLabel}>血氧</span>
|
||||||
|
<span className={styles.hrNum}>{spo2Stats?.latest ? Number(spo2Stats.latest.value) : '--'}</span>
|
||||||
|
<span className={styles.unit}>%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className={styles.quickActions}>
|
<div className={styles.quickActions}>
|
||||||
{QUICK_ACTIONS.map((action) => (
|
{QUICK_ACTIONS.map((action) => (
|
||||||
<button
|
<button key={action.key} className={styles.quickAction} onClick={() => navigate(action.path)}>
|
||||||
key={action.label}
|
<span className={styles.quickIcon}>
|
||||||
className={styles.quickAction}
|
{action.icon}
|
||||||
onClick={() => navigate(action.path)}
|
{action.badge && unreadCount > 0 && (
|
||||||
>
|
<span style={{
|
||||||
<span className={styles.quickIcon}>{action.icon}</span>
|
position: 'absolute', top: -4, right: -8, minWidth: 18, height: 18,
|
||||||
|
borderRadius: 9, background: '#EF4444', color: '#fff', fontSize: 10,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600,
|
||||||
|
}}>{unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={styles.quickLabel}>{action.label}</span>
|
<span className={styles.quickLabel}>{action.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Health Tip */}
|
|
||||||
<Card
|
|
||||||
className={styles.tipCard}
|
|
||||||
onClick={() => setTipIndex((prev) => (prev + 1) % HEALTH_TIPS.length)}
|
|
||||||
>
|
|
||||||
<div className={styles.tipHeader}>
|
|
||||||
<span>💡</span>
|
|
||||||
<span className={styles.tipTitle}>健康小贴士</span>
|
|
||||||
<span className={styles.tipHint}>点击换一条</span>
|
|
||||||
</div>
|
|
||||||
<p className={styles.tipContent}>{HEALTH_TIPS[tipIndex]}</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,60 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Button } from '@/components/common/Button';
|
import { Button } from '@/components/common/Button';
|
||||||
import { PieChart } from '@/components/charts/PieChart';
|
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||||
import * as medicationService from '@/services/medication.service';
|
import * as medicationService from '@/services/medication.service';
|
||||||
import type { Medication, MedicationAdherence } from '@/types';
|
import type { Medication, MedicationRecord } from '@/types';
|
||||||
import styles from './MedicationDetailPage.module.css';
|
import styles from './MedicationDetailPage.module.css';
|
||||||
|
|
||||||
export function MedicationDetailPage() {
|
export function MedicationDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const [med, setMed] = useState<Medication | null>(null);
|
||||||
const [medications, setMedications] = useState<Medication[]>([]);
|
const [records, setRecords] = useState<MedicationRecord[]>([]);
|
||||||
const [adherence, setAdherence] = useState<MedicationAdherence | null>(null);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = () => {
|
||||||
medicationService.getMedications().then(setMedications);
|
if (!id) return;
|
||||||
if (id) medicationService.getAdherence(id).then(setAdherence).catch(() => {});
|
medicationService.getMedications().then((meds) => {
|
||||||
}, [id]);
|
const m = meds.find((x) => x.id === id);
|
||||||
|
if (m) setMed(m);
|
||||||
|
});
|
||||||
|
medicationService.getMedicationRecords(id).then(setRecords);
|
||||||
|
};
|
||||||
|
|
||||||
const med = medications.find((m) => m.id === id);
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const todayRecords = records.filter((r) => r.takenAt?.startsWith(today) || !r.takenAt);
|
||||||
|
const todayTaken = todayRecords.filter((r) => r.isTaken);
|
||||||
|
const todaySlots = med?.timeSlots || [];
|
||||||
|
|
||||||
|
const handleMarkTaken = async (slot: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await medicationService.markTaken(id, slot);
|
||||||
|
toast('已记录');
|
||||||
|
load();
|
||||||
|
} catch { toast('失败', 'error'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
if (!med) {
|
if (!med) {
|
||||||
return (
|
return <div className="page--no-tab"><PageHeader title="药品详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>药品不存在</div></div>;
|
||||||
<div className="page--no-tab">
|
}
|
||||||
<PageHeader title="药品详情" />
|
|
||||||
<div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>药品不存在</div>
|
const slotTaken = (slot: string) => todayRecords.some((r) => r.timeSlot === slot && r.isTaken);
|
||||||
</div>
|
|
||||||
);
|
// Recent 7 days adherence
|
||||||
|
const last7Days: { date: string; taken: number; total: number }[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const ds = d.toISOString().split('T')[0];
|
||||||
|
const dayRecords = records.filter((r) => r.takenAt?.startsWith(ds) && r.isTaken);
|
||||||
|
last7Days.push({ date: ds, taken: dayRecords.length, total: todaySlots.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,24 +63,69 @@ export function MedicationDetailPage() {
|
|||||||
<Card className={styles.infoCard}>
|
<Card className={styles.infoCard}>
|
||||||
<div className={styles.infoTitle}>{med.drugName}</div>
|
<div className={styles.infoTitle}>{med.drugName}</div>
|
||||||
<div className={styles.infoRow}><span>剂量</span><span>{med.dosage}</span></div>
|
<div className={styles.infoRow}><span>剂量</span><span>{med.dosage}</span></div>
|
||||||
<div className={styles.infoRow}><span>服用时间</span><span>{med.timeSlots.join(', ')}</span></div>
|
<div className={styles.infoRow}><span>频次</span><span>{med.frequency} · {med.timeSlots.join(', ')}</span></div>
|
||||||
<div className={styles.infoRow}><span>日期</span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div>
|
<div className={styles.infoRow}><span>日期</span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div>
|
||||||
<div className={styles.infoRow}><span>状态</span><span className={med.status === 'active' ? styles.activeBadge : ''}>{med.status === 'active' ? '进行中' : '已结束'}</span></div>
|
|
||||||
{med.notes && <div className={styles.infoRow}><span>备注</span><span>{med.notes}</span></div>}
|
{med.notes && <div className={styles.infoRow}><span>备注</span><span>{med.notes}</span></div>}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{adherence && (
|
{/* Today's medication tracking */}
|
||||||
<Card className={styles.adherenceCard}>
|
<Card className={styles.infoCard}>
|
||||||
<div className={styles.adherenceTitle}>近30天依从性</div>
|
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>
|
||||||
<div className={styles.adherenceRate}>{adherence.rate}%</div>
|
今日服药 <span style={{ color: '#9CA3AF', fontWeight: 400, fontSize: 13 }}>{today}</span>
|
||||||
<PieChart
|
</div>
|
||||||
data={[
|
{todaySlots.map((slot) => {
|
||||||
{ name: '已服用', value: adherence.rate, color: '#10B981' },
|
const taken = slotTaken(slot);
|
||||||
{ name: '未服用', value: 100 - adherence.rate, color: '#EF4444' },
|
return (
|
||||||
]}
|
<div key={slot} style={{
|
||||||
/>
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
</Card>
|
padding: '10px 0', borderBottom: '1px solid #f0f0f0',
|
||||||
)}
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 10, height: 10, borderRadius: 5,
|
||||||
|
background: taken ? '#10B981' : '#E5E7EB',
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 14 }}>{slot}</span>
|
||||||
|
<span style={{ fontSize: 12, color: taken ? '#10B981' : '#9CA3AF' }}>
|
||||||
|
{taken ? '已服用' : '未服用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!taken && med.status === 'active' && (
|
||||||
|
<Button size="sm" variant="outline" loading={loading} onClick={() => handleMarkTaken(slot)}>
|
||||||
|
打卡
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ marginTop: 12, fontSize: 12, color: '#9CA3AF' }}>
|
||||||
|
今日已服 {todaySlots.filter((s) => slotTaken(s)).length}/{todaySlots.length} 次
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 7-day adherence */}
|
||||||
|
<Card className={styles.infoCard}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>近7天记录</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{last7Days.map((d) => {
|
||||||
|
const pct = d.total > 0 ? (d.taken / d.total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={d.date} style={{ flex: 1, textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
height: 40, borderRadius: 6, marginBottom: 4,
|
||||||
|
background: pct === 100 ? '#10B981' : pct > 0 ? '#F59E0B' : '#E5E7EB',
|
||||||
|
transition: 'background 0.3s',
|
||||||
|
}} />
|
||||||
|
<div style={{ fontSize: 10, color: '#9CA3AF' }}>{d.date.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 8, fontSize: 11, color: '#9CA3AF' }}>
|
||||||
|
<span>🟢 全勤</span><span>🟡 漏服</span><span>⬜ 未开始</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,27 @@ export function FollowUpEditPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [doctorName, setDoctorName] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [scheduledAt, setScheduledAt] = useState('');
|
const [scheduledAt, setScheduledAt] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!title) { toast('请填写标题', 'error'); return; }
|
if (!title) { toast('请填写标题', 'error'); return; }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await followupService.addFollowUp({
|
try {
|
||||||
title, description,
|
await followupService.addFollowUp({
|
||||||
scheduledAt: scheduledAt || new Date().toISOString(),
|
title, description, notes,
|
||||||
status: 'upcoming',
|
scheduledAt: scheduledAt || new Date().toISOString(),
|
||||||
reminderEnabled: true,
|
status: 'upcoming',
|
||||||
});
|
reminderEnabled: true,
|
||||||
toast('添加成功');
|
});
|
||||||
navigate(-1);
|
toast('添加成功');
|
||||||
|
navigate(-1);
|
||||||
|
} catch {
|
||||||
|
toast('添加失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,12 +39,11 @@ export function FollowUpEditPage() {
|
|||||||
<PageHeader title="新增复查" />
|
<PageHeader title="新增复查" />
|
||||||
<div className={styles.form}>
|
<div className={styles.form}>
|
||||||
<Input label="复查标题" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:PCI术后3个月复查" />
|
<Input label="复查标题" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:PCI术后3个月复查" />
|
||||||
<Input label="医生" value={doctorName} onChange={(e) => setDoctorName(e.target.value)} placeholder="医生姓名" />
|
<Input label="复查描述" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="简单描述复查目的" />
|
||||||
<Input label="描述" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="复查描述" />
|
|
||||||
<Input label="复查时间" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} type="datetime-local" />
|
<Input label="复查时间" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} type="datetime-local" />
|
||||||
<div className={styles.textareaWrap}>
|
<div className={styles.textareaWrap}>
|
||||||
<label className={styles.label}>备注</label>
|
<label className={styles.label}>备注</label>
|
||||||
<textarea className={styles.textarea} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="复查说明..." rows={3} />
|
<textarea className={styles.textarea} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="其他补充说明..." rows={3} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>保存</Button>
|
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>保存</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function FollowUpListPage() {
|
|||||||
<Empty icon="🏥" message="暂无复查计划" />
|
<Empty icon="🏥" message="暂无复查计划" />
|
||||||
) : (
|
) : (
|
||||||
filtered.map((f) => (
|
filtered.map((f) => (
|
||||||
<Card key={f.id} className={styles.card} onClick={() => navigate(`/health/medications`)}>
|
<Card key={f.id} className={styles.card}>
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
<span className={styles.title}>{f.title}</span>
|
<span className={styles.title}>{f.title}</span>
|
||||||
<span className={styles.status} style={{ color: statusColor(f.status) }}>
|
<span className={styles.status} style={{ color: statusColor(f.status) }}>
|
||||||
|
|||||||
@@ -2,63 +2,99 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Button } from '@/components/common/Button';
|
|
||||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
|
||||||
import * as reportService from '@/services/report.service';
|
|
||||||
import type { Report } from '@/types';
|
|
||||||
import { formatDate } from '@/utils/format';
|
import { formatDate } from '@/utils/format';
|
||||||
|
import * as reportService from '@/services/report.service';
|
||||||
import styles from './ReportDetailPage.module.css';
|
import styles from './ReportDetailPage.module.css';
|
||||||
|
|
||||||
|
interface ReportData {
|
||||||
|
id: string; title: string; category: string; status: string;
|
||||||
|
imageUrls: string[]; riskLevel?: string; summary?: string; suggestions?: string;
|
||||||
|
createdAt: string; completedAt?: string; patientName?: string; doctorName?: string;
|
||||||
|
items?: { id: string; itemName: string; resultValue: string; unit?: string; referenceRange?: string; isAbnormal: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportDetailPage() {
|
export function ReportDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [report, setReport] = useState<Report | null>(null);
|
const [report, setReport] = useState<ReportData | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) reportService.getReport(id).then((r) => setReport(r || null));
|
if (id) {
|
||||||
|
import('@/services/api-client').then(({ api }) => {
|
||||||
|
api.get<ReportData>(`/api/reports/${id}`).then((r) => setReport(r.data));
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (!report) {
|
if (!report) return <div className="page--no-tab"><PageHeader title="报告详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>加载中...</div></div>;
|
||||||
return <div className="page--no-tab"><PageHeader title="报告详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>报告不存在</div></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const riskLabels = { normal: '正常', attention: '需关注', abnormal: '异常' };
|
const riskMap: Record<string, { label: string; color: string }> = {
|
||||||
const riskColors = { normal: '#10B981', attention: '#F59E0B', abnormal: '#EF4444' };
|
normal: { label: '正常', color: '#10B981' },
|
||||||
|
attention: { label: '需关注', color: '#F59E0B' },
|
||||||
|
abnormal: { label: '异常', color: '#EF4444' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCompleted = report.status === 'completed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title={report.title} />
|
<PageHeader title={report.title} />
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.infoRow}><span>类型</span><span>{report.category}</span></div>
|
<div className={styles.infoRow}><span>类型</span><span>{report.category}</span></div>
|
||||||
<div className={styles.infoRow}><span>上传时间</span><span>{formatDate(report.uploadAt)}</span></div>
|
<div className={styles.infoRow}><span>上传时间</span><span>{formatDate(report.createdAt)}</span></div>
|
||||||
<div className={styles.infoRow}><span>状态</span><span>{report.status === 'completed' ? '✅ 已解读' : report.status === 'interpreting' ? '⏳ 解读中' : '📤 待解读'}</span></div>
|
<div className={styles.infoRow}>
|
||||||
{report.result && (
|
<span>状态</span>
|
||||||
<div className={styles.result}>
|
<span style={{ color: isCompleted ? '#10B981' : '#F59E0B' }}>
|
||||||
<div className={styles.riskBadge} style={{ background: riskColors[report.result.riskLevel] }}>
|
{isCompleted ? '已完成解读' : '待解读'}
|
||||||
{riskLabels[report.result.riskLevel]}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.summary}>{report.result.summary}</p>
|
|
||||||
|
|
||||||
<div className={styles.findingsTitle}>检查结果</div>
|
{report.imageUrls && report.imageUrls.length > 0 && (
|
||||||
{report.result.findings.map((f, i) => (
|
<div style={{ marginTop: 12 }}>
|
||||||
<div key={i} className={styles.finding}>
|
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}>报告图片</div>
|
||||||
<div className={styles.findingHeader}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span className={styles.findingItem}>{f.item}</span>
|
{report.imageUrls.map((url, i) => (
|
||||||
<span className={`${styles.findingValue} ${f.assessment === 'abnormal' ? styles.abnormal : ''}`}>{f.value}</span>
|
<img key={i} src={`http://localhost:5000${url}`} alt="report"
|
||||||
</div>
|
style={{ width: 80, height: 80, borderRadius: 8, objectFit: 'cover', border: '1px solid #eee' }} />
|
||||||
<div className={styles.findingRef}>参考范围:{f.referenceRange}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className={styles.findingsTitle}>建议</div>
|
|
||||||
<ul className={styles.suggestions}>
|
|
||||||
{report.result.suggestions.map((s, i) => (
|
|
||||||
<li key={i}>{s}</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<div className={styles.result}>
|
||||||
|
{report.riskLevel && (
|
||||||
|
<div className={styles.riskBadge} style={{ background: riskMap[report.riskLevel]?.color || '#888' }}>
|
||||||
|
{riskMap[report.riskLevel]?.label || report.riskLevel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={styles.summary}>{report.summary}</p>
|
||||||
|
|
||||||
|
{report.items && report.items.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={styles.findingsTitle}>检查项目</div>
|
||||||
|
{report.items.map((item) => (
|
||||||
|
<div key={item.id} className={styles.finding}>
|
||||||
|
<div className={styles.findingHeader}>
|
||||||
|
<span className={styles.findingItem}>{item.itemName}</span>
|
||||||
|
<span className={styles.findingValue} style={{ color: item.isAbnormal ? '#EF4444' : '#10B981' }}>
|
||||||
|
{item.resultValue} {item.unit || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.findingRef}>参考:{item.referenceRange || '-'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.suggestions && (
|
||||||
|
<>
|
||||||
|
<div className={styles.findingsTitle}>医生建议</div>
|
||||||
|
<p style={{ fontSize: 13, color: '#555', lineHeight: 1.6 }}>{report.suggestions}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<ToastContainer />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ export async function getFollowUps(): Promise<FollowUp[]> {
|
|||||||
return res.data.map(mapFollowUp);
|
return res.data.map(mapFollowUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFollowUp(data: Omit<FollowUp, 'id' | 'patientId' | 'createdAt'>): Promise<FollowUp> {
|
export async function addFollowUp(data: Omit<FollowUp, 'id' | 'patientId' | 'createdAt'> & { notes?: string }): Promise<FollowUp> {
|
||||||
const res = await api.post<RawFollowUp>('/api/follow-ups', {
|
const res = await api.post<RawFollowUp>('/api/follow-ups', {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
scheduledAt: data.scheduledAt,
|
scheduledAt: data.scheduledAt,
|
||||||
reminderEnabled: data.reminderEnabled,
|
reminderEnabled: data.reminderEnabled,
|
||||||
notes: data.notes,
|
notes: (data as Record<string, unknown>).notes || null,
|
||||||
});
|
});
|
||||||
return mapFollowUp(res.data);
|
return mapFollowUp(res.data);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user