Initial commit: HealthManager full-stack health management platform
Backend: .NET 10 + PostgreSQL + EF Core + JWT + SignalR Frontend patient: React 19 + TypeScript + Vite (mobile H5) Frontend doctor: React 19 + TypeScript + Vite (desktop web)
This commit is contained in:
85
frontend-patient/src/pages/services/ChatPage.module.css
Normal file
85
frontend-patient/src/pages/services/ChatPage.module.css
Normal file
@@ -0,0 +1,85 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding-top: calc(var(--header-height) + 8px);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
margin-bottom: 14px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.patient {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.doctor {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.bubbleContent {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.patient .bubbleContent {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.doctor .bubbleContent {
|
||||
background: var(--color-white);
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.bubbleTime {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inputBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-white);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-bottom: env(safe-area-inset-bottom, 10px);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
padding: 10px 18px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sendBtn:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
98
frontend-patient/src/pages/services/ChatPage.tsx
Normal file
98
frontend-patient/src/pages/services/ChatPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import * as consultationService from '@/services/consultation.service';
|
||||
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
|
||||
import { formatRelative } from '@/utils/format';
|
||||
import styles from './ChatPage.module.css';
|
||||
|
||||
export function ChatPage() {
|
||||
const { doctorId } = useParams<{ doctorId: string }>();
|
||||
const [doctor, setDoctor] = useState<Doctor | null>(null);
|
||||
const [consultation, setConsultation] = useState<Consultation | null>(null);
|
||||
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (doctorId) {
|
||||
consultationService.getDoctor(doctorId).then((d) => setDoctor(d || null));
|
||||
consultationService.getConsultation(doctorId).then(async (c) => {
|
||||
if (c) {
|
||||
setConsultation(c);
|
||||
} else {
|
||||
const newC = await consultationService.startConsultation(doctorId);
|
||||
setConsultation(newC);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [doctorId]);
|
||||
|
||||
// Fetch messages when consultation is loaded
|
||||
useEffect(() => {
|
||||
if (consultation?.id) {
|
||||
consultationService.getDoctorReply(consultation.id).then(() => {
|
||||
// The messages are fetched as a side effect; fetch them directly
|
||||
import('@/services/api-client').then(({ api }) => {
|
||||
api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`)
|
||||
.then((res) => setMessages(res.data));
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [consultation?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!text.trim() || !consultation || sending) return;
|
||||
setSending(true);
|
||||
const msgText = text;
|
||||
setText('');
|
||||
const sent = await consultationService.sendMessage(consultation.id, msgText);
|
||||
setMessages((prev) => [...prev, sent]);
|
||||
setSending(false);
|
||||
// Poll for doctor reply after delay
|
||||
setTimeout(async () => {
|
||||
const reply = await consultationService.getDoctorReply(consultation.id);
|
||||
if (reply) {
|
||||
setMessages((prev) => {
|
||||
if (prev.find((m) => m.id === reply.id)) return prev;
|
||||
return [...prev, reply];
|
||||
});
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title={doctor?.name || '咨询'} />
|
||||
<div className={styles.messages}>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}
|
||||
>
|
||||
<div className={styles.bubbleContent}>{msg.content}</div>
|
||||
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<div className={styles.inputBar}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="输入消息..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
/>
|
||||
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}>
|
||||
{sending ? '...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.filterBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 4px;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filterBar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.filterChip {
|
||||
white-space: nowrap;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.active { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||
|
||||
.docCard { margin-bottom: 8px; }
|
||||
|
||||
.docHeader { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||
|
||||
.avatar {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docInfo { flex: 1; }
|
||||
|
||||
.docName {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.onlineDot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.docTitle { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
|
||||
.docHospital { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.docStats { font-size: var(--font-size-xs); color: var(--color-text-tertiary); display: flex; gap: 12px; margin-top: 2px; }
|
||||
|
||||
.docFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.fee { font-size: var(--font-size-sm); color: var(--color-danger); font-weight: 600; }
|
||||
58
frontend-patient/src/pages/services/DoctorListPage.tsx
Normal file
58
frontend-patient/src/pages/services/DoctorListPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { DEPARTMENT_OPTIONS } from '@/utils/constants';
|
||||
import * as consultationService from '@/services/consultation.service';
|
||||
import type { Doctor } from '@/types';
|
||||
import styles from './DoctorListPage.module.css';
|
||||
|
||||
export function DoctorListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [dept, setDept] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
consultationService.getDoctors(dept || undefined).then(setDoctors);
|
||||
}, [dept]);
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="在线问诊" />
|
||||
<div className={styles.filterBar}>
|
||||
<button className={`${styles.filterChip} ${!dept ? styles.active : ''}`} onClick={() => setDept('')}>全部</button>
|
||||
{DEPARTMENT_OPTIONS.slice(0, 5).map((d) => (
|
||||
<button key={d} className={`${styles.filterChip} ${dept === d ? styles.active : ''}`} onClick={() => setDept(d)}>{d}</button>
|
||||
))}
|
||||
</div>
|
||||
{doctors.length === 0 ? (
|
||||
<Empty icon="👨⚕️" message="暂无医生" />
|
||||
) : (
|
||||
doctors.map((doc) => (
|
||||
<Card key={doc.id} className={styles.docCard}>
|
||||
<div className={styles.docHeader}>
|
||||
<div className={styles.avatar}>{doc.name[0]}</div>
|
||||
<div className={styles.docInfo}>
|
||||
<div className={styles.docName}>
|
||||
{doc.name}
|
||||
{doc.isAvailable && <span className={styles.onlineDot} />}
|
||||
</div>
|
||||
<div className={styles.docTitle}>{doc.title} · {doc.department}</div>
|
||||
<div className={styles.docHospital}>{doc.department}</div>
|
||||
<div className={styles.docStats}>
|
||||
<span>{doc.specialty?.slice(0, 2).join('、') || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.docFooter}>
|
||||
<span className={styles.fee}>{doc.title}</span>
|
||||
<Button size="sm" onClick={() => navigate(`/services/consultation/chat/${doc.id}`)}>立即咨询</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.textareaWrap { display: flex; flex-direction: column; gap: 6px; }
|
||||
.label { font-size: var(--font-size-sm); font-weight: 500; color: var(--color-text-secondary); }
|
||||
.textarea { padding: 10px 14px; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); resize: vertical; font-size: var(--font-size-sm); background: var(--color-bg); }
|
||||
.textarea:focus { border-color: var(--color-primary); background: var(--color-white); outline: none; }
|
||||
48
frontend-patient/src/pages/services/FollowUpEditPage.tsx
Normal file
48
frontend-patient/src/pages/services/FollowUpEditPage.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as followupService from '@/services/followup.service';
|
||||
import styles from './FollowUpEditPage.module.css';
|
||||
|
||||
export function FollowUpEditPage() {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [doctorName, setDoctorName] = useState('');
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title) { toast('请填写标题', 'error'); return; }
|
||||
setLoading(true);
|
||||
await followupService.addFollowUp({
|
||||
title, description,
|
||||
scheduledAt: scheduledAt || new Date().toISOString(),
|
||||
status: 'upcoming',
|
||||
reminderEnabled: true,
|
||||
});
|
||||
toast('添加成功');
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="新增复查" />
|
||||
<div className={styles.form}>
|
||||
<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={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} type="datetime-local" />
|
||||
<div className={styles.textareaWrap}>
|
||||
<label className={styles.label}>备注</label>
|
||||
<textarea className={styles.textarea} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="复查说明..." rows={3} />
|
||||
</div>
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>保存</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
||||
.card { margin-bottom: 8px; }
|
||||
.cardHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.title { font-size: var(--font-size-base); font-weight: 600; }
|
||||
.status { font-size: var(--font-size-xs); font-weight: 500; }
|
||||
.meta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||
.fab { position: fixed; bottom: 80px; right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px)); padding: 12px 20px; background: var(--color-primary); color: var(--color-text-inverse); border-radius: var(--radius-full); font-weight: 600; box-shadow: var(--shadow-lg); z-index: 50; }
|
||||
58
frontend-patient/src/pages/services/FollowUpListPage.tsx
Normal file
58
frontend-patient/src/pages/services/FollowUpListPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import * as followupService from '@/services/followup.service';
|
||||
import type { FollowUp } from '@/types';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import styles from './FollowUpListPage.module.css';
|
||||
|
||||
export function FollowUpListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [followups, setFollowups] = useState<FollowUp[]>([]);
|
||||
const [tab, setTab] = useState<'upcoming' | 'completed'>('upcoming');
|
||||
|
||||
useEffect(() => {
|
||||
followupService.getFollowUps().then(setFollowups);
|
||||
}, []);
|
||||
|
||||
const filtered = followups.filter((f) => tab === 'upcoming' ? f.status === 'upcoming' : f.status === 'completed');
|
||||
|
||||
const statusColor = (s: FollowUp['status']) => {
|
||||
if (s === 'upcoming') return '#3B82F6';
|
||||
if (s === 'completed') return '#10B981';
|
||||
return '#EF4444';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="复查管理" />
|
||||
<div className={styles.tabs}>
|
||||
<button className={`${styles.tab} ${tab === 'upcoming' ? styles.tabActive : ''}`} onClick={() => setTab('upcoming')}>即将到来</button>
|
||||
<button className={`${styles.tab} ${tab === 'completed' ? styles.tabActive : ''}`} onClick={() => setTab('completed')}>已完成</button>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<Empty icon="🏥" message="暂无复查计划" />
|
||||
) : (
|
||||
filtered.map((f) => (
|
||||
<Card key={f.id} className={styles.card} onClick={() => navigate(`/health/medications`)}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.title}>{f.title}</span>
|
||||
<span className={styles.status} style={{ color: statusColor(f.status) }}>
|
||||
{f.status === 'upcoming' ? '待复查' : '已完成'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
<span>{f.doctorName || '未分配'} · {f.patientName || ''}</span>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
<span>{formatDate(f.scheduledAt, 'YYYY-MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
<button className={styles.fab} onClick={() => navigate('/services/follow-ups/add')}>+ 新增复查</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.card { margin-bottom: 12px; }
|
||||
.infoRow { display: flex; justify-content: space-between; padding: 8px 0; font-size: var(--font-size-sm); border-bottom: 1px solid var(--color-border-light); color: var(--color-text-secondary); }
|
||||
.result { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--color-border); }
|
||||
.riskBadge { display: inline-block; padding: 4px 12px; border-radius: var(--radius-full); color: white; font-size: var(--font-size-xs); font-weight: 600; margin-bottom: 8px; }
|
||||
.summary { font-size: var(--font-size-sm); color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 12px; }
|
||||
.findingsTitle { font-size: var(--font-size-sm); font-weight: 600; margin: 12px 0 8px; }
|
||||
.finding { padding: 8px 0; border-bottom: 1px solid var(--color-border-light); }
|
||||
.findingHeader { display: flex; justify-content: space-between; }
|
||||
.findingItem { font-size: var(--font-size-sm); color: var(--color-text-primary); }
|
||||
.findingValue { font-size: var(--font-size-sm); font-weight: 600; }
|
||||
.abnormal { color: var(--color-danger); }
|
||||
.findingRef { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||
.suggestions { padding-left: 18px; }
|
||||
.suggestions li { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin-bottom: 4px; }
|
||||
64
frontend-patient/src/pages/services/ReportDetailPage.tsx
Normal file
64
frontend-patient/src/pages/services/ReportDetailPage.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
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 styles from './ReportDetailPage.module.css';
|
||||
|
||||
export function ReportDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) reportService.getReport(id).then((r) => setReport(r || null));
|
||||
}, [id]);
|
||||
|
||||
if (!report) {
|
||||
return <div className="page--no-tab"><PageHeader title="报告详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>报告不存在</div></div>;
|
||||
}
|
||||
|
||||
const riskLabels = { normal: '正常', attention: '需关注', abnormal: '异常' };
|
||||
const riskColors = { normal: '#10B981', attention: '#F59E0B', abnormal: '#EF4444' };
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title={report.title} />
|
||||
<Card className={styles.card}>
|
||||
<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>{report.status === 'completed' ? '✅ 已解读' : report.status === 'interpreting' ? '⏳ 解读中' : '📤 待解读'}</span></div>
|
||||
{report.result && (
|
||||
<div className={styles.result}>
|
||||
<div className={styles.riskBadge} style={{ background: riskColors[report.result.riskLevel] }}>
|
||||
{riskLabels[report.result.riskLevel]}
|
||||
</div>
|
||||
<p className={styles.summary}>{report.result.summary}</p>
|
||||
|
||||
<div className={styles.findingsTitle}>检查结果</div>
|
||||
{report.result.findings.map((f, i) => (
|
||||
<div key={i} className={styles.finding}>
|
||||
<div className={styles.findingHeader}>
|
||||
<span className={styles.findingItem}>{f.item}</span>
|
||||
<span className={`${styles.findingValue} ${f.assessment === 'abnormal' ? styles.abnormal : ''}`}>{f.value}</span>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
||||
.card { margin-bottom: 8px; }
|
||||
.cardHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.cardTitle { font-size: var(--font-size-base); font-weight: 600; }
|
||||
.cardMeta { display: flex; justify-content: space-between; font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.fab { position: fixed; bottom: 80px; right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px)); padding: 12px 20px; background: var(--color-primary); color: var(--color-text-inverse); border-radius: var(--radius-full); font-weight: 600; box-shadow: var(--shadow-lg); z-index: 50; }
|
||||
62
frontend-patient/src/pages/services/ReportListPage.tsx
Normal file
62
frontend-patient/src/pages/services/ReportListPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import * as reportService from '@/services/report.service';
|
||||
import type { Report } from '@/types';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import styles from './ReportListPage.module.css';
|
||||
|
||||
export function ReportListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [tab, setTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
reportService.getReports().then(setReports);
|
||||
}, []);
|
||||
|
||||
const filtered = reports.filter((r) => {
|
||||
if (tab === 'all') return true;
|
||||
return tab === 'pending' ? r.status !== 'completed' : r.status === 'completed';
|
||||
});
|
||||
|
||||
const statusBadge = (status: Report['status']) => {
|
||||
const map = {
|
||||
pending: { label: '待解读', color: '#9CA3AF' },
|
||||
interpreting: { label: '解读中', color: '#F59E0B' },
|
||||
completed: { label: '已解读', color: '#10B981' },
|
||||
};
|
||||
const s = map[status];
|
||||
return <span style={{ color: s.color, fontSize: '12px', fontWeight: 500 }}>{s.label}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="报告解读" />
|
||||
<div className={styles.tabs}>
|
||||
{[{ key: 'all', label: '全部' }, { key: 'pending', label: '待解读' }, { key: 'completed', label: '已解读' }].map((t) => (
|
||||
<button key={t.key} className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`} onClick={() => setTab(t.key as typeof tab)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<Empty icon="📋" message="暂无报告" />
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<Card key={r.id} className={styles.card} onClick={() => navigate(`/services/reports/${r.id}`)}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.cardTitle}>{r.title}</span>
|
||||
{statusBadge(r.status)}
|
||||
</div>
|
||||
<div className={styles.cardMeta}>
|
||||
<span>{r.category}</span>
|
||||
<span>{formatDate(r.uploadAt, 'MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
<button className={styles.fab} onClick={() => navigate('/services/reports/upload')}>+ 上传报告</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.catLabel { font-size: var(--font-size-sm); font-weight: 500; color: var(--color-text-secondary); }
|
||||
.catGrid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.catChip { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
||||
.catActive { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||
.uploadArea { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px; background: var(--color-bg); border: 2px dashed var(--color-border); border-radius: var(--radius-lg); cursor: pointer; }
|
||||
52
frontend-patient/src/pages/services/ReportUploadPage.tsx
Normal file
52
frontend-patient/src/pages/services/ReportUploadPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as reportService from '@/services/report.service';
|
||||
import styles from './ReportUploadPage.module.css';
|
||||
|
||||
export function ReportUploadPage() {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState('血液检查');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const categories = ['血液检查', '心电图', '影像学', '尿液检查', '其他'];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) { toast('请输入报告名称', 'error'); return; }
|
||||
setLoading(true);
|
||||
await reportService.uploadReport({ title, category });
|
||||
toast('上传成功,正在解读中...');
|
||||
setTimeout(() => navigate(-1), 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="上传报告" />
|
||||
<div className={styles.form}>
|
||||
<Input label="报告名称" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:血脂全套检查报告" />
|
||||
|
||||
<div className={styles.catLabel}>报告类型</div>
|
||||
<div className={styles.catGrid}>
|
||||
{categories.map((c) => (
|
||||
<button key={c} className={`${styles.catChip} ${category === c ? styles.catActive : ''}`} onClick={() => setCategory(c)}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.uploadArea}>
|
||||
<span style={{ fontSize: 40 }}>📸</span>
|
||||
<span style={{ fontSize: '14px', color: '#6B7280' }}>点击拍照或选择图片上传</span>
|
||||
<span style={{ fontSize: '11px', color: '#9CA3AF' }}>模拟上传,直接保存即可</span>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
||||
提交上传
|
||||
</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 20px;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.card:active { transform: scale(0.98); }
|
||||
.icon { font-size: 36px; }
|
||||
.label { font-size: var(--font-size-md); font-weight: 600; }
|
||||
.desc { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
28
frontend-patient/src/pages/services/ServicesHubPage.tsx
Normal file
28
frontend-patient/src/pages/services/ServicesHubPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import styles from './ServicesHubPage.module.css';
|
||||
|
||||
const SERVICES = [
|
||||
{ label: '在线问诊', icon: '👨⚕️', desc: '图文咨询医生', path: '/services/consultation' },
|
||||
{ label: '报告解读', icon: '📋', desc: '上传检查报告', path: '/services/reports' },
|
||||
{ label: '复查管理', icon: '🏥', desc: '管理复查计划', path: '/services/follow-ups' },
|
||||
];
|
||||
|
||||
export function ServicesHubPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="服务" showBack={false} />
|
||||
<div className={styles.grid}>
|
||||
{SERVICES.map((s) => (
|
||||
<button key={s.label} className={styles.card} onClick={() => navigate(s.path)}>
|
||||
<span className={styles.icon}>{s.icon}</span>
|
||||
<span className={styles.label}>{s.label}</span>
|
||||
<span className={styles.desc}>{s.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user