Przeglądaj źródła

重构感知信息实时展示

zengy 4 dni temu
rodzic
commit
5d4deac419

+ 39 - 0
src/api/pipeNetwork/perception.js

@@ -0,0 +1,39 @@
+import request from "@/utils/request";
+
+// 感知信息实时展示
+export function pagePerceptionEquipment(query) {
+  return request({
+    url: "/perception/equipment",
+    method: "get",
+    params: query
+  });
+}
+
+export function getEquipmentProfile(equipmentId) {
+  return request({
+    url: `/perception/equipment/${equipmentId}/profile`,
+    method: "get"
+  });
+}
+
+export function pageActiveAlarms(query) {
+  return request({
+    url: "/perception/alarms",
+    method: "get",
+    params: query
+  });
+}
+
+export function getAreaRank() {
+  return request({
+    url: "/perception/area-rank",
+    method: "get"
+  });
+}
+
+export function getAlarmTypeDist() {
+  return request({
+    url: "/perception/alarm-type-dist",
+    method: "get"
+  });
+}

+ 685 - 473
src/views/subSystem/pipeNetwork/pinteControl/PrealTimeShow.vue

@@ -1,590 +1,802 @@
-<!-- 感知信息实时展示 -->
+<!-- 感知信息实时展示(重写) -->
 <template>
-  <div class="app-container realtime-page" v-loading="loading">
-    <div class="hero-panel">
-      <div>
-        <div class="hero-title">感知信息实时展示</div>
-        <div class="hero-subtitle">展示传感设备名称、位置、工作状态及故障关注信息,并同步呈现人机交互记录</div>
-      </div>
-      <el-button type="primary" icon="Refresh" @click="loadDashboard">刷新</el-button>
-    </div>
+  <div class="app-container perception-page">
+    <div class="grid-main">
+      <!-- ============ 左:设备状态总览 ============ -->
+      <div class="card">
+        <div class="card-head">
+          <div class="card-title">
+            设备状态总览
+            <span class="title-sub">按片区聚合 · 设备点阵(3s 刷新)</span>
+          </div>
+          <div class="row gap8 wrap">
+            <el-input
+              v-model="locateKeyword"
+              placeholder="定位设备:编码 / 名称"
+              style="width: 260px"
+              clearable
+              @keyup.enter="locateDevice"
+            />
+            <el-button type="primary" @click="locateDevice">定位</el-button>
+          </div>
+        </div>
 
-    <div class="summary-grid">
-      <el-card shadow="hover" class="summary-card danger">
-        <div class="summary-label">报警关注</div>
-        <div class="summary-value">{{ alarmCount }}</div>
-        <div class="summary-desc">预警、维修、停用等需值班关注的对象</div>
-      </el-card>
-      <el-card shadow="hover" class="summary-card success">
-        <div class="summary-label">正常监测点</div>
-        <div class="summary-value">{{ normalCount }}</div>
-        <div class="summary-desc">状态为正常或启用的点位</div>
-      </el-card>
-      <el-card shadow="hover" class="summary-card primary">
-        <div class="summary-label">感知设备</div>
-        <div class="summary-value">{{ equipmentCount }}</div>
-        <div class="summary-desc">通过点位详情聚合出的关联设备总量</div>
-      </el-card>
-      <el-card shadow="hover" class="summary-card info">
-        <div class="summary-label">设施覆盖</div>
-        <div class="summary-value">{{ networkOptions.length }}</div>
-        <div class="summary-desc">已纳入实时展示的管网设施数量</div>
-      </el-card>
-    </div>
+        <div class="ov-toolbar">
+          <span class="legend-inline">
+            <span><i style="background:#16a34a"></i>在线</span>
+            <span><i style="background:#cbd5e1"></i>离线</span>
+            <span><i class="flash" style="background:#ef4444"></i>报警</span>
+          </span>
+        </div>
+
+        <!-- 全域健康度带 -->
+        <div class="health-band">
+          <div class="hb-rate">
+            <b :class="{ 'text-red': (stats.online ?? 0) < (stats.total ?? 1) * 0.6 }">{{ onlineRate }}%</b>
+            <span>设备在线率</span>
+          </div>
+          <div class="hb-bar">
+            <i class="on" :style="{ width: onlineRate + '%' }"></i>
+            <i class="off" :style="{ width: offlineRate + '%' }"></i>
+            <i class="al" :style="{ width: alarmRate + '%' }"></i>
+          </div>
+          <div class="hb-items">
+            <div><b>{{ stats.total ?? 0 }}</b><span>总数(台)</span></div>
+            <div><b class="text-green">{{ stats.online ?? 0 }}</b><span>在线(台)</span></div>
+            <div><b class="text-gray">{{ stats.offline ?? 0 }}</b><span>离线(台)</span></div>
+            <div><b class="text-red">{{ stats.alarm ?? 0 }}</b><span>报警(台)</span></div>
+          </div>
+        </div>
 
-    <el-row :gutter="16">
-      <el-col :xs="24" :lg="16">
-        <el-card shadow="never" class="main-card">
-          <template #header>
-            <div class="card-header-row">
-              <div>
-                <div class="card-title">设备信息实时展示</div>
-                <div class="card-subtitle">以图元卡片方式查看监测点及所挂接感知设备的实时分布</div>
+        <!-- 片区卡 + 点阵 -->
+        <div class="area-grid">
+          <div
+            v-for="a in areaRank"
+            :key="a.district"
+            class="acard"
+            :class="{ 'has-alarm': a.alarm > 0 }"
+          >
+            <div class="ac-head">
+              <div class="ac-id">
+                <div class="ac-name">{{ a.districtName }}</div>
+                <div class="ac-sub">片区设备总览</div>
               </div>
-              <div class="filter-group">
-                <el-select v-model="filters.networkId" clearable placeholder="按设施筛选" style="width: 200px">
-                  <el-option
-                    v-for="item in networkOptions"
-                    :key="item.networkId"
-                    :label="item.networkName"
-                    :value="item.networkId"
-                  />
-                </el-select>
-                <el-select v-model="filters.status" clearable placeholder="按状态筛选" style="width: 160px">
-                  <el-option v-for="item in statusOptions" :key="item" :label="item" :value="item" />
-                </el-select>
+              <div class="ac-rate">
+                <b>{{ areaOnlineRate(a) }}%</b>
+                <span>在线率</span>
               </div>
             </div>
