|
|
@@ -1,1396 +1,1052 @@
|
|
|
<template>
|
|
|
- <div class="gwyxqk-screen">
|
|
|
- <!-- ==================== 顶部标题栏 ==================== -->
|
|
|
- <div class="screen-header">
|
|
|
- <div class="header-left">
|
|
|
- <span class="header-time">{{ currentDateTime }}</span>
|
|
|
- </div>
|
|
|
- <div class="header-center">
|
|
|
- <h1 class="header-title">管网运行情况GIS一张图</h1>
|
|
|
+ <div class="app-container gwyxqk-page">
|
|
|
+ <!-- 页头 -->
|
|
|
+ <div class="page-head">
|
|
|
+ <div class="page-head-text">
|
|
|
+ <div class="page-title">管网运行情况GIS一张图</div>
|
|
|
+ <div class="page-sub">
|
|
|
+ 排水监测设备 {{ stats.total }} 台 · 在线 {{ stats.online }} · 离线 {{ stats.offline }} · 报警
|
|
|
+ {{ stats.alarm }}<span v-if="stats.noCoord"> · 未维护坐标 {{ stats.noCoord }} 台</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="header-right">
|
|
|
+ <div class="page-head-actions">
|
|
|
+ <el-input v-model="keyword" class="locate-input" placeholder="设备名称 / 编码" clearable
|
|
|
+ @keyup.enter="locateDevice" />
|
|
|
+ <el-button icon="Aim" @click="locateDevice">定位</el-button>
|
|
|
+ <el-button icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
|
|
|
+ <el-button icon="FullScreen" @click="fitMap">全图</el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- ==================== 主体三栏布局 ==================== -->
|
|
|
- <div class="screen-body">
|
|
|
- <!-- ========== 左侧面板 ========== -->
|
|
|
- <div class="panel-left">
|
|
|
- <!-- 管网概况 -->
|
|
|
- <div class="panel-box">
|
|
|
- <div class="panel-title">
|
|
|
- <span class="title-icon">▶</span> 管网概况
|
|
|
- </div>
|
|
|
- <div class="stats-cards">
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-val">{{ overviewStats.total }}</div>
|
|
|
- <div class="stat-label">监测点总数</div>
|
|
|
- </div>
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-val green">{{ overviewStats.normal }}</div>
|
|
|
- <div class="stat-label">正常运行</div>
|
|
|
- </div>
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-val orange">{{ overviewStats.alarm }}</div>
|
|
|
- <div class="stat-label">异常报警</div>
|
|
|
- </div>
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-val gray">{{ overviewStats.offline }}</div>
|
|
|
- <div class="stat-label">离线设备</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="pie-chart-wrapper">
|
|
|
- <div id="gwyxqkPieChart" class="chart-area"></div>
|
|
|
- <div class="pie-legend">
|
|
|
- <div class="legend-item" v-for="(item, i) in pieData" :key="i">
|
|
|
- <span class="legend-dot" :style="{ background: item.color }"></span>
|
|
|
- <span class="legend-label">{{ item.name }}</span>
|
|
|
- <span class="legend-value">{{ item.value }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 实时数据表 -->
|
|
|
- <div class="panel-box" style="flex: 1;">
|
|
|
- <div class="panel-title">
|
|
|
- <span class="title-icon">▶</span> 实时数据
|
|
|
- <span class="refresh-time">刷新: {{ refreshTime }}</span>
|
|
|
- </div>
|
|
|
- <div class="realtime-table-wrap">
|
|
|
- <table class="dark-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>监测点名称</th>
|
|
|
- <th>流量(m³/h)</th>
|
|
|
- <th>液位(m)</th>
|
|
|
- <th>状态</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- <tr v-for="(row, i) in realtimeData" :key="i">
|
|
|
- <td :title="row.name">{{ row.name }}</td>
|
|
|
- <td>{{ row.flow }}</td>
|
|
|
- <td>{{ row.level }}</td>
|
|
|
- <td>
|
|
|
- <span :class="'status-tag status-' + row.statusClass">{{ row.status }}</span>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ <!-- 运行概况 -->
|
|
|
+ <div class="kpi-row">
|
|
|
+ <div class="kpi-card">
|
|
|
+ <div class="kpi-label">设备总数(台)</div>
|
|
|
+ <div class="kpi-value">{{ stats.total }}</div>
|
|
|
</div>
|
|
|
-
|
|
|
- <!-- ========== 中间地图区域 ========== -->
|
|
|
- <div class="panel-center">
|
|
|
- <div class="map-search">
|
|
|
- <input type="text" placeholder="输入监测点名称" class="search-input" />
|
|
|
- <span class="search-icon">🔍</span>
|
|
|
- </div>
|
|
|
- <div id="gwyxqkMap" class="map-container"></div>
|
|
|
- <div class="pipeline-map-status">
|
|
|
- <span class="pipeline-status-dot"></span>
|
|
|
- 排水管线 {{ gisPipelines.length }} 段
|
|
|
- </div>
|
|
|
- <div v-if="pipelineMapError" class="pipeline-map-error">{{ pipelineMapError }}</div>
|
|
|
+ <div class="kpi-card">
|
|
|
+ <div class="kpi-label">在线设备(台)</div>
|
|
|
+ <div class="kpi-value is-online">{{ stats.online }}</div>
|
|
|
+ </div>
|
|
|
+ <div class="kpi-card">
|
|
|
+ <div class="kpi-label">离线设备(台)</div>
|
|
|
+ <div class="kpi-value is-offline">{{ stats.offline }}</div>
|
|
|
</div>
|
|
|
+ <div class="kpi-card">
|
|
|
+ <div class="kpi-label">报警设备(台)</div>
|
|
|
+ <div class="kpi-value is-alarm">{{ stats.alarm }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
- <!-- ========== 右侧面板 ========== -->
|
|
|
- <div class="panel-right">
|
|
|
- <!-- 监测曲线 -->
|
|
|
- <div class="panel-box">
|
|
|
- <div class="panel-title">
|
|
|
- <span class="title-icon">▶</span> 监测曲线
|
|
|
- <select v-model="selectedCurveDevice" class="device-select" @change="onCurveDeviceChange">
|
|
|
- <option value="">请选择设备</option>
|
|
|
- <option v-for="d in linkedDeviceList" :key="d.equipmentId" :value="d.equipmentCode">{{ d.equipmentName }}</option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- <div class="curve-tabs">
|
|
|
- <span
|
|
|
- v-for="tab in curveTabs"
|
|
|
- :key="tab.value"
|
|
|
- :class="['curve-tab', { active: activeCurveTab === tab.value }]"
|
|
|
- @click="activeCurveTab = tab.value"
|
|
|
- >{{ tab.label }}</span>
|
|
|
- </div>
|
|
|
- <div id="gwyxqkLineChart" class="chart-area-sm"></div>
|
|
|
+ <!-- 一张图 -->
|
|
|
+ <el-card shadow="never" class="map-card" :body-style="{ padding: '0' }">
|
|
|
+ <div class="map-wrap" v-loading="loading">
|
|
|
+ <GisMapCanvas ref="mapRef" :points="mapPoints" :lines="mapLines" cluster-label="设备集合"
|
|
|
+ :marker-size="32" @ready="onMapReady" @error="onMapError" @point-click="openDevice"
|
|
|
+ @cluster-click="openCluster" @line-click="openLine" />
|
|
|
+
|
|
|
+ <div class="map-legend">
|
|
|
+ <div class="legend-title">点击切换显示</div>
|
|
|
+ <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.online }"
|
|
|
+ @click="legendVisible.online = !legendVisible.online">
|
|
|
+ <i class="legend-dot dot-online"></i>在线({{ stats.online - stats.alarmOnline }})
|
|
|
+ </button>
|
|
|
+ <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.offline }"
|
|
|
+ @click="legendVisible.offline = !legendVisible.offline">
|
|
|
+ <i class="legend-dot dot-offline"></i>离线({{ stats.offline - stats.alarmOffline }})
|
|
|
+ </button>
|
|
|
+ <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.alarm }"
|
|
|
+ @click="legendVisible.alarm = !legendVisible.alarm">
|
|
|
+ <i class="legend-dot dot-alarm"></i>报警({{ stats.alarm }})
|
|
|
+ </button>
|
|
|
+ <div class="legend-split"></div>
|
|
|
+ <div class="legend-line"><i style="background: #2f9be8"></i>雨水管</div>
|
|
|
+ <div class="legend-line"><i style="background: #35be83"></i>污水管</div>
|
|
|
+ <div class="legend-line"><i style="background: #909399"></i>未识别</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 流量统计 -->
|
|
|
- <div class="panel-box">
|
|
|
- <div class="panel-title">
|
|
|
- <span class="title-icon">▶</span> 流量统计
|
|
|
- </div>
|
|
|
- <div id="gwyxqkBarChart" class="chart-area-sm"></div>
|
|
|
+ <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError">
|
|
|
+ <el-button link type="primary" @click="loadData">重试</el-button>
|
|
|
+ </el-alert>
|
|
|
+ <el-empty v-if="!loading && !mapError && !mapPoints.length" class="map-empty" :image-size="80"
|
|
|
+ description="当前没有可定位的排水设备,请先在设备台账维护经纬度" />
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 设备详情:按设备类型差异化展示 -->
|
|
|
+ <el-drawer v-model="detailVisible" size="min(760px, 100vw)" append-to-body class="device-drawer">
|
|
|
+ <template #header>
|
|
|
+ <div class="drawer-title">
|
|
|
+ <span class="drawer-name">{{ profile.base?.equipmentName || '设备详情' }}</span>
|
|
|
+ <template v-if="profile.base">
|
|
|
+ <span class="tag" :class="'tag-' + deviceStatusKey(profile.base)">{{ deviceStatusText(profile.base) }}</span>
|
|
|
+ <span class="drawer-type">{{ profile.base.equipmentTypeName || '未分类' }}</span>
|
|
|
+ </template>
|
|
|
</div>
|
|
|
-
|
|
|
- <!-- 报警信息 -->
|
|
|
- <div class="panel-box">
|
|
|
- <div class="panel-title">
|
|
|
- <span class="title-icon">▶</span> 报警信息
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div v-loading="detailLoading" class="drawer-body">
|
|
|
+ <template v-if="profile.base">
|
|
|
+ <!-- 基础信息 -->
|
|
|
+ <div class="dev-block">
|
|
|
+ <div class="dev-grid">
|
|
|
+ <div>设备编码:<b>{{ profile.base.equipmentCode || '—' }}</b></div>
|
|
|
+ <div>设备类型:<b>{{ profile.base.equipmentTypeName || '—' }}</b></div>
|
|
|
+ <div>所属片区:<b>{{ districtLabel(profile.base.district) }}</b></div>
|
|
|
+ <div>安装位置:<b>{{ profile.base.equipmentLocation || '—' }}</b></div>
|
|
|
+ <div>经度:<b>{{ profile.base.longitude ?? '—' }}</b></div>
|
|
|
+ <div>纬度:<b>{{ profile.base.latitude ?? '—' }}</b></div>
|
|
|
+ <div class="full">最后通信:<b>{{ profile.lastCommTime || '—' }}</b></div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="alert-stats">
|
|
|
- <div class="alert-stat-card">
|
|
|
- <div class="alert-stat-val">{{ alarmStats.total }}</div>
|
|
|
- <div class="alert-stat-label">报警总数</div>
|
|
|
+
|
|
|
+ <!-- 实时数据:列由设备类型对应的采集表决定(与远程测控同一取值口径) -->
|
|
|
+ <div class="dev-section">
|
|
|
+ <div class="sec-head">
|
|
|
+ <span class="sec-bar"></span>
|
|
|
+ <span class="sec-title">实时数据</span>
|
|
|
+ <span class="sec-hint">{{ metricHint }}</span>
|
|
|
</div>
|
|
|
- <div class="alert-stat-card">
|
|
|
- <div class="alert-stat-val green">{{ alarmStats.handled }}</div>
|
|
|
- <div class="alert-stat-label">已处理</div>
|
|
|
+ <div v-if="latestMetrics.length" class="metric-grid">
|
|
|
+ <div v-for="m in latestMetrics" :key="m.key" class="metric-card" :class="{ 'is-alarm': m.alarm }">
|
|
|
+ <div class="metric-label" :title="m.label">{{ m.label }}</div>
|
|
|
+ <div class="metric-value">
|
|
|
+ {{ m.value }}<small v-if="m.unit">{{ m.unit }}</small>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="alert-stat-card">
|
|
|
- <div class="alert-stat-val red">{{ alarmStats.unhandled }}</div>
|
|
|
- <div class="alert-stat-label">未处理</div>
|
|
|
+ <div v-else class="sec-empty">
|
|
|
+ {{ recordRows.length ? '该设备上报的是音视频附件,明细见下方采集记录' : '该设备暂无采集数据(设备类型未配置原始采集表或尚未上报)' }}
|
|
|
</div>
|
|
|
+ <div v-if="latestTime" class="metric-time">采集时间:{{ latestTime }}</div>
|
|
|
</div>
|
|
|
- <div class="alert-table-wrap">
|
|
|
- <table class="dark-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>序号</th>
|
|
|
- <th>监测点</th>
|
|
|
- <th>时间</th>
|
|
|
- <th>类型</th>
|
|
|
- <th>状态</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- <tr v-for="(row, i) in alarmList" :key="i">
|
|
|
- <td>{{ i + 1 }}</td>
|
|
|
- <td :title="row.point">{{ row.point }}</td>
|
|
|
- <td>{{ row.time }}</td>
|
|
|
- <td>{{ row.type }}</td>
|
|
|
- <td>
|
|
|
- <span :class="'status-tag status-' + row.handleClass">{{ row.handleStatus }}</span>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
+
|
|
|
+ <!-- 采集记录 -->
|
|
|
+ <div class="dev-section">
|
|
|
+ <div class="sec-head">
|
|
|
+ <span class="sec-bar"></span>
|
|
|
+ <span class="sec-title">采集记录</span>
|
|
|
+ <span class="sec-hint">最近 {{ recordRows.length }} 条</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="recordColumns.length" class="record-wrap">
|
|
|
+ <el-table :data="pagedRecordRows" size="small" max-height="420">
|
|
|
+ <el-table-column v-for="c in recordColumns" :key="c.key" :label="columnLabel(c)"
|
|
|
+ :min-width="c.key === 'create_time' ? 168 : (c.key === 'filepath' ? 170 : 104)">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <span v-if="c.key === 'create_time'">{{ row.createTime || '—' }}</span>
|
|
|
+ <span v-else-if="c.key === 'filepath'" class="record-audio-cell">
|
|
|
+ <audio v-if="hasCellValue(row, c.key)" class="record-audio" controls preload="none"
|
|
|
+ :src="buildMinioAttachmentUrl(valueOf(row, c.key))" @play="pauseOtherAudios"></audio>
|
|
|
+ <span v-else>—</span>
|
|
|
+ </span>
|
|
|
+ <span v-else :class="{ 'cell-alarm': alarmOf(row, c.key) }">{{ valueOf(row, c.key) }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <template #empty><div class="sec-empty">暂无采集记录</div></template>
|
|
|
+ </el-table>
|
|
|
+ <el-pagination v-if="recordRows.length > recordPageSize" class="record-pager" small
|
|
|
+ layout="prev, pager, next" :total="recordRows.length" :page-size="recordPageSize"
|
|
|
+ v-model:current-page="recordPage" />
|
|
|
+ </div>
|
|
|
+ <div v-else class="sec-empty">该设备类型未配置采集记录表</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </template>
|
|
|
+ <el-empty v-else-if="!detailLoading" description="未找到设备档案" />
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </el-drawer>
|
|
|
+
|
|
|
+ <!-- 聚合点位 -->
|
|
|
+ <el-dialog v-model="clusterVisible" title="聚合设备" width="min(460px, 94vw)" append-to-body>
|
|
|
+ <div class="cluster-list">
|
|
|
+ <button v-for="item in clusterDevices" :key="item.id" type="button" class="cluster-item"
|
|
|
+ @click="pickClusterDevice(item)">
|
|
|
+ <span class="cluster-dot" :class="dotClassOfKey(item.statusKey)"></span>
|
|
|
+ <span class="cluster-name">{{ item.name }}</span>
|
|
|
+ <span class="cluster-status">{{ statusTextOfKey(item.statusKey) }}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div v-if="!clusterDevices.length" class="sec-empty">该聚合点没有设备</div>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 管线详情 -->
|
|
|
+ <el-dialog :model-value="Boolean(lineDetail)" title="排水管线" width="min(520px, 94vw)" append-to-body
|
|
|
+ @update:model-value="(v) => !v && (lineDetail = null)">
|
|
|
+ <el-descriptions v-if="lineDetail" :column="2" border size="small">
|
|
|
+ <el-descriptions-item label="管线名称" :span="2">{{ lineDetail.name || '—' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="设施编号">{{ lineDetail.code || '—' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="管线类别">
|
|
|
+ <span class="sewage-tag" :style="{ color: sewageColor(lineDetail.level) }">
|
|
|
+ {{ sewageLabel(lineDetail.level) }}
|
|
|
+ </span>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="管径(mm)">{{ lineDetail.diameter ?? '—' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="长度(m)">{{ lineDetail.length ?? '—' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="材质">{{ lineDetail.material || '—' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="所在道路">{{ lineDetail.location || '—' }}</el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup name="Gwyxqk">
|
|
|
-import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
|
|
|
-import * as echarts from 'echarts'
|
|
|
-import locationIcon from '@/assets/images/location.png'
|
|
|
-import { getEquipmentPage, getAlarmDataPage, getRadarDataByCode, getRadarDataHistory } from '@/api/drainage'
|
|
|
-import { getGisPipeLines } from '@/api/pipeNetwork/basic'
|
|
|
-import { convertCoord } from '@/utils/coordTransform'
|
|
|
-import { whenMapReady } from '@/utils/mapApi'
|
|
|
-
|
|
|
-// ==================== 实时时间 ====================
|
|
|
-const currentDateTime = ref('')
|
|
|
-let timer = null
|
|
|
-
|
|
|
-function updateTime() {
|
|
|
- const now = new Date()
|
|
|
- const pad = n => String(n).padStart(2, '0')
|
|
|
- const weekDays = ['日', '一', '二', '三', '四', '五', '六']
|
|
|
- currentDateTime.value = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())} 星期${weekDays[now.getDay()]}`
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== 刷新时间 ====================
|
|
|
-const refreshTime = ref('')
|
|
|
-function updateRefreshTime() {
|
|
|
- const now = new Date()
|
|
|
- const pad = n => String(n).padStart(2, '0')
|
|
|
- refreshTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== 管网概况(从API数据计算)====================
|
|
|
-const overviewStats = reactive({ total: 0, normal: 0, alarm: 0, offline: 0 })
|
|
|
-
|
|
|
-const pieData = ref([
|
|
|
- { name: '正常运行', value: 0, color: '#67c23a' },
|
|
|
- { name: '预警', value: 0, color: '#e6a23c' },
|
|
|
- { name: '报警', value: 0, color: '#f56c6c' },
|
|
|
- { name: '离线', value: 0, color: '#909399' }
|
|
|
-])
|
|
|
-
|
|
|
-// ==================== 实时数据(从radar_data API加载)====================
|
|
|
-const realtimeData = ref([])
|
|
|
-
|
|
|
-// ==================== 监测曲线 ====================
|
|
|
-const curveTabs = [
|
|
|
- { label: '24小时', value: '24h' },
|
|
|
- { label: '7天', value: '7d' },
|
|
|
- { label: '15天', value: '15d' },
|
|
|
- { label: '30天', value: '30d' }
|
|
|
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
|
|
|
+import { useDict } from '@/utils/dict'
|
|
|
+import { getEquipmentGisPoints, getGisPipeLines } from '@/api/pipeNetwork/basic'
|
|
|
+import { getEquipmentProfile, pagePerceptionEquipment } from '@/api/pipeNetwork/perception'
|
|
|
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
|
|
|
+
|
|
|
+/** 排水设备根类型:equipment_type.parent_id = 2,后端按根节点动态展开整棵子树 */
|
|
|
+const ROOT_TYPE_ID = 2
|
|
|
+/** 排水管线 networkType:1-排水 */
|
|
|
+const DRAINAGE_NETWORK_TYPE = '1'
|
|
|
+
|
|
|
+const { drainage_district: districtDict } = useDict('drainage_district')
|
|
|
+
|
|
|
+const DISTRICT_FALLBACK = [
|
|
|
+ { value: '0', label: '城南' },
|
|
|
+ { value: '1', label: '城北' },
|
|
|
+ { value: '2', label: '太常' }
|
|
|
]
|
|
|
-const activeCurveTab = ref('24h')
|
|
|
|
|
|
-// ==================== 设备下拉与曲线选择 ====================
|
|
|
-const linkedDeviceList = ref([])
|
|
|
-const selectedCurveDevice = ref('')
|
|
|
+// 雨污配色与「排水管网管理」一致:0-污水(青) 1-雨水(蓝),未识别灰
|
|
|
+const SEWAGE_COLORS = { '0': '#35be83', '1': '#2f9be8' }
|
|
|
+const SEWAGE_LABELS = { '0': '污水', '1': '雨水' }
|
|
|
+
|
|
|
+// ==================== 数据源 ====================
|
|
|
+const loading = ref(false)
|
|
|
+const mapError = ref('')
|
|
|
+const mapRef = ref(null)
|
|
|
+/** 带经纬度的设备(地图打点数据源) */
|
|
|
+const devices = ref([])
|
|
|
+/** 台账总数(含未维护经纬度的设备),取不到时退化为地图点数 */
|
|
|
+const ledgerTotal = ref(0)
|
|
|
+const mapLines = ref([])
|
|
|
+
|
|
|
+const legendVisible = reactive({ online: true, offline: true, alarm: true })
|
|
|
+
|
|
|
+/** 三态口径:报警优先,其次在线,最后离线(与远程测控列表一致) */
|
|
|
+function deviceStatusKey(device = {}) {
|
|
|
+ if (Number(device.alarmStatus) === 1) return 'alarm'
|
|
|
+ return Number(device.onlineStatus) === 1 ? 'normal' : 'offline'
|
|
|
+}
|
|
|
+
|
|
|
+function deviceStatusText(device = {}) {
|
|
|
+ const key = deviceStatusKey(device)
|
|
|
+ return key === 'alarm' ? '报警' : key === 'normal' ? '在线' : '离线'
|
|
|
+}
|
|
|
+
|
|
|
+function statusTextOfKey(key) {
|
|
|
+ return key === 'alarm' ? '报警' : key === 'offline' ? '离线' : '在线'
|
|
|
+}
|
|
|
+
|
|
|
+/** 状态 → 圆点样式(图例与聚合列表共用) */
|
|
|
+function dotClassOfKey(key) {
|
|
|
+ if (key === 'alarm') return 'dot-alarm'
|
|
|
+ return key === 'offline' ? 'dot-offline' : 'dot-online'
|
|
|
+}
|
|
|
+
|
|
|
+const stats = computed(() => {
|
|
|
+ const total = Math.max(ledgerTotal.value, devices.value.length)
|
|
|
+ const online = devices.value.filter((d) => Number(d.onlineStatus) === 1).length
|
|
|
+ const alarm = devices.value.filter((d) => Number(d.alarmStatus) === 1).length
|
|
|
+ const alarmOnline = devices.value.filter(
|
|
|
+ (d) => Number(d.alarmStatus) === 1 && Number(d.onlineStatus) === 1
|
|
|
+ ).length
|
|
|
+ const alarmOffline = alarm - alarmOnline
|
|
|
+ return {
|
|
|
+ total,
|
|
|
+ online,
|
|
|
+ offline: devices.value.length - online,
|
|
|
+ alarm,
|
|
|
+ alarmOnline,
|
|
|
+ alarmOffline,
|
|
|
+ noCoord: Math.max(0, total - devices.value.length)
|
|
|
+ }
|
|
|
+})
|
|
|
|
|
|
-// ==================== 报警信息(从API加载)====================
|
|
|
-const alarmStats = reactive({ total: 0, handled: 0, unhandled: 0 })
|
|
|
-const alarmList = ref([])
|
|
|
+// LEGEND_KEY:状态 → 图例项,隐藏图例即隐藏对应点位
|
|
|
+const LEGEND_KEY = { normal: 'online', offline: 'offline', alarm: 'alarm' }
|
|
|
|
|
|
-// ==================== 地图数据(从API加载)====================
|
|
|
-const mapPoints = ref([])
|
|
|
-const gisPipelines = ref([])
|
|
|
-const pipelineMapError = ref('')
|
|
|
+const mapPoints = computed(() =>
|
|
|
+ devices.value
|
|
|
+ .map((device) => {
|
|
|
+ const coord = toCoord(device.longitude, device.latitude)
|
|
|
+ if (!coord) return null
|
|
|
+ return {
|
|
|
+ id: device.equipmentId,
|
|
|
+ name: device.equipmentName || device.equipmentCode || '排水设备',
|
|
|
+ ...coord,
|
|
|
+ statusKey: deviceStatusKey(device),
|
|
|
+ raw: device
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .filter((point) => point && legendVisible[LEGEND_KEY[point.statusKey]])
|
|
|
+)
|
|
|
|
|
|
-// ==================== 设备状态映射 ====================
|
|
|
-function getEquipStatusInfo(equipment) {
|
|
|
- if (equipment.onlineStatus === 0) return { status: '离线', color: '#909399' }
|
|
|
- if (equipment.alarmStatus === 1) return { status: '报警', color: '#f56c6c' }
|
|
|
- if (equipment.currentStatus === 3) return { status: '预警', color: '#e6a23c' }
|
|
|
- return { status: '正常', color: '#67c23a' }
|
|
|
+function toCoord(lng, lat) {
|
|
|
+ const x = Number(lng)
|
|
|
+ const y = Number(lat)
|
|
|
+ if (!Number.isFinite(x) || !Number.isFinite(y) || (x === 0 && y === 0)) return null
|
|
|
+ return { lng: x, lat: y }
|
|
|
}
|
|
|
|
|
|
-function getCurrentStatusText(val) {
|
|
|
- const map = { 1: '在用', 2: '闲置', 3: '维修', 4: '报废', 5: '待入库' }
|
|
|
- return map[val] || '未知'
|
|
|
+function sewageColor(level) {
|
|
|
+ const key = level === null || level === undefined ? '' : String(level)
|
|
|
+ return SEWAGE_COLORS[key] || '#909399'
|
|
|
}
|
|
|
|
|
|
-// ==================== 分页响应兼容提取 ====================
|
|
|
-function extractPage(res) {
|
|
|
- const page = res.code !== undefined ? res.data : res
|
|
|
- return page.records || page.rows || []
|
|
|
+function sewageLabel(level) {
|
|
|
+ const key = level === null || level === undefined ? '' : String(level)
|
|
|
+ return SEWAGE_LABELS[key] || '未识别'
|
|
|
}
|
|
|
|
|
|
-// ==================== 数据加载 ====================
|
|
|
-async function loadMapData() {
|
|
|
+// ==================== 加载 ====================
|
|
|
+async function loadData() {
|
|
|
+ loading.value = true
|
|
|
+ mapError.value = ''
|
|
|
try {
|
|
|
- await loadGisPipelineData()
|
|
|
- // 1. 旧排水管网关联数据已下线,页面不再加载管网-设备关联;
|
|
|
- // 地图管线由 loadGisPipelineData 从 /api/gis/pipe-lines 提供。
|
|
|
-
|
|
|
- // 2. 获取所有排水设备
|
|
|
- const eqRes = await getEquipmentPage(1, 999, { equipmentTypeId: 'drainage' })
|
|
|
- const allEquipment = extractPage(eqRes)
|
|
|
- console.log('[gwyxqk] 排水设备总数:', allEquipment.length)
|
|
|
-
|
|
|
- // 3. 旧表下线后以全部排水设备作为统计、点位与曲线数据源
|
|
|
- const linkedEquipment = allEquipment
|
|
|
-
|
|
|
- // 构建关联设备列表(供下拉选择)
|
|
|
- linkedDeviceList.value = linkedEquipment.map(eq => ({
|
|
|
- equipmentId: eq.equipmentId || eq.id,
|
|
|
- equipmentCode: eq.equipmentCode || '',
|
|
|
- equipmentName: eq.equipmentName || '未命名设备'
|
|
|
- }))
|
|
|
- if (linkedDeviceList.value.length > 0 && !selectedCurveDevice.value) {
|
|
|
- selectedCurveDevice.value = linkedDeviceList.value[0].equipmentCode
|
|
|
- }
|
|
|
-
|
|
|
- // 5. 获取报警数据
|
|
|
- let alarmRecords = []
|
|
|
- try {
|
|
|
- const alarmRes = await getAlarmDataPage(1, 999, { deviceType: 'drainage' })
|
|
|
- alarmRecords = extractPage(alarmRes)
|
|
|
- } catch (alarmErr) {
|
|
|
- console.warn('[gwyxqk] 报警数据加载失败:', alarmErr)
|
|
|
- }
|
|
|
-
|
|
|
- // 构建报警设备编码集合
|
|
|
- const alarmDeviceCodes = new Set(alarmRecords.map(a => a.deviceCode))
|
|
|
-
|
|
|
- // 6. 确定用于展示和统计的设备列表(优先关联设备,无关联则用全部设备)
|
|
|
- const displayEquipment = linkedEquipment.length > 0 ? linkedEquipment : allEquipment
|
|
|
-
|
|
|
- // 7. 统计概况
|
|
|
- const totalCount = displayEquipment.length
|
|
|
- const offlineCount = displayEquipment.filter(e => e.onlineStatus === 0).length
|
|
|
- const alarmCount = displayEquipment.filter(e => e.onlineStatus === 1 && alarmDeviceCodes.has(e.equipmentCode)).length
|
|
|
- const warningCount = displayEquipment.filter(e => e.onlineStatus === 1 && !alarmDeviceCodes.has(e.equipmentCode) && e.currentStatus === 3).length
|
|
|
- const normalCount = totalCount - offlineCount - alarmCount - warningCount
|
|
|
-
|
|
|
- overviewStats.total = totalCount
|
|
|
- overviewStats.offline = offlineCount
|
|
|
- overviewStats.alarm = alarmCount
|
|
|
- overviewStats.normal = normalCount >= 0 ? normalCount : 0
|
|
|
-
|
|
|
- pieData.value = [
|
|
|
- { name: '正常运行', value: overviewStats.normal, color: '#67c23a' },
|
|
|
- { name: '预警', value: warningCount, color: '#e6a23c' },
|
|
|
- { name: '报警', value: overviewStats.alarm, color: '#f56c6c' },
|
|
|
- { name: '离线', value: overviewStats.offline, color: '#909399' }
|
|
|
- ]
|
|
|
-
|
|
|
- // 8. 转换设备为地图点位(使用设备名称作为监测点名称)
|
|
|
- mapPoints.value = displayEquipment
|
|
|
- .filter(e => {
|
|
|
- const lng = parseFloat(e.longitude)
|
|
|
- const lat = parseFloat(e.latitude)
|
|
|
- return lng && lat && !isNaN(lng) && !isNaN(lat)
|
|
|
- })
|
|
|
- .map(e => {
|
|
|
- const statusInfo = getEquipStatusInfo(e)
|
|
|
- return {
|
|
|
- name: e.equipmentName || '未命名设备',
|
|
|
- lng: parseFloat(e.longitude),
|
|
|
- lat: parseFloat(e.latitude),
|
|
|
- status: statusInfo.status,
|
|
|
- color: statusInfo.color,
|
|
|
- equipment: e
|
|
|
- }
|
|
|
- })
|
|
|
- console.log('[gwyxqk] mapPoints生成:', mapPoints.value.length, '个点', mapPoints.value.map(p => p.name + '(' + p.lng + ',' + p.lat + ')'))
|
|
|
-
|
|
|
- // 9. 加载实时数据
|
|
|
- await loadRealtimeData(displayEquipment)
|
|
|
-
|
|
|
- // 10. 加载报警信息
|
|
|
- loadAlarmData(alarmRecords, displayEquipment)
|
|
|
-
|
|
|
- console.log('[gwyxqk] 数据加载完成')
|
|
|
+ const [pointRes, ledgerRes] = await Promise.all([
|
|
|
+ getEquipmentGisPoints({ rootTypeId: ROOT_TYPE_ID }),
|
|
|
+ // 只要总数:台账里未维护坐标的设备也应计入「设备总数」
|
|
|
+ pagePerceptionEquipment({ pageNum: 1, pageSize: 1, rootTypeId: ROOT_TYPE_ID }).catch(() => null)
|
|
|
+ ])
|
|
|
+ const list = pointRes?.data ?? pointRes
|
|
|
+ devices.value = (Array.isArray(list) ? list : []).filter(
|
|
|
+ (item) => toCoord(item.longitude, item.latitude) !== null
|
|
|
+ )
|
|
|
+ const ledgerPage = ledgerRes?.data ?? ledgerRes
|
|
|
+ ledgerTotal.value = Number(ledgerPage?.total ?? devices.value.length) || devices.value.length
|
|
|
+ await loadPipeLines()
|
|
|
+ await nextTick()
|
|
|
+ mapRef.value?.fitBounds()
|
|
|
} catch (error) {
|
|
|
- console.error('加载地图数据失败:', error)
|
|
|
+ console.error('[gwyxqk] 设备点位加载失败', error)
|
|
|
+ mapError.value = '设备点位数据加载失败,请点击刷新重试'
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function loadGisPipelineData() {
|
|
|
- pipelineMapError.value = ''
|
|
|
+/** 排水管网:GeoJSON → GisMapCanvas 线;颜色仍按雨污类型(库中 status 全为 0,不按状态着色) */
|
|
|
+async function loadPipeLines() {
|
|
|
try {
|
|
|
- const response = await getGisPipeLines({ networkType: '1' })
|
|
|
+ const response = await getGisPipeLines({ networkType: DRAINAGE_NETWORK_TYPE })
|
|
|
const features = Array.isArray(response?.data?.features) ? response.data.features : []
|
|
|
- gisPipelines.value = features.map(feature => {
|
|
|
- const properties = feature?.properties || {}
|
|
|
- const coordinates = feature?.geometry?.type === 'LineString' ? feature.geometry.coordinates : []
|
|
|
- const points = coordinates
|
|
|
- .map(item => ({ lng: Number(item?.[0]), lat: Number(item?.[1]) }))
|
|
|
- .filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
|
|
|
- return {
|
|
|
- id: properties.networkId,
|
|
|
- code: properties.networkCode,
|
|
|
- name: properties.networkName || properties.networkCode || '排水管网',
|
|
|
- level: properties.networkLevel,
|
|
|
- diameter: properties.diameter,
|
|
|
- length: properties.length,
|
|
|
- material: properties.material,
|
|
|
- location: properties.location,
|
|
|
- points
|
|
|
- }
|
|
|
- }).filter(line => line.points.length >= 2)
|
|
|
- if (!gisPipelines.value.length) pipelineMapError.value = '暂无带完整起止坐标的排水管网数据'
|
|
|
+ mapLines.value = features
|
|
|
+ .map((feature) => {
|
|
|
+ const properties = feature?.properties || {}
|
|
|
+ const coordinates = feature?.geometry?.type === 'LineString' ? feature.geometry.coordinates : []
|
|
|
+ const points = coordinates
|
|
|
+ .map((item) => ({ lng: Number(item?.[0]), lat: Number(item?.[1]) }))
|
|
|
+ .filter((item) => Number.isFinite(item.lng) && Number.isFinite(item.lat))
|
|
|
+ return {
|
|
|
+ id: properties.networkId,
|
|
|
+ name: properties.networkName || properties.networkCode || '排水管线',
|
|
|
+ code: properties.networkCode,
|
|
|
+ level: properties.networkLevel,
|
|
|
+ diameter: properties.diameter,
|
|
|
+ length: properties.length,
|
|
|
+ material: properties.material,
|
|
|
+ location: properties.location,
|
|
|
+ points,
|
|
|
+ color: sewageColor(properties.networkLevel),
|
|
|
+ strokeWeight: 4,
|
|
|
+ strokeOpacity: 0.85
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .filter((line) => line.points.length >= 2)
|
|
|
} catch (error) {
|
|
|
- gisPipelines.value = []
|
|
|
- pipelineMapError.value = '排水管网数据加载失败,请检查后端服务。'
|
|
|
- console.error('[gwyxqk] 排水管网 GIS 线路加载失败:', error)
|
|
|
+ console.warn('[gwyxqk] 排水管网 GIS 线路加载失败', error)
|
|
|
+ mapLines.value = []
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// ==================== 实时数据加载(/latest 返回单条 RadarData 对象)====================
|
|
|
-async function loadRealtimeData(equipmentList) {
|
|
|
- console.log('[gwyxqk] loadRealtimeData 收到设备:', equipmentList.length, '条', equipmentList.map(e => e.equipmentCode))
|
|
|
- const rows = []
|
|
|
- for (const eq of equipmentList) {
|
|
|
- const code = eq.equipmentCode
|
|
|
- const name = eq.equipmentName || '未命名设备'
|
|
|
- let flow = '--'
|
|
|
- let level = '--'
|
|
|
- let status = '离线'
|
|
|
- let statusClass = 'offline'
|
|
|
-
|
|
|
- if (eq.onlineStatus === 1) {
|
|
|
- status = '正常'
|
|
|
- statusClass = 'normal'
|
|
|
- }
|
|
|
-
|
|
|
- if (code) {
|
|
|
- try {
|
|
|
- const radarRes = await getRadarDataByCode(code)
|
|
|
- console.log('[gwyxqk] 设备', code, 'radar /latest 原始响应:', radarRes)
|
|
|
- // /latest 接口直接返回单条 RadarData 对象
|
|
|
- const latest = radarRes.code !== undefined ? radarRes.data : radarRes
|
|
|
- if (latest) {
|
|
|
- // 兼容驼峰和下划线两种字段命名
|
|
|
- const waterLevel = latest.waterLevel ?? latest.water_level
|
|
|
- const instantFlow = latest.meter1InstantFlow ?? latest.meter1_instant_flow
|
|
|
- const flowSpeed = latest.flowSpeed ?? latest.flow_speed
|
|
|
- level = waterLevel != null ? Number(waterLevel).toFixed(2) : '--'
|
|
|
- flow = instantFlow != null ? Number(instantFlow).toFixed(1) :
|
|
|
- flowSpeed != null ? Number(flowSpeed).toFixed(1) : '--'
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.warn('[gwyxqk] 设备', code, 'radar_data查询失败:', err)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- rows.push({ name, flow, level, status, statusClass })
|
|
|
- }
|
|
|
- realtimeData.value = rows
|
|
|
- console.log('[gwyxqk] 实时数据加载完成:', rows.length, '条')
|
|
|
+function onMapReady() {
|
|
|
+ nextTick(() => setTimeout(fitMap, 120))
|
|
|
}
|
|
|
|
|
|
-// ==================== 报警数据加载 ====================
|
|
|
-function loadAlarmData(alarmRecords, equipmentList) {
|
|
|
- // 构建设备编码→名称映射
|
|
|
- const codeNameMap = {}
|
|
|
- equipmentList.forEach(eq => {
|
|
|
- if (eq.equipmentCode) codeNameMap[eq.equipmentCode] = eq.equipmentName || eq.equipmentCode
|
|
|
- })
|
|
|
+function onMapError(message) {
|
|
|
+ mapError.value = message || '天地图加载失败,请检查网络或地图服务配置'
|
|
|
+}
|
|
|
|
|
|
- // 过滤只保留关联设备的报警
|
|
|
- const linkedCodes = new Set(Object.keys(codeNameMap))
|
|
|
- const filtered = linkedCodes.size > 0
|
|
|
- ? alarmRecords.filter(a => linkedCodes.has(a.deviceCode))
|
|
|
- : alarmRecords
|
|
|
-
|
|
|
- // 统计
|
|
|
- const total = filtered.length
|
|
|
- const handled = filtered.filter(a => a.handleStatus === 1 || a.handleStatus === '已处理' || a.status === 1).length
|
|
|
- const unhandled = total - handled
|
|
|
-
|
|
|
- alarmStats.total = total
|
|
|
- alarmStats.handled = handled
|
|
|
- alarmStats.unhandled = unhandled
|
|
|
-
|
|
|
- // 列表
|
|
|
- alarmList.value = filtered.slice(0, 50).map(a => {
|
|
|
- const isHandled = a.handleStatus === 1 || a.handleStatus === '已处理' || a.status === 1
|
|
|
- return {
|
|
|
- point: codeNameMap[a.deviceCode] || a.deviceCode || '-',
|
|
|
- time: a.createTime || '-',
|
|
|
- type: a.warningType || a.alarmType || '-',
|
|
|
- handleStatus: isHandled ? '已处理' : '未处理',
|
|
|
- handleClass: isHandled ? 'normal' : 'alarm'
|
|
|
- }
|
|
|
- })
|
|
|
- console.log('[gwyxqk] 报警数据加载完成:', total, '条')
|
|
|
+function fitMap() {
|
|
|
+ mapRef.value?.fitBounds()
|
|
|
}
|
|
|
|
|
|
-// ==================== 地图初始化 ====================
|
|
|
-let mapInstance = null
|
|
|
+// ==================== 定位 ====================
|
|
|
+const keyword = ref('')
|
|
|
|
|
|
-function initMap() {
|
|
|
- const container = document.getElementById('gwyxqkMap')
|
|
|
- if (!container || !window.TdtMap) {
|
|
|
- console.warn('TdtMap 未加载或容器不存在')
|
|
|
+function locateDevice() {
|
|
|
+ const kw = keyword.value.trim()
|
|
|
+ if (!kw) {
|
|
|
+ ElMessage.warning('请输入设备名称或设备编码')
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
- try {
|
|
|
- mapInstance = new TdtMap.Map('gwyxqkMap')
|
|
|
- // 默认中心点,有数据时以第一个设备点为中心
|
|
|
- let centerLng = 110.393
|
|
|
- let centerLat = 28.452
|
|
|
- if (mapPoints.value.length > 0) {
|
|
|
- centerLng = mapPoints.value[0].lng
|
|
|
- centerLat = mapPoints.value[0].lat
|
|
|
- }
|
|
|
- const [mapCenterLng, mapCenterLat] = convertCoord(centerLng, centerLat)
|
|
|
- const centerPoint = new TdtMap.Point(mapCenterLng, mapCenterLat)
|
|
|
- mapInstance.centerAndZoom(centerPoint, 14)
|
|
|
- mapInstance.enableScrollWheelZoom(true)
|
|
|
-
|
|
|
- const viewportPoints = drawGisPipelines()
|
|
|
- // 叠加实时数据中的设备点位,并让视野覆盖管线和设备。
|
|
|
- console.log('[gwyxqk] 初始化地图,排水管线数量:', gisPipelines.value.length, '设备点数量:', mapPoints.value.length)
|
|
|
- mapPoints.value.forEach(point => {
|
|
|
- addMapMarker(point)
|
|
|
- const [lng, lat] = convertCoord(point.lng, point.lat)
|
|
|
- viewportPoints.push(new TdtMap.Point(lng, lat))
|
|
|
- })
|
|
|
- if (viewportPoints.length > 0) mapInstance.setViewport(viewportPoints)
|
|
|
- } catch (e) {
|
|
|
- console.error('天地图初始化失败:', e)
|
|
|
+ const matched = devices.value.filter(
|
|
|
+ (d) => String(d.equipmentCode || '') === kw || String(d.equipmentName || '').includes(kw)
|
|
|
+ )
|
|
|
+ if (!matched.length) {
|
|
|
+ ElMessage.warning('未匹配到已维护坐标的设备')
|
|
|
+ return
|
|
|
}
|
|
|
+ const first = matched[0]
|
|
|
+ const point = mapPoints.value.find((p) => p.id === first.equipmentId)
|
|
|
+ if (point) mapRef.value?.locatePoint(point)
|
|
|
+ if (matched.length > 1) ElMessage.info(`匹配到 ${matched.length} 台设备,已定位第一台`)
|
|
|
+ openDevice({ id: first.equipmentId, raw: first })
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 设备详情(取值口径同终端设备远程测控) ====================
|
|
|
+const detailVisible = ref(false)
|
|
|
+const detailLoading = ref(false)
|
|
|
+const profile = ref(emptyProfile())
|
|
|
+const recordPage = ref(1)
|
|
|
+const recordPageSize = 10
|
|
|
+
|
|
|
+const recordView = computed(() => profile.value.recordView || { columns: [], rows: [] })
|
|
|
+const recordColumns = computed(() => recordView.value.columns || [])
|
|
|
+const recordRows = computed(() => recordView.value.rows || [])
|
|
|
+const pagedRecordRows = computed(() => {
|
|
|
+ const start = (recordPage.value - 1) * recordPageSize
|
|
|
+ return recordRows.value.slice(start, start + recordPageSize)
|
|
|
+})
|
|
|
+
|
|
|
+const metricHint = computed(() => {
|
|
|
+ const columns = recordColumns.value.filter((c) => c.key !== 'create_time' && c.key !== 'filepath')
|
|
|
+ if (!columns.length) return ''
|
|
|
+ return columns.map((c) => c.label).filter(Boolean).join(' / ')
|
|
|
+})
|
|
|
+
|
|
|
+function emptyProfile() {
|
|
|
+ return { base: null, lastCommTime: '', recordView: { columns: [], rows: [] } }
|
|
|
}
|
|
|
|
|
|
-function getGisPipelineColor(level) {
|
|
|
- const text = String(level || '').toLowerCase()
|
|
|
- if (text.includes('主') || text.includes('一级') || text.includes('main')) return '#00e5ff'
|
|
|
- if (text.includes('支') || text.includes('二级') || text.includes('branch')) return '#409eff'
|
|
|
- return '#67c23a'
|
|
|
+function columnMap(profileValue) {
|
|
|
+ const map = {}
|
|
|
+ ;(profileValue?.recordView?.columns || []).forEach((col) => {
|
|
|
+ map[col.key] = col
|
|
|
+ })
|
|
|
+ return map
|
|
|
}
|
|
|
|
|
|
-function escapeMapText(value) {
|
|
|
- return String(value ?? '')
|
|
|
- .replace(/&/g, '&')
|
|
|
- .replace(/</g, '<')
|
|
|
- .replace(/>/g, '>')
|
|
|
- .replace(/"/g, '"')
|
|
|
- .replace(/'/g, ''')
|
|
|
+function openDevice(point) {
|
|
|
+ const device = point?.raw || point
|
|
|
+ const target = device?.equipmentId || point?.id || device?.id
|
|
|
+ if (!target) return
|
|
|
+ detailVisible.value = true
|
|
|
+ detailLoading.value = true
|
|
|
+ profile.value = emptyProfile()
|
|
|
+ recordPage.value = 1
|
|
|
+ loadProfile(target)
|
|
|
}
|
|
|
|
|
|
-function drawGisPipelines() {
|
|
|
- const viewportPoints = []
|
|
|
- gisPipelines.value.forEach(line => {
|
|
|
- const linePoints = line.points.map(point => {
|
|
|
- const [lng, lat] = convertCoord(point.lng, point.lat)
|
|
|
- return new TdtMap.Point(lng, lat)
|
|
|
+function loadProfile(equipmentId) {
|
|
|
+ return getEquipmentProfile(equipmentId)
|
|
|
+ .then((res) => {
|
|
|
+ profile.value = res?.data || emptyProfile()
|
|
|
})
|
|
|
- const polyline = new TdtMap.Polyline(linePoints, {
|
|
|
- strokeColor: getGisPipelineColor(line.level),
|
|
|
- strokeWeight: 5,
|
|
|
- strokeOpacity: 0.9
|
|
|
+ .catch((error) => {
|
|
|
+ console.warn('[gwyxqk] 设备档案加载失败', equipmentId, error)
|
|
|
+ profile.value = emptyProfile()
|
|
|
+ ElMessage.error('设备档案加载失败,请稍后重试')
|
|
|
})
|
|
|
- polyline.addEventListener('click', event => {
|
|
|
- event?.domEvent?.stopPropagation?.()
|
|
|
- const content = `<div style="min-width:220px;color:#303133;line-height:1.8">
|
|
|
- <div style="font-size:15px;font-weight:700;color:#1677ff;margin-bottom:6px">${escapeMapText(line.name)}</div>
|
|
|
- <div>设施编号:${escapeMapText(line.code || '-')}</div>
|
|
|
- <div>管网等级:${escapeMapText(line.level || '-')}</div>
|
|
|
- <div>管径:${escapeMapText(line.diameter ?? '-')} mm</div>
|
|
|
- <div>长度:${escapeMapText(line.length ?? '-')} m</div>
|
|
|
- <div>材质:${escapeMapText(line.material || '-')}</div>
|
|
|
- <div>位置:${escapeMapText(line.location || '-')}</div>
|
|
|
- </div>`
|
|
|
- mapInstance.openInfoWindow(new TdtMap.InfoWindow(content, { width: 280, enableMessage: false }), linePoints[0])
|
|
|
+ .finally(() => {
|
|
|
+ detailLoading.value = false
|
|
|
})
|
|
|
- mapInstance.addOverlay(polyline)
|
|
|
- viewportPoints.push(...linePoints)
|
|
|
- })
|
|
|
- return viewportPoints
|
|
|
}
|
|
|
|
|
|
-function addMapMarker(point) {
|
|
|
- const color = point.color || '#409eff'
|
|
|
- const [lng, lat] = convertCoord(point.lng, point.lat)
|
|
|
- const bPoint = new TdtMap.Point(lng, lat)
|
|
|
-
|
|
|
- const html = `<div style="text-align:center;cursor:pointer;">
|
|
|
- <div style="color:#fff;background:${color};border-radius:4px;padding:2px 6px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;">${point.name}</div>
|
|
|
- <div style="color:#fff;font-size:10px;background:rgba(0,0,0,0.6);border-radius:3px;padding:1px 4px;margin-bottom:2px;">状态:${point.status}</div>
|
|
|
- <div><img src="${locationIcon}" style="width:24px;height:24px;display:block;margin:0 auto;"/></div>
|
|
|
- </div>`
|
|
|
+/**
|
|
|
+ * 最新采集值 → 指标卡片,列与单位完全由设备类型对应的原始采集表决定(与远程测控同一口径)。
|
|
|
+ * 宽表(layout=wide):首行即最新一条,value 为纯值、单位在列定义里,仅无纯值时用 display 兜底。
|
|
|
+ * 竖表(layout=long,水质一读数多因子):一行一个因子,行按时间倒序,按因子取首次出现即最新值。
|
|
|
+ */
|
|
|
+const latest = computed(() => {
|
|
|
+ const view = recordView.value
|
|
|
+ const rows = view.rows || []
|
|
|
+ if (!rows.length) return { metrics: [], time: '' }
|
|
|
+ const columns = columnMap(profile.value)
|
|
|
+
|
|
|
+ if (view.layout === 'long') {
|
|
|
+ // 竖表一行一个监测因子,按因子取各自最新一次值(行按时间倒序,首次出现即最新)
|
|
|
+ const factorMap = new Map()
|
|
|
+ rows.forEach((row) => {
|
|
|
+ ;(row.cells || []).forEach((cell) => {
|
|
|
+ if (cell.key === 'filepath') return
|
|
|
+ const { label, value } = splitLongDisplay(cell.display, columns[cell.key]?.label)
|
|
|
+ if (!factorMap.has(label)) {
|
|
|
+ factorMap.set(label, {
|
|
|
+ key: `${cell.key}-${label}`,
|
|
|
+ label,
|
|
|
+ value,
|
|
|
+ unit: '',
|
|
|
+ alarm: Boolean(cell.alarm)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ return { metrics: [...factorMap.values()], time: rows[0]?.createTime || '' }
|
|
|
+ }
|
|
|
|
|
|
- const label = new TdtMap.Label(html, {
|
|
|
- position: bPoint,
|
|
|
- offset: new TdtMap.Size(-30, -55)
|
|
|
- })
|
|
|
- label.setStyle({
|
|
|
- border: 'none',
|
|
|
- background: 'transparent',
|
|
|
- padding: '0'
|
|
|
- })
|
|
|
- mapInstance.addOverlay(label)
|
|
|
-
|
|
|
- // 点击标记弹出设备信息窗口
|
|
|
- label.addEventListener('click', function () {
|
|
|
- const eq = point.equipment || {}
|
|
|
- const infoContent = `<div style="color:#333;font-size:13px;padding:4px;">
|
|
|
- <div style="font-weight:bold;margin-bottom:6px;">${point.name}</div>
|
|
|
- <div>设备编号:${eq.equipmentCode || '-'}</div>
|
|
|
- <div>安装位置:${eq.equipmentLocation || '-'}</div>
|
|
|
- <div>设备状态:<b style="color:${color}">${getCurrentStatusText(eq.currentStatus)}</b></div>
|
|
|
- <div>报警状态:<b style="color:${eq.alarmStatus === 1 ? '#f56c6c' : '#67c23a'}">${eq.alarmStatus === 1 ? '报警' : '正常'}</b></div>
|
|
|
- <div>在线状态:<b style="color:${eq.onlineStatus === 1 ? '#67c23a' : '#909399'}">${eq.onlineStatus === 1 ? '在线' : '离线'}</b></div>
|
|
|
- <div>最近维护:${eq.lastMaintainDate || '-'}</div>
|
|
|
- <div>下次维护:${eq.nextMaintainDate || '-'}</div>
|
|
|
- <div>经度:${point.lng}</div>
|
|
|
- <div>纬度:${point.lat}</div>
|
|
|
- </div>`
|
|
|
- const infoWindow = new TdtMap.InfoWindow(infoContent, {
|
|
|
- width: 240,
|
|
|
- title: ''
|
|
|
+ const first = rows[0]
|
|
|
+ const metrics = (first.cells || [])
|
|
|
+ .filter((cell) => cell.key !== 'filepath')
|
|
|
+ .map((cell) => {
|
|
|
+ const col = columns[cell.key] || {}
|
|
|
+ const hasValue = cell.value !== null && cell.value !== undefined && cell.value !== ''
|
|
|
+ return {
|
|
|
+ key: cell.key,
|
|
|
+ label: col.label || cell.key,
|
|
|
+ value: hasValue ? cell.value : cell.display || '—',
|
|
|
+ unit: hasValue ? col.unit || '' : '',
|
|
|
+ alarm: Boolean(cell.alarm)
|
|
|
+ }
|
|
|
})
|
|
|
- mapInstance.openInfoWindow(infoWindow, bPoint)
|
|
|
- })
|
|
|
-}
|
|
|
+ return { metrics, time: first.createTime || '' }
|
|
|
+})
|
|
|
|
|
|
-// ==================== ECharts 图表 ====================
|
|
|
-let pieChart = null
|
|
|
-let lineChart = null
|
|
|
-let barChart = null
|
|
|
-
|
|
|
-function initPieChart() {
|
|
|
- const dom = document.getElementById('gwyxqkPieChart')
|
|
|
- if (!dom) return
|
|
|
- pieChart = echarts.init(dom)
|
|
|
- const total = pieData.value.reduce((s, d) => s + d.value, 0)
|
|
|
- pieChart.setOption({
|
|
|
- tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
|
|
- legend: { show: false },
|
|
|
- graphic: [{
|
|
|
- type: 'text',
|
|
|
- left: 'center',
|
|
|
- top: '38%',
|
|
|
- style: { text: '管网状态', fill: '#a0cfff', fontSize: 12, textAlign: 'center' }
|
|
|
- }, {
|
|
|
- type: 'text',
|
|
|
- left: 'center',
|
|
|
- top: '52%',
|
|
|
- style: { text: String(total), fill: '#fff', fontSize: 22, fontWeight: 'bold', textAlign: 'center' }
|
|
|
- }],
|
|
|
- series: [{
|
|
|
- type: 'pie',
|
|
|
- radius: ['45%', '70%'],
|
|
|
- center: ['50%', '50%'],
|
|
|
- label: { show: false },
|
|
|
- labelLine: { show: false },
|
|
|
- data: pieData.value.map(d => ({ name: d.name, value: d.value, itemStyle: { color: d.color } })),
|
|
|
- emphasis: { scale: true, scaleSize: 5 }
|
|
|
- }]
|
|
|
- })
|
|
|
+/**
|
|
|
+ * 竖表展示串形如「氨氮:2mg/L」(名称 + 值含单位),指标卡片需要拆成标签与值。
|
|
|
+ * 无冒号时退回列标题,整串作为值。
|
|
|
+ */
|
|
|
+function splitLongDisplay(display, fallbackLabel) {
|
|
|
+ const text = String(display ?? '').trim()
|
|
|
+ const hit = text.match(/^([^::]+)[::]\s*(.*)$/)
|
|
|
+ if (!hit) return { label: fallbackLabel || '指标', value: text || '—' }
|
|
|
+ return { label: hit[1].trim(), value: hit[2].trim() || '—' }
|
|
|
}
|
|
|
|
|
|
-// ==================== 监测曲线(/page 分页接口,按 startTime/endTime 查询)====================
|
|
|
-const curveChartData = ref({ labels: [], flowData: [], levelData: [] })
|
|
|
+const latestMetrics = computed(() => latest.value.metrics)
|
|
|
+const latestTime = computed(() => latest.value.time)
|
|
|
|
|
|
-// 时间格式化工具:yyyy-MM-dd HH:mm:ss
|
|
|
-function fmtDateTime(d) {
|
|
|
- const pad = n => String(n).padStart(2, '0')
|
|
|
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
|
+function columnLabel(col) {
|
|
|
+ return col.unit ? `${col.label}(${col.unit})` : col.label
|
|
|
}
|
|
|
|
|
|
-async function loadCurveHistory() {
|
|
|
- const code = selectedCurveDevice.value
|
|
|
- if (!code) {
|
|
|
- curveChartData.value = { labels: [], flowData: [], levelData: [] }
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 根据 activeCurveTab 计算时间范围
|
|
|
- const now = new Date()
|
|
|
- const start = new Date()
|
|
|
- const tab = activeCurveTab.value
|
|
|
- if (tab === '24h') start.setHours(start.getHours() - 24)
|
|
|
- else if (tab === '7d') start.setDate(start.getDate() - 7)
|
|
|
- else if (tab === '15d') start.setDate(start.getDate() - 15)
|
|
|
- else start.setDate(start.getDate() - 30)
|
|
|
-
|
|
|
- const startTime = fmtDateTime(start)
|
|
|
- const endTime = fmtDateTime(now)
|
|
|
-
|
|
|
- try {
|
|
|
- const res = await getRadarDataHistory(code, startTime, endTime)
|
|
|
- console.log('[gwyxqk] 曲线历史数据原始响应:', res)
|
|
|
- const pageData = res.code !== undefined ? res.data : res
|
|
|
- const records = pageData.records || pageData.rows || (Array.isArray(pageData) ? pageData : [])
|
|
|
- console.log('[gwyxqk] 曲线历史数据:', code, startTime, '~', endTime, records.length, '条')
|
|
|
-
|
|
|
- const labels = []
|
|
|
- const flowData = []
|
|
|
- const levelData = []
|
|
|
-
|
|
|
- records.forEach(r => {
|
|
|
- const t = r.createTime || r.create_time || ''
|
|
|
- // 24h 显示 HH:mm,其他显示 MM-dd
|
|
|
- if (tab === '24h') {
|
|
|
- labels.push(t.substring(11, 16))
|
|
|
- } else {
|
|
|
- labels.push(t.substring(5, 10))
|
|
|
- }
|
|
|
- // 兼容驼峰和下划线字段命名
|
|
|
- const instantFlow = r.meter1InstantFlow ?? r.meter1_instant_flow
|
|
|
- const waterLevel = r.waterLevel ?? r.water_level
|
|
|
- flowData.push(instantFlow != null ? Number(instantFlow) : 0)
|
|
|
- levelData.push(waterLevel != null ? Number(waterLevel) : 0)
|
|
|
- })
|
|
|
+function valueOf(row, key) {
|
|
|
+ const cell = (row.cells || []).find((item) => item.key === key)
|
|
|
+ if (!cell) return '—'
|
|
|
+ // 竖表展示串已含指标名与单位,宽表用不含单位的纯值
|
|
|
+ if (recordView.value.layout === 'long') return cell.display || '—'
|
|
|
+ return cell.value !== null && cell.value !== undefined && cell.value !== '' ? cell.value : cell.display || '—'
|
|
|
+}
|
|
|
|
|
|
- // 如果数据为空,显示空图表提示
|
|
|
- if (labels.length === 0) {
|
|
|
- labels.push('暂无数据')
|
|
|
- flowData.push(0)
|
|
|
- levelData.push(0)
|
|
|
- }
|
|
|
-
|
|
|
- curveChartData.value = { labels, flowData, levelData }
|
|
|
- } catch (err) {
|
|
|
- console.warn('[gwyxqk] 曲线历史数据加载失败:', err)
|
|
|
- curveChartData.value = { labels: [], flowData: [], levelData: [] }
|
|
|
- }
|
|
|
+function hasCellValue(row, key) {
|
|
|
+ const value = valueOf(row, key)
|
|
|
+ return Boolean(value) && value !== '—'
|
|
|
}
|
|
|
|
|
|
-function onCurveDeviceChange() {
|
|
|
- console.log('[gwyxqk] 切换曲线设备:', selectedCurveDevice.value)
|
|
|
- loadCurveHistory().then(() => {
|
|
|
- initLineChart()
|
|
|
- })
|
|
|
+function alarmOf(row, key) {
|
|
|
+ const cell = (row.cells || []).find((item) => item.key === key)
|
|
|
+ return cell ? Boolean(cell.alarm) : false
|
|
|
}
|
|
|
|
|
|
-function initLineChart(tab, labels, flowData, levelData) {
|
|
|
- const dom = document.getElementById('gwyxqkLineChart')
|
|
|
- if (!dom) return
|
|
|
- if (!lineChart) {
|
|
|
- lineChart = echarts.init(dom)
|
|
|
- }
|
|
|
- const currentTab = tab || activeCurveTab.value
|
|
|
- // 优先使用外部传入数据,否则从 curveChartData 取
|
|
|
- const chartLabels = labels || curveChartData.value.labels
|
|
|
- const chartFlow = flowData || curveChartData.value.flowData
|
|
|
- const chartLevel = levelData || curveChartData.value.levelData
|
|
|
- lineChart.setOption({
|
|
|
- tooltip: { trigger: 'axis' },
|
|
|
- legend: {
|
|
|
- data: ['流量(m³/h)', '液位(m)'],
|
|
|
- textStyle: { color: '#7ec8e3', fontSize: 10 },
|
|
|
- top: 0,
|
|
|
- right: 10,
|
|
|
- itemWidth: 12,
|
|
|
- itemHeight: 8
|
|
|
- },
|
|
|
- grid: { left: 40, right: 40, top: 30, bottom: 25 },
|
|
|
- xAxis: {
|
|
|
- type: 'category',
|
|
|
- data: chartLabels,
|
|
|
- axisLabel: { color: '#7ec8e3', fontSize: 9, interval: currentTab === '30d' ? 4 : currentTab === '15d' ? 2 : 'auto' },
|
|
|
- axisLine: { lineStyle: { color: '#1a3a5c' } }
|
|
|
- },
|
|
|
- yAxis: [
|
|
|
- {
|
|
|
- type: 'value',
|
|
|
- name: 'm³/h',
|
|
|
- nameTextStyle: { color: '#7ec8e3', fontSize: 10 },
|
|
|
- axisLabel: { color: '#7ec8e3', fontSize: 10 },
|
|
|
- splitLine: { lineStyle: { color: '#1a3a5c' } },
|
|
|
- axisLine: { show: false }
|
|
|
- },
|
|
|
- {
|
|
|
- type: 'value',
|
|
|
- name: 'm',
|
|
|
- nameTextStyle: { color: '#e6a23c', fontSize: 10 },
|
|
|
- axisLabel: { color: '#e6a23c', fontSize: 10 },
|
|
|
- splitLine: { show: false },
|
|
|
- axisLine: { show: false }
|
|
|
- }
|
|
|
- ],
|
|
|
- series: [
|
|
|
- {
|
|
|
- name: '流量(m³/h)',
|
|
|
- type: 'line',
|
|
|
- data: chartFlow,
|
|
|
- smooth: true,
|
|
|
- symbol: 'none',
|
|
|
- lineStyle: { color: '#409eff', width: 2 },
|
|
|
- areaStyle: {
|
|
|
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
- { offset: 0, color: 'rgba(64,158,255,0.3)' },
|
|
|
- { offset: 1, color: 'rgba(64,158,255,0.02)' }
|
|
|
- ])
|
|
|
- }
|
|
|
- },
|
|
|
- {
|
|
|
- name: '液位(m)',
|
|
|
- type: 'line',
|
|
|
- yAxisIndex: 1,
|
|
|
- data: chartLevel,
|
|
|
- smooth: true,
|
|
|
- symbol: 'none',
|
|
|
- lineStyle: { color: '#e6a23c', width: 2 },
|
|
|
- areaStyle: {
|
|
|
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
- { offset: 0, color: 'rgba(230,162,60,0.3)' },
|
|
|
- { offset: 1, color: 'rgba(230,162,60,0.02)' }
|
|
|
- ])
|
|
|
- }
|
|
|
- }
|
|
|
- ]
|
|
|
+// 采集记录音频:一次只播一条
|
|
|
+function pauseOtherAudios(event) {
|
|
|
+ document.querySelectorAll('audio.record-audio').forEach((el) => {
|
|
|
+ if (el !== event.target) el.pause()
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-// ==================== 流量统计(各设备最新流量柱状图,/latest 返回单条对象)====================
|
|
|
-async function initBarChart() {
|
|
|
- const dom = document.getElementById('gwyxqkBarChart')
|
|
|
- if (!dom) return
|
|
|
- if (!barChart) {
|
|
|
- barChart = echarts.init(dom)
|
|
|
- }
|
|
|
-
|
|
|
- const devices = linkedDeviceList.value
|
|
|
- if (devices.length === 0) {
|
|
|
- barChart.setOption({
|
|
|
- xAxis: { type: 'category', data: [] },
|
|
|
- yAxis: { type: 'value' },
|
|
|
- series: [{ type: 'bar', data: [] }]
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
+// ==================== 聚合点位 ====================
|
|
|
+const clusterVisible = ref(false)
|
|
|
+const clusterDevices = ref([])
|
|
|
|
|
|
- const names = []
|
|
|
- const values = []
|
|
|
- for (const d of devices) {
|
|
|
- names.push(d.equipmentName || d.equipmentCode)
|
|
|
- let flowVal = 0
|
|
|
- if (d.equipmentCode) {
|
|
|
- try {
|
|
|
- const radarRes = await getRadarDataByCode(d.equipmentCode)
|
|
|
- console.log('[gwyxqk] 柱状图设备', d.equipmentCode, '/latest 响应:', radarRes)
|
|
|
- // /latest 直接返回单条对象
|
|
|
- const latest = radarRes.code !== undefined ? radarRes.data : radarRes
|
|
|
- if (latest) {
|
|
|
- const instantFlow = latest.meter1InstantFlow ?? latest.meter1_instant_flow
|
|
|
- const flowSpeed = latest.flowSpeed ?? latest.flow_speed
|
|
|
- flowVal = instantFlow != null ? Number(instantFlow) :
|
|
|
- flowSpeed != null ? Number(flowSpeed) : 0
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.warn('[gwyxqk] 柱状图设备', d.equipmentCode, '数据加载失败:', err)
|
|
|
- }
|
|
|
- }
|
|
|
- values.push(Math.round(flowVal * 10) / 10)
|
|
|
- }
|
|
|
+function openCluster(cluster) {
|
|
|
+ // GisMapCanvas 抛出的是聚合对象 { points, count, bounds },兼容直接给数组的情况
|
|
|
+ const points = Array.isArray(cluster) ? cluster : cluster?.points || []
|
|
|
+ clusterDevices.value = points
|
|
|
+ clusterVisible.value = points.length > 0
|
|
|
+}
|
|
|
|
|
|
- console.log('[gwyxqk] 流量统计柱状图数据:', names.length, '个设备')
|
|
|
- barChart.setOption({
|
|
|
- tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
- grid: { left: 50, right: 10, top: 10, bottom: 30 },
|
|
|
- xAxis: {
|
|
|
- type: 'category',
|
|
|
- data: names,
|
|
|
- axisLabel: { color: '#7ec8e3', fontSize: 9, rotate: 20 },
|
|
|
- axisLine: { lineStyle: { color: '#1a3a5c' } }
|
|
|
- },
|
|
|
- yAxis: {
|
|
|
- type: 'value',
|
|
|
- name: 'm³/h',
|
|
|
- nameTextStyle: { color: '#7ec8e3', fontSize: 10 },
|
|
|
- axisLabel: { color: '#7ec8e3', fontSize: 10 },
|
|
|
- splitLine: { lineStyle: { color: '#1a3a5c' } },
|
|
|
- axisLine: { show: false }
|
|
|
- },
|
|
|
- series: [{
|
|
|
- type: 'bar',
|
|
|
- data: values,
|
|
|
- barWidth: 14,
|
|
|
- itemStyle: {
|
|
|
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
- { offset: 0, color: '#409eff' },
|
|
|
- { offset: 1, color: '#1a3a5c' }
|
|
|
- ]),
|
|
|
- borderRadius: [2, 2, 0, 0]
|
|
|
- }
|
|
|
- }]
|
|
|
- })
|
|
|
+function pickClusterDevice(item) {
|
|
|
+ clusterVisible.value = false
|
|
|
+ openDevice(item)
|
|
|
}
|
|
|
|
|
|
-// ==================== 监测曲线Tab切换 ====================
|
|
|
-watch(activeCurveTab, (val) => {
|
|
|
- console.log('[gwyxqk] 切换曲线Tab:', val)
|
|
|
- if (selectedCurveDevice.value) {
|
|
|
- loadCurveHistory().then(() => {
|
|
|
- initLineChart(val)
|
|
|
- })
|
|
|
- } else {
|
|
|
- initLineChart(val)
|
|
|
- }
|
|
|
-})
|
|
|
+// ==================== 管线详情 ====================
|
|
|
+const lineDetail = ref(null)
|
|
|
|
|
|
-// ==================== 生命周期 ====================
|
|
|
-onMounted(async () => {
|
|
|
- updateTime()
|
|
|
- timer = setInterval(() => {
|
|
|
- updateTime()
|
|
|
- updateRefreshTime()
|
|
|
- }, 1000)
|
|
|
- updateRefreshTime()
|
|
|
-
|
|
|
- // 先从API加载数据
|
|
|
- await loadMapData()
|
|
|
-
|
|
|
- // 延迟初始化确保 DOM 渲染完成
|
|
|
- setTimeout(() => {
|
|
|
- whenMapReady(initMap, () => { pipelineMapError.value = '天地图加载超时,请检查网络或地图配置。' })
|
|
|
- initPieChart()
|
|
|
- // 加载默认设备的曲线历史数据
|
|
|
- if (selectedCurveDevice.value) {
|
|
|
- loadCurveHistory().then(() => {
|
|
|
- initLineChart()
|
|
|
- })
|
|
|
- } else {
|
|
|
- initLineChart()
|
|
|
- }
|
|
|
- initBarChart()
|
|
|
- }, 300)
|
|
|
-})
|
|
|
+function openLine(line) {
|
|
|
+ lineDetail.value = line || null
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 通用 ====================
|
|
|
+function districtLabel(value) {
|
|
|
+ if (value === null || value === undefined || value === '') return '—'
|
|
|
+ const hit = (districtDict?.value || []).find((item) => String(item.value) === String(value))
|
|
|
+ if (hit) return hit.label
|
|
|
+ const fallback = DISTRICT_FALLBACK.find((item) => item.value === String(value))
|
|
|
+ return fallback ? fallback.label : String(value)
|
|
|
+}
|
|
|
|
|
|
-onUnmounted(() => {
|
|
|
- if (timer) clearInterval(timer)
|
|
|
- if (pieChart) pieChart.dispose()
|
|
|
- if (lineChart) lineChart.dispose()
|
|
|
- if (barChart) barChart.dispose()
|
|
|
- if (mapInstance) mapInstance = null
|
|
|
+onMounted(() => {
|
|
|
+ loadData()
|
|
|
})
|
|
|
|
|
|
-// 监听窗口大小变化,重绘图表
|
|
|
-window.addEventListener('resize', () => {
|
|
|
- if (pieChart) pieChart.resize()
|
|
|
- if (lineChart) lineChart.resize()
|
|
|
- if (barChart) barChart.resize()
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ document.querySelectorAll('audio.record-audio').forEach((el) => el.pause())
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
-<style scoped>
|
|
|
-/* ==================== 全局暗色大屏 ==================== */
|
|
|
-.gwyxqk-screen {
|
|
|
- width: 100%;
|
|
|
- height: calc(100vh - 84px);
|
|
|
- background: #0a1929;
|
|
|
- color: #fff;
|
|
|
- font-family: 'Microsoft YaHei', sans-serif;
|
|
|
- overflow: hidden;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
+<style scoped lang="scss">
|
|
|
+.gwyxqk-page {
|
|
|
+ padding: 20px;
|
|
|
+ background: #f9f9f9;
|
|
|
+ min-height: calc(100vh - 84px);
|
|
|
}
|
|
|
|
|
|
-/* ==================== 顶部标题栏 ==================== */
|
|
|
-.screen-header {
|
|
|
- height: 64px;
|
|
|
- background: linear-gradient(180deg, #0d2137 0%, #0a1929 100%);
|
|
|
- border-bottom: 1px solid rgba(64, 158, 255, 0.3);
|
|
|
+/* ==================== 页头 ==================== */
|
|
|
+.page-head {
|
|
|
display: flex;
|
|
|
- align-items: center;
|
|
|
+ align-items: flex-start;
|
|
|
justify-content: space-between;
|
|
|
- padding: 0 24px;
|
|
|
- flex-shrink: 0;
|
|
|
- position: relative;
|
|
|
+ gap: 16px;
|
|
|
+ margin-bottom: 16px;
|
|
|
}
|
|
|
|
|
|
-.screen-header::after {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- bottom: 0;
|
|
|
- left: 10%;
|
|
|
- right: 10%;
|
|
|
- height: 1px;
|
|
|
- background: linear-gradient(90deg, transparent, #409eff, transparent);
|
|
|
+.page-title {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #0a1628;
|
|
|
}
|
|
|
|
|
|
-.header-left {
|
|
|
- flex: 1;
|
|
|
- text-align: left;
|
|
|
+.page-sub {
|
|
|
+ margin-top: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #97a3b7;
|
|
|
}
|
|
|
|
|
|
-.header-time {
|
|
|
- font-size: 15px;
|
|
|
- color: #a0cfff;
|
|
|
- letter-spacing: 1px;
|
|
|
+.page-head-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
-.header-center {
|
|
|
- flex: 2;
|
|
|
- text-align: center;
|
|
|
+.locate-input {
|
|
|
+ width: 200px;
|
|
|
}
|
|
|
|
|
|
-.header-title {
|
|
|
- font-size: 26px;
|
|
|
- font-weight: 700;
|
|
|
- margin: 0;
|
|
|
- letter-spacing: 4px;
|
|
|
- background: linear-gradient(180deg, #fff 0%, #7ec8e3 100%);
|
|
|
- -webkit-background-clip: text;
|
|
|
- -webkit-text-fill-color: transparent;
|
|
|
- text-shadow: none;
|
|
|
+/* ==================== 概况卡片 ==================== */
|
|
|
+.kpi-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 16px;
|
|
|
}
|
|
|
|
|
|
-.header-right {
|
|
|
+.kpi-card {
|
|
|
flex: 1;
|
|
|
- text-align: right;
|
|
|
+ padding: 14px 16px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 主体三栏 ==================== */
|
|
|
-.screen-body {
|
|
|
- flex: 1;
|
|
|
- display: flex;
|
|
|
- padding: 12px;
|
|
|
- gap: 12px;
|
|
|
- min-height: 0;
|
|
|
+.kpi-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #97a3b7;
|
|
|
}
|
|
|
|
|
|
-.panel-left,
|
|
|
-.panel-right {
|
|
|
- width: 22%;
|
|
|
- min-width: 260px;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: 12px;
|
|
|
- min-height: 0;
|
|
|
+.kpi-value {
|
|
|
+ margin-top: 6px;
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #0a1628;
|
|
|
+ line-height: 1.2;
|
|
|
}
|
|
|
|
|
|
-.panel-center {
|
|
|
- flex: 1;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- min-height: 0;
|
|
|
- position: relative;
|
|
|
+.kpi-value.is-online {
|
|
|
+ color: #16a34a;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 面板盒子 ==================== */
|
|
|
-.panel-box {
|
|
|
- background: rgba(6, 30, 65, 0.8);
|
|
|
- border: 1px solid rgba(64, 158, 255, 0.2);
|
|
|
- border-radius: 6px;
|
|
|
- padding: 12px;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- min-height: 0;
|
|
|
+.kpi-value.is-offline {
|
|
|
+ color: #64748b;
|
|
|
+}
|
|
|
+
|
|
|
+.kpi-value.is-alarm {
|
|
|
+ color: #ef4444;
|
|
|
+}
|
|
|
+
|
|
|
+/* ==================== 地图 ==================== */
|
|
|
+.map-card {
|
|
|
+ border-radius: 8px;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
-.panel-title {
|
|
|
- font-size: 14px;
|
|
|
- font-weight: 600;
|
|
|
- color: #7ec8e3;
|
|
|
- margin-bottom: 10px;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 4px;
|
|
|
- flex-shrink: 0;
|
|
|
+.map-wrap {
|
|
|
+ position: relative;
|
|
|
+ height: calc(100vh - 320px);
|
|
|
+ min-height: 460px;
|
|
|
}
|
|
|
|
|
|
-.title-icon {
|
|
|
- color: #409eff;
|
|
|
- font-size: 10px;
|
|
|
+/* 天地图底层是 Leaflet 1.9,内部图层层级固定:瓦片 200 / 覆盖物 400 / 标记 600 / 弹窗 700 /
|
|
|
+ 控件 800。自定义浮层若低于 400 会被瓦片整层盖住(缩放重绘、瓦片淡入后图例就"消失"了),
|
|
|
+ 所以统一取 850+,同时保持在 1000(控件容器)与 2000+(抽屉/弹窗)以下,
|
|
|
+ 避免浮层跑到缩放按钮或详情抽屉上面 */
|
|
|
+.map-legend {
|
|
|
+ position: absolute;
|
|
|
+ left: 14px;
|
|
|
+ bottom: 14px;
|
|
|
+ z-index: 900;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ background: rgba(255, 255, 255, 0.95);
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 4px 12px rgba(31, 74, 121, 0.1);
|
|
|
}
|
|
|
|
|
|
-.refresh-time {
|
|
|
- margin-left: auto;
|
|
|
+.legend-title {
|
|
|
font-size: 11px;
|
|
|
- color: #607080;
|
|
|
- font-weight: 400;
|
|
|
+ color: #97a3b7;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 管网概况统计卡片 ==================== */
|
|
|
-.stats-cards {
|
|
|
+.legend-row {
|
|
|
display: flex;
|
|
|
- gap: 8px;
|
|
|
- margin-bottom: 10px;
|
|
|
- flex-shrink: 0;
|
|
|
+ align-items: center;
|
|
|
+ gap: 7px;
|
|
|
+ padding: 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #334155;
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
}
|
|
|
|
|
|
-.stat-card {
|
|
|
- flex: 1;
|
|
|
- background: rgba(10, 25, 50, 0.6);
|
|
|
- border: 1px solid rgba(64, 158, 255, 0.15);
|
|
|
- border-radius: 6px;
|
|
|
- padding: 8px 4px;
|
|
|
- text-align: center;
|
|
|
+.legend-row.is-off {
|
|
|
+ opacity: 0.35;
|
|
|
}
|
|
|
|
|
|
-.stat-val {
|
|
|
- font-size: 20px;
|
|
|
- font-weight: 700;
|
|
|
- color: #409eff;
|
|
|
+.legend-dot {
|
|
|
+ width: 9px;
|
|
|
+ height: 9px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: inline-block;
|
|
|
}
|
|
|
|
|
|
-.stat-val.green {
|
|
|
- color: #67c23a;
|
|
|
+.dot-online {
|
|
|
+ background: #2ecc71;
|
|
|
}
|
|
|
|
|
|
-.stat-val.orange {
|
|
|
- color: #e6a23c;
|
|
|
+.dot-offline {
|
|
|
+ background: #95a5a6;
|
|
|
}
|
|
|
|
|
|
-.stat-val.gray {
|
|
|
- color: #909399;
|
|
|
+.dot-alarm {
|
|
|
+ background: #e74c3c;
|
|
|
}
|
|
|
|
|
|
-.stat-label {
|
|
|
- font-size: 10px;
|
|
|
- color: #7ec8e3;
|
|
|
- margin-top: 2px;
|
|
|
+.legend-split {
|
|
|
+ height: 1px;
|
|
|
+ margin: 3px 0;
|
|
|
+ background: #e8eef5;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 环形图 ==================== */
|
|
|
-.pie-chart-wrapper {
|
|
|
+.legend-line {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- height: 140px;
|
|
|
+ gap: 7px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #5e6d82;
|
|
|
}
|
|
|
|
|
|
-.chart-area {
|
|
|
- width: 55%;
|
|
|
- height: 140px;
|
|
|
+.legend-line i {
|
|
|
+ width: 14px;
|
|
|
+ height: 3px;
|
|
|
+ border-radius: 2px;
|
|
|
}
|
|
|
|
|
|
-.pie-legend {
|
|
|
- width: 45%;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: 8px;
|
|
|
+.map-error {
|
|
|
+ position: absolute;
|
|
|
+ top: 14px;
|
|
|
+ left: 14px;
|
|
|
+ z-index: 950;
|
|
|
+ width: 320px;
|
|
|
}
|
|
|
|
|
|
-.legend-item {
|
|
|
+.map-empty {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 850;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- gap: 6px;
|
|
|
- font-size: 12px;
|
|
|
-}
|
|
|
-
|
|
|
-.legend-dot {
|
|
|
- width: 10px;
|
|
|
- height: 10px;
|
|
|
- border-radius: 2px;
|
|
|
- flex-shrink: 0;
|
|
|
+ justify-content: center;
|
|
|
+ background: rgba(249, 249, 249, 0.85);
|
|
|
+ pointer-events: none;
|
|
|
}
|
|
|
|
|
|
-.legend-label {
|
|
|
- color: #a0cfff;
|
|
|
- flex: 1;
|
|
|
+/* ==================== 设备详情抽屉 ==================== */
|
|
|
+.drawer-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ min-width: 0;
|
|
|
}
|
|
|
|
|
|
-.legend-value {
|
|
|
- color: #fff;
|
|
|
+.drawer-name {
|
|
|
+ font-size: 16px;
|
|
|
font-weight: 600;
|
|
|
+ color: #0a1628;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 实时数据表格 ==================== */
|
|
|
-.realtime-table-wrap {
|
|
|
- flex: 1;
|
|
|
- overflow-y: auto;
|
|
|
- min-height: 0;
|
|
|
-}
|
|
|
-
|
|
|
-.dark-table {
|
|
|
- width: 100%;
|
|
|
- border-collapse: collapse;
|
|
|
+.drawer-type {
|
|
|
font-size: 12px;
|
|
|
+ color: #97a3b7;
|
|
|
}
|
|
|
|
|
|
-.dark-table th {
|
|
|
- background: rgba(20, 60, 110, 0.6);
|
|
|
- color: #7ec8e3;
|
|
|
- padding: 7px 6px;
|
|
|
- text-align: center;
|
|
|
- border-bottom: 1px solid rgba(64, 158, 255, 0.2);
|
|
|
+.tag {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 5px;
|
|
|
+ padding: 2px 9px;
|
|
|
+ border-radius: 20px;
|
|
|
+ font-size: 12px;
|
|
|
font-weight: 500;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
-.dark-table td {
|
|
|
- padding: 6px;
|
|
|
- text-align: center;
|
|
|
- color: #d0e8ff;
|
|
|
- border-bottom: 1px solid rgba(64, 158, 255, 0.08);
|
|
|
- white-space: nowrap;
|
|
|
- overflow: hidden;
|
|
|
- text-overflow: ellipsis;
|
|
|
- max-width: 80px;
|
|
|
+.tag-normal {
|
|
|
+ background: #dcfce7;
|
|
|
+ color: #16a34a;
|
|
|
}
|
|
|
|
|
|
-.dark-table tbody tr:nth-child(odd) {
|
|
|
- background: rgba(10, 25, 50, 0.4);
|
|
|
+.tag-offline {
|
|
|
+ background: #f1f5f9;
|
|
|
+ color: #64748b;
|
|
|
}
|
|
|
|
|
|
-.dark-table tbody tr:nth-child(even) {
|
|
|
- background: rgba(20, 40, 70, 0.3);
|
|
|
+.tag-alarm {
|
|
|
+ background: #fee2e2;
|
|
|
+ color: #ef4444;
|
|
|
}
|
|
|
|
|
|
-.dark-table tbody tr:hover {
|
|
|
- background: rgba(64, 158, 255, 0.1);
|
|
|
+.dev-block {
|
|
|
+ padding: 14px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ background: #f8fafc;
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
-.status-tag {
|
|
|
- padding: 1px 6px;
|
|
|
- border-radius: 3px;
|
|
|
- font-size: 11px;
|
|
|
+.dev-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 6px 14px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #5e6d82;
|
|
|
}
|
|
|
|
|
|
-.status-normal {
|
|
|
- color: #67c23a;
|
|
|
- background: rgba(103, 194, 58, 0.15);
|
|
|
+.dev-grid .full {
|
|
|
+ grid-column: 1 / -1;
|
|
|
}
|
|
|
|
|
|
-.status-warning {
|
|
|
- color: #e6a23c;
|
|
|
- background: rgba(230, 162, 60, 0.15);
|
|
|
+.dev-grid b {
|
|
|
+ color: #0a1628;
|
|
|
+ font-weight: 500;
|
|
|
}
|
|
|
|
|
|
-.status-alarm {
|
|
|
- color: #f56c6c;
|
|
|
- background: rgba(245, 108, 108, 0.15);
|
|
|
+.dev-section {
|
|
|
+ margin-bottom: 18px;
|
|
|
}
|
|
|
|
|
|
-.status-offline {
|
|
|
- color: #909399;
|
|
|
- background: rgba(144, 147, 153, 0.15);
|
|
|
+.sec-head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 10px;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 中间地图 ==================== */
|
|
|
-.map-container {
|
|
|
+.sec-bar {
|
|
|
+ width: 3px;
|
|
|
+ height: 14px;
|
|
|
+ border-radius: 2px;
|
|
|
+ background: #1677ff;
|
|
|
+}
|
|
|
+
|
|
|
+.sec-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #0a1628;
|
|
|
+}
|
|
|
+
|
|
|
+.sec-hint {
|
|
|
flex: 1;
|
|
|
- border-radius: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #97a3b7;
|
|
|
overflow: hidden;
|
|
|
- border: 1px solid rgba(64, 158, 255, 0.2);
|
|
|
- min-height: 0;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
-.map-search {
|
|
|
- position: absolute;
|
|
|
- top: 12px;
|
|
|
- left: 12px;
|
|
|
- z-index: 10;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
+.sec-empty {
|
|
|
+ padding: 22px 0;
|
|
|
+ color: #9aa7b8;
|
|
|
+ font-size: 12px;
|
|
|
+ text-align: center;
|
|
|
}
|
|
|
|
|
|
-.pipeline-map-status {
|
|
|
- position: absolute;
|
|
|
- top: 48px;
|
|
|
- left: 12px;
|
|
|
- z-index: 5;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 7px;
|
|
|
- padding: 7px 12px;
|
|
|
- color: #d9f4ff;
|
|
|
- background: rgba(6, 30, 65, 0.86);
|
|
|
- border: 1px solid rgba(0, 229, 255, 0.42);
|
|
|
- border-radius: 4px;
|
|
|
- font-size: 12px;
|
|
|
- pointer-events: none;
|
|
|
+/* 指标卡片:列随设备类型变化 */
|
|
|
+.metric-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
|
|
+ gap: 10px;
|
|
|
}
|
|
|
|
|
|
-.pipeline-status-dot {
|
|
|
- width: 9px;
|
|
|
- height: 9px;
|
|
|
- background: #00e5ff;
|
|
|
- border-radius: 50%;
|
|
|
- box-shadow: 0 0 8px rgba(0, 229, 255, 0.9);
|
|
|
+.metric-card {
|
|
|
+ padding: 10px 12px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
-.pipeline-map-error {
|
|
|
- position: absolute;
|
|
|
- left: 50%;
|
|
|
- bottom: 18px;
|
|
|
- z-index: 5;
|
|
|
- max-width: 70%;
|
|
|
- padding: 8px 14px;
|
|
|
- color: #ffd591;
|
|
|
- background: rgba(80, 45, 0, 0.88);
|
|
|
- border: 1px solid rgba(230, 162, 60, 0.55);
|
|
|
- border-radius: 4px;
|
|
|
- font-size: 12px;
|
|
|
- text-align: center;
|
|
|
- transform: translateX(-50%);
|
|
|
- pointer-events: none;
|
|
|
+.metric-card.is-alarm {
|
|
|
+ background: #fff5f5;
|
|
|
+ border-color: #fecaca;
|
|
|
}
|
|
|
|
|
|
-.search-input {
|
|
|
- background: rgba(6, 30, 65, 0.85);
|
|
|
- border: 1px solid rgba(64, 158, 255, 0.3);
|
|
|
- color: #a0cfff;
|
|
|
- padding: 6px 30px 6px 12px;
|
|
|
- border-radius: 4px;
|
|
|
+.metric-label {
|
|
|
font-size: 12px;
|
|
|
- outline: none;
|
|
|
- width: 160px;
|
|
|
+ color: #97a3b7;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
-.search-input::placeholder {
|
|
|
- color: #4a6a8a;
|
|
|
+.metric-value {
|
|
|
+ margin-top: 5px;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #0a1628;
|
|
|
+ line-height: 1.3;
|
|
|
+ word-break: break-all;
|
|
|
}
|
|
|
|
|
|
-.search-icon {
|
|
|
- position: absolute;
|
|
|
- right: 8px;
|
|
|
- font-size: 14px;
|
|
|
- cursor: pointer;
|
|
|
+.metric-card.is-alarm .metric-value {
|
|
|
+ color: #ef4444;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 监测曲线Tab ==================== */
|
|
|
-.curve-tabs {
|
|
|
- display: flex;
|
|
|
- gap: 0;
|
|
|
- background: rgba(10, 25, 50, 0.6);
|
|
|
- border: 1px solid rgba(64, 158, 255, 0.2);
|
|
|
- border-radius: 4px;
|
|
|
- margin-bottom: 8px;
|
|
|
- flex-shrink: 0;
|
|
|
- overflow: hidden;
|
|
|
+.metric-value small {
|
|
|
+ margin-left: 2px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 400;
|
|
|
+ color: #97a3b7;
|
|
|
}
|
|
|
|
|
|
-.curve-tab {
|
|
|
- flex: 1;
|
|
|
- text-align: center;
|
|
|
- padding: 5px 4px;
|
|
|
- font-size: 11px;
|
|
|
- color: #a0cfff;
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.3s;
|
|
|
- border-right: 1px solid rgba(64, 158, 255, 0.1);
|
|
|
+.metric-time {
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #97a3b7;
|
|
|
}
|
|
|
|
|
|
-.curve-tab:last-child {
|
|
|
- border-right: none;
|
|
|
+/* 采集记录 */
|
|
|
+.record-wrap :deep(.el-table) {
|
|
|
+ font-size: 12px;
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
-.curve-tab.active {
|
|
|
- background: rgba(64, 158, 255, 0.25);
|
|
|
- color: #fff;
|
|
|
+.record-wrap :deep(.el-table th.el-table__cell) {
|
|
|
+ background: #f7f9fc;
|
|
|
+ color: #5e6d82;
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
-.curve-tab:hover {
|
|
|
- background: rgba(64, 158, 255, 0.15);
|
|
|
+.record-wrap :deep(.el-table td.el-table__cell .cell) {
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
-.device-select {
|
|
|
- margin-left: 10px;
|
|
|
- background: #0d2137;
|
|
|
- color: #7ec8e3;
|
|
|
- border: 1px solid #1a3a5c;
|
|
|
- border-radius: 3px;
|
|
|
- padding: 2px 6px;
|
|
|
- font-size: 11px;
|
|
|
- outline: none;
|
|
|
+.cell-alarm {
|
|
|
+ color: #ef4444;
|
|
|
+ font-weight: 600;
|
|
|
}
|
|
|
|
|
|
-.chart-area-sm {
|
|
|
- width: 100%;
|
|
|
- height: 150px;
|
|
|
- flex-shrink: 0;
|
|
|
+.record-audio-cell {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ overflow: hidden;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 报警信息 ==================== */
|
|
|
-.alert-stats {
|
|
|
- display: flex;
|
|
|
- gap: 8px;
|
|
|
- margin-bottom: 8px;
|
|
|
- flex-shrink: 0;
|
|
|
+.record-audio {
|
|
|
+ width: 150px;
|
|
|
+ max-width: 100%;
|
|
|
+ height: 26px;
|
|
|
+ vertical-align: middle;
|
|
|
}
|
|
|
|
|
|
-.alert-stat-card {
|
|
|
- flex: 1;
|
|
|
- background: rgba(10, 25, 50, 0.6);
|
|
|
- border: 1px solid rgba(64, 158, 255, 0.15);
|
|
|
- border-radius: 6px;
|
|
|
- padding: 8px;
|
|
|
- text-align: center;
|
|
|
+.record-pager {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ margin-top: 10px;
|
|
|
}
|
|
|
|
|
|
-.alert-stat-val {
|
|
|
- font-size: 20px;
|
|
|
- font-weight: 700;
|
|
|
- color: #e6a23c;
|
|
|
+/* 聚合点位 */
|
|
|
+.cluster-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ max-height: 420px;
|
|
|
+ overflow: auto;
|
|
|
}
|
|
|
|
|
|
-.alert-stat-val.green {
|
|
|
- color: #67c23a;
|
|
|
+.cluster-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 9px 12px;
|
|
|
+ background: #f8fafc;
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
+ border-radius: 6px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #0a1628;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: 0.15s;
|
|
|
}
|
|
|
|
|
|
-.alert-stat-val.red {
|
|
|
- color: #f56c6c;
|
|
|
+.cluster-item:hover {
|
|
|
+ background: #eef5ff;
|
|
|
+ border-color: #bcd8ff;
|
|
|
}
|
|
|
|
|
|
-.alert-stat-label {
|
|
|
- font-size: 10px;
|
|
|
- color: #7ec8e3;
|
|
|
- margin-top: 2px;
|
|
|
+.cluster-dot {
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
-.alert-table-wrap {
|
|
|
+.cluster-name {
|
|
|
flex: 1;
|
|
|
- overflow-y: auto;
|
|
|
- min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
-/* ==================== 滚动条样式 ==================== */
|
|
|
-::-webkit-scrollbar {
|
|
|
- width: 4px;
|
|
|
+.cluster-status {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #97a3b7;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
-::-webkit-scrollbar-track {
|
|
|
- background: rgba(10, 25, 50, 0.3);
|
|
|
+.sewage-tag {
|
|
|
+ font-weight: 600;
|
|
|
}
|
|
|
|
|
|
-::-webkit-scrollbar-thumb {
|
|
|
- background: rgba(64, 158, 255, 0.3);
|
|
|
- border-radius: 2px;
|
|
|
+:deep(.el-drawer__header) {
|
|
|
+ margin-bottom: 0;
|
|
|
+ padding: 12px 20px;
|
|
|
+ border-bottom: 1px solid #e3e9f2;
|
|
|
+ color: #0a1628;
|
|
|
}
|
|
|
|
|
|
-::-webkit-scrollbar-thumb:hover {
|
|
|
- background: rgba(64, 158, 255, 0.5);
|
|
|
+:deep(.el-drawer__body) {
|
|
|
+ padding: 16px 20px 20px;
|
|
|
}
|
|
|
</style>
|