TdDeviceInfo.vue 19 KB

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