Showing
1 changed file
with
367 additions
and
58 deletions
| @@ -88,64 +88,38 @@ | @@ -88,64 +88,38 @@ | ||
| 88 | </div> | 88 | </div> |
| 89 | </div><!-- /tab-content realtime --> | 89 | </div><!-- /tab-content realtime --> |
| 90 | 90 | ||
| 91 | - <!-- ========== 时序状态:时间轴甘特图 ========== --> | ||
| 92 | - <div v-else-if="currentStatus === 'timeseries'" class="tab-content timeseries-view"> | 91 | + <!-- ========== 时序状态:Canvas甘特图 ========== --> |
| 92 | + <div v-else-if="currentStatus === 'timeseries'" class="tab-content timeseries-view" ref="tsWrapRef"> | ||
| 93 | <div class="ts-toolbar"> | 93 | <div class="ts-toolbar"> |
| 94 | <span class="ts-label">查询方式:</span> | 94 | <span class="ts-label">查询方式:</span> |
| 95 | - <el-radio-group v-model="tsQueryMode" size="small"> | 95 | + <el-radio-group v-model="tsQueryMode" size="small" @change="onTsModeChange"> |
| 96 | <el-radio-button value="day">日查询</el-radio-button> | 96 | <el-radio-button value="day">日查询</el-radio-button> |
| 97 | </el-radio-group> | 97 | </el-radio-group> |
| 98 | - <el-date-picker v-model="tsDateRange" type="daterange" size="small" range-separator="-" | ||
| 99 | - start-placeholder="" end-placeholder="" style="width: 260px; margin-left: 8px;" /> | ||
| 100 | - <el-button type="primary" size="small">查询</el-button> | 98 | + <el-date-picker v-model="tsSelectedDate" type="date" placeholder="" size="small" |
| 99 | + style="width:160px;margin-left:8px;" value-format="YYYY-MM-DD" | ||
| 100 | + :disabled-date="disabledDateFuture" @change="fetchTimelineData" /> | ||
| 101 | + <el-button type="primary" size="small" style="margin-left:8px;" @click="fetchTimelineData">查询</el-button> | ||
| 101 | </div> | 102 | </div> |
| 102 | 103 | ||
| 103 | - <div class="ts-table-wrap"> | ||
| 104 | - <div class="ts-header-row"> | ||
| 105 | - <div class="ts-col-name">设备名称</div> | ||
| 106 | - <div class="ts-col-name ts-sub-col">稼动率</div> | ||
| 107 | - <div class="ts-col-name ts-sub-col2">用电量</div> | ||
| 108 | - <div class="ts-timeline-area"> | ||
| 109 | - <div style="display:flex;justify-content:space-between;padding-right:16px;"> | ||
| 110 | - <span style="font-size:12px;color:#333;font-weight:bold;">{{ tsHeaderDate }}</span> | ||
| 111 | - <span style="font-size:12px;color:#333;font-weight:bold;">202</span> | ||
| 112 | - </div> | ||
| 113 | - <svg viewBox="0 0 1200 1" preserveAspectRatio="none" style="width:100%;height:30px;"> | ||
| 114 | - <g font-size="11" fill="#666" text-anchor="middle"> | ||
| 115 | - <text x="50" y="-5">10:15</text><text x="150" y="-5">10:30</text> | ||
| 116 | - <text x="250" y="-5">10:45</text> | ||
| 117 | - <text x="350" y="-5">11:00</text> | ||
| 118 | - <text x="450" y="-5">11:15</text> | ||
| 119 | - <text x="550" y="-5">11:30</text> | ||
| 120 | - <text x="650" y="-5">11:45</text><text x="750" y="-5">12:00</text> | ||
| 121 | - <text x="850" y="-5">12:15</text> | ||
| 122 | - <text x="950" y="-5">12:30</text><text x="1050" y="-5">12:</text><text x="1150" y="-5"></text> | ||
| 123 | - </g> | ||
| 124 | - </svg> | ||
| 125 | - </div> | ||
| 126 | - </div> | ||
| 127 | - | ||
| 128 | - <div v-for="(dev, idx) in energyTimeSeriesData" :key="idx" | ||
| 129 | - :class="['ts-row', { 'row-gray': dev.rate === 0 }]"> | ||
| 130 | - <div class="ts-cell-name"> | ||
| 131 | - <span class="ts-link">{{ dev.name }}</span> | ||
| 132 | - </div> | ||
| 133 | - <div class="ts-cell-rate">{{ dev.rate }}%</div> | ||
| 134 | - <div class="ts-cell-rate">{{ dev.power }}</div> | ||
| 135 | - <div class="ts-cell-bars"> | ||
| 136 | - <div class="bar-track"> | ||
| 137 | - <template v-for="(seg, si) in dev.segments" :key="si"> | ||
| 138 | - <div class="bar-seg" :class="'seg-' + seg.color" | ||
| 139 | - :style="{ left: seg.left + '%', width: seg.width + '%' }"></div> | ||
| 140 | - </template> | ||
| 141 | - </div> | 104 | + <div class="ts-gantt-wrap" v-loading="tsLoading"> |
| 105 | + <div class="ts-fixed-col"><canvas ref="tsFixedCanvasRef"></canvas></div> | ||
| 106 | + <div class="ts-scroll-area" ref="tsScrollAreaRef" @scroll="onTsScroll"> | ||
| 107 | + <canvas ref="tsGanttCanvasRef" @mousemove="onTsGanttMouseMove" @mouseleave="onTsGanttMouseLeave" @wheel.prevent.stop="onTsGanttWheel"></canvas> | ||
| 108 | + <div v-if="tsHover.show" class="gantt-tooltip" :style="{ left: tsHover.x + 'px', top: tsHover.y + 'px' }"> | ||
| 109 | + <div class="gtt-title">{{ tsHover.deviceName }}</div> | ||
| 110 | + <div class="gtt-row"><span class="gtt-label">状态</span><span class="gtt-val" :style="{ color: TS_STATUS_COLORS[tsHover.status] }">{{ tsStatusLabel(tsHover.status) }}</span></div> | ||
| 111 | + <div class="gtt-row"><span class="gtt-label">开始</span><span class="gtt-val">{{ tsHover.startTime }}</span></div> | ||
| 112 | + <div class="gtt-row"><span class="gtt-label">结束</span><span class="gtt-val">{{ tsHover.endTime || '-' }}</span></div> | ||
| 113 | + <div class="gtt-row"><span class="gtt-label">时长</span><span class="gtt-val gtt-highlight">{{ tsFormatDuration(tsHover.duration) }}</span></div> | ||
| 142 | </div> | 114 | </div> |
| 143 | </div> | 115 | </div> |
| 144 | </div> | 116 | </div> |
| 145 | 117 | ||
| 146 | <div class="pagination-wrapper"> | 118 | <div class="pagination-wrapper"> |
| 147 | - <span>共 {{ energyTimeSeriesData.length }} 条</span> | ||
| 148 | - <el-pagination :current-page="1" :page-size="20" layout="prev, pager, next, total, jumper" :total="energyTimeSeriesData.length" small /> | 119 | + <span>共 {{ tsTotal }} 条</span> |
| 120 | + <el-pagination small layout="sizes, prev, pager, next, jumper" | ||
| 121 | + v-model:current-page="tsPageNo" v-model:page-size="tsPageSize" | ||
| 122 | + :total="tsTotal" :page-sizes="[12, 24, 48]" @size-change="fetchTimelineData" @current-change="fetchTimelineData" /> | ||
| 149 | </div> | 123 | </div> |
| 150 | </div> | 124 | </div> |
| 151 | 125 | ||
| @@ -294,7 +268,7 @@ | @@ -294,7 +268,7 @@ | ||
| 294 | </template> | 268 | </template> |
| 295 | 269 | ||
| 296 | <script setup> | 270 | <script setup> |
| 297 | -import { ref, reactive, computed, onMounted } from 'vue' | 271 | +import { ref, reactive, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue' |
| 298 | import { Search, Menu, Document, Lock, Setting, Warning, Histogram } from '@element-plus/icons-vue' | 272 | import { Search, Menu, Document, Lock, Setting, Warning, Histogram } from '@element-plus/icons-vue' |
| 299 | import EnergyReportDialog from '../components/EnergyReportDialog.vue' | 273 | import EnergyReportDialog from '../components/EnergyReportDialog.vue' |
| 300 | import SafetyDialog from '../components/SafetyDialog.vue' | 274 | import SafetyDialog from '../components/SafetyDialog.vue' |
| @@ -422,14 +396,304 @@ function openDetail(type, device) { | @@ -422,14 +396,304 @@ function openDetail(type, device) { | ||
| 422 | dialogVisible[type] = true | 396 | dialogVisible[type] = true |
| 423 | } | 397 | } |
| 424 | 398 | ||
| 425 | -// ========== 时序状态数据 ========== | 399 | +// ========== 时序状态:Canvas甘特图 ========== |
| 400 | +const TS_STATUS_COLORS = { 0: '#909399', 1: '#e74c3c', 2: '#67c23a', 3: '#c5d94e' } | ||
| 401 | +const TS_STATUS_MAP = { 0: '离线', 1: '停机', 2: '运行', 3: '待机' } | ||
| 402 | + | ||
| 426 | const tsQueryMode = ref('day') | 403 | const tsQueryMode = ref('day') |
| 427 | -const tsDateRange = ref(null) | ||
| 428 | -const tsHeaderDate = ref('2026-04-28') | ||
| 429 | -const energyTimeSeriesData = ref([ | ||
| 430 | - { name: '能耗设备1', rate: 0, power: 0, segments: [{color:'gy',left:0,width:100}] }, | ||
| 431 | - { name: '能耗设备2', rate: 0, power: 0, segments: [{color:'gy',left:0,width:100}] } | ||
| 432 | -]) | 404 | +const tsSelectedDate = ref(new Date().toISOString().slice(0, 10)) |
| 405 | +const tsPageNo = ref(1) | ||
| 406 | +const tsPageSize = ref(12) | ||
| 407 | +const tsTotal = ref(0) | ||
| 408 | +const tsLoading = ref(false) | ||
| 409 | +const tsTimelineList = ref([]) | ||
| 410 | +// 视图缩放:zoomLevel=1显示24h,越大显示的时间范围越短 | ||
| 411 | +const tsZoomLevel = ref(1) | ||
| 412 | +const TS_ZOOM_MIN = 1 // 最小:一屏24h | ||
| 413 | +const TS_ZOOM_MAX = 8 // 最大:一屏约3h | ||
| 414 | +// 视图中心时间点(毫秒),用于鼠标位置为中心的缩放 | ||
| 415 | +const tsViewCenterMs = ref(0) | ||
| 416 | + | ||
| 417 | +function disabledDateFuture(time) { return time.getTime() > Date.now() } | ||
| 418 | +function onTsModeChange() { fetchTimelineData() } | ||
| 419 | +function tsStatusLabel(s) { return TS_STATUS_MAP[s] || '未知' } | ||
| 420 | +function tsFormatDuration(sec) { | ||
| 421 | + if (!sec && sec !== 0) return '-' | ||
| 422 | + sec = Number(sec) | ||
| 423 | + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60 | ||
| 424 | + let str = '' | ||
| 425 | + if (h > 0) str += h + '时' | ||
| 426 | + if (m > 0) str += m + '分' | ||
| 427 | + if (s > 0 || !str) str += s + '秒' | ||
| 428 | + return str | ||
| 429 | +} | ||
| 430 | + | ||
| 431 | +// Canvas refs | ||
| 432 | +const tsFixedCanvasRef = ref(null) | ||
| 433 | +const tsGanttCanvasRef = ref(null) | ||
| 434 | +const tsScrollAreaRef = ref(null) | ||
| 435 | +let tsResizeObs = null | ||
| 436 | + | ||
| 437 | +// Hover | ||
| 438 | +const tsHover = reactive({ show: false, x: 0, y: 0, rowIdx: -1, segIdx: -1, | ||
| 439 | + deviceName: '', status: 0, startTime: '', endTime: '', duration: 0 }) | ||
| 440 | +let tsHitRects = [] | ||
| 441 | + | ||
| 442 | +// 布局常量 | ||
| 443 | +const TS_ROW_H = 36 | ||
| 444 | +const TS_FIXED_W = 220 | ||
| 445 | +const TS_AXIS_H = 32 | ||
| 446 | + | ||
| 447 | +async function fetchTimelineData() { | ||
| 448 | + tsLoading.value = true | ||
| 449 | + tsZoomLevel.value = 1 | ||
| 450 | + tsViewCenterMs.value = 0 | ||
| 451 | + try { | ||
| 452 | + const url = `/api/energy/timelineStatus?date=${tsSelectedDate.value}&pageSize=${tsPageSize.value}&pageNo=${tsPageNo.value}` | ||
| 453 | + const res = await fetch(url) | ||
| 454 | + const data = await res.json() | ||
| 455 | + if (data.code === 200) { | ||
| 456 | + tsTimelineList.value = data.list || [] | ||
| 457 | + tsTotal.value = data.total || 0 | ||
| 458 | + await nextTick() | ||
| 459 | + drawTsAll() | ||
| 460 | + } | ||
| 461 | + } catch (err) { | ||
| 462 | + console.error('获取时序状态失败:', err) | ||
| 463 | + } finally { | ||
| 464 | + tsLoading.value = false | ||
| 465 | + } | ||
| 466 | +} | ||
| 467 | + | ||
| 468 | +function getDpr() { return window.devicePixelRatio || 1 } | ||
| 469 | + | ||
| 470 | +function drawTsAll() { drawTsFixedCol(); drawTsGanttChart(); } | ||
| 471 | + | ||
| 472 | +// 左侧固定列绘制 | ||
| 473 | +function drawTsFixedCol() { | ||
| 474 | + const canvas = tsFixedCanvasRef.value; if (!canvas) return | ||
| 475 | + const list = tsTimelineList.value | ||
| 476 | + const h = Math.max(TS_AXIS_H + list.length * TS_ROW_H + 8, 80) | ||
| 477 | + canvas.width = TS_FIXED_W * getDpr(); canvas.height = h * getDpr() | ||
| 478 | + canvas.style.width = TS_FIXED_W + 'px'; canvas.style.height = h + 'px' | ||
| 479 | + const ctx = canvas.getContext('2d'); ctx.scale(getDpr(), getDpr()) | ||
| 480 | + ctx.fillStyle = '#fafafa'; ctx.fillRect(0, 0, TS_FIXED_W, h) | ||
| 481 | + | ||
| 482 | + // 表头 | ||
| 483 | + ctx.fillStyle = '#f0f2f5'; ctx.fillRect(0, 0, TS_FIXED_W, TS_AXIS_H) | ||
| 484 | + ctx.strokeStyle = '#e4e7ed'; ctx.lineWidth = 1 | ||
| 485 | + ctx.beginPath(); ctx.moveTo(0, TS_AXIS_H); ctx.lineTo(TS_FIXED_W, TS_AXIS_H); ctx.stroke() | ||
| 486 | + | ||
| 487 | + ctx.font = 'bold 13px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#333' | ||
| 488 | + const cW = [TS_FIXED_W * 0.42, TS_FIXED_W * 0.22, TS_FIXED_W * 0.36] | ||
| 489 | + ctx.fillText('设备名称', cW[0] / 2, TS_AXIS_H / 2) | ||
| 490 | + ctx.fillText('稼动率', cW[0] + cW[1] / 2, TS_AXIS_H / 2) | ||
| 491 | + ctx.fillText('用电量', cW[0] + cW[1] + cW[2] / 2, TS_AXIS_H / 2) | ||
| 492 | + | ||
| 493 | + // 列分隔线 | ||
| 494 | + ctx.strokeStyle = '#ebeef5' | ||
| 495 | + let cx = cW[0]; ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, h); ctx.stroke() | ||
| 496 | + cx += cW[1]; ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, h); ctx.stroke() | ||
| 497 | + | ||
| 498 | + // 数据行 | ||
| 499 | + ctx.font = '12px sans-serif' | ||
| 500 | + list.forEach((item, i) => { | ||
| 501 | + const y = TS_AXIS_H + i * TS_ROW_H | ||
| 502 | + if (i % 2 === 1) { ctx.fillStyle = '#f9f9f9'; ctx.fillRect(0, y, TS_FIXED_W, TS_ROW_H) } | ||
| 503 | + ctx.strokeStyle = '#f0f0f0'; ctx.beginPath(); ctx.moveTo(0, y + TS_ROW_H); ctx.lineTo(TS_FIXED_W, y + TS_ROW_H); ctx.stroke() | ||
| 504 | + const cy = y + TS_ROW_H / 2 | ||
| 505 | + ctx.fillStyle = '#303133'; ctx.textAlign = 'left' | ||
| 506 | + ctx.fillText(item.deviceName || item.dtuSn || '-', 10, cy) | ||
| 507 | + const ur = item.utilizationRate ?? 0 | ||
| 508 | + ctx.fillStyle = ur >= 30 ? '#67c23a' : ur > 0 ? '#e6a23c' : '#909399' | ||
| 509 | + ctx.textAlign = 'center' | ||
| 510 | + ctx.fillText((ur % 1 === 0 ? ur.toFixed(1) : ur.toFixed(2)) + '%', cW[0] + cW[1] / 2, cy) | ||
| 511 | + ctx.fillStyle = '#303133'; ctx.textAlign = 'right' | ||
| 512 | + ctx.fillText(String(item.totalKwh ?? 0), cW[0] + cW[1] + cW[2] - 8, cy) | ||
| 513 | + }) | ||
| 514 | +} | ||
| 515 | + | ||
| 516 | +// 甘特图绘制(视图缩放:canvas宽度始终=容器宽度,不产生滚动条) | ||
| 517 | +function drawTsGanttChart() { | ||
| 518 | + const canvas = tsGanttCanvasRef.value; const wrap = tsScrollAreaRef.value | ||
| 519 | + if (!canvas || !wrap) return | ||
| 520 | + const list = tsTimelineList.value | ||
| 521 | + const w = wrap.clientWidth || 800 | ||
| 522 | + const h = Math.max(TS_AXIS_H + list.length * TS_ROW_H + 8, 80) | ||
| 523 | + | ||
| 524 | + canvas.width = w * getDpr(); canvas.height = h * getDpr() | ||
| 525 | + canvas.style.width = w + 'px'; canvas.style.height = h + 'px' | ||
| 526 | + const ctx = canvas.getContext('2d'); ctx.scale(getDpr(), getDpr()) | ||
| 527 | + | ||
| 528 | + ctx.clearRect(0, 0, w, h) | ||
| 529 | + tsHitRects = [] | ||
| 530 | + | ||
| 531 | + const dateStr = tsSelectedDate.value || new Date().toISOString().slice(0, 10) | ||
| 532 | + const dayStartMs = new Date(dateStr + 'T00:00:00').getTime() | ||
| 533 | + const dayEndMs = dayStartMs + 86400000 | ||
| 534 | + | ||
| 535 | + // 根据zoomLevel计算可见时间范围(小时) | ||
| 536 | + const visibleHours = Math.max(24 / tsZoomLevel.value, 3) | ||
| 537 | + const visibleMs = visibleHours * 3600000 | ||
| 538 | + | ||
| 539 | + // 视图中心点,默认为当天中午 | ||
| 540 | + let center = tsViewCenterMs.value || (dayStartMs + 43200000) | ||
| 541 | + if (tsZoomLevel.value <= 1) center = dayStartMs + 43200000 | ||
| 542 | + const halfVis = visibleMs / 2 | ||
| 543 | + if (center - halfVis < dayStartMs) center = dayStartMs + halfVis | ||
| 544 | + if (center + halfVis > dayEndMs) center = dayEndMs - halfVis | ||
| 545 | + | ||
| 546 | + const viewStartMs = center - halfVis | ||
| 547 | + const viewEndMs = center + halfVis | ||
| 548 | + const viewRangeMs = viewEndMs - viewStartMs | ||
| 549 | + | ||
| 550 | + // 表头背景 | ||
| 551 | + ctx.fillStyle = '#f0f2f5'; ctx.fillRect(0, 0, w, TS_AXIS_H) | ||
| 552 | + ctx.strokeStyle = '#e4e7ed'; ctx.lineWidth = 1 | ||
| 553 | + ctx.beginPath(); ctx.moveTo(0, TS_AXIS_H); ctx.lineTo(w, TS_AXIS_H); ctx.stroke() | ||
| 554 | + | ||
| 555 | + // 时间刻度(根据可见范围动态调整间隔) | ||
| 556 | + ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#666' | ||
| 557 | + let stepMinutes = 60 | ||
| 558 | + if (visibleHours <= 4) stepMinutes = 15 | ||
| 559 | + else if (visibleHours <= 8) stepMinutes = 30 | ||
| 560 | + else if (visibleHours <= 16) stepMinutes = 45 | ||
| 561 | + | ||
| 562 | + const tickStep = stepMinutes * 60000 | ||
| 563 | + const firstTick = Math.ceil(viewStartMs / tickStep) * tickStep | ||
| 564 | + for (let t = firstTick; t <= viewEndMs; t += tickStep) { | ||
| 565 | + const px = ((t - viewStartMs) / viewRangeMs) * w | ||
| 566 | + ctx.strokeStyle = '#ebeef5'; ctx.lineWidth = 0.5 | ||
| 567 | + ctx.beginPath(); ctx.moveTo(px, TS_AXIS_H); ctx.lineTo(px, h); ctx.stroke() | ||
| 568 | + const hh = new Date(t).getHours(), mm = new Date(t).getMinutes() | ||
| 569 | + ctx.fillText(hh.toString().padStart(2,'0')+':'+mm.toString().padStart(2,'0'), px, TS_AXIS_H/2) | ||
| 570 | + } | ||
| 571 | + | ||
| 572 | + // 数据行条带 | ||
| 573 | + list.forEach((item, rowIdx) => { | ||
| 574 | + const y = TS_AXIS_H + rowIdx * TS_ROW_H | ||
| 575 | + const barY = y + TS_ROW_H * 0.15; const barH = TS_ROW_H * 0.7 | ||
| 576 | + if (rowIdx % 2 === 1) { ctx.fillStyle = '#f9f9f9'; ctx.fillRect(0, y, w, TS_ROW_H) } | ||
| 577 | + ctx.strokeStyle = '#f0f0f0'; ctx.beginPath(); ctx.moveTo(0, y+TS_ROW_H); ctx.lineTo(w, y+TS_ROW_H); ctx.stroke() | ||
| 578 | + | ||
| 579 | + ;(item.timelineList || []).forEach((seg, segIdx) => { | ||
| 580 | + if (!seg.duration || seg.duration <= 0) return | ||
| 581 | + const sMs = new Date(seg.startTime).getTime() | ||
| 582 | + const eMs = seg.endTime ? new Date(seg.endTime).getTime() : sMs + (seg.duration||0)*1000 | ||
| 583 | + const x = ((sMs - viewStartMs) / viewRangeMs) * w | ||
| 584 | + const sw = Math.max(((eMs - sMs) / viewRangeMs) * w, 2) | ||
| 585 | + const drawW = Math.max(Math.min(sw, w - x - 1), 0) | ||
| 586 | + | ||
| 587 | + const isHover = (tsHover.show && rowIdx===tsHover.rowIdx && segIdx===tsHover.segIdx) | ||
| 588 | + ctx.fillStyle = isHover ? (TS_STATUS_COLORS[seg.runStatus]||'#ccc'):(TS_STATUS_COLORS[seg.runStatus]||'#ccc') | ||
| 589 | + ctx.globalAlpha = isHover?1:0.85 | ||
| 590 | + if(drawW > 0) roundRect(ctx,x,barY,drawW,barH,0);ctx.fill() | ||
| 591 | + ctx.globalAlpha=1 | ||
| 592 | + | ||
| 593 | + if(x+sw>=-50 && x<w+50){ | ||
| 594 | + tsHitRects.push({rowIdx,segIdx,x,y:barY,w:drawW,h:barH, | ||
| 595 | + ...item,runStatus:seg.runStatus,startTime:seg.startTime,endTime:seg.endTime||'',duration:seg.duration}) | ||
| 596 | + } | ||
| 597 | + }) | ||
| 598 | + }) | ||
| 599 | + | ||
| 600 | + // 当前时间线 | ||
| 601 | + const now=Date.now(),nowPx=((now-viewStartMs)/viewRangeMs)*w | ||
| 602 | + if(nowPx>=0 && nowPx<=w){ctx.strokeStyle='#e74c3c';ctx.lineWidth=1.5;ctx.setLineDash([4,3]) | ||
| 603 | + ctx.beginPath();ctx.moveTo(nowPx,TS_AXIS_H);ctx.lineTo(nowPx,h);ctx.stroke();ctx.setLineDash([])} | ||
| 604 | +} | ||
| 605 | + | ||
| 606 | +// 鼠标滚轮缩放:以鼠标位置为中心放大/缩小可见时间范围(无滚动条) | ||
| 607 | +let tsZoomLock=false | ||
| 608 | +function onTsGanttWheel(e){ | ||
| 609 | + e.preventDefault();if(tsZoomLock)return | ||
| 610 | + tsZoomLock=true;setTimeout(()=>{tsZoomLock=false},60) | ||
| 611 | + | ||
| 612 | + const delta=e.deltaY>0?-0.25:0.25 | ||
| 613 | + let newL=Math.max(TS_ZOOM_MIN,Math.min(TS_ZOOM_MAX,tsZoomLevel.value+delta)) | ||
| 614 | + if(newL===tsZoomLevel.value)return | ||
| 615 | + | ||
| 616 | + const wrap=tsScrollAreaRef.value,cnv=tsGanttCanvasRef.value | ||
| 617 | + if(!wrap||!cnv)return | ||
| 618 | + const rect=cnv.getBoundingClientRect(),mx=e.clientX-rect.left | ||
| 619 | + | ||
| 620 | + const dateStr=tsSelectedDate.value||new Date().toISOString().slice(0,10) | ||
| 621 | + const dayStartMs=new Date(dateStr+'T00:00:00').getTime() | ||
| 622 | + | ||
| 623 | + const oldVH=Math.max(24/tsZoomLevel.value,3),oldVM=oldVH*3600000 | ||
| 624 | + let center=tsViewCenterMs.value||(dayStartMs+43200000) | ||
| 625 | + let oldVS=center-oldVM/2;if(oldVS<dayStartMs)oldVS=dayStartMs | ||
| 626 | + const mouseTimeAt=oldVS+(mx/rect.width)*oldVM | ||
| 627 | + | ||
| 628 | + tsZoomLevel.value=newL | ||
| 629 | + const newVH=Math.max(24/newL,3),newVM=newVH*3600000 | ||
| 630 | + const newVS=mouseTimeAt-(mx/rect.width)*newVM | ||
| 631 | + tsViewCenterMs.value=newVS+newVM/2 | ||
| 632 | + | ||
| 633 | + drawTsAll() | ||
| 634 | +} | ||
| 635 | + | ||
| 636 | +function roundRect(ctx, x, y, w, h, r) { | ||
| 637 | + if (w < 1 || h < 1) return | ||
| 638 | + if (r > w / 2) r = w / 2 | ||
| 639 | + if (r > h / 2) r = h / 2 | ||
| 640 | + if (r <= 0) { ctx.fillRect(x, y, w, h); return } | ||
| 641 | + ctx.beginPath(); ctx.moveTo(x+r, y); ctx.arcTo(x+w, y, x+w, y+h, r) | ||
| 642 | + ctx.arcTo(x+w, y+h, x, y+h, r); ctx.arcTo(x, y+h, x, y, r); ctx.arcTo(x, y, x+w, y, r); ctx.closePath() | ||
| 643 | +} | ||
| 644 | + | ||
| 645 | +// Hover事件 | ||
| 646 | +function onTsGanttMouseMove(e) { | ||
| 647 | + const canvas = tsGanttCanvasRef.value; const wrap = tsScrollAreaRef.value | ||
| 648 | + if (!canvas || !wrap) return | ||
| 649 | + const rect = canvas.getBoundingClientRect() | ||
| 650 | + const mx = e.clientX - rect.left, my = e.clientY - rect.top | ||
| 651 | + let hit = null | ||
| 652 | + for (let i = tsHitRects.length - 1; i >= 0; i--) { | ||
| 653 | + const r = tsHitRects[i] | ||
| 654 | + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { hit = r; break } | ||
| 655 | + } | ||
| 656 | + if (hit) { | ||
| 657 | + tsHover.rowIdx = hit.rowIdx; tsHover.segIdx = hit.segIdx | ||
| 658 | + tsHover.deviceName = hit.deviceName || hit.dtuSn || '' | ||
| 659 | + tsHover.status = hit.runStatus ?? 0 | ||
| 660 | + tsHover.startTime = hit.startTime ? hit.startTime.slice(11, 19) : '' | ||
| 661 | + tsHover.endTime = hit.endTime ? hit.endTime.slice(11, 19) : '' | ||
| 662 | + tsHover.duration = hit.duration || 0 | ||
| 663 | + if (!tsHover.show) { | ||
| 664 | + tsHover.show = true | ||
| 665 | + let tx = mx + 12, ty = my - 100 | ||
| 666 | + if (tx + 180 > wrap.clientWidth - 20) tx = mx - 190 | ||
| 667 | + if (ty < 10) ty = my + 16 | ||
| 668 | + tsHover.x = tx; tsHover.y = ty | ||
| 669 | + } | ||
| 670 | + } else { | ||
| 671 | + tsHover.show = false | ||
| 672 | + } | ||
| 673 | + drawTsGanttChart() | ||
| 674 | +} | ||
| 675 | +function onTsGanttMouseLeave() { tsHover.show = false; drawTsGanttChart() } | ||
| 676 | +function onTsScroll() { drawTsFixedCol() } | ||
| 677 | + | ||
| 678 | +// 监听tab切换自动加载 | ||
| 679 | +watch(currentStatus, async (val) => { | ||
| 680 | + if (val === 'timeseries') { | ||
| 681 | + await nextTick() | ||
| 682 | + initTsObserver() | ||
| 683 | + fetchTimelineData() | ||
| 684 | + } else { | ||
| 685 | + destroyTsObserver() | ||
| 686 | + } | ||
| 687 | +}) | ||
| 688 | + | ||
| 689 | +function initTsObserver() { | ||
| 690 | + destroyTsObserver() | ||
| 691 | + tsResizeObs = new ResizeObserver(() => { if (currentStatus.value === 'timeseries') drawTsAll() }) | ||
| 692 | + const el = document.querySelector('.ts-gantt-wrap') | ||
| 693 | + if (el) tsResizeObs.observe(el) | ||
| 694 | +} | ||
| 695 | +function destroyTsObserver() { if (tsResizeObs) { tsResizeObs.disconnect(); tsResizeObs = null } } | ||
| 696 | +onBeforeUnmount(() => destroyTsObserver()) | ||
| 433 | 697 | ||
| 434 | // ========== 稼动率数据 ========== | 698 | // ========== 稼动率数据 ========== |
| 435 | const utilQueryMode = ref('day') | 699 | const utilQueryMode = ref('day') |
| @@ -664,7 +928,7 @@ const effLine2Points = computed(() => { | @@ -664,7 +928,7 @@ const effLine2Points = computed(() => { | ||
| 664 | display: flex; | 928 | display: flex; |
| 665 | align-items: center; | 929 | align-items: center; |
| 666 | justify-content: flex-end; | 930 | justify-content: flex-end; |
| 667 | - padding: 14px 20px; | 931 | + padding: 8px 20px; |
| 668 | border-top: 1px solid #e8e8e8; | 932 | border-top: 1px solid #e8e8e8; |
| 669 | } | 933 | } |
| 670 | .pagination-info { font-size: 13px; color: #666; } | 934 | .pagination-info { font-size: 13px; color: #666; } |
| @@ -727,7 +991,7 @@ const effLine2Points = computed(() => { | @@ -727,7 +991,7 @@ const effLine2Points = computed(() => { | ||
| 727 | flex: 1; | 991 | flex: 1; |
| 728 | display: flex; | 992 | display: flex; |
| 729 | flex-direction: column; | 993 | flex-direction: column; |
| 730 | - overflow: hidden; | 994 | + min-height: 0; |
| 731 | } | 995 | } |
| 732 | 996 | ||
| 733 | /* ========== 时序状态 ========== */ | 997 | /* ========== 时序状态 ========== */ |
| @@ -736,7 +1000,7 @@ const effLine2Points = computed(() => { | @@ -736,7 +1000,7 @@ const effLine2Points = computed(() => { | ||
| 736 | } | 1000 | } |
| 737 | .ts-toolbar { | 1001 | .ts-toolbar { |
| 738 | background: #fff; | 1002 | background: #fff; |
| 739 | - padding: 10px 20px; | 1003 | + padding: 8px 20px; |
| 740 | display: flex; | 1004 | display: flex; |
| 741 | align-items: center; | 1005 | align-items: center; |
| 742 | gap: 10px; | 1006 | gap: 10px; |
| @@ -825,6 +1089,51 @@ const effLine2Points = computed(() => { | @@ -825,6 +1089,51 @@ const effLine2Points = computed(() => { | ||
| 825 | .seg-r { background: #f56c6c; } | 1089 | .seg-r { background: #f56c6c; } |
| 826 | .seg-gy { background: #909399; } | 1090 | .seg-gy { background: #909399; } |
| 827 | 1091 | ||
| 1092 | +/* ========== 时序状态:Canvas甘特图 ========== */ | ||
| 1093 | +.ts-gantt-wrap { | ||
| 1094 | + flex: 1; | ||
| 1095 | + display: flex; | ||
| 1096 | + min-height: 0; | ||
| 1097 | + overflow: auto; | ||
| 1098 | + background: #fff; | ||
| 1099 | + margin: 8px 20px 0; | ||
| 1100 | + border: 1px solid #e8e8e8; | ||
| 1101 | + position: relative; | ||
| 1102 | +} | ||
| 1103 | +.ts-fixed-col { | ||
| 1104 | + width: 220px; | ||
| 1105 | + flex-shrink: 0; | ||
| 1106 | +} | ||
| 1107 | +.ts-fixed-col canvas { display: block; } | ||
| 1108 | +.ts-scroll-area { | ||
| 1109 | + flex: 1; | ||
| 1110 | + overflow-x: hidden; | ||
| 1111 | + overflow-y: auto; | ||
| 1112 | + position: relative; | ||
| 1113 | +} | ||
| 1114 | +.ts-scroll-area canvas { display: block; } | ||
| 1115 | + | ||
| 1116 | +/* Tooltip - 相对于 ts-scroll-area 定位 */ | ||
| 1117 | +.gantt-tooltip { | ||
| 1118 | + position: absolute; | ||
| 1119 | + background: rgba(30,40,55,0.95); | ||
| 1120 | + border-radius: 6px; | ||
| 1121 | + padding: 8px 14px; | ||
| 1122 | + min-width: 180px; | ||
| 1123 | + z-index: 200; | ||
| 1124 | + pointer-events: none; | ||
| 1125 | + box-shadow: 0 4px 16px rgba(0,0,0,0.25); | ||
| 1126 | +} | ||
| 1127 | +.gtt-title { | ||
| 1128 | + font-size: 12px; font-weight:bold; color:#eef1f7; margin-bottom:6px; padding-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.1); | ||
| 1129 | +} | ||
| 1130 | +.gtt-row { | ||
| 1131 | + display:flex; align-items:center; justify-content:space-between; gap:12px; line-height:2; font-size:12px; | ||
| 1132 | +} | ||
| 1133 | +.gtt-label { color:#aab2c0; flex-shrink:0; } | ||
| 1134 | +.gtt-val { color:#eef1f7; font-weight:500; display:flex; align-items:center; gap:4px; } | ||
| 1135 | +.gtt-highlight { font-weight:bold; } | ||
| 1136 | + | ||
| 828 | /* ========== 稼动率 ========== */ | 1137 | /* ========== 稼动率 ========== */ |
| 829 | .util-view { | 1138 | .util-view { |
| 830 | background: #f5f7fa; | 1139 | background: #f5f7fa; |