Showing
18 changed files
with
8609 additions
and
4 deletions
| 1 | <template> | 1 | <template> |
| 2 | - <div class="app-container"> | 2 | + <div v-if="isH5Route" class="h5-container"> |
| 3 | + <router-view /> | ||
| 4 | + </div> | ||
| 5 | + <div v-else class="app-container"> | ||
| 3 | <el-container> | 6 | <el-container> |
| 4 | <el-aside width="180px" class="sidebar"> | 7 | <el-aside width="180px" class="sidebar"> |
| 5 | <div class="logo-area"> | 8 | <div class="logo-area"> |
| @@ -30,17 +33,22 @@ | @@ -30,17 +33,22 @@ | ||
| 30 | </template> | 33 | </template> |
| 31 | 34 | ||
| 32 | <script setup> | 35 | <script setup> |
| 33 | -import { computed, ref, provide } from 'vue' | 36 | +import { computed, provide, watchEffect } from 'vue' |
| 34 | import { useRoute, useRouter } from 'vue-router' | 37 | import { useRoute, useRouter } from 'vue-router' |
| 38 | +import { setCorpCode } from './config/api.js' | ||
| 35 | 39 | ||
| 36 | const route = useRoute() | 40 | const route = useRoute() |
| 37 | const router = useRouter() | 41 | const router = useRouter() |
| 38 | const currentRoute = computed(() => route.path) | 42 | const currentRoute = computed(() => route.path) |
| 43 | +const isH5Route = computed(() => Boolean(route.meta?.h5)) | ||
| 39 | 44 | ||
| 40 | // 从URL获取corpCode,全局共享(hash模式下参数在#后面,需用route.query) | 45 | // 从URL获取corpCode,全局共享(hash模式下参数在#后面,需用route.query) |
| 41 | // 使用computed确保路由切换时corpCode始终同步 | 46 | // 使用computed确保路由切换时corpCode始终同步 |
| 42 | const corpCode = computed(() => route.query.corpCode || '') | 47 | const corpCode = computed(() => route.query.corpCode || '') |
| 43 | provide('corpCode', corpCode) | 48 | provide('corpCode', corpCode) |
| 49 | +watchEffect(() => { | ||
| 50 | + setCorpCode(corpCode.value) | ||
| 51 | +}) | ||
| 44 | 52 | ||
| 45 | // 给URL拼接corpCode | 53 | // 给URL拼接corpCode |
| 46 | function withCorpCode(path) { | 54 | function withCorpCode(path) { |
| @@ -56,6 +64,10 @@ function navigateTo(path) { | @@ -56,6 +64,10 @@ function navigateTo(path) { | ||
| 56 | </script> | 64 | </script> |
| 57 | 65 | ||
| 58 | <style scoped> | 66 | <style scoped> |
| 67 | +.h5-container { | ||
| 68 | + min-height: 100vh; | ||
| 69 | + background-color: #f5f6f8; | ||
| 70 | +} | ||
| 59 | .app-container { | 71 | .app-container { |
| 60 | height: 100vh; | 72 | height: 100vh; |
| 61 | overflow: hidden; | 73 | overflow: hidden; |
src/components/h5/EnergyH5EfficiencyTab.vue
0 → 100644
| 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> |
src/components/h5/EnergyH5RealtimeTab.vue
0 → 100644
| 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> |
src/components/h5/EnergyH5TimeseriesTab.vue
0 → 100644
| 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> |
src/components/h5/EnergyH5UtilizationTab.vue
0 → 100644
| 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> |
src/components/h5/SmartLightH5StartupTab.vue
0 → 100644
| 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 | // API基础地址 | 1 | // API基础地址 |
| 2 | const API_BASE = import.meta.env.VITE_API_BASE || '' | 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 | // 生产环境后端路径不带 /api/ 前缀,这里做兼容处理 | 18 | // 生产环境后端路径不带 /api/ 前缀,这里做兼容处理 |
| 5 | export function getApiUrl(url) { | 19 | export function getApiUrl(url) { |
| 20 | + if (!url) return API_BASE | ||
| 21 | + if (isAbsoluteUrl(url)) return url | ||
| 6 | const cleanUrl = url.replace(/^\/api\//, '/') | 22 | const cleanUrl = url.replace(/^\/api\//, '/') |
| 7 | return API_BASE + cleanUrl | 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 | export default API_BASE | 43 | export default API_BASE |
| @@ -6,12 +6,52 @@ const routes = [ | @@ -6,12 +6,52 @@ const routes = [ | ||
| 6 | redirect: '/smart-light' | 6 | redirect: '/smart-light' |
| 7 | }, | 7 | }, |
| 8 | { | 8 | { |
| 9 | + path: '/h5', | ||
| 10 | + redirect: '/smart-light-h5' | ||
| 11 | + }, | ||
| 12 | + { | ||
| 9 | path: '/smart-light', | 13 | path: '/smart-light', |
| 10 | name: 'SmartLight', | 14 | name: 'SmartLight', |
| 11 | component: () => import('../views/SmartLight.vue'), | 15 | component: () => import('../views/SmartLight.vue'), |
| 12 | meta: { title: '动态监控' } | 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 | path: '/energy', | 55 | path: '/energy', |
| 16 | name: 'Energy', | 56 | name: 'Energy', |
| 17 | component: () => import('../views/Energy.vue'), | 57 | component: () => import('../views/Energy.vue'), |
| @@ -24,4 +64,4 @@ const router = createRouter({ | @@ -24,4 +64,4 @@ const router = createRouter({ | ||
| 24 | routes | 64 | routes |
| 25 | }) | 65 | }) |
| 26 | 66 | ||
| 27 | -export default router | ||
| 67 | +export default router |
src/views/EnergyH5.vue
0 → 100644
| 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> |
src/views/EnergyH5RunStatus.vue
0 → 100644
| 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> |
src/views/EnergyH5Usage.vue
0 → 100644
| 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> |
src/views/SmartLightH5.vue
0 → 100644
| 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> |
src/views/SmartLightH5Oee.vue
0 → 100644
| 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> |
src/views/SmartLightH5Utilization.vue
0 → 100644
| 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,6 +16,14 @@ export default defineConfig({ | ||
| 16 | changeOrigin: true, | 16 | changeOrigin: true, |
| 17 | rewrite: (path) => path.replace(/^\/api/, '/iot-scheduler'), | 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 | resolve: { | 29 | resolve: { |
| @@ -23,4 +31,4 @@ export default defineConfig({ | @@ -23,4 +31,4 @@ export default defineConfig({ | ||
| 23 | '@': fileURLToPath(new URL('./src', import.meta.url)) | 31 | '@': fileURLToPath(new URL('./src', import.meta.url)) |
| 24 | }, | 32 | }, |
| 25 | }, | 33 | }, |
| 26 | -}) | 34 | +}) |