feat: add SignalR real-time chat to doctor and patient frontends
This commit is contained in:
@@ -1,27 +1,32 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
|
||||
import { api } from '@/services/api-client';
|
||||
import * as consultationService from '@/services/consultation.service';
|
||||
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
|
||||
import { formatRelative } from '@/utils/format';
|
||||
import styles from './ChatPage.module.css';
|
||||
|
||||
function getToken(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem('hrt_auth');
|
||||
if (!raw) return '';
|
||||
const state = JSON.parse(raw);
|
||||
return state?.state?.token ?? '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const [doctor, setDoctor] = useState<Doctor | null>(null);
|
||||
const [consultation, setConsultation] = useState<Consultation | null>(null);
|
||||
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const connRef = useRef<HubConnection | null>(null);
|
||||
const initRef = useRef(false);
|
||||
|
||||
const loadMessages = useCallback((cid: string) => {
|
||||
api.get<ConsultationMessage[]>(`/api/consultations/${cid}/messages`)
|
||||
.then((res) => setMessages(res.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Init once - prevent Strict Mode double-fire
|
||||
// Init consultation once
|
||||
useEffect(() => {
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
@@ -30,52 +35,93 @@ export function ChatPage() {
|
||||
if (docs.length > 0) {
|
||||
const doc = docs[0];
|
||||
setDoctor(doc);
|
||||
// Find existing active consultation first
|
||||
api.get<Consultation[]>('/api/consultations').then((res) => {
|
||||
const existing = (res.data as Record<string, unknown>[]).find(
|
||||
(c) => c.doctorId === doc.id && c.status === 'active'
|
||||
);
|
||||
if (existing) {
|
||||
setConsultation(existing as unknown as Consultation);
|
||||
loadMessages((existing as Record<string, string>).id);
|
||||
} else {
|
||||
// Create new only if none exists
|
||||
consultationService.startConsultation(doc.id, '在线咨询').then((c) => {
|
||||
setConsultation(c);
|
||||
loadMessages(c.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [loadMessages]);
|
||||
}, []);
|
||||
|
||||
// Load initial messages + set up SignalR when consultation is ready
|
||||
useEffect(() => {
|
||||
if (!consultation?.id) return;
|
||||
|
||||
// Load message history
|
||||
api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`)
|
||||
.then((res) => setMessages(res.data))
|
||||
.catch(() => {});
|
||||
|
||||
// Set up SignalR connection
|
||||
const conn = new HubConnectionBuilder()
|
||||
.withUrl('http://localhost:5000/hubs/chat', {
|
||||
accessTokenFactory: () => getToken(),
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
conn.on('ReceiveMessage', (msg: ConsultationMessage) => {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||
return [...prev, msg];
|
||||
});
|
||||
});
|
||||
|
||||
conn.onreconnected(() => {
|
||||
conn.invoke('JoinConsultation', consultation.id).catch(() => {});
|
||||
});
|
||||
|
||||
conn.start()
|
||||
.then(() => {
|
||||
setConnected(true);
|
||||
return conn.invoke('JoinConsultation', consultation.id);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
connRef.current = conn;
|
||||
|
||||
return () => {
|
||||
if (conn.state === HubConnectionState.Connected) {
|
||||
conn.invoke('LeaveConsultation', consultation.id).catch(() => {});
|
||||
}
|
||||
conn.stop();
|
||||
};
|
||||
}, [consultation?.id]);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Poll every 3s
|
||||
useEffect(() => {
|
||||
if (!consultation?.id) return;
|
||||
const timer = setInterval(() => loadMessages(consultation.id), 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [consultation?.id, loadMessages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!text.trim() || !consultation || sending) return;
|
||||
setSending(true);
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!text.trim() || !consultation?.id || !connRef.current) return;
|
||||
const msgText = text;
|
||||
setText('');
|
||||
try {
|
||||
const sent = await consultationService.sendMessage(consultation.id, msgText);
|
||||
setMessages((prev) => [...prev, sent]);
|
||||
await connRef.current.invoke('SendMessage', consultation.id, msgText);
|
||||
} catch { /* ignore */ }
|
||||
setSending(false);
|
||||
};
|
||||
}, [text, consultation?.id]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title={doctor?.name || '在线问诊'} />
|
||||
<PageHeader
|
||||
title={doctor?.name || '在线问诊'}
|
||||
rightAction={
|
||||
<span style={{
|
||||
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||
background: connected ? '#4caf50' : '#ccc',
|
||||
marginLeft: 8,
|
||||
}} />
|
||||
}
|
||||
/>
|
||||
<div className={styles.messages}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#9CA3AF', marginTop: 40, fontSize: 14 }}>
|
||||
@@ -93,7 +139,7 @@ export function ChatPage() {
|
||||
<div className={styles.inputBar}>
|
||||
<input className={styles.input} value={text} onChange={(e) => setText(e.target.value)}
|
||||
placeholder="输入消息..." onKeyDown={(e) => e.key === 'Enter' && handleSend()} />
|
||||
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}>↑</button>
|
||||
<button className={styles.sendBtn} onClick={handleSend}>↑</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user