Explorar el Código

Merge branch 'master' of http://121.40.40.223:3000/zizhong.wang/dtScreen

hebotao hace 4 semanas
padre
commit
4cec0de19f
Se han modificado 32 ficheros con 3169 adiciones y 3350 borrados
  1. 31 0
      extract_status_coords.js
  2. BIN
      src/assets/images/icon_straight_down.png
  3. BIN
      src/assets/images/icon_straight_left.png
  4. BIN
      src/assets/images/icon_straight_right.png
  5. BIN
      src/assets/images/icon_straight_up.png
  6. BIN
      src/assets/images/icon_turn_down_left.png
  7. BIN
      src/assets/images/icon_turn_down_left_uturn.png
  8. BIN
      src/assets/images/icon_turn_left_down.png
  9. BIN
      src/assets/images/icon_turn_left_down_uturn.png
  10. BIN
      src/assets/images/icon_turn_right_up.png
  11. BIN
      src/assets/images/icon_turn_right_up_uturn.png
  12. BIN
      src/assets/images/icon_turn_up_left.png
  13. BIN
      src/assets/images/icon_turn_up_left_uturn.png
  14. 2 2
      src/components/IntersectionSignalMonitoring.vue
  15. 0 338
      src/components/SignalTimingChart.vue
  16. 616 186
      src/components/TongzhouTrafficMap.vue
  17. 285 123
      src/components/ui/BottomDock.vue
  18. 69 0
      src/components/ui/CrossingDetailHeader.vue
  19. 24 14
      src/components/ui/CrossingDetailPanel.vue
  20. 2 4
      src/components/ui/CrossingListPanel.vue
  21. 38 6
      src/components/ui/CrossingMultiView.vue
  22. 28 7
      src/components/ui/MenuItem.vue
  23. 199 135
      src/components/ui/SignalTimingChart.vue
  24. 16 6
      src/components/ui/SmartDialog.vue
  25. 34 1
      src/components/ui/TaskMonitorHeader.vue
  26. 8 8
      src/components/ui/TechTable.vue
  27. 3 1
      src/layouts/DashboardLayout.vue
  28. 96 32
      src/mock/api.js
  29. 17 27
      src/mock/data.js
  30. 1479 2345
      src/mock/mock_data.json
  31. 3 3
      src/views/Main.vue
  32. 219 112
      src/views/StatusMonitoring.vue

+ 31 - 0
extract_status_coords.js

@@ -0,0 +1,31 @@
+const fs = require('fs');
+
+// 读取 mock 数据文件
+const data = JSON.parse(fs.readFileSync('src/mock/map_data_gaode.json', 'utf8'));
+const totalData = data.length;
+
+// 计算 chunkSize
+const normalStatusCount = 6;
+const abnormalStatusCount = 3;
+const chunkSize = Math.floor(totalData / (normalStatusCount + abnormalStatusCount));
+const maxAbnormalCount = 10;
+
+// 提取不同状态的坐标点
+const offlineData = data.slice(chunkSize * 6, Math.min(chunkSize * 7, chunkSize * 6 + maxAbnormalCount));
+const degradedData = data.slice(chunkSize * 7, Math.min(chunkSize * 8, chunkSize * 7 + maxAbnormalCount));
+const faultData = data.slice(chunkSize * 8, Math.min(chunkSize * 9, chunkSize * 8 + maxAbnormalCount));
+
+// 转换为坐标格式
+const offlineCoords = offlineData.map(item => [item['位置-经度'], item['位置-纬度']]);
+const degradedCoords = degradedData.map(item => [item['位置-经度'], item['位置-纬度']]);
+const faultCoords = faultData.map(item => [item['位置-经度'], item['位置-纬度']]);
+
+// 输出结果
+console.log('总数据量:', totalData);
+console.log('chunkSize:', chunkSize);
+console.log('\n离线状态坐标点:');
+console.log(JSON.stringify(offlineCoords, null, 2));
+console.log('\n降级状态坐标点:');
+console.log(JSON.stringify(degradedCoords, null, 2));
+console.log('\n故障状态坐标点:');
+console.log(JSON.stringify(faultCoords, null, 2));

BIN
src/assets/images/icon_straight_down.png


BIN
src/assets/images/icon_straight_left.png


BIN
src/assets/images/icon_straight_right.png


BIN
src/assets/images/icon_straight_up.png


BIN
src/assets/images/icon_turn_down_left.png


BIN
src/assets/images/icon_turn_down_left_uturn.png


BIN
src/assets/images/icon_turn_left_down.png


BIN
src/assets/images/icon_turn_left_down_uturn.png


BIN
src/assets/images/icon_turn_right_up.png


BIN
src/assets/images/icon_turn_right_up_uturn.png


BIN
src/assets/images/icon_turn_up_left.png


BIN
src/assets/images/icon_turn_up_left_uturn.png


+ 2 - 2
src/components/IntersectionSignalMonitoring.vue

@@ -20,7 +20,7 @@
 
 <script>
 
-import SignalTimingChart from '@/components/SignalTimingChart.vue';
+import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
 import IntersectionMap from '@/components/ui/IntersectionMap.vue';
 import { apiGetSignalTiming, apiGetIntersectionData } from '@/api';
 import video1 from '@/assets/videos/video1.mp4';
@@ -188,4 +188,4 @@ export default {
     flex-shrink: 0;
     overflow: hidden;
 }
-</style>
+</style>

+ 0 - 338
src/components/SignalTimingChart.vue

@@ -1,338 +0,0 @@
-<template>
-  <div class="signal-timing-widget">
-    <div class="header">
-      <div class="title-area">
-        <span class="main-title">方案状态</span>
-        <span class="sub-info" v-if="!loading">(周期: {{ cycleLength }} 相位差: 协调时间: 0)</span>
-      </div>
-      <div class="checkbox-area">
-        <div class="checkbox-mock" :class="{ 'is-checked': followPhase }" @click="followPhase = !followPhase">
-          <span v-if="followPhase" style="color: #fff; font-size: 12px; margin-left: 1px;">✓</span>
-        </div>
-        <span>跟随相位</span>
-      </div>
-    </div>
-    
-    <div v-if="loading" class="loading-overlay">
-      <span>数据加载中...</span>
-    </div>
-
-    <div v-show="!loading" ref="chartDom" class="chart-container"></div>
-  </div>
-</template>
-
-<script>
-import * as echarts from 'echarts';
-
-// 静态资源与常量
-const COLORS = {
-  GREEN_LIGHT: '#8dc453', GREEN_DARK: '#73a542', YELLOW: '#fbd249', RED: '#ff7575', STRIPE_GREEN: '#a3d76e',
-  TEXT_DARK: '#1e2638', AXIS_LINE: '#758599', DIVIDER_LINE: '#111827', MARK_BLUE: '#4da8ff', TEXT_LIGHT: '#d1d5db'
-};
-
-const stripeCanvas = document.createElement('canvas');
-stripeCanvas.width = 6; stripeCanvas.height = 20;
-const ctx = stripeCanvas.getContext('2d');
-ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 6, 20);
-ctx.fillStyle = COLORS.STRIPE_GREEN; ctx.fillRect(0, 0, 3, 20); 
-const stripePattern = { image: stripeCanvas, repeat: 'repeat' };
-
-const ICON_PATHS = {
-  UP: 'M10 2 H14 V14 H20 L12 22 L4 14 H10 Z',
-  DOWN: 'M10 22 H14 V10 H20 L12 2 L4 10 H10 Z',
-  TURN_LEFT: 'M 21 22 H 15 V 14 C 15 11 13 9 10 9 H 8 V 14 L 0 7 L 8 0 V 5 H 10 C 15 5 21 9 21 14 V 22 Z',
-  TURN_RIGHT: 'M 3 22 H 9 V 14 C 9 11 11 9 14 9 H 16 V 14 L 24 7 L 16 0 V 5 H 14 C 9 5 3 9 3 14 V 22 Z',
-  UTURN: 'M 18 22 V 10 C 18 5 15 2 12 2 C 9 2 6 5 6 10 V 14 H 0 L 8 22 L 16 14 H 10 V 10 C 10 7 11 6 12 6 C 13 6 14 7 14 10 V 22 H 18 Z'
-};
-
-export default {
-  name: 'SignalTimingChart',
-  // 核心改变:从外部接收所有数据
-  props: {
-    loading: {
-      type: Boolean,
-      default: false
-    },
-    cycleLength: {
-      type: Number,
-      default: 0
-    },
-    currentTime: {
-      type: Number,
-      default: 0
-    },
-    phaseData: {
-      type: Array,
-      default: () => []
-    }
-  },
-  data() {
-    return {
-      chartInstance: null,
-      followPhase: false,
-      scaleFactor: 1
-    };
-  },
-  mounted() {
-    this.updateScale();
-    this.initChart();
-    window.addEventListener('resize', this.handleResize);
-    this._resizePending = false;
-    this._resizeObserver = new ResizeObserver(() => {
-      if (!this._resizePending) {
-        this._resizePending = true;
-        requestAnimationFrame(() => {
-          this._resizePending = false;
-          this.handleResize();
-        });
-      }
-    });
-    if (this.$refs.chartDom) {
-      this._resizeObserver.observe(this.$refs.chartDom);
-    }
-  },
-  beforeDestroy() {
-    window.removeEventListener('resize', this.handleResize);
-    if (this._resizeObserver) {
-      this._resizeObserver.disconnect();
-    }
-    if (this.chartInstance) {
-      this.chartInstance.dispose();
-      this.chartInstance = null;
-    }
-  },
-  watch: {
-    // 监听 props 变化,一旦父组件传入新数据,立即触发 ECharts 重绘
-    currentTime() {
-      if (!this.loading && this.chartInstance) {
-        this.updateChart();
-      }
-    },
-    phaseData: {
-      deep: true,
-      handler(newVal) {
-        if (!this.loading && this.chartInstance && newVal.length > 0) {
-          this.updateChart();
-        }
-      }
-    },
-    loading(newVal) {
-      // 当 loading 结束时,销毁旧实例并重建,确保使用正确的容器尺寸
-      if (!newVal && this.phaseData.length > 0) {
-        this.$nextTick(() => {
-          setTimeout(() => {
-            if (this.chartInstance) {
-              this.chartInstance.dispose();
-              this.chartInstance = null;
-            }
-            this.initChart();
-          }, 50);
-        });
-      }
-    }
-  },
-  methods: {
-    handleResize() {
-      this.updateScale();
-      if (this.chartInstance) {
-        this.chartInstance.resize();
-        this.updateChart();
-      }
-    },
-    updateScale() {
-      const el = this.$el;
-      if (!el) return;
-      const baseWidth = 600; // 设计基准宽度
-      this.scaleFactor = Math.max(0.5, el.clientWidth / baseWidth);
-      el.style.setProperty('--s', this.scaleFactor);
-    },
-    
-    initChart() {
-      const chartDom = this.$refs.chartDom;
-      if (!chartDom) return;
-      this.chartInstance = echarts.init(chartDom);
-      // 如果初始化时就已经有数据了,直接渲染
-      if (this.phaseData.length > 0) {
-        this.updateChart();
-      }
-      // 延迟一帧 resize,确保 v-show 切换后容器有正确宽度
-      this.$nextTick(() => {
-        requestAnimationFrame(() => {
-          this.handleResize();
-        });
-      });
-    },
-    
-    updateChart() {
-      if (!this.chartInstance) return;
-      this.chartInstance.setOption(this.getChartOption(), true);
-    },
-    
-    getChartOption() {
-      const s = this.scaleFactor;
-      return {
-        backgroundColor: 'transparent',
-        grid: { left: Math.round(10 * s), right: Math.round(10 * s), top: Math.round(50 * s), bottom: Math.round(10 * s), containLabel: false },
-        xAxis: { type: 'value', min: 0, max: this.cycleLength, show: false },
-        yAxis: { type: 'category', data: ['Track 0', 'Track 1'], inverse: true, show: false },
-        series: [
-          {
-            type: 'custom',
-            renderItem: this.renderCustomItem,
-            encode: { x: [1, 2], y: 0 },
-            data: this.phaseData,
-            markLine: {
-              symbol: ['none', 'none'],
-              silent: true,
-              label: {
-                show: true,
-                position: 'start',
-                formatter: `${this.currentTime}/${this.cycleLength}`,
-                color: '#fff',
-                backgroundColor: COLORS.MARK_BLUE,
-                padding: [Math.round(4 * s), Math.round(8 * s)],
-                borderRadius: 2,
-                fontSize: Math.round(10 * s),
-                fontWeight: 'bold',
-                offset: [0, -2]
-              },
-              lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.round(2 * s) },
-              data: [ { xAxis: this.currentTime } ]
-            }
-          }
-        ]
-      };
-    },
-
-    renderCustomItem(params, api) {
-      const s = this.scaleFactor;
-      const trackIndex = api.value(0);
-      const start = api.coord([api.value(1), trackIndex]);
-      const end = api.coord([api.value(2), trackIndex]);
-
-      const blockHeight = api.size([0, 1])[1];
-      const yPos = start[1] - blockHeight / 2;
-      const blockWidth = end[0] - start[0];
-      const dividerY = (api.coord([0, 1])[1] + api.coord([0, 0])[1]) / 2;
-
-      const phaseName = api.value(3);
-      const duration = api.value(4);
-      const type = api.value(5);
-      const iconKey = api.value(6);
-
-      let fillStyle = COLORS.GREEN_LIGHT;
-      if (type === 'stripe') fillStyle = stripePattern;
-      else if (type === 'yellow') fillStyle = COLORS.YELLOW;
-      else if (type === 'red') fillStyle = COLORS.RED;
-
-      const rectShape = echarts.graphic.clipRectByRect(
-        { x: start[0], y: yPos, width: blockWidth, height: blockHeight },
-        { x: params.coordSys.x, y: params.coordSys.y, width: params.coordSys.width, height: params.coordSys.height }
-      );
-
-      if (!rectShape) return;
-      const children = [];
-
-      // A. 绘制刻度
-      if (params.dataIndex === 0) {
-        const axisBaseY = params.coordSys.y - Math.round(20 * s);
-        [0, 35, 70, 105, 140].forEach(val => {
-          const x = api.coord([val, 0])[0];
-          children.push({ type: 'line', shape: { x1: x, y1: axisBaseY - Math.round(5 * s), x2: x, y2: axisBaseY + Math.round(5 * s) }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.round(1.5 * s) } });
-        });
-        const stages = [ {n:'S1', s:0, e:35}, {n:'S2', s:35, e:70}, {n:'S3', s:70, e:105}, {n:'S4', s:105, e:140} ];
-        stages.forEach(st => {
-          const x1 = api.coord([st.s, 0])[0], x2 = api.coord([st.e, 0])[0], midX = (x1 + x2) / 2;
-          const textHalf = Math.round(14 * s);
-          children.push({ type: 'line', shape: { x1: x1, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.round(1.5 * s) } });
-          children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: x2, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.round(1.5 * s) } });
-          children.push({ type: 'text', style: { text: st.n, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: Math.round(14 * s), align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
-        });
-      }
-
-      // B. 绘制色块底色
-      children.push({ type: 'rect', shape: rectShape, style: { fill: fillStyle, stroke: 'none' } });
-
-      // C. 绘制内部元素
-      const fs = Math.max(0.8, s * 0.9); // 文字/图标用更小的缩放
-      if (type === 'green' && blockWidth > 20) {
-        const darkWidth = Math.round(25 * fs);
-        const midY = yPos + blockHeight / 2;
-
-        children.push({ type: 'rect', shape: { x: start[0], y: yPos, width: darkWidth, height: blockHeight }, style: { fill: COLORS.GREEN_DARK } });
-        const arrowH = Math.round(4 * fs);
-        children.push({ type: 'polygon', shape: { points: [ [start[0] + darkWidth, midY - arrowH], [start[0] + darkWidth, midY + arrowH], [start[0] + darkWidth + arrowH, midY] ] }, style: { fill: COLORS.GREEN_DARK } });
-
-        if (iconKey && ICON_PATHS[iconKey]) {
-          const iconSize = Math.round(14 * fs);
-          const iconX = start[0] + (darkWidth - iconSize) / 2;
-          const iconY = midY - iconSize / 2;
-
-          children.push({
-            type: 'path',
-            shape: { pathData: ICON_PATHS[iconKey], x: iconX, y: iconY, width: iconSize, height: iconSize, layout: 'center' },
-            style: { fill: COLORS.TEXT_DARK, stroke: 'none' }
-          });
-        }
-
-        // 文字不受 clipRect 裁剪,独立绘制
-        children.push({ type: 'text', style: { text: `${phaseName}\n${duration}`, x: start[0] + darkWidth + Math.round(4 * fs), y: midY, fill: COLORS.TEXT_DARK, fontSize: Math.round(12 * fs), fontFamily: 'Arial', fontWeight: 'bold', align: 'left', verticalAlign: 'middle' } });
-      }
-
-      // D. 分割线
-      if (trackIndex === 1) {
-        children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: Math.round(1.5 * s) } });
-      }
-
-      return { type: 'group', children: children };
-    }
-  }
-};
-</script>
-
-<style scoped>
-.signal-timing-widget {
-  --s: 1;
-  width: 100%;
-  height: 100%;
-  min-width: 0;
-  background-color: transparent;
-  box-sizing: border-box;
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-
-.header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: calc(var(--s) * 25px);
-  color: #e0e6f1;
-}
-
-.title-area { font-size: calc(var(--s) * 16px); }
-.main-title { font-size: calc(var(--s) * 20px); font-weight: bold; margin-right: calc(var(--s) * 10px); }
-.sub-info { font-size: calc(var(--s) * 14px); opacity: 0.8; }
-
-.checkbox-area {
-  font-size: calc(var(--s) * 14px); display: flex; align-items: center;
-  cursor: pointer; opacity: 0.7; user-select: none;
-}
-.checkbox-area:hover { opacity: 1; }
-
-.checkbox-mock {
-  width: calc(var(--s) * 14px); height: calc(var(--s) * 14px); border: 1px solid rgba(255, 255, 255, 0.5);
-  margin-right: calc(var(--s) * 6px); border-radius: 2px;
-  display: flex; align-items: center; justify-content: center;
-}
-.checkbox-mock.is-checked { background-color: #4da8ff; border-color: #4da8ff; }
-
-.chart-container { width: 100%; min-width: 0; flex: 1; min-height: 120px; overflow: hidden; }
-
-.loading-overlay {
-  flex: 1; min-height: 120px; display: flex; align-items: center; justify-content: center;
-  color: #758599; font-size: 14px;
-}
-</style>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 616 - 186
src/components/TongzhouTrafficMap.vue


+ 285 - 123
src/components/ui/BottomDock.vue

@@ -1,21 +1,40 @@
 <template>
-    <div class="dock-wrapper" 
+    <div class="dock-wrapper"
+         ref="dockWrapper"
          :class="[
-            autoHide ? 'is-auto-hide' : 'is-always-show', 
-            customClass
+            autoHide ? 'is-auto-hide' : 'is-always-show',
+            customClass,
+            { 'is-expanded': dockExpanded }
          ]"
