Commit 9782fb4bf2e437b514b0eeda35a1f37b9d950322

Authored by 杨鸣坤
1 parent e9265335

feat: 实现稼动率Canvas图表及数据对接

Showing 1 changed file with 651 additions and 88 deletions
@@ -123,77 +123,65 @@ @@ -123,77 +123,65 @@
123 </div> 123 </div>
124 </div> 124 </div>
125 125
126 - <!-- ========== 稼动率:多图表视图 ========== -->  
127 - <div v-else-if="currentStatus === 'utilization'" class="tab-content util-view"> 126 + <!-- ========== 稼动率:Canvas多图表视图 ========== -->
  127 + <div v-else-if="currentStatus === 'utilization'" class="tab-content util-view" ref="utilWrapRef">
128 <div class="util-toolbar"> 128 <div class="util-toolbar">
129 <span class="util-label">查询方式:</span> 129 <span class="util-label">查询方式:</span>
130 <el-radio-group v-model="utilQueryMode" size="small"> 130 <el-radio-group v-model="utilQueryMode" size="small">
131 <el-radio-button value="day">日查询</el-radio-button> 131 <el-radio-button value="day">日查询</el-radio-button>
132 - <el-radio-button value="week">周查询</el-radio-button>  
133 <el-radio-button value="month">月查询</el-radio-button> 132 <el-radio-button value="month">月查询</el-radio-button>
134 </el-radio-group> 133 </el-radio-group>
135 - <el-date-picker v-model="utilDate" type="date" placeholder="2026-04-28" size="small" style="width:160px;margin-left:8px;" /> 134 + <el-date-picker v-if="utilQueryMode === 'day'" v-model="utilDayDate" type="date"
  135 + placeholder="" size="small" style="width:160px;margin-left:8px;"
  136 + value-format="YYYY-MM-DD" :disabled-date="disabledDateFuture" @change="fetchUtilData" />
  137 + <el-date-picker v-if="utilQueryMode === 'week'" v-model="utilWeekDate" type="date"
  138 + placeholder="" size="small" style="width:160px;margin-left:8px;"
  139 + value-format="YYYY-MM-DD" :disabled-date="disabledDateFuture" @change="fetchUtilData" />
  140 + <el-date-picker v-if="utilQueryMode === 'month'" v-model="utilMonthDate" type="month"
  141 + placeholder="" size="small" style="width:160px;margin-left:8px;"
  142 + value-format="YYYY-MM" :disabled-date="disabledMonthFuture" @change="fetchUtilData" />
136 <div style="flex:1"></div> 143 <div style="flex:1"></div>
137 - <el-button type="primary" size="small">查询</el-button> 144 + <el-button type="primary" size="small" @click="fetchUtilData">查询</el-button>
138 </div> 145 </div>
139 146
140 <div class="util-top-charts"> 147 <div class="util-top-charts">
141 <div class="pie-card"> 148 <div class="pie-card">
142 <div class="pie-title">总稼动率:</div> 149 <div class="pie-title">总稼动率:</div>
143 - <div class="pie-chart-svg">  
144 - <svg viewBox="0 0 200 180"><circle cx="90" cy="90" r="70" fill="none" stroke="#ddd" stroke-width="35"/></svg>  
145 - <div class="pie-empty-text">暂无数据</div>  
146 - </div> 150 + <div class="pie-canvas-wrap"><canvas ref="utilTotalPieCanvasRef"></canvas></div>
147 </div> 151 </div>
148 <div class="pie-card"> 152 <div class="pie-card">
149 <div class="pie-title">当前机台运行状态:</div> 153 <div class="pie-title">当前机台运行状态:</div>
150 - <div class="pie-chart-svg">  
151 - <svg viewBox="0 0 200 180"><circle cx="90" cy="90" r="70" fill="none" stroke="#909399" stroke-width="35" stroke-dasharray="440 440" transform="rotate(-90 90 90)"/>  
152 - <text x="130" y="85" text-anchor="middle" font-size="12" fill="#333"><tspan>x</tspan> 离线</text>  
153 - </svg>  
154 - <div class="pie-legend center-leg">  
155 - <span class="leg-item"><i class="dot g"></i>绿灯</span>  
156 - <span class="leg-item"><i class="dot r"></i>红灯</span>  
157 - <span class="leg-item"><i class="dot gy"></i>离线</span>  
158 - </div>  
159 - </div> 154 + <div class="pie-canvas-wrap"><canvas ref="utilStatusPieCanvasRef"></canvas></div>
160 </div> 155 </div>
161 <div class="bar-card"> 156 <div class="bar-card">
162 <div class="pie-title">异常机台排名:</div> 157 <div class="pie-title">异常机台排名:</div>
163 - <div class="abnormal-list"></div>  
164 - <div class="abn-legend" style="margin-top:auto;"><i class="dot y"></i>待机 &nbsp; <i class="dot r"></i>停机</div> 158 + <div class="bar-canvas-wrap" style="position:relative;">
  159 + <canvas ref="utilRankCanvasRef" @mousemove="onUtilRankHover" @mouseleave="onUtilRankLeave"></canvas>
  160 + <div v-if="utilRankHover.show" class="rank-tooltip" :style="{ left: utilRankHover.x + 'px', top: utilRankHover.y + 'px' }">
  161 + <div class="rtt-name">{{ utilRankHover.deviceName }}</div>
  162 + <div class="rtt-row"><i class="dot y"></i>待机<span>{{ utilRankHover.s2Label }}</span></div>
  163 + <div class="rtt-row"><i class="dot r"></i>停机<span>{{ utilRankHover.s1Label }}</span></div>
  164 + </div>
  165 + </div>
