浏览代码

完成状态监控页面的制作;新增三级菜单组件;新增顶部按钮组组件;新增dock组件;新增图列组件;新增设备状态组件;

画安 3 周之前
父节点
当前提交
c2f79fae92

+ 33 - 0
package-lock.json

@@ -27,6 +27,7 @@
         "@vue/cli-service": "~5.0.0",
         "eslint": "^7.32.0",
         "eslint-plugin-vue": "^8.0.3",
+        "postcss-pxtorem": "^5.1.1",
         "vue-template-compiler": "^2.6.14"
       }
     },
@@ -9677,6 +9678,38 @@
         "postcss": "^8.2.15"
       }
     },
+    "node_modules/postcss-pxtorem": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/postcss-pxtorem/-/postcss-pxtorem-5.1.1.tgz",
+      "integrity": "sha512-uvgIujL/pn0GbZ+rczESD2orHsbXrrCqi+q9wJO8PCk3ZGCoVVtu5hZTbtk+tbZHZP5UkTfCvqOrTZs9Ncqfsg==",
+      "dev": true,
+      "dependencies": {
+        "postcss": "^7.0.27"
+      }
+    },
+    "node_modules/postcss-pxtorem/node_modules/picocolors": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
+      "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
+      "dev": true
+    },
+    "node_modules/postcss-pxtorem/node_modules/postcss": {
+      "version": "7.0.39",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
+      "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
+      "dev": true,
+      "dependencies": {
+        "picocolors": "^0.2.1",
+        "source-map": "^0.6.1"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/postcss/"
+      }
+    },
     "node_modules/postcss-reduce-initial": {
       "version": "5.1.2",
       "resolved": "https://registry.npmmirror.com/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz",

+ 1 - 0
package.json

@@ -27,6 +27,7 @@
     "@vue/cli-service": "~5.0.0",
     "eslint": "^7.32.0",
     "eslint-plugin-vue": "^8.0.3",
+    "postcss-pxtorem": "^5.1.1",
     "vue-template-compiler": "^2.6.14"
   },
   "eslintConfig": {

+ 28 - 0
postcss.config.js

@@ -0,0 +1,28 @@
+// postcss.config.js
+module.exports = {
+  plugins: {
+    'postcss-pxtorem': {
+      // 这里的 16 必须和我们之前在 rem.js 中设置的 baseSize 保持绝对一致!
+      // 设计稿 1920px 时,1rem = 16px
+      rootValue: 16, 
+      
+      // 需要转换的 CSS 属性,'*' 代表全部属性(包括 width, height, font-size, margin 等)
+      propList: ['*'], 
+      
+      // 黑名单:指定不转换为 rem 的类名。
+      // 比如你有个盒子严格要求 1px 边框,你可以给它加个 class="norem"
+      selectorBlackList: ['.norem'], 
+      
+      // 忽略第三方插件包(极其重要!)
+      // 如果你的大屏里用到了 Element UI 或其他组件库,通常不要去转换它们的内部样式,否则容易错位
+      exclude: /node_modules/i, 
+      
+      // 允许在媒体查询中转换 px
+      mediaQuery: false, 
+      
+      // 设置要替换的最小像素值 (默认 0)。
+      // 建议设为 2,意味着 1px 的线条或边框不会被转成 rem,避免在大屏上太细看不见
+      minPixelValue: 2 
+    }
+  }
+}

+ 1 - 1
src/components/IntersectionSignalMonitoring.vue

@@ -21,7 +21,7 @@
 <script>
 
 import SignalTimingChart from '@/components/SignalTimingChart.vue';
-import IntersectionMap from '@/components/IntersectionMap.vue';
+import IntersectionMap from '@/components/ui/IntersectionMap.vue';
 import { fetchSignalTimingData, getIntersectionData } from '@/mock/data';
 import video1 from '@/assets/videos/video1.mp4';
 import video2 from '@/assets/videos/video2.mp4';

+ 124 - 0
src/components/ui/AlarmPopup.vue

@@ -0,0 +1,124 @@
+<template>
+  <div class="map-alarm-popup">
+    <div class="popup-header">
+      <div class="status-icon warning">
+        <i class="el-icon-warning-outline"></i>
+      </div>
+      <span class="title-text">{{ title }}</span>
+    </div>
+
+    <div class="popup-content">
+      <div class="info-row">
+        <span class="label">路口:</span>
+        <span class="value">{{ intersection }}</span>
+      </div>
+      <div class="info-row">
+        <span class="label">发生时间:</span>
+        <span class="value">{{ time }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'AlarmPopup',
+  props: {
+    title: {
+      type: String,
+      default: '降级黄闪'
+    },
+    intersection: {
+      type: String,
+      default: '北京路与南京路'
+    },
+    time: {
+      type: String,
+      default: '2026.1.23.12:00'
+    },
+    // 可选:用于控制不同状态的颜色 (warning:黄, error:红, normal:绿)
+    type: {
+      type: String,
+      default: 'warning'
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* ================= 整体卡片样式 ================= */
+.map-alarm-popup {
+  width: max-content; /* 宽度根据内容自适应 */
+  min-width: 240px;
+  /* 完美的暗黑透明背景 + 毛玻璃,高度还原图片 */
+  background: rgba(15, 20, 35, 0.85); 
+  backdrop-filter: blur(8px);
+  -webkit-backdrop-filter: blur(8px);
+  border: 1px solid rgba(255, 255, 255, 0.1); /* 极细边缘光 */
+  border-radius: 8px; /* 圆角 */
+  padding: 16px 20px;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
+  pointer-events: auto; /* 允许鼠标点击交互 */
+}
+
+/* ================= 头部标题区 ================= */
+.popup-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+/* 状态图标容器 */
+.status-icon {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  margin-right: 10px;
+  color: #fff;
+  font-size: 14px;
+}
+
+/* 警告状态(黄色) */
+.status-icon.warning {
+  background-color: #e6a23c; 
+  box-shadow: 0 0 8px rgba(230, 162, 60, 0.6);
+}
+
+/* 异常状态(红色,预留) */
+.status-icon.error {
+  background-color: #f56c6c;
+  box-shadow: 0 0 8px rgba(245, 108, 108, 0.6);
+}
+
+.title-text {
+  font-size: 16px;
+  font-weight: bold;
+  color: #ffffff;
+  letter-spacing: 1px;
+}
+
+/* ================= 内容信息区 ================= */
+.popup-content {
+  display: flex;
+  flex-direction: column;
+  gap: 8px; /* 行间距 */
+}
+
+.info-row {
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  white-space: nowrap; /* 防止文字换行 */
+}
+
+.label {
+  color: #a0a5b0; /* 标签文字用灰白色区分层级 */
+}
+
+.value {
+  color: #ffffff; /* 核心数据纯白高亮 */
+}
+</style>

+ 336 - 0
src/components/ui/BottomDock.vue

@@ -0,0 +1,336 @@
+<template>
+    <div class="dock-wrapper">
+        <div class="nav-arrow left-arrow" :class="{ 'is-disabled': !canScrollLeft, 'is-active': canScrollLeft }"
+            @click="scrollList(-1)">
+            <img v-if="canScrollLeft" src="@/assets/main/main-right.png" class="arrow-img left-facing" />
+            <img v-else src="@/assets/main/main-left.png" class="arrow-img" />
+        </div>
+
+        <div class="dock-list-container" ref="listContainer" @scroll="checkScrollState">
+            <div class="dock-list">
+                <div v-for="(item, index) in dockItems" :key="index" class="dock-item"
+                    @click="handleSelect(index, item)">
+                    <div class="item-icon">
+                        <img v-if="item.imgUrl" :src="item.imgUrl" class="custom-icon" />
+
+                        <i v-else :class="item.iconClass"></i>
+                    </div>
+                    <div class="item-label">{{ item.label }}</div>
+                </div>
+            </div>
+        </div>
+
+        <div class="nav-arrow right-arrow" :class="{ 'is-disabled': !canScrollRight, 'is-active': canScrollRight }"
+            @click="scrollList(1)">
+            <img v-if="canScrollRight" src="@/assets/main/main-right.png" class="arrow-img" />
+            <img v-else src="@/assets/main/main-left.png" class="arrow-img left-facing" />
+        </div>
+    </div>
+</template>
+
+<script>
+export default {
+    name: 'BottomDock',
+    data() {
+        return {
+            activeIndex: 3,
+            // 新增两个状态变量,控制左右按钮的状态
+            canScrollLeft: false, // 初始默认在最左侧,所以左侧不能滚
+            canScrollRight: true, // 初始默认右侧有内容,可以滚
+            dockItems: [
+                /* ... 你的菜单数据 ... */
+                {
+                    label: '首页',
+                    imgUrl: require('@/assets/main/main-home.png'),
+                    theme: 'blue',
+                },
+                {
+                    label: '状态监控',
+                    imgUrl: require('@/assets/main/main-surve.png'),
+                    theme: 'blue',
+                },
+                {
+                    label: '特勤安保',
+                    imgUrl: require('@/assets/main/main-security.png'),
+                    theme: 'gold',
+                },
+                {
+                    label: '干线协调',
+                    imgUrl: require('@/assets/main/main-coor.png'),
+                    theme: 'blue',
+                },
+                {
+                    label: '状态展示',
+                    imgUrl: require('@/assets/main/main-watch.png'),
+                    theme: 'blue',
+                },
+                {
+                    label: '系统设置',
+                    imgUrl: require('@/assets/main/main-setting.png'),
+                    theme: 'blue',
+                },
+                // (建议多加几个测试数据,撑爆容器宽度,才能看到滚动和状态切换)
+                {
+                    label: '测试1',
+                    imgUrl: require('@/assets/main/main-home.png'),
+                    theme: 'blue',
+                },
+                {
+                    label: '测试2',
+                    imgUrl: require('@/assets/main/main-surve.png'),
+                    theme: 'blue',
+                },
+                {
+                    label: '测试3',
+                    imgUrl: require('@/assets/main/main-security.png'),
+                    theme: 'blue',
+                },
+            ]
+        };
+    },
+    mounted() {
+        // 组件挂载后,等 DOM 渲染完毕,立即计算一次初始状态
+        this.$nextTick(() => {
+            this.checkScrollState();
+            // 监听窗口大小变化,防止屏幕拉伸导致容器尺寸变化
+            window.addEventListener('resize', this.checkScrollState);
+        });
+    },
+    beforeDestroy() {
+        // 销毁时移除监听
+        window.removeEventListener('resize', this.checkScrollState);
+    },
+    methods: {
+        handleSelect(index, item) {
+            if (this.activeIndex === index) return;
+            this.activeIndex = index;
+            this.$emit('change', item);
+        },
+
+        // 【新增核心方法】:检查并更新滚动状态
+        checkScrollState() {
+            const container = this.$refs.listContainer;
+            if (!container) return;
+
+            // 1. 如果 scrollLeft 大于 0,说明不在最左边,左侧按钮可点击 (状态1)
+            this.canScrollLeft = container.scrollLeft > 0;
+
+            // 2. 如果 scrollLeft + 可视宽度 < 实际总宽度,说明右侧还没滚到底,右侧按钮可点击 (状态1)
+            // 使用 Math.ceil 是为了防止部分高分屏下出现小数像素导致的精度误差
+            this.canScrollRight = Math.ceil(container.scrollLeft + container.clientWidth) < container.scrollWidth;
+        },
+
+        scrollList(direction) {
+            // 如果处于不可滚动状态(状态2),直接 return,不执行滚动
+            if (direction === -1 && !this.canScrollLeft) return;
+            if (direction === 1 && !this.canScrollRight) return;
+
+            const container = this.$refs.listContainer;
+            // 1 个图标宽度 100px + 右侧间距 30px = 130px
+            const scrollAmount = 130; // 调整每次滑动的距离
+
+            if (container) {
+                container.scrollBy({
+                    left: direction * scrollAmount,
+                    behavior: 'smooth'
+                });
+                // 这里的平滑滚动会自动触发上面绑定的 @scroll 事件,
+                // 从而实时调用 checkScrollState 更新按钮状态
+            }
+        }
+    }
+};
+</script>
+
+<style scoped>
+/* ================= 整体容器布局 (修复垂直排列的核心) ================= */
+.dock-wrapper {
+    display: flex !important;
+    flex-direction: row !important;
+    /* 1. 强制左右水平排列 */
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 160px;
+    position: relative;
+}
+
+/* 视窗容器:定宽,超出部分隐藏并允许横向滚动 
+    【精算容器宽度】:
+    要想完美不漏边,容器宽度必须等于:(显示个数 * 元素宽度) + ((显示个数 - 1) * 间距)
+    假设你想完美显示 6 个:(6 * 100px) + (5 * 30px) = 750px
+  */
+.dock-list-container {
+    width: 750px;
+    height: 160px;
+    overflow-x: auto;
+    overflow-y: hidden;
+    white-space: nowrap;
+    /* 2. 强制内部文本/元素绝对不换行 */
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+}
+
+.dock-list-container::-webkit-scrollbar {
+    display: none;
+}
+
+/* 真实的菜单列表:尺寸由内容撑开 */
+.dock-list {
+    display: flex !important;
+    flex-direction: row !important;
+    /* 3. 菜单项强制水平排列 */
+    flex-wrap: nowrap !important;
+    /* 4. 绝对不允许换行 */
+    align-items: flex-end;
+    /* 底部对齐 */
+    height: 100%;
+    width: max-content;
+    /* 5. 关键:真实宽度由内部项决定,从而完美触发父级滚动 */
+    min-width: 100%;
+    padding-bottom: 20px;
+    gap: 30px;
+    /* 图标之间的横向间距 */
+}
+
+/* ================= 左右控制箭头 ================= */
+/* 容器基础样式 */
+.nav-arrow {
+    flex-shrink: 0;
+    width: 40px;
+    height: 40px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    transition: all 0.3s;
+    margin: 0 15px;
+    /* 【修改】:去掉之前的底色和边框,因为你现在用整张切图了 */
+    background: transparent !important;
+    border: none !important;
+}
+
+.arrow-img {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+  /* transition: transform 0.2s ease, filter 0.3s ease; */
+}
+
+/* 强制让左侧的图片掉头指向上一个 */
+.left-facing {
+  transform: rotate(180deg);
+}
+
+/* 状态2:允许切换时的悬浮放大效果 */
+.nav-arrow.is-active {
+  cursor: pointer;
+}
+.nav-arrow.is-active:hover .arrow-img {
+  transform: scale(1.1); 
+  filter: drop-shadow(0 0 10px rgba(0, 229, 255, 0.8));
+}
+
+/* 【关键修复】:由于左箭头自带 rotate(180deg),悬浮放大时不能丢掉它的旋转角度,否则它会瞬间掉头! */
+.nav-arrow.left-arrow.is-active:hover .arrow-img.left-facing {
+  transform: rotate(180deg) scale(1.1);
+}
+
+/* 状态1:不允许切换时稍微变暗 */
+.nav-arrow.is-disabled {
+  cursor: not-allowed;
+  opacity: 0.6; 
+}
+
+/* ================= 单个导航项 ================= */
+.dock-item {
+    flex-shrink: 0;
+    /* 7. 关键:禁止菜单项自身被挤压 */
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    /* 菜单项内部(图标+文字)是上下垂直排布的 */
+    align-items: center;
+    justify-content: flex-end;
+    cursor: pointer;
+    width: 100px;
+    height: 90px;
+    transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+/* --- 图标部分 --- */
+.item-icon {
+    font-size: 32px;
+    color: #a0cfff;
+    z-index: 3;
+    margin-bottom: 5px;
+    transition: all 0.3s;
+}
+
+/* --- 文字部分 --- */
+.item-label {
+    color: #c0c4cc;
+    font-size: 14px;
+    text-align: center;
+    transition: all 0.3s;
+    letter-spacing: 1px;
+}
+
+/* ================= 交互状态:悬浮与选中 ================= */
+
+.dock-item:hover,
+.dock-item.is-active {
+    transform: translateY(-15px) scale(1.15);
+}
+
+.dock-item:hover .item-icon,
+.dock-item.is-active .item-icon {
+    color: #ffffff;
+    text-shadow: 0 0 15px #00e5ff;
+}
+
+.dock-item:hover .item-label,
+.dock-item.is-active .item-label {
+    color: #ffffff;
+    font-weight: bold;
+    text-shadow: 0 0 8px #00e5ff;
+}
+
+.dock-item:hover .top-solid,
+.dock-item.is-active .top-solid {
+    background: linear-gradient(135deg, rgba(0, 229, 255, 0.9), rgba(0, 115, 255, 0.9));
+    box-shadow: 0 0 20px rgba(0, 229, 255, 0.6);
+}
+
+.dock-item.theme-gold.is-active .item-icon {
+    color: #ffd700;
+    text-shadow: 0 0 15px #ffaa00;
+}
+
+.dock-item.theme-gold.is-active .item-label {
+    color: #ffd700;
+    text-shadow: 0 0 8px #ffaa00;
+}
+
+/* --- 图标部分 --- */
+.item-icon {
+    z-index: 3;
+    margin-bottom: 5px;
+    /* 确保图片也能有悬浮时的丝滑过渡 */
+    transition: transform 0.3s ease, filter 0.3s ease;
+}
+
+/* 专门针对图片的样式 */
+.custom-icon {
+    width: 88px;
+    /* 根据你切图的实际大小调整 */
+    height: 64px;
+    object-fit: contain;
+    /* 保证图片不变形 */
+}
+
+/* 悬浮时,如果想让图片也发光,可以使用 CSS 的 drop-shadow 滤镜 */
+.dock-item:hover .custom-icon,
+.dock-item.is-active .custom-icon {
+    filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.8));
+}
+</style>

+ 99 - 0
src/components/ui/ButtonGroup.vue

@@ -0,0 +1,99 @@
+<template>
+  <div class="custom-button-group">
+    <div
+      v-for="(item, index) in tabs"
+      :key="index"
+      class="group-item"
+      :class="{ 'is-active': activeIndex === index }"
+      @click="handleTabClick(index, item)"
+    >
+      {{ item.label }}
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'ButtonGroup',
+  // 如果你想从外部传入初始选中的值,可以通过 props
+  props: {
+    defaultIndex: {
+      type: Number,
+      default: 0
+    }
+  },
+  data() {
+    return {
+      activeIndex: this.defaultIndex,
+      // 定义按钮组的数据和对应的标识(value)
+      tabs: [
+        { label: '总览', value: 'overview' },
+        { label: '路口', value: 'intersection' },
+        { label: '干线', value: 'arterial' },
+        { label: '特勤', value: 'special' }
+      ]
+    };
+  },
+  methods: {
+    handleTabClick(index, item) {
+      // 如果点击的是当前已选中的,则不重复触发
+      if (this.activeIndex === index) return;
+      
+      this.activeIndex = index;
+      
+      // 向父组件抛出 change 事件,并传递当前选中的 value
+      this.$emit('change', item.value);
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* 整个按钮组的外层容器 */
+.custom-button-group {
+  display: flex;
+  align-items: center;
+  /* 假设整体宽度 400px,高度 40px,pxtorem 会自动转换 */
+  width: 700px; 
+  height: 50px;
+  /* 整体边框颜色:淡蓝色半透明 */
+  border: 1px solid rgba(100, 150, 255, 0.6);
+  background: rgba(20, 40, 80, 0.4); /* 未选中的底层背景色 */
+  box-sizing: border-box;
+}
+
+/* 单个按钮项 */
+.group-item {
+  flex: 1; /* 平分宽度 */
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #a0a5b0; /* 未选中时的灰白色字体 */
+  font-size: 14px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  /* 右侧分割线 */
+  border-right: 1px solid rgba(100, 150, 255, 0.6);
+  box-sizing: border-box;
+}
+
+/* 最后一个按钮去掉右侧分割线 */
+.group-item:last-child {
+  border-right: none;
+}
+
+/* 悬浮效果:稍微提亮一点 */
+.group-item:hover {
+  background: rgba(100, 150, 255, 0.5);
+  color: #ffffff;
+}
+
+/* 选中状态(激活态)的样式 */
+.group-item.is-active {
+  /* 选中时的背景色:更亮、不透明度更高的蓝色 */
+  background: rgba(70, 130, 255, 0.2); 
+  color: #ffffff; /* 选中时字体纯白 */
+  font-weight: bold; /* 选中时字体加粗 */
+}
+</style>

+ 178 - 0
src/components/ui/DeviceDonutChart.vue

@@ -0,0 +1,178 @@
+<template>
+  <div class="chart-wrapper">
+    <div class="echarts-container" ref="chartRef"></div>
+
+    <div class="custom-legend">
+      <div class="legend-item">
+        <span class="dot dot-online"></span>
+        <span class="label">在线</span>
+      </div>
+      <div class="legend-item">
+        <span class="dot dot-offline"></span>
+        <span class="label">离线</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts';
+
+// 设定设计稿基准宽度 (与你大屏的设计稿尺寸一致)
+const DESIGN_WIDTH = 1920;
+
+export default {
+  name: 'DeviceDonutChart',
+  props: {
+    online: { type: Number, required: true },
+    total: { type: Number, required: true }
+  },
+  data() {
+    return {
+      chart: null,
+      resizeTimer: null
+    };
+  },
+  computed: {
+    offline() {
+      return this.total - this.online;
+    },
+    percent() {
+      return this.total === 0 ? 0 : Math.round((this.online / this.total) * 100);
+    }
+  },
+  watch: {
+    online() { this.updateChart(); },
+    total() { this.updateChart(); }
+  },
+  mounted() {
+    this.initChart();
+    // 监听窗口大小变化
+    window.addEventListener('resize', this.handleResize);
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize);
+    if (this.chart) {
+      this.chart.dispose();
+      this.chart = null;
+    }
+  },
+  methods: {
+    // 1. 【新增】:获取当前屏幕真实的缩放比例
+    getRealScale() {
+      return window.innerWidth / DESIGN_WIDTH;
+    },
+
+    initChart() {
+      this.chart = echarts.init(this.$refs.chartRef);
+      this.updateChart();
+    },
+
+    updateChart() {
+      if (!this.chart) return;
+
+      // 2. 【新增】:每次更新图表时,获取最新的缩放比例
+      const scale = this.getRealScale();
+
+      const option = {
+        title: {
+          text: `{percent|${this.percent}%}\n{count|${this.online}/${this.total}}`,
+          left: 'center',
+          top: 'center',
+          textStyle: {
+            rich: {
+              // 3. 【核心修复】:将原本写死的字体大小(28, 14)和边距乘以 scale!
+              // 使用 Math.round 保证字体像素是整数,渲染更清晰
+              percent: { 
+                fontSize: Math.round(28 * scale), 
+                color: '#ffffff', 
+                fontWeight: 'bold', 
+                padding: [0, 0, Math.round(8 * scale), 0] 
+              },
+              count: { 
+                fontSize: Math.round(14 * scale), 
+                color: '#e2e8f0' 
+              }
+            }
+          }
+        },
+        series: [
+          {
+            type: 'pie',
+            radius: ['65%', '85%'], 
+            center: ['50%', '50%'],
+            avoidLabelOverlap: false,
+            label: { show: false }, 
+            labelLine: { show: false },
+            data: [
+              { value: this.online, name: '在线', itemStyle: { color: '#33ccff' } },
+              { value: this.offline, name: '离线', itemStyle: { color: '#ff7744' } }
+            ]
+          }
+        ]
+      };
+      
+      this.chart.setOption(option);
+    },
+
+    handleResize() {
+      if (this.resizeTimer) clearTimeout(this.resizeTimer);
+      this.resizeTimer = setTimeout(() => {
+        if (this.chart) {
+          // 4. 【核心修复】:窗口缩放时,不仅要重置 Canvas 画布大小
+          this.chart.resize(); 
+          // 还要重新执行 updateChart 触发 setOption,这样新计算出来的 scale 字体才能生效!
+          this.updateChart();  
+        }
+      }, 100);
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* ================== CSS 部分无需修改,由于你使用了 postcss-pxtorem,这里的 px 会自动转为 rem 并自适应 ================== */
+.chart-wrapper {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+}
+
+.echarts-container {
+  flex: 1; 
+  height: 100%;
+  min-height: 160px;
+}
+
+.custom-legend {
+  width: 120px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 15px; 
+  margin-right: 10px;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  background: rgba(255, 255, 255, 0.08); 
+  padding: 8px 15px;
+  border-radius: 2px;
+}
+
+.dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 2px; 
+  margin-right: 10px;
+}
+.dot-online { background-color: #33ccff; }
+.dot-offline { background-color: #ff7744; }
+
+.label {
+  color: #c0c4cc;
+  font-size: 13px;
+}
+</style>

+ 141 - 0
src/components/ui/DeviceStatusPanel.vue

@@ -0,0 +1,141 @@
+<template>
+  <div class="device-status-panel">
+    <div class="tab-bar">
+      <div 
+        v-for="(tab, index) in tabs" 
+        :key="index"
+        class="tab-item"
+        :class="{ 'is-active': activeIndex === index }"
+        @click="handleManualSwitch(index)"
+      >
+        {{ tab.name }}
+      </div>
+    </div>
+
+    <div class="panel-body">
+      <DeviceDonutChart 
+        :online="currentData.online" 
+        :total="currentData.total" 
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+import DeviceDonutChart from './DeviceDonutChart.vue';
+
+export default {
+  name: 'DeviceStatusPanel',
+  components: {
+    DeviceDonutChart
+  },
+  data() {
+    return {
+      activeIndex: 0,
+      timer: null,
+      
+      // 测试数据与 Tab 配置
+      tabs: [
+        { name: '信号机', data: { online: 980, total: 1000 } },
+        { name: '检测器', data: { online: 450, total: 500 } },
+        { name: '相机',   data: { online: 1100, total: 1200 } }
+      ]
+    };
+  },
+  computed: {
+    // 获取当前激活 Tab 的数据
+    currentData() {
+      return this.tabs[this.activeIndex].data;
+    }
+  },
+  mounted() {
+    this.startAutoSwitch();
+  },
+  beforeDestroy() {
+    this.stopAutoSwitch();
+  },
+  methods: {
+    // 开启自动切换
+    startAutoSwitch() {
+      this.timer = setInterval(() => {
+        this.activeIndex = (this.activeIndex + 1) % this.tabs.length;
+      }, 2000); // 2秒切换一次
+    },
+    
+    // 停止自动切换
+    stopAutoSwitch() {
+      if (this.timer) {
+        clearInterval(this.timer);
+        this.timer = null;
+      }
+    },
+
+    // 用户手动点击 Tab 时
+    handleManualSwitch(index) {
+      if (this.activeIndex === index) return;
+      
+      this.activeIndex = index;
+      
+      // 核心体验优化:手动点击后,重新计算 2 秒倒计时,防止刚点完就跳走
+      this.stopAutoSwitch();
+      this.startAutoSwitch();
+    }
+  }
+};
+</script>
+
+<style scoped>
+.device-status-panel {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+}
+
+/* ================= Tab 栏样式 ================= */
+.tab-bar {
+  display: flex;
+  height: 26px;
+  flex-shrink: 0;
+  border: 1px solid rgba(100, 150, 255, 0.2);
+  border-radius: 2px;
+  margin-bottom: 10px;
+}
+
+.tab-item {
+  flex: 1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #8b92a5;
+  font-size: 14px;
+  cursor: pointer;
+  border-right: 1px solid rgba(100, 150, 255, 0.2);
+  background: rgba(20, 30, 60, 0.4);
+  transition: all 0.3s;
+}
+.tab-item:last-child {
+  border-right: none;
+}
+
+/* 选中态样式 (还原图中带一点渐变的蓝色高亮) */
+.tab-item.is-active {
+  color: #ffffff;
+  font-weight: bold;
+  background: linear-gradient(to bottom, rgba(51, 153, 255, 0.4) 0%, rgba(51, 153, 255, 0.1) 100%);
+  border: 1px solid rgba(51, 153, 255, 0.5); /* 覆盖原本的边框让其发光 */
+  margin: -1px; /* 抵消 border 增加的 1px,防止挤压 */
+  z-index: 1;
+}
+
+/* ================= 底部图表区 ================= */
+.panel-body {
+  flex: 1;
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  display: flex;
+}
+</style>

src/components/IntersectionMap.vue → src/components/ui/IntersectionMap.vue


+ 352 - 0
src/components/ui/IntersectionMapVideos.vue

@@ -0,0 +1,352 @@
+<template>
+  <div class="map-wrapper" ref="wrapper">
+    <div class="konva-container" ref="konvaContainer"></div>
+
+    <div class="corner-videos-overlay" v-if="hasAnyVideo" :style="{ width: stageWidth + 'px', height: stageHeight + 'px' }">
+      
+      <div v-if="videoUrls.nw" class="video-corner top-left">
+        <video :src="videoUrls.nw" autoplay loop muted class="corner-video"></video>
+      </div>
+      
+      <div v-if="videoUrls.ne" class="video-corner top-right">
+        <video :src="videoUrls.ne" autoplay loop muted class="corner-video"></video>
+      </div>
+
+      <div v-if="videoUrls.sw" class="video-corner bottom-left">
+        <video :src="videoUrls.sw" autoplay loop muted class="corner-video"></video>
+      </div>
+
+      <div v-if="videoUrls.se" class="video-corner bottom-right">
+        <video :src="videoUrls.se" autoplay loop muted class="corner-video"></video>
+      </div>
+
+    </div>
+  </div>
+</template>
+
+<script>
+import Konva from 'konva';
+
+export default {
+  name: 'IntersectionMapVideos',
+  props: {
+    // 1. 路口数字孪生数据
+    mapData: {
+      type: Object,
+      required: true
+    },
+    // 2. 【新增】:四路视频地址配置
+    videoUrls: {
+      type: Object,
+      default: () => ({
+        nw: '', ne: '', sw: '', se: ''
+      })
+    }
+  },
+  data() {
+    return {
+      stage: null,
+      layer: null,
+      armsNodes: {}, 
+      panelNodes: {}, 
+      resizeObserver: null, 
+      C: {
+        BG: '#212842', ROAD: '#3d3938', YELLOW: '#D9A73D', WHITE: '#E0E0E0',
+        SIGNAL_RED: '#FF5252', SIGNAL_GREEN: '#8DF582',
+        PANEL_BG: 'rgba(30, 30, 40, 0.85)', BLUE: '#448AFF'
+      },
+      sizeConfig: {
+        stageSize: 900, 
+        laneWidth: 40,
+        halfRoad: 160,
+        roadWidth: 320,
+        armLength: 350
+      },
+      stageWidth: 900,  // 当前画布缩放后的真实宽度
+      stageHeight: 900, // 当前画布缩放后的真实高度
+    };
+  },
+  computed: {
+    // 判断是否传入了至少一个视频,如果没有,直接不渲染遮罩层提升性能
+    hasAnyVideo() {
+      return this.videoUrls && (this.videoUrls.nw || this.videoUrls.ne || this.videoUrls.sw || this.videoUrls.se);
+    }
+  },
+  mounted() {
+    this.initKonvaStage();
+    if (this.mapData && Object.keys(this.mapData).length > 0) {
+      this.renderStaticConfig();
+      this.updateDynamicSignals();
+    }
+    this.initResizeObserver();
+  },
+  beforeDestroy() {
+    if (this.resizeObserver) this.resizeObserver.disconnect();
+    if (this.stage) this.stage.destroy();
+  },
+  watch: {
+    mapData: {
+      handler(newData, oldData) {
+        if (!newData) return;
+        if (!oldData || JSON.stringify(newData.armsConfig) !== JSON.stringify(oldData.armsConfig)) {
+          this.renderStaticConfig();
+        }
+        this.updateDynamicSignals();
+      },
+      deep: true
+    }
+  },
+  methods: {
+    // ================= 以下为原有的 Konva 绘制逻辑,完全保持不变 =================
+    initKonvaStage() {
+      const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
+      const center = stageSize / 2;
+
+      this.stage = new Konva.Stage({
+        container: this.$refs.konvaContainer,
+        width: stageSize,
+        height: stageSize
+      });
+      this.layer = new Konva.Layer();
+      this.stage.add(this.layer);
+
+      this.layer.add(new Konva.Rect({ width: stageSize, height: stageSize, fill: this.C.BG }));
+      this.layer.add(new Konva.Rect({ x: center - halfRoad, y: center - halfRoad, width: roadWidth, height: roadWidth, fill: this.C.ROAD }));
+
+      this.armsNodes = {
+        N: this.createRoadArm(center, center - halfRoad, 0),
+        E: this.createRoadArm(center + halfRoad, center, 90),
+        S: this.createRoadArm(center, center + halfRoad, 180),
+        W: this.createRoadArm(center - halfRoad, center, 270)
+      };
+      Object.values(this.armsNodes).forEach(arm => this.layer.add(arm));
+
+      this.createCenterPanel(center);
+      this.layer.draw();
+    },
+
+    initResizeObserver() {
+      this.resizeObserver = new ResizeObserver(() => {
+        window.requestAnimationFrame(() => {
+          this.handleResize();
+        });
+      });
+      if (this.$refs.wrapper) {
+        this.resizeObserver.observe(this.$refs.wrapper);
+      }
+    },
+
+    handleResize() {
+      if (!this.stage || !this.$refs.wrapper) return;
+      const containerWidth = this.$refs.wrapper.clientWidth;
+      const containerHeight = this.$refs.wrapper.clientHeight;
+      if (containerWidth === 0 || containerHeight === 0) return;
+
+      const scaleX = containerWidth / this.sizeConfig.stageSize;
+      const scaleY = containerHeight / this.sizeConfig.stageSize;
+      const scale = Math.min(scaleX, scaleY);
+
+      // 【核心修改】:记录缩放后的实际物理尺寸,供视频遮罩层使用
+      this.stageWidth = this.sizeConfig.stageSize * scale;
+      this.stageHeight = this.sizeConfig.stageSize * scale;
+
+      this.stage.width(this.stageWidth);
+      this.stage.height(this.stageHeight);
+      this.stage.scale({ x: scale, y: scale });
+    },
+
+    createRoadArm(x, y, rotation) {
+      const { halfRoad, roadWidth, laneWidth } = this.sizeConfig;
+      const group = new Konva.Group({ x, y, rotation });
+      
+      group.add(new Konva.Rect({ x: -halfRoad, y: -350, width: roadWidth, height: 350, fill: this.C.ROAD }));
+      group.add(new Konva.Line({ points: [0, -350, 0, -35], stroke: this.C.YELLOW, strokeWidth: 3 }));
+      group.add(new Konva.Path({ data: `M -160 -350 L -160 -30 Q -160 0 -180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
+      group.add(new Konva.Path({ data: `M 160 -350 L 160 -30 Q 160 0 180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
+      group.add(new Konva.Line({ points: [-160, -35, 0, -35], stroke: this.C.WHITE, strokeWidth: 4 }));
+      
+      for (let i = 1; i < 4; i++) {
+        let ox = i * laneWidth;
+        group.add(new Konva.Line({ points: [-ox, -35, -ox, -120], stroke: this.C.WHITE, strokeWidth: 2 }));
+        group.add(new Konva.Line({ points: [-ox, -120, -ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
+        group.add(new Konva.Line({ points: [ox, -35, ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
+      }
+      
+      const lightGroup = new Konva.Group();
+      const rectOpts = { y: -16, width: 8, height: 24, cornerRadius: 2, offsetX: 4, offsetY: 12 };
+      for (let lx = -148; lx <= -20; lx += 16) lightGroup.add(new Konva.Rect({ x: lx, ...rectOpts }));
+      for (let rx = 20; rx <= 148; rx += 16) lightGroup.add(new Konva.Rect({ x: rx, ...rectOpts }));
+      group.add(lightGroup);
+      group.lightGroup = lightGroup;
+
+      group.arrowNodes = { 0: null, 1: null, 2: null, 3: null };
+      group.cameraNode = null;
+
+      return group;
+    },
+
+    createCenterPanel(center) {
+      const panelGroup = new Konva.Group({ x: center - 80, y: center - 45 });
+      panelGroup.add(new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 }));
+      
+      const labelFont = { fontSize: 18, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE }; 
+      const valueFont = { fontSize: 28, fontFamily: 'monospace', fontStyle: 'bold' };              
+      
+      this.panelNodes.nsLabel = new Konva.Text({ ...labelFont, x: 15, y: 22, text: '相位-:' });
+      this.panelNodes.nsVal = new Konva.Text({ ...valueFont, x: 90, y: 15, text: '--', fill: this.C.SIGNAL_GREEN });
+      
+      this.panelNodes.ewLabel = new Konva.Text({ ...labelFont, x: 15, y: 55, text: '相位-:' });
+      this.panelNodes.ewVal = new Konva.Text({ ...valueFont, x: 90, y: 48, text: '--', fill: this.C.SIGNAL_GREEN });
+      
+      panelGroup.add(this.panelNodes.nsLabel, this.panelNodes.nsVal, this.panelNodes.ewLabel, this.panelNodes.ewVal);
+      this.layer.add(panelGroup);
+    },
+
+    createArrowIcon(type, x, y, color = this.C.WHITE) {
+      const group = new Konva.Group({ x, y, scaleX: 0.65, scaleY: 0.65 });
+      group.add(new Konva.Circle({ x: 0, y: -35, radius: 3, fill: color, name: 'colorFill' }));
+      let pathData = '';
+      if (type === 'S') pathData = 'M 0 -35 L 0 0 M -7 -10 L 0 0 L 7 -10';
+      else if (type === 'L') pathData = 'M 0 -35 L 0 -15 Q 0 0 15 0 M 5 -7 L 15 0 L 5 7';
+      else if (type === 'R') pathData = 'M 0 -35 L 0 -15 Q 0 0 -15 0 M -5 -7 L -15 0 L -5 7';
+      else if (type === 'U') pathData = 'M 0 -35 L 0 -15 Q 0 0 14 0 Q 28 0 28 -15 L 28 -25 M 21 -18 L 28 -25 L 35 -18';
+      group.add(new Konva.Path({ data: pathData, stroke: color, strokeWidth: 3, lineCap: 'round', lineJoin: 'round', name: 'colorStroke' }));
+      return group;
+    },
+
+    createCameraIcon(type, x, y) {
+      const group = new Konva.Group({ x, y });
+      if (type === 1) { 
+        group.add(new Konva.Line({ points: [-16, -30, 16, -30], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
+        group.add(new Konva.Line({ points: [0, -30, 0, -10], stroke: this.C.BLUE, strokeWidth: 2.5 }));
+        const body = new Konva.Group({ y: -10, rotation: 15 });
+        body.add(new Konva.Rect({ x: -16, y: -8, width: 32, height: 16, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 2 }));
+        body.add(new Konva.Rect({ x: 16, y: -4, width: 6, height: 8, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 1 }));
+        group.add(body);
+      } else if (type === 2) { 
+        group.add(new Konva.Rect({ x: -14, y: -24, width: 28, height: 24, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 6 }));
+        group.add(new Konva.Circle({ x: 0, y: -12, radius: 5, stroke: this.C.BLUE, strokeWidth: 2.5 }));
+        group.add(new Konva.Line({ points: [-10, 0, 10, 0], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
+        group.add(new Konva.Line({ points: [0, 0, 0, 6], stroke: this.C.BLUE, strokeWidth: 2.5 }));
+      }
+      return group;
+    },
+
+    renderStaticConfig() {
+      const config = this.mapData.armsConfig;
+      if (!config) return;
+
+      Object.keys(config).forEach(dir => {
+        const armData = config[dir];
+        const armNode = this.armsNodes[dir];
+
+        if (armNode.cameraNode) armNode.cameraNode.destroy();
+        if (armData.cameraType > 0) {
+          const cam = this.createCameraIcon(armData.cameraType, -80, -190);
+          armNode.add(cam);
+          armNode.cameraNode = cam;
+        }
+
+        armData.lanes.forEach((type, index) => {
+          if (armNode.arrowNodes[index]) armNode.arrowNodes[index].destroy();
+          if (type) {
+            const lx = -20 - (index * this.sizeConfig.laneWidth);
+            const arrow = this.createArrowIcon(type, lx, -80, this.C.WHITE);
+            armNode.add(arrow);
+            armNode.arrowNodes[index] = arrow;
+          }
+        });
+      });
+      this.layer.draw();
+    },
+
+    updateDynamicSignals() {
+      const signals = this.mapData.signals;
+      if (!signals) return;
+
+      const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+      const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+
+      const dyeArm = (armNode, color) => {
+        armNode.lightGroup.getChildren().forEach(r => r.fill(color));
+        Object.values(armNode.arrowNodes).forEach(arr => {
+          if (arr) {
+            arr.findOne('.colorFill').fill(color);
+            arr.findOne('.colorStroke').stroke(color);
+          }
+        });
+      };
+
+      dyeArm(this.armsNodes.N, nsColor);
+      dyeArm(this.armsNodes.S, nsColor);
+      dyeArm(this.armsNodes.E, ewColor);
+      dyeArm(this.armsNodes.W, ewColor);
+
+      this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
+      this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
+      
+      this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
+      this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
+
+      this.layer.draw();
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* ================= 地图外层 ================= */
+.map-wrapper {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  background-color: #212842;
+  position: relative; /* 核心:让子元素能在其内部绝对定位 */
+}
+
+.konva-container {
+  position: absolute; 
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 1; /* 图层垫底 */
+}
+
+/* ================= 视频遮罩与挂件 ================= */
+.corner-videos-overlay {
+  position: absolute;
+  /* 【核心修改】:和 Canvas 一样,使用绝对居中对齐 */
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 10;
+  pointer-events: none; 
+}
+
+.video-corner {
+  position: absolute;
+  /* 【核心修改】:(900-320)/2 / 900 = 32.222% */
+  width: 32.222%; 
+  height: 32.222%; 
+  background: #000;
+  pointer-events: auto; 
+  
+  /* 加上极细的边框,配合内减盒模型,防止尺寸撑大导致错位 */
+  box-sizing: border-box;
+  border: 1px solid rgba(68, 138, 255, 0.4);
+  overflow: hidden;
+}
+
+/* 【核心修改】:去掉所有 margin 和 top/left 间距,严丝合缝贴死四个角 */
+.top-left { top: 0; left: 0; }
+.top-right { top: 0; right: 0; }
+.bottom-left { bottom: 0; left: 0; }
+.bottom-right { bottom: 0; right: 0; }
+
+.corner-video {
+  width: 100%;
+  height: 100%;
+  object-fit: cover; 
+  display: block;
+}
+</style>

+ 136 - 0
src/components/ui/MapLegend.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="map-legend">
+    <div class="legend-header">图例</div>
+    
+    <div class="legend-list">
+      <div 
+        class="legend-item" 
+        v-for="(item, index) in legendData" 
+        :key="index"
+      >
+        <div class="legend-icon" :style="{ backgroundColor: item.color }">
+          <span v-if="item.type === 'text'">{{ item.char }}</span>
+          
+          <div v-else-if="item.type === 'signal'" class="css-signal-icon">
+            <i></i><i></i><i></i>
+          </div>
+        </div>
+        
+        <span class="legend-label">{{ item.label }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'MapLegend',
+  data() {
+    return {
+      // 完全按照图片内容配置的数据字典
+      legendData: [
+        { type: 'text', char: '中', label: '中心计划', color: '#3b5cff' },
+        { type: 'text', char: '干', label: '干线协调', color: '#2ecc71' },
+        { type: 'text', char: '勤', label: '勤务路线', color: '#e74c3c' },
+        { type: 'text', char: '定', label: '定周期控制', color: '#3498db' },
+        { type: 'text', char: '感', label: '感应控制', color: '#e67e22' },
+        { type: 'text', char: '自', label: '自适应控制', color: '#9b59b6' },
+        { type: 'text', char: '手', label: '手动控制', color: '#f39c12' },
+        { type: 'text', char: '特', label: '特殊控制', color: '#a0522d' }, // 棕色
+        { type: 'signal', label: '离线', color: '#5b5c60' }, // 灰色
+        { type: 'signal', label: '降级', color: '#f1c40f' }, // 黄色
+        { type: 'signal', label: '故障', color: '#e74c3c' }  // 红色
+      ]
+    };
+  }
+};
+</script>
+
+<style scoped>
+/* ================== 容器主面板 ================== */
+.map-legend {
+  width: 160px;
+  background: rgba(16, 28, 56, 0.85); /* 深蓝色半透明背景 */
+  backdrop-filter: blur(10px);        /* 毛玻璃模糊效果 */
+  -webkit-backdrop-filter: blur(10px);
+  border: 1px solid rgba(100, 150, 255, 0.2); /* 淡淡的科技感边框 */
+  border-radius: 10px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); /* 底部阴影让它浮起来 */
+  overflow: hidden;
+  user-select: none;
+}
+
+/* ================== 头部标题 ================== */
+.legend-header {
+  color: #ffffff;
+  font-size: 15px;
+  font-weight: bold;
+  padding: 12px 16px;
+  background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), transparent);
+  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+  letter-spacing: 1px;
+}
+
+/* ================== 列表区域 ================== */
+.legend-list {
+  padding: 12px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px; /* 控制每一行之间的间距 */
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+}
+
+/* ================== 左侧图标统一底座 ================== */
+.legend-icon {
+  width: 22px;
+  height: 22px;
+  border-radius: 50%; /* 正圆形 */
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-right: 12px; /* 和右侧文字的间距 */
+  box-shadow: 0 0 6px rgba(0, 0, 0, 0.3) inset; /* 内阴影增加立体感 */
+}
+
+/* 单字图标样式 */
+.legend-icon span {
+  color: #ffffff;
+  font-size: 12px;
+  font-weight: bold;
+  transform: scale(0.9); /* 汉字稍微缩放一点更好看 */
+}
+
+/* ================== 纯 CSS 手绘信号灯 ================== */
+.css-signal-icon {
+  width: 8px;
+  height: 14px;
+  border: 1px solid rgba(255, 255, 255, 0.9);
+  border-radius: 3px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-evenly;
+  align-items: center;
+  box-sizing: border-box;
+}
+
+/* 信号灯里面的三个小圆点 */
+.css-signal-icon i {
+  display: block;
+  width: 2px;
+  height: 2px;
+  background-color: #ffffff;
+  border-radius: 50%;
+}
+
+/* ================== 右侧文字标签 ================== */
+.legend-label {
+  /* 提取图片中极其特殊的淡薄荷绿文字颜色 */
+  color: #8ce6cc; 
+  font-size: 14px;
+  letter-spacing: 1px;
+}
+</style>

+ 154 - 0
src/components/ui/MenuItem.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="menu-item-wrapper">
+    <div 
+      class="menu-row" 
+      :class="{
+        'is-root': level === 0,
+        'is-sub': level > 0,
+        'is-leaf': !hasChildren
+      }"
+      :style="{ paddingLeft: level * 20 + 20 + 'px' }"
+      @click="handleClick"
+    >
+      <i v-if="node.icon" :class="node.icon" class="node-icon"></i>
+      
+      <span class="node-label">{{ node.label }}</span>
+      
+      <span 
+        v-if="hasChildren" 
+        class="arrow-icon" 
+        :class="{ 'is-open': isOpen }"
+      >
+        ^
+      </span>
+    </div>
+
+    <div class="menu-children" v-show="isOpen" v-if="hasChildren">
+      <MenuItem 
+        v-for="child in node.children" 
+        :key="child.id" 
+        :node="child" 
+        :level="level + 1"
+        @node-click="passEventUp" 
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  // 组件必须有 name 才能进行递归调用!
+  name: 'MenuItem', 
+  props: {
+    node: {
+      type: Object,
+      required: true
+    },
+    // 当前节点的层级,默认从 0 开始
+    level: {
+      type: Number,
+      default: 0
+    }
+  },
+  data() {
+    return {
+      // 默认展开所有节点(你可以根据需求改为 false)
+      isOpen: true 
+    };
+  },
+  computed: {
+    // 判断是否有子节点
+    hasChildren() {
+      return this.node.children && this.node.children.length > 0;
+    }
+  },
+  methods: {
+    handleClick() {
+      if (this.hasChildren) {
+        // 如果有子节点,点击则切换展开/折叠状态
+        this.isOpen = !this.isOpen;
+      } else {
+        // 如果是叶子节点(最底层路口),触发点击事件,并把当前节点数据传出去
+        this.$emit('node-click', this.node);
+      }
+    },
+    // 递归组件极其关键的一步:子组件触发了事件,父组件要继续往上抛,直到最外层
+    passEventUp(nodeData) {
+      this.$emit('node-click', nodeData);
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* 基础行样式 */
+.menu-row {
+  display: flex;
+  align-items: center;
+  height: 44px;
+  font-size: 14px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  user-select: none;
+}
+
+/* ================= 核心颜色配置区 ================= */
+
+/* 1. 主控中心标题 (顶级菜单 level: 0) 的背景颜色 */
+.menu-row.is-root {
+  background-color: #112445; /* 偏亮的深蓝色背景 */
+  color: #ffffff;            /* 白色字体 */
+  font-weight: bold;         /* 标题可以稍微加粗 */
+}
+
+/* 2. 其他子菜单 (level > 0) 的背景颜色 */
+.menu-row.is-sub {
+  background-color: #0b1a37; /* 更深的暗色背景 */
+  color: #ffffff ;            /* 浅灰色字体 */
+}
+
+/* 3. 最后一级菜单 (叶子节点) 的特殊样式 */
+.menu-row.is-leaf {
+  color: #6b7280; /* 深灰色字体 (覆盖掉上面的浅灰色) */
+}
+
+/* ================================================== */
+
+/* 统一的悬浮效果 */
+.menu-row:hover {
+  background-color: rgba(0, 229, 255, 0.1); /* 鼠标放上去给一点科技蓝的反馈 */
+  color: #00e5ff; /* 悬浮时文字变亮蓝 */
+}
+
+/* 最后一级菜单悬浮时,字体也要变亮,否则看不清 */
+.menu-row.is-leaf:hover {
+  color: #ffffff; 
+}
+
+.node-icon {
+  margin-right: 8px;
+  font-size: 16px;
+}
+
+.node-label {
+  flex: 1; /* 占据剩余空间,把箭头挤到最右边 */
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* 右侧箭头样式与动画 */
+.arrow-icon {
+  margin-right: 20px;
+  font-size: 12px;
+  color: #909399;
+  /* 默认箭头朝下 (通过旋转实现) */
+  transform: rotate(180deg);
+  transition: transform 0.3s ease;
+}
+
+/* 展开状态时,箭头朝上 */
+.arrow-icon.is-open {
+  transform: rotate(0deg);
+}
+</style>

+ 123 - 0
src/components/ui/SecurityRoutePanel.vue

@@ -0,0 +1,123 @@
+<template>
+  <div class="security-route-panel">
+    <div class="secrity-route-list">
+        <div class="secrity-route-item" v-for="(route, index) in 3" :key="index">
+            <div class="route-video">
+                <video class="responsive-video" src="@/assets/videos/video1.mp4" autoplay loop muted></video>
+            </div>
+            <div class="route-monitoring">
+                <div class="route-name">靖远路与北公路交叉口</div>
+                <div class="monitoring-status">
+                    <div class="map-container">
+                        <IntersectionMap 
+                            :mapData="intersectionData" 
+                            :videoUrls="{
+                                nw: require('@/assets/videos/video1.mp4'), // 左上
+                                ne: require('@/assets/videos/video2.mp4'), // 右上
+                                sw: require('@/assets/videos/video2.mp4'), // 左下
+                                se: require('@/assets/videos/video1.mp4')  // 右下
+                            }" 
+                        />
+                    </div>
+                    <div class="status-info">
+                        <span>等级: 一级</span>
+                        <span>方式: 快进</span>
+                        <span>时间: 30s</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import IntersectionMap from '@/components/ui/IntersectionMapVideos.vue';
+import { getIntersectionData } from '@/mock/data';
+
+export default {
+  name: 'SecurityRoutePanel',
+  components: {
+    IntersectionMap,
+  },
+  data() {
+    return {
+      intersectionData: {}, // 获取交叉口数据
+      
+      
+    };
+  },
+  computed: {
+    
+  },
+  async created() {
+},
+async mounted() {
+      this.intersectionData = await getIntersectionData(); // 获取交叉口数据
+      console.log(this.intersectionData);
+    
+  },
+  beforeDestroy() {
+    
+  },
+  methods: {
+    
+  }
+};
+</script>
+
+<style scoped>
+.device-status-panel {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+}
+.secrity-route-list {
+    display: flex;
+    gap: 10px;
+}
+.secrity-route-item {
+    flex: 1;
+}
+.route-video {
+  width: 160px;
+  /* height: auto; */
+  overflow: hidden;
+  background: #000;
+  flex-shrink: 0;
+}
+
+.responsive-video {
+  width: 100%;
+  height: 100%;
+  display: block;
+  object-fit: cover; 
+}
+
+.route-name {
+    color: #fff;
+    font-size: 12px;
+    padding: 4px 0;
+    margin-top: 10px;
+}
+
+.monitoring-status {
+    display: flex;
+    gap: 5px;
+}
+
+.map-container {
+    width: 100px;
+    height: 100px;
+    flex-shrink: 0;
+}
+.status-info {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    font-size: 12px;
+    color: #55aa55;
+}
+</style>

+ 104 - 0
src/components/ui/SidebarMenu.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="sidebar-container">
+    <MenuItem 
+      v-for="item in menuData" 
+      :key="item.id" 
+      :node="item" 
+      @node-click="handleLeafNodeClick"
+    />
+  </div>
+</template>
+
+<script>
+// 引入刚刚写的递归组件
+import MenuItem from './MenuItem.vue';
+
+export default {
+  name: 'SidebarMenu',
+  components: {
+    MenuItem
+  },
+  data() {
+    return {
+      // 模拟后端返回的树形结构数据
+      menuData: [
+        {
+          id: 'root-1',
+          label: '主控中心',
+          icon: 'el-icon-monitor', // 这里可以替换为你项目用的图标类名,比如 iconfont
+          children: [
+            {
+              id: 'team-1',
+              label: '北京市交警总队',
+              children: [
+                {
+                  id: 'dist-1',
+                  label: '昌平区',
+                  children: [
+                    {
+                      id: 'street-1',
+                      label: '龙泽园街道',
+                      children: [
+                        { id: 'node-101', label: '文华路口', lng: 116.3, lat: 40.1 },
+                        { id: 'node-102', label: '同城街路口', lng: 116.4, lat: 40.2 }
+                      ]
+                    }
+                  ]
+                },
+                {
+                  id: 'dist-2',
+                  label: '丰台区',
+                  children: [
+                    {
+                      id: 'street-2',
+                      label: '龙泽园街道', // 图里似乎数据有重复,这里照猫画虎
+                      children: [
+                        { id: 'node-201', label: '文华路口' },
+                        { id: 'node-202', label: '同城街路口' }
+                      ]
+                    }
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    };
+  },
+  methods: {
+    // 捕获最底层路口的点击事件
+    handleLeafNodeClick(nodeData) {
+      console.log('用户点击了最底层路口:', nodeData);
+      this.$emit('leaf-node-click', nodeData); // 将事件和数据传递给父组件
+      // 在大屏项目中,通常下一步是:
+      // 1. 触发全局状态管理 (Vuex/Pinia) 记录当前选中的路口 ID
+      // 2. 命令地图组件平移/放大到该路口的经纬度 (nodeData.lng, nodeData.lat)
+      // 3. 通知右侧图表组件重新拉取该路口的数据
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* 整个侧边栏的深色背景 */
+.sidebar-container {
+  width: 100%;
+  height: 100%;
+  background-color: #112445; /* 匹配截图中的深海蓝色 */
+  overflow-y: auto;
+  overflow-x: hidden;
+}
+
+/* 自定义滚动条样式,适配暗黑风 */
+.sidebar-container::-webkit-scrollbar {
+  width: 6px;
+}
+.sidebar-container::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 3px;
+}
+.sidebar-container::-webkit-scrollbar-track {
+  background: transparent;
+}
+</style>

+ 356 - 0
src/components/ui/SmartDialog.vue

@@ -0,0 +1,356 @@
+<template>
+  <div v-show="visible" class="smart-dialog" :style="dialogStyle" @mousedown="bringToFront">
+    <div class="dialog-header" :class="{ 'is-draggable': draggable }" @mousedown="startDrag" v-if="title">
+      <div class="title-content">
+        <slot name="header">
+          <span class="title">{{ title }}</span>
+        </slot>
+      </div>
+      <span v-if="showClose" class="close-btn" @click.stop="close">✕</span>
+    </div>
+    <div v-else class="dialog-header not-title" :class="{ 'is-draggable': draggable }" @mousedown="startDrag"></div>
+
+    <div v-if="title && false" class="dialog-divider"></div>
+
+    <div class="dialog-body" :class="{ 'no-padding': noPadding }">
+      <slot></slot>
+    </div>
+
+    <div v-if="resizable" class="resize-handle" @mousedown.prevent="startResize"></div>
+  </div>
+</template>
+
+<script>
+// 1. 【核心修改】:删除原本引入的 pageScale
+// import { pageScale } from '@/utils/rem.js';
+
+let globalZIndex = 2000;
+
+// 2. 【核心修改】:在这里定义你的大屏设计稿基准宽度 (通常是 1920)
+const DESIGN_WIDTH = 1920; 
+
+export default {
+  name: 'SmartDialog',
+  props: {
+    id: { type: [String, Number], required: true },
+    visible: { type: Boolean, default: false },
+    title: { type: String, default: '提示' },
+    
+    center: { type: Boolean, default: true },
+    position: { type: Object, default: () => ({ x: 0, y: 0 }) }, 
+    
+    showClose: { type: Boolean, default: true },
+    draggable: { type: Boolean, default: true }, 
+    resizable: { type: Boolean, default: false }, 
+    noPadding: { type: Boolean, default: false }, 
+
+    defaultWidth: { type: [Number, String], default: 350 },
+    defaultHeight: { type: [Number, String], default: 250 },
+    minWidth: { type: Number, default: 200 },
+    minHeight: { type: Number, default: 150 }
+  },
+  data() {
+    return {
+      x: 0,
+      y: 0,
+      w: 0,
+      h: 0,
+      currentScale: 1, 
+      zIndex: globalZIndex,
+      isDragging: false,
+      isResizing: false,
+      dragOffset: { x: 0, y: 0 },
+      resizeStart: { x: 0, y: 0, w: 0, h: 0 }
+    };
+  },
+  computed: {
+    dialogStyle() {
+      return {
+        left: `${this.x}px`,
+        top: `${this.y}px`,
+        width: `${this.w}px`,
+        height: `${this.h}px`,
+        zIndex: this.zIndex
+      };
+    }
+  },
+  created() {
+    // 初始化时获取真实的缩放比例
+    this.currentScale = this.getRealScale();
+    this.w = this._parseSize(this.defaultWidth, window.innerWidth);
+    this.h = this._parseSize(this.defaultHeight, window.innerHeight);
+  },
+  mounted() {
+    window.addEventListener('resize', this._onWindowResize);
+    if (this.visible) {
+      this.bringToFront();
+      this.calculatePosition();
+    }
+  },
+  watch: {
+    visible(newVal) {
+      if (newVal) {
+        this.bringToFront();
+        this.calculatePosition();
+      }
+    },
+    position: {
+      deep: true,
+      handler() {
+        if (this.visible && !this.center) {
+          this.calculatePosition();
+        }
+      }
+    }
+  },
+  methods: {
+    // 3. 【新增方法】:弹窗自己实时计算当前屏幕相当于设计稿的缩放比例
+    getRealScale() {
+      return window.innerWidth / DESIGN_WIDTH;
+    },
+
+    _parseSize(value, base) {
+      if (typeof value === 'string' && value.endsWith('%')) {
+        return Math.round((parseFloat(value) / 100) * base);
+      }
+      // 4. 【核心修改】:使用内部实时计算的 scale,绝不出错
+      const scale = this.getRealScale();
+      return Number(value) * scale;
+    },
+    
+    _onWindowResize() {
+      setTimeout(() => {
+        // 重新获取当前最新比例
+        const newScale = this.getRealScale();
+        const scaleRatio = newScale / this.currentScale;
+        
+        // 重新按比例计算宽高
+        this.w = this._parseSize(this.defaultWidth, window.innerWidth);
+        this.h = this._parseSize(this.defaultHeight, window.innerHeight);
+
+        if (this.visible) {
+          // 保持相对位置等比缩放
+          this.x = this.x * scaleRatio;
+          this.y = this.y * scaleRatio;
+
+          if (this.x + this.w > window.innerWidth) this.x = window.innerWidth - this.w;
+          if (this.y + this.h > window.innerHeight) this.y = window.innerHeight - this.h;
+          if (this.x < 0) this.x = 0;
+          if (this.y < 0) this.y = 0;
+        }
+        
+        this.currentScale = newScale;
+      }, 50); 
+    },
+    
+    close() {
+      this.$emit('update:visible', false);
+      this.$emit('close');
+    },
+    
+    bringToFront() {
+      globalZIndex++;
+      this.zIndex = globalZIndex;
+    },
+    
+    calculatePosition() {
+      this.$nextTick(() => {
+        const winWidth = window.innerWidth;
+        const winHeight = window.innerHeight;
+        const scale = this.getRealScale();
+        
+        let targetX = 0;
+        let targetY = 0;
+
+        if (this.center) {
+          targetX = Math.max(0, (winWidth - this.w) / 2);
+          targetY = Math.max(0, (winHeight - this.h) / 2);
+        } else {
+          targetX = (this.position.x || 0) * scale;
+          targetY = (this.position.y || 0) * scale;
+        }
+
+        const offsetStep = 20 * scale; 
+        let collision = true;
+        let attempts = 0; 
+
+        const existingDialogs = document.querySelectorAll('.smart-dialog');
+
+        while (collision && attempts < 15) {
+          collision = false;
+          for (let i = 0; i < existingDialogs.length; i++) {
+            const el = existingDialogs[i];
+            if (el === this.$el || el.style.display === 'none') continue; 
+
+            const rect = el.getBoundingClientRect();
+            if (Math.abs(rect.left - targetX) < 2 && Math.abs(rect.top - targetY) < 2) {
+              collision = true;
+              break; 
+            }
+          }
+          if (collision) {
+            targetX += offsetStep;
+            targetY += offsetStep;
+            attempts++;
+          }
+        }
+
+        if (targetX + this.w > winWidth) targetX = winWidth - this.w - 10;
+        if (targetY + this.h > winHeight) targetY = winHeight - this.h - 10;
+
+        this.x = Math.max(0, targetX);
+        this.y = Math.max(0, targetY);
+      });
+    },
+
+    startDrag(e) {
+      if (!this.draggable || e.target.classList.contains('close-btn')) return;
+      this.isDragging = true;
+      this.dragOffset.x = e.clientX - this.x;
+      this.dragOffset.y = e.clientY - this.y;
+      document.addEventListener('mousemove', this.onDrag);
+      document.addEventListener('mouseup', this.stopDrag);
+    },
+    onDrag(e) {
+      if (!this.isDragging) return;
+      let newX = e.clientX - this.dragOffset.x;
+      let newY = e.clientY - this.dragOffset.y;
+      
+      const scale = this.getRealScale();
+      const safeHeaderHeight = 40 * scale;
+      
+      this.x = Math.max(0, Math.min(newX, window.innerWidth - this.w));
+      this.y = Math.max(0, Math.min(newY, window.innerHeight - safeHeaderHeight)); 
+    },
+    stopDrag() {
+      this.isDragging = false;
+      document.removeEventListener('mousemove', this.onDrag);
+      document.removeEventListener('mouseup', this.stopDrag);
+    },
+
+    startResize(e) {
+      if (!this.resizable) return;
+      this.isResizing = true;
+      this.resizeStart = { x: e.clientX, y: e.clientY, w: this.w, h: this.h };
+      document.addEventListener('mousemove', this.onResize);
+      document.addEventListener('mouseup', this.stopResize);
+    },
+    onResize(e) {
+      if (!this.isResizing) return;
+      const deltaX = e.clientX - this.resizeStart.x;
+      const deltaY = e.clientY - this.resizeStart.y;
+      
+      const scale = this.getRealScale();
+      const currentMinWidth = this.minWidth * scale;
+      const currentMinHeight = this.minHeight * scale;
+      
+      this.w = Math.max(currentMinWidth, this.resizeStart.w + deltaX);
+      this.h = Math.max(currentMinHeight, this.resizeStart.h + deltaY);
+    },
+    stopResize() {
+      this.isResizing = false;
+      document.removeEventListener('mousemove', this.onResize);
+      document.removeEventListener('mouseup', this.stopResize);
+    }
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this._onWindowResize);
+    document.removeEventListener('mousemove', this.onDrag);
+    document.removeEventListener('mouseup', this.stopDrag);
+    document.removeEventListener('mousemove', this.onResize);
+    document.removeEventListener('mouseup', this.stopResize);
+  }
+};
+</script>
+
+<style scoped>
+/* =========== CSS 保持不变 =========== */
+.smart-dialog {
+  position: fixed;
+  background: radial-gradient(circle at 20% 0%, rgba(40,120,200,0.2) 0%, rgba(20,60,130,0.4) 70%);
+  box-shadow: inset 0px 0px 0.625rem 0px rgba(88, 146, 255, 0.4), inset 1.25rem 0px 1.875rem -0.625rem rgba(88, 146, 255, 0.15);
+  border: 1px solid rgba(255, 255, 255, 0.15);
+  border-radius: 12px;
+  backdrop-filter: blur(8px);
+  -webkit-backdrop-filter: blur(8px);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  user-select: none;
+}
+
+.dialog-header {
+  height: auto;
+  background: transparent;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 10px 0px 10px;
+}
+
+.dialog-header.not-title {
+  height: 10px;
+  padding: 0;
+}
+
+.dialog-header.is-draggable {
+  cursor: move;
+}
+
+.title-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.title {
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 600;
+  letter-spacing: 1px;
+}
+
+.close-btn {
+  cursor: pointer;
+  color: #ffffff;
+  font-size: 16px;
+  line-height: 1;
+  font-weight: 300;
+  opacity: 0.8;
+  transition: all 0.2s;
+}
+
+.close-btn:hover {
+  opacity: 1;
+  transform: scale(1.1);
+}
+
+.dialog-divider {
+  height: 1px;
+  background-color: rgba(255, 255, 255, 0.3);
+  margin: 0 20px;
+}
+
+.dialog-body {
+  flex: 1;
+  min-height: 0;
+  padding: 20px;
+  overflow: hidden;
+}
+
+.dialog-body.no-padding {
+  padding: 0;
+  color: #e2e8f0;
+  overflow: hidden;
+  cursor: default;
+}
+
+.resize-handle {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  width: 16px;
+  height: 16px;
+  cursor: se-resize;
+  background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.2) 50%);
+}
+</style>

+ 82 - 100
src/components/TrafficTimeSpace.vue

@@ -5,60 +5,22 @@
 <script>
 import * as echarts from 'echarts';
 
+// 定义大屏设计稿基准宽度 (假设为 1920)
+const DESIGN_WIDTH = 1920;
+
 export default {
   name: 'TrafficTimeSpace',
   props: {
-    // 路口名称数组(从起点到终点)
-    intersections: {
-      type: Array,
-      required: true
-    },
-    // 各路口到起点的距离(米),与 intersections 一一对应
-    distances: {
-      type: Array,
-      required: true
-    },
-    // 绿波带数据: [{ yBottom, yTop, xBL, xBR, xTL, xTR, label, direction }]
-    // direction: 'up' | 'down'
-    waveData: {
-      type: Array,
-      default: () => []
-    },
-    // 绿灯数据: [{ y, start, end }]
-    greenData: {
-      type: Array,
-      default: () => []
-    },
-    // 可视窗口显示的时间跨度(秒)
-    viewWindow: {
-      type: Number,
-      default: 350
-    },
-    // 是否启用自动滚动
-    autoScroll: {
-      type: Boolean,
-      default: false
-    },
-    // 自动滚动速度(秒/帧)
-    scrollSpeed: {
-      type: Number,
-      default: 0.5
-    },
-    // 上行波带颜色
-    upWaveColor: {
-      type: String,
-      default: 'rgba(46, 196, 182, 0.45)'
-    },
-    // 下行波带颜色
-    downWaveColor: {
-      type: String,
-      default: 'rgba(104, 231, 95, 0.4)'
-    },
-    // 波带文字颜色
-    waveLabelColor: {
-      type: String,
-      default: '#e0f7fa'
-    }
+    intersections: { type: Array, required: true },
+    distances: { type: Array, required: true },
+    waveData: { type: Array, default: () => [] },
+    greenData: { type: Array, default: () => [] },
+    viewWindow: { type: Number, default: 350 },
+    autoScroll: { type: Boolean, default: false },
+    scrollSpeed: { type: Number, default: 0.5 },
+    upWaveColor: { type: String, default: 'rgba(46, 196, 182, 0.45)' },
+    downWaveColor: { type: String, default: 'rgba(104, 231, 95, 0.4)' },
+    waveLabelColor: { type: String, default: '#e0f7fa' }
   },
   data() {
     return {
@@ -68,35 +30,19 @@ export default {
     };
   },
   computed: {
-    maxDistance() {
-      return Math.max(...this.distances);
-    },
+    maxDistance() { return Math.max(...this.distances); },
     maxDataTime() {
       let max = 0;
-      this.waveData.forEach(w => {
-        max = Math.max(max, w.xBR, w.xTR);
-      });
-      this.greenData.forEach(g => {
-        max = Math.max(max, g.end);
-      });
+      this.waveData.forEach(w => max = Math.max(max, w.xBR, w.xTR));
+      this.greenData.forEach(g => max = Math.max(max, g.end));
       return max;
     },
-    // 转为 ECharts custom series 所需的数组格式
     echartsWaveData() {
-      return this.waveData.map(w => [
-        w.yBottom, w.yTop, w.xBL, w.xBR, w.xTL, w.xTR, w.label || '', w.direction || 'up'
-      ]);
+      return this.waveData.map(w => [w.yBottom, w.yTop, w.xBL, w.xBR, w.xTL, w.xTR, w.label || '', w.direction || 'up']);
     },
-    echartsGreenData() {
-      return this.greenData.map(g => [g.y, g.start, g.end]);
-    },
-    echartsRedData() {
-      return this.distances.map(y => [y]);
-    },
-    // 路口名称需要反转以匹配 Y 轴方向
-    reversedIntersections() {
-      return [...this.intersections].reverse();
-    }
+    echartsGreenData() { return this.greenData.map(g => [g.y, g.start, g.end]); },
+    echartsRedData() { return this.distances.map(y => [y]); },
+    reversedIntersections() { return [...this.intersections].reverse(); }
   },
   mounted() {
     this.$nextTick(() => {
@@ -119,17 +65,27 @@ export default {
   watch: {
     waveData() { this.updateChart(); },
     greenData() { this.updateChart(); },
-    autoScroll(val) {
-      val ? this.startScroll() : this.stopScroll();
-    }
+    autoScroll(val) { val ? this.startScroll() : this.stopScroll(); }
   },
   methods: {
+    // 【新增】:获取真实缩放比例
+    getRealScale() {
+      return window.innerWidth / DESIGN_WIDTH;
+    },
+
     initChart() {
       this.chart = echarts.init(this.$refs.chartContainer);
-      this._resizeHandler = () => this.chart && this.chart.resize();
+      
+      // 窗口变化时的防抖处理
+      this._resizeHandler = () => {
+        if (this.chart) {
+          this.chart.resize();
+          this.updateChart(); // 【关键】:尺寸变了,需要重新生成 option 刷新字体和线条粗细!
+        }
+      };
       window.addEventListener('resize', this._resizeHandler);
 
-      // 监听容器尺寸变化(弹窗拉伸、延迟渲染等场景)
+      // 容器变化时的处理 (拖拽拉伸弹窗)
       if (typeof ResizeObserver !== 'undefined') {
         this._roaPending = false;
         this._resizeObserver = new ResizeObserver(() => {
@@ -137,7 +93,10 @@ export default {
             this._roaPending = true;
             requestAnimationFrame(() => {
               this._roaPending = false;
-              this.chart && this.chart.resize();
+              if (this.chart) {
+                this.chart.resize();
+                this.updateChart(); // 【关键】:同样需要重新渲染内部配置
+              }
             });
           }
         });
@@ -154,17 +113,30 @@ export default {
       const distances = this.distances;
       const intersections = this.reversedIntersections;
       const maxDist = this.maxDistance;
+      
+      // 拿到当前缩放比例
+      const scale = this.getRealScale();
 
       this.chart.setOption({
         backgroundColor: 'transparent',
         animation: false,
         tooltip: { show: false },
-        grid: { left: 110, right: 30, top: 40, bottom: 40 },
+        // 网格的边距也乘一下,防止屏幕缩小时文字被切掉
+        grid: { 
+          left: Math.round(90 * scale), 
+          right: Math.round(15 * scale), 
+          top: Math.round(10 * scale), 
+          bottom: Math.round(30 * scale) 
+        },
         xAxis: {
           type: 'value',
           min: this.currentViewTime,
           max: this.currentViewTime + this.viewWindow,
-          axisLabel: { color: '#7b95b9', formatter: '{value}s', fontSize: 13 },
+          axisLabel: { 
+            color: '#7b95b9', 
+            formatter: '{value}s', 
+            fontSize: Math.round(10 * scale) // 【修复】坐标轴字体随比例缩放
+          },
           splitLine: { show: true, lineStyle: { color: '#1a305d', type: 'solid' } },
           axisLine: { lineStyle: { color: '#31548e' } }
         },
@@ -177,7 +149,7 @@ export default {
             interval: 0,
             color: '#9cb1d4',
             fontWeight: 'bold',
-            fontSize: 13,
+            fontSize: Math.round(10 * scale), // 【修复】路口名字体随比例缩放
             formatter: value => distances.includes(value) ? intersections[distances.indexOf(value)] : ''
           },
           splitLine: { show: true, lineStyle: { color: '#1a305d' } }
@@ -185,27 +157,21 @@ export default {
         series: [
           {
             type: 'custom',
-            renderItem: function (params, api) {
-              return self.renderWave(params, api);
-            },
+            renderItem: function (params, api) { return self.renderWave(params, api, scale); },
             data: this.echartsWaveData,
             clip: true,
             z: 1
           },
           {
             type: 'custom',
-            renderItem: function (params, api) {
-              return self.renderRedBackground(params, api);
-            },
+            renderItem: function (params, api) { return self.renderRedBackground(params, api, scale); },
             data: this.echartsRedData,
             clip: true,
             z: 2
           },
           {
             type: 'custom',
-            renderItem: function (params, api) {
-              return self.renderGreenLight(params, api);
-            },
+            renderItem: function (params, api) { return self.renderGreenLight(params, api, scale); },
             data: this.echartsGreenData,
             clip: true,
             z: 3
@@ -214,29 +180,38 @@ export default {
       });
     },
 
-    renderRedBackground(params, api) {
+    // 注意:这里的 renderItem 都把 scale 参数传进去了
+    renderRedBackground(params, api, scale) {
       const y = api.value(0);
       const startX = api.coord([0, y])[0];
       const endX = api.coord([this.maxDataTime, y])[0];
+      // 高度和 Y轴偏移量 都要乘上 scale
+      const rectHeight = Math.round(6 * scale);
+      const rectOffsetY = Math.round(3 * scale);
+      
       return {
         type: 'rect',
-        shape: { x: startX, y: api.coord([0, y])[1] - 3, width: endX - startX, height: 6 },
+        shape: { x: startX, y: api.coord([0, y])[1] - rectOffsetY, width: endX - startX, height: rectHeight },
         style: { fill: '#f02828' }
       };
     },
 
-    renderGreenLight(params, api) {
+    renderGreenLight(params, api, scale) {
       const y = api.value(0);
       const p1 = api.coord([api.value(1), y]);
       const p2 = api.coord([api.value(2), y]);
+      // 高度和 Y轴偏移量 都要乘上 scale
+      const rectHeight = Math.round(8 * scale);
+      const rectOffsetY = Math.round(4 * scale);
+
       return {
         type: 'rect',
-        shape: { x: p1[0], y: p1[1] - 4, width: p2[0] - p1[0], height: 8 },
+        shape: { x: p1[0], y: p1[1] - rectOffsetY, width: p2[0] - p1[0], height: rectHeight },
         style: api.style({ fill: '#68e75f' })
       };
     },
 
-    renderWave(params, api) {
+    renderWave(params, api, scale) {
       const yBottom = api.value(0), yTop = api.value(1);
       const xBL = api.value(2), xBR = api.value(3), xTL = api.value(4), xTR = api.value(5);
       const text = api.value(6), dir = api.value(7);
@@ -246,6 +221,9 @@ export default {
       const angle = -Math.atan2(ptTL[1] - ptBL[1], ptTL[0] - ptBL[0]);
       const fillColor = dir === 'up' ? this.upWaveColor : this.downWaveColor;
 
+      // 动态字体大小
+      const fontSize = Math.round(12 * scale);
+
       return {
         type: 'group',
         children: [
@@ -264,7 +242,7 @@ export default {
             style: {
               text: text,
               fill: this.waveLabelColor,
-              font: 'bold 15px sans-serif',
+              font: `bold ${fontSize}px sans-serif`, // 【修复】绿波带上的说明文字按比例缩放
               textAlign: 'center',
               textVerticalAlign: 'middle'
             }
@@ -281,6 +259,7 @@ export default {
           this.currentViewTime = 0;
         }
         if (this.chart) {
+          // 这里仅仅更新 X 轴视图,非常轻量
           this.chart.setOption({
             xAxis: { min: this.currentViewTime, max: this.currentViewTime + this.viewWindow }
           });
@@ -296,7 +275,10 @@ export default {
     },
 
     resize() {
-      if (this.chart) this.chart.resize();
+      if (this.chart) {
+        this.chart.resize();
+        this.updateChart(); // 对外暴露的 resize 方法也加上重新渲染配置
+      }
     }
   }
 };
@@ -307,4 +289,4 @@ export default {
   width: 100%;
   height: 100%;
 }
-</style>
+</style>

+ 55 - 0
src/mixins/echartsResize.js

@@ -0,0 +1,55 @@
+import { pageScale } from '@/utils/rem.js';
+
+// 【神仙工具函数】专门解决 ECharts 内部字号、线宽在大屏上太小的问题
+export function px2echarts(px) {
+  return px * pageScale; 
+}
+
+export default {
+  data() {
+    return {
+      $_chart: null,
+      $_resizeHandler: null
+    };
+  },
+  mounted() {
+    this.$_initResizeEvent();
+  },
+  beforeDestroy() {
+    this.$_destroyResizeEvent();
+    if (this.$_chart) {
+      this.$_chart.dispose();
+      this.$_chart = null;
+    }
+  },
+  methods: {
+    $_initResizeEvent() {
+      // 防抖
+      const debounce = (fn, delay) => {
+        let timer = null;
+        return function () {
+          if (timer) clearTimeout(timer);
+          timer = setTimeout(() => { fn.apply(this, arguments); }, delay);
+        };
+      };
+
+      this.$_resizeHandler = debounce(() => {
+        if (this.$_chart) {
+          // 容器尺寸改变时,强制 ECharts 重绘
+          this.$_chart.resize();
+          
+          // 如果需要内部字体也跟着变,可以重新 setOption(可选)
+          // 很多时候单纯的 resize 就足够了,具体看业务需求
+        }
+      }, 100);
+
+      window.addEventListener('resize', this.$_resizeHandler);
+    },
+    $_destroyResizeEvent() {
+      if (this.$_resizeHandler) {
+        window.removeEventListener('resize', this.$_resizeHandler);
+        this.$_resizeHandler = null;
+      }
+    }
+  }
+};

+ 3 - 1
src/router/index.js

@@ -6,6 +6,7 @@ import Home from "@/views/Home.vue";
 import Main from "@/views/Main.vue";
 import TransitionPage from "@/views/TransitionPage.vue";
 import MainWatch from "@/views/MainWatch.vue"; 
+import StatusMonitoring from "@/views/StatusMonitoring.vue";
 
 Vue.use(Router);
 
@@ -17,6 +18,7 @@ export default new Router({
     { path: "/main", component: Main },
     { path: "/transition", component: TransitionPage },
     { path: "/home", component: Home },
-    { path: "/main-watch", component: MainWatch }
+    { path: "/main-watch", component: MainWatch },
+    { path: "/main-surve", component: StatusMonitoring }
   ]
 });

+ 134 - 0
src/styles/global.css

@@ -0,0 +1,134 @@
+/* --- 基础与图层 --- */
+.fluid-dashboard {
+  width: 100vw;
+  height: 100vh;
+  position: relative;
+  overflow: hidden;
+  background: #050a17;
+}
+
+.map-layer {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1;
+}
+
+.ui-layer {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 2;
+  pointer-events: none;
+  /* 让鼠标穿透点到底图 */
+  display: grid;
+  grid-template-rows: 80px 1fr;
+  /* 顶部 80px,下面全部给 main */
+}
+
+/* 恢复 UI 层交互 */
+.top-header,
+.left-sidebar,
+.right-sidebar,
+.float-video-panel,
+.float-alarm-popup,
+.float-bottom-dock {
+  pointer-events: auto;
+}
+
+/* --- 顶部 Header --- */
+.top-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 40px;
+  background: url('@/assets/header_deco1.png') no-repeat center top;
+  /* 替换为你的顶部切图 */
+  background-size: 100% 100%;
+  position: relative;
+}
+
+.center-title {
+  font-size: 36px;
+  color: #fff;
+  font-weight: bold;
+  letter-spacing: 4px;
+}
+
+.top-header .right-wrap {
+  position: absolute;
+  right: 50px;
+  bottom: 0;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 10px;
+}
+
+.right-wrap .weather {
+  font-size: 18px;
+  color: #ffffff;
+}
+
+.right-wrap .temperature {
+  font-size: 14px;
+  color: #ababab;
+}
+
+.right-wrap .time {
+  font-size: 18px;
+  color: #ffffff;
+}
+
+.right-wrap .date {
+  font-size: 14px;
+  color: #ababab;
+  display: flex;
+  flex-direction: column;
+}
+
+
+/* --- 主体布局 --- */
+.main-layout {
+  display: grid;
+  grid-template-columns: 320px 1fr 480px;
+  /* 左宽320,中间自适应,右宽480 */
+  gap: 20px;
+  padding: 20px;
+  height: 100%;
+  box-sizing: border-box;
+}
+
+.top-center-controls {
+  position: absolute;
+  top: 80px;
+  left: 50%;
+  transform: translateX(-50%); 
+  pointer-events: auto;
+}
+
+/* 核心定位:将 Dock 导航栏绝对定位在屏幕最底部居中 */
+.float-bottom-dock {
+  position: absolute;
+  bottom: 20px; /* 距离底部的间距 */
+  left: 50%;
+  transform: translateX(-50%); /* 保证完美水平居中 */
+  z-index: 999; /* 保证层级最高,不会被图表或地图挡住点不到 */
+}
+
+/* --- 左侧与右侧边栏 --- */
+.left-sidebar {
+  height: 100%;
+}
+
+.right-sidebar {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  height: 100%;
+}
+

+ 24 - 0
src/utils/rem.js

@@ -0,0 +1,24 @@
+// 基准大小:设定在 1920 屏幕下,1rem = 16px
+const baseSize = 16;
+// 设计稿宽度
+const designWidth = 1920;
+
+function setRem() {
+  // 获取当前屏幕的可视宽度
+  const clientWidth = document.documentElement.clientWidth;
+  
+  // 计算缩放比例 (当前屏幕宽度 / 设计稿宽度)
+  let scale = clientWidth / designWidth;
+  
+  // 设置页面根节点字体大小
+  // Math.min(scale, 2) 是为了防止在超大带鱼屏上字体被放大得过于离谱,设置一个上限
+  document.documentElement.style.fontSize = (baseSize * Math.min(scale, 2)) + 'px';
+}
+
+// 1. 初始化调用
+setRem();
+
+// 2. 改变窗口大小时重新设置 rem
+window.addEventListener('resize', () => {
+  setRem();
+});

+ 58 - 0
src/utils/scale.js

@@ -0,0 +1,58 @@
+// 记录防抖定时器和绑定的函数,方便销毁时解绑
+let resizeTimer = null;
+let boundResizeFn = null;
+
+export function initScale(options) {
+  const {
+    wrapperId = 'screen-wrapper',
+    designWidth = 1920,
+    designHeight = 1080
+  } = options;
+
+  const wrapper = document.getElementById(wrapperId);
+  if (!wrapper) {
+    console.error(`未找到ID为 ${wrapperId} 的大屏容器`);
+    return;
+  }
+
+  // 核心计算逻辑
+  const calcAndApplyScale = () => {
+    const clientWidth = window.innerWidth;
+    const clientHeight = window.innerHeight;
+
+    const scaleX = clientWidth / designWidth;
+    const scaleY = clientHeight / designHeight;
+
+    // 取最小比例,保证等比缩放且全部可见
+    const scale = Math.min(scaleX, scaleY);
+
+    // 应用缩放,配合 CSS 的绝对定位居中
+    wrapper.style.transform = `scale(${scale}) translate(-50%, -50%)`;
+  };
+
+  // 防抖处理:避免窗口拖拽时频繁触发重绘引发卡顿
+  boundResizeFn = () => {
+    if (resizeTimer) clearTimeout(resizeTimer);
+    resizeTimer = setTimeout(() => {
+      calcAndApplyScale();
+    }, 100);
+  };
+
+  // 1. 初始化执行一次
+  calcAndApplyScale();
+
+  // 2. 挂载监听
+  window.addEventListener('resize', boundResizeFn);
+}
+
+// 销毁函数:在 Vue 组件销毁前调用,防止内存泄漏
+export function destroyScale() {
+  if (boundResizeFn) {
+    window.removeEventListener('resize', boundResizeFn);
+    boundResizeFn = null;
+  }
+  if (resizeTimer) {
+    clearTimeout(resizeTimer);
+    resizeTimer = null;
+  }
+}

+ 1 - 1
src/views/Main.vue

@@ -60,7 +60,7 @@ export default {
         { key: "watch", label: "状态监控", img: "main-watch.png", route: { path: "/main-watch", query: { panel: "watch" } } },
         { key: "security", label: "特勤安保", img: "main-security.png", route: { path: "/home", query: { panel: "security" } } },
         { key: "coor", label: "干线协调", img: "main-coor.png", route: { path: "/home", query: { panel: "coor" } } },
-        { key: "surve", label: "状态监控", img: "main-surve.png", route: { path: "/home", query: { panel: "surve" } } },
+        { key: "surve", label: "状态监控", img: "main-surve.png", route: { path: "/main-surve", query: { panel: "surve" } } },
         { key: "setting", label: "系统设置", img: "main-setting.png", route: { path: "/home", query: { panel: "setting" } } },
       ],
 

+ 1 - 1
src/views/MainWatch.vue

@@ -222,7 +222,7 @@
 <script>
 import MenuItem from '@/components/MenuItem.vue';
 import SmartDialog from '@/components/SmartDialog.vue';
-import TrafficTimeSpace from '@/components/TrafficTimeSpace.vue';
+import TrafficTimeSpace from '@/components/ui/TrafficTimeSpace.vue';
 import IntersectionSignalMonitoring from '@/components/IntersectionSignalMonitoring.vue';
 import SegmentedRadio from '@/components/SegmentedRadio.vue';
 import DropdownSelect from '@/components/DropdownSelect.vue';

+ 260 - 0
src/views/StatusMonitoring.vue

@@ -0,0 +1,260 @@
+<template>
+    <div class="fluid-dashboard">
+        <div id="map-container" class="map-layer"></div>
+
+        <div class="ui-layer">
+
+            <header class="top-header">
+                <div class="right-wrap">
+                    <span class="weather">{{ weather }}</span>
+                    <span class="temperature">{{ temperature }}</span>
+                    <span class="time">{{ time }}</span>
+                    <div class="date">
+                        <span>{{ week }}</span>
+                        <span>{{ date }}</span>
+                    </div>
+                </div>
+            </header>
+
+            <main class="main-layout">
+                <!-- 左侧边栏 -->
+                <aside class="left-sidebar">
+                    <div>组织机构</div>
+                    <SidebarMenu @leaf-node-click="handleLeafNodeClick" />
+                </aside>
+
+                <section class="center-area">
+                    <div class="top-center-controls">
+                        <ButtonGroup @change="handleModeChange" />
+                    </div>
+
+                    <div class="float-bottom-dock">
+                        <BottomDock @change="handleDockChange" />
+                    </div>
+
+                </section>
+
+                <aside class="right-sidebar">
+                    <MapLegend style="position: absolute; right: 20px; bottom: 80px; z-index: 100;" />
+                </aside>
+
+            </main>
+            
+        </div>
+        <SmartDialog v-for="dialog in activeDialogs" :key="dialog.id" 
+            :id="dialog.id"
+            :visible.sync="dialog.visible" 
+            :title="dialog.title" 
+            :defaultWidth="dialog.width || 400"
+            :defaultHeight="dialog.height || 300" 
+            :center="dialog.center !== false" 
+            :position="dialog.position"
+            :showClose="dialog.showClose"
+            @close="handleDialogClose(dialog.id)">
+
+            <component :is="dialog.componentName" v-bind="dialog.data"></component>
+        </SmartDialog>
+        <AlarmPopup 
+            style="position: absolute; bottom: 30%; right: 30%;"
+            title="降级黄闪"
+            intersection="长安街与王府井路口"
+            time="2026.03.14 10:30"
+        />
+    </div>
+</template>
+
+<script>
+import '@/styles/global.css';
+import '@/utils/rem.js';
+
+import SidebarMenu from '@/components/ui/SidebarMenu.vue';
+import ButtonGroup from '@/components/ui/ButtonGroup.vue';
+import BottomDock from '@/components/ui/BottomDock.vue';
+import SmartDialog from '@/components/ui/SmartDialog.vue';
+import AlarmPopup from '@/components/ui/AlarmPopup.vue';
+import DeviceStatusPanel from '@/components/ui/DeviceStatusPanel.vue';
+import SecurityRoutePanel from '@/components/ui/SecurityRoutePanel.vue';
+import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
+import TrafficTimeSpace from '@/components/ui/TrafficTimeSpace.vue';
+import MapLegend from '@/components/ui/MapLegend.vue';
+import { getIntersectionData, makeTrafficTimeSpaceData } from '@/mock/data';
+
+export default {
+    name: 'StatusMonitoring',
+    components: {
+        SidebarMenu,
+        ButtonGroup,
+        BottomDock,
+        SmartDialog,
+        AlarmPopup,
+        DeviceStatusPanel,
+        SecurityRoutePanel,
+        IntersectionMapVideos,
+        TrafficTimeSpace,
+        MapLegend,
+    },
+    data() {
+        return {
+            weather: '☀️ 晴',
+            temperature: '32/17℃',
+            time: '10:30:05',
+            week: '周五',
+            date: '2023.08.10',
+            currentModule: '干线协调',
+            activeDialogs: [],
+        };
+    },
+    methods: {
+        handleModeChange(val) {
+            console.log('当前切换到了模式:', val);
+
+        },
+        handleDockChange(item) {
+            console.log('父组件接收到了 Dock 切换事件:', item.label);
+            this.currentModule = item.label;
+
+            // 在这里执行你具体的业务逻辑联动
+            if (item.label === '首页') {
+                // 重置地图视角
+            } else if (item.label === '特勤安保') {
+                // 画出安保路线,弹出视频监控框
+            } else if (item.label === '系统设置') {
+                // 弹出一个全屏的设置弹窗
+            }
+        },
+        handleLeafNodeClick(nodeData) {
+            console.log('父组件接收到了最底层路口点击事件:', nodeData);
+            // 这里可以根据 nodeData 的经纬度来控制地图组件的视角
+            this.testOpenDeviceStatus();
+            this.testOpenSecurityRoute();
+            this.testOpenSecurityRoute2();
+            this.testOpenTrafficTimeSpace();
+        },
+        openDialog(config) {
+            // 1. 防止重复打开同一个弹窗 (根据 id 判断)
+            const existingDialog = this.activeDialogs.find(d => d.id === config.id);
+
+            if (existingDialog) {
+                // 如果已经存在,只是将其设为可见 (SmartDialog 内部会自动把它 bringToFront 置顶)
+                existingDialog.visible = true;
+                return;
+            }
+
+            // 2. 如果不存在,则 push 一个新的弹窗对象进去
+            this.activeDialogs.push({
+                id: config.id,                     // 唯一标识 (例如路口ID 'node-101')
+                title: config.title,               // 弹窗左上角标题
+                componentName: config.component,   // 要加载的内部组件名
+                visible: true,                     // 默认可见
+                width: config.width || 450,        // 自定义宽度
+                height: config.height || 300,      // 自定义高度
+                center: config.center !== false,   // 是否居中显示
+                position: config.position || null, // 自定义坐标 {x, y}
+                showClose: config.showClose !== false, // 是否显示关闭按钮
+                data: config.data || {}            // 传给内部组件的业务数据
+            });
+        },
+
+        /**
+         * 关闭弹窗的回调
+         */
+        handleDialogClose(dialogId) {
+            // 性能优化:当用户点击 ✕ 关闭弹窗时,将其从数组中彻底移除,销毁内部组件释放内存
+            this.activeDialogs = this.activeDialogs.filter(d => d.id !== dialogId);
+        },
+
+        // ================= 测试用例:模拟各种点击行为 =================
+
+        // 模拟 1:打开设备状态面板
+        testOpenDeviceStatus() {
+            this.openDialog({
+                id: 'device-status-node-101', // 这里的 ID 可以根据实际业务场景动态生成,例如 'node-101' 代表某个路口
+                title: '',
+                component: 'DeviceStatusPanel', // 对应 components 里注册的名字
+                width: 300,
+                height: 200,
+                center: false,
+                position: { x: 400, y: 200 }, // 直接指定坐标,SmartDialog 内部会自动转换成 left/top
+                showClose: false, // 是否显示关闭按钮
+            });
+
+            this.openDialog({
+                id: 'device-status-node-102', // 这里的 ID 可以根据实际业务场景动态生成,例如 'node-101' 代表某个路口
+                title: '',
+                component: 'DeviceStatusPanel', // 对应 components 里注册的名字
+                width: 300,
+                height: 200,
+                center: false,
+                position: { x: 1600, y: 100 }, // 直接指定坐标,SmartDialog 内部会自动转换成 left/top
+                showClose: false, // 是否显示关闭按钮
+            });
+        },
+
+        // 模拟 2:打开特勤安保路线面板
+        testOpenSecurityRoute() {
+            this.openDialog({
+                id: 'dev-security-route', // 这里的 ID 可以根据实际业务场景动态生成,例如 'dev-security-route' 代表特勤安保路线弹窗
+                title: '特勤安保路线 未开始 一级',
+                component: 'SecurityRoutePanel',
+                width: 540,
+                height: 300,
+                center: false,
+                position: { x: 400, y: 450 },
+            });
+        },
+
+        // 模拟 3:打开本地协调控制面板
+        testOpenSecurityRoute2() {
+            const dialogId = 'dev-security-route2';
+            this.openDialog({
+                id: dialogId,
+                title: '长安街-府右街口 本地协调控制',
+                component: 'IntersectionMapVideos',
+                width: 300,
+                height: 200,
+                center: false,
+                position: { x: 1100, y: 200 },
+                data: {
+                    mapData: {},
+                    intersectionName: '长安街-府右街口',
+                    videos: [
+                        { id: 'cam-1', name: '信号机视频', url: 'https://example.com/video1' },
+                        { id: 'cam-2', name: '路口全景', url: 'https://example.com/video2' },
+                        { id: 'cam-3', name: '人行横道', url: 'https://example.com/video3' },
+                    ]
+                }
+            });
+
+            // 异步获取数据后更新弹窗
+            getIntersectionData().then(mapData => {
+                const dialog = this.activeDialogs.find(d => d.id === dialogId);
+                if (dialog) {
+                    this.$set(dialog.data, 'mapData', mapData);
+                }
+            });
+        },
+
+        // 模拟 4:打开新干线协调控制面板
+        testOpenTrafficTimeSpace() {
+            const tsData = makeTrafficTimeSpaceData();
+            this.openDialog({
+                id: 'dev-traffic-time-space',
+                title: '新干线协调控制 早高峰',
+                component: 'TrafficTimeSpace',
+                width: 300,
+                height: 300,
+                center: false,
+                position: { x: 1400, y: 500 },
+                data: {
+                    intersections: tsData.intersections,
+                    distances: tsData.distances,
+                    waveData: tsData.waveData,
+                    greenData: tsData.greenData,
+                }
+            });
+        }
+    }
+}
+</script>
+
+<style scoped></style>