From 9d384dc6fb266e50bc94f817131b1ad14d38ba82 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Fri, 22 May 2026 14:51:02 +0800 Subject: [PATCH] feat: add SignalR real-time chat to doctor and patient frontends --- frontend-doctor/package-lock.json | 176 ++++++++++++++++- frontend-doctor/package.json | 1 + .../src/pages/consultations/ChatPage.tsx | 75 +++++++- frontend-patient/package-lock.json | 180 +++++++++++++++++- frontend-patient/package.json | 1 + .../src/pages/services/ChatPage.tsx | 104 +++++++--- 6 files changed, 496 insertions(+), 41 deletions(-) diff --git a/frontend-doctor/package-lock.json b/frontend-doctor/package-lock.json index 8c22209..8b6ebcd 100644 --- a/frontend-doctor/package-lock.json +++ b/frontend-doctor/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend-doctor", "version": "0.0.0", "dependencies": { + "@microsoft/signalr": "^10.0.0", "dayjs": "^1.11.20", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", @@ -574,6 +575,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1224,6 +1238,18 @@ } } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1674,6 +1700,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1712,6 +1756,16 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2355,6 +2409,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.44", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", @@ -2491,16 +2565,33 @@ "node": ">= 0.8.0" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -2560,6 +2651,12 @@ "react-dom": ">=18" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -2672,6 +2769,27 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2749,6 +2867,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2790,6 +2917,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", @@ -2868,6 +3005,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2894,6 +3047,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend-doctor/package.json b/frontend-doctor/package.json index f5af741..c1b133f 100644 --- a/frontend-doctor/package.json +++ b/frontend-doctor/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@microsoft/signalr": "^10.0.0", "dayjs": "^1.11.20", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", diff --git a/frontend-doctor/src/pages/consultations/ChatPage.tsx b/frontend-doctor/src/pages/consultations/ChatPage.tsx index efd15b5..fe4d035 100644 --- a/frontend-doctor/src/pages/consultations/ChatPage.tsx +++ b/frontend-doctor/src/pages/consultations/ChatPage.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { useParams } from 'react-router-dom'; +import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr'; import { api } from '../../services/api-client'; interface Message { @@ -7,12 +8,24 @@ interface Message { content: string; contentType: string; createdAt: string; } +function getToken(): string { + try { + const raw = localStorage.getItem('doc_auth'); + if (!raw) return ''; + const state = JSON.parse(raw); + return state?.state?.token ?? ''; + } catch { return ''; } +} + export function ChatPage() { const { id } = useParams<{ id: string }>(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); + const [connected, setConnected] = useState(false); const bottomRef = useRef(null); + const connRef = useRef(null); + // Load initial messages via HTTP useEffect(() => { if (!id) return; api.get(`/api/consultations/${id}/messages`) @@ -20,23 +33,69 @@ export function ChatPage() { .catch(() => {}); }, [id]); + // Set up SignalR connection + useEffect(() => { + if (!id) return; + + const conn = new HubConnectionBuilder() + .withUrl('http://localhost:5000/hubs/chat', { + accessTokenFactory: () => getToken(), + }) + .withAutomaticReconnect() + .build(); + + conn.on('ReceiveMessage', (msg: Message) => { + setMessages((prev) => { + // Dedup — guard against reconnection replay + if (prev.some((m) => m.id === msg.id)) return prev; + return [...prev, msg]; + }); + }); + + conn.onreconnected(() => { + conn.invoke('JoinConsultation', id).catch(() => {}); + }); + + conn.start() + .then(() => { + setConnected(true); + return conn.invoke('JoinConsultation', id); + }) + .catch(() => {}); + + connRef.current = conn; + + return () => { + if (conn.state === HubConnectionState.Connected) { + conn.invoke('LeaveConsultation', id).catch(() => {}); + } + conn.stop(); + }; + }, [id]); + + // Auto-scroll on new messages useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); - const handleSend = async () => { - if (!input.trim() || !id) return; + const handleSend = useCallback(async () => { + if (!input.trim() || !id || !connRef.current) return; + const text = input; + setInput(''); try { - const res = await api.post(`/api/consultations/${id}/messages`, { content: input }); - setMessages((prev) => [...prev, res.data]); - setInput(''); + await connRef.current.invoke('SendMessage', id, text); } catch { /* ignore */ } - }; + }, [input, id]); return (
-
+
在线问诊 +
diff --git a/frontend-patient/package-lock.json b/frontend-patient/package-lock.json index b6a7315..6ca090c 100644 --- a/frontend-patient/package-lock.json +++ b/frontend-patient/package-lock.json @@ -1,13 +1,14 @@ { - "name": "haruite-medical-demo", + "name": "health-manager-demo", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "haruite-medical-demo", + "name": "health-manager-demo", "version": "0.0.0", "dependencies": { + "@microsoft/signalr": "^10.0.0", "dayjs": "^1.11.20", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", @@ -550,6 +551,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1192,6 +1206,18 @@ } } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1648,6 +1674,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1686,6 +1730,16 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2323,6 +2377,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.44", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", @@ -2459,16 +2533,33 @@ "node": ">= 0.8.0" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -2528,6 +2619,12 @@ "react-dom": ">=18" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -2640,6 +2737,27 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2717,6 +2835,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2758,6 +2885,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", @@ -2836,6 +2973,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2862,6 +3015,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend-patient/package.json b/frontend-patient/package.json index 1adfb69..a2caba2 100644 --- a/frontend-patient/package.json +++ b/frontend-patient/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@microsoft/signalr": "^10.0.0", "dayjs": "^1.11.20", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", diff --git a/frontend-patient/src/pages/services/ChatPage.tsx b/frontend-patient/src/pages/services/ChatPage.tsx index e8439d7..6304496 100644 --- a/frontend-patient/src/pages/services/ChatPage.tsx +++ b/frontend-patient/src/pages/services/ChatPage.tsx @@ -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(null); const [consultation, setConsultation] = useState(null); const [messages, setMessages] = useState([]); const [text, setText] = useState(''); - const [sending, setSending] = useState(false); + const [connected, setConnected] = useState(false); const bottomRef = useRef(null); + const connRef = useRef(null); const initRef = useRef(false); - const loadMessages = useCallback((cid: string) => { - api.get(`/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('/api/consultations').then((res) => { const existing = (res.data as Record[]).find( (c) => c.doctorId === doc.id && c.status === 'active' ); if (existing) { setConsultation(existing as unknown as Consultation); - loadMessages((existing as Record).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(`/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 (
- + + } + />
{messages.length === 0 && (
@@ -93,7 +139,7 @@ export function ChatPage() {
setText(e.target.value)} placeholder="输入消息..." onKeyDown={(e) => e.key === 'Enter' && handleSend()} /> - +
);