165 </div> 166 </div>
166 </div> 167 </div>
167 168
168 <div class="util-bottom-chart"> 169 <div class="util-bottom-chart">
169 - <div class="stack-bar-toolbar">  
170 - <span>排序:</span>  
171 - <el-radio-group v-model="sortMode" size="small">  
172 - <el-radio-button value="duration">绿灯时长</el-radio-button>  
173 - <el-radio-button value="rate" checked>稼动率</el-radio-button>  
174 - </el-radio-group>  
175 - </div>  
176 <div class="stack-bar-legend"> 170 <div class="stack-bar-legend">
177 <span class="leg-item"><i class="dot g"></i>运行</span> 171 <span class="leg-item"><i class="dot g"></i>运行</span>
178 <span class="leg-item"><i class="dot y"></i>待机</span> 172 <span class="leg-item"><i class="dot y"></i>待机</span>
179 <span class="leg-item"><i class="dot r"></i>停机</span> 173 <span class="leg-item"><i class="dot r"></i>停机</span>
180 <span class="leg-item"><i class="dot gy"></i>离线</span> 174 <span class="leg-item"><i class="dot gy"></i>离线</span>
181 </div> 175 </div>
182 - <div class="stack-bar-chart">  
183 - <svg viewBox="0 0 1400 280">  
184 - <g font-size="10" fill="#999" text-anchor="end">  
185 - <text x="28" y="18">3时</text><text x="28" y="73">3时</text>  
186 - <text x="28" y="128">2时</text><text x="28" y="183">1时</text><text x="28" y="238">0时</text>  
187 - </g>  
188 - <line x1="36" y1="240" x2="1380" y2="240" stroke="#ddd" stroke-width="1"/>  
189 - <template v-for="(col, ci) in energyStackBarData" :key="ci">  
190 - <rect :x="200+ci*80" :y="240-col.g*60" width="40" :height="col.g*60" fill="#67c23a" rx="1"/>  
191 - <rect :x="200+ci*80" :y="240-(col.g+col.y)*60" width="40" :height="col.y*60" fill="#e6a23c" rx="1"/>  
192 - <rect :x="200+ci*80" :y="240-(col.g+col.y+col.r)*60" width="40" :height="col.r*60" fill="#f56c6c" rx="1"/>  
193 - <rect :x="200+ci*80" :y="240-(col.g+col.y+col.r+col.gy)*60" width="40" :height="col.gy*60" fill="#909399" rx="1"/>  
194 - <text :x="220+ci*80" y="258" text-anchor="middle" font-size="9" fill="#666">{{ col.name }}</text>  
195 - </template>  
196 - </svg> 176 + <div class="stack-bar-chart canvas-stack-bar" style="position:relative;">
  177 + <canvas ref="utilStackBarCanvasRef" @mousemove="onUtilStackHover" @mouseleave="onUtilStackLeave"></canvas>
  178 + <div v-if="utilStackHover.show" class="stack-tooltip" :style="{ left: utilStackHover.x + 'px', top: utilStackHover.y + 'px' }">
  179 + <div class="stt-name">{{ utilStackHover.deviceName }}</div>
  180 + <div class="stt-row"><i class="dot g"></i>运行<span>{{ utilStackHover.s3Label }}</span></div>
  181 + <div class="stt-row"><i class="dot y"></i>待机<span>{{ utilStackHover.s2Label }}</span></div>
  182 + <div class="stt-row"><i class="dot r"></i>停机<span>{{ utilStackHover.s1Label }}</span></div>
  183 + <div class="stt-row"><i class="dot gy"></i>离线<span>{{ utilStackHover.s0Label }}</span></div>
  184 + </div>
