Переглянути джерело

新增PlandonutChart组件;CrossingDetailPanel 添加实时方案和下周期方案圆饼图

  1. 引入 PlanDonutChart 组件,左右并排显示实时方案(剩余时长94/总时长180)和下周期方案(总时长98)
  2. 通过 panelScale prop 将面板缩放比例传递给 PlanDonutChart,实现弹窗缩放时圆饼图自适应
  3. 新增 donut-row 左右布局样式,间距跟随 --s 缩放变量自适应
画安 2 тижнів тому
батько
коміт
af8295e836

+ 60 - 2
src/components/ui/CrossingDetailPanel.vue

@@ -67,6 +67,31 @@
                                 </div>
                             </div>
 
+                            <!-- 方案圆饼图 -->
+                            <div class="donut-row" v-if="!showLockTime">
+                                <div class="donut-item">
+                                    <div class="donut-title">实时方案(执行方案3)</div>
+                                    <PlanDonutChart
+                                        :chartData="realtimeDonutData"
+                                        centerValue="94"
+                                        centerLabel="剩余时长"
+                                        :showTotal="true"
+                                        :totalValue="180"
+                                        :scale="panelScale"
+                                    />
+                                </div>
+                                <div class="donut-item">
+                                    <div class="donut-title">下周期方案</div>
+                                    <PlanDonutChart
+                                        :chartData="nextCycleDonutData"
+                                        centerValue="98"
+                                        centerLabel="总时长"
+                                        :showTotal="false"
+                                        :scale="panelScale"
+                                    />
+                                </div>
+                            </div>
+
                             <transition name="fade">
                                 <div class="lock-time" v-if="showLockTime">
                                     <div class="lock-time-label-wrap glow-header">
@@ -111,6 +136,7 @@ import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
 import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
 import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
 import DropdownSelect from '@/components/ui/DropdownSelect.vue';
+import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
 
 import { apiGetCrossingDetailData } from '@/api';
 
