feat(miniprogram): 医护端小程序页面 — 8 页面覆盖患者/咨询/随访/报告
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

Iteration 2 医护端前端核心页面:

- 新增 doctor.ts service 层(仪表盘/患者/咨询/随访/报告 API)
- 升级医生首页:接入真实仪表盘数据 + 快捷操作入口
- 患者管理:搜索 + 标签筛选 + 详情页(基本信息/过敏史/健康概览)
- 咨询回复:会话列表 + 状态筛选 + 聊天详情 + 发送消息 + 关闭会话
- 随访管理:任务列表 + 状态筛选 + 详情 + 填写随访记录
- 报告解读:化验报告列表 + 异常高亮 + 指标表格 + 医生审核注释
- 修复 login 页面重复解构
- 注册 8 个新页面路由到 app.config.ts
This commit is contained in:
iven
2026-04-26 13:32:08 +08:00
parent a0b72b0f73
commit 3723cd93c0
21 changed files with 2795 additions and 28 deletions

View File

@@ -30,6 +30,14 @@ export default defineAppConfig({
'pages/legal/user-agreement',
'pages/legal/privacy-policy',
'pages/doctor/index',
'pages/doctor/patients/index',
'pages/doctor/patients/detail/index',
'pages/doctor/consultation/index',
'pages/doctor/consultation/detail/index',
'pages/doctor/followup/index',
'pages/doctor/followup/detail/index',
'pages/doctor/report/index',
'pages/doctor/report/detail/index',
],
tabBar: {
color: '#94A3B8',

View File

@@ -0,0 +1,140 @@
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f4f8;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__title {
font-size: 30px;
font-weight: 600;
color: #0f172a;
}
&__close {
font-size: 26px;
color: #ef4444;
padding: 8px 16px;
}
}
.chat-messages {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.msg-row {
display: flex;
margin-bottom: 20px;
&--self {
justify-content: flex-end;
}
}
.msg-bubble {
max-width: 70%;
padding: 20px 24px;
border-radius: 16px;
position: relative;
&--other {
background: #fff;
border-top-left-radius: 4px;
}
&--self {
background: #0891b2;
border-top-right-radius: 4px;
}
}
.msg-text {
font-size: 28px;
color: #0f172a;
display: block;
line-height: 1.6;
word-break: break-all;
.msg-bubble--self & {
color: #fff;
}
}
.msg-time {
font-size: 20px;
color: #94a3b8;
display: block;
margin-top: 8px;
text-align: right;
.msg-bubble--self & {
color: rgba(255, 255, 255, 0.7);
}
}
.chat-empty {
text-align: center;
padding: 120px 32px;
&__text {
font-size: 26px;
color: #94a3b8;
}
}
.chat-input-bar {
display: flex;
align-items: center;
padding: 16px 24px;
background: #fff;
border-top: 1px solid #e2e8f0;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.chat-input {
flex: 1;
background: #f1f5f9;
border-radius: 12px;
padding: 16px 20px;
font-size: 28px;
margin-right: 16px;
}
.chat-send-btn {
background: #0891b2;
border-radius: 12px;
padding: 16px 28px;
flex-shrink: 0;
&--disabled {
opacity: 0.5;
}
&__text {
font-size: 28px;
color: #fff;
font-weight: 500;
}
}
.chat-closed-bar {
padding: 24px;
text-align: center;
background: #fff;
border-top: 1px solid #e2e8f0;
&__text {
font-size: 26px;
color: #94a3b8;
}
}

View File

@@ -0,0 +1,153 @@
import { useState, useEffect, useRef } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
export default function ConsultationDetail() {
const router = useRouter();
const sessionId = router.params.id || '';
const [session, setSession] = useState<doctorApi.ConsultationSession | null>(null);
const [messages, setMessages] = useState<doctorApi.ConsultationMessage[]>([]);
const [inputText, setInputText] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const scrollViewRef = useRef('');
useEffect(() => {
if (sessionId) {
loadData();
markRead();
}
}, [sessionId]);
const loadData = async () => {
setLoading(true);
try {
const [s, m] = await Promise.all([
doctorApi.getSession(sessionId),
doctorApi.listMessages(sessionId, { page: 1, page_size: 50 }),
]);
setSession(s);
setMessages(m.data || []);
scrollViewRef.current = `msg-${(m.data || []).length}`;
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const markRead = async () => {
try {
await doctorApi.markSessionRead(sessionId);
} catch { /* ignore */ }
};
const handleSend = async () => {
const text = inputText.trim();
if (!text || sending) return;
setSending(true);
setInputText('');
try {
const msg = await doctorApi.sendMessage(sessionId, text);
setMessages((prev) => [...prev, msg]);
scrollViewRef.current = `msg-${messages.length + 1}`;
} catch {
Taro.showToast({ title: '发送失败', icon: 'none' });
setInputText(text);
} finally {
setSending(false);
}
};
const handleClose = () => {
Taro.showModal({
title: '确认关闭',
content: '关闭后将无法继续对话,确认关闭?',
success: async (res) => {
if (res.confirm) {
try {
await doctorApi.closeSession(sessionId);
Taro.showToast({ title: '已关闭', icon: 'success' });
loadData();
} catch {
Taro.showToast({ title: '操作失败', icon: 'none' });
}
}
},
});
};
const formatTime = (dateStr: string) => {
const d = new Date(dateStr);
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
};
if (loading) return <Loading />;
const isOpen = session?.status !== 'closed';
return (
<View className='chat-page'>
{/* Header */}
<View className='chat-header'>
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
{isOpen && (
<Text className='chat-header__close' onClick={handleClose}></Text>
)}
</View>
{/* Messages */}
<ScrollView
scrollY
className='chat-messages'
scrollIntoView={scrollViewRef.current}
scrollWithAnimation
>
{messages.map((msg, idx) => {
const isDoctor = msg.sender_role === 'doctor';
return (
<View key={msg.id} id={`msg-${idx + 1}`} className={`msg-row ${isDoctor ? 'msg-row--self' : ''}`}>
<View className={`msg-bubble ${isDoctor ? 'msg-bubble--self' : 'msg-bubble--other'}`}>
<Text className='msg-text'>{msg.content}</Text>
<Text className='msg-time'>{formatTime(msg.created_at)}</Text>
</View>
</View>
);
})}
{messages.length === 0 && (
<View className='chat-empty'>
<Text className='chat-empty__text'></Text>
</View>
)}
</ScrollView>
{/* Input */}
{isOpen ? (
<View className='chat-input-bar'>
<Input
className='chat-input'
placeholder='输入消息...'
value={inputText}
onInput={(e) => setInputText(e.detail.value)}
confirmType='send'
onConfirm={handleSend}
disabled={sending}
/>
<View
className={`chat-send-btn ${(!inputText.trim() || sending) ? 'chat-send-btn--disabled' : ''}`}
onClick={handleSend}
>
<Text className='chat-send-btn__text'>{sending ? '...' : '发送'}</Text>
</View>
</View>
) : (
<View className='chat-closed-bar'>
<Text className='chat-closed-bar__text'></Text>
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,156 @@
.consultation-page {
min-height: 100vh;
background: #f0f4f8;
}
.tabs {
display: flex;
background: #fff;
padding: 0 16px;
border-bottom: 1px solid #e2e8f0;
}
.tab {
flex: 1;
text-align: center;
padding: 24px 0;
font-size: 28px;
color: #64748b;
position: relative;
&--active {
color: #0891b2;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 30%;
right: 30%;
height: 4px;
background: #0891b2;
border-radius: 2px;
}
}
}
.session-list {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.session-card {
background: #fff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
position: relative;
&:active {
background: #f8fafc;
}
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__subject {
font-size: 28px;
font-weight: 600;
color: #0f172a;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16px;
}
&__status {
padding: 4px 14px;
border-radius: 12px;
flex-shrink: 0;
}
&__status-text {
font-size: 22px;
font-weight: 500;
}
&__info {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
&__type {
font-size: 24px;
color: #0891b2;
background: #e0f2fe;
padding: 2px 12px;
border-radius: 8px;
}
&__time {
font-size: 24px;
color: #94a3b8;
}
&__preview {
font-size: 26px;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__badge {
position: absolute;
top: 20px;
right: 20px;
min-width: 36px;
height: 36px;
background: #ef4444;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
}
&__badge-text {
font-size: 22px;
color: #fff;
font-weight: 600;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
padding: 24px;
&__btn {
font-size: 26px;
color: #0891b2;
padding: 12px 24px;
&.disabled {
color: #cbd5e1;
}
}
&__info {
font-size: 24px;
color: #64748b;
}
}

View File

@@ -0,0 +1,134 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
waiting: { label: '等待中', color: '#f59e0b' },
active: { label: '进行中', color: '#10b981' },
closed: { label: '已关闭', color: '#94a3b8' },
};
const TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '进行中' },
{ key: 'waiting', label: '等待中' },
{ key: 'closed', label: '已关闭' },
];
export default function ConsultationList() {
const [sessions, setSessions] = useState<doctorApi.ConsultationSession[]>([]);
const [activeTab, setActiveTab] = useState('');
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
useEffect(() => {
loadSessions();
}, [page, activeTab]);
const loadSessions = async () => {
setLoading(true);
try {
const res = await doctorApi.listSessions({
page,
page_size: 20,
status: activeTab || undefined,
});
setSessions(res.data || []);
setTotal(res.total || 0);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleTabChange = (key: string) => {
setActiveTab(key);
setPage(1);
};
const formatTime = (dateStr?: string | null) => {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
};
if (loading && sessions.length === 0) return <Loading />;
return (
<ScrollView scrollY className='consultation-page'>
<View className='tabs'>
{TABS.map((t) => (
<View
key={t.key}
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
onClick={() => handleTabChange(t.key)}
>
<Text>{t.label}</Text>
</View>
))}
</View>
{sessions.length === 0 ? (
<EmptyState text='暂无咨询会话' />
) : (
<View className='session-list'>
{sessions.map((s) => {
const st = STATUS_MAP[s.status] || { label: s.status, color: '#94a3b8' };
return (
<View
key={s.id}
className='session-card'
onClick={() => Taro.navigateTo({ url: `/pages/doctor/consultation/detail/index?id=${s.id}` })}
>
<View className='session-card__top'>
<Text className='session-card__subject'>{s.subject || '在线咨询'}</Text>
<View className='session-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
<Text className='session-card__status-text'>{st.label}</Text>
</View>
</View>
<View className='session-card__info'>
<Text className='session-card__type'>
{s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询'}
</Text>
<Text className='session-card__time'>{formatTime(s.last_message_at)}</Text>
</View>
{s.last_message && (
<Text className='session-card__preview'>{s.last_message}</Text>
)}
{(s.unread_count_doctor ?? 0) > 0 && (
<View className='session-card__badge'>
<Text className='session-card__badge-text'>{s.unread_count_doctor}</Text>
</View>
)}
</View>
);
})}
</View>
)}
{total > 20 && (
<View className='pagination'>
<Text
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
onClick={() => page > 1 && setPage(page - 1)}
></Text>
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
<Text
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
></Text>
</View>
)}
</ScrollView>
);
}

View File

@@ -0,0 +1,185 @@
.followup-detail {
min-height: 100vh;
background: #f0f4f8;
padding: 24px;
padding-bottom: 120px;
}
.section {
background: #fff;
border-radius: 16px;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 20px;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
&__title {
font-size: 30px;
font-weight: 700;
color: #0f172a;
}
&__status {
font-size: 24px;
padding: 6px 16px;
border-radius: 12px;
font-weight: 500;
&--pending { background: #fef3c7; color: #b45309; }
&--in_progress { background: #e0f2fe; color: #0369a1; }
&--completed { background: #dcfce7; color: #16a34a; }
&--overdue { background: #fee2e2; color: #dc2626; }
&--cancelled { background: #f1f5f9; color: #94a3b8; }
}
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 22px;
color: #94a3b8;
}
.info-value {
font-size: 26px;
color: #0f172a;
font-weight: 500;
}
.task-template {
margin-top: 16px;
padding: 16px;
background: #f8fafc;
border-radius: 12px;
&__label {
font-size: 22px;
color: #64748b;
display: block;
margin-bottom: 8px;
}
&__text {
font-size: 26px;
color: #334155;
line-height: 1.6;
}
}
.record-item {
padding: 20px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&__date {
font-size: 22px;
color: #94a3b8;
display: block;
margin-bottom: 8px;
}
&__text {
font-size: 26px;
color: #334155;
display: block;
margin-bottom: 4px;
line-height: 1.5;
}
}
.start-btn {
text-align: center;
padding: 16px;
background: #0891b2;
border-radius: 12px;
margin-bottom: 24px;
color: #fff;
font-size: 28px;
font-weight: 500;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
font-size: 26px;
color: #475569;
font-weight: 500;
display: block;
margin-bottom: 12px;
}
.form-textarea {
width: 100%;
min-height: 160px;
background: #f8fafc;
border-radius: 12px;
padding: 16px 20px;
font-size: 26px;
color: #0f172a;
box-sizing: border-box;
line-height: 1.6;
}
.form-date {
width: 100%;
padding: 16px 20px;
background: #f8fafc;
border-radius: 12px;
font-size: 26px;
color: #0f172a;
box-sizing: border-box;
}
.submit-btn {
background: #0891b2;
border-radius: 12px;
padding: 20px;
text-align: center;
margin-top: 16px;
&--disabled {
opacity: 0.5;
}
&__text {
font-size: 28px;
color: #fff;
font-weight: 600;
}
}
.error-text {
text-align: center;
padding: 80px 32px;
color: #94a3b8;
font-size: 28px;
}

View File

@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react';
import { View, Text, Textarea, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
const STATUS_LABELS: Record<string, string> = {
pending: '待处理',
in_progress: '进行中',
completed: '已完成',
overdue: '已逾期',
cancelled: '已取消',
};
export default function FollowUpDetail() {
const router = useRouter();
const taskId = router.params.id || '';
const [task, setTask] = useState<doctorApi.FollowUpTask | null>(null);
const [records, setRecords] = useState<doctorApi.FollowUpRecord[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
// 表单
const [result, setResult] = useState('');
const [patientCondition, setPatientCondition] = useState('');
const [medicalAdvice, setMedicalAdvice] = useState('');
const [nextDate, setNextDate] = useState('');
useEffect(() => {
if (taskId) loadData();
}, [taskId]);
const loadData = async () => {
setLoading(true);
try {
const [t, r] = await Promise.all([
doctorApi.getFollowUpTask(taskId),
doctorApi.listFollowUpRecords({ task_id: taskId }),
]);
setTask(t);
setRecords(r.data || []);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleSubmit = async () => {
if (!result.trim()) {
Taro.showToast({ title: '请填写随访结果', icon: 'none' });
return;
}
setSubmitting(true);
try {
await doctorApi.createFollowUpRecord(taskId, {
result: result.trim(),
patient_condition: patientCondition.trim() || undefined,
medical_advice: medicalAdvice.trim() || undefined,
next_follow_up_date: nextDate || undefined,
});
Taro.showToast({ title: '提交成功', icon: 'success' });
setResult('');
setPatientCondition('');
setMedicalAdvice('');
setNextDate('');
loadData();
} catch {
Taro.showToast({ title: '提交失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const handleStartTask = async () => {
if (!task) return;
try {
await doctorApi.updateFollowUpTask(taskId, { status: 'in_progress' }, task.version);
Taro.showToast({ title: '已开始', icon: 'success' });
loadData();
} catch {
Taro.showToast({ title: '操作失败', icon: 'none' });
}
};
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
if (loading) return <Loading />;
if (!task) return <View className='error-text'><Text></Text></View>;
const canSubmit = task.status === 'in_progress' || task.status === 'pending' || task.status === 'overdue';
return (
<ScrollView scrollY className='followup-detail'>
<View className='section'>
<View className='task-header'>
<Text className='task-header__title'>访</Text>
<Text className={`task-header__status task-header__status--${task.status}`}>
{STATUS_LABELS[task.status] || task.status}
</Text>
</View>
<View className='info-grid'>
<View className='info-item'>
<Text className='info-label'></Text>
<Text className='info-value'>{task.patient_name || '-'}</Text>
</View>
<View className='info-item'>
<Text className='info-label'></Text>
<Text className='info-value'>{task.follow_up_type}</Text>
</View>
<View className='info-item'>
<Text className='info-label'></Text>
<Text className='info-value'>{formatDate(task.planned_date)}</Text>
</View>
</View>
{task.content_template && (
<View className='task-template'>
<Text className='task-template__label'>访</Text>
<Text className='task-template__text'>{task.content_template}</Text>
</View>
)}
</View>
{/* 历史记录 */}
{records.length > 0 && (
<View className='section'>
<Text className='section-title'></Text>
{records.map((r) => (
<View key={r.id} className='record-item'>
<Text className='record-item__date'>{formatDate(r.executed_date)}</Text>
{r.result && <Text className='record-item__text'>: {r.result}</Text>}
{r.patient_condition && <Text className='record-item__text'>: {r.patient_condition}</Text>}
{r.medical_advice && <Text className='record-item__text'>: {r.medical_advice}</Text>}
</View>
))}
</View>
)}
{/* 填写表单 */}
{canSubmit && (
<View className='section'>
<Text className='section-title'>访</Text>
{(task.status === 'pending' || task.status === 'overdue') && (
<View className='start-btn' onClick={handleStartTask}>
<Text>访</Text>
</View>
)}
<View className='form-group'>
<Text className='form-label'>访 *</Text>
<Textarea
className='form-textarea'
placeholder='请输入随访结果'
value={result}
onInput={(e) => setResult(e.detail.value)}
maxlength={500}
/>
</View>
<View className='form-group'>
<Text className='form-label'></Text>
<Textarea
className='form-textarea'
placeholder='描述患者当前状况'
value={patientCondition}
onInput={(e) => setPatientCondition(e.detail.value)}
maxlength={500}
/>
</View>
<View className='form-group'>
<Text className='form-label'></Text>
<Textarea
className='form-textarea'
placeholder='输入医嘱和建议'
value={medicalAdvice}
onInput={(e) => setMedicalAdvice(e.detail.value)}
maxlength={500}
/>
</View>
<View className='form-group'>
<Text className='form-label'>访</Text>
<input
type='date'
className='form-date'
value={nextDate}
onChange={(e: any) => setNextDate(e.target.value)}
/>
</View>
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
<Text className='submit-btn__text'>{submitting ? '提交中...' : '提交记录'}</Text>
</View>
</View>
)}
</ScrollView>
);
}

View File

@@ -0,0 +1,102 @@
.followup-page {
min-height: 100vh;
background: #f0f4f8;
}
.tabs {
display: flex;
background: #fff;
padding: 0 12px;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
white-space: nowrap;
}
.tab {
display: inline-block;
padding: 24px 16px;
font-size: 26px;
color: #64748b;
position: relative;
flex-shrink: 0;
&--active {
color: #0891b2;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
right: 20%;
height: 4px;
background: #0891b2;
border-radius: 2px;
}
}
}
.task-count {
padding: 20px 28px;
text {
font-size: 24px;
color: #94a3b8;
}
}
.task-list {
padding: 0 24px 120px;
display: flex;
flex-direction: column;
gap: 16px;
}
.task-card {
background: #fff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
&:active {
background: #f8fafc;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__type {
font-size: 28px;
font-weight: 600;
color: #0f172a;
}
&__status {
padding: 4px 14px;
border-radius: 12px;
font-size: 22px;
font-weight: 500;
}
&__patient {
font-size: 26px;
color: #475569;
display: block;
margin-bottom: 8px;
}
&__footer {
display: flex;
justify-content: space-between;
}
&__date {
font-size: 24px;
color: #94a3b8;
}
}

View File

@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
pending: { label: '待处理', color: '#f59e0b' },
in_progress: { label: '进行中', color: '#0891b2' },
completed: { label: '已完成', color: '#10b981' },
overdue: { label: '已逾期', color: '#ef4444' },
cancelled: { label: '已取消', color: '#94a3b8' },
};
const TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已逾期' },
];
export default function FollowUpList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const [tasks, setTasks] = useState<doctorApi.FollowUpTask[]>([]);
const [activeTab, setActiveTab] = useState('');
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
useEffect(() => {
loadTasks();
}, [activeTab, patientId]);
const loadTasks = async () => {
setLoading(true);
try {
const res = await doctorApi.listFollowUpTasks({
page: 1,
page_size: 50,
status: activeTab || undefined,
patient_id: patientId || undefined,
});
setTasks(res.data || []);
setTotal(res.total || 0);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
};
const getTypeLabel = (type: string) => {
const map: Record<string, string> = {
phone: '电话随访',
visit: '门诊随访',
online: '线上随访',
home: '家访',
};
return map[type] || type;
};
if (loading && tasks.length === 0) return <Loading />;
return (
<ScrollView scrollY className='followup-page'>
<View className='tabs'>
{TABS.map((t) => (
<View
key={t.key}
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
onClick={() => setActiveTab(t.key)}
>
<Text>{t.label}</Text>
</View>
))}
</View>
<View className='task-count'>
<Text> {total} </Text>
</View>
{tasks.length === 0 ? (
<EmptyState text='暂无随访任务' />
) : (
<View className='task-list'>
{tasks.map((task) => {
const st = STATUS_MAP[task.status] || { label: task.status, color: '#94a3b8' };
return (
<View
key={task.id}
className='task-card'
onClick={() => Taro.navigateTo({ url: `/pages/doctor/followup/detail/index?id=${task.id}` })}
>
<View className='task-card__header'>
<Text className='task-card__type'>{getTypeLabel(task.follow_up_type)}</Text>
<View className='task-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
<Text>{st.label}</Text>
</View>
</View>
<Text className='task-card__patient'>{task.patient_name || '未知患者'}</Text>
<View className='task-card__footer'>
<Text className='task-card__date'>: {formatDate(task.planned_date)}</Text>
</View>
</View>
);
})}
</View>
)}
</ScrollView>
);
}

View File

@@ -2,6 +2,7 @@
min-height: 100vh;
background: #f0f4f8;
padding: 32px;
padding-bottom: 120px;
&__header {
margin-bottom: 40px;
@@ -12,16 +13,23 @@
font-weight: 700;
color: #0f172a;
display: block;
margin-bottom: 16px;
margin-bottom: 12px;
}
&__greeting {
font-size: 28px;
color: #64748b;
display: block;
margin-bottom: 8px;
}
&__date {
font-size: 24px;
color: #94a3b8;
}
&__section {
margin-bottom: 32px;
margin-bottom: 40px;
}
&__section-title {
@@ -35,21 +43,32 @@
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
gap: 20px;
}
&__card {
background: #fff;
border-radius: 16px;
padding: 32px;
padding: 28px 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: transform 0.15s;
&:active {
transform: scale(0.97);
}
}
&__card-icon {
font-size: 36px;
display: block;
margin-bottom: 8px;
}
&__card-num {
font-size: 48px;
font-weight: 700;
color: #0891b2;
color: #0f172a;
display: block;
margin-bottom: 8px;
}
@@ -59,13 +78,45 @@
color: #64748b;
}
&__quick-actions {
display: flex;
gap: 24px;
}
&__footer {
margin-top: 80px;
margin-top: 60px;
text-align: center;
padding-bottom: env(safe-area-inset-bottom);
}
&__logout {
color: #ef4444;
font-size: 28px;
padding: 16px 48px;
display: inline-block;
}
}
.quick-action {
flex: 1;
background: #fff;
border-radius: 16px;
padding: 28px 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&:active {
opacity: 0.8;
}
&__icon {
font-size: 40px;
display: block;
margin-bottom: 8px;
}
&__label {
font-size: 24px;
color: #475569;
}
}

View File

@@ -1,41 +1,101 @@
import { View, Text } from '@tarojs/components';
import { useAuthStore } from '@/stores/auth';
import { useState, useEffect } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
interface CardConfig {
key: keyof doctorApi.DoctorDashboard;
label: string;
icon: string;
route: string;
color: string;
}
const CARDS: CardConfig[] = [
{ key: 'total_patients', label: '我的患者', icon: '👥', route: '/pages/doctor/patients/index', color: '#0891b2' },
{ key: 'unread_messages', label: '未读消息', icon: '💬', route: '/pages/doctor/consultation/index', color: '#f59e0b' },
{ key: 'pending_follow_ups', label: '待处理随访', icon: '📋', route: '/pages/doctor/followup/index', color: '#8b5cf6' },
{ key: 'today_consultations', label: '今日咨询', icon: '🩺', route: '/pages/doctor/consultation/index', color: '#10b981' },
];
export default function DoctorHome() {
const { user, logout } = useAuthStore();
const [dashboard, setDashboard] = useState<doctorApi.DoctorDashboard | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadDashboard();
}, []);
const loadDashboard = async () => {
try {
const data = await doctorApi.getDashboard();
setDashboard(data);
} catch {
// 静默失败,显示占位
} finally {
setLoading(false);
}
};
const handleCardClick = (card: CardConfig) => {
Taro.navigateTo({ url: card.route });
};
const handleLogout = () => {
logout();
Taro.redirectTo({ url: '/pages/login/index' });
};
const getValue = (key: keyof doctorApi.DoctorDashboard): number | string => {
if (!dashboard) return '-';
return dashboard[key] ?? 0;
};
if (loading) return <Loading />;
return (
<View className='doctor-home'>
<ScrollView scrollY className='doctor-home'>
<View className='doctor-home__header'>
<Text className='doctor-home__title'></Text>
<Text className='doctor-home__greeting'>{user?.display_name || user?.username || '医生'}</Text>
<Text className='doctor-home__greeting'>
{user?.display_name || user?.username || '医生'}
</Text>
<Text className='doctor-home__date'>
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })}
</Text>
</View>
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__grid'>
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
<Text className='doctor-home__card-num'>-</Text>
<Text className='doctor-home__card-label'></Text>
{CARDS.map((card) => (
<View
key={card.key}
className='doctor-home__card'
style={`border-left: 6px solid ${card.color}`}
onClick={() => handleCardClick(card)}
>
<Text className='doctor-home__card-icon'>{card.icon}</Text>
<Text className='doctor-home__card-num'>{getValue(card.key)}</Text>
<Text className='doctor-home__card-label'>{card.label}</Text>
</View>
))}
</View>
</View>
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__quick-actions'>
<View className='quick-action' onClick={() => Taro.navigateTo({ url: '/pages/doctor/report/index' })}>
<Text className='quick-action__icon'>📊</Text>
<Text className='quick-action__label'></Text>
</View>
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
<Text className='doctor-home__card-num'>-</Text>
<Text className='doctor-home__card-label'>访</Text>
</View>
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
<Text className='doctor-home__card-num'>-</Text>
<Text className='doctor-home__card-label'></Text>
</View>
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
<Text className='doctor-home__card-num'>-</Text>
<Text className='doctor-home__card-label'></Text>
<View className='quick-action' onClick={() => Taro.navigateTo({ url: '/pages/doctor/patients/index' })}>
<Text className='quick-action__icon'>🔍</Text>
<Text className='quick-action__label'></Text>
</View>
</View>
</View>
@@ -43,6 +103,6 @@ export default function DoctorHome() {
<View className='doctor-home__footer'>
<Text className='doctor-home__logout' onClick={handleLogout}>退</Text>
</View>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,199 @@
.patient-detail {
min-height: 100vh;
background: #f0f4f8;
padding: 24px;
padding-bottom: 120px;
}
.section {
background: #fff;
border-radius: 16px;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 20px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 22px;
color: #94a3b8;
}
.info-value {
font-size: 28px;
color: #0f172a;
font-weight: 500;
}
.warning-card {
background: #fef3c7;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.warning-label {
font-size: 22px;
color: #b45309;
font-weight: 600;
display: block;
margin-bottom: 8px;
}
.warning-text {
font-size: 26px;
color: #92400e;
}
.info-block {
margin-bottom: 12px;
}
.info-block-label {
font-size: 22px;
color: #94a3b8;
display: block;
margin-bottom: 8px;
}
.info-block-text {
font-size: 26px;
color: #334155;
line-height: 1.6;
}
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.vital-item {
background: #f0f9ff;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.vital-value {
font-size: 36px;
font-weight: 700;
color: #0891b2;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: 22px;
color: #64748b;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
}
.stat-label {
font-size: 26px;
color: #475569;
}
.stat-value {
font-size: 26px;
font-weight: 600;
color: #0f172a;
&--warn {
color: #f59e0b;
}
}
.lab-item {
padding: 20px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&:active {
background: #f8fafc;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__type {
font-size: 26px;
font-weight: 500;
color: #0f172a;
}
&__date {
font-size: 24px;
color: #94a3b8;
}
&__abnormal {
font-size: 24px;
color: #ef4444;
font-weight: 500;
}
}
.action-buttons {
display: flex;
gap: 16px;
}
.action-btn {
flex: 1;
text-align: center;
padding: 20px;
border-radius: 12px;
background: #0891b2;
color: #fff;
font-size: 26px;
font-weight: 500;
&:active {
opacity: 0.85;
}
text {
color: #fff;
}
}
.error-text {
text-align: center;
padding: 80px 32px;
color: #94a3b8;
font-size: 28px;
}

View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
export default function PatientDetail() {
const router = useRouter();
const patientId = router.params.id || '';
const [patient, setPatient] = useState<doctorApi.PatientDetail | null>(null);
const [summary, setSummary] = useState<doctorApi.HealthSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (patientId) loadData();
}, [patientId]);
const loadData = async () => {
setLoading(true);
try {
const [p, s] = await Promise.all([
doctorApi.getPatient(patientId),
doctorApi.getHealthSummary(patientId),
]);
setPatient(p);
setSummary(s);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const getGenderLabel = (g?: string) => (g === 'male' ? '男' : g === 'female' ? '女' : g || '-');
const calcAge = (bd?: string) => {
if (!bd) return '-';
const diff = Date.now() - new Date(bd).getTime();
return Math.floor(diff / (365.25 * 24 * 3600 * 1000));
};
if (loading) return <Loading />;
if (!patient) return <View className='error-text'><Text></Text></View>;
return (
<ScrollView scrollY className='patient-detail'>
{/* 基本信息 */}
<View className='section'>
<View className='section-header'>
<Text className='section-title'></Text>
</View>
<View className='info-grid'>
<View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{patient.name}</Text></View>
<View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{getGenderLabel(patient.gender)}</Text></View>
<View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{calcAge(patient.birth_date)}</Text></View>
{patient.blood_type && <View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{patient.blood_type}</Text></View>}
</View>
</View>
{/* 医疗信息 */}
{(patient.allergy_history || patient.medical_history_summary) && (
<View className='section'>
<Text className='section-title'></Text>
{patient.allergy_history && (
<View className='warning-card'>
<Text className='warning-label'></Text>
<Text className='warning-text'>{patient.allergy_history}</Text>
</View>
)}
{patient.medical_history_summary && (
<View className='info-block'>
<Text className='info-block-label'></Text>
<Text className='info-block-text'>{patient.medical_history_summary}</Text>
</View>
)}
</View>
)}
{/* 健康摘要 */}
{summary && (
<View className='section'>
<Text className='section-title'></Text>
{summary.latest_vital_signs && (
<View className='vitals-grid'>
{summary.latest_vital_signs.systolic_bp != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.systolic_bp}/{summary.latest_vital_signs.diastolic_bp}</Text>
<Text className='vital-label'> mmHg</Text>
</View>
)}
{summary.latest_vital_signs.heart_rate != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.heart_rate}</Text>
<Text className='vital-label'> bpm</Text>
</View>
)}
{summary.latest_vital_signs.weight != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.weight}</Text>
<Text className='vital-label'> kg</Text>
</View>
)}
{summary.latest_vital_signs.blood_sugar != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.blood_sugar}</Text>
<Text className='vital-label'> mmol/L</Text>
</View>
)}
</View>
)}
{summary.pending_follow_ups != null && summary.pending_follow_ups > 0 && (
<View className='stat-row'>
<Text className='stat-label'>访</Text>
<Text className='stat-value stat-value--warn'>{summary.pending_follow_ups} </Text>
</View>
)}
</View>
)}
{/* 近期化验 */}
{summary?.recent_lab_reports && summary.recent_lab_reports.length > 0 && (
<View className='section'>
<Text className='section-title'></Text>
{summary.recent_lab_reports.map((r) => (
<View
key={r.id}
className='lab-item'
onClick={() => Taro.navigateTo({ url: `/pages/doctor/report/detail/index?patientId=${patientId}&id=${r.id}` })}
>
<View className='lab-item__header'>
<Text className='lab-item__type'>{r.report_type}</Text>
<Text className='lab-item__date'>{r.report_date}</Text>
</View>
{r.abnormal_count > 0 && (
<Text className='lab-item__abnormal'>{r.abnormal_count} </Text>
)}
</View>
))}
</View>
)}
{/* 快捷操作 */}
<View className='section'>
<Text className='section-title'></Text>
<View className='action-buttons'>
<View className='action-btn' onClick={() => Taro.navigateTo({ url: `/pages/doctor/report/index?patientId=${patientId}` })}>
<Text></Text>
</View>
<View className='action-btn' onClick={() => Taro.navigateTo({ url: `/pages/doctor/followup/index?patientId=${patientId}` })}>
<Text>访</Text>
</View>
</View>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,140 @@
.patient-list-page {
min-height: 100vh;
background: #f0f4f8;
padding: 24px;
padding-bottom: 120px;
}
.search-bar {
margin-bottom: 20px;
.search-input {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
font-size: 28px;
width: 100%;
box-sizing: border-box;
}
}
.tag-filter {
white-space: nowrap;
margin-bottom: 20px;
width: 100%;
}
.tag-chip {
display: inline-block;
padding: 10px 24px;
border-radius: 20px;
background: #e2e8f0;
font-size: 24px;
color: #475569;
margin-right: 16px;
&.active {
background: #0891b2;
color: #fff;
}
}
.patient-count {
margin-bottom: 16px;
text {
font-size: 24px;
color: #94a3b8;
}
}
.patient-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.patient-card {
background: #fff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
&:active {
background: #f8fafc;
}
&__header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
&__name {
font-size: 30px;
font-weight: 600;
color: #0f172a;
margin-right: 16px;
}
&__meta {
font-size: 24px;
color: #64748b;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
&__status {
font-size: 22px;
padding: 4px 12px;
border-radius: 8px;
&--active {
background: #dcfce7;
color: #16a34a;
}
&--inactive {
background: #f1f5f9;
color: #94a3b8;
}
}
}
.patient-tag {
padding: 4px 14px;
border-radius: 12px;
background: #e0f2fe;
&__text {
font-size: 22px;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
margin-top: 32px;
&__btn {
font-size: 26px;
color: #0891b2;
padding: 12px 24px;
&.disabled {
color: #cbd5e1;
}
}
&__info {
font-size: 24px;
color: #64748b;
}
}

View File

@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
export default function PatientList() {
const [patients, setPatients] = useState<doctorApi.PatientItem[]>([]);
const [tags, setTags] = useState<doctorApi.PatientTag[]>([]);
const [activeTag, setActiveTag] = useState<string>('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
useEffect(() => {
loadTags();
}, []);
useEffect(() => {
loadPatients();
}, [page, activeTag]);
const loadTags = async () => {
try {
const res = await doctorApi.listPatientTags();
setTags(res.data || []);
} catch { /* ignore */ }
};
const loadPatients = async () => {
setLoading(true);
try {
const res = await doctorApi.listPatients({
page,
page_size: 20,
search: search || undefined,
tag_id: activeTag || undefined,
});
setPatients(res.data || []);
setTotal(res.total || 0);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleSearch = () => {
setPage(1);
loadPatients();
};
const handleTagFilter = (tagId: string) => {
setActiveTag(tagId === activeTag ? '' : tagId);
setPage(1);
};
const getGenderLabel = (gender?: string) => {
if (!gender) return '';
return gender === 'male' ? '男' : gender === 'female' ? '女' : gender;
};
const calcAge = (birthDate?: string) => {
if (!birthDate) return '';
const birth = new Date(birthDate);
const now = new Date();
let age = now.getFullYear() - birth.getFullYear();
if (now.getMonth() < birth.getMonth() ||
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate())) {
age--;
}
return `${age}`;
};
if (loading && patients.length === 0) return <Loading />;
return (
<ScrollView scrollY className='patient-list-page'>
<View className='search-bar'>
<Input
className='search-input'
placeholder='搜索患者姓名/手机号'
value={search}
onInput={(e) => setSearch(e.detail.value)}
confirmType='search'
onConfirm={handleSearch}
/>
</View>
{tags.length > 0 && (
<ScrollView scrollX className='tag-filter'>
<View
className={`tag-chip ${!activeTag ? 'active' : ''}`}
onClick={() => handleTagFilter('')}
>
<Text></Text>
</View>
{tags.map((tag) => (
<View
key={tag.id}
className={`tag-chip ${activeTag === tag.id ? 'active' : ''}`}
style={activeTag === tag.id && tag.color ? `background: ${tag.color}; color: #fff` : ''}
onClick={() => handleTagFilter(tag.id)}
>
<Text>{tag.name}</Text>
</View>
))}
</ScrollView>
)}
<View className='patient-count'>
<Text> {total} </Text>
</View>
{patients.length === 0 ? (
<EmptyState text='暂无患者数据' />
) : (
<View className='patient-cards'>
{patients.map((p) => (
<View
key={p.id}
className='patient-card'
onClick={() => Taro.navigateTo({ url: `/pages/doctor/patients/detail/index?id=${p.id}` })}
>
<View className='patient-card__header'>
<Text className='patient-card__name'>{p.name}</Text>
<Text className='patient-card__meta'>
{getGenderLabel(p.gender)} {calcAge(p.birth_date)}
</Text>
</View>
{p.tags && p.tags.length > 0 && (
<View className='patient-card__tags'>
{p.tags.map((t) => (
<View
key={t.id}
className='patient-tag'
style={t.color ? `background: ${t.color}20; color: ${t.color}` : ''}
>
<Text className='patient-tag__text'>{t.name}</Text>
</View>
))}
</View>
)}
{p.status && (
<Text className={`patient-card__status patient-card__status--${p.status}`}>
{p.status === 'active' ? '活跃' : p.status === 'inactive' ? '非活跃' : p.status}
</Text>
)}
</View>
))}
</View>
)}
{total > 20 && (
<View className='pagination'>
<Text
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
onClick={() => page > 1 && setPage(page - 1)}
>
</Text>
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
<Text
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
>
</Text>
</View>
)}
</ScrollView>
);
}

View File

@@ -0,0 +1,170 @@
.report-detail {
min-height: 100vh;
background: #f0f4f8;
padding: 24px;
padding-bottom: 120px;
}
.section {
background: #fff;
border-radius: 16px;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 20px;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
&__type {
font-size: 32px;
font-weight: 700;
color: #0f172a;
}
&__status {
font-size: 24px;
padding: 6px 16px;
border-radius: 12px;
font-weight: 500;
&--pending { background: #fef3c7; color: #b45309; }
&--reviewed { background: #dcfce7; color: #16a34a; }
}
}
.report-date {
font-size: 26px;
color: #64748b;
display: block;
}
.review-info {
font-size: 24px;
color: #10b981;
display: block;
margin-top: 8px;
}
.indicator-table {
width: 100%;
}
.indicator-row {
display: flex;
padding: 16px 0;
border-bottom: 1px solid #f1f5f9;
align-items: center;
&--header {
border-bottom: 2px solid #e2e8f0;
padding-bottom: 12px;
}
&--abnormal {
background: #fef2f2;
margin: 0 -12px;
padding: 16px 12px;
border-radius: 8px;
}
}
.indicator-cell {
font-size: 24px;
&--name {
flex: 2;
color: #334155;
font-weight: 500;
}
&--value {
flex: 2;
color: #0f172a;
font-weight: 600;
text-align: center;
}
&--ref {
flex: 2;
color: #94a3b8;
text-align: center;
}
&--flag {
flex: 1;
text-align: right;
color: #10b981;
}
.indicator-row--abnormal &--flag {
color: #ef4444;
font-weight: 700;
}
.indicator-row--header & {
font-size: 22px;
color: #94a3b8;
font-weight: 400;
}
}
.notes-display {
background: #f0f9ff;
border-radius: 12px;
padding: 20px;
}
.notes-text {
font-size: 26px;
color: #334155;
line-height: 1.6;
}
.notes-textarea {
width: 100%;
min-height: 200px;
background: #f8fafc;
border-radius: 12px;
padding: 20px;
font-size: 26px;
color: #0f172a;
box-sizing: border-box;
line-height: 1.6;
margin-bottom: 20px;
}
.review-btn {
background: #0891b2;
border-radius: 12px;
padding: 20px;
text-align: center;
&--disabled {
opacity: 0.5;
}
&__text {
font-size: 28px;
color: #fff;
font-weight: 600;
}
}
.error-text {
text-align: center;
padding: 80px 32px;
color: #94a3b8;
font-size: 28px;
}

View File

@@ -0,0 +1,133 @@
import { useState, useEffect } from 'react';
import { View, Text, Textarea, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
export default function ReportDetail() {
const router = useRouter();
const patientId = router.params.patientId || '';
const reportId = router.params.id || '';
const [report, setReport] = useState<doctorApi.LabReportDetail | null>(null);
const [loading, setLoading] = useState(true);
const [doctorNotes, setDoctorNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (patientId && reportId) loadReport();
}, [patientId, reportId]);
const loadReport = async () => {
setLoading(true);
try {
const r = await doctorApi.getLabReport(patientId, reportId);
setReport(r);
setDoctorNotes(r.doctor_notes || '');
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleReview = async () => {
if (!report) return;
setSubmitting(true);
try {
const updated = await doctorApi.reviewLabReport(patientId, reportId, {
doctor_notes: doctorNotes.trim() || undefined,
version: report.version,
});
setReport(updated);
Taro.showToast({ title: '审核完成', icon: 'success' });
} catch {
Taro.showToast({ title: '审核失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
if (loading) return <Loading />;
if (!report) return <View className='error-text'><Text></Text></View>;
return (
<ScrollView scrollY className='report-detail'>
{/* 基本信息 */}
<View className='section'>
<View className='report-header'>
<Text className='report-header__type'>{report.report_type}</Text>
<Text className={`report-header__status report-header__status--${report.status}`}>
{report.status === 'pending' ? '待审核' : report.status === 'reviewed' ? '已审核' : report.status}
</Text>
</View>
<Text className='report-date'>: {formatDate(report.report_date)}</Text>
{report.reviewed_at && (
<Text className='review-info'>: {formatDate(report.reviewed_at)}</Text>
)}
</View>
{/* 指标列表 */}
{report.items && report.items.length > 0 && (
<View className='section'>
<Text className='section-title'></Text>
<View className='indicator-table'>
<View className='indicator-row indicator-row--header'>
<Text className='indicator-cell indicator-cell--name'></Text>
<Text className='indicator-cell indicator-cell--value'></Text>
<Text className='indicator-cell indicator-cell--ref'></Text>
<Text className='indicator-cell indicator-cell--flag'></Text>
</View>
{report.items.map((item, idx) => (
<View
key={idx}
className={`indicator-row ${item.is_abnormal ? 'indicator-row--abnormal' : ''}`}
>
<Text className='indicator-cell indicator-cell--name'>{item.name}</Text>
<Text className='indicator-cell indicator-cell--value'>
{item.value} {item.unit || ''}
</Text>
<Text className='indicator-cell indicator-cell--ref'>
{item.reference_min != null && item.reference_max != null
? `${item.reference_min}-${item.reference_max}`
: '-'}
</Text>
<Text className='indicator-cell indicator-cell--flag'>
{item.is_abnormal ? '↑' : '正常'}
</Text>
</View>
))}
</View>
</View>
)}
{/* 医生注释 */}
<View className='section'>
<Text className='section-title'></Text>
{report.status === 'reviewed' && report.doctor_notes ? (
<View className='notes-display'>
<Text className='notes-text'>{report.doctor_notes}</Text>
</View>
) : (
<View>
<Textarea
className='notes-textarea'
placeholder='输入诊断意见和备注...'
value={doctorNotes}
onInput={(e) => setDoctorNotes(e.detail.value)}
maxlength={1000}
/>
<View
className={`review-btn ${submitting ? 'review-btn--disabled' : ''}`}
onClick={handleReview}
>
<Text className='review-btn__text'>{submitting ? '提交中...' : '确认审核'}</Text>
</View>
</View>
)}
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,88 @@
.report-page {
min-height: 100vh;
background: #f0f4f8;
padding: 24px;
padding-bottom: 120px;
}
.search-bar {
margin-bottom: 20px;
.search-input {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
font-size: 28px;
width: 100%;
box-sizing: border-box;
}
}
.report-count {
margin-bottom: 16px;
text {
font-size: 24px;
color: #94a3b8;
}
}
.report-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.report-card {
background: #fff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
&:active {
background: #f8fafc;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__type {
font-size: 28px;
font-weight: 600;
color: #0f172a;
}
&__date {
font-size: 24px;
color: #94a3b8;
}
&__indicators {
display: flex;
align-items: center;
gap: 16px;
}
&__abnormal {
font-size: 26px;
color: #ef4444;
font-weight: 600;
}
&__normal {
font-size: 26px;
color: #10b981;
}
&__reviewed {
font-size: 22px;
color: #0891b2;
background: #e0f2fe;
padding: 2px 12px;
border-radius: 8px;
}
}

View File

@@ -0,0 +1,109 @@
import { useState, useEffect } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
export default function ReportList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const [searchPatient, setSearchPatient] = useState('');
const [currentPatientId, setCurrentPatientId] = useState(patientId);
const [reports, setReports] = useState<doctorApi.LabReportItem[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
useEffect(() => {
if (currentPatientId) loadReports();
}, [currentPatientId]);
const loadReports = async () => {
setLoading(true);
try {
const res = await doctorApi.listLabReports(currentPatientId, { page: 1, page_size: 50 });
setReports(res.data || []);
setTotal(res.total || 0);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleSearch = async () => {
if (!searchPatient.trim()) return;
setLoading(true);
try {
const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
if (res.data && res.data.length > 0) {
setCurrentPatientId(res.data[0].id);
Taro.setNavigationBarTitle({ title: res.data[0].name + '的化验报告' });
} else {
Taro.showToast({ title: '未找到患者', icon: 'none' });
}
} catch {
Taro.showToast({ title: '搜索失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
if (loading && reports.length === 0) return <Loading />;
return (
<ScrollView scrollY className='report-page'>
{!patientId && (
<View className='search-bar'>
<Input
className='search-input'
placeholder='搜索患者姓名'
value={searchPatient}
onInput={(e) => setSearchPatient(e.detail.value)}
confirmType='search'
onConfirm={handleSearch}
/>
</View>
)}
{!currentPatientId ? (
<EmptyState text='请搜索并选择患者' />
) : reports.length === 0 ? (
<EmptyState text='暂无化验报告' />
) : (
<View className='report-list'>
<View className='report-count'>
<Text> {total} </Text>
</View>
{reports.map((r) => (
<View
key={r.id}
className='report-card'
onClick={() => Taro.navigateTo({
url: `/pages/doctor/report/detail/index?patientId=${currentPatientId}&id=${r.id}`,
})}
>
<View className='report-card__header'>
<Text className='report-card__type'>{r.report_type}</Text>
<Text className='report-card__date'>{formatDate(r.report_date)}</Text>
</View>
<View className='report-card__indicators'>
{(r.abnormal_count ?? 0) > 0 ? (
<Text className='report-card__abnormal'>{r.abnormal_count} </Text>
) : (
<Text className='report-card__normal'></Text>
)}
{r.status === 'reviewed' && (
<Text className='report-card__reviewed'></Text>
)}
</View>
</View>
))}
</View>
)}
</ScrollView>
);
}

View File

@@ -7,8 +7,6 @@ import './index.scss';
export default function Login() {
const [needBind, setNeedBind] = useState(false);
const [agreed, setAgreed] = useState(false);
const { login, bindPhone, loading } = useAuthStore();
const { login, bindPhone, loading, isMedicalStaff } = useAuthStore();
/** 登录/绑定成功后根据角色跳转 */

View File

@@ -0,0 +1,296 @@
import { api } from './request';
// ── Dashboard ──────────────────────────────────────
export interface DoctorDashboard {
total_patients: number;
active_sessions: number;
unread_messages: number;
pending_follow_ups: number;
today_consultations: number;
}
export async function getDashboard() {
return api.get<DoctorDashboard>('/health/doctor/dashboard');
}
// ── Patient (doctor view) ──────────────────────────
export interface PatientItem {
id: string;
name: string;
gender?: string;
birth_date?: string;
phone?: string;
status?: string;
tags?: { id: string; name: string; color?: string }[];
last_visit_date?: string;
version: number;
}
export interface PatientDetail extends PatientItem {
blood_type?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface HealthSummary {
latest_vital_signs?: {
record_date: string;
systolic_bp?: number;
diastolic_bp?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
};
active_conditions?: string[];
recent_lab_reports?: { id: string; report_date: string; report_type: string; abnormal_count: number }[];
upcoming_appointments?: { id: string; appointment_date: string; type: string }[];
pending_follow_ups?: number;
}
export interface PatientTag {
id: string;
name: string;
color?: string;
is_system?: boolean;
}
export async function listPatients(params?: {
page?: number;
page_size?: number;
search?: string;
tag_id?: string;
}) {
return api.get<{ data: PatientItem[]; total: number }>('/health/patients', params);
}
export async function getPatient(id: string) {
return api.get<PatientDetail>(`/health/patients/${id}`);
}
export async function getHealthSummary(patientId: string) {
return api.get<HealthSummary>(`/health/patients/${patientId}/health-summary`);
}
export async function listPatientTags() {
return api.get<{ data: PatientTag[]; total: number }>('/health/patient-tags');
}
// ── Consultation (doctor view) ─────────────────────
export interface ConsultationSession {
id: string;
patient_id: string;
patient_name?: string;
doctor_id: string | null;
consultation_type: string;
status: string;
subject: string | null;
last_message: string | null;
last_message_at: string | null;
unread_count_doctor?: number;
created_at: string;
}
export interface ConsultationMessage {
id: string;
session_id: string;
sender_id: string;
sender_role: string;
content_type: string;
content: string;
created_at: string;
}
export async function listSessions(params?: {
page?: number;
page_size?: number;
status?: string;
}) {
return api.get<{ data: ConsultationSession[]; total: number }>(
'/health/consultation-sessions',
params,
);
}
export async function getSession(id: string) {
return api.get<ConsultationSession>(`/health/consultation-sessions/${id}`);
}
export async function listMessages(sessionId: string, params?: { page?: number; page_size?: number }) {
return api.get<{ data: ConsultationMessage[]; total: number }>(
`/health/consultation-sessions/${sessionId}/messages`,
params,
);
}
export async function sendMessage(sessionId: string, content: string, contentType = 'text') {
return api.post<ConsultationMessage>('/health/consultation-messages', {
session_id: sessionId,
content_type: contentType,
content,
});
}
export async function markSessionRead(sessionId: string) {
return api.put<void>(`/health/consultation-sessions/${sessionId}/read`);
}
export async function closeSession(sessionId: string) {
return api.put<void>(`/health/consultation-sessions/${sessionId}/close`);
}
// ── Follow-up (doctor view) ────────────────────────
export interface FollowUpTask {
id: string;
patient_id: string;
patient_name?: string;
assigned_to?: string;
follow_up_type: string;
planned_date: string;
content_template?: string;
status: string;
created_at: string;
version: number;
}
export interface FollowUpRecord {
id: string;
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
created_at: string;
}
export async function listFollowUpTasks(params?: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
}) {
return api.get<{ data: FollowUpTask[]; total: number }>('/health/follow-up-tasks', params);
}
export async function getFollowUpTask(id: string) {
return api.get<FollowUpTask>(`/health/follow-up-tasks/${id}`);
}
export async function updateFollowUpTask(id: string, data: Record<string, unknown>, version: number) {
return api.put<FollowUpTask>(`/health/follow-up-tasks/${id}`, { ...data, version });
}
export async function createFollowUpRecord(taskId: string, data: {
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
}) {
return api.post<FollowUpRecord>(`/health/follow-up-tasks/${taskId}/records`, data);
}
export async function listFollowUpRecords(params?: { task_id?: string; page?: number }) {
return api.get<{ data: FollowUpRecord[]; total: number }>('/health/follow-up-records', params);
}
// ── Lab Report (doctor view) ───────────────────────
export interface LabReportItem {
id: string;
report_date: string;
report_type: string;
status: string;
abnormal_count?: number;
reviewed_by?: string;
reviewed_at?: string;
doctor_notes?: string;
version: number;
}
export interface LabReportDetail extends LabReportItem {
items?: {
name: string;
value: number;
unit?: string;
reference_min?: number;
reference_max?: number;
is_abnormal?: boolean;
}[];
image_urls?: string[];
}
export async function listLabReports(patientId: string, params?: { page?: number; page_size?: number }) {
return api.get<{ data: LabReportItem[]; total: number }>(
`/health/patients/${patientId}/lab-reports`,
params,
);
}
export async function getLabReport(patientId: string, reportId: string) {
return api.get<LabReportDetail>(`/health/patients/${patientId}/lab-reports/${reportId}`);
}
export async function reviewLabReport(
patientId: string,
reportId: string,
data: { doctor_notes?: string; version: number },
) {
return api.put<LabReportDetail>(
`/health/patients/${patientId}/lab-reports/${reportId}/review`,
data,
);
}
// ── Appointments (doctor view) ─────────────────────
export async function listAppointments(params?: {
page?: number;
page_size?: number;
status?: string;
date?: string;
}) {
return api.get<{ data: any[]; total: number }>('/health/appointments', params);
}
// ── Statistics ─────────────────────────────────────
export interface PatientStats {
total: number;
active: number;
new_this_month: number;
by_source?: Record<string, number>;
}
export interface ConsultationStats {
total: number;
active: number;
avg_response_time_minutes?: number;
}
export interface FollowUpStats {
total: number;
completed: number;
overdue: number;
completion_rate?: number;
}
export async function getPatientStats() {
return api.get<PatientStats>('/health/admin/statistics/patients');
}
export async function getConsultationStats() {
return api.get<ConsultationStats>('/health/admin/statistics/consultations');
}
export async function getFollowUpStats() {
return api.get<FollowUpStats>('/health/admin/statistics/follow-ups');
}