197 </div> 185 </div>
198 </div> 186 </div>
199 </div> 187 </div>
@@ -695,14 +683,524 @@ function initTsObserver() { @@ -695,14 +683,524 @@ function initTsObserver() {
695 function destroyTsObserver() { if (tsResizeObs) { tsResizeObs.disconnect(); tsResizeObs = null } } 683 function destroyTsObserver() { if (tsResizeObs) { tsResizeObs.disconnect(); tsResizeObs = null } }
696 onBeforeUnmount(() => destroyTsObserver()) 684 onBeforeUnmount(() => destroyTsObserver())
697 685
698 -// ========== 稼动率数据 ========== 686 +// ========== 稼动率:Canvas多图表 ==========
  687 +const UTIL_COLORS = { 0: '#909399', 1: '#f56c6c', 2: '#e6a23c', 3: '#67c23a' }
  688 +const UTIL_STATUS_LABEL = { 0: '离线', 1: '停机', 2: '待机', 3: '运行' }
  689 +
699 const utilQueryMode = ref('day') 690 const utilQueryMode = ref('day')
700 -const utilDate = ref('2026-04-28') 691 +const utilDayDate = ref(new Date().toISOString().slice(0, 10))
  692 +const utilWeekDate = ref(new Date().toISOString().slice(0, 10))
  693 +const utilMonthDate = ref(new Date().toISOString().slice(0, 7))
701 const sortMode = ref('rate') 694 const sortMode = ref('rate')
702 -const energyStackBarData = computed(() => [  
703 - { name: '磨粉设备1', g: 3.5, y: 0, r: 0, gy: 1 },  
704 - { name: '磨粉设备2', g: 3.5, y: 0, r: 0, gy: 1 }  
705 -]) 695 +
  696 +// Canvas refs
  697 +const utilTotalPieCanvasRef = ref(null)
  698 +const utilStatusPieCanvasRef = ref(null)
  699 +const utilRankCanvasRef = ref(null)
  700 +const utilStackBarCanvasRef = ref(null)
  701 +let utilResizeObs = null
  702 +
  703 +// 堆积柱状图 hover
  704 +const utilStackHover = reactive({ show: false, x: 0, y: 0, deviceName: '', s3Label: '', s2Label: '', s1Label: '', s0Label: '' })
  705 +let stackHitRects = []
  706 +
  707 +// 接口数据
  708 +const utilData = reactive({
  709 + currentStatus: {},
  710 + deviceList: [],
  711 + summary: {},
  712 + abnormalRanking: []
  713 +})
  714 +
  715 +// 异常机台排名 hover 状态
  716 +const utilRankHover = reactive({ show: false, x: 0, y: 0, deviceName: '', s1Label: '', s2Label: '' })
  717 +let rankHitRects = []
  718 +
  719 +function disabledMonthFuture(time) {
  720 + const now = new Date()
  721 + return time.getFullYear() > now.getFullYear() || (time.getFullYear() === now.getFullYear() && time.getMonth() > now.getMonth())
  722 +}
  723 +
  724 +async function fetchUtilData() {
  725 + let startDate, endDate
  726 + if (utilQueryMode.value === 'day') {
  727 + startDate = utilDayDate.value
  728 + endDate = utilDayDate.value
  729 + } else if (utilQueryMode.value === 'week') {
  730 + const d = new Date(utilWeekDate.value)
  731 + const day = d.getDay() || 7
  732 + const mon = new Date(d)
  733 + mon.setDate(d.getDate() - day + 1)
  734 + const sun = new Date(mon)
  735 + sun.setDate(mon.getDate() + 6)
  736 + startDate = mon.toISOString().slice(0, 10)
  737 + endDate = sun.toISOString().slice(0, 10)
  738 + } else {
  739 + startDate = utilMonthDate.value + '-01'
  740 + const [y, m] = utilMonthDate.value.split('-')
  741 + const lastDay = new Date(parseInt(y), parseInt(m), 0).getDate()
  742 + endDate = utilMonthDate.value + '-' + String(lastDay).padStart(2, '0')
  743 + }
  744 + try {
  745 + const res = await fetch(`/api/energy/eqKwhStatistics?startDate=${startDate}&endDate=${endDate}`)
  746 + const result = await res.json()
  747 + if (result.code === 200) {
  748 + const data = result.data || {}
  749 + Object.assign(utilData.currentStatus, data.currentStatus || {})
  750 + utilData.deviceList = data.deviceList || []
  751 + Object.assign(utilData.summary, data.summary || {})
  752 + utilData.abnormalRanking = data.abnormalRanking || []
  753 + await nextTick()
  754 + drawUtilAll()
  755 + }
  756 + } catch (err) {
  757 + console.error('获取稼动率数据失败:', err)
  758 + }
  759 +}
  760 +
  761 +function drawUtilAll() { drawUtilTotalPie(); drawUtilStatusPie(); drawUtilRankBar(); drawUtilStackBar(); }
  762 +
  763 +// ---- 总稼动率饼图(普通饼图,不含离线) ----
  764 +function drawUtilTotalPie() {
  765 + const canvas = utilTotalPieCanvasRef.value; if (!canvas) return
  766 + const wrap = canvas.parentElement; if (!wrap) return
  767 + const w = wrap.clientWidth, h = wrap.clientHeight || 220
  768 + canvas.width = w * getDpr(); canvas.height = h * getDpr()
  769 + canvas.style.width = w + 'px'; canvas.style.height = h + 'px'
  770 + const ctx = canvas.getContext('2d'); ctx.scale(getDpr(), getDpr())
  771 + ctx.clearRect(0, 0, w, h)
  772 +
  773 + const summary = utilData.summary
  774 + const totalDur = summary.totalStatusDuration || {}
  775 + const s1 = totalDur.status1?.durationSeconds || 0
  776 + const s2 = totalDur.status2?.durationSeconds || 0
  777 + const s3 = totalDur.status3?.durationSeconds || 0
  778 + const total = s1 + s2 + s3
  779 +
  780 + // 饼图居中偏左,给右侧图例留空间,半径更大
  781 + const cx = w * 0.4, cy = h / 2, R = Math.min(cx, cy) * 0.85
  782 +
  783 + if (total <= 0) {
  784 + ctx.fillStyle = '#c0c4cc'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
  785 + ctx.fillText('暂无数据', cx, cy)
  786 + return
  787 + }
  788 +
  789 + // 扇区:运行、待机、停机(不含离线)
  790 + const segs = [
  791 + { val: s3, color: UTIL_COLORS[3], label: '运行' },
  792 + { val: s2, color: UTIL_COLORS[2], label: '待机' },
  793 + { val: s1, color: UTIL_COLORS[1], label: '停机' },
  794 + ]
  795 +
  796 + let startA = -Math.PI / 2
  797 + segs.forEach(seg => {
  798 + const sweep = (seg.val / total) * Math.PI * 2
  799 + if (seg.val > 0 && sweep > 0.02) {
  800 + ctx.beginPath()
  801 + ctx.moveTo(cx, cy)
  802 + ctx.arc(cx, cy, R, startA, startA + sweep)
  803 + ctx.closePath()
  804 + ctx.fillStyle = seg.color; ctx.fill()
  805 +
  806 + // 标签:百分比 + 状态名:时长(水平居中)
  807 + if (sweep > 0.25 || seg.val / total > 0.15) {
  808 + const midA = startA + sweep / 2
  809 + const lr = R * 0.6
  810 + const tx = cx + Math.cos(midA) * lr, ty = cy + Math.sin(midA) * lr
  811 + const pct = ((seg.val / total) * 100).toFixed(2).replace(/\.?0+$/, '') + '%'
  812 + const durHrs = (seg.val / 3600).toFixed(2).replace(/\.?0+$/, '')
  813 + const durLabel = `${seg.label}:${durHrs}时`
  814 + ctx.fillStyle = '#fff'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
  815 + // 水平显示,不旋转
  816 + ctx.fillText(pct, tx, ty - 7)
  817 + ctx.font = '11px sans-serif'
  818 + ctx.fillText(durLabel, tx, ty + 8)
  819 + }
  820 + } else if (seg.val > 0) {
  821 + ctx.beginPath(); ctx.moveTo(cx, cy)
  822 + ctx.arc(cx, cy, R, startA, startA + sweep)
  823 + ctx.closePath()
  824 + ctx.fillStyle = seg.color; ctx.fill()
  825 + }
  826 + startA += sweep
  827 + })
  828 +
  829 + // 右侧图例(仅运行/待机/停机),向中间靠拢
  830 + const legX = cx + R + 20, legStartY = cy - 30
  831 + ;segs.map(s => ({ c: s.color, l: s.label })).forEach((leg, i) => {
  832 + const ly = legStartY + i * 24
  833 + ctx.fillStyle = leg.c; roundRect(ctx, legX, ly - 6, 12, 12, 2); ctx.fill()
  834 + ctx.fillStyle = '#666'; ctx.font = '12px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'
  835 + ctx.fillText(leg.l, legX + 18, ly)
  836 + })
  837 +}
  838 +
  839 +function drawUtilLegend(ctx, cx, ly) {
  840 + ctx.textAlign = 'center'
  841 + ;[{ c: UTIL_COLORS[3], l: '运行' }, { c: UTIL_COLORS[2], l: '待机' }, { c: UTIL_COLORS[1], l: '停机' }, { c: UTIL_COLORS[0], l: '离线' }].forEach((leg, i) => {
  842 + const lx = cx - 60 + i * 40
  843 + ctx.fillStyle = leg.c; roundRect(ctx, lx - 4, ly - 4, 10, 10, 2); ctx.fill()
  844 + ctx.fillStyle = '#666'; ctx.font = '11px sans-serif'; ctx.fillText(leg.l, lx + 7, ly + 4)
  845 + })
  846 +}
  847 +
  848 +// ---- 当前机台运行状态饼图(普通,图例在右侧) ----
  849 +function drawUtilStatusPie() {
  850 + const canvas = utilStatusPieCanvasRef.value; if (!canvas) return
  851 + const wrap = canvas.parentElement; if (!wrap) return
  852 + const w = wrap.clientWidth, h = wrap.clientHeight || 220
  853 + canvas.width = w * getDpr(); canvas.height = h * getDpr()
  854 + canvas.style.width = w + 'px'; canvas.style.height = h + 'px'
  855 + const ctx = canvas.getContext('2d'); ctx.scale(getDpr(), getDpr())
  856 + ctx.clearRect(0, 0, w, h)
  857 +
  858 + const cs = utilData.currentStatus
  859 + const v3 = parseInt(cs['3']) || 0
  860 + const v2 = parseInt(cs['2']) || 0
  861 + const v1 = parseInt(cs['1']) || 0
  862 + const v0 = parseInt(cs['0']) || 0
  863 + const total = v0 + v1 + v2 + v3
  864 +
  865 + // 饼图居中偏左,与总稼动率一致的大小和布局
  866 + const cx = w * 0.4, cy = h / 2, R = Math.min(cx, cy) * 0.85
  867 +
  868 + if (total <= 0) {
  869 + ctx.fillStyle = '#c0c4cc'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
  870 + ctx.fillText('暂无数据', cx, cy); return
  871 + }
  872 +
  873 + // 全部4个状态
  874 + const segs = [
  875 + { val: v3, color: UTIL_COLORS[3], label: '运行' },
  876 + { val: v2, color: UTIL_COLORS[2], label: '待机' },
  877 + { val: v1, color: UTIL_COLORS[1], label: '停机' },
  878 + { val: v0, color: UTIL_COLORS[0], label: '离线' },
  879 + ]
  880 +
  881 + let startA = -Math.PI / 2
  882 + segs.forEach(seg => {
  883 + const sweep = (seg.val / total) * Math.PI * 2
  884 + if (seg.val > 0 && sweep > 0.02) {
  885 + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, R, startA, startA + sweep); ctx.closePath()
  886 + ctx.fillStyle = seg.color; ctx.fill()
  887 +
  888 + // 标签水平显示:状态名X台
  889 + if (sweep > 0.25 || seg.val / total > 0.15) {
  890 + const midA = startA + sweep / 2
  891 + const lr = R * 0.6
  892 + const tx = cx + Math.cos(midA) * lr, ty = cy + Math.sin(midA) * lr
  893 + const labelText = `${seg.label}${seg.val}台`
  894 + ctx.fillStyle = '#fff'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
  895 + ctx.fillText(labelText, tx, ty)
  896 + }
  897 + } else if (seg.val > 0) {
  898 + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, R, startA, startA + sweep); ctx.closePath()
  899 + ctx.fillStyle = seg.color; ctx.fill()
  900 + }
  901 + startA += sweep
  902 + })
  903 +
  904 + // 右侧图例(全部4项)
  905 + const legX = cx + R + 20, legStartY = cy - 36
  906 + segs.forEach((leg, i) => {
  907 + const ly = legStartY + i * 24
  908 + ctx.fillStyle = leg.color; roundRect(ctx, legX, ly - 6, 12, 12, 2); ctx.fill()
  909 + ctx.fillStyle = '#666'; ctx.font = '12px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'
  910 + ctx.fillText(leg.label, legX + 18, ly)
  911 + })
  912 +}
  913 +
  914 +// ---- 异常机台排名(横向堆叠条形,仅停机+待机) ----
  915 +function drawUtilRankBar() {
  916 + const canvas = utilRankCanvasRef.value; if (!canvas) return
  917 + const wrap = canvas.parentElement; if (!wrap) return
  918 + const w = wrap.clientWidth
  919 + // 高度按实际数据行数计算,不留多余空白
  920 + const list = utilData.abnormalRanking
  921 + const rowCount = Math.min(list.length, 6)
  922 + const h = Math.max((rowCount === 0 ? 0 : rowCount * 28 + 12 + 20), 60)
  923 + canvas.width = w * getDpr(); canvas.height = h * getDpr()
  924 + canvas.style.width = w + 'px'; canvas.style.height = h + 'px'
  925 + const ctx = canvas.getContext('2d'); ctx.scale(getDpr(), getDpr())
  926 + ctx.clearRect(0, 0, w, h)
  927 +
  928 + rankHitRects = []
  929 +
  930 + if (!list.length) {
  931 + ctx.fillStyle = '#c0c4cc'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
  932 + ctx.fillText('暂无数据', w / 2, h / 2); return
  933 + }
  934 +
  935 + const padL = 100, padT = 12, padB = 20, barH = 22, gap = 6
  936 + const legW = 55 // 右侧图例宽度
  937 + const chartW = w - padL - legW - 10
  938 + const chartH = h - padT - padB
  939 +
  940 + // X轴基准:取数据中 s1+s2 的最大值,向上取整到漂亮数字
  941 + const rawMaxSec = Math.max(...list.slice(0, 6).map(d =>
  942 + (Number(d.status1?.durationSeconds || 0)) + (Number(d.status2?.durationSeconds || 0))), 1)
  943 + const axisMaxSec = niceAxisMaxHours(rawMaxSec / 3600) * 3600
  944 +
  945 + // 网格线
  946 + ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1
  947 + for (let g = 1; g <= 5; g++) {
  948 + const gy = padT + chartH * (g / 5)
  949 + ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(padL + chartW, gy); ctx.stroke()
  950 + }
  951 +
  952 + list.slice(0, 6).forEach((item, i) => {
  953 + const y = padT + i * (barH + gap)
  954 + // 设备名
  955 + ctx.fillStyle = '#303133'; ctx.font = '11px sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'
  956 + ctx.fillText(item.deviceName || item.dtuSn || '-', padL - 8, y + barH / 2)
  957 +
  958 + // 背景条(浅灰)
  959 + ctx.fillStyle = '#fafafa'; roundRect(ctx, padL, y, chartW, barH, 2); ctx.fill()
  960 +
  961 + // 取停机/待机时长(秒)
  962 + const s1 = Number(item.status1?.durationSeconds ?? 0) || 0
  963 + const s2 = Number(item.status2?.durationSeconds ?? 0) || 0
  964 +
  965 + let px = padL
  966 + const hitInfo = { x: padL, y, w: 0, h: barH, deviceName: item.deviceName || item.dtuSn || '', s1, s2 }
  967 + // 先画待机(橙#e6a23c),再画停机(红#f56c6c)
  968 + ;[[s2, UTIL_COLORS[2]], [s1, UTIL_COLORS[1]]].forEach(([sec, col]) => {
  969 + if (sec <= 0) return
  970 + const sw = Math.max(chartW * (sec / axisMaxSec), 3)
  971 + ctx.fillStyle = col
  972 + roundRect(ctx, px, y, sw, barH, 2)
  973 + ctx.fill()
  974 + px += sw
  975 + hitInfo.w = px - padL
  976 + })
  977 +
  978 + rankHitRects.push(hitInfo)
  979 + })
  980 +
  981 + // X轴时间标签(根据实际最大值动态显示)
  982 + for (let t = 0; t <= 4; t++) {
  983 + const valSec = (t / 4) * axisMaxSec
  984 + const gx = padL + (t / 4) * chartW
  985 + ctx.fillStyle = '#909399'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'
  986 + ctx.fillText(formatHoursShort(valSec), gx, h - padB + 6)
  987 + }
  988 +
  989 + // 右侧图例(带颜色的方块+文字)
  990 + const legX = padL + chartW + 12, legStartY = padT + 10
  991 + ;[
  992 + { color: UTIL_COLORS[2], label: '待机' },
  993 + { color: UTIL_COLORS[1], label: '停机' },
  994 + ].forEach((leg, i) => {
  995 + const ly = legStartY + i * 22
  996 + ctx.fillStyle = leg.color
  997 + roundRect(ctx, legX, ly - 6, 11, 11, 2)
  998 + ctx.fill()
  999 + ctx.fillStyle = '#606266'
  1000 + ctx.font = '12px sans-serif'
  1001 + ctx.textAlign = 'left'
  1002 + ctx.textBaseline = 'middle'
  1003 + ctx.fillText(leg.label, legX + 16, ly)
  1004 + })
  1005 +}
  1006 +
  1007 +function onUtilRankHover(e) {
  1008 + const canvas = utilRankCanvasRef.value; if (!canvas) return
  1009 + const rect = canvas.getBoundingClientRect()
  1010 + const mx = e.clientX - rect.left, my = e.clientY - rect.top
  1011 + let hit = null
  1012 + for (const r of rankHitRects) {
  1013 + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { hit = r; break }
  1014 + }
  1015 + if (hit) {
  1016 + utilRankHover.deviceName = hit.deviceName
  1017 + const totalS12 = hit.s1 + hit.s2
  1018 + const s2Pct = totalS12 > 0 ? ((hit.s2 / totalS12) * 100).toFixed(0) : '0'
  1019 + const s1Pct = totalS12 > 0 ? ((hit.s1 / totalS12) * 100).toFixed(0) : '0'
  1020 + utilRankHover.s2Label = `${tsFormatDuration(hit.s2)}(${s2Pct}%)`
  1021 + utilRankHover.s1Label = `${tsFormatDuration(hit.s1)}(${s1Pct}%)`
  1022 + if (!utilRankHover.show) {
  1023 + utilRankHover.show = true
  1024 + let tx = mx + 12, ty = my - 80
  1025 + if (tx + 180 > rect.width) tx = mx - 190
  1026 + if (ty < 10) ty = my + 16
  1027 + utilRankHover.x = tx; utilRankHover.y = ty
  1028 + }
  1029 + } else {
  1030 + utilRankHover.show = false
  1031 + }
  1032 +}
  1033 +function onUtilRankLeave() { utilRankHover.show = false }
  1034 +
  1035 +function formatHoursShort(sec) {
  1036 + if (!sec) return ''
  1037 + sec = Number(sec)
  1038 + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60)
  1039 + if (h > 0) return h + '.' + String(Math.round(m/60*10)) + '时'
  1040 + if (m > 0) return m + '分'
  1041 + return sec + '秒'
  1042 +}
  1043 +
  1044 +// ---- 设备状态时长堆积柱状图 ----
  1045 +function drawUtilStackBar() {
  1046 + const canvas = utilStackBarCanvasRef.value; if (!canvas) return
  1047 + const wrap = canvas.parentElement; if (!wrap) return
  1048 + const w = wrap.clientWidth, h = wrap.clientHeight || 260
  1049 + canvas.width = w * getDpr(); canvas.height = h * getDpr()
  1050 + canvas.style.width = w + 'px'; canvas.style.height = h + 'px'
  1051 + const ctx = canvas.getContext('2d'); ctx.scale(getDpr(), getDpr())
  1052 + ctx.clearRect(0, 0, w, h)
  1053 +
  1054 + let list = [...utilData.deviceList]
  1055 + if (sortMode.value === 'rate') {
  1056 + list.sort((a, b) => (b.availabilityRateValue || 0) - (a.availabilityRateValue || 0))
  1057 + } else {
  1058 + list.sort((a, b) => ((b.status3?.durationSeconds||0)) - ((a.status3?.durationSeconds||0)))
  1059 + }
  1060 +
  1061 + stackHitRects = []
  1062 + if (!list.length) {
  1063 + ctx.fillStyle = '#c0c4cc'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
  1064 + ctx.fillText('暂无数据', w / 2, h / 2); return
  1065 + }
  1066 +
  1067 + const padL = 44, padR = 14, padT = 22, padB = 24 // padB给柱子下方设备名留空间
  1068 + const chartW = w - padL - padR
  1069 + const chartH = h - padT - padB
  1070 + const maxCols = Math.min(list.length, 12)
  1071 + const barW = Math.min(Math.max(chartW / maxCols * 0.55, 24), 50)
  1072 + const totalBarsW = barW * list.length
  1073 + const barGap = list.length > 1 ? (chartW - totalBarsW) / (list.length + 1) : chartW / 2 - barW / 2
  1074 +
  1075 + // Y轴:自动找合适的最大值和刻度
  1076 + const rawMaxHrs = Math.max(...list.map(d => (d.totalDurationSeconds || 0) / 3600), 1)
  1077 + const yMax = niceAxisMax(rawMaxHrs)
  1078 + const yTicks = 5
  1079 +
  1080 + // Y轴网格和刻度
  1081 + ctx.strokeStyle = '#ebeef5'; ctx.lineWidth = 1; ctx.font = '10px sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'
  1082 + for (let i = 0; i <= yTicks; i++) {
  1083 + const vy = padT + chartH - (i / yTicks) * chartH
  1084 + const val = (i / yTicks) * yMax
  1085 + ctx.beginPath(); ctx.moveTo(padL, vy); ctx.lineTo(w - padR, vy); ctx.stroke()
  1086 + ctx.fillStyle = '#999'; ctx.fillText(val >= 1 ? val.toFixed(0) + '时' : (val * 60).toFixed(0) + '分', padL - 5, vy)
  1087 + }
  1088 +
  1089 + // 基线
  1090 + ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1
  1091 + ctx.beginPath(); ctx.moveTo(padL, padT + chartH); ctx.lineTo(w - padR, padT + chartH); ctx.stroke()
  1092 +
  1093 + const scale = chartH / yMax
  1094 +
  1095 + list.forEach((item, i) => {
  1096 + const bx = padL + barGap + i * (barW + barGap)
  1097 + const s0s = item.status0?.durationSeconds || 0
  1098 + const s1s = item.status1?.durationSeconds || 0
  1099 + const s2s = item.status2?.durationSeconds || 0
  1100 + const s3s = item.status3?.durationSeconds || 0
  1101 +
  1102 + let by = padT + chartH
  1103 + // 从下到上:运行、待机、停机、离线
  1104 + ;[
  1105 + { sec: s3s, color: UTIL_COLORS[3] },
  1106 + { sec: s2s, color: UTIL_COLORS[2] },
  1107 + { sec: s1s, color: UTIL_COLORS[1] },
  1108 + { sec: s0s, color: UTIL_COLORS[0] },
  1109 + ].reverse().forEach(seg => {
  1110 + const sh = seg.sec / 3600 * scale
  1111 + by -= sh
  1112 + if (sh > 0.5) { ctx.fillStyle = seg.color; roundRect(ctx, bx, by, barW, sh, 0); ctx.fill() }
  1113 + })
  1114 +
  1115 + // 设备名:水平显示在柱子正下方
  1116 + ctx.fillStyle = '#606266'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'
  1117 + const dn = item.deviceName || item.dtuSn || ''
  1118 + // 截取后几位,确保不超出柱子宽度
  1119 + ctx.fillText(dn.length > 12 ? dn.slice(-12) : dn, bx + barW / 2, padT + chartH + 6)
  1120 +
  1121 + // 存储hover区域
  1122 + stackHitRects.push({ x: bx, y: padT, w: barW, h: chartH, deviceName: dn, s0: s0s, s1: s1s, s2: s2s, s3: s3s })
  1123 + })
  1124 +}
  1125 +
  1126 +function onUtilStackHover(e) {
  1127 + const canvas = utilStackBarCanvasRef.value; if (!canvas) return
  1128 + const rect = canvas.getBoundingClientRect()
  1129 + const wrap = canvas.parentElement
  1130 + const mx = e.clientX - rect.left, my = e.clientY - rect.top
  1131 + let hit = null
  1132 + for (const r of stackHitRects) {
  1133 + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { hit = r; break }
  1134 + }
  1135 + if (hit) {
  1136 + utilStackHover.deviceName = hit.deviceName
  1137 + const total = hit.s0 + hit.s1 + hit.s2 + hit.s3
  1138 + utilStackHover.s3Label = `${tsFormatDuration(hit.s3)}${total > 0 ? '(' + ((hit.s3/total)*100).toFixed(1) + '%)' : ''}`
  1139 + utilStackHover.s2Label = `${tsFormatDuration(hit.s2)}${total > 0 ? '(' + ((hit.s2/total)*100).toFixed(1) + '%)' : ''}`
  1140 + utilStackHover.s1Label = `${tsFormatDuration(hit.s1)}${total > 0 ? '(' + ((hit.s1/total)*100).toFixed(1) + '%)' : ''}`
  1141 + utilStackHover.s0Label = `${tsFormatDuration(hit.s0)}${total > 0 ? '(' + ((hit.s0/total)*100).toFixed(1) + '%)' : ''}`
  1142 + // 定位:优先显示在柱子上方偏右,超出则改到下方或左侧
  1143 + const tipW = 200, tipH = 130
  1144 + let tx = mx + 14, ty = my - tipH - 8
  1145 + if (tx + tipW > rect.width - 4) tx = mx - tipW - 6
  1146 + if (ty < 4) ty = my + 14
  1147 + utilStackHover.x = Math.max(4, tx); utilStackHover.y = Math.max(4, ty)
  1148 + utilStackHover.show = true
  1149 + } else {
  1150 + utilStackHover.show = false
  1151 + }
  1152 +}
  1153 +function onUtilStackLeave() { utilStackHover.show = false }
  1154 +
  1155 +// 计算合适的Y轴最大值(向上取整到整数,方便展示)
  1156 +function niceAxisMax(val) {
  1157 + if (val <= 0) return 10
  1158 + let max = Math.ceil(val)
  1159 + if (max <= 1) return 1
  1160 + if (max <= 2) return 2
  1161 + if (max <= 5) return 5
  1162 + if (max <= 10) return 10
  1163 + if (max <= 12) return 12
  1164 + if (max <= 15) return 15
  1165 + if (max <= 20) return 20
  1166 + return Math.ceil(max / 5) * 5
  1167 +}
  1168 +
  1169 +// X轴小时基准:根据实际最大小时数取整(支持月查询的大数值)
  1170 +function niceAxisMaxHours(hrs) {
  1171 + if (hrs <= 0) return 12
  1172 + const m = Math.ceil(hrs)
  1173 + if (m <= 6) return 6
  1174 + if (m <= 12) return 12
  1175 + if (m <= 24) return 24
  1176 + if (m <= 48) return 48
  1177 + if (m <= 72) return 72
  1178 + if (m <= 120) return 120
  1179 + if (m <= 168) return 168 // 一周
  1180 + if (m <= 336) return 336 // 两周
  1181 + if (m <= 720) return 720 // 30天
  1182 + return Math.ceil(m / 120) * 120
  1183 +}
  1184 +
  1185 +// 稼动率 tab 初始化
  1186 +watch(currentStatus, async (val) => {
  1187 + if (val === 'utilization') {
  1188 + await nextTick()
  1189 + initUtilObserver()
  1190 + fetchUtilData()
  1191 + } else {
  1192 + destroyUtilObserver()
  1193 + }
  1194 +})
  1195 +
  1196 +function initUtilObserver() {
  1197 + destroyUtilObserver()
  1198 + utilResizeObs = new ResizeObserver(() => { if (currentStatus.value === 'utilization') drawUtilAll() })
  1199 + const el = document.querySelector('.util-view')
  1200 + if (el) utilResizeObs.observe(el)
  1201 +}
  1202 +function destroyUtilObserver() { if (utilResizeObs) { utilResizeObs.disconnect(); utilResizeObs = null } }
  1203 +onBeforeUnmount(() => destroyUtilObserver())
