Commit cae352713e2ba15e115546f85fbeb0b9eddd6aee

Authored by 杨鸣坤
1 parent ff47c5b7

feat: 重构能耗报表为交互式图表

Showing 1 changed file with 1178 additions and 142 deletions
@@ -11,105 +11,84 @@ @@ -11,105 +11,84 @@
11 > 11 >
12 <template #header> 12 <template #header>
13 <div class="dialog-header"> 13 <div class="dialog-header">
14 - <span class="title-text">{{ device?.name || '能耗设备1' }} 能耗报表</span>  
15 - <div class="header-right">  
16 - <el-icon :size="16" style="cursor:pointer;color:#409eff;"><FullScreen /></el-icon>  
17 - <el-icon :size="16" style="cursor:pointer;color:#409eff;margin-left:8px;" @click="$emit('update:visible', false)"><Close /></el-icon>  
18 - </div> 14 + <span class="title-text">{{ device?.name || dtuSn }} 查询方式:</span>
  15 + <el-radio-group v-model="queryMode" size="small" @change="fetchData">
  16 + <el-radio-button value="day">日查询</el-radio-button>
  17 + </el-radio-group>
  18 + <el-date-picker v-model="selectedDate" type="date" placeholder="" size="small"
  19 + style="width:160px;margin-left:8px;margin-right: 8px;" value-format="YYYY-MM-DD" @change="fetchData" />
  20 + <el-button type="primary" size="small" @click="fetchData">查询</el-button>
  21 + <div style="flex:1"></div>
19 </div> 22 </div>
20 </template> 23 </template>
21 24
22 - <div class="report-body">  
23 - <!-- 上排:6个能耗卡片 + 碳排放统计 -->  
24 - <div class="top-section">  
25 - <div class="energy-cards-grid">  
26 - <div v-for="(card, idx) in energyCards" :key="idx" :class="['energy-card-item', card.color]">  
27 - <span class="card-label">{{ card.label }}</span>  
28 - <span class="card-val">{{ card.value }}<small>{{ card.unit }}</small></span>  
29 - </div> 25 + <div class="report-body" v-loading="loading">
  26 + <!-- 设备运行状态图 -->
  27 + <div class="chart-section">
  28 + <div class="section-title">
  29 + 设备运行状态图:
  30 + <span class="legend-item"><i class="dot g"></i>运行</span>
  31 + <span class="legend-item"><i class="dot r"></i>停机</span>
  32 + <span class="legend-item"><i class="dot y"></i>待机</span>
  33 + <span class="legend-item"><i class="dot gy"></i>离线</span>
30 </div> 34 </div>
31 - <div class="carbon-panel">  
32 - <div class="carbon-title">碳排放统计 <i style="font-size:12px;">▼</i></div>  
33 - <div class="carbon-sub">碳排放系数0</div>  
34 - <div v-for="item in carbonItems" :key="item.label" :class="['carbon-row', item.color]">  
35 - {{ item.label }}{{ item.val }} 35 + <div class="timeline-canvas-wrap" ref="timelineWrapRef"
  36 + @mouseenter="onTimelineMouseEnter" @mouseleave="onTimelineMouseLeave"
  37 + @wheel.prevent="onTimelineWheel">
  38 + <canvas ref="timelineCanvasRef"></canvas>
  39 + <div v-if="timelineHover.show" class="tl-tooltip" :class="{ 'tt-below': timelineHover.ttBelow }"
  40 + :style="{ left: timelineHover.x + 'px', top: timelineHover.y + 'px' }">
  41 + <div class="tt-row"><span class="tt-label">开始时间</span><span class="tt-val">{{ ttData.startTime }}</span></div>
  42 + <div class="tt-row"><span class="tt-label">结束时间</span><span class="tt-val">{{ ttData.endTime }}</span></div>
  43 + <div class="tt-row"><span class="tt-label">状态</span><span class="tt-val" :class="'status-' + ttData.runStatus"><i :class="['dot', statusDotClass(ttData.runStatus)]"></i>{{ statusLabel(ttData.runStatus) }}</span></div>
  44 + <div class="tt-row"><span class="tt-label">持续时长</span><span class="tt-val">{{ ttData.duration }}</span></div>
36 </div> 45 </div>
37 </div> 46 </div>
38 </div> 47 </div>
39 48
40 - <!-- 下排:3个图表 -->  
41 - <div class="charts-grid">  
42 - <!-- 时能耗 -->  
43 - <div class="chart-card">  
44 - <div class="chart-header">  
45 - <span class="chart-title">时能耗</span>  
46 - <div class="chart-tools">  
47 - <label><input type="radio" name="t1" checked /> 2025-04-28</label>&nbsp;  
48 - <label><input type="radio" name="t2" checked /> 昨日日期</label>  
49 - <el-icon :size="14"><ZoomIn /></el-icon>  
50 - </div>  
51 - </div>  
52 - <div class="chart-body">  
53 - <svg viewBox="0 0 500 220">  
54 - <g font-size="10" fill="#999" text-anchor="end">  
55 - <text x="24" y="20">1</text><text x="24" y="60">0.8</text><text x="24" y="100">0.6</text>  
56 - <text x="24" y="140">0.4</text><text x="24" y="180">0.2</text><text x="24" y="210">0</text>  
57 - </g>  
58 - <line x1="30" y1="206" x2="490" y2="206" stroke="#ddd"/>  
59 - <g font-size="9" fill="#666" text-anchor="middle">  
60 - <template v-for="i in 25" :key="'th'+i"><text :x="36+i*18" y="218">{{ i-1 }}</text></template>  
61 - </g>  
62 - <polyline points="36,206 54,206 72,206 90,206 108,206 126,206 144,206 162,206 180,206 198,206 216,206 234,206 252,206 270,206 288,206 306,206 324,206 342,206 360,206 378,206 396,206 414,206 432,206 450,206 468,206 486,206"  
63 - fill="none" stroke="#409eff" stroke-width="1.5"/>  
64 - </svg> 49 + <!-- 设备用电量 -->
  50 + <div class="chart-section">
  51 + <div class="section-title">设备用电量:</div>
  52 + <div class="bar-canvas-wrap" @mousemove="onBarMouseMove" @mouseleave="onBarMouseLeave">
  53 + <canvas ref="barCanvasRef"></canvas>
  54 + <div v-if="barHover.show" class="bar-tooltip" :style="{ left: barHover.x + 'px', top: barHover.y + 'px' }">
  55 + <div class="btt-row"><span class="btt-label">时段</span><span class="btt-val">{{ barTtData.hour }}时</span></div>
  56 + <div class="btt-row"><span class="btt-label">用电量</span><span class="btt-val btt-highlight">{{ barTtData.value }} kw.h</span></div>
65 </div> 57 </div>
66 </div> 58 </div>
  59 + </div>
