import { useState, useEffect, useCallback, useRef } from 'react'; import { Button, Input, Spin, Popconfirm, message, Typography } from 'antd'; import { SendOutlined, CloseCircleOutlined, ArrowUpOutlined } from '@ant-design/icons'; import { useParams } from 'react-router-dom'; import { consultationApi, type Session, type Message } from '../../api/health/consultations'; import { StatusTag } from './components/StatusTag'; import { ImagePreview } from './components/ImagePreview'; 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', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } /** Parse image URLs from message content (JSON array or single URL string). */ function parseImageUrls(content: string): string[] { try { const parsed = JSON.parse(content); if (Array.isArray(parsed)) return parsed.map(String); return [String(parsed)]; } catch { return [content]; } } const ROLE_ALIGN: Record = { patient: 'flex-start', doctor: 'flex-end', system: 'center', }; export default function ConsultationDetail() { const { id } = useParams<{ id: string }>(); const sessionId = id ?? ''; // Session info const [session, setSession] = useState(null); const [sessionLoading, setSessionLoading] = useState(true); // Messages const [messages, setMessages] = useState([]); const [msgPage, setMsgPage] = useState(1); const [msgLoading, setMsgLoading] = useState(false); const [sending, setSending] = useState(false); const [inputText, setInputText] = useState(''); const [hasMore, setHasMore] = useState(false); const chatEndRef = useRef(null); const shouldScrollRef = useRef(true); const pollRef = useRef | null>(null); const isDark = useThemeMode(); // --- Fetch session info --- const fetchSession = useCallback(async () => { if (!sessionId) return; setSessionLoading(true); try { const result = await consultationApi.getSession(sessionId); setSession(result); } catch { message.error('加载会话信息失败'); } setSessionLoading(false); }, [sessionId]); // --- Fetch messages --- const fetchMessages = useCallback( async (page: number, append: boolean) => { if (!sessionId) return; setMsgLoading(true); try { const result = await consultationApi.listMessages(sessionId, { page, page_size: PAGE_SIZE, }); const newMsgs = result.data; const totalPages = Math.ceil(result.total / PAGE_SIZE); if (append) { setMessages((prev) => [...newMsgs, ...prev]); } else { setMessages(newMsgs); } setHasMore(page < totalPages); } catch { message.error('加载消息失败'); } setMsgLoading(false); }, [sessionId], ); // Initial load useEffect(() => { fetchSession(); 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) { chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages.length]); // --- Send message --- const handleSend = async () => { const text = inputText.trim(); if (!text || !sessionId) return; setSending(true); try { // Optimistically append to UI const optimisticMsg: Message = { id: `temp_${Date.now()}`, session_id: sessionId, sender_id: '', sender_role: 'doctor', content_type: 'text', content: text, is_read: false, created_at: new Date().toISOString(), }; setMessages((prev) => [...prev, optimisticMsg]); setInputText(''); shouldScrollRef.current = true; await consultationApi.createMessage({ session_id: sessionId, sender_id: '', sender_role: 'doctor', content_type: 'text', content: text, }); // Refresh to replace optimistic message with server version await fetchMessages(msgPage, false); } catch { message.error('发送失败'); } finally { setSending(false); } }; // --- Load more (older messages) --- const handleLoadMore = () => { const nextPage = msgPage + 1; setMsgPage(nextPage); shouldScrollRef.current = false; fetchMessages(nextPage, true); }; // --- Close session --- const handleClose = async () => { if (!session) return; try { const updated = await consultationApi.closeSession(session.id, { version: session.version, }); setSession(updated); message.success('会话已关闭'); } catch { message.error('关闭会话失败'); } }; // --- Render a single message bubble --- const renderMessage = (msg: Message) => { const align = ROLE_ALIGN[msg.sender_role] ?? 'flex-start'; // System messages: centered plain text if (msg.sender_role === 'system') { return (
{msg.content}
); } const isImage = msg.content_type === 'image'; return (
{isImage ? ( ) : (
{msg.content}
)} {formatTime(msg.created_at)}
); }; // --- Full render --- if (sessionLoading && messages.length === 0) { return (
); } const isClosed = session?.status === 'closed'; return (
{/* Top bar */}
咨询会话 {session && ( <> 患者: {session.patient_id.slice(0, 8)} 医护: {session.doctor_id ? session.doctor_id.slice(0, 8) : '-'} )} {!session && ( ID: {sessionId.slice(0, 8)} )} {session && !isClosed && ( )}
{/* Chat area */}
{hasMore && (
)} {msgLoading && messages.length === 0 && (
)} {messages.map(renderMessage)}
{/* Input area */}
setInputText(e.target.value)} placeholder={isClosed ? '会话已关闭' : '输入消息...'} autoSize={{ minRows: 1, maxRows: 4 }} style={{ flex: 1, borderRadius: 8 }} onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend(); } }} disabled={isClosed} />
); }