706 1204
707 // ========== 能耗效率数据 ========== 1205 // ========== 能耗效率数据 ==========
708 const effQueryMode = ref('day') 1206 const effQueryMode = ref('day')
@@ -727,10 +1225,12 @@ const effLine2Points = computed(() => { @@ -727,10 +1225,12 @@ const effLine2Points = computed(() => {
727 <style scoped> 1225 <style scoped>
728 .energy-page { 1226 .energy-page {
729 min-height: 100%; 1227 min-height: 100%;
730 - height: calc(100vh - 0px); 1228 + height: 100vh;
731 display: flex; 1229 display: flex;
732 flex-direction: column; 1230 flex-direction: column;
733 background-color: #f0f2f5; 1231 background-color: #f0f2f5;
  1232 + overflow: hidden;
  1233 + min-width: 1200px;
734 } 1234 }
735 .device-grid { 1235 .device-grid {
736 flex: 1; 1236 flex: 1;
@@ -1134,10 +1634,63 @@ const effLine2Points = computed(() => { @@ -1134,10 +1634,63 @@ const effLine2Points = computed(() => {
1134 .gtt-val { color:#eef1f7; font-weight:500; display:flex; align-items:center; gap:4px; } 1634 .gtt-val { color:#eef1f7; font-weight:500; display:flex; align-items:center; gap:4px; }
1135 .gtt-highlight { font-weight:bold; } 1635 .gtt-highlight { font-weight:bold; }
1136 1636
  1637 +/* 异常机台排名 hover tooltip */
  1638 +.rank-tooltip {
  1639 + position: absolute;
  1640 + background: #fff;
  1641 + border-radius: 6px;
  1642 + padding: 10px 14px;
  1643 + min-width: 160px;
  1644 + z-index: 200;
  1645 + pointer-events: none;
  1646 + box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  1647 + border: 1px solid #ebeef5;
  1648 +}
  1649 +.rtt-name {
  1650 + font-size: 13px; font-weight:bold; color:#303133; margin-bottom:6px; padding-bottom:6px; border-bottom:1px solid #ebeef5;
  1651 +}
  1652 +.rtt-row {
  1653 + display:flex; align-items:center; gap:8px; line-height:1.8; font-size:12px; color:#606266;
  1654 +}
  1655 +.rtt-row i { width:8px; height:8px; border-radius:2px; flex-shrink:0; }
  1656 +.rtt-row .dot.y { background:#e6a23c; }
  1657 +.rtt-row .dot.r { background:#f56c6c; }
  1658 +.rtt-row span { margin-left:auto; color:#f56c6c; font-weight:500; }
  1659 +.rtt-row span:first-of-type { color:#67c23a; }
  1660 +
  1661 +/* 堆积柱状图 hover tooltip */
  1662 +.stack-tooltip {
  1663 + position: absolute;
  1664 + background: #fff;
  1665 + border-radius: 6px;
  1666 + padding: 10px 14px;
  1667 + min-width: 180px;
  1668 + z-index: 200;
  1669 + pointer-events: none;
  1670 + box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  1671 + border: 1px solid #ebeef5;
  1672 +}
  1673 +.stt-name {
  1674 + font-size: 13px; font-weight:bold; color:#303133; margin-bottom:6px; padding-bottom:6px; border-bottom:1px solid #ebeef5;
  1675 +}
  1676 +.stt-row {
  1677 + display:flex; align-items:center; gap:8px; line-height:1.8; font-size:12px; color:#606266;
  1678 +}
  1679 +.stt-row i { width:8px; height:8px; border-radius:2px; flex-shrink:0; }
  1680 +.stt-row .dot.g { background:#67c23a; }
  1681 +.stt-row .dot.y { background:#e6a23c; }
  1682 +.stt-row .dot.r { background:#f56c6c; }
  1683 +.stt-row .dot.gy { background:#909399; }
  1684 +.stt-row span { margin-left:auto; color:#303133; font-weight:500; }
  1685 +
1137 /* ========== 稼动率 ========== */ 1686 /* ========== 稼动率 ========== */
1138 .util-view { 1687 .util-view {
1139 background: #f5f7fa; 1688 background: #f5f7fa;
1140 - overflow-y: auto; 1689 + flex: 1;
  1690 + display: flex;
  1691 + flex-direction: column;
  1692 + min-height: 0;
  1693 + overflow: hidden;
1141 } 1694 }
1142 .util-toolbar { 1695 .util-toolbar {
1143 background: #fff; 1696 background: #fff;
@@ -1152,62 +1705,50 @@ const effLine2Points = computed(() => { @@ -1152,62 +1705,50 @@ const effLine2Points = computed(() => {
1152 } 1705 }
1153 .util-top-charts { 1706 .util-top-charts {
1154 display: grid; 1707 display: grid;
1155 - grid-template-columns: repeat(4, 1fr); 1708 + grid-template-columns: repeat(3, 1fr);
1156 gap: 14px; 1709 gap: 14px;
1157 padding: 14px 20px; 1710 padding: 14px 20px;
  1711 + flex-shrink: 0;
1158 } 1712 }
1159 .pie-card, .bar-card { 1713 .pie-card, .bar-card {
1160 background: #fff; 1714 background: #fff;
1161 - border-radius: 6px;  
1162 box-shadow: 0 1px 4px rgba(0,0,0,0.06); 1715 box-shadow: 0 1px 4px rgba(0,0,0,0.06);
1163 padding: 14px; 1716 padding: 14px;
1164 display: flex; 1717 display: flex;
1165 flex-direction: column; 1718 flex-direction: column;
  1719 + min-height: 0;
  1720 + max-height: 280px;
  1721 + overflow: hidden;
1166 } 1722 }
1167 .pie-title { 1723 .pie-title {
1168 font-size: 13px; 1724 font-size: 13px;
1169 font-weight: bold; 1725 font-weight: bold;
1170 color: #333; 1726 color: #333;
1171 margin-bottom: 10px; 1727 margin-bottom: 10px;
  1728 + flex-shrink: 0;
1172 } 1729 }
1173 -.pie-chart-svg { 1730 +.pie-canvas-wrap {
1174 flex: 1; 1731 flex: 1;
  1732 + min-height: 0;
  1733 + position: relative;
1175 display: flex; 1734 display: flex;
1176 align-items: center; 1735 align-items: center;
1177 justify-content: center; 1736 justify-content: center;
1178 - min-height: 180px;  
1179 - position: relative;  
1180 } 1737 }
1181 -.pie-chart-svg svg { max-width: 200px; max-height: 180px; }  
1182 -.pie-empty-text {  
1183 - position: absolute;  
1184 - top: 50%; left: 50%;  
1185 - transform: translate(-50%,-50%);  
1186 - font-size: 14px; color: #ccc; 1738 +.pie-canvas-wrap canvas {
  1739 + display: block;
  1740 + max-width: 100%;
1187 } 1741 }
1188 -.pie-legend {  
1189 - margin-top: 8px;  
1190 - display: flex;  
1191 - gap: 10px;  
1192 - font-size: 11px;  
1193 - color: #666;  
1194 - line-height: 1.5;  
1195 -}  
1196 -.center-leg { justify-content: center; }  
1197 -.leg-item { display: inline-flex; align-items: center; gap: 3px; }  
1198 -.dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }  
1199 -.dot.g { background: #67c23a; }  
1200 -.dot.y { background: #e6a23c; }  
1201 -.dot.r { background: #f56c6c; }  
1202 -.dot.gy { background: #909399; }  
1203 -  
1204 -.abnormal-list { flex: 1; overflow-y: auto; }  
1205 -.abn-footer {  
1206 - margin-top: 6px;  
1207 - font-size: 10px;  
1208 - color: #bbb;  
1209 - text-align: right; 1742 +.bar-canvas-wrap {
  1743 + flex: 1;
  1744 + min-height: 0;
  1745 + position: relative;
  1746 +}
  1747 +.bar-canvas-wrap canvas {
  1748 + display: block;
  1749 + width: 100%;
1210 } 1750 }
  1751 +
1211 .abn-legend { 1752 .abn-legend {
1212 margin-top: 4px; 1753 margin-top: 4px;
1213 font-size: 11px; 1754 font-size: 11px;
@@ -1217,12 +1758,21 @@ const effLine2Points = computed(() => { @@ -1217,12 +1758,21 @@ const effLine2Points = computed(() => {
1217 justify-content: flex-end; 1758 justify-content: flex-end;
1218 } 1759 }
1219 1760
  1761 +.rank-leg-inline {
  1762 + display:flex; align-items:center; gap:8px; font-size:11px; color:#666; font-weight:normal;
  1763 +}
  1764 +.rank-leg-inline i { width:10px;height:10px;border-radius:2px;display:inline-block; }
  1765 +
1220 .util-bottom-chart { 1766 .util-bottom-chart {
1221 margin: 0 20px 14px; 1767 margin: 0 20px 14px;
1222 background: #fff; 1768 background: #fff;
1223 - border-radius: 6px;  
1224 box-shadow: 0 1px 4px rgba(0,0,0,0.06); 1769 box-shadow: 0 1px 4px rgba(0,0,0,0.06);
1225 padding: 14px; 1770 padding: 14px;
  1771 + flex: 1;
  1772 + display: flex;
  1773 + flex-direction: column;
  1774 + min-height: 0;
  1775 + overflow: visible;
1226 } 1776 }
1227 .stack-bar-toolbar { 1777 .stack-bar-toolbar {
1228 display: flex; 1778 display: flex;
@@ -1231,6 +1781,7 @@ const effLine2Points = computed(() => { @@ -1231,6 +1781,7 @@ const effLine2Points = computed(() => {
1231 margin-bottom: 10px; 1781 margin-bottom: 10px;
1232 font-size: 13px; 1782 font-size: 13px;
1233 color: #666; 1783 color: #666;
  1784 + flex-shrink: 0;
1234 } 1785 }
1235 .stack-bar-legend { 1786 .stack-bar-legend {
1236 display: flex; 1787 display: flex;
@@ -1238,11 +1789,23 @@ const effLine2Points = computed(() => { @@ -1238,11 +1789,23 @@ const effLine2Points = computed(() => {
1238 margin-bottom: 8px; 1789 margin-bottom: 8px;
1239 font-size: 12px; 1790 font-size: 12px;
1240 color: #666; 1791 color: #666;
  1792 + flex-shrink: 0;
1241 } 1793 }
1242 -.stack-bar-chart {  
1243 - overflow-x: auto; 1794 +.leg-item { display:flex; align-items:center; gap:4px; }
  1795 +.leg-item i { width:10px; height:10px; border-radius:2px; display:inline-block; }
  1796 +.leg-item .dot.g { background:#67c23a; }
  1797 +.leg-item .dot.y { background:#e6a23c; }
  1798 +.leg-item .dot.r { background:#f56c6c; }
  1799 +.leg-item .dot.gy { background:#909399; }
  1800 +.stack-bar-chart.canvas-stack-bar {
  1801 + flex: 1;
  1802 + min-height: 0;
  1803 + overflow: visible;
  1804 +}
  1805 +.stack-bar-chart canvas {
  1806 + display: block;
  1807 + width: 100%;
1244 } 1808 }
1245 -.stack-bar-chart svg { min-width: 100%; }  
1246 1809
1247 /* ========== 能耗效率 ========== */ 1810 /* ========== 能耗效率 ========== */
1248 .eff-view { 1811 .eff-view {