67 60
68 - <!-- 日能耗 -->  
69 - <div class="chart-card">  
70 - <div class="chart-header">  
71 - <span class="chart-title">日能耗</span>  
72 - <div class="chart-tools">  
73 - <label><input type="radio" checked/> 2025-04</label>&nbsp;  
74 - <label><input type="radio" checked/> 昨日日期</label>  
75 - <el-icon :size="14"><ZoomIn /></el-icon>  
76 - </div>  
77 - </div>  
78 - <div class="chart-body">  
79 - <svg viewBox="0 0 500 220">  
80 - <g font-size="10" fill="#999" text-anchor="end">  
81 - <text x="24" y="20">1</text><text x="24" y="60">0.8</text><text x="24" y="100">0.6</text>  
82 - <text x="24" y="140">0.4</text><text x="24" y="180">0.2</text><text x="24" y="210">0</text>  
83 - </g>  
84 - <line x1="30" y1="206" x2="490" y2="206" stroke="#ddd"/>  
85 - <g font-size="9" fill="#666" text-anchor="middle">  
86 - <template v-for="i in 31" :key="'dh'+i"><text :x="32+(i-1)*15" y="218">{{ i }}</text></template>  
87 - </g>  
88 - <polyline fill="none" stroke="#67c23a" stroke-width="1.5"/>  
89 - </svg> 61 + <!-- 底部:明细表格 + 饼图 -->
  62 + <div class="bottom-row">
  63 + <div class="detail-panel">
  64 + <div class="section-title">设备运行状态明细</div>
  65 + <div class="detail-table-wrap">
  66 + <table class="detail-table">
  67 + <thead>
  68 + <tr><th>开始时间</th><th>状态</th><th>运行时长</th></tr>
  69 + </thead>
  70 + <tbody>
  71 + <tr v-for="(item, idx) in apiData.oeeData.list" :key="idx">
  72 + <td>{{ item.startTime ? item.startTime.slice(0, 19) : '' }}</td>
  73 + <td><i :class="['dot', statusDotClass(item.runStatus)]"></i>{{ statusLabel(item.runStatus) }}</td>
  74 + <td>{{ formatDuration(item.duration) }}</td>
  75 + </tr>
  76 + </tbody>
  77 + </table>
90 </div> 78 </div>
91 </div> 79 </div>
92 80
93 - <!-- 月能耗 -->  
94 - <div class="chart-card full-width">  
95 - <div class="chart-header">  
96 - <span class="chart-title">月能耗</span>  
97 - <div class="chart-tools">  
98 - <label><input type="radio" checked/> 2025</label>  
99 - <el-icon :size="14"><ZoomIn /></el-icon> 81 + <div class="pie-panel">
  82 + <div class="pie-canvas-wrap">
  83 + <canvas ref="pieCanvasRef" @mousemove="onPieMouseMove" @mouseleave="onPieMouseLeave"></canvas>
  84 + <div v-if="pieHover.show" class="pie-tooltip" :style="{ left: pieHover.x + 'px', top: pieHover.y + 'px' }">
  85 + <div class="ptt-row"><span class="ptt-label">状态</span><span class="ptt-val" :style="{ color: STATUS_COLORS[pieTtData.status] }"><i :class="['dot', statusDotClass(pieTtData.status)]"></i>{{ statusLabel(pieTtData.status) }}</span></div>
  86 + <div class="ptt-row"><span class="ptt-label">占比</span><span class="ptt-val ptt-highlight">{{ pieTtData.percent }}%</span></div>
100 </div> 87 </div>
101 </div> 88 </div>
102 - <div class="chart-body">  
103 - <svg viewBox="0 0 500 180">  
104 - <g font-size="10" fill="#999" text-anchor="end">  
105 - <text x="22" y="18">1</text><text x="22" y="53">0.8</text><text x="22" y="88">0.6</text>  
106 - <text x="22" y="123">0.4</text><text x="22" y="158">0.2</text>  
107 - </g>  
108 - <line x1="28" y1="164" x2="488" y2="164" stroke="#ddd"/>  
109 - <g font-size="9" fill="#666" text-anchor="middle">  
110 - <template v-for="i in 12" :key="'mh'+i"><text :x="34+(i-1)*39" y="177">{{ i }}</text></template>  
111 - </g>  
112 - </svg> 89 + <div class="stats-area">
  90 + <div class="stat-row"><span class="stat-label">总时长:</span><span class="stat-val">{{ totalDurationFormatted }}</span></div>
  91 + <div class="stat-row"><span class="stat-label">总用电量:</span><span class="stat-val">{{ totalKwh }}kw.h</span></div>
