diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index d31156b..c6b200d 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -16,6 +16,7 @@ export default defineAppConfig({ 'pages/ai-report/detail/index', 'pages/followup/detail/index', 'pages/consultation/index', + 'pages/consultation/detail/index', 'pages/mall/index', 'pages/mall/exchange/index', 'pages/mall/orders/index', diff --git a/apps/miniprogram/src/pages/consultation/detail/index.scss b/apps/miniprogram/src/pages/consultation/detail/index.scss new file mode 100644 index 0000000..ba35d74 --- /dev/null +++ b/apps/miniprogram/src/pages/consultation/detail/index.scss @@ -0,0 +1,141 @@ +@import '../../../styles/variables.scss'; + +.chat-page { + display: flex; + flex-direction: column; + height: 100vh; + background: $bg; +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 32px; + background: $card; + border-bottom: 1px solid #e2e8f0; + + &__title { + font-size: 30px; + font-weight: 600; + color: $tx; + } + + &__status { + font-size: 24px; + color: $tx3; + } +} + +.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: $card; + border-top-left-radius: 4px; + } + + &--self { + background: $pri; + border-top-right-radius: 4px; + } +} + +.msg-text { + font-size: 28px; + color: $tx; + display: block; + line-height: 1.6; + word-break: break-all; + + .msg-bubble--self & { + color: #fff; + } +} + +.msg-time { + font-size: 20px; + color: $tx3; + 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: $tx3; + } +} + +.chat-input-bar { + display: flex; + align-items: center; + padding: 16px 24px; + background: $card; + border-top: 1px solid #e2e8f0; + padding-bottom: calc(16px + env(safe-area-inset-bottom)); +} + +.chat-input { + flex: 1; + background: $bg; + border-radius: 12px; + padding: 16px 20px; + font-size: 28px; + margin-right: 16px; +} + +.chat-send-btn { + background: $pri; + 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: $card; + border-top: 1px solid #e2e8f0; + + &__text { + font-size: 26px; + color: $tx3; + } +} diff --git a/apps/miniprogram/src/pages/consultation/detail/index.tsx b/apps/miniprogram/src/pages/consultation/detail/index.tsx new file mode 100644 index 0000000..b698ca0 --- /dev/null +++ b/apps/miniprogram/src/pages/consultation/detail/index.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect, useRef } from 'react'; +import { View, Text, Input, ScrollView } from '@tarojs/components'; +import Taro, { useRouter } from '@tarojs/taro'; +import { + getSession, + listMessages, + sendMessage, + markSessionRead, + type ConsultationSession, + type ConsultationMessage, +} from '@/services/consultation'; +import Loading from '@/components/Loading'; +import './index.scss'; + +const POLL_INTERVAL = 8000; + +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(''); + const pollTimerRef = useRef | null>(null); + + useEffect(() => { + if (sessionId) { + loadData(); + markRead(); + startPolling(); + } + return () => stopPolling(); + }, [sessionId]); + + const startPolling = () => { + stopPolling(); + pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL); + }; + + const stopPolling = () => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + + const pollNewMessages = async () => { + if (!session || session.status === 'closed') { + stopPolling(); + return; + } + try { + const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined; + const m = await listMessages(sessionId, { + page: 1, + page_size: 50, + after_id: lastId, + }); + const newMsgs = m.data || []; + if (newMsgs.length > 0) { + setMessages((prev) => { + const existing = new Set(prev.map((msg) => msg.id)); + const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); + return [...prev, ...fresh]; + }); + scrollViewRef.current = `msg-${messages.length + newMsgs.length}`; + } + } catch { /* 轮询失败静默忽略 */ } + }; + + const loadData = async () => { + setLoading(true); + try { + const [s, m] = await Promise.all([ + getSession(sessionId), + listMessages(sessionId, { page: 1, page_size: 50 }), + ]); + setSession(s); + setMessages(m.data || []); + scrollViewRef.current = `msg-${(m.data || []).length}`; + if (s.status === 'closed') stopPolling(); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + setLoading(false); + } + }; + + const markRead = async () => { + try { + await markSessionRead(sessionId); + } catch { /* ignore */ } + }; + + const handleSend = async () => { + const text = inputText.trim(); + if (!text || sending) return; + setSending(true); + setInputText(''); + try { + const msg = await 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 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 ( + + + {session?.subject || '在线咨询'} + {!isOpen && ( + 已结束 + )} + + + + {messages.map((msg, idx) => { + const isSelf = msg.sender_role === 'patient'; + return ( + + + {msg.content} + {formatTime(msg.created_at)} + + + ); + })} + {messages.length === 0 && ( + + 暂无消息,发送第一条消息开始对话 + + )} + + + {isOpen ? ( + + setInputText(e.detail.value)} + confirmType='send' + onConfirm={handleSend} + disabled={sending} + /> + + {sending ? '...' : '发送'} + + + ) : ( + + 会话已关闭 + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/consultation/index.tsx b/apps/miniprogram/src/pages/consultation/index.tsx index 201a160..7d8cf97 100644 --- a/apps/miniprogram/src/pages/consultation/index.tsx +++ b/apps/miniprogram/src/pages/consultation/index.tsx @@ -70,8 +70,8 @@ export default function Consultation() { }); }); - const handleTapSession = (_session: ConsultationSession) => { - Taro.showToast({ title: '即将上线', icon: 'none' }); + const handleTapSession = (session: ConsultationSession) => { + Taro.navigateTo({ url: `/pages/consultation/detail/index?id=${session.id}` }); }; return ( @@ -93,7 +93,7 @@ export default function Consultation() { 💬 暂无咨询记录 - 在线咨询功能即将上线,敬请期待 + 发起咨询后即可在这里与医生交流 ) : ( diff --git a/apps/miniprogram/src/services/consultation.ts b/apps/miniprogram/src/services/consultation.ts index 7412dbd..f78f5e0 100644 --- a/apps/miniprogram/src/services/consultation.ts +++ b/apps/miniprogram/src/services/consultation.ts @@ -13,6 +13,17 @@ export interface ConsultationSession { created_at: string; } +export interface ConsultationMessage { + id: string; + session_id: string; + sender_id: string; + sender_role: string; + content_type: string; + content: string; + is_read: boolean; + created_at: string; +} + export async function listConsultations(params?: { page?: number; page_size?: number; @@ -22,3 +33,30 @@ export async function listConsultations(params?: { params, ); } + +export async function getSession(sessionId: string) { + return api.get(`/health/consultation-sessions/${sessionId}`); +} + +export async function listMessages(sessionId: string, params?: { + page?: number; + page_size?: number; + after_id?: string; +}) { + 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('/health/consultation-messages', { + session_id: sessionId, + content_type: contentType, + content, + }); +} + +export async function markSessionRead(sessionId: string) { + return api.put(`/health/consultation-sessions/${sessionId}/read`); +} diff --git a/apps/web/src/api/health/consultations.ts b/apps/web/src/api/health/consultations.ts index e31f9a2..6b12308 100644 --- a/apps/web/src/api/health/consultations.ts +++ b/apps/web/src/api/health/consultations.ts @@ -86,7 +86,7 @@ export const consultationApi = { listMessages: async ( sessionId: string, - params: { page?: number; page_size?: number }, + params: { page?: number; page_size?: number; after_id?: string }, ) => { const { data } = await client.get<{ success: boolean; diff --git a/apps/web/src/pages/health/ConsultationDetail.tsx b/apps/web/src/pages/health/ConsultationDetail.tsx index f99423d..f38843f 100644 --- a/apps/web/src/pages/health/ConsultationDetail.tsx +++ b/apps/web/src/pages/health/ConsultationDetail.tsx @@ -9,6 +9,7 @@ import { useThemeMode } from '../../hooks/useThemeMode'; import { AuthButton } from '../../components/AuthButton'; const PAGE_SIZE = 30; +const POLL_INTERVAL = 10_000; function formatTime(value: string): string { return new Date(value).toLocaleString('zh-CN', { @@ -54,6 +55,7 @@ export default function ConsultationDetail() { const chatEndRef = useRef(null); const shouldScrollRef = useRef(true); + const pollRef = useRef | null>(null); const isDark = useThemeMode(); @@ -103,6 +105,39 @@ export default function ConsultationDetail() { fetchMessages(1, false); }, [fetchSession, fetchMessages]); + // Poll new messages while session is active + useEffect(() => { + if (!session || session.status === 'closed') return; + + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + stopPolling(); + pollRef.current = setInterval(async () => { + if (!sessionId) return; + try { + const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined; + const result = await consultationApi.listMessages(sessionId, { + page: 1, + page_size: 50, + after_id: lastId, + }); + if (result.data.length > 0) { + setMessages((prev) => [...prev, ...result.data]); + shouldScrollRef.current = true; + } + } catch { + // silent + } + }, POLL_INTERVAL); + + return stopPolling; + }, [session?.status, sessionId, messages.length]); + // Auto-scroll to bottom on new messages useEffect(() => { if (shouldScrollRef.current && chatEndRef.current) {