diff --git a/.env.development b/.env.development
index 41c0026..1b0da2e 100644
--- a/.env.development
+++ b/.env.development
@@ -1,3 +1,5 @@
+# 获取服务器信息
VITE_USE_MOCK=true
# VITE_API_BASE_URL=http://localhost:5173
-VITE_API_BASE_URL=http://127.0.0.1:8080
+VITE_API_BASE_URL=http://127.0.0.1:8082
+VITE_WS_URL=ws://127.0.0.1:8082
\ No newline at end of file
diff --git a/.env.production b/.env.production
index 7bae5d3..da44451 100644
--- a/.env.production
+++ b/.env.production
@@ -1,4 +1,4 @@
VITE_USE_MOCK=false
# VITE_API_BASE_URL=http://localhost:5173
-VITE_API_BASE_URL=http://127.0.0.1:8080
-# http://127.0.0.1:8081
\ No newline at end of file
+VITE_API_BASE_URL=http://127.0.0.1:8082
+# http://127.0.0.1:8081
diff --git a/src/components/SimpleKeyboard.vue b/src/components/SimpleKeyboard.vue
index 42f4dbc..f088c43 100644
--- a/src/components/SimpleKeyboard.vue
+++ b/src/components/SimpleKeyboard.vue
@@ -18,7 +18,7 @@ export default {
},
hideKeyBoard: {
type: Function,
- default: () => {},
+ default: () => { },
},
},
data: () => ({
@@ -60,7 +60,7 @@ export default {
-
\ No newline at end of file
diff --git a/src/pages/Index/Regular/Consumables.vue b/src/pages/Index/Regular/Consumables.vue
index 71ec318..34105a2 100644
--- a/src/pages/Index/Regular/Consumables.vue
+++ b/src/pages/Index/Regular/Consumables.vue
@@ -13,12 +13,12 @@
-
+
+ :bufferBig="bufferBig" :emergencyInfo="emergencyInfo" :wasteStatus="wasteStatus"
+ @loadConsumables="handleIsLoad" @unloadConsumables="handleIsUnload" @updateTipNum="updateTipNum" />
@@ -48,8 +48,16 @@ import {
LiquidState,
Tube,
} from '../../../types/Index'
+import { createWebSocket } from '../../../websocket/socket'
+import type { SensorStateMessage } from '../../../websocket/socket';
+const socket = createWebSocket("/api/v1/app/ws/state")
+
const consumableStore = useConsumablesStore()
const emergencyStore = useEmergencyStore()
+// 温度状态
+const currentTemperature = ref(40);
+// 废料区状态
+const wasteStatus = ref(false)
// 添加加载进度状态
const loadingProgress = ref(0)
// 父组件状态
@@ -149,18 +157,22 @@ const getEmergencyInfo = async () => {
emergencyInfo.value = res.data.tube
emergencyStore.setInfo(res.data.tube)
}
-let socket: WebSocket | null = null
//使用websocket保证数据的实时性
const startWebSocket = () => {
- socket = new WebSocket('ws://localhost:8080/ws')
- socket.onmessage = (event) => {
- const res = JSON.parse(event.data)
- moveLiquids.value = res.moveLiquidInfo
- plates.value = res.plateInfo
- bufferLittles.value = res.bufferLittleInfo
- bufferBig.value = res.bufferBigInfo
- }
+ socket.connect()
}
+// 处理传感器状态消息
+const handleSensorState = (data: SensorStateMessage['data']) => {
+ // 更新温度值(这里使用孵育盒温度)
+ currentTemperature.value = data.incubateBoxTemperature;
+ wasteStatus.value = data.wasteBinFullFlag
+
+ // 可以添加温度异常处理逻辑
+ if (currentTemperature.value > 40) {
+ console.warn('温度过高警告');
+ // 可以在这里添加其他警告逻辑
+ }
+};
// 使用事件总线更新状态
const updatePlatesAndBuffers = ({
value,
@@ -179,14 +191,16 @@ const updatePlatesAndBuffers = ({
onMounted(() => {
eventBus.on('confirm', updatePlatesAndBuffers)
startWebSocket()
+ socket.subscribe
('SensorState', handleSensorState);
getEmergencyInfo()
})
onBeforeUnmount(() => {
// 清除事件总线的监听
eventBus.off('confirm', updatePlatesAndBuffers)
- if (socket) {
- socket.close()
+ if (socket !== null) {
+ socket.disconnect() // 断开连接
}
+ socket.unsubscribe('SensorState', handleSensorState);
})
// 在组件激活时恢复状态
onActivated(() => {
@@ -202,7 +216,7 @@ const handleIsLoad = async () => {
// 创建进度动画
const startTime = Date.now()
- const duration = 3000 // 3秒
+ const duration = 30000 // 30秒
const updateProgress = () => {
const elapsed = Date.now() - startTime
diff --git a/src/pages/Index/Regular/Emergency.vue b/src/pages/Index/Regular/Emergency.vue
index 2807547..3374518 100644
--- a/src/pages/Index/Regular/Emergency.vue
+++ b/src/pages/Index/Regular/Emergency.vue
@@ -83,10 +83,8 @@
-
+
-
@@ -99,7 +97,6 @@ import { nanoid } from 'nanoid';
import { insertEmergency } from '../../../services/Index/index';
import { useEmergencyStore, useConsumablesStore } from '../../../store';
import type { ReactionPlate, AddEmergencyInfo, Subtank } from '../../../types/Index';
-import { Keyboard } from '../../../components';
const consumableStore = useConsumablesStore();
const emergencyStore = useEmergencyStore();
@@ -273,9 +270,11 @@ const currentInputValue = ref(''); // 当前键盘绑定的值
const showKeyboard = (field: string) => {
if (field === 'sampleBarcode') {
+ console.log("sampleBarcode");
currentInputField.value = 'sampleBarcode';
currentInputValue.value = emergencyPosition.value.sampleBarcode || ''; // 确保清空上一次输入的值
} else {
+ console.log("userid");
currentInputField.value = 'userid';
currentInputValue.value = emergencyPosition.value.userid || ''; // 同上
}
@@ -283,9 +282,11 @@ const showKeyboard = (field: string) => {
};
const handleKeyboardInput = (value: string) => {
if (currentInputField.value === 'sampleBarcode') {
+ console.log("sampleBarcode");
emergencyPosition.value.sampleBarcode = value;
currentInputValue.value = value; // 更新输入框的值
} else if (currentInputField.value === 'userid') {
+ console.log("userid");
emergencyPosition.value.userid = value;
currentInputValue.value = value; // 更新输入框的值
}
@@ -305,7 +306,7 @@ const hideKeyboard = () => {
margin: 0;
padding: 0;
position: relative;
- height: 1775px;
+ height: 100%;
width: 100%;
background-color: #f4f6f9;
box-sizing: border-box;
@@ -327,7 +328,7 @@ const hideKeyboard = () => {
}
.page-header {
- width: 1200px;
+ width: 100%;
height: 100px;
display: flex;
align-items: center;
@@ -392,7 +393,7 @@ const hideKeyboard = () => {
}
.emergency-info {
- width: 1200px;
+ width: 100%;
height: auto;
margin-top: 20px;
border-radius: 8px;
@@ -427,7 +428,7 @@ const hideKeyboard = () => {
padding: 8px 5px;
border: 1px solid #ccc;
border-radius: 4px;
- width: 98%;
+ width: 80%;
font-size: 32px;
transition: box-shadow 0.2s ease;
@@ -456,7 +457,7 @@ const hideKeyboard = () => {
justify-content: center;
.open-button {
- width: 100%;
+ width: 80%;
height: 70%;
background-color: #4caf50;
/* 开启状态颜色:绿色 */
@@ -470,7 +471,7 @@ const hideKeyboard = () => {
}
.close-button {
- width: 100%;
+ width: 80%;
height: 70%;
background-color: #f44336;
/* 关闭状态颜色:红色 */
@@ -742,7 +743,7 @@ const hideKeyboard = () => {
}
.emergency-controller {
- width: 1200px;
+ width: 100%;
height: 120px;
display: flex;
margin-top: 20px;
@@ -784,7 +785,7 @@ const hideKeyboard = () => {
/* 宽度为视口宽度,确保不超出 */
max-width: 100%;
/* 最多占满父容器的宽度 */
- height: 600px;
+ height: 400px;
background-color: #f0f0f0;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
diff --git a/src/pages/Index/Regular/Running.vue b/src/pages/Index/Regular/Running.vue
index ea2ef44..2b43c0f 100644
--- a/src/pages/Index/Regular/Running.vue
+++ b/src/pages/Index/Regular/Running.vue
@@ -41,7 +41,7 @@
- 添加急诊
+ 急诊
@@ -208,7 +208,6 @@ const confirmEmergency = () => {
}
//确认结果
const confirmEmergencyWarn = (val: any) => {
- console.log('点击确认的值是', val)
isDialogVisible.value = false
}
//获取路由参数
@@ -530,10 +529,10 @@ onUnmounted(() => {
#running-container {
display: flex;
flex-direction: column;
- justify-content: space-between;
+ justify-content: space-around;
align-items: center;
background-color: #f9fafb;
- height: 93.5vh;
+ height: 92vh;
/* 更柔和的背景色 */
// 孵育盘
@@ -642,7 +641,7 @@ onUnmounted(() => {
.consumables-container {
width: 100%;
box-sizing: border-box;
- padding: 20px 30px;
+ padding: 0 30px;
display: flex;
flex-direction: column;
background-color: #ffffff;
@@ -654,13 +653,9 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: flex-start;
- margin-bottom: 30px;
- gap: 30px;
// 急诊按钮
.emergency-button {
- width: 140px;
- height: 140px;
background: linear-gradient(135deg, #ff6b6b, #ff4757);
border-radius: 20px;
display: flex;
@@ -689,7 +684,7 @@ onUnmounted(() => {
gap: 20px;
.tube-project-tab {
- width: 120px;
+ width: 100px;
height: 50px;
background: linear-gradient(to right, #e3f2fd, #bbdefb);
border-radius: 12px;
@@ -720,7 +715,6 @@ onUnmounted(() => {
.row-second {
display: grid;
grid-template-columns: 3fr 4fr 2fr 1fr;
- gap: 20px;
padding: 10px 0;
.tips-and-big-buffer {
@@ -729,8 +723,8 @@ onUnmounted(() => {
gap: 20px;
.tips-item {
- width: 160px;
- height: 110px;
+ width: 200px;
+ height: 137px;
border-radius: 16px;
display: flex;
align-items: center;
@@ -759,9 +753,11 @@ onUnmounted(() => {
}
}
+ .buffer-grid {
+ margin-top: -10px;
+ }
+
.waste-area {
- width: 100px;
- height: 380px;
border-radius: 25px;
display: flex;
flex-direction: column;
@@ -772,7 +768,7 @@ onUnmounted(() => {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
.waste-text {
- font-size: 36px;
+ font-size: 28px;
font-weight: 700;
color: #ffffff;
writing-mode: vertical-rl;
@@ -788,16 +784,16 @@ onUnmounted(() => {
}
// 添加响应式设计
- @media screen and (max-width: 1366px) {
- padding: 15px 20px;
+ @media screen and (max-width: 800px) {
+ padding: 15px 20px; // 修改padding值以适应小屏幕
.row-first {
margin-bottom: 20px;
gap: 20px;
.emergency-button {
- width: 120px;
- height: 120px;
+ width: 200px;
+ height: 100px;
span {
font-size: 28px;
@@ -809,8 +805,8 @@ onUnmounted(() => {
gap: 15px;
.tips-item {
- width: 220px;
- height: 160px;
+ width: 180px;
+ height: 120px;
.tip-text {
font-size: 32px;
@@ -819,10 +815,10 @@ onUnmounted(() => {
.waste-area {
width: 90px;
- height: 340px;
+ height: 285px;
.waste-text {
- font-size: 32px;
+ font-size: 24px;
}
}
}
@@ -850,6 +846,25 @@ onUnmounted(() => {
text-align: center;
max-width: 600px;
animation: slideIn 0.3s ease;
+ position: relative;
+
+ .alert-icon {
+ .icon {
+ width: 80px;
+ height: 80px;
+ margin: 0 auto;
+ }
+
+ span {
+ font-size: 32px;
+ position: absolute;
+ top: 30%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: #fff;
+ }
+ }
+
.alert-title {
font-size: 36px;
diff --git a/src/pages/Index/components/Consumables/MoveLiquidArea.vue b/src/pages/Index/components/Consumables/MoveLiquidArea.vue
index 90f2f32..3fcd836 100644
--- a/src/pages/Index/components/Consumables/MoveLiquidArea.vue
+++ b/src/pages/Index/components/Consumables/MoveLiquidArea.vue
@@ -128,6 +128,7 @@ const props = defineProps({
type: Object as () => Tube,
},
tempTipNum: Array,
+ wasteStatus: Boolean,
})
const emit = defineEmits([
'loadConsumables',
@@ -146,7 +147,7 @@ const handleIsUnload = () => {
}
const activeTab = ref(0)
// 废料区状态判断
-const wasteStatus = ref(false)
+const wasteStatus = ref(props.wasteStatus)
//设置轮询时间间隔
const pollInterVal = 3000
//声明id接口
diff --git a/src/pages/Index/components/Consumables/SpttingPlates.vue b/src/pages/Index/components/Consumables/SpttingPlates.vue
index 45104f2..3928405 100644
--- a/src/pages/Index/components/Consumables/SpttingPlates.vue
+++ b/src/pages/Index/components/Consumables/SpttingPlates.vue
@@ -44,14 +44,19 @@
import Plate from './Plate.vue';
import { ref, onMounted, onBeforeUnmount, defineProps } from 'vue';
import ChangeNum from './ChangeNum.vue';
+
const props = defineProps({
plates: {
type: Array,
default: () => []
+ },
+ temperature: {
+ type: Number,
+ default: 40
}
})
// 温度状态
-const currentTemperature = ref(40);
+const currentTemperature = ref(props.temperature);
const changeNumRef = ref()
const changeNumber = (plate, index) => {
diff --git a/src/pages/Index/components/Running/LittleBufferDisplay.vue b/src/pages/Index/components/Running/LittleBufferDisplay.vue
index 1f6b584..978c7f3 100644
--- a/src/pages/Index/components/Running/LittleBufferDisplay.vue
+++ b/src/pages/Index/components/Running/LittleBufferDisplay.vue
@@ -32,7 +32,7 @@ const getFillStyle = (item: BottleGroup) => {
.buffer-item {
position: relative;
width: 110px;
- height: 155px;
+ height: 137px;
background-color: #d3d3d3;
border-radius: 10px;
display: flex;
diff --git a/src/pages/Index/components/Running/PlateDisplay.vue b/src/pages/Index/components/Running/PlateDisplay.vue
index 6d4bf91..8bc16ff 100644
--- a/src/pages/Index/components/Running/PlateDisplay.vue
+++ b/src/pages/Index/components/Running/PlateDisplay.vue
@@ -40,13 +40,13 @@ const getPercentage = (num: number) => {
justify-content: space-between;
.project-info {
- width: 160px;
- height: 46px;
+ width: 120px;
+ height: 40px;
border-radius: 40px;
flex: 1;
.project-name {
- font-size: 20px;
+ font-size: 26px;
font-weight: 700;
color: white;
}
@@ -54,7 +54,7 @@ const getPercentage = (num: number) => {
.project-count {
width: 90px;
- height: 46px;
+ height: 40px;
border-radius: 40px;
background-color: #1485ee;
align-items: center;
diff --git a/src/pages/Index/components/Running/SampleDisplay.vue b/src/pages/Index/components/Running/SampleDisplay.vue
index 2d9515c..ba53646 100644
--- a/src/pages/Index/components/Running/SampleDisplay.vue
+++ b/src/pages/Index/components/Running/SampleDisplay.vue
@@ -122,12 +122,30 @@ const showPopover = (index: number) => {
.samples {
display: flex;
align-items: center;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
background-color: #fafafa;
padding: 10px;
+ &::-webkit-scrollbar {
+ height: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 3px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #c1c1c1;
+ border-radius: 3px;
+
+ &:hover {
+ background: #a8a8a8;
+ }
+ }
+
.item-detail {
- width: 300px;
+ width: 100%;
height: auto;
display: flex;
flex-direction: column;
@@ -136,8 +154,8 @@ const showPopover = (index: number) => {
}
.sample-item {
- width: 85px;
- height: 85px;
+ width: 70px;
+ height: 70px;
border-radius: 50%;
border: 2px solid black;
display: flex;
diff --git a/src/utils/axios.ts b/src/utils/axios.ts
index 8a4bbab..e9ffcbb 100644
--- a/src/utils/axios.ts
+++ b/src/utils/axios.ts
@@ -4,11 +4,12 @@ import axios, {
InternalAxiosRequestConfig,
AxiosHeaders,
} from 'axios'
-
+import { getServerInfo } from './getServerInfo'
import { eventBus } from '../eventBus'
+const serverInfo = getServerInfo()
// 创建 Axios 实例
const apiClient: AxiosInstance = axios.create({
- baseURL: import.meta.env.VITE_API_BASE_URL,
+ baseURL: serverInfo.httpUrl, // 设置请求的根路径
timeout: 10000,
headers: {
'Content-Type': 'application/json',
diff --git a/src/utils/getServerInfo.ts b/src/utils/getServerInfo.ts
new file mode 100644
index 0000000..9b98eb1
--- /dev/null
+++ b/src/utils/getServerInfo.ts
@@ -0,0 +1,22 @@
+export function getServerInfo() {
+ // 获取当前页面的 URL 对象
+ const url = new URL(window.location.href)
+
+ // 获取主机名(IP 或域名)和端口号
+ const host = url.hostname // 例如: "192.168.1.100" 或 "localhost"
+ const port = '8082' // 使用固定的后端端口
+
+ // 构建 WebSocket URL
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ const wsUrl = `${wsProtocol}//${host}:${port}/api/v1/app/ws/state`
+
+ // 构建 HTTP URL
+ const httpUrl = `${window.location.protocol}//${host}:${port}` // 例如: "http://192.168.1.100:8082" 或 "http://localhost:8082"
+
+ return {
+ wsUrl,
+ httpUrl,
+ host,
+ port,
+ }
+}
diff --git a/src/websocket/socket.ts b/src/websocket/socket.ts
new file mode 100644
index 0000000..39c2cfa
--- /dev/null
+++ b/src/websocket/socket.ts
@@ -0,0 +1,326 @@
+// src/websocket/socket.ts
+
+import { getServerInfo } from '../utils/getServerInfo'
+const serverInfo = getServerInfo()
+// 基础消息接口
+interface BaseMessage {
+ messageType: 'Report' // 消息类型
+ dataType: string // 数据类型
+ timestamp: number // 时间戳
+}
+
+// 耗材状态消息
+interface OptScanModuleStateMessage extends BaseMessage {
+ type: 'OptScanModuleState'
+ messageType: 'Report'
+ dataType: 'OptScanModuleState'
+ data: {
+ state: 'EMPTY' | 'OCCUPIED' // 状态
+ isErrorPlate: boolean // 是否为错误板
+ bloodType: 'WHOLE_BLOOD' | 'SEUM_OR_PLASMA' // 血液类型
+ sampleBarcode: string // 样本条码
+ userid: string // 用户ID
+ projInfo: ProjectInfo // 项目信息
+ sampleId: string // 样本ID
+ projId: number // 项目ID
+ }
+ timestamp: number
+}
+// 提示信息接口
+interface PromptInfo {
+ type: 'Warn' | 'Error'
+ info: string
+ detailInfos: string[]
+ stackInfo: null
+}
+
+// 应用事件消息接口
+interface AppEventMessage extends BaseMessage {
+ type: 'AppEvent'
+ messageType: 'Report'
+ dataType: 'AppEvent'
+ data: {
+ typeName: 'AppPromptEvents'
+ timestamp: number
+ prompt: PromptInfo[]
+ }
+ timestamp: number
+}
+// 设备工作状态消息
+interface DeviceWorkStateMessage extends BaseMessage {
+ type: 'DeviceWorkState'
+ messageType: 'Report'
+ dataType: 'DeviceWorkState'
+ data: {
+ workState: 'IDLE' | 'RUNNING' | 'ERROR' | 'PAUSE' | 'STOP' // 设备工作状态
+ fatalErrorFlag: boolean // 致命错误标志
+ ecodeList: string[] // 错误代码列表
+ pending: boolean // 待处理状态
+ }
+ timestamp: number // 时间戳
+}
+
+// 急诊位状态消息
+interface EmergencyPosStateMessage extends BaseMessage {
+ type: 'EmergencyPosState'
+ messageType: 'Report'
+ dataType: 'EmergencyPosState'
+ data: {
+ tube: {
+ sampleId: string | null // 样本ID
+ pos: number // 位置
+ isHighTube: boolean // 是否为高试管
+ isEmergency: boolean // 是否为急诊
+ bloodType: 'WHOLE_BLOOD' | 'SEUM_OR_PLASMA' // 血液类型
+ sampleBarcode: string // 样本条码
+ userid: string // 用户ID
+ projInfo: ProjectInfo[] // 项目信息列表
+ projIds: number[] // 项目ID列表
+ state: 'EMPTY' | 'OCCUPIED' // 状态
+ errors: string[] // 错误信息列表
+ }
+ }
+ timestamp: number
+}
+
+// 试管信息接口
+interface Tube {
+ sampleId: string | null // 样本ID
+ pos: number // 位置
+ isHighTube: boolean // 是否为高试管
+ isEmergency: boolean // 是否为急诊
+ bloodType: 'WHOLE_BLOOD' | 'SEUM_OR_PLASMA' // 血液类型
+ sampleBarcode: string // 样本条码
+ userid: string // 用户ID
+ projInfo: ProjectInfo[] // 项目信息列表
+ projIds: number[] // 项目ID列表
+ state: 'EMPTY' | 'OCCUPIED' // 状态
+ errors: string[] // 错误信息列表
+}
+
+// 试管架状态消息
+interface TubeHolderStateMessage extends BaseMessage {
+ type: 'TubeHolderState'
+ messageType: 'Report'
+ dataType: 'TubeHolderState'
+ data: {
+ tubeHolderType: 'BloodTube' // 试管架类型
+ tubes: Tube[] // 试管列表
+ state: 'IDLE' | 'RUNNING' | 'ERROR' // 试管架状态
+ }
+ timestamp: number
+}
+
+// 传感器状态消息
+interface SensorStateMessage extends BaseMessage {
+ type: 'SensorState'
+ messageType: 'Report'
+ dataType: 'SensorState'
+ data: {
+ pboxTemperature: number // P盒温度
+ incubateBoxTemperature: number // 孵育盒温度
+ wasteBinFullFlag: boolean // 废物箱满标志
+ }
+ timestamp: number
+}
+
+// 项目信息接口
+interface ProjectInfo {
+ projId: number
+ projName: string
+ projShortName: string
+ color: string
+}
+
+// 子槽位信息接口
+interface Subtank {
+ pos: string // 位置编号 SPACE01-SPACE20
+ state: 'EMPTY' | 'OCCUPIED' // 槽位状态
+ bloodType: 'WHOLE_BLOOD' | 'SEUM_OR_PLASMA' // 血液类型
+ sampleBarcode: string // 样本条码
+ userid: string // 用户ID
+ projInfo: ProjectInfo // 项目信息
+ sampleId: string // 样本ID
+ projId: number // 项目ID
+ startIncubatedTime: number // 开始孵育时间
+ incubatedTimeSec: number // 孵育时间(秒)
+ errors: string[] // 错误信息列表
+}
+
+// 孵育板状态消息
+interface IncubationPlateStateMessage extends BaseMessage {
+ type: 'IncubationPlateState'
+ messageType: 'Report'
+ dataType: 'IncubationPlateState'
+ data: {
+ subtanks: Subtank[] // 20个子槽位信息
+ }
+ timestamp: number
+}
+// 消息类型联合
+type WebSocketMessage =
+ | OptScanModuleStateMessage
+ | DeviceWorkStateMessage
+ | EmergencyPosStateMessage
+ | TubeHolderStateMessage
+ | SensorStateMessage
+ | IncubationPlateStateMessage
+ | AppEventMessage // 添加这一行
+
+// 消息处理器类型
+type MessageHandler = (data: T['data']) => void
+
+class WebSocketClient {
+ private ws: WebSocket | null = null
+ private url: string
+ private reconnectAttempts: number = 0
+ private maxReconnectAttempts: number = 5
+ private reconnectInterval: number = 3000
+
+ // 使用类型安全的消息处理器映射
+ private messageHandlers: Map<
+ WebSocketMessage['type'],
+ Set>
+ > = new Map()
+
+ constructor(url: string) {
+ this.url = url
+ }
+
+ // 类型安全的订阅方法
+ subscribe(
+ messageType: T['type'],
+ handler: MessageHandler,
+ ): void {
+ if (!this.messageHandlers.has(messageType)) {
+ this.messageHandlers.set(messageType, new Set())
+ }
+ this.messageHandlers.get(messageType)?.add(handler)
+ }
+
+ // 类型安全的取消订阅方法
+ unsubscribe(
+ messageType: T['type'],
+ handler: MessageHandler,
+ ): void {
+ this.messageHandlers.get(messageType)?.delete(handler)
+ }
+
+ private handleMessage(message: WebSocketMessage): void {
+ const handlers = this.messageHandlers.get(message.type)
+
+ if (handlers) {
+ handlers.forEach((handler) => {
+ try {
+ handler(message.data)
+ } catch (error) {
+ console.error(`处理 ${message.type} 消息时出错:`, error)
+ }
+ })
+ }
+ }
+
+ // 连接 WebSocket
+ connect(): void {
+ try {
+ this.ws = new WebSocket(serverInfo.wsUrl)
+ this.bindEvents()
+ } catch (error) {
+ console.error('WebSocket 连接失败:', error)
+ this.reconnect()
+ }
+ }
+
+ // 绑定事件
+ private bindEvents(): void {
+ if (!this.ws) return
+
+ // 连接建立时的处理
+ this.ws.onopen = () => {
+ console.log('WebSocket 连接已建立')
+ this.reconnectAttempts = 0 // 重置重连次数
+ }
+
+ // 接收消息的处理
+ this.ws.onmessage = (event: MessageEvent) => {
+ try {
+ const data = JSON.parse(event.data)
+ this.handleMessage(data)
+ } catch (error) {
+ console.error('消息解析错误:', error)
+ }
+ }
+
+ // 连接关闭时的处理
+ this.ws.onclose = () => {
+ console.log('WebSocket 连接已关闭')
+ this.reconnect()
+ }
+
+ // 错误处理
+ this.ws.onerror = (error) => {
+ console.error('WebSocket 错误:', error)
+ }
+ }
+
+ // 重连机制
+ private reconnect(): void {
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.log('达到最大重连次数,停止重连')
+ return
+ }
+
+ setTimeout(() => {
+ console.log(`尝试第 ${this.reconnectAttempts + 1} 次重连...`)
+ this.reconnectAttempts++
+ this.connect()
+ }, this.reconnectInterval)
+ }
+
+ // 关闭连接
+ disconnect(): void {
+ if (this.ws) {
+ this.ws.close()
+ this.ws = null
+ }
+ }
+}
+
+// 创建单例
+let wsInstance: WebSocketClient | null = null
+
+// 导出消息类型
+export type {
+ WebSocketMessage,
+ OptScanModuleStateMessage,
+ DeviceWorkStateMessage,
+ EmergencyPosStateMessage,
+ TubeHolderStateMessage,
+ SensorStateMessage,
+ IncubationPlateStateMessage,
+ ProjectInfo,
+ Subtank,
+ AppEventMessage,
+}
+
+// 导出 WebSocket 客户端
+export const createWebSocket = (url: string): WebSocketClient => {
+ if (!wsInstance) {
+ wsInstance = new WebSocketClient(url)
+ }
+ return wsInstance
+}
+
+// 使用示例:
+/*
+import { createWebSocket } from './websocket/socket';
+
+// 创建 WebSocket 连接
+const ws = createWebSocket('ws://your-websocket-server-url');
+ws.connect();
+
+// 在组件销毁时断开连接
+onUnmounted(() => {
+ ws.disconnect();
+});
+*/