113 </div> 92 </div>
114 </div> 93 </div>
115 </div> 94 </div>
@@ -118,67 +97,1124 @@ @@ -118,67 +97,1124 @@
118 </template> 97 </template>
119 98
120 <script setup> 99 <script setup>
121 -import { ref } from 'vue'  
122 -import { FullScreen, Close, ZoomIn } from '@element-plus/icons-vue'  
123 -  
124 -defineProps({ visible: Boolean, device: Object })  
125 -defineEmits(['update:visible'])  
126 -  
127 -const energyCards = [  
128 - { label: '本小时能耗', value: '0', unit: 'kw·h', color: 'orange' },  
129 - { label: '本日能耗', value: '0', unit: 'kw·h', color: 'green' },  
130 - { label: '本月能耗', value: '0', unit: 'kw·h', color: 'blue' },  
131 - { label: '上小时能耗', value: '0', unit: 'kw·h', color: 'orange' },  
132 - { label: '昨日能耗', value: '0', unit: 'kw·h', color: 'green' },  
133 - { label: '上月能耗', value: '0', unit: 'kw·h', color: 'blue' }  
134 -]  
135 -const carbonItems = [  
136 - { label: '累计碳排放:', val: '0', color: 'blue' },  
137 - { label: '时:', val: '0.00', color: 'blue' },  
138 - { label: '日:', val: '0.00', color: 'blue' },  
139 - { label: '月:', val: '0.00', color: 'blue' }  
140 -] 100 +import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
  101 +
  102 +const props = defineProps({ visible: Boolean, device: Object })
  103 +const emit = defineEmits(['update:visible'])
  104 +
  105 +const loading = ref(false)
  106 +const queryMode = ref('day')
  107 +const selectedDate = ref(new Date().toISOString().slice(0, 10))
  108 +const unitSelect = ref('single')
  109 +const dtuSn = computed(() => props.device?._raw?.dtuSn || props.device?.name || '')
  110 +
  111 +// API 数据
  112 +const apiData = reactive({
  113 + oeeData: { list: [], statusStats: [], totalDurationSeconds: 0, totalDurationFormatted: '' },
  114 + kwhData: { list: [], totalKwh: 0 },
  115 + date: ''
  116 +})
  117 +const totalDurationFormatted = computed(() => apiData.oeeData.totalDurationFormatted || '0秒')
  118 +const totalKwh = computed(() => apiData.kwhData.totalKwh ?? 0)
  119 +
  120 +function formatDuration(seconds) {
  121 + if (!seconds && seconds !== 0) return '0秒'
  122 + seconds = Number(seconds)
  123 + if (seconds <= 0) return '0秒'
  124 + const h = Math.floor(seconds / 3600)
  125 + const m = Math.floor((seconds % 3600) / 60)
  126 + const s = seconds % 60
  127 + let str = ''
  128 + if (h > 0) str += h + '时'
  129 + if (m > 0) str += m + '分'
  130 + if (s > 0 || !str) str += s + '秒'
  131 + return str
  132 +}
  133 +
  134 +const STATUS_MAP = { 0: '离线', 1: '停机', 2: '运行', 3: '待机' }
  135 +function statusLabel(s) { return STATUS_MAP[s] || '未知' }
  136 +function statusDotClass(s) { return { 0: 'gy', 1: 'r', 2: 'g', 3: 'y' }[s] || 'gy' }
  137 +
  138 +const STATUS_COLORS = {
  139 + 0: '#909399',
  140 + 1: '#e74c3c',
  141 + 2: '#67c23a',
  142 + 3: '#c5d94e'
  143 +}
  144 +const STATUS_COLORS_HOVER = {
  145 + 0: '#7a7d82',
  146 + 1: '#d63a3a',
  147 + 2: '#4cae4c',
  148 + 3: '#b8cc38'
  149 +}
  150 +
  151 +// Canvas refs
  152 +const timelineCanvasRef = ref(null)
  153 +const timelineWrapRef = ref(null)
  154 +const barCanvasRef = ref(null)
  155 +const pieCanvasRef = ref(null)
  156 +
  157 +let resizeObserver = null
  158 +let canvasRAF = null
  159 +
  160 +// ==================== 饼图 Hover 交互 ====================
  161 +const pieHover = reactive({ show: false, x: 0, y: 0, index: -1 })
  162 +const pieTtData = computed(() => {
  163 + if (!pieHover.show || pieHover.index < 0) return { status: '-', percent: '-' }
  164 + const stats = apiData.oeeData.statusStats || []
  165 + const item = stats[pieHover.index]
  166 + if (!item) return { status: '-', percent: '-' }
  167 + return { status: item.status, percent: (item.percent || 0).toFixed(2) }
  168 +})
  169 +// 扇区角度范围缓存(用于碰撞检测)
  170 +let pieAngleRanges = []
  171 +
  172 +function onPieMouseMove(e) {
  173 + const canvas = pieCanvasRef.value
  174 + if (!canvas) return
  175 + const rect = canvas.getBoundingClientRect()
  176 + const mx = e.clientX - rect.left
  177 + const my = e.clientY - rect.top
  178 + // 转换为 canvas 内部坐标(考虑 dpr 缩放)
  179 + const scaleX = canvas.width / rect.width
  180 + const scaleY = canvas.height / rect.height
  181 + const px = mx * scaleX
  182 + const py = my * scaleY
  183 +
  184 + let hoveredIdx = -1
  185 +
  186 + // 从后往前检测
  187 + for (let i = pieAngleRanges.length - 1; i >= 0; i--) {
  188 + const seg = pieAngleRanges[i]
  189 + const dx = px - seg.cx
  190 + const dy = py - seg.cy
  191 + const dist = Math.sqrt(dx * dx + dy * dy)
  192 + if (dist > seg.radius) continue
  193 +
  194 + let mouseAngle = Math.atan2(dy, dx)
  195 + // 角度范围判断(统一到 [-PI, PI] 后比较)
  196 + function inRange(a, sa, ea) {
  197 + // 将所有角归一化到 [sa, sa + 2PI) 范围
  198 + let na = a - sa
  199 + let nea = ea - sa
  200 + if (nea < 0) nea += Math.PI * 2
  201 + if (na < 0) na += Math.PI * 2
  202 + return na >= 0 && na <= nea
  203 + }
  204 + if (inRange(mouseAngle, seg.startAngle, seg.endAngle)) {
  205 + hoveredIdx = i
  206 + break
  207 + }
  208 + }
  209 +
  210 + if (hoveredIdx !== pieHover.index) {
  211 + pieHover.index = hoveredIdx
  212 + pieHover.show = hoveredIdx >= 0
  213 + if (hoveredIdx >= 0) {
  214 + const ttW = 120
  215 + // 默认在鼠标右侧
  216 + let tx = mx + 10
  217 + let ty = my - 54
  218 + // 右边界检查
  219 + if (tx + ttW > rect.width - 4) tx = mx - ttW - 8
  220 + // 上边界检查
  221 + if (ty < 4) ty = my + 14
  222 + pieHover.x = Math.max(4, tx)
  223 + pieHover.y = Math.max(4, ty)
  224 + }
  225 + drawPieChart()
  226 + }
  227 +}
  228 +
  229 +function onPieMouseLeave() {
  230 + pieHover.show = false
  231 + pieHover.index = -1
  232 + drawPieChart()
  233 +}
  234 +
  235 +// ==================== 柱状图 Hover 交互 ====================
  236 +const barHover = reactive({ show: false, x: 0, y: 0, index: -1 })
  237 +const barTtData = computed(() => {
  238 + if (!barHover.show || barHover.index < 0) return { hour: '-', value: '-' }
  239 + const list = apiData.kwhData.list || []
  240 + const item = list[barHover.index]
  241 + if (!item) return { hour: '-', value: '-' }
  242 + return { hour: barHover.index + 1, value: item.value ?? 0 }
  243 +})
  244 +// 柱子碰撞矩形缓存
  245 +let barRects = []
  246 +
  247 +function onBarMouseMove(e) {
  248 + const canvas = barCanvasRef.value
  249 + if (!canvas) return
  250 + const rect = canvas.getBoundingClientRect()
  251 + const mx = e.clientX - rect.left
  252 + const my = e.clientY - rect.top
  253 +
  254 + let hoveredIdx = -1
  255 + for (let i = 0; i < barRects.length; i++) {
  256 + const br = barRects[i]
  257 + if (mx >= br.x && mx <= br.x + br.w && my >= br.y && my <= br.y + br.h) {
  258 + hoveredIdx = i
  259 + break
  260 + }
  261 + }
  262 +
  263 + if (hoveredIdx !== barHover.index) {
  264 + barHover.index = hoveredIdx
  265 + barHover.show = hoveredIdx >= 0
  266 + if (hoveredIdx >= 0) {
  267 + const br = barRects[hoveredIdx]
  268 + // tooltip 定位在柱子上方居中
  269 + const ttW = 140
  270 + let tx = br.x + br.w / 2 - ttW / 2
  271 + tx = Math.max(4, Math.min(tx, rect.width - ttW - 4))
  272 + barHover.x = tx
  273 + barHover.y = br.y - 70
  274 + }
  275 + drawBarChart()
  276 + }
  277 +}
  278 +
  279 +function onBarMouseLeave() {
  280 + barHover.show = false
  281 + barHover.index = -1
  282 + drawBarChart()
  283 +}
  284 +
  285 +// ==================== 时间轴 缩放系统 ====================
  286 +const TL_H = 104 // 时间轴高度
  287 +const zoomLevel = ref(1)
  288 +const minZoom = 0.001
  289 +const maxZoom = 1
  290 +const viewOffsetX = ref(0)
  291 +let tlContainerW = 900
  292 +
  293 +// 原始段数据(数据空间坐标,不受缩放影响)
  294 +const rawSegments = computed(() => {
  295 + const list = apiData.oeeData.list || []
  296 + if (!list.length) return []
  297 + const baseDate = apiData.date || selectedDate.value
  298 + const dayStartMs = new Date(baseDate + ' 00:00:00').getTime()
  299 + const daySpanMs = 86400000
  300 + const plotLeft = 55
  301 + const plotW = tlContainerW - plotLeft - 10
  302 +
  303 + return list.map((item, idx) => {
  304 + const st = new Date(item.startTime).getTime()
  305 + let et = item.endTime ? new Date(item.endTime).getTime() : st + (item.duration || 0) * 1000
  306 + const startX = ((st - dayStartMs) / daySpanMs) * plotW + plotLeft
  307 + const endX = ((et - dayStartMs) / daySpanMs) * plotW + plotLeft
  308 + const x = Math.max(plotLeft, startX)
  309 + const w = Math.max(1, endX - x)
  310 + return {
  311 + x, w,
  312 + index: idx,
  313 + runStatus: item.runStatus,
  314 + startTime: item.startTime,
  315 + endTime: item.endTime,
  316 + duration: item.duration,
  317 + startTimeText: item.startTime ? item.startTime.slice(0, 19) : '',
  318 + endTimeText: item.endTime ? item.endTime.slice(0, 19) : '',
  319 + }
  320 + })
  321 +})
  322 +
  323 +// ==================== 时间轴 Hover 交互 ====================
  324 +const timelineHover = reactive({ show: false, x: 0, y: 0, segIndex: -1, ttBelow: false })
  325 +const ttData = computed(() => {
  326 + if (!timelineHover.show || timelineHover.segIndex < 0) return {}
  327 + const list = apiData.oeeData.list || []
  328 + const item = list[timelineHover.segIndex]
  329 + if (!item) return {}
  330 + return {
  331 + startTime: item.startTime ? item.startTime.slice(0, 19) : '-',
  332 + endTime: item.endTime ? item.endTime.slice(0, 19) : '-',
  333 + runStatus: item.runStatus,
  334 + duration: formatDuration(item.duration),
  335 + }
  336 +})
  337 +
  338 +function onTimelineWheel(e) {
  339 + const wrap = timelineWrapRef.value
  340 + if (!wrap) return
  341 + const rect = wrap.getBoundingClientRect()
  342 + const mx = e.clientX - rect.left
  343 + // 鼠标位置 → 数据空间坐标
  344 + const mouseDataX = mx * zoomLevel.value + viewOffsetX.value
  345 + // 向上滚动(deltaY < 0)放大,向下(deltaY > 0)缩小
  346 + const delta = e.deltaY < 0 ? 0.8 : 1.25
  347 + const nextZ = Math.max(minZoom, Math.min(maxZoom, zoomLevel.value * delta))
  348 + // 以鼠标位置为中心缩放,调整偏移量
  349 + viewOffsetX.value = mouseDataX - mx * nextZ
  350 + zoomLevel.value = nextZ
  351 +}
  352 +
  353 +function onTimelineMouseEnter() {
  354 + const canvas = timelineCanvasRef.value
  355 + if (!canvas) return
  356 + canvas.addEventListener('mousemove', onTimelineMouseMove)
  357 +}
  358 +function onTimelineMouseLeave() {
  359 + const canvas = timelineCanvasRef.value
  360 + if (!canvas) return
  361 + canvas.removeEventListener('mousemove', onTimelineMouseMove)
  362 + timelineHover.show = false
  363 + timelineHover.segIndex = -1
  364 + drawTimelineChart()
  365 +}
  366 +
  367 +function onTimelineMouseMove(e) {
  368 + const wrap = timelineWrapRef.value
  369 + if (!wrap) return
  370 + const rect = wrap.getBoundingClientRect()
  371 + const mx = e.clientX - rect.left
  372 + const my = e.clientY - rect.top
  373 + const z = zoomLevel.value
  374 + const vo = viewOffsetX.value
  375 + // 屏幕坐标 → 数据空间坐标
  376 + const dataX = mx * z + vo
  377 +
  378 + // 条带区域(与 drawTimelineChart 中一致)
  379 + const barY = 24
  380 + const barH = 46
  381 + let hoveredIdx = -1
  382 +
  383 + // 从后往前遍历(后面的段覆盖前面的)
  384 + for (let i = rawSegments.value.length - 1; i >= 0; i--) {
  385 + const seg = rawSegments.value[i]
  386 + if (dataX >= seg.x && dataX <= seg.x + seg.w && my >= barY && my <= barY + barH) {
  387 + hoveredIdx = seg.index
  388 + break
  389 + }
  390 + }
  391 +
  392 + if (hoveredIdx !== timelineHover.segIndex) {
  393 + timelineHover.segIndex = hoveredIdx
  394 + timelineHover.show = hoveredIdx >= 0
  395 + if (hoveredIdx >= 0) {
  396 + const seg = rawSegments.value.find(s => s.index === hoveredIdx)
  397 + if (seg) {
  398 + // 计算该段在屏幕上的位置用于 tooltip 定位
  399 + const sx = (seg.x - vo) / z
  400 + const sw = seg.w / z
  401 + const ttW = 220
  402 + let tx = sx + sw / 2 - ttW / 2
  403 + tx = Math.max(2, Math.min(tx, rect.width - ttW - 2))
  404 + const ttH = 100
  405 + const aboveY = barY - ttH - 12
  406 + timelineHover.ttBelow = aboveY < 4
  407 + timelineHover.x = tx
  408 + timelineHover.y = timelineHover.ttBelow ? barY + barH + 12 : aboveY
  409 + }
  410 + }
  411 + drawTimelineChart()
  412 + }
  413 +}
  414 +
  415 +// 获取数据
  416 +async function fetchData() {
  417 + if (!dtuSn.value || !selectedDate.value) return
  418 + loading.value = true
  419 + try {
  420 + const res = await fetch(`/api/energy/detail?dtuSn=${dtuSn.value}&date=${selectedDate.value}`)
  421 + const data = await res.json()
  422 + if (data.code === 200) {
  423 + Object.assign(apiData.oeeData, data.oeeData || { list: [], statusStats: [], totalDurationSeconds: 0, totalDurationFormatted: '' })
  424 + Object.assign(apiData.kwhData, data.kwhData || { list: [], totalKwh: 0 })
  425 + apiData.date = data.date || selectedDate.value
  426 + await nextTick()
  427 + zoomLevel.value = 1
  428 + viewOffsetX.value = 0
  429 + await nextTick()
  430 + updateTimelineSize()
  431 + drawAllCharts()
  432 + }
  433 + } catch (err) {
  434 + console.error('获取能耗详情失败:', err)
  435 + } finally {
  436 + loading.value = false
  437 + }
  438 +}
  439 +
  440 +watch(() => props.visible, async (val) => {
  441 + if (val) {
  442 + await nextTick()
  443 + initCanvasObserver()
  444 + fetchData()
  445 + } else {
  446 + destroyCanvasObserver()
  447 + timelineHover.show = false
  448 + }
  449 +})
  450 +
  451 +// 监听缩放/偏移/数据变化重绘时间轴
  452 +watch([zoomLevel, viewOffsetX, () => apiData.oeeData.list], () => {
  453 + if (props.visible) {
  454 + if (canvasRAF) cancelAnimationFrame(canvasRAF)
  455 + canvasRAF = requestAnimationFrame(() => drawTimelineChart())
  456 + }
  457 +}, { deep: true })
  458 +
  459 +function updateTimelineSize() {
  460 + const wrap = timelineWrapRef.value
  461 + if (!wrap) return
  462 + tlContainerW = wrap.clientWidth - 12 // 减去 padding
  463 +}
  464 +
  465 +function initCanvasObserver() {
  466 + destroyCanvasObserver()
  467 + const dialogBody = document.querySelector('.ereport-dialog .el-dialog__body')
  468 + if (!dialogBody) return
  469 + resizeObserver = new ResizeObserver(() => {
  470 + if (canvasRAF) cancelAnimationFrame(canvasRAF)
  471 + canvasRAF = requestAnimationFrame(() => {
  472 + updateTimelineSize()
  473 + drawAllCharts()
  474 + })
  475 + })
  476 + resizeObserver.observe(dialogBody)
  477 +}
  478 +
  479 +function destroyCanvasObserver() {
  480 + if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
  481 + if (canvasRAF) { cancelAnimationFrame(canvasRAF); canvasRAF = null }
  482 +}
  483 +
  484 +onBeforeUnmount(() => destroyCanvasObserver())
  485 +
  486 +// ==================== Canvas 绘制 ====================
  487 +function getDpr() { return window.devicePixelRatio || 1 }
  488 +
  489 +function setupCanvas(canvas, w, h) {
  490 + const dpr = getDpr()
  491 + canvas.width = w * dpr
  492 + canvas.height = h * dpr
  493 + canvas.style.width = w + 'px'
  494 + canvas.style.height = h + 'px'
  495 + const ctx = canvas.getContext('2d')
  496 + ctx.scale(dpr, dpr)
  497 + return ctx
  498 +}
  499 +
  500 +function drawAllCharts() {
  501 + drawTimelineChart()
  502 + drawBarChart()
  503 + drawPieChart()
  504 +}
  505 +
  506 +// ---------- 1. 设备运行状态图(甘特图)+ 缩放 + hover放大 ----------
  507 +function drawTimelineChart() {
  508 + const canvas = timelineCanvasRef.value
  509 + if (!canvas) return
  510 + const wrap = timelineWrapRef.value
  511 + const w = wrap ? wrap.clientWidth - 12 : tlContainerW
  512 + const h = TL_H
  513 + const ctx = setupCanvas(canvas, w, h)
  514 +
  515 + ctx.fillStyle = '#fff'
  516 + ctx.fillRect(0, 0, w, h)
  517 +
  518 + const list = apiData.oeeData.list || []
  519 + if (list.length === 0) {
  520 + ctx.fillStyle = '#999'
  521 + ctx.font = '13px sans-serif'
  522 + ctx.textAlign = 'center'
  523 + ctx.fillText('暂无数据', w / 2, h / 2)
  524 + return
  525 + }
  526 +
  527 + // 布局参数(与 OeeDialog 一致)
  528 + const labelLeft = 50 // "时刻" 标签 X
  529 + const plotLeft = 55 // 数据区起点
  530 + const plotW = w - plotLeft - 10
  531 + const barY = 24 // 条带顶部
  532 + const barH = 46 // 默认条带高度
  533 + const axisY = barY + barH + 8 // 分隔线 Y
  534 + const timeLabelY = h - 4 // 时间标签 Y
  535 + const z = zoomLevel.value
  536 + const vo = viewOffsetX.value
  537 +
  538 + // "时刻" 标签
  539 + ctx.fillStyle = '#999'
  540 + ctx.font = '12px sans-serif'
  541 + ctx.textAlign = 'center'
  542 + ctx.fillText('时刻', labelLeft - vo / z, 16)
  543 +
  544 + // ===== 时间轴标签 — 根据缩放级别动态选择步长 =====
  545 + const totalSec = 24 * 3600
  546 + const visSpanSec = Math.max(60, (w * z / plotW) * totalSec)
  547 +
  548 + let stepSec = 14400 // 默认 4 小时
  549 + if (visSpanSec <= 120) stepSec = 30
  550 + else if (visSpanSec <= 300) stepSec = 60
  551 + else if (visSpanSec <= 600) stepSec = 300
  552 + else if (visSpanSec <= 1800) stepSec = 600
  553 + else if (visSpanSec <= 3600) stepSec = 900
  554 + else if (visSpanSec <= 7200) stepSec = 1800
  555 + else if (visSpanSec <= 28800) stepSec = 3600
  556 + else stepSec = 7200
  557 +
  558 + ctx.font = '11px sans-serif'
  559 + ctx.fillStyle = '#666'
  560 + ctx.textAlign = 'center'
  561 +
  562 + const leftSec = ((vo - plotLeft) / plotW) * totalSec
  563 + let firstSec = Math.floor(leftSec / stepSec) * stepSec
  564 + if (firstSec < 0) firstSec = 0
  565 +
  566 + for (let s = firstSec; s <= totalSec + stepSec * 2; s += stepSec) {
  567 + const dataPx = plotLeft + (s / totalSec) * plotW
  568 + const screenPx = (dataPx - vo) / z
  569 + if (screenPx < -50 || screenPx > w + 50) continue
  570 +
  571 + const hh = Math.floor(s / 3600)
  572 + const mm = Math.floor((s % 3600) / 60)
  573 + const ss = s % 60
  574 +
  575 + if (stepSec < 60) {
  576 + ctx.fillText(`${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`, screenPx, timeLabelY)
  577 + } else {
  578 + ctx.fillText(`${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`, screenPx, timeLabelY)
  579 + }
  580 + }
  581 +
  582 + // 分隔线(条带与时间轴之间)
  583 + ctx.strokeStyle = '#ddd'
  584 + ctx.lineWidth = 1
  585 + ctx.beginPath()
  586 + ctx.moveTo((plotLeft - vo) / z, axisY)
  587 + ctx.lineTo(((plotLeft + plotW) - vo) / z, axisY)
  588 + ctx.stroke()
  589 +
  590 + // ===== 绘制条带 =====
  591 + const hoverIdx = timelineHover.segIndex
  592 +
  593 + // 先画非 hover 段
  594 + rawSegments.value.forEach((seg) => {
  595 + if (seg.index === hoverIdx) return
  596 + drawSegment(ctx, seg, z, vo, w, barY, barH, false)
  597 + })
  598 +
  599 + // 再画 hover 段(最上层,有放大效果)
  600 + if (hoverIdx >= 0) {
  601 + const hoverSeg = rawSegments.value.find(s => s.index === hoverIdx)
  602 + if (hoverSeg) {
  603 + drawSegment(ctx, hoverSeg, z, vo, w, barY, barH, true)
  604 + }
  605 + }
  606 +}
  607 +
  608 +/** 绘制单个色段 */
  609 +function drawSegment(ctx, seg, z, vo, canvasW, barY, barH, isHover) {
  610 + const sx = (seg.x - vo) / z
  611 + const sw = seg.w / z
  612 + // 裁剪不可见段
  613 + if (sx + sw < -1 || sx > canvasW + 1) return
  614 +
  615 + // hover 放大参数
  616 + let finalX = sx
  617 + let finalY = barY
  618 + let finalW = sw
  619 + let finalH = barH
  620 + if (isHover) {
  621 + finalH = barH + 18 // 高度增加
  622 + finalY = barY - 9 // 向上偏移(居中放大)
  623 + finalX = sx - 2 // 左右微扩展
  624 + finalW = sw + 4
  625 + }
  626 +
  627 + ctx.save()
  628 +
  629 + if (isHover) {
  630 + ctx.shadowColor = 'rgba(0, 0, 0, 0.35)'
  631 + ctx.shadowBlur = 16
  632 + ctx.shadowOffsetX = 0
  633 + ctx.shadowOffsetY = 4
  634 + ctx.strokeStyle = 'rgba(255,255,255,0.7)'
  635 + ctx.lineWidth = 2
  636 + }
  637 +
  638 + ctx.fillStyle = isHover
  639 + ? (STATUS_COLORS_HOVER[seg.runStatus] || STATUS_COLORS[seg.runStatus])
  640 + : (STATUS_COLORS[seg.runStatus] || '#ccc')
  641 +
  642 + // hover 段用圆角,非 hover 用直角(与 OEE 一致风格)
  643 + if (isHover) {
  644 + ctx.beginPath()
  645 + ctx.roundRect(finalX, finalY, finalW, finalH, 5)
  646 + ctx.fill()
  647 + ctx.stroke()
  648 + } else {
  649 + ctx.globalAlpha = 0.9
  650 + ctx.fillRect(finalX, finalY, finalW, finalH)
  651 + }
  652 +
  653 + ctx.restore()
  654 +}
  655 +
  656 +// ---------- 2. 设备用电量柱状图 + hover ----------
  657 +function drawBarChart() {
  658 + const canvas = barCanvasRef.value
  659 + if (!canvas) return
  660 + const wrap = canvas.parentElement
  661 + const w = wrap.clientWidth
  662 + const h = 184
  663 + const ctx = setupCanvas(canvas, w, h)
  664 +
  665 + ctx.fillStyle = '#fff'
  666 + ctx.fillRect(0, 0, w, h)
  667 +
  668 + const kwhList = apiData.kwhData.list || []
  669 + // 清空碰撞矩形
  670 + barRects = []
  671 +
  672 + if (kwhList.length === 0) {
  673 + ctx.fillStyle = '#999'
  674 + ctx.font = '13px sans-serif'
  675 + ctx.textAlign = 'center'
  676 + ctx.fillText('暂无数据', w / 2, h / 2)
  677 + return
  678 + }
  679 +
  680 + const padLeft = 36
  681 + const padRight = 16
  682 + const padTop = 20
  683 + const padBottom = 32
  684 + const chartW = w - padLeft - padRight
  685 + const chartH = h - padTop - padBottom
  686 +
  687 + const maxVal = Math.max(...kwhList.map(d => d.value || 0), 70)
  688 + const niceMax = Math.ceil(maxVal / 10) * 10
  689 +
  690 + // 网格线 + Y轴标签
  691 + ctx.strokeStyle = '#eee'
  692 + ctx.lineWidth = 0.5
  693 + ctx.fillStyle = '#999'
  694 + ctx.font = '11px sans-serif'
  695 + ctx.textAlign = 'right'
  696 + for (let v = 0; v <= niceMax; v += 10) {
  697 + const y = padTop + chartH - (v / niceMax) * chartH
  698 + ctx.beginPath()
  699 + ctx.moveTo(padLeft, y)
  700 + ctx.lineTo(w - padRight, y)
  701 + ctx.stroke()
  702 + ctx.fillText(String(v), padLeft - 6, y + 4)
  703 + }
  704 +
  705 + // X 轴线
  706 + ctx.strokeStyle = '#ddd'
  707 + ctx.lineWidth = 1
  708 + ctx.beginPath()
  709 + ctx.moveTo(padLeft, padTop + chartH)
  710 + ctx.lineTo(w - padRight, padTop + chartH)
  711 + ctx.stroke()
  712 +
  713 + // 柱子参数
  714 + const barCount = kwhList.length
  715 + const totalGapRatio = 0.3
  716 + let barW_px = Math.max(4, (chartW / barCount) * (1 - totalGapRatio))
  717 + const gap = (chartW / barCount) * totalGapRatio
  718 + const hoverIdx = barHover.index
  719 +
  720 + kwhList.forEach((item, i) => {
  721 + const val = item.value || 0
  722 + const x = padLeft + (i * (chartW / barCount)) + gap / 2
  723 + const barH_px = (val / niceMax) * chartH
  724 + const y = padTop + chartH - barH_px
  725 +
  726 + // 存储碰撞矩形(默认尺寸)
  727 + barRects.push({ x, y, w: barW_px, h: barH_px })
  728 +
  729 + // 判断是否 hover
  730 + const isHover = (i === hoverIdx)
  731 +
  732 + if (isHover) {
  733 + // hover 柱:加宽、加深色、阴影、圆角
  734 + const hw = barW_px + 6
  735 + const hx = x - 3
  736 + ctx.save()
  737 + ctx.shadowColor = 'rgba(51, 126, 204, 0.4)'
  738 + ctx.shadowBlur = 14
  739 + ctx.shadowOffsetY = 3
  740 + ctx.fillStyle = '#2060a8'
  741 + ctx.beginPath()
  742 + ctx.roundRect(hx, y, hw, barH_px, 3)
  743 + ctx.fill()
  744 + ctx.restore()
  745 +
  746 + // 数值标签加粗加大
  747 + ctx.fillStyle = '#2060a8'
  748 + ctx.font = 'bold 13px sans-serif'
  749 + ctx.textAlign = 'center'
  750 + ctx.fillText(String(val), x + barW_px / 2, y - 7)
  751 +
  752 + // X 轴标签高亮
  753 + ctx.fillStyle = '#2060a8'
  754 + ctx.font = 'bold 11px sans-serif'
  755 + ctx.fillText(String(i + 1), x + barW_px / 2, padTop + chartH + 16)
  756 + } else {
  757 + // 默认柱
  758 + ctx.fillStyle = '#337ecc'
  759 + ctx.globalAlpha = isHover ? 1 : 0.85
  760 + ctx.fillRect(x, y, barW_px, barH_px)
  761 + ctx.globalAlpha = 1
  762 +
  763 + // 数值标签
  764 + if (val > 0) {
  765 + ctx.fillStyle = '#555'
  766 + ctx.font = '9px sans-serif'
  767 + ctx.textAlign = 'center'
  768 + ctx.fillText(String(val), x + barW_px / 2, y - 4)
  769 + }
  770 +
  771 + // X 轴标签
  772 + ctx.fillStyle = '#666'
  773 + ctx.font = '10px sans-serif'
  774 + ctx.textAlign = 'center'
  775 + ctx.fillText(String(i + 1), x + barW_px / 2, padTop + chartH + 16)
  776 + }
  777 + })
  778 +}
  779 +
  780 +// ---------- 3. 实心饼图 + hover + 外部标签 ----------
  781 +function drawPieChart() {
  782 + const canvas = pieCanvasRef.value
  783 + if (!canvas) return
  784 + const wrap = canvas.parentElement
  785 + const w = wrap.clientWidth
  786 + const h = 230
  787 + const ctx = setupCanvas(canvas, w, h)
  788 +
  789 + ctx.clearRect(0, 0, w, h)
  790 +
  791 + const stats = apiData.oeeData.statusStats || []
  792 + pieAngleRanges = []
  793 +
  794 + if (!stats.length || stats.every(s => !s.status && s.status !== 0)) {
  795 + ctx.fillStyle = '#999'
  796 + ctx.font = '12px sans-serif'
  797 + ctx.textAlign = 'center'
  798 + ctx.textBaseline = 'middle'
  799 + ctx.fillText('暂无数据', w / 2, h / 2)
  800 + return
  801 + }
  802 +
  803 + const cx = w * 0.4
  804 + const cy = h * 0.52
  805 + const radius = Math.min(cx, cy) - 24
  806 + const MIN_SLICE_DEG = 2 // 0% 的扇区也给最小角度用于展示
  807 +
  808 + // 计算每个扇区的角度(含 0% 最小角度)
  809 + const totalPct = stats.reduce((sum, s) => sum + (s.percent || 0), 0)
  810 + const sliceInfos = stats.map((statItem, i) => {
  811 + const pct = statItem.percent || 0
  812 + const deg = pct > 0 ? (pct / totalPct) * 360 : MIN_SLICE_DEG
  813 + return { index: i, status: statItem.status, percent: pct, deg }
  814 + })
  815 + // 归一化:如果全0则均分;否则按比例缩放非0的
  816 + const hasNonZero = sliceInfos.some(si => si.percent > 0)
  817 + if (!hasNonZero && sliceInfos.length > 0) {
  818 + const eqDeg = 360 / sliceInfos.length
  819 + sliceInfos.forEach(si => si.deg = eqDeg)
  820 + } else if (hasNonZero) {
  821 + const usedByNonZero = sliceInfos.filter(si => si.percent > 0).reduce((s, si) => s + si.deg, 0)
  822 + const zeroCount = sliceInfos.filter(si => si.percent === 0).length
  823 + const remaining = Math.max(0, 360 - usedByNonZero - zeroCount * MIN_SLICE_DEG)
  824 + // 非零的按比例补上剩余空间
  825 + sliceInfos.forEach(si => {
  826 + if (si.percent > 0) {
  827 + si.deg = si.deg + (si.deg / usedByNonZero) * remaining
  828 + }
  829 + })
  830 + }
  831 +
  832 + // 转弧度并累积计算起止角
  833 + let startAngle = -Math.PI / 2
  834 + sliceInfos.forEach(si => {
  835 + si.startAngle = startAngle
  836 + si.sliceAngle = (si.deg / 180) * Math.PI
  837 + si.endAngle = startAngle + si.sliceAngle
  838 + si.midAngle = startAngle + si.sliceAngle / 2
  839 + startAngle = si.endAngle
  840 + })
  841 +
  842 + // 记录碰撞信息
  843 + sliceInfos.forEach(si => {
  844 + pieAngleRanges.push({
  845 + index: si.index,
  846 + status: si.status,
  847 + cx, cy,
  848 + radius,
  849 + innerR: 0,
  850 + startAngle: si.startAngle,
  851 + endAngle: si.endAngle
  852 + })
  853 + })
  854 +
  855 + // 第一遍:画扇区(hover的最后画)
  856 + sliceInfos.forEach(si => {
  857 + const isHover = (si.index === pieHover.index)
  858 +
  859 + if (!isHover) {
  860 + ctx.beginPath()
  861 + ctx.moveTo(cx, cy)
  862 + ctx.arc(cx, cy, radius, si.startAngle, si.endAngle)
  863 + ctx.closePath()
  864 + ctx.fillStyle = STATUS_COLORS[si.status] || '#ccc'
  865 + ctx.globalAlpha = 0.85
  866 + ctx.fill()
  867 + ctx.globalAlpha = 1
  868 + }
  869 + })
  870 +
  871 + // 第二遍:画 hover 扇区(放大+阴影)
  872 + if (pieHover.index >= 0) {
  873 + const si = sliceInfos.find(s => s.index === pieHover.index)
  874 + if (si && si.percent >= 0) {
  875 + const { startAngle: sa, endAngle: ea, midAngle: ma } = si
  876 + const expandR = radius + 5
  877 + const offsetDist = 6
  878 + const ox = cx + Math.cos(ma) * offsetDist
  879 + const oy = cy + Math.sin(ma) * offsetDist
  880 +
  881 + ctx.save()
  882 + ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
  883 + ctx.shadowBlur = 12
  884 + ctx.shadowOffsetY = 3
  885 + ctx.beginPath()
  886 + ctx.moveTo(ox, oy)
  887 + ctx.arc(ox, oy, expandR, sa, ea)
  888 + ctx.closePath()
  889 + ctx.fillStyle = STATUS_COLORS_HOVER[si.status] || STATUS_COLORS[si.status] || '#ccc'
  890 + ctx.fill()
  891 + ctx.restore()
  892 + }
  893 + }
  894 +
  895 + // 第三遍:引导线 + 标签
  896 + sliceInfos.forEach(si => {
  897 + const isHover = (si.index === pieHover.index)
  898 + const { midAngle, status: s, percent: pct } = si
  899 +
  900 + const lineStartX = cx + Math.cos(midAngle) * radius
  901 + const lineStartY = cy + Math.sin(midAngle) * radius
  902 + const elbowLen = 14
  903 + const elbowX = cx + Math.cos(midAngle) * (radius + elbowLen)
  904 + const elbowY = cy + Math.sin(midAngle) * (radius + elbowLen)
  905 + const textDir = midAngle > Math.PI / 2 && midAngle <= Math.PI * 1.5 ? -1 : 1
  906 + const textLen = 36
  907 + const lineEndX = elbowX + textDir * textLen
  908 + const lineEndY = elbowY
  909 +
  910 + ctx.strokeStyle = isHover ? '#666' : '#aaa'
  911 + ctx.lineWidth = isHover ? 1.2 : 0.8
  912 + ctx.beginPath()
  913 + ctx.moveTo(lineStartX, lineStartY)
  914 + ctx.lineTo(elbowX, elbowY)
  915 + ctx.lineTo(lineEndX, lineEndY)
  916 + ctx.stroke()
  917 +
  918 + const labelText = `${statusLabel(s)}${pct.toFixed(pct % 1 === 0 ? 0 : 2)}%`
  919 + const tx = lineEndX + textDir * 4
  920 + const ty = lineEndY + (isHover ? -2 : 4)
  921 +
  922 + ctx.fillStyle = isHover ? '#333' : '#555'
  923 + ctx.font = `${isHover ? 'bold ' : ''}11px sans-serif`
  924 + ctx.textAlign = textDir > 0 ? 'left' : 'right'
  925 + ctx.textBaseline = 'middle'
  926 + ctx.fillText(labelText, tx, ty)
  927 + })
  928 +}