-          </template>
+            <div class="ac-bar">
+              <i class="on" :style="{ width: areaOnlineRate(a) + '%' }"></i>
+              <i class="off" :style="{ width: areaOfflineRate(a) + '%' }"></i>
+              <i class="al" :style="{ width: areaAlarmRate(a) + '%' }"></i>
+            </div>
+            <div class="ac-metrics">
+              <div><span>总数</span><b>{{ a.total }}</b></div>
+              <div><span>在线</span><b class="text-green">{{ a.online }}</b></div>
+              <div><span>离线</span><b class="text-gray">{{ a.offline }}</b></div>
+            </div>
+            <div class="dot-matrix">
+              <div
+                v-for="d in devicesByDistrict(a.district)"
+                :key="d.equipmentId"
+                class="dot"
+                :class="dotClass(d)"
+                :title="`${d.equipmentName}(${d.equipmentCode})`"
+                @click="openProfile(d)"
+              ></div>
+              <span v-if="!devicesByDistrict(a.district).length" class="ac-tip">暂无设备</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 类型构成 -->
+        <div class="type-dist">
+          <div class="sr-head">设备类型构成</div>
+          <div class="td-grid">
+            <div v-for="t in typeDistribution" :key="t.name" class="td-row">
+              <span class="td-name">{{ t.name }}</span>
+              <div class="td-track"><i :style="{ width: t.rate + '%' }"></i></div>
+              <span class="td-num">{{ t.count }} 台 · 在线 {{ t.online }}</span>
+            </div>
+          </div>
+        </div>
 
-          <div v-if="displayCards.length" class="device-grid">
+        <!-- 定位结果 -->
+        <div v-if="locateResults.length" class="search-result">
+          <div class="sr-head">定位结果(回车或点击定位,可精确打开档案)</div>
+          <div class="sr-list">
             <div
-              v-for="card in displayCards"
-              :key="card.pointId"
-              class="device-card"
-              :class="getCardClass(card.status)"
-              @click="openDetail(card)"
+              v-for="d in locateResults"
+              :key="d.equipmentId"
+              class="sr-item"
+              @click="openProfile(d)"
             >
-              <div class="device-top">
-                <div class="device-icon" :class="{ pulse: isAlertStatus(card.status) }">
-                  {{ getPointEmoji(card.pointType) }}
-                </div>
-                <div class="device-info">
-                  <div class="device-name">{{ card.pointName }}</div>
-                  <div class="device-location">{{ card.location || "未维护位置" }}</div>
-                  <div class="device-network">{{ card.networkName || "未关联设施" }}</div>
-                </div>
-              </div>
-              <div class="device-status-line">
-                <span class="status-dot" :class="getStatusDotClass(card.status)"></span>
-                <span>{{ card.status || "状态未维护" }}</span>
-                <span class="device-count">设备 {{ card.equipmentCount }}</span>
-              </div>
-              <div class="device-meta">
-                <span>编码:{{ card.pointCode }}</span>
-                <span>类型:{{ card.pointType || "-" }}</span>
-              </div>
+              <span class="sr-dot" :class="dotClass(d)"></span>
+              <span class="sr-name">{{ d.equipmentName }}</span>
+              <span class="sr-meta">{{ d.equipmentTypeName || "未分类" }}</span>
+              <span class="sr-code">{{ d.equipmentCode }}</span>
             </div>
           </div>
-          <el-empty v-else description="暂无可展示的感知信息" />
-        </el-card>
-      </el-col>
-
-      <el-col :xs="24" :lg="8">
-        <el-card shadow="never" class="side-card">
-          <template #header>
-            <div class="card-title">操作员操作记录</div>
-          </template>
-          <div class="record-list">
-            <div v-for="item in operationRecords" :key="item.id" class="record-item">
-              <div class="record-time">{{ item.time }}</div>
-              <div class="record-content">
-                <div class="record-action">{{ item.action }}</div>
-                <div class="record-target">{{ item.target }}</div>
+        </div>
+
+        <el-empty v-if="!equipmentList.length" description="暂无可展示的感知设备" />
+      </div>
+
+      <!-- ============ 右:实时报警 / 片区健康排行 / 报警类型分布 ============ -->
+      <div class="side-col">
+        <div class="card alarm-card">
+          <div class="card-head">
+            <div class="card-title">实时报警 <span class="badge" :class="{ zero: !alarms.length }">{{ alarms.length }}</span></div>
+          </div>
+          <div class="alarm-list">
+            <div v-for="a in alarms" :key="a.id" class="alarm-item">
+              <div class="alarm-top">
+                <span class="alarm-dev">{{ a.deviceName || a.deviceCode }}<span class="alarm-code">{{ a.deviceCode }}</span></span>
+                <span class="alarm-level" :style="{ color: levelColor(a.alarmLevel) }">{{ levelName(a.alarmLevel) }}</span>
               </div>
+              <div class="alarm-time">{{ a.createTime }}</div>
+              <div class="alarm-desc">{{ a.alarmContent || "—" }} <span v-if="a.actualValue != null">实测 {{ a.actualValue }}</span></div>
             </div>
+            <el-empty v-if="!alarms.length" description="当前无未解除报警" :image-size="60" />
           </div>
-        </el-card>
-
-        <el-card shadow="never" class="side-card">
-          <template #header>
-            <div class="card-title">人机交互信息</div>
-          </template>
-          <div class="event-list">
-            <div v-for="item in eventList" :key="item.id" class="event-item">
-              <div class="event-title">{{ item.title }}</div>
-              <div class="event-desc">{{ item.description }}</div>
-              <div class="event-meta">{{ item.time }} · {{ item.handler }}</div>
+        </div>
+
+        <div class="card">
+          <div class="card-head"><div class="card-title">报警类型分布</div></div>
+          <div class="type-dist side">
+            <div v-for="t in alarmTypeDist" :key="t.warningCode" class="td-row">
+              <span class="td-name">{{ t.warningName || t.warningCode }}</span>
+              <div class="td-track"><i :style="{ width: typeDistRate(t) + '%' }"></i></div>
+              <span class="td-num">{{ t.count }} 条</span>
             </div>
+            <el-empty v-if="!alarmTypeDist.length" description="暂无报警分布" :image-size="50" />
           </div>
-        </el-card>
+        </div>
 
