TdDeviceInfo.vue 13 KB

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