Files
hms/apps/web/src/pages/health/ConsultationDetail.tsx
iven 5bb6105127
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat: 咨询消息轮询优化 — Web 自动刷新 + 患者端聊天详情页
Web 端:
- ConsultationDetail 添加 10s 自动轮询新消息(after_id 增量拉取)
- consultations API 补充 after_id 参数

小程序患者端:
- 新增 consultation service 消息 API(listMessages/sendMessage/markSessionRead)
- 新增聊天详情页(8s 轮询 + 发送消息 + 自动标记已读)
- 咨询列表页点击跳转详情页(替换"即将上线"占位)
2026-04-26 14:40:46 +08:00

407 lines
11 KiB
TypeScript

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<string, 'flex-start' | 'flex-end' | 'center'> = {
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<Session | null>(null);
const [sessionLoading, setSessionLoading] = useState(true);
// Messages
const [messages, setMessages] = useState<Message[]>([]);
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<HTMLDivElement>(null);
const shouldScrollRef = useRef(true);
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<div key={msg.id} style={{ textAlign: 'center', padding: '8px 0' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{msg.content}
</Typography.Text>
</div>
);
}
const isImage = msg.content_type === 'image';
return (
<div
key={msg.id}
style={{
display: 'flex',
justifyContent: align,
marginBottom: 12,
}}
>
<div style={{ maxWidth: '70%' }}>
{isImage ? (
<ImagePreview urls={parseImageUrls(msg.content)} width={200} />
) : (
<div
style={{
background: msg.sender_role === 'doctor' ? '#1890ff' : '#f0f0f0',
color: msg.sender_role === 'doctor' ? '#fff' : '#000',
padding: '8px 12px',
borderRadius: 8,
wordBreak: 'break-word',
}}
>
<Typography.Paragraph style={{ margin: 0, color: 'inherit' }}>
{msg.content}
</Typography.Paragraph>
</div>
)}
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{formatTime(msg.created_at)}
</Typography.Text>
</div>
</div>
);
};
// --- Full render ---
if (sessionLoading && messages.length === 0) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
const isClosed = session?.status === 'closed';
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 120px)',
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
{/* Top bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
flexShrink: 0,
}}
>
<Typography.Text strong style={{ fontSize: 15 }}>
</Typography.Text>
{session && (
<>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
: {session.patient_id.slice(0, 8)}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
: {session.doctor_id ? session.doctor_id.slice(0, 8) : '-'}
</Typography.Text>
<StatusTag status={session.status} />
</>
)}
{!session && (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
ID: {sessionId.slice(0, 8)}
</Typography.Text>
)}
{session && !isClosed && (
<AuthButton code="health.consultation.manage">
<Popconfirm
title="确认关闭该咨询会话?"
onConfirm={handleClose}
okText="确认"
cancelText="取消"
>
<Button
size="small"
danger
icon={<CloseCircleOutlined />}
style={{ marginLeft: 'auto' }}
>
</Button>
</Popconfirm>
</AuthButton>
)}
</div>
{/* Chat area */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '16px 20px',
background: isDark ? '#0f172a' : '#f8fafc',
}}
>
{hasMore && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Button
size="small"
icon={<ArrowUpOutlined />}
loading={msgLoading}
onClick={handleLoadMore}
>
</Button>
</div>
)}
{msgLoading && messages.length === 0 && (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
)}
{messages.map(renderMessage)}
<div ref={chatEndRef} />
</div>
{/* Input area */}
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
padding: '12px 20px',
borderTop: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
flexShrink: 0,
background: isDark ? '#111827' : '#FFFFFF',
}}
>
<Input.TextArea
value={inputText}
onChange={(e) => 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}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sending}
disabled={!inputText.trim() || isClosed}
>
</Button>
</div>
</div>
);
}