dedsudiyu 1 周之前
父節點
當前提交
cedfb52c6d

+ 7 - 0
.claude/settings.local.json

@@ -0,0 +1,7 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(npm run:*)"
+    ]
+  }
+}

+ 11 - 0
.env.development

@@ -0,0 +1,11 @@
+# 开发环境配置
+VUE_APP_TITLE=实验室安全智能监测与管控中心
+
+# 后端接口基础地址(开发环境走代理,此处填 /dev-api)
+VUE_APP_BASE_API=/dev-api
+
+# laboratory 服务代理目标(改为实际后端地址)
+VUE_APP_LAB_TARGET=http://192.168.1.8/api
+
+# auth 服务代理目标(改为实际后端地址)
+VUE_APP_AUTH_TARGET=http://192.168.1.8/api

+ 5 - 0
.env.production

@@ -0,0 +1,5 @@
+# 生产环境配置
+VUE_APP_TITLE=实验室安全智能监测与管控中心
+
+# 生产环境接口地址(直接写后端域名,不走本地代理)
+VUE_APP_BASE_API=https://your-production-domain.com

二進制
public/favicon.ico


+ 8 - 22
src/api/auth.js

@@ -1,27 +1,13 @@
+import request from '@/utils/request'
+
 /**
- * 模拟登录 API
- * 使用 setTimeout 模拟异步请求
+ * 大屏登录
+ * POST /auth/bigLogin
  */
 export function loginApi(data) {
-  return new Promise((resolve) => {
-    setTimeout(() => {
-      // 模拟登录成功,返回 token
-      if (data.username && data.password) {
-        resolve({
-          code: 200,
-          message: '登录成功',
-          data: {
-            token: 'mock_token_' + Date.now(),
-            username: data.username
-          }
-        })
-      } else {
-        resolve({
-          code: 400,
-          message: '用户名或密码不能为空',
-          data: null
-        })
-      }
-    }, 500)
+  return request({
+    url: '/auth/bigLogin',
+    method: 'post',
+    data
   })
 }

+ 97 - 191
src/api/screen.js

@@ -1,220 +1,126 @@
+import request from '@/utils/request'
+
 /**
- * 大屏页面模拟数据 - 匹配 index-v2.html
+ * 1. 实验室基本情况统计 & 安全分级统计
+ * GET /laboratory/labScreen/labBasicStatistics
  */
+export function getLabBasicStatistics() {
+  return request({
+    url: '/laboratory/labScreen/labBasicStatistics',
+    method: 'get'
+  })
+}
 
-// 实验室基本情况统计
-export function getLabStats() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: {
-          total: 128,
-          levels: [
-            { name: 'I级', label: 'I 级(危险)', value: 12, color: '#cc0000' },
-            { name: 'II级', label: 'II 级(较危险)', value: 28, color: '#ff8000' },
-            { name: 'III级', label: 'III 级(一般)', value: 45, color: '#ffcc00' },
-            { name: 'IV级', label: 'IV 级(较安全)', value: 43, color: '#0066cc' }
-          ],
-          status: { active: 20, warning: 3, idle: 105 }
-        }
-      })
-    }, 300)
+/**
+ * 2. 实验室进入人数统计及走势(24小时)
+ * GET /laboratory/labScreen/passOutTrend
+ */
+export function getPassOutTrend() {
+  return request({
+    url: '/laboratory/labScreen/passOutTrend',
+    method: 'get'
   })
 }
 
-// 实验室安全分级统计
-export function getSafetyLevelStats() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: {
-          levels: ['I级', 'II级', 'III级', 'IV级'],
-          colors: ['#cc0000', '#ff8000', '#ffcc00', '#0066cc'],
-          units: ['化学所', '生物所', '材料所', '物理所', '工程所', '核能所', '信息所', '环境所', '计量所', '医学所'],
-          totals: [28, 22, 18, 24, 16, 12, 20, 15, 10, 14],
-          series: [
-            [2, 3, 5, 4, 2, 1, 3, 2, 1, 2],
-            [5, 6, 4, 7, 4, 3, 5, 4, 3, 4],
-            [9, 8, 6, 8, 5, 5, 7, 5, 4, 5],
-            [12, 5, 3, 5, 5, 3, 5, 4, 2, 3]
-          ]
-        }
-      })
-    }, 300)
+/**
+ * 3. 实验环境安全实时监测(分页)
+ * GET /laboratory/labScreen/envMonitor
+ */
+export function getEnvMonitor(params = { page: 1, pageSize: 10 }) {
+  return request({
+    url: '/laboratory/labScreen/envMonitor',
+    method: 'get',
+    params
   })
 }
 
