Commit 81a9b1e4d327976148fd2ac95584d2ecbc4497c6

Authored by gesilong
1 parent 6e26edb2

commit:H5开发联调

Too many changes to show.

To preserve performance only 9 of 18 files are displayed.

1 <template> 1 <template>
2 - <div class="app-container"> 2 + <div v-if="isH5Route" class="h5-container">
  3 + <router-view />
  4 + </div>
  5 + <div v-else class="app-container">
3 <el-container> 6 <el-container>
4 <el-aside width="180px" class="sidebar"> 7 <el-aside width="180px" class="sidebar">
5 <div class="logo-area"> 8 <div class="logo-area">
@@ -30,17 +33,22 @@ @@ -30,17 +33,22 @@
30 </template> 33 </template>
31 34
32 <script setup> 35 <script setup>
33 -import { computed, ref, provide } from 'vue' 36 +import { computed, provide, watchEffect } from 'vue'
34 import { useRoute, useRouter } from 'vue-router' 37 import { useRoute, useRouter } from 'vue-router'
  38 +import { setCorpCode } from './config/api.js'
35 39
36 const route = useRoute() 40 const route = useRoute()
37 const router = useRouter() 41 const router = useRouter()
38 const currentRoute = computed(() => route.path) 42 const currentRoute = computed(() => route.path)
  43 +const isH5Route = computed(() => Boolean(route.meta?.h5))
39 44
40 // 从URL获取corpCode,全局共享(hash模式下参数在#后面,需用route.query) 45 // 从URL获取corpCode,全局共享(hash模式下参数在#后面,需用route.query)
41 // 使用computed确保路由切换时corpCode始终同步 46 // 使用computed确保路由切换时corpCode始终同步
42 const corpCode = computed(() => route.query.corpCode || '') 47 const corpCode = computed(() => route.query.corpCode || '')
43 provide('corpCode', corpCode) 48 provide('corpCode', corpCode)
  49 +watchEffect(() => {
  50 + setCorpCode(corpCode.value)
  51 +})