@@ -120,7 +146,8 @@ export default {
         SignalTimingChart,
         IntersectionMapVideos,
         SegmentedRadio,
-        DropdownSelect
+        DropdownSelect,
+        PlanDonutChart
     },
     props: {
         preloadedData: { type: Object, default: null }
@@ -141,12 +168,26 @@ export default {
             phaseDiff: 0,
             coordTime: 0,
             mockPhaseData: [],
-            
+            panelScale: 1,
+
             // 控制方式数据
             controlMethodOptions: [],
             currentMethod: 'temp',
             currentScheme: 'early_peak',
             schemeOptions: [],
+            // 实时方案圆饼图数据
+            realtimeDonutData: [
+                { label: '已走时长', value: 86, color: '#8892a0' },
+                { label: '1-西单面', value: 0, color: '#3b82f6' },
+                { label: '2-东西直行', value: 43, color: '#a855f7' },
+                { label: '3-北左转', value: 51, color: '#14b8a6' }
+            ],
+            // 下周期方案圆饼图数据
+            nextCycleDonutData: [
+                { label: '1-西单面', value: 23, color: '#3b82f6' },
+                { label: '2-东西直行', value: 51, color: '#a855f7' },
+                { label: '3-北左转', value: 24, color: '#14b8a6' }
+            ],
             currentLocktime: 50,
             locktimeOptions: [],
             currentStage: '1', 
@@ -185,6 +226,7 @@ export default {
                 const { width } = entries[0].contentRect;
                 const s = Math.min(width / 1315, 1);
                 this.$el.style.setProperty('--s', s);
+                this.panelScale = s;
             });
             ro.observe(this.$el);
             this._ro = ro;
@@ -718,4 +760,20 @@ export default {
     gap: clamp(3px, calc(var(--s) * 6px), 8px);
     cursor: pointer;
 }
+
+/* ===== 方案圆饼图左右布局 ===== */
+.donut-row {
+    display: flex;
+    gap: clamp(4px, calc(var(--s) * 16px), 16px);
+    width: 100%;
+}
+.donut-item {
+    flex: 1;
+    min-width: 0;
+}
+.donut-title {
+    font-size: clamp(11px, calc(var(--s) * 13px), 14px);
+    color: #a0aec0;
+    margin-bottom: 4px;
+}
 </style>

+ 226 - 0
src/components/ui/PlanDonutChart.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="dashboard-donut-wrapper" :style="{ gap: uiScale.gap + 'px' }">
+    
+    <div class="chart-container" :style="{ width: uiScale.chartBox + 'px', height: uiScale.chartBox + 'px' }">
+      <div class="chart-dom" ref="chartRef"></div>
+    </div>
+
+    <div class="legend-container">
+      <div v-if="showTotal" class="total-header" :style="{ fontSize: uiScale.totalFont + 'px', marginBottom: uiScale.gap + 'px' }">
+        总时长 <span class="total-num">{{ totalValue }}</span>
+      </div>
+      
+      <div class="legend-list" :style="{ gap: (uiScale.gap * 0.6) + 'px' }">
+        <div 
+          class="legend-item" 
+          v-for="(item, index) in chartData" 
+          :key="index"
+          :style="{ fontSize: uiScale.legendFont + 'px' }"
+        >
+          <i class="color-square" :style="{ 
+            backgroundColor: item.color, 
+            width: uiScale.square + 'px', 
+            height: uiScale.square + 'px',
+            marginRight: (uiScale.gap * 0.6) + 'px'
+          }"></i>
+          <span class="item-label" :style="{ minWidth: uiScale.labelWidth + 'px', marginRight: (uiScale.gap * 0.6) + 'px' }">{{ item.label }}</span>
+          <span class="item-value">{{ item.value }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts';
+import echartsResizeMixin from '@/mixins/echartsResize.js';
+
+export default {
+  name: 'PlanDonutChart',
+  // 仍然保留 mixin 用于监听容器尺寸变化触发重绘
+  mixins: [echartsResizeMixin], 
+  props: {
+    chartData: { type: Array, required: true, default: () => [] },
+    centerValue: { type: [Number, String], default: 0 },
+    centerLabel: { type: String, default: '' },
+    showTotal: { type: Boolean, default: true },
+    totalValue: { type: [Number, String], default: 0 },
+    scale: { type: Number, default: 0 }
+  },
+  data() {
+    return {
+      uiScale: {
+        gap: 12,
+        chartBox: 140, 
+        totalFont: 13,
+        legendFont: 12,
+        square: 10,
+        labelWidth: 65
+      }
+    };
+  },
+  watch: {
+    chartData: {
+      deep: true,
+      handler() {
+        this.updateChart();
+      }
+    },
+    scale() {
+      this.updateChart();
+    }
+  },
+  mounted() {
+    this.initChart();
+  },
+  methods: {
+    initChart() {
+      if (!this.$refs.chartRef) return;
+      this.$_chart = echarts.init(this.$refs.chartRef);
+      this.updateChart();
+    },
+    
+    // 【核心改造】获取真实的容器缩放比例
+    getLocalScale() {
+      // 优先使用父组件传入的 scale prop
+      if (this.scale > 0) return this.scale;
+      if (!this.$el) return 1;
+      // 降级:读取 CSS 变量 --s
+      const sVal = getComputedStyle(this.$el).getPropertyValue('--s');
+      if (sVal && sVal.trim() !== '' && !isNaN(parseFloat(sVal))) {
+        return parseFloat(sVal);
+      }
+      return window.innerWidth / 1920;
+    },
+
+    calcSize(px) {
+      return Math.round(px * this.getLocalScale());
+    },
+
+    updateChart() {
+      // 每次重绘时,获取最新的局部缩放比例 s
+      const s = this.getLocalScale();
+
+      // 同步更新 HTML 元素的尺寸
+      this.uiScale = {
+        gap: Math.round(12 * s),
+        chartBox: Math.round(140 * s),
+        totalFont: Math.round(13 * s),
+        legendFont: Math.round(12 * s),
+        square: Math.round(10 * s),
+        labelWidth: Math.round(65 * s)
+      };
+
+      if (!this.$_chart) return;
+
+      const option = {
+        color: this.chartData.map(item => item.color),
+        graphic: [
+          {
+            type: 'text',
+            left: 'center',
+            top: '38%',
+            style: {
+              text: this.centerValue,
+              fill: '#ffffff',
+              fontSize: Math.round(24 * s),
+              fontWeight: 'bold'
+            }
+          },
+          {
+            type: 'text',
+            left: 'center',
+            top: '60%',
+            style: {
+              text: this.centerLabel,
+              fill: '#a0aec0',
+              fontSize: Math.round(12 * s)
+            }
+          }
+        ],
+        series: [
+          {
+            type: 'pie',
+            radius: [Math.round(50 * s), Math.round(65 * s)],
+            center: ['50%', '50%'],
+            avoidLabelOverlap: false,
+            label: { show: false },
+            labelLine: { show: false },
+            hoverAnimation: false,
+            data: this.chartData.map(item => ({
+              name: item.label,
+              value: item.value
+            }))
+          }
+        ]
+      };
+
+      this.$_chart.setOption(option);
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* 整体容器:水平弹性布局 */
+.dashboard-donut-wrapper {
+  display: flex;
+  align-items: center;
+  background-color: transparent;
+  padding: 0;
+  color: #ffffff;
+  font-family: sans-serif;
+}
+
+.chart-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.chart-dom {
+  width: 100%;
+  height: 100%;
+}
+
+.legend-container {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.total-header {
+  color: #a0aec0;
+}
+
+.total-num {
+  margin-left: 4px;
+  color: #ffffff;
+}
+
+.legend-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  color: #cbd5e1;
+  white-space: nowrap;
+}
+
+.color-square {
+  border-radius: 1px;
+  display: inline-block;
+}
+
+.item-label {
+  display: inline-block;
+}
+
+.item-value {
+  color: #ffffff;
+}
+</style>