detail.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  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. TrendCharts
  11. } from '@element-plus/icons-vue'
  12. import { AnimatedCountTo } from '@/components/AnimatedCountTo'
  13. import { neonColors } from '@/utils/td-color'
  14. import dayjs from 'dayjs'
  15. import * as echarts from 'echarts'
  16. import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
  17. import { useSocketBus } from '@/utils/useSocketBus'
  18. import { rangeShortcuts } from '@/utils/formatTime'
  19. const { query } = useRoute()
  20. const data = ref({
  21. deviceCode: query.code || '',
  22. deviceName: query.name || '',
  23. lastInlineTime: query.time || '',
  24. ifInline: query.ifInline === '3',
  25. dept: query.dept || '',
  26. vehicle: query.vehicle || '',
  27. carOnline: query.carOnline === 'true'
  28. })
  29. const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
  30. onAny((msg) => {
  31. if (!Array.isArray(msg) || msg.length === 0) return
  32. const valueMap = new Map<string, number>()
  33. for (const item of msg) {
  34. const { identity, modelName, readTime, logValue } = item
  35. const value = logValue ? Number(logValue) : 0
  36. if (identity) {
  37. valueMap.set(identity, value)
  38. }
  39. if (modelName && chartData.value[modelName]) {
  40. chartData.value[modelName].push({
  41. ts: dayjs(readTime).valueOf(),
  42. value
  43. })
  44. updateSingleSeries(modelName)
  45. }
  46. }
  47. const updateDimensions = (list) => {
  48. list.forEach((item) => {
  49. const v = valueMap.get(item.identifier)
  50. if (v !== undefined) {
  51. item.value = v
  52. }
  53. })
  54. }
  55. updateDimensions(dimensions.value)
  56. updateDimensions(gatewayDimensions.value)
  57. updateDimensions(carDimensions.value)
  58. // 3️⃣ 统一一次调用
  59. genderIntervalArr()
  60. })
  61. function hexToRgba(hex: string, alpha: number) {
  62. const r = parseInt(hex.slice(1, 3), 16)
  63. const g = parseInt(hex.slice(3, 5), 16)
  64. const b = parseInt(hex.slice(5, 7), 16)
  65. return `rgba(${r}, ${g}, ${b}, ${alpha})`
  66. }
  67. interface HeaderItem {
  68. label: string
  69. key: keyof typeof data.value
  70. judgment?: boolean
  71. }
  72. const headerCenterContent: HeaderItem[] = [
  73. { label: '设备名称', key: 'deviceName' },
  74. { label: '所属部门', key: 'dept' },
  75. { label: '车牌号码', key: 'vehicle', judgment: true },
  76. { label: '最后上报时间', key: 'lastInlineTime' }
  77. ]
  78. const tagProps = { size: 'default', round: true } as const
  79. const headerTagContent: HeaderItem[] = [
  80. { label: '网关', key: 'ifInline' },
  81. { label: '北斗', key: 'carOnline', judgment: true }
  82. ]
  83. interface Dimensions {
  84. identifier: string
  85. name: string
  86. value: string | number
  87. color: string
  88. bgHover: string
  89. bgActive: string
  90. response?: boolean
  91. }
  92. const dimensions = ref<Dimensions[]>([])
  93. const gatewayDimensions = ref<Dimensions[]>([])
  94. const carDimensions = ref<Dimensions[]>([])
  95. const dimensionsContent = computed(() => [
  96. {
  97. label: '网关数采',
  98. icon: DataLine,
  99. value: gatewayDimensions.value,
  100. countColor: 'text-blue-600',
  101. countBg: 'bg-blue-50'
  102. },
  103. {
  104. label: '中航北斗',
  105. icon: TrendCharts,
  106. value: carDimensions.value,
  107. countColor: 'text-indigo-600',
  108. countBg: 'bg-indigo-50',
  109. judgment: true
  110. }
  111. ])
  112. const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
  113. const selectedDimension = ref<Record<string, boolean>>({})
  114. const dimensionLoading = ref(false)
  115. async function loadDimensions() {
  116. if (!query.id) return
  117. dimensionLoading.value = true
  118. try {
  119. const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
  120. .sort((a, b) => b.modelOrder - a.modelOrder)
  121. .map((item) => ({
  122. identifier: item.identifier,
  123. name: item.modelName,
  124. value: item.value,
  125. response: false
  126. }))
  127. const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
  128. .sort((a, b) => b.modelOrder - a.modelOrder)
  129. .map((item) => ({
  130. identifier: item.identifier,
  131. name: item.modelName,
  132. value: item.value,
  133. response: false
  134. }))
  135. // 合并并分配霓虹色
  136. dimensions.value = [...gateway, ...car]
  137. .filter((item) => !disabledDimensions.value.includes(item.identifier))
  138. .map((item, index) => {
  139. const color = neonColors[index]
  140. return {
  141. ...item,
  142. color: color,
  143. bgHover: hexToRgba(color, 0.08),
  144. bgActive: hexToRgba(color, 0.12)
  145. }
  146. })
  147. gatewayDimensions.value = dimensions.value.filter((d) =>
  148. gateway.some((g) => g.identifier === d.identifier)
  149. )
  150. carDimensions.value = dimensions.value.filter((d) =>
  151. car.some((c) => c.identifier === d.identifier)
  152. )
  153. selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
  154. if (dimensions.value.length > 0) {
  155. selectedDimension.value[dimensions.value[0].name] = true
  156. }
  157. } catch (e) {
  158. console.error(e)
  159. } finally {
  160. dimensionLoading.value = false
  161. }
  162. }
  163. // async function updateDimensionValues() {
  164. // if (!query.id) return
  165. // try {
  166. // // 1. 并行获取最新数据
  167. // const [gatewayRes, carRes] = await Promise.all([
  168. // IotDeviceApi.getIotDeviceTds(Number(query.id)),
  169. // IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
  170. // ])
  171. // // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
  172. // // 这样可以将复杂度从 O(N*M) 降低到 O(N)
  173. // const newValueMap = new Map<string, any>()
  174. // const addToMap = (data: any[]) => {
  175. // if (!data) return
  176. // data.forEach((item) => {
  177. // if (item.identifier) {
  178. // newValueMap.set(item.identifier, item.value)
  179. // }
  180. // })
  181. // }
  182. // addToMap(gatewayRes as any[])
  183. // addToMap(carRes as any[])
  184. // // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
  185. // dimensions.value.forEach((item) => {
  186. // if (newValueMap.has(item.identifier)) {
  187. // item.value = newValueMap.get(item.identifier)
  188. // }
  189. // })
  190. // // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
  191. // // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
  192. // // 如果它们是独立的对象数组,则需要显式更新)
  193. // // 更新 Gateway 原始列表
  194. // gatewayDimensions.value.forEach((item) => {
  195. // if (newValueMap.has(item.identifier)) {
  196. // item.value = newValueMap.get(item.identifier)
  197. // }
  198. // })
  199. // // 更新 Car 原始列表
  200. // carDimensions.value.forEach((item) => {
  201. // if (newValueMap.has(item.identifier)) {
  202. // item.value = newValueMap.get(item.identifier)
  203. // }
  204. // })
  205. // } catch (error) {
  206. // console.error('Failed to update dimension values:', error)
  207. // }
  208. // }
  209. const selectedDate = ref<string[]>([
  210. dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
  211. dayjs().format('YYYY-MM-DD HH:mm:ss')
  212. ])
  213. interface ChartData {
  214. [key: Dimensions['name']]: { ts: number; value: number }[]
  215. }
  216. const chartData = ref<ChartData>({})
  217. let intervalArr = ref<number[]>([])
  218. let maxInterval = ref(0)
  219. let minInterval = ref(0)
  220. const chartRef = ref<HTMLDivElement | null>(null)
  221. let chart: echarts.ECharts | null = null
  222. // const genderIntervalArrDebounce = useDebounceFn(
  223. // (init: boolean = false) => genderIntervalArr(init),
  224. // 300
  225. // )
  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: '6%',
  275. top: '5%',
  276. right: '6%',
  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. axisLabel: {
  304. formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
  305. rotate: 0,
  306. align: 'left'
  307. }
  308. },
  309. dataZoom: [
  310. { type: 'inside', xAxisIndex: 0 },
  311. { type: 'slider', xAxisIndex: 0 }
  312. ],
  313. yAxis: {
  314. type: 'value',
  315. min: -minInterval.value,
  316. max: maxInterval.value,
  317. interval: 1,
  318. axisLabel: {
  319. formatter: (v) => {
  320. const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
  321. return num.toLocaleString()
  322. }
  323. },
  324. show: false
  325. },
  326. legend: {
  327. data: dimensions.value.map((item) => item.name),
  328. selected: selectedDimension.value,
  329. show: false
  330. },
  331. // series: dimensions.value.map((item) => ({
  332. // name: item.name,
  333. // type: 'line',
  334. // smooth: true,
  335. // showSymbol: false,
  336. // color: item.color,
  337. // data: [] // 占位数组
  338. // }))
  339. series: dimensions.value.map((item) => ({
  340. name: item.name,
  341. type: 'line',
  342. smooth: 0.2,
  343. showSymbol: false,
  344. endLabel: {
  345. show: true,
  346. formatter: (params) => params.value[2]?.toFixed(2),
  347. offset: [6, 0],
  348. color: item.color,
  349. fontSize: 12
  350. },
  351. emphasis: {
  352. focus: 'series'
  353. },
  354. lineStyle: {
  355. width: 2
  356. },
  357. color: item.color,
  358. data: [] // 占位数组
  359. }))
  360. })
  361. }
  362. function mapData({ value, ts }) {
  363. if (value === null || value === undefined || value === 0) return [ts, 0, 0]
  364. const isPositive = value > 0
  365. const absItem = Math.abs(value)
  366. if (!intervalArr.value.length) return [ts, 0, value]
  367. const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
  368. const min_index = intervalArr.value.findIndex((v) => v === min_value)
  369. let denominator = 1
  370. if (min_index < intervalArr.value.length - 1) {
  371. denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
  372. } else {
  373. denominator = intervalArr.value[min_index] || 1
  374. }
  375. const new_value = (absItem - min_value) / denominator + min_index
  376. return [ts, isPositive ? new_value : -new_value, value]
  377. }
  378. function updateSingleSeries(name: string) {
  379. if (!chart) render()
  380. if (!chart) return
  381. const idx = dimensions.value.findIndex((item) => item.name === name)
  382. if (idx === -1) return
  383. const data = chartData.value[name].map((v) => mapData(v))
  384. chart.setOption({
  385. series: [{ name, data }]
  386. })
  387. }
  388. const lastTsMap = ref<Record<Dimensions['name'], number>>({})
  389. // async function fetchIncrementData() {
  390. // for (const item of dimensions.value) {
  391. // const { identifier, name } = item
  392. // const lastTs = lastTsMap.value[name]
  393. // if (!lastTs) continue
  394. // item.response = true
  395. // IotStatApi.getDeviceInfoChart(
  396. // data.value.deviceCode,
  397. // identifier,
  398. // dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
  399. // dayjs().format('YYYY-MM-DD HH:mm:ss')
  400. // )
  401. // .then((res) => {
  402. // if (!res.length) return
  403. // const sorted = res
  404. // .sort((a, b) => a.ts - b.ts)
  405. // .map((item) => ({ ts: item.ts, value: item.value }))
  406. // // push 到本地
  407. // chartData.value[name].push(...sorted)
  408. // // 更新 lastTs
  409. // lastTsMap.value[identifier] = sorted.at(-1).ts
  410. // // 更新图表
  411. // updateSingleSeries(name)
  412. // })
  413. // .finally(() => {
  414. // item.response = false
  415. // })
  416. // }
  417. // }
  418. // const timer = ref<NodeJS.Timeout | null>(null)
  419. // function startAutoFetch() {
  420. // timer.value = setInterval(() => {
  421. // updateDimensionValues()
  422. // fetchIncrementData()
  423. // }, 10000)
  424. // }
  425. // function stopAutoFetch() {
  426. // cancelAllRequests()
  427. // if (timer.value) clearInterval(timer.value)
  428. // timer.value = null
  429. // }
  430. const chartLoading = ref(false)
  431. async function initLoadChartData(real_time: boolean = true) {
  432. if (!dimensions.value.length) return
  433. chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
  434. chartLoading.value = true
  435. dimensions.value = dimensions.value.map((item) => {
  436. item.response = true
  437. return item
  438. })
  439. for (const item of dimensions.value) {
  440. const { identifier, name } = item
  441. try {
  442. const res = await IotStatApi.getDeviceInfoChart(
  443. data.value.deviceCode,
  444. identifier,
  445. selectedDate.value[0],
  446. selectedDate.value[1]
  447. )
  448. const sorted = res
  449. .sort((a, b) => a.ts - b.ts)
  450. .map((item) => ({ ts: item.ts, value: item.value }))
  451. chartData.value[name] = sorted
  452. lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
  453. genderIntervalArr(true)
  454. updateSingleSeries(name)
  455. chartLoading.value = false
  456. // if (selectedDimension.value[name]) {
  457. // genderIntervalArr()
  458. // }
  459. } finally {
  460. item.response = false
  461. }
  462. }
  463. if (real_time) {
  464. // startAutoFetch()
  465. connect()
  466. }
  467. }
  468. async function initfn(load: boolean = true, real_time: boolean = true) {
  469. if (load) await loadDimensions()
  470. render()
  471. initLoadChartData(real_time)
  472. }
  473. onMounted(() => {
  474. initfn()
  475. })
  476. function reset() {
  477. cancelAllRequests().then(() => {
  478. selectedDate.value = [
  479. dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
  480. dayjs().format('YYYY-MM-DD HH:mm:ss')
  481. ]
  482. close()
  483. // stopAutoFetch()
  484. if (chart) chart.clear()
  485. initfn(false)
  486. })
  487. }
  488. function handleDateChange() {
  489. cancelAllRequests().then(() => {
  490. close()
  491. // stopAutoFetch()
  492. if (chart) chart.clear()
  493. initfn(false, false)
  494. })
  495. }
  496. function handleClickSpec(modelName: string) {
  497. selectedDimension.value[modelName] = !selectedDimension.value[modelName]
  498. chart?.setOption({
  499. legend: {
  500. selected: selectedDimension.value
  501. }
  502. })
  503. genderIntervalArr()
  504. if (selectedDimension.value[modelName]) {
  505. updateSingleSeries(modelName)
  506. }
  507. nextTick(() => {
  508. chart?.resize()
  509. })
  510. }
  511. const exportChart = () => {
  512. if (!chart) return
  513. let img = new Image()
  514. img.src = chart.getDataURL({
  515. type: 'png',
  516. pixelRatio: 1,
  517. backgroundColor: '#fff'
  518. })
  519. img.onload = function () {
  520. let canvas = document.createElement('canvas')
  521. canvas.width = img.width
  522. canvas.height = img.height
  523. let ctx = canvas.getContext('2d')
  524. ctx?.drawImage(img, 0, 0)
  525. let dataURL = canvas.toDataURL('image/png')
  526. let a = document.createElement('a')
  527. let event = new MouseEvent('click')
  528. a.href = dataURL
  529. a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
  530. a.dispatchEvent(event)
  531. }
  532. }
  533. const maxmin = computed(() => {
  534. if (!dimensions.value.length) return []
  535. return dimensions.value
  536. .filter((v) => selectedDimension.value[v.name])
  537. .map((v) => ({
  538. name: v.name,
  539. color: v.color,
  540. bgHover: v.bgHover,
  541. max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
  542. min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
  543. }))
  544. })
  545. onUnmounted(() => {
  546. // stopAutoFetch()
  547. close()
  548. window.removeEventListener('resize', () => {
  549. if (chart) chart.resize()
  550. })
  551. })
  552. </script>
  553. <template>
  554. <div
  555. class="grid grid-cols-[260px_1fr] grid-rows-[80px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
  556. >
  557. <div
  558. class="grid-col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 border-solid px-6 flex items-center justify-between shrink-0"
  559. >
  560. <div class="flex items-center gap-4">
  561. <div
  562. class="size-12 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center shadow-inner"
  563. >
  564. <el-icon :size="24"><Odometer /></el-icon>
  565. </div>
  566. <div>
  567. <div class="text-xs text-gray-400 font-medium tracking-wider">资产编码</div>
  568. <div class="text-xl font-bold font-mono text-gray-800">{{ data.deviceCode }}</div>
  569. </div>
  570. </div>
  571. <div class="flex-1 flex justify-center divide-x divide-gray-100">
  572. <template v-for="item in headerCenterContent" :key="item.key">
  573. <div
  574. class="px-8 flex flex-col items-center"
  575. v-if="item.judgment ? Boolean(query[item.key]) : true"
  576. >
  577. <span class="text-xs text-gray-400 mb-1">{{ item.label }}</span>
  578. <span class="font-semibold text-gray-700">{{ data[item.key] }}</span>
  579. </div>
  580. </template>
  581. </div>
  582. <div class="flex items-center gap-6">
  583. <template v-for="item in headerTagContent" :key="item.key">
  584. <div class="text-center" v-if="item.judgment ? Boolean(query[item.key]) : true">
  585. <div class="text-xs text-gray-400 mb-1">{{ item.label }}</div>
  586. <el-tag v-if="data[item.key]" type="success" v-bind="tagProps">
  587. <el-icon class="mr-1"><CircleCheckFilled /></el-icon>在线
  588. </el-tag>
  589. <el-tag v-else type="danger" v-bind="tagProps">
  590. <el-icon class="mr-1"><CircleCloseFilled /></el-icon>离线
  591. </el-tag>
  592. </div>
  593. </template>
  594. </div>
  595. </div>
  596. <el-scrollbar
  597. class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden"
  598. view-class="flex flex-col min-h-full"
  599. v-loading="dimensionLoading"
  600. >
  601. <template v-for="citem in dimensionsContent" :key="citem.label">
  602. <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
  603. <div
  604. class="sticky-title z-88 bg-white/95 flex justify-between items-center py-3 px-4 border-0 border-solid border-b border-gray-50"
  605. >
  606. <span class="font-bold text-sm text-gray-700! flex items-center gap-2">
  607. <el-icon><component :is="citem.icon" /></el-icon>
  608. {{ citem.label }}
  609. </span>
  610. <span
  611. class="text-xs px-2 py-0.5 rounded-full font-mono"
  612. :class="[citem.countBg, citem.countColor]"
  613. >
  614. {{ citem.value.length }}
  615. </span>
  616. </div>
  617. <div class="px-3 pb-4 pt-2 space-y-3">
  618. <div
  619. v-for="item in citem.value"
  620. :key="item.identifier"
  621. @click="handleClickSpec(item.name)"
  622. class="dimension-card group relative p-3 rounded-lg border border-solid bg-white border-gray-200 transition-all duration-300 cursor-pointer select-none"
  623. :class="{ 'is-active': selectedDimension[item.name] }"
  624. :style="{
  625. '--theme-color': item.color,
  626. '--theme-bg-hover': item.bgHover,
  627. '--theme-bg-active': item.bgActive
  628. }"
  629. >
  630. <div class="flex justify-between items-center mb-1">
  631. <span
  632. class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
  633. :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
  634. >
  635. {{ item.name }}
  636. </span>
  637. <div
  638. class="size-2 rounded-full transition-all duration-300 shadow-sm"
  639. :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
  640. :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
  641. ></div>
  642. </div>
  643. <div class="flex items-baseline justify-between relative z-10">
  644. <animated-count-to
  645. :value="Number(item.value)"
  646. :duration="500"
  647. class="text-lg font-bold font-mono tracking-tight text-slate-800"
  648. />
  649. </div>
  650. <div
  651. class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
  652. :class="
  653. selectedDimension[item.name]
  654. ? 'opacity-100 shadow-[0_0_8px_currentColor]'
  655. : 'opacity-0'
  656. "
  657. :style="{ backgroundColor: item.color, color: item.color }"
  658. >
  659. </div>
  660. </div>
  661. </div>
  662. </template>
  663. </template>
  664. </el-scrollbar>
  665. <div
  666. class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col"
  667. >
  668. <header class="flex items-center justify-between mb-4">
  669. <h3 class="flex items-center gap-2">
  670. <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
  671. 数据趋势
  672. </h3>
  673. <div class="flex gap-4">
  674. <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
  675. <el-button size="default" @click="reset">重置</el-button>
  676. <el-date-picker
  677. v-model="selectedDate"
  678. value-format="YYYY-MM-DD HH:mm:ss"
  679. type="datetimerange"
  680. unlink-panels
  681. start-placeholder="开始日期"
  682. end-placeholder="结束日期"
  683. :shortcuts="rangeShortcuts"
  684. size="default"
  685. class="w-100!"
  686. placement="bottom-end"
  687. @change="handleDateChange"
  688. />
  689. </div>
  690. </header>
  691. <div class="flex flex-1">
  692. <div class="flex gap-1 select-none">
  693. <div
  694. v-for="item of maxmin"
  695. :key="item.name"
  696. :style="{
  697. '--theme-bg-hover': item.bgHover
  698. }"
  699. class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded-full group relative bg-gray-50 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"
  700. @click="handleClickSpec(item.name)"
  701. >
  702. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
  703. <div
  704. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  705. :style="{ backgroundColor: item.color }"
  706. ></div>
  707. <span
  708. class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
  709. :style="{ color: item.color }"
  710. >
  711. {{ item.name }}
  712. </span>
  713. <div
  714. class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
  715. :style="{ backgroundColor: item.color }"
  716. ></div>
  717. <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
  718. </div>
  719. </div>
  720. <div
  721. class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden"
  722. >
  723. <div
  724. v-loading="chartLoading"
  725. element-loading-background="transparent"
  726. ref="chartRef"
  727. class="w-full h-full"
  728. >
  729. </div>
  730. </div>
  731. </div>
  732. </div>
  733. </div>
  734. </template>
  735. <style scoped>
  736. /* Icon Fix */
  737. :deep(.el-tag__content) {
  738. display: flex;
  739. align-items: center;
  740. gap: 2px;
  741. }
  742. /* Sticky Header */
  743. .sticky-title {
  744. position: sticky;
  745. top: 0;
  746. }
  747. /*
  748. 核心样式:霓虹卡片效果
  749. 使用 CSS 变量实现动态颜色
  750. */
  751. /* Hover 状态:背景微亮,边框变色 */
  752. .dimension-card:hover {
  753. background-color: var(--theme-bg-hover);
  754. border-color: var(--theme-bg-active);
  755. box-shadow: 0 4px 12px -2px rgb(0 0 0 / 5%);
  756. }
  757. /* Active 状态:背景更亮,边框为主题色,带轻微发光投影 */
  758. .dimension-card.is-active {
  759. background-color: var(--theme-bg-active);
  760. border-color: var(--theme-color);
  761. box-shadow:
  762. 0 0 0 1px var(--theme-bg-active),
  763. 0 4px 12px -2px var(--theme-bg-active);
  764. }
  765. /* 滚动条美化 */
  766. :deep(.el-scrollbar__bar.is-vertical) {
  767. right: 2px;
  768. width: 4px;
  769. }
  770. :deep(.el-scrollbar__thumb) {
  771. background-color: #cbd5e1;
  772. opacity: 0.6;
  773. }
  774. :deep(.el-scrollbar__thumb:hover) {
  775. background-color: #94a3b8;
  776. opacity: 1;
  777. }
  778. </style>