Commit 81a9b1e4d327976148fd2ac95584d2ecbc4497c6

Authored by gesilong
1 parent 6e26edb2

commit:H5开发联调

1 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 6 <el-container>
4 7 <el-aside width="180px" class="sidebar">
5 8 <div class="logo-area">
... ... @@ -30,17 +33,22 @@
30 33 </template>
31 34
32 35 <script setup>
33   -import { computed, ref, provide } from 'vue'
  36 +import { computed, provide, watchEffect } from 'vue'
34 37 import { useRoute, useRouter } from 'vue-router'
  38 +import { setCorpCode } from './config/api.js'
35 39
36 40 const route = useRoute()
37 41 const router = useRouter()
38 42 const currentRoute = computed(() => route.path)
  43 +const isH5Route = computed(() => Boolean(route.meta?.h5))
39 44
40 45 // 从URL获取corpCode,全局共享(hash模式下参数在#后面,需用route.query)
41 46 // 使用computed确保路由切换时corpCode始终同步
42 47 const corpCode = computed(() => route.query.corpCode || '')
43 48 provide('corpCode', corpCode)
  49 +watchEffect(() => {
  50 + setCorpCode(corpCode.value)
  51 +})
44 52
45 53 // 给URL拼接corpCode
46 54 function withCorpCode(path) {
... ... @@ -56,6 +64,10 @@ function navigateTo(path) {
56 64 </script>
57 65
58 66 <style scoped>
  67 +.h5-container {
  68 + min-height: 100vh;
  69 + background-color: #f5f6f8;
  70 +}
59 71 .app-container {
60 72 height: 100vh;
61 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>
... ...
1 1 // API基础地址
2 2 const API_BASE = import.meta.env.VITE_API_BASE || ''
3 3
  4 +let corpCode = ''
  5 +
  6 +export function setCorpCode(value) {
  7 + corpCode = value ? String(value) : ''
  8 +}
  9 +
  10 +export function getCorpCode() {
  11 + return corpCode
  12 +}
  13 +
  14 +function isAbsoluteUrl(url) {
  15 + return /^https?:\/\//i.test(url)
  16 +}
  17 +
4 18 // 生产环境后端路径不带 /api/ 前缀,这里做兼容处理
5 19 export function getApiUrl(url) {
  20 + if (!url) return API_BASE
  21 + if (isAbsoluteUrl(url)) return url
6 22 const cleanUrl = url.replace(/^\/api\//, '/')
7 23 return API_BASE + cleanUrl
8 24 }
9 25
  26 +export function withCorpCode(url) {
  27 + const fullUrl = getApiUrl(url)
  28 + if (!corpCode) return fullUrl
  29 + try {
  30 + const u = new URL(fullUrl, window.location.origin)
  31 + if (!u.searchParams.has('corpCode')) u.searchParams.append('corpCode', corpCode)
  32 + return u.toString()
  33 + } catch {
  34 + const sep = fullUrl.includes('?') ? '&' : '?'
  35 + return `${fullUrl}${sep}corpCode=${encodeURIComponent(corpCode)}`
  36 + }
  37 +}
  38 +
  39 +export function apiFetch(url, options) {
  40 + return fetch(withCorpCode(url), options)
  41 +}
  42 +
10 43 export default API_BASE
... ...
... ... @@ -6,12 +6,52 @@ const routes = [
6 6 redirect: '/smart-light'
7 7 },
8 8 {
  9 + path: '/h5',
  10 + redirect: '/smart-light-h5'
  11 + },
  12 + {
9 13 path: '/smart-light',
10 14 name: 'SmartLight',
11 15 component: () => import('../views/SmartLight.vue'),
12 16 meta: { title: '动态监控' }
13 17 },
14 18 {
  19 + path: '/smart-light-h5',
  20 + name: 'SmartLightH5',
  21 + component: () => import('../views/SmartLightH5.vue'),
  22 + meta: { title: '智能灯', h5: true }
  23 + },
  24 + {
  25 + path: '/smart-light-h5/oee',
  26 + name: 'SmartLightH5Oee',
  27 + component: () => import('../views/SmartLightH5Oee.vue'),
  28 + meta: { title: 'OEE时序', h5: true }
  29 + },
  30 + {
  31 + path: '/smart-light-h5/utilization',
  32 + name: 'SmartLightH5Utilization',
  33 + component: () => import('../views/SmartLightH5Utilization.vue'),
  34 + meta: { title: '稼动率', h5: true }
  35 + },
  36 + {
  37 + path: '/energy-h5',
  38 + name: 'EnergyH5',
  39 + component: () => import('../views/EnergyH5.vue'),
  40 + meta: { title: '能耗', h5: true }
  41 + },
  42 + {
  43 + path: '/energy-h5/run-status',
  44 + name: 'EnergyH5RunStatus',
  45 + component: () => import('../views/EnergyH5RunStatus.vue'),
  46 + meta: { title: '运行状态', h5: true }
  47 + },
  48 + {
  49 + path: '/energy-h5/usage',
  50 + name: 'EnergyH5Usage',
  51 + component: () => import('../views/EnergyH5Usage.vue'),
  52 + meta: { title: '用时用电', h5: true }
  53 + },
  54 + {
15 55 path: '/energy',
16 56 name: 'Energy',
17 57 component: () => import('../views/Energy.vue'),
... ... @@ -24,4 +64,4 @@ const router = createRouter({
24 64 routes
25 65 })
26 66
27   -export default router
\ No newline at end of file
  67 +export default router
... ...
  1 +<template>
  2 + <div class="energy-h5">
  3 + <div class="h5-header">
  4 + <div class="h5-top">
  5 + <div class="h5-title">云物联网平台</div>
  6 + <div class="h5-tabs">
  7 + <button
  8 + type="button"
  9 + :class="['h5-tab', { active: activeTab === 'smart' }]"
  10 + @click="goH5('/smart-light-h5')"
  11 + >
  12 + 智能灯
  13 + </button>
  14 + <button
  15 + type="button"
  16 + :class="['h5-tab', { active: activeTab === 'energy' }]"
  17 + @click="goH5('/energy-h5')"
  18 + >
  19 + 能耗
  20 + </button>
  21 + </div>
  22 + </div>
  23 +
  24 + <el-tabs v-model="currentStatus" class="h5-subtabs" :stretch="true">
  25 + <el-tab-pane label="实时状态" name="realtime" />
  26 + <el-tab-pane label="时序状态" name="timeseries" />
  27 + <el-tab-pane label="稼动率" name="utilization" />
  28 + <el-tab-pane label="能耗效率" name="efficiency" />
  29 + </el-tabs>
  30 + </div>
  31 +
  32 + <div class="h5-content">
  33 + <KeepAlive>
  34 + <EnergyH5RealtimeTab v-if="currentStatus === 'realtime'" />
  35 + <EnergyH5TimeseriesTab v-else-if="currentStatus === 'timeseries'" />
  36 + <EnergyH5UtilizationTab v-else-if="currentStatus === 'utilization'" />
  37 + <EnergyH5EfficiencyTab v-else />
  38 + </KeepAlive>
  39 + </div>
  40 + </div>
  41 +</template>
  42 +
  43 +<script setup>
  44 +import { computed, ref } from 'vue'
  45 +import { useRoute, useRouter } from 'vue-router'
  46 +import EnergyH5RealtimeTab from '../components/h5/EnergyH5RealtimeTab.vue'
  47 +import EnergyH5TimeseriesTab from '../components/h5/EnergyH5TimeseriesTab.vue'
  48 +import EnergyH5UtilizationTab from '../components/h5/EnergyH5UtilizationTab.vue'
  49 +import EnergyH5EfficiencyTab from '../components/h5/EnergyH5EfficiencyTab.vue'
  50 +
  51 +const route = useRoute()
  52 +const router = useRouter()
  53 +
  54 +const activeTab = computed(() => (route.path === '/smart-light-h5' ? 'smart' : 'energy'))
  55 +
  56 +function goH5(path) {
  57 + router.push({ path, query: route.query })
  58 +}
  59 +
  60 +const currentStatus = ref('realtime')
  61 +</script>
  62 +
  63 +<style scoped>
  64 +.energy-h5 {
  65 + min-height: 100vh;
  66 + background-color: #f5f6f8;
  67 +}
  68 +
  69 +.h5-header {
  70 + position: sticky;
  71 + top: 0;
  72 + z-index: 10;
  73 + background-color: #fff;
  74 + padding: 12px 12px 10px;
  75 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  76 +}
  77 +
  78 +.h5-top {
  79 + display: flex;
  80 + align-items: center;
  81 + justify-content: space-between;
  82 + gap: 10px;
  83 + margin-bottom: 10px;
  84 +}
  85 +
  86 +.h5-title {
  87 + font-size: 16px;
  88 + font-weight: 700;
  89 + line-height: 1.2;
  90 + color: #111827;
  91 +}
  92 +
  93 +.h5-tabs {
  94 + display: inline-flex;
  95 + border: 1px solid rgba(0, 0, 0, 0.08);
  96 + border-radius: 999px;
  97 + overflow: hidden;
  98 +}
  99 +
  100 +.h5-subtabs {
  101 + margin-top: 10px;
  102 +}
  103 +
  104 +.h5-subtabs :deep(.el-tabs__header) {
  105 + margin: 0;
  106 +}
  107 +
  108 +.h5-subtabs :deep(.el-tabs__nav-wrap) {
  109 + padding: 0;
  110 +}
  111 +
  112 +.h5-subtabs :deep(.el-tabs__item) {
  113 + font-size: 12px;
  114 + padding: 0 10px;
  115 + height: 34px;
  116 + line-height: 34px;
  117 +}
  118 +
  119 +.h5-subtabs :deep(.el-tabs__active-bar) {
  120 + height: 2px;
  121 +}
  122 +
  123 +.h5-tab {
  124 + appearance: none;
  125 + border: 0;
  126 + background: transparent;
  127 + padding: 6px 12px;
  128 + font-size: 12px;
  129 + color: #334155;
  130 +}
  131 +
  132 +.h5-tab.active {
  133 + background: rgba(64, 158, 255, 0.12);
  134 + color: #1d4ed8;
  135 +}
  136 +
  137 +.h5-content {
  138 + padding: 12px;
  139 +}
  140 +</style>
... ...
  1 +<template>
  2 + <div class="h5-page">
  3 + <div class="topbar">
  4 + <el-button text @click="goBack">返回</el-button>
  5 + <div class="title">运行状态</div>
  6 + <div class="spacer"></div>
  7 + </div>
  8 +
  9 + <div class="content">
  10 + <div class="device">{{ deviceName || '-' }}</div>
  11 + <el-empty v-if="!dtuSn" description="缺少 dtuSn" />
  12 +
  13 + <template v-else>
  14 + <div class="query">
  15 + <div class="query-left">
  16 + <div class="query-label">日查询</div>
  17 + <el-date-picker
  18 + v-model="queryDate"
  19 + type="date"
  20 + value-format="YYYY-MM-DD"
  21 + placeholder="选择日期"
  22 + style="width: 150px;"
  23 + :disabled-date="disabledDateFuture"
  24 + @change="fetchData"
  25 + />
  26 + </div>
  27 + <el-button type="primary" size="small" :loading="loading" @click="fetchData">查询</el-button>
  28 + </div>
  29 +
  30 + <div class="section">
  31 + <div class="section-title">
  32 + 设备运行状态图
  33 + <span class="leg"><i class="dot g"></i>运行</span>
  34 + <span class="leg"><i class="dot r"></i>停机</span>
  35 + <span class="leg"><i class="dot y"></i>待机</span>
  36 + <span class="leg"><i class="dot gy"></i>离线</span>
  37 + </div>
  38 + <div
  39 + ref="timelineWrapRef"
  40 + class="timeline-wrap"
  41 + @wheel.prevent="onTimelineWheel"
  42 + @pointerdown="onTimelinePointerDown"
  43 + @pointermove="onTimelinePointerMove"
  44 + @pointerup="onTimelinePointerUp"
  45 + @pointercancel="onTimelinePointerUp"
  46 + @pointerleave="onTimelinePointerLeave"
  47 + >
  48 + <canvas ref="timelineCanvasRef" class="timeline-canvas"></canvas>
  49 + </div>
  50 + <div v-if="timelineSelected.show" class="tip-card">
  51 + <div class="tip-title">{{ timelineSelected.statusLabel }}</div>
  52 + <div class="tip-line">开始时间 {{ timelineSelected.startTime }}</div>
  53 + <div class="tip-line">结束时间 {{ timelineSelected.endTime }}</div>
  54 + <div class="tip-line">持续时长 {{ timelineSelected.duration }}</div>
  55 + </div>
  56 + </div>
  57 +
  58 + <div class="section">
  59 + <div class="section-title">设备用电量</div>
  60 + <div class="subline">总用电量:{{ totalKwh.toFixed(2) }} kw·h</div>
  61 + <div class="bar-scroll">
  62 + <div ref="barWrapRef" class="bar-wrap">
  63 + <canvas
  64 + ref="barCanvasRef"
  65 + class="bar-canvas"
  66 + @pointerdown="onBarPointerDown"
  67 + @pointermove="onBarPointerMove"
  68 + @pointerup="onBarPointerUp"
  69 + @pointercancel="onBarPointerUp"
  70 + @pointerleave="onBarPointerLeave"
  71 + ></canvas>
  72 + </div>
  73 + </div>
  74 + <div v-if="barSelected.show" class="tip-card">
  75 + <div class="tip-title">{{ barSelected.hour }}时</div>
  76 + <div class="tip-line">用电量 {{ barSelected.value }} kw·h</div>
  77 + </div>
  78 + </div>
  79 +
  80 + <div class="section">
  81 + <div class="section-title">设备运行状态明细</div>
  82 + <div class="pie-box">
  83 + <div ref="pieWrapRef" class="pie-wrap">
  84 + <canvas
  85 + ref="pieCanvasRef"
  86 + class="pie-canvas"
  87 + @pointerdown="onPieTap"
  88 + ></canvas>
  89 + </div>
  90 + <div class="pie-stats">
  91 + <div class="stat-row">
  92 + <div class="stat-k">总时长</div>
  93 + <div class="stat-v">{{ totalDurationFormatted || '-' }}</div>
  94 + </div>
  95 + <div class="stat-row">
  96 + <div class="stat-k">总用电量</div>
  97 + <div class="stat-v">{{ totalKwh.toFixed(2) }} kw·h</div>
  98 + </div>
  99 + </div>
  100 + </div>
  101 + <div v-if="pieSelected.show" class="tip-card" style="margin-top: 10px;">
  102 + <div class="tip-title">{{ pieSelected.statusLabel }}</div>
  103 + <div class="tip-line">占比 {{ pieSelected.percent }}%</div>
  104 + </div>
  105 + <el-empty v-if="!loading && oeeList.length === 0" description="暂无数据" />
  106 + <div v-else class="detail-list">
  107 + <div v-for="(item, idx) in oeeList" :key="idx" class="detail-item">
  108 + <div class="d-row">
  109 + <div class="d-k">开始</div>
  110 + <div class="d-v">{{ formatTime(item.startTime) }}</div>
  111 + </div>
  112 + <div class="d-row">
  113 + <div class="d-k">结束</div>
  114 + <div class="d-v">{{ formatTime(item.endTime) }}</div>
  115 + </div>
  116 + <div class="d-row">
  117 + <div class="d-k">状态</div>
  118 + <div class="d-v">
  119 + <span class="d-status">
  120 + <i :class="['dot', statusDotClass(item.runStatus)]"></i>
  121 + {{ statusLabel(item.runStatus) }}
  122 + </span>
  123 + </div>
  124 + </div>
  125 + <div class="d-row">
  126 + <div class="d-k">时长</div>
  127 + <div class="d-v">{{ formatDuration(item.duration) }}</div>
  128 + </div>
  129 + </div>
  130 + </div>
  131 + </div>
  132 + </template>
  133 + </div>
  134 + </div>
  135 +</template>
  136 +
  137 +<script setup>
  138 +import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
  139 +import { useRoute, useRouter } from 'vue-router'
  140 +import { ElMessage } from 'element-plus'
  141 +import { apiFetch } from '../config/api.js'
  142 +
  143 +const route = useRoute()
  144 +const router = useRouter()
  145 +
  146 +const dtuSn = computed(() => String(route.query.dtuSn || ''))
  147 +const deviceName = computed(() => String(route.query.deviceName || ''))
  148 +
  149 +function goBack() {
  150 + router.back()
  151 +}
  152 +
  153 +const queryDate = ref(new Date().toISOString().slice(0, 10))
  154 +const loading = ref(false)
  155 +
  156 +const oeeList = ref([])
  157 +const kwhList = ref([])
  158 +const totalKwh = ref(0)
  159 +const statusStats = ref([])
  160 +const totalDurationFormatted = ref('')
  161 +
  162 +function disabledDateFuture(time) {
  163 + return time.getTime() > Date.now()
  164 +}
  165 +
  166 +const STATUS_MAP = { 0: '离线', 1: '停机', 2: '运行', 3: '待机' }
  167 +const STATUS_COLORS = { 0: '#909399', 1: '#e74c3c', 2: '#67c23a', 3: '#c5d94e' }
  168 +function statusLabel(s) {
  169 + return STATUS_MAP[Number(s)] || '未知'
  170 +}
  171 +function statusDotClass(s) {
  172 + return { 0: 'gy', 1: 'r', 2: 'g', 3: 'y' }[Number(s)] || 'gy'
  173 +}
  174 +
  175 +function formatDuration(seconds) {
  176 + if (!seconds && seconds !== 0) return '0秒'
  177 + seconds = Number(seconds)
  178 + if (seconds <= 0) return '0秒'
  179 + const h = Math.floor(seconds / 3600)
  180 + const m = Math.floor((seconds % 3600) / 60)
  181 + const s = Math.floor(seconds % 60)
  182 + let str = ''
  183 + if (h > 0) str += h + '时'
  184 + if (m > 0) str += m + '分'
  185 + if (s > 0 || !str) str += s + '秒'
  186 + return str
  187 +}
  188 +
  189 +function formatTime(ts) {
  190 + if (!ts) return '-'
  191 + const d = new Date(String(ts).replace(/-/g, '/'))
  192 + if (Number.isNaN(d.getTime())) return String(ts).slice(0, 19)
  193 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(
  194 + d.getMinutes()
  195 + ).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
  196 +}
  197 +
  198 +async function fetchData() {
  199 + if (!dtuSn.value || !queryDate.value) return
  200 + loading.value = true
  201 + try {
  202 + const res = await apiFetch(`/api/energy/detail?dtuSn=${encodeURIComponent(dtuSn.value)}&date=${encodeURIComponent(queryDate.value)}`)
  203 + const data = await res.json()
  204 + if (data?.code !== 200) throw new Error('bad response')
  205 + oeeList.value = data?.oeeData?.list || []
  206 + statusStats.value = data?.oeeData?.statusStats || []
  207 + totalDurationFormatted.value = data?.oeeData?.totalDurationFormatted || ''
  208 + kwhList.value = data?.kwhData?.list || []
  209 + totalKwh.value = Number(data?.kwhData?.totalKwh || 0)
  210 + timelineSelected.value.show = false
  211 + barSelected.value.show = false
  212 + pieSelected.value.show = false
  213 + zoomLevel.value = 1
  214 + viewOffsetX.value = 0
  215 + await nextTick()
  216 + drawAll()
  217 + } catch (e) {
  218 + ElMessage.error('获取数据失败')
  219 + console.warn(e)
  220 + } finally {
  221 + loading.value = false
  222 + }
  223 +}
  224 +
  225 +const dpr = window.devicePixelRatio || 1
  226 +function setupCanvas(canvas, cssW, cssH) {
  227 + canvas.style.width = cssW + 'px'
  228 + canvas.style.height = cssH + 'px'
  229 + canvas.width = Math.round(cssW * dpr)
  230 + canvas.height = Math.round(cssH * dpr)
  231 + const ctx = canvas.getContext('2d')
  232 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  233 + return { ctx, W: cssW, H: cssH }
  234 +}
  235 +
  236 +const timelineCanvasRef = ref(null)
  237 +const timelineWrapRef = ref(null)
  238 +const barCanvasRef = ref(null)
  239 +const barWrapRef = ref(null)
  240 +const pieCanvasRef = ref(null)
  241 +const pieWrapRef = ref(null)
  242 +
  243 +const zoomLevel = ref(1)
  244 +const viewOffsetX = ref(0)
  245 +let timelineRects = []
  246 +let barRects = []
  247 +
  248 +const timelineSelected = ref({
  249 + show: false,
  250 + startTime: '',
  251 + endTime: '',
  252 + statusLabel: '',
  253 + duration: ''
  254 +})
  255 +const barSelected = ref({ show: false, hour: '', value: '' })
  256 +const pieSelected = ref({ show: false, status: -1, statusLabel: '', percent: '0.00' })
  257 +
  258 +function clamp(v, min, max) {
  259 + return Math.max(min, Math.min(max, v))
  260 +}
  261 +
  262 +function getDayBounds() {
  263 + const dateStr = queryDate.value || ''
  264 + const start = dateStr ? new Date(dateStr) : new Date()
  265 + start.setHours(0, 0, 0, 0)
  266 + const end = new Date(start)
  267 + end.setHours(23, 59, 59, 999)
  268 + return { startMs: start.getTime(), endMs: end.getTime() + 1 }
  269 +}
  270 +
  271 +function parseTimeMs(v) {
  272 + if (!v) return NaN
  273 + const d = new Date(String(v).replace(/-/g, '/'))
  274 + return d.getTime()
  275 +}
  276 +
  277 +function buildSegments() {
  278 + const { startMs } = getDayBounds()
  279 + return (oeeList.value || [])
  280 + .map((item, idx) => {
  281 + const sMs = parseTimeMs(item.startTime)
  282 + const eMs = parseTimeMs(item.endTime)
  283 + const durSec = Number(item.duration || 0)
  284 + const startMs2 = Number.isFinite(sMs) ? sMs : NaN
  285 + let endMs2 = Number.isFinite(eMs) ? eMs : NaN
  286 + if (!Number.isFinite(endMs2) && Number.isFinite(startMs2) && durSec > 0) endMs2 = startMs2 + durSec * 1000
  287 + if (!Number.isFinite(startMs2) || !Number.isFinite(endMs2)) return null
  288 + const startSec = clamp((startMs2 - startMs) / 1000, 0, 86400)
  289 + const endSec = clamp((endMs2 - startMs) / 1000, 0, 86400)
  290 + const st = Number(item.runStatus ?? 0)
  291 + return {
  292 + index: idx,
  293 + startSec,
  294 + endSec: Math.max(startSec, endSec),
  295 + runStatus: st,
  296 + startTime: item.startTime,
  297 + endTime: item.endTime,
  298 + duration: formatDuration(item.duration)
  299 + }
  300 + })
  301 + .filter(Boolean)
  302 +}
  303 +
  304 +function drawTimeline() {
  305 + const canvas = timelineCanvasRef.value
  306 + const wrap = timelineWrapRef.value
  307 + if (!canvas || !wrap) return
  308 + const cssW = Math.max(320, wrap.clientWidth)
  309 + const cssH = 140
  310 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  311 + ctx.clearRect(0, 0, W, H)
  312 +
  313 + const PAD_L = 10
  314 + const PAD_R = 10
  315 + const PAD_T = 26
  316 + const PAD_B = 26
  317 + const plotW = W - PAD_L - PAD_R
  318 + const plotH = H - PAD_T - PAD_B
  319 +
  320 + ctx.fillStyle = '#64748b'
  321 + ctx.font = '10px sans-serif'
  322 + ctx.textAlign = 'center'
  323 + ctx.textBaseline = 'middle'
  324 + for (let i = 0; i <= 6; i++) {
  325 + const hour = i * 4
  326 + const x = PAD_L + (plotW * i) / 6
  327 + ctx.fillText(String(hour).padStart(2, '0') + ':00', x, 12)
  328 + if (i > 0) {
  329 + ctx.strokeStyle = 'rgba(0,0,0,0.06)'
  330 + ctx.beginPath()
  331 + ctx.moveTo(x, PAD_T)
  332 + ctx.lineTo(x, PAD_T + plotH)
  333 + ctx.stroke()
  334 + }
  335 + }
  336 +
  337 + const z = zoomLevel.value
  338 + const maxOffset = Math.max(0, plotW * (z - 1))
  339 + viewOffsetX.value = clamp(viewOffsetX.value, 0, maxOffset)
  340 + const vo = viewOffsetX.value
  341 +
  342 + ctx.fillStyle = 'rgba(15,23,42,0.04)'
  343 + ctx.fillRect(PAD_L, PAD_T, plotW, plotH)
  344 +
  345 + const segs = buildSegments()
  346 + timelineRects = []
  347 + const barY = PAD_T + 14
  348 + const barH = plotH - 28
  349 +
  350 + segs.forEach(seg => {
  351 + const sx = PAD_L + (seg.startSec / 86400) * plotW * z - vo
  352 + const ex = PAD_L + (seg.endSec / 86400) * plotW * z - vo
  353 + const w = Math.max(1, ex - sx)
  354 + if (sx > PAD_L + plotW || sx + w < PAD_L) return
  355 + const x = clamp(sx, PAD_L, PAD_L + plotW)
  356 + const w2 = clamp(sx + w, PAD_L, PAD_L + plotW) - x
  357 + ctx.fillStyle = STATUS_COLORS[seg.runStatus] || '#909399'
  358 + ctx.fillRect(x, barY, w2, barH)
  359 + timelineRects.push({ x, y: barY, w: w2, h: barH, seg })
  360 + })
  361 +
  362 + ctx.strokeStyle = 'rgba(0,0,0,0.08)'
  363 + ctx.strokeRect(PAD_L, barY, plotW, barH)
  364 +
  365 + if (z > 1) {
  366 + const ratio = vo / maxOffset
  367 + const trackY = H - 12
  368 + const trackW = plotW
  369 + const thumbW = Math.max(40, trackW / z)
  370 + const thumbX = PAD_L + (trackW - thumbW) * ratio
  371 + ctx.fillStyle = 'rgba(148,163,184,0.35)'
  372 + ctx.fillRect(PAD_L, trackY, trackW, 4)
  373 + ctx.fillStyle = 'rgba(100,116,139,0.55)'
  374 + ctx.fillRect(thumbX, trackY, thumbW, 4)
  375 + }
  376 +}
  377 +
  378 +let tlPointerDown = false
  379 +let tlStartX = 0
  380 +let tlStartOffset = 0
  381 +let tlMoved = false
  382 +
  383 +function onTimelinePointerDown(e) {
  384 + tlPointerDown = true
  385 + tlMoved = false
  386 + tlStartX = e.clientX
  387 + tlStartOffset = viewOffsetX.value
  388 + e.currentTarget.setPointerCapture?.(e.pointerId)
  389 +}
  390 +
  391 +function onTimelinePointerMove(e) {
  392 + if (!tlPointerDown) return
  393 + const dx = e.clientX - tlStartX
  394 + if (Math.abs(dx) > 3) tlMoved = true
  395 + const wrap = timelineWrapRef.value
  396 + if (!wrap) return
  397 + const cssW = Math.max(320, wrap.clientWidth)
  398 + const plotW = cssW - 20
  399 + const z = zoomLevel.value
  400 + const maxOffset = Math.max(0, plotW * (z - 1))
  401 + viewOffsetX.value = clamp(tlStartOffset - dx, 0, maxOffset)
  402 + drawTimeline()
  403 +}
  404 +
  405 +function onTimelinePointerUp(e) {
  406 + if (!tlPointerDown) return
  407 + tlPointerDown = false
  408 + if (!tlMoved) {
  409 + const rect = e.currentTarget.getBoundingClientRect()
  410 + const mx = e.clientX - rect.left
  411 + const my = e.clientY - rect.top
  412 + const hit = timelineRects.find(r => mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h)
  413 + if (hit) {
  414 + timelineSelected.value = {
  415 + show: true,
  416 + startTime: formatTime(hit.seg.startTime),
  417 + endTime: formatTime(hit.seg.endTime),
  418 + statusLabel: statusLabel(hit.seg.runStatus),
  419 + duration: hit.seg.duration
  420 + }
  421 + } else {
  422 + timelineSelected.value = { show: false, startTime: '', endTime: '', statusLabel: '', duration: '' }
  423 + }
  424 + }
  425 + e.currentTarget.releasePointerCapture?.(e.pointerId)
  426 +}
  427 +
  428 +function onTimelinePointerLeave() {
  429 + tlPointerDown = false
  430 +}
  431 +
  432 +function onTimelineWheel(e) {
  433 + const wrap = timelineWrapRef.value
  434 + if (!wrap) return
  435 + const cssW = Math.max(320, wrap.clientWidth)
  436 + const plotW = cssW - 20
  437 + const oldZ = zoomLevel.value
  438 + const delta = e.deltaY > 0 ? -0.15 : 0.15
  439 + const nextZ = clamp(oldZ + delta, 1, 6)
  440 + if (nextZ === oldZ) return
  441 + const maxOffsetOld = Math.max(0, plotW * (oldZ - 1))
  442 + const anchor = maxOffsetOld > 0 ? viewOffsetX.value / maxOffsetOld : 0
  443 + zoomLevel.value = nextZ
  444 + const maxOffsetNew = Math.max(0, plotW * (nextZ - 1))
  445 + viewOffsetX.value = clamp(anchor * maxOffsetNew, 0, maxOffsetNew)
  446 + drawTimeline()
  447 +}
  448 +
  449 +function drawBars() {
  450 + const canvas = barCanvasRef.value
  451 + const wrap = barWrapRef.value
  452 + if (!canvas || !wrap) return
  453 + const list = kwhList.value || []
  454 + const PAD_L = 34
  455 + const PAD_R = 16
  456 + const PAD_T = 18
  457 + const PAD_B = 34
  458 + const colW = 12
  459 + const gap = 6
  460 + const baseW = Math.max(320, wrap.clientWidth)
  461 + const cssW = Math.max(baseW, PAD_L + PAD_R + list.length * (colW + gap) + gap)
  462 + const cssH = 220
  463 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  464 + ctx.clearRect(0, 0, W, H)
  465 +
  466 + const plotW = W - PAD_L - PAD_R
  467 + const plotH = H - PAD_T - PAD_B
  468 + const maxVal = Math.max(1, ...list.map(i => Number(i.value || 0)))
  469 +
  470 + ctx.strokeStyle = 'rgba(0,0,0,0.06)'
  471 + ctx.lineWidth = 1
  472 + ctx.fillStyle = '#94a3b8'
  473 + ctx.font = '10px sans-serif'
  474 + ctx.textAlign = 'end'
  475 + for (let i = 0; i <= 4; i++) {
  476 + const v = (maxVal * i) / 4
  477 + const y = PAD_T + plotH * (1 - i / 4)
  478 + ctx.fillText(v.toFixed(1), PAD_L - 4, y + 3)
  479 + if (i > 0) {
  480 + ctx.beginPath()
  481 + ctx.moveTo(PAD_L, y)
  482 + ctx.lineTo(W - PAD_R, y)
  483 + ctx.stroke()
  484 + }
  485 + }
  486 +
  487 + ctx.strokeStyle = 'rgba(0,0,0,0.08)'
  488 + ctx.lineWidth = 1.5
  489 + ctx.beginPath()
  490 + ctx.moveTo(PAD_L, PAD_T + plotH)
  491 + ctx.lineTo(W - PAD_R, PAD_T + plotH)
  492 + ctx.stroke()
  493 +
  494 + barRects = []
  495 + list.forEach((item, idx) => {
  496 + const val = Number(item.value || 0)
  497 + const h = (val / maxVal) * plotH
  498 + const x = PAD_L + gap + idx * (colW + gap)
  499 + const y = PAD_T + plotH - h
  500 + const isSel = barSelected.value.show && Number(barSelected.value.hour) === idx + 1
  501 + ctx.fillStyle = isSel ? 'rgba(64,158,255,0.9)' : 'rgba(64,158,255,0.55)'
  502 + ctx.fillRect(x, y, colW, h)
  503 + if ((idx + 1) % 3 === 0 || idx === 0 || idx === list.length - 1) {
  504 + ctx.save()
  505 + ctx.fillStyle = '#64748b'
  506 + ctx.font = '9px sans-serif'
  507 + ctx.textAlign = 'center'
  508 + ctx.translate(x + colW / 2, PAD_T + plotH + 14)
  509 + ctx.fillText(String(idx + 1), 0, 0)
  510 + ctx.restore()
  511 + }
  512 + barRects.push({ x, y, w: colW, h, idx, val })
  513 + })
  514 +}
  515 +
  516 +let pieRanges = []
  517 +function buildPieSlices() {
  518 + const stats = statusStats.value || []
  519 + if (!stats.length) return []
  520 + const totalPct = stats.reduce((sum, s) => sum + (Number(s.percent) || 0), 0)
  521 + const MIN_SLICE_DEG = 2
  522 + const sliceInfos = stats.map((s, i) => {
  523 + const pct = Number(s.percent) || 0
  524 + const deg = pct > 0 && totalPct > 0 ? (pct / totalPct) * 360 : MIN_SLICE_DEG
  525 + return { index: i, status: Number(s.status), percent: pct, deg }
  526 + })
  527 + const hasNonZero = sliceInfos.some(si => si.percent > 0)
  528 + if (!hasNonZero && sliceInfos.length > 0) {
  529 + const eq = 360 / sliceInfos.length
  530 + sliceInfos.forEach(si => (si.deg = eq))
  531 + } else if (hasNonZero) {
  532 + const usedByNonZero = sliceInfos.filter(si => si.percent > 0).reduce((s, si) => s + si.deg, 0)
  533 + const zeroCount = sliceInfos.filter(si => si.percent === 0).length
  534 + const remaining = Math.max(0, 360 - usedByNonZero - zeroCount * MIN_SLICE_DEG)
  535 + sliceInfos.forEach(si => {
  536 + if (si.percent > 0) si.deg = si.deg + (si.deg / usedByNonZero) * remaining
  537 + })
  538 + }
  539 + let startAngle = -Math.PI / 2
  540 + sliceInfos.forEach(si => {
  541 + const sliceAngle = (si.deg / 180) * Math.PI
  542 + si.startAngle = startAngle
  543 + si.endAngle = startAngle + sliceAngle
  544 + si.midAngle = startAngle + sliceAngle / 2
  545 + startAngle = si.endAngle
  546 + })
  547 + return sliceInfos
  548 +}
  549 +
  550 +function drawPie() {
  551 + const canvas = pieCanvasRef.value
  552 + const wrap = pieWrapRef.value
  553 + if (!canvas || !wrap) return
  554 + const cssW = Math.max(320, wrap.clientWidth)
  555 + const cssH = 240
  556 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  557 + ctx.clearRect(0, 0, W, H)
  558 +
  559 + const slices = buildPieSlices()
  560 + pieRanges = []
  561 + if (!slices.length) {
  562 + ctx.fillStyle = '#999'
  563 + ctx.font = '13px sans-serif'
  564 + ctx.textAlign = 'center'
  565 + ctx.textBaseline = 'middle'
  566 + ctx.fillText('暂无数据', W / 2, H / 2)
  567 + return
  568 + }
  569 +
  570 + const cx = W * 0.42
  571 + const cy = H * 0.54
  572 + const radius = Math.min(cx, cy) - 18
  573 + slices.forEach(si => {
  574 + pieRanges.push({ ...si, cx, cy, radius })
  575 + })
  576 +
  577 + const selectedStatus = pieSelected.value.show ? pieSelected.value.status : -999
  578 + slices.forEach(si => {
  579 + if (si.status === selectedStatus) return
  580 + ctx.beginPath()
  581 + ctx.moveTo(cx, cy)
  582 + ctx.arc(cx, cy, radius, si.startAngle, si.endAngle)
  583 + ctx.closePath()
  584 + ctx.fillStyle = STATUS_COLORS[si.status] || '#ccc'
  585 + ctx.globalAlpha = 0.85
  586 + ctx.fill()
  587 + ctx.globalAlpha = 1
  588 + })
  589 +
  590 + const sel = slices.find(s => s.status === selectedStatus)
  591 + if (sel) {
  592 + const expandR = radius + 6
  593 + const offset = 6
  594 + const ox = cx + Math.cos(sel.midAngle) * offset
  595 + const oy = cy + Math.sin(sel.midAngle) * offset
  596 + ctx.save()
  597 + ctx.shadowColor = 'rgba(0,0,0,0.25)'
  598 + ctx.shadowBlur = 12
  599 + ctx.shadowOffsetY = 3
  600 + ctx.beginPath()
  601 + ctx.moveTo(ox, oy)
  602 + ctx.arc(ox, oy, expandR, sel.startAngle, sel.endAngle)
  603 + ctx.closePath()
  604 + ctx.fillStyle = STATUS_COLORS[sel.status] || '#ccc'
  605 + ctx.fill()
  606 + ctx.restore()
  607 + }
  608 +
  609 + slices.forEach(si => {
  610 + const { midAngle } = si
  611 + const lineStartX = cx + Math.cos(midAngle) * radius
  612 + const lineStartY = cy + Math.sin(midAngle) * radius
  613 + const elbowLen = 12
  614 + const elbowX = cx + Math.cos(midAngle) * (radius + elbowLen)
  615 + const elbowY = cy + Math.sin(midAngle) * (radius + elbowLen)
  616 + const leftSide = midAngle > Math.PI / 2 && midAngle <= Math.PI * 1.5
  617 + const textDir = leftSide ? -1 : 1
  618 + const textLen = 34
  619 + const lineEndX = elbowX + textDir * textLen
  620 + const lineEndY = elbowY
  621 +
  622 + ctx.strokeStyle = 'rgba(0,0,0,0.25)'
  623 + ctx.lineWidth = 0.8
  624 + ctx.beginPath()
  625 + ctx.moveTo(lineStartX, lineStartY)
  626 + ctx.lineTo(elbowX, elbowY)
  627 + ctx.lineTo(lineEndX, lineEndY)
  628 + ctx.stroke()
  629 +
  630 + const labelText = `${statusLabel(si.status)}${si.percent.toFixed(si.percent % 1 === 0 ? 0 : 2)}%`
  631 + ctx.fillStyle = '#475569'
  632 + ctx.font = '11px sans-serif'
  633 + ctx.textAlign = leftSide ? 'right' : 'left'
  634 + ctx.textBaseline = 'middle'
  635 + ctx.fillText(labelText, lineEndX + textDir * 4, lineEndY)
  636 + })
  637 +}
  638 +
  639 +function onPieTap(e) {
  640 + const canvas = pieCanvasRef.value
  641 + if (!canvas || !pieRanges.length) return
  642 + const rect = canvas.getBoundingClientRect()
  643 + const mx = e.clientX - rect.left
  644 + const my = e.clientY - rect.top
  645 + const first = pieRanges[0]
  646 + const cx = first.cx
  647 + const cy = first.cy
  648 + const dx = mx - cx
  649 + const dy = my - cy
  650 + const dist = Math.sqrt(dx * dx + dy * dy)
  651 + if (dist > first.radius) {
  652 + pieSelected.value = { show: false, status: -1, statusLabel: '', percent: '0.00' }
  653 + drawPie()
  654 + return
  655 + }
  656 + let angle = Math.atan2(dy, dx)
  657 + const inRange = (a, sa, ea) => {
  658 + let na = a - sa
  659 + let nea = ea - sa
  660 + if (nea < 0) nea += Math.PI * 2
  661 + if (na < 0) na += Math.PI * 2
  662 + return na >= 0 && na <= nea
  663 + }
  664 + const hit = [...pieRanges].reverse().find(seg => inRange(angle, seg.startAngle, seg.endAngle))
  665 + if (!hit) {
  666 + pieSelected.value = { show: false, status: -1, statusLabel: '', percent: '0.00' }
  667 + } else {
  668 + pieSelected.value = {
  669 + show: true,
  670 + status: hit.status,
  671 + statusLabel: statusLabel(hit.status),
  672 + percent: (hit.percent || 0).toFixed(2)
  673 + }
  674 + }
  675 + drawPie()
  676 +}
  677 +
  678 +let barPointerDown = false
  679 +let barMoved = false
  680 +let barStartX = 0
  681 +let barStartScrollLeft = 0
  682 +
  683 +function onBarPointerDown(e) {
  684 + barPointerDown = true
  685 + barMoved = false
  686 + barStartX = e.clientX
  687 + barStartScrollLeft = e.currentTarget.parentElement?.scrollLeft || 0
  688 + e.currentTarget.setPointerCapture?.(e.pointerId)
  689 +}
  690 +
  691 +function onBarPointerMove(e) {
  692 + if (!barPointerDown) return
  693 + const dx = e.clientX - barStartX
  694 + if (Math.abs(dx) > 3) barMoved = true
  695 + const scroller = e.currentTarget.parentElement
  696 + if (scroller && barMoved) scroller.scrollLeft = barStartScrollLeft - dx
  697 +}
  698 +
  699 +function onBarPointerUp(e) {
  700 + if (!barPointerDown) return
  701 + barPointerDown = false
  702 + if (!barMoved) {
  703 + const rect = e.currentTarget.getBoundingClientRect()
  704 + const mx = e.clientX - rect.left
  705 + const my = e.clientY - rect.top
  706 + const hit = barRects.find(r => mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h)
  707 + if (hit) {
  708 + barSelected.value = { show: true, hour: String(hit.idx + 1), value: (hit.val || 0).toFixed(2) }
  709 + } else {
  710 + barSelected.value = { show: false, hour: '', value: '' }
  711 + }
  712 + drawBars()
  713 + }
  714 + e.currentTarget.releasePointerCapture?.(e.pointerId)
  715 +}
  716 +
  717 +function onBarPointerLeave() {
  718 + barPointerDown = false
  719 +}
  720 +
  721 +function drawAll() {
  722 + drawTimeline()
  723 + drawBars()
  724 + drawPie()
  725 +}
  726 +
  727 +let ro = null
  728 +onMounted(() => {
  729 + fetchData()
  730 + if ('ResizeObserver' in window) {
  731 + ro = new ResizeObserver(() => drawAll())
  732 + if (timelineWrapRef.value) ro.observe(timelineWrapRef.value)
  733 + if (barWrapRef.value) ro.observe(barWrapRef.value)
  734 + if (pieWrapRef.value) ro.observe(pieWrapRef.value)
  735 + }
  736 +})
  737 +
  738 +onBeforeUnmount(() => {
  739 + if (ro) {
  740 + ro.disconnect()
  741 + ro = null
  742 + }
  743 +})
  744 +</script>
  745 +
  746 +<style scoped>
  747 +.h5-page {
  748 + min-height: 100vh;
  749 + background: #f5f6f8;
  750 +}
  751 +
  752 +.topbar {
  753 + position: sticky;
  754 + top: 0;
  755 + z-index: 10;
  756 + display: flex;
  757 + align-items: center;
  758 + justify-content: space-between;
  759 + padding: 10px 12px;
  760 + background: #fff;
  761 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  762 +}
  763 +
  764 +.title {
  765 + font-weight: 700;
  766 + color: #0f172a;
  767 +}
  768 +
  769 +.spacer {
  770 + width: 48px;
  771 +}
  772 +
  773 +.content {
  774 + padding: 12px;
  775 +}
  776 +
  777 +.device {
  778 + font-weight: 700;
  779 + color: #0f172a;
  780 + margin-bottom: 10px;
  781 +}
  782 +
  783 +.query {
  784 + display: flex;
  785 + align-items: center;
  786 + justify-content: space-between;
  787 + gap: 10px;
  788 + margin-bottom: 10px;
  789 +}
  790 +
  791 +.query-left {
  792 + display: flex;
  793 + align-items: center;
  794 + gap: 10px;
  795 +}
  796 +
  797 +.query-label {
  798 + font-size: 12px;
  799 + color: #64748b;
  800 +}
  801 +
  802 +.section {
  803 + background: #fff;
  804 + border-radius: 12px;
  805 + padding: 12px;
  806 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  807 + margin-bottom: 10px;
  808 +}
  809 +
  810 +.section-title {
  811 + font-weight: 700;
  812 + color: #0f172a;
  813 + font-size: 14px;
  814 + margin-bottom: 8px;
  815 + display: flex;
  816 + align-items: center;
  817 + flex-wrap: wrap;
  818 + gap: 10px;
  819 +}
  820 +
  821 +.leg {
  822 + display: inline-flex;
  823 + align-items: center;
  824 + gap: 6px;
  825 + font-weight: 500;
  826 + color: #475569;
  827 + font-size: 12px;
  828 +}
  829 +
  830 +.dot {
  831 + width: 10px;
  832 + height: 10px;
  833 + border-radius: 999px;
  834 + display: inline-block;
  835 +}
  836 +
  837 +.dot.g {
  838 + background: #67c23a;
  839 +}
  840 +
  841 +.dot.r {
  842 + background: #e74c3c;
  843 +}
  844 +
  845 +.dot.y {
  846 + background: #c5d94e;
  847 +}
  848 +
  849 +.dot.gy {
  850 + background: #909399;
  851 +}
  852 +
  853 +.timeline-wrap {
  854 + border-radius: 10px;
  855 + overflow: hidden;
  856 + background: rgba(15, 23, 42, 0.02);
  857 + touch-action: pan-y;
  858 +}
  859 +
  860 +.timeline-canvas {
  861 + width: 100%;
  862 + height: 140px;
  863 + display: block;
  864 +}
  865 +
  866 +.subline {
  867 + font-size: 12px;
  868 + color: #64748b;
  869 + margin-bottom: 8px;
  870 +}
  871 +
  872 +.bar-scroll {
  873 + overflow-x: auto;
  874 + -webkit-overflow-scrolling: touch;
  875 + touch-action: pan-x;
  876 +}
  877 +
  878 +.bar-wrap {
  879 + min-width: 100%;
  880 +}
  881 +
  882 +.bar-canvas {
  883 + height: 220px;
  884 + display: block;
  885 +}
  886 +
  887 +.tip-card {
  888 + margin-top: 10px;
  889 + border-radius: 12px;
  890 + padding: 10px 12px;
  891 + background: rgba(64, 158, 255, 0.06);
  892 + border: 1px solid rgba(64, 158, 255, 0.18);
  893 +}
  894 +
  895 +.tip-title {
  896 + font-weight: 700;
  897 + color: #0f172a;
  898 + margin-bottom: 6px;
  899 +}
  900 +
  901 +.tip-line {
  902 + font-size: 12px;
  903 + color: #334155;
  904 + line-height: 1.6;
  905 +}
  906 +
  907 +.detail-list {
  908 + display: grid;
  909 + gap: 10px;
  910 +}
  911 +
  912 +.detail-item {
  913 + border: 1px solid rgba(0, 0, 0, 0.06);
  914 + border-radius: 12px;
  915 + padding: 10px 12px;
  916 + background: #fff;
  917 +}
  918 +
  919 +.d-row {
  920 + display: flex;
  921 + align-items: center;
  922 + justify-content: space-between;
  923 + gap: 10px;
  924 + font-size: 12px;
  925 + padding: 4px 0;
  926 +}
  927 +
  928 +.d-k {
  929 + color: #64748b;
  930 +}
  931 +
  932 +.d-v {
  933 + color: #0f172a;
  934 + font-weight: 600;
  935 + text-align: right;
  936 +}
  937 +
  938 +.d-status {
  939 + display: inline-flex;
  940 + align-items: center;
  941 + gap: 6px;
  942 + font-weight: 600;
  943 +}
  944 +
  945 +.pie-box {
  946 + display: grid;
  947 + grid-template-columns: 1fr;
  948 + gap: 10px;
  949 + align-items: center;
  950 +}
  951 +
  952 +.pie-wrap {
  953 + width: 100%;
  954 +}
  955 +
  956 +.pie-canvas {
  957 + width: 100%;
  958 + height: 240px;
  959 + display: block;
  960 +}
  961 +
  962 +.pie-stats {
  963 + display: grid;
  964 + grid-template-columns: repeat(2, minmax(0, 1fr));
  965 + gap: 10px;
  966 +}
  967 +
  968 +.stat-row {
  969 + background: #f8fafc;
  970 + border: 1px solid rgba(0, 0, 0, 0.06);
  971 + border-radius: 10px;
  972 + padding: 10px;
  973 +}
  974 +
  975 +.stat-k {
  976 + font-size: 12px;
  977 + color: #64748b;
  978 +}
  979 +
  980 +.stat-v {
  981 + margin-top: 6px;
  982 + font-size: 14px;
  983 + font-weight: 800;
  984 + color: #0f172a;
  985 + white-space: nowrap;
  986 + overflow: hidden;
  987 + text-overflow: ellipsis;
  988 +}
  989 +</style>
... ...
  1 +<template>
  2 + <div class="h5-page">
  3 + <div class="topbar">
  4 + <el-button text @click="goBack">返回</el-button>
  5 + <div class="title">用时用电</div>
  6 + <div class="spacer"></div>
  7 + </div>
  8 +
  9 + <div class="content">
  10 + <div class="device">{{ deviceName || '-' }}</div>
  11 + <el-empty v-if="!dtuSn" description="缺少 dtuSn" />
  12 +
  13 + <template v-else>
  14 + <div class="query">
  15 + <div class="query-left">
  16 + <el-radio-group v-model="queryMode" size="small" @change="onModeChange">
  17 + <el-radio-button value="hour">时查询</el-radio-button>
  18 + <el-radio-button value="day">日查询</el-radio-button>
  19 + <el-radio-button value="month">月查询</el-radio-button>
  20 + </el-radio-group>
  21 + </div>
  22 + <el-button type="primary" size="small" :loading="loading" @click="fetchData">查询</el-button>
  23 + </div>
  24 +
  25 + <div class="query2">
  26 + <el-date-picker
  27 + v-if="queryMode === 'hour'"
  28 + v-model="selectedDate"
  29 + type="date"
  30 + value-format="YYYY-MM-DD"
  31 + placeholder="选择日期"
  32 + size="small"
  33 + style="width: 160px;"
  34 + :disabled-date="disabledDateFuture"
  35 + @change="fetchData"
  36 + />
  37 + <el-date-picker
  38 + v-else-if="queryMode === 'day'"
  39 + v-model="dayRange"
  40 + type="daterange"
  41 + value-format="YYYY-MM-DD"
  42 + start-placeholder="开始"
  43 + end-placeholder="结束"
  44 + size="small"
  45 + style="width: 100%;"
  46 + :disabled-date="disabledDateFuture"
  47 + @change="fetchData"
  48 + />
  49 + <el-date-picker
  50 + v-else
  51 + v-model="yearValue"
  52 + type="year"
  53 + value-format="YYYY"
  54 + placeholder="选择年份"
  55 + size="small"
  56 + style="width: 140px;"
  57 + @change="fetchData"
  58 + />
  59 + </div>
  60 +
  61 + <div class="section">
  62 + <div class="section-title">
  63 + 运行时长明细
  64 + <span class="leg"><i class="dot g"></i>运行</span>
  65 + <span class="leg"><i class="dot y"></i>待机</span>
  66 + <span class="leg"><i class="dot r"></i>停机</span>
  67 + <span class="leg"><i class="dot gy"></i>离线</span>
  68 + <span class="leg"><i class="line-dot"></i>用电量</span>
  69 + </div>
  70 + <div class="chart-scroll">
  71 + <div ref="comboWrapRef" class="chart-wrap">
  72 + <canvas
  73 + ref="comboCanvasRef"
  74 + class="combo-canvas"
  75 + @pointerdown="onComboTap"
  76 + ></canvas>
  77 + </div>
  78 + </div>
  79 + <div v-if="comboSelected.show" class="tip-card">
  80 + <div class="tip-title">{{ comboSelected.title }}</div>
  81 + <div v-for="(row, i) in comboSelected.rows" :key="i" class="tip-line">
  82 + <i v-if="row.dotClass" :class="['dot', row.dotClass]"></i>
  83 + <i v-else-if="row.isLine" class="line-dot-sm"></i>
  84 + <span class="tlabel">{{ row.label }}</span>
  85 + <span class="tval">{{ row.value }}</span>
  86 + </div>
  87 + </div>
  88 + <el-empty v-if="!loading && flatKwhList.length === 0" description="暂无数据" />
  89 + </div>
  90 +
  91 + <div class="section">
  92 + <div class="section-title">运行时长统计</div>
  93 + <div class="stat-top">
  94 + <div class="stat-item">
  95 + <div class="k">总时长</div>
  96 + <div class="v">{{ apiSummary.totalDurationFormatted || '0秒' }}</div>
  97 + </div>
  98 + <div class="stat-item">
  99 + <div class="k">总用电量</div>
  100 + <div class="v">{{ formatKwh(apiSummary.totalKwh) }} kw·h</div>
  101 + </div>
  102 + </div>
  103 + <div class="pie-area">
  104 + <div ref="pieWrapRef" class="pie-wrap">
  105 + <canvas ref="pieCanvasRef" class="pie-canvas" @pointerdown="onPieTap"></canvas>
  106 + </div>
  107 + </div>
  108 + <div v-if="pieSelected.show" class="tip-card">
  109 + <div class="tip-title">{{ pieSelected.statusLabel }}</div>
  110 + <div class="tip-line">占比 {{ pieSelected.percent }}%</div>
  111 + </div>
  112 + </div>
  113 + </template>
  114 + </div>
  115 + </div>
  116 +</template>
  117 +
  118 +<script setup>
  119 +import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
  120 +import { useRoute, useRouter } from 'vue-router'
  121 +import { ElMessage } from 'element-plus'
  122 +import { apiFetch } from '../config/api.js'
  123 +
  124 +const route = useRoute()
  125 +const router = useRouter()
  126 +
  127 +const dtuSn = computed(() => String(route.query.dtuSn || ''))
  128 +const deviceName = computed(() => String(route.query.deviceName || ''))
  129 +
  130 +function goBack() {
  131 + router.back()
  132 +}
  133 +
  134 +const today = new Date().toISOString().slice(0, 10)
  135 +const weekAgo = new Date(Date.now() - 6 * 86400000).toISOString().slice(0, 10)
  136 +
  137 +const queryMode = ref('hour')
  138 +const selectedDate = ref(today)
  139 +const dayRange = ref([weekAgo, today])
  140 +const yearValue = ref(String(new Date().getFullYear()))
  141 +
  142 +const loading = ref(false)
  143 +const apiSummary = reactive({ totalKwh: 0, totalDurationSeconds: 0, statusStats: [], totalDurationFormatted: '0秒' })
  144 +const detailList = ref([])
  145 +
  146 +function disabledDateFuture(time) {
  147 + return time.getTime() > Date.now()
  148 +}
  149 +
  150 +function onModeChange() {
  151 + fetchData()
  152 +}
  153 +
  154 +const STATUS_MAP = { 0: '离线', 1: '停机', 2: '运行', 3: '待机' }
  155 +const STATUS_COLORS = { 0: '#909399', 1: '#e74c3c', 2: '#67c23a', 3: '#c5d94e' }
  156 +function statusLabel(s) {
  157 + return STATUS_MAP[Number(s)] || '未知'
  158 +}
  159 +function statusDotClass(s) {
  160 + return { 0: 'gy', 1: 'r', 2: 'g', 3: 'y' }[Number(s)] || 'gy'
  161 +}
  162 +
  163 +function formatKwh(v) {
  164 + if (!v && v !== 0) return '0'
  165 + const n = Number(v)
  166 + return n.toFixed(n % 1 === 0 ? 0 : 2)
  167 +}
  168 +
  169 +function formatDuration(seconds) {
  170 + if (!seconds && seconds !== 0) return '0秒'
  171 + seconds = Number(seconds)
  172 + if (seconds <= 0) return '0秒'
  173 + const h = Math.floor(seconds / 3600)
  174 + const m = Math.floor((seconds % 3600) / 60)
  175 + const s = Math.floor(seconds % 60)
  176 + let str = ''
  177 + if (h > 0) str += h + '时'
  178 + if (m > 0) str += m + '分'
  179 + if (s > 0 || !str) str += s + '秒'
  180 + return str
  181 +}
  182 +
  183 +function formatHours(seconds) {
  184 + if (!seconds && seconds !== 0) return '0时'
  185 + const h = Number(seconds) / 3600
  186 + return h.toFixed(2).replace(/\.?0+$/, '') + '时'
  187 +}
  188 +
  189 +const flatKwhList = computed(() => {
  190 + const dl = detailList.value || []
  191 + if (!dl.length) return []
  192 + if (dl[0] && dl[0].kwhList) return dl[0].kwhList
  193 + return dl.map(item => {
  194 + const row = { date: item.date || '', value: item.totalKwh || 0 }
  195 + ;(item.statusStats || []).forEach(s => {
  196 + row[s.status] = s.durationSeconds || 0
  197 + })
  198 + for (let k = 0; k <= 3; k++) row[k] = row[k] ?? 0
  199 + return row
  200 + })
  201 +})
  202 +
  203 +function getXLabel(item, idx) {
  204 + if (queryMode.value === 'hour') return item.date || `${idx + 1}时`
  205 + if (queryMode.value === 'month') return item.date || `${idx + 1}`
  206 + return item.date || `${idx + 1}`
  207 +}
  208 +
  209 +async function fetchData() {
  210 + if (!dtuSn.value) return
  211 + loading.value = true
  212 + try {
  213 + let type = 1
  214 + let sd = selectedDate.value
  215 + let ed = ''
  216 +
  217 + if (queryMode.value === 'hour') {
  218 + type = 1
  219 + sd = selectedDate.value
  220 + } else if (queryMode.value === 'day') {
  221 + type = 2
  222 + if (!dayRange.value || !dayRange.value.length) throw new Error('missing range')
  223 + sd = dayRange.value[0]
  224 + ed = dayRange.value[1]
  225 + } else {
  226 + type = 3
  227 + const y = yearValue.value || String(new Date().getFullYear())
  228 + sd = `${y}-01-01`
  229 + }
  230 +
  231 + let url = `/api/energy/runtimeDetail?dtuSn=${encodeURIComponent(dtuSn.value)}&startDate=${encodeURIComponent(sd)}&type=${type}`
  232 + if (ed) url += `&endDate=${encodeURIComponent(ed)}`
  233 +
  234 + const res = await apiFetch(url)
  235 + const data = await res.json()
  236 + if (data?.code !== 200) throw new Error('bad response')
  237 + Object.assign(apiSummary, data.summary || { totalKwh: 0, totalDurationSeconds: 0, statusStats: [], totalDurationFormatted: '0秒' })
  238 + detailList.value = data.detailList || []
  239 + comboSelected.value.show = false
  240 + pieSelected.value.show = false
  241 + await nextTick()
  242 + drawAll()
  243 + } catch (e) {
  244 + ElMessage.error('获取数据失败')
  245 + console.warn(e)
  246 + } finally {
  247 + loading.value = false
  248 + }
  249 +}
  250 +
  251 +const dpr = window.devicePixelRatio || 1
  252 +function setupCanvas(canvas, cssW, cssH) {
  253 + canvas.style.width = cssW + 'px'
  254 + canvas.style.height = cssH + 'px'
  255 + canvas.width = Math.round(cssW * dpr)
  256 + canvas.height = Math.round(cssH * dpr)
  257 + const ctx = canvas.getContext('2d')
  258 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  259 + return { ctx, W: cssW, H: cssH }
  260 +}
  261 +
  262 +const comboCanvasRef = ref(null)
  263 +const comboWrapRef = ref(null)
  264 +let comboRects = []
  265 +
  266 +const comboSelected = ref({ show: false, index: -1, title: '', rows: [] })
  267 +
  268 +function buildComboSelected(idx) {
  269 + const list = flatKwhList.value
  270 + const item = list[idx]
  271 + if (!item) return { show: false, index: -1, title: '', rows: [] }
  272 + const statuses = [
  273 + { key: 2, dotClass: 'g', label: '运行' },
  274 + { key: 3, dotClass: 'y', label: '待机' },
  275 + { key: 1, dotClass: 'r', label: '停机' },
  276 + { key: 0, dotClass: 'gy', label: '离线' }
  277 + ]
  278 + const rows = []
  279 + const totalSec = (item['0'] || 0) + (item['1'] || 0) + (item['2'] || 0) + (item['3'] || 0)
  280 + statuses.forEach(s => {
  281 + const sec = item[s.key] ?? 0
  282 + if (sec > 0) {
  283 + const pct = totalSec > 0 ? ` (${((sec / totalSec) * 100).toFixed(1)}%)` : ''
  284 + rows.push({ dotClass: s.dotClass, label: s.label, value: formatHours(sec) + pct })
  285 + }
  286 + })
  287 + rows.push({ isLine: true, label: '用电量', value: `${formatKwh(item.value ?? 0)} kw·h` })
  288 + return { show: true, index: idx, title: getXLabel(item, idx), rows }
  289 +}
  290 +
  291 +function drawCombo() {
  292 + const canvas = comboCanvasRef.value
  293 + const wrap = comboWrapRef.value
  294 + if (!canvas || !wrap) return
  295 + const list = flatKwhList.value || []
  296 + const PAD_L = 38
  297 + const PAD_R = 16
  298 + const PAD_T = 18
  299 + const PAD_B = 48
  300 + const colW = 14
  301 + const gap = 10
  302 + const cssH = 360
  303 + const minW = Math.max(320, wrap.clientWidth)
  304 + const cssW = Math.max(minW, PAD_L + PAD_R + list.length * (colW + gap) + gap)
  305 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  306 + ctx.clearRect(0, 0, W, H)
  307 +
  308 + if (!list.length) return
  309 +
  310 + const plotW = W - PAD_L - PAD_R
  311 + const plotH = H - PAD_T - PAD_B
  312 +
  313 + let maxSec = 1
  314 + let maxKwh = 1
  315 + list.forEach(item => {
  316 + const sec = (item['0'] || 0) + (item['1'] || 0) + (item['2'] || 0) + (item['3'] || 0)
  317 + maxSec = Math.max(maxSec, sec)
  318 + maxKwh = Math.max(maxKwh, Number(item.value || 0))
  319 + })
  320 +
  321 + ctx.strokeStyle = 'rgba(0,0,0,0.06)'
  322 + ctx.lineWidth = 1
  323 + ctx.fillStyle = '#94a3b8'
  324 + ctx.font = '10px sans-serif'
  325 + ctx.textAlign = 'end'
  326 + for (let i = 0; i <= 4; i++) {
  327 + const sec = (maxSec * i) / 4
  328 + const y = PAD_T + plotH * (1 - i / 4)
  329 + ctx.fillText((sec / 3600).toFixed(0) + 'h', PAD_L - 4, y + 3)
  330 + if (i > 0) {
  331 + ctx.beginPath()
  332 + ctx.moveTo(PAD_L, y)
  333 + ctx.lineTo(W - PAD_R, y)
  334 + ctx.stroke()
  335 + }
  336 + }
  337 +
  338 + ctx.strokeStyle = 'rgba(0,0,0,0.08)'
  339 + ctx.lineWidth = 1.5
  340 + ctx.beginPath()
  341 + ctx.moveTo(PAD_L, PAD_T + plotH)
  342 + ctx.lineTo(W - PAD_R, PAD_T + plotH)
  343 + ctx.stroke()
  344 +
  345 + comboRects = []
  346 + const selectedIdx = comboSelected.value.show ? comboSelected.value.index : -1
  347 +
  348 + const order = [
  349 + { key: 2, color: STATUS_COLORS[2] },
  350 + { key: 3, color: STATUS_COLORS[3] },
  351 + { key: 1, color: STATUS_COLORS[1] },
  352 + { key: 0, color: STATUS_COLORS[0] }
  353 + ]
  354 +
  355 + let lastLineX = null
  356 + let lastLineY = null
  357 + ctx.save()
  358 + ctx.strokeStyle = 'rgba(64,158,255,0.9)'
  359 + ctx.lineWidth = 2
  360 + list.forEach((item, idx) => {
  361 + const x = PAD_L + gap + idx * (colW + gap)
  362 + const totalSec = (item['0'] || 0) + (item['1'] || 0) + (item['2'] || 0) + (item['3'] || 0)
  363 + const barH = (totalSec / maxSec) * plotH
  364 + const baseY = PAD_T + plotH
  365 + let curY = baseY
  366 + order.forEach(seg => {
  367 + const sec = item[seg.key] || 0
  368 + const h = (sec / maxSec) * plotH
  369 + if (h <= 0) return
  370 + curY -= h
  371 + ctx.fillStyle = seg.color
  372 + ctx.globalAlpha = idx === selectedIdx ? 0.95 : 0.78
  373 + ctx.fillRect(x, curY, colW, h)
  374 + ctx.globalAlpha = 1
  375 + })
  376 +
  377 + const kwh = Number(item.value || 0)
  378 + const ly = PAD_T + plotH - (kwh / maxKwh) * plotH
  379 + const lx = x + colW / 2
  380 + if (lastLineX != null) {
  381 + ctx.beginPath()
  382 + ctx.moveTo(lastLineX, lastLineY)
  383 + ctx.lineTo(lx, ly)
  384 + ctx.stroke()
  385 + }
  386 + lastLineX = lx
  387 + lastLineY = ly
  388 + ctx.fillStyle = '#409eff'
  389 + ctx.beginPath()
  390 + ctx.arc(lx, ly, idx === selectedIdx ? 3.5 : 2.5, 0, Math.PI * 2)
  391 + ctx.fill()
  392 +
  393 + if (idx % Math.max(1, Math.floor(list.length / 8)) === 0 || idx === list.length - 1) {
  394 + ctx.save()
  395 + ctx.fillStyle = '#64748b'
  396 + ctx.font = '9px sans-serif'
  397 + ctx.textAlign = 'center'
  398 + ctx.translate(lx, PAD_T + plotH + 14)
  399 + ctx.rotate(-Math.PI / 6)
  400 + ctx.fillText(getXLabel(item, idx), 0, 0)
  401 + ctx.restore()
  402 + }
  403 +
  404 + comboRects.push({ x, y: PAD_T, w: colW, h: plotH, idx })
  405 + })
  406 + ctx.restore()
  407 +}
  408 +
  409 +function onComboTap(e) {
  410 + const canvas = comboCanvasRef.value
  411 + if (!canvas || !comboRects.length) return
  412 + const rect = canvas.getBoundingClientRect()
  413 + const mx = e.clientX - rect.left
  414 + const my = e.clientY - rect.top
  415 + const hit = comboRects.find(r => mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h)
  416 + if (!hit) {
  417 + comboSelected.value = { show: false, index: -1, title: '', rows: [] }
  418 + } else {
  419 + comboSelected.value = buildComboSelected(hit.idx)
  420 + }
  421 + drawCombo()
  422 +}
  423 +
  424 +const pieCanvasRef = ref(null)
  425 +const pieWrapRef = ref(null)
  426 +const pieSelected = ref({ show: false, status: -1, statusLabel: '', percent: '0.00' })
  427 +let pieRanges = []
  428 +
  429 +function buildPieSlices() {
  430 + const stats = apiSummary.statusStats || []
  431 + if (!stats.length) return []
  432 + const totalPct = stats.reduce((sum, s) => sum + (Number(s.percent) || 0), 0)
  433 + const MIN_SLICE_DEG = 2
  434 + const sliceInfos = stats.map((s, i) => {
  435 + const pct = Number(s.percent) || 0
  436 + const deg = pct > 0 && totalPct > 0 ? (pct / totalPct) * 360 : MIN_SLICE_DEG
  437 + return { index: i, status: Number(s.status), percent: pct, deg }
  438 + })
  439 + const hasNonZero = sliceInfos.some(si => si.percent > 0)
  440 + if (!hasNonZero && sliceInfos.length > 0) {
  441 + const eq = 360 / sliceInfos.length
  442 + sliceInfos.forEach(si => (si.deg = eq))
  443 + } else if (hasNonZero) {
  444 + const usedByNonZero = sliceInfos.filter(si => si.percent > 0).reduce((s, si) => s + si.deg, 0)
  445 + const zeroCount = sliceInfos.filter(si => si.percent === 0).length
  446 + const remaining = Math.max(0, 360 - usedByNonZero - zeroCount * MIN_SLICE_DEG)
  447 + sliceInfos.forEach(si => {
  448 + if (si.percent > 0) si.deg = si.deg + (si.deg / usedByNonZero) * remaining
  449 + })
  450 + }
  451 + let startAngle = -Math.PI / 2
  452 + sliceInfos.forEach(si => {
  453 + const sliceAngle = (si.deg / 180) * Math.PI
  454 + si.startAngle = startAngle
  455 + si.endAngle = startAngle + sliceAngle
  456 + si.midAngle = startAngle + sliceAngle / 2
  457 + startAngle = si.endAngle
  458 + })
  459 + return sliceInfos
  460 +}
  461 +
  462 +function drawPie() {
  463 + const canvas = pieCanvasRef.value
  464 + const wrap = pieWrapRef.value
  465 + if (!canvas || !wrap) return
  466 + const cssW = Math.max(320, wrap.clientWidth)
  467 + const cssH = 240
  468 + const { ctx, W, H } = setupCanvas(canvas, cssW, cssH)
  469 + ctx.clearRect(0, 0, W, H)
  470 + const slices = buildPieSlices()
  471 + pieRanges = []
  472 + if (!slices.length) {
  473 + ctx.fillStyle = '#999'
  474 + ctx.font = '13px sans-serif'
  475 + ctx.textAlign = 'center'
  476 + ctx.textBaseline = 'middle'
  477 + ctx.fillText('暂无数据', W / 2, H / 2)
  478 + return
  479 + }
  480 +
  481 + const cx = W * 0.5
  482 + const cy = H * 0.54
  483 + const radius = Math.min(cx, cy) - 18
  484 + slices.forEach(si => pieRanges.push({ ...si, cx, cy, radius }))
  485 +
  486 + const selectedStatus = pieSelected.value.show ? pieSelected.value.status : -999
  487 + slices.forEach(si => {
  488 + if (si.status === selectedStatus) return
  489 + ctx.beginPath()
  490 + ctx.moveTo(cx, cy)
  491 + ctx.arc(cx, cy, radius, si.startAngle, si.endAngle)
  492 + ctx.closePath()
  493 + ctx.fillStyle = STATUS_COLORS[si.status] || '#ccc'
  494 + ctx.globalAlpha = 0.85
  495 + ctx.fill()
  496 + ctx.globalAlpha = 1
  497 + })
  498 +
  499 + const sel = slices.find(s => s.status === selectedStatus)
  500 + if (sel) {
  501 + const expandR = radius + 6
  502 + const offset = 6
  503 + const ox = cx + Math.cos(sel.midAngle) * offset
  504 + const oy = cy + Math.sin(sel.midAngle) * offset
  505 + ctx.save()
  506 + ctx.shadowColor = 'rgba(0,0,0,0.25)'
  507 + ctx.shadowBlur = 12
  508 + ctx.shadowOffsetY = 3
  509 + ctx.beginPath()
  510 + ctx.moveTo(ox, oy)
  511 + ctx.arc(ox, oy, expandR, sel.startAngle, sel.endAngle)
  512 + ctx.closePath()
  513 + ctx.fillStyle = STATUS_COLORS[sel.status] || '#ccc'
  514 + ctx.fill()
  515 + ctx.restore()
  516 + }
  517 +
  518 + slices.forEach(si => {
  519 + const { midAngle } = si
  520 + const lineStartX = cx + Math.cos(midAngle) * radius
  521 + const lineStartY = cy + Math.sin(midAngle) * radius
  522 + const elbowLen = 12
  523 + const elbowX = cx + Math.cos(midAngle) * (radius + elbowLen)
  524 + const elbowY = cy + Math.sin(midAngle) * (radius + elbowLen)
  525 + const leftSide = midAngle > Math.PI / 2 && midAngle <= Math.PI * 1.5
  526 + const textDir = leftSide ? -1 : 1
  527 + const textLen = 34
  528 + const lineEndX = elbowX + textDir * textLen
  529 + const lineEndY = elbowY
  530 +
  531 + ctx.strokeStyle = 'rgba(0,0,0,0.25)'
  532 + ctx.lineWidth = 0.8
  533 + ctx.beginPath()
  534 + ctx.moveTo(lineStartX, lineStartY)
  535 + ctx.lineTo(elbowX, elbowY)
  536 + ctx.lineTo(lineEndX, lineEndY)
  537 + ctx.stroke()
  538 +
  539 + const labelText = `${statusLabel(si.status)}${si.percent.toFixed(si.percent % 1 === 0 ? 0 : 2)}%`
  540 + ctx.fillStyle = '#475569'
  541 + ctx.font = '11px sans-serif'
  542 + ctx.textAlign = leftSide ? 'right' : 'left'
  543 + ctx.textBaseline = 'middle'
  544 + ctx.fillText(labelText, lineEndX + textDir * 4, lineEndY)
  545 + })
  546 +}
  547 +
  548 +function onPieTap(e) {
  549 + const canvas = pieCanvasRef.value
  550 + if (!canvas || !pieRanges.length) return
  551 + const rect = canvas.getBoundingClientRect()
  552 + const mx = e.clientX - rect.left
  553 + const my = e.clientY - rect.top
  554 + const first = pieRanges[0]
  555 + const cx = first.cx
  556 + const cy = first.cy
  557 + const dx = mx - cx
  558 + const dy = my - cy
  559 + const dist = Math.sqrt(dx * dx + dy * dy)
  560 + if (dist > first.radius) {
  561 + pieSelected.value = { show: false, status: -1, statusLabel: '', percent: '0.00' }
  562 + drawPie()
  563 + return
  564 + }
  565 + const inRange = (a, sa, ea) => {
  566 + let na = a - sa
  567 + let nea = ea - sa
  568 + if (nea < 0) nea += Math.PI * 2
  569 + if (na < 0) na += Math.PI * 2
  570 + return na >= 0 && na <= nea
  571 + }
  572 + const angle = Math.atan2(dy, dx)
  573 + const hit = [...pieRanges].reverse().find(seg => inRange(angle, seg.startAngle, seg.endAngle))
  574 + if (!hit) {
  575 + pieSelected.value = { show: false, status: -1, statusLabel: '', percent: '0.00' }
  576 + } else {
  577 + pieSelected.value = { show: true, status: hit.status, statusLabel: statusLabel(hit.status), percent: (hit.percent || 0).toFixed(2) }
  578 + }
  579 + drawPie()
  580 +}
  581 +
  582 +function drawAll() {
  583 + drawCombo()
  584 + drawPie()
  585 +}
  586 +
  587 +let ro = null
  588 +onMounted(() => {
  589 + fetchData()
  590 + if ('ResizeObserver' in window) {
  591 + ro = new ResizeObserver(() => drawAll())
  592 + if (comboWrapRef.value) ro.observe(comboWrapRef.value)
  593 + if (pieWrapRef.value) ro.observe(pieWrapRef.value)
  594 + }
  595 +})
  596 +
  597 +onBeforeUnmount(() => {
  598 + if (ro) {
  599 + ro.disconnect()
  600 + ro = null
  601 + }
  602 +})
  603 +</script>
  604 +
  605 +<style scoped>
  606 +.h5-page {
  607 + min-height: 100vh;
  608 + background: #f5f6f8;
  609 +}
  610 +
  611 +.topbar {
  612 + position: sticky;
  613 + top: 0;
  614 + z-index: 10;
  615 + display: flex;
  616 + align-items: center;
  617 + justify-content: space-between;
  618 + padding: 10px 12px;
  619 + background: #fff;
  620 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  621 +}
  622 +
  623 +.title {
  624 + font-weight: 700;
  625 + color: #0f172a;
  626 +}
  627 +
  628 +.spacer {
  629 + width: 48px;
  630 +}
  631 +
  632 +.content {
  633 + padding: 12px;
  634 +}
  635 +
  636 +.device {
  637 + font-weight: 700;
  638 + color: #0f172a;
  639 + margin-bottom: 10px;
  640 +}
  641 +
  642 +.query {
  643 + display: flex;
  644 + align-items: center;
  645 + justify-content: space-between;
  646 + gap: 10px;
  647 + margin-bottom: 10px;
  648 +}
  649 +
  650 +.query-left {
  651 + display: flex;
  652 + align-items: center;
  653 + gap: 10px;
  654 + flex-wrap: wrap;
  655 +}
  656 +
  657 +.query2 {
  658 + margin-bottom: 10px;
  659 +}
  660 +
  661 +.section {
  662 + background: #fff;
  663 + border-radius: 12px;
  664 + padding: 12px;
  665 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  666 + margin-bottom: 10px;
  667 +}
  668 +
  669 +.section-title {
  670 + font-weight: 700;
  671 + color: #0f172a;
  672 + font-size: 14px;
  673 + margin-bottom: 8px;
  674 + display: flex;
  675 + align-items: center;
  676 + flex-wrap: wrap;
  677 + gap: 10px;
  678 +}
  679 +
  680 +.leg {
  681 + display: inline-flex;
  682 + align-items: center;
  683 + gap: 6px;
  684 + font-weight: 500;
  685 + color: #475569;
  686 + font-size: 12px;
  687 +}
  688 +
  689 +.dot {
  690 + width: 10px;
  691 + height: 10px;
  692 + border-radius: 999px;
  693 + display: inline-block;
  694 +}
  695 +
  696 +.dot.g {
  697 + background: #67c23a;
  698 +}
  699 +
  700 +.dot.r {
  701 + background: #e74c3c;
  702 +}
  703 +
  704 +.dot.y {
  705 + background: #c5d94e;
  706 +}
  707 +
  708 +.dot.gy {
  709 + background: #909399;
  710 +}
  711 +
  712 +.line-dot {
  713 + width: 18px;
  714 + height: 0;
  715 + border-top: 2px solid rgba(64, 158, 255, 0.95);
  716 + position: relative;
  717 +}
  718 +
  719 +.line-dot::after {
  720 + content: '';
  721 + position: absolute;
  722 + right: -2px;
  723 + top: -3px;
  724 + width: 6px;
  725 + height: 6px;
  726 + border-radius: 999px;
  727 + background: rgba(64, 158, 255, 0.95);
  728 +}
  729 +
  730 +.line-dot-sm {
  731 + width: 10px;
  732 + height: 10px;
  733 + border-radius: 999px;
  734 + background: rgba(64, 158, 255, 0.95);
  735 + display: inline-block;
  736 +}
  737 +
  738 +.chart-scroll {
  739 + overflow-x: auto;
  740 + -webkit-overflow-scrolling: touch;
  741 +}
  742 +
  743 +.chart-wrap {
  744 + min-width: 100%;
  745 +}
  746 +
  747 +.combo-canvas {
  748 + height: 360px;
  749 + display: block;
  750 +}
  751 +
  752 +.tip-card {
  753 + margin-top: 10px;
  754 + border-radius: 12px;
  755 + padding: 10px 12px;
  756 + background: rgba(64, 158, 255, 0.06);
  757 + border: 1px solid rgba(64, 158, 255, 0.18);
  758 +}
  759 +
  760 +.tip-title {
  761 + font-weight: 700;
  762 + color: #0f172a;
  763 + margin-bottom: 6px;
  764 +}
  765 +
  766 +.tip-line {
  767 + display: flex;
  768 + align-items: center;
  769 + gap: 6px;
  770 + font-size: 12px;
  771 + color: #334155;
  772 + line-height: 1.6;
  773 +}
  774 +
  775 +.tlabel {
  776 + color: #64748b;
  777 +}
  778 +
  779 +.tval {
  780 + margin-left: auto;
  781 + font-weight: 700;
  782 + color: #0f172a;
  783 +}
  784 +
  785 +.stat-top {
  786 + display: grid;
  787 + grid-template-columns: repeat(2, minmax(0, 1fr));
  788 + gap: 10px;
  789 + margin-bottom: 10px;
  790 +}
  791 +
  792 +.stat-item {
  793 + background: #f8fafc;
  794 + border: 1px solid rgba(0, 0, 0, 0.06);
  795 + border-radius: 10px;
  796 + padding: 10px;
  797 +}
  798 +
  799 +.stat-item .k {
  800 + font-size: 12px;
  801 + color: #64748b;
  802 +}
  803 +
  804 +.stat-item .v {
  805 + margin-top: 6px;
  806 + font-size: 14px;
  807 + font-weight: 800;
  808 + color: #0f172a;
  809 + white-space: nowrap;
  810 + overflow: hidden;
  811 + text-overflow: ellipsis;
  812 +}
  813 +
  814 +.pie-area {
  815 + display: flex;
  816 + justify-content: center;
  817 +}
  818 +
  819 +.pie-wrap {
  820 + width: 100%;
  821 + max-width: 360px;
  822 +}
  823 +
  824 +.pie-canvas {
  825 + width: 100%;
  826 + height: 240px;
  827 + display: block;
  828 +}
  829 +</style>
... ...
  1 +<template>
  2 + <div class="smart-light-h5">
  3 + <div class="h5-header">
  4 + <div class="h5-top">
  5 + <div class="h5-title">云物联网平台</div>
  6 + <div class="h5-tabs">
  7 + <button
  8 + type="button"
  9 + :class="['h5-tab', { active: activeTab === 'smart' }]"
  10 + @click="goH5('/smart-light-h5')"
  11 + >
  12 + 智能灯
  13 + </button>
  14 + <button
  15 + type="button"
  16 + :class="['h5-tab', { active: activeTab === 'energy' }]"
  17 + @click="goH5('/energy-h5')"
  18 + >
  19 + 能耗
  20 + </button>
  21 + </div>
  22 + </div>
  23 +
  24 + <el-tabs v-model="currentStatus" class="h5-subtabs" :stretch="true">
  25 + <el-tab-pane label="实时状态" name="realtime" />
  26 + <el-tab-pane label="时序状态" name="timeseries" />
  27 + <el-tab-pane label="稼动率" name="utilization" />
  28 + <el-tab-pane label="开机率" name="startup" />
  29 + </el-tabs>
  30 + </div>
  31 +
  32 + <div class="h5-content">
  33 + <KeepAlive>
  34 + <SmartLightH5RealtimeTab v-if="currentStatus === 'realtime'" />
  35 + <SmartLightH5TimeseriesTab v-else-if="currentStatus === 'timeseries'" />
  36 + <SmartLightH5UtilizationTab v-else-if="currentStatus === 'utilization'" />
  37 + <SmartLightH5StartupTab v-else />
  38 + </KeepAlive>
  39 + </div>
  40 + </div>
  41 +</template>
  42 +
  43 +<script setup>
  44 +import { computed, ref } from 'vue'
  45 +import { useRoute, useRouter } from 'vue-router'
  46 +import SmartLightH5RealtimeTab from '../components/h5/SmartLightH5RealtimeTab.vue'
  47 +import SmartLightH5TimeseriesTab from '../components/h5/SmartLightH5TimeseriesTab.vue'
  48 +import SmartLightH5UtilizationTab from '../components/h5/SmartLightH5UtilizationTab.vue'
  49 +import SmartLightH5StartupTab from '../components/h5/SmartLightH5StartupTab.vue'
  50 +
  51 +const route = useRoute()
  52 +const router = useRouter()
  53 +
  54 +const activeTab = computed(() => (route.path === '/energy-h5' ? 'energy' : 'smart'))
  55 +
  56 +function goH5(path) {
  57 + router.push({ path, query: route.query })
  58 +}
  59 +
  60 +const currentStatus = ref('realtime')
  61 +</script>
  62 +
  63 +<style scoped>
  64 +.smart-light-h5 {
  65 + min-height: 100vh;
  66 + background-color: #f5f6f8;
  67 +}
  68 +
  69 +.h5-header {
  70 + position: sticky;
  71 + top: 0;
  72 + z-index: 10;
  73 + background-color: #fff;
  74 + padding: 12px 12px 10px;
  75 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  76 +}
  77 +
  78 +.h5-top {
  79 + display: flex;
  80 + align-items: center;
  81 + justify-content: space-between;
  82 + gap: 10px;
  83 + margin-bottom: 10px;
  84 +}
  85 +
  86 +.h5-title {
  87 + font-size: 16px;
  88 + font-weight: 700;
  89 + line-height: 1.2;
  90 + color: #111827;
  91 +}
  92 +
  93 +.h5-tabs {
  94 + display: inline-flex;
  95 + border: 1px solid rgba(0, 0, 0, 0.08);
  96 + border-radius: 999px;
  97 + overflow: hidden;
  98 +}
  99 +
  100 +.h5-subtabs {
  101 + margin-top: 10px;
  102 +}
  103 +
  104 +.h5-subtabs :deep(.el-tabs__header) {
  105 + margin: 0;
  106 +}
  107 +
  108 +.h5-subtabs :deep(.el-tabs__nav-wrap) {
  109 + padding: 0;
  110 +}
  111 +
  112 +.h5-subtabs :deep(.el-tabs__item) {
  113 + font-size: 12px;
  114 + padding: 0 10px;
  115 + height: 34px;
  116 + line-height: 34px;
  117 +}
  118 +
  119 +.h5-subtabs :deep(.el-tabs__active-bar) {
  120 + height: 2px;
  121 +}
  122 +
  123 +.h5-tab {
  124 + appearance: none;
  125 + border: 0;
  126 + background: transparent;
  127 + padding: 6px 12px;
  128 + font-size: 12px;
  129 + color: #334155;
  130 +}
  131 +
  132 +.h5-tab.active {
  133 + background: rgba(64, 158, 255, 0.12);
  134 + color: #1d4ed8;
  135 +}
  136 +
  137 +.h5-content {
  138 + padding: 12px;
  139 +}
  140 +</style>
... ...
  1 +<template>
  2 + <div class="h5-page">
  3 + <div class="topbar">
  4 + <el-button text @click="goBack">返回</el-button>
  5 + <div class="title">OEE时序</div>
  6 + <div class="spacer"></div>
  7 + </div>
  8 +
  9 + <div class="content">
  10 + <div class="device">{{ deviceName || '-' }}</div>
  11 +
  12 + <div class="query">
  13 + <div class="query-left">
  14 + <div class="query-label">日查询</div>
  15 + <el-date-picker
  16 + v-model="queryDate"
  17 + type="date"
  18 + value-format="YYYY-MM-DD"
  19 + placeholder="选择日期"
  20 + style="width: 150px;"
  21 + @change="fetchLampData"
  22 + />
  23 + </div>
  24 + <el-button type="primary" :loading="loading" @click="fetchLampData">查询</el-button>
  25 + </div>
  26 +
  27 + <el-empty v-if="!dtuSn" description="缺少 dtuSn" />
  28 +
  29 + <template v-else>
  30 + <div class="section">
  31 + <div class="section-title">OEE时序</div>
  32 + <div class="timeline-wrap" ref="timelineRef" @wheel.prevent="onWheel">
  33 + <canvas
  34 + ref="canvasRef"
  35 + :width="canvasW"
  36 + :height="canvasH"
  37 + class="timeline-canvas"
  38 + @mousemove="onCanvasMouseMove"
  39 + @mouseleave="hoverSeg = null"
  40 + ></canvas>
  41 + <div
  42 + v-if="hoverSeg"
  43 + class="hover-tooltip"
  44 + :style="{ left: tooltipPos.x + 'px', top: tooltipPos.y + 'px' }"
  45 + >
  46 + <div class="tip-row"><span class="tip-label">开始:</span>{{ hoverSeg.startTimeText }}</div>
  47 + <div class="tip-row"><span class="tip-label">结束:</span>{{ hoverSeg.endTimeText }}</div>
  48 + <div class="tip-row">
  49 + <span class="tip-label">状态:</span>
  50 + <span :style="{ color: stateColorMap[hoverSeg.state] }">{{ stateNameMap[hoverSeg.state] || '未知' }}</span>
  51 + </div>
  52 + <div class="tip-row"><span class="tip-label">时长:</span>{{ formatDuration(hoverSeg.duration) }}</div>
  53 + </div>
  54 + </div>
  55 + </div>
  56 + <div class="section">
  57 + <div class="section-title">当日时长分布和异常原因分布</div>
  58 + <div class="charts">
  59 + <div class="chart-card">
  60 + <div class="chart-title">当日时长分布</div>
  61 + <div class="legend">
  62 + <div class="legend-item"><span class="dot green"></span>绿灯</div>
  63 + <div class="legend-item"><span class="dot red"></span>红灯</div>
  64 + <div class="legend-item"><span class="dot yellow"></span>黄灯</div>
  65 + </div>
  66 + <div class="pie-wrap">
  67 + <canvas
  68 + ref="pieCanvasRef"
  69 + width="280"
  70 + height="280"
  71 + class="pie-canvas"
  72 + @mousemove="onPieMouseMove"
  73 + @mouseleave="pieHoverItem = null"
  74 + ></canvas>
  75 + <div
  76 + v-if="pieHoverItem"
  77 + class="pie-tooltip"
  78 + :style="{ left: pieTooltipPos.x + 'px', top: pieTooltipPos.y + 'px' }"
  79 + >
  80 + <div class="pie-tip-title">时长详情</div>
  81 + <div class="pie-tip-row">
  82 + <span class="dot" :style="{ background: pieHoverItem.color }"></span>{{ pieHoverItem.key }}:{{
  83 + formatDuration(pieHoverItem.sec)
  84 + }}
  85 + </div>
  86 + </div>
  87 + </div>
  88 + </div>
  89 +
  90 + <div class="chart-card">
  91 + <div class="chart-title">异常原因分布</div>
  92 + <div class="legend">
  93 + <div class="legend-item"><span class="dot red"></span>红灯未知原因</div>
  94 + <div class="legend-item"><span class="dot yellow"></span>黄灯未知原因</div>
  95 + </div>
  96 + <div class="pie-wrap">
  97 + <canvas
  98 + ref="reasonCanvasRef"
  99 + width="280"
  100 + height="280"
  101 + class="pie-canvas"
  102 + @mousemove="onReasonMouseMove"
  103 + @mouseleave="reasonHoverItem = null"
  104 + ></canvas>
  105 + <div
  106 + v-if="reasonHoverItem"
  107 + class="pie-tooltip"
  108 + :style="{ left: reasonTooltipPos.x + 'px', top: reasonTooltipPos.y + 'px' }"
  109 + >
  110 + <div class="pie-tip-title">时长占比</div>
  111 + <div v-for="(row, i) in reasonHoverItem.rows" :key="i" class="pie-tip-row">
  112 + {{ row.key }}总时长 {{ formatDuration(row.sec) }}
  113 + </div>
  114 + <div class="pie-tip-row">时长占比 {{ reasonHoverItem.pct }}%</div>
  115 + </div>
  116 + </div>
  117 + </div>
  118 + </div>
  119 + </div>
  120 + <div class="section">
  121 + <div class="section-title">OEE时序详情</div>
  122 + <el-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
  123 + <div v-else class="detail-list">
  124 + <div v-for="(row, idx) in tableData" :key="idx" class="detail-item">
  125 + <div class="detail-top">
  126 + <div class="detail-time">{{ row.startTime }}</div>
  127 + <el-tag :type="statusTagType(row.statusName)" size="small">{{ row.statusName }}</el-tag>
  128 + </div>
  129 + <div class="detail-row">
  130 + <div class="k">运行时长</div>
  131 + <div class="v">{{ row.durationText }}</div>
  132 + </div>
  133 + <div class="detail-row">
  134 + <div class="k">原因</div>
  135 + <div class="v">{{ row.reason }}</div>
  136 + </div>
  137 + <div class="detail-row">
  138 + <div class="k">操作人</div>
  139 + <div class="v">{{ row.operator }}</div>
  140 + </div>
  141 + </div>
  142 + </div>
  143 + </div>
  144 +
  145 +
  146 + </template>
  147 + </div>
  148 + </div>
  149 +</template>
  150 +
  151 +<script setup>
  152 +import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
  153 +import { useRoute, useRouter } from 'vue-router'
  154 +import { ElMessage } from 'element-plus'
  155 +import { apiFetch } from '../config/api.js'
  156 +
  157 +const route = useRoute()
  158 +const router = useRouter()
  159 +
  160 +const deviceName = computed(() => String(route.query.deviceName || ''))
  161 +const dtuSn = computed(() => String(route.query.dtuSn || ''))
  162 +const queryDate = ref(new Date().toISOString().slice(0, 10))
  163 +const loading = ref(false)
  164 +
  165 +const stateColorMap = { 0: '#909399', 1: '#c0392b', 2: '#e67e22', 3: '#67c23a', 4: '#2463aa' }
  166 +const stateNameMap = { '0': '灭灯', '1': '红灯', '2': '黄灯', '3': '绿灯', '4': '蓝灯' }
  167 +
  168 +const lampData = ref([])
  169 +const stats = reactive({ off: '0秒', red: '0秒', yellow: '0秒', green: '0秒', blue: '0秒' })
  170 +
  171 +function formatDuration(seconds) {
  172 + if (!seconds || seconds <= 0) return '0秒'
  173 + seconds = Number(seconds)
  174 + const h = Math.floor(seconds / 3600)
  175 + const m = Math.floor((seconds % 3600) / 60)
  176 + const s = Math.round(seconds % 60)
  177 + if (h > 0) return `${h}时${m}分${s}秒`
  178 + if (m > 0) return `${m}分${s}秒`
  179 + return `${s}秒`
  180 +}
  181 +
  182 +function parseDurationToSec(str) {
  183 + if (!str) return 0
  184 + let total = 0
  185 + const hMatch = str.match(/(\d+)时/)
  186 + const mMatch = str.match(/(\d+)分/)
  187 + const sMatch = str.match(/(\d+)秒/)
  188 + if (hMatch) total += parseInt(hMatch[1]) * 3600
  189 + if (mMatch) total += parseInt(mMatch[1]) * 60
  190 + if (sMatch) total += parseInt(sMatch[1])
  191 + return total
  192 +}
  193 +
  194 +function formatTimeStr(isoStr) {
  195 + if (!isoStr) return ''
  196 + const d = new Date(isoStr)
  197 + const m = String(d.getMonth() + 1).padStart(2, '0')
  198 + const dd = String(d.getDate()).padStart(2, '0')
  199 + const h = String(d.getHours()).padStart(2, '0')
  200 + const min = String(d.getMinutes()).padStart(2, '0')
  201 + const s = String(d.getSeconds()).padStart(2, '0')
  202 + return `${m}/${dd} ${h}:${min}:${s}`
  203 +}
  204 +
  205 +function calcEndTime(startTime, durationSec) {
  206 + if (!startTime) return ''
  207 + const d = new Date(startTime)
  208 + d.setSeconds(d.getSeconds() + (durationSec || 0))
  209 + return formatTimeStr(d.toISOString())
  210 +}
  211 +
  212 +const tableData = computed(() => {
  213 + return lampData.value.map(item => ({
  214 + startTime: formatTimeStr(item.startTime),
  215 + status: item.lampState,
  216 + statusName: stateNameMap[String(item.lampState)] || '未知',
  217 + duration: item.duration,
  218 + durationText: formatDuration(item.duration),
  219 + reason: '无',
  220 + operator: '设备上传'
  221 + }))
  222 +})
  223 +
  224 +function statusTagType(status) {
  225 + const map = { '绿灯': '', '黄灯': 'warning', '红灯': 'danger', '蓝灯': '', '灭灯': 'info' }
  226 + return map[status] || ''
  227 +}
  228 +
  229 +async function fetchLampData() {
  230 + if (!dtuSn.value) return
  231 + loading.value = true
  232 + try {
  233 + const res = await apiFetch(`/api/device/lampData?dtuSn=${encodeURIComponent(dtuSn.value)}&date=${encodeURIComponent(queryDate.value)}`)
  234 + const data = await res.json()
  235 + const entry = (data.list && data.list[0]) || {}
  236 + lampData.value = (entry.lampData || []).sort((a, b) => new Date(b.startTime) - new Date(a.startTime))
  237 + const s = data.lampDurationStats || {}
  238 + stats.off = s.off || '0秒'
  239 + stats.red = s.red || '0秒'
  240 + stats.yellow = s.yellow || '0秒'
  241 + stats.green = s.green || '0秒'
  242 + stats.blue = s.blue || '0秒'
  243 + } catch (e) {
  244 + ElMessage.error('获取OEE数据失败')
  245 + console.warn(e)
  246 + } finally {
  247 + loading.value = false
  248 + }
  249 +}
  250 +
  251 +const CANVAS_H = 100
  252 +const timelineRef = ref(null)
  253 +const canvasRef = ref(null)
  254 +const hoverSeg = ref(null)
  255 +const tooltipPos = reactive({ x: 0, y: 0 })
  256 +const canvasW = ref(320)
  257 +const canvasH = ref(CANVAS_H)
  258 +const zoomLevel = ref(1)
  259 +const viewOffsetX = ref(0)
  260 +const minZoom = 0.001
  261 +const maxZoom = 1
  262 +
  263 +function onWheel(e) {
  264 + const container = timelineRef.value
  265 + if (!container) return
  266 + const rect = container.getBoundingClientRect()
  267 + const mx = e.clientX - rect.left
  268 + const mouseDataX = mx * zoomLevel.value + viewOffsetX.value
  269 + const delta = e.deltaY < 0 ? 0.8 : 1.25
  270 + const nextZ = Math.max(minZoom, Math.min(maxZoom, zoomLevel.value * delta))
  271 + viewOffsetX.value = mouseDataX - mx * nextZ
  272 + zoomLevel.value = nextZ
  273 +}
  274 +
  275 +const rawSegments = computed(() => {
  276 + if (!lampData.value.length) return []
  277 + const dayStr = queryDate.value || new Date().toISOString().slice(0, 10)
  278 + const [y, m, d] = dayStr.split('-').map(Number)
  279 + const dayStartMs = new Date(y, m - 1, d).getTime()
  280 + const plotLeft = 55
  281 + const plotW = canvasW.value - plotLeft - 10
  282 +
  283 + return lampData.value.map(item => {
  284 + const startMs = new Date(item.startTime).getTime()
  285 + const durSec = item.duration || 0
  286 + const endMs = startMs + durSec * 1000
  287 +
  288 + const startX = ((startMs - dayStartMs) / 86400000) * plotW + plotLeft
  289 + const endX = ((endMs - dayStartMs) / 86400000) * plotW + plotLeft
  290 + const x = Math.max(plotLeft, startX)
  291 + const w = Math.max(1, endX - x)
  292 +
  293 + return {
  294 + x,
  295 + w,
  296 + state: String(item.lampState),
  297 + startTime: item.startTime,
  298 + duration: item.duration,
  299 + startTimeText: formatTimeStr(item.startTime),
  300 + endTimeText: calcEndTime(item.startTime, item.duration)
  301 + }
  302 + })
  303 +})
  304 +
  305 +function drawTimeline() {
  306 + const canvas = canvasRef.value
  307 + if (!canvas) return
  308 + const ctx = canvas.getContext('2d')
  309 + const W = canvasW.value
  310 + const H = canvasH.value
  311 + const z = zoomLevel.value
  312 + const vo = viewOffsetX.value
  313 +
  314 + const labelLeft = 50
  315 + const plotLeft = 55
  316 + const plotW = W - plotLeft - 10
  317 + const barY = 24
  318 + const barH = 46
  319 + const axisY = barY + barH + 8
  320 + const timeLabelY = H - 4
  321 +
  322 + ctx.clearRect(0, 0, W, H)
  323 +
  324 + ctx.fillStyle = '#999'
  325 + ctx.font = '12px sans-serif'
  326 + ctx.fillText('时刻', labelLeft - vo / z, 16)
  327 +
  328 + const totalSec = 24 * 3600
  329 + const visSpanSec = Math.max(60, (W * z / plotW) * totalSec)
  330 + let stepSec = 14400
  331 + if (visSpanSec <= 120) stepSec = 30
  332 + else if (visSpanSec <= 300) stepSec = 60
  333 + else if (visSpanSec <= 600) stepSec = 300
  334 + else if (visSpanSec <= 1800) stepSec = 600
  335 + else if (visSpanSec <= 3600) stepSec = 900
  336 + else if (visSpanSec <= 7200) stepSec = 1800
  337 + else if (visSpanSec <= 28800) stepSec = 3600
  338 + else stepSec = 7200
  339 +
  340 + ctx.font = '11px sans-serif'
  341 + ctx.fillStyle = '#666'
  342 +
  343 + const leftSec = ((vo - plotLeft) / plotW) * totalSec
  344 + let firstSec = Math.floor(leftSec / stepSec) * stepSec
  345 + if (firstSec < 0) firstSec = 0
  346 +
  347 + for (let s = firstSec; s <= totalSec + stepSec * 2; s += stepSec) {
  348 + const dataPx = plotLeft + (s / totalSec) * plotW
  349 + const screenPx = (dataPx - vo) / z
  350 + if (screenPx < -50 || screenPx > W + 50) continue
  351 +
  352 + const h = Math.floor(s / 3600)
  353 + const mm = Math.floor((s % 3600) / 60)
  354 + const ss = s % 60
  355 +
  356 + if (stepSec < 60) {
  357 + ctx.fillText(`${String(h).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`, screenPx, timeLabelY)
  358 + } else {
  359 + ctx.fillText(`${String(h).padStart(2, '0')}:${String(mm).padStart(2, '0')}`, screenPx, timeLabelY)
  360 + }
  361 + }
  362 +
  363 + ctx.strokeStyle = '#ddd'
  364 + ctx.lineWidth = 1
  365 + ctx.beginPath()
  366 + ctx.moveTo((plotLeft - vo) / z, axisY)
  367 + ctx.lineTo(((plotLeft + plotW) - vo) / z, axisY)
  368 + ctx.stroke()
  369 +
  370 + const hoveredIdx = hoverSeg.value ? rawSegments.value.indexOf(hoverSeg.value) : -1
  371 + rawSegments.value.forEach((seg, idx) => {
  372 + const sx = (seg.x - vo) / z
  373 + const sw = seg.w / z
  374 + if (sx + sw < -1 || sx > W + 1) return
  375 + ctx.fillStyle = stateColorMap[seg.state] || '#909399'
  376 + ctx.globalAlpha = idx === hoveredIdx ? 0.7 : 1
  377 + ctx.fillRect(sx, barY, sw, barH)
  378 + })
  379 + ctx.globalAlpha = 1
  380 +}
  381 +
  382 +function onCanvasMouseMove(e) {
  383 + const container = timelineRef.value
  384 + if (!container) return
  385 + const rect = container.getBoundingClientRect()
  386 + const mx = e.clientX - rect.left
  387 + const my = e.clientY - rect.top
  388 + const z = zoomLevel.value
  389 + const vo = viewOffsetX.value
  390 + const dataX = mx * z + vo
  391 + const barY = 24
  392 + const barH = 46
  393 +
  394 + let found = null
  395 + for (let i = rawSegments.value.length - 1; i >= 0; i--) {
  396 + const seg = rawSegments.value[i]
  397 + if (dataX >= seg.x && dataX <= seg.x + seg.w && my >= barY && my <= barY + barH) {
  398 + found = seg
  399 + break
  400 + }
  401 + }
  402 +
  403 + hoverSeg.value = found
  404 + if (found) {
  405 + tooltipPos.x = Math.min(Math.max(mx + 12, 12), rect.width - 190)
  406 + tooltipPos.y = Math.max(my - 80, 6)
  407 + }
  408 +}
  409 +
  410 +function updateCanvasSize() {
  411 + if (!timelineRef.value) return
  412 + canvasW.value = Math.max(300, timelineRef.value.clientWidth)
  413 + canvasH.value = CANVAS_H
  414 +}
  415 +
  416 +const pieCanvasRef = ref(null)
  417 +const reasonCanvasRef = ref(null)
  418 +const pieHoverItem = ref(null)
  419 +const pieTooltipPos = reactive({ x: 0, y: 0 })
  420 +const reasonHoverItem = ref(null)
  421 +const reasonTooltipPos = reactive({ x: 0, y: 0 })
  422 +let pieAngleRanges = []
  423 +let reasonAngleRanges = []
  424 +
  425 +const stateSeconds = computed(() => ({
  426 + green: parseDurationToSec(stats.green),
  427 + red: parseDurationToSec(stats.red),
  428 + yellow: parseDurationToSec(stats.yellow)
  429 +}))
  430 +
  431 +const reasonStats = computed(() => {
  432 + const redSec = parseDurationToSec(stats.red)
  433 + const yellowSec = parseDurationToSec(stats.yellow)
  434 + const items = []
  435 + if (redSec > 0) items.push({ key: '红灯', sec: redSec, color: '#f56c6c' })
  436 + if (yellowSec > 0) items.push({ key: '黄灯', sec: yellowSec, color: '#f5a623' })
  437 + return items
  438 +})
  439 +
  440 +function drawPieChart(canvasRefKey, items, options = {}) {
  441 + const canvas = canvasRefKey?.value
  442 + if (!canvas) return
  443 + const ctx = canvas.getContext('2d')
  444 + const W = canvas.width
  445 + const H = canvas.height
  446 + const cx = W / 2
  447 + const cy = H / 2
  448 + const r = Math.min(W, H) / 2 - 8
  449 + const solid = !!options.solid
  450 + const innerR = solid ? 0 : r * 0.55
  451 +
  452 + ctx.clearRect(0, 0, W, H)
  453 +
  454 + if (!items || items.length === 0) {
  455 + ctx.beginPath()
  456 + ctx.arc(cx, cy, r, 0, Math.PI * 2)
  457 + if (!solid) ctx.arc(cx, cy, innerR, 0, Math.PI * 2, true)
  458 + ctx.fillStyle = '#eee'
  459 + ctx.fill()
  460 + ctx.fillStyle = '#999'
  461 + ctx.font = '12px sans-serif'
  462 + ctx.textAlign = 'center'
  463 + ctx.textBaseline = 'middle'
  464 + ctx.fillText('暂无数据', cx, cy)
  465 + if (canvas === pieCanvasRef.value) pieAngleRanges = []
  466 + if (canvas === reasonCanvasRef.value) reasonAngleRanges = []
  467 + return
  468 + }
  469 +
  470 + const total = items.reduce((s, i) => s + i.sec, 0) || 1
  471 + let startAngle = -Math.PI / 2
  472 +
  473 + if (canvas === pieCanvasRef.value) pieAngleRanges = []
  474 + if (canvas === reasonCanvasRef.value) reasonAngleRanges = []
  475 +
  476 + items.forEach(item => {
  477 + const angle = (item.sec / total) * Math.PI * 2
  478 + const endAngle = startAngle + angle
  479 + const midAngle = startAngle + angle / 2
  480 +
  481 + ctx.beginPath()
  482 + if (solid) {
  483 + ctx.moveTo(cx, cy)
  484 + ctx.arc(cx, cy, r, startAngle, endAngle)
  485 + ctx.closePath()
  486 + } else {
  487 + ctx.moveTo(cx + innerR * Math.cos(startAngle), cy + innerR * Math.sin(startAngle))
  488 + ctx.arc(cx, cy, r, startAngle, endAngle)
  489 + ctx.arc(cx, cy, innerR, endAngle, startAngle, true)
  490 + ctx.closePath()
  491 + }
  492 + ctx.fillStyle = item.color
  493 + ctx.fill()
  494 +
  495 + if (canvas === pieCanvasRef.value) pieAngleRanges.push({ ...item, startAngle, endAngle, cx, cy, r, innerR, total })
  496 + else if (canvas === reasonCanvasRef.value) reasonAngleRanges.push({ ...item, startAngle, endAngle, cx, cy, r, innerR, total })
  497 +
  498 + if (angle > 0.25) {
  499 + const labelR = solid ? r * 0.65 : (r + innerR) / 2
  500 + const lx = cx + labelR * Math.cos(midAngle)
  501 + const ly = cy + labelR * Math.sin(midAngle)
  502 + const pct = ((item.sec / total) * 100).toFixed(2)
  503 +
  504 + ctx.fillStyle = '#fff'
  505 + ctx.font = solid ? 'bold 16px sans-serif' : 'bold 14px sans-serif'
  506 + ctx.textAlign = 'center'
  507 + ctx.textBaseline = 'middle'
  508 + ctx.fillText(`${pct}%`, lx, ly - (solid ? 10 : 8))
  509 + ctx.font = solid ? '12px sans-serif' : '12px sans-serif'
  510 + ctx.fillText(item.key, lx, ly + 10)
  511 + }
  512 +
  513 + startAngle = endAngle
  514 + })
  515 +}
  516 +
  517 +function inAngleRange(a, sa, ea) {
  518 + const norm = v => {
  519 + let x = v % (Math.PI * 2)
  520 + if (x < 0) x += Math.PI * 2
  521 + return x
  522 + }
  523 + const na = norm(a)
  524 + const nsa = norm(sa)
  525 + const nea = norm(ea)
  526 + if (nea >= nsa) return na >= nsa && na <= nea
  527 + return na >= nsa || na <= nea
  528 +}
  529 +
  530 +function onPieMouseMove(e) {
  531 + const canvas = pieCanvasRef.value
  532 + if (!canvas) return
  533 + const rect = canvas.getBoundingClientRect()
  534 + const mx = e.clientX - rect.left
  535 + const my = e.clientY - rect.top
  536 + const scaleX = canvas.width / rect.width
  537 + const scaleY = canvas.height / rect.height
  538 + const px = mx * scaleX
  539 + const py = my * scaleY
  540 +
  541 + let found = null
  542 + for (let i = pieAngleRanges.length - 1; i >= 0; i--) {
  543 + const seg = pieAngleRanges[i]
  544 + const dx = px - seg.cx
  545 + const dy = py - seg.cy
  546 + const dist = Math.sqrt(dx * dx + dy * dy)
  547 + const inRadius = dist <= seg.r && dist >= seg.innerR
  548 + if (!inRadius) continue
  549 + const mouseAngle = Math.atan2(dy, dx)
  550 + if (inAngleRange(mouseAngle, seg.startAngle, seg.endAngle)) {
  551 + found = seg
  552 + break
  553 + }
  554 + }
  555 +
  556 + pieHoverItem.value = found
  557 + if (found) {
  558 + pieTooltipPos.x = Math.min(Math.max(mx + 12, 12), rect.width - 160)
  559 + pieTooltipPos.y = Math.max(my - 60, 6)
  560 + }
  561 +}
  562 +
  563 +function onReasonMouseMove(e) {
  564 + const canvas = reasonCanvasRef.value
  565 + if (!canvas) return
  566 + const rect = canvas.getBoundingClientRect()
  567 + const mx = e.clientX - rect.left
  568 + const my = e.clientY - rect.top
  569 + const scaleX = canvas.width / rect.width
  570 + const scaleY = canvas.height / rect.height
  571 + const px = mx * scaleX
  572 + const py = my * scaleY
  573 +
  574 + let found = null
  575 + for (let i = reasonAngleRanges.length - 1; i >= 0; i--) {
  576 + const seg = reasonAngleRanges[i]
  577 + const dx = px - seg.cx
  578 + const dy = py - seg.cy
  579 + const dist = Math.sqrt(dx * dx + dy * dy)
  580 + const inRadius = dist <= seg.r && dist >= seg.innerR
  581 + if (!inRadius) continue
  582 + const mouseAngle = Math.atan2(dy, dx)
  583 + if (inAngleRange(mouseAngle, seg.startAngle, seg.endAngle)) {
  584 + found = seg
  585 + break
  586 + }
  587 + }
  588 +
  589 + if (found || reasonAngleRanges.length > 0) {
  590 + const total = reasonAngleRanges.reduce((s, r) => s + r.sec, 0) || 1
  591 + const pct = ((found ? found.sec / total : 1) * 100).toFixed(2)
  592 + reasonHoverItem.value = found
  593 + ? { rows: [{ key: found.key, sec: found.sec }], pct }
  594 + : { rows: reasonAngleRanges.map(r => ({ key: r.key, sec: r.sec })), pct: '100.00' }
  595 + } else {
  596 + reasonHoverItem.value = null
  597 + }
  598 +
  599 + if (reasonHoverItem.value) {
  600 + reasonTooltipPos.x = Math.min(Math.max(mx + 12, 12), rect.width - 180)
  601 + reasonTooltipPos.y = Math.max(my - 70, 6)
  602 + }
  603 +}
  604 +
  605 +function drawDurationPie() {
  606 + const { green, red, yellow } = stateSeconds.value
  607 + const items = []
  608 + if (green > 0) items.push({ key: '绿灯', sec: green, color: '#67c23a' })
  609 + if (red > 0) items.push({ key: '红灯', sec: red, color: '#f56c6c' })
  610 + if (yellow > 0) items.push({ key: '黄灯', sec: yellow, color: '#f5a623' })
  611 + drawPieChart(pieCanvasRef, items, { solid: true })
  612 +}
  613 +
  614 +function drawReasonPie() {
  615 + drawPieChart(reasonCanvasRef, reasonStats.value)
  616 +}
  617 +
  618 +function goBack() {
  619 + if (window.history.length > 1) router.back()
  620 + else router.push({ path: '/smart-light-h5', query: route.query })
  621 +}
  622 +
  623 +watch([lampData, zoomLevel, viewOffsetX, canvasW], () => {
  624 + nextTick(drawTimeline)
  625 +}, { deep: true })
  626 +
  627 +watch([stats, lampData], () => {
  628 + nextTick(() => {
  629 + drawDurationPie()
  630 + drawReasonPie()
  631 + })
  632 +}, { deep: true })
  633 +
  634 +watch([dtuSn, queryDate], () => {
  635 + if (dtuSn.value) fetchLampData()
  636 +})
  637 +
  638 +onMounted(() => {
  639 + updateCanvasSize()
  640 + const el = timelineRef.value
  641 + if (el) {
  642 + const ro = new ResizeObserver(updateCanvasSize)
  643 + ro.observe(el)
  644 + }
  645 + nextTick(() => {
  646 + drawDurationPie()
  647 + drawReasonPie()
  648 + })
  649 + if (dtuSn.value) fetchLampData()
  650 +})
  651 +</script>
  652 +
  653 +<style scoped>
  654 +.h5-page {
  655 + min-height: 100vh;
  656 + background-color: #f5f6f8;
  657 +}
  658 +
  659 +.topbar {
  660 + position: sticky;
  661 + top: 0;
  662 + z-index: 10;
  663 + height: 48px;
  664 + display: flex;
  665 + align-items: center;
  666 + gap: 8px;
  667 + padding: 0 8px;
  668 + background: #fff;
  669 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  670 +}
  671 +
  672 +.title {
  673 + flex: 1 1 auto;
  674 + text-align: center;
  675 + font-weight: 700;
  676 + color: #111827;
  677 +}
  678 +
  679 +.spacer {
  680 + width: 48px;
  681 +}
  682 +
  683 +.content {
  684 + padding: 12px;
  685 +}
  686 +
  687 +.device {
  688 + font-size: 14px;
  689 + font-weight: 700;
  690 + color: #0f172a;
  691 + margin-bottom: 10px;
  692 + word-break: break-all;
  693 +}
  694 +
  695 +.query {
  696 + display: flex;
  697 + align-items: center;
  698 + justify-content: space-between;
  699 + gap: 10px;
  700 + margin-bottom: 10px;
  701 +}
  702 +
  703 +.query-left {
  704 + display: flex;
  705 + align-items: center;
  706 + gap: 8px;
  707 +}
  708 +
  709 +.query-label {
  710 + font-size: 12px;
  711 + color: #64748b;
  712 +}
  713 +
  714 +.section {
  715 + background: #fff;
  716 + border-radius: 12px;
  717 + padding: 12px;
  718 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  719 + margin-top: 10px;
  720 +}
  721 +
  722 +.section-title {
  723 + font-weight: 700;
  724 + color: #0f172a;
  725 + font-size: 14px;
  726 + margin-bottom: 10px;
  727 +}
  728 +
  729 +.timeline-wrap {
  730 + position: relative;
  731 + padding: 6px;
  732 + border: 1px solid rgba(0, 0, 0, 0.06);
  733 + border-radius: 10px;
  734 + overflow: hidden;
  735 + background: #fff;
  736 +}
  737 +
  738 +.timeline-canvas {
  739 + width: 100%;
  740 + height: 100px;
  741 + display: block;
  742 +}
  743 +
  744 +.hover-tooltip {
  745 + position: absolute;
  746 + width: 180px;
  747 + padding: 8px 10px;
  748 + background: rgba(17, 24, 39, 0.92);
  749 + color: #fff;
  750 + font-size: 12px;
  751 + border-radius: 10px;
  752 + pointer-events: none;
  753 +}
  754 +
  755 +.tip-row {
  756 + line-height: 1.45;
  757 +}
  758 +
  759 +.tip-label {
  760 + opacity: 0.85;
  761 +}
  762 +
  763 +.detail-list {
  764 + display: grid;
  765 + gap: 10px;
  766 +}
  767 +
  768 +.detail-item {
  769 + border: 1px solid rgba(0, 0, 0, 0.06);
  770 + border-radius: 12px;
  771 + padding: 10px;
  772 +}
  773 +
  774 +.detail-top {
  775 + display: flex;
  776 + align-items: center;
  777 + justify-content: space-between;
  778 + gap: 10px;
  779 + margin-bottom: 6px;
  780 +}
  781 +
  782 +.detail-time {
  783 + font-weight: 700;
  784 + color: #0f172a;
  785 + font-size: 13px;
  786 +}
  787 +
  788 +.detail-row {
  789 + display: flex;
  790 + align-items: center;
  791 + justify-content: space-between;
  792 + gap: 12px;
  793 + font-size: 12px;
  794 + padding-top: 4px;
  795 +}
  796 +
  797 +.detail-row .k {
  798 + color: #64748b;
  799 +}
  800 +
  801 +.detail-row .v {
  802 + color: #0f172a;
  803 + font-weight: 600;
  804 +}
  805 +
  806 +.charts {
  807 + display: grid;
  808 + gap: 12px;
  809 +}
  810 +
  811 +.chart-card {
  812 + border: 1px solid rgba(0, 0, 0, 0.06);
  813 + border-radius: 12px;
  814 + padding: 10px;
  815 +}
  816 +
  817 +.chart-title {
  818 + font-weight: 700;
  819 + color: #0f172a;
  820 + font-size: 13px;
  821 + margin-bottom: 8px;
  822 +}
  823 +
  824 +.legend {
  825 + display: flex;
  826 + gap: 12px;
  827 + flex-wrap: wrap;
  828 + color: #475569;
  829 + font-size: 12px;
  830 + margin-bottom: 8px;
  831 +}
  832 +
  833 +.legend-item {
  834 + display: inline-flex;
  835 + align-items: center;
  836 + gap: 6px;
  837 +}
  838 +
  839 +.dot {
  840 + width: 10px;
  841 + height: 10px;
  842 + border-radius: 999px;
  843 +}
  844 +
  845 +.dot.green {
  846 + background: #67c23a;
  847 +}
  848 +
  849 +.dot.red {
  850 + background: #f56c6c;
  851 +}
  852 +
  853 +.dot.yellow {
  854 + background: #f5a623;
  855 +}
  856 +
  857 +.pie-wrap {
  858 + position: relative;
  859 + display: flex;
  860 + justify-content: center;
  861 +}
  862 +
  863 +.pie-canvas {
  864 + width: 240px;
  865 + height: 240px;
  866 +}
  867 +
  868 +.pie-tooltip {
  869 + position: absolute;
  870 + min-width: 160px;
  871 + padding: 8px 10px;
  872 + background: rgba(17, 24, 39, 0.92);
  873 + color: #fff;
  874 + font-size: 12px;
  875 + border-radius: 10px;
  876 + pointer-events: none;
  877 +}
  878 +
  879 +.pie-tip-title {
  880 + font-weight: 700;
  881 + margin-bottom: 6px;
  882 +}
  883 +
  884 +.pie-tip-row {
  885 + display: flex;
  886 + align-items: center;
  887 + gap: 6px;
  888 + line-height: 1.45;
  889 +}
  890 +
  891 +.pie-tip-row .dot {
  892 + width: 8px;
  893 + height: 8px;
  894 +}
  895 +</style>
... ...
  1 +<template>
  2 + <div class="h5-page">
  3 + <div class="topbar">
  4 + <el-button text @click="goBack">返回</el-button>
  5 + <div class="title">稼动率</div>
  6 + <div class="spacer"></div>
  7 + </div>
  8 +
  9 + <div class="content">
  10 + <div class="device">{{ deviceName || '-' }}</div>
  11 +
  12 + <div class="query">
  13 + <el-radio-group v-model="queryMode" size="small">
  14 + <el-radio-button value="day">日查询</el-radio-button>
  15 + <el-radio-button value="week">周查询</el-radio-button>
  16 + <el-radio-button value="month">月查询</el-radio-button>
  17 + </el-radio-group>
  18 + <el-date-picker
  19 + v-if="queryMode === 'day'"
  20 + v-model="queryDateRange"
  21 + type="daterange"
  22 + start-placeholder="开始"
  23 + end-placeholder="结束"
  24 + value-format="YYYY-MM-DD"
  25 + size="small"
  26 + class="range"
  27 + />
  28 + <el-button type="primary" size="small" :loading="loading" @click="fetchOeeData">查询</el-button>
  29 + </div>
  30 +
  31 + <el-empty v-if="!dtuSn" description="缺少 dtuSn" />
  32 +
  33 + <template v-else>
  34 + <div class="section">
  35 + <div class="section-title">颜色时长明细</div>
  36 + <div class="legend-inline">
  37 + <span class="leg"><i class="dot green"></i>绿灯</span>
  38 + <span class="leg"><i class="dot yellow"></i>黄灯</span>
  39 + <span class="leg"><i class="dot red"></i>红灯</span>
  40 + <span class="leg"><i class="dot gray"></i>离线</span>
  41 + </div>
  42 + <div class="canvas-scroll">
  43 + <div class="canvas-wrap">
  44 + <canvas ref="durCanvas" :width="durCanvasW" :height="240"></canvas>
  45 + </div>
  46 + </div>
  47 + </div>
  48 +
  49 + <div class="section">
  50 + <div class="section-title">颜色时长总计</div>
  51 + <div class="pie-wrap">
  52 + <canvas ref="durPieCanvas" width="200" height="200"></canvas>
  53 + </div>
  54 + <div class="pie-legend">
  55 + <div class="pleg"><span class="pdot green"></span>绿灯</div>
  56 + <div class="pleg"><span class="pdot yellow"></span>黄灯</div>
  57 + <div class="pleg"><span class="pdot red"></span>红灯</div>
  58 + <div class="pleg"><span class="pdot gray"></span>离线</div>
  59 + </div>
  60 + </div>
  61 +
  62 + <div class="section">
  63 + <div class="section-title">颜色次数明细</div>
  64 + <div class="legend-inline">
  65 + <span class="leg"><i class="dot green"></i>绿灯</span>
  66 + <span class="leg"><i class="dot yellow"></i>黄灯</span>
  67 + <span class="leg"><i class="dot red"></i>红灯</span>
  68 + <span class="leg"><i class="dot gray"></i>离线</span>
  69 + </div>
  70 + <div class="canvas-scroll">
  71 + <div class="canvas-wrap">
  72 + <canvas ref="freqCanvas" :width="freqCanvasW" :height="260"></canvas>
  73 + </div>
  74 + </div>
  75 + </div>
  76 +
  77 + <div class="section">
  78 + <div class="section-title">颜色次数总计</div>
  79 + <div class="pie-wrap">
  80 + <canvas ref="freqPieCanvas" width="200" height="200"></canvas>
  81 + </div>
  82 + <div class="pie-legend">
  83 + <div class="pleg"><span class="pdot green"></span>绿灯</div>
  84 + <div class="pleg"><span class="pdot yellow"></span>黄灯</div>
  85 + <div class="pleg"><span class="pdot red"></span>红灯</div>
  86 + <div class="pleg"><span class="pdot gray"></span>离线</div>
  87 + </div>
  88 + </div>
  89 + </template>
  90 + </div>
  91 + </div>
  92 +</template>
  93 +
  94 +<script setup>
  95 +import { computed, nextTick, onMounted, ref, watch } from 'vue'
  96 +import { useRoute, useRouter } from 'vue-router'
  97 +import { ElMessage } from 'element-plus'
  98 +import { apiFetch } from '../config/api.js'
  99 +
  100 +const route = useRoute()
  101 +const router = useRouter()
  102 +
  103 +const deviceName = computed(() => String(route.query.deviceName || ''))
  104 +const dtuSn = computed(() => String(route.query.dtuSn || ''))
  105 +
  106 +const queryMode = ref('day')
  107 +const now = new Date()
  108 +const weekAgo = new Date(now)
  109 +weekAgo.setDate(weekAgo.getDate() - 6)
  110 +const queryDateRange = ref([formatDate(weekAgo), formatDate(now)])
  111 +
  112 +const loading = ref(false)
  113 +const oeeData = ref(null)
  114 +
  115 +const summary = computed(() => oeeData.value?.summary || {})
  116 +const displayList = computed(() => oeeData.value?.list || [])
  117 +
  118 +const padL = 50
  119 +const padB = 24
  120 +const COLORS = { green: '#67c23a', yellow: '#e6a23c', red: '#f56c6c', off: '#909399' }
  121 +const COLOR_NAMES = { green: '绿灯', yellow: '黄灯', red: '红灯', off: '离线' }
  122 +
  123 +const durCanvas = ref(null)
  124 +const freqCanvas = ref(null)
  125 +const durPieCanvas = ref(null)
  126 +const freqPieCanvas = ref(null)
  127 +
  128 +const durCanvasW = computed(() => {
  129 + const n = Math.max(displayList.value.length, 1)
  130 + return Math.max(360, padL + n * 64 + 20)
  131 +})
  132 +const freqCanvasW = computed(() => {
  133 + const n = Math.max(displayList.value.length, 1)
  134 + return Math.max(360, padL + n * 64 + 20)
  135 +})
  136 +
  137 +const maxDurSec = computed(() => {
  138 + const list = displayList.value
  139 + if (!list.length) return 86400
  140 + let max = 1
  141 + list.forEach(item => {
  142 + const total = (item.off?.seconds || 0) + (item.red?.seconds || 0) + (item.yellow?.seconds || 0) + (item.green?.seconds || 0)
  143 + max = Math.max(max, total)
  144 + })
  145 + return max
  146 +})
  147 +
  148 +const maxFreqCount = computed(() => {
  149 + const list = displayList.value
  150 + if (!list.length) return 100
  151 + let max = 1
  152 + list.forEach(item => {
  153 + const total = (item.off?.count || 0) + (item.red?.count || 0) + (item.yellow?.count || 0) + (item.green?.count || 0)
  154 + max = Math.max(max, total)
  155 + })
  156 + return max
  157 +})
  158 +
  159 +const durPieSegs = computed(() => {
  160 + const s = summary.value
  161 + const items = [
  162 + { key: 'green', name: '绿灯', val: s.green?.seconds || 0, color: COLORS.green },
  163 + { key: 'yellow', name: '黄灯', val: s.yellow?.seconds || 0, color: COLORS.yellow },
  164 + { key: 'red', name: '红灯', val: s.red?.seconds || 0, color: COLORS.red },
  165 + { key: 'off', name: '离线', val: s.off?.seconds || 0, color: COLORS.off }
  166 + ].filter(x => x.val > 0).sort((a, b) => b.val - a.val)
  167 + const total = items.reduce((sum, x) => sum + x.val, 0) || 1
  168 + let angle = -Math.PI / 2
  169 + return items.map(item => {
  170 + const sweep = (item.val / total) * Math.PI * 2
  171 + const seg = { ...item, startAngle: angle, endAngle: angle + sweep, pct: ((item.val / total) * 100).toFixed(2) }
  172 + angle += sweep
  173 + return seg
  174 + })
  175 +})
  176 +
  177 +const freqPieSegs = computed(() => {
  178 + const s = summary.value
  179 + const items = [
  180 + { key: 'green', name: '绿灯', val: s.green?.count || 0, color: COLORS.green },
  181 + { key: 'yellow', name: '黄灯', val: s.yellow?.count || 0, color: COLORS.yellow },
  182 + { key: 'red', name: '红灯', val: s.red?.count || 0, color: COLORS.red },
  183 + { key: 'off', name: '离线', val: s.off?.count || 0, color: COLORS.off }
  184 + ].filter(x => x.val > 0).sort((a, b) => b.val - a.val)
  185 + const total = items.reduce((sum, x) => sum + x.val, 0) || 1
  186 + let angle = -Math.PI / 2
  187 + return items.map(item => {
  188 + const sweep = (item.val / total) * Math.PI * 2
  189 + const seg = { ...item, startAngle: angle, endAngle: angle + sweep, pct: ((item.val / total) * 100).toFixed(2) }
  190 + angle += sweep
  191 + return seg
  192 + })
  193 +})
  194 +
  195 +function formatDate(d) {
  196 + const dt = new Date(d)
  197 + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
  198 +}
  199 +
  200 +function formatDateShort(s) {
  201 + if (!s) return ''
  202 + return String(s).slice(5)
  203 +}
  204 +
  205 +function formatSec(s) {
  206 + if (!s || s <= 0) return '0秒'
  207 + const h = Math.floor(s / 3600)
  208 + const m = Math.floor((s % 3600) / 60)
  209 + const sec = Math.floor(s % 60)
  210 + if (h > 0) return `${h}时${m}分${sec}秒`
  211 + if (m > 0) return `${m}分${sec}秒`
  212 + return `${sec}秒`
  213 +}
  214 +
  215 +const dpr = window.devicePixelRatio || 1
  216 +function setupCanvas(cvs, cssW, cssH) {
  217 + cvs.width = Math.round(cssW * dpr)
  218 + cvs.height = Math.round(cssH * dpr)
  219 + cvs.style.width = cssW + 'px'
  220 + cvs.style.height = cssH + 'px'
  221 + const ctx = cvs.getContext('2d')
  222 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  223 + return { ctx, W: cssW, H: cssH }
  224 +}
  225 +
  226 +function drawDurBar() {
  227 + const cvs = durCanvas.value
  228 + if (!cvs) return
  229 + const wrap = cvs.parentElement
  230 + const cssH = 240
  231 + const cssW = Math.max(wrap ? wrap.clientWidth : durCanvasW.value, durCanvasW.value)
  232 + const { ctx, W, H } = setupCanvas(cvs, cssW, cssH)
  233 + ctx.clearRect(0, 0, W, H)
  234 +
  235 + const plotTop = 18
  236 + const plotBottom = H - padB
  237 + const plotH = plotBottom - plotTop
  238 + const n = displayList.value.length
  239 + const groupW = n ? (W - padL - 10) / n : 1
  240 + const maxSec = maxDurSec.value || 1
  241 +
  242 + ctx.font = '10px sans-serif'
  243 + ctx.fillStyle = '#999'
  244 + ctx.textAlign = 'right'
  245 + const tickCount = 5
  246 + for (let i = 0; i <= tickCount; i++) {
  247 + const sec = (maxSec * i) / tickCount
  248 + const y = plotBottom - (i / tickCount) * plotH + 3
  249 + const h = Math.floor(sec / 3600)
  250 + const m = Math.floor((sec % 3600) / 60)
  251 + ctx.fillText(h > 0 ? `${h}时` : `${m}分`, 32, y)
  252 + }
  253 +
  254 + ctx.font = '9px sans-serif'
  255 + ctx.fillStyle = '#666'
  256 + ctx.textAlign = 'center'
  257 + displayList.value.forEach((item, i) => {
  258 + ctx.fillText(item.label || formatDateShort(item.startDate), padL + (i + 0.5) * groupW, H - 4)
  259 + })
  260 +
  261 + ctx.strokeStyle = '#f0f0f0'
  262 + ctx.lineWidth = 1
  263 + for (let i = 0; i <= tickCount; i++) {
  264 + const y = plotBottom - (i / tickCount) * plotH
  265 + ctx.beginPath()
  266 + ctx.moveTo(padL, y)
  267 + ctx.lineTo(W - 10, y)
  268 + ctx.stroke()
  269 + }
  270 +
  271 + displayList.value.forEach((item, idx) => {
  272 + const bx = padL + idx * groupW + (groupW - Math.min(groupW - 8, 44)) / 2
  273 + const bw = Math.min(groupW - 8, 44)
  274 + const scale = plotH / maxSec
  275 + let curY = plotBottom
  276 + const stack = [
  277 + { k: 'off', sec: item.off?.seconds || 0 },
  278 + { k: 'red', sec: item.red?.seconds || 0 },
  279 + { k: 'yellow', sec: item.yellow?.seconds || 0 },
  280 + { k: 'green', sec: item.green?.seconds || 0 }
  281 + ]
  282 + stack.forEach(seg => {
  283 + if (!seg.sec) return
  284 + const h = seg.sec * scale
  285 + curY -= h
  286 + ctx.fillStyle = COLORS[seg.k]
  287 + ctx.fillRect(bx, curY, bw, h)
  288 + })
  289 + })
  290 +}
  291 +
  292 +function drawFreqBar() {
  293 + const cvs = freqCanvas.value
  294 + if (!cvs) return
  295 + const wrap = cvs.parentElement
  296 + const cssH = 260
  297 + const cssW = Math.max(wrap ? wrap.clientWidth : freqCanvasW.value, freqCanvasW.value)
  298 + const { ctx, W, H } = setupCanvas(cvs, cssW, cssH)
  299 + ctx.clearRect(0, 0, W, H)
  300 +
  301 + const plotTop = 22
  302 + const plotBottom = H - padB
  303 + const plotH = plotBottom - plotTop
  304 + const n = displayList.value.length
  305 + const groupW = n ? (W - padL - 10) / n : 1
  306 + const maxCnt = maxFreqCount.value || 1
  307 +
  308 + ctx.font = '10px sans-serif'
  309 + ctx.fillStyle = '#999'
  310 + ctx.textAlign = 'right'
  311 + const tickCount = 6
  312 + const step = Math.ceil(maxCnt / tickCount / 10) * 10 || 10
  313 + for (let i = 0; i <= tickCount; i++) {
  314 + const y = plotBottom - (i / tickCount) * plotH + 3
  315 + ctx.fillText(i * step + '次', 32, y)
  316 + }
  317 +
  318 + ctx.font = '9px sans-serif'
  319 + ctx.fillStyle = '#666'
  320 + ctx.textAlign = 'center'
  321 + displayList.value.forEach((item, i) => {
  322 + ctx.fillText(item.label || formatDateShort(item.startDate), padL + (i + 0.5) * groupW, H - 4)
  323 + })
  324 +
  325 + ctx.strokeStyle = '#f0f0f0'
  326 + ctx.lineWidth = 1
  327 + for (let i = 0; i <= tickCount; i++) {
  328 + const y = plotBottom - (i / tickCount) * plotH
  329 + ctx.beginPath()
  330 + ctx.moveTo(padL, y)
  331 + ctx.lineTo(W - 10, y)
  332 + ctx.stroke()
  333 + }
  334 +
  335 + displayList.value.forEach((item, idx) => {
  336 + const bx = padL + idx * groupW + (groupW - Math.min(groupW - 8, 44)) / 2
  337 + const bw = Math.min(groupW - 8, 44)
  338 + const scale = plotH / (step * tickCount)
  339 + let curY = plotBottom
  340 + const stack = [
  341 + { k: 'off', cnt: item.off?.count || 0 },
  342 + { k: 'red', cnt: item.red?.count || 0 },
  343 + { k: 'yellow', cnt: item.yellow?.count || 0 },
  344 + { k: 'green', cnt: item.green?.count || 0 }
  345 + ]
  346 + stack.forEach(seg => {
  347 + if (!seg.cnt) return
  348 + const h = seg.cnt * scale
  349 + curY -= h
  350 + ctx.fillStyle = COLORS[seg.k]
  351 + ctx.fillRect(bx, curY, bw, h)
  352 + })
  353 + })
  354 +}
  355 +
  356 +function drawPie(cvsRef, segs) {
  357 + const cvs = cvsRef.value
  358 + if (!cvs) return
  359 + const { ctx } = setupCanvas(cvs, 200, 200)
  360 + ctx.clearRect(0, 0, 200, 200)
  361 + const cx = 100
  362 + const cy = 100
  363 + const R = 74
  364 +
  365 + if (!segs.length) {
  366 + ctx.beginPath()
  367 + ctx.arc(cx, cy, R, 0, Math.PI * 2)
  368 + ctx.fillStyle = '#eee'
  369 + ctx.fill()
  370 + ctx.fillStyle = '#999'
  371 + ctx.font = '12px sans-serif'
  372 + ctx.textAlign = 'center'
  373 + ctx.textBaseline = 'middle'
  374 + ctx.fillText('暂无数据', cx, cy)
  375 + return
  376 + }
  377 +
  378 + segs.forEach(seg => {
  379 + ctx.beginPath()
  380 + ctx.moveTo(cx, cy)
  381 + ctx.arc(cx, cy, R, seg.startAngle, seg.endAngle)
  382 + ctx.closePath()
  383 + ctx.fillStyle = seg.color
  384 + ctx.fill()
  385 + })
  386 +
  387 + ctx.fillStyle = '#fff'
  388 + ctx.textAlign = 'center'
  389 + ctx.textBaseline = 'middle'
  390 + segs.forEach(seg => {
  391 + const midAngle = (seg.startAngle + seg.endAngle) / 2
  392 + const tx = cx + Math.cos(midAngle) * R * 0.58
  393 + const ty = cy + Math.sin(midAngle) * R * 0.58
  394 + ctx.font = 'bold 12px sans-serif'
  395 + ctx.fillText(seg.pct + '%', tx, ty - 6)
  396 + ctx.font = '10px sans-serif'
  397 + ctx.fillText(seg.name, tx, ty + 7)
  398 + })
  399 +}
  400 +
  401 +function redrawAll() {
  402 + nextTick(() => {
  403 + drawDurBar()
  404 + drawFreqBar()
  405 + drawPie(durPieCanvas, durPieSegs.value)
  406 + drawPie(freqPieCanvas, freqPieSegs.value)
  407 + })
  408 +}
  409 +
  410 +async function fetchOeeData() {
  411 + if (!dtuSn.value) return
  412 + let params = `dtuSn=${encodeURIComponent(dtuSn.value)}&type=${encodeURIComponent(queryMode.value)}`
  413 + if (queryMode.value === 'day') {
  414 + const [start, end] = queryDateRange.value || []
  415 + if (!start || !end) return
  416 + params += `&startDate=${encodeURIComponent(start)}&endDate=${encodeURIComponent(end)}`
  417 + }
  418 + loading.value = true
  419 + try {
  420 + const res = await apiFetch(`/api/device/oeeStats?${params}`)
  421 + oeeData.value = await res.json()
  422 + } catch (e) {
  423 + ElMessage.error('获取稼动率数据失败')
  424 + console.warn(e)
  425 + } finally {
  426 + loading.value = false
  427 + }
  428 +}
  429 +
  430 +function goBack() {
  431 + if (window.history.length > 1) router.back()
  432 + else router.push({ path: '/smart-light-h5', query: route.query })
  433 +}
  434 +
  435 +watch([durCanvasW, freqCanvasW], () => redrawAll())
  436 +watch(oeeData, () => redrawAll(), { deep: true })
  437 +watch(queryMode, () => fetchOeeData())
  438 +watch(queryDateRange, () => {
  439 + if (queryMode.value === 'day') fetchOeeData()
  440 +})
  441 +
  442 +onMounted(() => {
  443 + if (dtuSn.value) fetchOeeData()
  444 +})
  445 +</script>
  446 +
  447 +<style scoped>
  448 +.h5-page {
  449 + min-height: 100vh;
  450 + background-color: #f5f6f8;
  451 +}
  452 +
  453 +.topbar {
  454 + position: sticky;
  455 + top: 0;
  456 + z-index: 10;
  457 + height: 48px;
  458 + display: flex;
  459 + align-items: center;
  460 + gap: 8px;
  461 + padding: 0 8px;
  462 + background: #fff;
  463 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  464 +}
  465 +
  466 +.title {
  467 + flex: 1 1 auto;
  468 + text-align: center;
  469 + font-weight: 700;
  470 + color: #111827;
  471 +}
  472 +
  473 +.spacer {
  474 + width: 48px;
  475 +}
  476 +
  477 +.content {
  478 + padding: 12px;
  479 +}
  480 +
  481 +.device {
  482 + font-size: 14px;
  483 + font-weight: 700;
  484 + color: #0f172a;
  485 + margin-bottom: 10px;
  486 + word-break: break-all;
  487 +}
  488 +
  489 +.query {
  490 + display: flex;
  491 + align-items: center;
  492 + flex-wrap: wrap;
  493 + gap: 8px;
  494 + margin-bottom: 10px;
  495 +}
  496 +
  497 +.range {
  498 + width: 100%;
  499 +}
  500 +
  501 +.section {
  502 + background: #fff;
  503 + border-radius: 12px;
  504 + padding: 12px;
  505 + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  506 + margin-top: 10px;
  507 +}
  508 +
  509 +.section-title {
  510 + font-weight: 700;
  511 + color: #0f172a;
  512 + font-size: 14px;
  513 + margin-bottom: 10px;
  514 +}
  515 +
  516 +.legend-inline {
  517 + display: flex;
  518 + gap: 12px;
  519 + flex-wrap: wrap;
  520 + color: #475569;
  521 + font-size: 12px;
  522 + margin-bottom: 8px;
  523 +}
  524 +
  525 +.leg {
  526 + display: inline-flex;
  527 + align-items: center;
  528 + gap: 6px;
  529 +}
  530 +
  531 +.dot {
  532 + width: 10px;
  533 + height: 10px;
  534 + border-radius: 999px;
  535 +}
  536 +
  537 +.dot.green {
  538 + background: #67c23a;
  539 +}
  540 +
  541 +.dot.yellow {
  542 + background: #e6a23c;
  543 +}
  544 +
  545 +.dot.red {
  546 + background: #f56c6c;
  547 +}
  548 +
  549 +.dot.gray {
  550 + background: #909399;
  551 +}
  552 +
  553 +.canvas-scroll {
  554 + overflow-x: auto;
  555 + -webkit-overflow-scrolling: touch;
  556 +}
  557 +
  558 +.canvas-wrap {
  559 + min-width: 100%;
  560 +}
  561 +
  562 +.pie-wrap {
  563 + display: flex;
  564 + justify-content: center;
  565 + padding-top: 6px;
  566 +}
  567 +
  568 +.pie-legend {
  569 + display: grid;
  570 + grid-template-columns: repeat(2, minmax(0, 1fr));
  571 + gap: 8px;
  572 + margin-top: 10px;
  573 + color: #475569;
  574 + font-size: 12px;
  575 +}
  576 +
  577 +.pleg {
  578 + display: inline-flex;
  579 + align-items: center;
  580 + gap: 6px;
  581 +}
  582 +
  583 +.pdot {
  584 + width: 10px;
  585 + height: 10px;
  586 + border-radius: 999px;
  587 +}
  588 +
  589 +.pdot.green {
  590 + background: #67c23a;
  591 +}
  592 +
  593 +.pdot.yellow {
  594 + background: #e6a23c;
  595 +}
  596 +
  597 +.pdot.red {
  598 + background: #f56c6c;
  599 +}
  600 +
  601 +.pdot.gray {
  602 + background: #909399;
  603 +}
  604 +</style>
... ...
... ... @@ -16,6 +16,14 @@ export default defineConfig({
16 16 changeOrigin: true,
17 17 rewrite: (path) => path.replace(/^\/api/, '/iot-scheduler'),
18 18 },
  19 + '/device': {
  20 + target: 'http://10.9.5.84:33221',
  21 + changeOrigin: true,
  22 + },
  23 + '/energy': {
  24 + target: 'http://10.9.5.84:33221',
  25 + changeOrigin: true,
  26 + },
19 27 },
20 28 },
21 29 resolve: {
... ... @@ -23,4 +31,4 @@ export default defineConfig({
23 31 '@': fileURLToPath(new URL('./src', import.meta.url))
24 32 },
25 33 },
26   -})
  34 +})
\ No newline at end of file
... ...