TdDeviceInfo.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876
  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. TrendCharts
  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 { rangeShortcuts } from '@/utils/formatTime'
  20. import { Dimensions, formatIotValue, HeaderItem } from '@/utils/useSocketBus'
  21. import { useFullscreen } from '@vueuse/core'
  22. import { snapdom } from '@zumer/snapdom'
  23. const { query } = useRoute()
  24. const data = ref({
  25. deviceCode: query.code || '',
  26. deviceName: query.name || '',
  27. lastInlineTime: query.time || '',
  28. ifInline: query.ifInline === '3',
  29. dept: query.dept || '',
  30. vehicle: query.vehicle || '',
  31. carOnline: query.carOnline === 'true'
  32. })
  33. function hexToRgba(hex: string, alpha: number) {
  34. const r = parseInt(hex.slice(1, 3), 16)
  35. const g = parseInt(hex.slice(3, 5), 16)
  36. const b = parseInt(hex.slice(5, 7), 16)
  37. return `rgba(${r}, ${g}, ${b}, ${alpha})`
  38. }
  39. const headerCenterContent: HeaderItem[] = [
  40. { label: '设备名称', key: 'deviceName' },
  41. { label: '所属部门', key: 'dept' },
  42. { label: '车牌号码', key: 'vehicle', judgment: true },
  43. { label: '最后上报时间', key: 'lastInlineTime' }
  44. ]
  45. const tagProps = { size: 'default', round: true } as const
  46. const headerTagContent: HeaderItem[] = [
  47. { label: '网关', key: 'ifInline' },
  48. { label: '北斗', key: 'carOnline', judgment: true }
  49. ]
  50. const dimensions = ref<Dimensions[]>([])
  51. const gatewayDimensions = ref<Dimensions[]>([])
  52. const carDimensions = ref<Dimensions[]>([])
  53. const dimensionsContent = computed(() => [
  54. {
  55. label: '网关数采',
  56. icon: DataLine,
  57. value: gatewayDimensions.value,
  58. countColor: 'text-blue-600',
  59. countBg: 'bg-blue-50'
  60. },
  61. {
  62. label: '中航北斗',
  63. icon: TrendCharts,
  64. value: carDimensions.value,
  65. countColor: 'text-indigo-600',
  66. countBg: 'bg-indigo-50',
  67. judgment: true
  68. }
  69. ])
  70. const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
  71. const selectedDimension = ref<Record<string, boolean>>({})
  72. const dimensionLoading = ref(false)
  73. async function loadDimensions() {
  74. if (!query.id) return
  75. dimensionLoading.value = true
  76. try {
  77. const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
  78. .sort((a, b) => b.modelOrder - a.modelOrder)
  79. .map((item) => {
  80. const { value, suffix, isText } = formatIotValue(item.value)
  81. return {
  82. identifier: item.identifier,
  83. name: item.modelName,
  84. value: value,
  85. suffix: suffix,
  86. isText: isText,
  87. response: false
  88. }
  89. })
  90. const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
  91. .sort((a, b) => b.modelOrder - a.modelOrder)
  92. .map((item) => {
  93. const { value, suffix, isText } = formatIotValue(item.value)
  94. return {
  95. identifier: item.identifier,
  96. name: item.modelName,
  97. value: value,
  98. suffix: suffix,
  99. isText: isText,
  100. response: false
  101. }
  102. })
  103. const rawList = [...gateway, ...car]
  104. const uniqueMap = new Map()
  105. rawList.forEach((item) => {
  106. const uniqueKey = `${item.identifier}|${item.name}`
  107. // if (!uniqueMap.has(uniqueKey)) {
  108. uniqueMap.set(uniqueKey, item)
  109. // }
  110. })
  111. dimensions.value = Array.from(uniqueMap.values()).map((item, index) => {
  112. const color = neonColors[index % neonColors.length]
  113. return {
  114. ...item,
  115. color: color,
  116. bgHover: hexToRgba(color, 0.08),
  117. bgActive: hexToRgba(color, 0.12)
  118. }
  119. })
  120. gatewayDimensions.value = dimensions.value.filter((d) =>
  121. gateway.some((g) => g.identifier === d.identifier)
  122. )
  123. carDimensions.value = dimensions.value.filter((d) =>
  124. car.some((c) => c.identifier === d.identifier)
  125. )
  126. selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
  127. if (dimensions.value.length > 0) {
  128. selectedDimension.value[dimensions.value[0].name] = true
  129. }
  130. } catch (e) {
  131. console.error(e)
  132. } finally {
  133. dimensionLoading.value = false
  134. }
  135. }
  136. async function updateDimensionValues() {
  137. if (!query.id) return
  138. try {
  139. // 1. 并行获取最新数据
  140. const [gatewayRes, carRes] = await Promise.all([
  141. IotDeviceApi.getIotDeviceTds(Number(query.id)),
  142. IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
  143. ])
  144. const newValueMap = new Map<string, { value: string; suffix?: string; isText?: boolean }>()
  145. const addToMap = (data: any[]) => {
  146. if (!data) return
  147. data.forEach((item) => {
  148. if (item.identifier) {
  149. const { value, suffix, isText } = formatIotValue(item.value)
  150. newValueMap.set(item.identifier, { value, suffix, isText })
  151. }
  152. })
  153. }
  154. addToMap(gatewayRes as any[])
  155. addToMap(carRes as any[])
  156. dimensions.value.forEach((item) => {
  157. if (newValueMap.has(item.identifier)) {
  158. const { value, suffix, isText } = newValueMap.get(item.identifier) ?? {}
  159. item.value = value ?? ''
  160. item.suffix = suffix
  161. item.isText = isText
  162. }
  163. })
  164. gatewayDimensions.value.forEach((item) => {
  165. if (newValueMap.has(item.identifier)) {
  166. const { value, suffix, isText } = newValueMap.get(item.identifier) ?? {}
  167. item.value = value ?? ''
  168. item.suffix = suffix
  169. item.isText = isText
  170. }
  171. })
  172. carDimensions.value.forEach((item) => {
  173. if (newValueMap.has(item.identifier)) {
  174. const { value, suffix, isText } = newValueMap.get(item.identifier) ?? {}
  175. item.value = value ?? ''
  176. item.suffix = suffix
  177. item.isText = isText
  178. }
  179. })
  180. } catch (error) {
  181. console.error('Failed to update dimension values:', error)
  182. }
  183. }
  184. const selectedDate = ref<string[]>([
  185. ...rangeShortcuts[0].value().map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
  186. ])
  187. interface ChartData {
  188. [key: Dimensions['name']]: { ts: number; value: number }[]
  189. }
  190. const chartData = ref<ChartData>({})
  191. let intervalArr = ref<number[]>([])
  192. let maxInterval = ref(0)
  193. let minInterval = ref(0)
  194. const chartRef = ref<HTMLDivElement | null>(null)
  195. let chart: echarts.ECharts | null = null
  196. // const genderIntervalArrDebounce = useDebounceFn(
  197. // (init: boolean = false) => genderIntervalArr(init),
  198. // 300
  199. // )
  200. function genderIntervalArr(init: boolean = false) {
  201. const values: number[] = []
  202. for (const [key, value] of Object.entries(selectedDimension.value)) {
  203. if (value) {
  204. values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
  205. }
  206. }
  207. const maxVal = values.length === 0 ? 10000 : Math.max(...values)
  208. const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
  209. const maxDigits = (Math.floor(maxVal) + '').length
  210. const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
  211. const interval = Math.max(maxDigits, minDigits)
  212. maxInterval.value = interval
  213. minInterval.value = minDigits
  214. intervalArr.value = [0]
  215. for (let i = 1; i <= interval; i++) {
  216. intervalArr.value.push(Math.pow(10, i))
  217. }
  218. if (!init) {
  219. chart?.setOption({
  220. yAxis: {
  221. min: -minInterval.value,
  222. max: maxInterval.value
  223. }
  224. })
  225. }
  226. }
  227. function chartInit() {
  228. if (!chart) return
  229. chart.on('legendselectchanged', (params: any) => {
  230. selectedDimension.value = params.selected
  231. })
  232. window.addEventListener('resize', () => {
  233. if (chart) chart.resize()
  234. })
  235. }
  236. function render() {
  237. if (!chartRef.value) return
  238. if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
  239. chartInit()
  240. genderIntervalArr(true)
  241. chart.setOption({
  242. animation: true,
  243. animationDuration: 200,
  244. animationEasing: 'linear',
  245. animationDurationUpdate: 200,
  246. animationEasingUpdate: 'linear',
  247. grid: {
  248. left: '5%',
  249. top: '5%',
  250. right: '5%',
  251. bottom: '12%'
  252. },
  253. tooltip: {
  254. trigger: 'axis',
  255. axisPointer: {
  256. type: 'line'
  257. },
  258. formatter: (params) => {
  259. let d = `${params[0].axisValueLabel}<br>`
  260. const exist: string[] = []
  261. params = params.filter((el) => {
  262. if (exist.includes(el.seriesName)) return false
  263. exist.push(el.seriesName)
  264. return true
  265. })
  266. let item = params.map(
  267. (el) => `<div class="flex items-center justify-between mt-1">
  268. <span>${el.marker} ${el.seriesName}</span>
  269. <span>${el.value[2]?.toFixed(2)}</span>
  270. </div>`
  271. )
  272. return d + item.join('')
  273. }
  274. },
  275. xAxis: {
  276. type: 'time',
  277. axisLabel: {
  278. formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
  279. rotate: 0,
  280. align: 'left'
  281. }
  282. },
  283. dataZoom: [
  284. { type: 'inside', xAxisIndex: 0 },
  285. { type: 'slider', xAxisIndex: 0 }
  286. ],
  287. yAxis: {
  288. type: 'value',
  289. min: -minInterval.value,
  290. max: maxInterval.value,
  291. interval: 1,
  292. axisLabel: {
  293. formatter: (v) => {
  294. const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
  295. return num.toLocaleString()
  296. }
  297. },
  298. show: false
  299. },
  300. legend: {
  301. data: dimensions.value.map((item) => item.name),
  302. selected: selectedDimension.value,
  303. show: false
  304. },
  305. series: dimensions.value.map((item) => ({
  306. name: item.name,
  307. type: 'line',
  308. smooth: 0.2,
  309. showSymbol: false,
  310. endLabel: {
  311. show: true,
  312. formatter: (params) => params.value[2]?.toFixed(2),
  313. offset: [6, 0],
  314. color: item.color,
  315. fontSize: 12
  316. },
  317. emphasis: {
  318. focus: 'series'
  319. },
  320. lineStyle: {
  321. width: 2
  322. },
  323. color: item.color,
  324. data: [] // 占位数组
  325. }))
  326. })
  327. }
  328. function mapData({ value, ts }) {
  329. if (value === null || value === undefined || value === 0) return [ts, 0, 0]
  330. const isPositive = value > 0
  331. const absItem = Math.abs(value)
  332. if (!intervalArr.value.length) return [ts, 0, value]
  333. const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
  334. const min_index = intervalArr.value.findIndex((v) => v === min_value)
  335. let denominator = 1
  336. if (min_index < intervalArr.value.length - 1) {
  337. denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
  338. } else {
  339. denominator = intervalArr.value[min_index] || 1
  340. }
  341. const new_value = (absItem - min_value) / denominator + min_index
  342. return [ts, isPositive ? new_value : -new_value, value]
  343. }
  344. function updateSingleSeries(name: string) {
  345. if (!chart) render()
  346. if (!chart) return
  347. const idx = dimensions.value.findIndex((item) => item.name === name)
  348. if (idx === -1) return
  349. const data = chartData.value[name].map((v) => mapData(v))
  350. chart.setOption({
  351. series: [{ name, data }]
  352. })
  353. }
  354. const lastTsMap = ref<Record<Dimensions['name'], number>>({})
  355. async function fetchIncrementData() {
  356. for (const item of dimensions.value) {
  357. const { identifier, name } = item
  358. const lastTs = lastTsMap.value[name]
  359. if (!lastTs) continue
  360. item.response = true
  361. IotStatApi.getDeviceInfoChart(
  362. data.value.deviceCode,
  363. identifier,
  364. dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
  365. dayjs().format('YYYY-MM-DD HH:mm:ss')
  366. )
  367. .then((res) => {
  368. if (!res.length) return
  369. const sorted = res
  370. .sort((a, b) => a.ts - b.ts)
  371. .map((item) => ({ ts: item.ts, value: item.value }))
  372. // push 到本地
  373. chartData.value[name].push(...sorted)
  374. // 更新 lastTs
  375. lastTsMap.value[identifier] = sorted.at(-1).ts
  376. // 更新图表
  377. updateSingleSeries(name)
  378. })
  379. .finally(() => {
  380. item.response = false
  381. })
  382. }
  383. }
  384. const timer = ref<NodeJS.Timeout | null>(null)
  385. function startAutoFetch() {
  386. timer.value = setInterval(() => {
  387. updateDimensionValues()
  388. fetchIncrementData()
  389. }, 10000)
  390. }
  391. function stopAutoFetch() {
  392. cancelAllRequests()
  393. if (timer.value) clearInterval(timer.value)
  394. timer.value = null
  395. }
  396. const chartLoading = ref(false)
  397. async function initLoadChartData(real_time: boolean = true) {
  398. if (!dimensions.value.length) return
  399. chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
  400. chartLoading.value = true
  401. dimensions.value = dimensions.value.map((item) => {
  402. item.response = true
  403. return item
  404. })
  405. for (const item of dimensions.value) {
  406. const { identifier, name } = item
  407. try {
  408. const res = await IotStatApi.getDeviceInfoChart(
  409. data.value.deviceCode,
  410. identifier,
  411. selectedDate.value[0],
  412. selectedDate.value[1]
  413. )
  414. const sorted = res
  415. .sort((a, b) => a.ts - b.ts)
  416. .map((item) => ({ ts: item.ts, value: item.value }))
  417. chartData.value[name] = sorted
  418. lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
  419. genderIntervalArr(true)
  420. updateSingleSeries(name)
  421. chartLoading.value = false
  422. // if (selectedDimension.value[name]) {
  423. // genderIntervalArr()
  424. // }
  425. } finally {
  426. item.response = false
  427. }
  428. }
  429. if (real_time) {
  430. startAutoFetch()
  431. }
  432. }
  433. async function initfn(load: boolean = true, real_time: boolean = true) {
  434. if (load) await loadDimensions()
  435. render()
  436. initLoadChartData(real_time)
  437. }
  438. onMounted(() => {
  439. initfn()
  440. })
  441. function reset() {
  442. cancelAllRequests().then(() => {
  443. selectedDate.value = rangeShortcuts[0]
  444. .value()
  445. .map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
  446. stopAutoFetch()
  447. if (chart) chart.clear()
  448. initfn(false)
  449. })
  450. }
  451. function handleDateChange() {
  452. cancelAllRequests().then(() => {
  453. stopAutoFetch()
  454. if (chart) chart.clear()
  455. initfn(false, false)
  456. })
  457. }
  458. function handleClickSpec(modelName: string) {
  459. selectedDimension.value[modelName] = !selectedDimension.value[modelName]
  460. chart?.setOption({
  461. legend: {
  462. selected: selectedDimension.value
  463. }
  464. })
  465. genderIntervalArr()
  466. if (selectedDimension.value[modelName]) {
  467. updateSingleSeries(modelName)
  468. }
  469. nextTick(() => {
  470. chart?.resize()
  471. })
  472. }
  473. const downloadRef = ref(null)
  474. const exportChart = async () => {
  475. try {
  476. if (!downloadRef.value) return
  477. const result = await snapdom(downloadRef.value, {
  478. scale: 2,
  479. backgroundColor: '#fff'
  480. })
  481. await result.download({
  482. filename: `${data.value.deviceName}-设备监控`,
  483. type: 'png'
  484. })
  485. } catch (error) {
  486. console.error(error)
  487. ElMessage.error('导出图表失败')
  488. }
  489. }
  490. const maxmin = computed(() => {
  491. if (!dimensions.value.length) return []
  492. return dimensions.value
  493. .filter((v) => selectedDimension.value[v.name])
  494. .map((v) => ({
  495. name: v.name,
  496. color: v.color,
  497. bgHover: v.bgHover,
  498. max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
  499. min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
  500. }))
  501. })
  502. onUnmounted(() => {
  503. stopAutoFetch()
  504. window.removeEventListener('resize', () => {
  505. if (chart) chart.resize()
  506. })
  507. })
  508. const targetArea = ref(null)
  509. const { toggle, isFullscreen } = useFullscreen(targetArea)
  510. </script>
  511. <template>
  512. <div
  513. class="grid grid-rows-[80px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
  514. >
  515. <div
  516. 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"
  517. >
  518. <div class="flex items-center gap-4">
  519. <div
  520. class="size-12 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center shadow-inner"
  521. >
  522. <el-icon :size="24"><Odometer /></el-icon>
  523. </div>
  524. <div>
  525. <div class="text-xs text-gray-400 font-medium tracking-wider">资产编码</div>
  526. <div class="text-xl font-bold font-mono text-gray-800">{{ data.deviceCode }}</div>
  527. </div>
  528. </div>
  529. <div class="flex-1 flex justify-center divide-x divide-gray-100">
  530. <template v-for="item in headerCenterContent" :key="item.key">
  531. <div
  532. class="px-8 flex flex-col items-center"
  533. v-if="item.judgment ? Boolean(query[item.key]) : true"
  534. >
  535. <span class="text-xs text-gray-400 mb-1">{{ item.label }}</span>
  536. <span class="font-semibold text-gray-700">{{ data[item.key] }}</span>
  537. </div>
  538. </template>
  539. </div>
  540. <div class="flex items-center gap-6">
  541. <template v-for="item in headerTagContent" :key="item.key">
  542. <div class="text-center" v-if="item.judgment ? Boolean(query[item.key]) : true">
  543. <div class="text-xs text-gray-400 mb-1">{{ item.label }}</div>
  544. <el-tag v-if="data[item.key]" type="success" v-bind="tagProps">
  545. <el-icon class="mr-1"><CircleCheckFilled /></el-icon>在线
  546. </el-tag>
  547. <el-tag v-else type="danger" v-bind="tagProps">
  548. <el-icon class="mr-1"><CircleCloseFilled /></el-icon>离线
  549. </el-tag>
  550. </div>
  551. </template>
  552. </div>
  553. </div>
  554. <div ref="targetArea" class="h-full min-h-0 relative">
  555. <div class="grid grid-cols-[260px_1fr] gap-4 h-full">
  556. <el-scrollbar
  557. class="rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden bg-gradient-to-b from-blue-100 to-white"
  558. view-class="flex flex-col min-h-full"
  559. v-loading="dimensionLoading"
  560. >
  561. <template v-for="citem in dimensionsContent" :key="citem.label">
  562. <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
  563. <div
  564. class="sticky-title bg-blue-100 z-10 flex justify-between items-center py-3 px-4 border-0 border-solid"
  565. >
  566. <span class="font-bold text-sm text-gray-700! flex items-center gap-2">
  567. <el-icon><component :is="citem.icon" /></el-icon>
  568. {{ citem.label }}
  569. </span>
  570. <span
  571. class="text-xs px-2 py-0.5 rounded-full font-mono"
  572. :class="[citem.countBg, citem.countColor]"
  573. >
  574. {{ citem.value.length }}
  575. </span>
  576. </div>
  577. <div class="px-3 pb-4 pt-2 space-y-3">
  578. <div
  579. :data-disabled="disabledDimensions.includes(item.identifier)"
  580. v-for="item in citem.value"
  581. :key="item.identifier"
  582. @click="handleClickSpec(item.name)"
  583. 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"
  584. :class="{ 'is-active': selectedDimension[item.name] }"
  585. :style="{
  586. '--theme-color': item.color,
  587. '--theme-bg-hover': item.bgHover,
  588. '--theme-bg-active': item.bgActive
  589. }"
  590. >
  591. <div class="flex justify-between items-center mb-1">
  592. <span
  593. class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
  594. :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
  595. >
  596. {{ item.name }}
  597. </span>
  598. <div
  599. class="size-2 rounded-full transition-all duration-300 shadow-sm"
  600. :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
  601. :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
  602. ></div>
  603. </div>
  604. <div class="flex items-baseline justify-between relative z-19">
  605. <animated-count-to
  606. v-if="!item.isText"
  607. :value="Number(item.value)"
  608. :duration="500"
  609. :suffix="item.suffix"
  610. class="text-lg font-bold font-mono tracking-tight text-slate-800"
  611. />
  612. <span v-else class="text-lg font-bold font-mono tracking-tight text-slate-800">
  613. {{ item.value }}
  614. </span>
  615. </div>
  616. <div
  617. class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
  618. :class="
  619. selectedDimension[item.name]
  620. ? 'opacity-100 shadow-[0_0_8px_currentColor]'
  621. : 'opacity-0'
  622. "
  623. :style="{ backgroundColor: item.color, color: item.color }"
  624. >
  625. </div>
  626. </div>
  627. </div>
  628. </template>
  629. </template>
  630. </el-scrollbar>
  631. <div
  632. 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"
  633. >
  634. <header class="flex items-center justify-between mb-4">
  635. <h3 class="flex items-center gap-2">
  636. <div
  637. class="i-material-symbols:area-chart-outline-rounded text-sky size-6"
  638. text-sky
  639. ></div>
  640. 数据趋势
  641. </h3>
  642. <div class="flex gap-4">
  643. <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
  644. <el-button size="default" @click="reset">重置</el-button>
  645. <el-date-picker
  646. v-model="selectedDate"
  647. value-format="YYYY-MM-DD HH:mm:ss"
  648. type="datetimerange"
  649. unlink-panels
  650. start-placeholder="开始日期"
  651. end-placeholder="结束日期"
  652. :shortcuts="rangeShortcuts"
  653. size="default"
  654. class="w-100!"
  655. placement="bottom-end"
  656. @change="handleDateChange"
  657. />
  658. <el-button
  659. size="default"
  660. :type="isFullscreen ? 'info' : 'primary'"
  661. :icon="isFullscreen ? Crop : FullScreen"
  662. @click="toggle"
  663. >
  664. {{ isFullscreen ? '退出全屏' : '全屏' }}
  665. </el-button>
  666. </div>
  667. </header>
  668. <div ref="downloadRef" class="flex flex-1">
  669. <div class="flex gap-1 select-none">
  670. <div
  671. v-for="item of maxmin"
  672. :key="item.name"
  673. :style="{
  674. '--theme-bg-hover': item.bgHover
  675. }"
  676. 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"
  677. @click="handleClickSpec(item.name)"
  678. >
  679. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
  680. <div
  681. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  682. :style="{ backgroundColor: item.color }"
  683. ></div>
  684. <span
  685. class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
  686. :style="{ color: item.color }"
  687. >
  688. {{ item.name }}
  689. </span>
  690. <div
  691. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  692. :style="{ backgroundColor: item.color }"
  693. ></div>
  694. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
  695. </div>
  696. </div>
  697. <div
  698. 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]"
  699. >
  700. <div
  701. v-loading="chartLoading"
  702. element-loading-background="transparent"
  703. ref="chartRef"
  704. class="w-full h-full"
  705. >
  706. </div>
  707. </div>
  708. </div>
  709. </div>
  710. </div>
  711. </div>
  712. </div>
  713. </template>
  714. <style scoped>
  715. :deep(.el-tag__content) {
  716. display: flex;
  717. align-items: center;
  718. gap: 2px;
  719. }
  720. .sticky-title {
  721. position: sticky;
  722. top: 0;
  723. }
  724. .dimension-card:hover {
  725. background-color: var(--theme-bg-hover);
  726. border-color: var(--theme-bg-active);
  727. box-shadow: 0 4px 12px -2px rgb(0 0 0 / 5%);
  728. }
  729. .dimension-card.is-active {
  730. background-color: var(--theme-bg-active);
  731. border-color: var(--theme-color);
  732. box-shadow:
  733. 0 0 0 1px var(--theme-bg-active),
  734. 0 4px 12px -2px var(--theme-bg-active);
  735. }
  736. :deep(.el-scrollbar__bar.is-vertical) {
  737. right: 2px;
  738. width: 4px;
  739. }
  740. :deep(.el-scrollbar__thumb) {
  741. background-color: #cbd5e1;
  742. opacity: 0.6;
  743. }
  744. :deep(.el-scrollbar__thumb:hover) {
  745. background-color: #94a3b8;
  746. opacity: 1;
  747. }
  748. :fullscreen {
  749. padding: 16px;
  750. background-color: #fff;
  751. }
  752. /* 兼容写法 */
  753. ::backdrop {
  754. background-color: #fff;
  755. }
  756. </style>