feat(miniprogram): 医护端小程序页面 — 8 页面覆盖患者/咨询/随访/报告
Iteration 2 医护端前端核心页面: - 新增 doctor.ts service 层(仪表盘/患者/咨询/随访/报告 API) - 升级医生首页:接入真实仪表盘数据 + 快捷操作入口 - 患者管理:搜索 + 标签筛选 + 详情页(基本信息/过敏史/健康概览) - 咨询回复:会话列表 + 状态筛选 + 聊天详情 + 发送消息 + 关闭会话 - 随访管理:任务列表 + 状态筛选 + 详情 + 填写随访记录 - 报告解读:化验报告列表 + 异常高亮 + 指标表格 + 医生审核注释 - 修复 login 页面重复解构 - 注册 8 个新页面路由到 app.config.ts
This commit is contained in:
@@ -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',
|
||||
|
||||
140
apps/miniprogram/src/pages/doctor/consultation/detail/index.scss
Normal file
140
apps/miniprogram/src/pages/doctor/consultation/detail/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
153
apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx
Normal file
153
apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
apps/miniprogram/src/pages/doctor/consultation/index.scss
Normal file
156
apps/miniprogram/src/pages/doctor/consultation/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
134
apps/miniprogram/src/pages/doctor/consultation/index.tsx
Normal file
134
apps/miniprogram/src/pages/doctor/consultation/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/miniprogram/src/pages/doctor/followup/detail/index.scss
Normal file
185
apps/miniprogram/src/pages/doctor/followup/detail/index.scss
Normal 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;
|
||||
}
|
||||
195
apps/miniprogram/src/pages/doctor/followup/detail/index.tsx
Normal file
195
apps/miniprogram/src/pages/doctor/followup/detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
apps/miniprogram/src/pages/doctor/followup/index.scss
Normal file
102
apps/miniprogram/src/pages/doctor/followup/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
118
apps/miniprogram/src/pages/doctor/followup/index.tsx
Normal file
118
apps/miniprogram/src/pages/doctor/followup/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
199
apps/miniprogram/src/pages/doctor/patients/detail/index.scss
Normal file
199
apps/miniprogram/src/pages/doctor/patients/detail/index.scss
Normal 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;
|
||||
}
|
||||
156
apps/miniprogram/src/pages/doctor/patients/detail/index.tsx
Normal file
156
apps/miniprogram/src/pages/doctor/patients/detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
apps/miniprogram/src/pages/doctor/patients/index.scss
Normal file
140
apps/miniprogram/src/pages/doctor/patients/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
176
apps/miniprogram/src/pages/doctor/patients/index.tsx
Normal file
176
apps/miniprogram/src/pages/doctor/patients/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
apps/miniprogram/src/pages/doctor/report/detail/index.scss
Normal file
170
apps/miniprogram/src/pages/doctor/report/detail/index.scss
Normal 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;
|
||||
}
|
||||
133
apps/miniprogram/src/pages/doctor/report/detail/index.tsx
Normal file
133
apps/miniprogram/src/pages/doctor/report/detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
apps/miniprogram/src/pages/doctor/report/index.scss
Normal file
88
apps/miniprogram/src/pages/doctor/report/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
109
apps/miniprogram/src/pages/doctor/report/index.tsx
Normal file
109
apps/miniprogram/src/pages/doctor/report/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
/** 登录/绑定成功后根据角色跳转 */
|
||||
|
||||
296
apps/miniprogram/src/services/doctor.ts
Normal file
296
apps/miniprogram/src/services/doctor.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user