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:
MingNian
2026-05-20 16:18:56 +08:00
commit 435af55c4a
215 changed files with 18595 additions and 0 deletions

View 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;
}

View 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>
);
}

View File

@@ -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; }

View 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>
);
}

View File

@@ -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; }

View 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>
);
}

View File

@@ -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; }

View 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>
);
}

View File

@@ -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; }

View 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>
);
}

View File

@@ -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; }

View 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>
);
}

View File

@@ -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; }

View 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>
);
}

View File

@@ -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); }

View 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>
);
}