147 lines
4.9 KiB
TypeScript
147 lines
4.9 KiB
TypeScript
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 [connected, setConnected] = useState(false);
|
||
const bottomRef = useRef<HTMLDivElement>(null);
|
||
const connRef = useRef<HubConnection | null>(null);
|
||
const initRef = useRef(false);
|
||
|
||
// Init consultation once
|
||
useEffect(() => {
|
||
if (initRef.current) return;
|
||
initRef.current = true;
|
||
|
||
consultationService.getDoctors().then((docs) => {
|
||
if (docs.length > 0) {
|
||
const doc = docs[0];
|
||
setDoctor(doc);
|
||
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);
|
||
} else {
|
||
consultationService.startConsultation(doc.id, '在线咨询').then((c) => {
|
||
setConsultation(c);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
// 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]);
|
||
|
||
const handleSend = useCallback(async () => {
|
||
if (!text.trim() || !consultation?.id || !connRef.current) return;
|
||
const msgText = text;
|
||
setText('');
|
||
try {
|
||
await connRef.current.invoke('SendMessage', consultation.id, msgText);
|
||
} catch { /* ignore */ }
|
||
}, [text, consultation?.id]);
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<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 }}>
|
||
您好,我是{doctor?.name || '医生'},请问有什么可以帮您?
|
||
</div>
|
||
)}
|
||
{messages.map((msg) => (
|
||
<div key={msg.id} className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}>
|
||
<div className={styles.bubbleContent}>{msg.content}</div>
|
||
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
|
||
</div>
|
||
))}
|
||
<div ref={bottomRef} />
|
||
</div>
|
||
<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}>↑</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|