44 52
45 // 给URL拼接corpCode 53 // 给URL拼接corpCode
46 function withCorpCode(path) { 54 function withCorpCode(path) {
@@ -56,6 +64,10 @@ function navigateTo(path) { @@ -56,6 +64,10 @@ function navigateTo(path) {
56 </script> 64 </script>
57 65
58 <style scoped> 66 <style scoped>
  67 +.h5-container {
  68 + min-height: 100vh;
  69 + background-color: #f5f6f8;
  70 +}
59 .app-container { 71 .app-container {
60 height: 100vh; 72 height: 100vh;
61 overflow: hidden; 73 overflow: hidden;
  1 +<template>
  2 + <div class="eff-h5">
  3 + <div class="toolbar">
  4 + <div class="left">
  5 + <el-radio-group v-model="effQueryMode" size="small" @change="fetchEffData">
  6 + <el-radio-button value="hour">时查询</el-radio-button>
  7 + <el-radio-button value="day">日查询</el-radio-button>
  8 + <el-radio-button value="month">月查询</el-radio-button>
  9 + </el-radio-group>
  10 + </div>
  11 + <el-button type="primary" size="small" :loading="loading" @click="fetchEffData">查询</el-button>
  12 + </div>
  13 +
  14 + <div class="toolbar" style="margin-top: 8px;">
  15 + <div class="left">
  16 + <el-date-picker
  17 + v-if="effQueryMode === 'hour'"
  18 + v-model="effHourDate"
  19 + type="date"
  20 + value-format="YYYY-MM-DD"
  21 + placeholder="选择日期"
  22 + size="small"
  23 + style="width: 160px;"
  24 + :disabled-date="disabledDateFuture"
  25 + @change="fetchEffData"
  26 + />
  27 + <el-date-picker
  28 + v-else-if="effQueryMode === 'day'"
  29 + v-model="effDayDate"
  30 + type="month"
  31 + value-format="YYYY-MM"
  32 + placeholder="选择月份"
  33 + size="small"
  34 + style="width: 160px;"
  35 + :disabled-date="disabledMonthFuture"
  36 + @change="fetchEffData"
  37 + />
  38 + <el-date-picker
  39 + v-else
  40 + v-model="effMonthDate"
  41 + type="year"
  42 + value-format="YYYY"
  43 + placeholder="选择年份"
  44 + size="small"
  45 + style="width: 140px;"
  46 + :disabled-date="disabledYearFuture"
  47 + @change="fetchEffData"
  48 + />
  49 + </div>
  50 + </div>
  51 +
  52 + <div class="toolbar" style="margin-top: 8px;">
  53 + <div class="left" style="width: 100%;">
  54 + <el-select
  55 + v-model="effDeviceFilter"
  56 + size="small"
  57 + multiple
  58 + collapse-tags
  59 + collapse-tags-tooltip
  60 + :max-collapse-tags="2"
  61 + placeholder="选择设备(可多选)"
  62 + style="width: 100%;"
  63 + @change="onDeviceFilterChange"
  64 + >
  65 + <el-option v-for="dev in effAllDevices" :key="dev.dtuSn" :label="dev.deviceName || dev.dtuSn" :value="dev.dtuSn" />
  66 + </el-select>
  67 + </div>
  68 + </div>
  69 +
  70 + <div class="toolbar" style="margin-top: 8px;">
  71 + <div class="left">
  72 + <el-radio-group v-model="viewMode" size="small" @change="onViewChange">
  73 + <el-radio-button value="chart">图表</el-radio-button>
  74 + <el-radio-button value="table">表格</el-radio-button>
  75 + </el-radio-group>
  76 + </div>
  77 + <div class="right">
  78 + <span v-if="totalKwh != null" class="total">总用电量 {{ formatKwh(totalKwh) }} kw·h</span>
  79 + </div>
  80 + </div>
  81 +
  82 + <div class="section">
  83 + <template v-if="viewMode === 'chart'">
  84 + <div ref="chartWrapRef" class="chart-wrap">
  85 + <canvas ref="chartCanvasRef" class="chart-canvas" @pointerdown="onChartTap"></canvas>
  86 + </div>
  87 + <div v-if="chartTip.show" class="tip-card">
  88 + <div class="tip-title">{{ chartTip.timeLabel || '-' }}</div>
  89 + <div v-for="(d, i) in chartTip.devices" :key="i" class="tip-line">
  90 + <i :style="{ background: d.color }"></i>
  91 + <span class="tlabel">{{ d.name }}</span>
  92 + <span class="tval">{{ formatKwh(d.value) }}</span>
  93 + </div>
  94 + </div>
  95 + <el-empty v-if="!loading && effDeviceList.length === 0" description="暂无数据" />
  96 + </template>
  97 +
  98 + <template v-else>
  99 + <div class="table-scroll">
  100 + <table v-if="tableColumns.length > 0 && tableRows.length > 0" class="table">
  101 + <thead>
  102 + <tr>
  103 + <th class="col-name">设备名称</th>
  104 + <th v-for="(col, ci) in tableColumns" :key="ci">{{ col.label }}</th>
  105 + </tr>
  106 + </thead>
  107 + <tbody>
  108 + <tr v-for="(row, ri) in tableRows" :key="ri">
  109 + <td class="td-name">{{ row.deviceName }}</td>
  110 + <td v-for="(col, ci) in tableColumns" :key="ci">{{ row.values[ci] != null ? formatKwh(row.values[ci]) : '-' }}</td>
  111 + </tr>
  112 + </tbody>
  113 + </table>
  114 + <el-empty v-else-if="!loading" description="暂无数据" />
  115 + </div>
  116 + </template>
  117 + </div>
  118 + </div>
  119 +</template>
  120 +
  121 +<script setup>
  122 +import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
  123 +import { ElMessage } from 'element-plus'
  124 +import { apiFetch } from '../../config/api.js'
  125 +
  126 +const EFF_LINE_COLORS = [
  127 + '#5470c6',
  128 + '#91cc75',
  129 + '#fac858',
  130 + '#ee6666',
  131 + '#73c0de',
  132 + '#3ba272',
  133 + '#fc8452',
  134 + '#9a60b4',
  135 + '#ea7ccc'
  136 +]
  137 +
  138 +const viewMode = ref('chart')
  139 +const effQueryMode = ref('hour')
  140 +const effHourDate = ref(new Date().toISOString().slice(0, 10))
  141 +const effDayDate = ref(new Date().toISOString().slice(0, 7))
  142 +const effMonthDate = ref(String(new Date().getFullYear()))
  143 +const effDeviceFilter = ref([])
  144 +
  145 +const loading = ref(false)
  146 +const totalKwh = ref(null)
  147 +
  148 +const effDataList = ref([])
  149 +const effAllDevices = computed(() => effDataList.value.map(d => ({ dtuSn: d.dtuSn, deviceName: d.deviceName || d.dtuSn })))
  150 +const effDeviceList = computed(() => {
  151 + if (!effDeviceFilter.value.length) return effDataList.value
  152 + return effDataList.value.filter(d => effDeviceFilter.value.includes(d.dtuSn))
  153 +})
  154 +
  155 +function disabledDateFuture(time) {
  156 + return time.getTime() > Date.now()
  157 +}
  158 +function disabledMonthFuture(time) {
  159 + const now = new Date()
  160 + return time.getFullYear() > now.getFullYear() || (time.getFullYear() === now.getFullYear() && time.getMonth() > now.getMonth())
  161 +}
  162 +function disabledYearFuture(time) {
  163 + return time.getFullYear() > new Date().getFullYear()
  164 +}
  165 +
  166 +function formatKwh(v) {
  167 + if (v == null) return '0'
  168 + const n = Number(v)
  169 + if (!Number.isFinite(n)) return '0'
  170 + return n.toFixed(n % 1 === 0 ? 0 : 2)
  171 +}
  172 +
  173 +async function fetchEffData() {
  174 + loading.value = true
  175 + let startDate = ''
  176 + let endDate = ''
  177 + let type = 1
  178 + if (effQueryMode.value === 'hour') {
  179 + type = 1
  180 + startDate = effHourDate.value
  181 + endDate = effHourDate.value
  182 + } else if (effQueryMode.value === 'day') {
  183 + type = 2
  184 + const [y, m] = effDayDate.value.split('-')
  185 + startDate = effDayDate.value + '-01'
  186 + const lastDay = new Date(parseInt(y), parseInt(m), 0).getDate()
  187 + endDate = effDayDate.value + '-' + String(lastDay).padStart(2, '0')
  188 + } else {
  189 + type = 3
  190 + startDate = effMonthDate.value + '-01-01'
  191 + endDate = effMonthDate.value + '-12-31'
  192 + }
  193 + try {
  194 + const res = await apiFetch(`/api/energy/eqKwhByType?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}&type=${type}`)
  195 + const data = await res.json()
  196 + if (data?.code !== 200) throw new Error('bad response')
  197 + effDataList.value = data.list || []
  198 + totalKwh.value = data.grandTotalKwh ?? data.totalKwh ?? null
  199 +
  200 + if (effDeviceFilter.value.length) {
  201 + effDeviceFilter.value = effDeviceFilter.value.filter(sn => effDataList.value.some(d => d.dtuSn === sn))
  202 + }
  203 + chartTip.show = false
  204 + await nextTick()
  205 + drawChart()
  206 + } catch (e) {
  207 + ElMessage.error('获取数据失败')
  208 + console.warn(e)
  209 + } finally {
  210 + loading.value = false
  211 + }
  212 +}
  213 +
  214 +function onDeviceFilterChange() {
  215 + chartTip.show = false
  216 + nextTick(() => drawChart())
  217 +}
  218 +
  219 +function onViewChange() {
  220 + chartTip.show = false
  221 + nextTick(() => drawChart())
  222 +}
  223 +
  224 +const tableColumns = computed(() => {
  225 + const list = effDeviceList.value
  226 + if (!list.length) return []
  227 + if (effQueryMode.value === 'hour') {
  228 + if (!list[0].kwhList || !list[0].kwhList.length) return []
  229 + return list[0].kwhList.map((k, i) => ({ label: k.date || '', key: i }))
  230 + }
  231 + if (effQueryMode.value === 'day') {
  232 + if (!list[0].dailyData || !list[0].dailyData.length) return []
  233 + return list[0].dailyData.map((d, i) => ({ label: d.date ? d.date.slice(5) : '', key: i }))
  234 + }
  235 + if (!list[0].monthlyData || !list[0].monthlyData.length) return []
  236 + return list[0].monthlyData.map((m, i) => ({ label: m.label || '', key: i }))
  237 +})
  238 +
  239 +const tableRows = computed(() => {
  240 + const list = effDeviceList.value
  241 + if (!list.length) return []
  242 + return list.map(dev => {
  243 + let values = []
  244 + if (effQueryMode.value === 'hour') values = (dev.kwhList || []).map(k => Number(k.value) || 0)
  245 + else if (effQueryMode.value === 'day') values = (dev.dailyData || []).map(d => Number(d.totalKwh) || 0)
  246 + else values = (dev.monthlyData || []).map(m => Number(m.totalKwh) || 0)
  247 + while (values.length < tableColumns.value.length) values.push(null)
  248 + return { deviceName: dev.deviceName || dev.dtuSn, values }
  249 + })
  250 +})
  251 +
  252 +const chartCanvasRef = ref(null)
  253 +const chartWrapRef = ref(null)
  254 +let hitAreas = []
  255 +
  256 +const chartTip = reactive({ show: false, timeLabel: '', devices: [] })
  257 +
  258 +const dpr = window.devicePixelRatio || 1
  259 +function setupCanvas(canvas, cssW, cssH) {
  260 + canvas.style.width = cssW + 'px'
  261 + canvas.style.height = cssH + 'px'
  262 + canvas.width = Math.round(cssW * dpr)
  263 + canvas.height = Math.round(cssH * dpr)
  264 + const ctx = canvas.getContext('2d')
  265 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  266 + return { ctx, W: cssW, H: cssH }
  267 +}
  268 +
  269 +function niceEffYMax(val) {
  270 + if (val <= 0) return 10
  271 + if (val <= 5) return 5
  272 + if (val <= 10) return 10
  273 + if (val <= 20) return 20
  274 + if (val <= 30) return 30
  275 + if (val <= 50) return 50
  276 + if (val <= 70) return 70
  277 + if (val <= 100) return 100
  278 + if (val <= 200) return 200
  279 + if (val <= 500) return 500
  280 + return Math.ceil(val / 100) * 100
  281 +}
  282 +
  283 +function drawChart() {
  284 + const canvas = chartCanvasRef.value
  285 + const wrap = chartWrapRef.value
  286 + if (!canvas || !wrap) return
  287 + const list = effDeviceList.value
  288 + const cssW = Math.max(320, wrap.clientWidth)
  289 + const cssH = 360
  290 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  291 + ctx.clearRect(0, 0, W, H)
  292 + hitAreas = []
  293 + if (!list.length) return
  294 +
  295 + const padL = 44
  296 + const padR = 16
  297 + const padT = 18
  298 + const padB = 44
  299 + const chartW = W - padL - padR
  300 + const chartH = H - padT - padB
  301 +
  302 + let xLabels = []
  303 + const xDataMap = {}
  304 + if (effQueryMode.value === 'hour') {
  305 + xLabels = (list[0].kwhList || []).map(k => k.date || '')
  306 + list.forEach(dev => (xDataMap[dev.dtuSn] = (dev.kwhList || []).map(k => Number(k.value) || 0)))
  307 + } else if (effQueryMode.value === 'day') {
  308 + xLabels = (list[0].dailyData || []).map(d => (d.date ? d.date.slice(5) : ''))
  309 + list.forEach(dev => (xDataMap[dev.dtuSn] = (dev.dailyData || []).map(d => Number(d.totalKwh) || 0)))
  310 + } else {
  311 + xLabels = (list[0].monthlyData || []).map(m => m.label || '')
  312 + list.forEach(dev => (xDataMap[dev.dtuSn] = (dev.monthlyData || []).map(m => Number(m.totalKwh) || 0)))
  313 + }
  314 + const pointCount = xLabels.length
  315 + if (!pointCount) return
  316 +
  317 + let maxY = 1
  318 + list.forEach(dev => {
  319 + ;(xDataMap[dev.dtuSn] || []).forEach(v => {
  320 + if (v > maxY) maxY = v
  321 + })
  322 + })
  323 + const yMax = niceEffYMax(maxY)
  324 + const yTicks = 5
  325 +
  326 + ctx.strokeStyle = '#ebeef5'
  327 + ctx.lineWidth = 1
  328 + ctx.font = '10px sans-serif'
  329 + ctx.textAlign = 'right'
  330 + ctx.textBaseline = 'middle'
  331 + for (let i = 0; i <= yTicks; i++) {
  332 + const y = padT + chartH - (i / yTicks) * chartH
  333 + const val = (i / yTicks) * yMax
  334 + ctx.beginPath()
  335 + ctx.moveTo(padL, y)
  336 + ctx.lineTo(W - padR, y)
  337 + ctx.stroke()
  338 + ctx.fillStyle = '#94a3b8'
  339 + ctx.fillText(val >= 1 ? val.toFixed(0) : val.toFixed(1), padL - 6, y)
  340 + }
  341 + ctx.strokeStyle = '#ddd'
  342 + ctx.lineWidth = 1.5
  343 + ctx.beginPath()
  344 + ctx.moveTo(padL, padT + chartH)
  345 + ctx.lineTo(W - padR, padT + chartH)
  346 + ctx.stroke()
  347 + ctx.beginPath()
  348 + ctx.moveTo(padL, padT)
  349 + ctx.lineTo(padL, padT + chartH)
  350 + ctx.stroke()
  351 +
  352 + const stepX = chartW / Math.max(pointCount - 1, 1)
  353 + const xLabelStep = pointCount > 20 ? Math.ceil(pointCount / 12) : 1
  354 + ctx.fillStyle = '#64748b'
  355 + ctx.font = '10px sans-serif'
  356 + ctx.textAlign = 'center'
  357 + ctx.textBaseline = 'top'
  358 + xLabels.forEach((lbl, i) => {
  359 + if (i % xLabelStep === 0 || i === pointCount - 1) {
  360 + const x = padL + i * stepX
  361 + ctx.fillText(lbl, x, padT + chartH + 10)
  362 + }
  363 + })
  364 +
  365 + for (let i = 0; i < pointCount; i++) {
  366 + hitAreas.push({ x: padL + i * stepX - stepX / 2, w: stepX, index: i, label: xLabels[i] })
  367 + }
  368 +
  369 + const yScale = chartH / yMax
  370 + hitAreas.forEach(a => (a.devs = []))
  371 + list.forEach((dev, devIdx) => {
  372 + const color = EFF_LINE_COLORS[devIdx % EFF_LINE_COLORS.length]
  373 + const vals = xDataMap[dev.dtuSn] || []
  374 + vals.forEach((v, i) => {
  375 + hitAreas[i].devs.push({ name: dev.deviceName || dev.dtuSn, value: Number(v) || 0, color })
  376 + })
  377 + if (!vals.length) return
  378 + const points = vals.map((v, i) => ({
  379 + x: padL + i * stepX,
  380 + y: padT + chartH - (Number(v) || 0) * yScale
  381 + }))
  382 + ctx.strokeStyle = color
  383 + ctx.lineWidth = 2
  384 + ctx.lineJoin = 'round'
  385 + ctx.beginPath()
  386 + ctx.moveTo(points[0].x, points[0].y)
  387 + for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y)
  388 + ctx.stroke()
  389 + points.forEach(p => {
  390 + ctx.fillStyle = '#fff'
  391 + ctx.beginPath()
  392 + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
  393 + ctx.fill()
  394 + ctx.strokeStyle = color
  395 + ctx.lineWidth = 1.4
  396 + ctx.beginPath()
  397 + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
  398 + ctx.stroke()
  399 + })
  400 + })
  401 +}
  402 +
  403 +function onChartTap(e) {
  404 + const canvas = chartCanvasRef.value
  405 + if (!canvas || !hitAreas.length) return
  406 + const rect = canvas.getBoundingClientRect()
  407 + const mx = e.clientX - rect.left
  408 + let hit = null
  409 + for (const a of hitAreas) {
  410 + if (mx >= a.x && mx <= a.x + a.w) {
  411 + hit = a
  412 + break
  413 + }
  414 + }
  415 + if (!hit || !hit.devs) {
  416 + chartTip.show = false
  417 + return
  418 + }
  419 + chartTip.timeLabel = hit.label || ''
  420 + chartTip.devices = [...hit.devs].sort((a, b) => (b.value || 0) - (a.value || 0))
  421 + chartTip.show = true
  422 +}
  423 +
  424 +let ro = null
  425 +onMounted(() => {
  426 + fetchEffData()
  427 + if ('ResizeObserver' in window) {
  428 + ro = new ResizeObserver(() => nextTick(() => drawChart()))
  429 + if (chartWrapRef.value) ro.observe(chartWrapRef.value)
  430 + }
  431 +})
  432 +
  433 +onBeforeUnmount(() => {
  434 + if (ro) {
  435 + ro.disconnect()
  436 + ro = null
  437 + }
  438 +})
  439 +</script>
  440 +
  441 +<style scoped>
  442 +.eff-h5 {
  443 + padding: 0;
  444 +}
  445 +
  446 +.toolbar {
  447 + display: flex;
  448 + align-items: center;
  449 + justify-content: space-between;
  450 + gap: 10px;
  451 +}
  452 +
  453 +.left {
  454 + display: flex;
  455 + align-items: center;
  456 + gap: 8px;
  457 + flex-wrap: wrap;
  458 +}
  459 +
  460 +.right {
  461 + display: flex;
  462 + align-items: center;
  463 +}
  464 +
  465 +.total {
  466 + font-size: 12px;
  467 + color: #64748b;
  468 +}
  469 +
  470 +.section {
  471 + background: #fff;
  472 + border-radius: 12px;
  473 + padding: 12px;
  474 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  475 + margin-top: 10px;
  476 +}
  477 +
  478 +.chart-wrap {
  479 + width: 100%;
  480 +}
  481 +
  482 +.chart-canvas {
  483 + width: 100%;
  484 + height: 360px;
  485 + display: block;
  486 +}
  487 +
  488 +.tip-card {
  489 + margin-top: 10px;
  490 + border-radius: 12px;
  491 + padding: 10px 12px;
  492 + background: rgba(64, 158, 255, 0.06);
  493 + border: 1px solid rgba(64, 158, 255, 0.18);
  494 +}
  495 +
  496 +.tip-title {
  497 + font-weight: 800;
  498 + color: #0f172a;
  499 + margin-bottom: 6px;
  500 +}
  501 +
  502 +.tip-line {
  503 + display: flex;
  504 + align-items: center;
  505 + gap: 8px;
  506 + font-size: 12px;
  507 + color: #334155;
  508 + line-height: 1.6;
  509 +}
  510 +
  511 +.tip-line i {
  512 + width: 10px;
  513 + height: 10px;
  514 + border-radius: 999px;
  515 + display: inline-block;
  516 +}
  517 +
  518 +.tlabel {
  519 + color: #64748b;
  520 +}
  521 +
  522 +.tval {
  523 + margin-left: auto;
  524 + font-weight: 800;
  525 + color: #0f172a;
  526 +}
  527 +
  528 +.table-scroll {
  529 + overflow-x: auto;
  530 + -webkit-overflow-scrolling: touch;
  531 +}
  532 +
  533 +.table {
  534 + width: max-content;
  535 + min-width: 100%;
  536 + border-collapse: collapse;
  537 + font-size: 12px;
  538 +}
  539 +
  540 +.table th,
  541 +.table td {
  542 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  543 + padding: 8px 10px;
  544 + text-align: center;
  545 + white-space: nowrap;
  546 +}
  547 +
  548 +.col-name,
  549 +.td-name {
  550 + position: sticky;
  551 + left: 0;
  552 + background: #fff;
  553 + text-align: left !important;
  554 + min-width: 120px;
  555 + max-width: 160px;
  556 +}
  557 +
  558 +.table thead th {
  559 + position: sticky;
  560 + top: 0;
  561 + background: #f8fafc;
  562 + z-index: 1;
  563 +}
  564 +
  565 +.table thead .col-name {
  566 + z-index: 2;
  567 +}
  568 +</style>
  1 +<template>
  2 + <div class="energy-realtime">
  3 + <div class="h5-search">
  4 + <el-input
  5 + v-model="searchKeyword"
  6 + placeholder="输入设备名称搜索"
  7 + clearable
  8 + @keyup.enter="onSearch"
  9 + @clear="onSearch"
  10 + >
  11 + <template #append>
  12 + <el-button :loading="loading" @click="onSearch">搜索</el-button>
  13 + </template>
  14 + </el-input>
  15 + </div>
  16 +
  17 + <div class="h5-filters">
  18 + <button
  19 + v-for="item in filterItems"
  20 + :key="item.key"
  21 + :class="['filter-chip', { active: runStatusFilter === item.key }]"
  22 + type="button"
  23 + @click="onFilter(item.key)"
  24 + >
  25 + <span class="dot" :style="{ backgroundColor: item.dotColor }"></span>
  26 + <span class="label">{{ item.label }}</span>
  27 + <span class="count">{{ item.count }}</span>
  28 + </button>
  29 + </div>
  30 +
  31 + <el-empty v-if="!loading && deviceList.length === 0" description="暂无设备" />
  32 +
  33 + <div v-for="device in deviceList" :key="device.id" class="device-card">
  34 + <div class="card-top">
  35 + <div class="device-name">{{ device.name }}</div>
  36 + <div class="device-status">
  37 + <span class="status-dot" :style="{ backgroundColor: device.statusColor }"></span>
  38 + <span class="status-text">{{ device.statusLabel }}</span>
  39 + </div>
  40 + </div>
  41 +
  42 + <div class="card-body">
  43 + <div class="row">
  44 + <div class="k">用电量</div>
  45 + <div class="v">{{ device.evalue }} kw·h</div>
  46 + </div>
  47 + <div class="row">
  48 + <div class="k">{{ device.statusLabel }}时长</div>
  49 + <div class="v">{{ device.duration }}</div>
  50 + </div>
  51 + </div>
  52 +
  53 + <div class="card-actions">
  54 + <el-button size="small" @click="goRunStatus(device)">运行状态</el-button>
  55 + <el-button size="small" type="primary" @click="goUsage(device)">用时用电</el-button>
  56 + </div>
  57 + </div>
  58 +
  59 + <div class="h5-footer">
  60 + <el-button
  61 + v-if="canLoadMore"
  62 + type="primary"
  63 + :loading="loadingMore"
  64 + style="width: 100%;"
  65 + @click="loadMore"
  66 + >
  67 + 加载更多
  68 + </el-button>
  69 + <div v-else-if="deviceList.length > 0" class="no-more">没有更多了</div>
  70 + </div>
  71 + </div>
  72 +</template>
  73 +
  74 +<script setup>
  75 +import { computed, onMounted, reactive, ref } from 'vue'
  76 +import { useRoute, useRouter } from 'vue-router'
  77 +import { ElMessage } from 'element-plus'
  78 +import { apiFetch } from '../../config/api.js'
  79 +
  80 +const route = useRoute()
  81 +const router = useRouter()
  82 +
  83 +const searchKeyword = ref('')
  84 +const runStatusFilter = ref('')
  85 +const currentPage = ref(1)
  86 +const PAGE_SIZE = 20
  87 +
  88 +const deviceList = ref([])
  89 +const totalDevices = ref(0)
  90 +const loading = ref(false)
  91 +const loadingMore = ref(false)
  92 +
  93 +const totalCounts = reactive({ all: 0, offline: 0, stop: 0, standby: 0, run: 0 })
  94 +const canLoadMore = computed(() => deviceList.value.length < totalDevices.value)
  95 +
  96 +const STATUS_LABEL_MAP = { '0': '离线', '1': '停机', '2': '待机', '3': '运行' }
  97 +const STATUS_COLOR_MAP = { '0': '#95a5a6', '1': '#e74c3c', '2': '#67c23a', '3': '#3498db' }
  98 +
  99 +function toDeviceViewModel(item) {
  100 + const runStatus = String(item.runStatus ?? '0')
  101 + return {
  102 + id: item.id,
  103 + name: item.deviceName || item.dtuSn,
  104 + dtuSn: item.dtuSn || item.deviceSn || item.sn || '',
  105 + evalue: parseFloat(item.evalue) || 0,
  106 + duration: item.duration || '0秒',
  107 + runStatus,
  108 + statusLabel: STATUS_LABEL_MAP[runStatus] || '未知',
  109 + statusColor: STATUS_COLOR_MAP[runStatus] || '#95a5a6',
  110 + _raw: item
  111 + }
  112 +}
  113 +
  114 +const filterItems = computed(() => [
  115 + { key: '', label: '全部', count: `${totalCounts.all}台`, dotColor: '#2c3e50' },
  116 + { key: '3', label: '运行', count: `${totalCounts.run}台`, dotColor: STATUS_COLOR_MAP['3'] },
  117 + { key: '2', label: '待机', count: `${totalCounts.standby}台`, dotColor: STATUS_COLOR_MAP['2'] },
  118 + { key: '1', label: '停机', count: `${totalCounts.stop}台`, dotColor: STATUS_COLOR_MAP['1'] },
  119 + { key: '0', label: '离线', count: `${totalCounts.offline}台`, dotColor: STATUS_COLOR_MAP['0'] }
  120 +])
  121 +
  122 +async function fetchStats() {
  123 + const res = await apiFetch('/api/energy/stats')
  124 + if (!res.ok) throw new Error(`HTTP ${res.status}`)
  125 + const data = await res.json()
  126 + totalCounts.all = data.total || 0
  127 + totalCounts.offline = parseInt(data['0']) || 0
  128 + totalCounts.stop = parseInt(data['1']) || 0
  129 + totalCounts.standby = parseInt(data['2']) || 0
  130 + totalCounts.run = parseInt(data['3']) || 0
  131 +}
  132 +
  133 +async function fetchDeviceList({ append }) {
  134 + const params = new URLSearchParams({
  135 + pageNo: String(currentPage.value),
  136 + pageSize: String(PAGE_SIZE),
  137 + projectState: '1'
  138 + })
  139 + if (searchKeyword.value) params.append('deviceName', searchKeyword.value)
  140 + if (runStatusFilter.value !== '') params.append('runStatus', runStatusFilter.value)
  141 +
  142 + const res = await apiFetch(`/api/energy/list?${params}`)
  143 + if (!res.ok) throw new Error(`HTTP ${res.status}`)
  144 + const data = await res.json()
  145 + const mapped = (data.list || []).map(toDeviceViewModel)
  146 + deviceList.value = append ? deviceList.value.concat(mapped) : mapped
  147 + totalDevices.value = data.total || 0
  148 +}
  149 +
  150 +async function refresh({ append }) {
  151 + try {
  152 + if (append) loadingMore.value = true
  153 + else loading.value = true
  154 + await Promise.all([fetchDeviceList({ append }), fetchStats()])
  155 + } catch (e) {
  156 + ElMessage.error('获取数据失败,请检查后端接口或代理配置')
  157 + console.warn(e)
  158 + } finally {
  159 + loading.value = false
  160 + loadingMore.value = false
  161 + }
  162 +}
  163 +
  164 +function onSearch() {
  165 + currentPage.value = 1
  166 + refresh({ append: false })
  167 +}
  168 +
  169 +function onFilter(key) {
  170 + if (runStatusFilter.value === key) return
  171 + runStatusFilter.value = key
  172 + currentPage.value = 1
  173 + refresh({ append: false })
  174 +}
  175 +
  176 +function loadMore() {
  177 + if (!canLoadMore.value || loading.value || loadingMore.value) return
  178 + currentPage.value += 1
  179 + refresh({ append: true })
  180 +}
  181 +
  182 +function goRunStatus(device) {
  183 + router.push({
  184 + path: '/energy-h5/run-status',
  185 + query: { ...route.query, dtuSn: device.dtuSn || '', deviceName: device.name || '' }
  186 + })
  187 +}
  188 +
  189 +function goUsage(device) {
  190 + router.push({
  191 + path: '/energy-h5/usage',
  192 + query: { ...route.query, dtuSn: device.dtuSn || '', deviceName: device.name || '' }
  193 + })
  194 +}
  195 +
  196 +onMounted(() => {
  197 + refresh({ append: false })
  198 +})
  199 +</script>
  200 +
  201 +<style scoped>
  202 +.h5-search :deep(.el-input__wrapper) {
  203 + border-radius: 10px;
  204 +}
  205 +
  206 +.h5-filters {
  207 + display: flex;
  208 + gap: 8px;
  209 + overflow-x: auto;
  210 + padding-top: 10px;
  211 + -webkit-overflow-scrolling: touch;
  212 +}
  213 +
  214 +.filter-chip {
  215 + display: inline-flex;
  216 + align-items: center;
  217 + gap: 6px;
  218 + border: 1px solid rgba(0, 0, 0, 0.08);
  219 + background: #fff;
  220 + border-radius: 999px;
  221 + padding: 6px 10px;
  222 + font-size: 12px;
  223 + color: #334155;
  224 + white-space: nowrap;
  225 +}
  226 +
  227 +.filter-chip.active {
  228 + border-color: rgba(64, 158, 255, 0.45);
  229 + background: rgba(64, 158, 255, 0.08);
  230 + color: #1d4ed8;
  231 +}
  232 +
  233 +.filter-chip .dot {
  234 + width: 8px;
  235 + height: 8px;
  236 + border-radius: 999px;
  237 + flex: 0 0 auto;
  238 +}
  239 +
  240 +.filter-chip .count {
  241 + opacity: 0.75;
  242 +}
  243 +
  244 +.device-card {
  245 + background: #fff;
  246 + border-radius: 12px;
  247 + padding: 12px;
  248 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  249 + margin-bottom: 10px;
  250 +}
  251 +
  252 +.card-top {
  253 + display: flex;
  254 + align-items: flex-start;
  255 + justify-content: space-between;
  256 + gap: 10px;
  257 +}
  258 +
  259 +.device-name {
  260 + font-weight: 700;
  261 + color: #0f172a;
  262 + font-size: 14px;
  263 + line-height: 1.25;
  264 + word-break: break-all;
  265 +}
  266 +
  267 +.device-status {
  268 + display: inline-flex;
  269 + align-items: center;
  270 + gap: 6px;
  271 + font-size: 12px;
  272 + color: #334155;
  273 + flex: 0 0 auto;
  274 +}
  275 +
  276 +.status-dot {
  277 + width: 10px;
  278 + height: 10px;
  279 + border-radius: 999px;
  280 +}
  281 +
  282 +.card-body {
  283 + margin-top: 10px;
  284 + display: grid;
  285 + gap: 6px;
  286 +}
  287 +
  288 +.card-actions {
  289 + margin-top: 10px;
  290 + display: flex;
  291 + gap: 10px;
  292 +}
  293 +
  294 +.card-actions :deep(.el-button) {
  295 + flex: 1 1 auto;
  296 +}
  297 +
  298 +.row {
  299 + display: flex;
  300 + align-items: center;
  301 + justify-content: space-between;
  302 + gap: 12px;
  303 + font-size: 13px;
  304 +}
  305 +
  306 +.row .k {
  307 + color: #64748b;
  308 +}
  309 +
  310 +.row .v {
  311 + color: #0f172a;
  312 + font-weight: 600;
  313 +}
  314 +
  315 +.h5-footer {
  316 + padding: 10px 0 18px;
  317 +}
  318 +
  319 +.no-more {
  320 + text-align: center;
  321 + color: #94a3b8;
  322 + font-size: 12px;
  323 + padding: 8px 0;
  324 +}
  325 +</style>
  1 +<template>
  2 + <div class="ts-h5">
  3 + <div class="toolbar">
  4 + <div class="left">
  5 + <div class="mode">日查询</div>
  6 + <el-date-picker
  7 + v-model="tsDate"
  8 + type="date"
  9 + value-format="YYYY-MM-DD"
  10 + placeholder="选择日期"
  11 + size="small"
  12 + style="width: 150px;"
  13 + :disabled-date="disabledDateFuture"
  14 + @change="onDateChange"
  15 + />
  16 + </div>
  17 + <el-button type="primary" size="small" :loading="loading" @click="resetAndFetch">查询</el-button>
  18 + </div>
  19 +
  20 + <div class="canvas-card">
  21 + <div class="gantt-row">
  22 + <div class="fixed-col">
  23 + <canvas ref="fixedCanvasRef" class="fixed-canvas"></canvas>
  24 + </div>
  25 + <div ref="scrollAreaRef" class="scroll-area" @scroll="onScroll">
  26 + <canvas
  27 + ref="ganttCanvasRef"
  28 + class="gantt-canvas"
  29 + @mousemove="onMouseMove"
  30 + @mouseleave="onMouseLeave"
  31 + @click="onCanvasClick"
  32 + ></canvas>
  33 + </div>
  34 + </div>
  35 +
  36 + <div v-if="tooltip.show" class="gantt-tooltip" :style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }">
  37 + <div class="gtt-title">{{ tooltip.seg.deviceName }}</div>
  38 + <div class="gtt-row">
  39 + <span class="gtt-dot" :style="{ background: getStatusColor(tooltip.seg.runStatus) }"></span>
  40 + {{ statusLabel(tooltip.seg.runStatus) }}: {{ formatDuration(tooltip.seg.duration) }}
  41 + </div>
  42 + <div class="gtt-sub">{{ formatTimeRange(tooltip.seg.startTime, tooltip.seg.endTime) }}</div>
  43 + </div>
  44 +
  45 + <el-empty v-if="!loading && timeSeriesData.length === 0" description="暂无数据" />
  46 +
  47 + <div v-if="timeSeriesData.length > 0" class="load-more">
  48 + <div ref="sentinelRef" class="load-sentinel"></div>
  49 + <div v-if="loadingMore" class="load-text">加载中...</div>
  50 + <div v-else-if="!loading && !hasMore" class="load-text">没有更多了</div>
  51 + </div>
  52 + </div>
  53 + </div>
  54 +</template>
  55 +
  56 +<script setup>
  57 +import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue'
  58 +import { ElMessage } from 'element-plus'
  59 +import { apiFetch } from '../../config/api.js'
  60 +
  61 +const tsDate = ref('')
  62 +const loading = ref(false)
  63 +const loadingMore = ref(false)
  64 +const tsPageNo = ref(0)
  65 +const tsPageSize = 12
  66 +const timeSeriesData = ref([])
  67 +const hasMore = ref(true)
  68 +
  69 +const canLoadMore = computed(() => timeSeriesData.value.length > 0 && hasMore.value)
  70 +
  71 +function disabledDateFuture(time) {
  72 + return time.getTime() > Date.now()
  73 +}
  74 +
  75 +function onDateChange() {
  76 + resetAndFetch()
  77 +}
  78 +
  79 +const STATUS_MAP = { 0: '离线', 1: '停机', 2: '运行', 3: '待机' }
  80 +const STATUS_COLORS = { 0: '#909399', 1: '#e74c3c', 2: '#67c23a', 3: '#c5d94e' }
  81 +
  82 +function statusLabel(s) {
  83 + return STATUS_MAP[Number(s)] || '未知'
  84 +}
  85 +
  86 +function getStatusColor(s) {
  87 + return STATUS_COLORS[Number(s)] || '#909399'
  88 +}
  89 +
  90 +function formatDuration(dur) {
  91 + const v = Number(dur || 0)
  92 + if (v <= 0) return '0秒'
  93 + if (v >= 3600) {
  94 + const h = Math.floor(v / 3600)
  95 + const m = Math.floor((v % 3600) / 60)
  96 + const s = Math.floor(v % 60)
  97 + if (h > 0 && m === 0 && s === 0) return `${h}时`
  98 + if (h > 0 && s === 0) return `${h}时${m}分`
  99 + return `${h}时${m}分${s}秒`
  100 + }
  101 + if (v >= 60) {
  102 + const m = Math.floor(v / 60)
  103 + const s = Math.floor(v % 60)
  104 + return s === 0 ? `${m}分` : `${m}分${s}秒`
  105 + }
  106 + return `${Math.floor(v)}秒`
  107 +}
  108 +
  109 +function formatTimeRange(start, end) {
  110 + const fmt = (ts) => {
  111 + const d = ts ? new Date(String(ts).replace(/-/g, '/')) : new Date()
  112 + return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(
  113 + d.getHours()
  114 + ).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
  115 + }
  116 + return `${fmt(start)} ~ ${fmt(end || start)}`
  117 +}
  118 +
  119 +function parseTimeMs(timeStr) {
  120 + const d = new Date(String(timeStr || '').replace(/-/g, '/'))
  121 + return d.getTime()
  122 +}
  123 +
  124 +function getDayStartMs() {
  125 + const dateStr = tsDate.value || ''
  126 + const d = dateStr ? new Date(dateStr) : new Date()
  127 + d.setHours(0, 0, 0, 0)
  128 + return d.getTime()
  129 +}
  130 +
  131 +function resetAndFetch() {
  132 + tsPageNo.value = 0
  133 + timeSeriesData.value = []
  134 + hasMore.value = true
  135 + tooltip.show = false
  136 + fetchTimeSeriesData({ page: 1, append: false })
  137 +}
  138 +
  139 +function mapRow(item) {
  140 + const dayStartMs = getDayStartMs()
  141 + const segments = (item.timelineList || [])
  142 + .map((seg) => {
  143 + const sMs = parseTimeMs(seg.startTime)
  144 + let eMs = seg.endTime ? parseTimeMs(seg.endTime) : NaN
  145 + const dur = Number(seg.duration || 0)
  146 + if (!Number.isFinite(eMs) && Number.isFinite(sMs) && dur > 0) eMs = sMs + dur * 1000
  147 + if (!Number.isFinite(sMs) || !Number.isFinite(eMs)) return null
  148 + const startSec = Math.max(0, Math.min(86400, (sMs - dayStartMs) / 1000))
  149 + const endSec = Math.max(0, Math.min(86400, (eMs - dayStartMs) / 1000))
  150 + return {
  151 + runStatus: Number(seg.runStatus ?? 0),
  152 + startSec,
  153 + endSec: Math.max(startSec, endSec),
  154 + startTime: seg.startTime,
  155 + endTime: seg.endTime || '',
  156 + duration: seg.duration || dur
  157 + }
  158 + })
  159 + .filter(Boolean)
  160 +
  161 + return {
  162 + name: item.deviceName || item.dtuSn || '',
  163 + dtuSn: item.dtuSn || '',
  164 + utilizationRate: Number(item.utilizationRate ?? 0),
  165 + totalKwh: Number(item.totalKwh ?? 0),
  166 + segments
  167 + }
  168 +}
  169 +
  170 +async function fetchTimeSeriesData({ page = 1, append = false } = {}) {
  171 + if (!tsDate.value) {
  172 + ElMessage.warning('请选择查询日期')
  173 + return
  174 + }
  175 + if (loading.value || loadingMore.value) return
  176 + if (append && !hasMore.value) return
  177 + if (append) loadingMore.value = true
  178 + else loading.value = true
  179 + try {
  180 + const res = await apiFetch(
  181 + `/api/energy/timelineStatus?date=${encodeURIComponent(tsDate.value)}&pageSize=${encodeURIComponent(tsPageSize)}&pageNo=${encodeURIComponent(page)}`
  182 + )
  183 + const data = await res.json()
  184 + if (data?.code !== 200) throw new Error('bad response')
  185 + const list = (data.list || []).map(mapRow)
  186 + hasMore.value = list.length >= tsPageSize
  187 + timeSeriesData.value = append ? timeSeriesData.value.concat(list) : list
  188 + tsPageNo.value = page
  189 + await nextTick()
  190 + drawAll()
  191 + await nextTick()
  192 + ensureAutoLoad()
  193 + } catch (e) {
  194 + ElMessage.error('获取时序状态失败')
  195 + console.warn(e)
  196 + } finally {
  197 + loading.value = false
  198 + loadingMore.value = false
  199 + }
  200 +}
  201 +
  202 +const ganttCanvasRef = ref(null)
  203 +const fixedCanvasRef = ref(null)
  204 +const scrollAreaRef = ref(null)
  205 +
  206 +const ROW_H = 36
  207 +const AXIS_H = 32
  208 +const FIXED_W = 210
  209 +const PAD = 10
  210 +const PX_PER_HOUR = 100
  211 +const MIN_TIMELINE_W = 24 * PX_PER_HOUR
  212 +
  213 +const tooltip = reactive({ show: false, x: 0, y: 0, seg: null })
  214 +let ganttHitRects = []
  215 +
  216 +function clamp(v, min, max) {
  217 + return Math.max(min, Math.min(max, v))
  218 +}
  219 +
  220 +function formatRate(v) {
  221 + const n = Number(v || 0)
  222 + if (!Number.isFinite(n)) return '0.0%'
  223 + return (n % 1 === 0 ? n.toFixed(1) : n.toFixed(2)) + '%'
  224 +}
  225 +
  226 +function drawTextEllipsis(ctx, text, x, y, maxW) {
  227 + if (!text) return
  228 + const t = String(text)
  229 + if (ctx.measureText(t).width <= maxW) {
  230 + ctx.fillText(t, x, y)
  231 + return
  232 + }
  233 + let cur = ''
  234 + for (let i = 0; i < t.length; i++) {
  235 + const next = cur + t[i]
  236 + if (ctx.measureText(next + '…').width > maxW) break
  237 + cur = next
  238 + }
  239 + ctx.fillText(cur + '…', x, y)
  240 +}
  241 +
  242 +function drawFixedCol(cssH) {
  243 + const canvas = fixedCanvasRef.value
  244 + if (!canvas) return
  245 + const dpr = window.devicePixelRatio || 1
  246 + canvas.style.width = FIXED_W + 'px'
  247 + canvas.style.height = cssH + 'px'
  248 + canvas.width = Math.round(FIXED_W * dpr)
  249 + canvas.height = Math.round(cssH * dpr)
  250 + const ctx = canvas.getContext('2d')
  251 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  252 + ctx.clearRect(0, 0, FIXED_W, cssH)
  253 +
  254 + const rows = timeSeriesData.value || []
  255 + ctx.fillStyle = '#f0f2f5'
  256 + ctx.fillRect(0, 0, FIXED_W, AXIS_H)
  257 + ctx.strokeStyle = '#e4e7ed'
  258 + ctx.lineWidth = 1
  259 + ctx.beginPath()
  260 + ctx.moveTo(0, AXIS_H)
  261 + ctx.lineTo(FIXED_W, AXIS_H)
  262 + ctx.stroke()
  263 +
  264 + ctx.font = 'bold 12px sans-serif'
  265 + ctx.fillStyle = '#334155'
  266 + ctx.textBaseline = 'middle'
  267 + ctx.textAlign = 'left'
  268 + ctx.fillText('设备名称', 10, AXIS_H / 2)
  269 + ctx.textAlign = 'center'
  270 + ctx.fillText('稼动率', FIXED_W - 86, AXIS_H / 2)
  271 + ctx.textAlign = 'right'
  272 + ctx.fillText('用电量', FIXED_W - 8, AXIS_H / 2)
  273 +
  274 + ctx.font = '12px sans-serif'
  275 + rows.forEach((row, idx) => {
  276 + const y = AXIS_H + idx * ROW_H
  277 + if (idx % 2 === 1) {
  278 + ctx.fillStyle = '#fafafa'
  279 + ctx.fillRect(0, y, FIXED_W, ROW_H)
  280 + }
  281 + ctx.strokeStyle = '#f0f0f0'
  282 + ctx.beginPath()
  283 + ctx.moveTo(0, y + ROW_H)
  284 + ctx.lineTo(FIXED_W, y + ROW_H)
  285 + ctx.stroke()
  286 +
  287 + const cy = y + ROW_H / 2
  288 + ctx.textAlign = 'left'
  289 + ctx.fillStyle = '#0f172a'
  290 + drawTextEllipsis(ctx, row.name, 10, cy, FIXED_W - 110)
  291 +
  292 + const ur = Number(row.utilizationRate ?? 0)
  293 + ctx.textAlign = 'center'
  294 + ctx.fillStyle = ur >= 30 ? '#67c23a' : ur > 0 ? '#e6a23c' : '#909399'
  295 + ctx.fillText(formatRate(ur), FIXED_W - 86, cy)
  296 +
  297 + ctx.textAlign = 'right'
  298 + ctx.fillStyle = '#0f172a'
  299 + ctx.fillText(String(row.totalKwh ?? 0), FIXED_W - 8, cy)
  300 + })
  301 +}
  302 +
  303 +function drawTimeline(cssW, cssH) {
  304 + const canvas = ganttCanvasRef.value
  305 + if (!canvas) return
  306 + const rows = timeSeriesData.value || []
  307 +
  308 + const dpr = window.devicePixelRatio || 1
  309 + canvas.style.width = cssW + 'px'
  310 + canvas.style.height = cssH + 'px'
  311 + canvas.width = Math.round(cssW * dpr)
  312 + canvas.height = Math.round(cssH * dpr)
  313 + const ctx = canvas.getContext('2d')
  314 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  315 + ctx.clearRect(0, 0, cssW, cssH)
  316 +
  317 + const plotX = PAD
  318 + const plotW = cssW - PAD - PAD
  319 + const viewStartSec = 0
  320 + const viewRangeSec = 86400
  321 + const viewEndSec = 86400
  322 +
  323 + ctx.fillStyle = '#f0f2f5'
  324 + ctx.fillRect(0, 0, cssW, AXIS_H)
  325 + ctx.strokeStyle = '#e4e7ed'
  326 + ctx.lineWidth = 1
  327 + ctx.beginPath()
  328 + ctx.moveTo(0, AXIS_H)
  329 + ctx.lineTo(cssW, AXIS_H)
  330 + ctx.stroke()
  331 +
  332 + ctx.font = '11px sans-serif'
  333 + ctx.fillStyle = '#64748b'
  334 + ctx.textAlign = 'center'
  335 + ctx.textBaseline = 'middle'
  336 + for (let h = 0; h <= 24; h++) {
  337 + const t = h * 3600
  338 + const px = plotX + (t / 86400) * plotW
  339 + ctx.strokeStyle = h % 2 === 0 ? 'rgba(0,0,0,0.08)' : 'rgba(0,0,0,0.05)'
  340 + ctx.lineWidth = 0.8
  341 + ctx.beginPath()
  342 + ctx.moveTo(px, AXIS_H)
  343 + ctx.lineTo(px, cssH)
  344 + ctx.stroke()
  345 + if (h % 2 === 0) ctx.fillText(String(h).padStart(2, '0') + ':00', px, AXIS_H / 2)
  346 + }
  347 +
  348 + ganttHitRects = []
  349 + rows.forEach((row, idx) => {
  350 + const y = AXIS_H + idx * ROW_H
  351 + const barY = y + ROW_H * 0.18
  352 + const barH = ROW_H * 0.64
  353 +
  354 + if (idx % 2 === 1) {
  355 + ctx.fillStyle = '#fafafa'
  356 + ctx.fillRect(0, y, cssW, ROW_H)
  357 + }
  358 + ctx.strokeStyle = '#f0f0f0'
  359 + ctx.beginPath()
  360 + ctx.moveTo(0, y + ROW_H)
  361 + ctx.lineTo(cssW, y + ROW_H)
  362 + ctx.stroke()
  363 +
  364 + ctx.fillStyle = 'rgba(15,23,42,0.03)'
  365 + ctx.fillRect(plotX, barY, plotW, barH)
  366 +
  367 + ;(row.segments || []).forEach((seg, segIdx) => {
  368 + const s = seg.startSec
  369 + const e = seg.endSec
  370 + if (e <= viewStartSec || s >= viewEndSec) return
  371 + const x1 = plotX + ((Math.max(s, viewStartSec) - viewStartSec) / viewRangeSec) * plotW
  372 + const x2 = plotX + ((Math.min(e, viewEndSec) - viewStartSec) / viewRangeSec) * plotW
  373 + const w = Math.max(1, x2 - x1)
  374 + const isHover = tooltip.show && tooltip.seg && tooltip.seg.rowIdx === idx && tooltip.seg.segIdx === segIdx
  375 + ctx.fillStyle = getStatusColor(seg.runStatus)
  376 + ctx.globalAlpha = isHover ? 1 : 0.86
  377 + ctx.fillRect(x1, barY, w, barH)
  378 + ctx.globalAlpha = 1
  379 + ganttHitRects.push({
  380 + rowIdx: idx,
  381 + segIdx,
  382 + x: x1,
  383 + y: barY,
  384 + w,
  385 + h: barH,
  386 + ...seg,
  387 + deviceName: row.name
  388 + })
  389 + })
  390 + })
  391 +
  392 + const now = Date.now()
  393 + const dayStartMs = getDayStartMs()
  394 + const nowSec = (now - dayStartMs) / 1000
  395 + if (nowSec >= 0 && nowSec <= 86400) {
  396 + const px = plotX + (nowSec / 86400) * plotW
  397 + ctx.strokeStyle = '#e74c3c'
  398 + ctx.lineWidth = 1.2
  399 + ctx.setLineDash([4, 3])
  400 + ctx.beginPath()
  401 + ctx.moveTo(px, AXIS_H)
  402 + ctx.lineTo(px, cssH)
  403 + ctx.stroke()
  404 + ctx.setLineDash([])
  405 + }
  406 +}
  407 +
  408 +function getHitAtCanvas(mx, my) {
  409 + for (let i = ganttHitRects.length - 1; i >= 0; i--) {
  410 + const r = ganttHitRects[i]
  411 + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) return r
  412 + }
  413 + return null
  414 +}
  415 +
  416 +function showTooltip(hit, clientX, clientY) {
  417 + const vw = window.innerWidth || document.documentElement.clientWidth
  418 + const vh = window.innerHeight || document.documentElement.clientHeight
  419 + const ttW = 240
  420 + const ttH = 108
  421 + let tx = clientX + 12
  422 + let ty = clientY - ttH - 10
  423 + if (tx + ttW > vw - 8) tx = clientX - ttW - 10
  424 + if (ty < 8) ty = clientY + 14
  425 + tooltip.x = clamp(tx, 6, vw - ttW - 6)
  426 + tooltip.y = clamp(ty, 6, vh - ttH - 6)
  427 + tooltip.seg = hit
  428 + tooltip.show = true
  429 +}
  430 +
  431 +function hideTooltip() {
  432 + tooltip.show = false
  433 + tooltip.seg = null
  434 +}
  435 +
  436 +function onMouseMove(e) {
  437 + if (!(window.matchMedia && window.matchMedia('(hover: hover)').matches)) return
  438 + const canvas = ganttCanvasRef.value
  439 + if (!canvas) return
  440 + const mx = e.offsetX
  441 + const my = e.offsetY
  442 + const hit = getHitAtCanvas(mx, my)
  443 + if (!hit) {
  444 + hideTooltip()
  445 + drawAll()
  446 + return
  447 + }
  448 + showTooltip(hit, e.clientX, e.clientY)
  449 + drawAll()
  450 +}
  451 +
  452 +function onMouseLeave() {
  453 + if (window.matchMedia && window.matchMedia('(hover: hover)').matches) {
  454 + hideTooltip()
  455 + drawAll()
  456 + }
  457 +}
  458 +
  459 +function onCanvasClick(e) {
  460 + const canvas = ganttCanvasRef.value
  461 + if (!canvas) return
  462 + const mx = e.offsetX
  463 + const my = e.offsetY
  464 + const hit = getHitAtCanvas(mx, my)
  465 + if (!hit) {
  466 + hideTooltip()
  467 + drawAll()
  468 + return
  469 + }
  470 + showTooltip(hit, e.clientX, e.clientY)
  471 + drawAll()
  472 +}
  473 +
  474 +function onScroll() {
  475 + hideTooltip()
  476 +}
  477 +
  478 +const sentinelRef = ref(null)
  479 +let sentinelObserver = null
  480 +let ensureTimer = null
  481 +
  482 +function loadMoreIfNeeded() {
  483 + if (loading.value || loadingMore.value) return
  484 + if (!canLoadMore.value) return
  485 + const nextPage = tsPageNo.value + 1
  486 + fetchTimeSeriesData({ page: nextPage, append: true })
  487 +}
  488 +
  489 +function ensureAutoLoad() {
  490 + if (ensureTimer) return
  491 + ensureTimer = window.setTimeout(() => {
  492 + ensureTimer = null
  493 + if (!sentinelRef.value) return
  494 + if (loading.value || loadingMore.value) return
  495 + if (!canLoadMore.value) return
  496 + const rect = sentinelRef.value.getBoundingClientRect()
  497 + const vh = window.innerHeight || document.documentElement.clientHeight
  498 + if (rect.top <= vh + 200) loadMoreIfNeeded()
  499 + }, 0)
  500 +}
  501 +
  502 +function attachObserver() {
  503 + if (!sentinelRef.value) return
  504 + if (!('IntersectionObserver' in window)) return
  505 + if (sentinelObserver) return
  506 + sentinelObserver = new IntersectionObserver(
  507 + (entries) => {
  508 + if (entries.some(e => e.isIntersecting)) loadMoreIfNeeded()
  509 + },
  510 + { root: null, rootMargin: '300px 0px 300px 0px', threshold: 0 }
  511 + )
  512 + sentinelObserver.observe(sentinelRef.value)
  513 +}
  514 +
  515 +function detachObserver() {
  516 + if (sentinelObserver) {
  517 + sentinelObserver.disconnect()
  518 + sentinelObserver = null
  519 + }
  520 +}
  521 +
  522 +let ro = null
  523 +
  524 +onMounted(() => {
  525 + const today = new Date()
  526 + tsDate.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
  527 + resetAndFetch()
  528 + attachObserver()
  529 +
  530 + if ('ResizeObserver' in window) {
  531 + ro = new ResizeObserver(() => drawAll())
  532 + if (scrollAreaRef.value) ro.observe(scrollAreaRef.value)
  533 + }
  534 +})
  535 +
  536 +onActivated(() => {
  537 + attachObserver()
  538 + ensureAutoLoad()
  539 + drawAll()
  540 +})
  541 +
  542 +onDeactivated(() => {
  543 + detachObserver()
  544 +})
  545 +
  546 +onBeforeUnmount(() => {
  547 + detachObserver()
  548 + if (ensureTimer) {
  549 + window.clearTimeout(ensureTimer)
  550 + ensureTimer = null
  551 + }
  552 + if (ro) {
  553 + ro.disconnect()
  554 + ro = null
  555 + }
  556 +})
  557 +
  558 +function drawAll() {
  559 + const scroller = scrollAreaRef.value
  560 + const cssViewportW = Math.max(320, scroller ? scroller.clientWidth : 320)
  561 + const cssTimelineW = Math.max(MIN_TIMELINE_W, cssViewportW)
  562 + const rows = timeSeriesData.value || []
  563 + const cssH = Math.max(AXIS_H + rows.length * ROW_H + 8, 120)
  564 + drawFixedCol(cssH)
  565 + drawTimeline(cssTimelineW, cssH)
  566 +}
  567 +</script>
  568 +
  569 +<style scoped>
  570 +.toolbar {
  571 + display: flex;
  572 + align-items: center;
  573 + justify-content: space-between;
  574 + gap: 10px;
  575 +}
  576 +
  577 +.left {
  578 + display: flex;
  579 + align-items: center;
  580 + gap: 10px;
  581 +}
  582 +
  583 +.mode {
  584 + font-size: 12px;
  585 + color: #64748b;
  586 + white-space: nowrap;
  587 +}
  588 +
  589 +.canvas-card {
  590 + background: #fff;
  591 + border-radius: 12px;
  592 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  593 + padding: 10px;
  594 + margin-top: 10px;
  595 +}
  596 +
  597 +.gantt-row {
  598 + display: flex;
  599 + align-items: flex-start;
  600 + border-radius: 10px;
  601 + overflow: hidden;
  602 + border: 1px solid rgba(0, 0, 0, 0.06);
  603 +}
  604 +
  605 +.fixed-col {
  606 + flex: 0 0 auto;
  607 + background: #fff;
  608 + border-right: 1px solid rgba(0, 0, 0, 0.06);
  609 +}
  610 +
  611 +.fixed-canvas {
  612 + display: block;
  613 + width: 210px;
  614 +}
  615 +
  616 +.gantt-tooltip {
  617 + position: fixed;
  618 + width: 240px;
  619 + background: rgba(15, 23, 42, 0.92);
  620 + color: #fff;
  621 + border-radius: 10px;
  622 + padding: 10px 10px;
  623 + pointer-events: none;
  624 + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
  625 + z-index: 1000;
  626 +}
  627 +
  628 +.gtt-title {
  629 + font-size: 12px;
  630 + font-weight: 800;
  631 + margin-bottom: 6px;
  632 + white-space: nowrap;
  633 + overflow: hidden;
  634 + text-overflow: ellipsis;
  635 +}
  636 +
  637 +.gtt-row {
  638 + font-size: 12px;
  639 + font-weight: 600;
  640 + display: flex;
  641 + align-items: center;
  642 + gap: 8px;
  643 +}
  644 +
  645 +.gtt-sub {
  646 + margin-top: 6px;
  647 + font-size: 11px;
  648 + opacity: 0.9;
  649 + line-height: 1.35;
  650 +}
  651 +
  652 +.gtt-dot {
  653 + width: 10px;
  654 + height: 10px;
  655 + border-radius: 999px;
  656 + flex: 0 0 auto;
  657 +}
  658 +
  659 +.scroll-area {
  660 + flex: 1 1 auto;
  661 + overflow-x: auto;
  662 + -webkit-overflow-scrolling: touch;
  663 + background: #fff;
  664 +}
  665 +
  666 +.gantt-canvas {
  667 + display: block;
  668 + height: auto;
  669 +}
  670 +
  671 +.load-more {
  672 + margin-top: 10px;
  673 +}
  674 +
  675 +.load-sentinel {
  676 + height: 1px;
  677 +}
  678 +
  679 +.load-text {
  680 + font-size: 12px;
  681 + color: #64748b;
  682 + text-align: center;
  683 + padding: 10px 0 4px;
  684 +}
  685 +</style>
  1 +<template>
  2 + <div class="util-h5">
  3 + <div class="toolbar">
  4 + <div class="left">
  5 + <el-radio-group v-model="utilQueryMode" size="small" @change="onModeChange">
  6 + <el-radio-button value="day">日查询</el-radio-button>
  7 + <el-radio-button value="week">周查询</el-radio-button>
  8 + <el-radio-button value="month">月查询</el-radio-button>
  9 + </el-radio-group>
  10 + </div>
  11 + <el-button type="primary" size="small" :loading="loading" @click="fetchUtilData">查询</el-button>
  12 + </div>
  13 +
  14 + <div class="toolbar" style="margin-top: 8px;">
  15 + <div class="left">
  16 + <el-date-picker
  17 + v-if="utilQueryMode === 'day'"
  18 + v-model="utilDate"
  19 + type="date"
  20 + placeholder="选择日期"
  21 + size="small"
  22 + value-format="YYYY-MM-DD"
  23 + :disabled-date="disabledDateFuture"
  24 + style="width: 160px;"
  25 + @change="fetchUtilData"
  26 + />
  27 + <el-date-picker
  28 + v-else-if="utilQueryMode === 'week'"
  29 + v-model="utilWeekDate"
  30 + type="date"
  31 + :format="utilWeekDisplayFormat"
  32 + placeholder="选择周"
  33 + size="small"
  34 + value-format="YYYY-MM-DD"
  35 + :disabled-date="disableNonMonday"
  36 + style="width: 160px;"
  37 + @change="fetchUtilData"
  38 + />
  39 + <el-date-picker
  40 + v-else
  41 + v-model="utilMonthDate"
  42 + type="month"
  43 + placeholder="选择月份"
  44 + size="small"
  45 + value-format="YYYY-MM"
  46 + style="width: 140px;"
  47 + @change="fetchUtilData"
  48 + />
  49 + </div>
  50 + </div>
  51 +
  52 + <div class="section">
  53 + <div class="section-title">总稼动率</div>
  54 + <div class="subline">稼动率:{{ totalAvailabilityRate }}</div>
  55 + <div class="legend-total">
  56 + <div v-for="(seg, i) in totalSegments" :key="i" class="legend-total-item">
  57 + <div class="lt-left">
  58 + <span class="dot" :style="{ background: seg.color }"></span>
  59 + <span class="name">{{ seg.label }}</span>
  60 + </div>
  61 + <div class="lt-right">
  62 + <div class="val">{{ seg.duration }}</div>
  63 + <div class="pct">{{ seg.pct }}</div>
  64 + </div>
  65 + </div>
  66 + </div>
  67 + <div ref="pieTotalWrapRef" class="pie-wrap">
  68 + <canvas ref="pieTotalRef" class="pie-canvas"></canvas>
  69 + </div>
  70 + </div>
  71 +
  72 + <div class="section">
  73 + <div class="section-title">当前机台运行状态</div>
  74 + <div class="legend">
  75 + <div class="legend-item">
  76 + <span class="dot" style="background:#67c23a"></span><span class="name">运行</span><span class="val">{{ currentCounts.run }}台</span>
  77 + </div>
  78 + <div class="legend-item">
  79 + <span class="dot" style="background:#e6a23c"></span><span class="name">待机</span><span class="val">{{ currentCounts.standby }}台</span>
  80 + </div>
  81 + <div class="legend-item">
  82 + <span class="dot" style="background:#f56c6c"></span><span class="name">停机</span><span class="val">{{ currentCounts.stop }}台</span>
  83 + </div>
  84 + <div class="legend-item">
  85 + <span class="dot" style="background:#909399"></span><span class="name">离线</span><span class="val">{{ currentCounts.offline }}台</span>
  86 + </div>
  87 + </div>
  88 + <div ref="pieStatusWrapRef" class="pie-wrap">
  89 + <canvas ref="pieStatusRef" class="pie-canvas"></canvas>
  90 + </div>
  91 + </div>
  92 +
  93 + <div class="section">
  94 + <div class="section-title">异常机台排名</div>
  95 + <div class="subline">待机 + 停机时长</div>
  96 + <div ref="abnormalWrapRef" class="chart-wrap">
  97 + <canvas ref="abnormalRef" class="chart-canvas" @pointerdown="onAbnormalTap"></canvas>
  98 + </div>
  99 + <div v-if="abnormalSelected.show" class="tip-card">
  100 + <div class="tip-title">{{ abnormalSelected.name }}</div>
  101 + <div class="tip-line"><i style="background:#e6a23c"></i>待机 {{ formatDuration(abnormalSelected.s2) }}</div>
  102 + <div class="tip-line"><i style="background:#f56c6c"></i>停机 {{ formatDuration(abnormalSelected.s1) }}</div>
  103 + </div>
  104 + <el-empty v-if="!loading && abnormalList.length === 0" description="暂无异常数据" />
  105 + </div>
  106 +
  107 + <div class="section">
  108 + <div class="section-title">设备状态时长分布</div>
  109 + <div class="stack-toolbar">
  110 + <div class="subline" style="margin: 0;">排序</div>
  111 + <el-radio-group v-model="sortMode" size="small" @change="redrawStackBar">
  112 + <el-radio-button value="rate">稼动率</el-radio-button>
  113 + <el-radio-button value="duration">运行时长</el-radio-button>
  114 + </el-radio-group>
  115 + </div>
  116 + <div class="stack-legend">
  117 + <span class="leg"><i class="dot" style="background:#67c23a"></i>运行</span>
  118 + <span class="leg"><i class="dot" style="background:#e6a23c"></i>待机</span>
  119 + <span class="leg"><i class="dot" style="background:#f56c6c"></i>停机</span>
  120 + <span class="leg"><i class="dot" style="background:#909399"></i>离线</span>
  121 + </div>
  122 + <div class="stack-scroll">
  123 + <div ref="stackWrapRef" class="stack-wrap">
  124 + <canvas ref="stackRef" class="stack-canvas" @pointerdown="onStackTap"></canvas>
  125 + </div>
  126 + </div>
  127 + <div v-if="stackSelected.show" class="tip-card">
  128 + <div class="tip-title">{{ stackSelected.name }}</div>
  129 + <div class="tip-line"><i style="background:#67c23a"></i>运行 {{ formatDuration(stackSelected.s3) }}</div>
  130 + <div class="tip-line"><i style="background:#e6a23c"></i>待机 {{ formatDuration(stackSelected.s2) }}</div>
  131 + <div class="tip-line"><i style="background:#f56c6c"></i>停机 {{ formatDuration(stackSelected.s1) }}</div>
  132 + <div class="tip-line"><i style="background:#909399"></i>离线 {{ formatDuration(stackSelected.s0) }}</div>
  133 + </div>
  134 + <el-empty v-if="!loading && deviceList.length === 0" description="暂无数据" />
  135 + </div>
  136 + </div>
  137 +</template>
  138 +
  139 +<script setup>
  140 +import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
  141 +import { ElMessage } from 'element-plus'
  142 +import { apiFetch } from '../../config/api.js'
  143 +
  144 +const utilQueryMode = ref('day')
  145 +const utilDate = ref('')
  146 +const utilWeekDate = ref('')
  147 +const utilMonthDate = ref('')
  148 +const loading = ref(false)
  149 +const sortMode = ref('rate')
  150 +
  151 +const utilData = reactive({
  152 + currentStatus: {},
  153 + deviceList: [],
  154 + summary: {},
  155 + abnormalRanking: []
  156 +})
  157 +
  158 +const UTIL_COLORS = { 0: '#909399', 1: '#f56c6c', 2: '#e6a23c', 3: '#67c23a' }
  159 +
  160 +function disabledDateFuture(time) {
  161 + return time.getTime() > Date.now()
  162 +}
  163 +
  164 +function formatYMD(d) {
  165 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  166 +}
  167 +
  168 +function getWeekNumber(date) {
  169 + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
  170 + const dayNum = d.getUTCDay() || 7
  171 + d.setUTCDate(d.getUTCDate() + 4 - dayNum)
  172 + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
  173 + return Math.ceil(((d - yearStart) / 86400000 + 1) / 7)
  174 +}
  175 +
  176 +const utilWeekDisplayFormat = computed(() => {
  177 + if (!utilWeekDate.value) return ''
  178 + const d = new Date(utilWeekDate.value)
  179 + return `${d.getFullYear()}-${String(getWeekNumber(d)).padStart(2, '0')}周`
  180 +})
  181 +
  182 +function getCurrentMonday() {
  183 + const today = new Date()
  184 + const day = today.getDay() || 7
  185 + today.setDate(today.getDate() - day + 1)
  186 + return formatYMD(today)
  187 +}
  188 +
  189 +function getCurrentYM() {
  190 + const now = new Date()
  191 + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
  192 +}
  193 +
  194 +function getWeekRange(dateStr) {
  195 + if (!dateStr) return { start: '', end: '' }
  196 + const d = new Date(dateStr)
  197 + const day = d.getDay() || 7
  198 + const diff = d.getDate() - day + 1
  199 + const monday = new Date(d.setDate(diff))
  200 + const sunday = new Date(monday)
  201 + sunday.setDate(monday.getDate() + 6)
  202 + return { start: formatYMD(monday), end: formatYMD(sunday) }
  203 +}
  204 +
  205 +function getMonthRange(ymStr) {
  206 + if (!ymStr) return { start: '', end: '' }
  207 + const [y, m] = ymStr.split('-').map(Number)
  208 + const lastDay = new Date(y, m, 0).getDate()
  209 + return { start: `${ymStr}-01`, end: `${ymStr}-${String(lastDay).padStart(2, '0')}` }
  210 +}
  211 +
  212 +function disableNonMonday(date) {
  213 + return date.getDay() !== 1
  214 +}
  215 +
  216 +function setDefaultDate() {
  217 + if (utilQueryMode.value === 'day') {
  218 + if (!utilDate.value) utilDate.value = formatYMD(new Date())
  219 + } else if (utilQueryMode.value === 'week') {
  220 + if (!utilWeekDate.value) utilWeekDate.value = getCurrentMonday()
  221 + } else {
  222 + if (!utilMonthDate.value) utilMonthDate.value = getCurrentYM()
  223 + }
  224 +}
  225 +
  226 +function onModeChange() {
  227 + setDefaultDate()
  228 + fetchUtilData()
  229 +}
  230 +
  231 +function formatDuration(seconds) {
  232 + seconds = Number(seconds || 0)
  233 + if (seconds <= 0) return '0秒'
  234 + const h = Math.floor(seconds / 3600)
  235 + const m = Math.floor((seconds % 3600) / 60)
  236 + const s = Math.floor(seconds % 60)
  237 + if (h > 0) return `${h}时${m}分${s}秒`
  238 + if (m > 0) return `${m}分${s}秒`
  239 + return `${s}秒`
  240 +}
  241 +
  242 +function formatHours(seconds) {
  243 + seconds = Number(seconds || 0)
  244 + return (seconds / 3600).toFixed(2).replace(/\.?0+$/, '') + '时'
  245 +}
  246 +
  247 +const totalSegments = computed(() => {
  248 + const totalDur = utilData.summary?.totalStatusDuration || {}
  249 + const s1 = totalDur.status1?.durationSeconds || 0
  250 + const s2 = totalDur.status2?.durationSeconds || 0
  251 + const s3 = totalDur.status3?.durationSeconds || 0
  252 + const total = s1 + s2 + s3
  253 + if (!total) return []
  254 + return [
  255 + { label: '运行', color: UTIL_COLORS[3], seconds: s3, duration: formatHours(s3), pct: ((s3 / total) * 100).toFixed(2) + '%' },
  256 + { label: '待机', color: UTIL_COLORS[2], seconds: s2, duration: formatHours(s2), pct: ((s2 / total) * 100).toFixed(2) + '%' },
  257 + { label: '停机', color: UTIL_COLORS[1], seconds: s1, duration: formatHours(s1), pct: ((s1 / total) * 100).toFixed(2) + '%' }
  258 + ]
  259 +})
  260 +
  261 +const totalAvailabilityRate = computed(() => {
  262 + const segs = totalSegments.value
  263 + if (!segs.length) return '0%'
  264 + const total = segs.reduce((s, x) => s + x.seconds, 0)
  265 + const run = segs.find(x => x.label === '运行')?.seconds || 0
  266 + return total > 0 ? ((run / total) * 100).toFixed(2).replace(/\.?0+$/, '') + '%' : '0%'
  267 +})
  268 +
  269 +const currentCounts = computed(() => {
  270 + const cs = utilData.currentStatus || {}
  271 + return {
  272 + offline: parseInt(cs['0']) || 0,
  273 + stop: parseInt(cs['1']) || 0,
  274 + standby: parseInt(cs['2']) || 0,
  275 + run: parseInt(cs['3']) || 0
  276 + }
  277 +})
  278 +
  279 +const abnormalList = computed(() => {
  280 + return (utilData.abnormalRanking || []).filter(item => (Number(item.status1?.durationSeconds || 0) + Number(item.status2?.durationSeconds || 0)) > 0)
  281 +})
  282 +
  283 +const deviceList = computed(() => utilData.deviceList || [])
  284 +
  285 +async function fetchUtilData() {
  286 + let startDate = ''
  287 + let endDate = ''
  288 + if (utilQueryMode.value === 'day') {
  289 + if (!utilDate.value) { ElMessage.warning('请选择查询日期'); return }
  290 + startDate = endDate = utilDate.value
  291 + } else if (utilQueryMode.value === 'week') {
  292 + if (!utilWeekDate.value) { ElMessage.warning('请选择查询周'); return }
  293 + const range = getWeekRange(utilWeekDate.value)
  294 + startDate = range.start
  295 + endDate = range.end
  296 + } else {
  297 + if (!utilMonthDate.value) { ElMessage.warning('请选择查询月份'); return }
  298 + const range = getMonthRange(utilMonthDate.value)
  299 + startDate = range.start
  300 + endDate = range.end
  301 + }
  302 +
  303 + loading.value = true
  304 + try {
  305 + const res = await apiFetch(`/api/energy/eqKwhStatistics?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`)
  306 + const result = await res.json()
  307 + if (result?.code !== 200) throw new Error('bad response')
  308 + const summary = result?.data?.summary || {}
  309 + utilData.currentStatus = summary.currentStatus || {}
  310 + utilData.deviceList = (summary.deviceList || []).map(d => ({ ...d, availabilityRateValue: parseFloat(d.availabilityRate) || 0 }))
  311 + utilData.summary = summary || {}
  312 + utilData.abnormalRanking = summary.abnormalRanking || []
  313 + abnormalSelected.show = false
  314 + stackSelected.show = false
  315 + await nextTick()
  316 + drawAll()
  317 + } catch (e) {
  318 + ElMessage.error('获取数据失败')
  319 + console.warn(e)
  320 + } finally {
  321 + loading.value = false
  322 + }
  323 +}
  324 +
  325 +const pieTotalRef = ref(null)
  326 +const pieStatusRef = ref(null)
  327 +const abnormalRef = ref(null)
  328 +const stackRef = ref(null)
  329 +
  330 +const pieTotalWrapRef = ref(null)
  331 +const pieStatusWrapRef = ref(null)
  332 +const abnormalWrapRef = ref(null)
  333 +const stackWrapRef = ref(null)
  334 +
  335 +const dpr = window.devicePixelRatio || 1
  336 +function setupCanvas(canvas, cssW, cssH) {
  337 + canvas.style.width = cssW + 'px'
  338 + canvas.style.height = cssH + 'px'
  339 + canvas.width = Math.round(cssW * dpr)
  340 + canvas.height = Math.round(cssH * dpr)
  341 + const ctx = canvas.getContext('2d')
  342 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  343 + return { ctx, W: cssW, H: cssH }
  344 +}
  345 +
  346 +function drawPie(ctx, cx, cy, r, segments) {
  347 + const total = segments.reduce((s, seg) => s + seg.value, 0)
  348 + if (total <= 0) return
  349 + let angle = -Math.PI / 2
  350 + segments.forEach(seg => {
  351 + const sweep = (seg.value / total) * Math.PI * 2
  352 + ctx.beginPath()
  353 + ctx.moveTo(cx, cy)
  354 + ctx.arc(cx, cy, r, angle, angle + sweep)
  355 + ctx.closePath()
  356 + ctx.fillStyle = seg.color
  357 + ctx.fill()
  358 + ctx.strokeStyle = 'rgba(255,255,255,0.5)'
  359 + ctx.lineWidth = 0.8
  360 + ctx.stroke()
  361 + angle += sweep
  362 + })
  363 +}
  364 +
  365 +function drawTotalPie() {
  366 + const canvas = pieTotalRef.value
  367 + const wrap = pieTotalWrapRef.value
  368 + if (!canvas || !wrap) return
  369 + const cssSize = Math.max(240, Math.min(320, wrap.clientWidth || 0))
  370 + const { ctx, W, H } = setupCanvas(canvas, cssSize, cssSize)
  371 + ctx.clearRect(0, 0, W, H)
  372 + const segs = totalSegments.value
  373 + const total = segs.reduce((s, x) => s + x.seconds, 0)
  374 + const cx = W / 2
  375 + const cy = H / 2
  376 + const r = Math.min(W, H) / 2 - 16
  377 + if (!total) {
  378 + ctx.fillStyle = '#999'
  379 + ctx.font = '13px sans-serif'
  380 + ctx.textAlign = 'center'
  381 + ctx.textBaseline = 'middle'
  382 + ctx.fillText('暂无数据', cx, cy)
  383 + return
  384 + }
  385 + drawPie(ctx, cx, cy, r, segs.map(s => ({ label: s.label, value: s.seconds, color: s.color })))
  386 + ctx.fillStyle = '#0f172a'
  387 + ctx.font = 'bold 14px sans-serif'
  388 + ctx.textAlign = 'center'
  389 + ctx.textBaseline = 'middle'
  390 + ctx.fillText(totalAvailabilityRate.value, cx, cy)
  391 +}
  392 +
  393 +function drawStatusPie() {
  394 + const canvas = pieStatusRef.value
  395 + const wrap = pieStatusWrapRef.value
  396 + if (!canvas || !wrap) return
  397 + const cssSize = Math.max(240, Math.min(320, wrap.clientWidth || 0))
  398 + const { ctx, W, H } = setupCanvas(canvas, cssSize, cssSize)
  399 + ctx.clearRect(0, 0, W, H)
  400 + const cs = currentCounts.value
  401 + const total = cs.offline + cs.stop + cs.standby + cs.run
  402 + const cx = W / 2
  403 + const cy = H / 2
  404 + const r = Math.min(W, H) / 2 - 16
  405 + if (!total) {
  406 + ctx.fillStyle = '#999'
  407 + ctx.font = '13px sans-serif'
  408 + ctx.textAlign = 'center'
  409 + ctx.textBaseline = 'middle'
  410 + ctx.fillText('暂无数据', cx, cy)
  411 + return
  412 + }
  413 + drawPie(ctx, cx, cy, r, [
  414 + { label: '运行', value: cs.run, color: UTIL_COLORS[3] },
  415 + { label: '待机', value: cs.standby, color: UTIL_COLORS[2] },
  416 + { label: '停机', value: cs.stop, color: UTIL_COLORS[1] },
  417 + { label: '离线', value: cs.offline, color: UTIL_COLORS[0] }
  418 + ])
  419 +}
  420 +
  421 +const abnormalSelected = reactive({ show: false, name: '', s1: 0, s2: 0 })
  422 +let abnormalRects = []
  423 +
  424 +function drawAbnormal() {
  425 + const canvas = abnormalRef.value
  426 + const wrap = abnormalWrapRef.value
  427 + if (!canvas || !wrap) return
  428 + const list = abnormalList.value.slice(0, 8)
  429 + const cssW = Math.max(300, wrap.clientWidth)
  430 + const cssH = Math.min(420, Math.max(220, list.length * 28 + 80))
  431 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  432 + ctx.clearRect(0, 0, W, H)
  433 + abnormalRects = []
  434 + if (!list.length) return
  435 +
  436 + const PAD_L = 88
  437 + const PAD_R = 14
  438 + const PAD_T = 26
  439 + const PAD_B = 10
  440 + const plotW = W - PAD_L - PAD_R
  441 + const rowH = 26
  442 + const barH = 18
  443 +
  444 + let maxVal = 1
  445 + list.forEach(item => {
  446 + const v = Number(item.status1?.durationSeconds || 0) + Number(item.status2?.durationSeconds || 0)
  447 + maxVal = Math.max(maxVal, v)
  448 + })
  449 +
  450 + ctx.fillStyle = '#64748b'
  451 + ctx.font = '10px sans-serif'
  452 + ctx.textAlign = 'center'
  453 + for (let i = 0; i <= 4; i++) {
  454 + const val = (maxVal * i) / 4
  455 + const x = PAD_L + (plotW * i) / 4
  456 + ctx.fillText(formatHours(val), x, 14)
  457 + if (i > 0) {
  458 + ctx.strokeStyle = '#eee'
  459 + ctx.lineWidth = 1
  460 + ctx.beginPath()
  461 + ctx.moveTo(x, PAD_T)
  462 + ctx.lineTo(x, H - PAD_B)
  463 + ctx.stroke()
  464 + }
  465 + }
  466 +
  467 + list.forEach((item, idx) => {
  468 + const y = PAD_T + idx * rowH
  469 + const name = item.deviceName || item.dtuSn || ''
  470 + const s1 = Number(item.status1?.durationSeconds || 0)
  471 + const s2 = Number(item.status2?.durationSeconds || 0)
  472 + const total = s1 + s2
  473 + ctx.fillStyle = '#0f172a'
  474 + ctx.font = '11px sans-serif'
  475 + ctx.textAlign = 'right'
  476 + ctx.textBaseline = 'middle'
  477 + const dn = name.length > 10 ? name.slice(-10) : name
  478 + ctx.fillText(dn, PAD_L - 8, y + rowH / 2)
  479 +
  480 + const yW = (s2 / maxVal) * plotW
  481 + const rW = (s1 / maxVal) * plotW
  482 + const by = y + (rowH - barH) / 2
  483 + ctx.fillStyle = 'rgba(15,23,42,0.04)'
  484 + ctx.fillRect(PAD_L, by, plotW, barH)
  485 + ctx.fillStyle = UTIL_COLORS[2]
  486 + ctx.fillRect(PAD_L, by, yW, barH)
  487 + ctx.fillStyle = UTIL_COLORS[1]
  488 + ctx.fillRect(PAD_L + yW, by, rW, barH)
  489 + abnormalRects.push({ x: PAD_L, y: by, w: plotW, h: barH, name, s1, s2, total })
  490 + })
  491 +}
  492 +
  493 +function onAbnormalTap(e) {
  494 + const canvas = abnormalRef.value
  495 + if (!canvas) return
  496 + const rect = canvas.getBoundingClientRect()
  497 + const mx = e.clientX - rect.left
  498 + const my = e.clientY - rect.top
  499 + const hit = abnormalRects.find(r => mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h)
  500 + if (!hit) {
  501 + abnormalSelected.show = false
  502 + return
  503 + }
  504 + abnormalSelected.show = true
  505 + abnormalSelected.name = hit.name
  506 + abnormalSelected.s1 = hit.s1
  507 + abnormalSelected.s2 = hit.s2
  508 +}
  509 +
  510 +const stackSelected = reactive({ show: false, name: '', s0: 0, s1: 0, s2: 0, s3: 0 })
  511 +let stackRects = []
  512 +
  513 +function drawStackBar() {
  514 + const canvas = stackRef.value
  515 + const wrap = stackWrapRef.value
  516 + if (!canvas || !wrap) return
  517 + const list = [...(deviceList.value || [])]
  518 + if (sortMode.value === 'rate') list.sort((a, b) => (b.availabilityRateValue || 0) - (a.availabilityRateValue || 0))
  519 + else list.sort((a, b) => (Number(b.status3?.durationSeconds || 0) - Number(a.status3?.durationSeconds || 0)))
  520 +
  521 + const PAD_L = 36
  522 + const PAD_R = 16
  523 + const PAD_T = 18
  524 + const PAD_B = 44
  525 + const colW = 18
  526 + const gap = 8
  527 + const minW = Math.max(320, wrap.clientWidth)
  528 + const cssW = Math.max(minW, PAD_L + PAD_R + list.length * (colW + gap) + gap)
  529 + const cssH = 280
  530 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  531 + ctx.clearRect(0, 0, W, H)
  532 + stackRects = []
  533 + if (!list.length) return
  534 +
  535 + const plotW = W - PAD_L - PAD_R
  536 + const plotH = H - PAD_T - PAD_B
  537 +
  538 + let maxVal = 1
  539 + list.forEach(item => {
  540 + const total =
  541 + Number(item.status0?.durationSeconds || 0) +
  542 + Number(item.status1?.durationSeconds || 0) +
  543 + Number(item.status2?.durationSeconds || 0) +
  544 + Number(item.status3?.durationSeconds || 0)
  545 + maxVal = Math.max(maxVal, total)
  546 + })
  547 +
  548 + const maxHours = maxVal / 3600 || 1
  549 + const yTicks = [Math.ceil(maxHours), Math.ceil(maxHours * 0.66), Math.ceil(maxHours * 0.33), 0]
  550 + ctx.strokeStyle = '#f0f0f0'
  551 + ctx.lineWidth = 1
  552 + ctx.fillStyle = '#94a3b8'
  553 + ctx.font = '10px sans-serif'
  554 + ctx.textAlign = 'end'
  555 + yTicks.forEach(val => {
  556 + const py = PAD_T + plotH * (1 - val / maxHours)
  557 + ctx.fillText(val + '时', PAD_L - 4, py + 3)
  558 + ctx.beginPath()
  559 + ctx.moveTo(PAD_L, py)
  560 + ctx.lineTo(W - PAD_R, py)
  561 + ctx.stroke()
  562 + })
  563 +
  564 + ctx.strokeStyle = '#ddd'
  565 + ctx.beginPath()
  566 + ctx.moveTo(PAD_L, PAD_T + plotH)
  567 + ctx.lineTo(W - PAD_R, PAD_T + plotH)
  568 + ctx.stroke()
  569 +
  570 + const scale = plotH / maxVal
  571 + list.forEach((item, idx) => {
  572 + const px = PAD_L + gap + idx * (colW + gap)
  573 + const s0 = Number(item.status0?.durationSeconds || 0)
  574 + const s1 = Number(item.status1?.durationSeconds || 0)
  575 + const s2 = Number(item.status2?.durationSeconds || 0)
  576 + const s3 = Number(item.status3?.durationSeconds || 0)
  577 + const total = s0 + s1 + s2 + s3
  578 + if (!total) return
  579 + let curY = PAD_T + plotH
  580 + const parts = [
  581 + { sec: s0, color: UTIL_COLORS[0] },
  582 + { sec: s1, color: UTIL_COLORS[1] },
  583 + { sec: s2, color: UTIL_COLORS[2] },
  584 + { sec: s3, color: UTIL_COLORS[3] }
  585 + ]
  586 + parts.forEach(p => {
  587 + const h = p.sec * scale
  588 + if (h <= 0) return
  589 + curY -= h
  590 + ctx.fillStyle = p.color
  591 + ctx.fillRect(px, curY, colW, h)
  592 + })
  593 +
  594 + ctx.fillStyle = '#64748b'
  595 + ctx.font = '9px sans-serif'
  596 + ctx.textAlign = 'center'
  597 + ctx.save()
  598 + ctx.translate(px + colW / 2, PAD_T + plotH + 12)
  599 + ctx.rotate(-Math.PI / 6)
  600 + const dn = (item.deviceName || item.dtuSn || '').slice(0, 8)
  601 + ctx.fillText(dn, 0, 0)
  602 + ctx.restore()
  603 +
  604 + stackRects.push({ x: px, y: PAD_T, w: colW, h: plotH, name: item.deviceName || item.dtuSn || '', s0, s1, s2, s3 })
  605 + })
  606 +}
  607 +
  608 +function onStackTap(e) {
  609 + const canvas = stackRef.value
  610 + if (!canvas) return
  611 + const rect = canvas.getBoundingClientRect()
  612 + const mx = e.clientX - rect.left
  613 + const my = e.clientY - rect.top
  614 + const hit = stackRects.find(r => mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h)
  615 + if (!hit) {
  616 + stackSelected.show = false
  617 + drawStackBar()
  618 + return
  619 + }
  620 + stackSelected.show = true
  621 + stackSelected.name = hit.name
  622 + stackSelected.s0 = hit.s0
  623 + stackSelected.s1 = hit.s1
  624 + stackSelected.s2 = hit.s2
  625 + stackSelected.s3 = hit.s3
  626 +}
  627 +
  628 +function drawAll() {
  629 + drawTotalPie()
  630 + drawStatusPie()
  631 + drawAbnormal()
  632 + drawStackBar()
  633 +}
  634 +
  635 +function redrawStackBar() {
  636 + nextTick(drawStackBar)
  637 +}
  638 +
  639 +let ro = null
  640 +onMounted(() => {
  641 + setDefaultDate()
  642 + fetchUtilData()
  643 + if ('ResizeObserver' in window) {
  644 + ro = new ResizeObserver(() => drawAll())
  645 + if (pieTotalWrapRef.value) ro.observe(pieTotalWrapRef.value)
  646 + if (pieStatusWrapRef.value) ro.observe(pieStatusWrapRef.value)
  647 + if (abnormalWrapRef.value) ro.observe(abnormalWrapRef.value)
  648 + if (stackWrapRef.value) ro.observe(stackWrapRef.value)
  649 + }
  650 +})
  651 +
  652 +onBeforeUnmount(() => {
  653 + if (ro) {
  654 + ro.disconnect()
  655 + ro = null
  656 + }
  657 +})
  658 +</script>
  659 +
  660 +<style scoped>
  661 +.util-h5 {
  662 + padding: 0;
  663 +}
  664 +
  665 +.toolbar {
  666 + display: flex;
  667 + align-items: center;
  668 + justify-content: space-between;
  669 + gap: 10px;
  670 +}
  671 +
  672 +.left {
  673 + display: flex;
  674 + align-items: center;
  675 + gap: 8px;
  676 + flex-wrap: wrap;
  677 +}
  678 +
  679 +.section {
  680 + background: #fff;
  681 + border-radius: 12px;
  682 + padding: 12px;
  683 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  684 + margin-top: 10px;
  685 +}
  686 +
  687 +.section-title {
  688 + font-weight: 700;
  689 + color: #0f172a;
  690 + font-size: 14px;
  691 + margin-bottom: 8px;
  692 +}
  693 +
  694 +.subline {
  695 + font-size: 12px;
  696 + color: #64748b;
  697 + margin-bottom: 8px;
  698 +}
  699 +
  700 +.legend {
  701 + display: grid;
  702 + grid-template-columns: repeat(2, minmax(0, 1fr));
  703 + gap: 8px;
  704 + margin-bottom: 8px;
  705 +}
  706 +
  707 +.legend-item {
  708 + display: grid;
  709 + grid-template-columns: 12px 1fr auto;
  710 + align-items: center;
  711 + gap: 6px;
  712 + font-size: 12px;
  713 + color: #475569;
  714 +}
  715 +
  716 +.legend-item .val {
  717 + justify-self: end;
  718 + color: #0f172a;
  719 + font-weight: 700;
  720 +}
  721 +
  722 +.legend-total {
  723 + display: grid;
  724 + grid-template-columns: repeat(2, minmax(0, 1fr));
  725 + gap: 10px 12px;
  726 + margin-bottom: 8px;
  727 +}
  728 +
  729 +.legend-total-item {
  730 + display: flex;
  731 + align-items: center;
  732 + justify-content: space-between;
  733 + gap: 10px;
  734 + min-width: 0;
  735 +}
  736 +
  737 +.lt-left {
  738 + display: inline-flex;
  739 + align-items: center;
  740 + gap: 8px;
  741 + min-width: 0;
  742 +}
  743 +
  744 +.lt-left .name {
  745 + font-size: 12px;
  746 + color: #0f172a;
  747 + font-weight: 600;
  748 + white-space: nowrap;
  749 +}
  750 +
  751 +.lt-right {
  752 + text-align: right;
  753 + min-width: 0;
  754 +}
  755 +
  756 +.legend-total-item .val {
  757 + font-size: 12px;
  758 + color: #0f172a;
  759 + font-weight: 800;
  760 + line-height: 1.15;
  761 + white-space: nowrap;
  762 +}
  763 +
  764 +.legend-total-item .pct {
  765 + font-size: 12px;
  766 + color: #64748b;
  767 + line-height: 1.15;
  768 + margin-top: 4px;
  769 + white-space: nowrap;
  770 +}
  771 +
  772 +.dot {
  773 + width: 10px;
  774 + height: 10px;
  775 + border-radius: 999px;
  776 + flex: 0 0 auto;
  777 +}
  778 +
  779 +.pie-wrap {
  780 + display: flex;
  781 + justify-content: center;
  782 +}
  783 +
  784 +.pie-canvas {
  785 + width: 100%;
  786 + max-width: 320px;
  787 + aspect-ratio: 1 / 1;
  788 + display: block;
  789 +}
  790 +
  791 +.chart-wrap {
  792 + width: 100%;
  793 + overflow: hidden;
  794 +}
  795 +
  796 +.chart-canvas {
  797 + width: 100%;
  798 + display: block;
  799 +}
  800 +
  801 +.stack-toolbar {
  802 + display: flex;
  803 + align-items: center;
  804 + justify-content: space-between;
  805 + gap: 10px;
  806 + margin-bottom: 10px;
  807 +}
  808 +
  809 +.stack-legend {
  810 + display: flex;
  811 + gap: 12px;
  812 + flex-wrap: wrap;
  813 + font-size: 12px;
  814 + color: #475569;
  815 + margin-bottom: 10px;
  816 +}
  817 +
  818 +.leg {
  819 + display: inline-flex;
  820 + align-items: center;
  821 + gap: 6px;
  822 +}
  823 +
  824 +.stack-scroll {
  825 + overflow-x: auto;
  826 + -webkit-overflow-scrolling: touch;
  827 +}
  828 +
  829 +.stack-wrap {
  830 + min-width: 100%;
  831 +}
  832 +
  833 +.stack-canvas {
  834 + height: 280px;
  835 + display: block;
  836 +}
  837 +
  838 +.tip-card {
  839 + margin-top: 10px;
  840 + border-radius: 12px;
  841 + padding: 10px 12px;
  842 + background: rgba(64, 158, 255, 0.06);
  843 + border: 1px solid rgba(64, 158, 255, 0.18);
  844 +}
  845 +
  846 +.tip-title {
  847 + font-weight: 700;
  848 + color: #0f172a;
  849 + margin-bottom: 6px;
  850 +}
  851 +
  852 +.tip-line {
  853 + display: flex;
  854 + align-items: center;
  855 + gap: 6px;
  856 + font-size: 12px;
  857 + color: #334155;
  858 + line-height: 1.6;
  859 +}
  860 +
  861 +.tip-line i {
  862 + width: 10px;
  863 + height: 10px;
  864 + border-radius: 999px;
  865 + display: inline-block;
  866 +}
  867 +</style>
  1 +<template>
  2 + <div class="smart-light-h5-realtime">
  3 + <div class="h5-search">
  4 + <el-input
  5 + v-model="searchKeyword"
  6 + placeholder="输入设备名称搜索"
  7 + clearable
  8 + @keyup.enter="onSearch"
  9 + @clear="onSearch"
  10 + >
  11 + <template #append>
  12 + <el-button :loading="loading" @click="onSearch">搜索</el-button>
  13 + </template>
  14 + </el-input>
  15 + </div>
  16 +
  17 + <div class="h5-filters">
  18 + <button
  19 + v-for="item in filterItems"
  20 + :key="item.key"
  21 + :class="['filter-chip', { active: lampStateFilter === item.key }]"
  22 + type="button"
  23 + @click="onFilter(item.key)"
  24 + >
  25 + <span class="dot" :style="{ backgroundColor: item.dotColor }"></span>
  26 + <span class="label">{{ item.label }}</span>
  27 + <span class="count">{{ item.count }}</span>
  28 + </button>
  29 + </div>
  30 +
  31 + <el-empty v-if="!loading && deviceList.length === 0" description="暂无设备" />
  32 +
  33 + <div v-for="device in deviceList" :key="device.id" class="device-card">
  34 + <div class="card-top">
  35 + <div class="device-name">{{ device.name }}</div>
  36 + <div class="device-status">
  37 + <span class="status-dot" :style="{ backgroundColor: device.statusColor }"></span>
  38 + <span class="status-text">{{ device.statusLabel }}</span>
  39 + </div>
  40 + </div>
  41 +
  42 + <div class="card-body">
  43 + <div class="row">
  44 + <div class="k">稼动率</div>
  45 + <div class="v">{{ device.utilization }}%</div>
  46 + </div>
  47 + <div class="row">
  48 + <div class="k">{{ device.statusLabel }}时长</div>
  49 + <div class="v">{{ device.lightTime }}</div>
  50 + </div>
  51 + </div>
  52 +
  53 + <div class="card-actions">
  54 + <el-button size="small" @click="goOeeTimeline(device)">OEE时序</el-button>
  55 + <el-button size="small" type="primary" @click="goUtilization(device)">稼动率</el-button>
  56 + </div>
  57 + </div>
  58 +
  59 + <div class="h5-footer">
  60 + <el-button
  61 + v-if="canLoadMore"
  62 + type="primary"
  63 + :loading="loadingMore"
  64 + style="width: 100%;"
  65 + @click="loadMore"
  66 + >
  67 + 加载更多
  68 + </el-button>
  69 + <div v-else-if="deviceList.length > 0" class="no-more">没有更多了</div>
  70 + </div>
  71 + </div>
  72 +</template>
  73 +
  74 +<script setup>
  75 +import { computed, onMounted, reactive, ref } from 'vue'
  76 +import { useRoute, useRouter } from 'vue-router'
  77 +import { ElMessage } from 'element-plus'
  78 +import { apiFetch } from '../../config/api.js'
  79 +
  80 +const route = useRoute()
  81 +const router = useRouter()
  82 +
  83 +const searchKeyword = ref('')
  84 +const lampStateFilter = ref('')
  85 +const currentPage = ref(1)
  86 +const PAGE_SIZE = 20
  87 +
  88 +const deviceList = ref([])
  89 +const totalDevices = ref(0)
  90 +const loading = ref(false)
  91 +const loadingMore = ref(false)
  92 +
  93 +const totalCounts = reactive({ all: 0, red: 0, yellow: 0, green: 0, blue: 0, gray: 0 })
  94 +const canLoadMore = computed(() => deviceList.value.length < totalDevices.value)
  95 +
  96 +function mapLampStatus(lampState) {
  97 + if (lampState === '3') return 'green'
  98 + if (lampState === '1') return 'red'
  99 + if (lampState === '2') return 'yellow'
  100 + if (lampState === '4') return 'blue'
  101 + return 'gray'
  102 +}
  103 +
  104 +const LAMP_LABEL_MAP = { green: '绿灯', yellow: '黄灯', red: '红灯', blue: '蓝灯', gray: '灭灯' }
  105 +const LAMP_COLOR_MAP = { green: '#2ecc71', yellow: '#f1c40f', red: '#e74c3c', blue: '#3498db', gray: '#95a5a6' }
  106 +
  107 +function toDeviceViewModel(item) {
  108 + const status = mapLampStatus(item.lampState)
  109 + return {
  110 + id: item.id,
  111 + name: item.deviceName || item.dtuSn,
  112 + dtuSn: item.dtuSn || item.deviceSn || item.sn || '',
  113 + utilization: parseFloat(item.utilizationRate) || 0,
  114 + lightTime: item.duration || '0分',
  115 + statusLabel: LAMP_LABEL_MAP[status] || '灭灯',
  116 + statusColor: LAMP_COLOR_MAP[status] || '#95a5a6',
  117 + _raw: item
  118 + }
  119 +}
  120 +
  121 +const filterItems = computed(() => [
  122 + { key: '', label: '全部', count: `${totalCounts.all}台`, dotColor: '#2c3e50' },
  123 + { key: '1', label: '红', count: `${totalCounts.red}台`, dotColor: LAMP_COLOR_MAP.red },
  124 + { key: '2', label: '黄', count: `${totalCounts.yellow}台`, dotColor: LAMP_COLOR_MAP.yellow },
  125 + { key: '3', label: '绿', count: `${totalCounts.green}台`, dotColor: LAMP_COLOR_MAP.green },
  126 + { key: '4', label: '蓝', count: `${totalCounts.blue}台`, dotColor: LAMP_COLOR_MAP.blue },
  127 + { key: '0', label: '灭', count: `${totalCounts.gray}台`, dotColor: LAMP_COLOR_MAP.gray }
  128 +])
  129 +
  130 +async function fetchStats() {
  131 + const res = await apiFetch('/api/device/stats')
  132 + if (!res.ok) throw new Error(`HTTP ${res.status}`)
  133 + const data = await res.json()
  134 + totalCounts.all = data.all || 0
  135 + totalCounts.red = data.red || 0
  136 + totalCounts.yellow = data.yellow || 0
  137 + totalCounts.green = data.green || 0
  138 + totalCounts.blue = data.blue || 0
  139 + totalCounts.gray = data.off || 0
  140 +}
  141 +
  142 +async function fetchDeviceList({ append }) {
  143 + const params = new URLSearchParams({
  144 + pageNo: String(currentPage.value),
  145 + pageSize: String(PAGE_SIZE)
  146 + })
  147 + if (searchKeyword.value) params.append('deviceName', searchKeyword.value)
  148 + if (lampStateFilter.value) params.append('lampState', lampStateFilter.value)
  149 +
  150 + const res = await apiFetch(`/api/device/list?${params}`)
  151 + if (!res.ok) throw new Error(`HTTP ${res.status}`)
  152 + const data = await res.json()
  153 +
  154 + const mapped = (data.list || []).map(toDeviceViewModel)
  155 + deviceList.value = append ? deviceList.value.concat(mapped) : mapped
  156 + totalDevices.value = data.total || 0
  157 +}
  158 +
  159 +async function refresh({ append }) {
  160 + try {
  161 + if (append) loadingMore.value = true
  162 + else loading.value = true
  163 +
  164 + await Promise.all([fetchDeviceList({ append }), fetchStats()])
  165 + } catch (e) {
  166 + ElMessage.error('获取数据失败,请检查后端接口或代理配置')
  167 + console.warn(e)
  168 + } finally {
  169 + loading.value = false
  170 + loadingMore.value = false
  171 + }
  172 +}
  173 +
  174 +function onSearch() {
  175 + currentPage.value = 1
  176 + refresh({ append: false })
  177 +}
  178 +
  179 +function onFilter(key) {
  180 + if (lampStateFilter.value === key) return
  181 + lampStateFilter.value = key
  182 + currentPage.value = 1
  183 + refresh({ append: false })
  184 +}
  185 +
  186 +function loadMore() {
  187 + if (!canLoadMore.value || loading.value || loadingMore.value) return
  188 + currentPage.value += 1
  189 + refresh({ append: true })
  190 +}
  191 +
  192 +function goOeeTimeline(device) {
  193 + router.push({
  194 + path: '/smart-light-h5/oee',
  195 + query: { ...route.query, dtuSn: device.dtuSn || '', deviceName: device.name || '' }
  196 + })
  197 +}
  198 +
  199 +function goUtilization(device) {
  200 + router.push({
  201 + path: '/smart-light-h5/utilization',
  202 + query: { ...route.query, dtuSn: device.dtuSn || '', deviceName: device.name || '' }
  203 + })
  204 +}
  205 +
  206 +onMounted(() => {
  207 + refresh({ append: false })
  208 +})
  209 +</script>
  210 +
  211 +<style scoped>
  212 +.h5-search :deep(.el-input__wrapper) {
  213 + border-radius: 10px;
  214 +}
  215 +
  216 +.h5-filters {
  217 + display: flex;
  218 + gap: 8px;
  219 + overflow-x: auto;
  220 + padding-top: 10px;
  221 + -webkit-overflow-scrolling: touch;
  222 +}
  223 +
  224 +.filter-chip {
  225 + display: inline-flex;
  226 + align-items: center;
  227 + gap: 6px;
  228 + border: 1px solid rgba(0, 0, 0, 0.08);
  229 + background: #fff;
  230 + border-radius: 999px;
  231 + padding: 6px 10px;
  232 + font-size: 12px;
  233 + color: #334155;
  234 + white-space: nowrap;
  235 +}
  236 +
  237 +.filter-chip.active {
  238 + border-color: rgba(64, 158, 255, 0.45);
  239 + background: rgba(64, 158, 255, 0.08);
  240 + color: #1d4ed8;
  241 +}
  242 +
  243 +.filter-chip .dot {
  244 + width: 8px;
  245 + height: 8px;
  246 + border-radius: 999px;
  247 + flex: 0 0 auto;
  248 +}
  249 +
  250 +.filter-chip .count {
  251 + opacity: 0.75;
  252 +}
  253 +
  254 +.device-card {
  255 + background: #fff;
  256 + border-radius: 12px;
  257 + padding: 12px;
  258 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  259 + margin-top: 10px;
  260 +}
  261 +
  262 +.card-top {
  263 + display: flex;
  264 + align-items: flex-start;
  265 + justify-content: space-between;
  266 + gap: 10px;
  267 +}
  268 +
  269 +.device-name {
  270 + font-weight: 700;
  271 + color: #0f172a;
  272 + font-size: 14px;
  273 + line-height: 1.25;
  274 + word-break: break-all;
  275 +}
  276 +
  277 +.device-status {
  278 + display: inline-flex;
  279 + align-items: center;
  280 + gap: 6px;
  281 + font-size: 12px;
  282 + color: #334155;
  283 + flex: 0 0 auto;
  284 +}
  285 +
  286 +.status-dot {
  287 + width: 10px;
  288 + height: 10px;
  289 + border-radius: 999px;
  290 +}
  291 +
  292 +.card-body {
  293 + margin-top: 10px;
  294 + display: grid;
  295 + gap: 6px;
  296 +}
  297 +
  298 +.card-actions {
  299 + margin-top: 10px;
  300 + display: flex;
  301 + gap: 10px;
  302 +}
  303 +
  304 +.card-actions :deep(.el-button) {
  305 + flex: 1 1 auto;
  306 +}
  307 +
  308 +.row {
  309 + display: flex;
  310 + align-items: center;
  311 + justify-content: space-between;
  312 + gap: 12px;
  313 + font-size: 13px;
  314 +}
  315 +
  316 +.row .k {
  317 + color: #64748b;
  318 +}
  319 +
  320 +.row .v {
  321 + color: #0f172a;
  322 + font-weight: 600;
  323 +}
  324 +
  325 +.h5-footer {
  326 + padding: 10px 0 18px;
  327 +}
  328 +
  329 +.no-more {
  330 + text-align: center;
  331 + color: #94a3b8;
  332 + font-size: 12px;
  333 + padding: 8px 0;
  334 +}
  335 +</style>
  1 +<template>
  2 + <div class="startup-h5">
  3 + <div class="toolbar">
  4 + <div class="left">
  5 + <el-radio-group v-model="queryMode" size="small" @change="onModeChange">
  6 + <el-radio-button value="day">日查询</el-radio-button>
  7 + <el-radio-button value="week">周查询</el-radio-button>
  8 + <el-radio-button value="month">月查询</el-radio-button>
  9 + </el-radio-group>
  10 + </div>
  11 + <el-button type="primary" size="small" :loading="loading" @click="fetchData">查询</el-button>
  12 + </div>
  13 +
  14 + <div class="toolbar" style="margin-top: 8px;">
  15 + <div class="left">
  16 + <el-date-picker
  17 + v-if="queryMode === 'day'"
  18 + v-model="dayDate"
  19 + type="date"
  20 + placeholder="选择日期"
  21 + size="small"
  22 + value-format="YYYY-MM-DD"
  23 + :disabled-date="disabledDateFuture"
  24 + style="width: 160px;"
  25 + @change="fetchData"
  26 + />
  27 + <el-date-picker
  28 + v-else-if="queryMode === 'week'"
  29 + v-model="weekDate"
  30 + type="date"
  31 + :format="weekDisplayFormat"
  32 + placeholder="选择周"
  33 + size="small"
  34 + value-format="YYYY-MM-DD"
  35 + :disabled-date="disableNonMonday"
  36 + style="width: 160px;"
  37 + @change="fetchData"
  38 + />
  39 + <el-date-picker
  40 + v-else
  41 + v-model="monthDate"
  42 + type="month"
  43 + placeholder="选择月份"
  44 + size="small"
  45 + value-format="YYYY-MM"
  46 + style="width: 140px;"
  47 + @change="fetchData"
  48 + />
  49 + </div>
  50 + </div>
  51 +
  52 + <div class="section">
  53 + <div class="section-title">概览</div>
  54 + <div class="summary-grid">
  55 + <div class="summary-item">
  56 + <div class="k">设备数</div>
  57 + <div class="v">{{ summary.totalDevices || 0 }}</div>
  58 + </div>
  59 + <div class="summary-item">
  60 + <div class="k">整体开机率</div>
  61 + <div class="v">{{ summary.overallBootRate || '0%' }}</div>
  62 + </div>
  63 + <div class="summary-item">
  64 + <div class="k">总时长</div>
  65 + <div class="v">{{ summary.totalDuration || '-' }}</div>
  66 + </div>
  67 + <div class="summary-item">
  68 + <div class="k">开机时长</div>
  69 + <div class="v">{{ summary.onDuration || '-' }}</div>
  70 + </div>
  71 + <div class="summary-item">
  72 + <div class="k">关机时长</div>
  73 + <div class="v">{{ summary.offDuration || '-' }}</div>
  74 + </div>
  75 + <div class="summary-item">
  76 + <div class="k">统计天数</div>
  77 + <div class="v">{{ summary.dataDays || 0 }}</div>
  78 + </div>
  79 + </div>
  80 + </div>
  81 +
  82 + <div class="section">
  83 + <div class="section-title">开机率</div>
  84 + <div class="subline"><i class="dot" style="background:#67c23a"></i>开机率</div>
  85 + <div class="chart-scroll">
  86 + <div ref="chartWrapRef" class="chart-wrap">
  87 + <canvas
  88 + ref="canvasRef"
  89 + class="chart-canvas"
  90 + @pointerdown="onCanvasTap"
  91 + @pointermove="onCanvasMove"
  92 + @pointerleave="onCanvasLeave"
  93 + ></canvas>
  94 + <div v-if="loading" class="loading-overlay"><div class="spinner"></div></div>
  95 + </div>
  96 + </div>
  97 +
  98 + <div v-if="selected.show" class="tip-card">
  99 + <div class="tip-title">{{ selected.name }}</div>
  100 + <div class="tip-line"><i style="background:#67c23a"></i>开机率 {{ selected.bootRate }}</div>
  101 + <div class="tip-line"><i style="background:#91cc75"></i>开机时长 {{ selected.onDuration }}</div>
  102 + <div class="tip-line"><i style="background:#909399"></i>关机时长 {{ selected.offDuration }}</div>
  103 + <div class="tip-line">总时长 {{ selected.totalDuration }}</div>
  104 + </div>
  105 +
  106 + <el-empty v-if="!loading && list.length === 0" description="暂无数据" />
  107 + </div>
  108 + </div>
  109 +</template>
  110 +
  111 +<script setup>
  112 +import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
  113 +import { ElMessage } from 'element-plus'
  114 +import { apiFetch } from '../../config/api.js'
  115 +
  116 +const queryMode = ref('day')
  117 +const dayDate = ref('')
  118 +const weekDate = ref('')
  119 +const monthDate = ref('')
  120 +const loading = ref(false)
  121 +
  122 +const list = ref([])
  123 +const summary = reactive({
  124 + totalDevices: 0,
  125 + overallBootRate: '0%',
  126 + totalDuration: '',
  127 + onDuration: '',
  128 + offDuration: '',
  129 + dataDays: 0
  130 +})
  131 +
  132 +const selected = reactive({
  133 + show: false,
  134 + name: '',
  135 + bootRate: '',
  136 + onDuration: '',
  137 + offDuration: '',
  138 + totalDuration: ''
  139 +})
  140 +
  141 +function disabledDateFuture(time) {
  142 + return time.getTime() > Date.now()
  143 +}
  144 +
  145 +function formatYMD(d) {
  146 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  147 +}
  148 +
  149 +function getWeekNumber(date) {
  150 + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
  151 + const dayNum = d.getUTCDay() || 7
  152 + d.setUTCDate(d.getUTCDate() + 4 - dayNum)
  153 + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
  154 + return Math.ceil(((d - yearStart) / 86400000 + 1) / 7)
  155 +}
  156 +
  157 +const weekDisplayFormat = computed(() => {
  158 + if (!weekDate.value) return ''
  159 + const d = new Date(weekDate.value)
  160 + return `${d.getFullYear()}-${String(getWeekNumber(d)).padStart(2, '0')}周`
  161 +})
  162 +
  163 +function getCurrentMonday() {
  164 + const today = new Date()
  165 + const day = today.getDay() || 7
  166 + today.setDate(today.getDate() - day + 1)
  167 + return formatYMD(today)
  168 +}
  169 +
  170 +function getCurrentYM() {
  171 + const now = new Date()
  172 + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
  173 +}
  174 +
  175 +function getWeekRange(dateStr) {
  176 + if (!dateStr) return { start: '', end: '' }
  177 + const d = new Date(dateStr)
  178 + const day = d.getDay() || 7
  179 + const diff = d.getDate() - day + 1
  180 + const monday = new Date(d.setDate(diff))
  181 + const sunday = new Date(monday)
  182 + sunday.setDate(monday.getDate() + 6)
  183 + return { start: formatYMD(monday), end: formatYMD(sunday) }
  184 +}
  185 +
  186 +function getMonthRange(ymStr) {
  187 + if (!ymStr) return { start: '', end: '' }
  188 + const [y, m] = ymStr.split('-').map(Number)
  189 + const lastDay = new Date(y, m, 0).getDate()
  190 + return { start: `${ymStr}-01`, end: `${ymStr}-${String(lastDay).padStart(2, '0')}` }
  191 +}
  192 +
  193 +function disableNonMonday(date) {
  194 + return date.getDay() !== 1
  195 +}
  196 +
  197 +function setDefaultDate() {
  198 + if (queryMode.value === 'day') {
  199 + if (!dayDate.value) dayDate.value = formatYMD(new Date())
  200 + } else if (queryMode.value === 'week') {
  201 + if (!weekDate.value) weekDate.value = getCurrentMonday()
  202 + } else {
  203 + if (!monthDate.value) monthDate.value = getCurrentYM()
  204 + }
  205 +}
  206 +
  207 +function onModeChange() {
  208 + setDefaultDate()
  209 + fetchData()
  210 +}
  211 +
  212 +function getDateRange() {
  213 + if (queryMode.value === 'day') {
  214 + if (!dayDate.value) return { startDate: '', endDate: '' }
  215 + return { startDate: dayDate.value, endDate: dayDate.value }
  216 + }
  217 + if (queryMode.value === 'week') {
  218 + if (!weekDate.value) return { startDate: '', endDate: '' }
  219 + const range = getWeekRange(weekDate.value)
  220 + return { startDate: range.start, endDate: range.end }
  221 + }
  222 + if (!monthDate.value) return { startDate: '', endDate: '' }
  223 + const range = getMonthRange(monthDate.value)
  224 + return { startDate: range.start, endDate: range.end }
  225 +}
  226 +
  227 +const canvasRef = ref(null)
  228 +const chartWrapRef = ref(null)
  229 +let bars = []
  230 +let hitIdx = -1
  231 +const dpr = window.devicePixelRatio || 1
  232 +
  233 +function roundRect(ctx, x, y, w, h, r) {
  234 + r = Math.min(r, w / 2, h / 2)
  235 + ctx.beginPath()
  236 + ctx.moveTo(x + r, y)
  237 + ctx.lineTo(x + w - r, y)
  238 + ctx.arcTo(x + w, y, x + w, y + r, r)
  239 + ctx.lineTo(x + w, y + h - r)
  240 + ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
  241 + ctx.lineTo(x + r, y + h)
  242 + ctx.arcTo(x, y + h, x, y + h - r, r)
  243 + ctx.lineTo(x, y + r)
  244 + ctx.arcTo(x, y, x + r, y, r)
  245 + ctx.closePath()
  246 +}
  247 +
  248 +function lightenColor(hex, amount) {
  249 + let r = parseInt(hex.slice(1, 3), 16)
  250 + let g = parseInt(hex.slice(3, 5), 16)
  251 + let b = parseInt(hex.slice(5, 7), 16)
  252 + r = Math.min(255, r + amount)
  253 + g = Math.min(255, g + amount)
  254 + b = Math.min(255, b + amount)
  255 + return `rgb(${r},${g},${b})`
  256 +}
  257 +
  258 +function setupCanvas(canvas, cssW, cssH) {
  259 + canvas.style.width = cssW + 'px'
  260 + canvas.style.height = cssH + 'px'
  261 + canvas.width = Math.round(cssW * dpr)
  262 + canvas.height = Math.round(cssH * dpr)
  263 + const ctx = canvas.getContext('2d')
  264 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  265 + return { ctx }
  266 +}
  267 +
  268 +function drawChart() {
  269 + const canvas = canvasRef.value
  270 + const wrap = chartWrapRef.value
  271 + if (!canvas || !wrap) return
  272 +
  273 + const data = list.value || []
  274 + const PAD_L = 40
  275 + const PAD_R = 16
  276 + const PAD_T = 18
  277 + const PAD_B = 46
  278 + const colW = 18
  279 + const gap = 10
  280 + const cssH = 320
  281 + const minW = Math.max(320, wrap.clientWidth)
  282 + const cssW = Math.max(minW, PAD_L + PAD_R + data.length * (colW + gap) + gap)
  283 + const { ctx } = setupCanvas(canvas, cssW, cssH)
  284 + ctx.clearRect(0, 0, cssW, cssH)
  285 +
  286 + if (data.length === 0) return
  287 +
  288 + const plotW = cssW - PAD_L - PAD_R
  289 + const plotH = cssH - PAD_T - PAD_B
  290 +
  291 + ctx.strokeStyle = '#eee'
  292 + ctx.lineWidth = 1
  293 + ctx.fillStyle = '#94a3b8'
  294 + ctx.font = '10px sans-serif'
  295 + ctx.textAlign = 'end'
  296 +
  297 + for (let i = 0; i <= 5; i++) {
  298 + const val = i * 20
  299 + const py = PAD_T + plotH * (1 - val / 100)
  300 + ctx.fillText(val + '%', PAD_L - 4, py + 3)
  301 + if (i > 0) {
  302 + ctx.beginPath()
  303 + ctx.moveTo(PAD_L, py)
  304 + ctx.lineTo(cssW - PAD_R, py)
  305 + ctx.stroke()
  306 + }
  307 + }
  308 +
  309 + ctx.strokeStyle = '#ddd'
  310 + ctx.lineWidth = 1.5
  311 + ctx.beginPath()
  312 + ctx.moveTo(PAD_L, PAD_T + plotH)
  313 + ctx.lineTo(cssW - PAD_R, PAD_T + plotH)
  314 + ctx.stroke()
  315 +
  316 + data.forEach((item, idx) => {
  317 + const px = PAD_L + gap + idx * (colW + gap)
  318 + ctx.fillStyle = '#f2f3f5'
  319 + roundRect(ctx, px, PAD_T, colW, plotH, 3)
  320 + ctx.fill()
  321 + })
  322 +
  323 + bars = []
  324 + data.forEach((item, idx) => {
  325 + const px = PAD_L + gap + idx * (colW + gap)
  326 + const rate = Number(item.bootRateValue || 0)
  327 + const barH = (rate / 100) * plotH
  328 + const barY = PAD_T + plotH - barH
  329 +
  330 + let color = '#67c23a'
  331 + if (rate >= 95) color = '#4caf50'
  332 + else if (rate < 50) color = '#91cc75'
  333 +
  334 + const isHovered = idx === hitIdx
  335 + if (isHovered) {
  336 + ctx.save()
  337 + ctx.shadowColor = 'rgba(103,194,58,0.45)'
  338 + ctx.shadowBlur = 14
  339 + ctx.shadowOffsetY = 3
  340 + ctx.fillStyle = lightenColor(color, 26)
  341 + const hoverPad = 2
  342 + roundRect(ctx, px - hoverPad, barY - hoverPad, colW + hoverPad * 2, barH + hoverPad * 2, 4)
  343 + ctx.fill()
  344 + ctx.restore()
  345 + ctx.strokeStyle = '#3d8b3d'
  346 + ctx.lineWidth = 1.2
  347 + roundRect(ctx, px - hoverPad, barY - hoverPad, colW + hoverPad * 2, barH + hoverPad * 2, 4)
  348 + ctx.stroke()
  349 + } else {
  350 + roundRect(ctx, px, barY, colW, barH, 3)
  351 + ctx.fillStyle = color
  352 + ctx.fill()
  353 + }
  354 +
  355 + ctx.fillStyle = '#64748b'
  356 + ctx.font = '9px sans-serif'
  357 + ctx.textAlign = 'center'
  358 + ctx.save()
  359 + ctx.translate(px + colW / 2, PAD_T + plotH + 12)
  360 + ctx.rotate(-Math.PI / 6)
  361 + const dn = (item.deviceName || item.dtuSn || '').slice(0, 8)
  362 + ctx.fillText(dn, 0, 0)
  363 + ctx.restore()
  364 +
  365 + bars.push({
  366 + x: px - 2,
  367 + y: PAD_T,
  368 + w: colW + 4,
  369 + h: plotH,
  370 + name: item.deviceName || item.dtuSn,
  371 + bootRate: item.bootRate,
  372 + onDuration: item.onDuration,
  373 + offDuration: item.offDuration,
  374 + totalDuration: item.totalDuration
  375 + })
  376 + })
  377 +}
  378 +
  379 +function hitTest(clientX, clientY) {
  380 + const canvas = canvasRef.value
  381 + if (!canvas) return -1
  382 + const rect = canvas.getBoundingClientRect()
  383 + const mx = clientX - rect.left
  384 + const my = clientY - rect.top
  385 + for (let i = 0; i < bars.length; i++) {
  386 + const b = bars[i]
  387 + if (mx >= b.x && mx <= b.x + b.w && my >= b.y && my <= b.y + b.h) return i
  388 + }
  389 + return -1
  390 +}
  391 +
  392 +function applySelection(idx) {
  393 + if (idx < 0) {
  394 + selected.show = false
  395 + hitIdx = -1
  396 + drawChart()
  397 + return
  398 + }
  399 + const b = bars[idx]
  400 + selected.name = b.name
  401 + selected.bootRate = b.bootRate
  402 + selected.onDuration = b.onDuration
  403 + selected.offDuration = b.offDuration
  404 + selected.totalDuration = b.totalDuration
  405 + selected.show = true
  406 + hitIdx = idx
  407 + drawChart()
  408 +}
  409 +
  410 +function onCanvasTap(e) {
  411 + const idx = hitTest(e.clientX, e.clientY)
  412 + applySelection(idx)
  413 +}
  414 +
  415 +function onCanvasMove(e) {
  416 + if (window.matchMedia && window.matchMedia('(hover: hover)').matches) {
  417 + const idx = hitTest(e.clientX, e.clientY)
  418 + if (idx !== hitIdx) applySelection(idx)
  419 + }
  420 +}
  421 +
  422 +function onCanvasLeave() {
  423 + if (window.matchMedia && window.matchMedia('(hover: hover)').matches) {
  424 + applySelection(-1)
  425 + }
  426 +}
  427 +
  428 +async function fetchData() {
  429 + const { startDate, endDate } = getDateRange()
  430 + if (!startDate || !endDate) {
  431 + ElMessage.warning('请选择查询时间')
  432 + return
  433 + }
  434 + loading.value = true
  435 + try {
  436 + const res = await apiFetch(`/api/device/bootRate?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`)
  437 + const data = await res.json()
  438 + list.value = data.list || []
  439 + if (data.summary) Object.assign(summary, data.summary)
  440 + selected.show = false
  441 + hitIdx = -1
  442 + await nextTick()
  443 + drawChart()
  444 + } catch (e) {
  445 + ElMessage.error('获取数据失败')
  446 + console.warn(e)
  447 + } finally {
  448 + loading.value = false
  449 + }
  450 +}
  451 +
  452 +let ro = null
  453 +onMounted(() => {
  454 + setDefaultDate()
  455 + fetchData()
  456 + if (chartWrapRef.value && 'ResizeObserver' in window) {
  457 + ro = new ResizeObserver(() => drawChart())
  458 + ro.observe(chartWrapRef.value)
  459 + }
  460 +})
  461 +
  462 +onBeforeUnmount(() => {
  463 + if (ro) {
  464 + ro.disconnect()
  465 + ro = null
  466 + }
  467 +})
  468 +</script>
  469 +
  470 +<style scoped>
  471 +.startup-h5 {
  472 + padding: 0;
  473 +}
  474 +
  475 +.toolbar {
  476 + display: flex;
  477 + align-items: center;
  478 + justify-content: space-between;
  479 + gap: 10px;
  480 +}
  481 +
  482 +.left {
  483 + display: flex;
  484 + align-items: center;
  485 + gap: 8px;
  486 + flex-wrap: wrap;
  487 +}
  488 +
  489 +.section {
  490 + background: #fff;
  491 + border-radius: 12px;
  492 + padding: 12px;
  493 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  494 + margin-top: 10px;
  495 +}
  496 +
  497 +.section-title {
  498 + font-weight: 700;
  499 + color: #0f172a;
  500 + font-size: 14px;
  501 + margin-bottom: 8px;
  502 +}
  503 +
  504 +.subline {
  505 + display: inline-flex;
  506 + align-items: center;
  507 + gap: 6px;
  508 + font-size: 12px;
  509 + color: #475569;
  510 + margin-bottom: 8px;
  511 +}
  512 +
  513 +.dot {
  514 + width: 10px;
  515 + height: 10px;
  516 + border-radius: 999px;
  517 +}
  518 +
  519 +.summary-grid {
  520 + display: grid;
  521 + grid-template-columns: repeat(3, minmax(0, 1fr));
  522 + gap: 8px;
  523 +}
  524 +
  525 +.summary-item {
  526 + background: #f8fafc;
  527 + border: 1px solid rgba(0, 0, 0, 0.06);
  528 + border-radius: 10px;
  529 + padding: 10px;
  530 + min-width: 0;
  531 +}
  532 +
  533 +.summary-item .k {
  534 + font-size: 12px;
  535 + color: #64748b;
  536 + line-height: 1.1;
  537 +}
  538 +
  539 +.summary-item .v {
  540 + margin-top: 6px;
  541 + font-size: 13px;
  542 + font-weight: 700;
  543 + color: #0f172a;
  544 + white-space: nowrap;
  545 + overflow: hidden;
  546 + text-overflow: ellipsis;
  547 +}
  548 +
  549 +.chart-scroll {
  550 + overflow-x: auto;
  551 + -webkit-overflow-scrolling: touch;
  552 +}
  553 +
  554 +.chart-wrap {
  555 + position: relative;
  556 + min-width: 100%;
  557 +}
  558 +
  559 +.chart-canvas {
  560 + height: 320px;
  561 + display: block;
  562 +}
  563 +
  564 +.loading-overlay {
  565 + position: absolute;
  566 + inset: 0;
  567 + background: rgba(255, 255, 255, 0.6);
  568 + display: grid;
  569 + place-items: center;
  570 +}
  571 +
  572 +.spinner {
  573 + width: 22px;
  574 + height: 22px;
  575 + border-radius: 999px;
  576 + border: 2px solid rgba(0, 0, 0, 0.1);
  577 + border-top-color: rgba(64, 158, 255, 0.9);
  578 + animation: spin 0.9s linear infinite;
  579 +}
  580 +
  581 +@keyframes spin {
  582 + from {
  583 + transform: rotate(0deg);
  584 + }
  585 + to {
  586 + transform: rotate(360deg);
  587 + }
  588 +}
  589 +
  590 +.tip-card {
  591 + margin-top: 10px;
  592 + border-radius: 12px;
  593 + padding: 10px 12px;
  594 + background: rgba(64, 158, 255, 0.06);
  595 + border: 1px solid rgba(64, 158, 255, 0.18);
  596 +}
  597 +
  598 +.tip-title {
  599 + font-weight: 700;
  600 + color: #0f172a;
  601 + margin-bottom: 6px;
  602 +}
  603 +
  604 +.tip-line {
  605 + display: flex;
  606 + align-items: center;
  607 + gap: 6px;
  608 + font-size: 12px;
  609 + color: #334155;
  610 + line-height: 1.5;
  611 +}
  612 +
  613 +.tip-line i {
  614 + width: 10px;
  615 + height: 10px;
  616 + border-radius: 999px;
  617 + display: inline-block;
  618 +}
  619 +</style>
  1 +<template>
  2 + <div class="ts-h5">
  3 + <div class="toolbar">
  4 + <div class="left">
  5 + <div class="mode">日查询</div>
  6 + <el-date-picker
  7 + v-model="tsDate"
  8 + type="date"
  9 + value-format="YYYY-MM-DD"
  10 + placeholder="选择日期"
  11 + size="small"
  12 + style="width: 150px;"
  13 + :disabled-date="disabledDate"
  14 + @change="onDateChange"
  15 + />
  16 + </div>
  17 + <el-button type="primary" size="small" :loading="loading" @click="resetAndFetch">查询</el-button>
  18 + </div>
  19 +
  20 + <div class="canvas-card">
  21 + <div
  22 + ref="ganttContainerRef"
  23 + class="gantt-wrap"
  24 + @wheel.prevent="onGanttWheel"
  25 + @pointerdown="onPointerDown"
  26 + @pointermove="onPointerMove"
  27 + @pointerup="onPointerUp"
  28 + @pointercancel="onPointerUp"
  29 + @pointerleave="onPointerLeave"
  30 + >
  31 + <canvas ref="ganttCanvasRef" class="gantt-canvas"></canvas>
  32 + <div
  33 + v-if="ganttHoveredSeg"
  34 + class="gantt-tooltip"
  35 + :style="{ left: ganttTooltipPos.x + 'px', top: ganttTooltipPos.y + 'px' }"
  36 + >
  37 + <div class="gtt-row">
  38 + <span class="gtt-dot" :style="{ background: getLampColor(ganttHoveredSeg.lampState) }"></span>
  39 + {{ getLampLabelName(ganttHoveredSeg.lampState) }}: {{ formatDuration(ganttHoveredSeg.duration) }}
  40 + </div>
  41 + <div class="gtt-sub">{{ formatTimeRange(ganttHoveredSeg.startTime, ganttHoveredSeg.endTime) }}</div>
  42 + </div>
  43 + </div>
  44 +
  45 + <el-empty v-if="!loading && timeSeriesData.length === 0" description="暂无数据" />
  46 +
  47 + <div v-if="timeSeriesData.length > 0" class="load-more">
  48 + <div ref="sentinelRef" class="load-sentinel"></div>
  49 + <div v-if="loadingMore" class="load-text">加载中...</div>
  50 + <div v-else-if="!loading && !hasMore" class="load-text">没有更多了</div>
  51 + </div>
  52 + </div>
  53 + </div>
  54 +</template>
  55 +
  56 +<script setup>
  57 +import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue'
  58 +import { ElMessage } from 'element-plus'
  59 +import { apiFetch } from '../../config/api.js'
  60 +
  61 +const tsDate = ref('')
  62 +const loading = ref(false)
  63 +const loadingMore = ref(false)
  64 +const tsPageNo = ref(0)
  65 +const tsPageSize = 12
  66 +const timeSeriesData = ref([])
  67 +const hasMore = ref(true)
  68 +
  69 +const canLoadMore = computed(() => timeSeriesData.value.length > 0 && hasMore.value)
  70 +
  71 +function disabledDate(time) {
  72 + return time.getTime() > Date.now()
  73 +}
  74 +
  75 +function onDateChange() {
  76 + resetAndFetch()
  77 +}
  78 +
  79 +function getLampLabelName(lampState) {
  80 + if (lampState === 3 || lampState === '3') return '绿灯'
  81 + if (lampState === 2 || lampState === '2') return '黄灯'
  82 + if (lampState === 1 || lampState === '1') return '红灯'
  83 + return '灭灯'
  84 +}
  85 +
  86 +function getLampColor(lampState) {
  87 + if (lampState === 3 || lampState === '3') return '#67c23a'
  88 + if (lampState === 2 || lampState === '2') return '#e6a23c'
  89 + if (lampState === 1 || lampState === '1') return '#f56c6c'
  90 + return '#909399'
  91 +}
  92 +
  93 +function formatDuration(dur) {
  94 + const v = Number(dur || 0)
  95 + if (v <= 0) return '0秒'
  96 + if (v > 3600) {
  97 + const h = Math.floor(v / 3600)
  98 + const m = Math.floor((v % 3600) / 60)
  99 + return `${h}时${m}分`
  100 + }
  101 + if (v > 60) {
  102 + const m = Math.floor(v / 60)
  103 + const s = Math.floor(v % 60)
  104 + return `${m}分${s}秒`
  105 + }
  106 + return `${Math.floor(v)}秒`
  107 +}
  108 +
  109 +function formatTimeRange(start, end) {
  110 + const fmt = (ts) => {
  111 + const d = ts ? new Date(String(ts).replace(/-/g, '/')) : new Date()
  112 + return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(
  113 + d.getHours()
  114 + ).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
  115 + }
  116 + return `${fmt(start)} ~ ${fmt(end || start)}`
  117 +}
  118 +
  119 +function getTimeBounds() {
  120 + const dateStr = tsDate.value || ''
  121 + const startDate = dateStr ? new Date(dateStr) : new Date()
  122 + startDate.setHours(0, 0, 0, 0)
  123 + const endDate = new Date(startDate)
  124 + endDate.setHours(23, 59, 59, 999)
  125 + return { startTime: startDate.getTime(), endTime: endDate.getTime() + 1 }
  126 +}
  127 +
  128 +function parseStartTime(timeStr) {
  129 + const d = new Date(String(timeStr).replace(/-/g, '/'))
  130 + return d.getTime()
  131 +}
  132 +
  133 +function resetAndFetch() {
  134 + tsPageNo.value = 0
  135 + timeSeriesData.value = []
  136 + hasMore.value = true
  137 + ganttZoomLevel.value = 1
  138 + ganttViewOffsetX.value = 0
  139 + fetchTimeSeriesData({ page: 1, append: false })
  140 +}
  141 +
  142 +async function fetchTimeSeriesData({ page = 1, append = false } = {}) {
  143 + if (!tsDate.value) {
  144 + ElMessage.warning('请选择查询日期')
  145 + return
  146 + }
  147 +
  148 + if (loading.value || loadingMore.value) return
  149 + if (append && !hasMore.value) return
  150 + if (append) loadingMore.value = true
  151 + else loading.value = true
  152 + try {
  153 + const params = new URLSearchParams({
  154 + startDate: tsDate.value,
  155 + endDate: tsDate.value,
  156 + pageNo: String(page),
  157 + pageSize: String(tsPageSize)
  158 + })
  159 + const res = await apiFetch(`/api/device/oeeTimeline?${params}`)
  160 + const data = await res.json()
  161 + const records = data?.data?.records || []
  162 + const mapped = records.map(record => ({
  163 + name: record.deviceName || record.dtuSn || '',
  164 + rate: Number.parseFloat(record.availabilityRatio || 0).toFixed(1),
  165 + segments: (record.lampData || []).map(item => ({
  166 + duration: item.duration,
  167 + lampState: item.lampState,
  168 + startTime: item.startTime,
  169 + endTime: item.endTime || ''
  170 + }))
  171 + }))
  172 + hasMore.value = mapped.length >= tsPageSize
  173 + timeSeriesData.value = append ? timeSeriesData.value.concat(mapped) : mapped
  174 + tsPageNo.value = page
  175 +
  176 + await nextTick()
  177 + drawGanttChart()
  178 + await nextTick()
  179 + ensureAutoLoad()
  180 + } catch (e) {
  181 + ElMessage.error('获取时序数据失败')
  182 + console.warn(e)
  183 + } finally {
  184 + loading.value = false
  185 + loadingMore.value = false
  186 + }
  187 +}
  188 +
  189 +const ganttCanvasRef = ref(null)
  190 +const ganttContainerRef = ref(null)
  191 +const ganttZoomLevel = ref(1)
  192 +const ganttViewOffsetX = ref(0)
  193 +const GANTT_MIN_ZOOM = 0.06
  194 +const GANTT_MAX_ZOOM = 1
  195 +
  196 +const ganttHoveredSeg = ref(null)
  197 +const ganttTooltipPos = ref({ x: 0, y: 0 })
  198 +
  199 +function clampViewOffset(vo, z, plotW) {
  200 + const maxVo = Math.max(0, plotW - plotW * z)
  201 + return Math.max(0, Math.min(maxVo, vo))
  202 +}
  203 +
  204 +function onGanttWheel(e) {
  205 + const container = ganttContainerRef.value
  206 + if (!container) return
  207 + const rect = container.getBoundingClientRect()
  208 + const PAD = 8
  209 + const nameColWidth = 100
  210 + const rateColWidth = 56
  211 + const leftFixedWidth = nameColWidth + rateColWidth
  212 + const mx = e.clientX - rect.left - PAD
  213 + if (mx < leftFixedWidth || mx < 0) return
  214 +
  215 + const plotW = container.clientWidth - PAD * 2 - leftFixedWidth
  216 + if (plotW <= 0) return
  217 +
  218 + const z = ganttZoomLevel.value
  219 + const vo = ganttViewOffsetX.value
  220 + const mouseDataX = vo + (mx - leftFixedWidth) * z
  221 + const delta = e.deltaY < 0 ? 0.8 : 1.25
  222 + const nextZ = Math.max(GANTT_MIN_ZOOM, Math.min(GANTT_MAX_ZOOM, z * delta))
  223 + let nextVo = mouseDataX - (mx - leftFixedWidth) * nextZ
  224 + nextVo = clampViewOffset(nextVo, nextZ, plotW)
  225 + ganttViewOffsetX.value = nextVo
  226 + ganttZoomLevel.value = nextZ
  227 + drawGanttChart()
  228 +}
  229 +
  230 +const drag = ref({ active: false, pointerId: null, startX: 0, startVo: 0, moved: false })
  231 +
  232 +function onPointerDown(e) {
  233 + if (!ganttContainerRef.value) return
  234 + drag.value.active = true
  235 + drag.value.pointerId = e.pointerId
  236 + drag.value.startX = e.clientX
  237 + drag.value.startVo = ganttViewOffsetX.value
  238 + drag.value.moved = false
  239 + e.currentTarget.setPointerCapture?.(e.pointerId)
  240 +}
  241 +
  242 +function onPointerMove(e) {
  243 + const container = ganttContainerRef.value
  244 + if (!container) return
  245 +
  246 + if (drag.value.active && drag.value.pointerId === e.pointerId) {
  247 + const dx = e.clientX - drag.value.startX
  248 + if (Math.abs(dx) > 4) drag.value.moved = true
  249 +
  250 + const PAD = 8
  251 + const nameColWidth = 100
  252 + const rateColWidth = 56
  253 + const leftFixedWidth = nameColWidth + rateColWidth
  254 + const plotW = container.clientWidth - PAD * 2 - leftFixedWidth
  255 + if (plotW > 0) {
  256 + const z = ganttZoomLevel.value
  257 + let nextVo = drag.value.startVo - dx * z
  258 + nextVo = clampViewOffset(nextVo, z, plotW)
  259 + ganttViewOffsetX.value = nextVo
  260 + drawGanttChart()
  261 + }
  262 + return
  263 + }
  264 +
  265 + if (e.pointerType === 'mouse') onHitTest(e)
  266 +}
  267 +
  268 +function onPointerUp(e) {
  269 + if (drag.value.active && drag.value.pointerId === e.pointerId) {
  270 + drag.value.active = false
  271 + drag.value.pointerId = null
  272 + if (!drag.value.moved) onHitTest(e)
  273 + }
  274 +}
  275 +
  276 +function onPointerLeave() {
  277 + if (ganttHoveredSeg.value) {
  278 + ganttHoveredSeg.value = null
  279 + drawGanttChart()
  280 + }
  281 +}
  282 +
  283 +function onHitTest(e) {
  284 + const container = ganttContainerRef.value
  285 + if (!container) return
  286 + const rect = container.getBoundingClientRect()
  287 + const PAD = 8
  288 + const nameColWidth = 100
  289 + const rateColWidth = 56
  290 + const leftFixedWidth = nameColWidth + rateColWidth
  291 + const mx = e.clientX - rect.left - PAD
  292 + const my = e.clientY - rect.top - PAD
  293 + if (mx < leftFixedWidth || mx < 0) {
  294 + if (ganttHoveredSeg.value) {
  295 + ganttHoveredSeg.value = null
  296 + drawGanttChart()
  297 + }
  298 + return
  299 + }
  300 +
  301 + const rowHeight = 34
  302 + const headerHeight = 36
  303 + const rowIdx = Math.floor((my - headerHeight) / rowHeight)
  304 + if (rowIdx < 0 || rowIdx >= timeSeriesData.value.length) {
  305 + if (ganttHoveredSeg.value) {
  306 + ganttHoveredSeg.value = null
  307 + drawGanttChart()
  308 + }
  309 + return
  310 + }
  311 +
  312 + const dev = timeSeriesData.value[rowIdx]
  313 + const { startTime: tStart, endTime: tEnd } = getTimeBounds()
  314 + const totalMs = tEnd - tStart || 86400000
  315 + const plotW = container.clientWidth - PAD * 2 - leftFixedWidth
  316 + const barPad = 4
  317 + const innerPlotW = plotW - barPad * 2
  318 + const z = ganttZoomLevel.value
  319 + const vo = ganttViewOffsetX.value
  320 + const mouseDataX = vo + (mx - leftFixedWidth) * z
  321 +
  322 + const rowY = headerHeight + rowIdx * rowHeight
  323 + const barY = rowY + (rowHeight - 16) / 2
  324 + const barH = 16
  325 + if (my < barY || my > barY + barH) {
  326 + if (ganttHoveredSeg.value) {
  327 + ganttHoveredSeg.value = null
  328 + drawGanttChart()
  329 + }
  330 + return
  331 + }
  332 +
  333 + let hitSeg = null
  334 + for (let i = (dev.segments || []).length - 1; i >= 0; i--) {
  335 + const seg = dev.segments[i]
  336 + const segStartMs = parseStartTime(seg.startTime)
  337 + const durMs = (seg.duration || 0) * 1000
  338 + const pct = Math.max(0, (segStartMs - tStart) / totalMs)
  339 + const wPct = durMs / totalMs
  340 + const dataSx = barPad + pct * innerPlotW
  341 + const dataSw = Math.max(wPct * innerPlotW, 1)
  342 + if (mouseDataX >= dataSx && mouseDataX <= dataSx + dataSw) {
  343 + hitSeg = seg
  344 + break
  345 + }
  346 + }
  347 +
  348 + if (hitSeg !== ganttHoveredSeg.value) {
  349 + ganttHoveredSeg.value = hitSeg
  350 + if (hitSeg) {
  351 + ganttTooltipPos.value = { x: mx + PAD + 8, y: my + PAD + 8 }
  352 + }
  353 + drawGanttChart()
  354 + } else if (hitSeg) {
  355 + ganttTooltipPos.value = { x: mx + PAD + 8, y: my + PAD + 8 }
  356 + }
  357 +}
  358 +
  359 +function drawGanttChart() {
  360 + const canvas = ganttCanvasRef.value
  361 + const container = ganttContainerRef.value
  362 + if (!canvas || !container) return
  363 +
  364 + const PAD = 8
  365 + const rowHeight = 34
  366 + const headerHeight = 36
  367 + const nameColWidth = 100
  368 + const rateColWidth = 56
  369 + const leftFixedWidth = nameColWidth + rateColWidth
  370 + const containerW = container.clientWidth - PAD * 2
  371 + const plotW = containerW - leftFixedWidth
  372 + const totalHeight = Math.max(headerHeight + timeSeriesData.value.length * rowHeight, 200)
  373 +
  374 + canvas.width = containerW
  375 + canvas.height = totalHeight
  376 + canvas.style.width = containerW + 'px'
  377 + canvas.style.height = totalHeight + 'px'
  378 +
  379 + const ctx = canvas.getContext('2d')
  380 + const W = containerW
  381 + const H = totalHeight
  382 +
  383 + ctx.fillStyle = '#fff'
  384 + ctx.fillRect(0, 0, W, H)
  385 +
  386 + const { startTime: tStart, endTime: tEnd } = getTimeBounds()
  387 + const totalMs = tEnd - tStart || 86400000
  388 +
  389 + const z = ganttZoomLevel.value
  390 + const vo = clampViewOffset(ganttViewOffsetX.value, z, plotW)
  391 + ganttViewOffsetX.value = vo
  392 +
  393 + ctx.fillStyle = '#fafafa'
  394 + ctx.fillRect(0, 0, leftFixedWidth, headerHeight)
  395 + ctx.strokeStyle = '#e0e0e0'
  396 + ctx.lineWidth = 2
  397 + ctx.beginPath()
  398 + ctx.moveTo(0, headerHeight)
  399 + ctx.lineTo(leftFixedWidth, headerHeight)
  400 + ctx.stroke()
  401 + ctx.fillStyle = '#333'
  402 + ctx.font = 'bold 12px sans-serif'
  403 + ctx.textAlign = 'center'
  404 + ctx.textBaseline = 'middle'
  405 + ctx.fillText('设备', nameColWidth / 2, headerHeight / 2)
  406 + ctx.fillText('稼动', nameColWidth + rateColWidth / 2, headerHeight / 2)
  407 + ctx.strokeStyle = '#ebeef5'
  408 + ctx.lineWidth = 1
  409 + ctx.beginPath()
  410 + ctx.moveTo(nameColWidth, 0)
  411 + ctx.lineTo(nameColWidth, H)
  412 + ctx.stroke()
  413 + ctx.beginPath()
  414 + ctx.moveTo(leftFixedWidth, 0)
  415 + ctx.lineTo(leftFixedWidth, H)
  416 + ctx.stroke()
  417 +
  418 + timeSeriesData.value.forEach((dev, idx) => {
  419 + const y = headerHeight + idx * rowHeight
  420 + ctx.fillStyle = idx % 2 === 0 ? '#fff' : '#fafbfc'
  421 + ctx.fillRect(0, y, leftFixedWidth, rowHeight)
  422 + ctx.strokeStyle = '#ebeef5'
  423 + ctx.beginPath()
  424 + ctx.moveTo(0, y)
  425 + ctx.lineTo(leftFixedWidth, y)
  426 + ctx.stroke()
  427 + ctx.fillStyle = '#409eff'
  428 + ctx.font = '11px sans-serif'
  429 + ctx.textAlign = 'left'
  430 + ctx.textBaseline = 'middle'
  431 + const dn = dev.name.length > 9 ? dev.name.slice(0, 9) + '..' : dev.name
  432 + ctx.fillText(dn, 8, y + rowHeight / 2)
  433 + ctx.fillStyle = '#333'
  434 + ctx.font = 'bold 11px sans-serif'
  435 + ctx.textAlign = 'center'
  436 + ctx.fillText(`${dev.rate}%`, nameColWidth + rateColWidth / 2, y + rowHeight / 2)
  437 + })
  438 +
  439 + ctx.fillStyle = '#fafafa'
  440 + ctx.fillRect(leftFixedWidth, 0, plotW, headerHeight)
  441 + ctx.strokeStyle = '#e0e0e0'
  442 + ctx.lineWidth = 2
  443 + ctx.beginPath()
  444 + ctx.moveTo(leftFixedWidth, headerHeight)
  445 + ctx.lineTo(W, headerHeight)
  446 + ctx.stroke()
  447 +
  448 + const visMs = Math.max(1000, totalMs * z)
  449 + let stepMs = 2 * 3600 * 1000
  450 + if (visMs <= 600000) stepMs = 15 * 60 * 1000
  451 + else if (visMs <= 1800000) stepMs = 30 * 60 * 1000
  452 + else if (visMs <= 28800000) stepMs = 3600 * 1000
  453 + else stepMs = 2 * 3600 * 1000
  454 +
  455 + const leftMs = (vo / plotW) * totalMs
  456 + let firstMs = Math.floor(leftMs / stepMs) * stepMs
  457 + if (firstMs < 0) firstMs = 0
  458 +
  459 + ctx.font = '10px sans-serif'
  460 + ctx.fillStyle = '#666'
  461 + for (let ms = firstMs; ms <= totalMs + stepMs * 2; ms += stepMs) {
  462 + const dataX = (ms / totalMs) * plotW
  463 + const screenX = leftFixedWidth + (dataX - vo) / z
  464 + if (screenX < leftFixedWidth - 60 || screenX > W + 60) continue
  465 + const dt = new Date(tStart + ms)
  466 + ctx.textAlign = 'center'
  467 + ctx.textBaseline = 'middle'
  468 + ctx.fillText(`${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`, screenX, headerHeight / 2)
  469 + ctx.strokeStyle = '#f0f0f0'
  470 + ctx.lineWidth = 0.5
  471 + ctx.beginPath()
  472 + ctx.moveTo(screenX, headerHeight)
  473 + ctx.lineTo(screenX, H)
  474 + ctx.stroke()
  475 + }
  476 +
  477 + timeSeriesData.value.forEach((dev, idx) => {
  478 + const y = headerHeight + idx * rowHeight
  479 + ctx.fillStyle = idx % 2 === 0 ? '#fff' : '#fafbfc'
  480 + ctx.fillRect(leftFixedWidth, y, W - leftFixedWidth, rowHeight)
  481 + ctx.strokeStyle = '#ebeef5'
  482 + ctx.lineWidth = 1
  483 + ctx.beginPath()
  484 + ctx.moveTo(leftFixedWidth, y)
  485 + ctx.lineTo(W, y)
  486 + ctx.stroke()
  487 +
  488 + const segs = dev.segments || []
  489 + if (segs.length === 0) return
  490 +
  491 + const barY = y + (rowHeight - 16) / 2
  492 + const barH = 16
  493 + const barPad = 4
  494 + const innerPlotW = plotW - barPad * 2
  495 +
  496 + ctx.save()
  497 + ctx.beginPath()
  498 + ctx.rect(leftFixedWidth, headerHeight, W - leftFixedWidth, H - headerHeight)
  499 + ctx.clip()
  500 +
  501 + segs.forEach(seg => {
  502 + const segStartMs = parseStartTime(seg.startTime)
  503 + const durMs = (seg.duration || 0) * 1000
  504 + const pct = Math.max(0, (segStartMs - tStart) / totalMs)
  505 + const wPct = durMs / totalMs
  506 + const dataSx = barPad + pct * innerPlotW
  507 + const dataSw = Math.max(wPct * innerPlotW, 1)
  508 + const sx = leftFixedWidth + (dataSx - vo) / z
  509 + const sw = dataSw / z
  510 + if (sx + sw < leftFixedWidth - 20 || sx > W + 20) return
  511 +
  512 + const isHovered = ganttHoveredSeg.value === seg
  513 + ctx.fillStyle = getLampColor(seg.lampState)
  514 + if (isHovered) {
  515 + ctx.globalAlpha = 0.7
  516 + ctx.fillRect(sx, barY - 2, sw, barH + 4)
  517 + ctx.globalAlpha = 1
  518 + ctx.strokeStyle = 'rgba(0,0,0,0.5)'
  519 + ctx.lineWidth = 1.5
  520 + ctx.strokeRect(sx, barY - 2, sw, barH + 4)
  521 + } else {
  522 + ctx.fillRect(sx, barY, sw, barH)
  523 + ctx.strokeStyle = 'rgba(255,255,255,0.35)'
  524 + ctx.lineWidth = 0.5
  525 + ctx.strokeRect(sx, barY, sw, barH)
  526 + }
  527 + })
  528 +
  529 + ctx.restore()
  530 + })
  531 +}
  532 +
  533 +const sentinelRef = ref(null)
  534 +let sentinelObserver = null
  535 +let ensureTimer = null
  536 +
  537 +function loadMoreIfNeeded() {
  538 + if (loading.value || loadingMore.value) return
  539 + if (!canLoadMore.value) return
  540 + const nextPage = tsPageNo.value + 1
  541 + fetchTimeSeriesData({ page: nextPage, append: true })
  542 +}
  543 +
  544 +function ensureAutoLoad() {
  545 + if (ensureTimer) return
  546 + ensureTimer = window.setTimeout(() => {
  547 + ensureTimer = null
  548 + if (!sentinelRef.value) return
  549 + if (loading.value || loadingMore.value) return
  550 + if (!canLoadMore.value) return
  551 + const rect = sentinelRef.value.getBoundingClientRect()
  552 + const vh = window.innerHeight || document.documentElement.clientHeight
  553 + if (rect.top <= vh + 200) loadMoreIfNeeded()
  554 + }, 0)
  555 +}
  556 +
  557 +function attachObserver() {
  558 + if (!sentinelRef.value) return
  559 + if (!('IntersectionObserver' in window)) return
  560 + if (sentinelObserver) return
  561 + sentinelObserver = new IntersectionObserver(
  562 + (entries) => {
  563 + if (entries.some(e => e.isIntersecting)) loadMoreIfNeeded()
  564 + },
  565 + { root: null, rootMargin: '300px 0px 300px 0px', threshold: 0 }
  566 + )
  567 + sentinelObserver.observe(sentinelRef.value)
  568 +}
  569 +
  570 +function detachObserver() {
  571 + if (sentinelObserver) {
  572 + sentinelObserver.disconnect()
  573 + sentinelObserver = null
  574 + }
  575 +}
  576 +
  577 +onMounted(() => {
  578 + const today = new Date()
  579 + tsDate.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
  580 + resetAndFetch()
  581 + attachObserver()
  582 +})
  583 +
  584 +onBeforeUnmount(() => {
  585 + detachObserver()
  586 + if (ensureTimer) {
  587 + window.clearTimeout(ensureTimer)
  588 + ensureTimer = null
  589 + }
  590 +})
  591 +
  592 +onActivated(() => {
  593 + attachObserver()
  594 + ensureAutoLoad()
  595 +})
  596 +
  597 +onDeactivated(() => {
  598 + detachObserver()
  599 +})
  600 +</script>
  601 +
  602 +<style scoped>
  603 +.ts-h5 {
  604 + padding: 0;
  605 +}
  606 +
  607 +.toolbar {
  608 + display: flex;
  609 + align-items: center;
  610 + justify-content: space-between;
  611 + gap: 10px;
  612 +}
  613 +
  614 +.left {
  615 + display: flex;
  616 + align-items: center;
  617 + gap: 8px;
  618 +}
  619 +
  620 +.mode {
  621 + font-size: 12px;
  622 + color: #64748b;
  623 + white-space: nowrap;
  624 +}
  625 +
  626 +.canvas-card {
  627 + margin-top: 10px;
  628 +}
  629 +
  630 +.gantt-wrap {
  631 + position: relative;
  632 + background: #fff;
  633 + border: 1px solid rgba(0, 0, 0, 0.06);
  634 + border-radius: 12px;
  635 + padding: 8px;
  636 + overflow: hidden;
  637 + touch-action: pan-y;
  638 +}
  639 +
  640 +.gantt-canvas {
  641 + width: 100%;
  642 + display: block;
  643 +}
  644 +
  645 +.gantt-tooltip {
  646 + position: absolute;
  647 + min-width: 180px;
  648 + max-width: 240px;
  649 + padding: 8px 10px;
  650 + background: rgba(17, 24, 39, 0.92);
  651 + color: #fff;
  652 + font-size: 12px;
  653 + border-radius: 10px;
  654 + pointer-events: none;
  655 +}
  656 +
  657 +.gtt-row {
  658 + display: flex;
  659 + align-items: center;
  660 + gap: 6px;
  661 + line-height: 1.45;
  662 +}
  663 +
  664 +.gtt-dot {
  665 + width: 8px;
  666 + height: 8px;
  667 + border-radius: 999px;
  668 + flex: 0 0 auto;
  669 +}
  670 +
  671 +.gtt-sub {
  672 + margin-top: 6px;
  673 + opacity: 0.9;
  674 + line-height: 1.35;
  675 + word-break: break-all;
  676 +}
  677 +
  678 +.page-info,
  679 +.load-text {
  680 + font-size: 12px;
  681 + color: #475569;
  682 +}
  683 +
  684 +.load-more {
  685 + margin-top: 10px;
  686 + text-align: center;
  687 +}
  688 +
  689 +.load-sentinel {
  690 + height: 1px;
  691 +}
  692 +</style>
  1 +<template>
  2 + <div class="util-h5">
  3 + <div class="toolbar">
  4 + <div class="left">
  5 + <el-radio-group v-model="utilQueryMode" size="small" @change="onModeChange">
  6 + <el-radio-button value="day">日查询</el-radio-button>
  7 + <el-radio-button value="week">周查询</el-radio-button>
  8 + <el-radio-button value="month">月查询</el-radio-button>
  9 + </el-radio-group>
  10 + </div>
  11 + <el-button type="primary" size="small" :loading="utilLoading" @click="fetchUtilData">查询</el-button>
  12 + </div>
  13 +
  14 + <div class="toolbar" style="margin-top: 8px;">
  15 + <div class="left">
  16 + <el-date-picker
  17 + v-if="utilQueryMode === 'day'"
  18 + v-model="utilDate"
  19 + type="date"
  20 + placeholder="选择日期"
  21 + size="small"
  22 + value-format="YYYY-MM-DD"
  23 + :disabled-date="disabledDateFuture"
  24 + style="width: 160px;"
  25 + @change="fetchUtilData"
  26 + />
  27 + <el-date-picker
  28 + v-else-if="utilQueryMode === 'week'"
  29 + v-model="utilWeekDate"
  30 + type="date"
  31 + :format="utilWeekDisplayFormat"
  32 + placeholder="选择周"
  33 + size="small"
  34 + value-format="YYYY-MM-DD"
  35 + :disabled-date="disableNonMonday"
  36 + style="width: 160px;"
  37 + @change="fetchUtilData"
  38 + />
  39 + <el-date-picker
  40 + v-else
  41 + v-model="utilMonthDate"
  42 + type="month"
  43 + placeholder="选择月份"
  44 + size="small"
  45 + value-format="YYYY-MM"
  46 + style="width: 140px;"
  47 + @change="fetchUtilData"
  48 + />
  49 + </div>
  50 + </div>
  51 +
  52 + <div class="section">
  53 + <div class="section-title">总时长</div>
  54 + <div class="legend-total">
  55 + <div v-for="(seg, i) in utilTotalSegments" :key="i" class="legend-total-item">
  56 + <div class="lt-left">
  57 + <span class="dot" :style="{ background: seg.color }"></span>
  58 + <span class="name">{{ seg.label }}</span>
  59 + </div>
  60 + <div class="lt-right">
  61 + <div class="val">{{ seg.duration }}</div>
  62 + <div class="pct">{{ seg.pct }}</div>
  63 + </div>
  64 + </div>
  65 + </div>
  66 + <div ref="pieTotalWrapRef" class="pie-wrap">
  67 + <canvas ref="pieTotalRef" class="pie-canvas"></canvas>
  68 + </div>
  69 + </div>
  70 +
  71 + <div class="section">
  72 + <div class="section-title">稼动率</div>
  73 + <div class="subline">稼动率:{{ utilData.availabilityRate || '0%' }}</div>
  74 + <div ref="pieRateWrapRef" class="pie-wrap">
  75 + <canvas ref="pieRateRef" class="pie-canvas"></canvas>
  76 + </div>
  77 + </div>
  78 +
  79 + <div class="section">
  80 + <div class="section-title">当前机台运行状态</div>
  81 + <div class="legend">
  82 + <div class="legend-item">
  83 + <span class="dot" style="background:#67c23a"></span><span class="name">绿灯</span><span class="val">{{ utilData.currentStatus.green || 0 }}台</span>
  84 + </div>
  85 + <div class="legend-item">
  86 + <span class="dot" style="background:#e6a23c"></span><span class="name">黄灯</span><span class="val">{{ utilData.currentStatus.yellow || 0 }}台</span>
  87 + </div>
  88 + <div class="legend-item">
  89 + <span class="dot" style="background:#f56c6c"></span><span class="name">红灯</span><span class="val">{{ utilData.currentStatus.red || 0 }}台</span>
  90 + </div>
  91 + <div class="legend-item">
  92 + <span class="dot" style="background:#909399"></span><span class="name">灭灯</span><span class="val">{{ utilData.currentStatus.off || 0 }}台</span>
  93 + </div>
  94 + </div>
  95 + <div ref="pieStatusWrapRef" class="pie-wrap">
  96 + <canvas ref="pieStatusRef" class="pie-canvas"></canvas>
  97 + </div>
  98 + </div>
  99 +
  100 + <div class="section">
  101 + <div class="section-title">异常机台排名</div>
  102 + <div class="subline">黄灯 + 红灯时长</div>
  103 + <div ref="abnormalWrapRef" class="chart-wrap">
  104 + <canvas ref="abnormalRef" class="chart-canvas"></canvas>
  105 + </div>
  106 + <el-empty v-if="!utilLoading && abnormalList.length === 0" description="暂无异常数据" />
  107 + </div>
  108 +
  109 + <div class="section">
  110 + <div class="section-title">排序</div>
  111 + <div class="stack-toolbar">
  112 + <el-radio-group v-model="sortMode" size="small" @change="redrawStackBar">
  113 + <el-radio-button value="duration">绿灯时长</el-radio-button>
  114 + <el-radio-button value="rate">稼动率</el-radio-button>
  115 + </el-radio-group>
  116 + </div>
  117 + <div class="stack-legend">
  118 + <span class="leg"><i class="dot" style="background:#67c23a"></i>绿灯</span>
  119 + <span class="leg"><i class="dot" style="background:#e6a23c"></i>黄灯</span>
  120 + <span class="leg"><i class="dot" style="background:#f56c6c"></i>红灯</span>
  121 + <span class="leg"><i class="dot" style="background:#909399"></i>灭灯</span>
  122 + </div>
  123 + <div class="stack-scroll">
  124 + <div ref="stackWrapRef" class="stack-wrap">
  125 + <canvas ref="stackRef" class="stack-canvas"></canvas>
  126 + </div>
  127 + </div>
  128 + <el-empty v-if="!utilLoading && (utilData.deviceList || []).length === 0" description="暂无数据" />
  129 + </div>
  130 + </div>
  131 +</template>
  132 +
  133 +<script setup>
  134 +import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
  135 +import { ElMessage } from 'element-plus'
  136 +import { apiFetch } from '../../config/api.js'
  137 +
  138 +const utilQueryMode = ref('day')
  139 +const utilDate = ref('')
  140 +const utilWeekDate = ref('')
  141 +const utilMonthDate = ref('')
  142 +const utilLoading = ref(false)
  143 +const sortMode = ref('rate')
  144 +
  145 +const utilData = reactive({
  146 + totalDuration: { green: {}, yellow: {}, red: {}, off: {}, blue: {} },
  147 + availabilityRate: '0%',
  148 + currentStatus: { green: 0, red: 0, off: 0, blue: 0, yellow: 0 },
  149 + abnormalRanking: [],
  150 + deviceList: []
  151 +})
  152 +
  153 +const utilWeekDisplayFormat = computed(() => {
  154 + if (!utilWeekDate.value) return ''
  155 + const d = new Date(utilWeekDate.value)
  156 + return `${d.getFullYear()}-${String(getWeekNumber(d)).padStart(2, '0')}周`
  157 +})
  158 +
  159 +function disabledDateFuture(time) {
  160 + return time.getTime() > Date.now()
  161 +}
  162 +
  163 +function getWeekNumber(date) {
  164 + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
  165 + const dayNum = d.getUTCDay() || 7
  166 + d.setUTCDate(d.getUTCDate() + 4 - dayNum)
  167 + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
  168 + return Math.ceil(((d - yearStart) / 86400000 + 1) / 7)
  169 +}
  170 +
  171 +function formatYMD(d) {
  172 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  173 +}
  174 +
  175 +function getCurrentMonday() {
  176 + const today = new Date()
  177 + const day = today.getDay() || 7
  178 + today.setDate(today.getDate() - day + 1)
  179 + return formatYMD(today)
  180 +}
  181 +
  182 +function getCurrentYM() {
  183 + const now = new Date()
  184 + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
  185 +}
  186 +
  187 +function getWeekRange(dateStr) {
  188 + if (!dateStr) return { start: '', end: '' }
  189 + const d = new Date(dateStr)
  190 + const day = d.getDay() || 7
  191 + const diff = d.getDate() - day + 1
  192 + const monday = new Date(d.setDate(diff))
  193 + const sunday = new Date(monday)
  194 + sunday.setDate(monday.getDate() + 6)
  195 + return { start: formatYMD(monday), end: formatYMD(sunday) }
  196 +}
  197 +
  198 +function getMonthRange(ymStr) {
  199 + if (!ymStr) return { start: '', end: '' }
  200 + const [y, m] = ymStr.split('-').map(Number)
  201 + const lastDay = new Date(y, m, 0).getDate()
  202 + return { start: `${ymStr}-01`, end: `${ymStr}-${String(lastDay).padStart(2, '0')}` }
  203 +}
  204 +
  205 +function disableNonMonday(date) {
  206 + return date.getDay() !== 1
  207 +}
  208 +
  209 +function setDefaultDate() {
  210 + if (utilQueryMode.value === 'day') {
  211 + if (!utilDate.value) utilDate.value = formatYMD(new Date())
  212 + } else if (utilQueryMode.value === 'week') {
  213 + if (!utilWeekDate.value) utilWeekDate.value = getCurrentMonday()
  214 + } else {
  215 + if (!utilMonthDate.value) utilMonthDate.value = getCurrentYM()
  216 + }
  217 +}
  218 +
  219 +function onModeChange() {
  220 + setDefaultDate()
  221 + fetchUtilData()
  222 +}
  223 +
  224 +function fmtSecHour(sec) {
  225 + sec = Math.max(0, sec || 0)
  226 + return (sec / 3600).toFixed(2) + '时'
  227 +}
  228 +
  229 +function fmtSec(sec) {
  230 + sec = Math.max(0, sec | 0)
  231 + const h = Math.floor(sec / 3600)
  232 + const m = Math.floor((sec % 3600) / 60)
  233 + const s = sec % 60
  234 + if (h > 0) return `${h}时${m}分${s}秒`
  235 + if (m > 0) return `${m}分${s}秒`
  236 + return `${s}秒`
  237 +}
  238 +
  239 +const utilTotalSegments = computed(() => {
  240 + const td = utilData.totalDuration
  241 + const totalSec = (td.green?.seconds || 0) + (td.yellow?.seconds || 0) + (td.red?.seconds || 0) + (td.off?.seconds || 0)
  242 + if (!totalSec) return []
  243 + return [
  244 + { label: '绿灯', color: '#67c23a', duration: td.green?.duration || '', pct: (((td.green?.seconds || 0) / totalSec) * 100).toFixed(2) + '%' },
  245 + { label: '黄灯', color: '#e6a23c', duration: td.yellow?.duration || '', pct: (((td.yellow?.seconds || 0) / totalSec) * 100).toFixed(2) + '%' },
  246 + { label: '红灯', color: '#f56c6c', duration: td.red?.duration || '', pct: (((td.red?.seconds || 0) / totalSec) * 100).toFixed(2) + '%' },
  247 + { label: '灭灯', color: '#909399', duration: td.off?.duration || '', pct: (((td.off?.seconds || 0) / totalSec) * 100).toFixed(2) + '%' }
  248 + ]
  249 +})
  250 +
  251 +const abnormalList = computed(() => {
  252 + return (utilData.abnormalRanking || []).filter(item => (item.yellowSeconds || 0) + (item.redSeconds || 0) > 0)
  253 +})
  254 +
  255 +const pieTotalRef = ref(null)
  256 +const pieRateRef = ref(null)
  257 +const pieStatusRef = ref(null)
  258 +const abnormalRef = ref(null)
  259 +const stackRef = ref(null)
  260 +
  261 +const pieTotalWrapRef = ref(null)
  262 +const pieRateWrapRef = ref(null)
  263 +const pieStatusWrapRef = ref(null)
  264 +const abnormalWrapRef = ref(null)
  265 +const stackWrapRef = ref(null)
  266 +
  267 +const dpr = window.devicePixelRatio || 1
  268 +function setupCanvas(canvas, cssW, cssH) {
  269 + canvas.style.width = cssW + 'px'
  270 + canvas.style.height = cssH + 'px'
  271 + canvas.width = Math.round(cssW * dpr)
  272 + canvas.height = Math.round(cssH * dpr)
  273 + const ctx = canvas.getContext('2d')
  274 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  275 + return { ctx, W: cssW, H: cssH }
  276 +}
  277 +
  278 +function drawPie(ctx, cx, cy, r, segments) {
  279 + const total = segments.reduce((s, seg) => s + seg.value, 0)
  280 + if (total <= 0) return
  281 + let angle = -Math.PI / 2
  282 + segments.forEach(seg => {
  283 + const sweep = (seg.value / total) * Math.PI * 2
  284 + ctx.beginPath()
  285 + ctx.moveTo(cx, cy)
  286 + ctx.arc(cx, cy, r, angle, angle + sweep)
  287 + ctx.closePath()
  288 + ctx.fillStyle = seg.color
  289 + ctx.fill()
  290 + ctx.strokeStyle = 'rgba(255,255,255,0.5)'
  291 + ctx.lineWidth = 0.8
  292 + ctx.stroke()
  293 +
  294 + if (seg.value > 0) {
  295 + const midAngle = angle + sweep / 2
  296 + const labelR = r * 0.62
  297 + const lx = cx + Math.cos(midAngle) * labelR
  298 + const ly = cy + Math.sin(midAngle) * labelR
  299 + const pct = ((seg.value / total) * 100).toFixed(2) + '%'
  300 + ctx.save()
  301 + ctx.fillStyle = '#fff'
  302 + ctx.textAlign = 'center'
  303 + ctx.textBaseline = 'middle'
  304 + ctx.font = 'bold 12px sans-serif'
  305 + ctx.fillText(pct, lx, ly - 7)
  306 + ctx.font = '10px sans-serif'
  307 + ctx.fillText(seg.textLabel || '', lx, ly + 8)
  308 + ctx.restore()
  309 + }
  310 + angle += sweep
  311 + })
  312 +}
  313 +
  314 +function drawTotalPie() {
  315 + const canvas = pieTotalRef.value
  316 + const wrap = pieTotalWrapRef.value
  317 + if (!canvas || !wrap) return
  318 + const cssSize = Math.max(240, Math.min(320, wrap.clientWidth || 0))
  319 + const { ctx, W, H } = setupCanvas(canvas, cssSize, cssSize)
  320 + ctx.clearRect(0, 0, W, H)
  321 + const td = utilData.totalDuration
  322 + const gSec = td.green?.seconds || 0
  323 + const ySec = td.yellow?.seconds || 0
  324 + const rSec = td.red?.seconds || 0
  325 + const oSec = td.off?.seconds || 0
  326 + const totalSec = gSec + ySec + rSec + oSec
  327 + const cx = W / 2
  328 + const cy = H / 2
  329 + const r = Math.min(W, H) / 2 - 16
  330 + if (totalSec <= 0) {
  331 + ctx.fillStyle = '#999'
  332 + ctx.font = '13px sans-serif'
  333 + ctx.textAlign = 'center'
  334 + ctx.fillText('暂无数据', cx, cy)
  335 + return
  336 + }
  337 + drawPie(ctx, cx, cy, r, [
  338 + { label: '绿灯', value: gSec, color: '#67c23a', textLabel: '绿灯' },
  339 + { label: '黄灯', value: ySec, color: '#e6a23c', textLabel: `黄灯:${fmtSecHour(ySec)}` },
  340 + { label: '红灯', value: rSec, color: '#f56c6c', textLabel: `红灯:${fmtSecHour(rSec)}` },
  341 + { label: '灭灯', value: oSec, color: '#909399', textLabel: `灭灯:${fmtSecHour(oSec)}` }
  342 + ])
  343 +}
  344 +
  345 +function drawRatePie() {
  346 + const canvas = pieRateRef.value
  347 + const wrap = pieRateWrapRef.value
  348 + if (!canvas || !wrap) return
  349 + const cssSize = Math.max(240, Math.min(320, wrap.clientWidth || 0))
  350 + const { ctx, W, H } = setupCanvas(canvas, cssSize, cssSize)
  351 + ctx.clearRect(0, 0, W, H)
  352 + const td = utilData.totalDuration
  353 + const gSec = td.green?.seconds || 0
  354 + const ySec = td.yellow?.seconds || 0
  355 + const rSec = td.red?.seconds || 0
  356 + const total = gSec + ySec + rSec
  357 + const cx = W / 2
  358 + const cy = H / 2
  359 + const r = Math.min(W, H) / 2 - 16
  360 + if (total <= 0) {
  361 + ctx.fillStyle = '#999'
  362 + ctx.font = '13px sans-serif'
  363 + ctx.textAlign = 'center'
  364 + ctx.fillText('暂无数据', cx, cy)
  365 + return
  366 + }
  367 + drawPie(ctx, cx, cy, r, [
  368 + { label: '绿灯', value: gSec, color: '#67c23a', textLabel: `绿灯:${fmtSecHour(gSec)}` },
  369 + { label: '黄灯', value: ySec, color: '#e6a23c', textLabel: `黄灯:${fmtSecHour(ySec)}` },
  370 + { label: '红灯', value: rSec, color: '#f56c6c', textLabel: `红灯:${fmtSecHour(rSec)}` }
  371 + ])
  372 +}
  373 +
  374 +function drawStatusPie() {
  375 + const canvas = pieStatusRef.value
  376 + const wrap = pieStatusWrapRef.value
  377 + if (!canvas || !wrap) return
  378 + const cssSize = Math.max(240, Math.min(320, wrap.clientWidth || 0))
  379 + const { ctx, W, H } = setupCanvas(canvas, cssSize, cssSize)
  380 + ctx.clearRect(0, 0, W, H)
  381 + const cs = utilData.currentStatus
  382 + const gN = cs.green || 0
  383 + const yN = cs.yellow || 0
  384 + const rN = cs.red || 0
  385 + const oN = cs.off || 0
  386 + const total = gN + yN + rN + oN
  387 + const cx = W / 2
  388 + const cy = H / 2
  389 + const r = Math.min(W, H) / 2 - 16
  390 + if (total <= 0) {
  391 + ctx.fillStyle = '#999'
  392 + ctx.font = '13px sans-serif'
  393 + ctx.textAlign = 'center'
  394 + ctx.fillText('暂无数据', cx, cy)
  395 + return
  396 + }
  397 + drawPie(ctx, cx, cy, r, [
  398 + { label: '绿灯', value: gN, color: '#67c23a', textLabel: `绿灯${gN}台` },
  399 + { label: '黄灯', value: yN, color: '#e6a23c', textLabel: `黄灯${yN}台` },
  400 + { label: '红灯', value: rN, color: '#f56c6c', textLabel: `红灯${rN}台` },
  401 + { label: '灭灯', value: oN, color: '#909399', textLabel: `灭灯${oN}台` }
  402 + ])
  403 +}
  404 +
  405 +function drawAbnormal() {
  406 + const canvas = abnormalRef.value
  407 + const wrap = abnormalWrapRef.value
  408 + if (!canvas || !wrap) return
  409 + const list = abnormalList.value
  410 + const cssW = Math.max(300, wrap.clientWidth)
  411 + const cssH = Math.min(420, Math.max(220, list.length * 26 + 60))
  412 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  413 + ctx.clearRect(0, 0, W, H)
  414 + if (list.length === 0) return
  415 +
  416 + const PAD_L = 64
  417 + const PAD_R = 14
  418 + const PAD_T = 30
  419 + const PAD_B = 10
  420 + const plotW = W - PAD_L - PAD_R
  421 + const plotH = H - PAD_T - PAD_B
  422 + const rowH = Math.min(24, plotH / list.length)
  423 + const barH = rowH - 6
  424 +
  425 + let maxVal = 1
  426 + list.forEach(item => {
  427 + maxVal = Math.max(maxVal, (item.yellowSeconds || 0) + (item.redSeconds || 0))
  428 + })
  429 +
  430 + const tickCount = 4
  431 + ctx.strokeStyle = '#eee'
  432 + ctx.lineWidth = 1
  433 + ctx.fillStyle = '#64748b'
  434 + ctx.font = '10px sans-serif'
  435 + ctx.textAlign = 'center'
  436 + for (let i = 0; i <= tickCount; i++) {
  437 + const val = (maxVal * i) / tickCount
  438 + const x = PAD_L + (plotW * i) / tickCount
  439 + ctx.fillText(fmtSec(Math.round(val)), x, 16)
  440 + if (i > 0) {
  441 + ctx.beginPath()
  442 + ctx.moveTo(x, PAD_T)
  443 + ctx.lineTo(x, H - PAD_B)
  444 + ctx.stroke()
  445 + }
  446 + }
  447 +
  448 + ctx.strokeStyle = '#ddd'
  449 + ctx.lineWidth = 1.5
  450 + ctx.beginPath()
  451 + ctx.moveTo(PAD_L, PAD_T)
  452 + ctx.lineTo(W - PAD_R, PAD_T)
  453 + ctx.stroke()
  454 +
  455 + list.slice(0, 12).forEach((item, idx) => {
  456 + const y = PAD_T + idx * rowH
  457 + ctx.fillStyle = '#0f172a'
  458 + ctx.font = '11px sans-serif'
  459 + ctx.textAlign = 'right'
  460 + ctx.textBaseline = 'middle'
  461 + const dn = (item.deviceName || item.dtuSn || '').slice(0, 8)
  462 + ctx.fillText(dn, PAD_L - 8, y + rowH / 2)
  463 +
  464 + const yS = item.yellowSeconds || 0
  465 + const rS = item.redSeconds || 0
  466 + const yW = (yS / maxVal) * plotW
  467 + const rW = (rS / maxVal) * plotW
  468 + ctx.fillStyle = '#e6a23c'
  469 + ctx.fillRect(PAD_L, y + 3, yW, barH)
  470 + ctx.fillStyle = '#f56c6c'
  471 + ctx.fillRect(PAD_L + yW, y + 3, rW, barH)
  472 + })
  473 +}
  474 +
  475 +function drawStackBar() {
  476 + const canvas = stackRef.value
  477 + const wrap = stackWrapRef.value
  478 + if (!canvas || !wrap) return
  479 + const list = utilData.deviceList || []
  480 + if (list.length === 0) {
  481 + const { ctx, W, H } = setupCanvas(canvas, Math.max(320, wrap.clientWidth), 260)
  482 + ctx.clearRect(0, 0, W, H)
  483 + ctx.fillStyle = '#999'
  484 + ctx.font = '13px sans-serif'
  485 + ctx.textAlign = 'center'
  486 + ctx.fillText('暂无数据', W / 2, H / 2)
  487 + return
  488 + }
  489 +
  490 + const sorted = sortMode.value === 'rate'
  491 + ? [...list].sort((a, b) => parseFloat(b.availabilityRatio || 0) - parseFloat(a.availabilityRatio || 0))
  492 + : [...list].sort((a, b) => (b.greenSeconds || 0) - (a.greenSeconds || 0))
  493 +
  494 + const PAD_L = 36
  495 + const PAD_R = 16
  496 + const PAD_T = 18
  497 + const PAD_B = 44
  498 + const colW = 18
  499 + const gap = 8
  500 + const minW = Math.max(320, wrap.clientWidth)
  501 + const cssW = Math.max(minW, PAD_L + PAD_R + sorted.length * (colW + gap) + gap)
  502 + const cssH = 280
  503 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  504 + ctx.clearRect(0, 0, W, H)
  505 +
  506 + const plotW = W - PAD_L - PAD_R
  507 + const plotH = H - PAD_T - PAD_B
  508 +
  509 + let maxVal = 1
  510 + sorted.forEach(item => {
  511 + const t = (item.greenSeconds || 0) + (item.yellowSeconds || 0) + (item.redSeconds || 0) + (item.offSeconds || 0)
  512 + maxVal = Math.max(maxVal, t)
  513 + })
  514 +
  515 + const maxHours = maxVal / 3600 || 1
  516 + const yTicks = [Math.ceil(maxHours), Math.ceil(maxHours * 0.66), Math.ceil(maxHours * 0.33), 0]
  517 + ctx.strokeStyle = '#f0f0f0'
  518 + ctx.lineWidth = 1
  519 + ctx.fillStyle = '#94a3b8'
  520 + ctx.font = '10px sans-serif'
  521 + ctx.textAlign = 'end'
  522 + yTicks.forEach(val => {
  523 + const py = PAD_T + plotH * (1 - val / maxHours)
  524 + ctx.fillText(val + '时', PAD_L - 4, py + 3)
  525 + ctx.beginPath()
  526 + ctx.moveTo(PAD_L, py)
  527 + ctx.lineTo(W - PAD_R, py)
  528 + ctx.stroke()
  529 + })
  530 +
  531 + ctx.strokeStyle = '#ddd'
  532 + ctx.beginPath()
  533 + ctx.moveTo(PAD_L, PAD_T + plotH)
  534 + ctx.lineTo(W - PAD_R, PAD_T + plotH)
  535 + ctx.stroke()
  536 +
  537 + const scale = plotH / maxVal
  538 + sorted.forEach((item, idx) => {
  539 + const px = PAD_L + gap + idx * (colW + gap)
  540 + const gS = item.greenSeconds || 0
  541 + const yS = item.yellowSeconds || 0
  542 + const rS = item.redSeconds || 0
  543 + const oS = item.offSeconds || 0
  544 + const total = gS + yS + rS + oS
  545 + if (total <= 0) return
  546 + let curY = PAD_T + plotH
  547 + const gH = gS * scale
  548 + curY -= gH
  549 + ctx.fillStyle = '#67c23a'
  550 + ctx.fillRect(px, curY, colW, gH)
  551 + const yH = yS * scale
  552 + curY -= yH
  553 + ctx.fillStyle = '#e6a23c'
  554 + ctx.fillRect(px, curY, colW, yH)
  555 + const rH = rS * scale
  556 + curY -= rH
  557 + ctx.fillStyle = '#f56c6c'
  558 + ctx.fillRect(px, curY, colW, rH)
  559 + const oH = oS * scale
  560 + curY -= oH
  561 + ctx.fillStyle = '#909399'
  562 + ctx.fillRect(px, curY, colW, oH)
  563 +
  564 + ctx.fillStyle = '#64748b'
  565 + ctx.font = '9px sans-serif'
  566 + ctx.textAlign = 'center'
  567 + ctx.save()
  568 + ctx.translate(px + colW / 2, PAD_T + plotH + 12)
  569 + ctx.rotate(-Math.PI / 6)
  570 + const dn = (item.deviceName || item.dtuSn || '').replace(/中速|高速|号机/g, '').slice(0, 6)
  571 + ctx.fillText(dn, 0, 0)
  572 + ctx.restore()
  573 + })
  574 +}
  575 +
  576 +function drawAll() {
  577 + drawTotalPie()
  578 + drawRatePie()
  579 + drawStatusPie()
  580 + drawAbnormal()
  581 + drawStackBar()
  582 +}
  583 +
  584 +function redrawStackBar() {
  585 + nextTick(drawStackBar)
  586 +}
  587 +
  588 +async function fetchUtilData() {
  589 + let startDate = ''
  590 + let endDate = ''
  591 + if (utilQueryMode.value === 'day') {
  592 + if (!utilDate.value) { ElMessage.warning('请选择查询日期'); return }
  593 + startDate = endDate = utilDate.value
  594 + } else if (utilQueryMode.value === 'week') {
  595 + if (!utilWeekDate.value) { ElMessage.warning('请选择查询周'); return }
  596 + const range = getWeekRange(utilWeekDate.value)
  597 + startDate = range.start
  598 + endDate = range.end
  599 + } else {
  600 + if (!utilMonthDate.value) { ElMessage.warning('请选择查询月份'); return }
  601 + const range = getMonthRange(utilMonthDate.value)
  602 + startDate = range.start
  603 + endDate = range.end
  604 + }
  605 +
  606 + utilLoading.value = true
  607 + try {
  608 + const res = await apiFetch(`/api/device/lampStatistics?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`)
  609 + const data = await res.json()
  610 + if (data.totalDuration) Object.assign(utilData.totalDuration, data.totalDuration)
  611 + if (data.availabilityRate) utilData.availabilityRate = data.availabilityRate
  612 + if (data.currentStatus) Object.assign(utilData.currentStatus, data.currentStatus)
  613 + utilData.abnormalRanking = data.abnormalRanking || []
  614 + utilData.deviceList = data.deviceList || []
  615 + await nextTick()
  616 + drawAll()
  617 + } catch (e) {
  618 + ElMessage.error('获取数据失败')
  619 + console.warn(e)
  620 + } finally {
  621 + utilLoading.value = false
  622 + }
  623 +}
  624 +
  625 +let ro = null
  626 +onMounted(() => {
  627 + setDefaultDate()
  628 + fetchUtilData()
  629 + const target = pieTotalWrapRef.value || null
  630 + if (target && 'ResizeObserver' in window) {
  631 + ro = new ResizeObserver(() => { drawAll() })
  632 + ro.observe(target)
  633 + }
  634 +})
  635 +
  636 +onBeforeUnmount(() => {
  637 + if (ro) {
  638 + ro.disconnect()
  639 + ro = null
  640 + }
  641 +})
  642 +
  643 +watch(() => utilData.totalDuration, () => nextTick(drawAll), { deep: true })
  644 +</script>
  645 +
  646 +<style scoped>
  647 +.util-h5 {
  648 + padding: 0;
  649 +}
  650 +
  651 +.toolbar {
  652 + display: flex;
  653 + align-items: center;
  654 + justify-content: space-between;
  655 + gap: 10px;
  656 +}
  657 +
  658 +.left {
  659 + display: flex;
  660 + align-items: center;
  661 + gap: 8px;
  662 + flex-wrap: wrap;
  663 +}
  664 +
  665 +.section {
  666 + background: #fff;
  667 + border-radius: 12px;
  668 + padding: 12px;
  669 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  670 + margin-top: 10px;
  671 +}
  672 +
  673 +.section-title {
  674 + font-weight: 700;
  675 + color: #0f172a;
  676 + font-size: 14px;
  677 + margin-bottom: 8px;
  678 +}
  679 +
  680 +.subline {
  681 + font-size: 12px;
  682 + color: #64748b;
  683 + margin-bottom: 8px;
  684 +}
  685 +
  686 +.legend {
  687 + display: grid;
  688 + grid-template-columns: repeat(2, minmax(0, 1fr));
  689 + gap: 8px;
  690 + margin-bottom: 8px;
  691 +}
  692 +
  693 +.legend-item {
  694 + display: grid;
  695 + grid-template-columns: 12px 1fr auto;
  696 + align-items: center;
  697 + gap: 6px;
  698 + font-size: 12px;
  699 + color: #475569;
  700 +}
  701 +
  702 +.legend-item .val {
  703 + justify-self: end;
  704 + color: #0f172a;
  705 + font-weight: 600;
  706 +}
  707 +
  708 +.legend-item .pct {
  709 + justify-self: end;
  710 + color: #64748b;
  711 + margin-left: 6px;
  712 +}
  713 +
  714 +.legend-total {
  715 + display: grid;
  716 + grid-template-columns: repeat(2, minmax(0, 1fr));
  717 + gap: 10px 12px;
  718 + margin-bottom: 8px;
  719 +}
  720 +
  721 +.legend-total-item {
  722 + display: flex;
  723 + align-items: center;
  724 + justify-content: space-between;
  725 + gap: 10px;
  726 + min-width: 0;
  727 +}
  728 +
  729 +.lt-left {
  730 + display: inline-flex;
  731 + align-items: center;
  732 + gap: 8px;
  733 + min-width: 0;
  734 +}
  735 +
  736 +.lt-left .name {
  737 + font-size: 12px;
  738 + color: #0f172a;
  739 + font-weight: 600;
  740 + white-space: nowrap;
  741 +}
  742 +
  743 +.lt-right {
  744 + text-align: right;
  745 + min-width: 0;
  746 +}
  747 +
  748 +.legend-total-item .val {
  749 + font-size: 12px;
  750 + color: #0f172a;
  751 + font-weight: 700;
  752 + line-height: 1.15;
  753 + white-space: nowrap;
  754 +}
  755 +
  756 +.legend-total-item .pct {
  757 + font-size: 12px;
  758 + color: #64748b;
  759 + line-height: 1.15;
  760 + margin-top: 4px;
  761 + white-space: nowrap;
  762 +}
  763 +
  764 +.dot {
  765 + width: 10px;
  766 + height: 10px;
  767 + border-radius: 999px;
  768 +}
  769 +
  770 +.pie-wrap {
  771 + display: flex;
  772 + justify-content: center;
  773 +}
  774 +
  775 +.pie-canvas {
  776 + width: 100%;
  777 + max-width: 320px;
  778 + aspect-ratio: 1 / 1;
  779 + display: block;
  780 +}
  781 +
  782 +.chart-wrap {
  783 + width: 100%;
  784 + overflow: hidden;
  785 +}
  786 +
  787 +.chart-canvas {
  788 + width: 100%;
  789 + display: block;
  790 +}
  791 +
  792 +.stack-toolbar {
  793 + margin-bottom: 10px;
  794 +}
  795 +
  796 +.stack-legend {
  797 + display: flex;
  798 + gap: 12px;
  799 + flex-wrap: wrap;
  800 + font-size: 12px;
  801 + color: #475569;
  802 + margin-bottom: 10px;
  803 +}
  804 +
  805 +.leg {
  806 + display: inline-flex;
  807 + align-items: center;
  808 + gap: 6px;
  809 +}
  810 +
  811 +.stack-scroll {
  812 + overflow-x: auto;
  813 + -webkit-overflow-scrolling: touch;
  814 +}
  815 +
  816 +.stack-wrap {
  817 + min-width: 100%;
  818 +}
  819 +
  820 +.stack-canvas {
  821 + height: 280px;
  822 + display: block;
  823 +}
  824 +</style>