dedsudiyu 1 týždeň pred
commit
ef4ef5ee73
45 zmenil súbory, kde vykonal 13409 pridanie a 0 odobranie
  1. 1 0
      .gitignore
  2. 5 0
      babel.config.js
  3. BIN
      call-word/UI.png
  4. 1420 0
      call-word/index-v2.html
  5. 101 0
      call-word/play.md
  6. 1 0
      dist/css/515.286b12d8.css
  7. 1 0
      dist/css/553.6898c014.css
  8. 1 0
      dist/css/app.397d4214.css
  9. 1 0
      dist/css/chunk-vendors.10dd4e95.css
  10. BIN
      dist/fonts/element-icons.f1a45d74.ttf
  11. BIN
      dist/fonts/element-icons.ff18efd1.woff
  12. 1 0
      dist/index.html
  13. 2 0
      dist/js/515.40e467b6.js
  14. 1 0
      dist/js/515.40e467b6.js.map
  15. 12 0
      dist/js/553.7a41c852.js
  16. 1 0
      dist/js/553.7a41c852.js.map
  17. 2 0
      dist/js/app.16ae2cba.js
  18. 1 0
      dist/js/app.16ae2cba.js.map
  19. 55 0
      dist/js/chunk-vendors.db81f0fc.js
  20. 1 0
      dist/js/chunk-vendors.db81f0fc.js.map
  21. 7459 0
      package-lock.json
  22. 33 0
      package.json
  23. 16 0
      public/index.html
  24. 18 0
      src/App.vue
  25. 27 0
      src/api/auth.js
  26. 220 0
      src/api/screen.js
  27. 243 0
      src/components/AlarmInfo.vue
  28. 144 0
      src/components/CategoryChart.vue
  29. 330 0
      src/components/EnvMonitorStats.vue
  30. 403 0
      src/components/EquipmentStats.vue
  31. 570 0
      src/components/EventStats.vue
  32. 310 0
      src/components/LabEnvironment.vue
  33. 285 0
      src/components/PersonnelTrend.vue
  34. 165 0
      src/components/SafetyCompliance.vue
  35. 278 0
      src/components/ScreenHeader.vue
  36. 593 0
      src/components/SecurityMonitor.vue
  37. 21 0
      src/main.js
  38. 40 0
      src/router/index.js
  39. 31 0
      src/store/index.js
  40. 161 0
      src/styles/global.scss
  41. 37 0
      src/styles/variables.scss
  42. 33 0
      src/utils/request.js
  43. 238 0
      src/views/Login.vue
  44. 130 0
      src/views/Screen.vue
  45. 17 0
      vue.config.js

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+node_modules

+ 5 - 0
babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

BIN
call-word/UI.png


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1420 - 0
call-word/index-v2.html


+ 101 - 0
call-word/play.md

@@ -0,0 +1,101 @@
+# 项目:大屏数据展示(Vue2)
+
+## 一、项目基础信息
+- **项目名称**:大屏数据展示
+- **技术栈**:
+  - 框架:Vue 2.7.x
+  - 构建工具:@vue/cli 4/5
+  - UI组件库:Element UI(完整引入)
+  - 状态管理:Vuex 3.x
+  - 路由:Vue Router 3.x
+  - HTTP请求:axios
+  - 图表:ECharts 5
+  - 工具库:js-md5(用于密码加密)
+  - CSS预处理器:Sass(使用 `node-sass` 或 `sass`)
+  - 包管理器:npm
+- **Node版本要求**:>= 14.0
+
+## 二、核心功能需求
+- 构建一个典型的大屏数据展示系统,分辨率尺寸为9600*2800px
+- 包含以下完整页面:
+
+### 1. 登录页(Login)
+- 表单:用户名/手机号、密码(密码使用 `js-md5` 加密后模拟提交)
+- 验证码(可选,可用模拟验证码 0000)
+- 登录按钮,点击后调用模拟登录API(使用 `axios`,可先mock数据)
+- 登录成功后保存token(使用Vuex存储)并跳转到首页
+- 该页面样式你自行发挥 主要配色与UI图相近即可
+
+### 2. 大屏页面
+- 该页面应由多个组件组成 
+- 如 实验室事件情况统计 为一个组件 实实验室安全合格统计为一个组件 如此类推
+- 页面样式严格按照 UI图 样式 项目基础尺寸严格按照9600*2800px制作
+
+## 三、设计风格与规范
+- 样式:统一使用Sass编写自定义样式,变量文件统一管理
+
+## 四、执行计划
+
+### 步骤1:初始化项目骨架
+- 使用 `vue create` 创建项目,选择以下特性:
+  - Babel
+  - Router(使用history模式)
+  - Vuex
+  - CSS Pre-processors(选择Sass/SCSS)
+- 删除默认的HelloWorld组件,清理App.vue
+- 安装依赖:`element-ui`、`axios`、`echarts`、`js-md5`
+- 在 `main.js` 中完整引入Element UI和样式
+- 创建基本目录结构:
+src/
+api/ # axios接口封装
+assets/ # 静态资源
+components/ # 公共组件
+router/ # 路由配置
+store/ # Vuex模块
+styles/ # 全局样式
+utils/ # 工具函数
+views/ # 页面视图
+
+- 配置 `vue.config.js`(如有必要,可设置别名等)
+
+
+### 步骤2:配置路由和基础布局
+- 在 `router/index.js` 中配置登录页(/login)
+- 在 `App.vue` 中放置路由视图
+
+### 步骤3:实现页面
+- 实现 大屏页面
+- 页面中的视频相关功能不用实现 后期我来处理 只需提供基础box与样式即可
+## 五、附加说明
+- 所有API请求先在 `src/api` 中用 `setTimeout` 模拟返回数据,便于独立开发
+- 代码风格使用函数式组件(Vue2 Options API即可)
+- 注释:复杂逻辑(如图表初始化、加密)需添加注释
+
+**开始执行吧**
+
+# 修改页面
+- 观察UI图 调整4个大区域的比例
+
+# 修改页面
+- 调整4个区域比例 从左至右 第一个区域占比20% 第二个区域占比15% 第三个区域占比45% 第四个区域占比20%
+
+# 修改页面-解析 call-word\index-v2.html 文件
+1. 解析文件 把页面中的内容拆分到我的项目中替换原有组件
+
+# 读取新的UI图
+- @call-word/UI.png
+- 检查重构的页面是否一致
+
+# 排版布局错误 修改组件尺寸
+1. 左侧第一个列 从上到下的组件应是
+- 实验室基本情况统计 宽1892 高956
+- 实验室安全分级统计 宽1892 高1017
+- 实验室进入人数统计及走势 宽1892 高591
+2. 左侧第二个列 从上到下的组件应是
+- 智能环境感知应用设备统计 宽1414 高666
+- 实验室设备分类及使用统计 宽1414 高1916
+3. 左侧第三个列
+- 实时监控  宽4282 高2600
+3. 左侧第四个列 从上到下的组件应是
+- 实验环境安全智能感知 宽1892 高1807
+- 实验室实时风险预警 宽1892 高775

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/css/515.286b12d8.css


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/css/553.6898c014.css


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/css/app.397d4214.css


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/css/chunk-vendors.10dd4e95.css


BIN
dist/fonts/element-icons.f1a45d74.ttf


BIN
dist/fonts/element-icons.ff18efd1.woff


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/index.html


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 2 - 0
dist/js/515.40e467b6.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/js/515.40e467b6.js.map


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 12 - 0
dist/js/553.7a41c852.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/js/553.7a41c852.js.map


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 2 - 0
dist/js/app.16ae2cba.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/js/app.16ae2cba.js.map


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 55 - 0
dist/js/chunk-vendors.db81f0fc.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
dist/js/chunk-vendors.db81f0fc.js.map


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 7459 - 0
package-lock.json


+ 33 - 0
package.json

@@ -0,0 +1,33 @@
+{
+  "name": "lab-safety-screen",
+  "version": "1.0.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build"
+  },
+  "dependencies": {
+    "axios": "^0.27.2",
+    "core-js": "^3.8.3",
+    "echarts": "^5.4.3",
+    "element-ui": "^2.15.14",
+    "js-md5": "^0.8.3",
+    "vue": "^2.7.16",
+    "vue-router": "^3.6.5",
+    "vuex": "^3.6.2"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "~5.0.8",
+    "@vue/cli-plugin-router": "~5.0.8",
+    "@vue/cli-plugin-vuex": "~5.0.8",
+    "@vue/cli-service": "~5.0.8",
+    "sass": "~1.32.13",
+    "sass-loader": "^12.0.0",
+    "vue-template-compiler": "^2.7.16"
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead"
+  ]
+}

+ 16 - 0
public/index.html

@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <title>实验室安全智能监测与管控中心</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>请启用JavaScript以获得最佳体验。</strong>
+    </noscript>
+    <div id="app"></div>
+  </body>
+</html>

+ 18 - 0
src/App.vue

@@ -0,0 +1,18 @@
+<template>
+  <div id="app">
+    <router-view />
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'App'
+}
+</script>
+
+<style lang="scss">
+#app {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 27 - 0
src/api/auth.js

@@ -0,0 +1,27 @@
+/**
+ * 模拟登录 API
+ * 使用 setTimeout 模拟异步请求
+ */
+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)
+  })
+}

+ 220 - 0
src/api/screen.js

@@ -0,0 +1,220 @@
+/**
+ * 大屏页面模拟数据 - 匹配 index-v2.html
+ */
+
+// 实验室基本情况统计
+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)
+  })
+}
+
+// 实验室安全分级统计
+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)
+  })
+}
+
+// 实验室进入人数统计及走势
+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)
+  })
+}
+
+// 智能环境感知应用设备统计
+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)
+  })
+}
+
+// 实验室设备分类及使用统计
+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)
+  })
+}
+
+// 实验环境安全智能感知
+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)
+  })
+}
+
+// 实验室实时风险预警
+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)
+  })
+}
+
+// 监控摄像头列表
+export function getCameraList() {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      resolve({
+        code: 200,
+        data: [
+          'A301 有机合成室', 'A302 核磁共振室', 'A303 质谱室',
+          'A301 走廊', 'A302 走廊', 'A303 走廊',
+          'A层公共区域', 'A层安全通道', 'A层出入口'
+        ]
+      })
+    }, 300)
+  })
+}
+
+// 监控树形数据
+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)
+  })
+}

+ 243 - 0
src/components/AlarmInfo.vue