-         :style="dockStyles">
+         :style="dockStyles"
+         @mouseleave="handleDockLeave">
          
-        <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 class="nav-arrow left-arrow" 
+            :class="{ 'is-disabled': mode === 'linear' && !canScrollLeft, 'is-active': mode === 'ellipse' || canScrollLeft }"
+            @click="mode === 'ellipse' ? rotateMenu(-1) : scrollList(-1)"
+            @mouseenter="pauseAutoRotate"
+            @mouseleave="resumeAutoRotate">
+            <img v-if="mode === 'ellipse' || canScrollLeft" src="@/assets/main/main-right.png" class="arrow-img left-facing" draggable="false" />
+            <img v-else src="@/assets/main/main-left.png" class="arrow-img" draggable="false" />
         </div>
 
-        <div class="dock-list-container" ref="listContainer" @scroll="checkScrollState">
-            <div class="dock-list">
+        <div class="dock-list-container" 
+             :class="{ 'is-ellipse-mode': mode === 'ellipse' }"
+             ref="listContainer" 
+             @scroll="checkScrollState"
+             @mousedown.prevent="handleDragStart"
+             @touchstart.passive="handleDragStart"
+             @mouseenter="pauseAutoRotate"
+             @mouseleave="resumeAutoRotate">
+             
+            <div class="dock-list" :class="{ 'is-ellipse-mode': mode === 'ellipse' }">
                 <div v-for="(item, index) in dockItems" :key="index" class="dock-item"
-                    :class="{ 'is-active': activeIndex === index, [`theme-${item.theme}`]: item.theme}"
+                    :class="{ 
+                        'is-active': activeIndex === index, 
+                        [`theme-${item.theme}`]: item.theme,
+                        'is-front': mode === 'ellipse' && frontIndex === index 
+                    }"
+                    :style="mode === 'ellipse' ? getEllipseStyle(index) : {}"
                     @click="handleSelect(index, item)"
                     @mouseenter="hoverIndex = index"
                     @mouseleave="hoverIndex = null"
@@ -23,8 +42,7 @@
                     <div class="item-icon">
                         <img v-if="item.imgUrl" 
                         :src="(activeIndex === index || hoverIndex === index) && item.activeImgUrl ? item.activeImgUrl : item.imgUrl" 
-                        class="custom-icon" />
-
+                        class="custom-icon" draggable="false" />
                         <i v-else :class="item.iconClass"></i>
                     </div>
                     <div class="item-label">{{ item.label }}</div>
@@ -32,10 +50,13 @@
             </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 class="nav-arrow right-arrow" 
+            :class="{ 'is-disabled': mode === 'linear' && !canScrollRight, 'is-active': mode === 'ellipse' || canScrollRight }"
+            @click="mode === 'ellipse' ? rotateMenu(1) : scrollList(1)"
+            @mouseenter="pauseAutoRotate"
+            @mouseleave="resumeAutoRotate">
+            <img v-if="mode === 'ellipse' || canScrollRight" src="@/assets/main/main-right.png" class="arrow-img" draggable="false" />
+            <img v-else src="@/assets/main/main-left.png" class="arrow-img left-facing" draggable="false" />
         </div>
     </div>
 </template>