-// 实验室进入人数统计及走势
-export function getPersonnelStats() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: {
-          totalEntry: 1284,
-          currentPresent: 47,
-          trend: {
-            times: ['00:00', '03:00', '06:00', '09:00', '12:00', '15:00', '18:00', '21:00', '24:00'],
-            entry: [0, 0, 2, 86, 128, 145, 160, 90, 30],
-            present: [0, 0, 2, 62, 98, 108, 120, 72, 20]
-          }
-        }
-      })
-    }, 300)
+/**
+ * 4. 实验室实时风险预警
+ * GET /laboratory/labScreen/riskWarning
+ */
+export function getRiskWarning() {
+  return request({
+    url: '/laboratory/labScreen/riskWarning',
+    method: 'get'
+  })
+}
+
+/**
+ * 5. 智能环境感知应用设备统计
+ * GET /laboratory/labScreen/iotDeviceStatistics
+ */
+export function getIotDeviceStatistics() {
+  return request({
+    url: '/laboratory/labScreen/iotDeviceStatistics',
+    method: 'get'
   })
 }
 
-// 智能环境感知应用设备统计
-export function getDeviceStats() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: {
-          online: 312,
-          offline: 18,
-          onlineRate: 94.5,
-          devices: [
-            { name: '电子信息铭牌', value: 86, icon: '🏷️' },
-            { name: '化学品智能终端', value: 44, icon: '⚗️' },
-            { name: '传感器套件', value: 128, icon: '🌡️' },
-            { name: '智能摄像设备', value: 72, icon: '📷' }
-          ]
-        }
-      })
-    }, 300)
+/**
+ * 6. 实验室设备分类及使用统计
+ * GET /laboratory/labScreen/deviceCategoryStat
+ */
+export function getDeviceCategoryStat() {
+  return request({
+    url: '/laboratory/labScreen/deviceCategoryStat',
+    method: 'get'
   })
 }
 
-// 实验室设备分类及使用统计
-export function getEquipmentStats() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: {
-          totalEquipment: 2458,
-          totalHours: 18620,
-          usageRate: 62.4,
-          categories: [
-            { value: 680, name: '检测设备', color: '#1e90ff' },
-            { value: 520, name: '分析仪器', color: '#4361ee' },
-            { value: 380, name: '制备设备', color: '#00e676' },
-            { value: 280, name: '安全设备', color: '#ffd740' },
-            { value: 240, name: '辅助设备', color: '#00e5c8' },
-            { value: 358, name: '其他', color: '#f97316' }
-          ],
-          usageStatus: [
-            { value: 486, name: '使用', color: '#1e90ff' },
-            { value: 1840, name: '空闲', color: '#00e676' },
-            { value: 34, name: '维修', color: '#f59e0b' }
-          ]
-        }
-      })
-    }, 300)
+/**
+ * 7. 风险预警 - 触发预案信息查询
+ * GET /laboratory/large/selectTriggerInfo
+ */
+export function selectTriggerInfo() {
+  return request({
+    url: '/laboratory/large/selectTriggerInfo',
+    method: 'get'
   })
 }
 
-// 实验环境安全智能感知
-export function getSensorList() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: [
-          { name: '化学分析实验室', room: 'A301', unit: '化学研究所', t: 22.5, h: 58, tvoc: 0.82, co2: 650, o2: 20.9, alert: true },
-          { name: '生物安全实验室', room: 'B201', unit: '生物研究所', t: 20.1, h: 62, tvoc: 0.18, co2: 580, o2: 20.8, alert: false },
-          { name: '材料测试实验室', room: 'C401', unit: '材料研究所', t: 24.0, h: 45, tvoc: 0.09, co2: 520, o2: 20.9, alert: false },
-          { name: '精密仪器实验室', room: 'A205', unit: '物理研究所', t: 21.5, h: 50, tvoc: 0.14, co2: 490, o2: 20.9, alert: false },
-          { name: '有机合成实验室', room: 'B302', unit: '化学研究所', t: 23.8, h: 55, tvoc: 2.10, co2: 725, o2: 20.5, alert: true },
-          { name: '光学检测实验室', room: 'C203', unit: '物理研究所', t: 22.0, h: 52, tvoc: 0.11, co2: 510, o2: 20.9, alert: false },
-          { name: '高压实验室', room: 'D101', unit: '工程研究所', t: 25.5, h: 48, tvoc: 0.30, co2: 600, o2: 20.7, alert: false },
-          { name: '低温实验室', room: 'D205', unit: '物理研究所', t: 18.0, h: 40, tvoc: 0.07, co2: 480, o2: 20.9, alert: false },
-          { name: '核磁共振室', room: 'A302', unit: '化学研究所', t: 20.5, h: 54, tvoc: 0.05, co2: 505, o2: 20.9, alert: false },
-          { name: '质谱分析室', room: 'A303', unit: '化学研究所', t: 21.0, h: 53, tvoc: 0.19, co2: 530, o2: 20.8, alert: false }
-        ]
-      })
-    }, 300)
+/**
+ * 8. 实时监控 - 楼栋楼层树
+ * POST /laboratory/labScreen/monitorTree
+ */
+export function getMonitorTree(data = {}) {
+  return request({
+    url: '/laboratory/labScreen/monitorTree',
+    method: 'post',
+    data
   })
 }
 