-        <div v-if="activeAlarms.length" class="alert-bar">
-          <div class="alert-icon">!</div>
-          <div class="alert-messages">
-            <div v-for="item in activeAlarms.slice(0, 3)" :key="item.pointId">{{ item.message }}</div>
+        <div class="card">
+          <div class="card-head"><div class="card-title">片区健康排行</div></div>
+          <div class="rank-list">
+            <div v-for="(a, i) in areaRank" :key="a.district" class="rank-row" :class="{ top: i === 0 }">
+              <span class="rank-no">{{ i + 1 }}</span>
+              <span class="rank-name">{{ a.districtName }}</span>
+              <div class="rank-track"><i :style="{ width: areaOnlineRate(a) + '%', background: '#16a34a' }"></i></div>
+              <span class="rank-val">在线 {{ a.online }} / {{ a.total }}</span>
+            </div>
           </div>
         </div>
-      </el-col>
-    </el-row>
-
-    <el-drawer title="实时展示详情" v-model="detailOpen" size="960px" append-to-body class="detail-drawer" direction="rtl">
-      <el-descriptions :column="2" border v-if="detailData.pointInfo">
-        <el-descriptions-item label="监测点名称">{{ detailData.pointInfo.pointName || "-" }}</el-descriptions-item>
-        <el-descriptions-item label="监测点编码">{{ detailData.pointInfo.pointCode || "-" }}</el-descriptions-item>
-        <el-descriptions-item label="监测类型">{{ detailData.pointInfo.pointType || "-" }}</el-descriptions-item>
-        <el-descriptions-item label="当前状态">{{ detailData.pointInfo.status || "-" }}</el-descriptions-item>
-        <el-descriptions-item label="所属设施">{{ detailData.networkInfo?.networkName || "-" }}</el-descriptions-item>
-        <el-descriptions-item label="位置描述">{{ detailData.pointInfo.location || "-" }}</el-descriptions-item>
-      </el-descriptions>
-
-      <el-divider content-position="left">关联设备</el-divider>
-      <el-table :data="detailData.equipmentList || []" border max-height="320">
-        <el-table-column label="设备编码" prop="equipmentCode" min-width="140" />
-        <el-table-column label="设备名称" prop="equipmentName" min-width="180" show-overflow-tooltip />
-        <el-table-column label="设备型号" prop="equipmentModel" min-width="120" />
-        <el-table-column label="制造商" prop="manufacturer" min-width="140" show-overflow-tooltip />
-        <el-table-column label="设备位置" prop="equipmentLocation" min-width="180" show-overflow-tooltip />
-      </el-table>
+      </div>
+    </div>
+
+    <!-- ============ 设备档案抽屉 ============ -->
+    <el-drawer
+      v-model="profileOpen"
+      title="设备档案"
+      size="720px"
+      append-to-body
+      direction="rtl"
+      @opened="startProfileTimer"
+      @closed="stopProfileTimer"
+    >
+      <template #header>
+        <div class="drawer-title" v-if="profile.base">
+          <span class="dt-name">{{ profile.base.equipmentName }}</span>
+          <span class="tag" :class="profileTagClass(profile.base)">
+            {{ statusText(profile.base) }}
+          </span>
+        </div>
+      </template>
+
+      <div v-loading="profileLoading">
+        <template v-if="profile.base">
+          <div class="ctl-dev">
+            <div class="ctl-dev-grid">
+              <div>设备编号:<b>{{ profile.base.equipmentCode }}</b></div>
+              <div>设备类型:<b>{{ profile.base.equipmentTypeName || "—" }}</b></div>
+              <div>所属片区:<b>{{ districtName(profile.base.district) }}</b></div>
+              <div>安装位置:<b>{{ profile.base.equipmentLocation || "—" }}</b></div>
+              <div class="full">最后通信:<b>{{ profile.lastCommTime || "—" }}</b></div>
+            </div>
+          </div>
+
+          <div class="form-item">
+            <span>采集记录(最近 50 条)</span>
+            <div class="hist-wrap">
+              <el-table :data="pagedRecordRows" max-height="480" size="small">
+                <el-table-column
+                  v-for="c in recordViewColumns"
+                  :key="c.key"
+                  :label="c.label + (c.unit ? '(' + c.unit + ')' : '')"
+                  :min-width="c.key === 'create_time' ? 168 : 104"
+                  show-overflow-tooltip
+                >
+                  <template #default="{ row }">
+                    <span v-if="c.key === 'create_time'">{{ row.createTime || '—' }}</span>
+                    <span v-else :class="{ 'cell-alarm': alarmOf(row, c.key) }">{{ valueOf(row, c.key) }}</span>
+                  </template>
+                </el-table-column>
+                <template #empty>
+                  <div class="rec-empty">暂无采集记录</div>
+                </template>
+              </el-table>
+              <el-pagination
+                v-if="recordRows.length"
+                class="rec-pager"
+                layout="prev, pager, next"
+                :total="recordRows.length"
+                :page-size="recordPageSize"
+                small
+                v-model:current-page="recordPage"
+              />
+            </div>
+          </div>
+
+          <div class="drawer-actions">
+            <el-button @click="profileOpen = false">关闭</el-button>
+          </div>
+        </template>
+        <el-empty v-else description="未找到设备档案" />
+      </div>
     </el-drawer>
   </div>
 </template>
 
 <script setup>
-import { computed, onMounted, reactive, ref } from "vue";
-import { getMonitorPointDetail, listMonitorPointAll, listPipeNetworkAll } from "@/api/pipeNetwork/pdeviceInform";
+import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
+import { getEquipmentStatusStatistics, listEquipmentTypeAll } from "@/api/pipeNetwork/pdeviceInform";
+import {
+  pagePerceptionEquipment,
+  getEquipmentProfile,
+  pageActiveAlarms,
+  getAreaRank,
+  getAlarmTypeDist
+} from "@/api/pipeNetwork/perception";
 
 const loading = ref(false);
-const networkOptions = ref([]);
-const pointList = ref([]);
-const pointDetailMap = ref({});
-const detailOpen = ref(false);
-const detailData = ref({});
+const stats = ref({});
+const equipmentList = ref([]);
+const total = ref(0);
+const areaRank = ref([]);
+const alarmTypeDist = ref([]);
+const alarms = ref([]);
+const typeOptions = ref([]);
+const locateKeyword = ref("");
+const locateResults = ref([]);
 
 const filters = reactive({
-  networkId: undefined,
-  status: undefined
+  onlineStatus: undefined,
+  district: undefined,
+  equipmentTypeId: undefined
 });
