diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index c0449c7..1006d8f 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -30,6 +30,14 @@ export default defineAppConfig({ 'pages/legal/user-agreement', 'pages/legal/privacy-policy', 'pages/doctor/index', + 'pages/doctor/patients/index', + 'pages/doctor/patients/detail/index', + 'pages/doctor/consultation/index', + 'pages/doctor/consultation/detail/index', + 'pages/doctor/followup/index', + 'pages/doctor/followup/detail/index', + 'pages/doctor/report/index', + 'pages/doctor/report/detail/index', ], tabBar: { color: '#94A3B8', diff --git a/apps/miniprogram/src/pages/doctor/consultation/detail/index.scss b/apps/miniprogram/src/pages/doctor/consultation/detail/index.scss new file mode 100644 index 0000000..c9fba40 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/consultation/detail/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx new file mode 100644 index 0000000..fe45e59 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx @@ -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(null); + const [messages, setMessages] = useState([]); + 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 ; + + const isOpen = session?.status !== 'closed'; + + return ( + + {/* Header */} + + {session?.subject || '在线咨询'} + {isOpen && ( + 关闭会话 + )} + + + {/* Messages */} + + {messages.map((msg, idx) => { + const isDoctor = msg.sender_role === 'doctor'; + return ( + + + {msg.content} + {formatTime(msg.created_at)} + + + ); + })} + {messages.length === 0 && ( + + 暂无消息,发送第一条消息开始对话 + + )} + + + {/* Input */} + {isOpen ? ( + + setInputText(e.detail.value)} + confirmType='send' + onConfirm={handleSend} + disabled={sending} + /> + + {sending ? '...' : '发送'} + + + ) : ( + + 会话已关闭 + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/doctor/consultation/index.scss b/apps/miniprogram/src/pages/doctor/consultation/index.scss new file mode 100644 index 0000000..d8f0af8 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/consultation/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/pages/doctor/consultation/index.tsx b/apps/miniprogram/src/pages/doctor/consultation/index.tsx new file mode 100644 index 0000000..4f70c9b --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/consultation/index.tsx @@ -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 = { + 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([]); + 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 ; + + return ( + + + {TABS.map((t) => ( + handleTabChange(t.key)} + > + {t.label} + + ))} + + + {sessions.length === 0 ? ( + + ) : ( + + {sessions.map((s) => { + const st = STATUS_MAP[s.status] || { label: s.status, color: '#94a3b8' }; + return ( + Taro.navigateTo({ url: `/pages/doctor/consultation/detail/index?id=${s.id}` })} + > + + {s.subject || '在线咨询'} + + {st.label} + + + + + {s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询'} + + {formatTime(s.last_message_at)} + + {s.last_message && ( + {s.last_message} + )} + {(s.unread_count_doctor ?? 0) > 0 && ( + + {s.unread_count_doctor} + + )} + + ); + })} + + )} + + {total > 20 && ( + + page > 1 && setPage(page - 1)} + >上一页 + {page} / {Math.ceil(total / 20)} + = Math.ceil(total / 20) ? 'disabled' : ''}`} + onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)} + >下一页 + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/doctor/followup/detail/index.scss b/apps/miniprogram/src/pages/doctor/followup/detail/index.scss new file mode 100644 index 0000000..d890517 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/followup/detail/index.scss @@ -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; +} diff --git a/apps/miniprogram/src/pages/doctor/followup/detail/index.tsx b/apps/miniprogram/src/pages/doctor/followup/detail/index.tsx new file mode 100644 index 0000000..a2130cb --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/followup/detail/index.tsx @@ -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 = { + pending: '待处理', + in_progress: '进行中', + completed: '已完成', + overdue: '已逾期', + cancelled: '已取消', +}; + +export default function FollowUpDetail() { + const router = useRouter(); + const taskId = router.params.id || ''; + const [task, setTask] = useState(null); + const [records, setRecords] = useState([]); + 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 ; + if (!task) return 任务加载失败; + + const canSubmit = task.status === 'in_progress' || task.status === 'pending' || task.status === 'overdue'; + + return ( + + + + 随访详情 + + {STATUS_LABELS[task.status] || task.status} + + + + + 患者 + {task.patient_name || '-'} + + + 类型 + {task.follow_up_type} + + + 计划日期 + {formatDate(task.planned_date)} + + + {task.content_template && ( + + 随访模板 + {task.content_template} + + )} + + + {/* 历史记录 */} + {records.length > 0 && ( + + 历史记录 + {records.map((r) => ( + + {formatDate(r.executed_date)} + {r.result && 结果: {r.result}} + {r.patient_condition && 患者状况: {r.patient_condition}} + {r.medical_advice && 医嘱: {r.medical_advice}} + + ))} + + )} + + {/* 填写表单 */} + {canSubmit && ( + + 记录随访 + {(task.status === 'pending' || task.status === 'overdue') && ( + + 开始随访 + + )} + + 随访结果 * +