Web 端: - ConsultationDetail 添加 10s 自动轮询新消息(after_id 增量拉取) - consultations API 补充 after_id 参数 小程序患者端: - 新增 consultation service 消息 API(listMessages/sendMessage/markSessionRead) - 新增聊天详情页(8s 轮询 + 发送消息 + 自动标记已读) - 咨询列表页点击跳转详情页(替换"即将上线"占位)
407 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|