-// 实验室实时风险预警
-export function getWarningList() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: {
-          total: 42,
-          list: [
-            { lab: '化学分析实验室', room: 'A301', unit: '化学研究所', metric: 'TVOC浓度超标', val: '2.85 mg/m³', time: '2026-03-05 14:32:18' },
-            { lab: '有机合成实验室', room: 'B302', unit: '化学研究所', metric: 'CO₂浓度偏高', val: '725 ppm', time: '2026-03-05 13:58:44' },
-            { lab: '高压实验室', room: 'D101', unit: '工程研究所', metric: '温度异常', val: '38.5 °C', time: '2026-03-05 12:15:07' },
-            { lab: '生物安全实验室', room: 'B201', unit: '生物研究所', metric: '湿度超标', val: '88% RH', time: '2026-03-05 11:40:22' },
-            { lab: '材料测试实验室', room: 'C401', unit: '材料研究所', metric: 'TVOC超标', val: '1.2 mg/m³', time: '2026-03-05 10:08:55' },
-            { lab: '光学检测实验室', room: 'C203', unit: '物理研究所', metric: 'O₂浓度偏低', val: '19.2 %', time: '2026-03-05 09:22:33' },
-            { lab: '精密仪器实验室', room: 'A205', unit: '物理研究所', metric: '温度超标', val: '28.5 °C', time: '2026-03-05 08:45:11' },
-            { lab: '低温实验室', room: 'D205', unit: '物理研究所', metric: '气压异常', val: '85 kPa', time: '2026-03-04 23:12:40' }
-          ]
-        }
-      })
-    }, 300)
+/**
+ * 9. 实时监控 - 摄像头流地址
+ * POST /laboratory/labScreen/cameraStream
+ */
+export function getCameraStream(data) {
+  return request({
+    url: '/laboratory/labScreen/cameraStream',
+    method: 'post',
+    data
   })
 }
 
-// 监控摄像头列表
-export function getCameraList() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: [
-          'A301 有机合成室', 'A302 核磁共振室', 'A303 质谱室',
-          'A301 走廊', 'A302 走廊', 'A303 走廊',
-          'A层公共区域', 'A层安全通道', 'A层出入口'
-        ]
-      })
-    }, 300)
+/**
+ * getCameraList - 兼容 SecurityMonitor 组件,获取摄像头列表名称
+ * 复用 cameraStream,返回设备名列表
+ */
+export function getCameraList(data = {}) {
+  return request({
+    url: '/laboratory/labScreen/cameraStream',
+    method: 'post',
+    data
   })
 }
 
