Commit e926533578cdf122f1406b8950d72f94a8711106

Authored by 杨鸣坤
1 parent 8c866f0f

feat: 重构时序状态为Canvas甘特图

Showing 1 changed file with 367 additions and 58 deletions
... ... @@ -88,64 +88,38 @@
88 88 </div>
89 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 93 <div class="ts-toolbar">
94 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 96 <el-radio-button value="day">日查询</el-radio-button>
97 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 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 114 </div>
143 115 </div>
144 116 </div>
145 117
146 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 123 </div>
150 124 </div>
151 125
... ... @@ -294,7 +268,7 @@
294 268 </template>
295 269
296 270 <script setup>
297   -import { ref, reactive, computed, onMounted } from 'vue'
  271 +import { ref, reactive, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
298 272 import { Search, Menu, Document, Lock, Setting, Warning, Histogram } from '@element-plus/icons-vue'
299 273 import EnergyReportDialog from '../components/EnergyReportDialog.vue'
300 274 import SafetyDialog from '../components/SafetyDialog.vue'
... ... @@ -422,14 +396,304 @@ function openDetail(type, device) {
422 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 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 699 const utilQueryMode = ref('day')
... ... @@ -664,7 +928,7 @@ const effLine2Points = computed(() => {
664 928 display: flex;
665 929 align-items: center;
666 930 justify-content: flex-end;
667   - padding: 14px 20px;
  931 + padding: 8px 20px;
668 932 border-top: 1px solid #e8e8e8;
669 933 }
670 934 .pagination-info { font-size: 13px; color: #666; }
... ... @@ -727,7 +991,7 @@ const effLine2Points = computed(() => {
727 991 flex: 1;
728 992 display: flex;
729 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 1000 }
737 1001 .ts-toolbar {
738 1002 background: #fff;
739   - padding: 10px 20px;
  1003 + padding: 8px 20px;
740 1004 display: flex;
741 1005 align-items: center;
742 1006 gap: 10px;
... ... @@ -825,6 +1089,51 @@ const effLine2Points = computed(() => {
825 1089 .seg-r { background: #f56c6c; }
826 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 1138 .util-view {
830 1139 background: #f5f7fa;
... ...