-
-const operationRecords = ref([
-  { id: 1, time: "08:30:21", action: "值班员查看监测点详情", target: "化工园区关键点位" },
-  { id: 2, time: "09:15:47", action: "刷新感知信息展示", target: "管网设施全量数据" },
-  { id: 3, time: "10:02:12", action: "核查异常状态点位", target: "城南泵站压力监测点" },
-  { id: 4, time: "10:38:55", action: "确认日常巡检结果", target: "今日管网感知列表" }
-]);
-
-const eventList = ref([
-  { id: 1, title: "重大灾害事件", description: "重点关注异常点位运行情况,必要时联动应急处置。", time: "今日", handler: "中控值班" },
-  { id: 2, title: "管网事件", description: "对预警、维修中的监测点进行重点巡查与记录。", time: "实时", handler: "管网运维" },
-  { id: 3, title: "日常事件", description: "持续更新巡检、交接班和设备观察信息。", time: "本班次", handler: "值班班组" }
-]);
-
-const statusOptions = computed(() => {
-  return [...new Set(pointList.value.map((item) => item.status).filter(Boolean))];
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 300
 });
 
-const displayCards = computed(() => {
-  return pointList.value
-    .filter((item) => !filters.networkId || item.networkId === filters.networkId)
-    .filter((item) => !filters.status || item.status === filters.status)
-    .map((item) => {
-      const detail = pointDetailMap.value[item.pointId] || {};
-      return {
-        ...item,
-        equipmentCount: (detail.equipmentList || []).length,
-        networkName: networkOptions.value.find((network) => network.networkId === item.networkId)?.networkName
-      };
-    });
+const profileOpen = ref(false);
+const profileLoading = ref(false);
+const profile = ref({ base: null, records: [], indicators: [], origCollTable: "", recordView: { columns: [], rows: [] } });
+const currentProfileId = ref("");
+let profileTimer = null;
+let refreshTimer = null;
+
+const districtOptions = [
+  { value: 0, label: "城南" },
+  { value: 1, label: "城北" },
+  { value: 2, label: "太常" }
+];
+
+const onlineRate = computed(() => {
+  const t = stats.value.total || 0;
+  return t ? Math.round(((stats.value.online || 0) / t) * 100) : 0;
 });
-
-const activeAlarms = computed(() => {
-  return displayCards.value
-    .filter((item) => isAlertStatus(item.status))
-    .map((item) => ({
-      pointId: item.pointId,
-      message: `${item.pointName} 当前状态为 ${item.status || "异常"},请值班人员重点关注`
-    }));
+const offlineRate = computed(() => {
+  const t = stats.value.total || 0;
+  return t ? Math.round(((stats.value.offline || 0) / t) * 100) : 0;
 });
-
-const alarmCount = computed(() => activeAlarms.value.length);
-const normalCount = computed(() => displayCards.value.filter((item) => ["正常", "启用"].includes(item.status)).length);
-const equipmentCount = computed(() => {
-  return Object.values(pointDetailMap.value).reduce((count, item) => count + (item.equipmentList || []).length, 0);
+const alarmRate = computed(() => {
+  const t = stats.value.total || 0;
+  return t ? Math.round(((stats.value.alarm || 0) / t) * 100) : 0;
 });
 
-function isAlertStatus(status) {
-  return !["正常", "启用"].includes(status);
-}
-
-function getPointEmoji(type) {
-  if (type?.includes("压力")) return "📊";
-  if (type?.includes("流量")) return "💧";
-  if (type?.includes("温度")) return "🌡️";
-  if (type?.includes("泄漏")) return "🚨";
-  return "📡";
-}
-
-function getCardClass(status) {
-  if (["正常", "启用"].includes(status)) return "is-normal";
-  if (status === "预警") return "is-warning";
-  return "is-danger";
-}
-
-function getStatusDotClass(status) {
-  if (["正常", "启用"].includes(status)) return "dot-normal";
-  if (status === "预警") return "dot-warning";
-  return "dot-danger";
-}
-
-function loadDashboard() {
-  loading.value = true;
-  return Promise.all([listPipeNetworkAll(), listMonitorPointAll()])
-    .then(async ([networkRes, pointRes]) => {
-      networkOptions.value = networkRes.data || [];
-      pointList.value = pointRes.data || [];
-      const details = await Promise.all(pointList.value.map((item) => getMonitorPointDetail(item.pointId)));
-      const map = {};
-      details.forEach((detail, index) => {
-        map[pointList.value[index].pointId] = detail.data || {};
-      });
-      pointDetailMap.value = map;
-      operationRecords.value.unshift({
-        id: Date.now(),
-        time: new Date().toLocaleTimeString(),
-        action: "刷新感知信息展示",
-        target: `加载 ${pointList.value.length} 个监测点`
-      });
-      operationRecords.value = operationRecords.value.slice(0, 8);
-    })
-    .finally(() => {
-      loading.value = false;
-    });
-}
-
-function openDetail(card) {
-  detailData.value = pointDetailMap.value[card.pointId] || {};
-  detailOpen.value = true;
-  operationRecords.value.unshift({
-    id: Date.now() + Math.random(),
-    time: new Date().toLocaleTimeString(),
-    action: "查看实时详情",
-    target: card.pointName
+const typeDistribution = computed(() => {
+  const map = {};
+  equipmentList.value.forEach((d) => {
+    const name = d.equipmentTypeName || "未分类";
+    if (!map[name]) map[name] = { count: 0, online: 0 };
+    map[name].count += 1;
+    if (d.onlineStatus === 1) map[name].online += 1;
   });
-  operationRecords.value = operationRecords.value.slice(0, 8);
-}
-
-onMounted(() => {
-  loadDashboard();
+  const entries = Object.keys(map).map((name) => ({
+    name,
+    count: map[name].count,
+    online: map[name].online
+  }));
+  // 按总台数降序排布
+  entries.sort((a, b) => b.count - a.count);
+  const max = Math.max(1, ...entries.map((e) => e.count));
+  return entries.map((e) => ({ ...e, rate: Math.round((e.count / max) * 100) }));
 });
-</script>
 
-<style scoped>
-.realtime-page {
-  background: linear-gradient(180deg, #f3f7fd 0%, #f8fafc 100%);
-}
+const recordView = computed(() => profile.value.recordView || { columns: [], rows: [] });
+const recordViewColumns = computed(() => recordView.value.columns || []);
+const recordRows = computed(() => recordView.value.rows || []);
 
-.hero-panel {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  gap: 16px;
-  margin-bottom: 16px;
-  padding: 20px 24px;
-  border-radius: 18px;
-  background: linear-gradient(135deg, #0f2742, #1d4f7d);
-  color: #fff;
-}
+// 采集记录分页(每页 10 条)
+const recordPage = ref(1);
+const recordPageSize = 10;
+const pagedRecordRows = computed(() => {
+  const start = (recordPage.value - 1) * recordPageSize;
+  return recordRows.value.slice(start, start + recordPageSize);
+});
 
-.hero-title {
-  font-size: 24px;
-  font-weight: 700;
+function districtName(district) {
+  const found = districtOptions.find((d) => d.value === district);
+  return found ? found.label : "—";
 }
 
-.hero-subtitle {
-  margin-top: 8px;
-  font-size: 13px;
-  color: rgba(255, 255, 255, 0.84);
+function isOnline(d) {
+  return d.onlineStatus === 1;
 }
-
-.summary-grid {
-  display: grid;
-  grid-template-columns: repeat(4, minmax(0, 1fr));
-  gap: 16px;
-  margin-bottom: 16px;
+function isAlarm(d) {
+  return d.alarmStatus === 1;
 }
-
-.summary-card {
-  border: none;
-  color: #fff;
+function dotClass(d) {
+  if (isAlarm(d)) return "al";
+  return isOnline(d) ? "on" : "off";
 }
-
-.summary-card :deep(.el-card__body) {
-  padding: 20px;
+function profileTagClass(base) {
+  if (isAlarm(base)) return "tag-red";
+  return isOnline(base) ? "tag-green" : "tag-gray";
 }
-
-.summary-card.primary {
-  background: linear-gradient(135deg, #2563eb, #5b93ff);
+function statusText(d) {
+  if (isAlarm(d)) return "报警";
+  return isOnline(d) ? "在线" : "离线";
 }
 
-.summary-card.success {
-  background: linear-gradient(135deg, #059669, #34d399);
+function areaOnlineRate(a) {
+  return a.total ? Math.round((a.online / a.total) * 100) : 0;
 }
-
-.summary-card.danger {
-  background: linear-gradient(135deg, #dc2626, #ff7070);
+function areaOfflineRate(a) {
+  return a.total ? Math.round((a.offline / a.total) * 100) : 0;
 }
-
-.summary-card.info {
-  background: linear-gradient(135deg, #7c3aed, #9d72ff);
+function areaAlarmRate(a) {
+  return a.total ? Math.round((a.alarm / a.total) * 100) : 0;
 }
-
-.summary-label {
-  font-size: 14px;
+function typeDistRate(t) {
+  const max = Math.max(1, ...alarmTypeDist.value.map((x) => x.count || 0));
+  return Math.round(((t.count || 0) / max) * 100);
 }
 
-.summary-value {
-  margin: 8px 0 10px;
-  font-size: 30px;
-  font-weight: 700;
+function devicesByDistrict(district) {
+  return equipmentList.value.filter((d) => d.district === district);
 }
 
-.summary-desc {
-  font-size: 12px;
-  opacity: 0.9;
+function valueOf(row, key) {
+  const cells = row.cells || [];
+  const c = cells.find((x) => x.key === key);
+  if (!c) return "—";
+  // 竖表(水质指标)展示串已含指标名与单位;宽表使用不含单位的纯值
+  if (recordView.value.layout === "long") return c.display;
+  return c.value && c.value !== "" ? c.value : c.display;
 }
 
-.main-card,
-.side-card {
-  border-radius: 16px;
+function alarmOf(row, key) {
+  const cells = row.cells || [];
+  const c = cells.find((x) => x.key === key);
+  return c ? !!c.alarm : false;
 }
 
-.card-header-row {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  gap: 12px;
+// 报警等级配色(与系统 alarm 模块保持一致:1红 2橙 3蓝 4绿)
+function levelColor(level) {
+  return ({ 1: "#f56c6c", 2: "#e6a23c", 3: "#409eff", 4: "#67c23a" }[level] || "#909399");
 }
-
-.card-title {
-  font-size: 16px;
-  font-weight: 600;
-  color: #1f2937;
+// 报警等级字典名称(与 alarm_level 字典一致:一级/二级/三级/四级报警)
+function levelName(level) {
+  return ({ 1: "一级报警", 2: "二级报警", 3: "三级报警", 4: "四级报警" }[level] || (level != null ? level + "级" : "—"));
 }
 
-.card-subtitle {
-  margin-top: 4px;
-  font-size: 12px;
-  color: #6b7280;
+function loadStatistics() {
+  return getEquipmentStatusStatistics().then((res) => {
+    stats.value = res.data || {};
+  });
 }
 
-.filter-group {
-  display: flex;
-  gap: 8px;
-  flex-wrap: wrap;
+function loadEquipment() {
+  loading.value = true;
+  return pagePerceptionEquipment({
+    pageNum: queryParams.pageNum,
+    pageSize: queryParams.pageSize,
+    equipmentCode: undefined,
+    equipmentName: undefined,
+    equipmentTypeId: filters.equipmentTypeId,
+    rootTypeId: undefined,
+    onlineStatus: filters.onlineStatus,
+    alarmStatus: undefined,
+    district: filters.district,
+    currentStatus: undefined
+  })
+    .then((res) => {
+      const page = res.data || {};
+      equipmentList.value = page.records || [];
+      total.value = page.total || 0;
+    })
+    .finally(() => {
+      loading.value = false;
+    });
 }
 
-.device-grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 16px;
+function loadAreaRank() {
+  return getAreaRank().then((res) => {
+    areaRank.value = res.data || [];
+  });
 }
 
-.device-card {
-  padding: 16px;
-  border-radius: 16px;
-  background: #fff;
-  border: 1px solid #e5edf7;
-  cursor: pointer;
-  transition: all 0.2s ease;
+function loadAlarmTypeDist() {
+  return getAlarmTypeDist().then((res) => {
+    alarmTypeDist.value = res.data || [];
+  });
 }
 
-.device-card:hover {
-  transform: translateY(-2px);
-  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
+function loadAlarms() {
+  return pageActiveAlarms({ pageNum: 1, pageSize: 20 }).then((res) => {
+    const page = res.data || {};
+    alarms.value = page.records || [];
+  });
 }
 
-.device-card.is-normal {
-  border-left: 4px solid #16a34a;
+function loadProfile(id) {
+  profileLoading.value = true;
+  return getEquipmentProfile(id)
+    .then((res) => {
+      profile.value = res.data || { base: null, records: [], indicators: [], origCollTable: "", recordView: { columns: [], rows: [] } };
+    })
+    .finally(() => {
+      profileLoading.value = false;
+    });
 }
 
-.device-card.is-warning {
-  border-left: 4px solid #f59e0b;
-  background: #fffaf0;
+function locateDevice() {
+  const kw = locateKeyword.value.trim();
+  if (!kw) {
+    locateResults.value = [];
+    return;
+  }
+  const matched = equipmentList.value.filter(
+    (d) => d.equipmentCode === kw || d.equipmentName?.includes(kw) || d.equipmentCode?.includes(kw)
+  );
+  locateResults.value = matched;
+  if (matched.length === 1) {
+    openProfile(matched[0]);
+  }
 }
 
-.device-card.is-danger {
-  border-left: 4px solid #dc2626;
-  background: #fff5f5;
+function locateByCode(code) {
+  locateKeyword.value = code || "";
+  const matched = equipmentList.value.filter((d) => d.equipmentCode === code);
+  locateResults.value = matched;
+  if (matched.length) openProfile(matched[0]);
 }
 
-.device-top {
-  display: flex;
-  gap: 12px;
+function openProfile(d) {
+  currentProfileId.value = d.equipmentId;
+  profileOpen.value = true;
+  recordPage.value = 1;
+  loadProfile(d.equipmentId);
 }
 
-.device-icon {
-  width: 52px;
-  height: 52px;
-  border-radius: 14px;
-  background: #eff6ff;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 26px;
+function startProfileTimer() {
+  stopProfileTimer();
+  profileTimer = setInterval(() => {
+    if (currentProfileId.value) loadProfile(currentProfileId.value);
+  }, 3000);
 }
-
-.device-icon.pulse {
-  animation: pulse 1s infinite;
+function stopProfileTimer() {
+  if (profileTimer) clearInterval(profileTimer);
+  profileTimer = null;
 }
 
-.device-info {
-  flex: 1;
+function refreshAll() {
+  loadStatistics();
+  loadEquipment();
+  loadAreaRank();
+  loadAlarmTypeDist();
+  loadAlarms();
 }
 
-.device-name {
-  font-size: 16px;
-  font-weight: 700;
-  color: #111827;
+function loadTypeOptions() {
+  return listEquipmentTypeAll().then((res) => {
+    typeOptions.value = res.data || [];
+  });
 }
 
-.device-location,
-.device-network,
-.device-meta,
-.record-time,
-.record-target,
-.event-desc,
-.event-meta {
-  font-size: 12px;
-  color: #6b7280;
-}
+onMounted(() => {
+  loadTypeOptions();
+  refreshAll();
+  refreshTimer = setInterval(refreshAll, 3000);
+});
 
-.device-network {
-  margin-top: 4px;
-}
+onBeforeUnmount(() => {
+  if (refreshTimer) clearInterval(refreshTimer);
+  stopProfileTimer();
+});
+</script>
 
-.device-status-line {
+<style scoped>
+.perception-page {
+  background: #f0f3f8;
+  /* 84 = 顶部导航 50 + 标签栏 34,与 AppMain 的 .hasTagsView .app-main 高度一致,
+     使整页恰好占满内容区、不产生页面级滚动条,溢出由各内部区域消化 */
+  height: calc(100vh - 84px);
+  overflow: hidden;
   display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-top: 14px;
-  font-size: 12px;
-  color: #374151;
-}
-
-.status-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  display: inline-block;
-}
-
-.dot-normal {
-  background: #16a34a;
-}
-
-.dot-warning {
-  background: #f59e0b;
+  flex-direction: column;
 }
 
-.dot-danger {
-  background: #dc2626;
+/* 顶部 KPI(与设备统计页一致) */
+.kpi-row {
+  display: flex;
+  gap: 16px;
+  margin-bottom: 20px;
 }
-
-.device-count {
-  margin-left: auto;
-  color: #1d4ed8;
+.kpi {
+  flex: 1 1 0;
+  min-width: 0;
+  border-radius: 12px;
 }
-
-.device-meta {
-  margin-top: 8px;
+.kpi :deep(.el-card__body) {
   display: flex;
-  gap: 12px;
-  flex-wrap: wrap;
+  align-items: center;
+  gap: 16px;
+  padding: 20px;
 }
-
-.record-list,
-.event-list {
+.kpi-icon {
   display: flex;
-  flex-direction: column;
-  gap: 12px;
+  align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  flex: 0 0 44px;
+  border-radius: 10px;
+  font-size: 22px;
+}
+.kpi-icon.total { background: #edf3fc; color: #014db2; }
+.kpi-icon.online { background: #e7f8f1; color: #10b981; }
+.kpi-icon.offline { background: #fef4e2; color: #f59e0b; }
+.kpi-icon.alarm { background: #fdecec; color: #ef4444; }
+.kpi-label { font-size: 13px; font-weight: 400; color: #6b7280; }
+.kpi-value { margin-top: 6px; font-size: 28px; font-weight: 700; line-height: 1; color: #0a1628; }
+
+/* 主区 + 侧栏 */
+.grid-main {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 380px;
+  gap: 16px;
+  align-items: stretch;
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
 }
-
-.record-item,
-.event-item {
-  padding: 12px;
-  border-radius: 12px;
-  background: #f8fafc;
-  border: 1px solid #edf2f7;
+/* 左侧主卡片内部滚动,避免页面级滚动条 */
+.grid-main > .card {
+  min-height: 0;
+  overflow-y: auto;
 }
-
-.record-action,
-.event-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #111827;
+.grid-main > .card > .card-head {
+  position: sticky;
+  top: 0;
+  background: #fff;
+  z-index: 1;
 }
-
-.record-target,
-.event-desc,
-.event-meta {
-  margin-top: 4px;
+.card {
+  background: #fff;
+  border: 1px solid #e3e9f2;
+  border-radius: 10px;
+  box-shadow: 0 2px 8px rgba(15, 34, 67, 0.06);
+  margin-bottom: 16px;
 }
-
-.alert-bar {
-  margin-top: 16px;
+.card-head {
   display: flex;
-  gap: 12px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
   padding: 14px 16px;
-  border-radius: 14px;
-  background: #dc2626;
-  color: #fff;
+  border-bottom: 1px solid #eef2f7;
+  flex-wrap: wrap;
 }
+.card-title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
+.card-title:before { content: ""; width: 3px; height: 15px; border-radius: 2px; background: #1677ff; }
+.title-sub { font-size: 12px; color: #97a3b7; font-weight: 400; }
+.row { display: flex; align-items: center; }
+.row.gap8 { gap: 8px; }
+.wrap { flex-wrap: wrap; }
 
-.alert-icon {
-  width: 24px;
-  height: 24px;
-  border-radius: 50%;
-  background: rgba(255, 255, 255, 0.2);
+.ov-toolbar {
   display: flex;
   align-items: center;
-  justify-content: center;
-  font-weight: 700;
-}
-
-.alert-messages {
+  gap: 10px;
+  padding: 10px 16px;
+  border-bottom: 1px solid #eef2f7;
+  background: #fbfdff;
   font-size: 12px;
-  line-height: 1.7;
+  color: #97a3b7;
+  flex-wrap: wrap;
+}
+.legend-inline { display: flex; align-items: center; gap: 13px; font-size: 12px; color: #5e6d82; }
+.legend-inline i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; margin-right: 5px; vertical-align: -1px; }
+.legend-inline i.flash { animation: dotFlash 0.9s steps(1, end) infinite; }
+.sp { flex: 1; }
+.ov-stat { color: #5e6d82; }
+
+/* 健康带 */
+.health-band { display: flex; align-items: center; gap: 28px; padding: 22px 24px; border-bottom: 1px solid #eef2f7; background: linear-gradient(180deg, #fbfdff 0%, #fff 100%); flex-wrap: wrap; }
+.hb-rate { display: flex; flex-direction: column; gap: 4px; flex: none; min-width: 150px; }
+.hb-rate b { font-size: 44px; font-weight: 700; color: #16a34a; font-variant-numeric: tabular-nums; line-height: 1; }
+.hb-rate span { font-size: 12px; color: #97a3b7; }
+.hb-bar { flex: 1; min-width: 240px; height: 16px; border-radius: 8px; overflow: hidden; background: #eef2f7; display: flex; }
+.hb-bar i { display: block; height: 100%; transition: width 0.4s; }
+.hb-bar i.on { background: #16a34a; }
+.hb-bar i.off { background: #94a3b8; }
+.hb-bar i.al { background: #ef4444; }
+.hb-items { display: flex; gap: 32px; font-size: 12px; color: #5e6d82; flex: none; }
+.hb-items div { display: flex; flex-direction: column; gap: 3px; }
+.hb-items b { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; }
+.text-green { color: #16a34a; }
+.text-gray { color: #64748b; }
+.text-red { color: #ef4444; }
+
+/* 片区卡 */
+.area-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 20px; }
+.acard { border: 1px solid #e3e9f2; border-radius: 14px; background: #fff; overflow: hidden; transition: 0.18s; display: flex; flex-direction: column; }
+.acard:hover { border-color: #1677ff; box-shadow: 0 10px 30px rgba(22, 119, 255, 0.14); transform: translateY(-2px); }
+.acard.has-alarm { border-color: #fca5a5; background: linear-gradient(180deg, #fffafa 0%, #fff 42%); }
+.ac-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 18px 20px 14px; }
+.ac-name { font-size: 22px; font-weight: 700; letter-spacing: 0.5px; line-height: 1.2; }
+.ac-sub { font-size: 12px; color: #97a3b7; }
+.ac-rate { text-align: right; flex: none; line-height: 1.1; }
+.ac-rate b { display: block; font-size: 30px; font-weight: 700; color: #16a34a; font-variant-numeric: tabular-nums; }
+.ac-rate span { font-size: 11px; color: #97a3b7; }
+.ac-bar { display: flex; height: 7px; background: #eef2f7; overflow: hidden; margin: 0 20px 16px; border-radius: 4px; }
+.ac-bar i { display: block; height: 100%; transition: width 0.4s; }
+.ac-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: #eef2f7; margin: 0 20px 16px; border: 1px solid #eef2f7; border-radius: 10px; overflow: hidden; }
+.ac-metrics div { background: #fff; padding: 11px 6px; text-align: center; }
+.ac-metrics span { display: block; font-size: 11px; color: #97a3b7; margin-bottom: 3px; }
+.ac-metrics b { font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums; }
+.dot-matrix { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 20px 18px; align-content: flex-start; flex: 1; }
+.dot { width: 11px; height: 11px; border-radius: 3px; background: #16a34a; cursor: pointer; transition: transform 0.1s; }
+.dot.off { background: #cbd5e1; }
+.dot.al { background: #ef4444; animation: dotFlash 0.9s steps(1, end) infinite; }
+.dot:hover { transform: scale(1.55); box-shadow: 0 0 0 2px rgba(15, 23, 42, 0.16); position: relative; z-index: 2; }
+.ac-tip { font-size: 11px; color: #1677ff; padding: 4px; }
+
+/* 类型构成 */
+.type-dist { padding: 4px 20px 20px; }
+.td-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px 28px; }
+.td-row { display: flex; align-items: center; gap: 12px; font-size: 13px; }
+.td-name { width: 150px; color: #5e6d82; flex: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.td-track { flex: 1; height: 16px; border-radius: 5px; background: #f1f5f9; display: flex; overflow: hidden; min-width: 120px; }
+.td-track i { display: block; height: 100%; background: #1677ff; }
+.td-num { width: 104px; text-align: right; color: #97a3b7; flex: none; font-variant-numeric: tabular-nums; font-size: 12px; }
+.type-dist.side { padding: 4px 18px 18px; }
+.type-dist.side .td-grid { grid-template-columns: 1fr; }
+.type-dist.side .td-name { width: 160px; }
+
+/* 定位结果 */
+.search-result { padding: 0 16px 16px; }
+.sr-head { font-size: 13px; font-weight: 600; margin-bottom: 9px; display: flex; align-items: center; gap: 7px; }
+.sr-head:before { content: ""; width: 3px; height: 13px; border-radius: 2px; background: #1677ff; }
+.sr-list { display: flex; flex-wrap: wrap; gap: 8px; }
+.sr-item { display: inline-flex; align-items: center; gap: 7px; border: 1px solid #e3e9f2; border-radius: 8px; padding: 6px 11px; background: #fff; cursor: pointer; font-size: 12px; transition: 0.15s; max-width: 340px; }
+.sr-item:hover { border-color: #1677ff; background: #f4f9ff; }
+.sr-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sr-meta { color: #97a3b7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sr-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; color: #1677ff; background: #eef5ff; border: 1px solid #d6e6ff; border-radius: 4px; padding: 1px 5px; white-space: nowrap; }
+.sr-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
+.sr-dot.on { background: #16a34a; }
+.sr-dot.off { background: #cbd5e1; }
+.sr-dot.al { background: #ef4444; }
+
+/* 侧栏 */
+.side-col { display: flex; flex-direction: column; gap: 16px; min-height: 0; overflow: hidden; }
+.alarm-card { display: flex; flex-direction: column; flex: 1; min-height: 200px; }
+.alarm-list { overflow-y: auto; flex: 1; }
+.badge { display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: #ef4444; color: #fff; font-size: 12px; align-items: center; justify-content: center; font-weight: 600; }
+.badge.zero { background: #cbd5e1; }
+.alarm-item { padding: 11px 16px; border-bottom: 1px solid #eef2f7; border-left: 3px solid #ef4444; }
+.alarm-item:hover { background: #f8fbff; }
+.alarm-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 4px; }
+.alarm-time { font-size: 12px; color: #97a3b7; font-variant-numeric: tabular-nums; margin-bottom: 4px; }
+.alarm-dev { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; min-width: 0; }
+.alarm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; font-weight: 600; color: #5e6d82; background: #f1f5f9; border: 1px solid #e3e9f2; border-radius: 4px; padding: 1px 5px; margin-left: 6px; flex: none; white-space: nowrap; }
+.alarm-level { font-size: 12px; font-weight: 600; flex: none; }
+.alarm-desc { font-size: 12px; color: #5e6d82; line-height: 1.6; margin: 2px 0 6px; }
+
+.rank-list { padding: 6px 18px 16px; }
+.rank-row { display: flex; align-items: center; gap: 11px; padding: 11px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
+.rank-row:last-child { border-bottom: none; }
+.rank-no { width: 22px; height: 22px; border-radius: 6px; background: #eef2f7; color: #97a3b7; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
+.rank-row.top .rank-no { background: #1677ff; color: #fff; }
+.rank-name { font-weight: 600; width: 58px; flex: none; }
+.rank-track { flex: 1; height: 9px; border-radius: 5px; background: #eef2f7; overflow: hidden; display: flex; }
+.rank-track i { display: block; height: 100%; }
+.rank-val { width: 110px; text-align: right; color: #97a3b7; font-size: 12px; flex: none; font-variant-numeric: tabular-nums; }
+
+/* 抽屉 */
+.drawer-title { display: flex; align-items: center; gap: 10px; }
+.dt-name { font-weight: 700; }
+.tag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 20px; font-size: 12px; font-weight: 500; }
+.tag-green { background: #dcfce7; color: #16a34a; }
+.tag-gray { background: #f1f5f9; color: #64748b; }
+.tag-red { background: #fee2e2; color: #ef4444; }
+.hist-wrap { overflow: auto; border-radius: 8px; background: #fff; }
+.cell-alarm { color: #ef4444; font-weight: 600; }
+/* 采集记录表格边框与基本信息 .ctl-dev 一致(1px #e3e9f2 / 圆角 8px),仅保留淡行线,不画竖线 */
+.hist-wrap :deep(.el-table) { font-size: 12px; border: 1px solid #e3e9f2; border-radius: 8px; }
+.hist-wrap :deep(.el-table th.el-table__cell) {
+  background: #f7f9fc;
+  color: #5e6d82;
+  font-weight: 600;
 }
+.hist-wrap :deep(.el-table td.el-table__cell) { padding: 6px 0; border-bottom: 1px solid #eef2f7; }
+.hist-wrap :deep(.el-table .cell) { padding: 0 10px; line-height: 1.5; }
+.hist-wrap :deep(.el-table td.el-table__cell .cell) { white-space: nowrap; }
+.hist-wrap :deep(.el-table__header th.el-table__cell) { border-bottom: 1px solid #e3e9f2; }
+/* 去掉 el-table 自带底部/右侧伪元素线(含新版 inner-wrapper 上的),避免与外框叠加变粗;末行行线交给外框封底 */
+.hist-wrap :deep(.el-table::before),
+.hist-wrap :deep(.el-table::after),
+.hist-wrap :deep(.el-table__inner-wrapper::before),
+.hist-wrap :deep(.el-table__inner-wrapper::after) { display: none; }
+.hist-wrap :deep(.el-table .el-table__row:last-child td.el-table__cell) { border-bottom: none; }
+.rec-empty { color: #9aa7b8; font-size: 12px; padding: 28px 0; }
+.rec-pager { margin-top: 10px; justify-content: flex-end; }
 
 @keyframes pulse {
-  0%,
-  100% {
-    transform: scale(1);
-  }
-  50% {
-    transform: scale(1.08);
-  }
+  0%, 100% { transform: scale(1); }
+  50% { transform: scale(1.08); }
 }
-
-@media (max-width: 1200px) {
-  .summary-grid {
-    grid-template-columns: repeat(2, minmax(0, 1fr));
-  }
-
-  .device-grid {
-    grid-template-columns: 1fr;
-  }
+@keyframes dotFlash {
+  0%, 50% { opacity: 1; }
+  51%, 100% { opacity: 0.25; }
 }
 
+@media (max-width: 1280px) {
+  .grid-main { grid-template-columns: 1fr; }
+  .kpi { flex: 1 1 45%; }
+  .area-grid { grid-template-columns: 1fr; }
+  .td-grid { grid-template-columns: 1fr; }
+}
 @media (max-width: 768px) {
-  .hero-panel,
-  .card-header-row {
-    flex-direction: column;
-    align-items: flex-start;
-  }
-
-  .summary-grid {
-    grid-template-columns: 1fr;
-  }
+  .kpi { flex: 1 1 100%; }
+}
+
+/* 设备档案抽屉(严格参考原型 view-monitor) */
+.ctl-dev { background: #f8fafc; border: 1px solid #e3e9f2; border-radius: 8px; padding: 14px; margin-bottom: 16px; }
+.ctl-dev-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px; font-size: 12px; color: #5e6d82; }
+.ctl-dev-grid .full { grid-column: 1 / -1; }
+.ctl-dev-grid b { color: #0a1628; font-weight: 500; }
+.form-item { display: block; margin-bottom: 14px; }
+.form-item > span { display: block; font-size: 13px; color: #5e6d82; margin-bottom: 6px; font-weight: 500; }
+.drawer-actions { display: flex; gap: 10px; margin-top: 20px; }
+.drawer-actions :deep(.el-button) {
+  flex: 1;
+  height: 36px;
+  padding: 0 14px;
+  border-radius: 6px;
+  border: 1px solid #e3e9f2;
+  background: #fff;
+  color: #0a1628;
+  font-size: 13px;
+}
+.drawer-actions :deep(.el-button:hover) {
+  border-color: #1677ff;
+  color: #1677ff;
+  background: #fff;
+}
+:deep(.el-drawer__header) {
+  margin-bottom: 0;
+  padding: 10px 20px;
+  border-bottom: 1px solid #e3e9f2;
+  color: #0a1628;
 }
+:deep(.el-drawer__body) { padding: 10px 20px 20px; }
 </style>