TdDeviceInfo.vue 14 KB

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