-// 监控树形数据
-export function getMonitorTree() {
-  return new Promise(resolve => {
-    setTimeout(() => {
-      resolve({
-        code: 200,
-        data: {
-          label: '安科院主园区',
-          children: [
-            {
-              label: '综合实验楼A',
-              children: [
-                { label: '1层', children: [{ label: 'A101 化学实验室' }, { label: 'A102 分析室' }] },
-                { label: '2层', children: [{ label: 'A201 生物实验室' }] },
-                { label: '3层', children: [{ label: 'A301 有机合成室' }, { label: 'A302 核磁共振室' }, { label: 'A303 质谱室' }] },
-                { label: '4层', children: [{ label: 'A401 X射线室' }] }
-              ]
-            },
-            {
-              label: '物理实验楼B',
-              children: [
-                { label: '1层', children: [{ label: 'B101 光学实验室' }] },
-                { label: '2层', children: [{ label: 'B201 低温实验室' }] }
-              ]
-            },
-            {
-              label: '工程技术楼C',
-              children: [
-                { label: '1层', children: [{ label: 'C101 机械加工室' }] },
-                { label: '2层', children: [{ label: 'C201 材料测试室' }] }
-              ]
-            }
-          ]
-        }
-      })
-    }, 300)
+/**
+ * 10. 大屏天气及服务器时间
+ * GET /laboratory/labScreen/weather
+ */
+export function getWeather() {
+  return request({
+    url: '/laboratory/labScreen/weather',
+    method: 'get'
   })
 }

+ 24 - 6
src/components/AlarmInfo.vue

@@ -34,14 +34,17 @@
 </template>
 
 <script>
-import { getWarningList } from '@/api/screen'
+import { getRiskWarning } from '@/api/screen'
+
+const LEVEL_LABELS = { 1: '低风险', 2: '中风险', 3: '较高风险', 4: '高风险' }
 
 export default {
   name: 'AlarmInfo',
   data() {
     return {
       totalCount: 0,
-      warningList: []
+      warningList: [],
+      pollTimer: null
     }
   },
   computed: {
@@ -52,13 +55,28 @@ export default {
   },
   mounted() {
     this.fetchData()
+    this.pollTimer = setInterval(this.fetchData, 5 * 60 * 1000)
+  },
+  beforeDestroy() {
+    if (this.pollTimer) clearInterval(this.pollTimer)
   },
   methods: {
     async fetchData() {
-      const res = await getWarningList()
-      if (res.code === 200) {
-        this.totalCount = res.data.total
-        this.warningList = res.data.list
+      try {
+        const res = await getRiskWarning()
+        if (res.code === 200) {
+          this.totalCount = res.data.monthAlarmCount
+          this.warningList = (res.data.eventList || []).map(e => ({
+            lab:    e.subName,
+            room:   '',
+            unit:   '',
+            metric: e.eventName,
+            val:    LEVEL_LABELS[e.riskPlanLevel] || '',
+            time:   e.eventStartTime ? e.eventStartTime.replace('T', ' ') : ''
+          }))
+        }
+      } catch (e) {
+        // 错误已由拦截器处理
       }
     }
   }

+ 2 - 6
src/components/CategoryChart.vue

@@ -12,7 +12,6 @@
 
 <script>
 import * as echarts from 'echarts'
-import { getHazardStats } from '@/api/screen'
 
 export default {
   name: 'CategoryChart',
@@ -30,11 +29,8 @@ export default {
   },
   methods: {
     async fetchData() {
-      const res = await getHazardStats()
-      if (res.code === 200) {
-        this.total = res.data.total
-        this.$nextTick(() => this.initChart(res.data.categories))
-      }
+      // 该组件所需接口暂无对应 API,使用空数据占位
+      this.$nextTick(() => this.initChart([]))
     },
     /** 初始化环形图 - 展示危险源分类分布 */
     initChart(categories) {

+ 25 - 17
src/components/EnvMonitorStats.vue

@@ -38,43 +38,51 @@
 
 <script>
 import * as echarts from 'echarts'
-import { getDeviceStats } from '@/api/screen'
+import { getIotDeviceStatistics } from '@/api/screen'
 
 export default {
   name: 'EnvMonitorStats',
   data() {
     return {
-      online: 312,
-      offline: 18,
-      onlineRate: 94.5,
-      devices: [
-        { icon: '🏷️', value: 86, name: '电子信息铭牌' },
-        { icon: '⚗️', value: 44, name: '化学品智能终端' },
-        { icon: '🌡️', value: 128, name: '传感器套件' },
-        { icon: '📷', value: 72, name: '智能摄像设备' }
-      ],
-      chart: null
+      online: 0,
+      offline: 0,
+      onlineRate: 0,
+      devices: [],
+      chart: null,
+      pollTimer: null
     }
   },
   mounted() {
     this.fetchData()
+    this.pollTimer = setInterval(this.fetchData, 5 * 60 * 1000)
   },
   beforeDestroy() {
+    if (this.pollTimer) clearInterval(this.pollTimer)
     if (this.chart) this.chart.dispose()
     window.removeEventListener('resize', this.handleResize)
   },
   methods: {
     async fetchData() {
       try {
-        const res = await getDeviceStats()
+        const res = await getIotDeviceStatistics()
         if (res.code === 200) {
-          this.online = res.data.online
-          this.offline = res.data.offline
-          this.onlineRate = res.data.onlineRate
-          this.devices = res.data.devices
+          const d = res.data
+          this.online = d.onlineCount
+          this.offline = d.offlineCount
+          this.onlineRate = d.onlineRate
+          this.devices = [
+            { icon: '🏷️', value: d.tagCount,      name: '电子信息铭牌' },
+            { icon: '🌡️', value: d.sensorCount,   name: '传感器套件' },
+            { icon: '⚗️', value: d.terminalCount, name: '化学品智能终端' },
+            { icon: '📷', value: d.cameraCount,   name: '智能摄像设备' }
+          ]
         }
       } catch (e) {
-        // use defaults
+        // 保持默认值
+      }
+      if (this.chart) {
+        this.chart.dispose()
+        this.chart = null
       }
       this.$nextTick(() => this.initChart())
     },

+ 30 - 25
src/components/EquipmentStats.vue

@@ -38,7 +38,7 @@
 
 <script>
 import * as echarts from 'echarts'
-import { getEquipmentStats } from '@/api/screen'
+import { getDeviceCategoryStat } from '@/api/screen'
 
 const TOOLTIP_CFG = {
   backgroundColor: 'rgba(3,14,42,0.92)',
@@ -46,34 +46,28 @@ const TOOLTIP_CFG = {
   textStyle: { color: '#a8cce8', fontSize: 28 }
 }
 
+const CATEGORY_COLORS = ['#1e90ff', '#4361ee', '#00e676', '#ffd740', '#00e5c8', '#f97316', '#e040fb', '#ff5252']
+
 export default {
   name: 'EquipmentStats',
   data() {
     return {
-      totalEquipment: 2458,
-      totalHours: 18620,
-      usageRate: 62.4,
-      categories: [
-        { value: 680, name: '检测设备', color: '#1e90ff' },
-        { value: 520, name: '分析仪器', color: '#4361ee' },
-        { value: 380, name: '制备设备', color: '#00e676' },
-        { value: 280, name: '安全设备', color: '#ffd740' },
-        { value: 240, name: '辅助设备', color: '#00e5c8' },
-        { value: 358, name: '其他', color: '#f97316' }
-      ],
-      usageStatus: [
-        { value: 486, name: '使用', color: '#1e90ff' },
-        { value: 1840, name: '空闲', color: '#00e676' },
-        { value: 34, name: '维修', color: '#f59e0b' }
-      ],
+      totalEquipment: 0,
+      totalHours: 0,
+      usageRate: 0,
+      categories: [],
+      usageStatus: [],
       ringChart: null,
-      pieChart: null
+      pieChart: null,
+      pollTimer: null
     }
   },
   mounted() {
     this.fetchData()
+    this.pollTimer = setInterval(this.fetchData, 5 * 60 * 1000)
   },
   beforeDestroy() {
+    if (this.pollTimer) clearInterval(this.pollTimer)
     if (this.ringChart) this.ringChart.dispose()
     if (this.pieChart) this.pieChart.dispose()
     window.removeEventListener('resize', this.handleResize)
@@ -81,17 +75,28 @@ export default {
   methods: {
     async fetchData() {
       try {
-        const res = await getEquipmentStats()
+        const res = await getDeviceCategoryStat()
         if (res.code === 200) {
-          this.totalEquipment = res.data.totalEquipment
-          this.totalHours = res.data.totalHours
-          this.usageRate = res.data.usageRate
-          this.categories = res.data.categories
-          this.usageStatus = res.data.usageStatus
+          const d = res.data
+          this.totalEquipment = d.totalCount
+          this.totalHours = d.totalUsageHours
+          this.usageRate = d.usageRate
+          this.categories = (d.categoryList || []).map((c, i) => ({
+            value: c.totalCount,
+            name:  c.categoryOneName,
+            color: CATEGORY_COLORS[i % CATEGORY_COLORS.length]
+          }))
+          this.usageStatus = [
+            { value: d.inUseCount,  name: '使用', color: '#1e90ff' },
+            { value: d.idleCount,   name: '空闲', color: '#00e676' },
+            { value: d.repairCount, name: '维修', color: '#f59e0b' }
+          ]
         }
       } catch (e) {
-        // use defaults
+        // 保持默认值
       }
+      if (this.ringChart) { this.ringChart.dispose(); this.ringChart = null }
+      if (this.pieChart)  { this.pieChart.dispose();  this.pieChart  = null }
       this.$nextTick(() => {
         this.initRingChart()
         this.initPieChart()

+ 25 - 8
src/components/EventStats.vue

@@ -111,7 +111,7 @@
 
 <script>
 import * as echarts from 'echarts'
-import { getLabStats } from '@/api/screen'
+import { getLabBasicStatistics } from '@/api/screen'
 
 export default {
   name: 'EventStats',
@@ -120,7 +120,8 @@ export default {
       total: 0,
       levels: [],
       status: { active: 0, warning: 0, idle: 0 },
-      chart: null
+      chart: null,
+      timer: null
     }
   },
   computed: {
@@ -153,8 +154,10 @@ export default {
   },
   mounted() {
     this.fetchData()
+    this.timer = setInterval(this.fetchData, 5 * 60 * 1000)
   },
   beforeDestroy() {
+    if (this.timer) clearInterval(this.timer)
     if (this.chart) {
       this.chart.dispose()
       this.chart = null
@@ -162,12 +165,26 @@ export default {
   },
   methods: {
     async fetchData() {
-      const res = await getLabStats()
-      if (res.code === 200) {
-        this.total = res.data.total
-        this.levels = res.data.levels
-        this.status = res.data.status
-        this.$nextTick(() => this.initDonutChart())
+      try {
+        const res = await getLabBasicStatistics()
+        if (res.code === 200) {
+          const d = res.data
+          this.total = d.labTotal
+          this.levels = [
+            { name: 'I级',  label: 'I 级(重大风险)', value: d.levelOneCount,   color: '#cc0000' },
+            { name: 'II级', label: 'II 级(高风险)',  value: d.levelTwoCount,   color: '#ff8000' },
+            { name: 'III级',label: 'III 级(中风险)', value: d.levelThreeCount, color: '#ffcc00' },
+            { name: 'IV级', label: 'IV 级(低风险)',  value: d.levelFourCount,  color: '#0066cc' }
+          ]
+          this.status = {
+            active:  d.useTotal,
+            warning: d.exceptionalTotal,
+            idle:    d.availableTotal
+          }
+          this.$nextTick(() => this.initDonutChart())
+        }
+      } catch (e) {
+        // 错误已由拦截器处理
       }
     },
     /** 根据分级主色计算稍亮的数值展示色 */

+ 34 - 5
src/components/LabEnvironment.vue

@@ -42,13 +42,14 @@
 </template>
 
 <script>
-import { getSensorList } from '@/api/screen'
+import { getEnvMonitor } from '@/api/screen'
 
 export default {
   name: 'LabEnvironment',
   data() {
     return {
-      sensorList: []
+      sensorList: [],
+      pollTimer: null
     }
   },
   computed: {
@@ -59,12 +60,40 @@ export default {
   },
   mounted() {
     this.fetchData()
+    this.pollTimer = setInterval(this.fetchData, 5 * 60 * 1000)
+  },
+  beforeDestroy() {
+    if (this.pollTimer) clearInterval(this.pollTimer)
   },
   methods: {
     async fetchData() {
-      const res = await getSensorList()
-      if (res.code === 200) {
-        this.sensorList = res.data
+      try {
+        const res = await getEnvMonitor({ page: 1, pageSize: 20 })
+        if (res.code === 200) {
+          const records = (res.data.records || [])
+          const list = []
+          records.forEach(lab => {
+            const sensors = lab.sensorList || []
+            const get = (code) => {
+              const s = sensors.find(s => s.code === code)
+              return s ? s.deviceValue : null
+            }
+            list.push({
+              name:  lab.subName,
+              room:  lab.roomNum,
+              unit:  '',
+              t:     get('temperature'),
+              h:     get('humidity'),
+              tvoc:  get('tvoc'),
+              co2:   get('co2'),
+              o2:    get('o2'),
+              alert: lab.hasException
+            })
+          })
+          this.sensorList = list
+        }
+      } catch (e) {
+        // 错误已由拦截器处理
       }
     }
   }

+ 34 - 12
src/components/PersonnelTrend.vue

@@ -39,7 +39,7 @@
 
 <script>
 import * as echarts from 'echarts'
-import { getPersonnelStats } from '@/api/screen'
+import { getPassOutTrend } from '@/api/screen'
 
 export default {
   name: 'PersonnelTrend',
@@ -51,7 +51,8 @@ export default {
       displayPresent: 0,
       chart: null,
       animTimerTotal: null,
-      animTimerPresent: null
+      animTimerPresent: null,
+      pollTimer: null
     }
   },
   computed: {
@@ -64,8 +65,10 @@ export default {
   },
   mounted() {
     this.fetchData()
+    this.pollTimer = setInterval(this.fetchData, 5 * 60 * 1000)
   },
   beforeDestroy() {
+    if (this.pollTimer) clearInterval(this.pollTimer)
     if (this.animTimerTotal) cancelAnimationFrame(this.animTimerTotal)
     if (this.animTimerPresent) cancelAnimationFrame(this.animTimerPresent)
     if (this.chart) {
@@ -75,17 +78,36 @@ export default {
   },
   methods: {
     async fetchData() {
-      const res = await getPersonnelStats()
-      if (res.code === 200) {
-        this.totalEntry = res.data.totalEntry
-        this.currentPresent = res.data.currentPresent
+      try {
+        const res = await getPassOutTrend()
+        if (res.code === 200) {
+          const d = res.data
+          this.totalEntry = d.todayEntryCount
+          this.currentPresent = d.currentLabUserCount
 
-        // Animated count-up on mount
-        this.$nextTick(() => {
-          setTimeout(() => this.animateCount('total', 0, this.totalEntry, 2000), 600)
-          setTimeout(() => this.animateCount('present', 0, this.currentPresent, 1500), 900)
-          this.initChart(res.data.trend)
-        })
+          const hours = Array.from({ length: 24 }, (_, i) => i)
+          const entryMap = {}
+          const presentMap = {}
+          ;(d.entryTrend || []).forEach(item => { entryMap[item.hour] = item.count })
+          ;(d.labUserTrend || []).forEach(item => { presentMap[item.hour] = item.count })
+          const trend = {
+            times:   hours.map(h => `${String(h).padStart(2, '0')}:00`),
+            entry:   hours.map(h => entryMap[h] || 0),
+            present: hours.map(h => presentMap[h] || 0)
+          }
+
+          this.$nextTick(() => {
+            setTimeout(() => this.animateCount('total', 0, this.totalEntry, 2000), 600)
+            setTimeout(() => this.animateCount('present', 0, this.currentPresent, 1500), 900)
+            if (this.chart) {
+              this.chart.dispose()
+              this.chart = null
+            }
+            this.initChart(trend)
+          })
+        }
+      } catch (e) {
+        // 错误已由拦截器处理
       }
     },
 

+ 29 - 5
src/components/SafetyCompliance.vue

@@ -13,7 +13,7 @@
 
 <script>
 import * as echarts from 'echarts'
-import { getSafetyLevelStats } from '@/api/screen'
+import { getLabBasicStatistics } from '@/api/screen'
 
 export default {
   name: 'SafetyCompliance',
@@ -21,13 +21,16 @@ export default {
     return {
       chart: null,
       scrollTimer: null,
-      scrollIndex: 0
+      scrollIndex: 0,
+      pollTimer: null
     }
   },
   mounted() {
     this.fetchData()
+    this.pollTimer = setInterval(this.fetchData, 5 * 60 * 1000)
   },
   beforeDestroy() {
+    if (this.pollTimer) clearInterval(this.pollTimer)
     if (this.scrollTimer) {
       clearInterval(this.scrollTimer)
       this.scrollTimer = null
@@ -39,9 +42,30 @@ export default {
   },
   methods: {
     async fetchData() {
-      const res = await getSafetyLevelStats()
-      if (res.code === 200) {
-        this.$nextTick(() => this.initChart(res.data))
+      try {
+        const res = await getLabBasicStatistics()
+        if (res.code === 200) {
+          const list = res.data.labLevelVOList || []
+          const chartData = {
+            levels: ['I级', 'II级', 'III级', 'IV级'],
+            colors: ['#cc0000', '#ff8000', '#ffcc00', '#0066cc'],
+            units:  list.map(i => i.deptName),
+            totals: list.map(i => i.levelOneCount + i.levelTwoCount + i.levelThreeCount + i.levelFourCount),
+            series: [
+              list.map(i => i.levelOneCount),
+              list.map(i => i.levelTwoCount),
+              list.map(i => i.levelThreeCount),
+              list.map(i => i.levelFourCount)
+            ]
+          }
+          if (this.chart) {
+            this.chart.dispose()
+            this.chart = null
+          }
+          this.$nextTick(() => this.initChart(chartData))
+        }
+      } catch (e) {
+        // 错误已由拦截器处理
       }
     },
     /** 初始化堆叠柱状图 - 展示各单位实验室安全分级统计 */

+ 3 - 2
src/components/SecurityMonitor.vue

@@ -203,9 +203,10 @@ export default {
   methods: {
     async fetchCameras() {
       try {
-        const res = await getCameraList()
+        const res = await getCameraList({ page: 1, pageSize: 9 })
         if (res.code === 200) {
-          this.cameras = res.data
+          const records = (res.data && res.data.records) || []
+          this.cameras = records.map(r => r.deviceName || r.room || r.subjectName || '')
         }
       } catch (e) {
         // fallback to static data

+ 35 - 8
src/utils/request.js

@@ -1,31 +1,58 @@
 import axios from 'axios'
+import { Message } from 'element-ui'
+import router from '@/router'
 
 // 创建 axios 实例
 const service = axios.create({
-  baseURL: process.env.VUE_APP_BASE_API || '/api',
-  timeout: 10000
+  baseURL: process.env.VUE_APP_BASE_API || '/dev-api',
+  timeout: 15000
 })
 
-// 请求拦截器
+// 请求拦截器 —— 注入 token
 service.interceptors.request.use(
   config => {
     const token = localStorage.getItem('token')
     if (token) {
-      config.headers['Authorization'] = `Bearer ${token}`
+      config.headers['Authorization'] = `${token}`
     }
     return config
   },
-  error => {
-    return Promise.reject(error)
-  }
+  error => Promise.reject(error)
 )
 
 // 响应拦截器
 service.interceptors.response.use(
   response => {
-    return response.data
+    const res = response.data
+    // 业务层 401:token 失效
+    if (res.code === 401) {
+      localStorage.removeItem('token')
+      Message.error('登录已过期,请重新登录')
+      if (router.currentRoute.path !== '/login') {
+        router.replace('/login')
+      }
+      return Promise.reject(new Error('Unauthorized'))
+    }
+    return res
   },
   error => {
+    if (error.response) {
+      const { status, data } = error.response
+      if (status === 401) {
+        // HTTP 401:token 失效,跳回登录页
+        localStorage.removeItem('token')
+        Message.error('登录已过期,请重新登录')
+        if (router.currentRoute.path !== '/login') {
+          router.replace('/login')
+        }
+      } else {
+        // 其他错误:优先展示后端 message,否则展示 HTTP 状态
+        const msg = (data && data.message) || `请求失败(${status})`
+        Message.error(msg)
+      }
+    } else {
+      Message.error('网络异常,请检查连接')
+    }
     return Promise.reject(error)
   }
 )

+ 12 - 19
src/views/Login.vue

@@ -11,9 +11,9 @@
         :rules="loginRules"
         class="login-form"
       >
-        <el-form-item prop="username">
+        <el-form-item prop="account">
           <el-input
-            v-model="loginForm.username"
+            v-model="loginForm.account"
             placeholder="请输入用户名/手机号"
             prefix-icon="el-icon-user"
           />
@@ -64,12 +64,12 @@ export default {
   data() {
     return {
       loginForm: {
-        username: '',
+        account: '',
         password: '',
         captcha: ''
       },
       loginRules: {
-        username: [
+        account: [
           { required: true, message: '请输入用户名', trigger: 'blur' }
         ],
         password: [
@@ -80,43 +80,36 @@ export default {
         ]
       },
       loading: false,
-      captchaText: '0000'
+      captchaText: '0000',
+      captchaUuid: 'captcha-uuid-default'
     }
   },
   methods: {
     refreshCaptcha() {
-      // 模拟验证码刷新,固定为 0000
       this.captchaText = '0000'
     },
     handleLogin() {
       this.$refs.loginForm.validate(async (valid) => {
         if (!valid) return
 
-        // 验证码校验
-        if (this.loginForm.captcha !== '0000') {
-          this.$message.error('验证码错误')
-          return
-        }
-
         this.loading = true
         try {
-          // 密码使用 md5 加密后提交
-          const encryptedPassword = md5(this.loginForm.password)
           const res = await loginApi({
-            username: this.loginForm.username,
-            password: encryptedPassword
+            account: this.loginForm.account,
+            password: md5(this.loginForm.password),
+            code: this.loginForm.captcha,
+            uuid: this.captchaUuid
           })
 
           if (res.code === 200) {
-            // 保存 token 到 Vuex
             this.$store.dispatch('login', res.data.token)
             this.$message.success('登录成功')
             this.$router.push('/screen')
           } else {
-            this.$message.error(res.message)
+            this.$message.error(res.message || '登录失败')
           }
         } catch (err) {
-          this.$message.error('登录失败,请重试')
+          // 错误已由拦截器处理
         } finally {
           this.loading = false
         }

+ 46 - 1
vue.config.js

@@ -3,15 +3,60 @@ const { defineConfig } = require('@vue/cli-service')
 module.exports = defineConfig({
   transpileDependencies: true,
   lintOnSave: false,
+
+  // 生产环境不生成 source map,减小包体积
+  productionSourceMap: false,
+
   devServer: {
     port: 8080,
-    open: true
+    open: true,
+    proxy: {
+      // /dev-api/auth/** → auth 服务
+      '/dev-api/auth': {
+        target: process.env.VUE_APP_AUTH_TARGET || 'http://localhost:8080',
+        changeOrigin: true,
+        pathRewrite: { '^/dev-api': '' }
+      },
+      // /dev-api/laboratory/** → laboratory 服务
+      '/dev-api/laboratory': {
+        target: process.env.VUE_APP_LAB_TARGET || 'http://localhost:8080',
+        changeOrigin: true,
+        pathRewrite: { '^/dev-api': '' }
+      }
+    }
   },
+
   css: {
     loaderOptions: {
       sass: {
         additionalData: `@import "@/styles/variables.scss";`
       }
     }
+  },
+
+  // 按需加载:将第三方库单独分包
+  configureWebpack: {
+    optimization: {
+      splitChunks: {
+        chunks: 'all',
+        cacheGroups: {
+          echarts: {
+            name: 'chunk-echarts',
+            test: /[\\/]node_modules[\\/]echarts[\\/]/,
+            priority: 20
+          },
+          elementUI: {
+            name: 'chunk-element-ui',
+            test: /[\\/]node_modules[\\/]element-ui[\\/]/,
+            priority: 15
+          },
+          vendors: {
+            name: 'chunk-vendors',
+            test: /[\\/]node_modules[\\/]/,
+            priority: 10
+          }
+        }
+      }
+    }
   }
 })