@@ -0,0 +1,243 @@
+<template>
+  <div class="panel alarm-info">
+    <!-- Animated border beam -->
+    <div class="border-beam"></div>
+    <!-- Panel header -->
+    <div class="panel-header">
+      <div class="panel-header-icon">&#x26A0;&#xFE0F;</div>
+      <span class="panel-title">实验室实时风险预警</span>
+      <div class="header-count" style="margin-left:auto">
+        <span class="count-label">本月</span>
+        <span class="count-value">{{ totalCount }}</span>
+        <span class="count-label">次</span>
+      </div>
+    </div>
+    <!-- Scrolling warning list -->
+    <div class="warn-scroll-wrap">
+      <div class="warn-scroll-inner">
+        <div
+          v-for="(item, idx) in scrollList"
+          :key="idx"
+          class="warn-item"
+        >
+          <div class="warn-item-head">
+            <span class="warn-lab">&#x1F6A8; {{ item.lab }}({{ item.room }})- {{ item.unit }}</span>
+            <span class="warn-time">{{ item.time }}</span>
+          </div>
+          <div class="warn-detail">
+            异常指标:<span class="warn-metric-val">{{ item.metric }} {{ item.val }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getWarningList } from '@/api/screen'
+
+export default {
+  name: 'AlarmInfo',
+  data() {
+    return {
+      totalCount: 0,
+      warningList: []
+    }
+  },
+  computed: {
+    /** Duplicate data for seamless CSS scroll loop */
+    scrollList() {
+      return [...this.warningList, ...this.warningList]
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  methods: {
+    async fetchData() {
+      const res = await getWarningList()
+      if (res.code === 200) {
+        this.totalCount = res.data.total
+        this.warningList = res.data.list
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.alarm-info {
+  position: relative;
+  border-radius: 15px;
+  overflow: hidden;
+  background: $bg-panel;
+  border: 1px solid $border;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  &::before {
+    content: '';
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+    border-radius: inherit;
+    background: linear-gradient(135deg, rgba(30,144,255,0.05) 0%, transparent 50%, rgba(0,216,255,0.03) 100%);
+  }
+}
+
+/* ===== Animated border beam ===== */
+.border-beam {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  border-radius: inherit;
+  overflow: hidden;
+  z-index: 2;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(30,144,255,0.9), rgba(0,216,255,0.7), transparent);
+    animation: beamTop 5s linear infinite;
+  }
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    right: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(0,216,255,0.7), rgba(30,144,255,0.9), transparent);
+    animation: beamBottom 5s linear infinite 2.5s;
+  }
+}
+
+@keyframes beamTop    { from { left: -40%; }  to { left: 100%; } }
+@keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
+
+/* ===== Panel header ===== */
+.panel-header {
+  display: flex;
+  align-items: center;
+  gap: 25px;
+  padding: 20px 30px 18px;
+  border-bottom: 1px solid $border;
+  background: linear-gradient(90deg, rgba(0,60,160,0.18), transparent);
+  flex-shrink: 0;
+}
+
+.panel-header-icon {
+  width: 65px;
+  height: 65px;
+  border-radius: 12px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32px;
+  background: linear-gradient(135deg, rgba(30,144,255,0.25), rgba(0,216,255,0.15));
+  border: 1px solid rgba(30,144,255,0.35);
+  animation: iconGlow 3s ease-in-out infinite;
+}
+
+@keyframes iconGlow {
+  0%, 100% { box-shadow: 0 0 15px rgba(30,144,255,0.3); }
+  50%      { box-shadow: 0 0 35px rgba(30,144,255,0.7), 0 0 12px rgba(0,216,255,0.3); }
+}
+
+.panel-title {
+  font-size: 30px;
+  font-weight: 600;
+  letter-spacing: 2px;
+  color: $cyan;
+}
+
+/* ===== Header count ===== */
+.header-count {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-shrink: 0;
+}
+
+.count-label {
+  font-size: 25px;
+  color: $text-dim;
+}
+
+.count-value {
+  font-size: 50px;
+  font-weight: 700;
+  color: #f59e0b;
+}
+
+/* ===== Warning scroll ===== */
+.warn-scroll-wrap {
+  overflow: hidden;
+  flex: 1;
+  min-height: 0;
+  padding: 15px 20px;
+}
+
+.warn-scroll-inner {
+  animation: scrollUp 22s linear infinite;
+
+  &:hover {
+    animation-play-state: paused;
+  }
+}
+
+@keyframes scrollUp {
+  0%   { transform: translateY(0); }
+  100% { transform: translateY(-50%); }
+}
+
+/* ===== Warning item ===== */
+.warn-item {
+  padding: 18px 20px;
+  border-radius: 10px;
+  margin-bottom: 12px;
+  background: rgba(245,158,11,0.05);
+  border: 1px solid rgba(245,158,11,0.2);
+}
+
+.warn-item-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 10px;
+  gap: 15px;
+}
+
+.warn-lab {
+  font-size: 28px;
+  font-weight: 600;
+  color: #fcd34d;
+  flex: 1;
+}
+
+.warn-time {
+  font-size: 25px;
+  color: $text-dim;
+  white-space: nowrap;
+}
+
+.warn-detail {
+  font-size: 25px;
+  color: $text-dim;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.warn-metric-val {
+  color: #fb923c;
+  font-weight: 600;
+}
+</style>

+ 144 - 0
src/components/CategoryChart.vue

@@ -0,0 +1,144 @@
+<template>
+  <div class="panel category-chart">
+    <div class="panel__title">实验室危险源分类统计</div>
+    <div class="chart-wrap">
+      <div ref="chart" class="chart"></div>
+    </div>
+    <div class="btn-row">
+      <button class="btn-floorplan">返回平面图</button>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getHazardStats } from '@/api/screen'
+
+export default {
+  name: 'CategoryChart',
+  data() {
+    return {
+      total: 0,
+      chart: null
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  beforeDestroy() {
+    if (this.chart) this.chart.dispose()
+  },
+  methods: {
+    async fetchData() {
+      const res = await getHazardStats()
+      if (res.code === 200) {
+        this.total = res.data.total
+        this.$nextTick(() => this.initChart(res.data.categories))
+      }
+    },
+    /** 初始化环形图 - 展示危险源分类分布 */
+    initChart(categories) {
+      this.chart = echarts.init(this.$refs.chart)
+      const option = {
+        color: categories.map(c => c.color),
+        tooltip: {
+          trigger: 'item',
+          textStyle: { fontSize: 24 },
+          formatter: '{b}: {c} ({d}%)'
+        },
+        legend: {
+          orient: 'vertical',
+          right: 10,
+          top: 'center',
+          textStyle: { color: 'rgba(180,210,255,0.85)', fontSize: 24 },
+          itemWidth: 20,
+          itemHeight: 12,
+          itemGap: 18
+        },
+        series: [{
+          type: 'pie',
+          radius: ['42%', '65%'],
+          center: ['35%', '50%'],
+          label: { show: false },
+          data: categories.map(c => ({ name: c.name, value: c.value })),
+          itemStyle: {
+            borderColor: '#0a1a3a',
+            borderWidth: 3
+          }
+        }],
+        graphic: [{
+          type: 'group',
+          left: '23%',
+          top: 'center',
+          children: [
+            {
+              type: 'text',
+              left: 'center',
+              top: -28,
+              style: {
+                text: String(this.total),
+                fill: '#00e5ff',
+                fontSize: 56,
+                fontWeight: 'bold',
+                textAlign: 'center'
+              }
+            },
+            {
+              type: 'text',
+              left: 'center',
+              top: 28,
+              style: {
+                text: '危险源总数',
+                fill: 'rgba(180,210,255,0.7)',
+                fontSize: 24,
+                textAlign: 'center'
+              }
+            }
+          ]
+        }]
+      }
+      this.chart.setOption(option)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.category-chart {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.chart-wrap {
+  flex: 1;
+  min-height: 0;
+
+  .chart {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.btn-row {
+  display: flex;
+  justify-content: center;
+  padding-top: 16px;
+
+  .btn-floorplan {
+    padding: 12px 48px;
+    font-size: $font-size-small;
+    color: $color-cyan;
+    background: rgba(0, 229, 255, 0.1);
+    border: 1px solid rgba(0, 229, 255, 0.4);
+    border-radius: 6px;
+    cursor: pointer;
+    letter-spacing: 4px;
+    transition: all 0.3s;
+
+    &:hover {
+      background: rgba(0, 229, 255, 0.2);
+    }
+  }
+}
+</style>

+ 330 - 0
src/components/EnvMonitorStats.vue

@@ -0,0 +1,330 @@
+<template>
+  <div class="panel env-monitor-stats">
+    <div class="border-beam"></div>
+    <svg class="pc tl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc tr" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc bl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc br" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <div class="panel-header">
+      <div class="panel-header-icon">📡</div>
+      <span class="panel-title">智能环境感知应用设备统计</span>
+    </div>
+    <div class="panel-body">
+      <!-- 在线/离线设备 -->
+      <div class="device-online-row">
+        <div class="device-stat-chip online">
+          <div class="dv">{{ online }}</div>
+          <div class="dl">在线设备</div>
+        </div>
+        <div class="device-stat-chip offline">
+          <div class="dv">{{ offline }}</div>
+          <div class="dl">离线设备</div>
+        </div>
+      </div>
+      <!-- 仪表盘 + 设备列表 -->
+      <div class="device-middle">
+        <div ref="gauge" class="gauge-chart"></div>
+        <div class="device-list-row">
+          <div class="device-list-item" v-for="item in devices" :key="item.name">
+            <div class="dli-icon">{{ item.icon }}</div>
+            <div class="dli-num">{{ item.value }}</div>
+            <div class="dli-name">{{ item.name }}</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getDeviceStats } 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
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  beforeDestroy() {
+    if (this.chart) this.chart.dispose()
+    window.removeEventListener('resize', this.handleResize)
+  },
+  methods: {
+    async fetchData() {
+      try {
+        const res = await getDeviceStats()
+        if (res.code === 200) {
+          this.online = res.data.online
+          this.offline = res.data.offline
+          this.onlineRate = res.data.onlineRate
+          this.devices = res.data.devices
+        }
+      } catch (e) {
+        // use defaults
+      }
+      this.$nextTick(() => this.initChart())
+    },
+    handleResize() {
+      if (this.chart) this.chart.resize()
+    },
+    /** 初始化仪表盘 - 展示设备在线率 */
+    initChart() {
+      this.chart = echarts.init(this.$refs.gauge, null, { renderer: 'canvas', devicePixelRatio: 2 })
+      const option = {
+        backgroundColor: 'transparent',
+        series: [{
+          type: 'gauge',
+          radius: '90%',
+          center: ['50%', '60%'],
+          startAngle: 210,
+          endAngle: -30,
+          min: 0,
+          max: 100,
+          splitNumber: 5,
+          axisLine: {
+            lineStyle: {
+              width: 30,
+              color: [
+                [0.3, '#ef4444'],
+                [0.6, '#f59e0b'],
+                [1, '#1e90ff']
+              ]
+            }
+          },
+          axisTick: { show: false },
+          splitLine: { show: false },
+          axisLabel: { show: false },
+          pointer: {
+            icon: 'path://M12.8,0.7l12.3,0 M0,0 l9.2,12.4 M0,0 l2.2,-1.6',
+            offsetCenter: [0, '-60%'],
+            width: 8,
+            length: '60%',
+            itemStyle: { color: '#1e90ff' }
+          },
+          detail: {
+            valueAnimation: true,
+            formatter: '{value}%',
+            color: '#ffd740',
+            fontSize: 45,
+            fontWeight: 700,
+            offsetCenter: [0, '30%']
+          },
+          title: {
+            show: true,
+            offsetCenter: [0, '62%'],
+            color: 'rgba(110,165,210,0.6)',
+            fontSize: 25
+          },
+          data: [{ value: this.onlineRate, name: '在线率' }]
+        }]
+      }
+      this.chart.setOption(option)
+      window.addEventListener('resize', this.handleResize)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.env-monitor-stats {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+}
+
+/* ---- Border beam ---- */
+.border-beam {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  border-radius: inherit;
+  overflow: hidden;
+  z-index: 2;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(30, 144, 255, 0.9), rgba(0, 216, 255, 0.7), transparent);
+    animation: beamTop 5s linear infinite;
+  }
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    right: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(0, 216, 255, 0.7), rgba(30, 144, 255, 0.9), transparent);
+    animation: beamBottom 5s linear infinite 2.5s;
+  }
+}
+
+@keyframes beamTop    { from { left: -40%; } to { left: 100%; } }
+@keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
+
+/* ---- Corner ornaments ---- */
+.pc {
+  position: absolute;
+  width: 35px;
+  height: 35px;
+  z-index: 3;
+  pointer-events: none;
+
+  &.tl { top: 0; left: 0; }
+  &.tr { top: 0; right: 0; transform: scaleX(-1); }
+  &.bl { bottom: 0; left: 0; transform: scaleY(-1); }
+  &.br { bottom: 0; right: 0; transform: scale(-1); }
+
+  svg { width: 100%; height: 100%; }
+}
+
+/* ---- Panel header ---- */
+.panel-header {
+  display: flex;
+  align-items: center;
+  gap: 25px;
+  padding: 20px 30px 18px;
+  border-bottom: 1px solid $border;
+  background: linear-gradient(90deg, rgba(0, 60, 160, 0.18), transparent);
+  flex-shrink: 0;
+}
+
+.panel-header-icon {
+  width: 65px;
+  height: 65px;
+  border-radius: 12px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32px;
+  background: linear-gradient(135deg, rgba(30, 144, 255, 0.25), rgba(0, 216, 255, 0.15));
+  border: 1px solid rgba(30, 144, 255, 0.35);
+  animation: iconGlow 3s ease-in-out infinite;
+}
+
+@keyframes iconGlow {
+  0%, 100% { box-shadow: 0 0 15px rgba(30, 144, 255, 0.3); }
+  50% { box-shadow: 0 0 35px rgba(30, 144, 255, 0.7), 0 0 12px rgba(0, 216, 255, 0.3); }
+}
+
+.panel-title {
+  font-size: 30px;
+  font-weight: 600;
+  letter-spacing: 2px;
+  color: $cyan;
+}
+
+/* ---- Panel body ---- */
+.panel-body {
+  padding: 20px 25px;
+}
+
+/* ---- Online / Offline row ---- */
+.device-online-row {
+  display: flex;
+  gap: 18px;
+  margin-bottom: 18px;
+}
+
+.device-stat-chip {
+  flex: 1;
+  padding: 15px;
+  border-radius: 10px;
+  text-align: center;
+  background: $bg-card;
+  border: 1px solid $border;
+
+  &.online {
+    border-color: $green;
+  }
+
+  &.offline {
+    border-color: $red;
+  }
+
+  .dv {
+    font-size: 50px;
+    font-weight: 700;
+  }
+
+  &.online .dv {
+    color: $green;
+  }
+
+  &.offline .dv {
+    color: $red;
+  }
+
+  .dl {
+    font-size: 25px;
+    color: $text-dim;
+  }
+}
+
+/* ---- Middle: gauge + device grid ---- */
+.device-middle {
+  display: flex;
+  gap: 20px;
+  align-items: center;
+}
+
+.gauge-chart {
+  flex: 0 0 295px;
+  height: 295px;
+}
+
+.device-list-row {
+  flex: 1;
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+
+.device-list-item {
+  padding: 15px;
+  border-radius: 10px;
+  background: $bg-card;
+  border: 1px solid $border;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+
+  .dli-icon {
+    font-size: 40px;
+  }
+
+  .dli-num {
+    font-size: 38px;
+    font-weight: 700;
+    color: $gold;
+  }
+
+  .dli-name {
+    font-size: 22px;
+    color: $text-dim;
+    text-align: center;
+    line-height: 1.3;
+  }
+}
+</style>

+ 403 - 0
src/components/EquipmentStats.vue

@@ -0,0 +1,403 @@
+<template>
+  <div class="panel equipment-stats">
+    <div class="border-beam"></div>
+    <div class="panel-header">
+      <div class="panel-header-icon">🔬</div>
+      <span class="panel-title">实验室设备分类及使用统计</span>
+    </div>
+    <!-- 4:2:4 vertical layout -->
+    <div class="equip-layout">
+      <!-- Top: ring/donut chart - equipment classification -->
+      <div class="equip-section equip-top">
+        <div ref="ringChart" class="ring-chart"></div>
+      </div>
+      <!-- Middle: 3 stat items -->
+      <div class="equip-section equip-mid">
+        <div class="equip-mid-row">
+          <div class="equip-stat-item">
+            <div class="ev">{{ totalEquipment.toLocaleString() }}</div>
+            <div class="el">设备总数(台)</div>
+          </div>
+          <div class="equip-stat-item">
+            <div class="ev">{{ totalHours.toLocaleString() }}</div>
+            <div class="el">使用时长(h)</div>
+          </div>
+          <div class="equip-stat-item">
+            <div class="ev">{{ usageRate }}%</div>
+            <div class="el">设备使用率</div>
+          </div>
+        </div>
+      </div>
+      <!-- Bottom: pie chart - usage status -->
+      <div class="equip-section equip-bottom">
+        <div ref="pieChart" class="pie-chart"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getEquipmentStats } from '@/api/screen'
+
+const TOOLTIP_CFG = {
+  backgroundColor: 'rgba(3,14,42,0.92)',
+  borderColor: 'rgba(30,144,255,0.3)',
+  textStyle: { color: '#a8cce8', fontSize: 28 }
+}
+
+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' }
+      ],
+      ringChart: null,
+      pieChart: null
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  beforeDestroy() {
+    if (this.ringChart) this.ringChart.dispose()
+    if (this.pieChart) this.pieChart.dispose()
+    window.removeEventListener('resize', this.handleResize)
+  },
+  methods: {
+    async fetchData() {
+      try {
+        const res = await getEquipmentStats()
+        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
+        }
+      } catch (e) {
+        // use defaults
+      }
+      this.$nextTick(() => {
+        this.initRingChart()
+        this.initPieChart()
+        window.addEventListener('resize', this.handleResize)
+      })
+    },
+    handleResize() {
+      if (this.ringChart) this.ringChart.resize()
+      if (this.pieChart) this.pieChart.resize()
+    },
+    /** 初始化环形图 - 设备分类 */
+    initRingChart() {
+      this.ringChart = echarts.init(this.$refs.ringChart, null, { renderer: 'canvas', devicePixelRatio: 2 })
+
+      // Build count map for legend formatter
+      const countMap = {}
+      this.categories.forEach(c => { countMap[c.name] = c.value })
+
+      const ringData = this.categories.map(c => ({
+        value: c.value,
+        name: c.name,
+        itemStyle: {
+          color: c.color,
+          shadowBlur: 8,
+          shadowColor: c.color.replace(')', ',0.5)').replace('rgb', 'rgba')
+        }
+      }))
+
+      const option = {
+        backgroundColor: 'transparent',
+        tooltip: {
+          trigger: 'item',
+          formatter: '{b}: {c}台 ({d}%)',
+          ...TOOLTIP_CFG
+        },
+        legend: {
+          orient: 'vertical',
+          right: '1%',
+          top: 'middle',
+          icon: 'circle',
+          itemWidth: 28,
+          itemHeight: 28,
+          itemGap: 22,
+          textStyle: { color: '#a8cce8', fontSize: 22 },
+          formatter: function(name) {
+            return '{nm|' + name + '}  {vl|' + countMap[name] + '台}'
+          },
+          rich: {
+            nm: { fontSize: 22, color: '#a8cce8', width: 90 },
+            vl: { fontSize: 26, fontWeight: 700, color: '#fff' }
+          }
+        },
+        series: [{
+          type: 'pie',
+          radius: ['38%', '62%'],
+          center: ['36%', '50%'],
+          itemStyle: {
+            borderRadius: 4,
+            borderColor: 'rgba(3,14,31,0.5)',
+            borderWidth: 2
+          },
+          label: {
+            show: true,
+            formatter: '{c}台',
+            fontSize: 20,
+            color: '#a8cce8'
+          },
+          labelLine: {
+            length: 10,
+            length2: 10,
+            lineStyle: { color: 'rgba(30,144,255,0.4)', width: 2 }
+          },
+          data: ringData,
+          emphasis: {
+            scale: true,
+            scaleSize: 5,
+            itemStyle: {
+              shadowBlur: 20,
+              shadowColor: 'rgba(30,144,255,0.6)'
+            }
+          }
+        }]
+      }
+      this.ringChart.setOption(option)
+    },
+    /** 初始化饼图 - 设备使用状态 */
+    initPieChart() {
+      this.pieChart = echarts.init(this.$refs.pieChart, null, { renderer: 'canvas', devicePixelRatio: 2 })
+
+      // Build count map for legend formatter
+      const countMap = {}
+      this.usageStatus.forEach(s => { countMap[s.name] = s.value })
+
+      const pieData = this.usageStatus.map(s => ({
+        value: s.value,
+        name: s.name,
+        itemStyle: {
+          color: s.color,
+          shadowBlur: 10,
+          shadowColor: s.color.replace(')', ',0.5)').replace('rgb', 'rgba')
+        }
+      }))
+
+      const option = {
+        backgroundColor: 'transparent',
+        tooltip: {
+          trigger: 'item',
+          formatter: '{b}: {c}台 ({d}%)',
+          ...TOOLTIP_CFG
+        },
+        legend: {
+          show: true,
+          orient: 'vertical',
+          right: '2%',
+          top: 'middle',
+          icon: 'circle',
+          itemWidth: 36,
+          itemHeight: 36,
+          itemGap: 40,
+          textStyle: { color: '#a8cce8', fontSize: 28 },
+          formatter: function(name) {
+            return '{nm|' + name + '}  {vl|' + countMap[name] + '台}'
+          },
+          rich: {
+            nm: { fontSize: 28, color: '#a8cce8', width: 90 },
+            vl: { fontSize: 34, fontWeight: 700, color: '#fff' }
+          }
+        },
+        series: [{
+          type: 'pie',
+          radius: ['36%', '62%'],
+          center: ['38%', '52%'],
+          itemStyle: {
+            borderRadius: 5,
+            borderColor: 'rgba(3,14,31,0.5)',
+            borderWidth: 2
+          },
+          label: {
+            show: true,
+            formatter: '{b}\n{c}台',
+            fontSize: 24,
+            color: '#a8cce8',
+            lineHeight: 36,
+            distanceToLabelLine: 6
+          },
+          labelLine: {
+            length: 18,
+            length2: 14,
+            lineStyle: { color: 'rgba(30,144,255,0.4)', width: 2 }
+          },
+          data: pieData,
+          emphasis: {
+            scale: true,
+            scaleSize: 6,
+            itemStyle: {
+              shadowBlur: 25,
+              shadowColor: 'rgba(30,144,255,0.6)'
+            }
+          }
+        }]
+      }
+      this.pieChart.setOption(option)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.equipment-stats {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+/* ---- Border beam ---- */
+.border-beam {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  border-radius: inherit;
+  overflow: hidden;
+  z-index: 2;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(30, 144, 255, 0.9), rgba(0, 216, 255, 0.7), transparent);
+    animation: beamTop 5s linear infinite;
+  }
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    right: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(0, 216, 255, 0.7), rgba(30, 144, 255, 0.9), transparent);
+    animation: beamBottom 5s linear infinite 2.5s;
+  }
+}
+
+@keyframes beamTop    { from { left: -40%; } to { left: 100%; } }
+@keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
+
+/* ---- Panel header ---- */
+.panel-header {
+  display: flex;
+  align-items: center;
+  gap: 25px;
+  padding: 20px 30px 18px;
+  border-bottom: 1px solid $border;
+  background: linear-gradient(90deg, rgba(0, 60, 160, 0.18), transparent);
+  flex-shrink: 0;
+}
+
+.panel-header-icon {
+  width: 65px;
+  height: 65px;
+  border-radius: 12px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32px;
+  background: linear-gradient(135deg, rgba(30, 144, 255, 0.25), rgba(0, 216, 255, 0.15));
+  border: 1px solid rgba(30, 144, 255, 0.35);
+  animation: iconGlow 3s ease-in-out infinite;
+}
+
+@keyframes iconGlow {
+  0%, 100% { box-shadow: 0 0 15px rgba(30, 144, 255, 0.3); }
+  50% { box-shadow: 0 0 35px rgba(30, 144, 255, 0.7), 0 0 12px rgba(0, 216, 255, 0.3); }
+}
+
+.panel-title {
+  font-size: 30px;
+  font-weight: 600;
+  letter-spacing: 2px;
+  color: $cyan;
+}
+
+/* ---- 4:2:4 Layout ---- */
+.equip-layout {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  padding: 15px 25px;
+  gap: 12px;
+  min-height: 0;
+}
+
+.equip-section {
+  min-height: 0;
+}
+
+.equip-top {
+  flex: 4;
+}
+
+.equip-mid {
+  flex: 2;
+  display: flex;
+  align-items: center;
+}
+
+.equip-bottom {
+  flex: 4;
+}
+
+.ring-chart,
+.pie-chart {
+  width: 100%;
+  height: 100%;
+}
+
+/* ---- Middle stat row ---- */
+.equip-mid-row {
+  display: flex;
+  gap: 15px;
+  width: 100%;
+}
+
+.equip-stat-item {
+  flex: 1;
+  text-align: center;
+  padding: 15px 10px;
+  border-radius: 10px;
+  background: $bg-card;
+  border: 1px solid $border;
+
+  .ev {
+    font-size: 38px;
+    font-weight: 700;
+    color: $gold;
+  }
+
+  .el {
+    font-size: 22px;
+    color: $text-dim;
+    margin-top: 5px;
+  }
+}
+</style>

+ 570 - 0
src/components/EventStats.vue

@@ -0,0 +1,570 @@
+<template>
+  <div class="panel lab-stats">
+    <!-- Border beam animation -->
+    <div class="border-beam"></div>
+    <!-- Corner ornaments -->
+    <svg class="pc tl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc tr" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc bl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc br" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <!-- Panel header -->
+    <div class="panel-header">
+      <div class="panel-header-icon">🏛️</div>
+      <span class="panel-title">实验室基本情况统计</span>
+      <div class="status-dot green"></div>
+    </div>
+
+    <!-- Upper section: left gauge + right donut -->
+    <div class="upper-section">
+      <!-- Left: SVG gauge showing total -->
+      <div class="gauge-side">
+        <svg width="320" height="320" viewBox="0 0 320 320">
+          <defs>
+            <linearGradient id="arcG1" x1="0%" y1="0%" x2="100%" y2="100%">
+              <stop offset="0%" stop-color="#1e90ff"/>
+              <stop offset="100%" stop-color="#00d8ff"/>
+            </linearGradient>
+            <filter id="glow1">
+              <feGaussianBlur stdDeviation="4" result="blur"/>
+              <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
+            </filter>
+          </defs>
+          <!-- Outermost decorative ring -->
+          <circle cx="160" cy="160" r="148" fill="none" stroke="rgba(30,144,255,0.08)" stroke-width="1" stroke-dasharray="6 6"/>
+          <!-- Outer track -->
+          <circle cx="160" cy="160" r="136" fill="none" stroke="rgba(30,144,255,0.14)" stroke-width="3"/>
+          <!-- Main arc 300deg -->
+          <circle cx="160" cy="160" r="136" fill="none" stroke="url(#arcG1)" stroke-width="8"
+            stroke-dasharray="711 143" stroke-linecap="round" transform="rotate(-240 160 160)" filter="url(#glow1)"/>
+          <!-- Inner track -->
+          <circle cx="160" cy="160" r="110" fill="rgba(3,14,31,0.7)" stroke="rgba(0,216,255,0.15)" stroke-width="2"/>
+          <!-- Four-level color arcs -->
+          <circle cx="160" cy="160" r="88" fill="none" stroke="#cc0000" stroke-width="12"
+            :stroke-dasharray="levelArcs[0].dash" stroke-linecap="round" transform="rotate(-90 160 160)" opacity="0.9"/>
+          <circle cx="160" cy="160" r="88" fill="none" stroke="#ff8000" stroke-width="12"
+            :stroke-dasharray="levelArcs[1].dash" stroke-linecap="round" :transform="'rotate(' + levelArcs[1].rotate + ' 160 160)'" opacity="0.9"/>
+          <circle cx="160" cy="160" r="88" fill="none" stroke="#ffcc00" stroke-width="12"
+            :stroke-dasharray="levelArcs[2].dash" stroke-linecap="round" :transform="'rotate(' + levelArcs[2].rotate + ' 160 160)'" opacity="0.9"/>
+          <circle cx="160" cy="160" r="88" fill="none" stroke="#0066cc" stroke-width="12"
+            :stroke-dasharray="levelArcs[3].dash" stroke-linecap="round" :transform="'rotate(' + levelArcs[3].rotate + ' 160 160)'" opacity="0.9"/>
+          <!-- Endpoint glow -->
+          <circle cx="160" cy="24" r="6" fill="#1e90ff" opacity="0.9"/>
+          <!-- Center number -->
+          <text x="160" y="145" text-anchor="middle" fill="#ffd740" font-size="72" font-weight="900" font-family="Arial,sans-serif" letter-spacing="-2">{{ total }}</text>
+          <text x="160" y="190" text-anchor="middle" fill="rgba(168,204,232,0.75)" font-size="26" font-family="Arial,sans-serif" letter-spacing="4">间</text>
+        </svg>
+        <!-- Labels -->
+        <div class="gauge-label">
+          <div class="gauge-label-main">实验室总数</div>
+          <div class="gauge-label-sub">TOTAL LABORATORIES</div>
+        </div>
+        <!-- Color bar showing proportions -->
+        <div class="color-bar-wrap">
+          <div class="color-bar">
+            <div v-for="level in levels" :key="level.name" :style="{ flex: level.value, background: level.color, boxShadow: '0 0 10px ' + level.color + '80' }"></div>
+          </div>
+          <div class="color-bar-labels">
+            <span v-for="level in levels" :key="'lbl-' + level.name" :style="{ flex: level.value }">{{ level.name.replace('级', '') }}</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- Right: ECharts donut + level detail list -->
+      <div class="donut-side">
+        <div ref="donutChart" class="donut-chart"></div>
+        <div class="level-list">
+          <div
+            v-for="level in levels"
+            :key="'detail-' + level.name"
+            class="level-item"
+            :style="{ background: level.color + '12', borderLeftColor: level.color }"
+          >
+            <span class="level-item-left">
+              <span class="level-dot" :style="{ background: level.color, boxShadow: '0 0 8px ' + level.color + 'cc' }"></span>
+              {{ level.label }}
+            </span>
+            <span class="level-item-val" :style="{ color: levelValColor(level.color) }">{{ level.value }}间</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- Lower section: 3 status badges -->
+    <div class="lower-section">
+      <div class="status-row">
+        <div class="status-badge active">
+          <div class="val">{{ status.active }}</div>
+          <div class="lbl">使用(间)</div>
+        </div>
+        <div class="status-badge warning">
+          <div class="val">{{ status.warning }}</div>
+          <div class="lbl">异常(间)</div>
+        </div>
+        <div class="status-badge idle">
+          <div class="val">{{ status.idle }}</div>
+          <div class="lbl">空闲(间)</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getLabStats } from '@/api/screen'
+
+export default {
+  name: 'EventStats',
+  data() {
+    return {
+      total: 0,
+      levels: [],
+      status: { active: 0, warning: 0, idle: 0 },
+      chart: null
+    }
+  },
+  computed: {
+    /** 计算SVG仪表盘中四个分级色弧的dasharray和旋转角度 */
+    levelArcs() {
+      if (!this.levels.length) {
+        return [
+          { dash: '0 553', rotate: -90 },
+          { dash: '0 553', rotate: -90 },
+          { dash: '0 553', rotate: -90 },
+          { dash: '0 553', rotate: -90 }
+        ]
+      }
+      const circumference = 2 * Math.PI * 88 // ~553
+      const totalVal = this.levels.reduce((s, l) => s + l.value, 0)
+      const arcs = []
+      let accAngle = -90 // start at top
+      for (let i = 0; i < this.levels.length; i++) {
+        const ratio = this.levels[i].value / totalVal
+        const arcLen = Math.round(ratio * circumference)
+        const gapLen = Math.round(circumference - arcLen)
+        arcs.push({
+          dash: arcLen + ' ' + gapLen,
+          rotate: Math.round(accAngle)
+        })
+        accAngle += ratio * 360
+      }
+      return arcs
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  beforeDestroy() {
+    if (this.chart) {
+      this.chart.dispose()
+      this.chart = null
+    }
+  },
+  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())
+      }
+    },
+    /** 根据分级主色计算稍亮的数值展示色 */
+    levelValColor(color) {
+      const map = {
+        '#cc0000': '#ff6666',
+        '#ff8000': '#ffaa44',
+        '#ffcc00': '#ffe066',
+        '#0066cc': '#4499ff'
+      }
+      return map[color] || color
+    },
+    /** 初始化ECharts环形图 - 展示四级分类占比,中心显示总数 */
+    initDonutChart() {
+      this.chart = echarts.init(this.$refs.donutChart, null, {
+        renderer: 'canvas',
+        devicePixelRatio: 2
+      })
+      const option = {
+        backgroundColor: 'transparent',
+        tooltip: {
+          trigger: 'item',
+          formatter: '{b}: {c}间 ({d}%)',
+          backgroundColor: 'rgba(3,14,42,0.92)',
+          borderColor: 'rgba(30,144,255,0.3)',
+          textStyle: { color: '#a8cce8', fontSize: 28 }
+        },
+        graphic: [
+          {
+            type: 'text',
+            left: 'center',
+            top: '40%',
+            style: {
+              text: String(this.total),
+              fill: '#ffd740',
+              font: 'bold 80px Arial',
+              textAlign: 'center'
+            }
+          },
+          {
+            type: 'text',
+            left: 'center',
+            top: '58%',
+            style: {
+              text: '实验室总数(间)',
+              fill: 'rgba(168,204,232,0.65)',
+              font: '22px Arial',
+              textAlign: 'center'
+            }
+          }
+        ],
+        series: [{
+          type: 'pie',
+          radius: ['42%', '68%'],
+          center: ['50%', '50%'],
+          startAngle: 100,
+          itemStyle: {
+            borderRadius: 6,
+            borderColor: 'rgba(3,14,31,0.6)',
+            borderWidth: 3
+          },
+          label: {
+            show: true,
+            formatter: function (params) {
+              return '{lvl|' + params.name + '}\n{cnt|' + params.value + '间}\n{pct|' + params.percent + '%}'
+            },
+            rich: {
+              lvl: { fontSize: 24, fontWeight: 700, color: '#ddf0ff', lineHeight: 36 },
+              cnt: { fontSize: 30, fontWeight: 900, color: '#ffd740', lineHeight: 42 },
+              pct: { fontSize: 22, color: 'rgba(168,204,232,0.6)', lineHeight: 32 }
+            },
+            distanceToLabelLine: 8
+          },
+          labelLine: {
+            show: true,
+            length: 20,
+            length2: 25,
+            lineStyle: { color: 'rgba(30,144,255,0.45)', width: 2 }
+          },
+          data: this.levels.map(function (l) {
+            return {
+              value: l.value,
+              name: l.name,
+              itemStyle: {
+                color: l.color,
+                shadowBlur: 12,
+                shadowColor: l.color + '80'
+              }
+            }
+          }),
+          emphasis: {
+            scale: true,
+            scaleSize: 8,
+            itemStyle: { shadowBlur: 30, shadowColor: 'rgba(30,144,255,0.6)' },
+            label: { fontSize: 28 }
+          }
+        }]
+      }
+      this.chart.setOption(option)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.lab-stats {
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  border-radius: 15px;
+  overflow: hidden;
+  background: $bg-panel;
+  border: 1px solid $border;
+}
+
+.lab-stats::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  border-radius: inherit;
+  background: linear-gradient(135deg, rgba(30, 144, 255, 0.05) 0%, transparent 50%, rgba(0, 216, 255, 0.03) 100%);
+}
+
+/* ---- Border beam ---- */
+.border-beam {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  border-radius: inherit;
+  overflow: hidden;
+  z-index: 2;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(30, 144, 255, 0.9), rgba(0, 216, 255, 0.7), transparent);
+    animation: beamTop 5s linear infinite;
+  }
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    right: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(0, 216, 255, 0.7), rgba(30, 144, 255, 0.9), transparent);
+    animation: beamBottom 5s linear infinite 2.5s;
+  }
+}
+
+@keyframes beamTop    { from { left: -40%; }  to { left: 100%; } }
+@keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
+
+/* ---- Corner ornaments ---- */
+.pc {
+  position: absolute;
+  width: 35px;
+  height: 35px;
+  z-index: 3;
+  pointer-events: none;
+
+  &.tl { top: 0; left: 0; }
+  &.tr { top: 0; right: 0; transform: scaleX(-1); }
+  &.bl { bottom: 0; left: 0; transform: scaleY(-1); }
+  &.br { bottom: 0; right: 0; transform: scale(-1); }
+}
+
+/* ---- Panel header ---- */
+.panel-header {
+  display: flex;
+  align-items: center;
+  gap: 25px;
+  padding: 20px 30px 18px;
+  border-bottom: 1px solid $border;
+  background: linear-gradient(90deg, rgba(0, 60, 160, 0.18), transparent);
+  flex-shrink: 0;
+}
+
+.panel-header-icon {
+  width: 65px;
+  height: 65px;
+  border-radius: 12px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32px;
+  background: linear-gradient(135deg, rgba(30, 144, 255, 0.25), rgba(0, 216, 255, 0.15));
+  border: 1px solid rgba(30, 144, 255, 0.35);
+  animation: iconGlow 3s ease-in-out infinite;
+}
+
+@keyframes iconGlow {
+  0%, 100% { box-shadow: 0 0 15px rgba(30, 144, 255, 0.3); }
+  50%      { box-shadow: 0 0 35px rgba(30, 144, 255, 0.7), 0 0 12px rgba(0, 216, 255, 0.3); }
+}
+
+.panel-title {
+  font-size: 30px;
+  font-weight: 600;
+  letter-spacing: 2px;
+  color: $cyan;
+}
+
+/* ---- Status dot ---- */
+.status-dot {
+  width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  display: inline-block;
+  margin-left: auto;
+  animation: dotPulse 2s ease-in-out infinite;
+
+  &.green {
+    background: $green;
+    box-shadow: 0 0 15px $green;
+  }
+}
+
+@keyframes dotPulse {
+  0%, 100% { transform: scale(1); opacity: 1; }
+  50%      { transform: scale(1.3); opacity: 0.7; }
+}
+
+/* ---- Upper section ---- */
+.upper-section {
+  flex: 4;
+  display: flex;
+  min-height: 0;
+  overflow: hidden;
+}
+
+/* Left gauge side */
+.gauge-side {
+  flex: 4;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 18px;
+  padding: 25px 20px;
+  border-right: 1px solid $border;
+  background: linear-gradient(180deg, rgba(30, 144, 255, 0.04), transparent);
+}
+
+.gauge-label {
+  text-align: center;
+
+  &-main {
+    font-size: 30px;
+    font-weight: 700;
+    color: $cyan;
+    letter-spacing: 4px;
+  }
+
+  &-sub {
+    font-size: 22px;
+    color: $text-dim;
+    margin-top: 6px;
+    letter-spacing: 2px;
+  }
+}
+
+.color-bar-wrap {
+  width: 100%;
+  padding: 0 10px;
+}
+
+.color-bar {
+  display: flex;
+  gap: 5px;
+  height: 12px;
+  border-radius: 6px;
+  overflow: hidden;
+}
+
+.color-bar-labels {
+  display: flex;
+  gap: 5px;
+  margin-top: 8px;
+  font-size: 22px;
+  color: $text-dim;
+  text-align: center;
+}
+
+/* Right donut side */
+.donut-side {
+  flex: 6;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: 25px 25px 20px;
+  gap: 0;
+}
+
+.donut-chart {
+  flex: 0 0 340px;
+  width: 100%;
+}
+
+/* ---- Level detail list ---- */
+.level-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-top: 10px;
+}
+
+.level-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 26px;
+  padding: 10px 16px;
+  border-radius: 8px;
+  border-left: 4px solid transparent;
+
+  &-left {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+  }
+
+  &-val {
+    font-weight: 700;
+    font-size: 28px;
+  }
+}
+
+.level-dot {
+  width: 16px;
+  height: 16px;
+  border-radius: 3px;
+  display: inline-block;
+}
+
+/* ---- Lower section ---- */
+.lower-section {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  padding: 0 25px 20px;
+  border-top: 1px solid $border;
+  gap: 15px;
+  min-height: 0;
+}
+
+.status-row {
+  display: flex;
+  gap: 15px;
+  width: 100%;
+}
+
+.status-badge {
+  flex: 1;
+  padding: 15px 10px;
+  border-radius: 10px;
+  text-align: center;
+  background: $bg-card;
+  border: 1px solid $border;
+
+  &.active {
+    border-color: $green;
+    background: rgba(0, 230, 118, 0.07);
+
+    .val { color: $green; }
+  }
+
+  &.warning {
+    border-color: #f59e0b;
+    background: rgba(245, 158, 11, 0.07);
+
+    .val { color: #f59e0b; }
+  }
+
+  &.idle {
+    border-color: $indigo;
+    background: rgba(67, 97, 238, 0.07);
+
+    .val { color: $indigo; }
+  }
+
+  .val {
+    font-size: 48px;
+    font-weight: 700;
+  }
+
+  .lbl {
+    font-size: 25px;
+    color: $text-dim;
+    margin-top: 5px;
+  }
+}
+</style>

+ 310 - 0
src/components/LabEnvironment.vue

@@ -0,0 +1,310 @@
+<template>
+  <div class="panel lab-environment">
+    <!-- Animated border beam -->
+    <div class="border-beam"></div>
+    <!-- Corner ornaments -->
+    <svg class="pc tl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc tr" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc bl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <svg class="pc br" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
+    <!-- Panel header -->
+    <div class="panel-header">
+      <div class="panel-header-icon">&#x1F321;&#xFE0F;</div>
+      <span class="panel-title">实验环境安全智能感知</span>
+      <div class="status-dot red" style="margin-left:auto"></div>
+    </div>
+    <!-- Scrolling sensor list -->
+    <div class="sensor-scroll-wrap">
+      <div class="sensor-scroll-inner">
+        <div
+          v-for="(item, idx) in scrollList"
+          :key="idx"
+          class="sensor-item"
+          :class="{ alert: item.alert }"
+        >
+          <div class="sensor-item-head">
+            <span class="sensor-name">{{ item.name }}({{ item.room }})</span>
+            <span class="sensor-unit">{{ item.unit }}</span>
+            <span v-if="item.alert" class="sensor-status alarm-status">&#x1F6A8; 告警</span>
+            <span v-else class="sensor-status normal-status">&#x25CF; 正常</span>
+          </div>
+          <div class="sensor-metrics">
+            <div class="sensor-metric">&#x1F321;&#xFE0F; {{ item.t }}&deg;C</div>
+            <div class="sensor-metric">&#x1F4A7; {{ item.h }}%</div>
+            <div class="sensor-metric" :class="{ alarm: item.tvoc > 0.6 }">&#x1F9EA; TVOC {{ item.tvoc }}</div>
+            <div class="sensor-metric" :class="{ alarm: item.co2 > 700 }">&#x1F4A8; CO&#x2082; {{ item.co2 }}</div>
+            <div class="sensor-metric">&#x1FAE7; O&#x2082; {{ item.o2 }}%</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getSensorList } from '@/api/screen'
+
+export default {
+  name: 'LabEnvironment',
+  data() {
+    return {
+      sensorList: []
+    }
+  },
+  computed: {
+    /** Duplicate data for seamless CSS scroll loop */
+    scrollList() {
+      return [...this.sensorList, ...this.sensorList]
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  methods: {
+    async fetchData() {
+      const res = await getSensorList()
+      if (res.code === 200) {
+        this.sensorList = res.data
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.lab-environment {
+  position: relative;
+  border-radius: 15px;
+  overflow: hidden;
+  background: $bg-panel;
+  border: 1px solid $border;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  &::before {
+    content: '';
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+    border-radius: inherit;
+    background: linear-gradient(135deg, rgba(30,144,255,0.05) 0%, transparent 50%, rgba(0,216,255,0.03) 100%);
+  }
+}
+
+/* ===== Corner ornaments ===== */
+.pc {
+  position: absolute;
+  width: 35px;
+  height: 35px;
+  z-index: 3;
+  pointer-events: none;
+
+  &.tl { top: 0; left: 0; }
+  &.tr { top: 0; right: 0; transform: scaleX(-1); }
+  &.bl { bottom: 0; left: 0; transform: scaleY(-1); }
+  &.br { bottom: 0; right: 0; transform: scale(-1); }
+
+  svg { width: 100%; height: 100%; }
+}
+
+/* ===== Animated border beam ===== */
+.border-beam {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  border-radius: inherit;
+  overflow: hidden;
+  z-index: 2;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(30,144,255,0.9), rgba(0,216,255,0.7), transparent);
+    animation: beamTop 5s linear infinite;
+  }
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    right: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(0,216,255,0.7), rgba(30,144,255,0.9), transparent);
+    animation: beamBottom 5s linear infinite 2.5s;
+  }
+}
+
+@keyframes beamTop    { from { left: -40%; }  to { left: 100%; } }
+@keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
+
+/* ===== Panel header ===== */
+.panel-header {
+  display: flex;
+  align-items: center;
+  gap: 25px;
+  padding: 20px 30px 18px;
+  border-bottom: 1px solid $border;
+  background: linear-gradient(90deg, rgba(0,60,160,0.18), transparent);
+  flex-shrink: 0;
+}
+
+.panel-header-icon {
+  width: 65px;
+  height: 65px;
+  border-radius: 12px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32px;
+  background: linear-gradient(135deg, rgba(30,144,255,0.25), rgba(0,216,255,0.15));
+  border: 1px solid rgba(30,144,255,0.35);
+  animation: iconGlow 3s ease-in-out infinite;
+}
+
+@keyframes iconGlow {
+  0%, 100% { box-shadow: 0 0 15px rgba(30,144,255,0.3); }
+  50%      { box-shadow: 0 0 35px rgba(30,144,255,0.7), 0 0 12px rgba(0,216,255,0.3); }
+}
+
+.panel-title {
+  font-size: 30px;
+  font-weight: 600;
+  letter-spacing: 2px;
+  color: $cyan;
+}
+
+/* ===== Status dot ===== */
+.status-dot {
+  width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  display: inline-block;
+
+  &.red {
+    background: $red;
+    box-shadow: 0 0 15px $red;
+    animation: blinkRed 0.8s ease-in-out infinite;
+  }
+
+  &.green {
+    background: $green;
+    box-shadow: 0 0 15px $green;
+    animation: dotPulse 2s ease-in-out infinite;
+  }
+}
+
+@keyframes dotPulse {
+  0%, 100% { transform: scale(1); opacity: 1; }
+  50%      { transform: scale(1.3); opacity: 0.7; }
+}
+
+@keyframes blinkRed {
+  0%, 100% { opacity: 1; }
+  50%      { opacity: 0.35; }
+}
+
+/* ===== Sensor scroll ===== */
+.sensor-scroll-wrap {
+  overflow: hidden;
+  flex: 1;
+  min-height: 0;
+  padding: 15px 20px;
+}
+
+.sensor-scroll-inner {
+  animation: scrollUp 30s linear infinite;
+
+  &:hover {
+    animation-play-state: paused;
+  }
+}
+
+@keyframes scrollUp {
+  0%   { transform: translateY(0); }
+  100% { transform: translateY(-50%); }
+}
+
+/* ===== Sensor item ===== */
+.sensor-item {
+  padding: 18px 20px;
+  border-radius: 10px;
+  margin-bottom: 12px;
+  background: $bg-card;
+  border: 1px solid $border;
+
+  &.alert {
+    border-color: rgba(255,59,59,0.6);
+    background: rgba(255,30,30,0.07);
+    animation: alertGlow 1.5s ease-in-out infinite;
+  }
+}
+
+@keyframes alertGlow {
+  0%, 100% { box-shadow: 0 0 12px rgba(255,59,59,0.25); }
+  50%      { box-shadow: 0 0 40px rgba(255,59,59,0.6); }
+}
+
+.sensor-item-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+}
+
+.sensor-name {
+  font-size: 28px;
+  font-weight: 600;
+  color: $white;
+}
+
+.sensor-unit {
+  font-size: 25px;
+  color: $text-dim;
+}
+
+.sensor-status {
+  font-size: 25px;
+}
+
+.alarm-status {
+  color: $red;
+  animation: blinkRed 0.8s ease-in-out infinite;
+}
+
+.normal-status {
+  color: $green;
+}
+
+/* ===== Sensor metrics ===== */
+.sensor-metrics {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.sensor-metric {
+  padding: 5px 15px;
+  border-radius: 5px;
+  font-size: 25px;
+  background: rgba(4,16,45,0.7);
+  border: 1px solid rgba(30,144,255,0.2);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: $text;
+
+  &.alarm {
+    background: rgba(255,0,30,0.12);
+    border-color: $red;
+    color: $red;
+    animation: blinkRed 0.8s ease-in-out infinite;
+  }
+}
+</style>

+ 285 - 0
src/components/PersonnelTrend.vue

@@ -0,0 +1,285 @@
+<template>
+  <div class="panel personnel-trend">
+    <div class="border-beam"></div>
+    <div class="panel-header">
+      <div class="panel-header-icon">👥</div>
+      <span class="panel-title">实验室进入人数统计及走势</span>
+    </div>
+    <div class="panel-body">
+      <!-- Flip counter digits -->
+      <div class="flip-counters">
+        <div class="flip-counter">
+          <div class="fc-label">今日进入总人数</div>
+          <div class="fc-digits">
+            <div
+              v-for="(digit, idx) in totalDigits"
+              :key="'total-' + idx"
+              class="flip-digit"
+            >{{ digit }}</div>
+          </div>
+        </div>
+        <div class="flip-counter">
+          <div class="fc-label">当前在场实验人数</div>
+          <div class="fc-digits">
+            <div
+              v-for="(digit, idx) in presentDigits"
+              :key="'present-' + idx"
+              class="flip-digit"
+            >{{ digit }}</div>
+          </div>
+        </div>
+      </div>
+      <!-- Line chart -->
+      <div class="chart-wrap">
+        <div ref="chart" class="chart"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getPersonnelStats } from '@/api/screen'
+
+export default {
+  name: 'PersonnelTrend',
+  data() {
+    return {
+      totalEntry: 0,
+      currentPresent: 0,
+      displayTotal: 0,
+      displayPresent: 0,
+      chart: null,
+      animTimerTotal: null,
+      animTimerPresent: null
+    }
+  },
+  computed: {
+    totalDigits() {
+      return String(this.displayTotal).padStart(4, '0').split('')
+    },
+    presentDigits() {
+      return String(this.displayPresent).padStart(4, '0').split('')
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  beforeDestroy() {
+    if (this.animTimerTotal) cancelAnimationFrame(this.animTimerTotal)
+    if (this.animTimerPresent) cancelAnimationFrame(this.animTimerPresent)
+    if (this.chart) {
+      this.chart.dispose()
+      this.chart = null
+    }
+  },
+  methods: {
+    async fetchData() {
+      const res = await getPersonnelStats()
+      if (res.code === 200) {
+        this.totalEntry = res.data.totalEntry
+        this.currentPresent = res.data.currentPresent
+
+        // 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)
+        })
+      }
+    },
+
+    /** Animate count from `from` to `to` over `duration` ms */
+    animateCount(type, from, to, duration) {
+      const startTime = Date.now()
+      const tick = () => {
+        const elapsed = Date.now() - startTime
+        const progress = Math.min(elapsed / duration, 1)
+        const current = Math.round(from + (to - from) * progress)
+        if (type === 'total') {
+          this.displayTotal = current
+        } else {
+          this.displayPresent = current
+        }
+        if (progress < 1) {
+          if (type === 'total') {
+            this.animTimerTotal = requestAnimationFrame(tick)
+          } else {
+            this.animTimerPresent = requestAnimationFrame(tick)
+          }
+        }
+      }
+      tick()
+    },
+
+    /** 初始化面积折线图 - 展示24小时进入/在场人数走势 */
+    initChart(trend) {
+      this.chart = echarts.init(this.$refs.chart, null, {
+        renderer: 'canvas',
+        devicePixelRatio: 2
+      })
+
+      const TOOLTIP_CFG = {
+        backgroundColor: 'rgba(3,14,42,0.92)',
+        borderColor: 'rgba(30,144,255,0.3)',
+        textStyle: { color: '#a8cce8', fontSize: 28 }
+      }
+
+      const option = {
+        backgroundColor: 'transparent',
+        tooltip: {
+          trigger: 'axis',
+          ...TOOLTIP_CFG
+        },
+        legend: {
+          data: ['进入人数', '在场人数'],
+          top: 0,
+          right: 0,
+          textStyle: { color: '#a8cce8', fontSize: 25 },
+          icon: 'circle',
+          itemWidth: 20,
+          itemHeight: 20
+        },
+        grid: {
+          left: 28,
+          right: 8,
+          top: 45,
+          bottom: 50,
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: trend.times,
+          axisLabel: { color: '#5890b8', fontSize: 22 },
+          axisLine: { lineStyle: { color: 'rgba(30,144,255,0.2)' } },
+          axisTick: { show: false }
+        },
+        yAxis: {
+          type: 'value',
+          axisLabel: { color: '#5890b8', fontSize: 22 },
+          axisLine: { show: false },
+          splitLine: { lineStyle: { color: 'rgba(30,144,255,0.1)' } }
+        },
+        series: [
+          {
+            name: '进入人数',
+            type: 'line',
+            data: trend.entry,
+            smooth: true,
+            symbol: 'circle',
+            symbolSize: 12,
+            lineStyle: { color: '#1e90ff', width: 5 },
+            itemStyle: { color: '#1e90ff' },
+            areaStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: 'rgba(30,144,255,0.32)' },
+                { offset: 1, color: 'rgba(30,144,255,0.02)' }
+              ])
+            }
+          },
+          {
+            name: '在场人数',
+            type: 'line',
+            data: trend.present,
+            smooth: true,
+            symbol: 'circle',
+            symbolSize: 12,
+            lineStyle: { color: '#ffd740', width: 5 },
+            itemStyle: { color: '#ffd740' },
+            areaStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: 'rgba(255,215,64,0.22)' },
+                { offset: 1, color: 'rgba(255,215,64,0.01)' }
+              ])
+            }
+          }
+        ]
+      }
+
+      this.chart.setOption(option)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.personnel-trend {
+  display: flex;
+  flex-direction: column;
+}
+
+.panel-body {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  padding: 20px 25px;
+}
+
+// Flip counters section
+.flip-counters {
+  display: flex;
+  gap: 25px;
+}
+
+.flip-counter {
+  flex: 1;
+  text-align: center;
+  padding: 18px 15px;
+  border-radius: 10px;
+  background: $bg-card;
+  border: 1px solid $border;
+
+  .fc-label {
+    font-size: 25px;
+    color: $text-dim;
+    margin-bottom: 12px;
+    letter-spacing: 2px;
+  }
+
+  .fc-digits {
+    display: flex;
+    gap: 8px;
+    justify-content: center;
+  }
+}
+
+.flip-digit {
+  width: 50px;
+  height: 75px;
+  border-radius: 8px;
+  background: linear-gradient(180deg, rgba(4, 18, 55, 0.9), rgba(2, 10, 32, 0.95));
+  border: 1px solid rgba(30, 144, 255, 0.3);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 45px;
+  font-weight: 700;
+  color: $gold;
+  font-variant-numeric: tabular-nums;
+  position: relative;
+  overflow: hidden;
+
+  &::after {
+    content: '';
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 50%;
+    height: 2px;
+    background: rgba(0, 0, 0, 0.4);
+  }
+}
+
+// Chart area
+.chart-wrap {
+  flex: 1;
+  min-height: 0;
+  margin-top: 12px;
+
+  .chart {
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 165 - 0
src/components/SafetyCompliance.vue

@@ -0,0 +1,165 @@
+<template>
+  <div class="panel safety-compliance">
+    <div class="border-beam"></div>
+    <div class="panel-header">
+      <div class="panel-header-icon">📊</div>
+      <span class="panel-title">实验室安全分级统计</span>
+    </div>
+    <div class="chart-wrap">
+      <div ref="chart" class="chart"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getSafetyLevelStats } from '@/api/screen'
+
+export default {
+  name: 'SafetyCompliance',
+  data() {
+    return {
+      chart: null,
+      scrollTimer: null,
+      scrollIndex: 0
+    }
+  },
+  mounted() {
+    this.fetchData()
+  },
+  beforeDestroy() {
+    if (this.scrollTimer) {
+      clearInterval(this.scrollTimer)
+      this.scrollTimer = null
+    }
+    if (this.chart) {
+      this.chart.dispose()
+      this.chart = null
+    }
+  },
+  methods: {
+    async fetchData() {
+      const res = await getSafetyLevelStats()
+      if (res.code === 200) {
+        this.$nextTick(() => this.initChart(res.data))
+      }
+    },
+    /** 初始化堆叠柱状图 - 展示各单位实验室安全分级统计 */
+    initChart(data) {
+      this.chart = echarts.init(this.$refs.chart, null, {
+        renderer: 'canvas',
+        devicePixelRatio: 2
+      })
+
+      const { levels, colors, units, totals, series: seriesData } = data
+
+      // X-axis labels: "unitName\n(total)"
+      const xLabels = units.map((u, i) => `${u}\n(${totals[i]})`)
+
+      const TOOLTIP_CFG = {
+        backgroundColor: 'rgba(3,14,42,0.92)',
+        borderColor: 'rgba(30,144,255,0.3)',
+        textStyle: { color: '#a8cce8', fontSize: 28 }
+      }
+
+      const option = {
+        backgroundColor: 'transparent',
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' },
+          ...TOOLTIP_CFG
+        },
+        legend: {
+          data: levels,
+          top: 0,
+          right: 0,
+          textStyle: { color: '#a8cce8', fontSize: 25 },
+          icon: 'rect',
+          itemWidth: 25,
+          itemHeight: 15
+        },
+        grid: {
+          left: 30,
+          right: 8,
+          top: 55,
+          bottom: 110,
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: xLabels,
+          axisLabel: {
+            color: '#5890b8',
+            fontSize: 22,
+            interval: 0,
+            lineHeight: 35
+          },
+          axisLine: { lineStyle: { color: 'rgba(30,144,255,0.2)' } },
+          axisTick: { show: false }
+        },
+        yAxis: {
+          type: 'value',
+          name: '间',
+          nameTextStyle: { color: '#5890b8', fontSize: 25 },
+          axisLabel: { color: '#5890b8', fontSize: 25 },
+          axisLine: { show: false },
+          splitLine: { lineStyle: { color: 'rgba(30,144,255,0.1)' } }
+        },
+        dataZoom: [{
+          type: 'inside',
+          startValue: 0,
+          endValue: 5
+        }],
+        series: levels.map((name, i) => ({
+          name: name,
+          type: 'bar',
+          stack: 'total',
+          barMaxWidth: 55,
+          data: seriesData[i].map(v => ({
+            value: v,
+            itemStyle: { color: colors[i], opacity: 0.88 }
+          })),
+          emphasis: { itemStyle: { opacity: 1 } }
+        }))
+      }
+
+      this.chart.setOption(option)
+
+      // Auto-scroll dataZoom every 5 seconds
+      this.scrollIndex = 0
+      this.scrollTimer = setInterval(() => {
+        this.scrollIndex++
+        if (this.scrollIndex >= units.length - 5) {
+          this.scrollIndex = -1
+        }
+        if (this.chart) {
+          this.chart.dispatchAction({
+            type: 'dataZoom',
+            startValue: this.scrollIndex,
+            endValue: this.scrollIndex + 5
+          })
+        }
+      }, 5000)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.safety-compliance {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.chart-wrap {
+  flex: 1;
+  min-height: 0;
+  padding: 15px 20px;
+
+  .chart {
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 278 - 0
src/components/ScreenHeader.vue

@@ -0,0 +1,278 @@
+<template>
+  <div class="screen-header">
+    <!-- Left: Clock -->
+    <div class="nav-left">
+      <div class="nav-clock">
+        <div class="time">{{ currentTime }}</div>
+        <div class="date">{{ currentDate }}</div>
+      </div>
+    </div>
+
+    <!-- Center: Logo + Org + Divider + Title -->
+    <div class="nav-center">
+      <div class="nav-logo">
+        <div class="nav-logo-ring"></div>
+      </div>
+      <div class="nav-org">
+        <strong>中国安全生产科学研究院</strong>
+        <span>National Institute for Occupational Safety</span>
+      </div>
+      <div class="nav-divider"></div>
+      <div class="nav-title-wrap">
+        <div class="nav-title">实验室安全智能监测与管控中心</div>
+        <div class="nav-title-deco"></div>
+      </div>
+    </div>
+
+    <!-- Right: Weather -->
+    <div class="nav-right">
+      <div class="nav-weather">
+        <span class="icon">&#9925;</span>
+        <div>
+          <div class="weather-location">北京 &middot; 晴转多云</div>
+          <div class="weather-detail">12&deg;C / AQI 68</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'ScreenHeader',
+  data() {
+    return {
+      currentTime: '',
+      currentDate: '',
+      timer: null
+    }
+  },
+  mounted() {
+    this.updateTime()
+    this.timer = setInterval(this.updateTime, 1000)
+  },
+  beforeDestroy() {
+    if (this.timer) {
+      clearInterval(this.timer)
+      this.timer = null
+    }
+  },
+  methods: {
+    updateTime() {
+      const now = new Date()
+      const h = String(now.getHours()).padStart(2, '0')
+      const m = String(now.getMinutes()).padStart(2, '0')
+      const s = String(now.getSeconds()).padStart(2, '0')
+      this.currentTime = `${h}:${m}:${s}`
+
+      const y = now.getFullYear()
+      const mon = now.getMonth() + 1
+      const d = now.getDate()
+      const weekDays = ['日', '一', '二', '三', '四', '五', '六']
+      this.currentDate = `${y}年${mon}月${d}日 星期${weekDays[now.getDay()]}`
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import '../styles/variables.scss';
+
+/* ========== ringPulse: logo outer ring breathing animation ========== */
+@keyframes ringPulse {
+  0%, 100% {
+    transform: scale(1);
+    opacity: 0.5;
+  }
+  50% {
+    transform: scale(1.1);
+    opacity: 1;
+  }
+}
+
+/* ========== titleShine: title gradient scroll animation ========== */
+@keyframes titleShine {
+  from {
+    background-position: 0% center;
+  }
+  to {
+    background-position: 200% center;
+  }
+}
+
+/* ========== decoGlow: decorative line glow animation ========== */
+@keyframes decoGlow {
+  0%, 100% {
+    opacity: 0.5;
+  }
+  50% {
+    opacity: 1;
+    filter: blur(1px);
+  }
+}
+
+/* ========== TOP NAV ========== */
+.screen-header {
+  position: relative;
+  z-index: 10;
+  flex-shrink: 0;
+  width: 100%;
+  height: 160px;
+  display: flex;
+  align-items: stretch;
+  background: linear-gradient(180deg, rgba(4, 18, 52, 0.99) 0%, rgba(3, 12, 36, 0.97) 100%);
+  border-bottom: 1px solid rgba(30, 144, 255, 0.3);
+  box-shadow: 0 2px 30px rgba(0, 80, 220, 0.28), inset 0 1px 0 rgba(30, 144, 255, 0.15);
+  font-family: $font-main;
+}
+
+/* ========== LEFT: Clock ========== */
+.nav-left {
+  display: flex;
+  align-items: center;
+  padding: 0 50px;
+  width: 950px;
+  gap: 30px;
+}
+
+.nav-clock {
+  .time {
+    font-size: 55px;
+    font-weight: 300;
+    color: $gold;
+    font-variant-numeric: tabular-nums;
+    letter-spacing: 4px;
+    line-height: 1;
+  }
+
+  .date {
+    font-size: 30px;
+    color: $text-dim;
+    margin-top: 5px;
+  }
+}
+
+/* ========== CENTER: Logo + Org + Divider + Title ========== */
+.nav-center {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 30px;
+  position: relative;
+}
+
+.nav-logo {
+  width: 100px;
+  height: 100px;
+  border-radius: 20px;
+  flex-shrink: 0;
+  background: linear-gradient(135deg, #0d2d6b, #1565c0);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 0 40px rgba(30, 144, 255, 0.6), inset 0 0 20px rgba(255, 255, 255, 0.1);
+  position: relative;
+  overflow: visible;
+
+  &::after {
+    content: '安';
+    font-size: 45px;
+    font-weight: 900;
+    color: rgba(255, 255, 255, 0.95);
+  }
+}
+
+.nav-logo-ring {
+  position: absolute;
+  inset: -10px;
+  border-radius: 25px;
+  border: 2px solid rgba(30, 144, 255, 0.5);
+  animation: ringPulse 2s ease-in-out infinite;
+}
+
+.nav-org {
+  font-size: 30px;
+  color: $text-dim;
+  line-height: 1.4;
+  margin-right: 50px;
+
+  strong {
+    display: block;
+    font-size: 32px;
+    color: $cyan;
+  }
+}
+
+.nav-divider {
+  width: 2px;
+  height: 80px;
+  background: linear-gradient(180deg, transparent, rgba(30, 144, 255, 0.6), transparent);
+  flex-shrink: 0;
+  margin-right: 50px;
+}
+
+.nav-title-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: relative;
+}
+
+.nav-title {
+  font-size: 65px;
+  font-weight: 700;
+  letter-spacing: 10px;
+  background: linear-gradient(90deg, $blue, $cyan, $white, $cyan, $blue);
+  background-size: 200% auto;
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+  animation: titleShine 4s linear infinite;
+  filter: drop-shadow(0 0 40px rgba(30, 144, 255, 0.5));
+  white-space: nowrap;
+}
+
+.nav-title-deco {
+  position: absolute;
+  bottom: -5px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 900px;
+  height: 2px;
+  background: linear-gradient(90deg, transparent, $blue, $cyan, $blue, transparent);
+  animation: decoGlow 3s ease-in-out infinite;
+}
+
+/* ========== RIGHT: Weather ========== */
+.nav-right {
+  width: 950px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  padding: 0 50px;
+  gap: 50px;
+}
+
+.nav-weather {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  font-size: 32px;
+  color: $text-dim;
+
+  .icon {
+    font-size: 45px;
+  }
+
+  .weather-location {
+    color: $text-dim;
+  }
+
+  .weather-detail {
+    font-size: 28px;
+    color: $gold;
+    margin-top: 4px;
+  }
+}
+</style>

+ 593 - 0
src/components/SecurityMonitor.vue

@@ -0,0 +1,593 @@
+<template>
+  <div class="security-monitor panel">
+    <!-- Monitor Header -->
+    <div class="monitor-header">
+      <span class="monitor-title-cn">&#x1F4F9; &#x5B9E;&#x65F6;&#x76D1;&#x63A7;</span>
+      <span class="monitor-title-en">CCTV Live Feed</span>
+      <div class="monitor-status">
+        <div class="status-dot green"></div>
+        <span class="monitor-status-text">&#x4FE1;&#x53F7;&#x6B63;&#x5E38;</span>
+      </div>
+    </div>
+
+    <!-- Monitor Inner: 2-column grid -->
+    <div class="monitor-inner">
+      <!-- Left Column: Search + Filter + Tree -->
+      <div class="monitor-left">
+        <div class="search-box">
+          <span class="search-icon">&#x1F50D;</span>
+          <input
+            v-model="searchText"
+            type="text"
+            placeholder="&#x641C;&#x7D22;&#x697C;&#x680B; / &#x697C;&#x5C42; / &#x623F;&#x95F4;&#x2026;"
+          />
+        </div>
+
+        <select v-model="selectedUnit" class="filter-select">
+          <option value="">&#x5168;&#x90E8;&#x4E8C;&#x7EA7;&#x5355;&#x4F4D;</option>
+          <option>&#x5316;&#x5B66;&#x7814;&#x7A76;&#x6240;</option>
+          <option>&#x7269;&#x7406;&#x7814;&#x7A76;&#x6240;</option>
+          <option>&#x751F;&#x7269;&#x7814;&#x7A76;&#x6240;</option>
+          <option>&#x6750;&#x6599;&#x7814;&#x7A76;&#x6240;</option>
+          <option>&#x5DE5;&#x7A0B;&#x7814;&#x7A76;&#x6240;</option>
+        </select>
+
+        <div class="tree-wrap">
+          <tree-node
+            v-if="treeData"
+            :node="treeData"
+            :depth="0"
+            :selected-label="selectedTreeLabel"
+            @select="onTreeNodeSelect"
+          />
+        </div>
+      </div>
+
+      <!-- Right Column: Breadcrumb + Pager + Camera Grid -->
+      <div class="monitor-right">
+        <div class="camera-grid-header">
+          <div class="camera-breadcrumb">
+            <span>&#x5B89;&#x79D1;&#x9662;&#x4E3B;&#x56ED;&#x533A;</span> &#x203A;
+            <span>&#x7EFC;&#x5408;&#x5B9E;&#x9A8C;&#x697C;A</span> &#x203A;
+            <span>3&#x5C42;</span>
+          </div>
+          <div class="camera-pager">
+            <button class="pager-btn" @click="prevPage">&lsaquo;</button>
+            <span class="pager-info">{{ currentPage }} / {{ totalPages }} &#x9875;</span>
+            <button class="pager-btn" @click="nextPage">&rsaquo;</button>
+          </div>
+        </div>
+
+        <div class="camera-grid">
+          <div
+            v-for="(cam, idx) in cameras"
+            :key="'cam-' + idx"
+            class="camera-cell"
+            :class="{ 'ai-cam': idx === 0 }"
+          >
+            <!-- AI badge (first cell only) -->
+            <div v-if="idx === 0" class="camera-ai-badge">&#x1F916; AI&#x68C0;&#x6D4B;</div>
+
+            <!-- REC indicator -->
+            <div class="camera-rec">REC</div>
+
+            <!-- Video placeholder -->
+            <div class="camera-placeholder-text">CCTV</div>
+
+            <!-- AI detection box (first cell only) -->
+            <div v-if="idx === 0" class="ai-detection-box" style="left:28%;top:18%;width:22%;height:38%">
+              <div class="ai-detection-label">&#x5371;&#x9669;&#x884C;&#x4E3A;: &#x672A;&#x4F69;&#x6234;&#x9632;&#x62A4;</div>
+            </div>
+
+            <!-- Camera label -->
+            <div class="camera-label">{{ cam }}</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getCameraList } from '@/api/screen'
+
+const TreeNode = {
+  name: 'TreeNode',
+  props: {
+    node: { type: Object, required: true },
+    depth: { type: Number, default: 0 },
+    selectedLabel: { type: String, default: '' }
+  },
+  data() {
+    return {
+      expanded: this.depth === 0
+    }
+  },
+  computed: {
+    isLeaf() {
+      return !this.node.children || this.node.children.length === 0
+    },
+    icon() {
+      const icons = ['\u{1F3E2}', '\u{1F3D7}\uFE0F', '\u{1F4D0}', '\u{1F52C}']
+      return icons[Math.min(this.depth, 3)]
+    },
+    isSelected() {
+      return this.node.label === this.selectedLabel
+    }
+  },
+  methods: {
+    toggle() {
+      if (!this.isLeaf) {
+        this.expanded = !this.expanded
+      }
+      this.$emit('select', this.node.label)
+    },
+    onChildSelect(label) {
+      this.$emit('select', label)
+    }
+  },
+  template: `
+    <div class="tree-node">
+      <div
+        class="tree-node-label"
+        :class="{ selected: isSelected }"
+        @click="toggle"
+      >
+        <span class="arrow" :class="{ open: expanded }">
+          <template v-if="!isLeaf">&#x25B6;</template>
+          <template v-else>&nbsp;&nbsp;</template>
+        </span>
+        <span>{{ icon }}</span>
+        <span>{{ node.label }}</span>
+      </div>
+      <div v-if="!isLeaf" class="tree-children" :class="{ open: expanded }">
+        <tree-node
+          v-for="(child, i) in node.children"
+          :key="i"
+          :node="child"
+          :depth="depth + 1"
+          :selected-label="selectedLabel"
+          @select="onChildSelect"
+        />
+      </div>
+    </div>
+  `
+}
+
+export default {
+  name: 'SecurityMonitor',
+  components: {
+    TreeNode
+  },
+  data() {
+    return {
+      searchText: '',
+      selectedUnit: '',
+      selectedTreeLabel: '',
+      currentPage: 1,
+      totalPages: 3,
+      cameras: [],
+      treeData: {
+        label: '\u5B89\u79D1\u9662\u4E3B\u56ED\u533A',
+        children: [
+          {
+            label: '\u7EFC\u5408\u5B9E\u9A8C\u697CA',
+            children: [
+              { label: '1\u5C42', children: [{ label: 'A101 \u5316\u5B66\u5B9E\u9A8C\u5BA4' }, { label: 'A102 \u5206\u6790\u5BA4' }] },
+              { label: '2\u5C42', children: [{ label: 'A201 \u751F\u7269\u5B9E\u9A8C\u5BA4' }] },
+              { label: '3\u5C42', children: [{ label: 'A301 \u6709\u673A\u5408\u6210\u5BA4' }, { label: 'A302 \u6838\u78C1\u5171\u632F\u5BA4' }, { label: 'A303 \u8D28\u8C31\u5BA4' }] },
+              { label: '4\u5C42', children: [{ label: 'A401 X\u5C04\u7EBF\u5BA4' }] }
+            ]
+          },
+          {
+            label: '\u7269\u7406\u5B9E\u9A8C\u697CB',
+            children: [
+              { label: '1\u5C42', children: [{ label: 'B101 \u5149\u5B66\u5B9E\u9A8C\u5BA4' }] },
+              { label: '2\u5C42', children: [{ label: 'B201 \u4F4E\u6E29\u5B9E\u9A8C\u5BA4' }] }
+            ]
+          },
+          {
+            label: '\u5DE5\u7A0B\u6280\u672F\u697CC',
+            children: [
+              { label: '1\u5C42', children: [{ label: 'C101 \u673A\u68B0\u52A0\u5DE5\u5BA4' }] },
+              { label: '2\u5C42', children: [{ label: 'C201 \u6750\u6599\u6D4B\u8BD5\u5BA4' }] }
+            ]
+          }
+        ]
+      }
+    }
+  },
+  created() {
+    this.fetchCameras()
+  },
+  methods: {
+    async fetchCameras() {
+      try {
+        const res = await getCameraList()
+        if (res.code === 200) {
+          this.cameras = res.data
+        }
+      } catch (e) {
+        // fallback to static data
+        this.cameras = [
+          'A301 \u6709\u673A\u5408\u6210\u5BA4',
+          'A302 \u6838\u78C1\u5171\u632F\u5BA4',
+          'A303 \u8D28\u8C31\u5BA4',
+          'A301 \u8D70\u5ECA',
+          'A302 \u8D70\u5ECA',
+          'A303 \u8D70\u5ECA',
+          'A\u5C42\u516C\u5171\u533A\u57DF',
+          'A\u5C42\u5B89\u5168\u901A\u9053',
+          'A\u5C42\u51FA\u5165\u53E3'
+        ]
+      }
+    },
+    onTreeNodeSelect(label) {
+      this.selectedTreeLabel = label
+    },
+    prevPage() {
+      if (this.currentPage > 1) {
+        this.currentPage--
+      }
+    },
+    nextPage() {
+      if (this.currentPage < this.totalPages) {
+        this.currentPage++
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.security-monitor {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+}
+
+// ========== Monitor Header ==========
+.monitor-header {
+  padding: 20px 30px;
+  border-bottom: 1px solid $border;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 25px;
+  background: linear-gradient(90deg, rgba(0, 60, 160, 0.18), transparent);
+}
+
+.monitor-title-cn {
+  font-size: 32px;
+  font-weight: 600;
+  color: $cyan;
+  letter-spacing: 2px;
+}
+
+.monitor-title-en {
+  font-size: 28px;
+  color: $text-dim;
+  margin-left: 10px;
+}
+
+.monitor-status {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 15px;
+}
+
+.monitor-status-text {
+  font-size: 28px;
+  color: $text-dim;
+}
+
+// ========== Monitor Inner (2-column grid) ==========
+.monitor-inner {
+  flex: 1;
+  display: grid;
+  grid-template-columns: 575px 1fr;
+  gap: 0;
+  min-height: 0;
+  overflow: hidden;
+}
+
+// ========== Left Column ==========
+.monitor-left {
+  display: flex;
+  flex-direction: column;
+  gap: 15px;
+  padding: 20px;
+  border-right: 1px solid $border;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.search-box {
+  display: flex;
+  align-items: center;
+  gap: 18px;
+  padding: 15px 25px;
+  border-radius: 10px;
+  background: $bg-card;
+  border: 1px solid $border;
+  flex-shrink: 0;
+
+  .search-icon {
+    font-size: 32px;
+    color: $text-dim;
+  }
+
+  input {
+    flex: 1;
+    background: none;
+    border: none;
+    outline: none;
+    color: $text;
+    font-size: 30px;
+    font-family: inherit;
+
+    &::placeholder {
+      color: $text-dim;
+    }
+  }
+}
+
+.filter-select {
+  padding: 12px 25px;
+  border-radius: 10px;
+  width: 100%;
+  flex-shrink: 0;
+  background: $bg-card;
+  border: 1px solid $border;
+  color: $text;
+  font-size: 28px;
+  font-family: inherit;
+  outline: none;
+  cursor: pointer;
+  appearance: none;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M0 0l6 8 6-8z' fill='rgba(30,144,255,0.6)'/%3E%3C/svg%3E");
+  background-repeat: no-repeat;
+  background-position: right 25px center;
+}
+
+// ========== Tree ==========
+.tree-wrap {
+  flex: 1;
+  overflow-y: auto;
+  overflow-x: hidden;
+  min-height: 0;
+
+  &::-webkit-scrollbar {
+    width: 8px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: $border;
+    border-radius: 5px;
+  }
+}
+
+::v-deep .tree-node-label {
+  display: flex;
+  align-items: center;
+  gap: 15px;
+  padding: 12px 20px;
+  border-radius: 8px;
+  font-size: 28px;
+  color: $text;
+  transition: all 0.2s;
+  user-select: none;
+  cursor: pointer;
+
+  &:hover {
+    background: $blue-dim;
+    color: $blue;
+  }
+
+  &.selected {
+    background: rgba(30, 144, 255, 0.15);
+    color: $cyan;
+    border-left: 5px solid $blue;
+  }
+
+  .arrow {
+    transition: transform 0.2s;
+    font-size: 25px;
+    color: $text-dim;
+    flex-shrink: 0;
+
+    &.open {
+      transform: rotate(90deg);
+    }
+  }
+}
+
+::v-deep .tree-children {
+  padding-left: 35px;
+  display: none;
+
+  &.open {
+    display: block;
+  }
+}
+
+// ========== Right Column ==========
+.monitor-right {
+  padding: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 15px;
+  min-height: 0;
+}
+
+.camera-grid-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-shrink: 0;
+}
+
+.camera-breadcrumb {
+  font-size: 30px;
+  color: $text-dim;
+
+  span {
+    color: $cyan;
+  }
+}
+
+.camera-pager {
+  display: flex;
+  align-items: center;
+  gap: 15px;
+}
+
+.pager-btn {
+  width: 60px;
+  height: 60px;
+  border-radius: 8px;
+  cursor: pointer;
+  background: $bg-card;
+  border: 1px solid $border;
+  color: $text-dim;
+  font-size: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.2s;
+
+  &:hover {
+    border-color: $blue;
+    color: $blue;
+  }
+}
+
+.pager-info {
+  font-size: 28px;
+  color: $text-dim;
+}
+
+// ========== Camera Grid (3x3) ==========
+.camera-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  grid-template-rows: repeat(3, 1fr);
+  gap: 12px;
+  flex: 1;
+  min-height: 0;
+}
+
+.camera-cell {
+  position: relative;
+  border-radius: 10px;
+  overflow: hidden;
+  background: #020810;
+  border: 1px solid $border;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  &:hover {
+    border-color: $blue;
+  }
+
+  &.ai-cam {
+    border-color: rgba(0, 230, 118, 0.4);
+  }
+}
+
+.camera-placeholder-text {
+  font-size: 48px;
+  color: rgba(110, 165, 210, 0.15);
+  letter-spacing: 8px;
+  font-weight: 700;
+  user-select: none;
+}
+
+.camera-label {
+  position: absolute;
+  bottom: 10px;
+  left: 15px;
+  font-size: 25px;
+  color: rgba(255, 255, 255, 0.75);
+  background: rgba(0, 0, 0, 0.5);
+  padding: 3px 10px;
+  border-radius: 5px;
+}
+
+.camera-rec {
+  position: absolute;
+  top: 10px;
+  right: 15px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 25px;
+  color: $red;
+  background: rgba(0, 0, 0, 0.5);
+  padding: 3px 10px;
+  border-radius: 5px;
+
+  &::before {
+    content: '';
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    background: $red;
+    animation: blinkRed 1s ease-in-out infinite;
+  }
+}
+
+.camera-ai-badge {
+  position: absolute;
+  top: 10px;
+  left: 15px;
+  font-size: 25px;
+  color: $green;
+  background: rgba(0, 30, 15, 0.75);
+  padding: 3px 10px;
+  border-radius: 5px;
+  border: 1px solid rgba(0, 230, 118, 0.3);
+  z-index: 2;
+}
+
+.ai-detection-box {
+  position: absolute;
+  border: 5px solid $red;
+  background: rgba(255, 59, 59, 0.1);
+  border-radius: 5px;
+  animation: detBox 1.5s ease-in-out infinite;
+}
+
+.ai-detection-label {
+  position: absolute;
+  bottom: 100%;
+  left: 0;
+  white-space: nowrap;
+  font-size: 25px;
+  background: $red;
+  color: #fff;
+  padding: 3px 10px;
+  border-radius: 5px;
+  margin-bottom: 5px;
+}
+
+// ========== Keyframes ==========
+@keyframes blinkRed {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.35; }
+}
+
+@keyframes detBox {
+  0%, 100% {
+    border-color: #ff3b3b;
+    box-shadow: 0 0 20px rgba(255, 59, 59, 0.4);
+  }
+  50% {
+    border-color: #fca5a5;
+    box-shadow: 0 0 45px rgba(255, 59, 59, 0.7);
+  }
+}
+</style>

+ 21 - 0
src/main.js

@@ -0,0 +1,21 @@
+import Vue from 'vue'
+import App from './App.vue'
+import router from './router'
+import store from './store'
+
+// 完整引入 Element UI
+import ElementUI from 'element-ui'
+import 'element-ui/lib/theme-chalk/index.css'
+
+// 全局样式
+import '@/styles/global.scss'
+
+Vue.use(ElementUI)
+
+Vue.config.productionTip = false
+
+new Vue({
+  router,
+  store,
+  render: h => h(App)
+}).$mount('#app')

+ 40 - 0
src/router/index.js

@@ -0,0 +1,40 @@
+import Vue from 'vue'
+import VueRouter from 'vue-router'
+
+Vue.use(VueRouter)
+
+const routes = [
+  {
+    path: '/',
+    redirect: '/login'
+  },
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/Login.vue')
+  },
+  {
+    path: '/screen',
+    name: 'Screen',
+    component: () => import('@/views/Screen.vue'),
+    meta: { requiresAuth: true }
+  }
+]
+
+const router = new VueRouter({
+  mode: 'history',
+  base: process.env.BASE_URL,
+  routes
+})
+
+// 路由守卫:未登录跳转到登录页
+router.beforeEach((to, from, next) => {
+  const token = localStorage.getItem('token')
+  if (to.meta.requiresAuth && !token) {
+    next('/login')
+  } else {
+    next()
+  }
+})
+
+export default router

+ 31 - 0
src/store/index.js

@@ -0,0 +1,31 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+
+Vue.use(Vuex)
+
+export default new Vuex.Store({
+  state: {
+    token: localStorage.getItem('token') || ''
+  },
+  mutations: {
+    SET_TOKEN(state, token) {
+      state.token = token
+      localStorage.setItem('token', token)
+    },
+    CLEAR_TOKEN(state) {
+      state.token = ''
+      localStorage.removeItem('token')
+    }
+  },
+  actions: {
+    login({ commit }, token) {
+      commit('SET_TOKEN', token)
+    },
+    logout({ commit }) {
+      commit('CLEAR_TOKEN')
+    }
+  },
+  getters: {
+    isAuthenticated: state => !!state.token
+  }
+})

+ 161 - 0
src/styles/global.scss

@@ -0,0 +1,161 @@
+// 全局样式 - 匹配 index-v2.html
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  background: $bg-deep;
+  font-family: $font-main;
+  color: $text;
+}
+
+// 滚动条
+::-webkit-scrollbar { width: 8px; height: 8px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: $border; border-radius: 5px; }
+
+// ========== 面板系统 ==========
+.panel {
+  position: relative;
+  border-radius: 15px;
+  overflow: hidden;
+  background: $bg-panel;
+  border: 1px solid $border;
+
+  &::before {
+    content: '';
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+    border-radius: inherit;
+    background: linear-gradient(135deg, rgba(30, 144, 255, 0.05) 0%, transparent 50%, rgba(0, 216, 255, 0.03) 100%);
+  }
+}
+
+// 角标装饰
+.pc {
+  position: absolute;
+  width: 35px;
+  height: 35px;
+  z-index: 3;
+  pointer-events: none;
+
+  &.tl { top: 0; left: 0; }
+  &.tr { top: 0; right: 0; transform: scaleX(-1); }
+  &.bl { bottom: 0; left: 0; transform: scaleY(-1); }
+  &.br { bottom: 0; right: 0; transform: scale(-1); }
+
+  svg { width: 100%; height: 100%; }
+}
+
+// 光束边框动画
+.border-beam {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  border-radius: inherit;
+  overflow: hidden;
+  z-index: 2;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(30, 144, 255, 0.9), rgba(0, 216, 255, 0.7), transparent);
+    animation: beamTop 5s linear infinite;
+  }
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    right: -100%;
+    width: 40%;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, rgba(0, 216, 255, 0.7), rgba(30, 144, 255, 0.9), transparent);
+    animation: beamBottom 5s linear infinite 2.5s;
+  }
+}
+
+@keyframes beamTop { from { left: -40%; } to { left: 100%; } }
+@keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
+
+// 面板头部
+.panel-header {
+  display: flex;
+  align-items: center;
+  gap: 25px;
+  padding: 20px 30px 18px;
+  border-bottom: 1px solid $border;
+  background: linear-gradient(90deg, rgba(0, 60, 160, 0.18), transparent);
+  flex-shrink: 0;
+}
+
+.panel-header-icon {
+  width: 65px;
+  height: 65px;
+  border-radius: 12px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32px;
+  background: linear-gradient(135deg, rgba(30, 144, 255, 0.25), rgba(0, 216, 255, 0.15));
+  border: 1px solid rgba(30, 144, 255, 0.35);
+  animation: iconGlow 3s ease-in-out infinite;
+}
+
+@keyframes iconGlow {
+  0%, 100% { box-shadow: 0 0 15px rgba(30, 144, 255, 0.3); }
+  50% { box-shadow: 0 0 35px rgba(30, 144, 255, 0.7), 0 0 12px rgba(0, 216, 255, 0.3); }
+}
+
+.panel-title {
+  font-size: 30px;
+  font-weight: 600;
+  letter-spacing: 2px;
+  color: $cyan;
+}
+
+// 扫描线效果
+.scan-effect {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  overflow: hidden;
+  z-index: 0;
+
+  &::after {
+    content: '';
+    position: absolute;
+    left: 0;
+    right: 0;
+    height: 200px;
+    top: -200px;
+    background: linear-gradient(180deg, transparent, rgba(30, 144, 255, 0.025), transparent);
+    animation: scanFull 9s linear infinite;
+  }
+}
+
+@keyframes scanFull { from { top: -200px; } to { top: 100%; } }
+
+// 状态圆点
+.status-dot {
+  width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  display: inline-block;
+  animation: dotPulse 2s ease-in-out infinite;
+
+  &.green { background: $green; box-shadow: 0 0 15px $green; }
+  &.red { background: $red; box-shadow: 0 0 15px $red; animation: blinkRed 0.8s ease-in-out infinite; }
+  &.orange { background: #f59e0b; box-shadow: 0 0 15px #f59e0b; }
+}
+
+@keyframes dotPulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.3); opacity: 0.7; } }
+@keyframes blinkRed { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

+ 37 - 0
src/styles/variables.scss

@@ -0,0 +1,37 @@
+// Sass 全局变量 - 匹配 index-v2.html 深蓝科幻主题
+
+// 主背景色
+$bg-deep: #030e1f;
+$bg-panel: rgba(5, 16, 42, 0.95);
+$bg-card: rgba(7, 22, 54, 0.85);
+
+// 主色
+$blue: #1e90ff;
+$blue-dim: rgba(30, 144, 255, 0.14);
+$blue-glow: rgba(30, 144, 255, 0.5);
+$cyan: #00d8ff;
+$cyan-dim: rgba(0, 216, 255, 0.12);
+$teal: #00e5c8;
+$indigo: #4361ee;
+$red: #ff3b3b;
+$green: #00e676;
+$gold: #ffd740;
+
+// 文字
+$white: #ddf0ff;
+$text: #a8cce8;
+$text-dim: rgba(110, 165, 210, 0.55);
+
+// 边框
+$border: rgba(30, 144, 255, 0.22);
+$border-h: rgba(30, 144, 255, 0.65);
+
+// 阴影
+$shadow: 0 4px 24px rgba(0, 80, 220, 0.22);
+
+// 字体
+$font-main: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
+
+// 尺寸
+$screen-width: 9600px;
+$screen-height: 2800px;

+ 33 - 0
src/utils/request.js

@@ -0,0 +1,33 @@
+import axios from 'axios'
+
+// 创建 axios 实例
+const service = axios.create({
+  baseURL: process.env.VUE_APP_BASE_API || '/api',
+  timeout: 10000
+})
+
+// 请求拦截器
+service.interceptors.request.use(
+  config => {
+    const token = localStorage.getItem('token')
+    if (token) {
+      config.headers['Authorization'] = `Bearer ${token}`
+    }
+    return config
+  },
+  error => {
+    return Promise.reject(error)
+  }
+)
+
+// 响应拦截器
+service.interceptors.response.use(
+  response => {
+    return response.data
+  },
+  error => {
+    return Promise.reject(error)
+  }
+)
+
+export default service

+ 238 - 0
src/views/Login.vue

@@ -0,0 +1,238 @@
+<template>
+  <div class="login-container">
+    <div class="login-card">
+      <div class="login-header">
+        <h1>实验室安全智能监测与管控中心</h1>
+        <p>中国安全生产科学研究院</p>
+      </div>
+      <el-form
+        ref="loginForm"
+        :model="loginForm"
+        :rules="loginRules"
+        class="login-form"
+      >
+        <el-form-item prop="username">
+          <el-input
+            v-model="loginForm.username"
+            placeholder="请输入用户名/手机号"
+            prefix-icon="el-icon-user"
+          />
+        </el-form-item>
+        <el-form-item prop="password">
+          <el-input
+            v-model="loginForm.password"
+            type="password"
+            placeholder="请输入密码"
+            prefix-icon="el-icon-lock"
+            show-password
+            @keyup.enter.native="handleLogin"
+          />
+        </el-form-item>
+        <el-form-item prop="captcha">
+          <div class="captcha-row">
+            <el-input
+              v-model="loginForm.captcha"
+              placeholder="请输入验证码"
+              prefix-icon="el-icon-key"
+            />
+            <div class="captcha-img" @click="refreshCaptcha">
+              {{ captchaText }}
+            </div>
+          </div>
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            type="primary"
+            :loading="loading"
+            class="login-btn"
+            @click="handleLogin"
+          >
+            登 录
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script>
+import md5 from 'js-md5'
+import { loginApi } from '@/api/auth'
+
+export default {
+  name: 'Login',
+  data() {
+    return {
+      loginForm: {
+        username: '',
+        password: '',
+        captcha: ''
+      },
+      loginRules: {
+        username: [
+          { required: true, message: '请输入用户名', trigger: 'blur' }
+        ],
+        password: [
+          { required: true, message: '请输入密码', trigger: 'blur' }
+        ],
+        captcha: [
+          { required: true, message: '请输入验证码', trigger: 'blur' }
+        ]
+      },
+      loading: false,
+      captchaText: '0000'
+    }
+  },
+  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
+          })
+
+          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)
+          }
+        } catch (err) {
+          this.$message.error('登录失败,请重试')
+        } finally {
+          this.loading = false
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.login-container {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #0a1a3a 0%, #0d2b5e 50%, #0a1a3a 100%);
+  position: relative;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background:
+      radial-gradient(ellipse at 20% 50%, rgba(0, 100, 200, 0.15) 0%, transparent 50%),
+      radial-gradient(ellipse at 80% 50%, rgba(0, 150, 255, 0.1) 0%, transparent 50%);
+    pointer-events: none;
+  }
+}
+
+.login-card {
+  width: 480px;
+  padding: 48px 40px;
+  background: rgba(8, 30, 65, 0.9);
+  border: 1px solid rgba(46, 120, 210, 0.4);
+  border-radius: 12px;
+  backdrop-filter: blur(10px);
+  z-index: 1;
+}
+
+.login-header {
+  text-align: center;
+  margin-bottom: 40px;
+
+  h1 {
+    font-size: 24px;
+    color: $cyan;
+    margin-bottom: 8px;
+    letter-spacing: 2px;
+  }
+
+  p {
+    font-size: 14px;
+    color: $text-dim;
+  }
+}
+
+.login-form {
+  ::v-deep .el-input__inner {
+    background: rgba(6, 40, 90, 0.6);
+    border: 1px solid rgba(46, 120, 210, 0.3);
+    color: #fff;
+    height: 44px;
+
+    &::placeholder {
+      color: rgba(140, 180, 230, 0.5);
+    }
+
+    &:focus {
+      border-color: $cyan;
+    }
+  }
+
+  ::v-deep .el-input__prefix {
+    color: $cyan;
+  }
+}
+
+.captcha-row {
+  display: flex;
+  gap: 12px;
+
+  .el-input {
+    flex: 1;
+  }
+
+  .captcha-img {
+    width: 120px;
+    height: 44px;
+    background: rgba(6, 40, 90, 0.8);
+    border: 1px solid rgba(46, 120, 210, 0.3);
+    border-radius: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 20px;
+    color: $cyan;
+    letter-spacing: 8px;
+    cursor: pointer;
+    user-select: none;
+  }
+}
+
+.login-btn {
+  width: 100%;
+  height: 44px;
+  font-size: 16px;
+  background: linear-gradient(90deg, #1890ff, #00e5ff);
+  border: none;
+  letter-spacing: 8px;
+
+  &:hover {
+    opacity: 0.9;
+  }
+}
+</style>

+ 130 - 0
src/views/Screen.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="screen-container">
+    <div class="bg-hex"></div>
+    <div class="scan-effect"></div>
+    <ScreenHeader />
+    <div class="screen-content">
+      <!-- 1区:3个面板 -->
+      <div class="panel-col">
+        <EventStats />
+        <SafetyCompliance />
+        <PersonnelTrend />
+      </div>
+      <!-- 2区:2个面板 -->
+      <div class="panel-col">
+        <EnvMonitorStats />
+        <EquipmentStats />
+      </div>
+      <!-- 3区:实时监控 -->
+      <div class="center-col">
+        <SecurityMonitor />
+      </div>
+      <!-- 4区:环境感知 + 预警 -->
+      <div class="panel-col col-right">
+        <LabEnvironment />
+        <AlarmInfo />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import ScreenHeader from '@/components/ScreenHeader.vue'
+import EventStats from '@/components/EventStats.vue'
+import SafetyCompliance from '@/components/SafetyCompliance.vue'
+import PersonnelTrend from '@/components/PersonnelTrend.vue'
+import EnvMonitorStats from '@/components/EnvMonitorStats.vue'
+import EquipmentStats from '@/components/EquipmentStats.vue'
+import SecurityMonitor from '@/components/SecurityMonitor.vue'
+import LabEnvironment from '@/components/LabEnvironment.vue'
+import AlarmInfo from '@/components/AlarmInfo.vue'
+
+export default {
+  name: 'Screen',
+  components: {
+    ScreenHeader,
+    EventStats,
+    SafetyCompliance,
+    PersonnelTrend,
+    EnvMonitorStats,
+    EquipmentStats,
+    SecurityMonitor,
+    LabEnvironment,
+    AlarmInfo
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.screen-container {
+  width: 9600px;
+  height: 2800px;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  background: $bg-deep;
+}
+
+/* 六角形背景纹理 */
+.bg-hex {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 0;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='52' viewBox='0 0 60 52'%3E%3Cpolygon points='30,2 58,17 58,47 30,52 2,47 2,17' fill='none' stroke='rgba(30,144,255,0.05)' stroke-width='1'/%3E%3C/svg%3E");
+  background-size: 150px 130px;
+
+  &::after {
+    content: '';
+    position: absolute;
+    inset: 0;
+    background: radial-gradient(ellipse at 50% 50%, transparent 25%, #030e1f 85%);
+  }
+}
+
+/* 主内容区域:4列精确像素布局 */
+.screen-content {
+  flex: 1;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: 1892px 1414px 4282px 1892px;
+  column-gap: 20px;
+  padding: 20px 30px;
+  position: relative;
+  z-index: 2;
+}
+
+.panel-col {
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+  min-height: 0;
+}
+
+.center-col {
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  position: relative;
+}
+
+/* 第1列:基本情况(956) + 安全分级(1017) + 人数走势(591) */
+.panel-col:nth-child(1) {
+  > :nth-child(1) { flex: 956; min-height: 0; }
+  > :nth-child(2) { flex: 1017; min-height: 0; }
+  > :nth-child(3) { flex: 591; min-height: 0; }
+}
+
+/* 第2列:设备统计(666) + 设备分类(1916) */
+.panel-col:nth-child(2) {
+  > :nth-child(1) { flex: 666; min-height: 0; }
+  > :nth-child(2) { flex: 1916; min-height: 0; }
+}
+
+/* 第4列:环境感知(1807) + 风险预警(775) */
+.col-right {
+  > :first-child { flex: 1807; min-height: 0; }
+  > :last-child { flex: 775; min-height: 0; }
+}
+</style>

+ 17 - 0
vue.config.js

@@ -0,0 +1,17 @@
+const { defineConfig } = require('@vue/cli-service')
+
+module.exports = defineConfig({
+  transpileDependencies: true,
+  lintOnSave: false,
+  devServer: {
+    port: 8080,
+    open: true
+  },
+  css: {
+    loaderOptions: {
+      sass: {
+        additionalData: `@import "@/styles/variables.scss";`
+      }
+    }
+  }
+})