Explorar o código

新需求修改

dedsudiyu hai 1 día
pai
achega
37af4ae73a

+ 3 - 1
.claude/settings.local.json

@@ -3,7 +3,9 @@
     "allow": [
       "Bash(npm install:*)",
       "mcp__ide__getDiagnostics",
-      "Bash(npm run:*)"
+      "Bash(npm run:*)",
+      "Bash(head -30 grep -n \"^##\\\\|^###\\\\|^####\\\\|^#\" \"e:/git/2021项目/anKeYuan1080/call-word/api-contract-lab-screen\\(5\\).md\")",
+      "Bash(head -20 grep -n \"height\\\\|flex\" \"e:/git/2021项目/anKeYuan1080/src/components/LabStats/EquipmentStats.vue\")"
     ]
   }
 }

+ 7 - 0
src/api/index.js

@@ -220,6 +220,13 @@ export async function getMqttConfig() {
   throw new Error(res.message || '接口异常')
 }
 
+// ======================== 化学品库动态统计 ========================
+export async function getChemicalStats() {
+  const res = await request.get('/laboratory/labScreen/chemicalStatistics')
+  if (res.code === 200 && res.data) return res.data
+  throw new Error(res.message || '接口异常')
+}
+
 // ======================== 验证码接口 ========================
 export async function getCaptcha(id = 0) {
   const res = await request.get('/auth/captcha', { params: { id } })

+ 219 - 0
src/components/LabStats/ChemicalsStats.vue

@@ -0,0 +1,219 @@
+<template>
+  <div class="ChemicalsStats panel-box">
+    <div class="corner-deco tl"></div><div class="corner-deco tr"></div>
+    <div class="corner-deco bl"></div><div class="corner-deco br"></div>
+    <div class="panel-title">化学品库动态统计</div>
+    <div class="cards-grid">
+
+      <!-- 左上:存量总量 -->
+      <div class="chem-card">
+        <div class="card-icon">
+          <svg width="46" height="46" viewBox="0 0 46 46" fill="none">
+            <circle cx="23" cy="23" r="20" fill="rgba(72,215,255,0.1)" stroke="rgba(72,215,255,0.4)" stroke-width="1.5"/>
+            <!-- 倾斜试管,旋转-40度 -->
+            <g transform="rotate(-40,23,23)">
+              <rect x="19" y="9" width="8" height="18" rx="1.5" fill="rgba(72,215,255,0.15)" stroke="#48d7ff" stroke-width="1.5"/>
+              <path d="M19 27 Q19 35 23 35 Q27 35 27 27Z" fill="rgba(72,215,255,0.35)" stroke="#48d7ff" stroke-width="1.5"/>
+              <rect x="17" y="7" width="12" height="3" rx="1" fill="rgba(72,215,255,0.2)" stroke="#48d7ff" stroke-width="1.2"/>
+              <!-- 液体 -->
+              <rect x="19" y="22" width="8" height="5" fill="rgba(72,215,255,0.5)"/>
+              <path d="M19 27 Q19 35 23 35 Q27 35 27 27Z" fill="rgba(72,215,255,0.5)"/>
+            </g>
+          </svg>
+        </div>
+        <div class="card-body">
+          <div class="card-value cyan">{{ formatNum(stats.totalAmount) }}<span class="unit"> L</span></div>
+          <div class="card-label">存量化学品总量</div>
+        </div>
+      </div>
+
+      <!-- 右上:管控类 -->
+      <div class="chem-card card-red">
+        <div class="donut-pct orange">{{ controlledPct }}%</div>
+        <div class="donut-wrap" ref="chartControlled"></div>
+        <div class="card-body">
+          <div class="card-value orange">{{ formatNum(stats.controlledAmount) }}<span class="unit"> L</span></div>
+          <div class="card-label">管控类化学品</div>
+        </div>
+      </div>
+
+      <!-- 左下:非管控类 -->
+      <div class="chem-card card-green">
+        <div class="donut-pct green">{{ uncontrolledPct }}%</div>
+        <div class="donut-wrap" ref="chartUncontrolled"></div>
+        <div class="card-body">
+          <div class="card-value green">{{ formatNum(stats.uncontrolledAmount) }}<span class="unit"> L</span></div>
+          <div class="card-label">非管控类化学品</div>
+        </div>
+      </div>
+
+      <!-- 右下:类目统计 -->
+      <div class="chem-card">
+        <div class="card-body center">
+          <div class="card-value yellow">{{ stats.totalCategories }}</div>
+          <div class="card-label">存量化学品总类目</div>
+          <div class="card-sub">管控{{ stats.controlledCategories }}类 / 非管控{{ stats.uncontrolledCategories }}类</div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getChemicalStats } from '@/api'
+
+export default {
+  name: 'ChemicalsStats',
+  data() {
+    return {
+      stats: {
+        totalAmount: 0, controlledAmount: 0, uncontrolledAmount: 0,
+        totalCategories: 0, controlledCategories: 0, uncontrolledCategories: 0
+      },
+      controlledPct: 0,
+      uncontrolledPct: 0,
+      chartC: null,
+      chartU: null,
+      timer: null
+    }
+  },
+  async mounted() {
+    await this.loadData()
+    this.timer = setInterval(this.loadData, 5 * 60 * 1000)
+  },
+  beforeDestroy() {
+    if (this.chartC) this.chartC.dispose()
+    if (this.chartU) this.chartU.dispose()
+    clearInterval(this.timer)
+  },
+  methods: {
+    formatNum(n) { return (n || 0).toLocaleString() },
+    async loadData() {
+      try {
+        const d = await getChemicalStats()
+        this.stats = {
+          totalAmount: d.totalAmount || 0,
+          controlledAmount: d.controlledAmount || 0,
+          uncontrolledAmount: d.uncontrolledAmount || 0,
+          totalCategories: d.totalCategories || 0,
+          controlledCategories: d.controlledCategories || 0,
+          uncontrolledCategories: d.uncontrolledCategories || 0
+        }
+        const total = this.stats.totalAmount || 1
+        this.controlledPct = Math.round(this.stats.controlledAmount / total * 1000) / 10
+        this.uncontrolledPct = Math.round(this.stats.uncontrolledAmount / total * 1000) / 10
+        this.$nextTick(() => {
+          this.renderDonut('chartControlled', this.controlledPct, '#ff6b35', '#ff8c5a', 'C')
+          this.renderDonut('chartUncontrolled', this.uncontrolledPct, '#36d399', '#5de8b5', 'U')
+        })
+      } catch (e) {
+        console.error('ChemicalsStats:', e)
+      }
+    },
+    renderDonut(refName, pct, color, colorLight, key) {
+      const el = this.$refs[refName]
+      if (!el) return
+      const existing = key === 'C' ? this.chartC : this.chartU
+      const chart = existing || echarts.init(el)
+      if (key === 'C') this.chartC = chart
+      else this.chartU = chart
+      chart.setOption({
+        series: [{
+          type: 'pie',
+          radius: ['55%', '82%'],
+          center: ['50%', '50%'],
+          startAngle: 90,
+          silent: true,
+          label: { show: false },
+          data: [
+            { value: pct, itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: colorLight }, { offset: 1, color: color }] }, shadowBlur: 6, shadowColor: color } },
+            { value: 100 - pct, itemStyle: { color: 'rgba(255,255,255,0.05)' } }
+          ]
+        }]
+      })
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.ChemicalsStats {
+  height: 272px;
+  display: flex;
+  flex-direction: column;
+}
+
+.cards-grid {
+  flex: 1;
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-template-rows: 1fr 1fr;
+  gap: 8px;
+  padding: 6px 10px 10px;
+  min-height: 0;
+  z-index:3;
+}
+
+.chem-card {
+  background: rgba(0, 15, 50, 0.6);
+  border: 1px solid rgba(72, 180, 255, 0.12);
+  border-radius: 6px;
+  display: flex;
+  align-items: center;
+  padding: 0 10px;
+  gap: 6px;
+  overflow: hidden;
+  &.card-red   { border-color: rgba(255, 107, 53, 0.3); background: rgba(40, 8, 4, 0.6); }
+  &.card-green { border-color: rgba(54, 211, 153, 0.3); background: rgba(4, 28, 18, 0.6); }
+}
+
+.card-icon { flex-shrink: 0; }
+
+.donut-wrap {
+  flex-shrink: 0;
+  width: 52px;
+  height: 52px;
+}
+
+.donut-pct {
+  flex-shrink: 0;
+  font-size: 11px;
+  font-weight: 700;
+  line-height: 1;
+  white-space: nowrap;
+  &.orange { color: #ff6b35; }
+  &.green  { color: #36d399; }
+}
+
+.card-body {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+  &.center { align-items: center; width: 100%; }
+}
+
+.card-value {
+  font-size: 20px;
+  font-weight: 700;
+  line-height: 1;
+  letter-spacing: 1px;
+  .unit { font-size: 12px; font-weight: 400; }
+  &.cyan   { color: #48d7ff; text-shadow: 0 0 8px rgba(72,215,255,0.5); }
+  &.orange { color: #ff6b35; text-shadow: 0 0 8px rgba(255,107,53,0.5); }
+  &.green  { color: #36d399; text-shadow: 0 0 8px rgba(54,211,153,0.5); }
+  &.yellow { color: #ffb020; text-shadow: 0 0 8px rgba(255,176,32,0.5); font-size: 30px; }
+}
+
+.card-label {
+  font-size: 11px;
+  color: #7eacc8;
+}
+
+.card-sub {
+  font-size: 10px;
+  color: rgba(126, 172, 200, 0.65);
+}
+</style>

+ 2 - 1
src/components/LabStats/DeviceStats.vue

@@ -111,6 +111,7 @@ export default {
 
 <style lang="scss" scoped>
 .device-stats {
+  height:340px;
   display: flex;
   flex-direction: column;
 }
@@ -174,7 +175,7 @@ export default {
   display: flex;
   justify-content: space-between;
   align-items: center;
-  padding: 10px 14px;
+  padding: 5px 14px;
   border-radius: 4px;
   background: rgba(72,215,255,0.04);
   border: 1px solid rgba(72,215,255,0.08);

+ 2 - 2
src/components/LabStats/EnvSensing.vue

@@ -10,7 +10,7 @@
           v-for="(item, idx) in displayList" :key="idx">
             <div class="lab-name">{{ item.lab }}<span class="lab-unit">{{ item.dept }}</span></div>
             <div class="sensor-values">
-              <span class="sensor-val" 
+              <span class="sensor-val"
                :class="minItem.hasException?'redClass':''"
               v-for="(minItem,minIndex) in item.sensorList" :key="minIndex">{{ minItem.deviceName }} : {{ minItem.deviceValue?minItem.deviceValue:'-' }}{{ minItem.unit }}</span>
             </div>
@@ -38,7 +38,7 @@ export default {
   computed: {
     // 复制一份数据实现无缝滚动
     displayList() {
-      if(this.list[9]){
+      if(this.list[3]){
         return [...this.list, ...this.list]
       }else{
         return [...this.list]

+ 1 - 0
src/components/LabStats/EquipmentStats.vue

@@ -104,6 +104,7 @@ export default {
 
 <style lang="scss" scoped>
 .equipment-stats {
+  height:340px;
   display: flex;
   flex-direction: column;
 }

+ 1 - 1
src/components/LabStats/RiskWarning.vue

@@ -38,7 +38,7 @@ export default {
   },
   computed: {
     displayList() {
-      if(this.list[4]){
+      if(this.list[2]){
         return [...this.list, ...this.list]
       }else{
         return [...this.list]

+ 134 - 0
src/components/LabStats/videoStats.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="videoStats panel-box risk-warning">
+    <div class="corner-deco tl"></div><div class="corner-deco tr"></div>
+    <div class="corner-deco bl"></div><div class="corner-deco br"></div>
+    <div class="panel-title">实验室视频监控</div>
+    <div class="video-content">
+      <H5PlayerVideo v-for="(item,index) in videoList" :key="index" :videoProps="item"
+                     :style="'width:'+width+'px;height:'+height+'px;display: inline-block;'"></H5PlayerVideo>
+    </div>
+    <fullH5PlayerVideo v-if="fullVideoType" :fullVideoProps="fullVideoProps"></fullH5PlayerVideo>
+  </div>
+</template>
+<script>
+  import { getVideoList } from '@/api'
+  import H5PlayerVideo from '@/components/H5PlayerVideo/H5PlayerVideo.vue'
+  import fullH5PlayerVideo from '@/components/fullH5PlayerVideo/fullH5PlayerVideo.vue'
+  export default {
+    name: 'videoStats',
+    components: { H5PlayerVideo,fullH5PlayerVideo },
+    data () {
+      return {
+        videoQueryParams:{
+          page:1,
+          pageSize:4,
+          streamType:1,
+          source:0,
+        },
+        videoType:false,
+        videoList:[],
+        videoTotal:0,
+        //全屏视频参数
+        fullVideoProps:{},
+        fullVideoType:false,
+        width:470,
+        height:280,
+      }
+    },
+    created(){
+
+    },
+    mounted(){
+      this.getVideoData();
+    },
+    methods:{
+      //刷新视屏
+      getVideoData(data) {
+        let obj = {
+          page: 1,
+          pageSize: 4,
+          passageway: '',
+          protocol: window.location.href.indexOf('https') !== -1 ? 'wss' : 'ws',
+          streamType: 1,
+          source: 4,
+          subIds: data,
+        };
+        this.$set(this, 'videoQueryParams', obj);
+        this.$nextTick(() => {
+          this.videoInitialize()
+        })
+      },
+      async videoInitialize() {
+        let self = this;
+        self.$set(self, 'videoType', false);
+        self.$set(self, 'videoList', []);
+        try {
+          // const res = await getVideoList(this.videoQueryParams)
+
+          // 假数据开始
+          let res = {
+            data: {
+              total: 100,
+              records: [
+                {streamUrl: '1', deviceNo: '1'},
+                {streamUrl: '2', deviceNo: '2'},
+                {streamUrl: '3', deviceNo: '3'},
+                {streamUrl: '4', deviceNo: '4'},
+              ],
+            }
+          };
+          // 假数据结束
+
+          let list = [];
+          for(let i=0;i<res.data.records.length;i++){
+            list.push(
+                {
+                  width: self.width, //(宽度:非必传-默认600)
+                  height: self.height, //(高度:非必传-默认338)
+                  url: res.data.records[i].streamUrl,
+                  cameraIndexCode: res.data.records[i].deviceNo,
+                }
+            )
+          }
+          this.$set(this,'videoList',list)
+          this.$nextTick(()=>{
+            setTimeout(function(){
+              self.$set(self, 'videoType', true);
+            },1000);
+          })
+        } catch (e) {
+          console.error('VideoGrid:', e)
+        }
+      },
+    },
+  }
+</script>
+<style scoped lang="scss">
+  .videoStats{
+
+  }
+  .risk-warning {
+    display: flex;
+    flex-direction: column;
+  }
+  .video-content{
+    padding: 0;
+    position: relative;
+    z-index: 2;
+    flex: 1;
+    min-height: 0;
+    .H5PlayerVideo:nth-child(1){
+      margin-right:10px;
+      margin-bottom:10px;
+    }
+    .H5PlayerVideo:nth-child(2){
+      margin-bottom:10px;
+    }
+    .H5PlayerVideo:nth-child(3){
+      margin-right:10px;
+    }
+    .H5PlayerVideo:nth-child(4){
+
+    }
+  }
+</style>

+ 412 - 332
src/components/VideoMonitor/VideoGrid.vue

@@ -1,7 +1,9 @@
 <template>
   <div class="panel-box video-grid">
-    <div class="corner-deco tl"></div><div class="corner-deco tr"></div>
-    <div class="corner-deco bl"></div><div class="corner-deco br"></div>
+    <div class="corner-deco tl"></div>
+    <div class="corner-deco tr"></div>
+    <div class="corner-deco bl"></div>
+    <div class="corner-deco br"></div>
     <div class="panel-title">
       实时视频监控
       <span class="video-count">共 {{ videoTotal }} 路</span>
@@ -48,373 +50,451 @@
 </template>
 
 <script>
-import { getVideoList } from '@/api'
-import H5PlayerVideo from '@/components/H5PlayerVideo/H5PlayerVideo.vue'
-import fullH5PlayerVideo from '@/components/fullH5PlayerVideo/fullH5PlayerVideo.vue'
-
-export default {
-  name: 'VideoGrid',
-  components: { H5PlayerVideo,fullH5PlayerVideo },
-  data() {
-    return {
-      videoQueryParams:{
-        page:1,
-        pageSize:4,
-        streamType:1,
-        source:0,
-      },
-      videoType:false,
-      videoList:[],
-      videoTotal:0,
-      //全屏视频参数
-      fullVideoProps:{},
-      fullVideoType:false,
-    }
-  },
-  computed: {
-    totalPages() {
-      return Math.ceil(this.videoTotal / 9) || 1
-    }
-  },
-  mounted() {
-    
-  },
-  methods: {
-    //刷新视屏
-    getVideoData(data){
-      let obj = {
-        page:1,
-        pageSize:9,
-        passageway:'',
-        protocol:window.location.href.indexOf('https') !== -1?'wss':'ws',
-        streamType:1,
-        source:4,
-        subIds:data,
-      };
-      this.$set(this,'videoQueryParams',obj);
-      this.$nextTick(()=>{
-        this.videoInitialize()
-      })
-    },
-    async videoInitialize() {
-      let self = this;
-      self.$set(self, 'videoType', false);
-      self.$set(self, 'videoList', []);
-      try {
-        const res = await getVideoList(this.videoQueryParams)
-        let list = [];
-        res.data.total = 100;
-        res.data.records = [
-          {streamUrl:'1', deviceNo:'1'},
-          {streamUrl:'2', deviceNo:'2'},
-          {streamUrl:'3', deviceNo:'3'},
-          {streamUrl:'4', deviceNo:'4'},
-          {streamUrl:'5', deviceNo:'5'},
-          {streamUrl:'6', deviceNo:'6'},
-          {streamUrl:'7', deviceNo:'7'},
-          {streamUrl:'8', deviceNo:'8'},
-          {streamUrl:'9', deviceNo:'9'},
-        ];
-        for(let i=0;i<res.data.records.length;i++){
-          list.push(
-            {
-              width: 510, //(宽度:非必传-默认600)
-              height: 275, //(高度:非必传-默认338)
-              url: res.data.records[i].streamUrl,
-              cameraIndexCode: res.data.records[i].deviceNo,
-            }
-          )
-        }
-        this.$set(this,'videoList',list)
-        this.$set(this,'videoTotal',res.data.total);
-        this.$nextTick(()=>{
-          setTimeout(function(){
-            self.$set(self, 'videoType', true);
-          },1000);
-        })
-      } catch (e) {
-        console.error('VideoGrid:', e)
+  import {getVideoList} from '@/api'
+  import H5PlayerVideo from '@/components/H5PlayerVideo/H5PlayerVideo.vue'
+  import fullH5PlayerVideo from '@/components/fullH5PlayerVideo/fullH5PlayerVideo.vue'
+
+  export default {
+    name: 'VideoGrid',
+    components: {H5PlayerVideo, fullH5PlayerVideo},
+    data() {
+      return {
+        videoQueryParams: {
+          page: 1,
+          pageSize: 9,
+          streamType: 1,
+          source: 0,
+        },
+        videoType: false,
+        videoList: [],
+        videoTotal: 0,
+        //全屏视频参数
+        fullVideoProps: {},
+        fullVideoType: false,
       }
     },
-    changePage(delta) {
-      this.videoQueryParams.page += delta
-      this.videoInitialize()
-    },
-    //全屏开启-关闭轮播
-    stopTime(cameraIndexCode){
-      this.$set(this,'fullVideoProps',{cameraIndexCode:cameraIndexCode});
-      this.$set(this,'fullVideoType',true);
+    computed: {
+      totalPages() {
+        return Math.ceil(this.videoTotal / 9) || 1
+      }
     },
-    //全屏关闭-开启轮播
-    outFullScreen(){
-      let self = this;
-      this.$set(this,'fullVideoType',false);
-      this.$set(this,'fullVideoProps',{});
+    mounted() {
+
     },
+    methods: {
+      //刷新视屏
+      getVideoData(data) {
+        let obj = {
+          page: 1,
+          pageSize: 9,
+          passageway: '',
+          protocol: window.location.href.indexOf('https') !== -1 ? 'wss' : 'ws',
+          streamType: 1,
+          source: 4,
+          subIds: data,
+        };
+        this.$set(this, 'videoQueryParams', obj);
+        this.$nextTick(() => {
+          this.videoInitialize()
+        })
+      },
+      async videoInitialize() {
+        let self = this;
+        self.$set(self, 'videoType', false);
+        self.$set(self, 'videoList', []);
+        try {
+          // const res = await getVideoList(this.videoQueryParams)
+
+          // 假数据开始
+          let res = {
+            data: {
+              total: 100,
+              records: [
+                {streamUrl: '1', deviceNo: '1'},
+                {streamUrl: '2', deviceNo: '2'},
+                {streamUrl: '3', deviceNo: '3'},
+                {streamUrl: '4', deviceNo: '4'},
+                {streamUrl: '5', deviceNo: '5'},
+                {streamUrl: '6', deviceNo: '6'},
+                {streamUrl: '7', deviceNo: '7'},
+                {streamUrl: '8', deviceNo: '8'},
+                {streamUrl: '9', deviceNo: '9'},
+              ],
+            }
+          };
+          // 假数据结束
+
+          let list = [];
+          for (let i = 0; i < res.data.records.length; i++) {
+            list.push(
+                {
+                  width: 510, //(宽度:非必传-默认600)
+                  height: 275, //(高度:非必传-默认338)
+                  url: res.data.records[i].streamUrl,
+                  cameraIndexCode: res.data.records[i].deviceNo,
+                }
+            )
+          }
+          this.$set(this, 'videoList', list)
+          this.$set(this, 'videoTotal', res.data.total);
+          this.$nextTick(() => {
+            setTimeout(function () {
+              self.$set(self, 'videoType', true);
+            }, 1000);
+          })
+        } catch (e) {
+          console.error('VideoGrid:', e)
+        }
+      },
+      changePage(delta) {
+        this.videoQueryParams.page += delta
+        this.videoInitialize()
+      },
+      //全屏开启-关闭轮播
+      stopTime(cameraIndexCode) {
+        this.$set(this, 'fullVideoProps', {cameraIndexCode: cameraIndexCode});
+        this.$set(this, 'fullVideoType', true);
+      },
+      //全屏关闭-开启轮播
+      outFullScreen() {
+        let self = this;
+        this.$set(this, 'fullVideoType', false);
+        this.$set(this, 'fullVideoProps', {});
+      },
+    }
   }
-}
 </script>
 
 <style lang="scss" scoped>
-.video-grid {
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-
-  .panel-content {
-    flex: 1;
+  .video-grid {
+    height: 100%;
     display: flex;
     flex-direction: column;
+
+    .panel-content {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+    }
   }
-}
-
-.video-count {
-  font-size: 12px;
-  color: $text-muted;
-  font-weight: normal;
-  margin-left: auto;
-}
-
-.grid-container {
-  flex: 1;
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  grid-template-rows: repeat(3, 1fr);
-  gap: 10px;
-}
-
-/* ---- Camera Frame ---- */
-.cam-frame {
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  animation: camEntrance 0.5s ease-out both;
-}
-
-@keyframes camEntrance {
-  from { opacity: 0; transform: scale(0.92); }
-  to { opacity: 1; transform: scale(1); }
-}
-
-.cam-inner {
-  flex: 1;
-  position: relative;
-  background: rgba(6, 22, 56, 0.6);
-  clip-path: polygon(
-    0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px,
-    100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px)
-  );
-  overflow: hidden;
-  border: 1px solid rgba(72, 215, 255, 0.15);
-  transition: all 0.3s ease;
-
-  &::before {
-    content: '';
-    position: absolute;
-    inset: 0;
-    border: 1px solid rgba(72, 215, 255, 0.12);
-    clip-path: inherit;
-    pointer-events: none;
-    z-index: 2;
+
+  .video-count {
+    font-size: 12px;
+    color: $text-muted;
+    font-weight: normal;
+    margin-left: auto;
   }
 
-  &::after {
-    content: '';
-    position: absolute;
-    inset: -2px;
-    background: conic-gradient(from 0deg, transparent 0%, rgba(72,215,255,0.4) 10%, transparent 20%);
-    animation: camFrameBorderFlow 6s linear infinite;
-    clip-path: inherit;
-    z-index: 1;
-    opacity: 0;
-    transition: opacity 0.3s ease;
+  .grid-container {
+    flex: 1;
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    grid-template-rows: repeat(3, 1fr);
+    gap: 10px;
   }
 
-  .cam-frame:hover &::after {
-    opacity: 1;
+  /* ---- Camera Frame ---- */
+  .cam-frame {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    animation: camEntrance 0.5s ease-out both;
   }
-}
-
-@keyframes camFrameBorderFlow {
-  from { transform: rotate(0deg); }
-  to { transform: rotate(360deg); }
-}
-
-/* Camera corner accents */
-.cam-corner {
-  position: absolute;
-  width: 12px;
-  height: 12px;
-  z-index: 3;
-
-  &::before, &::after {
-    content: '';
-    position: absolute;
-    background: $accent;
-    box-shadow: 0 0 4px $accent;
+
+  @keyframes camEntrance {
+    from {
+      opacity: 0;
+      transform: scale(0.92);
+    }
+    to {
+      opacity: 1;
+      transform: scale(1);
+    }
   }
 
-  &.tl { top: 0; left: 0;
-    &::before { top: 0; left: 0; width: 12px; height: 1px; }
-    &::after { top: 0; left: 0; width: 1px; height: 12px; }
+  .cam-inner {
+    flex: 1;
+    position: relative;
+    background: rgba(6, 22, 56, 0.6);
+    clip-path: polygon(
+            0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px,
+            100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px)
+    );
+    overflow: hidden;
+    border: 1px solid rgba(72, 215, 255, 0.15);
+    transition: all 0.3s ease;
+
+    &::before {
+      content: '';
+      position: absolute;
+      inset: 0;
+      border: 1px solid rgba(72, 215, 255, 0.12);
+      clip-path: inherit;
+      pointer-events: none;
+      z-index: 2;
+    }
+
+    &::after {
+      content: '';
+      position: absolute;
+      inset: -2px;
+      background: conic-gradient(from 0deg, transparent 0%, rgba(72, 215, 255, 0.4) 10%, transparent 20%);
+      animation: camFrameBorderFlow 6s linear infinite;
+      clip-path: inherit;
+      z-index: 1;
+      opacity: 0;
+      transition: opacity 0.3s ease;
+    }
+
+    .cam-frame:hover &::after {
+      opacity: 1;
+    }
   }
-  &.tr { top: 0; right: 0;
-    &::before { top: 0; right: 0; width: 12px; height: 1px; }
-    &::after { top: 0; right: 0; width: 1px; height: 12px; }
+
+  @keyframes camFrameBorderFlow {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
   }
-  &.bl { bottom: 0; left: 0;
-    &::before { bottom: 0; left: 0; width: 12px; height: 1px; }
-    &::after { bottom: 0; left: 0; width: 1px; height: 12px; }
+
+  /* Camera corner accents */
+  .cam-corner {
+    position: absolute;
+    width: 12px;
+    height: 12px;
+    z-index: 3;
+
+    &::before, &::after {
+      content: '';
+      position: absolute;
+      background: $accent;
+      box-shadow: 0 0 4px $accent;
+    }
+
+    &.tl {
+      top: 0;
+      left: 0;
+      &::before {
+        top: 0;
+        left: 0;
+        width: 12px;
+        height: 1px;
+      }
+      &::after {
+        top: 0;
+        left: 0;
+        width: 1px;
+        height: 12px;
+      }
+    }
+    &.tr {
+      top: 0;
+      right: 0;
+      &::before {
+        top: 0;
+        right: 0;
+        width: 12px;
+        height: 1px;
+      }
+      &::after {
+        top: 0;
+        right: 0;
+        width: 1px;
+        height: 12px;
+      }
+    }
+    &.bl {
+      bottom: 0;
+      left: 0;
+      &::before {
+        bottom: 0;
+        left: 0;
+        width: 12px;
+        height: 1px;
+      }
+      &::after {
+        bottom: 0;
+        left: 0;
+        width: 1px;
+        height: 12px;
+      }
+    }
+    &.br {
+      bottom: 0;
+      right: 0;
+      &::before {
+        bottom: 0;
+        right: 0;
+        width: 12px;
+        height: 1px;
+      }
+      &::after {
+        bottom: 0;
+        right: 0;
+        width: 1px;
+        height: 12px;
+      }
+    }
   }
-  &.br { bottom: 0; right: 0;
-    &::before { bottom: 0; right: 0; width: 12px; height: 1px; }
-    &::after { bottom: 0; right: 0; width: 1px; height: 12px; }
+
+  /* AI camera variant - orange accents */
+  .ai-cam {
+    .cam-corner::before, .cam-corner::after {
+      background: $accent-orange;
+      box-shadow: 0 0 4px $accent-orange;
+    }
+
+    .cam-inner {
+      border-color: rgba(255, 176, 32, 0.2);
+
+      &::after {
+        background: conic-gradient(from 0deg, transparent 0%, rgba(255, 176, 32, 0.4) 10%, transparent 20%);
+      }
+    }
+
+    .cam-scan-line {
+      background: linear-gradient(180deg, transparent, rgba(255, 176, 32, 0.15), transparent);
+    }
   }
-}
 
-/* AI camera variant - orange accents */
-.ai-cam {
-  .cam-corner::before, .cam-corner::after {
-    background: $accent-orange;
-    box-shadow: 0 0 4px $accent-orange;
+  .cam-video-area {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: relative;
   }
 
-  .cam-inner {
-    border-color: rgba(255, 176, 32, 0.2);
+  .cam-icon {
+    font-size: 30px;
+    color: rgba(72, 215, 255, 0.12);
+    z-index: 1;
 
-    &::after {
-      background: conic-gradient(from 0deg, transparent 0%, rgba(255,176,32,0.4) 10%, transparent 20%);
+    .ai-cam & {
+      color: rgba(255, 176, 32, 0.12);
     }
   }
 
   .cam-scan-line {
-    background: linear-gradient(180deg, transparent, rgba(255,176,32,0.15), transparent);
+    position: absolute;
+    left: 0;
+    width: 100%;
+    height: 40%;
+    background: linear-gradient(180deg, transparent, rgba(72, 215, 255, 0.08), transparent);
+    animation: gridScan 4s ease-in-out infinite;
+    pointer-events: none;
+    z-index: 2;
+  }
+
+  @keyframes gridScan {
+    0% {
+      top: -40%;
+    }
+    50% {
+      top: 100%;
+    }
+    50.01% {
+      top: -40%;
+    }
+    100% {
+      top: -40%;
+    }
   }
-}
-
-.cam-video-area {
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  position: relative;
-}
-
-.cam-icon {
-  font-size: 30px;
-  color: rgba(72, 215, 255, 0.12);
-  z-index: 1;
-
-  .ai-cam & {
-    color: rgba(255, 176, 32, 0.12);
+
+  .cam-label {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 5px 8px;
+    font-size: 12px;
+    background: rgba(6, 22, 56, 0.8);
+    border-top: 1px solid rgba(72, 215, 255, 0.08);
+    flex-shrink: 0;
   }
-}
-
-.cam-scan-line {
-  position: absolute;
-  left: 0;
-  width: 100%;
-  height: 40%;
-  background: linear-gradient(180deg, transparent, rgba(72,215,255,0.08), transparent);
-  animation: gridScan 4s ease-in-out infinite;
-  pointer-events: none;
-  z-index: 2;
-}
-
-@keyframes gridScan {
-  0% { top: -40%; }
-  50% { top: 100%; }
-  50.01% { top: -40%; }
-  100% { top: -40%; }
-}
-
-.cam-label {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  padding: 5px 8px;
-  font-size: 12px;
-  background: rgba(6, 22, 56, 0.8);
-  border-top: 1px solid rgba(72, 215, 255, 0.08);
-  flex-shrink: 0;
-}
-
-.cam-name {
-  color: $text-secondary;
-  flex: 1;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.cam-ai-badge {
-  font-size: 10px;
-  padding: 1px 5px;
-  border-radius: 3px;
-  color: $accent-orange;
-  background: rgba(255, 176, 32, 0.15);
-  border: 1px solid rgba(255, 176, 32, 0.3);
-  font-weight: 700;
-}
-
-.cam-status {
-  font-size: 10px;
-  font-weight: 600;
-
-  &.online {
-    color: $accent-green;
-    animation: recBlink 1.5s ease-in-out infinite;
+
+  .cam-name {
+    color: $text-secondary;
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
 
-  &.offline {
-    color: $text-muted;
+  .cam-ai-badge {
+    font-size: 10px;
+    padding: 1px 5px;
+    border-radius: 3px;
+    color: $accent-orange;
+    background: rgba(255, 176, 32, 0.15);
+    border: 1px solid rgba(255, 176, 32, 0.3);
+    font-weight: 700;
   }
-}
-
-@keyframes recBlink {
-  0%, 100% { opacity: 1; }
-  50% { opacity: 0.3; }
-}
-
-/* ---- Pagination ---- */
-.grid-pagination {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  gap: 14px;
-  padding-top: 10px;
-  flex-shrink: 0;
-}
-
-.page-btn {
-  width: 30px;
-  height: 30px;
-  border-radius: 4px;
-  background: rgba(72, 215, 255, 0.06);
-  border: 1px solid rgba(72, 215, 255, 0.15);
-  color: $text-secondary;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: all 0.3s ease;
-
-  &:hover:not(:disabled) {
-    border-color: $accent;
-    color: $accent;
-    box-shadow: 0 0 8px rgba(72, 215, 255, 0.2);
+
+  .cam-status {
+    font-size: 10px;
+    font-weight: 600;
+
+    &.online {
+      color: $accent-green;
+      animation: recBlink 1.5s ease-in-out infinite;
+    }
+
+    &.offline {
+      color: $text-muted;
+    }
   }
 
-  &:disabled {
-    opacity: 0.3;
-    cursor: not-allowed;
+  @keyframes recBlink {
+    0%, 100% {
+      opacity: 1;
+    }
+    50% {
+      opacity: 0.3;
+    }
   }
-}
 
-.page-info {
-  font-size: 13px;
-  color: $text-secondary;
-  font-family: 'Courier New', monospace;
-}
+  /* ---- Pagination ---- */
+  .grid-pagination {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    gap: 14px;
+    padding-top: 10px;
+    flex-shrink: 0;
+  }
+
+  .page-btn {
+    width: 30px;
+    height: 30px;
+    border-radius: 4px;
+    background: rgba(72, 215, 255, 0.06);
+    border: 1px solid rgba(72, 215, 255, 0.15);
+    color: $text-secondary;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.3s ease;
+
+    &:hover:not(:disabled) {
+      border-color: $accent;
+      color: $accent;
+      box-shadow: 0 0 8px rgba(72, 215, 255, 0.2);
+    }
+
+    &:disabled {
+      opacity: 0.3;
+      cursor: not-allowed;
+    }
+  }
+
+  .page-info {
+    font-size: 13px;
+    color: $text-secondary;
+    font-family: 'Courier New', monospace;
+  }
 </style>

+ 1 - 1
src/utils/request.js

@@ -61,7 +61,7 @@ service.interceptors.response.use(
     if (error.response && error.response.status === 401) {
       store.dispatch('logout')
       if (router.currentRoute.path !== '/login') {
-        router.push('/login')
+        router.replace('/login')
       }
     } else {
       const msg = error.response && error.response.data && error.response.data.message

+ 15 - 7
src/views/LabStatus.vue

@@ -10,13 +10,17 @@
       </div>
       <!-- 中列 -->
       <div class="col col-center">
-        <EnvSensing class="section s-env" />
-        <RiskWarning class="section s-risk" />
+        <videoStats class="section s-env"></videoStats>
+        <div class="s-risk">
+          <EnvSensing class="section s-env" style="margin-right:15px;" />
+          <RiskWarning class="section s-env" />
+        </div>
       </div>
       <!-- 右列 -->
       <div class="col col-right">
-        <DeviceStats class="section s-device" />
-        <EquipmentStats class="section s-equip" />
+        <DeviceStats class="section " />
+        <EquipmentStats class="section" />
+        <ChemicalsStats class="section" />
       </div>
     </div>
   </div>
@@ -31,12 +35,15 @@ import EnvSensing from '@/components/LabStats/EnvSensing.vue'
 import RiskWarning from '@/components/LabStats/RiskWarning.vue'
 import DeviceStats from '@/components/LabStats/DeviceStats.vue'
 import EquipmentStats from '@/components/LabStats/EquipmentStats.vue'
+import videoStats from '@/components/LabStats/videoStats.vue'
+import ChemicalsStats from '@/components/LabStats/ChemicalsStats.vue'
 
 export default {
   name: 'LabStatus',
   components: {
     ScreenHeader, BasicStats, SafetyLevel, PersonStats,
-    EnvSensing, RiskWarning, DeviceStats, EquipmentStats
+    EnvSensing, RiskWarning, DeviceStats, EquipmentStats,
+    videoStats, ChemicalsStats
   }
 }
 </script>
@@ -82,9 +89,10 @@ export default {
 
 // 中列: 环境感知flex:1, 风险预警320px固定
 .s-env { flex: 1; }
-.s-risk { height: 320px; flex-shrink: 0; }
+.s-risk { height: 320px; flex-shrink: 0;display: flex; }
 
-// 右列: 设备统计和设备分类各一半
+// 右列: 设备统计和设备分类各一半,化学品固定高度
 .s-device { flex: 1; }
 .s-equip { flex: 1; }
+.col-right .section:last-child { height: 272px; flex-shrink: 0; }
 </style>