|
|
@@ -1,13 +1,21 @@
|
|
|
-<script setup lang="ts">
|
|
|
-import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
|
|
+<script lang="ts" setup>
|
|
|
+import { computed, ref } from 'vue'
|
|
|
+import { useRoute } from 'vue-router'
|
|
|
import { IotDeviceApi } from '@/api/pms/device'
|
|
|
+import {
|
|
|
+ Odometer,
|
|
|
+ CircleCheckFilled,
|
|
|
+ CircleCloseFilled,
|
|
|
+ DataLine,
|
|
|
+ TrendCharts
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+import { AnimatedCountTo } from '@/components/AnimatedCountTo'
|
|
|
+import { neonColors } from '@/utils/td-color'
|
|
|
import dayjs from 'dayjs'
|
|
|
-import { rangeShortcuts } from '@/utils/formatTime'
|
|
|
-import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
|
|
|
-
|
|
|
import * as echarts from 'echarts'
|
|
|
-import { colors } from '@/utils/td-color'
|
|
|
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
|
|
|
import { useSocketBus } from '@/utils/useSocketBus'
|
|
|
+import { rangeShortcuts } from '@/utils/formatTime'
|
|
|
|
|
|
const { query } = useRoute()
|
|
|
|
|
|
@@ -15,10 +23,10 @@ const data = ref({
|
|
|
deviceCode: query.code || '',
|
|
|
deviceName: query.name || '',
|
|
|
lastInlineTime: query.time || '',
|
|
|
- ifInline: query.ifInline || '',
|
|
|
+ ifInline: query.ifInline === '3',
|
|
|
dept: query.dept || '',
|
|
|
vehicle: query.vehicle || '',
|
|
|
- carOnline: query.carOnline || ''
|
|
|
+ carOnline: query.carOnline === 'true'
|
|
|
})
|
|
|
|
|
|
const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
|
|
|
@@ -28,7 +36,6 @@ onAny((msg) => {
|
|
|
|
|
|
const valueMap = new Map<string, number>()
|
|
|
|
|
|
- // 1️⃣ 一次遍历:建 Map + 推图表数据
|
|
|
for (const item of msg) {
|
|
|
const { identity, modelName, readTime, logValue } = item
|
|
|
|
|
|
@@ -48,7 +55,6 @@ onAny((msg) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 2️⃣ 批量更新 dimensions
|
|
|
const updateDimensions = (list) => {
|
|
|
list.forEach((item) => {
|
|
|
const v = valueMap.get(item.identifier)
|
|
|
@@ -66,11 +72,40 @@ onAny((msg) => {
|
|
|
genderIntervalArr()
|
|
|
})
|
|
|
|
|
|
+function hexToRgba(hex: string, alpha: number) {
|
|
|
+ const r = parseInt(hex.slice(1, 3), 16)
|
|
|
+ const g = parseInt(hex.slice(3, 5), 16)
|
|
|
+ const b = parseInt(hex.slice(5, 7), 16)
|
|
|
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
|
+}
|
|
|
+
|
|
|
+interface HeaderItem {
|
|
|
+ label: string
|
|
|
+ key: keyof typeof data.value
|
|
|
+ judgment?: boolean
|
|
|
+}
|
|
|
+
|
|
|
+const headerCenterContent: HeaderItem[] = [
|
|
|
+ { label: '设备名称', key: 'deviceName' },
|
|
|
+ { label: '所属部门', key: 'dept' },
|
|
|
+ { label: '车牌号码', key: 'vehicle', judgment: true },
|
|
|
+ { label: '最后上报时间', key: 'lastInlineTime' }
|
|
|
+]
|
|
|
+
|
|
|
+const tagProps = { size: 'default', round: true } as const
|
|
|
+
|
|
|
+const headerTagContent: HeaderItem[] = [
|
|
|
+ { label: '网关', key: 'ifInline' },
|
|
|
+ { label: '北斗', key: 'carOnline', judgment: true }
|
|
|
+]
|
|
|
+
|
|
|
interface Dimensions {
|
|
|
identifier: string
|
|
|
name: string
|
|
|
- value: string
|
|
|
- color?: string
|
|
|
+ value: string | number
|
|
|
+ color: string
|
|
|
+ bgHover: string
|
|
|
+ bgActive: string
|
|
|
response?: boolean
|
|
|
}
|
|
|
|
|
|
@@ -78,57 +113,80 @@ const dimensions = ref<Dimensions[]>([])
|
|
|
const gatewayDimensions = ref<Dimensions[]>([])
|
|
|
const carDimensions = ref<Dimensions[]>([])
|
|
|
|
|
|
-const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
|
|
|
-
|
|
|
-interface SelectedDimension {
|
|
|
- [key: Dimensions['name']]: boolean
|
|
|
-}
|
|
|
-
|
|
|
-const selectedDimension = ref<SelectedDimension>({})
|
|
|
+const dimensionsContent = computed(() => [
|
|
|
+ {
|
|
|
+ label: '网关数采',
|
|
|
+ icon: DataLine,
|
|
|
+ value: gatewayDimensions.value,
|
|
|
+ countColor: 'text-blue-600',
|
|
|
+ countBg: 'bg-blue-50'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '中航北斗',
|
|
|
+ icon: TrendCharts,
|
|
|
+ value: carDimensions.value,
|
|
|
+ countColor: 'text-indigo-600',
|
|
|
+ countBg: 'bg-indigo-50',
|
|
|
+ judgment: true
|
|
|
+ }
|
|
|
+])
|
|
|
|
|
|
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
|
|
|
+const selectedDimension = ref<Record<string, boolean>>({})
|
|
|
const dimensionLoading = ref(false)
|
|
|
|
|
|
-const disabledDimension = computed(() => (identifier: string) => {
|
|
|
- const response = dimensions.value.find((item) => item.identifier === identifier)?.response
|
|
|
-
|
|
|
- return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
|
|
|
-})
|
|
|
-
|
|
|
async function loadDimensions() {
|
|
|
if (!query.id) return
|
|
|
-
|
|
|
dimensionLoading.value = true
|
|
|
+ try {
|
|
|
+ const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
|
|
|
+ .sort((a, b) => b.modelOrder - a.modelOrder)
|
|
|
+ .map((item) => ({
|
|
|
+ identifier: item.identifier,
|
|
|
+ name: item.modelName,
|
|
|
+ value: item.value,
|
|
|
+ response: false
|
|
|
+ }))
|
|
|
+
|
|
|
+ const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
|
|
|
+ .sort((a, b) => b.modelOrder - a.modelOrder)
|
|
|
+ .map((item) => ({
|
|
|
+ identifier: item.identifier,
|
|
|
+ name: item.modelName,
|
|
|
+ value: item.value,
|
|
|
+ response: false
|
|
|
+ }))
|
|
|
+
|
|
|
+ // 合并并分配霓虹色
|
|
|
+ dimensions.value = [...gateway, ...car]
|
|
|
+ .filter((item) => !disabledDimensions.value.includes(item.identifier))
|
|
|
+ .map((item, index) => {
|
|
|
+ const color = neonColors[index]
|
|
|
+
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ color: color,
|
|
|
+ bgHover: hexToRgba(color, 0.08),
|
|
|
+ bgActive: hexToRgba(color, 0.12)
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
- const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
|
|
|
- .sort((a, b) => b.modelOrder - a.modelOrder)
|
|
|
- .map((item) => ({
|
|
|
- identifier: item.identifier,
|
|
|
- name: item.modelName,
|
|
|
- value: item.value
|
|
|
- }))
|
|
|
- const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
|
|
|
- .sort((a, b) => b.modelOrder - a.modelOrder)
|
|
|
- .map((item) => ({
|
|
|
- identifier: item.identifier,
|
|
|
- name: item.modelName,
|
|
|
- value: item.value
|
|
|
- }))
|
|
|
-
|
|
|
- dimensions.value = [...gateway, ...car]
|
|
|
- .filter((item) => !disabledDimensions.value.includes(item.identifier))
|
|
|
- .map((item, index) => ({
|
|
|
- ...item,
|
|
|
- color: colors[index]
|
|
|
- }))
|
|
|
-
|
|
|
- gatewayDimensions.value = gateway
|
|
|
- carDimensions.value = car
|
|
|
-
|
|
|
- selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
|
|
|
-
|
|
|
- selectedDimension.value[dimensions.value[0].name] = true
|
|
|
+ gatewayDimensions.value = dimensions.value.filter((d) =>
|
|
|
+ gateway.some((g) => g.identifier === d.identifier)
|
|
|
+ )
|
|
|
+ carDimensions.value = dimensions.value.filter((d) =>
|
|
|
+ car.some((c) => c.identifier === d.identifier)
|
|
|
+ )
|
|
|
|
|
|
- dimensionLoading.value = false
|
|
|
+ selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
|
|
|
+ if (dimensions.value.length > 0) {
|
|
|
+ selectedDimension.value[dimensions.value[0].name] = true
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ } finally {
|
|
|
+ dimensionLoading.value = false
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// async function updateDimensionValues() {
|
|
|
@@ -370,17 +428,24 @@ function render() {
|
|
|
}
|
|
|
|
|
|
function mapData({ value, ts }) {
|
|
|
- if (!value) return [ts, 0, 0]
|
|
|
+ if (value === null || value === undefined || value === 0) return [ts, 0, 0]
|
|
|
|
|
|
const isPositive = value > 0
|
|
|
const absItem = Math.abs(value)
|
|
|
|
|
|
+ if (!intervalArr.value.length) return [ts, 0, value]
|
|
|
+
|
|
|
const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
|
|
|
const min_index = intervalArr.value.findIndex((v) => v === min_value)
|
|
|
|
|
|
- const new_value =
|
|
|
- (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
|
|
|
- min_index
|
|
|
+ let denominator = 1
|
|
|
+ if (min_index < intervalArr.value.length - 1) {
|
|
|
+ denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
|
|
|
+ } else {
|
|
|
+ denominator = intervalArr.value[min_index] || 1
|
|
|
+ }
|
|
|
+
|
|
|
+ const new_value = (absItem - min_value) / denominator + min_index
|
|
|
|
|
|
return [ts, isPositive ? new_value : -new_value, value]
|
|
|
}
|
|
|
@@ -483,13 +548,15 @@ async function initLoadChartData(real_time: boolean = true) {
|
|
|
|
|
|
lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
|
|
|
|
|
|
+ genderIntervalArr(true)
|
|
|
+
|
|
|
updateSingleSeries(name)
|
|
|
|
|
|
chartLoading.value = false
|
|
|
|
|
|
- if (selectedDimension.value[name]) {
|
|
|
- genderIntervalArr()
|
|
|
- }
|
|
|
+ // if (selectedDimension.value[name]) {
|
|
|
+ // genderIntervalArr()
|
|
|
+ // }
|
|
|
} finally {
|
|
|
item.response = false
|
|
|
}
|
|
|
@@ -541,8 +608,16 @@ function handleClickSpec(modelName: string) {
|
|
|
selected: selectedDimension.value
|
|
|
}
|
|
|
})
|
|
|
- chart?.resize()
|
|
|
+
|
|
|
genderIntervalArr()
|
|
|
+
|
|
|
+ if (selectedDimension.value[modelName]) {
|
|
|
+ updateSingleSeries(modelName)
|
|
|
+ }
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ chart?.resize()
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
const exportChart = () => {
|
|
|
@@ -579,6 +654,7 @@ const maxmin = computed(() => {
|
|
|
.map((v) => ({
|
|
|
name: v.name,
|
|
|
color: v.color,
|
|
|
+ bgHover: v.bgHover,
|
|
|
max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
|
|
|
min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
|
|
|
}))
|
|
|
@@ -596,185 +672,240 @@ onUnmounted(() => {
|
|
|
|
|
|
<template>
|
|
|
<div
|
|
|
- class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-4 shadow"
|
|
|
- id="td-device-info"
|
|
|
+ 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))]"
|
|
|
>
|
|
|
- <h2 class="flex items-center gap-2">
|
|
|
- <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
|
|
|
- </h2>
|
|
|
- <el-form size="default" label-position="top" class="mt-4 grid grid-cols-4 gap-2">
|
|
|
- <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
|
|
|
- <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
|
|
|
- <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
|
|
|
- <el-form-item label="网关状态" class="online" type="plain">
|
|
|
- <el-tag
|
|
|
- v-if="data.ifInline === '3'"
|
|
|
- type="success"
|
|
|
- size="default"
|
|
|
- class="flex items-center"
|
|
|
- >
|
|
|
- <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
|
|
|
- 在线
|
|
|
- </el-tag>
|
|
|
-
|
|
|
- <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
|
|
|
- <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
|
|
|
- 离线
|
|
|
- </el-tag>
|
|
|
- </el-form-item>
|
|
|
- <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
|
|
|
- <el-tag
|
|
|
- v-if="data.carOnline === 'true'"
|
|
|
- type="success"
|
|
|
- size="default"
|
|
|
- class="flex items-center"
|
|
|
- >
|
|
|
- <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
|
|
|
- 在线
|
|
|
- </el-tag>
|
|
|
-
|
|
|
- <el-tag
|
|
|
- v-if="data.carOnline === 'false'"
|
|
|
- type="danger"
|
|
|
- size="default"
|
|
|
- class="flex items-center"
|
|
|
- >
|
|
|
- <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
|
|
|
- 离线
|
|
|
- </el-tag>
|
|
|
- </el-form-item>
|
|
|
- <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
|
|
|
- <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
|
|
|
- </el-form>
|
|
|
- </div>
|
|
|
- <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
|
|
|
- <header class="font-medium text-center w-full">网关数采</header>
|
|
|
<div
|
|
|
- v-loading="dimensionLoading"
|
|
|
- element-loading-background="transparent"
|
|
|
- class="w-full mt-4 grid grid-cols-5 gap-2 min-h-30"
|
|
|
- id="dimension"
|
|
|
+ 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"
|
|
|
>
|
|
|
- <button
|
|
|
- v-for="item in gatewayDimensions"
|
|
|
- :key="item.identifier"
|
|
|
- class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
|
|
|
- :class="{ 'bg-blue-200': selectedDimension[item.name] }"
|
|
|
- :disabled="disabledDimension(item.identifier).disabled"
|
|
|
- @click="handleClickSpec(item.name)"
|
|
|
- >
|
|
|
- <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
|
|
|
- <!-- <i
|
|
|
- v-show="disabledDimension(item.identifier).loading"
|
|
|
- class="i-line-md:loading-loop size-5 absolute -left-6"
|
|
|
- ></i> -->
|
|
|
- {{ item.name }}
|
|
|
- </span>
|
|
|
- <!-- <span class="text-lg font-medium ms-a">{{ item.value }}</span> -->
|
|
|
- <animated-count-to :value="item.value" will-change class="text-lg font-medium ms-a" />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div
|
|
|
- v-if="carDimensions.length"
|
|
|
- class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
|
|
|
- >
|
|
|
- <header class="font-medium text-center w-full">中航北斗</header>
|
|
|
- <div class="w-full mt-4 grid grid-cols-5 gap-2 min-h-30" id="dimension">
|
|
|
- <button
|
|
|
- v-for="item in carDimensions"
|
|
|
- :key="item.identifier"
|
|
|
- class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
|
|
|
- :class="{ 'bg-blue-200': selectedDimension[item.name] }"
|
|
|
- :disabled="disabledDimension(item.identifier).disabled"
|
|
|
- @click="handleClickSpec(item.name)"
|
|
|
- >
|
|
|
- <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
|
|
|
- <!-- <i
|
|
|
- v-show="disabledDimension(item.identifier).loading"
|
|
|
- class="i-line-md:loading-loop size-5"
|
|
|
- ></i> -->
|
|
|
- {{ item.name }}
|
|
|
- </span>
|
|
|
- <span class="text-lg font-medium ms-a">{{ item.value }}</span>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
|
|
|
- <header class="flex items-center justify-between">
|
|
|
- <h3 class="flex items-center gap-2">
|
|
|
- <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
|
|
|
- 数据趋势
|
|
|
- </h3>
|
|
|
- <div class="flex gap-4">
|
|
|
- <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
|
|
|
- <el-button size="default" @click="reset">重置</el-button>
|
|
|
- <el-date-picker
|
|
|
- v-model="selectedDate"
|
|
|
- value-format="YYYY-MM-DD HH:mm:ss"
|
|
|
- type="datetimerange"
|
|
|
- unlink-panels
|
|
|
- start-placeholder="开始日期"
|
|
|
- end-placeholder="结束日期"
|
|
|
- :shortcuts="rangeShortcuts"
|
|
|
- size="default"
|
|
|
- class="w-100!"
|
|
|
- placement="bottom-end"
|
|
|
- @change="handleDateChange"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </header>
|
|
|
- <div class="flex h-160 mt-4">
|
|
|
- <div class="flex gap-1">
|
|
|
- <button
|
|
|
- v-for="item of maxmin"
|
|
|
- :key="item.name"
|
|
|
- class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded bg-transparent border-none"
|
|
|
- @click="handleClickSpec(item.name)"
|
|
|
+ <div class="flex items-center gap-4">
|
|
|
+ <div
|
|
|
+ class="size-12 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center shadow-inner"
|
|
|
>
|
|
|
- <span class="[writing-mode:sideways-lr]">{{ item.max }}</span>
|
|
|
- <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
|
|
|
- <span class="[writing-mode:sideways-lr]">{{ item.name }}</span>
|
|
|
- <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
|
|
|
- <span class="[writing-mode:sideways-lr]">{{ item.min }}</span>
|
|
|
- </button>
|
|
|
+ <el-icon :size="24"><Odometer /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="text-xs text-gray-400 font-medium tracking-wider">资产编码</div>
|
|
|
+ <div class="text-xl font-bold font-mono text-gray-800">{{ data.deviceCode }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="flex-1 flex justify-center divide-x divide-gray-100">
|
|
|
+ <template v-for="item in headerCenterContent" :key="item.key">
|
|
|
+ <div
|
|
|
+ class="px-8 flex flex-col items-center"
|
|
|
+ v-if="item.judgment ? Boolean(query[item.key]) : true"
|
|
|
+ >
|
|
|
+ <span class="text-xs text-gray-400 mb-1">{{ item.label }}</span>
|
|
|
+ <span class="font-semibold text-gray-700">{{ data[item.key] }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
</div>
|
|
|
+ <div class="flex items-center gap-6">
|
|
|
+ <template v-for="item in headerTagContent" :key="item.key">
|
|
|
+ <div class="text-center" v-if="item.judgment ? Boolean(query[item.key]) : true">
|
|
|
+ <div class="text-xs text-gray-400 mb-1">{{ item.label }}</div>
|
|
|
+ <el-tag v-if="data[item.key]" type="success" v-bind="tagProps">
|
|
|
+ <el-icon class="mr-1"><CircleCheckFilled /></el-icon>在线
|
|
|
+ </el-tag>
|
|
|
+ <el-tag v-else type="danger" v-bind="tagProps">
|
|
|
+ <el-icon class="mr-1"><CircleCloseFilled /></el-icon>离线
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-scrollbar
|
|
|
+ class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden"
|
|
|
+ view-class="flex flex-col min-h-full"
|
|
|
+ v-loading="dimensionLoading"
|
|
|
+ >
|
|
|
+ <template v-for="citem in dimensionsContent" :key="citem.label">
|
|
|
+ <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
|
|
|
+ <div
|
|
|
+ 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"
|
|
|
+ >
|
|
|
+ <span class="font-bold text-sm text-gray-700! flex items-center gap-2">
|
|
|
+ <el-icon><component :is="citem.icon" /></el-icon>
|
|
|
+ {{ citem.label }}
|
|
|
+ </span>
|
|
|
+ <span
|
|
|
+ class="text-xs px-2 py-0.5 rounded-full font-mono"
|
|
|
+ :class="[citem.countBg, citem.countColor]"
|
|
|
+ >
|
|
|
+ {{ citem.value.length }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="px-3 pb-4 pt-2 space-y-3">
|
|
|
+ <div
|
|
|
+ v-for="item in citem.value"
|
|
|
+ :key="item.identifier"
|
|
|
+ @click="handleClickSpec(item.name)"
|
|
|
+ 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"
|
|
|
+ :class="{ 'is-active': selectedDimension[item.name] }"
|
|
|
+ :style="{
|
|
|
+ '--theme-color': item.color,
|
|
|
+ '--theme-bg-hover': item.bgHover,
|
|
|
+ '--theme-bg-active': item.bgActive
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ <div class="flex justify-between items-center mb-1">
|
|
|
+ <span
|
|
|
+ class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
|
|
|
+ :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
|
|
|
+ >
|
|
|
+ {{ item.name }}
|
|
|
+ </span>
|
|
|
+ <div
|
|
|
+ class="size-2 rounded-full transition-all duration-300 shadow-sm"
|
|
|
+ :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
|
|
|
+ :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex items-baseline justify-between relative z-10">
|
|
|
+ <animated-count-to
|
|
|
+ :value="Number(item.value)"
|
|
|
+ :duration="500"
|
|
|
+ class="text-lg font-bold font-mono tracking-tight text-slate-800"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
|
|
|
+ :class="
|
|
|
+ selectedDimension[item.name]
|
|
|
+ ? 'opacity-100 shadow-[0_0_8px_currentColor]'
|
|
|
+ : 'opacity-0'
|
|
|
+ "
|
|
|
+ :style="{ backgroundColor: item.color, color: item.color }"
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </el-scrollbar>
|
|
|
+
|
|
|
+ <div
|
|
|
+ class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col"
|
|
|
+ >
|
|
|
+ <header class="flex items-center justify-between mb-4">
|
|
|
+ <h3 class="flex items-center gap-2">
|
|
|
+ <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
|
|
|
+ 数据趋势
|
|
|
+ </h3>
|
|
|
+ <div class="flex gap-4">
|
|
|
+ <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
|
|
|
+ <el-button size="default" @click="reset">重置</el-button>
|
|
|
+ <el-date-picker
|
|
|
+ v-model="selectedDate"
|
|
|
+ value-format="YYYY-MM-DD HH:mm:ss"
|
|
|
+ type="datetimerange"
|
|
|
+ unlink-panels
|
|
|
+ start-placeholder="开始日期"
|
|
|
+ end-placeholder="结束日期"
|
|
|
+ :shortcuts="rangeShortcuts"
|
|
|
+ size="default"
|
|
|
+ class="w-100!"
|
|
|
+ placement="bottom-end"
|
|
|
+ @change="handleDateChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
<div class="flex flex-1">
|
|
|
+ <div class="flex gap-1 select-none">
|
|
|
+ <div
|
|
|
+ v-for="item of maxmin"
|
|
|
+ :key="item.name"
|
|
|
+ :style="{
|
|
|
+ '--theme-bg-hover': item.bgHover
|
|
|
+ }"
|
|
|
+ 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"
|
|
|
+ @click="handleClickSpec(item.name)"
|
|
|
+ >
|
|
|
+ <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
|
|
|
+ <div
|
|
|
+ class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
|
|
|
+ :style="{ backgroundColor: item.color }"
|
|
|
+ ></div>
|
|
|
+ <span
|
|
|
+ class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
|
|
|
+ :style="{ color: item.color }"
|
|
|
+ >
|
|
|
+ {{ item.name }}
|
|
|
+ </span>
|
|
|
+ <div
|
|
|
+ class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
|
|
|
+ :style="{ backgroundColor: item.color }"
|
|
|
+ ></div>
|
|
|
+ <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
<div
|
|
|
- v-loading="chartLoading"
|
|
|
- element-loading-background="transparent"
|
|
|
- ref="chartRef"
|
|
|
- class="flex-1 h-full"
|
|
|
+ class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden"
|
|
|
>
|
|
|
+ <div
|
|
|
+ v-loading="chartLoading"
|
|
|
+ element-loading-background="transparent"
|
|
|
+ ref="chartRef"
|
|
|
+ class="w-full h-full"
|
|
|
+ >
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
-<style lang="scss" scoped>
|
|
|
-:deep(.el-form-item) {
|
|
|
- margin-bottom: 0;
|
|
|
+<style scoped>
|
|
|
+/* Icon Fix */
|
|
|
+:deep(.el-tag__content) {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+}
|
|
|
|
|
|
- .el-form-item__label {
|
|
|
- margin-bottom: 0;
|
|
|
- }
|
|
|
+/* Sticky Header */
|
|
|
+.sticky-title {
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+}
|
|
|
|
|
|
- .el-form-item__content {
|
|
|
- font-size: 1rem;
|
|
|
- font-weight: 500;
|
|
|
- }
|
|
|
+/*
|
|
|
+ 核心样式:霓虹卡片效果
|
|
|
+ 使用 CSS 变量实现动态颜色
|
|
|
+*/
|
|
|
|
|
|
- &.online {
|
|
|
- .el-form-item__content {
|
|
|
- height: 2.5rem;
|
|
|
+/* Hover 状态:背景微亮,边框变色 */
|
|
|
+.dimension-card:hover {
|
|
|
+ background-color: var(--theme-bg-hover);
|
|
|
+ border-color: var(--theme-bg-active);
|
|
|
+ box-shadow: 0 4px 12px -2px rgb(0 0 0 / 5%);
|
|
|
+}
|
|
|
|
|
|
- .el-tag__content {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 2px;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+/* Active 状态:背景更亮,边框为主题色,带轻微发光投影 */
|
|
|
+.dimension-card.is-active {
|
|
|
+ background-color: var(--theme-bg-active);
|
|
|
+ border-color: var(--theme-color);
|
|
|
+ box-shadow:
|
|
|
+ 0 0 0 1px var(--theme-bg-active),
|
|
|
+ 0 4px 12px -2px var(--theme-bg-active);
|
|
|
+}
|
|
|
+
|
|
|
+/* 滚动条美化 */
|
|
|
+:deep(.el-scrollbar__bar.is-vertical) {
|
|
|
+ right: 2px;
|
|
|
+ width: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-scrollbar__thumb) {
|
|
|
+ background-color: #cbd5e1;
|
|
|
+ opacity: 0.6;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-scrollbar__thumb:hover) {
|
|
|
+ background-color: #94a3b8;
|
|
|
+ opacity: 1;
|
|
|
}
|
|
|
</style>
|