@@ -44,98 +65,52 @@
 export default {
     name: 'BottomDock',
     props: {
-        // 是否自动隐藏 (默认 true: 悬浮升起; false: 常驻显示)
-        autoHide: {
-            type: Boolean,
-            default: true
-        },
-        // 距离屏幕底部的偏移量 (单位 px,正数代表往上抬高)
-        bottomOffset: {
-            type: Number,
-            default: 0
-        },
-        // 允许外部传入的自定义容器样式 (如背景色、宽度等)
-        customStyle: {
-            type: Object,
-            default: () => ({})
-        },
-        // 允许外部传入自定义 class (支持字符串、数组、对象)
-        customClass: {
-            type: [String, Array, Object],
-            default: ''
-        }
+        autoHide: { type: Boolean, default: true },
+        bottomOffset: { type: Number, default: 0 },
+        customStyle: { type: Object, default: () => ({}) },
+        customClass: { type: [String, Array, Object], default: '' },
+        
+        mode: { type: String, default: 'linear' },         
+        autoRotate: { type: Boolean, default: true },      
+        autoRotateSpeed: { type: Number, default: 2500 },  
+        radiusX: { type: Number, default: 380 },           
+        radiusY: { type: Number, default: 60 },            
     },
     data() {
         return {
             activeIndex: -1,
             hoverIndex: null,
-            canScrollLeft: false, 
-            canScrollRight: true, 
+            dockExpanded: false,
+            canScrollLeft: false,
+            canScrollRight: true,
+            
+            ellipseRotation: Math.PI / 2, 
+            frontIndex: 0,
+            
+            // --- 拖拽交互状态 ---
+            isDragging: false,
+            hasDragged: false,
+            startX: 0,
+            currentX: 0,         // 记录实时拖动位置
+            startRotation: 0,
+            startFrontIndex: 0,  // 记录拖拽开始时的正前方项目
+            rotateTimer: null,
+
             dockItems: [
-                {
-                    label: '首页',
-                    imgUrl: require('@/assets/main/main-home.png'),
-                    activeImgUrl: require('@/assets/main/main-home-hover.png'),
-                    route: '/home',
-                    theme: 'blue',
-                },
-                {
-                    label: '状态监控',
-                    imgUrl: require('@/assets/main/main-surve.png'),
-                    activeImgUrl: require('@/assets/main/main-surve-hover.png'),
-                    route: '/surve',
-                    theme: 'blue',
-                },
-                {
-                    label: '勤务管理',
-                    imgUrl: require('@/assets/main/main-security.png'),
-                    activeImgUrl: require('@/assets/main/main-security-hover.png'),
-                    route: '/security',
-                    theme: 'gold',
-                },
-                {
-                    label: '干线协调',
-                    imgUrl: require('@/assets/main/main-coor.png'),
-                    activeImgUrl: require('@/assets/main/main-coor-hover.png'),
-                    route: '/coor',
-                    theme: 'blue',
-                },
-                {
-                    label: '数据分析',
-                    imgUrl: require('@/assets/main/main-watch.png'),
-                    activeImgUrl: require('@/assets/main/main-watch-hover.png'),
-                    route: '/watch',
-                    theme: 'blue',
-                },
-                {
-                    label: '系统设置',
-                    imgUrl: require('@/assets/main/main-setting.png'),
-                    activeImgUrl: require('@/assets/main/main-setting-hover.png'),
-                    route: '/setting',
-                    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',
-                },
+                { label: '首页', imgUrl: require('@/assets/main/main-home.png'), activeImgUrl: require('@/assets/main/main-home-hover.png'), route: '/home', theme: 'blue' },
+                { label: '状态监控', imgUrl: require('@/assets/main/main-surve.png'), activeImgUrl: require('@/assets/main/main-surve-hover.png'), route: '/surve', theme: 'blue' },
+                { label: '勤务管理', imgUrl: require('@/assets/main/main-security.png'), activeImgUrl: require('@/assets/main/main-security-hover.png'), route: '/security', theme: 'gold' },
+                { label: '干线协调', imgUrl: require('@/assets/main/main-coor.png'), activeImgUrl: require('@/assets/main/main-coor-hover.png'), route: '/coor', theme: 'blue' },
+                { label: '数据分析', imgUrl: require('@/assets/main/main-watch.png'), activeImgUrl: require('@/assets/main/main-watch-hover.png'), route: '/watch', theme: 'blue' },
+                { label: '系统设置', imgUrl: require('@/assets/main/main-setting.png'), activeImgUrl: require('@/assets/main/main-setting-hover.png'), route: '/setting', theme: 'blue' },
+                // { label: '测试1', imgUrl: require('@/assets/main/main-home.png'), activeImgUrl: require('@/assets/main/main-home-hover.png'), theme: 'blue' },
+                // { label: '测试2', imgUrl: require('@/assets/main/main-surve.png'), activeImgUrl: require('@/assets/main/main-surve-hover.png'), theme: 'blue' },
+                // { label: '测试3', imgUrl: require('@/assets/main/main-security.png'), activeImgUrl: require('@/assets/main/main-security-hover.png'), theme: 'blue' },
             ]
         };
     },
     computed: {
-        // 动态计算 CSS 变量和合并自定义样式
         dockStyles() {
-            // 统一只传一个基础 bottom 偏移量,动画交由 CSS transform 处理
             return {
                 '--dock-bottom': `${this.bottomOffset}px`,
                 ...this.customStyle 
@@ -152,12 +127,29 @@ export default {
     },
     mounted() {
         this.$nextTick(() => {
-            this.checkScrollState();
-            window.addEventListener('resize', this.checkScrollState);
+            if (this.mode === 'linear') {
+                this.checkScrollState();
+                window.addEventListener('resize', this.checkScrollState);
+            } else {
+                this.resumeAutoRotate();
+            }
+            if (this.autoHide) {
+                document.addEventListener('mousemove', this.handleGlobalMouseMove);
+            }
+            window.addEventListener('mousemove', this.handleDragging);
+            window.addEventListener('mouseup', this.handleDragEnd);
+            window.addEventListener('touchmove', this.handleDragging, { passive: false });
+            window.addEventListener('touchend', this.handleDragEnd);
         });
     },
     beforeDestroy() {
         window.removeEventListener('resize', this.checkScrollState);
+        document.removeEventListener('mousemove', this.handleGlobalMouseMove);
+        window.removeEventListener('mousemove', this.handleDragging);
+        window.removeEventListener('mouseup', this.handleDragEnd);
+        window.removeEventListener('touchmove', this.handleDragging);
+        window.removeEventListener('touchend', this.handleDragEnd);
+        this.pauseAutoRotate();
     },
     methods: {
         updateActiveIndexByRoute() {
@@ -168,10 +160,23 @@ export default {
 
             if (matchIndex !== -1) {
                 this.activeIndex = matchIndex;
+                if (this.mode === 'ellipse') {
+                    this.rotateTo(matchIndex);
+                }
             }
         },
         handleSelect(index, item) {
-            if (this.activeIndex === index) return;
+            if (this.hasDragged) {
+                this.hasDragged = false;
+                return;
+            }
+
+            if (this.mode === 'ellipse') {
+                this.rotateTo(index);
+            } else if (this.activeIndex === index) {
+                return;
+            }
+
             this.activeIndex = index;
 
             if (item.route) {
@@ -181,16 +186,32 @@ export default {
                     }
                 });
             }
-
             this.$emit('change', item);
         },
         checkScrollState() {
+            if (this.mode !== 'linear') return;
             const container = this.$refs.listContainer;
             if (!container) return;
 
             this.canScrollLeft = container.scrollLeft > 0;
             this.canScrollRight = Math.ceil(container.scrollLeft + container.clientWidth) < container.scrollWidth;
         },
+        handleGlobalMouseMove(e) {
+            if (!this.autoHide || this.dockExpanded) return;
+            const el = this.$refs.dockWrapper;
+            if (!el) return;
+            const rect = el.getBoundingClientRect();
+            const lineWidth = Math.min(250, Math.max(150, window.innerWidth * 0.2));
+            const centerX = window.innerWidth / 2;
+            const left = centerX - lineWidth / 2;
+            const right = centerX + lineWidth / 2;
+            if (e.clientX >= left && e.clientX <= right && e.clientY >= rect.top - 30) {
+                this.dockExpanded = true;
+            }
+        },
+        handleDockLeave() {
+            this.dockExpanded = false;
+        },
         scrollList(direction) {
             if (direction === -1 && !this.canScrollLeft) return;
             if (direction === 1 && !this.canScrollRight) return;
@@ -204,13 +225,125 @@ export default {
                     behavior: 'smooth'
                 });
             }
+        },
+        getEllipseStyle(index) {
+            const total = this.dockItems.length;
+            const baseAngle = (index / total) * Math.PI * 2;
+            const finalAngle = baseAngle + this.ellipseRotation;
+
+            const x = Math.cos(finalAngle) * this.radiusX;
+            const y = Math.sin(finalAngle) * this.radiusY;
+
+            const sinVal = Math.sin(finalAngle);
+            const normalizedDepth = (sinVal + 1) / 2; 
+
+            let scale = 0.5 + (normalizedDepth * 0.7);
+            if (this.hoverIndex === index) scale *= 1.2;
+
+            const opacity = 0.3 + (normalizedDepth * 0.7);
+            const zIndex = Math.round(normalizedDepth * 100);
+
+            return {
+                position: 'absolute',
+                left: '50%',
+                top: '50%',
+                transform: `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${scale})`,
+                opacity: opacity,
+                zIndex: zIndex,
+                transition: this.isDragging ? 'none' : 'transform 0.6s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.6s'
+            };
+        },
+        rotateMenu(direction) {
+            const total = this.dockItems.length;
+            const stepAngle = (Math.PI * 2) / total;
+            this.ellipseRotation -= direction * stepAngle;
+            this.updateFrontIndex();
+        },
+        rotateTo(index) {
+            const total = this.dockItems.length;
+            let diff = this.frontIndex - index;
+            if (diff > total / 2) diff -= total;
+            if (diff < -total / 2) diff += total;
+
+            const stepAngle = (Math.PI * 2) / total;
+            this.ellipseRotation += diff * stepAngle;
+            this.frontIndex = index;
+        },
+        updateFrontIndex() {
+            const total = this.dockItems.length;
+            const stepAngle = (Math.PI * 2) / total;
+            const currentRot = ((this.ellipseRotation % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
+            let targetIndex = Math.round((Math.PI / 2 - currentRot) / stepAngle);
+            this.frontIndex = ((targetIndex % total) + total) % total;
+        },
+        
+        // ================= 拖拽逻辑核心修改区 =================
+        handleDragStart(e) {
+            if (this.mode !== 'ellipse') return;
+            this.isDragging = true;
+            this.hasDragged = false;
+            this.pauseAutoRotate();
+            this.startX = e.clientX || (e.touches && e.touches[0].clientX);
+            this.currentX = this.startX;
+            
+            // 记录拖拽前的完美状态
+            this.startRotation = this.ellipseRotation;
+            this.startFrontIndex = this.frontIndex;
+        },
+        handleDragging(e) {
+            if (!this.isDragging) return;
+            this.currentX = e.clientX || (e.touches && e.touches[0].clientX);
+            const diffX = this.currentX - this.startX;
+            if (Math.abs(diffX) > 5) this.hasDragged = true; 
+            
+            // 拖动时,视觉上跟随鼠标
+            this.ellipseRotation = this.startRotation - (diffX / 350);
+            this.updateFrontIndex(); // 让中间的图标实时发光
+        },
+        handleDragEnd() {
+            if (!this.isDragging) return;
+            this.isDragging = false;
+            
+            const diffX = this.currentX - this.startX;
+            const threshold = 40; // 触发切换的距离阈值(滑动超过 40px 就切换)
+
+            // 【关键】无论拖动多远,先把底层状态恢复到起点
+            // 配合 isDragging = false 时的 transition 过渡,这能保证完美的滑动动画
+            this.ellipseRotation = this.startRotation;
+            this.frontIndex = this.startFrontIndex;
+
+            if (diffX < -threshold) {
+                // 向左滑:精准切换到“下一个”
+                this.rotateMenu(1);
+            } else if (diffX > threshold) {
+                // 向右滑:精准切换到“上一个”
+                this.rotateMenu(-1);
+            } else {
+                // 滑动距离不够,原地吸附回正(无操作,因为上面已经还原了 startRotation)
+            }
+
+            this.resumeAutoRotate();
+        },
+        // ======================================================
+
+        resumeAutoRotate() {
+            if (this.mode !== 'ellipse' || !this.autoRotate || this.rotateTimer) return;
+            this.rotateTimer = setInterval(() => {
+                this.rotateMenu(-1);
+            }, this.autoRotateSpeed);
+        },
+        pauseAutoRotate() {
+            if (this.rotateTimer) {
+                clearInterval(this.rotateTimer);
+                this.rotateTimer = null;
+            }
         }
     }
 };
 </script>
 
 <style scoped>
-/* ================= 整体容器布局 ================= */
+/* ================= 以下是你的原版 CSS (原封不动) ================= */
 .dock-wrapper {
     display: flex !important;
     flex-direction: row !important;
@@ -227,50 +360,46 @@ export default {
     pointer-events: auto !important;
 }
 
-/* === 状态 A:自动隐藏 === */
 .dock-wrapper.is-auto-hide {
-    /* 核心改动3:下沉 100%(即自身整个高度),但往回拉出一段可见距离。
-       clamp(最小值, 动态值, 最大值):保证小屏幕最少露出 25px,大屏最多露 40px */
-    transform: translateY(calc(100% - clamp(25px, 4vh, 40px)));
+    transform: translateY(calc(100% - clamp(15px, 4vh, 20px)));
+    pointer-events: none;
 }
-.dock-wrapper.is-auto-hide:hover {
-    /* 悬浮时,位移归零,完全升起 */
+.dock-wrapper.is-auto-hide.is-expanded {
     transform: translateY(0);
+    pointer-events: auto;
 }
 
-/* 增加隐形触发热区!防止线太细导致小屏幕极难 hover */
 .dock-wrapper.is-auto-hide::after {
     content: '';
     position: absolute;
-    top: -30px; /* 向上延伸 30px 的隐形热区 */
-    left: 0;
-    width: 100%;
-    height: 60px; /* 覆盖线上下区域 */
+    top: -30px;
+    left: 50%;
+    transform: translateX(-50%);
+    width: clamp(150px, 20vw, 250px);
+    height: 60px;
     background: transparent;
+    pointer-events: auto;
 }
 
-/* 发光的指示线自适应 */
 .dock-wrapper.is-auto-hide::before {
     content: '';
     position: absolute;
     top: 0;
     left: 50%;
     transform: translateX(-50%);
-    /* 宽度和厚度也做一点自适应,小屏自动变短点 */
     width: clamp(150px, 20vw, 250px);
     height: clamp(3px, 0.5vh, 5px); 
     background: rgba(0, 229, 255, 0.6);
     box-shadow: 0 0 10px rgba(0, 229, 255, 0.8);
     border-radius: 4px;
     transition: opacity 0.3s;
-    pointer-events: none; 
+    pointer-events: auto;
 }
 
-.dock-wrapper.is-auto-hide:hover::before {
+.dock-wrapper.is-auto-hide.is-expanded::before {
     opacity: 0;
 }
 
-/* === 状态 B:常驻显示 === */
 .dock-wrapper.is-always-show {
     transform: translateY(0);
 }
@@ -279,7 +408,6 @@ export default {
     display: none;
 }
 
-/* ================= 内部列表与滚动容器 ================= */
 .dock-list-container {
     width: 750px;
     height: 160px;
@@ -306,7 +434,6 @@ export default {
     gap: 30px;
 }
 
-/* ================= 左右控制箭头 ================= */
 .nav-arrow {
     flex-shrink: 0;
     width: 40px;
@@ -347,7 +474,6 @@ export default {
   opacity: 0.6; 
 }
 
-/* ================= 单个导航项 ================= */
 .dock-item {
     flex-shrink: 0;
     position: relative;
@@ -377,7 +503,6 @@ export default {
     letter-spacing: 1px;
 }
 
-/* ================= 交互状态:悬浮与选中 ================= */
 .dock-item:hover {
     transform: translateY(-15px) scale(1.15);
 }
@@ -427,4 +552,41 @@ export default {
 .dock-item.is-active .custom-icon {
     filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.8));
 }
+
+/* ================= 针对 3D 椭圆模式的样式覆盖 ================= */
+.dock-list-container.is-ellipse-mode {
+    overflow: visible !important;
+    cursor: grab;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+}
+.dock-list-container.is-ellipse-mode:active {
+    cursor: grabbing;
+}
+
+.dock-list.is-ellipse-mode {
+    position: relative !important;
+    width: 0 !important;
+    height: 0 !important;
+    min-width: 0 !important;
+    padding-bottom: 0 !important;
+    gap: 0 !important;
+}
+
+.dock-list.is-ellipse-mode .dock-item {
+    position: absolute;
+}
+.dock-list.is-ellipse-mode .dock-item:hover {
+    transform: none; 
+}
+
+.dock-list.is-ellipse-mode .dock-item.is-front .item-label {
+    color: #ffffff;
+    font-weight: bold;
+    text-shadow: 0 0 10px #00e5ff;
+}
+.dock-list.is-ellipse-mode .dock-item.is-front .custom-icon {
+    filter: drop-shadow(0 0 10px rgba(0, 229, 255, 0.8));
+}
 </style>

+ 69 - 0
src/components/ui/CrossingDetailHeader.vue

@@ -0,0 +1,69 @@
+<template>
+  <div class="crossing-detail-header">
+    <span class="route-name" :title="currentRoute.name || '未知路口'">
+      {{ currentRoute.name || '未知路口' }}
+    </span>
+    <span class="sep">/</span>
+    <span>{{ currentRoute.mode || '自适应控制' }}</span>
+    <span class="sep">/</span>
+    <span>{{ schemeName }}</span>
+    <span class="sep">/</span>
+    <span>运行时段:{{ currentRoute.runTime || '07:00-09:00' }}</span>
+    <span class="sep">/</span>
+    <span>设备:<span :class="isOnline ? 'online' : 'offline'">{{ intersectionData.status || '离线' }}</span></span>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'CrossingDetailHeader',
+  props: {
+    currentRoute: { type: Object, default: () => ({}) },
+    intersectionData: { type: Object, default: () => ({}) },
+    cycleLength: { type: [Number, String], default: 0 },
+    onClose: { type: Function, default: null }
+  },
+  computed: {
+    isOnline() {
+      return this.intersectionData.status === '在线';
+    },
+    schemeName() {
+      return this.currentRoute.schemeName || '早高峰方案';
+    }
+  }
+};
+</script>
+
+<style scoped>
+.crossing-detail-header {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
+  color: #e0e6f1;
+  font-size: clamp(10px, 1cqw, 16px);
+  padding: 10px 0px;
+  white-space: nowrap;
+  width: 100%;             /* 必须加上:撑满可用宽度 */
+  box-sizing: border-box;  /* 必须加上 */
+}
+.route-name {
+  white-space: nowrap;      /* 不换行 */
+  overflow: hidden;         /* 隐藏超出的部分 */
+  text-overflow: ellipsis;  /* 显示 ... */
+  min-width: 0;             /* 关键魔法:允许 Flex 子元素收缩到比内容本身更小 */
+}
+.sep {
+  margin: 0 8px;
+  opacity: 0.4;
+}
+
+.online {
+  color: #67c23a;
+}
+
+.offline {
+  color: #f56c6c;
+}
+
+</style>

+ 24 - 14
src/components/ui/CrossingDetailPanel.vue

@@ -129,6 +129,9 @@ export default {
         SegmentedRadio,
         DropdownSelect
     },
+    props: {
+        preloadedData: { type: Object, default: null }
+    },
     data() {
         return {
             // 核心状态控制
@@ -173,7 +176,11 @@ export default {
     },
     mounted() {
         this.initScaleObserver();
-        this.loadData();
+        if (this.preloadedData) {
+            this.applyData(this.preloadedData);
+        } else {
+            this.loadData();
+        }
     },
     beforeDestroy() {
         if (this._ro) this._ro.disconnect();
@@ -192,21 +199,24 @@ export default {
             const nodeId = this.$attrs.id || this.id;
             const data = await apiGetCrossingDetailData(nodeId);
             if (data) {
-                this.currentRoute = data.currentRoute || {};
-                this.intersectionData = data.intersectionData || {};
-                this.mockPhaseData = data.phaseData || [];
-                this.cycleLength = data.cycleLength || 140;
-                this.currentSec = data.currentTime || 0;
-                this.phaseDiff = data.phaseDiff || 0;
-                this.coordTime = data.coordTime || 0;
-                this.currentStageList = data.stageList || [];
-                this.schemeOptions = data.schemeOptions || [];
-                if (data.currentScheme) this.currentScheme = data.currentScheme;
-                if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
-                if (data.currentMethod) this.currentMethod = data.currentMethod;
-                if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
+                this.applyData(data);
             }
         },
+        applyData(data) {
+            this.currentRoute = data.currentRoute || {};
+            this.intersectionData = data.intersectionData || {};
+            this.mockPhaseData = data.phaseData || [];
+            this.cycleLength = data.cycleLength || 140;
+            this.currentSec = data.currentTime || 0;
+            this.phaseDiff = data.phaseDiff || 0;
+            this.coordTime = data.coordTime || 0;
+            this.currentStageList = data.stageList || [];
+            this.schemeOptions = data.schemeOptions || [];
+            if (data.currentScheme) this.currentScheme = data.currentScheme;
+            if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
+            if (data.currentMethod) this.currentMethod = data.currentMethod;
+            if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
+        },
         // 切换手动控制模式
         toggleManualMode() {
             this.isManualMode = !this.isManualMode;

+ 2 - 4
src/components/ui/CrossingListPanel.vue

@@ -15,8 +15,7 @@
             <TechTable :columns="tableColumns" :data="tableList" height="100%">
                 <template #phaseStatus="{ row }">
                     <div class="mini-chart-wrapper">
-                        <SignalTimingChart :phaseData="row.phaseData" :cycleLength="row.cycle" :currentTime="0"
-                            :isMiniMode="true" />
+                        <SignalTimingChart :phaseData="row.phaseData" :cycleLength="row.cycle" :currentTime="0" :showAxis="false" :showScanLine="false" />
                     </div>
                 </template>
 
@@ -333,9 +332,8 @@ export default {
 
 /* 控制相位图表在表格内的高度 */
 .mini-chart-wrapper {
-    height: 28px;
+    height: 30px;
     width: 100%;
-    border-radius: 2px;
     overflow: hidden;
     display: block;
 }

+ 38 - 6
src/components/ui/CrossingMultiView.vue

@@ -29,13 +29,18 @@
                     <div class="drag-handle" title="拖拽换位" v-if="!expandedId">
                         <span class="drag-icon">&#x2807;</span>
                     </div>
-                    <span class="cell-title">{{ slot.data.label || slot.data.name || '' }}</span>
-                    <span class="cell-close" @click.stop="handleRemove(slot.data.id)">&times;</span>
+                    <CrossingDetailHeader
+                        :currentRoute="slot.headerData ? slot.headerData.currentRoute : { name: slot.data.label || slot.data.name }"
+                        :intersectionData="slot.headerData ? slot.headerData.intersectionData : {}"
+                        :cycleLength="slot.headerData ? slot.headerData.cycleLength : 0"
+                    />
+                    <span class="cell-close" @click.stop="handleRemove(slot.data.id)">✕</span>
                 </div>
                 <div class="cell-body">
                     <CrossingDetailPanel
                         :id="slot.data.id"
                         v-bind="slot.data"
+                        :preloadedData="slot.headerData"
                     />
                 </div>
             </div>
@@ -46,12 +51,15 @@
 <script>
 import draggable from 'vuedraggable';
 import CrossingDetailPanel from '@/components/ui/CrossingDetailPanel.vue';
+import CrossingDetailHeader from '@/components/ui/CrossingDetailHeader.vue';
+import { apiGetCrossingDetailData } from '@/api';
 
 export default {
     name: 'CrossingMultiView',
     components: {
         draggable,
-        CrossingDetailPanel
+        CrossingDetailPanel,
+        CrossingDetailHeader
     },
     props: {
         crossings: {
@@ -75,7 +83,8 @@ export default {
     data() {
         return {
             localSlots: [],
-            expandedId: null
+            expandedId: null,
+            rebuildVersion: 0
         };
     },
     computed: {
@@ -123,8 +132,15 @@ export default {
         maxSlots() { this.rebuildSlots(); }
     },
     methods: {
-        rebuildSlots() {
-            this.localSlots = this.crossings.map(c => ({ type: 'panel', data: c }));
+        async rebuildSlots() {
+            // 保留已加载过的 headerData,避免重复请求
+            const oldMap = {};
+            this.localSlots.forEach(s => {
+                if (s.data && s.headerData) oldMap[s.data.id] = s.headerData;
+            });
+            this.localSlots = this.crossings.map(c => ({
+                type: 'panel', data: c, headerData: oldMap[c.id] || null
+            }));
             // 如果展开的路口被外部移除了,退出展开
             if (this.expandedId) {
                 const stillExists = this.crossings.find(c => c.id === this.expandedId);
@@ -133,6 +149,22 @@ export default {
             this.$nextTick(() => {
                 window.dispatchEvent(new Event('resize'));
             });
+            // 只对还没有 headerData 的路口发请求
+            const needLoad = this.localSlots
+                .map((s, i) => (!s.headerData ? { index: i, id: s.data.id } : null))
+                .filter(Boolean);
+            if (needLoad.length === 0) return;
+            const version = ++this.rebuildVersion;
+            const results = await Promise.all(
+                needLoad.map(item => apiGetCrossingDetailData(item.id).catch(() => null))
+            );
+            if (version !== this.rebuildVersion) return;
+            results.forEach((data, i) => {
+                const slotIndex = needLoad[i].index;
+                if (data && this.localSlots[slotIndex]) {
+                    this.$set(this.localSlots[slotIndex], 'headerData', data);
+                }
+            });
         },
 
         handleDblClick(slot) {

+ 28 - 7
src/components/ui/MenuItem.vue

@@ -18,9 +18,11 @@
       <i v-if="node.icon" :class="node.icon" class="node-icon"></i>
       
       <span class="node-label">
-        <slot name="label" :node="node">
-          {{ node.label }}
-        </slot>
+        <span @click.stop="handleLabelClick" class="node-label-span">
+          <slot name="label" :node="node">
+              {{ node.label }}
+          </slot>
+        </span>
       </span>
       
       <span 
@@ -39,7 +41,8 @@
         :node="child" 
         :level="level + 1"
         :theme="theme" 
-        @node-click="passEventUp" 
+        @node-click="passEventUp"
+        @folder-click="passFolderClickUp"
       >
         <template #label="{ node: innerNode }">
           <slot name="label" :node="innerNode"></slot>
@@ -80,9 +83,21 @@ export default {
   methods: {
     handleClick() {
       if (this.hasChildren) {
-        this.isOpen = !this.isOpen;
-      } 
-      this.$emit('node-click', this.node);
+          this.isOpen = !this.isOpen;       // 只做展开/折叠
+      } else {
+          this.$emit('node-click', this.node);  // 叶子节点点击事件
+      }
+    },
+    // 点击标题文字
+    handleLabelClick() {
+        if (this.hasChildren) {
+            this.$emit('folder-click', this.node);
+        } else {
+            this.$emit('node-click', this.node);
+        }
+    },
+    passFolderClickUp(nodeData) {
+      this.$emit('folder-click', nodeData);
     },
     passEventUp(nodeData) {
       this.$emit('node-click', nodeData);
@@ -206,4 +221,10 @@ export default {
   color: #00e5ff; 
 }
 .theme-dark .menu-row.is-leaf:hover { color: #ffffff; }
+
+.node-label-span:hover {
+  cursor: pointer;
+  user-select: none;
+  color: #00e5ff;
+}
 </style>

+ 199 - 135
src/components/ui/SignalTimingChart.vue

@@ -1,18 +1,17 @@
 <template>
-    <div ref="chartRef" class="chart-container"></div>
+  <div ref="chartRef" class="chart-container"></div>
 </template>
 
 <script>
 import * as echarts from 'echarts';
-// 引入你的全局自适应 Mixin
 import echartsResize from '@/mixins/echartsResize.js';
 
-// 静态资源与颜色常量保持不变
 const COLORS = {
   GREEN_LIGHT: '#8dc453', GREEN_DARK: '#73a542', YELLOW: '#fbd249', RED: '#ff7575', STRIPE_GREEN: '#a3d76e',
   TEXT_DARK: '#1e2638', AXIS_LINE: '#758599', DIVIDER_LINE: '#111827', MARK_BLUE: '#4da8ff', TEXT_LIGHT: '#d1d5db'
 };
 
+// 绘制条纹图案用于绿闪/预警
 const stripeCanvas = document.createElement('canvas');
 stripeCanvas.width = 6; stripeCanvas.height = 20;
 const ctx = stripeCanvas.getContext('2d');
@@ -20,148 +19,145 @@ ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 6, 20);
 ctx.fillStyle = COLORS.STRIPE_GREEN; ctx.fillRect(0, 0, 3, 20); 
 const stripePattern = { image: stripeCanvas, repeat: 'repeat' };
 
-const ICON_PATHS = {
-  UP: 'M10 2 H14 V14 H20 L12 22 L4 14 H10 Z',
-  DOWN: 'M10 22 H14 V10 H20 L12 2 L4 10 H10 Z',
-  TURN_LEFT: 'M 21 22 H 15 V 14 C 15 11 13 9 10 9 H 8 V 14 L 0 7 L 8 0 V 5 H 10 C 15 5 21 9 21 14 V 22 Z',
-  TURN_RIGHT: 'M 3 22 H 9 V 14 C 9 11 11 9 14 9 H 16 V 14 L 24 7 L 16 0 V 5 H 14 C 9 5 3 9 3 14 V 22 Z',
-  UTURN: 'M 18 22 V 10 C 18 5 15 2 12 2 C 9 2 6 5 6 10 V 14 H 0 L 8 22 L 16 14 H 10 V 10 C 10 7 11 6 12 6 C 13 6 14 7 14 10 V 22 H 18 Z'
+const IMAGE_MAP = {
+  'STRAIGHT_DOWN': require('@/assets/images/icon_straight_down.png'),
+  'TURN_DOWN_LEFT': require('@/assets/images/icon_turn_down_left.png'),
+  'TURN_DOWN_LEFT_UTURN': require('@/assets/images/icon_turn_down_left_uturn.png'),
+
+  'STRAIGHT_UP': require('@/assets/images/icon_straight_up.png'),
+  'TURN_UP_LEFT': require('@/assets/images/icon_turn_up_left.png'),
+  'TURN_UP_LEFT_UTURN': require('@/assets/images/icon_turn_up_left_uturn.png'),
+
+  'STRAIGHT_LEFT': require('@/assets/images/icon_straight_left.png'),
+  'TURN_LEFT_DOWN': require('@/assets/images/icon_turn_left_down.png'),
+  'TURN_LEFT_DOWN_UTURN': require('@/assets/images/icon_turn_left_down_uturn.png'), 
+
+  'STRAIGHT_RIGHT': require('@/assets/images/icon_straight_right.png'),
+  'TURN_RIGHT_UP': require('@/assets/images/icon_turn_right_up.png'),
+  'TURN_RIGHT_UP_UTURN': require('@/assets/images/icon_turn_right_up_uturn.png' )
+};
+
+// ==========================================
+// 核心逻辑:基于真实物理空间的对齐与自定义偏移/尺寸配置
+// pos: 位置(LT/RT/LB/RB), padX/padY: 基础像素偏移, baseW/baseH: 基础原始宽高
+// ==========================================
+const POS_MAP = {
+  // 1. 上方驶入 -> 靠左上角 (LT)
+  'STRAIGHT_DOWN':         { pos: 'LT', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },       
+  'TURN_DOWN_LEFT':        { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },      
+  'TURN_DOWN_LEFT_UTURN':  { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
+  
+  // 2. 下方驶入 -> 靠右下角 (RB)
+  'STRAIGHT_UP':           { pos: 'RB', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },         
+  'TURN_UP_LEFT':          { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },        
+  'TURN_UP_LEFT_UTURN':    { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
+  
+  // 3. 右侧驶入 -> 靠右上角 (RT)
+  'STRAIGHT_LEFT':         { pos: 'RT', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },       
+  'TURN_LEFT_DOWN':        { pos: 'RT', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },       
+  'TURN_LEFT_DOWN_UTURN':  { pos: 'RT', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
+  
+  // 4. 左侧驶入 -> 靠左下角 (LB)
+  'STRAIGHT_RIGHT':        { pos: 'LB', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
+  'TURN_RIGHT_UP':         { pos: 'LB', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
+  'TURN_RIGHT_UP_UTURN':   { pos: 'LB', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
 };
 
 export default {
   name: 'SignalTimingChart',
-  mixins: [echartsResize], // 注册自适应 mixin
+  mixins: [echartsResize],
   props: {
-    cycleLength: { type: Number, default: 0 },
+    cycleLength: { type: Number, default: 140 }, 
     currentTime: { type: Number, default: 0 },
     phaseData: { type: Array, default: () => [] },
-    isMiniMode: { type: Boolean, default: false } // 是否为表格内极简模式
+    showAxis: { type: Boolean, default: true },
+    showScanLine: { type: Boolean, default: true }
   },
   data() {
-    return {
-      scaleFactor: 1
-    };
+    return { scaleFactor: 1 };
   },
   mounted() {
     this.initChart();
   },
   watch: {
-    currentTime() {
-      // 使用 mixin 中的 $_chart 实例
-      if (this.$_chart) {
-        this.updateChart();
-      }
-    },
-    phaseData: {
-      deep: true,
-      handler(newVal) {
-        if (this.$_chart && newVal.length > 0) {
-          this.updateChart();
-        }
-      }
-    }
+    currentTime() { if (this.$_chart) this.updateChart(); },
+    phaseData: { deep: true, handler(newVal) { if (this.$_chart && newVal.length > 0) this.updateChart(); } },
+    showAxis() { this.updateChart(); },
+    showScanLine() { this.updateChart(); }
   },
   methods: {
-    // 动态计算缩放比例
     updateScale() {
       const el = this.$el;
       if (!el) return;
-      const baseWidth = 600; 
-      // 限制最小缩放为 0.5,防止极端小弹窗下彻底糊掉
-      this.scaleFactor = Math.max(0.5, el.clientWidth / baseWidth);
+      this.scaleFactor = Math.max(0.5, el.clientWidth / 600);
     },
-    
     initChart() {
       const chartDom = this.$refs.chartRef;
       if (!chartDom) return;
-      
       this.updateScale();
-      // 初始化 mixin 中的 $_chart
       this.$_chart = echarts.init(chartDom);
-      
-      if (this.phaseData.length > 0) {
-        this.updateChart();
-      }
+      if (this.phaseData.length > 0) this.updateChart();
     },
-    
-    // 该方法会自动被 echartsResize mixin 触发
     updateChart() {
       if (!this.$_chart) return;
-      
-      // 重绘前更新当前最新的尺寸比例
       this.updateScale();
       this.$_chart.setOption(this.getChartOption(), true);
     },
-    
+    getMaxTime() {
+      if (!this.phaseData || this.phaseData.length === 0) return this.cycleLength;
+      const maxDataTime = Math.max(...this.phaseData.map(item => item[2]));
+      return Math.max(this.cycleLength, maxDataTime);
+    },
     getChartOption() {
       const s = this.scaleFactor;
+      const isTwoRows = this.phaseData.some(item => item[0] === 1);
+      const yAxisData = isTwoRows ? ['Track 0', 'Track 1'] : ['Track 0'];
+      const realMaxTime = this.getMaxTime();
 
       return {
         backgroundColor: 'transparent',
-        // 因为去掉了头部,稍微减小了 top 的留白,让图表更紧凑
         grid: { 
-          left: 0, 
-          right: 0, 
-          top: this.isMiniMode ? 0 : Math.round(30 * s), 
-          bottom: this.isMiniMode ? 0 : Math.round(10 * s),
+          left: 0, right: 0, 
+          // 当隐藏坐标轴/扫描线时(即在表格中显示时),将上下边距设为 0,让色块铺满高度
+          top: (this.showAxis || this.showScanLine) ? Math.round(35 * s) : 0, 
+          bottom: (this.showAxis || this.showScanLine) ? Math.round(10 * s) : 0,
           containLabel: false 
         },
-        xAxis: { type: 'value', min: 0, max: this.cycleLength, show: false, boundaryGap: false },
-        yAxis: { type: 'category', data: ['Track 0', 'Track 1'], inverse: true, show: false },
-        series: [
-          {
-            type: 'custom',
-            // 箭头函数确保 this 指向 Vue 实例,以拿到 scaleFactor
-            renderItem: (params, api) => this.renderCustomItem(params, api),
-            encode: { x: [1, 2], y: 0 },
-            data: this.phaseData,
-            markLine: this.isMiniMode ? false : {
-              symbol: ['none', 'none'],
-              silent: true,
-              label: {
-                show: true,
-                position: 'start',
-                formatter: `${this.currentTime}/${this.cycleLength}`,
-                color: '#fff',
-                backgroundColor: COLORS.MARK_BLUE,
-                padding: [Math.round(4 * s), Math.round(8 * s)],
-                borderRadius: 2,
-                fontSize: Math.max(10, Math.round(10 * s)),
-                fontWeight: 'bold',
-                offset: [0, -2]
-              },
-              lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(1, Math.round(2 * s)) },
-              data: [ { xAxis: this.currentTime } ]
-            }
+        xAxis: { type: 'value', min: 0, max: realMaxTime, show: false },
+        yAxis: { type: 'category', data: yAxisData, inverse: true, show: false },
+        series: [{
+          type: 'custom',
+          renderItem: (params, api) => this.renderCustomItem(params, api, isTwoRows, realMaxTime),
+          encode: { x: [1, 2], y: 0 },
+          data: this.phaseData,
+          markLine: !this.showScanLine ? false : {
+            symbol: ['none', 'none'],
+            silent: true,
+            label: {
+              show: true, position: 'start', formatter: `${this.currentTime}/${realMaxTime}`, 
+              color: '#fff', backgroundColor: COLORS.MARK_BLUE, padding: [Math.round(4 * s), Math.round(8 * s)],
+              borderRadius: 2, fontSize: Math.max(10, Math.round(10 * s)), fontWeight: 'bold', offset: [0, Math.round(-15 * s)] 
+            },
+            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(1, Math.round(2 * s)), z: 100 },
+            data: [ { xAxis: this.currentTime } ]
           }
-        ]
+        }]
       };
     },
 
-    // 核心绘图逻辑保持不变,确保全链路使用 this.scaleFactor
-    renderCustomItem(params, api) {
+    renderCustomItem(params, api, isTwoRows, realMaxTime) {
       const s = this.scaleFactor;
       const trackIndex = api.value(0);
       const start = api.coord([api.value(1), trackIndex]);
       const end = api.coord([api.value(2), trackIndex]);
-
-      // 默认的色块高度和 Y 轴起始位置
-      let blockHeight = api.size([0, 1])[1];
-      let yPos = start[1] - blockHeight / 2;
-
-      // 如果是表格里的极简模式,无视轨道高度,强行占满整个可用区域!
-      if (this.isMiniMode) {
-          blockHeight = params.coordSys.height; // 色块高度 = 网格总高度
-          yPos = params.coordSys.y;             // 起始Y点 = 网格最顶部
-      }
-
+      const blockHeight = api.size([0, 1])[1];
+      const yPos = start[1] - blockHeight / 2;
       const blockWidth = end[0] - start[0];
-      const dividerY = (api.coord([0, 1])[1] + api.coord([0, 0])[1]) / 2;
 
       const phaseName = api.value(3);
       const duration = api.value(4);
       const type = api.value(5);
-      const iconKey = api.value(6);
+      const iconValue = api.value(6);
 
       let fillStyle = COLORS.GREEN_LIGHT;
       if (type === 'stripe') fillStyle = stripePattern;
@@ -176,56 +172,129 @@ export default {
       if (!rectShape) return;
       const children = [];
 
-      // A. 绘制刻度
-      if (params.dataIndex === 0 && !this.isMiniMode) {
-        const axisBaseY = params.coordSys.y - Math.round(20 * s);
-        [0, 35, 70, 105, 140].forEach(val => {
+      // A. 绘制阶段刻度 (S1, S2...)
+      if (params.dataIndex === 0 && this.showAxis) {
+        const axisBaseY = params.coordSys.y - Math.round(15 * s);
+        const track0Data = this.phaseData.filter(item => item[0] === 0);
+        let stagePoints = track0Data.filter(item => item[5] === 'green').map(item => item[1]);
+        if (!stagePoints.includes(0)) stagePoints.unshift(0);
+        stagePoints.push(realMaxTime); 
+        stagePoints = Array.from(new Set(stagePoints)).sort((a, b) => a - b);
+
+        stagePoints.forEach(val => {
           const x = api.coord([val, 0])[0];
           children.push({ type: 'line', shape: { x1: x, y1: axisBaseY - Math.round(5 * s), x2: x, y2: axisBaseY + Math.round(5 * s) }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
         });
-        const stages = [ {n:'S1', s:0, e:35}, {n:'S2', s:35, e:70}, {n:'S3', s:70, e:105}, {n:'S4', s:105, e:140} ];
-        stages.forEach(st => {
-          const x1 = api.coord([st.s, 0])[0], x2 = api.coord([st.e, 0])[0], midX = (x1 + x2) / 2;
+        for (let i = 0; i < stagePoints.length - 1; i++) {
+          const startX = api.coord([stagePoints[i], 0])[0];
+          const endX = api.coord([stagePoints[i + 1], 0])[0];
+          const midX = (startX + endX) / 2;
           const textHalf = Math.round(14 * s);
-          children.push({ type: 'line', shape: { x1: x1, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
-          children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: x2, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
-          children.push({ type: 'text', style: { text: st.n, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: Math.max(10, Math.round(14 * s)), align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
-        });
+          children.push({ type: 'line', shape: { x1: startX, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
+          children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: endX, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
+          children.push({ type: 'text', style: { text: `S${i + 1}`, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: Math.max(10, Math.round(14 * s)), align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
+        }
       }
 
-      // B. 绘制色块底色
+      // B. 画色块背景
       children.push({ type: 'rect', shape: rectShape, style: { fill: fillStyle, stroke: 'none' } });
 
-      // C. 绘制内部元素
-      const fs = Math.max(0.8, s * 0.9); // 文字/图标缩放
-      if (type === 'green' && blockWidth > 20) {
-        const darkWidth = Math.round(25 * fs);
+      // C. 绘制内部图标与文本
+      const fs = Math.max(0.8, s * 0.9); 
+      if (type === 'green' && blockWidth > 15) {
+        const darkWidth = Math.round(50 * fs); 
         const midY = yPos + blockHeight / 2;
+        
+        const innerGroup = {
+          type: 'group',
+          clipPath: { type: 'rect', shape: { x: start[0], y: yPos, width: blockWidth, height: blockHeight } },
+          children: [
+            { type: 'rect', shape: { x: start[0], y: yPos, width: darkWidth, height: blockHeight }, style: { fill: COLORS.GREEN_DARK } },
+            { 
+              type: 'polygon', 
+              shape: { points: [ [start[0] + darkWidth, midY - 4 * fs], [start[0] + darkWidth, midY + 4 * fs], [start[0] + darkWidth + 4 * fs, midY] ] }, 
+              style: { fill: COLORS.GREEN_DARK } 
+            }
+          ]
+        };
+
+        // --- 核心修改:支持将传入的 iconValue 作为多个图标渲染 ---
+        // 将 iconValue 统一解析为数组,支持数组格式 ['A', 'B'] 或 字符串逗号分隔格式 'A,B'
+        let iconList = [];
+        if (Array.isArray(iconValue)) {
+          iconList = iconValue;
+        } else if (typeof iconValue === 'string' && iconValue.trim() !== '') {
+          iconList = iconValue.split(',');
+        } else if (iconValue) {
+          iconList = [iconValue];
+        }
+
+        // 遍历所有图标,按它们各自配置的 pos (LT/RB/RT/LB) 计算坐标并绘制
+        iconList.forEach(icon => {
+          const valStr = String(icon).trim().toUpperCase();
+          const posConfig = POS_MAP[valStr] || { pos: 'RB', padX: 0, padY: 0, baseW: 20, baseH: 20 };
+          const pos = typeof posConfig === 'string' ? posConfig : (posConfig.pos || 'RB');
+          
+          const drawW = Math.round((posConfig.baseW || 20) * fs);
+          const drawH = Math.round((posConfig.baseH || 20) * fs);
+          
+          const padX = Math.round((posConfig.padX || 0) * fs); 
+          const padY = Math.round((posConfig.padY || 0) * fs);
 
-        children.push({ type: 'rect', shape: { x: start[0], y: yPos, width: darkWidth, height: blockHeight }, style: { fill: COLORS.GREEN_DARK } });
-        const arrowH = Math.round(4 * fs);
-        if (!this.isMiniMode) {
-          children.push({ type: 'polygon', shape: { points: [ [start[0] + darkWidth, midY - arrowH], [start[0] + darkWidth, midY + arrowH], [start[0] + darkWidth + arrowH, midY] ] }, style: { fill: COLORS.GREEN_DARK } });
-          if (iconKey && ICON_PATHS[iconKey]) {
-            const iconSize = Math.round(14 * fs);
-            const iconX = start[0] + (darkWidth - iconSize) / 2;
-            const iconY = midY - iconSize / 2;
-            
-            children.push({
-              type: 'path',
-              shape: { pathData: ICON_PATHS[iconKey], x: iconX, y: iconY, width: iconSize, height: iconSize, layout: 'center' },
-              style: { fill: COLORS.TEXT_DARK, stroke: 'none' }
+          let iconX, iconY;
+          if (pos === 'LT') {
+            iconX = start[0] + padX;
+            iconY = yPos + padY;
+          } else if (pos === 'RT') {
+            iconX = start[0] + darkWidth - drawW - padX;
+            iconY = yPos + padY;
+          } else if (pos === 'LB') {
+            iconX = start[0] + padX;
+            iconY = yPos + blockHeight - drawH - padY;
+          } else { // RB
+            iconX = start[0] + darkWidth - drawW - padX;
+            iconY = yPos + blockHeight - drawH - padY;
+          }
+
+          // 绘制单个图标并推入容器
+          if (IMAGE_MAP[valStr]) {
+            innerGroup.children.push({
+              type: 'image',
+              style: { 
+                image: IMAGE_MAP[valStr], 
+                x: iconX, 
+                y: iconY, 
+                width: drawW, 
+                height: drawH,
+                objectFit: 'contain' 
+              }
             });
           }
-          
-          children.push({ type: 'text', style: { text: `${phaseName}\n${duration}`, x: start[0] + darkWidth + Math.round(4 * fs), y: midY, fill: COLORS.TEXT_DARK, fontSize: Math.max(10, Math.round(12 * fs)), fontFamily: 'Arial', fontWeight: 'bold', align: 'left', verticalAlign: 'middle' } });
-        }
+        });
+        
+        // 渲染文本 (相位号与时长)
+        innerGroup.children.push({
+          type: 'text',
+          style: {
+            text: `${phaseName}\n${duration}`,
+            x: start[0] + darkWidth + Math.round(6 * fs),
+            y: midY,
+            fill: COLORS.TEXT_DARK,
+            fontSize: Math.max(10, Math.round(12 * fs)),
+            fontWeight: 'bold',
+            align: 'left',
+            verticalAlign: 'middle'
+          }
+        });
+
+        children.push(innerGroup);
       }
         
-        // D. 分割线
-        if (trackIndex === 1 && !this.isMiniMode) {
-          children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
-        }
+      // D. 轨道分割线 (仅在两排模式下 Track 1 顶部绘制)
+      if (isTwoRows && trackIndex === 1) {
+        const dividerY = (api.coord([0, 1])[1] + api.coord([0, 0])[1]) / 2;
+        children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
+      }
 
       return { type: 'group', children: children };
     }
@@ -234,12 +303,7 @@ export default {
 </script>
 
 <style scoped>
-
 .chart-container { 
-  width: 100%; 
-  height: 100%;
-  flex: 1; 
-  min-height: 0; 
-  overflow: hidden;
+  width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden;
 }
 </style>

+ 16 - 6
src/components/ui/SmartDialog.vue

@@ -1,5 +1,5 @@
 <template>
-  <div v-show="visible" class="smart-dialog" :style="dialogStyle" @mousedown="bringToFront" @dblclick="handleDoubleClick">
+  <div v-show="visible" class="smart-dialog" :style="dialogStyle" @mousedown="bringToFront" @dblclick="handleDoubleClick" :class="{ 'no-padding': noPadding }">
     <div class="dialog-header" :class="{ 'is-draggable': draggable }" @mousedown="startDrag" v-if="title">
       <div class="title-content">
         <slot name="header">
@@ -10,7 +10,7 @@
     </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 v-if="title" class="dialog-divider"></div>
 
     <div class="dialog-body" :class="{ 'no-padding': noPadding }">
       <slot></slot>
@@ -290,6 +290,11 @@ export default {
   flex-direction: column;
   overflow: hidden;
   user-select: none;
+  padding: 5px;
+  container-type: inline-size;
+}
+.smart-dialog.no-padding {
+  padding: 0;
 }
 
 .dialog-header {
@@ -298,17 +303,19 @@ export default {
   display: flex;
   justify-content: space-between;
   align-items: center;
-  padding: 10px 10px 0px 10px;
+  padding: 0px 10px 0px 10px;
 }
 
 .dialog-header.not-title {
-  height: 10px;
   padding: 0;
 }
 
 .dialog-header.is-draggable {
   cursor: move;
 }
+.not-title.is-draggable {
+  height: 10px;
+}
 
 .title-content {
   flex: 1;
@@ -317,7 +324,7 @@ export default {
 
 .title {
   color: #ffffff;
-  font-size: 14px;
+  font-size: clamp(10px, 2cqw, 18px);
   font-weight: 600;
   letter-spacing: 1px;
 }
@@ -340,7 +347,7 @@ export default {
 .dialog-divider {
   height: 1px;
   background-color: rgba(255, 255, 255, 0.3);
-  margin: 0 20px;
+  margin: 0 5px;
 }
 
 .dialog-body {
@@ -351,6 +358,9 @@ export default {
   display: flex;
   flex-direction: column;
 }
+.dialog-body.no-padding {
+  padding: 0;
+}
 
 .dialog-body.no-padding {
   padding: 0;

+ 34 - 1
src/components/ui/TaskMonitorHeader.vue

@@ -7,7 +7,8 @@
             <span class="info-text">{{ taskData.manager }}</span>
             <span class="level-tag">{{ taskData.level }}</span>
             <span class="info-text">{{ taskData.status }}</span>
-            <button class="btn btn-view" @click="handleEnd">立即结束</button>
+            <button v-if="taskData.status === '未开始'" class="btn btn-start" @click="handleStart">立即执行</button>
+            <button v-if="taskData.status === '进行中'" class="btn btn-end" @click="handleEnd">立即结束</button>
         </div>
     </div>
 </template>
@@ -17,9 +18,13 @@ export default {
     name: 'TaskMonitorHeader',
     props: {
         taskData: { type: Object, required: true },
+        onStartTask: { type: Function },
         onEndTask: { type: Function }
     },
     methods: {
+        handleStart() {
+            if (this.onStartTask) this.onStartTask();
+        },
         handleEnd() {
             if (this.onEndTask) this.onEndTask();
         }
@@ -34,6 +39,7 @@ export default {
     align-items: center;
     width: 100%;
     padding-right: 20px;
+    padding: 10px 0;
 }
 
 .left-info {
@@ -69,4 +75,31 @@ export default {
     border-radius: 4px;
     font-size: 12px;
 }
+
+.btn {
+    padding: 5px 8px;
+    border: none;
+    border-radius: 4px;
+    color: #fff;
+    cursor: pointer;
+    font-size: 14px;
+    font-weight: bold;
+    transition: opacity 0.3s;
+    background: #1E6AFF;
+}
+
+.btn:hover {
+    opacity: 0.8;
+}
+
+.btn-end {
+    background: rgba(40, 90, 180, 0.6);
+    border: 1px solid #448aff;
+    font-weight: normal;
+}
+
+.btn-end:hover {
+    background: rgba(40, 90, 180, 0.9);
+    box-shadow: 0 0 8px rgba(68, 138, 255, 0.5);
+}
 </style>

+ 8 - 8
src/components/ui/TechTable.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class="tech-table-wrapper" :style="{ maxHeight: height }">
+    <div class="tech-table-wrapper" :style="height && height !== 'auto' ? { maxHeight: height } : {}">
         <table class="tech-table" cellspacing="0" cellpadding="0">
 
             <thead>
@@ -68,13 +68,13 @@ export default {
 <style scoped>
 .tech-table-wrapper {
     flex: 1;
-    min-height: 0;
+    height: 0;
     min-width: 0;
     width: 100%;
-    overflow: hidden;
-    /* overflow-y: auto; */
+    overflow-y: auto;
     scrollbar-width: none; /* 兼容 Firefox 隐藏滚动条 */
     -ms-overflow-style: none; /* 兼容 IE/Edge 隐藏滚动条 */
+    container-type: inline-size;
 }
 
 .tech-table-wrapper::-webkit-scrollbar {
@@ -99,9 +99,9 @@ export default {
     top: 0;
     z-index: 10;
     color: #6CFFD2;
-    font-size: 14px;
+    font-size: clamp(14px, 2.5cqw, 16px);
     font-weight: 600;
-    padding: 10px 10px;
+    padding: clamp(4px, 1.5cqw, 10px) clamp(4px, 1.5cqw, 10px);
     box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.05);
     background-color: #112446;
 }
@@ -109,8 +109,8 @@ export default {
 /* ================= 表体样式 ================= */
 .tech-table tbody td {
     color: #ffffff;
-    font-size: 14px;
-    padding: 10px 10px;
+    font-size: clamp(14px, 2.5cqw, 16px);
+    padding: clamp(10px, 1.5cqw, 15px) clamp(10px, 1.5cqw, 15px);
     /* 文字超出省略号 */
     white-space: nowrap;
     overflow: hidden;

+ 3 - 1
src/layouts/DashboardLayout.vue

@@ -104,6 +104,7 @@ import TaskMonitorHeader from '@/components/ui/TaskMonitorHeader.vue';
 import SpecialTaskMonitorPanel from '@/components/ui/SpecialTaskMonitorPanel.vue';
 import ChangePassword from '@/components/ui/ChangePassword.vue';
 import CrossingMultiView from '@/components/ui/CrossingMultiView.vue';
+import CrossingDetailHeader from '@/components/ui/CrossingDetailHeader.vue';
 
 export default {
     name: 'DashboardLayout',
@@ -129,7 +130,8 @@ export default {
         TaskMonitorHeader,
         SpecialTaskMonitorPanel,
         ChangePassword,
-        CrossingMultiView
+        CrossingMultiView,
+        CrossingDetailHeader
     },
     provide() {
         return {

+ 96 - 32
src/mock/api.js

@@ -129,21 +129,74 @@ function _makeIntersectionConfig(id, name) {
   }
 }
 
-function _makePhaseData(cycleLength) {
-  const n = 4, tp = Math.floor(cycleLength / n)
-  const dirs = ['UP', 'TURN_LEFT', 'DOWN', 'TURN_RIGHT']
-  const pd = []
-  for (let track = 1; track >= 0; track--) {
-    let t = 0
-    for (let i = 0; i < n; i++) {
-      const g = tp - 8
-      pd.push([track, t, t + g, `P${track * n + i + 1}`, g, 'green', dirs[i]]); t += g
-      pd.push([track, t, t + 3, '', null, 'stripe', null]); t += 3
-      pd.push([track, t, t + 2, '', null, 'yellow', null]); t += 2
-      pd.push([track, t, t + 3, '', null, 'red', null]); t += 3
+/**
+ * 动态生成路口相位配时数据
+ * @param {number} cycleLength 周期总时长
+ * @param {boolean} isTwoRows 是否生成上下双排 8 相位 (默认 true)
+ */
+function _makePhaseData(cycleLength = 140, isTwoRows = true) {
+  const n = 4; // 4个阶段 (S1-S4)
+  const stageTime = Math.floor(cycleLength / n); 
+  const pd = [];
+
+  // ==========================================
+  // 修改点:将单个图标改为用逗号分隔的"成对图标"字符串
+  // 前端组件会按逗号切割并分别放到对角位置
+  // ==========================================
+  const iconsUD = [
+    'STRAIGHT_DOWN,STRAIGHT_UP',                 // 南北直行对放
+    'TURN_DOWN_LEFT,TURN_UP_LEFT',               // 南北左转对放
+    'TURN_DOWN_LEFT_UTURN,TURN_UP_LEFT_UTURN'    // 南北左转+掉头对放
+  ]; 
+  const iconsLR = [
+    'STRAIGHT_LEFT,STRAIGHT_RIGHT',              // 东西直行对放
+    'TURN_LEFT_DOWN,TURN_RIGHT_UP',              // 东西左转对放
+    'TURN_LEFT_DOWN_UTURN,TURN_RIGHT_UP_UTURN'   // 东西左转+掉头对放
+  ];
+
+  const getRandomIcon = (pool) => pool[Math.floor(Math.random() * pool.length)];
+
+  let t = 0; 
+  for (let i = 0; i < n; i++) {
+    const stageStart = t;
+    const stageEnd = stageStart + stageTime;
+    const currentIconPool = (i < 2) ? iconsUD : iconsLR;
+
+    // 辅助函数:生成单条轨道的一个阶段
+    const pushTrackData = (trackIdx, phaseNamePrefix) => {
+      // 这里的 icon 现在抽出来的是诸如 "STRAIGHT_DOWN,STRAIGHT_UP" 的字符串
+      const icon = getRandomIcon(currentIconPool);
+      const phaseName = `${phaseNamePrefix}${i + 1}`;
+      const g = Math.floor(Math.random() * 11) + 20; // 绿灯 20-30s
+      const s = 3; // 闪烁/条纹 3s
+      const y = 2; // 黄灯 2s
+      
+      let curT = stageStart;
+      
+      // 1. 绿灯 (第6个索引项传入组装好的成对 icon 字符串)
+      pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon]); 
+      curT += g;
+      // 2. 绿闪/条纹
+      pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null]); 
+      curT += s;
+      // 3. 黄灯
+      pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null]); 
+      curT += y;
+      // 4. 红灯补齐 (确保阶段对齐)
+      let remainRed = stageEnd - curT;
+      if (remainRed > 0) {
+        pd.push([trackIdx, curT, stageEnd, '', remainRed, 'red', null]);
+      }
+    };
+
+    pushTrackData(0, 'P'); // 生成第一排 (P1-P4)
+    if (isTwoRows) {
+      pushTrackData(1, 'P'); // 生成第二排 (P5-P8,由于逻辑相同,名称可根据需要改为 i+5)
     }
+
+    t = stageEnd; 
   }
-  return pd
+  return pd;
 }
 
 function _makeCornerVideos(seed = 0) {
@@ -306,7 +359,7 @@ export async function apiGetSignalTiming(id) {
   return ok({
     cycleLength,
     currentTime: Math.floor(Date.now() / 1000) % cycleLength,
-    phaseData: _makePhaseData(cycleLength),
+    phaseData: _makePhaseData(cycleLength, false),
   })
 }
 
@@ -315,7 +368,8 @@ export async function apiGetIntersectionStages(id) {
   await delay(200)
   const timing = DB.signalTimings[id]
   if (timing) {
-    const phases = timing.data.phaseData.filter(p => p[0] === 1 && p[4] !== null)
+    const hasTrack1 = timing.data.phaseData.some(p => p[0] === 1)
+    const phases = timing.data.phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[4] !== null)
     return ok(phases.map((p, i) => ({
       value: String(i + 1), time: p[4], phaseName: p[3], direction: p[6], img: ARROWS[i % ARROWS.length],
     })))
@@ -522,10 +576,20 @@ export async function apiGetCrossingList(params = {}) {
 
   // 动态状态:每次请求路口状态会变化
   const statuses = ['在线', '在线', '在线', '在线', '离线']
-  let list = DB.crossingList.map((r, i) => ({
-    ...r,
-    status: statuses[Math.floor(seededRand(Math.floor(Date.now() / 10000) + i) * statuses.length)],
-  }))
+  let list = DB.crossingList.map((r, i) => {
+    const preset = DB.signalTimings[r.id]
+    const cycleLength = preset ? preset.data.cycleLength : r.cycle
+    // const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength, false)
+
+    // 强制全部用 _makePhaseData 动态生成成对箭头
+    const phaseData = _makePhaseData(cycleLength, false)
+    return {
+      ...r,
+      status: statuses[Math.floor(seededRand(Math.floor(Date.now() / 10000) + i) * statuses.length)],
+      cycle: cycleLength,
+      phaseData,
+    }
+  })
 
   // 筛选(兼容中英文值映射)
   if (params.keyword || params.name) {
@@ -619,21 +683,20 @@ export async function apiGetSpecialTaskMonitorData(id) {
   const taskIdx = Math.abs(numId - 1) % (DB.securityTasks.length || 1)
   const task = DB.securityTasks.find(t => t.id === id || t.id === numId) || DB.securityTasks[taskIdx] || {}
   const timeSlots = ['07:30-09:30', '09:00-11:00', '12:00-14:00', '14:00-16:00', '17:00-19:00', '19:00-21:00']
-  const statusList = [
-    { status: '进行中', color: '#ff4d4f' },
-    { status: '待执行', color: '#ffaa00' },
-    { status: '进行中', color: '#ff4d4f' },
-    { status: '进行中', color: '#00e5ff' },
-  ]
-  const statusItem = statusList[seed % statusList.length]
+  const statusColorMap = {
+    '未开始': '#ffaa00',
+    '进行中': '#ff4d4f',
+    '已完成': '#8dc453',
+  }
+  const taskStatus = task.status || '未开始'
 
   const taskInfo = {
     name: task.name || '特勤路线',
     time: timeSlots[seed % timeSlots.length],
     manager: task.executor || DB.securityTasks[seed % DB.securityTasks.length]?.executor || '王建国',
     level: task.level || (seed % 3 === 0 ? '二级' : '一级'),
-    status: statusItem.status,
-    statusColor: statusItem.color,
+    status: taskStatus,
+    statusColor: statusColorMap[taskStatus] || '#ffaa00',
   }
 
   // 根据任务 id 选取不同的关键路口(每个任务关联不同的4个路口)
@@ -709,7 +772,7 @@ export async function apiGetCrossingPanelData(id) {
 
   const preset = DB.signalTimings[id]
   const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][Math.abs(seed) % 6]
-  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength)
+  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength, false)
   const currentTime = Math.floor(Date.now() / 1000) % cycleLength
 
   return ok({
@@ -739,10 +802,11 @@ export async function apiGetCrossingDetailData(id) {
   // 从真实阶段数据推导周期和相位
   const preset = DB.signalTimings[id]
   const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][seed % 6]
-  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength)
+  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength || 140, false)
 
-  // 从相位数据中提取阶段列表(上轨道绿灯相位,最多4个)
-  const greenPhases = phaseData.filter(p => p[0] === 1 && p[4] !== null).slice(0, 4)
+  // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0,最多4个)
+  const hasTrack1 = phaseData.some(p => p[0] === 1)
+  const greenPhases = phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[4] !== null).slice(0, 4)
   const stageList = greenPhases.map((p, i) => ({
     value: String(i + 1),
     time: p[4],

+ 17 - 27
src/mock/data.js

@@ -201,33 +201,23 @@ export function fetchSignalTimingData(id) {
           currentTime: 67,
           // [轨道(1上,0下), 开始时间, 结束时间, 相位名称, 时长, 颜色类型, 图标类型]
           phaseData: [
-            // 上轨道 (Track 1)
-            [1, 0, 30, 'P1', 30, 'green', 'UP'],
-            [1, 30, 35, '', null, 'stripe', null],
-            [1, 35, 38, '', null, 'yellow', null],
-            [1, 38, 41, '', null, 'red', null],
-            [1, 41, 71, 'P2', 30, 'green', 'TURN_LEFT'],
-            [1, 71, 76, '', null, 'stripe', null],
-            [1, 76, 79, '', null, 'yellow', null],
-            [1, 79, 82, '', null, 'red', null],
-            [1, 82, 122, 'P4', 40, 'green', 'UTURN'], 
-            [1, 122, 127, '', null, 'stripe', null],
-            [1, 127, 130, '', null, 'yellow', null],
-            [1, 130, 140, 'P3', 10, 'green', 'UP'],
-            
-            // 下轨道 (Track 0)
-            [0, 0, 30, 'P5', 30, 'green', 'DOWN'],
-            [0, 30, 35, '', null, 'stripe', null],
-            [0, 35, 38, '', null, 'yellow', null],
-            [0, 38, 41, '', null, 'red', null],
-            [0, 41, 71, 'P6', 30, 'green', 'TURN_RIGHT'],
-            [0, 71, 76, '', null, 'stripe', null],
-            [0, 76, 79, '', null, 'yellow', null],
-            [0, 79, 82, '', null, 'red', null],
-            [0, 82, 122, 'P7', 40, 'green', 'UTURN'],
-            [0, 122, 127, '', null, 'stripe', null],
-            [0, 127, 130, '', null, 'yellow', null],
-            [0, 130, 140, 'P8', 10, 'green', 'DOWN'], 
+            // 单轨道 P1-P4
+            [0, 0, 27, 'P1', 27, 'green', 'UP'],
+            [0, 27, 30, '', null, 'stripe', null],
+            [0, 30, 32, '', null, 'yellow', null],
+            [0, 32, 35, '', null, 'red', null],
+            [0, 35, 62, 'P2', 27, 'green', 'TURN_LEFT'],
+            [0, 62, 65, '', null, 'stripe', null],
+            [0, 65, 67, '', null, 'yellow', null],
+            [0, 67, 70, '', null, 'red', null],
+            [0, 70, 97, 'P3', 27, 'green', 'DOWN'],
+            [0, 97, 100, '', null, 'stripe', null],
+            [0, 100, 102, '', null, 'yellow', null],
+            [0, 102, 105, '', null, 'red', null],
+            [0, 105, 132, 'P4', 27, 'green', 'TURN_RIGHT'],
+            [0, 132, 135, '', null, 'stripe', null],
+            [0, 135, 137, '', null, 'yellow', null],
+            [0, 137, 140, '', null, 'red', null],
           ]
         }
       });

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1479 - 2345
src/mock/mock_data.json


+ 3 - 3
src/views/Main.vue

@@ -11,7 +11,7 @@
     </template>
 
     <template #main>
-      <BottomDock :auto-hide="false" :custom-class="['dock-style']"></BottomDock>
+      <BottomDock mode="ellipse" :auto-hide="false" :custom-class="['dock-style']" :auto-rotate="true"></BottomDock>
     </template>
 
   </LoginLayout>
@@ -55,14 +55,14 @@ export default {
     },
     // ---------- 闪烁点 ----------
     initDots() {
-      const count = 28;
+      const count = 50;
       const arr = [];
       for (let i = 0; i < count; i++) {
         arr.push({
           id: i,
           x: 18 + Math.random() * 64, // 百分比布局,适配任意屏幕
           y: 22 + Math.random() * 56,
-          r: 1.2 + Math.random() * 1.8,
+          r: 1.2 + Math.random() * 2.8, // 半径
           a: 0.45 + Math.random() * 0.55,
           d: Math.random() * 2.8, // delay
           t: 1.8 + Math.random() * 2.6, // duration

+ 219 - 112
src/views/StatusMonitoring.vue

@@ -14,7 +14,7 @@
                 <CrossingListPanel :onViewDetail="handleCrossingViewDetail"/>
             </div>
             <!-- 地图 -->
-            <TongzhouTrafficMap v-else
+            <TongzhouTrafficMap v-else ref="trafficMapRef"
                 amapKey="db2da7e3e248c3b2077d53fc809be63f"
                 securityJsCode="a7413c674852c5eaf01d90813c5b7ef6"
                 :mode="activeLeftTab === 'crossing' ? '路口' : activeLeftTab === 'trunkLine' ? '干线' : activeLeftTab === 'specialDuty' ? '特勤' : ''"
@@ -29,7 +29,7 @@
                 <TechTabs v-model="activeLeftTab" type="underline" @tab-click="handleTabClick">
                     <TechTabPane label="总览" name="overview" class="menu-scroll-view">
                         <MenuItem theme="tech" v-for="item in menuData" :key="item.id" :node="item" :level="0"
-                            @node-click="handleMenuClick" />
+                            @node-click="handleMenuClick" @folder-click="handleFolderClick"/>
                     </TechTabPane>
                     <TechTabPane label="路口" name="crossing" class="menu-scroll-view">
                         <MenuItem theme="tech" v-for="item in menuData" :key="item.id" :node="item" :level="0"
@@ -44,7 +44,26 @@
                         </template>
                         </MenuItem>
                     </TechTabPane>
-                    <TechTabPane label="特勤" name="specialDuty">
+                    <TechTabPane label="特勤" name="specialDuty" class="menu-scroll-view special-duty-pane">
+                        <TechTable ref="dutyTable" :columns="tableColumns" :data="tableData" class="duty-table">
+                            <template #level="{ row }">
+                                <span :title="row.level" :style="{ color: row.level === '二级' ? '#FFDF0C' : '#F00' }">
+                                    {{ row.level }}
+                                </span>
+                            </template>
+                            <template #status="{ row }">
+                                <span :title="row.status" :style="{ color: row.status === '进行中' ? '#FFDF0C' : row.status === '未开始' ? '#fff' : '#8dc453' }">
+                                    {{ row.status }}
+                                </span>
+                            </template>
+                            <template #action="{ row }">
+                                <div class="btn-group">
+                                    <span class="action-btn" @click="handleSpecialTaskView(row)">查看</span>
+                                    <span v-if="row.status === '未开始'" class="action-btn action-start" @click="handleSpecialTaskStart(row)">立即执行</span>
+                                    <span v-if="row.status === '进行中'" class="action-btn action-end" @click="handleSpecialTaskEnd(row)">立即结束</span>
+                                </div>
+                            </template>
+                        </TechTable>
                     </TechTabPane>
                 </TechTabs>
             </div>
@@ -55,32 +74,30 @@
             <div class="mode-switch" v-if="activeLeftTab === 'crossing'">
                 <ButtonGroup v-model="currentView" :options="viewOptions" @select="onViewSelect" />
             </div>
-            <!-- 特勤右上角表格 -->
-            <TechTable ref="dutyTable" :columns="tableColumns" :data="tableData" class="duty-table" v-if="activeLeftTab === 'specialDuty'">
-
-                <template #level="{ row }">
-                    <span :title="row.level" :style="{ color: row.level === '二级' ? '#FFDF0C' : '#F00' }">
-                        {{ row.level }}
-                    </span>
-                </template>
-
-                <template #status="{ row }">
-                    <span :title="row.status" :style="{ color: row.status === '进行中' ? '#FFDF0C' : '#F00' }">
-                        {{ row.status }}
-                    </span>
-                </template>
-
-                <template #action="{ row }">
-                    <span class="action-btn" @click="handleSpecialTaskView(row)">
-                        查看
-                    </span>
-                </template>
-
-            </TechTable>
         </template>
 
         <template #center>
-
+            <!-- 顶部常驻图表区域(替代弹窗) -->
+            <div class="top-charts-bar" v-if="currentView !== 'list-mode'">
+                <!-- 总览Tab -->
+                <template v-if="activeLeftTab === 'overview'">
+                    <div class="top-chart-box overview-chart-box">
+                        <OnlineStatusTabs />
+                    </div>
+                    <div class="top-chart-box overview-chart-box">
+                        <DeviceStatusTabs />
+                    </div>
+                </template>
+                <!-- 路口Tab -->
+                <template v-if="activeLeftTab === 'crossing'">
+                    <div class="top-chart-box crossing-chart-box">
+                        <RingDonutChart v-if="crossingTopCharts.onlineChart" v-bind="crossingTopCharts.onlineChart" />
+                    </div>
+                    <div class="top-chart-box crossing-chart-box">
+                        <RingDonutChart v-if="crossingTopCharts.faultChart" v-bind="crossingTopCharts.faultChart" />
+                    </div>
+                </template>
+            </div>
         </template>
 
     </DashboardLayout>
@@ -96,7 +113,10 @@ import MenuItem from '@/components/ui/MenuItem.vue';
 import ButtonGroup from '@/components/ui/ButtonGroup.vue';
 import TechTable from '@/components/ui/TechTable.vue';
 import CrossingListPanel from '@/components/ui/CrossingListPanel.vue';
-import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetOverviewTopCharts } from '@/api';
+import OnlineStatusTabs from '@/components/ui/OnlineStatusTabs.vue';
+import DeviceStatusTabs from '@/components/ui/DeviceStatusTabs.vue';
+import RingDonutChart from '@/components/ui/RingDonutChart.vue';
+import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetOverviewTopCharts, apiGetCrossingDetailData } from '@/api';
 
 
 export default {
@@ -110,12 +130,15 @@ export default {
         MenuItem,
         ButtonGroup,
         TechTable,
-        CrossingListPanel
+        CrossingListPanel,
+        OnlineStatusTabs,
+        DeviceStatusTabs,
+        RingDonutChart
     },
     data() {
         return {
             // 左侧边栏数据
-            activeLeftTab: 'overview',
+            activeLeftTab: '',
             menuData: [],
             // 地图模式切换数据
             currentView: 'map-mode',
@@ -123,17 +146,19 @@ export default {
                 { label: '列表模式', value: 'list-mode' },
                 { label: '地图模式', value: 'map-mode' },
             ],
-            // 1. 表头
+            // 特勤表头
             tableColumns: [
-                { label: '序号', key: 'id', width: '14%' },
-                { label: '名称', key: 'name', width: '20%' },
-                { label: '执行人', key: 'executor', width: '18%' },
-                { label: '等级', key: 'level', width: '14%' },
-                { label: '状态', key: 'status', width: '20%' },
-                { label: '操作', key: 'action', width: '14%' }
+                { label: '序号', key: 'id', width: '10%' },
+                { label: '名称', key: 'name', width: '30%' },
+                { label: '执行人', key: 'executor', width: '15%' },
+                { label: '等级', key: 'level', width: '12%' },
+                { label: '状态', key: 'status', width: '13%' },
+                { label: '操作', key: 'action', width: '20%' }
             ],
 
             tableData: [],
+            // 路口顶部图表数据
+            crossingTopCharts: {},
             // 路口多选分屏
             crossingSelections: [],
             maxCrossingSlots: 4,
@@ -165,18 +190,25 @@ export default {
 
         // 初始显示顶部图表(如果没有路由参数覆盖的话)
         if (Object.keys(this.$route.query).length === 0) {
+            this.activeLeftTab = 'overview';
             this.showTopChartDalogs();
         }
 
     },
     methods: {
         // 处理地图点击事件
-        handleMapCrossingClick(mapData, lnglat) {
-            console.log('父组件接收到了地图路口点击事件:', mapData, lnglat);
+        handleMapCrossingClick(mapData, lnglat, pixel) {
+            console.log('父组件接收到了地图路口点击事件:', mapData);
+            console.log('父组件接收到了地图路口点击事件:', lnglat);
+            console.log('父组件接收到了地图路口点击事件:', pixel);
             // 组装模拟数据
+            const scale = window.innerWidth / 1920;
             let nodeData = {
-                id: Math.floor(Math.random()*5)+1,
+                id: mapData.position[0] + mapData.position[1],
                 label: mapData.road,
+                // 反算为设计稿坐标(SmartDialog 内部会再乘 scale)
+                pixelX: pixel ? Math.round(pixel.x / scale) : 950,
+                pixelY: pixel ? Math.round(pixel.y / scale) : 430,
             }
             console.log(nodeData);
             if (this.activeLeftTab === 'overview') { // 总览
@@ -213,7 +245,7 @@ export default {
                 //     }
                 // });
             } else {
-                this.showCrossingTopDialogs();
+                this.loadCrossingTopCharts();
             }
         },
         // 处理tab点击
@@ -223,11 +255,41 @@ export default {
             this.crossingSelections = [];
             this.showTopChartDalogs(); // 根据当前Tab显示对应的顶部常驻图表
         },
+        // 处理菜单folder标题点击
+        handleFolderClick(nodeData) {
+            console.log('父组件接收到了文件夹点击事件:', nodeData);
+            // 临时逻辑,有真实接口后可以删除
+            const index = Math.floor(Math.random() * 10);
+            const position = localStorage.getItem(`pos${index + 1}`).split(',');
+
+            // 地图联动
+            this.$refs.trafficMapRef.focusByLocation([Number(position[0]), Number(position[1])]);
+        },
         // 处理菜单点击
         handleMenuClick(nodeData) {
             console.log('父组件接收到了最底层路口点击事件:', nodeData);
+            // 通过地图组件获取像素坐标(如果有经纬度的话)
+            // if (nodeData.lng && nodeData.lat && this.$refs.trafficMapRef) {
+            //     // 地图联动
+            //     this.$refs.trafficMapRef.focusByLocation([nodeData.lng, nodeData.lat]);
+
+            //     const pixel = this.$refs.trafficMapRef.lngLatToPixel(nodeData.lng, nodeData.lat);
+            //     if (pixel) {
+            //         const scale = window.innerWidth / 1920;
+            //         nodeData.pixelX = Math.round(pixel.x / scale) + 20;
+            //         nodeData.pixelY = Math.round(pixel.y / scale);
+            //     }
+            // }
+
             // 根据Tab来显示不同的弹窗内容
             if (this.activeLeftTab === 'overview') { // 总览
+                // 临时逻辑,有真实接口后可以删除
+                const index = Math.floor(Math.random() * 10);
+                const position = localStorage.getItem(`pos${index + 1}`).split(',');
+
+                // 地图联动
+                this.$refs.trafficMapRef.focusByLocation([Number(position[0]), Number(position[1])]);
+                
                 this.showOverviewDalogs(nodeData);
             } else if (this.activeLeftTab === 'crossing') { // 路口
                 this.showCrossingDalogs(nodeData);
@@ -236,7 +298,6 @@ export default {
             } else if (this.activeLeftTab === 'specialDuty') { // 特勤
                 this.showSpecialDutyDalogs(nodeData);
             }
-
         },
         // 处理弹窗双击展开(通过 onExpand 回调从 Layout 传入)
         handleDoubleClickExpend(nodeData) {
@@ -247,14 +308,8 @@ export default {
         },
         // 显示顶部常驻图表(根据当前Tab状态)
         showTopChartDalogs() {
-            if (this.activeLeftTab === 'overview') { // 总览
-                this.showOverviewTopDialogs();
-            } else if (this.activeLeftTab === 'crossing') { // 路口
-                this.showCrossingTopDialogs();
-            } else if (this.activeLeftTab === 'trunkLine') { // 干线
-                // TODO: 干线Tab的顶部图表
-            } else if (this.activeLeftTab === 'specialDuty') { // 特勤
-                // this.openDutyDetailDialog({id: 'route_' + new Date().getTime(), label: '特勤路口'});
+            if (this.activeLeftTab === 'crossing') {
+                this.loadCrossingTopCharts();
             }
         },
         // 显示总览弹窗组
@@ -269,7 +324,7 @@ export default {
                 height: 260,
                 center: false,
                 showClose: true,
-                position: { x: 950, y: 430 },
+                position: { x: (nodeData.pixelX || 950) + 20, y: nodeData.pixelY || 430 },
                 noPadding: false,
                 data: {
                     ...nodeData,
@@ -281,35 +336,10 @@ export default {
                 }
             });
         },
-        async showOverviewTopDialogs() {
-            this.$refs.layout.openDialog({
-                id: 'top-chart-overview-1',
-                title: '',
-                component: 'OnlineStatusTabs',
-                width: 300,
-                height: 160,
-                center: false,
-                showClose: false,
-                draggable: false,
-                resizable: false,
-                position: { x: 630, y: 130 },
-                noPadding: true,
-                data: {}
-            });
-            this.$refs.layout.openDialog({
-                id: 'top-chart-overview-2',
-                title: '',
-                component: 'DeviceStatusTabs',
-                width: 300,
-                height: 160,
-                center: false,
-                showClose: false,
-                draggable: false,
-                resizable: false,
-                position: { x: 980, y: 130 },
-                noPadding: true,
-                data: {}
-            });
+        async loadCrossingTopCharts() {
+            try {
+                this.crossingTopCharts = await apiGetCrossingTopCharts();
+            } catch (e) { /* ignore */ }
         },
         // 显示路口弹窗组(多选分屏)
         showCrossingDalogs(nodeData) {
@@ -342,15 +372,16 @@ export default {
         openCrossingMultiView() {
             this.$refs.layout.openDialog({
                 id: 'crossing-multi-view',
-                title: '路口监控 (' + this.crossingSelections.length + '/' + this.maxCrossingSlots + ')',
+                title: '',
                 component: 'CrossingMultiView',
                 width: 1400,
                 height: 700,
                 center: false,
                 position: { x: 500, y: 150 },
-                showClose: true,
+                showClose: false,
                 noPadding: true,
                 enableDblclickExpand: false,
+                draggable: false,
                 data: {
                     crossings: [...this.crossingSelections],
                     maxSlots: this.maxCrossingSlots,
@@ -375,11 +406,13 @@ export default {
         },
 
         // 单个路口详情弹窗(总览双击展开等场景使用)
-        showCrossingDetailDialogs(nodeData) {
+        async showCrossingDetailDialogs(nodeData) {
             console.log('显示路口详情弹窗组', nodeData.id, nodeData.label);
+            const detailData = await apiGetCrossingDetailData(nodeData.id);
+            const dialogId = 'crossing_detail' + nodeData.id;
             this.$refs.layout.openDialog({
-                id: 'crossing_detail' + nodeData.id,
-                title: nodeData.label || nodeData.name,
+                id: dialogId,
+                title: ' ',
                 component: 'CrossingDetailPanel',
                 width: 1315,
                 height: 682,
@@ -388,29 +421,13 @@ export default {
                 position: { x: 500, y: 170 },
                 noPadding: false,
                 enableDblclickExpand: false,
-                data: nodeData
-            });
-        },
-        async showCrossingTopDialogs() {
-            const chartData = await apiGetCrossingTopCharts();
-            const { onlineChart, faultChart } = chartData;
-            this.$refs.layout.openDialog({
-                id: 'top-chart-crossing-1',
-                title: '',
-                component: 'RingDonutChart',
-                width: 228, height: 124, center: false, showClose: false,
-                draggable: false, resizable: false,
-                position: { x: 730, y: 130 }, noPadding: true,
-                data: onlineChart
-            });
-            this.$refs.layout.openDialog({
-                id: 'top-chart-crossing-2',
-                title: '',
-                component: 'RingDonutChart',
-                width: 228, height: 124, center: false, showClose: false,
-                draggable: false, resizable: false,
-                position: { x: 980, y: 130 }, noPadding: true,
-                data: faultChart
+                data: { ...nodeData, preloadedData: detailData },
+                headerComponent: 'CrossingDetailHeader',
+                headerProps: {
+                    currentRoute: detailData?.currentRoute || {},
+                    intersectionData: detailData?.intersectionData || {},
+                    cycleLength: detailData?.cycleLength || 0,
+                }
             });
         },
         // 路口列表模式下弹窗
@@ -499,10 +516,17 @@ export default {
                 headerComponent: 'TaskMonitorHeader',
                 headerProps: {
                     taskData: panelData.taskInfo,
+                    onStartTask: () => {
+                        console.log('点击了立即执行');
+                        panelData.taskInfo.status = '进行中';
+                        const tableRow = this.tableData.find(r => r.id === nodeData.id);
+                        if (tableRow) tableRow.status = '进行中';
+                    },
                     onEndTask: () => {
-                        console.log('点击了结束任务');
-                        // this.$refs.layout.handleDialogClose(id);
-                        panelData.taskInfo.status = '已结束';
+                        console.log('点击了立即结束');
+                        panelData.taskInfo.status = '已完成';
+                        const tableRow = this.tableData.find(r => r.id === nodeData.id);
+                        if (tableRow) tableRow.status = '已完成';
                     }
                 }
             });
@@ -510,7 +534,14 @@ export default {
         handleSpecialTaskView(row) {
             console.log('查看特勤线路,当前数据:', row);
             this.openDutyDetailDialog(row);
-        
+        },
+        handleSpecialTaskStart(row) {
+            console.log('立即执行特勤任务:', row);
+            row.status = '进行中';
+        },
+        handleSpecialTaskEnd(row) {
+            console.log('立即结束特勤任务:', row);
+            row.status = '已完成';
         },
 
 
@@ -530,7 +561,83 @@ export default {
 .duty-table {
     margin-top: 10px;
 }
+.action-btn {
+    cursor: pointer;
+    color: #4da8ff;
+    margin-right: 10px;
+}
+.action-btn:hover {
+    text-decoration: underline;
+}
+.action-start {
+    color: #67c23a;
+}
+.action-end {
+    color: #f56c6c;
+}
+.top-charts-bar {
+    display: flex;
+    justify-content: center;
+    gap: clamp(10px, 1.04vw, 20px);
+    pointer-events: none;
+}
+.top-chart-box {
+    pointer-events: auto;
+    flex-shrink: 0;
+    background: radial-gradient(circle at 20% 0%, rgba(40,120,200,0.5) 0%, rgba(20,60,130,0.7) 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: clamp(6px, 0.625vw, 12px);
+    overflow: hidden;
+}
+/* --- 总览Tab图表尺寸适配 (原 300x160) --- */
+.overview-chart-box {
+    /* clamp(最小值, 理想值(1920下比例), 最大值) */
+    width: clamp(200px, 15.625vw, 300px);
+    height: clamp(106px, 8.333vw, 160px);
+}
+
+/* --- 路口Tab图表尺寸适配 (原 228x124) --- */
+.crossing-chart-box {
+    width: clamp(152px, 11.875vw, 228px);
+    height: clamp(82px, 6.458vw, 124px);
+}
 ::v-deep .list-mode-panel {
-    padding: 150px 30px 0 30px;
+    position: absolute;
+    inset: 0;
+    padding: 150px 30px 30px 30px;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+}
+/* 针对特勤 Tab 单独剥离背景和边框 */
+::v-deep .special-duty-pane {
+    background: transparent !important;
+    background-color: transparent !important;
+    background-image: none !important;
+    border: none !important;
+    box-shadow: none !important;
+    outline: none !important;
+}
+
+/* 如果你的科技UI边框是利用伪元素(::before / ::after)绘制的,再加上这两句抹除 */
+::v-deep .special-duty-pane::before,
+::v-deep .special-duty-pane::after {
+    display: none !important;
+}
+
+::v-deep .special-duty-pane .tech-table tbody td {
+    color: #ffffff;
+    font-size: clamp(12px, 2.5cqw, 14px);
+    padding: clamp(8px, 1.5cqw, 10px) clamp(8px, 1.5cqw, 10px);
+    white-space: normal !important; 
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+::v-deep .special-duty-pane .tech-table .btn-group {
+    display: flex;
+    gap: 10px;
+    flex-direction: column;
 }
 </style>