|
|
@@ -0,0 +1,3042 @@
|
|
|
+<template>
|
|
|
+ <div class="elderly-management-system large-screen">
|
|
|
+ <div class="cyber-bg"></div>
|
|
|
+ <div class="cyber-grid"></div>
|
|
|
+
|
|
|
+ <div class="my-container">
|
|
|
+ <!-- 大屏顶部信息栏 -->
|
|
|
+ <div class="top-info-bar">
|
|
|
+ <div class="system-title-section">
|
|
|
+ <h1 class="system-title">{{ getTenantName() }}</h1>
|
|
|
+ <p class="system-subtitle">智能守护 • 安心养老 • 实时监控</p>
|
|
|
+ </div>
|
|
|
+ <div class="time-display">
|
|
|
+ <div class="current-date">{{ currentDate }}</div>
|
|
|
+ <div class="current-time">{{ currentTime }}</div>
|
|
|
+ <button class="fullscreen-btn" @click="toggleFullScreen">
|
|
|
+ <Icon icon="ep:full-screen" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 统计信息 - 大屏优化 -->
|
|
|
+ <div class="stats-grid-large">
|
|
|
+ <div
|
|
|
+ v-for="stat in largeScreenStats"
|
|
|
+ :key="stat.label"
|
|
|
+ :class="['stat-card-large', { clickable: stat.clickable }]"
|
|
|
+ @click="handleStatCardClick(stat)"
|
|
|
+ >
|
|
|
+ <div class="stat-icon-large">
|
|
|
+ <Icon :icon="stat.icon" />
|
|
|
+ </div>
|
|
|
+ <div class="stat-content-large">
|
|
|
+ <div class="stat-value-large">{{ stat.value }}</div>
|
|
|
+ <div class="stat-label-large">{{ stat.label }}</div>
|
|
|
+ <div class="stat-trend" :class="stat.trend">
|
|
|
+ <span class="trend-icon">{{ stat.trend === 'up' ? '↗' : '→' }}</span>
|
|
|
+ <span class="trend-value">{{ stat.change }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="main-content-large">
|
|
|
+ <!-- 左侧:老人列表 -->
|
|
|
+ <div class="elderly-list-section">
|
|
|
+ <!-- 添加长者按钮 -->
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ size="large"
|
|
|
+ @click="openAddElderDialog"
|
|
|
+ style="width: 95%; margin: 5px auto 0"
|
|
|
+ >
|
|
|
+ <Icon icon="ep:plus" />
|
|
|
+ <span>添加长者</span>
|
|
|
+ </el-button>
|
|
|
+ <div class="section-header-large">
|
|
|
+ <h2>老人列表</h2>
|
|
|
+ <div class="search-section-large">
|
|
|
+ <el-input
|
|
|
+ v-model="searchQuery"
|
|
|
+ placeholder="搜索老人或设备..."
|
|
|
+ clearable
|
|
|
+ size="large"
|
|
|
+ >
|
|
|
+ <template #prefix>
|
|
|
+ <Icon icon="ep:search" />
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="elderly-scroll-container">
|
|
|
+ <div class="elderly-grid-large">
|
|
|
+ <div
|
|
|
+ class="elderly-card-large"
|
|
|
+ v-for="elderly in filteredElderlyList"
|
|
|
+ :key="elderly.id"
|
|
|
+ :class="{
|
|
|
+ active: selectedElderly?.id === elderly.id,
|
|
|
+ flashing: (elderly as any)._flashEffect ?? false
|
|
|
+ }"
|
|
|
+ @click="selectElderly(elderly)"
|
|
|
+ >
|
|
|
+ <!-- <el-tag
|
|
|
+ v-if="hasWarning(elderly.id)"
|
|
|
+ class="warning-flag"
|
|
|
+ type="danger"
|
|
|
+ size="small"
|
|
|
+ effect="dark"
|
|
|
+ >警</el-tag
|
|
|
+ > -->
|
|
|
+ <div v-if="hasWarning(elderly.id)" class="warning-action-group">
|
|
|
+ <Icon icon="mdi:alert" class="warning-flag" color="red" :size="50" />
|
|
|
+ <el-button
|
|
|
+ type="warning"
|
|
|
+ size="small"
|
|
|
+ class="handle-warning-btn"
|
|
|
+ @click.stop="openHandleWarningDialog(elderly)"
|
|
|
+ >
|
|
|
+ 去处理
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="elderly-avatar-large" :class="getGenderClass(elderly.gender)">
|
|
|
+ <span class="avatar-initial">{{ getNameInitial(elderly.name) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="elderly-info-large">
|
|
|
+ <h3>{{ elderly.name }}</h3>
|
|
|
+ <p> {{ elderly.age || 0 }}岁 • {{ genderMap[elderly.gender] || '未知' }} </p>
|
|
|
+ <div class="health-status-large">
|
|
|
+ <div class="status-dot" :class="getHealthStatusClass(elderly.healthText)"></div>
|
|
|
+ <span>{{ elderly.healthText || '未知' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="elderly-actions">
|
|
|
+ <div class="device-count">
|
|
|
+ <span>{{ elderly.deviceNumber || 0 }} 个设备</span>
|
|
|
+ </div>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ size="small"
|
|
|
+ class="add-device-btn"
|
|
|
+ style="padding: 10px !important"
|
|
|
+ @click.stop="openAddDeviceFromList(elderly)"
|
|
|
+ >
|
|
|
+ 添加设备
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧:详细信息 -->
|
|
|
+ <div class="detail-section" v-if="selectedElderly">
|
|
|
+ <div class="detail-header">
|
|
|
+ <h2>{{ selectedElderly.name }}的详细信息</h2>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="health-metrics">
|
|
|
+ <h3>健康指标</h3>
|
|
|
+ <div
|
|
|
+ class="metrics-grid"
|
|
|
+ v-if="selectedElderly.healthList && selectedElderly.healthList?.length"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="metric-card"
|
|
|
+ v-for="(metric, index) in selectedElderly.healthList"
|
|
|
+ :key="index"
|
|
|
+ >
|
|
|
+ <div class="metric-icon">
|
|
|
+ <Icon :icon="getHealthIcon(metric)" />
|
|
|
+ </div>
|
|
|
+ <div class="metric-info">
|
|
|
+ <div class="metric-value">{{ metric.value }}{{ metric.unit }}</div>
|
|
|
+ <div class="metric-name">{{ metric.name }}</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="metric-trend"
|
|
|
+ :class="metric.status.includes('警') ? 'warning' : 'normal'"
|
|
|
+ >
|
|
|
+ {{ metric.status }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-empty v-else description="暂无健康指标" :image-size="40" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="devices-section-large">
|
|
|
+ <div class="devices-header">
|
|
|
+ <h3>设备监控</h3>
|
|
|
+ <el-button type="primary" size="large" @click="openAddDeviceDialog(selectedElderly)">
|
|
|
+ <Icon icon="ep:plus" />
|
|
|
+ <span>添加设备</span>
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="devices-grid-large"
|
|
|
+ v-if="selectedElderly.deviceList && selectedElderly.deviceList?.length"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="device-card-large"
|
|
|
+ v-for="(device, index) in selectedElderly.deviceList"
|
|
|
+ :key="index"
|
|
|
+ :class="getDeviceStatusInfo(device).class"
|
|
|
+ >
|
|
|
+ <div class="device-header">
|
|
|
+ <div class="device-icon-large">
|
|
|
+ <Icon :icon="getDeviceInfo(device).icon" />
|
|
|
+ </div>
|
|
|
+ <div class="device-info-large">
|
|
|
+ <h4>
|
|
|
+ {{
|
|
|
+ deviceTypeOptions?.find(
|
|
|
+ (v: DeviceTypeVO) => v.deviceType == device.deviceType
|
|
|
+ )?.deviceTypeName || '-'
|
|
|
+ }}
|
|
|
+ </h4>
|
|
|
+ <p>{{ device.installPosition }}</p>
|
|
|
+ </div>
|
|
|
+ <el-tag :type="getDeviceStatusInfo(device).tagType">
|
|
|
+ {{ getDeviceStatusInfo(device).text }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="device-data">
|
|
|
+ <p>{{ device.indicatorText || '-' }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="device-actions-large">
|
|
|
+ <el-button type="primary" size="small" @click.stop="showDeviceDetail(device)">
|
|
|
+ 查看详情
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="danger"
|
|
|
+ size="small"
|
|
|
+ @click.stop="removeDevice(selectedElderly, device)"
|
|
|
+ >
|
|
|
+ 移除
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-empty v-else description="暂无设备" :image-size="40" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="detail-placeholder" v-else>
|
|
|
+ <div class="placeholder-content">
|
|
|
+ <div class="placeholder-icon">
|
|
|
+ <Icon icon="mdi:gesture-tap" />
|
|
|
+ </div>
|
|
|
+ <h3>请选择一位老人查看详细信息</h3>
|
|
|
+ <p>点击左侧老人卡片查看健康数据和设备状态</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部状态栏 -->
|
|
|
+ <div class="status-bar">
|
|
|
+ <div class="status-info">
|
|
|
+ <span
|
|
|
+ >系统状态:
|
|
|
+ <span :class="hasAlerts ? 'status-warning' : 'status-online'">{{
|
|
|
+ largeScreenStatsData.systemStatus || '未知状态'
|
|
|
+ }}</span></span
|
|
|
+ >
|
|
|
+ <span>最后数据同步: {{ largeScreenStatsData.lastTime || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="alert-indicator" :class="{ active: hasAlerts }">
|
|
|
+ {{ hasAlerts ? '有警告设备需要关注' : '所有设备运行正常' }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 添加设备对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="dialogVisible"
|
|
|
+ :title="'为' + (currentElderly ? currentElderly.name : '') + '添加设备'"
|
|
|
+ width="600px"
|
|
|
+ append-to-body
|
|
|
+ center
|
|
|
+ :before-close="closeDialog"
|
|
|
+ class="large-screen-dialog"
|
|
|
+ >
|
|
|
+ <el-form ref="deviceFormRef" :model="addDeviceForm" :rules="formRules" label-width="100px">
|
|
|
+ <el-form-item label="设备类型" prop="deviceType">
|
|
|
+ <el-select
|
|
|
+ v-model="addDeviceForm.deviceType"
|
|
|
+ placeholder="请选择设备类型"
|
|
|
+ style="width: 100%"
|
|
|
+ size="large"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="item in deviceTypeOptions"
|
|
|
+ :key="item.deviceType"
|
|
|
+ :label="item.deviceTypeName"
|
|
|
+ :value="item.deviceType"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="设备码" prop="deviceCode">
|
|
|
+ <el-input v-model="addDeviceForm.deviceCode" placeholder="请输入设备码" size="large" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="设备位置" prop="installPosition">
|
|
|
+ <el-input
|
|
|
+ v-model="addDeviceForm.installPosition"
|
|
|
+ placeholder="请输入设备安装位置"
|
|
|
+ size="large"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="closeDialog" size="large">取消</el-button>
|
|
|
+ <el-button type="primary" @click="addDevice" size="large">确认添加</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 设备详情对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="deviceDetailVisible"
|
|
|
+ :title="
|
|
|
+ (deviceTypeOptions?.find((v: DeviceTypeVO) => v.deviceType == deviceDetail?.deviceType)
|
|
|
+ ?.deviceTypeName || '-') + ' - 详细信息'
|
|
|
+ "
|
|
|
+ width="700px"
|
|
|
+ append-to-body
|
|
|
+ class="large-screen-dialog"
|
|
|
+ >
|
|
|
+ <div class="device-detail-content" v-if="deviceDetail">
|
|
|
+ <div class="device-detail-header">
|
|
|
+ <div class="device-icon-xlarge">
|
|
|
+ <Icon :icon="getDeviceInfo(deviceDetail).icon" />
|
|
|
+ </div>
|
|
|
+ <div class="device-detail-info">
|
|
|
+ <h3>
|
|
|
+ {{
|
|
|
+ deviceTypeOptions?.find(
|
|
|
+ (v: DeviceTypeVO) => v.deviceType == deviceDetail?.deviceType
|
|
|
+ )?.deviceTypeName || '-'
|
|
|
+ }}
|
|
|
+ </h3>
|
|
|
+ <p>设备类型: {{ deviceDetail.deviceType }}</p>
|
|
|
+ <p>安装位置: {{ deviceDetail.installPosition }}</p>
|
|
|
+ <p>
|
|
|
+ 设备状态:
|
|
|
+ <el-tag :type="textStatusMap[deviceDetail.status]">
|
|
|
+ {{ deviceDetail.status }}
|
|
|
+ </el-tag>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="device-data-detail">
|
|
|
+ <h4>设备数据</h4>
|
|
|
+ <div
|
|
|
+ class="data-grid"
|
|
|
+ v-if="deviceDetail.indicatorInfo && deviceDetail.indicatorInfo?.length"
|
|
|
+ >
|
|
|
+ <div class="data-item" v-for="(item, value) in deviceDetail.indicatorInfo" :key="value">
|
|
|
+ <span class="data-label">{{ item.name }}</span>
|
|
|
+ <span class="data-value">{{ item.value }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-empty v-else description="暂无设备" :image-size="40" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="device-history">
|
|
|
+ <h4>历史记录</h4>
|
|
|
+ <div
|
|
|
+ class="history-list"
|
|
|
+ v-if="deviceDetail.historyInfo && deviceDetail.historyInfo?.length"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="history-item"
|
|
|
+ v-for="(record, index) in deviceDetail.historyInfo"
|
|
|
+ :key="index"
|
|
|
+ >
|
|
|
+ <span class="history-time">{{
|
|
|
+ record.happensAt ? formatToDateTime(record.happensAt) : ''
|
|
|
+ }}</span>
|
|
|
+ <span class="history-event">{{ record.content }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-empty v-else description="暂无历史记录" :image-size="40" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="deviceDetailVisible = false" size="large">关闭</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 设备预警历史抽屉 -->
|
|
|
+ <el-drawer
|
|
|
+ v-model="warningDrawerVisible"
|
|
|
+ direction="rtl"
|
|
|
+ size="620px"
|
|
|
+ class="warning-drawer"
|
|
|
+ :title="`设备预警历史 (共${total}条)`"
|
|
|
+ append-to-body
|
|
|
+ @close="closeWarningDrawer"
|
|
|
+ >
|
|
|
+ <div class="warning-drawer-content">
|
|
|
+ <div class="warning-history">
|
|
|
+ <div class="history-list" v-if="warningData.length">
|
|
|
+ <div class="history-item" v-for="(record, index) in warningData" :key="index">
|
|
|
+ <div class="history-header">
|
|
|
+ <span class="history-time">{{ record.happensAt }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="history-event">{{ record.content }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-empty v-else description="暂无报警记录" />
|
|
|
+
|
|
|
+ <!-- 分页组件 -->
|
|
|
+ <div class="pagination-container" v-if="total > 0">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="pageNum"
|
|
|
+ v-model:page-size="pageSize"
|
|
|
+ :page-sizes="[5, 10, 20, 50]"
|
|
|
+ :small="false"
|
|
|
+ :background="true"
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ :total="total"
|
|
|
+ @size-change="handleSizeChange"
|
|
|
+ @current-change="handlePageChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-drawer>
|
|
|
+
|
|
|
+ <!-- 在设备详情对话框后面添加添加长者对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="addElderDialogVisible"
|
|
|
+ title="添加长者"
|
|
|
+ width="500px"
|
|
|
+ append-to-body
|
|
|
+ center
|
|
|
+ class="large-screen-dialog"
|
|
|
+ >
|
|
|
+ <el-form ref="elderFormRef" :model="addElderForm" :rules="elderFormRules" label-width="80px">
|
|
|
+ <el-form-item label="姓名" prop="name">
|
|
|
+ <el-input
|
|
|
+ v-model="addElderForm.name"
|
|
|
+ placeholder="请输入长者姓名"
|
|
|
+ size="large"
|
|
|
+ maxlength="20"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="地址" prop="address">
|
|
|
+ <el-input
|
|
|
+ v-model="addElderForm.address"
|
|
|
+ placeholder="请输入长者地址"
|
|
|
+ size="large"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ maxlength="100"
|
|
|
+ show-word-limit
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="性别" prop="gender">
|
|
|
+ <el-select v-model="addElderForm.gender" size="large" placeholder="请选择性别">
|
|
|
+ <el-option label="男" :value="1" />
|
|
|
+ <el-option label="女" :value="0" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="closeAddElderDialog" size="large">取消</el-button>
|
|
|
+ <el-button type="primary" @click="submitAddElder" size="large">确认添加</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 处理告警对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="handleWarningDialogVisible"
|
|
|
+ title="处理告警"
|
|
|
+ width="500px"
|
|
|
+ append-to-body
|
|
|
+ center
|
|
|
+ class="large-screen-dialog"
|
|
|
+ >
|
|
|
+ <div class="handle-warning-content">
|
|
|
+ <div class="elderly-info-section">
|
|
|
+ <p><strong>长者姓名:</strong>{{ currentWarningElderly?.name || '' }}</p>
|
|
|
+ </div>
|
|
|
+ <el-form ref="handleWarningFormRef" :model="handleWarningForm" label-width="120px">
|
|
|
+ <el-form-item
|
|
|
+ label="处理方式"
|
|
|
+ :rules="[{ required: true, message: '请选择处理方式', trigger: 'change' }]"
|
|
|
+ >
|
|
|
+ <el-radio-group v-model="handleWarningForm.handleType" size="large">
|
|
|
+ <el-radio label="phone">电话回访</el-radio>
|
|
|
+ <el-radio label="report">上报告警情况</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item
|
|
|
+ v-if="handleWarningForm.handleType === 'report'"
|
|
|
+ label="上报信息"
|
|
|
+ prop="message"
|
|
|
+ :rules="[{ required: true, message: '请输入上报信息', trigger: 'blur' }]"
|
|
|
+ >
|
|
|
+ <el-input
|
|
|
+ v-model="handleWarningForm.message"
|
|
|
+ type="textarea"
|
|
|
+ :rows="4"
|
|
|
+ placeholder="请输入上报信息"
|
|
|
+ maxlength="200"
|
|
|
+ show-word-limit
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="closeHandleWarningDialog" size="large">取消</el-button>
|
|
|
+ <el-button type="primary" @click="submitHandleWarning" size="large">确认处理</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { ref, reactive, computed, onMounted, onUnmounted, nextTick, h } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
|
|
+import fetchHttp from '@/config/axios/fetchHttp'
|
|
|
+import { getAccessToken, getLoginForm } from '@/utils/auth'
|
|
|
+import { formatToDateTime } from '@/utils/dateUtil'
|
|
|
+
|
|
|
+interface WarningHistory {
|
|
|
+ happensAt: string
|
|
|
+ content: string
|
|
|
+}
|
|
|
+
|
|
|
+interface StatisticsVO {
|
|
|
+ systemStatus: string
|
|
|
+ lastTime: string
|
|
|
+ isWarning: boolean
|
|
|
+}
|
|
|
+
|
|
|
+interface CommonVo {
|
|
|
+ name: string
|
|
|
+ value: string
|
|
|
+}
|
|
|
+
|
|
|
+interface HistoryInfoVo {
|
|
|
+ happensAt: string
|
|
|
+ content: string
|
|
|
+}
|
|
|
+
|
|
|
+interface Elderly {
|
|
|
+ id: number
|
|
|
+ avatar: string
|
|
|
+ name: string
|
|
|
+ age: number
|
|
|
+ gender: string
|
|
|
+ healthStatus: string
|
|
|
+ healthText: string
|
|
|
+ deviceNumber: number
|
|
|
+}
|
|
|
+
|
|
|
+interface HealthVO {
|
|
|
+ name: string
|
|
|
+ value: string
|
|
|
+ status: string
|
|
|
+ unit: string
|
|
|
+}
|
|
|
+
|
|
|
+interface DetailDevice {
|
|
|
+ deviceType: string
|
|
|
+ installPosition: string
|
|
|
+ status: string
|
|
|
+ indicatorText: string
|
|
|
+ deviceCode: string
|
|
|
+}
|
|
|
+
|
|
|
+interface SelectElderly {
|
|
|
+ id: number
|
|
|
+ healthList: HealthVO[]
|
|
|
+ name: string
|
|
|
+ deviceList: DetailDevice[]
|
|
|
+}
|
|
|
+
|
|
|
+interface Device {
|
|
|
+ deviceType: string
|
|
|
+ installPosition: string
|
|
|
+ status: string
|
|
|
+ indicatorInfo: CommonVo[]
|
|
|
+ historyInfo: HistoryInfoVo[]
|
|
|
+}
|
|
|
+
|
|
|
+type DeviceStatusTag = 'success' | 'warning' | 'danger' | 'info' | 'primary'
|
|
|
+
|
|
|
+interface LargeScreenStat {
|
|
|
+ icon: string
|
|
|
+ value: number | string
|
|
|
+ label: string
|
|
|
+ trend: 'up' | 'stable'
|
|
|
+ change: string
|
|
|
+ type?: 'warning'
|
|
|
+ clickable?: boolean
|
|
|
+ indicator: string
|
|
|
+}
|
|
|
+
|
|
|
+interface DeviceTypeVO {
|
|
|
+ deviceType: string
|
|
|
+ deviceTypeName: string
|
|
|
+ displayOrder: number
|
|
|
+}
|
|
|
+
|
|
|
+// 健康指标图标映射
|
|
|
+const healthIconMap: Record<string, string> = {
|
|
|
+ 血氧: 'mdi:oxygen-tank',
|
|
|
+ 心率: 'mdi:heart-pulse',
|
|
|
+ 血压: 'mdi:blood-bag',
|
|
|
+ 体温: 'mdi:thermometer',
|
|
|
+ 血糖: 'mdi:needle',
|
|
|
+ 血脂: 'mdi:blood-bag',
|
|
|
+ 呼吸频率: 'mdi:lungs',
|
|
|
+ 步数: 'mdi:walk',
|
|
|
+ 睡眠: 'mdi:sleep',
|
|
|
+ 体重: 'mdi:scale',
|
|
|
+ 身高: 'mdi:human-male-height',
|
|
|
+ BMI: 'mdi:human-male-height-variant',
|
|
|
+ 运动量: 'mdi:run',
|
|
|
+ 卡路里: 'mdi:fire',
|
|
|
+ 水分摄入: 'mdi:cup-water',
|
|
|
+ 血氧饱和度: 'mdi:oxygen-tank',
|
|
|
+ 心电图: 'mdi:heart-flash',
|
|
|
+ 肺功能: 'mdi:lungs',
|
|
|
+ 骨密度: 'mdi:bone',
|
|
|
+ 视力: 'mdi:eye',
|
|
|
+ 听力: 'mdi:ear-hearing',
|
|
|
+ 胆固醇: 'mdi:blood-bag',
|
|
|
+ 尿酸: 'mdi:flask',
|
|
|
+ 肝功能: 'mdi:liver',
|
|
|
+ 肾功能: 'mdi:kidney',
|
|
|
+ 血糖波动: 'mdi:chart-line',
|
|
|
+ 血压波动: 'mdi:chart-areaspline',
|
|
|
+ 心率变异性: 'mdi:chart-bell-curve',
|
|
|
+ 睡眠时长: 'mdi:clock-sleep',
|
|
|
+ 深睡时长: 'mdi:sleep',
|
|
|
+ 浅睡时长: 'mdi:sleep',
|
|
|
+ REM睡眠: 'mdi:sleep',
|
|
|
+ 入睡时间: 'mdi:clock-start',
|
|
|
+ 醒来时间: 'mdi:clock-end',
|
|
|
+ 夜间醒来次数: 'mdi:alert-circle',
|
|
|
+ 日间活动量: 'mdi:walk',
|
|
|
+ 静息心率: 'mdi:heart',
|
|
|
+ 最大心率: 'mdi:heart',
|
|
|
+ 最低心率: 'mdi:heart',
|
|
|
+ 收缩压: 'mdi:blood-bag',
|
|
|
+ 舒张压: 'mdi:blood-bag',
|
|
|
+ 平均血压: 'mdi:blood-bag',
|
|
|
+ 血糖餐前: 'mdi:food',
|
|
|
+ 血糖餐后: 'mdi:food',
|
|
|
+ 血糖空腹: 'mdi:food-off',
|
|
|
+ 血氧夜间: 'mdi:weather-night',
|
|
|
+ 血氧日间: 'mdi:weather-sunny',
|
|
|
+ 呼吸暂停: 'mdi:alert',
|
|
|
+ 打鼾指数: 'mdi:volume-high',
|
|
|
+ 体温晨起: 'mdi:weather-sunset-up',
|
|
|
+ 体温晚间: 'mdi:weather-sunset-down',
|
|
|
+ 体重变化: 'mdi:chart-line',
|
|
|
+ BMI趋势: 'mdi:trending-up',
|
|
|
+ 水分平衡: 'mdi:water-percent',
|
|
|
+ 运动强度: 'mdi:run-fast',
|
|
|
+ 卡路里消耗: 'mdi:fire',
|
|
|
+ 睡眠效率: 'mdi:percent',
|
|
|
+ 睡眠评分: 'mdi:star',
|
|
|
+ 健康评分: 'mdi:star',
|
|
|
+ 压力指数: 'mdi:alert',
|
|
|
+ 情绪状态: ' mdi:emoticon-happy',
|
|
|
+ 认知功能: 'mdi:brain',
|
|
|
+ 平衡能力: 'mdi:scale-balance',
|
|
|
+ 握力: 'mdi:hand-back-right',
|
|
|
+ 步行速度: 'mdi:speedometer',
|
|
|
+ 日常活动: 'mdi:home',
|
|
|
+ 用药依从性: 'mdi:pill',
|
|
|
+ 复诊提醒: 'mdi:calendar',
|
|
|
+ 紧急呼叫: 'mdi:alert-circle'
|
|
|
+}
|
|
|
+
|
|
|
+// 设备类型映射
|
|
|
+const deviceTypeMap: Record<
|
|
|
+ string,
|
|
|
+ {
|
|
|
+ name: string
|
|
|
+ icon: string
|
|
|
+ color: string
|
|
|
+ }
|
|
|
+> = {
|
|
|
+ ['health_band']: { name: '健康监测手环', icon: 'mdi:watch-variant', color: '#ff6b6b' },
|
|
|
+ ['smart_mattress']: { name: '智能床垫', icon: 'mdi:bed-queen', color: '#4ecdc4' },
|
|
|
+ ['security_camera']: { name: '安防摄像头', icon: 'mdi:cctv', color: '#45aaf2' },
|
|
|
+ ['blood_pressure_monitor']: {
|
|
|
+ name: '血压监测仪',
|
|
|
+ icon: 'mdi:heart-pulse',
|
|
|
+ color: '#a55eea'
|
|
|
+ },
|
|
|
+ ['emergency_button']: {
|
|
|
+ name: '紧急呼叫按钮',
|
|
|
+ icon: 'mdi:alarm-light',
|
|
|
+ color: '#fd9644'
|
|
|
+ },
|
|
|
+ ['smoke_sensor']: { name: '烟雾传感器', icon: 'mdi:smoke-detector', color: '#26de81' },
|
|
|
+ ['water_sensor']: { name: '水浸传感器', icon: 'mdi:water-alert', color: '#26de81' },
|
|
|
+ ['infrared_sensor']: {
|
|
|
+ name: '人体红外传感器',
|
|
|
+ icon: 'mdi:motion-sensor',
|
|
|
+ color: '#26de81'
|
|
|
+ },
|
|
|
+ ['door_sensor']: { name: '门磁传感器', icon: 'mdi:door-closed', color: '#26de81' },
|
|
|
+ ['gas_sensor']: { name: '燃气传感器', icon: 'mdi:gas-cylinder', color: '#26de81' },
|
|
|
+ ['temperature_sensor']: {
|
|
|
+ name: '温度传感器',
|
|
|
+ icon: 'mdi:thermometer',
|
|
|
+ color: '#ff6b6b'
|
|
|
+ },
|
|
|
+ ['humidity_sensor']: {
|
|
|
+ name: '湿度传感器',
|
|
|
+ icon: 'mdi:water-percent',
|
|
|
+ color: '#48dbfb'
|
|
|
+ },
|
|
|
+ ['fall_detection_sensor']: {
|
|
|
+ name: '跌倒检测传感器',
|
|
|
+ icon: 'mdi:human-falling',
|
|
|
+ color: '#ff9ff3'
|
|
|
+ },
|
|
|
+ ['pill_box']: {
|
|
|
+ name: '智能药盒',
|
|
|
+ icon: 'mdi:pill',
|
|
|
+ color: '#1dd1a1'
|
|
|
+ },
|
|
|
+ ['oxygen_saturation_monitor']: {
|
|
|
+ name: '血氧监测仪',
|
|
|
+ icon: 'mdi:heart-pulse',
|
|
|
+ color: '#a55eea'
|
|
|
+ },
|
|
|
+ ['glucose_meter']: {
|
|
|
+ name: '血糖仪',
|
|
|
+ icon: 'mdi:needle',
|
|
|
+ color: '#fed330'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const textStatusMap = {
|
|
|
+ 在线: 'success',
|
|
|
+ 离线: 'danger',
|
|
|
+ 警告: 'warning'
|
|
|
+}
|
|
|
+
|
|
|
+// 设备状态映射
|
|
|
+const deviceStatusMap: Record<
|
|
|
+ string,
|
|
|
+ {
|
|
|
+ text: string
|
|
|
+ class: string
|
|
|
+ tagType: DeviceStatusTag
|
|
|
+ }
|
|
|
+> = {
|
|
|
+ ['在线']: { text: '在线', class: 'online', tagType: 'success' },
|
|
|
+ ['离线']: { text: '离线', class: 'offline', tagType: 'danger' },
|
|
|
+ ['警告']: { text: '警告', class: 'warning', tagType: 'warning' }
|
|
|
+}
|
|
|
+
|
|
|
+const organizationId = localStorage.getItem('organizationId')
|
|
|
+
|
|
|
+// 响应式数据
|
|
|
+const total = ref(0)
|
|
|
+const pageNum = ref(1)
|
|
|
+const pageSize = ref(10)
|
|
|
+const elderlyList = ref<Elderly[]>([])
|
|
|
+const searchQuery = ref('')
|
|
|
+const dialogVisible = ref(false)
|
|
|
+const deviceDetailVisible = ref(false)
|
|
|
+const currentElderly = ref<SelectElderly | null>(null)
|
|
|
+const selectedElderly = ref<SelectElderly>({
|
|
|
+ id: 0,
|
|
|
+ name: '',
|
|
|
+ healthList: [],
|
|
|
+ deviceList: []
|
|
|
+})
|
|
|
+
|
|
|
+// 设备类型选项
|
|
|
+const deviceTypeOptions = ref<DeviceTypeVO[]>([])
|
|
|
+const warningDrawerVisible = ref(false)
|
|
|
+const warningData = ref<WarningHistory[]>([])
|
|
|
+const deviceDetail = ref<Device | null>(null)
|
|
|
+const deviceFormRef = ref()
|
|
|
+const currentDate = ref('')
|
|
|
+const currentTime = ref('')
|
|
|
+const lastSyncTime = ref('')
|
|
|
+const timeInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
|
|
+const largeScreenStatsData = ref<StatisticsVO>({
|
|
|
+ systemStatus: '正常',
|
|
|
+ lastTime: new Date().toLocaleString(),
|
|
|
+ isWarning: false
|
|
|
+})
|
|
|
+
|
|
|
+const addDeviceForm = reactive({
|
|
|
+ elderlyId: null as number | null,
|
|
|
+ deviceType: '',
|
|
|
+ deviceCode: '',
|
|
|
+ installPosition: '',
|
|
|
+ elderlyName: '',
|
|
|
+ organizationName: ''
|
|
|
+})
|
|
|
+
|
|
|
+const genderMap = {
|
|
|
+ 0: '女',
|
|
|
+ 1: '男'
|
|
|
+}
|
|
|
+
|
|
|
+// 告警标记:点击老人后清除;刷新后仍保留
|
|
|
+const WARNING_STORAGE_KEY = 'elder_warning_flags'
|
|
|
+const warningFlags = ref<number[]>([])
|
|
|
+
|
|
|
+const loadWarningFlags = () => {
|
|
|
+ try {
|
|
|
+ const raw = localStorage.getItem(WARNING_STORAGE_KEY)
|
|
|
+ warningFlags.value = raw ? JSON.parse(raw) : []
|
|
|
+ } catch (e) {
|
|
|
+ warningFlags.value = []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const saveWarningFlags = () => {
|
|
|
+ localStorage.setItem(WARNING_STORAGE_KEY, JSON.stringify(warningFlags.value))
|
|
|
+}
|
|
|
+
|
|
|
+const addWarningFlag = (elderId: number) => {
|
|
|
+ if (!elderId) return
|
|
|
+ if (!warningFlags.value.includes(elderId)) {
|
|
|
+ warningFlags.value.push(elderId)
|
|
|
+ saveWarningFlags()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const clearWarningFlag = (elderId: number) => {
|
|
|
+ const idx = warningFlags.value.indexOf(elderId)
|
|
|
+ if (idx !== -1) {
|
|
|
+ warningFlags.value.splice(idx, 1)
|
|
|
+ saveWarningFlags()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const hasWarning = (elderId: number) => warningFlags.value.includes(elderId)
|
|
|
+
|
|
|
+// 在响应式数据部分添加
|
|
|
+const addElderDialogVisible = ref(false)
|
|
|
+const elderFormRef = ref()
|
|
|
+const addElderForm = reactive({
|
|
|
+ name: '',
|
|
|
+ address: '',
|
|
|
+ gender: 1 // 默认男性
|
|
|
+})
|
|
|
+
|
|
|
+// 处理告警相关数据
|
|
|
+const handleWarningDialogVisible = ref(false)
|
|
|
+const handleWarningFormRef = ref()
|
|
|
+const currentWarningElderly = ref<Elderly | null>(null)
|
|
|
+const handleWarningForm = reactive({
|
|
|
+ handleType: 'phone' as 'phone' | 'report',
|
|
|
+ message: ''
|
|
|
+})
|
|
|
+
|
|
|
+// 添加长者表单验证规则
|
|
|
+const elderFormRules = {
|
|
|
+ name: [
|
|
|
+ { required: true, message: '请输入长者姓名', trigger: 'blur' },
|
|
|
+ { min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ address: [
|
|
|
+ { required: true, message: '请输入长者地址', trigger: 'blur' },
|
|
|
+ { min: 5, max: 100, message: '地址长度在 5 到 100 个字符', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ gender: [{ required: true, message: '请选择性别', trigger: 'change' }]
|
|
|
+}
|
|
|
+
|
|
|
+// 表单规则
|
|
|
+const formRules = {
|
|
|
+ deviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
|
|
+ deviceCode: [{ required: true, message: '请输入设备码', trigger: 'blur' }],
|
|
|
+ installPosition: [{ required: true, message: '请输入设备位置', trigger: 'blur' }]
|
|
|
+}
|
|
|
+
|
|
|
+// 计算属性
|
|
|
+const filteredElderlyList = computed(() => {
|
|
|
+ if (!searchQuery.value) {
|
|
|
+ return elderlyList.value
|
|
|
+ }
|
|
|
+ const query = searchQuery.value.toLowerCase()
|
|
|
+ return elderlyList.value.filter((elderly) => elderly.name.toLowerCase().includes(query))
|
|
|
+})
|
|
|
+
|
|
|
+// 大屏专用统计信息
|
|
|
+const largeScreenStats = ref<LargeScreenStat[]>([
|
|
|
+ {
|
|
|
+ icon: 'mdi:account-group',
|
|
|
+ value: 0,
|
|
|
+ label: '老人数量',
|
|
|
+ trend: 'up',
|
|
|
+ change: '',
|
|
|
+ indicator: 'elderCount'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ icon: 'mdi:devices',
|
|
|
+ value: 0,
|
|
|
+ label: '设备总数',
|
|
|
+ trend: 'up',
|
|
|
+ change: '',
|
|
|
+ indicator: 'deviceCount'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ icon: 'mdi:shield-check',
|
|
|
+ value: 0,
|
|
|
+ label: '在线设备',
|
|
|
+ trend: 'up',
|
|
|
+ change: '100%',
|
|
|
+ indicator: 'onlineCount'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ icon: 'mdi:alert-decagram',
|
|
|
+ value: 0,
|
|
|
+ label: '警告设备',
|
|
|
+ trend: 'up',
|
|
|
+ change: '需关注',
|
|
|
+ type: 'warning',
|
|
|
+ clickable: true,
|
|
|
+ indicator: 'warningCount'
|
|
|
+ }
|
|
|
+])
|
|
|
+
|
|
|
+// 是否有警告
|
|
|
+const hasAlerts = computed(() => largeScreenStatsData.value.isWarning)
|
|
|
+// 全屏切换功能
|
|
|
+const isFullscreen = ref(false)
|
|
|
+// 监听全屏状态变化
|
|
|
+const handleFullscreenChange = () => {
|
|
|
+ isFullscreen.value = !!document.fullscreenElement
|
|
|
+}
|
|
|
+
|
|
|
+document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
+const toggleFullScreen = () => {
|
|
|
+ if (!document.fullscreenElement) {
|
|
|
+ document.documentElement.requestFullscreen().catch((err) => {
|
|
|
+ ElMessage.error(`全屏请求失败: ${err.message}`)
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ if (document.exitFullscreen) {
|
|
|
+ document.exitFullscreen()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 添加相关方法
|
|
|
+const openAddElderDialog = () => {
|
|
|
+ addElderForm.name = ''
|
|
|
+ addElderForm.address = ''
|
|
|
+ addElderForm.gender = 1
|
|
|
+ addElderDialogVisible.value = true
|
|
|
+ // 清除表单验证
|
|
|
+ nextTick(() => {
|
|
|
+ if (elderFormRef.value) {
|
|
|
+ elderFormRef.value.clearValidate()
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const closeAddElderDialog = () => {
|
|
|
+ addElderDialogVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+const submitAddElder = async () => {
|
|
|
+ if (!elderFormRef.value) return
|
|
|
+ try {
|
|
|
+ const valid = await elderFormRef.value.validate()
|
|
|
+ if (!valid) return
|
|
|
+ // 构建请求数据
|
|
|
+ const elderData = {
|
|
|
+ name: addElderForm.name,
|
|
|
+ address: addElderForm.address,
|
|
|
+ gender: addElderForm.gender
|
|
|
+ }
|
|
|
+ // 调用添加长者接口
|
|
|
+ const res = await fetchHttp.post('/api/pc/admin/addElder', elderData, {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ })
|
|
|
+ if (res) {
|
|
|
+ ElMessage.success('长者添加成功!')
|
|
|
+ closeAddElderDialog()
|
|
|
+ // 刷新长者列表
|
|
|
+ await getElderList()
|
|
|
+ } else {
|
|
|
+ ElMessage.error('长者添加失败!')
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('添加长者失败:', error)
|
|
|
+ ElMessage.error('添加长者时出现错误')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 打开处理告警对话框
|
|
|
+const openHandleWarningDialog = (elderly: Elderly) => {
|
|
|
+ currentWarningElderly.value = elderly
|
|
|
+ handleWarningForm.handleType = 'phone'
|
|
|
+ handleWarningForm.message = ''
|
|
|
+ handleWarningDialogVisible.value = true
|
|
|
+ // 清除表单验证
|
|
|
+ nextTick(() => {
|
|
|
+ if (handleWarningFormRef.value) {
|
|
|
+ handleWarningFormRef.value.clearValidate()
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 关闭处理告警对话框
|
|
|
+const closeHandleWarningDialog = () => {
|
|
|
+ handleWarningDialogVisible.value = false
|
|
|
+ currentWarningElderly.value = null
|
|
|
+}
|
|
|
+
|
|
|
+// 提交处理告警
|
|
|
+const submitHandleWarning = async () => {
|
|
|
+ if (!currentWarningElderly.value) return
|
|
|
+ // 如果是上报
|
|
|
+ if (handleWarningForm.handleType === 'report') {
|
|
|
+ try {
|
|
|
+ let apiUrl = '/api/pc/admin/dealWith'
|
|
|
+ let requestData: any = {
|
|
|
+ elderId: currentWarningElderly.value.id,
|
|
|
+ message: handleWarningForm.message || ''
|
|
|
+ }
|
|
|
+ const res = await fetchHttp.post(apiUrl, requestData, {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ })
|
|
|
+ if (res) {
|
|
|
+ ElMessage.success('告警情况已上报!')
|
|
|
+ // 清除该老人的告警标记
|
|
|
+ clearWarningFlag(currentWarningElderly.value.id)
|
|
|
+ closeHandleWarningDialog()
|
|
|
+ if (selectedElderly.value.id === currentWarningElderly.value.id) {
|
|
|
+ await getElderDeviceMessage(selectedElderly.value.id)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ElMessage.error('处理失败,请重试')
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('处理告警失败:', error)
|
|
|
+ ElMessage.error('处理告警时出现错误')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (handleWarningForm.handleType === 'phone') {
|
|
|
+ // 展示家属电话和长者电话
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 方法
|
|
|
+const openAddDeviceDialog = (elderly: SelectElderly) => {
|
|
|
+ currentElderly.value = elderly
|
|
|
+ addDeviceForm.elderlyId = elderly.id
|
|
|
+ addDeviceForm.elderlyName = elderly.name
|
|
|
+ addDeviceForm.deviceType = ''
|
|
|
+ addDeviceForm.deviceCode = ''
|
|
|
+ addDeviceForm.installPosition = ''
|
|
|
+ dialogVisible.value = true
|
|
|
+
|
|
|
+ if (deviceFormRef.value) {
|
|
|
+ deviceFormRef.value.clearValidate()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 从左侧老人列表的卡片“添加设备”按钮触发
|
|
|
+const openAddDeviceFromList = (elderly: Elderly) => {
|
|
|
+ const temp: SelectElderly = {
|
|
|
+ id: elderly.id,
|
|
|
+ name: elderly.name,
|
|
|
+ healthList: [],
|
|
|
+ deviceList: []
|
|
|
+ }
|
|
|
+ openAddDeviceDialog(temp)
|
|
|
+}
|
|
|
+
|
|
|
+// 取消添加设备
|
|
|
+const closeDialog = () => {
|
|
|
+ dialogVisible.value = false
|
|
|
+ currentElderly.value = null
|
|
|
+}
|
|
|
+
|
|
|
+// 获取机构名称
|
|
|
+const getTenantName = () => {
|
|
|
+ return getLoginForm()?.tenantName || ''
|
|
|
+}
|
|
|
+
|
|
|
+// 新增设备
|
|
|
+const addDevice = async () => {
|
|
|
+ if (!deviceFormRef.value) return
|
|
|
+ try {
|
|
|
+ const valid = await deviceFormRef.value.validate()
|
|
|
+ if (!valid) return
|
|
|
+ const newDevice = {
|
|
|
+ deviceType: addDeviceForm.deviceType,
|
|
|
+ deviceCode: addDeviceForm.deviceCode,
|
|
|
+ elderId: Number(addDeviceForm.elderlyId),
|
|
|
+ organizationId: Number(organizationId),
|
|
|
+ installPosition: addDeviceForm.installPosition,
|
|
|
+ organizationName: getTenantName(),
|
|
|
+ elderlyName: addDeviceForm.elderlyName
|
|
|
+ }
|
|
|
+ const res = await fetchHttp.post('/api/pc/admin/bindDevice', newDevice, {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ })
|
|
|
+ if (res) {
|
|
|
+ ElMessage.success('设备添加成功!')
|
|
|
+ closeDialog()
|
|
|
+ getElderDeviceMessage(selectedElderly.value.id)
|
|
|
+ } else {
|
|
|
+ ElMessage.error('设备添加失败!')
|
|
|
+ }
|
|
|
+ } catch (error) {}
|
|
|
+}
|
|
|
+
|
|
|
+// 删除设备
|
|
|
+const removeDevice = (elderly: any, device: DetailDevice) => {
|
|
|
+ ElMessageBox.confirm(`确定要删除设备吗?`, '删除确认', {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ .then(async () => {
|
|
|
+ const res = await fetchHttp.post(
|
|
|
+ '/api/pc/admin/unbindDevice',
|
|
|
+ {
|
|
|
+ deviceCode: device.deviceCode,
|
|
|
+ elderId: Number(elderly.id)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if (res) {
|
|
|
+ ElMessage.success('设备删除成功!')
|
|
|
+ getElderDeviceMessage(selectedElderly.value.id)
|
|
|
+ } else {
|
|
|
+ ElMessage.error('设备删除失败!')
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ // 用户取消删除
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const getHealthIcon = (metric: HealthVO) => {
|
|
|
+ return healthIconMap[metric.name] || 'mdi:help-circle-outline'
|
|
|
+ // return metric.icon || 'mdi:help-circle-outline'
|
|
|
+}
|
|
|
+
|
|
|
+const getDeviceInfo = (device: Device | DetailDevice) => {
|
|
|
+ return (
|
|
|
+ deviceTypeMap[device.deviceType] || {
|
|
|
+ name: '未知设备',
|
|
|
+ icon: 'mdi:help-circle-outline',
|
|
|
+ color: '#a5b1c2'
|
|
|
+ }
|
|
|
+ )
|
|
|
+ // return (
|
|
|
+ // deviceTypeOptions.valur?.find((v: DeviceTypeVO) => v.deviceType == device.deviceType)?.icon ||
|
|
|
+ // 'mdi:help-circle-outline'
|
|
|
+ // )
|
|
|
+}
|
|
|
+
|
|
|
+const getDeviceStatusInfo = (
|
|
|
+ device: any
|
|
|
+): { text: string; class: string; tagType: DeviceStatusTag } => {
|
|
|
+ return deviceStatusMap[device.status] || { text: '未知', class: 'offline', tagType: 'info' }
|
|
|
+}
|
|
|
+
|
|
|
+const getHealthStatusClass = (healthText: string) => {
|
|
|
+ if (!healthText) return ''
|
|
|
+ if (healthText.includes('良好') || healthText.includes('稳定')) return 'good'
|
|
|
+ if (healthText.includes('偏高') || healthText.includes('关注') || healthText.includes('严重'))
|
|
|
+ return 'warning'
|
|
|
+ if (healthText.includes('危险')) return 'error'
|
|
|
+ return 'normal'
|
|
|
+}
|
|
|
+
|
|
|
+// 头像展示:按性别设置颜色,显示姓名首字
|
|
|
+const getGenderClass = (gender: string | number) => {
|
|
|
+ const maleVals = [1, '1', '男', 'male', 'M', 'm']
|
|
|
+ const femaleVals = [0, '0', '女', 'female', 'F', 'f']
|
|
|
+ if (maleVals.includes(gender as any)) return 'male'
|
|
|
+ if (femaleVals.includes(gender as any)) return 'female'
|
|
|
+ return 'unknown'
|
|
|
+}
|
|
|
+
|
|
|
+const getNameInitial = (name?: string) => {
|
|
|
+ if (!name) return '?'
|
|
|
+ const s = String(name).trim()
|
|
|
+ return s ? s[0] : '?'
|
|
|
+}
|
|
|
+
|
|
|
+// 大屏专用方法
|
|
|
+const selectElderly = (elderly: Elderly) => {
|
|
|
+ selectedElderly.value.id = elderly.id
|
|
|
+ selectedElderly.value.name = elderly.name
|
|
|
+ // 点击查看后清除该老人的告警标记
|
|
|
+ clearWarningFlag(elderly.id)
|
|
|
+ getElderDeviceMessage(selectedElderly.value.id)
|
|
|
+}
|
|
|
+
|
|
|
+const showDeviceDetail = async (device: DetailDevice) => {
|
|
|
+ await getDeviceDetail(device.deviceCode)
|
|
|
+ deviceDetailVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const closeWarningDrawer = () => {
|
|
|
+ warningDrawerVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+const handleStatCardClick = (stat: LargeScreenStat) => {
|
|
|
+ if (stat.type !== 'warning' || stat.value === 0) return
|
|
|
+ getAllWarning()
|
|
|
+ warningDrawerVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const updateDateTime = () => {
|
|
|
+ const now = new Date()
|
|
|
+ currentDate.value = now.toLocaleDateString('zh-CN', {
|
|
|
+ year: 'numeric',
|
|
|
+ month: 'long',
|
|
|
+ day: 'numeric',
|
|
|
+ weekday: 'long'
|
|
|
+ })
|
|
|
+ currentTime.value = now.toLocaleTimeString('zh-CN', {
|
|
|
+ hour12: false,
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ second: '2-digit'
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 获取统计数据
|
|
|
+const getStatistics = async () => {
|
|
|
+ const res = await fetchHttp.get(
|
|
|
+ '/api/pc/admin/getStatistics?organizationId=' + organizationId,
|
|
|
+ {},
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if (res.list && res.list.length) {
|
|
|
+ let resData = JSON.parse(JSON.stringify(res.list))
|
|
|
+ resData.forEach((v: any) => {
|
|
|
+ let index = largeScreenStats.value.findIndex(
|
|
|
+ (z: LargeScreenStat) => z.indicator == v.indicator
|
|
|
+ )
|
|
|
+ if (~index) {
|
|
|
+ largeScreenStats.value[index].value = v.total
|
|
|
+ largeScreenStats.value[index].change = v.change
|
|
|
+ largeScreenStats.value[index].trend = v.trend
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取长者列表
|
|
|
+const getElderList = async () => {
|
|
|
+ const res = await fetchHttp.get(
|
|
|
+ '/api/pc/admin/getElderList?organizationId=' + organizationId,
|
|
|
+ {},
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if (res?.length) {
|
|
|
+ elderlyList.value = res
|
|
|
+ // 默认选择第一个老人
|
|
|
+ if (elderlyList.value.length) {
|
|
|
+ selectedElderly.value.id = elderlyList.value[0].id
|
|
|
+ selectedElderly.value.name = elderlyList.value[0].name
|
|
|
+ getElderDeviceMessage(selectedElderly.value.id)
|
|
|
+ warningFlags.value = []
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 查询设备详情
|
|
|
+const getDeviceDetail = async (deviceCode: string) => {
|
|
|
+ const res = await fetchHttp.get(
|
|
|
+ '/api/pc/admin/getDeviceDetail?deviceCode=' + deviceCode,
|
|
|
+ {},
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if (res) {
|
|
|
+ deviceDetail.value = res
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 查询设备详情
|
|
|
+const getElderDeviceMessage = async (elderId: number) => {
|
|
|
+ const res = await fetchHttp.get(
|
|
|
+ '/api/pc/admin/getElderHealthAndDeviceInfo?elderId=' + elderId,
|
|
|
+ {},
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ res
|
|
|
+ if (res) {
|
|
|
+ selectedElderly.value.healthList = res.healthList
|
|
|
+ selectedElderly.value.deviceList = res.deviceList
|
|
|
+ }
|
|
|
+ console.log(selectedElderly.value)
|
|
|
+}
|
|
|
+
|
|
|
+// 查询设备详情
|
|
|
+const getAllWarning = async () => {
|
|
|
+ const params = {
|
|
|
+ pageNum: pageNum.value,
|
|
|
+ pageSize: pageSize.value
|
|
|
+ }
|
|
|
+ const res = await fetchHttp.get('/api/pc/admin/getAllWarning', params, {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ })
|
|
|
+ if (res) {
|
|
|
+ total.value = res.total
|
|
|
+ warningData.value = res.list || []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 添加分页相关方法
|
|
|
+const handlePageChange = (newPageNum: number) => {
|
|
|
+ pageNum.value = newPageNum
|
|
|
+ getAllWarning()
|
|
|
+}
|
|
|
+
|
|
|
+const handleSizeChange = (newPageSize: number) => {
|
|
|
+ pageSize.value = newPageSize
|
|
|
+ pageNum.value = 1 // 重置到第一页
|
|
|
+ getAllWarning()
|
|
|
+}
|
|
|
+
|
|
|
+const getAllDevices = async () => {
|
|
|
+ const res = await fetchHttp.get(
|
|
|
+ '/api/pc/admin/getAllDeviceTypes',
|
|
|
+ {},
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if (res && res?.length) {
|
|
|
+ deviceTypeOptions.value = res
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 生命周期
|
|
|
+onMounted(() => {
|
|
|
+ // 初始化本地持久化的未读告警标记
|
|
|
+ loadWarningFlags()
|
|
|
+ getTenantName()
|
|
|
+ // 初始化时间
|
|
|
+ updateDateTime()
|
|
|
+ lastSyncTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
|
|
+ // 设置定时器更新时间和同步状态
|
|
|
+ timeInterval.value = setInterval(() => {
|
|
|
+ updateDateTime()
|
|
|
+ // 每5分钟更新一次同步时间
|
|
|
+ if (new Date().getMinutes() % 5 === 0) {
|
|
|
+ lastSyncTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
|
|
+ }
|
|
|
+ }, 1000)
|
|
|
+ getAllDevices()
|
|
|
+ // 获取数据
|
|
|
+ getStatistics()
|
|
|
+ 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(() => {
|
|
|
+ document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
+ if (timeInterval.value) {
|
|
|
+ clearInterval(timeInterval.value)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理所有定时器
|
|
|
+ stopHeartbeat()
|
|
|
+ if (reconnectTimeout) {
|
|
|
+ clearTimeout(reconnectTimeout)
|
|
|
+ reconnectTimeout = null
|
|
|
+ }
|
|
|
+
|
|
|
+ // 正常关闭连接
|
|
|
+ if (socket.value) {
|
|
|
+ ;(socket.value as WebSocket).close(1000, '页面关闭')
|
|
|
+ socket.value = null
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// websocket设备连接
|
|
|
+// 响应式数据
|
|
|
+const socket = ref<WebSocket | null>(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: ReturnType<typeof setInterval> | null = null
|
|
|
+let heartbeatTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
+let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
+let lastActivity = Date.now()
|
|
|
+const heartbeatIntervalTime = 25000 // 25秒发送一次心跳
|
|
|
+const heartbeatTimeoutTime = 10000 // 10秒心跳响应超时
|
|
|
+
|
|
|
+// 添加心跳状态响应式变量
|
|
|
+const heartbeatStatus = ref('normal') // normal: 正常, waiting: 等待响应, timeout: 超时, expired: 过期
|
|
|
+const lastHeartbeatTime = ref<number | null>(null) // 最后一次发送心跳的时间
|
|
|
+const lastHeartbeatAckTime = ref<number | null>(null) // 最后一次收到心跳响应的时间
|
|
|
+
|
|
|
+// 连接websocket方法
|
|
|
+const connect = () => {
|
|
|
+ // 连接已存在或连接中,跳过重复连接
|
|
|
+ if (isConnecting.value || socket.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ isConnecting.value = true
|
|
|
+ // 连接中...
|
|
|
+ // 清除之前的重连定时器
|
|
|
+ if (reconnectTimeout) {
|
|
|
+ clearTimeout(reconnectTimeout)
|
|
|
+ reconnectTimeout = null as unknown as ReturnType<typeof setTimeout>
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const clientId = generateClientId() as string
|
|
|
+ // 连接地址: ${wsUrl}
|
|
|
+ const wsUrl = import.meta.env.VITE_API_WSS_URL + clientId
|
|
|
+ socket.value = new WebSocket(wsUrl) as WebSocket
|
|
|
+ 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 as WebSocket).readyState === WebSocket.OPEN) {
|
|
|
+ try {
|
|
|
+ ;(socket.value as WebSocket).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: 'homecare-web',
|
|
|
+ clientId: generateClientId(),
|
|
|
+ timestamp: Date.now(),
|
|
|
+ accessToken: `Bearer ${getAccessToken()}`
|
|
|
+ }
|
|
|
+ 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() as number
|
|
|
+ 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 || 0)
|
|
|
+ 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 'HEALTH_ALERT':
|
|
|
+ handleHealthAlert(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()
|
|
|
+ })
|
|
|
+ ElNotification({
|
|
|
+ title: '🚨🚨 SOS紧急预警 🚨🚨',
|
|
|
+ customClass: 'my-warning-notification',
|
|
|
+ message: h('div', {
|
|
|
+ style: {
|
|
|
+ color: '#fff'
|
|
|
+ },
|
|
|
+ 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>
|
|
|
+ `
|
|
|
+ }),
|
|
|
+ type: 'warning',
|
|
|
+ duration: 10000
|
|
|
+ })
|
|
|
+
|
|
|
+ // 标记该老人有未读告警
|
|
|
+ if (alert.elderId) addWarningFlag(alert.elderId)
|
|
|
+ // 找出elderlyList.value里面id和healthAlertData.elderId一致的老人,将这个老人排序到elderlyList.value第一个,并且边框红色闪烁
|
|
|
+ if (alert.elderId && elderlyList.value.length > 0) {
|
|
|
+ const elderIndex = elderlyList.value.findIndex((elder) => elder.id === alert.elderId)
|
|
|
+ if (elderIndex !== -1) {
|
|
|
+ // 创建一个新数组,将指定的老人移到第一位
|
|
|
+ const reorderedList = [...elderlyList.value]
|
|
|
+ const [selectedElder] = reorderedList.splice(elderIndex, 1)
|
|
|
+ reorderedList.unshift(selectedElder)
|
|
|
+ // 更新列表
|
|
|
+ elderlyList.value = reorderedList
|
|
|
+ // 为对应的老人卡片添加闪烁效果
|
|
|
+ addFlashingEffect(selectedElder)
|
|
|
+ getElderDeviceMessage(alert.elderId)
|
|
|
+ // 更新单个老人信息
|
|
|
+ }
|
|
|
+ }
|
|
|
+ largeScreenStatsData.value = {
|
|
|
+ systemStatus: '告警',
|
|
|
+ lastTime: new Date(alertData.timestamp).toLocaleString(),
|
|
|
+ isWarning: true
|
|
|
+ }
|
|
|
+ // 10s后恢复正常状态
|
|
|
+ setTimeout(() => {
|
|
|
+ largeScreenStatsData.value = {
|
|
|
+ systemStatus: '正常',
|
|
|
+ lastTime: new Date().toLocaleString(),
|
|
|
+ isWarning: false
|
|
|
+ }
|
|
|
+ }, 10000)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('处理SOS告警错误:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 健康数据推送处理逻辑
|
|
|
+const handleHealthAlert = (healthAlert) => {
|
|
|
+ console.log('healthAlert', healthAlert)
|
|
|
+ const healthAlertData = healthAlert.data
|
|
|
+
|
|
|
+ // 标记该老人有未读告警
|
|
|
+ if (healthAlertData.elderId) addWarningFlag(healthAlertData.elderId)
|
|
|
+
|
|
|
+ ElNotification({
|
|
|
+ title: `🚨🚨 ${healthAlertData.eventType} 🚨🚨`,
|
|
|
+ customClass: 'my-warning-notification',
|
|
|
+ message: h('div', {
|
|
|
+ style: {
|
|
|
+ color: '#fff'
|
|
|
+ },
|
|
|
+ innerHTML: `
|
|
|
+ <div>长者姓名: ${healthAlertData.elderName || '未知'}</div>
|
|
|
+ <div>预警指标: ${healthAlertData.indicatorName || '未知'}</div>
|
|
|
+ <div>预警信息: ${healthAlertData.message || '未知'}</div>
|
|
|
+ <div>处理建议: ${healthAlertData.suggestion || '未知'}</div>
|
|
|
+ <div>预警程度: ${healthAlertData.alertLevel || ''}</div>
|
|
|
+ <div>时间: ${new Date(healthAlertData.timestamp).toLocaleString()}</div>
|
|
|
+ `
|
|
|
+ }),
|
|
|
+ type: 'warning',
|
|
|
+ duration: 10000
|
|
|
+ })
|
|
|
+ // 找出elderlyList.value里面id和healthAlertData.elderId一致的老人,将这个老人排序到elderlyList.value第一个,并且边框红色闪烁
|
|
|
+ if (healthAlertData.elderId && elderlyList.value.length > 0) {
|
|
|
+ const elderIndex = elderlyList.value.findIndex((elder) => elder.id === healthAlertData.elderId)
|
|
|
+ if (elderIndex !== -1) {
|
|
|
+ elderlyList.value[elderIndex].healthText =
|
|
|
+ healthAlertData.message + healthAlertData.suggestion
|
|
|
+ // 创建一个新数组,将指定的老人移到第一位
|
|
|
+ const reorderedList = [...elderlyList.value]
|
|
|
+ const [selectedElder] = reorderedList.splice(elderIndex, 1)
|
|
|
+ reorderedList.unshift(selectedElder)
|
|
|
+ // 更新列表
|
|
|
+ elderlyList.value = reorderedList
|
|
|
+ // 为对应的老人卡片添加闪烁效果
|
|
|
+ addFlashingEffect(selectedElder)
|
|
|
+ getElderDeviceMessage(healthAlertData.elderId)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ largeScreenStatsData.value = {
|
|
|
+ systemStatus: '健康告警',
|
|
|
+ lastTime: new Date(healthAlertData.timestamp).toLocaleString(),
|
|
|
+ isWarning: true
|
|
|
+ }
|
|
|
+ // 10s后恢复正常状态
|
|
|
+ setTimeout(() => {
|
|
|
+ largeScreenStatsData.value = {
|
|
|
+ systemStatus: '设备正常',
|
|
|
+ lastTime: new Date().toLocaleString(),
|
|
|
+ isWarning: false
|
|
|
+ }
|
|
|
+ }, 10000)
|
|
|
+}
|
|
|
+
|
|
|
+// 添加一个方法来处理闪烁效果
|
|
|
+const addFlashingEffect = (elder) => {
|
|
|
+ // 添加一个临时的闪烁类名
|
|
|
+ elder._flashEffect = true
|
|
|
+ // 5秒后移除闪烁效果
|
|
|
+ setTimeout(() => {
|
|
|
+ elder._flashEffect = false
|
|
|
+ // 强制重新渲染列表
|
|
|
+ elderlyList.value = [...elderlyList.value]
|
|
|
+ }, 10000)
|
|
|
+}
|
|
|
+
|
|
|
+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>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+$primary-color: #1a73e8;
|
|
|
+$secondary-color: #00c6ff;
|
|
|
+$accent-color: #7b61ff;
|
|
|
+$dark-bg: #0a0e14;
|
|
|
+$card-bg: #1a1f2e;
|
|
|
+$text-light: #fff;
|
|
|
+$text-gray: #8a8f98;
|
|
|
+$success-color: #26de81;
|
|
|
+$warning-color: #fd9644;
|
|
|
+$danger-color: #ff6b6b;
|
|
|
+$bg-gradient-start: #04060f;
|
|
|
+$bg-gradient-mid: #0c1631;
|
|
|
+$bg-gradient-end: #101b3f;
|
|
|
+$bg-accent-1: rgb(32 156 255 / 35%);
|
|
|
+$bg-accent-2: rgb(123 97 255 / 25%);
|
|
|
+$border-radius: 12px;
|
|
|
+$transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
+
|
|
|
+@keyframes pulse {
|
|
|
+ 0% {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ 50% {
|
|
|
+ opacity: 0.7;
|
|
|
+ }
|
|
|
+
|
|
|
+ 100% {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes auroraShift {
|
|
|
+ 0% {
|
|
|
+ opacity: 0.6;
|
|
|
+ transform: translate(-10%, -10%) scale(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ 50% {
|
|
|
+ opacity: 0.9;
|
|
|
+ transform: translate(5%, 10%) scale(1.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ 100% {
|
|
|
+ opacity: 0.6;
|
|
|
+ transform: translate(15%, -5%) scale(1.05);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 添加闪烁动画
|
|
|
+@keyframes borderFlash {
|
|
|
+ 0%,
|
|
|
+ 100% {
|
|
|
+ border-color: rgb(255 255 255 / 8%);
|
|
|
+ box-shadow: 0 0 0 0 rgb(255 107 107 / 0%);
|
|
|
+ }
|
|
|
+
|
|
|
+ 50% {
|
|
|
+ border-color: $danger-color;
|
|
|
+ box-shadow: 0 0 20px 5px rgb(255 107 107 / 50%);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 大屏专用样式 */
|
|
|
+.large-screen {
|
|
|
+ position: relative;
|
|
|
+ padding: 20px;
|
|
|
+ overflow: hidden;
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
|
|
+ sans-serif;
|
|
|
+ color: $text-light;
|
|
|
+ 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%),
|
|
|
+ linear-gradient(135deg, $bg-gradient-start 0%, $bg-gradient-mid 45%, $bg-gradient-end 100%);
|
|
|
+
|
|
|
+ &::before,
|
|
|
+ &::after {
|
|
|
+ position: absolute;
|
|
|
+ pointer-events: none;
|
|
|
+ background: radial-gradient(circle, $bg-accent-1 0%, transparent 60%);
|
|
|
+ content: '';
|
|
|
+ opacity: 0.7;
|
|
|
+ filter: blur(80px);
|
|
|
+ animation: auroraShift 18s ease-in-out infinite alternate;
|
|
|
+ inset: -30%;
|
|
|
+ }
|
|
|
+
|
|
|
+ &::after {
|
|
|
+ background: radial-gradient(circle, $bg-accent-2 0%, transparent 60%);
|
|
|
+ animation-delay: 4s;
|
|
|
+ animation-duration: 22s;
|
|
|
+ }
|
|
|
+
|
|
|
+ .my-container {
|
|
|
+ display: flex;
|
|
|
+ height: 100%;
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 背景效果 */
|
|
|
+.cyber-bg {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ z-index: -2;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ background: radial-gradient(circle at 20% 30%, rgb(34 123 255 / 18%) 0%, transparent 45%),
|
|
|
+ radial-gradient(circle at 80% 70%, rgb(123 97 255 / 15%) 0%, transparent 50%),
|
|
|
+ radial-gradient(circle at 50% 50%, rgb(0 255 240 / 8%) 0%, transparent 55%);
|
|
|
+ opacity: 0.5;
|
|
|
+ filter: blur(10px);
|
|
|
+}
|
|
|
+
|
|
|
+.cyber-grid {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ z-index: -1;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ background-image: linear-gradient(rgb(255 255 255 / 5%) 1px, transparent 1px),
|
|
|
+ linear-gradient(90deg, rgb(255 255 255 / 5%) 1px, transparent 1px);
|
|
|
+ background-position: center;
|
|
|
+ background-size: 40px 40px;
|
|
|
+ opacity: 0.35;
|
|
|
+ mix-blend-mode: screen;
|
|
|
+}
|
|
|
+
|
|
|
+/* 顶部信息栏 */
|
|
|
+.top-info-bar {
|
|
|
+ display: flex;
|
|
|
+ padding: 15px 25px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ background: rgb(26 31 46 / 90%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 15%);
|
|
|
+ border-radius: 16px;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.system-title-section {
|
|
|
+ .system-title {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-size: 36px;
|
|
|
+ font-weight: 700;
|
|
|
+ background: linear-gradient(90deg, $primary-color, $secondary-color, $accent-color);
|
|
|
+ /* stylelint-disable-next-line property-no-vendor-prefix */
|
|
|
+ -webkit-background-clip: text;
|
|
|
+ -webkit-text-fill-color: transparent;
|
|
|
+ }
|
|
|
+
|
|
|
+ .system-subtitle {
|
|
|
+ font-size: 18px;
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.time-display {
|
|
|
+ text-align: right;
|
|
|
+
|
|
|
+ .current-date {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-size: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .current-time {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: $secondary-color;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 大屏统计卡片 */
|
|
|
+.stats-grid-large {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
+ gap: 15px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card-large {
|
|
|
+ display: flex;
|
|
|
+ padding: 25px;
|
|
|
+ background: rgb(26 31 46 / 85%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 12%);
|
|
|
+ border-radius: 16px;
|
|
|
+ transition: $transition;
|
|
|
+ align-items: center;
|
|
|
+ gap: 20px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ transform: translateY(-5px);
|
|
|
+ box-shadow: 0 10px 30px rgb(0 0 0 / 30%);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.clickable {
|
|
|
+ cursor: pointer;
|
|
|
+ border-color: rgb(253 150 68 / 60%);
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ box-shadow: 0 10px 35px rgb(253 150 68 / 30%);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.stat-icon-large {
|
|
|
+ display: flex;
|
|
|
+ width: 80px;
|
|
|
+ height: 80px;
|
|
|
+ background: rgb(255 255 255 / 10%);
|
|
|
+ border-radius: 16px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+
|
|
|
+ :deep(svg),
|
|
|
+ :deep(span) {
|
|
|
+ width: 48px !important;
|
|
|
+ height: 48px !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.stat-content-large {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-value-large {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-size: 42px;
|
|
|
+ font-weight: 700;
|
|
|
+ background: linear-gradient(90deg, $primary-color, $secondary-color);
|
|
|
+ /* stylelint-disable-next-line property-no-vendor-prefix */
|
|
|
+ -webkit-background-clip: text;
|
|
|
+ -webkit-text-fill-color: transparent;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-label-large {
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 16px;
|
|
|
+ color: $text-gray;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-trend {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 5px;
|
|
|
+ font-size: 14px;
|
|
|
+
|
|
|
+ &.up {
|
|
|
+ color: $success-color;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.stable {
|
|
|
+ color: #a5b1c2;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 主内容区域 */
|
|
|
+.main-content-large {
|
|
|
+ display: grid;
|
|
|
+ min-height: 0;
|
|
|
+ margin-bottom: 15px;
|
|
|
+ flex: 1;
|
|
|
+ grid-template-columns: 1fr 2fr;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧老人列表 */
|
|
|
+.elderly-list-section {
|
|
|
+ display: flex;
|
|
|
+ overflow: hidden;
|
|
|
+ background: rgb(26 31 46 / 85%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 12%);
|
|
|
+ border-radius: 16px;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.section-header-large {
|
|
|
+ display: flex;
|
|
|
+ padding: 20px;
|
|
|
+ border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ h2 {
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-scroll-container {
|
|
|
+ padding: 15px;
|
|
|
+ overflow-y: auto;
|
|
|
+ flex: 1;
|
|
|
+ max-height: 700px;
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-grid-large {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-card-large {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ padding: 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 8%);
|
|
|
+ border-radius: 12px;
|
|
|
+ transition: $transition;
|
|
|
+ align-items: center;
|
|
|
+ gap: 15px;
|
|
|
+
|
|
|
+ &:hover,
|
|
|
+ &.active {
|
|
|
+ background: rgb(26 115 232 / 20%);
|
|
|
+ border-color: rgb(26 115 232 / 50%);
|
|
|
+ transform: translateX(5px);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-card-large {
|
|
|
+ display: flex;
|
|
|
+ padding: 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 8%);
|
|
|
+ border-radius: 12px;
|
|
|
+ transition: $transition;
|
|
|
+ align-items: center;
|
|
|
+ gap: 15px;
|
|
|
+
|
|
|
+ &:hover,
|
|
|
+ &.active {
|
|
|
+ background: rgb(26 115 232 / 20%);
|
|
|
+ border-color: rgb(26 115 232 / 50%);
|
|
|
+ transform: translateX(5px);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加闪烁样式
|
|
|
+ &.flashing {
|
|
|
+ position: relative;
|
|
|
+ z-index: 1;
|
|
|
+ animation: borderFlash 1s ease-in-out infinite;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-avatar-large {
|
|
|
+ display: flex;
|
|
|
+ width: 60px;
|
|
|
+ height: 60px;
|
|
|
+ background: rgb(255 255 255 / 10%);
|
|
|
+ border-radius: 50%;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #fff;
|
|
|
+ user-select: none;
|
|
|
+
|
|
|
+ /* 旧的图标尺寸,仅保留给 svg 图标使用 */
|
|
|
+ :deep(svg) {
|
|
|
+ width: 32px !important;
|
|
|
+ height: 32px !important;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.male {
|
|
|
+ background: #409eff; /* 蓝色 */
|
|
|
+ }
|
|
|
+ &.female {
|
|
|
+ background: #ff69b4; /* 粉色 */
|
|
|
+ }
|
|
|
+ &.unknown {
|
|
|
+ background: #909399; /* 灰色 */
|
|
|
+ }
|
|
|
+
|
|
|
+ .avatar-initial {
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #fff;
|
|
|
+ width: auto !important;
|
|
|
+ height: auto !important;
|
|
|
+ line-height: 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-info-large {
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ h3 {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-size: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ margin-bottom: 8px;
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.health-status-large {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.status-dot {
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+ border-radius: 50%;
|
|
|
+ box-shadow: 0 0 10px currentcolor;
|
|
|
+}
|
|
|
+
|
|
|
+.status-dot.good {
|
|
|
+ color: $success-color;
|
|
|
+ background: $success-color;
|
|
|
+}
|
|
|
+
|
|
|
+.status-dot.warning {
|
|
|
+ color: $warning-color;
|
|
|
+ background: $warning-color;
|
|
|
+}
|
|
|
+
|
|
|
+.status-dot.error {
|
|
|
+ color: $danger-color;
|
|
|
+ background: $danger-color;
|
|
|
+}
|
|
|
+
|
|
|
+.status-dot.normal {
|
|
|
+ color: $primary-color;
|
|
|
+ background: $primary-color;
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-actions {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-end;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.device-count {
|
|
|
+ padding: 5px 10px;
|
|
|
+ font-size: 14px;
|
|
|
+ background: rgb(255 255 255 / 10%);
|
|
|
+ border-radius: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.add-device-btn {
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+/* 告警操作组 */
|
|
|
+.warning-action-group {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 160px;
|
|
|
+ z-index: 2;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.handle-warning-btn {
|
|
|
+ white-space: nowrap;
|
|
|
+ font-size: 14px;
|
|
|
+ padding: 8px 16px !important;
|
|
|
+}
|
|
|
+
|
|
|
+.handle-warning-content {
|
|
|
+ .elderly-info-section {
|
|
|
+ padding: 15px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border-radius: 8px;
|
|
|
+
|
|
|
+ p {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ color: $text-light;
|
|
|
+
|
|
|
+ strong {
|
|
|
+ color: $primary-color;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 右侧详情区域 */
|
|
|
+.detail-section {
|
|
|
+ display: flex;
|
|
|
+ padding: 25px;
|
|
|
+ overflow-y: auto;
|
|
|
+ background: rgb(26 31 46 / 85%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 12%);
|
|
|
+ border-radius: 16px;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 25px;
|
|
|
+}
|
|
|
+
|
|
|
+.detail-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding-bottom: 15px;
|
|
|
+ border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
|
+
|
|
|
+ h2 {
|
|
|
+ font-size: 28px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .last-update {
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.health-metrics h3,
|
|
|
+.devices-section-large h3 {
|
|
|
+ margin-bottom: 15px;
|
|
|
+ font-size: 22px;
|
|
|
+}
|
|
|
+
|
|
|
+.devices-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.metrics-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ gap: 15px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-card {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 20px;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border-radius: 12px;
|
|
|
+ gap: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-icon {
|
|
|
+ :deep(svg),
|
|
|
+ :deep(span) {
|
|
|
+ width: 32px !important;
|
|
|
+ height: 32px !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.metric-info {
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ .metric-value {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .metric-name {
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.metric-trend {
|
|
|
+ padding: 5px 10px;
|
|
|
+ font-size: 14px;
|
|
|
+ border-radius: 20px;
|
|
|
+
|
|
|
+ &.normal {
|
|
|
+ color: $success-color;
|
|
|
+ background: rgb(38 222 129 / 20%);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.warning {
|
|
|
+ color: $warning-color;
|
|
|
+ background: rgb(253 150 68 / 20%);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.devices-grid-large {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.device-card-large {
|
|
|
+ display: flex;
|
|
|
+ padding: 20px;
|
|
|
+ cursor: default;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 8%);
|
|
|
+ border-radius: 12px;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 15px;
|
|
|
+
|
|
|
+ &.online {
|
|
|
+ border-left: 4px solid $success-color;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.offline {
|
|
|
+ border-left: 4px solid $danger-color;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.warning {
|
|
|
+ cursor: pointer;
|
|
|
+ border-left: 4px solid $warning-color;
|
|
|
+ box-shadow: 0 0 20px rgb(253 150 68 / 25%);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.device-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.device-icon-large {
|
|
|
+ display: flex;
|
|
|
+ width: 50px;
|
|
|
+ height: 50px;
|
|
|
+ background: rgb(26 115 232 / 20%);
|
|
|
+ border-radius: 10px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+
|
|
|
+ :deep(svg),
|
|
|
+ :deep(span) {
|
|
|
+ width: 32px !important;
|
|
|
+ height: 32px !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.device-info-large {
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ h4 {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.device-data {
|
|
|
+ padding: 10px;
|
|
|
+ font-size: 14px;
|
|
|
+ background: rgb(255 255 255 / 3%);
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.device-actions-large {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.detail-placeholder {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: rgb(26 31 46 / 85%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 12%);
|
|
|
+ border-radius: 16px;
|
|
|
+
|
|
|
+ .placeholder-content {
|
|
|
+ text-align: center;
|
|
|
+
|
|
|
+ .placeholder-icon {
|
|
|
+ margin-bottom: 20px;
|
|
|
+
|
|
|
+ :deep(svg),
|
|
|
+ :deep(span) {
|
|
|
+ width: 32px !important;
|
|
|
+ height: 32px !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ h3 {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ font-size: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 底部状态栏 */
|
|
|
+.fullscreen-btn {
|
|
|
+ width: 40px;
|
|
|
+ height: 40px;
|
|
|
+ margin-left: 16px;
|
|
|
+ color: white;
|
|
|
+ cursor: pointer;
|
|
|
+ background: rgb(255 255 255 / 20%);
|
|
|
+ border: none;
|
|
|
+ border-radius: 8px;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.fullscreen-btn:hover {
|
|
|
+ background: rgb(255 255 255 / 30%);
|
|
|
+}
|
|
|
+
|
|
|
+.status-bar {
|
|
|
+ display: flex;
|
|
|
+ padding: 12px 25px;
|
|
|
+ font-size: 14px;
|
|
|
+ background: rgb(26 31 46 / 90%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 15%);
|
|
|
+ border-radius: 12px;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.status-info {
|
|
|
+ display: flex;
|
|
|
+ gap: 20px;
|
|
|
+
|
|
|
+ .status-online {
|
|
|
+ font-weight: 600;
|
|
|
+ color: $success-color;
|
|
|
+ }
|
|
|
+ .status-warning {
|
|
|
+ font-weight: 600;
|
|
|
+ color: $warning-color;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.alert-indicator {
|
|
|
+ padding: 5px 15px;
|
|
|
+ background: rgb(166 177 194 / 20%);
|
|
|
+ border-radius: 20px;
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ color: $warning-color;
|
|
|
+ background: rgb(253 150 68 / 30%);
|
|
|
+ animation: pulse 2s infinite;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 设备详情样式 */
|
|
|
+.device-detail-content {
|
|
|
+ .device-detail-header {
|
|
|
+ display: flex;
|
|
|
+ padding-bottom: 20px;
|
|
|
+ margin-bottom: 25px;
|
|
|
+ border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
|
+ align-items: center;
|
|
|
+ gap: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .device-icon-xlarge {
|
|
|
+ display: flex;
|
|
|
+ width: 80px;
|
|
|
+ height: 80px;
|
|
|
+ background: rgb(26 115 232 / 20%);
|
|
|
+ border-radius: 16px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+
|
|
|
+ :deep(svg),
|
|
|
+ :deep(span) {
|
|
|
+ width: 32px !important;
|
|
|
+ height: 32px !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .device-detail-info {
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ h3 {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ font-size: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .device-data-detail {
|
|
|
+ margin-bottom: 25px;
|
|
|
+
|
|
|
+ h4 {
|
|
|
+ margin-bottom: 15px;
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .data-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ gap: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .data-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 12px 15px;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border-radius: 8px;
|
|
|
+
|
|
|
+ .data-label {
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+
|
|
|
+ .data-value {
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .device-history {
|
|
|
+ h4 {
|
|
|
+ margin-bottom: 15px;
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .history-list {
|
|
|
+ max-height: 200px;
|
|
|
+ overflow-y: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .history-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 10px 15px;
|
|
|
+ border-bottom: 1px solid rgb(255 255 255 / 5%);
|
|
|
+
|
|
|
+ .history-time {
|
|
|
+ font-size: 14px;
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.warning-drawer {
|
|
|
+ color: $text-light;
|
|
|
+ background: linear-gradient(135deg, #111a3a, #0a1124);
|
|
|
+
|
|
|
+ .el-drawer__header {
|
|
|
+ padding-bottom: 10px;
|
|
|
+ margin-bottom: 0;
|
|
|
+ border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-drawer__title {
|
|
|
+ font-size: 20px;
|
|
|
+ color: $text-light;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-drawer__body {
|
|
|
+ padding: 20px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.warning-drawer-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.warning-device-header {
|
|
|
+ display: flex;
|
|
|
+ gap: 15px;
|
|
|
+ align-items: center;
|
|
|
+ padding: 15px;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border-radius: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.warning-device-info {
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ h3 {
|
|
|
+ margin-bottom: 6px;
|
|
|
+ font-size: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ margin-bottom: 8px;
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.warning-history {
|
|
|
+ h4 {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .history-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .history-item {
|
|
|
+ display: grid;
|
|
|
+ padding: 15px;
|
|
|
+ background: rgb(255 255 255 / 4%);
|
|
|
+ border-radius: 12px;
|
|
|
+ gap: 8px;
|
|
|
+ grid-template-columns: 1fr auto;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .history-time {
|
|
|
+ font-size: 14px;
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+
|
|
|
+ .history-event {
|
|
|
+ grid-column: 1 / span 2;
|
|
|
+ font-size: 16px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.warning-summary {
|
|
|
+ padding: 20px;
|
|
|
+ background: rgb(255 255 255 / 4%);
|
|
|
+ border-radius: 12px;
|
|
|
+
|
|
|
+ h3 {
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ color: $text-gray;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 滚动条优化 */
|
|
|
+.elderly-scroll-container::-webkit-scrollbar,
|
|
|
+.detail-section::-webkit-scrollbar,
|
|
|
+.history-list::-webkit-scrollbar {
|
|
|
+ width: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-scroll-container::-webkit-scrollbar-track,
|
|
|
+.detail-section::-webkit-scrollbar-track,
|
|
|
+.history-list::-webkit-scrollbar-track {
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.elderly-scroll-container::-webkit-scrollbar-thumb,
|
|
|
+.detail-section::-webkit-scrollbar-thumb,
|
|
|
+.history-list::-webkit-scrollbar-thumb {
|
|
|
+ background: rgb(26 115 232 / 50%);
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+/* Element Plus 组件大屏适配 */
|
|
|
+:deep(.el-input__wrapper) {
|
|
|
+ padding: 15px 20px !important;
|
|
|
+ font-size: 16px !important;
|
|
|
+ background: rgb(255 255 255 / 10%) !important;
|
|
|
+ border-radius: 10px !important;
|
|
|
+ // box-shadow: 0 0 0 1px var(--el-input-border-color, var(--el-border-color)) inset;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-input__inner) {
|
|
|
+ font-size: 16px !important;
|
|
|
+ color: $text-light !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-input__prefix),
|
|
|
+:deep(.el-input__suffix) {
|
|
|
+ color: $text-light !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-button) {
|
|
|
+ display: flex !important;
|
|
|
+ padding: 12px 24px !important;
|
|
|
+ font-size: 16px !important;
|
|
|
+ font-weight: 500 !important;
|
|
|
+ border-radius: 10px !important;
|
|
|
+ transition: $transition !important;
|
|
|
+ align-items: center !important;
|
|
|
+ gap: 8px !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-button--primary) {
|
|
|
+ background: linear-gradient(90deg, $primary-color, $accent-color) !important;
|
|
|
+ border: none !important;
|
|
|
+ box-shadow: 0 4px 15px rgb(26 115 232 / 30%) !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-button--primary:hover) {
|
|
|
+ transform: translateY(-2px) !important;
|
|
|
+ box-shadow: 0 8px 25px rgb(26 115 232 / 40%) !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-form-item__label) {
|
|
|
+ font-size: 16px !important;
|
|
|
+ font-weight: 500 !important;
|
|
|
+ color: $text-light !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-select .el-input__wrapper),
|
|
|
+:deep(.el-input .el-input__wrapper) {
|
|
|
+ background: rgb(255 255 255 / 10%) !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-select-dropdown) {
|
|
|
+ background: $card-bg !important;
|
|
|
+ border: 1px solid rgb(255 255 255 / 20%) !important;
|
|
|
+ border-radius: 10px !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-select-dropdown__item) {
|
|
|
+ padding: 12px 20px !important;
|
|
|
+ font-size: 16px !important;
|
|
|
+ color: $text-light !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-select-dropdown__item.hover) {
|
|
|
+ background: rgb(255 255 255 / 10%) !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-select-dropdown__item.selected) {
|
|
|
+ background: $primary-color !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-empty__description) {
|
|
|
+ color: $text-gray !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-empty) {
|
|
|
+ padding: 10px !important;
|
|
|
+}
|
|
|
+</style>
|
|
|
+<style lang="scss">
|
|
|
+.large-screen-dialog {
|
|
|
+ margin-top: 8vh !important;
|
|
|
+ background: #1a1f2e !important;
|
|
|
+ border: 1px solid rgb(255 255 255 / 10%) !important;
|
|
|
+ border-radius: 16px !important;
|
|
|
+ box-shadow: 0 20px 60px rgb(0 0 0 / 40%) !important;
|
|
|
+
|
|
|
+ .el-dialog__header {
|
|
|
+ padding: 25px !important;
|
|
|
+ margin-top: 20px;
|
|
|
+ color: white !important;
|
|
|
+ background: linear-gradient(90deg, var(--el-color-primary), #7b61ff) !important;
|
|
|
+ border-radius: 16px 16px 0 0 !important;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-dialog__title {
|
|
|
+ font-size: 20px !important;
|
|
|
+ font-weight: 600 !important;
|
|
|
+ color: white !important;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-dialog__body {
|
|
|
+ padding: 30px !important;
|
|
|
+ color: #fff !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.my-warning-notification {
|
|
|
+ color: #fff !important;
|
|
|
+ background: linear-gradient(135deg, #1e4184 0%, #3469e3 100%) !important;
|
|
|
+ border: 1px solid rgb(42 157 143 / 30%) !important;
|
|
|
+ box-shadow: 0 4px 20px rgb(0 0 0 / 50%) !important;
|
|
|
+
|
|
|
+ .el-notification__group {
|
|
|
+ .el-notification__title {
|
|
|
+ color: #fff !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|