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/user-agreement',
|
||||||
'pages/legal/privacy-policy',
|
'pages/legal/privacy-policy',
|
||||||
'pages/doctor/index',
|
'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: {
|
tabBar: {
|
||||||
color: '#94A3B8',
|
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;
|
min-height: 100vh;
|
||||||
background: #f0f4f8;
|
background: #f0f4f8;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
|
padding-bottom: 120px;
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
@@ -12,16 +13,23 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__greeting {
|
&__greeting {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section {
|
&__section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section-title {
|
&__section-title {
|
||||||
@@ -35,21 +43,32 @@
|
|||||||
&__grid {
|
&__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 24px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 32px;
|
padding: 28px 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
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 {
|
&__card-num {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0891b2;
|
color: #0f172a;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -59,13 +78,45 @@
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
margin-top: 80px;
|
margin-top: 60px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__logout {
|
&__logout {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
font-size: 28px;
|
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 { useState, useEffect } from 'react';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { View, Text, ScrollView } from '@tarojs/components';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import * as doctorApi from '@/services/doctor';
|
||||||
|
import Loading from '@/components/Loading';
|
||||||
import './index.scss';
|
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() {
|
export default function DoctorHome() {
|
||||||
const { user, logout } = useAuthStore();
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
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 (
|
return (
|
||||||
<View className='doctor-home'>
|
<ScrollView scrollY className='doctor-home'>
|
||||||
<View className='doctor-home__header'>
|
<View className='doctor-home__header'>
|
||||||
<Text className='doctor-home__title'>医护工作台</Text>
|
<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>
|
||||||
|
|
||||||
<View className='doctor-home__section'>
|
<View className='doctor-home__section'>
|
||||||
<Text className='doctor-home__section-title'>工作概览</Text>
|
<Text className='doctor-home__section-title'>工作概览</Text>
|
||||||
<View className='doctor-home__grid'>
|
<View className='doctor-home__grid'>
|
||||||
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
|
{CARDS.map((card) => (
|
||||||
<Text className='doctor-home__card-num'>-</Text>
|
<View
|
||||||
<Text className='doctor-home__card-label'>待回复咨询</Text>
|
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>
|
||||||
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
|
<View className='quick-action' onClick={() => Taro.navigateTo({ url: '/pages/doctor/patients/index' })}>
|
||||||
<Text className='doctor-home__card-num'>-</Text>
|
<Text className='quick-action__icon'>🔍</Text>
|
||||||
<Text className='doctor-home__card-label'>待处理随访</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>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -43,6 +103,6 @@ export default function DoctorHome() {
|
|||||||
<View className='doctor-home__footer'>
|
<View className='doctor-home__footer'>
|
||||||
<Text className='doctor-home__logout' onClick={handleLogout}>退出登录</Text>
|
<Text className='doctor-home__logout' onClick={handleLogout}>退出登录</Text>
|
||||||
</View>
|
</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() {
|
export default function Login() {
|
||||||
const [needBind, setNeedBind] = useState(false);
|
const [needBind, setNeedBind] = useState(false);
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const { login, bindPhone, loading } = useAuthStore();
|
|
||||||
|
|
||||||
const { login, bindPhone, loading, isMedicalStaff } = 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