index3.vue 26 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. <template>
  2. <div class="area-management-system">
  3. <!-- 查询条件区域 -->
  4. <el-card class="filter-card">
  5. <el-form :model="queryParams" ref="searchForm" :inline="true" class="search-form">
  6. <!-- 区域层级条件 -->
  7. <el-form-item label="区域名称">
  8. <el-input v-model="queryParams.areaName" placeholder="请输入区域名称" clearable />
  9. </el-form-item>
  10. <el-form-item label="层级">
  11. <el-select v-model="queryParams.level" placeholder="请选择层级" clearable style="width: 180px;">
  12. <el-option label="全部" value="" />
  13. <el-option label="一级区域" value="1" />
  14. <el-option label="二级区域" value="2" />
  15. <el-option label="三级区域" value="3" />
  16. </el-select>
  17. </el-form-item>
  18. <!-- 设备统计条件 -->
  19. <el-form-item label="设备类型">
  20. <el-select v-model="queryParams.deviceType" placeholder="请选择设备类型" clearable style="width: 180px;">
  21. <el-option label="全部" value="" />
  22. <el-option label="控制器" value="CONTROLLER" />
  23. <el-option label="传感器" value="SENSOR" />
  24. <el-option label="执行器" value="ACTUATOR" />
  25. <el-option label="广播设备" value="BROADCASTER" />
  26. <el-option label="监控设备" value="MONITOR" />
  27. </el-select>
  28. </el-form-item>
  29. <!-- 状态过滤条件 -->
  30. <el-form-item label="设备状态">
  31. <el-select v-model="queryParams.status" placeholder="请选择设备状态" clearable style="width: 180px;">
  32. <el-option label="全部" value="" />
  33. <el-option label="在线" value="在线" />
  34. <el-option label="离线" value="离线" />
  35. <el-option label="故障" value="故障" />
  36. </el-select>
  37. </el-form-item>
  38. <el-form-item>
  39. <el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
  40. <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
  41. <el-button type="success" :icon="Download" @click="handleExport">导出</el-button>
  42. </el-form-item>
  43. </el-form>
  44. </el-card>
  45. <!-- 统计卡片 -->
  46. <el-row :gutter="20" class="stats-row">
  47. <el-col :span="6">
  48. <el-card class="stats-card">
  49. <el-statistic title="区域总数" :value="stats.totalAreas">
  50. <template #prefix>
  51. <el-icon><Location /></el-icon>
  52. </template>
  53. </el-statistic>
  54. </el-card>
  55. </el-col>
  56. <el-col :span="6">
  57. <el-card class="stats-card">
  58. <el-statistic title="设备总数" :value="stats.totalDevices">
  59. <template #prefix>
  60. <el-icon><Monitor /></el-icon>
  61. </template>
  62. </el-statistic>
  63. </el-card>
  64. </el-col>
  65. <el-col :span="6">
  66. <el-card class="stats-card online-card">
  67. <el-statistic title="在线设备" :value="stats.onlineDevices">
  68. <template #prefix>
  69. <el-icon><CircleCheck /></el-icon>
  70. </template>
  71. <template #suffix>
  72. <span class="rate-text">({{ stats.onlineRate }}%)</span>
  73. </template>
  74. </el-statistic>
  75. </el-card>
  76. </el-col>
  77. <el-col :span="6">
  78. <el-card class="stats-card health-card">
  79. <el-statistic title="平均健康指数" :value="stats.avgHealthIndex" suffix="分">
  80. <template #prefix>
  81. <el-icon><TrendCharts /></el-icon>
  82. </template>
  83. </el-statistic>
  84. </el-card>
  85. </el-col>
  86. </el-row>
  87. <!-- 数据表格区域 -->
  88. <el-card class="data-table">
  89. <el-table
  90. :data="areaList"
  91. border
  92. v-loading="isLoading"
  93. row-key="areaId"
  94. @row-click="handleRowClick"
  95. :row-class-name="tableRowClassName"
  96. >
  97. <el-table-column label="区域ID" prop="areaId" align="center" width="120" fixed="left" />
  98. <el-table-column label="区域名称" prop="areaName" align="center" min-width="150">
  99. <template #default="scope">
  100. <div class="area-name-cell">
  101. <span class="level-indicator" :class="`level-${scope.row.level}`"></span>
  102. {{ scope.row.areaName }}
  103. </div>
  104. </template>
  105. </el-table-column>
  106. <el-table-column label="层级" prop="level" align="center" width="100">
  107. <template #default="scope">
  108. <el-tag :type="getLevelType(scope.row.level)" size="small">
  109. {{ scope.row.level === 1 ? '一级' : scope.row.level === 2 ? '二级' : '三级' }}
  110. </el-tag>
  111. </template>
  112. </el-table-column>
  113. <el-table-column label="设备统计" align="center" width="300">
  114. <template #default="scope">
  115. <div class="device-stats">
  116. <el-tooltip
  117. v-for="stat in scope.row.deviceStats"
  118. :key="stat.deviceType"
  119. :content="`${mapDeviceType(stat.deviceType)}: ${stat.count}台 (在线率: ${stat.onlineRate}%)`"
  120. placement="top"
  121. >
  122. <div class="device-stat-item">
  123. <el-icon :style="{color: getDeviceTypeColor(stat.deviceType)}">
  124. <component :is="getDeviceIcon(stat.deviceType)" />
  125. </el-icon>
  126. <span>{{ stat.count }}</span>
  127. </div>
  128. </el-tooltip>
  129. </div>
  130. </template>
  131. </el-table-column>
  132. <el-table-column label="设备总数" align="center" width="100" sortable>
  133. <template #default="scope">
  134. <el-badge :value="getTotalDeviceCount(scope.row.deviceStats)" class="device-count-badge">
  135. <el-icon><Monitor /></el-icon>
  136. </el-badge>
  137. </template>
  138. </el-table-column>
  139. <el-table-column label="平均在线率" align="center" width="120" sortable>
  140. <template #default="scope">
  141. <div class="online-rate">
  142. <el-progress
  143. :percentage="getAverageOnlineRate(scope.row.deviceStats)"
  144. :width="60"
  145. type="circle"
  146. :color="getOnlineRateColor(getAverageOnlineRate(scope.row.deviceStats))"
  147. >
  148. <template #default="{percentage}">
  149. <span class="percentage-text">{{ percentage.toFixed(0) }}%</span>
  150. </template>
  151. </el-progress>
  152. </div>
  153. </template>
  154. </el-table-column>
  155. <el-table-column label="健康指数" align="center" width="160" sortable>
  156. <template #default="scope">
  157. <div class="health-index">
  158. <span class="health-score">{{ parseKeyMetrics(scope.row.keyMetrics).healthIndex }}</span>
  159. <el-progress
  160. :percentage="Number(parseKeyMetrics(scope.row.keyMetrics).healthIndex)"
  161. :stroke-width="6"
  162. :color="getHealthColor(Number(parseKeyMetrics(scope.row.keyMetrics).healthIndex))"
  163. />
  164. </div>
  165. </template>
  166. </el-table-column>
  167. <el-table-column label="操作" align="center" width="150" fixed="right">
  168. <template #default="scope">
  169. <el-button type="primary" size="small" link @click.stop="handleViewDetail(scope.row)">
  170. 详情
  171. </el-button>
  172. <el-button type="warning" size="small" link @click.stop="handleViewChart(scope.row)">
  173. 图表
  174. </el-button>
  175. <el-button type="success" size="small" link @click.stop="handleViewMap(scope.row)">
  176. 地图
  177. </el-button>
  178. </template>
  179. </el-table-column>
  180. </el-table>
  181. <!-- 分页组件 -->
  182. <el-pagination
  183. v-show="total > 0"
  184. v-model:current-page="queryParams.pageNum"
  185. v-model:page-size="queryParams.pageSize"
  186. :page-sizes="[10, 20, 50, 100]"
  187. :total="total"
  188. layout="total, sizes, prev, pager, next, jumper"
  189. @size-change="handleSizeChange"
  190. @current-change="handlePageChange"
  191. class="pagination"
  192. />
  193. </el-card>
  194. <!-- 详情弹窗 -->
  195. <el-dialog v-model="detailDialogOpen" title="区域详情" width="1200px" top="5vh">
  196. <el-descriptions :column="3" border>
  197. <el-descriptions-item label="区域ID">{{ detailArea.areaId }}</el-descriptions-item>
  198. <el-descriptions-item label="区域名称">{{ detailArea.areaName }}</el-descriptions-item>
  199. <el-descriptions-item label="父区域">
  200. <el-tag v-if="detailArea.parentAreaId" type="info">{{ detailArea.parentAreaId }}</el-tag>
  201. <span v-else>无</span>
  202. </el-descriptions-item>
  203. <el-descriptions-item label="层级">
  204. <el-tag :type="getLevelType(detailArea.level)">
  205. {{ detailArea.level === 1 ? '一级区域' : detailArea.level === 2 ? '二级区域' : '三级区域' }}
  206. </el-tag>
  207. </el-descriptions-item>
  208. <el-descriptions-item label="平均能耗" :span="1">
  209. <el-statistic :value="parseKeyMetrics(detailArea.keyMetrics).averageEnergy" suffix="kWh">
  210. <template #prefix>
  211. <el-icon><Lightning /></el-icon>
  212. </template>
  213. </el-statistic>
  214. </el-descriptions-item>
  215. <el-descriptions-item label="最大负载" :span="1">
  216. <el-statistic :value="parseKeyMetrics(detailArea.keyMetrics).maxLoad" suffix="W">
  217. <template #prefix>
  218. <el-icon><Connection /></el-icon>
  219. </template>
  220. </el-statistic>
  221. </el-descriptions-item>
  222. </el-descriptions>
  223. <!-- 设备统计图表 -->
  224. <el-row :gutter="20" class="mt-4">
  225. <el-col :span="12">
  226. <el-card>
  227. <template #header>
  228. <span>设备类型分布</span>
  229. </template>
  230. <div ref="pieChartRef" style="height: 300px;"></div>
  231. </el-card>
  232. </el-col>
  233. <el-col :span="12">
  234. <el-card>
  235. <template #header>
  236. <span>设备在线率趋势</span>
  237. </template>
  238. <div ref="lineChartRef" style="height: 300px;"></div>
  239. </el-card>
  240. </el-col>
  241. </el-row>
  242. <!-- 空间分布热力图 -->
  243. <el-card class="mt-4">
  244. <template #header>
  245. <span>设备位置热力图</span>
  246. </template>
  247. <div class="heatmap-container" ref="heatmapContainer">
  248. <div
  249. v-for="point in heatmapPoints"
  250. :key="point.id"
  251. class="heatmap-point"
  252. :style="{
  253. left: point.x + 'px',
  254. top: point.y + 'px',
  255. width: point.size + 'px',
  256. height: point.size + 'px',
  257. background: `radial-gradient(circle, rgba(255, 87, 34, ${point.opacity}) 0%, transparent 70%)`
  258. }"
  259. @click="handlePointClick(point)"
  260. >
  261. <el-tooltip :content="`设备密度: ${point.density}`" placement="top">
  262. <div class="point-inner"></div>
  263. </el-tooltip>
  264. </div>
  265. </div>
  266. </el-card>
  267. <template #footer>
  268. <span class="dialog-footer">
  269. <el-button @click="detailDialogOpen = false">关闭</el-button>
  270. <el-button type="primary" @click="handlePrintDetail">打印</el-button>
  271. </span>
  272. </template>
  273. </el-dialog>
  274. </div>
  275. </template>
  276. <script setup>
  277. import { ref, reactive, computed, onMounted, nextTick } from 'vue';
  278. import { ElMessage, ElMessageBox } from 'element-plus';
  279. import {
  280. Search,
  281. Refresh,
  282. Download,
  283. Location,
  284. Monitor,
  285. CircleCheck,
  286. TrendCharts,
  287. Lightning,
  288. Connection,
  289. Cpu,
  290. Camera,
  291. Microphone,
  292. VideoCamera,
  293. DataAnalysis
  294. } from '@element-plus/icons-vue';
  295. import * as echarts from 'echarts';
  296. import { getBuildingEquipmentMonitoringAreaList, getAreaStats } from '@/api/buildingEquipmentMonitoring/buildingEquipmentMonitoring';
  297. // 查询参数
  298. const queryParams = reactive({
  299. areaName: '',
  300. level: '',
  301. deviceType: '',
  302. status: '',
  303. pageNum: 1,
  304. pageSize: 10,
  305. });
  306. // 数据状态
  307. const areaList = ref([]);
  308. const total = ref(0);
  309. const isLoading = ref(false);
  310. const detailDialogOpen = ref(false);
  311. const detailArea = ref({});
  312. // 统计数据
  313. const stats = reactive({
  314. totalAreas: 0,
  315. totalDevices: 0,
  316. onlineDevices: 0,
  317. onlineRate: 0,
  318. avgHealthIndex: 0
  319. });
  320. // 图表实例
  321. const pieChartRef = ref(null);
  322. const lineChartRef = ref(null);
  323. let pieChart = null;
  324. let lineChart = null;
  325. // 热力图数据
  326. const heatmapPoints = ref([]);
  327. const heatmapContainer = ref(null);
  328. // 获取层级标签类型
  329. const getLevelType = (level) => {
  330. const types = ['success', 'warning', 'danger'];
  331. return types[level - 1] || 'info';
  332. };
  333. // 获取设备图标
  334. const getDeviceIcon = (type) => {
  335. const icons = {
  336. 'CONTROLLER': Cpu,
  337. 'SENSOR': DataAnalysis,
  338. 'ACTUATOR': Connection,
  339. 'BROADCASTER': Microphone,
  340. 'MONITOR': Camera
  341. };
  342. return icons[type] || Monitor;
  343. };
  344. // 设备类型映射
  345. const mapDeviceType = (type) => {
  346. const typeMap = {
  347. 'CONTROLLER': '控制器',
  348. 'SENSOR': '传感器',
  349. 'ACTUATOR': '执行器',
  350. 'BROADCASTER': '广播设备',
  351. 'MONITOR': '监控设备',
  352. };
  353. return typeMap[type] || type;
  354. };
  355. // 设备类型颜色
  356. const getDeviceTypeColor = (type) => {
  357. const colorMap = {
  358. 'CONTROLLER': '#409eff',
  359. 'SENSOR': '#67c23a',
  360. 'ACTUATOR': '#f56c6c',
  361. 'BROADCASTER': '#e6a23c',
  362. 'MONITOR': '#909399',
  363. };
  364. return colorMap[type] || '#409eff';
  365. };
  366. // 计算设备总数
  367. const getTotalDeviceCount = (stats) => {
  368. return stats.reduce((sum, stat) => sum + stat.count, 0);
  369. };
  370. // 计算平均在线率
  371. const getAverageOnlineRate = (stats) => {
  372. if (stats.length === 0) return 0;
  373. const totalDevices = stats.reduce((sum, stat) => sum + stat.count, 0);
  374. const onlineDevices = stats.reduce((sum, stat) => sum + (stat.count * stat.onlineRate / 100), 0);
  375. return totalDevices > 0 ? (onlineDevices / totalDevices) * 100 : 0;
  376. };
  377. // 获取在线率颜色
  378. const getOnlineRateColor = (rate) => {
  379. if (rate >= 90) return '#67c23a';
  380. if (rate >= 70) return '#e6a23c';
  381. return '#f56c6c';
  382. };
  383. // 解析关键指标
  384. const parseKeyMetrics = (keyMetricsStr) => {
  385. if (!keyMetricsStr) return { averageEnergy: 0, maxLoad: 0, healthIndex: 0 };
  386. const [averageEnergy = 0, maxLoad = 0, healthIndex = 0] = keyMetricsStr.split(',').map(Number);
  387. return {
  388. averageEnergy: averageEnergy.toFixed(2),
  389. maxLoad: maxLoad.toFixed(0),
  390. healthIndex: healthIndex.toFixed(0),
  391. };
  392. };
  393. // 获取健康指数颜色
  394. const getHealthColor = (healthIndex) => {
  395. if (healthIndex >= 90) return '#67c23a';
  396. if (healthIndex >= 70) return '#409eff';
  397. if (healthIndex >= 50) return '#e6a23c';
  398. return '#f56c6c';
  399. };
  400. // 表格行样式
  401. const tableRowClassName = ({ row }) => {
  402. const healthIndex = Number(parseKeyMetrics(row.keyMetrics).healthIndex);
  403. if (healthIndex < 60) return 'warning-row';
  404. if (healthIndex < 40) return 'danger-row';
  405. return '';
  406. };
  407. // 获取统计数据
  408. const getStats = async () => {
  409. try {
  410. const response = await getAreaStats();
  411. Object.assign(stats, response.data);
  412. } catch (error) {
  413. console.error('获取统计数据失败:', error);
  414. }
  415. };
  416. // 获取数据
  417. const getList = async () => {
  418. isLoading.value = true;
  419. try {
  420. const response = await getBuildingEquipmentMonitoringAreaList(queryParams);
  421. areaList.value = response.data.list || [];
  422. total.value = response.data.total || 0;
  423. } catch (error) {
  424. ElMessage.error('获取区域数据失败');
  425. console.error(error);
  426. } finally {
  427. isLoading.value = false;
  428. }
  429. };
  430. // 搜索
  431. const handleSearch = () => {
  432. queryParams.pageNum = 1;
  433. getList();
  434. getStats();
  435. };
  436. // 重置
  437. const resetSearch = () => {
  438. Object.assign(queryParams, {
  439. areaName: '',
  440. level: '',
  441. deviceType: '',
  442. status: '',
  443. pageNum: 1,
  444. pageSize: 10,
  445. });
  446. getList();
  447. getStats();
  448. };
  449. // 导出数据
  450. const handleExport = async () => {
  451. try {
  452. isLoading.value = true;
  453. await exportAreaData(queryParams);
  454. ElMessage.success('导出成功');
  455. } catch (error) {
  456. ElMessage.error('导出失败');
  457. } finally {
  458. isLoading.value = false;
  459. }
  460. };
  461. // 分页
  462. const handlePageChange = (newPage) => {
  463. queryParams.pageNum = newPage;
  464. getList();
  465. };
  466. const handleSizeChange = (newSize) => {
  467. queryParams.pageSize = newSize;
  468. queryParams.pageNum = 1;
  469. getList();
  470. };
  471. // 查看详情
  472. const handleViewDetail = (row) => {
  473. detailArea.value = { ...row };
  474. detailDialogOpen.value = true;
  475. nextTick(() => {
  476. initCharts(row);
  477. parseHeatmapData(row.heatmapData);
  478. });
  479. };
  480. // 行点击事件
  481. const handleRowClick = (row) => {
  482. handleViewDetail(row);
  483. };
  484. // 查看图表
  485. const handleViewChart = (row) => {
  486. // 可以打开单独的图表弹窗
  487. ElMessage.info('图表功能开发中...');
  488. };
  489. // 查看地图
  490. const handleViewMap = (row) => {
  491. // 可以打开地图视图
  492. ElMessage.info('地图功能开发中...');
  493. };
  494. // 初始化图表
  495. const initCharts = (areaData) => {
  496. // 销毁旧实例
  497. if (pieChart) pieChart.dispose();
  498. if (lineChart) lineChart.dispose();
  499. // 饼图 - 设备类型分布
  500. if (pieChartRef.value) {
  501. pieChart = echarts.init(pieChartRef.value);
  502. const pieData = areaData.deviceStats.map(stat => ({
  503. name: mapDeviceType(stat.deviceType),
  504. value: stat.count,
  505. itemStyle: { color: getDeviceTypeColor(stat.deviceType) }
  506. }));
  507. const pieOption = {
  508. tooltip: {
  509. trigger: 'item',
  510. formatter: '{a} <br/>{b}: {c} ({d}%)'
  511. },
  512. legend: {
  513. orient: 'vertical',
  514. left: 'left',
  515. data: pieData.map(item => item.name)
  516. },
  517. series: [
  518. {
  519. name: '设备类型',
  520. type: 'pie',
  521. radius: ['40%', '70%'],
  522. avoidLabelOverlap: false,
  523. itemStyle: {
  524. borderRadius: 10,
  525. borderColor: '#fff',
  526. borderWidth: 2
  527. },
  528. label: {
  529. show: false,
  530. position: 'center'
  531. },
  532. emphasis: {
  533. label: {
  534. show: true,
  535. fontSize: '20',
  536. fontWeight: 'bold'
  537. }
  538. },
  539. labelLine: {
  540. show: false
  541. },
  542. data: pieData
  543. }
  544. ]
  545. };
  546. pieChart.setOption(pieOption);
  547. }
  548. // 折线图 - 在线率趋势(模拟数据)
  549. if (lineChartRef.value) {
  550. lineChart = echarts.init(lineChartRef.value);
  551. const hours = Array.from({ length: 24 }, (_, i) => `${i}:00`);
  552. const onlineRates = hours.map(() => 80 + Math.random() * 20);
  553. const lineOption = {
  554. tooltip: {
  555. trigger: 'axis',
  556. formatter: '{b}<br/>在线率: {c}%'
  557. },
  558. grid: {
  559. left: '3%',
  560. right: '4%',
  561. bottom: '3%',
  562. containLabel: true
  563. },
  564. xAxis: {
  565. type: 'category',
  566. boundaryGap: false,
  567. data: hours
  568. },
  569. yAxis: {
  570. type: 'value',
  571. min: 0,
  572. max: 100,
  573. axisLabel: {
  574. formatter: '{value}%'
  575. }
  576. },
  577. series: [
  578. {
  579. name: '在线率',
  580. type: 'line',
  581. smooth: true,
  582. symbol: 'none',
  583. lineStyle: {
  584. width: 3,
  585. color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
  586. { offset: 0, color: '#409eff' },
  587. { offset: 1, color: '#67c23a' }
  588. ])
  589. },
  590. areaStyle: {
  591. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  592. { offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
  593. { offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
  594. ])
  595. },
  596. data: onlineRates
  597. }
  598. ]
  599. };
  600. lineChart.setOption(lineOption);
  601. }
  602. };
  603. // 解析热力图数据
  604. const parseHeatmapData = (heatmapStr) => {
  605. try {
  606. if (!heatmapStr) {
  607. // 生成模拟数据
  608. heatmapPoints.value = Array.from({ length: 20 }, (_, i) => ({
  609. id: `point-${i}`,
  610. x: Math.random() * 500,
  611. y: Math.random() * 300,
  612. size: 20 + Math.random() * 40,
  613. opacity: 0.3 + Math.random() * 0.7,
  614. density: Math.floor(Math.random() * 10)
  615. }));
  616. return;
  617. }
  618. const { heatData } = JSON.parse(heatmapStr);
  619. heatmapPoints.value = heatData.map(([x, y, opacity], index) => ({
  620. id: `point-${index}`,
  621. x: x * 5,
  622. y: y * 3,
  623. size: 20 + opacity * 40,
  624. opacity: opacity,
  625. density: Math.floor(opacity * 10)
  626. }));
  627. } catch (error) {
  628. console.error('解析热力图数据失败:', error);
  629. heatmapPoints.value = [];
  630. }
  631. };
  632. // 点击热力图点
  633. const handlePointClick = (point) => {
  634. ElMessage.info(`设备密度: ${point.density}`);
  635. };
  636. // 打印详情
  637. const handlePrintDetail = () => {
  638. window.print();
  639. };
  640. // 窗口大小改变时重绘图表
  641. const handleResize = () => {
  642. if (pieChart) pieChart.resize();
  643. if (lineChart) lineChart.resize();
  644. };
  645. // 初始化
  646. onMounted(() => {
  647. getList();
  648. getStats();
  649. window.addEventListener('resize', handleResize);
  650. });
  651. // 清理
  652. onUnmounted(() => {
  653. window.removeEventListener('resize', handleResize);
  654. if (pieChart) pieChart.dispose();
  655. if (lineChart) lineChart.dispose();
  656. });
  657. </script>
  658. <style scoped>
  659. .area-management-system {
  660. padding: 20px;
  661. }
  662. .filter-card {
  663. margin-bottom: 20px;
  664. }
  665. .search-form {
  666. display: flex;
  667. flex-wrap: wrap;
  668. gap: 10px;
  669. }
  670. .stats-row {
  671. margin-bottom: 20px;
  672. }
  673. .stats-card {
  674. text-align: center;
  675. transition: all 0.3s;
  676. }
  677. .stats-card:hover {
  678. transform: translateY(-5px);
  679. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  680. }
  681. .online-card :deep(.el-statistic__number) {
  682. color: #67c23a;
  683. }
  684. .health-card :deep(.el-statistic__number) {
  685. color: #409eff;
  686. }
  687. .rate-text {
  688. font-size: 14px;
  689. color: #909399;
  690. margin-left: 5px;
  691. }
  692. .data-table {
  693. min-height: 500px;
  694. }
  695. .area-name-cell {
  696. display: flex;
  697. align-items: center;
  698. gap: 8px;
  699. }
  700. .level-indicator {
  701. width: 8px;
  702. height: 8px;
  703. border-radius: 50%;
  704. flex-shrink: 0;
  705. }
  706. .level-1 { background-color: #67c23a; }
  707. .level-2 { background-color: #e6a23c; }
  708. .level-3 { background-color: #f56c6c; }
  709. .device-stats {
  710. display: flex;
  711. justify-content: center;
  712. gap: 15px;
  713. }
  714. .device-stat-item {
  715. display: flex;
  716. align-items: center;
  717. gap: 4px;
  718. padding: 4px 8px;
  719. background: #f5f7fa;
  720. border-radius: 4px;
  721. cursor: pointer;
  722. transition: all 0.3s;
  723. }
  724. .device-stat-item:hover {
  725. background: #e6e8eb;
  726. transform: scale(1.05);
  727. }
  728. .device-count-badge {
  729. cursor: pointer;
  730. }
  731. .online-rate {
  732. display: flex;
  733. justify-content: center;
  734. }
  735. .percentage-text {
  736. font-size: 14px;
  737. font-weight: bold;
  738. }
  739. .health-index {
  740. display: flex;
  741. flex-direction: column;
  742. align-items: center;
  743. gap: 5px;
  744. }
  745. .health-score {
  746. font-size: 18px;
  747. font-weight: bold;
  748. color: #303133;
  749. }
  750. :deep(.warning-row) {
  751. background-color: #fef0f0;
  752. }
  753. :deep(.danger-row) {
  754. background-color: #fde2e2;
  755. }
  756. .mt-4 {
  757. margin-top: 20px;
  758. }
  759. .heatmap-container {
  760. position: relative;
  761. height: 400px;
  762. background: linear-gradient(135deg, #f5f7fa 0%, #e9ecef 100%);
  763. border-radius: 8px;
  764. overflow: hidden;
  765. }
  766. .heatmap-point {
  767. position: absolute;
  768. border-radius: 50%;
  769. cursor: pointer;
  770. transition: all 0.3s;
  771. animation: pulse 2s infinite;
  772. }
  773. .heatmap-point:hover {
  774. transform: scale(1.2);
  775. z-index: 10;
  776. }
  777. .point-inner {
  778. width: 100%;
  779. height: 100%;
  780. border-radius: 50%;
  781. }
  782. @keyframes pulse {
  783. 0% {
  784. transform: scale(1);
  785. }
  786. 50% {
  787. transform: scale(1.05);
  788. }
  789. 100% {
  790. transform: scale(1);
  791. }
  792. }
  793. .pagination {
  794. margin-top: 20px;
  795. display: flex;
  796. justify-content: flex-end;
  797. }
  798. .dialog-footer {
  799. display: flex;
  800. justify-content: flex-end;
  801. gap: 10px;
  802. }
  803. /* 响应式布局 */
  804. @media (max-width: 1200px) {
  805. .stats-row .el-col {
  806. margin-bottom: 10px;
  807. }
  808. .device-stats {
  809. flex-wrap: wrap;
  810. }
  811. }
  812. @media (max-width: 768px) {
  813. .search-form {
  814. display: block;
  815. }
  816. .search-form .el-form-item {
  817. margin-bottom: 10px;
  818. }
  819. .el-dialog {
  820. width: 95% !important;
  821. }
  822. .el-table {
  823. font-size: 12px;
  824. }
  825. .device-stat-item {
  826. padding: 2px 4px;
  827. font-size: 12px;
  828. }
  829. }
  830. /* 打印样式 */
  831. @media print {
  832. .filter-card,
  833. .stats-row,
  834. .pagination,
  835. .dialog-footer,
  836. .el-table__column:last-child {
  837. display: none !important;
  838. }
  839. .data-table {
  840. box-shadow: none !important;
  841. }
  842. .heatmap-point {
  843. animation: none !important;
  844. }
  845. }
  846. /* 自定义滚动条 */
  847. .el-table__body-wrapper::-webkit-scrollbar {
  848. width: 8px;
  849. height: 8px;
  850. }
  851. .el-table__body-wrapper::-webkit-scrollbar-track {
  852. background: #f1f1f1;
  853. }
  854. .el-table__body-wrapper::-webkit-scrollbar-thumb {
  855. background: #c0c4cc;
  856. border-radius: 4px;
  857. }
  858. .el-table__body-wrapper::-webkit-scrollbar-thumb:hover {
  859. background: #909399;
  860. }
  861. /* 加载动画 */
  862. .el-loading-mask {
  863. background-color: rgba(255, 255, 255, 0.9);
  864. }
  865. /* 修复Badge数字显示不全的问题 */
  866. :deep(.el-table__row) {
  867. /* 确保行有足够的高度容纳Badge */
  868. min-height: 55px;
  869. }
  870. :deep(.el-table__cell) {
  871. /* 允许内容溢出单元格 */
  872. overflow: visible !important;
  873. }
  874. /* 设备总数单元格特殊处理 */
  875. .total-device-count {
  876. display: flex;
  877. justify-content: center;
  878. align-items: center;
  879. position: relative;
  880. /* 给Badge留出足够的空间 */
  881. padding-top: 10px;
  882. padding-bottom: 5px;
  883. }
  884. /* Badge容器调整 */
  885. .device-count-badge {
  886. display: inline-flex;
  887. position: relative;
  888. }
  889. /* 确保Badge内容不被裁剪 */
  890. .device-count-badge :deep(.el-badge__content) {
  891. /* 调整Badge位置,确保在单元格内完全显示 */
  892. top: -5px !important;
  893. right: 2px !important;
  894. }
  895. /* 针对设备总数这一列的特殊处理 */
  896. :deep(.el-table__body td:nth-child(5)) {
  897. /* 第5列是设备总数列,根据实际情况调整序号 */
  898. overflow: visible !important;
  899. position: relative;
  900. }
  901. :deep(.el-table__body td:nth-child(5) .cell) {
  902. overflow: visible !important;
  903. /* 增加上内边距,为Badge留出空间 */
  904. /*padding-top: 2px !important;*/
  905. }
  906. /* 如果上面的方案还不够,可以尝试这个更激进的方案 */
  907. :deep(.el-table) {
  908. /* 允许表格内容溢出 */
  909. overflow: visible !important;
  910. }
  911. :deep(.el-table__body-wrapper) {
  912. overflow-x: auto !important;
  913. overflow-y: visible !important;
  914. }
  915. :deep(.el-table__body) {
  916. overflow: visible !important;
  917. }
  918. </style>