Commit ea214724a9572b45acd745f01da782da51e660bf
1 parent
9782fb4b
feat: 实现能耗效率动态折线图与数据表格
重构能耗效率模块,替换原静态SVG为Canvas动态折线图,新增时/日/月查询与设备筛选,支持折线图与表格视图切换及交互式图例悬浮提示
Showing
1 changed file
with
647 additions
and
61 deletions
| ... | ... | @@ -186,55 +186,104 @@ |
| 186 | 186 | </div> |
| 187 | 187 | </div> |
| 188 | 188 | |
| 189 | - <!-- ========== 能耗效率:折线图 ========== --> | |
| 190 | - <div v-else-if="currentStatus === 'efficiency'" class="tab-content eff-view"> | |
| 189 | + <!-- ========== 能耗效率:折线图 / 表格 ========== --> | |
| 190 | + <div v-else-if="currentStatus === 'efficiency'" class="tab-content eff-view" ref="effWrapRef"> | |
| 191 | 191 | <div class="eff-toolbar"> |
| 192 | 192 | <span class="eff-label">查询方式:</span> |
| 193 | - <el-radio-group v-model="effQueryMode" size="small"> | |
| 193 | + <el-radio-group v-model="effQueryMode" size="small" @change="onEffModeChange"> | |
| 194 | + <el-radio-button value="hour">时查询</el-radio-button> | |
| 194 | 195 | <el-radio-button value="day">日查询</el-radio-button> |
| 195 | - <el-radio-button value="week">周查询</el-radio-button> | |
| 196 | 196 | <el-radio-button value="month">月查询</el-radio-button> |
| 197 | 197 | </el-radio-group> |
| 198 | - <el-date-picker v-model="effDate" type="date" placeholder="2026-04-28" size="small" style="width:160px;margin-left:8px;" /> | |
| 199 | - <el-select v-model="effDeviceFilter" size="small" style="width:140px;margin-left:8px;"> | |
| 200 | - <el-option label="磨粉设备1 +1" value="dev1" /> | |
| 198 | + <el-date-picker v-if="effQueryMode === 'hour'" v-model="effHourDate" type="date" | |
| 199 | + placeholder="" size="small" style="width:160px;margin-left:8px;" | |
| 200 | + value-format="YYYY-MM-DD" :disabled-date="disabledDateFuture" @change="() => nextTick(fetchEffData)" /> | |
| 201 | + <el-date-picker v-if="effQueryMode === 'day'" v-model="effDayDate" type="month" | |
| 202 | + placeholder="" size="small" style="width:160px;margin-left:8px;" | |
| 203 | + value-format="YYYY-MM" :disabled-date="disabledMonthFuture" @change="() => nextTick(fetchEffData)" /> | |
| 204 | + <el-date-picker v-if="effQueryMode === 'month'" v-model="effMonthDate" type="year" | |
| 205 | + placeholder="" size="small" style="width:120px;margin-left:8px;" | |
| 206 | + value-format="YYYY" :disabled-date="disabledYearFuture" @change="() => nextTick(fetchEffData)" /> | |
| 207 | + <el-select v-model="effDeviceFilter" size="small" style="width:140px;margin-left:8px;" multiple collapse-tags collapse-tags-tooltip :max-collapse-tags="2"> | |
| 208 | + <el-option v-for="dev in effAllDevices" :key="dev.dtuSn" :label="dev.deviceName || dev.dtuSn" :value="dev.dtuSn" /> | |
| 201 | 209 | </el-select> |
| 202 | - <div style="flex:1"></div> | |
| 203 | - <el-button size="small" circle><el-icon><Histogram /></el-icon></el-button> | |
| 204 | - <el-button size="small" circle><el-icon><Document /></el-icon></el-button> | |
| 210 | + <el-button type="primary" size="small" @click="fetchEffData" style="margin-left:4px;">查询</el-button> | |
| 211 | + <!-- 视图切换图标按钮 --> | |
| 212 | + <el-tooltip content="历史数据表格" placement="bottom" :show-after="300"> | |
| 213 | + <button size="small" :class="['action-btn', 'view-btn', { 'active': effViewMode === 'table' }]" | |
| 214 | + @click="toggleEffView('table')" style="border:1px solid #dcdfe6;"> | |
| 215 | + <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"> | |
| 216 | + <rect x="1.5" y="2.5" width="5" height="4" rx="0.5"/><rect x="9.5" y="2.5" width="5" height="4" rx="0.5"/> | |
| 217 | + <rect x="1.5" y="8.5" width="5" height="5" rx="0.5"/><rect x="9.5" y="8.5" width="5" height="5" rx="0.5"/> | |
| 218 | + </svg> | |
| 219 | + </button> | |
| 220 | + </el-tooltip> | |
| 221 | + <el-tooltip content="历史数据折线图" placement="bottom" :show-after="300"> | |
| 222 | + <button size="small" :class="['action-btn', 'view-btn', { 'active': effViewMode === 'chart' }]" | |
| 223 | + @click="toggleEffView('chart')" style="border:1px solid #dcdfe6;"> | |
| 224 | + <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"> | |
| 225 | + <polyline points="1,13 4,7 8,10 12,3 15,6"/> | |
| 226 | + </svg> | |
| 227 | + </button> | |
| 228 | + </el-tooltip> | |
| 229 | + </div> | |
| 230 | + | |
| 231 | + <!-- ========== 折线图视图 ========== --> | |
| 232 | + <template v-if="effViewMode === 'chart'"> | |
| 233 | + <!-- 动态图例(可点击筛选) --> | |
| 234 | + <div class="eff-legend" v-if="effDeviceList.length > 0"> | |
| 235 | + <span v-for="(dev, idx) in effDeviceList" :key="dev.dtuSn" | |
| 236 | + class="leg-line" | |
| 237 | + :class="{ 'leg-dimmed': effHiddenDevices.has(dev.dtuSn) }" | |
| 238 | + :style="'--lc:' + EFF_LINE_COLORS[idx % EFF_LINE_COLORS.length]" | |
| 239 | + @click="toggleEffDevice(dev.dtuSn)"> | |
| 240 | + <i></i>{{ dev.deviceName || dev.dtuSn }} | |
| 241 | + </span> | |
| 242 | + </div> | |
| 243 | + | |
| 244 | + <!-- Canvas 折线图 --> | |
| 245 | + <div class="eff-chart canvas-eff-chart" style="position:relative;" v-loading="effLoading"> | |
| 246 | + <canvas ref="effChartCanvasRef" @mousemove="onEffChartHover" @mouseleave="onEffChartLeave"></canvas> | |
| 247 | + <div v-if="effHover.show" class="eff-tooltip" :style="{ left: effHover.x + 'px', top: effHover.y + 'px' }"> | |
| 248 | + <div class="eft-title">{{ effHover.timeLabel }}</div> | |
| 249 | + <div v-for="(item, idx) in effHover.devices" :key="idx" class="eft-row"> | |
| 250 | + <i class="dot" :style="{ background: item.color }"></i> | |
| 251 | + <span class="eft-name">{{ item.name }}</span> | |
| 252 | + <span class="eft-val">{{ item.value }} kw·h</span> | |
| 253 | + </div> | |
| 254 | + </div> | |
| 205 | 255 | </div> |
| 206 | - <div class="eff-legend"> | |
| 207 | - <span class="leg-line" style="--lc:#5470c6;"><i></i>磨粉设备1</span> | |
| 208 | - <span class="leg-line" style="--lc:#91cc75;"><i></i>磨粉设备2</span> | |
| 256 | + | |
| 257 | + <!-- 总用电量统计 --> | |
| 258 | + <div class="eff-summary" v-if="effTotalKwh !== null"> | |
| 259 | + <span class="eff-sum-label">总用电量:</span> | |
| 260 | + <span class="eff-sum-val">{{ effTotalKwh.toFixed(2) }} kw·h</span> | |
| 261 | + <span class="eff-sum-count">({{ effDeviceList.length }} 台设备)</span> | |
| 209 | 262 | </div> |
| 210 | - <div class="eff-chart"> | |
| 211 | - <svg viewBox="0 0 1400 400"> | |
| 212 | - <!-- Y轴刻度 --> | |
| 213 | - <g font-size="11" fill="#999" text-anchor="end"> | |
| 214 | - <text x="35" y="24">1</text><text x="35" y="96">0.8</text> | |
| 215 | - <text x="35" y="168">0.6</text><text x="35" y="240">0.4</text> | |
| 216 | - <text x="35" y="312">0.2</text><text x="35" y="380">0</text> | |
| 217 | - </g> | |
| 218 | - <!-- 网格线 --> | |
| 219 | - <g stroke="#eee" stroke-width="1"> | |
| 220 | - <line x1="46" y1="20" x2="1370" y2="20"/><line x1="46" y1="92" x2="1370" y2="92"/> | |
| 221 | - <line x1="46" y1="164" x2="1370" y2="164"/><line x1="46" y1="236" x2="1370" y2="236"/> | |
| 222 | - <line x1="46" y1="308" x2="1370" y2="308"/><line x1="46" y1="380" x2="1370" y2="380"/> | |
| 223 | - </g> | |
| 224 | - <!-- X轴标签 --> | |
| 225 | - <g font-size="10" fill="#666" text-anchor="middle"> | |
| 226 | - <template v-for="i in 24" :key="i"> | |
| 227 | - <text :x="46+(i-1)*55" y="398">{{ i }}</text> | |
| 228 | - </template> | |
| 229 | - </g> | |
| 230 | - <!-- 折线1 --> | |
| 231 | - <polyline :points="effLine1Points" fill="none" stroke="#5470c6" stroke-width="2"/> | |
| 232 | - <!-- 折线2 --> | |
| 233 | - <polyline :points="effLine2Points" fill="none" stroke="#91cc75" stroke-width="2"/> | |
| 234 | - <!-- X轴线 --> | |
| 235 | - <line x1="46" y1="380" x2="1370" y2="380" stroke="#ccc" stroke-width="1.5"/> | |
| 236 | - </svg> | |
| 263 | + </template> | |
| 264 | + | |
| 265 | + <!-- ========== 表格视图 ========== --> | |
| 266 | + <template v-else> | |
| 267 | + <div class="eff-table-wrap" v-loading="effLoading"> | |
| 268 | + <table class="eff-history-table" v-if="effTableColumns.length > 0 && effTableRows.length > 0"> | |
| 269 | + <thead> | |
| 270 | + <tr> | |
| 271 | + <th class="col-name">设备名称</th> | |
| 272 | + <th v-for="(col, ci) in effTableColumns" :key="ci">{{ col.label }}</th> | |
| 273 | + </tr> | |
| 274 | + </thead> | |
| 275 | + <tbody> | |
| 276 | + <tr v-for="(row, ri) in effTableRows" :key="ri"> | |
| 277 | + <td class="td-name">{{ row.deviceName }}</td> | |
| 278 | + <td v-for="(col, ci) in effTableColumns" :key="ci">{{ row.values[ci] != null ? row.values[ci] : '-' }}</td> | |
| 279 | + </tr> | |
| 280 | + </tbody> | |
| 281 | + </table> | |
| 282 | + <div v-else class="eff-table-empty"> | |
| 283 | + 暂无数据 | |
| 284 | + </div> | |
| 237 | 285 | </div> |
| 286 | + </template> | |
| 238 | 287 | </div> |
| 239 | 288 | |
| 240 | 289 | <!-- 能耗报表弹窗 --> |
| ... | ... | @@ -1202,24 +1251,433 @@ function initUtilObserver() { |
| 1202 | 1251 | function destroyUtilObserver() { if (utilResizeObs) { utilResizeObs.disconnect(); utilResizeObs = null } } |
| 1203 | 1252 | onBeforeUnmount(() => destroyUtilObserver()) |
| 1204 | 1253 | |
| 1205 | -// ========== 能耗效率数据 ========== | |
| 1206 | -const effQueryMode = ref('day') | |
| 1207 | -const effDate = ref('2026-04-28') | |
| 1208 | -const effDeviceFilter = ref('dev1') | |
| 1209 | -const effLine1Points = computed(() => { | |
| 1210 | - const pts = [] | |
| 1211 | - for (let i = 0; i < 24; i++) { | |
| 1212 | - pts.push(`${46 + i * 55},${380 - 0}`) | |
| 1254 | +// ========== 能耗效率:Canvas折线图 / 表格 ========== | |
| 1255 | +const EFF_LINE_COLORS = [ | |
| 1256 | + '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', | |
| 1257 | + '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc' | |
| 1258 | +] | |
| 1259 | + | |
| 1260 | +// 视图模式:chart(折线图) / table(表格) | |
| 1261 | +const effViewMode = ref('chart') | |
| 1262 | +function toggleEffView(mode) { | |
| 1263 | + if (effViewMode.value === mode) return | |
| 1264 | + effViewMode.value = mode | |
| 1265 | + if (mode === 'chart') nextTick(drawEffChart) | |
| 1266 | +} | |
| 1267 | + | |
| 1268 | +const effQueryMode = ref('hour') | |
| 1269 | +const effHourDate = ref(new Date().toISOString().slice(0, 10)) | |
| 1270 | +const effDayDate = ref(new Date().toISOString().slice(0, 7)) | |
| 1271 | +const effMonthDate = ref(new Date().getFullYear().toString()) | |
| 1272 | +const effDeviceFilter = ref([]) | |
| 1273 | +const effLoading = ref(false) | |
| 1274 | +const effTotalKwh = ref(null) | |
| 1275 | + | |
| 1276 | +// Canvas ref | |
| 1277 | +const effChartCanvasRef = ref(null) | |
| 1278 | +const effWrapRef = ref(null) | |
| 1279 | +let effResizeObs = null | |
| 1280 | + | |
| 1281 | +// 接口数据 | |
| 1282 | +const effDataList = ref([]) // 原始设备列表 | |
| 1283 | +const effAllDevices = computed(() => effDataList.value.map(d => ({ dtuSn: d.dtuSn, deviceName: d.deviceName || d.dtuSn }))) | |
| 1284 | +const effDeviceList = computed(() => { | |
| 1285 | + if (!effDeviceFilter.value.length) return effDataList.value | |
| 1286 | + return effDataList.value.filter(d => effDeviceFilter.value.includes(d.dtuSn)) | |
| 1287 | +}) | |
| 1288 | +const effVisibleDevices = computed(() => | |
| 1289 | + effDeviceList.value.filter(d => !effHiddenDevices.has(d.dtuSn)) | |
| 1290 | +) | |
| 1291 | + | |
| 1292 | +// 图例点击筛选:隐藏/显示设备 | |
| 1293 | +const effHiddenDevices = reactive(new Set()) | |
| 1294 | +function toggleEffDevice(dtuSn) { | |
| 1295 | + if (effHiddenDevices.has(dtuSn)) { | |
| 1296 | + effHiddenDevices.delete(dtuSn) | |
| 1297 | + } else { | |
| 1298 | + effHiddenDevices.add(dtuSn) | |
| 1299 | + } | |
| 1300 | + drawEffChart() | |
| 1301 | +} | |
| 1302 | + | |
| 1303 | +// 表格视图:根据查询模式动态生成列和行数据 | |
| 1304 | +const effTableColumns = computed(() => { | |
| 1305 | + const list = effDeviceList.value | |
| 1306 | + if (!list.length) return [] | |
| 1307 | + if (effQueryMode.value === 'hour') { | |
| 1308 | + // 时:X轴 = kwhList[].date (如 "1时" ~ "24时") | |
| 1309 | + if (!list[0].kwhList || !list[0].kwhList.length) return [] | |
| 1310 | + return list[0].kwhList.map((k, i) => ({ label: k.date || '', key: i })) | |
| 1311 | + } else if (effQueryMode.value === 'day') { | |
| 1312 | + // 日:X轴 = dailyData[].date → MM-DD | |
| 1313 | + if (!list[0].dailyData || !list[0].dailyData.length) return [] | |
| 1314 | + return list[0].dailyData.map((d, i) => ({ label: d.date ? d.date.slice(5) : '', key: i })) | |
| 1315 | + } else { | |
| 1316 | + // 月:X轴 = monthlyData[].label (如 "1月" ~ "12月") | |
| 1317 | + if (!list[0].monthlyData || !list[0].monthlyData.length) return [] | |
| 1318 | + return list[0].monthlyData.map((m, i) => ({ label: m.label || '', key: i })) | |
| 1213 | 1319 | } |
| 1214 | - return pts.join(' ') | |
| 1215 | 1320 | }) |
| 1216 | -const effLine2Points = computed(() => { | |
| 1217 | - const pts = [] | |
| 1218 | - for (let i = 0; i < 24; i++) { | |
| 1219 | - pts.push(`${46 + i * 55},${380 - 0}`) | |
| 1321 | + | |
| 1322 | +const effTableRows = computed(() => { | |
| 1323 | + const list = effDeviceList.value | |
| 1324 | + if (!list.length) return [] | |
| 1325 | + return list.map(dev => { | |
| 1326 | + let values = [] | |
| 1327 | + if (effQueryMode.value === 'hour') { | |
| 1328 | + values = (dev.kwhList || []).map(k => Number(k.value) || 0) | |
| 1329 | + } else if (effQueryMode.value === 'day') { | |
| 1330 | + values = (dev.dailyData || []).map(d => Number(d.totalKwh) || 0) | |
| 1331 | + } else { | |
| 1332 | + values = (dev.monthlyData || []).map(m => Number(m.totalKwh) || 0) | |
| 1333 | + } | |
| 1334 | + // 补齐到列数(某些设备可能缺少部分时段/日期的数据) | |
| 1335 | + while (values.length < effTableColumns.value.length) values.push(null) | |
| 1336 | + return { deviceName: dev.deviceName || dev.dtuSn, values } | |
| 1337 | + }) | |
| 1338 | +}) | |
| 1339 | + | |
| 1340 | +// Hover | |
| 1341 | +const effHover = reactive({ show: false, x: 0, y: 0, timeLabel: '', devices: [] }) | |
| 1342 | +let effHitAreas = [] // 每个X位置的hover区域 | |
| 1343 | + | |
| 1344 | +function disabledYearFuture(time) { | |
| 1345 | + return time.getFullYear() > new Date().getFullYear() | |
| 1346 | +} | |
| 1347 | + | |
| 1348 | +function onEffModeChange() { fetchEffData() } | |
| 1349 | + | |
| 1350 | +async function fetchEffData() { | |
| 1351 | + effLoading.value = true | |
| 1352 | + let startDate, endDate, type | |
| 1353 | + | |
| 1354 | + if (effQueryMode.value === 'hour') { | |
| 1355 | + type = 1 | |
| 1356 | + startDate = effHourDate.value | |
| 1357 | + endDate = effHourDate.value | |
| 1358 | + } else if (effQueryMode.value === 'day') { | |
| 1359 | + type = 2 | |
| 1360 | + const [y, m] = effDayDate.value.split('-') | |
| 1361 | + startDate = effDayDate.value + '-01' | |
| 1362 | + const lastDay = new Date(parseInt(y), parseInt(m), 0).getDate() | |
| 1363 | + endDate = effDayDate.value + '-' + String(lastDay).padStart(2, '0') | |
| 1364 | + } else { | |
| 1365 | + type = 3 | |
| 1366 | + const year = effMonthDate.value | |
| 1367 | + startDate = year + '-01-01' | |
| 1368 | + endDate = year + '-12-31' | |
| 1369 | + } | |
| 1370 | + | |
| 1371 | + try { | |
| 1372 | + const res = await fetch(`/api/energy/eqKwhByType?startDate=${startDate}&endDate=${endDate}&type=${type}`) | |
| 1373 | + const data = await res.json() | |
| 1374 | + if (data.code === 200) { | |
| 1375 | + effDataList.value = data.list || [] | |
| 1376 | + effTotalKwh.value = data.grandTotalKwh ?? data.totalKwh ?? null | |
| 1377 | + if (effDeviceFilter.value.length) { | |
| 1378 | + // 如果有筛选,检查是否还在列表中 | |
| 1379 | + effDeviceFilter.value = effDeviceFilter.value.filter(sn => | |
| 1380 | + effDataList.value.some(d => d.dtuSn === sn) | |
| 1381 | + ) | |
| 1382 | + } | |
| 1383 | + await nextTick() | |
| 1384 | + drawEffChart() | |
| 1385 | + } | |
| 1386 | + } catch (err) { | |
| 1387 | + console.error('获取能耗效率数据失败:', err) | |
| 1388 | + } finally { | |
| 1389 | + effLoading.value = false | |
| 1390 | + } | |
| 1391 | +} | |
| 1392 | + | |
| 1393 | +function drawEffChart() { | |
| 1394 | + const canvas = effChartCanvasRef.value; if (!canvas) return | |
| 1395 | + const wrap = canvas.parentElement; if (!wrap) return | |
| 1396 | + const w = wrap.clientWidth || 1200 | |
| 1397 | + const h = wrap.clientHeight || 400 | |
| 1398 | + canvas.width = w * getDpr(); canvas.height = h * getDpr() | |
| 1399 | + canvas.style.width = w + 'px'; canvas.style.height = h + 'px' | |
| 1400 | + const ctx = canvas.getContext('2d'); ctx.scale(getDpr(), getDpr()) | |
| 1401 | + ctx.clearRect(0, 0, w, h) | |
| 1402 | + effHitAreas = [] | |
| 1403 | + | |
| 1404 | + const list = effDeviceList.value | |
| 1405 | + if (!list.length) { | |
| 1406 | + ctx.fillStyle = '#c0c4cc'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle' | |
| 1407 | + ctx.fillText('暂无数据', w / 2, h / 2); return | |
| 1408 | + } | |
| 1409 | + | |
| 1410 | + // 布局参数 | |
| 1411 | + const padL = 50, padR = 20, padT = 24, padB = 36 | |
| 1412 | + const chartW = w - padL - padR | |
| 1413 | + const chartH = h - padT - padB | |
| 1414 | + | |
| 1415 | + // 获取X轴标签和数据点 | |
| 1416 | + let xLabels = [], xDataMap = {} // deviceName -> [values] | |
| 1417 | + | |
| 1418 | + if (effQueryMode.value === 'hour') { | |
| 1419 | + // 时查询: kwhList 的 date 字段作为X轴标签 | |
| 1420 | + if (list[0].kwhList && list[0].kwhList.length) { | |
| 1421 | + xLabels = list[0].kwhList.map(k => k.date || '') | |
| 1422 | + } | |
| 1423 | + list.forEach(dev => { | |
| 1424 | + const vals = (dev.kwhList || []).map(k => Number(k.value) || 0) | |
| 1425 | + xDataMap[dev.dtuSn] = vals | |
| 1426 | + }) | |
| 1427 | + } else if (effQueryMode.value === 'day') { | |
| 1428 | + // 日查询: dailyData 的 date 字段 | |
| 1429 | + if (list[0].dailyData && list[0].dailyData.length) { | |
| 1430 | + xLabels = list[0].dailyData.map(d => d.date ? d.date.slice(5) : '') // MM-DD | |
| 1431 | + } | |
| 1432 | + list.forEach(dev => { | |
| 1433 | + const vals = (dev.dailyData || []).map(d => Number(d.totalKwh) || 0) | |
| 1434 | + xDataMap[dev.dtuSn] = vals | |
| 1435 | + }) | |
| 1436 | + } else { | |
| 1437 | + // 月查询(monthlyData): label作为X轴标签(如"1月","2月") | |
| 1438 | + if (list[0].monthlyData && list[0].monthlyData.length) { | |
| 1439 | + xLabels = list[0].monthlyData.map(m => m.label || '') | |
| 1440 | + } | |
| 1441 | + list.forEach(dev => { | |
| 1442 | + const vals = (dev.monthlyData || []).map(m => Number(m.totalKwh) || 0) | |
| 1443 | + xDataMap[dev.dtuSn] = vals | |
| 1444 | + }) | |
| 1445 | + } | |
| 1446 | + | |
| 1447 | + const pointCount = xLabels.length | |
| 1448 | + if (pointCount === 0) { | |
| 1449 | + ctx.fillStyle = '#c0c4cc'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle' | |
| 1450 | + ctx.fillText('暂无数据', w / 2, h / 2); return | |
| 1451 | + } | |
| 1452 | + | |
| 1453 | + // Y轴最大值计算 | |
| 1454 | + let maxYVal = 1 | |
| 1455 | + list.forEach(dev => { | |
| 1456 | + const vals = xDataMap[dev.dtuSn] || [] | |
| 1457 | + vals.forEach(v => { if (v > maxYVal) maxYVal = v }) | |
| 1458 | + }) | |
| 1459 | + const yMax = niceEffYMax(maxYVal) | |
| 1460 | + const yTicks = 5 | |
| 1461 | + | |
| 1462 | + // 绘制Y轴网格线和刻度 | |
| 1463 | + ctx.strokeStyle = '#ebeef5'; ctx.lineWidth = 1; ctx.font = '11px sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle' | |
| 1464 | + for (let i = 0; i <= yTicks; i++) { | |
| 1465 | + const vy = padT + chartH - (i / yTicks) * chartH | |
| 1466 | + const val = (i / yTicks) * yMax | |
| 1467 | + ctx.beginPath(); ctx.moveTo(padL, vy); ctx.lineTo(w - padR, vy); ctx.stroke() | |
| 1468 | + ctx.fillStyle = '#999' | |
| 1469 | + ctx.fillText(val >= 1 ? val.toFixed(0) : val.toFixed(1), padL - 8, vy) | |
| 1470 | + } | |
| 1471 | + | |
| 1472 | + // X轴线 | |
| 1473 | + ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1.5 | |
| 1474 | + ctx.beginPath(); ctx.moveTo(padL, padT + chartH); ctx.lineTo(w - padR, padT + chartH); ctx.stroke() | |
| 1475 | + | |
| 1476 | + // Y轴线 | |
| 1477 | + ctx.beginPath(); ctx.moveTo(padL, padT); ctx.lineTo(padL, padT + chartH); ctx.stroke() | |
| 1478 | + | |
| 1479 | + // 计算X轴间距 | |
| 1480 | + const stepX = chartW / Math.max(pointCount - 1, 1) | |
| 1481 | + | |
| 1482 | + // 绘制X轴标签(根据点数量动态调整显示间隔) | |
| 1483 | + ctx.fillStyle = '#666'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top' | |
| 1484 | + const xLabelStep = pointCount > 20 ? Math.ceil(pointCount / 12) : 1 | |
| 1485 | + xLabels.forEach((lbl, i) => { | |
| 1486 | + if (i % xLabelStep === 0 || i === pointCount - 1) { | |
| 1487 | + const px = padL + i * stepX | |
| 1488 | + ctx.fillText(lbl, px, padT + chartH + 10) | |
| 1489 | + } | |
| 1490 | + }) | |
| 1491 | + | |
| 1492 | + // 存储每个X位置用于hover检测 | |
| 1493 | + for (let i = 0; i < pointCount; i++) { | |
| 1494 | + effHitAreas.push({ x: padL + i * stepX - stepX / 2, w: stepX, index: i, label: xLabels[i] }) | |
| 1495 | + } | |
| 1496 | + | |
| 1497 | + // 绘制每条折线(平滑曲线) | |
| 1498 | + const yScale = chartH / yMax | |
| 1499 | + // 用于存储每个设备的点坐标,供hover垂直线绘制圆点 | |
| 1500 | + const devPointsMap = {} // dtuSn -> [{x, y}] | |
| 1501 | + | |
| 1502 | + list.forEach((dev, devIdx) => { | |
| 1503 | + const color = EFF_LINE_COLORS[devIdx % EFF_LINE_COLORS.length] | |
| 1504 | + const vals = xDataMap[dev.dtuSn] || [] | |
| 1505 | + const points = [] | |
| 1506 | + vals.forEach((v, i) => { | |
| 1507 | + points.push({ | |
| 1508 | + x: padL + i * stepX, | |
| 1509 | + y: padT + chartH - (Number(v) || 0) * yScale | |
| 1510 | + }) | |
| 1511 | + }) | |
| 1512 | + devPointsMap[dev.dtuSn] = points | |
| 1513 | + | |
| 1514 | + // 初始化该设备在各X位置的hover数据 | |
| 1515 | + for (let i = 0; i < pointCount; i++) { | |
| 1516 | + if (!effHitAreas[i].devs) effHitAreas[i].devs = [] | |
| 1517 | + effHitAreas[i].devs.push({ | |
| 1518 | + name: dev.deviceName || dev.dtuSn, | |
| 1519 | + value: i < vals.length ? (Number(vals[i]) || 0) : 0, | |
| 1520 | + color | |
| 1521 | + }) | |
| 1522 | + } | |
| 1523 | + | |
| 1524 | + if (points.length === 0) return | |
| 1525 | + | |
| 1526 | + // 绘制单调三次样条曲线(Monotone Cubic,不会过冲) | |
| 1527 | + ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineJoin = 'round' | |
| 1528 | + ctx.moveTo(points[0].x, points[0].y) | |
| 1529 | + | |
| 1530 | + if (points.length === 2) { | |
| 1531 | + // 两点:直接连线 | |
| 1532 | + ctx.lineTo(points[1].x, points[1].y) | |
| 1533 | + } else if (points.length > 2) { | |
| 1534 | + const n = points.length | |
| 1535 | + // 计算各点斜率(中心差分 + 边界单侧差分) | |
| 1536 | + const slopes = new Array(n) | |
| 1537 | + for (let i = 1; i < n - 1; i++) { | |
| 1538 | + slopes[i] = (points[i + 1].y - points[i - 1].y) / (points[i + 1].x - points[i - 1].x) | |
| 1539 | + } | |
| 1540 | + slopes[0] = (points[1].y - points[0].y) / (points[1].x - points[0].x) | |
| 1541 | + slopes[n - 1] = (points[n - 1].y - points[n - 2].y) / (points[n - 1].x - points[n - 2].x) | |
| 1542 | + | |
| 1543 | + // Fritsch-Carlson 单调性修正:确保斜率不导致过冲 | |
| 1544 | + for (let i = 0; i < n - 1; i++) { | |
| 1545 | + const dx = points[i + 1].x - points[i].x | |
| 1546 | + const dy = points[i + 1].y - points[i].y | |
| 1547 | + if (Math.abs(dy) < 1e-8) { | |
| 1548 | + // 相邻两点Y相同 → 斜率归零(直线) | |
| 1549 | + slopes[i] = 0; slopes[i + 1] = 0 | |
| 1550 | + } else { | |
| 1551 | + const s = dy / dx | |
| 1552 | + // α, β: 斜率与割线的比值 | |
| 1553 | + const alpha = Math.abs(slopes[i] / s) | |
| 1554 | + const beta = Math.abs(slopes[i + 1] / s) | |
| 1555 | + const ab = alpha * beta | |
| 1556 | + if (ab > 3) { // 会过冲 → 缩小斜率 | |
| 1557 | + const tau = 3.0 / Math.sqrt(ab) | |
| 1558 | + slopes[i] *= Math.min(tau, 1) | |
| 1559 | + slopes[i + 1] *= Math.min(tau, 1) | |
| 1560 | + } | |
| 1561 | + } | |
| 1562 | + } | |
| 1563 | + | |
| 1564 | + // 用 Hermite 形式绘制每段 Bezier 曲线(控制点 Y 钳位防过冲) | |
| 1565 | + for (let i = 0; i < n - 1; i++) { | |
| 1566 | + const dx = points[i + 1].x - points[i].x | |
| 1567 | + const y0 = points[i].y, y1 = points[i + 1].y | |
| 1568 | + const cp1x = points[i].x + dx / 3 | |
| 1569 | + let cp1y = points[i].y + slopes[i] * (dx / 3) | |
| 1570 | + const cp2x = points[i + 1].x - dx / 3 | |
| 1571 | + let cp2y = points[i + 1].y - slopes[i + 1] * (dx / 3) | |
| 1572 | + // 将控制点 Y 钳位到两端点之间,彻底杜绝过冲/下冲 | |
| 1573 | + const lo = Math.min(y0, y1), hi = Math.max(y0, y1) | |
| 1574 | + cp1y = Math.max(lo, Math.min(hi, cp1y)) | |
| 1575 | + cp2y = Math.max(lo, Math.min(hi, cp2y)) | |
| 1576 | + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, points[i + 1].x, points[i + 1].y) | |
| 1577 | + } | |
| 1578 | + } | |
| 1579 | + ctx.stroke() | |
| 1580 | + | |
| 1581 | + // 绘制数据点圆圈 | |
| 1582 | + points.forEach(p => { | |
| 1583 | + ctx.fillStyle = '#fff' | |
| 1584 | + ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2); ctx.fill() | |
| 1585 | + ctx.strokeStyle = color; ctx.lineWidth = 1.5 | |
| 1586 | + ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2); ctx.stroke() | |
| 1587 | + }) | |
| 1588 | + }) | |
| 1589 | + | |
| 1590 | + // Hover 垂直虚线 + 高亮圆点 | |
| 1591 | + if (effHover.show && effHover.hoverPx !== undefined && !isNaN(effHover.hoverPx)) { | |
| 1592 | + const hx = effHover.hoverPx | |
| 1593 | + ctx.strokeStyle = '#c0c4cc'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]) | |
| 1594 | + ctx.beginPath(); ctx.moveTo(hx, padT); ctx.lineTo(hx, padT + chartH); ctx.stroke() | |
| 1595 | + ctx.setLineDash([]) | |
| 1596 | + | |
| 1597 | + // 在垂直线位置重绘高亮圆点(略大) | |
| 1598 | + list.forEach((dev, devIdx) => { | |
| 1599 | + const color = EFF_LINE_COLORS[devIdx % EFF_LINE_COLORS.length] | |
| 1600 | + const pts = devPointsMap[dev.dtuSn] || [] | |
| 1601 | + // 找最近的点 | |
| 1602 | + let bestP = null, bestD = Infinity | |
| 1603 | + pts.forEach(p => { const d = Math.abs(p.x - hx); if (d < bestD) { bestD = d; bestP = p } }) | |
| 1604 | + if (bestP && bestD < stepX / 2 + 2) { | |
| 1605 | + ctx.fillStyle = '#fff' | |
| 1606 | + ctx.beginPath(); ctx.arc(bestP.x, bestP.y, 5, 0, Math.PI * 2); ctx.fill() | |
| 1607 | + ctx.strokeStyle = color; ctx.lineWidth = 2 | |
| 1608 | + ctx.beginPath(); ctx.arc(bestP.x, bestP.y, 5, 0, Math.PI * 2); ctx.stroke() | |
| 1609 | + } | |
| 1610 | + }) | |
| 1611 | + } | |
| 1612 | +} | |
| 1613 | + | |
| 1614 | +// Y轴取整函数 | |
| 1615 | +function niceEffYMax(val) { | |
| 1616 | + if (val <= 0) return 10 | |
| 1617 | + if (val <= 5) return 5 | |
| 1618 | + if (val <= 10) return 10 | |
| 1619 | + if (val <= 20) return 20 | |
| 1620 | + if (val <= 30) return 30 | |
| 1621 | + if (val <= 50) return 50 | |
| 1622 | + if (val <= 70) return 70 | |
| 1623 | + if (val <= 100) return 100 | |
| 1624 | + if (val <= 200) return 200 | |
| 1625 | + if (val <= 500) return 500 | |
| 1626 | + return Math.ceil(val / 100) * 100 | |
| 1627 | +} | |
| 1628 | + | |
| 1629 | +function onEffChartHover(e) { | |
| 1630 | + const canvas = effChartCanvasRef.value; if (!canvas) return | |
| 1631 | + const rect = canvas.getBoundingClientRect() | |
| 1632 | + const mx = e.clientX - rect.left, my = e.clientY - rect.top | |
| 1633 | + | |
| 1634 | + // 始终更新hoverPx和tooltip位置,用于垂直虚线定位 | |
| 1635 | + effHover.hoverPx = mx | |
| 1636 | + | |
| 1637 | + let hit = null | |
| 1638 | + for (const area of effHitAreas) { | |
| 1639 | + if (mx >= area.x && mx <= area.x + area.w) { hit = area; break } | |
| 1640 | + } | |
| 1641 | + | |
| 1642 | + if (hit && hit.devs && hit.devs.length > 0) { | |
| 1643 | + effHover.timeLabel = hit.label || '' | |
| 1644 | + effHover.devices = [...hit.devs] | |
| 1645 | + effHover.show = true | |
| 1646 | + const tipW = 180, tipH = 40 + hit.devs.length * 28 | |
| 1647 | + let tx = mx + 12, ty = my - tipH - 8 | |
| 1648 | + if (tx + tipW > rect.width - 4) tx = mx - tipW - 6 | |
| 1649 | + if (ty < 4) ty = my + 14 | |
| 1650 | + effHover.x = Math.max(4, tx) | |
| 1651 | + effHover.y = Math.max(4, ty) | |
| 1652 | + } else { | |
| 1653 | + effHover.show = false | |
| 1654 | + } | |
| 1655 | + | |
| 1656 | + // 重绘以更新垂直虚线和高亮圆点 | |
| 1657 | + drawEffChart() | |
| 1658 | +} | |
| 1659 | + | |
| 1660 | +function onEffChartLeave() { effHover.show = false; effHover.hoverPx = undefined; drawEffChart() } | |
| 1661 | + | |
| 1662 | +// 能耗效率 tab 初始化 | |
| 1663 | +watch(currentStatus, async (val) => { | |
| 1664 | + if (val === 'efficiency') { | |
| 1665 | + await nextTick() | |
| 1666 | + initEffObserver() | |
| 1667 | + fetchEffData() | |
| 1668 | + } else { | |
| 1669 | + destroyEffObserver() | |
| 1220 | 1670 | } |
| 1221 | - return pts.join(' ') | |
| 1222 | 1671 | }) |
| 1672 | + | |
| 1673 | +function initEffObserver() { | |
| 1674 | + destroyEffObserver() | |
| 1675 | + effResizeObs = new ResizeObserver(() => { if (currentStatus.value === 'efficiency') drawEffChart() }) | |
| 1676 | + const el = document.querySelector('.eff-view') | |
| 1677 | + if (el) effResizeObs.observe(el) | |
| 1678 | +} | |
| 1679 | +function destroyEffObserver() { if (effResizeObs) { effResizeObs.disconnect(); effResizeObs = null } } | |
| 1680 | +onBeforeUnmount(() => destroyEffObserver()) | |
| 1223 | 1681 | </script> |
| 1224 | 1682 | |
| 1225 | 1683 | <style scoped> |
| ... | ... | @@ -1810,6 +2268,10 @@ const effLine2Points = computed(() => { |
| 1810 | 2268 | /* ========== 能耗效率 ========== */ |
| 1811 | 2269 | .eff-view { |
| 1812 | 2270 | background: #f5f7fa; |
| 2271 | + flex: 1; | |
| 2272 | + display: flex; | |
| 2273 | + flex-direction: column; | |
| 2274 | + min-height: 0; | |
| 1813 | 2275 | overflow-y: auto; |
| 1814 | 2276 | } |
| 1815 | 2277 | .eff-toolbar { |
| ... | ... | @@ -1819,23 +2281,32 @@ const effLine2Points = computed(() => { |
| 1819 | 2281 | align-items: center; |
| 1820 | 2282 | gap: 10px; |
| 1821 | 2283 | border-bottom: 1px solid #e8e8e8; |
| 2284 | + flex-shrink: 0; | |
| 1822 | 2285 | } |
| 1823 | 2286 | .eff-label { |
| 1824 | 2287 | font-size: 13px; color: #666; font-weight: bold; |
| 1825 | 2288 | } |
| 1826 | 2289 | .eff-legend { |
| 1827 | - padding: 10px 24px; | |
| 1828 | - font-size: 13px; | |
| 2290 | + padding: 8px 24px; | |
| 2291 | + font-size: 12px; | |
| 1829 | 2292 | color: #666; |
| 1830 | 2293 | display: flex; |
| 1831 | 2294 | align-items: center; |
| 1832 | 2295 | gap: 20px; |
| 2296 | + flex-wrap: wrap; | |
| 2297 | + flex-shrink: 0; | |
| 2298 | + background: #fff; | |
| 2299 | + border-bottom: 1px solid #ebeef5; | |
| 1833 | 2300 | } |
| 1834 | 2301 | .leg-line { |
| 1835 | 2302 | display: inline-flex; |
| 1836 | 2303 | align-items: center; |
| 1837 | 2304 | gap: 5px; |
| 2305 | + cursor: pointer; | |
| 2306 | + transition: opacity 0.2s; | |
| 1838 | 2307 | } |
| 2308 | +.leg-line:hover { opacity: 0.7; } | |
| 2309 | +.leg-line.leg-dimmed { opacity: 0.35; } | |
| 1839 | 2310 | .leg-line i { |
| 1840 | 2311 | display: inline-block; |
| 1841 | 2312 | width: 16px; |
| ... | ... | @@ -1843,13 +2314,128 @@ const effLine2Points = computed(() => { |
| 1843 | 2314 | border-radius: 2px; |
| 1844 | 2315 | background: var(--lc); |
| 1845 | 2316 | } |
| 1846 | -.eff-chart { | |
| 1847 | - margin: 0 20px 20px; | |
| 2317 | +.eff-chart.canvas-eff-chart { | |
| 2318 | + margin: 0 20px 14px; | |
| 1848 | 2319 | background: #fff; |
| 1849 | 2320 | border-radius: 6px; |
| 1850 | 2321 | box-shadow: 0 1px 4px rgba(0,0,0,0.06); |
| 1851 | - padding: 14px; | |
| 1852 | - overflow-x: auto; | |
| 2322 | + padding: 16px 14px 14px; | |
| 2323 | + flex: 1; | |
| 2324 | + min-height: 380px; | |
| 2325 | + overflow: visible; | |
| 2326 | + position: relative; | |
| 2327 | +} | |
| 2328 | +.eff-chart canvas { | |
| 2329 | + display: block; | |
| 2330 | + width: 100%; | |
| 2331 | +} | |
| 2332 | + | |
| 2333 | +/* 能耗效率 tooltip */ | |
| 2334 | +.eff-tooltip { | |
| 2335 | + position: absolute; | |
| 2336 | + background: rgba(30,40,55,0.95); | |
| 2337 | + border-radius: 6px; | |
| 2338 | + padding: 10px 14px; | |
| 2339 | + min-width: 160px; | |
| 2340 | + z-index: 200; | |
| 2341 | + pointer-events: none; | |
| 2342 | + box-shadow: 0 4px 16px rgba(0,0,0,0.25); | |
| 2343 | +} | |
| 2344 | +.eft-title { | |
| 2345 | + font-size: 12px; font-weight:bold; color:#eef1f7; margin-bottom:6px; padding-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.12); | |
| 2346 | +} | |
| 2347 | +.eft-row { | |
| 2348 | + display:flex; align-items:center; justify-content:space-between; gap:12px; line-height:2.2; font-size:12px; | |
| 2349 | +} | |
| 2350 | +.eft-row .dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } | |
| 2351 | +.eft-name { color:#aab2c0; } | |
| 2352 | +.eft-val { color:#eef1f7; font-weight:500; } | |
| 2353 | + | |
| 2354 | +/* 总用电量统计 */ | |
| 2355 | +.eff-summary { | |
| 2356 | + padding: 8px 24px 14px; | |
| 2357 | + font-size: 13px; | |
| 2358 | + color: #666; | |
| 2359 | + background: #fff; | |
| 2360 | + border-top: 1px solid #ebeef5; | |
| 2361 | + text-align: center; | |
| 2362 | + flex-shrink: 0; | |
| 2363 | +} | |
| 2364 | +.eff-sum-label { color: #909399; margin-right: 4px; } | |
| 2365 | +.eff-sum-val { color: #409eff; font-weight:bold; font-size:15px; margin-right: 6px; } | |
| 2366 | +.eff-sum-count { color: #999; font-size:12px; } | |
| 2367 | + | |
| 2368 | +/* 视图切换图标按钮 */ | |
| 2369 | +.view-btn { | |
| 2370 | + width: 30px; | |
| 2371 | + height: 30px; | |
| 2372 | + padding: 0; | |
| 2373 | + display: flex; | |
| 2374 | + align-items: center; | |
| 2375 | + justify-content: center; | |
| 2376 | + cursor: pointer; | |
| 2377 | + transition: all 0.2s; | |
| 2378 | + color: #606266; | |
| 2379 | + background: #fff; | |
| 2380 | +} | |
| 2381 | +.view-btn:hover { color: #409eff; border-color: #409eff !important; } | |
| 2382 | +.view-btn.active { color: #409eff; border-color: #409eff !important; } | |
| 2383 | + | |
| 2384 | +/* 历史数据表格(简洁风格) */ | |
| 2385 | +.eff-table-wrap { | |
| 2386 | + flex: 1; | |
| 2387 | + overflow: auto; | |
| 2388 | + background: #fff; | |
| 2389 | + margin: 8px 20px 14px; | |
| 2390 | + border: 1px solid #ebeef5; | |
| 2391 | +} | |
| 2392 | +.eff-history-table { | |
| 2393 | + width: 100%; | |
| 2394 | + border-collapse: collapse; | |
| 2395 | + table-layout: auto; | |
| 2396 | +} | |
| 2397 | +.eff-history-table th, | |
| 2398 | +.eff-history-table td { | |
| 2399 | + padding: 8px 12px; | |
| 2400 | + text-align: center; | |
| 2401 | + font-size: 13px; | |
| 2402 | + border-right: 1px solid #ebeef5; | |
| 2403 | + white-space: nowrap; | |
| 2404 | + line-height: 1.8; | |
| 2405 | +} | |
| 2406 | +.eff-history-table th { | |
| 2407 | + background: #fff; | |
| 2408 | + font-weight: 600; | |
| 2409 | + color: #333; | |
| 2410 | + position: sticky; | |
| 2411 | + top: 0; | |
| 2412 | + z-index: 2; | |
| 2413 | + border-bottom: 1px solid #ddd; | |
| 2414 | +} | |
| 2415 | +.eff-history-table td { | |
| 2416 | + color: #555; | |
| 2417 | + border-bottom: 1px solid #ebeef5; | |
| 2418 | +} | |
| 2419 | +.eff-history-table tbody tr:last-child td { border-bottom: 1px solid #ebeef5; } | |
| 2420 | +.eff-history-table .col-name, | |
| 2421 | +.eff-history-table .td-name { | |
| 2422 | + position: sticky; | |
| 2423 | + left: 0; | |
| 2424 | + z-index: 1; | |
| 2425 | + text-align: left !important; | |
| 2426 | + font-weight: 500; | |
| 2427 | + width: 120px; | |
| 2428 | + border-left: none !important; | |
| 2429 | +} | |
| 2430 | +.eff-history-table .td-name { background: #fff; } | |
| 2431 | +.eff-history-table tbody tr:hover .td-name, | |
| 2432 | +.eff-history-table tbody tr:hover { background: #fafafa; } | |
| 2433 | +.eff-table-empty { | |
| 2434 | + display: flex; | |
| 2435 | + align-items: center; | |
| 2436 | + justify-content: center; | |
| 2437 | + height: 160px; | |
| 2438 | + font-size: 13px; | |
| 2439 | + color: #909399; | |
| 1853 | 2440 | } |
| 1854 | -.eff-chart svg { min-width: 1200px; } | |
| 1855 | 2441 | </style> | ... | ... |