Commit ea214724a9572b45acd745f01da782da51e660bf

Authored by 杨鸣坤
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>
... ...