|
@@ -49,7 +49,7 @@
|
|
|
type="primary"
|
|
type="primary"
|
|
|
size="large"
|
|
size="large"
|
|
|
@click="openAddElderDialog"
|
|
@click="openAddElderDialog"
|
|
|
- style="margin: 5px auto 0 auto; width: 95%"
|
|
|
|
|
|
|
+ style="width: 95%; margin: 5px auto 0"
|
|
|
>
|
|
>
|
|
|
<Icon icon="ep:plus" />
|
|
<Icon icon="ep:plus" />
|
|
|
<span>添加长者</span>
|
|
<span>添加长者</span>
|
|
@@ -90,7 +90,7 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="elderly-info-large">
|
|
<div class="elderly-info-large">
|
|
|
<h3>{{ elderly.name }}</h3>
|
|
<h3>{{ elderly.name }}</h3>
|
|
|
- <p>{{ elderly.age || 0 }}岁 • {{ genderMap[elderly.gender] || '未知' }}</p>
|
|
|
|
|
|
|
+ <p> {{ elderly.age || 0 }}岁 • {{ genderMap[elderly.gender] || '未知' }} </p>
|
|
|
<div class="health-status-large">
|
|
<div class="health-status-large">
|
|
|
<div class="status-dot" :class="getHealthStatusClass(elderly.healthText)"></div>
|
|
<div class="status-dot" :class="getHealthStatusClass(elderly.healthText)"></div>
|
|
|
<span>{{ elderly.healthText || '未知' }}</span>
|
|
<span>{{ elderly.healthText || '未知' }}</span>
|
|
@@ -164,11 +164,13 @@
|
|
|
<Icon :icon="getDeviceInfo(device).icon" />
|
|
<Icon :icon="getDeviceInfo(device).icon" />
|
|
|
</div>
|
|
</div>
|
|
|
<div class="device-info-large">
|
|
<div class="device-info-large">
|
|
|
- <h4>{{
|
|
|
|
|
- deviceTypeOptions?.find(
|
|
|
|
|
- (v: DeviceTypeVO) => v.deviceType == device.deviceType
|
|
|
|
|
- )?.deviceTypeName || '-'
|
|
|
|
|
- }}</h4>
|
|
|
|
|
|
|
+ <h4>
|
|
|
|
|
+ {{
|
|
|
|
|
+ deviceTypeOptions?.find(
|
|
|
|
|
+ (v: DeviceTypeVO) => v.deviceType == device.deviceType
|
|
|
|
|
+ )?.deviceTypeName || '-'
|
|
|
|
|
+ }}
|
|
|
|
|
+ </h4>
|
|
|
<p>{{ device.installPosition }}</p>
|
|
<p>{{ device.installPosition }}</p>
|
|
|
</div>
|
|
</div>
|
|
|
<el-tag :type="getDeviceStatusInfo(device).tagType">
|
|
<el-tag :type="getDeviceStatusInfo(device).tagType">
|
|
@@ -284,14 +286,17 @@
|
|
|
<Icon :icon="getDeviceInfo(deviceDetail).icon" />
|
|
<Icon :icon="getDeviceInfo(deviceDetail).icon" />
|
|
|
</div>
|
|
</div>
|
|
|
<div class="device-detail-info">
|
|
<div class="device-detail-info">
|
|
|
- <h3>{{
|
|
|
|
|
- deviceTypeOptions?.find((v: DeviceTypeVO) => v.deviceType == deviceDetail?.deviceType)
|
|
|
|
|
- ?.deviceTypeName || '-'
|
|
|
|
|
- }}</h3>
|
|
|
|
|
|
|
+ <h3>
|
|
|
|
|
+ {{
|
|
|
|
|
+ deviceTypeOptions?.find(
|
|
|
|
|
+ (v: DeviceTypeVO) => v.deviceType == deviceDetail?.deviceType
|
|
|
|
|
+ )?.deviceTypeName || '-'
|
|
|
|
|
+ }}
|
|
|
|
|
+ </h3>
|
|
|
<p>设备类型: {{ deviceDetail.deviceType }}</p>
|
|
<p>设备类型: {{ deviceDetail.deviceType }}</p>
|
|
|
<p>安装位置: {{ deviceDetail.installPosition }}</p>
|
|
<p>安装位置: {{ deviceDetail.installPosition }}</p>
|
|
|
- <p
|
|
|
|
|
- >设备状态:
|
|
|
|
|
|
|
+ <p>
|
|
|
|
|
+ 设备状态:
|
|
|
<el-tag :type="textStatusMap[deviceDetail.status]">
|
|
<el-tag :type="textStatusMap[deviceDetail.status]">
|
|
|
{{ deviceDetail.status }}
|
|
{{ deviceDetail.status }}
|
|
|
</el-tag>
|
|
</el-tag>
|
|
@@ -595,7 +600,11 @@ const deviceTypeMap: Record<
|
|
|
['health_band']: { name: '健康监测手环', icon: 'mdi:watch-variant', color: '#ff6b6b' },
|
|
['health_band']: { name: '健康监测手环', icon: 'mdi:watch-variant', color: '#ff6b6b' },
|
|
|
['smart_mattress']: { name: '智能床垫', icon: 'mdi:bed-queen', color: '#4ecdc4' },
|
|
['smart_mattress']: { name: '智能床垫', icon: 'mdi:bed-queen', color: '#4ecdc4' },
|
|
|
['security_camera']: { name: '安防摄像头', icon: 'mdi:cctv', color: '#45aaf2' },
|
|
['security_camera']: { name: '安防摄像头', icon: 'mdi:cctv', color: '#45aaf2' },
|
|
|
- ['blood_pressure_monitor']: { name: '血压监测仪', icon: 'mdi:heart-pulse', color: '#a55eea' },
|
|
|
|
|
|
|
+ ['blood_pressure_monitor']: {
|
|
|
|
|
+ name: '血压监测仪',
|
|
|
|
|
+ icon: 'mdi:heart-pulse',
|
|
|
|
|
+ color: '#a55eea'
|
|
|
|
|
+ },
|
|
|
['emergency_button']: {
|
|
['emergency_button']: {
|
|
|
name: '紧急呼叫按钮',
|
|
name: '紧急呼叫按钮',
|
|
|
icon: 'mdi:alarm-light',
|
|
icon: 'mdi:alarm-light',
|
|
@@ -603,7 +612,11 @@ const deviceTypeMap: Record<
|
|
|
},
|
|
},
|
|
|
['smoke_sensor']: { name: '烟雾传感器', icon: 'mdi:smoke-detector', color: '#26de81' },
|
|
['smoke_sensor']: { name: '烟雾传感器', icon: 'mdi:smoke-detector', color: '#26de81' },
|
|
|
['water_sensor']: { name: '水浸传感器', icon: 'mdi:water-alert', color: '#26de81' },
|
|
['water_sensor']: { name: '水浸传感器', icon: 'mdi:water-alert', color: '#26de81' },
|
|
|
- ['infrared_sensor']: { name: '人体红外传感器', icon: 'mdi:motion-sensor', color: '#26de81' },
|
|
|
|
|
|
|
+ ['infrared_sensor']: {
|
|
|
|
|
+ name: '人体红外传感器',
|
|
|
|
|
+ icon: 'mdi:motion-sensor',
|
|
|
|
|
+ color: '#26de81'
|
|
|
|
|
+ },
|
|
|
['door_sensor']: { name: '门磁传感器', icon: 'mdi:door-closed', color: '#26de81' },
|
|
['door_sensor']: { name: '门磁传感器', icon: 'mdi:door-closed', color: '#26de81' },
|
|
|
['gas_sensor']: { name: '燃气传感器', icon: 'mdi:gas-cylinder', color: '#26de81' },
|
|
['gas_sensor']: { name: '燃气传感器', icon: 'mdi:gas-cylinder', color: '#26de81' },
|
|
|
['temperature_sensor']: {
|
|
['temperature_sensor']: {
|
|
@@ -933,6 +946,7 @@ const removeDevice = (elderly: any, device: DetailDevice) => {
|
|
|
|
|
|
|
|
const getHealthIcon = (metric: HealthVO) => {
|
|
const getHealthIcon = (metric: HealthVO) => {
|
|
|
return healthIconMap[metric.name] || 'mdi:help-circle-outline'
|
|
return healthIconMap[metric.name] || 'mdi:help-circle-outline'
|
|
|
|
|
+ // return metric.icon || 'mdi:help-circle-outline'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const getDeviceInfo = (device: Device | DetailDevice) => {
|
|
const getDeviceInfo = (device: Device | DetailDevice) => {
|
|
@@ -943,6 +957,10 @@ const getDeviceInfo = (device: Device | DetailDevice) => {
|
|
|
color: '#a5b1c2'
|
|
color: '#a5b1c2'
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
|
|
+ // return (
|
|
|
|
|
+ // deviceTypeOptions.valur?.find((v: DeviceTypeVO) => v.deviceType == device.deviceType)?.icon ||
|
|
|
|
|
+ // 'mdi:help-circle-outline'
|
|
|
|
|
+ // )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const getDeviceStatusInfo = (
|
|
const getDeviceStatusInfo = (
|
|
@@ -1139,6 +1157,38 @@ onMounted(() => {
|
|
|
// 获取数据
|
|
// 获取数据
|
|
|
getStatistics()
|
|
getStatistics()
|
|
|
getElderList()
|
|
getElderList()
|
|
|
|
|
+
|
|
|
|
|
+ // 页面加载后自动连接
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ connect()
|
|
|
|
|
+ }, 1000)
|
|
|
|
|
+
|
|
|
|
|
+ // 页面可见性变化处理
|
|
|
|
|
+ // 页面可见性变化处理
|
|
|
|
|
+ document.addEventListener('visibilitychange', () => {
|
|
|
|
|
+ if (document.hidden) {
|
|
|
|
|
+ console.log('页面切换到后台')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log('页面回到前台')
|
|
|
|
|
+ // 检查连接和心跳状态
|
|
|
|
|
+ checkConnectionHealth()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ // 添加定期健康检查
|
|
|
|
|
+ setInterval(() => {
|
|
|
|
|
+ checkConnectionHealth()
|
|
|
|
|
+ }, 30000) // 每30秒检查一次连接健康状态
|
|
|
|
|
+
|
|
|
|
|
+ // 网络状态监测
|
|
|
|
|
+ window.addEventListener('online', () => {
|
|
|
|
|
+ if (!socket.value) {
|
|
|
|
|
+ connect()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ window.addEventListener('offline', () => {
|
|
|
|
|
+ console.log('网络连接断开')
|
|
|
|
|
+ })
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
@@ -1146,7 +1196,457 @@ onUnmounted(() => {
|
|
|
if (timeInterval.value) {
|
|
if (timeInterval.value) {
|
|
|
clearInterval(timeInterval.value)
|
|
clearInterval(timeInterval.value)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // 清理所有定时器
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+ if (reconnectTimeout) {
|
|
|
|
|
+ clearTimeout(reconnectTimeout)
|
|
|
|
|
+ reconnectTimeout = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 正常关闭连接
|
|
|
|
|
+ if (socket.value) {
|
|
|
|
|
+ socket.value.close(1000, '页面关闭')
|
|
|
|
|
+ socket.value = null
|
|
|
|
|
+ }
|
|
|
})
|
|
})
|
|
|
|
|
+
|
|
|
|
|
+// websocket设备连接
|
|
|
|
|
+// 响应式数据
|
|
|
|
|
+const socket = ref(null)
|
|
|
|
|
+const isConnecting = ref(false)
|
|
|
|
|
+const connectionId = ref(null)
|
|
|
|
|
+const reconnectAttempts = ref(0)
|
|
|
|
|
+const maxReconnectAttempts = ref(10)
|
|
|
|
|
+const lastActivityTime = ref('-')
|
|
|
|
|
+const initTime = ref(new Date().toLocaleTimeString())
|
|
|
|
|
+const events = ref([])
|
|
|
|
|
+// 定时器引用
|
|
|
|
|
+let heartbeatInterval = null
|
|
|
|
|
+let heartbeatTimeout = null
|
|
|
|
|
+let reconnectTimeout = null
|
|
|
|
|
+let lastActivity = Date.now()
|
|
|
|
|
+const heartbeatIntervalTime = 25000 // 25秒发送一次心跳
|
|
|
|
|
+const heartbeatTimeoutTime = 10000 // 10秒心跳响应超时
|
|
|
|
|
+
|
|
|
|
|
+// 添加心跳状态响应式变量
|
|
|
|
|
+const heartbeatStatus = ref('normal') // normal: 正常, waiting: 等待响应, timeout: 超时, expired: 过期
|
|
|
|
|
+const lastHeartbeatTime = ref(null) // 最后一次发送心跳的时间
|
|
|
|
|
+const lastHeartbeatAckTime = ref(null) // 最后一次收到心跳响应的时间
|
|
|
|
|
+
|
|
|
|
|
+// 连接websocket方法
|
|
|
|
|
+const connect = () => {
|
|
|
|
|
+ // 连接已存在或连接中,跳过重复连接
|
|
|
|
|
+ if (isConnecting.value || socket.value) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ isConnecting.value = true
|
|
|
|
|
+ // 连接中...
|
|
|
|
|
+ // 清除之前的重连定时器
|
|
|
|
|
+ if (reconnectTimeout) {
|
|
|
|
|
+ clearTimeout(reconnectTimeout)
|
|
|
|
|
+ reconnectTimeout = null
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const clientId = generateClientId()
|
|
|
|
|
+ // 连接地址: ${wsUrl}
|
|
|
|
|
+ const wsUrl = import.meta.env.VITE_API_WSS_URL + clientId
|
|
|
|
|
+ socket.value = new WebSocket(wsUrl)
|
|
|
|
|
+ socket.value.onopen = handleOpen
|
|
|
|
|
+ socket.value.onmessage = handleMessage
|
|
|
|
|
+ socket.value.onclose = handleClose
|
|
|
|
|
+ socket.value.onerror = handleError
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 连接创建错误: ${error.message}
|
|
|
|
|
+ handleConnectionFailure()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const sendMessage = (message) => {
|
|
|
|
|
+ if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ socket.value.send(JSON.stringify(message))
|
|
|
|
|
+ lastActivity = Date.now()
|
|
|
|
|
+ updateLastActivity()
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 发送消息失败: ${error.message}
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 无法发送消息: WebSocket未连接
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 连接成功
|
|
|
|
|
+const handleOpen = (event) => {
|
|
|
|
|
+ isConnecting.value = false
|
|
|
|
|
+ reconnectAttempts.value = 0
|
|
|
|
|
+ lastActivity = Date.now()
|
|
|
|
|
+ // WebSocket连接成功
|
|
|
|
|
+ // 启动心跳机制
|
|
|
|
|
+ startHeartbeat()
|
|
|
|
|
+ // 发送身份验证消息
|
|
|
|
|
+ let postData = {
|
|
|
|
|
+ type: 'AUTH',
|
|
|
|
|
+ clientType: 'monitor',
|
|
|
|
|
+ clientId: generateClientId(),
|
|
|
|
|
+ timestamp: Date.now()
|
|
|
|
|
+ }
|
|
|
|
|
+ sendMessage(postData)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 获取到服务器发送的消息
|
|
|
|
|
+const handleMessage = (event) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ lastActivity = Date.now()
|
|
|
|
|
+ updateLastActivity()
|
|
|
|
|
+
|
|
|
|
|
+ const data = JSON.parse(event.data)
|
|
|
|
|
+ processIncomingData(data)
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('消息解析错误:', error, '原始数据:', event.data)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 处理心跳响应
|
|
|
|
|
+const handleHeartbeatAck = (data) => {
|
|
|
|
|
+ console.log('心跳消息', data)
|
|
|
|
|
+ // 清除超时检测
|
|
|
|
|
+ if (heartbeatTimeout) {
|
|
|
|
|
+ clearTimeout(heartbeatTimeout)
|
|
|
|
|
+ heartbeatTimeout = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ heartbeatStatus.value = 'normal'
|
|
|
|
|
+ lastHeartbeatAckTime.value = Date.now()
|
|
|
|
|
+ lastActivity = Date.now()
|
|
|
|
|
+ updateLastActivity()
|
|
|
|
|
+
|
|
|
|
|
+ console.log('💓 心跳响应正常')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 新增:连接健康检查
|
|
|
|
|
+const checkConnectionHealth = () => {
|
|
|
|
|
+ if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ const timeSinceLastActivity = now - lastActivity
|
|
|
|
|
+ const timeSinceLastHeartbeat = now - (lastHeartbeatTime.value || 0)
|
|
|
|
|
+ const totalTimeout = heartbeatIntervalTime + heartbeatTimeoutTime
|
|
|
|
|
+
|
|
|
|
|
+ console.log('🔍 连接健康检查:')
|
|
|
|
|
+ console.log('最后活动:', Math.round(timeSinceLastActivity / 1000) + '秒前')
|
|
|
|
|
+ console.log('最后心跳:', Math.round(timeSinceLastHeartbeat / 1000) + '秒前')
|
|
|
|
|
+ console.log('心跳状态:', heartbeatStatus.value)
|
|
|
|
|
+
|
|
|
|
|
+ // 如果超过总超时时间无活动,认为连接已死
|
|
|
|
|
+ if (timeSinceLastActivity > totalTimeout + 10000) {
|
|
|
|
|
+ // 额外给10秒缓冲
|
|
|
|
|
+ console.error('🚨 连接长时间无活动,可能已断开')
|
|
|
|
|
+ heartbeatStatus.value = 'expired'
|
|
|
|
|
+ handleHeartbeatExpired()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果心跳等待时间过长,发送测试消息
|
|
|
|
|
+ if (heartbeatStatus.value === 'waiting' && timeSinceLastHeartbeat > heartbeatTimeoutTime + 5000) {
|
|
|
|
|
+ console.warn('⚠️ 心跳响应延迟,发送测试消息')
|
|
|
|
|
+ sendMessage({ type: 'PING', timestamp: now })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleClose = (event) => {
|
|
|
|
|
+ console.log(`WebSocket连接关闭: 代码 ${event.code}, 原因: ${event.reason || '未知'}`)
|
|
|
|
|
+
|
|
|
|
|
+ isConnecting.value = false
|
|
|
|
|
+ socket.value = null
|
|
|
|
|
+ connectionId.value = null
|
|
|
|
|
+ heartbeatStatus.value = 'expired' // 连接关闭时标记为过期
|
|
|
|
|
+
|
|
|
|
|
+ // 停止心跳检测
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+
|
|
|
|
|
+ // 清除可能存在的重连定时器
|
|
|
|
|
+ if (reconnectTimeout) {
|
|
|
|
|
+ clearTimeout(reconnectTimeout)
|
|
|
|
|
+ reconnectTimeout = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 判断是否需要重连(非正常关闭且未超过最大重连次数)
|
|
|
|
|
+ if (event.code !== 1000 && reconnectAttempts.value < maxReconnectAttempts.value) {
|
|
|
|
|
+ reconnectAttempts.value++
|
|
|
|
|
+ const delay = Math.min(3000 * Math.pow(1.5, reconnectAttempts.value - 1), 30000)
|
|
|
|
|
+
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ `${Math.round(delay / 1000)}秒后尝试重连 (${reconnectAttempts.value}/${
|
|
|
|
|
+ maxReconnectAttempts.value
|
|
|
|
|
+ })`
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ reconnectTimeout = setTimeout(() => {
|
|
|
|
|
+ // 检查是否已经重新连接
|
|
|
|
|
+ if (!socket.value && !isConnecting.value) {
|
|
|
|
|
+ connect()
|
|
|
|
|
+ }
|
|
|
|
|
+ }, delay)
|
|
|
|
|
+ } else if (reconnectAttempts.value >= maxReconnectAttempts.value) {
|
|
|
|
|
+ console.error('已达到最大重连次数,停止自动重连')
|
|
|
|
|
+ heartbeatStatus.value = 'expired'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleError = (event) => {
|
|
|
|
|
+ console.error('WebSocket错误详情:', event)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 修改心跳检测逻辑
|
|
|
|
|
+const startHeartbeat = () => {
|
|
|
|
|
+ // 先停止可能存在的旧心跳
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化心跳状态
|
|
|
|
|
+ heartbeatStatus.value = 'normal'
|
|
|
|
|
+ lastHeartbeatTime.value = Date.now()
|
|
|
|
|
+
|
|
|
|
|
+ // 定时发送心跳
|
|
|
|
|
+ heartbeatInterval = setInterval(() => {
|
|
|
|
|
+ // 检查连接状态
|
|
|
|
|
+ if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
|
|
|
|
|
+ console.log('连接已断开,停止心跳')
|
|
|
|
|
+ heartbeatStatus.value = 'expired'
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查上次心跳是否过期(超过间隔+超时时间未收到响应)
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ const timeSinceLastHeartbeat = now - lastHeartbeatTime.value
|
|
|
|
|
+ const totalTimeout = heartbeatIntervalTime + heartbeatTimeoutTime
|
|
|
|
|
+
|
|
|
|
|
+ if (lastHeartbeatTime.value && timeSinceLastHeartbeat > totalTimeout) {
|
|
|
|
|
+ console.warn(
|
|
|
|
|
+ `💔 心跳已过期,距离上次心跳${Math.round(timeSinceLastHeartbeat / 1000)}秒,触发重连`
|
|
|
|
|
+ )
|
|
|
|
|
+ heartbeatStatus.value = 'expired'
|
|
|
|
|
+ handleHeartbeatExpired()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 发送心跳
|
|
|
|
|
+ heartbeatStatus.value = 'waiting'
|
|
|
|
|
+ lastHeartbeatTime.value = now
|
|
|
|
|
+ let params = {
|
|
|
|
|
+ type: 'HEARTBEAT',
|
|
|
|
|
+ timestamp: now,
|
|
|
|
|
+ clientTime: now
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log('params', params)
|
|
|
|
|
+ const success = sendMessage(params)
|
|
|
|
|
+
|
|
|
|
|
+ if (success) {
|
|
|
|
|
+ // 设置心跳超时检测
|
|
|
|
|
+ if (heartbeatTimeout) {
|
|
|
|
|
+ clearTimeout(heartbeatTimeout)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ heartbeatTimeout = setTimeout(() => {
|
|
|
|
|
+ console.warn('💔 心跳响应超时,连接可能已断开')
|
|
|
|
|
+ heartbeatStatus.value = 'timeout'
|
|
|
|
|
+ handleHeartbeatTimeout()
|
|
|
|
|
+ }, heartbeatTimeoutTime)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.warn('心跳发送失败,连接可能有问题')
|
|
|
|
|
+ heartbeatStatus.value = 'timeout'
|
|
|
|
|
+ handleHeartbeatTimeout()
|
|
|
|
|
+ }
|
|
|
|
|
+ }, heartbeatIntervalTime)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 新增:处理心跳过期
|
|
|
|
|
+const handleHeartbeatExpired = () => {
|
|
|
|
|
+ console.error('🚨 心跳已过期,关闭连接并重新连接')
|
|
|
|
|
+
|
|
|
|
|
+ // 停止所有定时器
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+
|
|
|
|
|
+ // 关闭当前连接
|
|
|
|
|
+ if (socket.value) {
|
|
|
|
|
+ socket.value.close(1000, '心跳过期')
|
|
|
|
|
+ socket.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 立即重新连接
|
|
|
|
|
+ reconnectAttempts.value = 0 // 重置重连计数
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ if (!socket.value && !isConnecting.value) {
|
|
|
|
|
+ console.log('开始心跳过期重连...')
|
|
|
|
|
+ connect()
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 1000)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 新增:处理心跳超时
|
|
|
|
|
+const handleHeartbeatTimeout = () => {
|
|
|
|
|
+ console.warn('⏰ 心跳响应超时,关闭连接触发重连')
|
|
|
|
|
+
|
|
|
|
|
+ // 停止心跳检测
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+
|
|
|
|
|
+ // 主动关闭连接触发重连机制
|
|
|
|
|
+ if (socket.value) {
|
|
|
|
|
+ socket.value.close(1000, '心跳响应超时')
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const stopHeartbeat = () => {
|
|
|
|
|
+ if (heartbeatInterval) {
|
|
|
|
|
+ clearInterval(heartbeatInterval)
|
|
|
|
|
+ heartbeatInterval = null
|
|
|
|
|
+ }
|
|
|
|
|
+ if (heartbeatTimeout) {
|
|
|
|
|
+ clearTimeout(heartbeatTimeout)
|
|
|
|
|
+ heartbeatTimeout = null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const processIncomingData = (data) => {
|
|
|
|
|
+ if (!data || !data.type) {
|
|
|
|
|
+ // 收到无效消息格式
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch (data.type) {
|
|
|
|
|
+ case 'CONNECT_SUCCESS':
|
|
|
|
|
+ handleConnectSuccess(data)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'AUTH_SUCCESS':
|
|
|
|
|
+ handleAuthSuccess(data)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'SOS_ALERT':
|
|
|
|
|
+ // 需要展示的数据
|
|
|
|
|
+ handleSOSAlert(data)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'DEVICE_DATA_UPDATE':
|
|
|
|
|
+ handleDeviceData(data)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'SYSTEM_STATS_UPDATE':
|
|
|
|
|
+ handleStatsUpdate(data)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'HEARTBEAT_ACK':
|
|
|
|
|
+ handleHeartbeatAck(data)
|
|
|
|
|
+ break
|
|
|
|
|
+ default:
|
|
|
|
|
+ handleGenericMessage(data)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleConnectSuccess = (data) => {
|
|
|
|
|
+ console.log('WebSocket连接成功')
|
|
|
|
|
+ // 连接成功,连接ID: ${connectionId.value}
|
|
|
|
|
+ connectionId.value = data.connectionId
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleAuthSuccess = (data) => {
|
|
|
|
|
+ console.log('身份验证成功')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleSOSAlert = (alertData) => {
|
|
|
|
|
+ console.log('alertData', alertData)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const alert = alertData.data || alertData
|
|
|
|
|
+ // 发送确认消息
|
|
|
|
|
+ sendMessage({
|
|
|
|
|
+ type: 'SOS_ACK',
|
|
|
|
|
+ alertId: alertData.timestamp,
|
|
|
|
|
+ timestamp: Date.now()
|
|
|
|
|
+ })
|
|
|
|
|
+ // notification.warning({
|
|
|
|
|
+ // message: h('div', {
|
|
|
|
|
+ // style: {
|
|
|
|
|
+ // color: '#fff',
|
|
|
|
|
+ // // fontSize: '18px'
|
|
|
|
|
+ // fontSize: '0.1rem'
|
|
|
|
|
+ // },
|
|
|
|
|
+ // innerHTML: `
|
|
|
|
|
+ // <div>🚨🚨 SOS紧急预警 🚨🚨</div>
|
|
|
|
|
+ // `
|
|
|
|
|
+ // }),
|
|
|
|
|
+ // duration: 10,
|
|
|
|
|
+ // // duration: null,
|
|
|
|
|
+ // class: 'my-warning-notification',
|
|
|
|
|
+ // style: {
|
|
|
|
|
+ // background: 'linear-gradient(135deg, #1e4184 0%, #3469e3 100%) !important',
|
|
|
|
|
+ // border: '1px solid rgba(42, 157, 143, 0.3) !important',
|
|
|
|
|
+ // 'box-shadow': '0 4px 20px rgba(0, 0, 0, 0.5) !important',
|
|
|
|
|
+ // color: '#fff !important',
|
|
|
|
|
+ // width: '2rem !important'
|
|
|
|
|
+ // },
|
|
|
|
|
+ // description: h('div', {
|
|
|
|
|
+ // style: {
|
|
|
|
|
+ // color: '#fff',
|
|
|
|
|
+ // fontSize: '0.1rem'
|
|
|
|
|
+ // },
|
|
|
|
|
+ // innerHTML: `
|
|
|
|
|
+ // <div>院区名称: ${alert.organizationName || '未知'}</div>
|
|
|
|
|
+ // <div>长者姓名: ${alert.elderName || '未知'}</div>
|
|
|
|
|
+ // <div>长者房间: ${alert.roomName || '未知'}</div>
|
|
|
|
|
+ // <div>设备类型: ${alert.deviceType || '未知'}</div>
|
|
|
|
|
+ // <div>设备电量: ${alert.batteryLevel || '0%'}</div>
|
|
|
|
|
+ // <div>时间: ${new Date(alertData.timestamp).toLocaleString()}</div>
|
|
|
|
|
+ // `
|
|
|
|
|
+ // })
|
|
|
|
|
+ // })
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('处理SOS告警错误:', error)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleDeviceData = (deviceData) => {
|
|
|
|
|
+ console.log('deviceData', deviceData)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleStatsUpdate = (statsData) => {
|
|
|
|
|
+ console.log('statsData', statsData)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 修改普通消息处理,不干扰心跳检测
|
|
|
|
|
+const handleGenericMessage = (data) => {
|
|
|
|
|
+ console.log('收到普通消息:', data)
|
|
|
|
|
+ // 这里只更新最后活动时间,但不影响心跳超时检测
|
|
|
|
|
+ lastActivity = Date.now()
|
|
|
|
|
+ updateLastActivity()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const updateLastActivity = () => {
|
|
|
|
|
+ lastActivityTime.value = new Date().toLocaleTimeString()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const generateClientId = () => {
|
|
|
|
|
+ const timestamp = Date.now()
|
|
|
|
|
+ const random = Math.random().toString(36).substr(2, 9)
|
|
|
|
|
+ return `monitor_${timestamp}_${random}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleConnectionFailure = () => {
|
|
|
|
|
+ isConnecting.value = false
|
|
|
|
|
+ socket.value = null
|
|
|
|
|
+
|
|
|
|
|
+ // 连接创建失败也尝试重连
|
|
|
|
|
+ if (reconnectAttempts.value < maxReconnectAttempts.value) {
|
|
|
|
|
+ reconnectAttempts.value++
|
|
|
|
|
+ const delay = Math.min(3000 * Math.pow(1.5, reconnectAttempts.value - 1), 30000)
|
|
|
|
|
+
|
|
|
|
|
+ reconnectTimeout = setTimeout(() => {
|
|
|
|
|
+ if (!socket.value && !isConnecting.value) {
|
|
|
|
|
+ connect()
|
|
|
|
|
+ }
|
|
|
|
|
+ }, delay)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
<style lang="scss" scoped>
|
|
@@ -1184,49 +1684,49 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
|
|
|
|
@keyframes auroraShift {
|
|
@keyframes auroraShift {
|
|
|
0% {
|
|
0% {
|
|
|
- transform: translate(-10%, -10%) scale(1);
|
|
|
|
|
opacity: 0.6;
|
|
opacity: 0.6;
|
|
|
|
|
+ transform: translate(-10%, -10%) scale(1);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
50% {
|
|
50% {
|
|
|
- transform: translate(5%, 10%) scale(1.1);
|
|
|
|
|
opacity: 0.9;
|
|
opacity: 0.9;
|
|
|
|
|
+ transform: translate(5%, 10%) scale(1.1);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
100% {
|
|
100% {
|
|
|
- transform: translate(15%, -5%) scale(1.05);
|
|
|
|
|
opacity: 0.6;
|
|
opacity: 0.6;
|
|
|
|
|
+ transform: translate(15%, -5%) scale(1.05);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/* 大屏专用样式 */
|
|
/* 大屏专用样式 */
|
|
|
.large-screen {
|
|
.large-screen {
|
|
|
|
|
+ position: relative;
|
|
|
padding: 20px;
|
|
padding: 20px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
|
|
sans-serif;
|
|
sans-serif;
|
|
|
color: $text-light;
|
|
color: $text-light;
|
|
|
background: radial-gradient(circle at 10% 20%, rgb(15 88 255 / 25%) 0%, transparent 35%),
|
|
background: radial-gradient(circle at 10% 20%, rgb(15 88 255 / 25%) 0%, transparent 35%),
|
|
|
radial-gradient(circle at 90% 10%, rgb(20 201 201 / 18%) 0%, transparent 30%),
|
|
radial-gradient(circle at 90% 10%, rgb(20 201 201 / 18%) 0%, transparent 30%),
|
|
|
linear-gradient(135deg, $bg-gradient-start 0%, $bg-gradient-mid 45%, $bg-gradient-end 100%);
|
|
linear-gradient(135deg, $bg-gradient-start 0%, $bg-gradient-mid 45%, $bg-gradient-end 100%);
|
|
|
- position: relative;
|
|
|
|
|
- overflow: hidden;
|
|
|
|
|
|
|
|
|
|
&::before,
|
|
&::before,
|
|
|
&::after {
|
|
&::after {
|
|
|
- content: '';
|
|
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
- inset: -30%;
|
|
|
|
|
|
|
+ pointer-events: none;
|
|
|
background: radial-gradient(circle, $bg-accent-1 0%, transparent 60%);
|
|
background: radial-gradient(circle, $bg-accent-1 0%, transparent 60%);
|
|
|
- filter: blur(80px);
|
|
|
|
|
|
|
+ content: '';
|
|
|
opacity: 0.7;
|
|
opacity: 0.7;
|
|
|
|
|
+ filter: blur(80px);
|
|
|
animation: auroraShift 18s ease-in-out infinite alternate;
|
|
animation: auroraShift 18s ease-in-out infinite alternate;
|
|
|
- pointer-events: none;
|
|
|
|
|
|
|
+ inset: -30%;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
&::after {
|
|
&::after {
|
|
|
background: radial-gradient(circle, $bg-accent-2 0%, transparent 60%);
|
|
background: radial-gradient(circle, $bg-accent-2 0%, transparent 60%);
|
|
|
- animation-duration: 22s;
|
|
|
|
|
animation-delay: 4s;
|
|
animation-delay: 4s;
|
|
|
|
|
+ animation-duration: 22s;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.my-container {
|
|
.my-container {
|
|
@@ -1260,8 +1760,8 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
height: 100%;
|
|
height: 100%;
|
|
|
background-image: linear-gradient(rgb(255 255 255 / 5%) 1px, transparent 1px),
|
|
background-image: linear-gradient(rgb(255 255 255 / 5%) 1px, transparent 1px),
|
|
|
linear-gradient(90deg, rgb(255 255 255 / 5%) 1px, transparent 1px);
|
|
linear-gradient(90deg, rgb(255 255 255 / 5%) 1px, transparent 1px);
|
|
|
- background-size: 40px 40px;
|
|
|
|
|
background-position: center;
|
|
background-position: center;
|
|
|
|
|
+ background-size: 40px 40px;
|
|
|
opacity: 0.35;
|
|
opacity: 0.35;
|
|
|
mix-blend-mode: screen;
|
|
mix-blend-mode: screen;
|
|
|
}
|
|
}
|
|
@@ -1284,8 +1784,8 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
font-size: 36px;
|
|
font-size: 36px;
|
|
|
font-weight: 700;
|
|
font-weight: 700;
|
|
|
background: linear-gradient(90deg, $primary-color, $secondary-color, $accent-color);
|
|
background: linear-gradient(90deg, $primary-color, $secondary-color, $accent-color);
|
|
|
|
|
+ /* stylelint-disable-next-line property-no-vendor-prefix */
|
|
|
-webkit-background-clip: text;
|
|
-webkit-background-clip: text;
|
|
|
- background-clip: text;
|
|
|
|
|
-webkit-text-fill-color: transparent;
|
|
-webkit-text-fill-color: transparent;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1368,8 +1868,8 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
font-size: 42px;
|
|
font-size: 42px;
|
|
|
font-weight: 700;
|
|
font-weight: 700;
|
|
|
background: linear-gradient(90deg, $primary-color, $secondary-color);
|
|
background: linear-gradient(90deg, $primary-color, $secondary-color);
|
|
|
|
|
+ /* stylelint-disable-next-line property-no-vendor-prefix */
|
|
|
-webkit-background-clip: text;
|
|
-webkit-background-clip: text;
|
|
|
- background-clip: text;
|
|
|
|
|
-webkit-text-fill-color: transparent;
|
|
-webkit-text-fill-color: transparent;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1638,12 +2138,12 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
.device-card-large {
|
|
.device-card-large {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
padding: 20px;
|
|
padding: 20px;
|
|
|
|
|
+ cursor: default;
|
|
|
background: rgb(255 255 255 / 5%);
|
|
background: rgb(255 255 255 / 5%);
|
|
|
border: 1px solid rgb(255 255 255 / 8%);
|
|
border: 1px solid rgb(255 255 255 / 8%);
|
|
|
border-radius: 12px;
|
|
border-radius: 12px;
|
|
|
flex-direction: column;
|
|
flex-direction: column;
|
|
|
gap: 15px;
|
|
gap: 15px;
|
|
|
- cursor: default;
|
|
|
|
|
|
|
|
|
|
&.online {
|
|
&.online {
|
|
|
border-left: 4px solid $success-color;
|
|
border-left: 4px solid $success-color;
|
|
@@ -1654,8 +2154,8 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
&.warning {
|
|
&.warning {
|
|
|
- border-left: 4px solid $warning-color;
|
|
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
+ border-left: 4px solid $warning-color;
|
|
|
box-shadow: 0 0 20px rgb(253 150 68 / 25%);
|
|
box-shadow: 0 0 20px rgb(253 150 68 / 25%);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -1887,12 +2387,12 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.warning-drawer {
|
|
.warning-drawer {
|
|
|
- background: linear-gradient(135deg, #111a3a, #0a1124);
|
|
|
|
|
color: $text-light;
|
|
color: $text-light;
|
|
|
|
|
+ background: linear-gradient(135deg, #111a3a, #0a1124);
|
|
|
|
|
|
|
|
.el-drawer__header {
|
|
.el-drawer__header {
|
|
|
- margin-bottom: 0;
|
|
|
|
|
padding-bottom: 10px;
|
|
padding-bottom: 10px;
|
|
|
|
|
+ margin-bottom: 0;
|
|
|
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2079,17 +2579,18 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
:deep(.el-empty__description) {
|
|
:deep(.el-empty__description) {
|
|
|
color: $text-gray !important;
|
|
color: $text-gray !important;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
:deep(.el-empty) {
|
|
:deep(.el-empty) {
|
|
|
padding: 10px !important;
|
|
padding: 10px !important;
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|
|
|
<style lang="scss">
|
|
<style lang="scss">
|
|
|
.large-screen-dialog {
|
|
.large-screen-dialog {
|
|
|
|
|
+ margin-top: 8vh !important;
|
|
|
background: #1a1f2e !important;
|
|
background: #1a1f2e !important;
|
|
|
border: 1px solid rgb(255 255 255 / 10%) !important;
|
|
border: 1px solid rgb(255 255 255 / 10%) !important;
|
|
|
border-radius: 16px !important;
|
|
border-radius: 16px !important;
|
|
|
box-shadow: 0 20px 60px rgb(0 0 0 / 40%) !important;
|
|
box-shadow: 0 20px 60px rgb(0 0 0 / 40%) !important;
|
|
|
- margin-top: 8vh !important;
|
|
|
|
|
|
|
|
|
|
.el-dialog__header {
|
|
.el-dialog__header {
|
|
|
padding: 25px !important;
|
|
padding: 25px !important;
|
|
@@ -2111,11 +2612,12 @@ $transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
color: #fff !important;
|
|
color: #fff !important;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
.my-warning-notification {
|
|
.my-warning-notification {
|
|
|
|
|
+ width: '2rem !important';
|
|
|
|
|
+ color: '#fff !important';
|
|
|
background: 'linear-gradient(135deg, #1e4184 0%, #3469e3 100%) !important';
|
|
background: 'linear-gradient(135deg, #1e4184 0%, #3469e3 100%) !important';
|
|
|
border: '1px solid rgba(42, 157, 143, 0.3) !important';
|
|
border: '1px solid rgba(42, 157, 143, 0.3) !important';
|
|
|
box-shadow: '0 4px 20px rgba(0, 0, 0, 0.5) !important';
|
|
box-shadow: '0 4px 20px rgba(0, 0, 0, 0.5) !important';
|
|
|
- color: '#fff !important';
|
|
|
|
|
- width: '2rem !important';
|
|
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|