141 </script> 929 </script>
142 930
143 <style scoped> 931 <style scoped>
144 .ereport-dialog :deep(.el-dialog) { 932 .ereport-dialog :deep(.el-dialog) {
145 - max-height: 92vh;  
146 - display: flex; flex-direction: column;  
147 -}  
148 -.ereport-dialog :deep(.el-dialog__header) { padding: 10px 20px; border-bottom: 1px solid #e8e8e8; margin: 0; flex-shrink: 0; }  
149 -.ereport-dialog :deep(.el-dialog__body) { overflow-y: auto; flex: 1; }  
150 -.dialog-header { display: flex; align-items: center; justify-content: space-between; }  
151 -.title-text { font-size: 15px; font-weight: bold; color: #333; }  
152 -.header-right { display: flex; align-items: center; }  
153 -  
154 -.report-body { padding: 12px 0; }  
155 -  
156 -.top-section { display: grid; grid-template-columns: 1fr 200px; gap: 14px; margin-bottom: 14px; padding: 0 16px; }  
157 -.energy-cards-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }  
158 -.energy-card-item {  
159 - border-radius: 6px; padding: 16px 12px; display: flex; flex-direction: column;  
160 - align-items: center; justify-content: center; color: #fff;  
161 -}  
162 -.energy-card-item.orange { background: linear-gradient(135deg, #f56c6c, #e74c3c); }  
163 -.energy-card-item.green { background: linear-gradient(135deg, #67c23a, #52c41a); }  
164 -.energy-card-item.blue { background: linear-gradient(135deg, #409eff, #1890ff); }  
165 -.card-label { font-size: 13px; opacity: 0.9; }  
166 -.card-val { font-size: 26px; font-weight: bold; margin-top: 6px; }  
167 -.card-val small { font-size: 13px; font-weight: normal; margin-left: 2px; }  
168 -  
169 -.carbon-panel { background: linear-gradient(160deg, #5b9bd5 0%, #2e75b6 100%); border-radius: 6px; padding: 14px; color: #fff; }  
170 -.carbon-title { font-size: 13px; font-weight: bold; margin-bottom: 8px; }  
171 -.carbon-sub { font-size: 11px; opacity: 0.85; margin-bottom: 10px; text-align: right; }  
172 -.carbon-row { padding: 7px 12px; border-radius: 4px; margin-bottom: 4px; font-size: 12px; background: rgba(255,255,255,0.18); }  
173 -.carbon-row.blue { background: rgba(64,158,255,0.35); }  
174 -  
175 -.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 0 16px; }  
176 -.chart-card { background: #fff; border: 1px solid #eee; border-radius: 6px; overflow: hidden; }  
177 -.chart-card.full-width { grid-column: 1 / -1; }  
178 -.chart-header { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; }  
179 -.chart-title { font-size: 13px; font-weight: bold; color: #333; }  
180 -.chart-tools { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #666; }  
181 -.chart-tools label { cursor: pointer; display: flex; align-items: center; gap: 2px; }  
182 -.chart-body { padding: 10px 14px; }  
183 -.chart-body svg { width: 100%; height: auto; } 933 + max-height: 94vh;
  934 + display: flex;
  935 + flex-direction: column;
  936 +}
  937 +.ereport-dialog :deep(.el-dialog__header) {
  938 + padding: 10px 18px;
  939 + border-bottom: 1px solid #e8e8e8;
  940 + margin: 0;
  941 + flex-shrink: 0;
  942 +}
  943 +.ereport-dialog :deep(.el-dialog__body) {
  944 + overflow: hidden;
  945 + flex: 1;
  946 +}
  947 +.dialog-header {
  948 + display: flex;
  949 + align-items: center;
  950 +}
  951 +.title-text {
  952 + font-size: 14px;
  953 + font-weight: bold;
  954 + color: #333;
  955 + margin-right: 8px;
  956 +}
  957 +
  958 +.report-body {
  959 + padding: 10px 14px;
  960 + display: flex;
  961 + flex-direction: column;
  962 + gap: 10px;
  963 +}
  964 +
  965 +.chart-section {
  966 + background: #fff;
  967 + border: 1px solid #e8e8e8;
  968 + border-radius: 4px;
  969 + padding: 10px 14px;
  970 +}
  971 +.section-title {
  972 + font-size: 13px;
  973 + font-weight: bold;
  974 + color: #333;
  975 + margin-bottom: 5px;
  976 + display: flex;
  977 + align-items: center;
  978 + gap: 12px;
  979 + flex-wrap: wrap;
  980 +}
  981 +.legend-item {
  982 + display: inline-flex;
  983 + align-items: center;
  984 + gap: 3px;
  985 + font-size: 11px;
  986 + font-weight: normal;
  987 + color: #666;
  988 +}
  989 +
  990 +/* 时间轴容器 */
  991 +.timeline-canvas-wrap {
  992 + position: relative;
  993 + background: #fff;
  994 + border: 1px solid #e0e0e0;
  995 + border-radius: 3px;
  996 + padding: 5px;
  997 + min-height: 122px;
  998 + cursor: default;
  999 +}
  1000 +.timeline-canvas-wrap canvas {
  1001 + display: block;
  1002 + width: 100%;
  1003 +}
  1004 +
  1005 +.canvas-wrap {
  1006 + width: 100%;
  1007 + overflow: hidden;
  1008 +}
  1009 +/* 柱状图容器 */
  1010 +.bar-canvas-wrap {
  1011 + position: relative;
  1012 + width: 100%;
  1013 +}
  1014 +.bar-canvas-wrap canvas {
  1015 + display: block;
  1016 + width: 100%;
  1017 +}
  1018 +
  1019 +.canvas-wrap canvas {
  1020 + display: block;
  1021 + width: 100%;
  1022 +}
  1023 +
  1024 +.bottom-row {
  1025 + display: grid;
  1026 + grid-template-columns: 1fr 1fr;
  1027 + gap: 10px;
  1028 +}
  1029 +
  1030 +/* 明细面板 */
  1031 +.detail-panel {
  1032 + background: #fff;
  1033 + border: 1px solid #e8e8e8;
  1034 + border-radius: 4px;
  1035 + padding: 10px 14px;
  1036 +}
  1037 +.detail-table-wrap {
  1038 + max-height: 253px;
  1039 + overflow-y: auto;
  1040 +}
  1041 +.detail-table {
  1042 + width: 100%;
  1043 + border-collapse: collapse;
  1044 + font-size: 12px;
  1045 +}
  1046 +.detail-table th {
  1047 + background: #fafafa;
  1048 + padding: 7px 12px;
  1049 + text-align: left;
  1050 + font-weight: bold;
  1051 + color: #333;
  1052 + border-bottom: 1px solid #eee;
  1053 +}
  1054 +.detail-table td {
  1055 + padding: 5px 12px;
  1056 + border-bottom: 1px solid #f5f5f5;
  1057 + color: #555;
  1058 +}
  1059 +.detail-table tbody tr:hover { background: #f9f9f9; }
  1060 +
  1061 +/* 饼图面板 */
  1062 +.pie-panel {
  1063 + background: #fff;
  1064 + border: 1px solid #e8e8e8;
  1065 + border-radius: 4px;
  1066 + padding: 8px 12px;
  1067 + display: flex;
  1068 + align-items: center;
  1069 +}
  1070 +.pie-canvas-wrap {
  1071 + flex: 1;
  1072 + min-width: 0;
  1073 + position: relative;
  1074 +}
  1075 +.pie-canvas-wrap canvas {
  1076 + display: block;
  1077 + width: 100%;
  1078 +}
  1079 +.stats-area {
  1080 + flex-shrink: 0;
  1081 + display: flex;
  1082 + flex-direction: column;
  1083 + gap: 10px;
  1084 + margin-left: -15px;
  1085 +}
  1086 +.stat-row {
  1087 + display: flex;
  1088 + flex-direction: column;
  1089 + gap: 2px;
  1090 +}
  1091 +.stat-label { font-size: 12px; color: #888; }
  1092 +.stat-val { font-size: 17px; font-weight: bold; color: #333; }
  1093 +
  1094 +/* 图例圆点 */
  1095 +.dot {
  1096 + display: inline-block;
  1097 + width: 10px;
  1098 + height: 10px;
  1099 + border-radius: 2px;
  1100 + flex-shrink: 0;
  1101 +}
  1102 +.dot.g { background: #67c23a; }
  1103 +.dot.r { background: #e74c3c; }
  1104 +.dot.y { background: #c5d94e; }
  1105 +.dot.gy { background: #909399; }
  1106 +
  1107 +/* ========== Tooltip 样式 ========== */
  1108 +.tl-tooltip {
  1109 + position: absolute;
  1110 + background: rgba(32, 40, 55, 0.95);
  1111 + border-radius: 6px;
  1112 + padding: 10px 16px;
  1113 + min-width: 210px;
  1114 + z-index: 200;
  1115 + pointer-events: none;
  1116 + box-shadow: 0 6px 20px rgba(0,0,0,0.3);
  1117 +}
  1118 +.tl-tooltip::after {
  1119 + content: '';
  1120 + position: absolute;
  1121 + bottom: -7px;
  1122 + left: 50%;
  1123 + transform: translateX(-50%);
  1124 + border-left: 7px solid transparent;
  1125 + border-right: 7px solid transparent;
  1126 + border-top: 7px solid rgba(32, 40, 55, 0.95);
  1127 +}
  1128 +.tl-tooltip.tt-below::after {
  1129 + top: -7px;
  1130 + bottom: auto;
  1131 + border-top: none;
  1132 + border-bottom: 7px solid rgba(32, 40, 55, 0.95);
  1133 +}
  1134 +
  1135 +.tt-row {
  1136 + display: flex;
  1137 + align-items: center;
  1138 + justify-content: space-between;
  1139 + gap: 14px;
  1140 + line-height: 1.9;
  1141 + font-size: 12px;
  1142 +}
  1143 +.tt-label { color: #aab2c0; flex-shrink: 0; }
  1144 +.tt-val {
  1145 + color: #eef1f7;
  1146 + font-weight: 500;
  1147 + display: flex;
  1148 + align-items: center;
  1149 + gap: 4px;
  1150 +}
  1151 +.tt-val.status-2 .dot { background: #67c23a; }
  1152 +.tt-val.status-3 .dot { background: #c5d94e; }
  1153 +.tt-val.status-1 .dot { background: #e74c3c; }
  1154 +.tt-val.status-0 .dot { background: #909399; }
  1155 +
  1156 +/* ========== 柱状图 Tooltip ========== */
  1157 +.bar-tooltip {
  1158 + position: absolute;
  1159 + background: rgba(32, 40, 55, 0.95);
  1160 + border-radius: 6px;
  1161 + padding: 10px 16px;
  1162 + min-width: 130px;
  1163 + z-index: 200;
  1164 + pointer-events: none;
  1165 + box-shadow: 0 6px 20px rgba(0,0,0,0.3);
  1166 +}
  1167 +.bar-tooltip::after {
  1168 + content: '';
  1169 + position: absolute;
  1170 + bottom: -7px;
  1171 + left: 50%;
  1172 + transform: translateX(-50%);
  1173 + border-left: 7px solid transparent;
  1174 + border-right: 7px solid transparent;
  1175 + border-top: 7px solid rgba(32, 40, 55, 0.95);
  1176 +}
  1177 +.btt-row {
  1178 + display: flex;
  1179 + align-items: center;
  1180 + justify-content: space-between;
  1181 + gap: 14px;
  1182 + line-height: 1.9;
  1183 + font-size: 12px;
  1184 +}
  1185 +.btt-label { color: #aab2c0; flex-shrink: 0; }
  1186 +.btt-val { color: #eef1f7; font-weight: 500; }
  1187 +.btt-highlight { color: #66b1ff; font-weight: bold; }
  1188 +
  1189 +/* ========== 饼图 Tooltip ========== */
  1190 +.pie-tooltip {
  1191 + position: absolute;
  1192 + background: rgba(32, 40, 55, 0.95);
  1193 + border-radius: 6px;
  1194 + padding: 8px 14px;
  1195 + min-width: 120px;
  1196 + z-index: 200;
  1197 + pointer-events: none;
  1198 + box-shadow: 0 4px 16px rgba(0,0,0,0.25);
  1199 +}
  1200 +.pie-tooltip::after {
  1201 + content: '';
  1202 + position: absolute;
  1203 + bottom: -7px;
  1204 + left: 18px;
  1205 + border-left: 7px solid transparent;
  1206 + border-right: 7px solid transparent;
  1207 + border-top: 7px solid rgba(32, 40, 55, 0.95);
  1208 +}
  1209 +.ptt-row {
  1210 + display: flex;
  1211 + align-items: center;
  1212 + justify-content: space-between;
  1213 + gap: 12px;
  1214 + line-height: 1.9;
  1215 + font-size: 12px;
  1216 +}
  1217 +.ptt-label { color: #aab2c0; flex-shrink: 0; }
  1218 +.ptt-val { color: #eef1f7; font-weight: 500; display: flex; align-items: center; gap: 4px; }
  1219 +.ptt-highlight { font-weight: bold; }
184 </style> 1220 </style>