detail.vue 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185
  1. <script lang="ts" setup>
  2. import { computed, ref } from 'vue'
  3. import { useRoute } from 'vue-router'
  4. import { IotDeviceApi } from '@/api/pms/device'
  5. import {
  6. Odometer,
  7. CircleCheckFilled,
  8. CircleCloseFilled,
  9. DataLine,
  10. Crop,
  11. FullScreen,
  12. Setting
  13. } from '@element-plus/icons-vue'
  14. import { AnimatedCountTo } from '@/components/AnimatedCountTo'
  15. import { neonColors } from '@/utils/td-color'
  16. import dayjs from 'dayjs'
  17. import * as echarts from 'echarts'
  18. import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
  19. import { Dimensions, formatIotValue, HeaderItem } from '@/utils/useSocketBus'
  20. import { rangeShortcuts } from '@/utils/formatTime'
  21. import { useFullscreen } from '@vueuse/core'
  22. import { snapdom } from '@zumer/snapdom'
  23. import { ElMessage } from 'element-plus'
  24. import { useMqtt } from '@/utils/useMqtt'
  25. const { query } = useRoute()
  26. const data = ref({
  27. deviceCode: query.code || '',
  28. deviceName: query.name || '',
  29. lastInlineTime: query.time || '',
  30. ifInline: query.ifInline === '3',
  31. dept: query.dept || '',
  32. vehicle: query.vehicle || '',
  33. carOnline: query.carOnline === 'true',
  34. mqttUrl: query.mqttUrl || ''
  35. })
  36. const { connect, destroy, isConnected, subscribe } = useMqtt()
  37. const handleMessageUpdate = (_topic: string, data: any) => {
  38. const valueMap = new Map<string, number>()
  39. for (const item of data) {
  40. const { id: identity, value: logValue, remark } = item
  41. const value = logValue ? Number(logValue) : 0
  42. if (identity) {
  43. valueMap.set(identity, value)
  44. }
  45. const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
  46. if (modelName && chartData.value[modelName]) {
  47. chartData.value[modelName].push({
  48. ts: dayjs.unix(remark).valueOf(),
  49. value
  50. })
  51. updateSingleSeries(modelName)
  52. }
  53. }
  54. const updateDimensions = (list) => {
  55. list.forEach((item) => {
  56. const v = valueMap.get(item.identifier)
  57. if (v !== undefined) {
  58. item.value = v
  59. }
  60. })
  61. }
  62. updateDimensions(dimensions.value)
  63. updateDimensions(gatewayDimensions.value)
  64. updateDimensions(carDimensions.value)
  65. genderIntervalArr()
  66. }
  67. watch(isConnected, (newVal) => {
  68. if (newVal && data.value.mqttUrl) {
  69. subscribe(data.value.mqttUrl as string)
  70. }
  71. })
  72. // const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
  73. // onAny((msg) => {
  74. // if (!Array.isArray(msg) || msg.length === 0) return
  75. // const valueMap = new Map<string, number>()
  76. // for (const item of msg) {
  77. // const { identity, modelName, readTime, logValue } = item
  78. // const value = logValue ? Number(logValue) : 0
  79. // if (identity) {
  80. // valueMap.set(identity, value)
  81. // }
  82. // if (modelName && chartData.value[modelName]) {
  83. // chartData.value[modelName].push({
  84. // ts: dayjs(readTime).valueOf(),
  85. // value
  86. // })
  87. // updateSingleSeries(modelName)
  88. // }
  89. // }
  90. // const updateDimensions = (list) => {
  91. // list.forEach((item) => {
  92. // const v = valueMap.get(item.identifier)
  93. // if (v !== undefined) {
  94. // item.value = v
  95. // }
  96. // })
  97. // }
  98. // updateDimensions(dimensions.value)
  99. // updateDimensions(gatewayDimensions.value)
  100. // updateDimensions(carDimensions.value)
  101. // // 3️⃣ 统一一次调用
  102. // genderIntervalArr()
  103. // })
  104. function hexToRgba(hex: string, alpha: number) {
  105. const r = parseInt(hex.slice(1, 3), 16)
  106. const g = parseInt(hex.slice(3, 5), 16)
  107. const b = parseInt(hex.slice(5, 7), 16)
  108. return `rgba(${r}, ${g}, ${b}, ${alpha})`
  109. }
  110. const headerCenterContent: HeaderItem[] = [
  111. { label: '设备名称', key: 'deviceName' },
  112. { label: '所属部门', key: 'dept' },
  113. { label: '车牌号码', key: 'vehicle', judgment: true },
  114. { label: '最后上报时间', key: 'lastInlineTime' }
  115. ]
  116. const tagProps = { size: 'default', round: true } as const
  117. const headerTagContent: HeaderItem[] = [
  118. { label: '网关', key: 'ifInline' },
  119. { label: '北斗', key: 'carOnline', judgment: true }
  120. ]
  121. const dimensions = ref<Dimensions[]>([])
  122. const gatewayDimensions = ref<Dimensions[]>([])
  123. const carDimensions = ref<Dimensions[]>([])
  124. const dimensionsContent = computed(() => [
  125. {
  126. label: '网关数采',
  127. icon: DataLine,
  128. value: gatewayDimensions.value,
  129. countColor: 'text-blue-600',
  130. countBg: 'bg-blue-50',
  131. judgment: false
  132. }
  133. // {
  134. // label: '中航北斗',
  135. // icon: TrendCharts,
  136. // value: carDimensions.value,
  137. // countColor: 'text-indigo-600',
  138. // countBg: 'bg-indigo-50',
  139. // judgment: true
  140. // }
  141. ])
  142. const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
  143. const selectedDimension = ref<Record<string, boolean>>({})
  144. const dimensionLoading = ref(false)
  145. async function loadDimensions() {
  146. if (!query.id) return
  147. dimensionLoading.value = true
  148. try {
  149. const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
  150. .sort((a, b) => b.modelOrder - a.modelOrder)
  151. .map((item) => {
  152. const { value, suffix, isText } = formatIotValue(item.value)
  153. return {
  154. identifier: item.identifier,
  155. name: item.modelName,
  156. value: value,
  157. suffix: suffix,
  158. isText: isText,
  159. response: false,
  160. id: item.alarmSettingId,
  161. maxValue: Number(item.maxValue),
  162. minValue: Number(item.minValue)
  163. }
  164. })
  165. // const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
  166. // .sort((a, b) => b.modelOrder - a.modelOrder)
  167. // .map((item) => {
  168. // const { value, suffix, isText } = formatIotValue(item.value)
  169. // console.log(`${item.modelName} :>> `, value)
  170. // return {
  171. // identifier: item.identifier,
  172. // name: item.modelName,
  173. // value: value,
  174. // suffix: suffix,
  175. // isText: isText,
  176. // response: false
  177. // }
  178. // })
  179. // const rawList = [...gateway, ...car]
  180. const rawList = [...gateway]
  181. const uniqueMap = new Map()
  182. rawList.forEach((item) => {
  183. const uniqueKey = `${item.identifier}|${item.name}`
  184. // if (!uniqueMap.has(uniqueKey)) {
  185. uniqueMap.set(uniqueKey, item)
  186. // }
  187. })
  188. dimensions.value = Array.from(uniqueMap.values()).map((item, index) => {
  189. const color = neonColors[index % neonColors.length]
  190. return {
  191. ...item,
  192. color: color,
  193. bgHover: hexToRgba(color, 0.08),
  194. bgActive: hexToRgba(color, 0.12)
  195. }
  196. })
  197. gatewayDimensions.value = dimensions.value.filter((d) =>
  198. gateway.some((g) => g.identifier === d.identifier)
  199. )
  200. // carDimensions.value = dimensions.value.filter((d) =>
  201. // car.some((c) => c.identifier === d.identifier)
  202. // )
  203. selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
  204. if (dimensions.value.length > 0) {
  205. selectedDimension.value[dimensions.value[0].name] = true
  206. }
  207. } catch (e) {
  208. console.error(e)
  209. } finally {
  210. dimensionLoading.value = false
  211. }
  212. }
  213. const selectedDate = ref<string[]>([
  214. dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
  215. dayjs().format('YYYY-MM-DD HH:mm:ss')
  216. ])
  217. interface ChartData {
  218. [key: Dimensions['name']]: { ts: number; value: number }[]
  219. }
  220. const chartData = ref<ChartData>({})
  221. let intervalArr = ref<number[]>([])
  222. let maxInterval = ref(0)
  223. let minInterval = ref(0)
  224. const chartRef = ref<HTMLDivElement | null>(null)
  225. let chart: echarts.ECharts | null = null
  226. function genderIntervalArr(init: boolean = false) {
  227. const values: number[] = []
  228. for (const [key, value] of Object.entries(selectedDimension.value)) {
  229. if (value) {
  230. values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
  231. }
  232. }
  233. const maxVal = values.length === 0 ? 10000 : Math.max(...values)
  234. const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
  235. const maxDigits = (Math.floor(maxVal) + '').length
  236. const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
  237. const interval = Math.max(maxDigits, minDigits)
  238. maxInterval.value = interval
  239. minInterval.value = minDigits
  240. intervalArr.value = [0]
  241. for (let i = 1; i <= interval; i++) {
  242. intervalArr.value.push(Math.pow(10, i))
  243. }
  244. if (!init) {
  245. chart?.setOption({
  246. yAxis: {
  247. min: -minInterval.value,
  248. max: maxInterval.value
  249. }
  250. })
  251. }
  252. }
  253. function chartInit() {
  254. if (!chart) return
  255. chart.on('legendselectchanged', (params: any) => {
  256. selectedDimension.value = params.selected
  257. })
  258. window.addEventListener('resize', () => {
  259. if (chart) chart.resize()
  260. })
  261. }
  262. function render() {
  263. if (!chartRef.value) return
  264. if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
  265. chartInit()
  266. genderIntervalArr(true)
  267. chart.setOption({
  268. animation: true,
  269. animationDuration: 200,
  270. animationEasing: 'linear',
  271. animationDurationUpdate: 200,
  272. animationEasingUpdate: 'linear',
  273. grid: {
  274. left: '0%',
  275. top: '5%',
  276. right: '5%',
  277. bottom: '12%'
  278. },
  279. tooltip: {
  280. trigger: 'axis',
  281. axisPointer: {
  282. type: 'line'
  283. },
  284. formatter: (params) => {
  285. let d = `${params[0].axisValueLabel}<br>`
  286. const exist: string[] = []
  287. params = params.filter((el) => {
  288. if (exist.includes(el.seriesName)) return false
  289. exist.push(el.seriesName)
  290. return true
  291. })
  292. let item = params.map(
  293. (el) => `<div class="flex items-center justify-between mt-1">
  294. <span>${el.marker} ${el.seriesName}</span>
  295. <span>${el.value[2]?.toFixed(2)}</span>
  296. </div>`
  297. )
  298. return d + item.join('')
  299. }
  300. },
  301. xAxis: {
  302. type: 'time',
  303. boundaryGap: ['0%', '25%'],
  304. axisLabel: {
  305. formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
  306. rotate: 0,
  307. align: 'left'
  308. }
  309. },
  310. dataZoom: [
  311. { type: 'inside', xAxisIndex: 0 },
  312. { type: 'slider', xAxisIndex: 0 }
  313. ],
  314. yAxis: {
  315. type: 'value',
  316. min: -minInterval.value,
  317. max: maxInterval.value,
  318. interval: 1,
  319. axisLabel: {
  320. formatter: (v) => {
  321. const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
  322. return num.toLocaleString()
  323. }
  324. },
  325. show: false
  326. },
  327. legend: {
  328. data: dimensions.value.map((item) => item.name),
  329. selected: selectedDimension.value,
  330. show: false
  331. },
  332. series: dimensions.value.map((item) => ({
  333. name: item.name,
  334. type: 'line',
  335. smooth: 0.2,
  336. showSymbol: false,
  337. endLabel: {
  338. show: true,
  339. formatter: (params) => params.value[2]?.toFixed(2),
  340. offset: [6, 0],
  341. color: item.color,
  342. fontSize: 12
  343. },
  344. emphasis: {
  345. focus: 'series'
  346. },
  347. lineStyle: {
  348. width: 2
  349. },
  350. color: item.color,
  351. data: [] // 占位数组
  352. }))
  353. })
  354. }
  355. function mapData({ value, ts }) {
  356. if (value === null || value === undefined || value === 0) return [ts, 0, 0]
  357. const isPositive = value > 0
  358. const absItem = Math.abs(value)
  359. if (!intervalArr.value.length) return [ts, 0, value]
  360. const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
  361. const min_index = intervalArr.value.findIndex((v) => v === min_value)
  362. let denominator = 1
  363. if (min_index < intervalArr.value.length - 1) {
  364. denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
  365. } else {
  366. denominator = intervalArr.value[min_index] || 1
  367. }
  368. const new_value = (absItem - min_value) / denominator + min_index
  369. return [ts, isPositive ? new_value : -new_value, value]
  370. }
  371. function updateSingleSeries(name: string) {
  372. if (!chart) render()
  373. if (!chart) return
  374. const idx = dimensions.value.findIndex((item) => item.name === name)
  375. if (idx === -1) return
  376. const data = chartData.value[name].map((v) => mapData(v))
  377. chart.setOption({
  378. series: [{ name, data }]
  379. })
  380. }
  381. const lastTsMap = ref<Record<Dimensions['name'], number>>({})
  382. const chartLoading = ref(false)
  383. const token = ref('')
  384. async function ensureToken() {
  385. if (token.value) return
  386. token.value = await IotDeviceApi.getToken()
  387. }
  388. async function initLoadChartData(real_time: boolean = true) {
  389. if (!dimensions.value.length) return
  390. chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
  391. chartLoading.value = true
  392. dimensions.value = dimensions.value.map((item) => {
  393. item.response = true
  394. return item
  395. })
  396. for (const item of dimensions.value) {
  397. const { identifier, name } = item
  398. try {
  399. const res = await IotStatApi.getDeviceInfoChart(
  400. data.value.deviceCode,
  401. identifier,
  402. selectedDate.value[0],
  403. selectedDate.value[1]
  404. )
  405. const sorted = res
  406. .sort((a, b) => a.ts - b.ts)
  407. .map((item) => ({ ts: item.ts, value: item.value }))
  408. chartData.value[name] = sorted
  409. lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
  410. genderIntervalArr()
  411. updateSingleSeries(name)
  412. chartLoading.value = false
  413. } finally {
  414. item.response = false
  415. }
  416. }
  417. if (real_time && data.value.mqttUrl) {
  418. await ensureToken()
  419. connect(`wss://aims.deepoil.cc/mqtt`, { password: token.value }, handleMessageUpdate)
  420. }
  421. }
  422. async function initfn(load: boolean = true, real_time: boolean = true) {
  423. if (load) await loadDimensions()
  424. render()
  425. await initLoadChartData(real_time)
  426. }
  427. onMounted(() => {
  428. initfn()
  429. })
  430. function reset() {
  431. cancelAllRequests().then(() => {
  432. selectedDate.value = [
  433. dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
  434. dayjs().format('YYYY-MM-DD HH:mm:ss')
  435. ]
  436. destroy()
  437. if (chart) chart.clear()
  438. initfn(false)
  439. })
  440. }
  441. function handleDateChange() {
  442. cancelAllRequests().then(() => {
  443. destroy()
  444. // stopAutoFetch()
  445. if (chart) chart.clear()
  446. initfn(false, false)
  447. })
  448. }
  449. function handleClickSpec(modelName: string) {
  450. selectedDimension.value[modelName] = !selectedDimension.value[modelName]
  451. chart?.setOption({
  452. legend: {
  453. selected: selectedDimension.value
  454. }
  455. })
  456. genderIntervalArr()
  457. if (selectedDimension.value[modelName]) {
  458. updateSingleSeries(modelName)
  459. }
  460. nextTick(() => {
  461. chart?.resize()
  462. })
  463. }
  464. const downloadRef = ref(null)
  465. const exportChart = async () => {
  466. try {
  467. if (!downloadRef.value) return
  468. const result = await snapdom(downloadRef.value, {
  469. scale: 2,
  470. backgroundColor: '#fff'
  471. })
  472. await result.download({
  473. filename: `${data.value.deviceName}-设备监控`,
  474. type: 'png'
  475. })
  476. } catch (error) {
  477. console.error(error)
  478. ElMessage.error('导出图表失败')
  479. }
  480. }
  481. const maxmin = computed(() => {
  482. if (!dimensions.value.length) return []
  483. return dimensions.value
  484. .filter((v) => selectedDimension.value[v.name])
  485. .map((v) => ({
  486. name: v.name,
  487. color: v.color,
  488. bgHover: v.bgHover,
  489. max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
  490. min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
  491. }))
  492. })
  493. onUnmounted(() => {
  494. destroy()
  495. window.removeEventListener('resize', () => {
  496. if (chart) chart.resize()
  497. })
  498. })
  499. const targetArea = ref(null)
  500. const { toggle, isFullscreen } = useFullscreen(targetArea)
  501. const message = useMessage()
  502. async function handleSave(item: Dimensions) {
  503. const { minValue, maxValue } = item
  504. // 1. 判断是否为空 (包括空字符串、null、undefined)
  505. if (!minValue || !maxValue) {
  506. return message.warning('最大值和最小值不能为空')
  507. }
  508. // 2. 判断是否为有效的数字
  509. // Number() 处理字符串数字,isNaN 排除非数字字符
  510. const min = Number(minValue)
  511. const max = Number(maxValue)
  512. if (isNaN(min) || isNaN(max)) {
  513. return message.warning('请输入有效的数字')
  514. }
  515. // 3. (附加逻辑) 比较大小
  516. if (min > max) {
  517. return message.warning('最小值不能大于最大值')
  518. }
  519. const body = {
  520. minValue: min,
  521. maxValue: max,
  522. deviceId: query.id,
  523. propertyCode: item.identifier,
  524. alarmProperty: item.name,
  525. deviceName: data.value.deviceName,
  526. id: item.id
  527. }
  528. const res = await IotDeviceApi.saveMaxMin(body)
  529. if (res.id) item.id = res.id
  530. message.success('设置成功')
  531. }
  532. async function handleReset(item: Dimensions) {
  533. item.minValue = undefined
  534. item.maxValue = undefined
  535. await IotDeviceApi.deleteMaxMin({ id: item.id })
  536. item.id = undefined
  537. message.success('清除重置成功')
  538. }
  539. </script>
  540. <template>
  541. <div
  542. class="grid grid-rows-[80px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
  543. >
  544. <div
  545. class="rounded-xl shadow-sm border border-gray-100 border-solid px-6 flex items-center justify-between shrink-0 bg-gradient-to-r from-blue-100 to-white"
  546. >
  547. <div class="flex items-center gap-4">
  548. <div
  549. class="size-12 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center shadow-inner"
  550. >
  551. <el-icon :size="24"><Odometer /></el-icon>
  552. </div>
  553. <div>
  554. <div class="text-xs text-gray-400 font-medium tracking-wider">资产编码</div>
  555. <div class="text-xl font-bold font-mono text-gray-800">{{ data.deviceCode }}</div>
  556. </div>
  557. </div>
  558. <div class="flex-1 flex justify-center divide-x divide-gray-100">
  559. <template v-for="item in headerCenterContent" :key="item.key">
  560. <div
  561. class="px-8 flex flex-col items-center"
  562. v-if="item.judgment ? Boolean(query[item.key]) : true"
  563. >
  564. <span class="text-xs text-gray-400 mb-1">{{ item.label }}</span>
  565. <span class="font-semibold text-gray-700">{{ data[item.key] }}</span>
  566. </div>
  567. </template>
  568. </div>
  569. <div class="flex items-center gap-6">
  570. <template v-for="item in headerTagContent" :key="item.key">
  571. <div class="text-center" v-if="item.judgment ? Boolean(query[item.key]) : true">
  572. <div class="text-xs text-gray-400 mb-1">{{ item.label }}</div>
  573. <el-tag v-if="data[item.key]" type="success" v-bind="tagProps">
  574. <el-icon class="mr-1"><CircleCheckFilled /></el-icon>在线
  575. </el-tag>
  576. <el-tag v-else type="danger" v-bind="tagProps">
  577. <el-icon class="mr-1"><CircleCloseFilled /></el-icon>离线
  578. </el-tag>
  579. </div>
  580. </template>
  581. </div>
  582. </div>
  583. <div ref="targetArea" class="relative">
  584. <div class="flex flex-col gap-4 h-full">
  585. <template v-for="citem in dimensionsContent" :key="citem.label">
  586. <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
  587. <div
  588. class="rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden bg-gradient-to-b from-blue-100 to-white p-4 flex flex-col gap-2"
  589. v-loading="dimensionLoading"
  590. >
  591. <div class="flex justify-center items-center gap-2 border-0 border-solid">
  592. <span class="font-bold text-gray-700! flex items-center gap-2">
  593. <el-icon><component :is="citem.icon" /></el-icon>
  594. {{ citem.label }}
  595. </span>
  596. <span
  597. class="text-xs px-2 py-0.5 rounded-full font-mono bg-gray-200"
  598. :class="[citem.countBg, citem.countColor]"
  599. >
  600. {{ citem.value.length }}
  601. </span>
  602. </div>
  603. <div class="grid grid-cols-4 gap-2">
  604. <div
  605. :data-disabled="disabledDimensions.includes(item.identifier)"
  606. v-for="item in citem.value"
  607. :key="item.identifier"
  608. @click="handleClickSpec(item.name)"
  609. class="dimension-card group relative p-3 rounded-lg border border-solid bg-transparent border-gray-300 transition-all duration-300 cursor-pointer select-none data-[disabled=true]:pointer-events-none"
  610. :class="{ 'is-active': selectedDimension[item.name] }"
  611. :style="{
  612. '--theme-color': item.color,
  613. '--theme-bg-hover': item.bgHover,
  614. '--theme-bg-active': item.bgActive
  615. }"
  616. >
  617. <div class="flex justify-between items-center mb-1">
  618. <span
  619. class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
  620. :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
  621. >
  622. {{ item.name }}
  623. </span>
  624. <el-popover placement="bottom" :width="280" trigger="click">
  625. <template #reference>
  626. <el-button class="group" link>
  627. <el-icon
  628. class="transition-transform duration-500 group-hover:rotate-180"
  629. :size="16"
  630. >
  631. <Setting />
  632. </el-icon>
  633. </el-button>
  634. </template>
  635. <div class="flex flex-col gap-3">
  636. <div class="text-sm font-bold text-gray-700 pb-1 border-b border-gray-100">
  637. 设置范围
  638. </div>
  639. <div class="grid grid-cols-[auto_1fr] gap-y-3 gap-x-2 items-center">
  640. <span class="text-xs text-gray-500 text-right">最小值:</span>
  641. <el-input-number
  642. v-model="item.minValue"
  643. size="default"
  644. class="!w-full"
  645. placeholder="Min"
  646. :controls="false"
  647. align="left"
  648. />
  649. <span class="text-xs text-gray-500 text-right">最大值:</span>
  650. <el-input-number
  651. v-model="item.maxValue"
  652. size="default"
  653. class="!w-full"
  654. placeholder="Max"
  655. :controls="false"
  656. align="left"
  657. />
  658. </div>
  659. <div class="flex justify-end gap-2 pt-1">
  660. <el-button size="small" text bg @click="handleReset(item)">
  661. 清除重置
  662. </el-button>
  663. <el-button size="small" type="primary" @click="handleSave(item)">
  664. 保存
  665. </el-button>
  666. </div>
  667. </div>
  668. </el-popover>
  669. <!-- <div
  670. class="size-2 rounded-full transition-all duration-300 shadow-sm"
  671. :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
  672. :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
  673. ></div> -->
  674. </div>
  675. <!-- <div class="flex items-baseline justify-between relative z-9">
  676. <animated-count-to
  677. v-if="!item.isText"
  678. :value="Number(item.value)"
  679. :duration="500"
  680. :suffix="item.suffix"
  681. class="text-lg font-bold font-mono tracking-tight text-slate-800"
  682. />
  683. <span v-else class="text-lg font-bold font-mono tracking-tight text-slate-800">
  684. {{ item.value }}
  685. </span>
  686. </div> -->
  687. <div class="flex items-center justify-between relative z-9 mt-1">
  688. <div class="flex-1 mr-2">
  689. <animated-count-to
  690. v-if="!item.isText"
  691. :value="Number(item.value)"
  692. :duration="500"
  693. :suffix="item.suffix"
  694. class="text-2xl font-black font-mono tracking-tight text-slate-800 leading-none"
  695. />
  696. <span
  697. v-else
  698. class="text-2xl font-black font-mono tracking-tight text-slate-800 leading-none"
  699. >
  700. {{ item.value }}
  701. </span>
  702. </div>
  703. <div v-if="item.minValue || item.maxValue" class="flex gap-1.5 items-center">
  704. <div
  705. v-if="item.maxValue"
  706. class="flex items-center px-2 py-1 rounded-md bg-emerald-50/80 border border-solid border-emerald-100/80 shadow-sm transition-all duration-300 hover:bg-emerald-100 hover:border-emerald-200"
  707. >
  708. <div
  709. class="flex items-center justify-center w-4 h-4 mr-1 rounded-full bg-emerald-100 text-emerald-600 group-hover/max:bg-white group-hover/max:scale-110 transition-all"
  710. >
  711. <div class="i-material-symbols:arrow-upward-alt-rounded"></div>
  712. </div>
  713. <span class="text-[10px] font-bold text-emerald-400/80 mr-1.5">MAX</span>
  714. <span class="text-sm font-bold font-mono text-emerald-700">{{
  715. item.maxValue
  716. }}</span>
  717. </div>
  718. <div
  719. v-if="item.minValue"
  720. class="flex items-center px-2 py-0.5 rounded-md bg-rose-50/80 border border-solid border-rose-100/80 shadow-sm transition-all duration-300 hover:bg-rose-100 hover:border-rose-200"
  721. >
  722. <div
  723. class="flex items-center justify-center w-4 h-4 mr-1 rounded-full bg-rose-100 text-rose-600 group-hover/min:bg-white group-hover/min:scale-110 transition-all"
  724. >
  725. <div class="i-material-symbols:arrow-downward-alt-rounded"></div>
  726. </div>
  727. <span class="text-[10px] font-bold text-rose-400/80 mr-1.5">MIN</span>
  728. <span class="text-sm font-bold font-mono text-rose-700">{{
  729. item.minValue
  730. }}</span>
  731. </div>
  732. </div>
  733. </div>
  734. <div
  735. class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
  736. :class="
  737. selectedDimension[item.name]
  738. ? 'opacity-100 shadow-[0_0_8px_currentColor]'
  739. : 'opacity-0'
  740. "
  741. :style="{ backgroundColor: item.color, color: item.color }"
  742. >
  743. </div>
  744. </div>
  745. </div>
  746. </div>
  747. </template>
  748. </template>
  749. <div
  750. class="flex-1 min-h-200 rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col bg-gradient-to-b from-blue-100 to-white"
  751. >
  752. <header class="flex items-center justify-between mb-4">
  753. <h3 class="flex items-center gap-2">
  754. <div
  755. class="i-material-symbols:area-chart-outline-rounded text-sky size-6"
  756. text-sky
  757. ></div>
  758. 数据趋势
  759. </h3>
  760. <div class="flex gap-4">
  761. <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
  762. <el-button size="default" @click="reset">重置</el-button>
  763. <el-date-picker
  764. v-model="selectedDate"
  765. value-format="YYYY-MM-DD HH:mm:ss"
  766. type="datetimerange"
  767. unlink-panels
  768. start-placeholder="开始日期"
  769. end-placeholder="结束日期"
  770. :shortcuts="rangeShortcuts"
  771. size="default"
  772. class="w-100!"
  773. placement="bottom-end"
  774. @change="handleDateChange"
  775. />
  776. <el-button
  777. size="default"
  778. :type="isFullscreen ? 'info' : 'primary'"
  779. :icon="isFullscreen ? Crop : FullScreen"
  780. @click="toggle"
  781. >
  782. {{ isFullscreen ? '退出全屏' : '全屏' }}
  783. </el-button>
  784. </div>
  785. </header>
  786. <div ref="downloadRef" class="flex flex-1">
  787. <div class="flex gap-1 select-none">
  788. <div
  789. v-for="item of maxmin"
  790. :key="item.name"
  791. :style="{
  792. '--theme-bg-hover': item.bgHover
  793. }"
  794. class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded-full group relative bg-transparent border border-solid border-transparent transition-all duration-300 hover:bg-[var(--theme-bg-hover)] hover-border-gray-200 hover:shadow-md cursor-pointer active:scale-95"
  795. @click="handleClickSpec(item.name)"
  796. >
  797. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
  798. <div
  799. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  800. :style="{ backgroundColor: item.color }"
  801. ></div>
  802. <span
  803. class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
  804. :style="{ color: item.color }"
  805. >
  806. {{ item.name }}
  807. </span>
  808. <div
  809. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  810. :style="{ backgroundColor: item.color }"
  811. ></div>
  812. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
  813. </div>
  814. </div>
  815. <div
  816. class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:20px_20px]"
  817. >
  818. <div
  819. v-loading="chartLoading"
  820. element-loading-background="transparent"
  821. ref="chartRef"
  822. class="w-full h-full"
  823. >
  824. </div>
  825. </div>
  826. </div>
  827. </div>
  828. </div>
  829. </div>
  830. <!-- <div ref="targetArea" class="h-full min-h-0 relative">
  831. <div class="grid grid-cols-[260px_1fr] gap-4 h-full">
  832. <el-scrollbar
  833. class="rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden bg-gradient-to-b from-blue-100 to-white"
  834. view-class="flex flex-col min-h-full"
  835. v-loading="dimensionLoading"
  836. >
  837. <template v-for="citem in dimensionsContent" :key="citem.label">
  838. <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
  839. <div
  840. class="sticky-title bg-blue-100 z-10 flex justify-between items-center py-3 px-4 border-0 border-solid"
  841. >
  842. <span class="font-bold text-sm text-gray-700! flex items-center gap-2">
  843. <el-icon><component :is="citem.icon" /></el-icon>
  844. {{ citem.label }}
  845. </span>
  846. <span
  847. class="text-xs px-2 py-0.5 rounded-full font-mono"
  848. :class="[citem.countBg, citem.countColor]"
  849. >
  850. {{ citem.value.length }}
  851. </span>
  852. </div>
  853. <div class="px-3 pb-4 pt-2 space-y-3">
  854. <div
  855. :data-disabled="disabledDimensions.includes(item.identifier)"
  856. v-for="item in citem.value"
  857. :key="item.identifier"
  858. @click="handleClickSpec(item.name)"
  859. class="dimension-card group relative p-3 rounded-lg border border-solid bg-transparent border-gray-300 transition-all duration-300 cursor-pointer select-none data-[disabled=true]:pointer-events-none"
  860. :class="{ 'is-active': selectedDimension[item.name] }"
  861. :style="{
  862. '--theme-color': item.color,
  863. '--theme-bg-hover': item.bgHover,
  864. '--theme-bg-active': item.bgActive
  865. }"
  866. >
  867. <div class="flex justify-between items-center mb-1">
  868. <span
  869. class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
  870. :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
  871. >
  872. {{ item.name }}
  873. </span>
  874. <div
  875. class="size-2 rounded-full transition-all duration-300 shadow-sm"
  876. :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
  877. :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
  878. ></div>
  879. </div>
  880. <div class="flex items-baseline justify-between relative z-9">
  881. <animated-count-to
  882. v-if="!item.isText"
  883. :value="Number(item.value)"
  884. :duration="500"
  885. :suffix="item.suffix"
  886. class="text-lg font-bold font-mono tracking-tight text-slate-800"
  887. />
  888. <span v-else class="text-lg font-bold font-mono tracking-tight text-slate-800">
  889. {{ item.value }}
  890. </span>
  891. </div>
  892. <div
  893. class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
  894. :class="
  895. selectedDimension[item.name]
  896. ? 'opacity-100 shadow-[0_0_8px_currentColor]'
  897. : 'opacity-0'
  898. "
  899. :style="{ backgroundColor: item.color, color: item.color }"
  900. >
  901. </div>
  902. </div>
  903. </div>
  904. </template>
  905. </template>
  906. </el-scrollbar>
  907. <div
  908. class="rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col bg-gradient-to-b from-blue-100 to-white"
  909. >
  910. <header class="flex items-center justify-between mb-4">
  911. <h3 class="flex items-center gap-2">
  912. <div
  913. class="i-material-symbols:area-chart-outline-rounded text-sky size-6"
  914. text-sky
  915. ></div>
  916. 数据趋势
  917. </h3>
  918. <div class="flex gap-4">
  919. <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
  920. <el-button size="default" @click="reset">重置</el-button>
  921. <el-date-picker
  922. v-model="selectedDate"
  923. value-format="YYYY-MM-DD HH:mm:ss"
  924. type="datetimerange"
  925. unlink-panels
  926. start-placeholder="开始日期"
  927. end-placeholder="结束日期"
  928. :shortcuts="rangeShortcuts"
  929. size="default"
  930. class="w-100!"
  931. placement="bottom-end"
  932. @change="handleDateChange"
  933. />
  934. <el-button
  935. size="default"
  936. :type="isFullscreen ? 'info' : 'primary'"
  937. :icon="isFullscreen ? Crop : FullScreen"
  938. @click="toggle"
  939. >
  940. {{ isFullscreen ? '退出全屏' : '全屏' }}
  941. </el-button>
  942. </div>
  943. </header>
  944. <div ref="downloadRef" class="flex flex-1">
  945. <div class="flex gap-1 select-none">
  946. <div
  947. v-for="item of maxmin"
  948. :key="item.name"
  949. :style="{
  950. '--theme-bg-hover': item.bgHover
  951. }"
  952. class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded-full group relative bg-transparent border border-solid border-transparent transition-all duration-300 hover:bg-[var(--theme-bg-hover)] hover-border-gray-200 hover:shadow-md cursor-pointer active:scale-95"
  953. @click="handleClickSpec(item.name)"
  954. >
  955. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
  956. <div
  957. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  958. :style="{ backgroundColor: item.color }"
  959. ></div>
  960. <span
  961. class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
  962. :style="{ color: item.color }"
  963. >
  964. {{ item.name }}
  965. </span>
  966. <div
  967. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  968. :style="{ backgroundColor: item.color }"
  969. ></div>
  970. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
  971. </div>
  972. </div>
  973. <div
  974. class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:20px_20px]"
  975. >
  976. <div
  977. v-loading="chartLoading"
  978. element-loading-background="transparent"
  979. ref="chartRef"
  980. class="w-full h-full"
  981. >
  982. </div>
  983. </div>
  984. </div>
  985. </div>
  986. </div>
  987. </div> -->
  988. </div>
  989. </template>
  990. <style scoped>
  991. :deep(.el-tag__content) {
  992. display: flex;
  993. align-items: center;
  994. gap: 2px;
  995. }
  996. .sticky-title {
  997. position: sticky;
  998. top: 0;
  999. }
  1000. .dimension-card:hover {
  1001. background-color: var(--theme-bg-hover);
  1002. border-color: var(--theme-bg-active);
  1003. box-shadow: 0 4px 12px -2px rgb(0 0 0 / 5%);
  1004. }
  1005. .dimension-card.is-active {
  1006. background-color: var(--theme-bg-active);
  1007. border-color: var(--theme-color);
  1008. box-shadow:
  1009. 0 0 0 1px var(--theme-bg-active),
  1010. 0 4px 12px -2px var(--theme-bg-active);
  1011. }
  1012. :deep(.el-scrollbar__bar.is-vertical) {
  1013. right: 2px;
  1014. width: 4px;
  1015. }
  1016. :deep(.el-scrollbar__thumb) {
  1017. background-color: #cbd5e1;
  1018. opacity: 0.6;
  1019. }
  1020. :deep(.el-scrollbar__thumb:hover) {
  1021. background-color: #94a3b8;
  1022. opacity: 1;
  1023. }
  1024. :fullscreen {
  1025. padding: 16px;
  1026. background-color: #fff;
  1027. }
  1028. /* 兼容写法 */
  1029. ::backdrop {
  1030. background-color: #fff;
  1031. }
  1032. </style>