Showing
9 changed files
with
4929 additions
and
2 deletions
Too many changes to show.
To preserve performance only 9 of 18 files are displayed.
| 1 | <template> | 1 | <template> |
| 2 | - <div class="app-container"> | 2 | + <div v-if="isH5Route" class="h5-container"> |
| 3 | + <router-view /> | ||
| 4 | + </div> | ||
| 5 | + <div v-else class="app-container"> | ||
| 3 | <el-container> | 6 | <el-container> |
| 4 | <el-aside width="180px" class="sidebar"> | 7 | <el-aside width="180px" class="sidebar"> |
| 5 | <div class="logo-area"> | 8 | <div class="logo-area"> |
| @@ -30,17 +33,22 @@ | @@ -30,17 +33,22 @@ | ||
| 30 | </template> | 33 | </template> |
| 31 | 34 | ||
| 32 | <script setup> | 35 | <script setup> |
| 33 | -import { computed, ref, provide } from 'vue' | 36 | +import { computed, provide, watchEffect } from 'vue' |
| 34 | import { useRoute, useRouter } from 'vue-router' | 37 | import { useRoute, useRouter } from 'vue-router' |
| 38 | +import { setCorpCode } from './config/api.js' | ||
| 35 | 39 | ||
| 36 | const route = useRoute() | 40 | const route = useRoute() |
| 37 | const router = useRouter() | 41 | const router = useRouter() |
| 38 | const currentRoute = computed(() => route.path) | 42 | const currentRoute = computed(() => route.path) |
| 43 | +const isH5Route = computed(() => Boolean(route.meta?.h5)) | ||
| 39 | 44 | ||
| 40 | // 从URL获取corpCode,全局共享(hash模式下参数在#后面,需用route.query) | 45 | // 从URL获取corpCode,全局共享(hash模式下参数在#后面,需用route.query) |
| 41 | // 使用computed确保路由切换时corpCode始终同步 | 46 | // 使用computed确保路由切换时corpCode始终同步 |
| 42 | const corpCode = computed(() => route.query.corpCode || '') | 47 | const corpCode = computed(() => route.query.corpCode || '') |
| 43 | provide('corpCode', corpCode) | 48 | provide('corpCode', corpCode) |
| 49 | +watchEffect(() => { | ||
| 50 | + setCorpCode(corpCode.value) | ||
| 51 | +}) | ||
| 44 | 52 | ||
| 45 | // 给URL拼接corpCode | 53 | // 给URL拼接corpCode |
| 46 | function withCorpCode(path) { | 54 | function withCorpCode(path) { |
| @@ -56,6 +64,10 @@ function navigateTo(path) { | @@ -56,6 +64,10 @@ function navigateTo(path) { | ||
| 56 | </script> | 64 | </script> |
| 57 | 65 | ||
| 58 | <style scoped> | 66 | <style scoped> |
| 67 | +.h5-container { | ||
| 68 | + min-height: 100vh; | ||
| 69 | + background-color: #f5f6f8; | ||
| 70 | +} | ||
| 59 | .app-container { | 71 | .app-container { |
| 60 | height: 100vh; | 72 | height: 100vh; |
| 61 | overflow: hidden; | 73 | overflow: hidden; |
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> |