TdDeviceInfo.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. <script setup lang="ts">
  2. import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
  3. import { IotDeviceApi } from '@/api/pms/device'
  4. import dayjs from 'dayjs'
  5. import { rangeShortcuts } from '@/utils/formatTime'
  6. import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
  7. import * as echarts from 'echarts'
  8. import { colors } from './color'
  9. const { query } = useRoute()
  10. const data = ref({
  11. deviceCode: query.code || '',
  12. deviceName: query.name || '',
  13. lastInlineTime: query.time || '',
  14. ifInline: query.ifInline || '',
  15. dept: query.dept || '',
  16. vehicle: query.vehicle || '',
  17. carOnline: query.carOnline || ''
  18. })
  19. interface Dimensions {
  20. identifier: string
  21. name: string
  22. value: string
  23. color?: string
  24. }
  25. const dimensions = ref<Dimensions[]>([])
  26. const gatewayDimensions = ref<Dimensions[]>([])
  27. const carDimensions = ref<Dimensions[]>([])
  28. const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
  29. interface SelectedDimension {
  30. [key: Dimensions['name']]: boolean
  31. }
  32. const selectedDimension = ref<SelectedDimension>({})
  33. const dimensionLoading = ref(false)
  34. async function loadDimensions() {
  35. if (!query.id) return
  36. dimensionLoading.value = true
  37. const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
  38. .sort((a, b) => b.modelOrder - a.modelOrder)
  39. .map((item) => ({
  40. identifier: item.identifier,
  41. name: item.modelName,
  42. value: item.value
  43. }))
  44. const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
  45. .sort((a, b) => b.modelOrder - a.modelOrder)
  46. .map((item) => ({
  47. identifier: item.identifier,
  48. name: item.modelName,
  49. value: item.value
  50. }))
  51. dimensions.value = [...gateway, ...car]
  52. .filter((item) => !disabledDimensions.value.includes(item.identifier))
  53. .map((item, index) => ({
  54. ...item,
  55. color: colors[index]
  56. }))
  57. gatewayDimensions.value = gateway
  58. carDimensions.value = car
  59. selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
  60. selectedDimension.value[dimensions.value[0].name] = true
  61. dimensionLoading.value = false
  62. }
  63. const selectedDate = ref<string[]>([
  64. ...rangeShortcuts[3].value().map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
  65. ])
  66. interface ChartData {
  67. [key: Dimensions['name']]: { ts: number; value: number }[]
  68. }
  69. const chartData = ref<ChartData>({})
  70. let intervalArr = ref<number[]>([])
  71. let maxInterval = ref(0)
  72. let minInterval = ref(0)
  73. const chartRef = ref<HTMLDivElement | null>(null)
  74. let chart: echarts.ECharts | null = null
  75. // const genderIntervalArrDebounce = useDebounceFn(
  76. // (init: boolean = false) => genderIntervalArr(init),
  77. // 300
  78. // )
  79. function genderIntervalArr(init: boolean = false) {
  80. const values: number[] = []
  81. for (const [key, value] of Object.entries(selectedDimension.value)) {
  82. if (value) {
  83. values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
  84. }
  85. }
  86. const maxVal = values.length === 0 ? 10000 : Math.max(...values)
  87. const minVal = values.length === 0 ? 0 : Math.min(...values)
  88. const maxDigits = (Math.floor(maxVal) + '').length
  89. const minDigits = (Math.floor(Math.abs(minVal)) + '').length
  90. const interval = Math.max(maxDigits, minDigits)
  91. maxInterval.value = interval
  92. minInterval.value = minDigits
  93. intervalArr.value = [0]
  94. for (let i = 1; i <= interval; i++) {
  95. intervalArr.value.push(Math.pow(10, i))
  96. }
  97. if (!init) {
  98. chart?.setOption({
  99. yAxis: {
  100. min: -minInterval.value,
  101. max: maxInterval.value
  102. }
  103. })
  104. }
  105. }
  106. function chartInit() {
  107. if (!chart) return
  108. chart.on('legendselectchanged', (params: any) => {
  109. selectedDimension.value = params.selected
  110. })
  111. window.addEventListener('resize', () => {
  112. if (chart) chart.resize()
  113. })
  114. }
  115. function render() {
  116. if (!chartRef.value) return
  117. if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
  118. chartInit()
  119. genderIntervalArr(true)
  120. chart.setOption({
  121. grid: {
  122. left: '8%',
  123. top: '0%',
  124. right: '8%',
  125. bottom: '12%'
  126. },
  127. tooltip: {
  128. trigger: 'axis',
  129. axisPointer: {
  130. type: 'line'
  131. },
  132. formatter: (params) => {
  133. let d = `${params[0].axisValueLabel}<br>`
  134. const exist: string[] = []
  135. params = params.filter((el) => {
  136. if (exist.includes(el.seriesName)) return false
  137. exist.push(el.seriesName)
  138. return true
  139. })
  140. let item = params.map(
  141. (el) => `<div class="flex items-center justify-between mt-1">
  142. <span>${el.marker} ${el.seriesName}</span>
  143. <span>${el.value[2].toFixed(2)}</span>
  144. </div>`
  145. )
  146. return d + item.join('')
  147. }
  148. },
  149. xAxis: {
  150. type: 'time',
  151. axisLabel: {
  152. formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
  153. rotate: 0,
  154. align: 'left'
  155. }
  156. },
  157. dataZoom: [
  158. { type: 'inside', xAxisIndex: 0 },
  159. { type: 'slider', xAxisIndex: 0 }
  160. ],
  161. yAxis: {
  162. type: 'value',
  163. min: minInterval.value,
  164. max: maxInterval.value,
  165. interval: 1,
  166. axisLabel: {
  167. formatter: (v) => {
  168. const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
  169. return num.toLocaleString()
  170. }
  171. },
  172. show: false
  173. },
  174. legend: {
  175. data: dimensions.value.map((item) => item.name),
  176. selected: selectedDimension.value,
  177. show: false
  178. },
  179. series: dimensions.value.map((item) => ({
  180. name: item.name,
  181. type: 'line',
  182. smooth: true,
  183. showSymbol: false,
  184. color: item.color,
  185. data: [] // 占位数组
  186. }))
  187. })
  188. }
  189. function mapData({ value, ts }) {
  190. if (value === 0) return [ts, 0, 0]
  191. const isPositive = value > 0
  192. const absItem = Math.abs(value)
  193. const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
  194. const min_index = intervalArr.value.findIndex((v) => v === min_value)
  195. const new_value =
  196. (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
  197. min_index
  198. return [ts, isPositive ? new_value : -new_value, value]
  199. }
  200. function updateSingleSeries(name: string) {
  201. if (!chart) render()
  202. if (!chart) return
  203. const idx = dimensions.value.findIndex((item) => item.name === name)
  204. if (idx === -1) return
  205. const data = chartData.value[name].map((v) => mapData(v))
  206. chart.setOption({
  207. series: [{ name, data }]
  208. })
  209. }
  210. const lastTsMap = ref<Record<Dimensions['name'], number>>({})
  211. async function fetchIncrementData() {
  212. for (const { identifier, name } of dimensions.value) {
  213. const lastTs = lastTsMap.value[name]
  214. if (!lastTs) continue
  215. IotStatApi.getDeviceInfoChart(
  216. data.value.deviceCode,
  217. identifier,
  218. dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
  219. dayjs().format('YYYY-MM-DD HH:mm:ss')
  220. ).then((res) => {
  221. if (!res.length) return
  222. const sorted = res.sort((a, b) => a.ts - b.ts)
  223. // push 到本地
  224. chartData.value[name].push(...sorted)
  225. // 更新 lastTs
  226. lastTsMap.value[identifier] = sorted.at(-1).ts
  227. // 更新图表
  228. updateSingleSeries(name)
  229. })
  230. }
  231. }
  232. const timer = ref<NodeJS.Timeout | null>(null)
  233. function startAutoFetch() {
  234. timer.value = setInterval(fetchIncrementData, 10000)
  235. }
  236. function stopAutoFetch() {
  237. if (timer.value) clearInterval(timer.value)
  238. timer.value = null
  239. }
  240. const chartLoading = ref(false)
  241. async function initLoadChartData(real_time: boolean = true) {
  242. if (!dimensions.value.length) return
  243. chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
  244. chartLoading.value = true
  245. for (const { identifier, name } of dimensions.value) {
  246. const res = await IotStatApi.getDeviceInfoChart(
  247. data.value.deviceCode,
  248. identifier,
  249. selectedDate.value[0],
  250. selectedDate.value[1]
  251. )
  252. const sorted = res
  253. .sort((a, b) => a.ts - b.ts)
  254. .map((item) => ({ ts: item.ts, value: item.value }))
  255. chartData.value[name] = sorted
  256. lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
  257. updateSingleSeries(name)
  258. chartLoading.value = false
  259. if (selectedDimension.value[name]) {
  260. genderIntervalArr()
  261. }
  262. }
  263. if (real_time) startAutoFetch()
  264. }
  265. async function initfn(load: boolean = true, real_time: boolean = true) {
  266. if (load) await loadDimensions()
  267. render()
  268. initLoadChartData(real_time)
  269. }
  270. onMounted(() => {
  271. initfn()
  272. })
  273. function reset() {
  274. cancelAllRequests().then(() => {
  275. selectedDate.value = rangeShortcuts[0]
  276. .value()
  277. .map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
  278. stopAutoFetch()
  279. if (chart) chart.clear()
  280. initfn(false)
  281. })
  282. }
  283. function handleDateChange() {
  284. cancelAllRequests().then(() => {
  285. stopAutoFetch()
  286. if (chart) chart.clear()
  287. initfn(false, false)
  288. })
  289. }
  290. function handleClickSpec(modelName: string) {
  291. selectedDimension.value[modelName] = !selectedDimension.value[modelName]
  292. chart?.setOption({
  293. legend: {
  294. selected: selectedDimension.value
  295. }
  296. })
  297. chart?.resize()
  298. // genderIntervalArrDebounce()
  299. }
  300. const exportChart = () => {
  301. if (!chart) return
  302. let img = new Image()
  303. img.src = chart.getDataURL({
  304. type: 'png',
  305. pixelRatio: 1,
  306. backgroundColor: '#fff'
  307. })
  308. img.onload = function () {
  309. let canvas = document.createElement('canvas')
  310. canvas.width = img.width
  311. canvas.height = img.height
  312. let ctx = canvas.getContext('2d')
  313. ctx?.drawImage(img, 0, 0)
  314. let dataURL = canvas.toDataURL('image/png')
  315. let a = document.createElement('a')
  316. let event = new MouseEvent('click')
  317. a.href = dataURL
  318. a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
  319. a.dispatchEvent(event)
  320. }
  321. }
  322. const maxmin = computed(() => {
  323. if (!dimensions.value.length) return []
  324. return dimensions.value
  325. .filter((v) => selectedDimension.value[v.name])
  326. .map((v) => ({
  327. name: v.name,
  328. color: v.color,
  329. max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
  330. min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
  331. }))
  332. })
  333. onUnmounted(() => {
  334. stopAutoFetch()
  335. window.removeEventListener('resize', () => {
  336. if (chart) chart.resize()
  337. })
  338. })
  339. </script>
  340. <template>
  341. <div
  342. class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
  343. id="td-device-info"
  344. >
  345. <h2 class="flex items-center gap-2">
  346. <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
  347. </h2>
  348. <el-form size="large" label-position="top" class="mt-4 grid grid-cols-3 gap-4">
  349. <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
  350. <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
  351. <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
  352. <el-form-item label="网关状态" class="online" type="plain">
  353. <el-tag
  354. v-if="data.ifInline === '3'"
  355. type="success"
  356. size="default"
  357. class="flex items-center"
  358. >
  359. <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
  360. 在线
  361. </el-tag>
  362. <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
  363. <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
  364. 离线
  365. </el-tag>
  366. </el-form-item>
  367. <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
  368. <el-tag
  369. v-if="data.carOnline === 'true'"
  370. type="success"
  371. size="default"
  372. class="flex items-center"
  373. >
  374. <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
  375. 在线
  376. </el-tag>
  377. <el-tag
  378. v-if="data.carOnline === 'false'"
  379. type="danger"
  380. size="default"
  381. class="flex items-center"
  382. >
  383. <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
  384. 离线
  385. </el-tag>
  386. </el-form-item>
  387. <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
  388. <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
  389. </el-form>
  390. </div>
  391. <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
  392. <header class="font-medium text-center w-full">网关数采</header>
  393. <div
  394. v-loading="dimensionLoading"
  395. element-loading-background="transparent"
  396. class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
  397. id="dimension"
  398. >
  399. <button
  400. v-for="item in gatewayDimensions"
  401. :key="item.identifier"
  402. class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
  403. :class="{ 'bg-blue-200': selectedDimension[item.name] }"
  404. :disabled="disabledDimensions.includes(item.identifier)"
  405. @click="handleClickSpec(item.name)"
  406. >
  407. <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.name }}</span>
  408. <span class="text-lg font-medium ms-a">{{ item.value }}</span>
  409. </button>
  410. </div>
  411. </div>
  412. <div
  413. v-if="carDimensions.length"
  414. class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
  415. >
  416. <header class="font-medium text-center w-full">中航北斗</header>
  417. <div class="w-full mt-4 grid grid-cols-4 gap-4" id="dimension">
  418. <button
  419. v-for="item in carDimensions"
  420. :key="item.identifier"
  421. class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
  422. :class="{ 'bg-blue-200': selectedDimension[item.name] }"
  423. :disabled="disabledDimensions.includes(item.identifier)"
  424. @click="handleClickSpec(item.name)"
  425. >
  426. <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.name }}</span>
  427. <span class="text-lg font-medium ms-a">{{ item.value }}</span>
  428. </button>
  429. </div>
  430. </div>
  431. <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
  432. <header class="flex items-center justify-between">
  433. <h3 class="flex items-center gap-2">
  434. <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
  435. 数据趋势
  436. </h3>
  437. <div class="flex gap-4">
  438. <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
  439. <el-button size="default" @click="reset">重置</el-button>
  440. <el-date-picker
  441. v-model="selectedDate"
  442. value-format="YYYY-MM-DD HH:mm:ss"
  443. type="datetimerange"
  444. unlink-panels
  445. start-placeholder="开始日期"
  446. end-placeholder="结束日期"
  447. :shortcuts="rangeShortcuts"
  448. size="default"
  449. class="w-100!"
  450. placement="bottom-end"
  451. @change="handleDateChange"
  452. />
  453. </div>
  454. </header>
  455. <div class="flex h-160 mt-4">
  456. <div class="flex gap-1">
  457. <button
  458. v-for="item of maxmin"
  459. :key="item.name"
  460. class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded bg-transparent border-none"
  461. @click="handleClickSpec(item.name)"
  462. >
  463. <span class="[writing-mode:sideways-lr]">{{ item.max }}</span>
  464. <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
  465. <span class="[writing-mode:sideways-lr]">{{ item.name }}</span>
  466. <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
  467. <span class="[writing-mode:sideways-lr]">{{ item.min }}</span>
  468. </button>
  469. </div>
  470. <div class="flex flex-1">
  471. <div
  472. v-loading="chartLoading"
  473. element-loading-background="transparent"
  474. ref="chartRef"
  475. class="flex-1 h-full"
  476. >
  477. </div>
  478. </div>
  479. </div>
  480. </div>
  481. </template>
  482. <style lang="scss" scoped>
  483. :deep(.el-form-item) {
  484. margin-bottom: 0;
  485. .el-form-item__label {
  486. margin-bottom: 0;
  487. }
  488. .el-form-item__content {
  489. font-size: 1rem;
  490. font-weight: 500;
  491. }
  492. &.online {
  493. .el-form-item__content {
  494. height: 2.5rem;
  495. .el-tag__content {
  496. display: flex;
  497. align-items: center;
  498. gap: 2px;
  499. }
  500. }
  501. }
  502. }
  503. </style>