|
|
@@ -0,0 +1,780 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
|
|
+import { IotDeviceApi } from '@/api/pms/device'
|
|
|
+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 { useSocketBus } from '@/utils/useSocketBus'
|
|
|
+
|
|
|
+const { query } = useRoute()
|
|
|
+
|
|
|
+const data = ref({
|
|
|
+ deviceCode: query.code || '',
|
|
|
+ deviceName: query.name || '',
|
|
|
+ lastInlineTime: query.time || '',
|
|
|
+ ifInline: query.ifInline || '',
|
|
|
+ dept: query.dept || '',
|
|
|
+ vehicle: query.vehicle || '',
|
|
|
+ carOnline: query.carOnline || ''
|
|
|
+})
|
|
|
+
|
|
|
+const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
|
|
|
+
|
|
|
+onAny((msg) => {
|
|
|
+ if (!Array.isArray(msg) || msg.length === 0) return
|
|
|
+
|
|
|
+ const valueMap = new Map<string, number>()
|
|
|
+
|
|
|
+ // 1️⃣ 一次遍历:建 Map + 推图表数据
|
|
|
+ for (const item of msg) {
|
|
|
+ const { identity, modelName, readTime, logValue } = item
|
|
|
+
|
|
|
+ const value = logValue ? Number(logValue) : 0
|
|
|
+
|
|
|
+ if (identity) {
|
|
|
+ valueMap.set(identity, value)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (modelName && chartData.value[modelName]) {
|
|
|
+ chartData.value[modelName].push({
|
|
|
+ ts: dayjs(readTime).valueOf(),
|
|
|
+ value
|
|
|
+ })
|
|
|
+
|
|
|
+ updateSingleSeries(modelName)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2️⃣ 批量更新 dimensions
|
|
|
+ const updateDimensions = (list) => {
|
|
|
+ list.forEach((item) => {
|
|
|
+ const v = valueMap.get(item.identifier)
|
|
|
+ if (v !== undefined) {
|
|
|
+ item.value = v
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ updateDimensions(dimensions.value)
|
|
|
+ updateDimensions(gatewayDimensions.value)
|
|
|
+ updateDimensions(carDimensions.value)
|
|
|
+
|
|
|
+ // 3️⃣ 统一一次调用
|
|
|
+ genderIntervalArr()
|
|
|
+})
|
|
|
+
|
|
|
+interface Dimensions {
|
|
|
+ identifier: string
|
|
|
+ name: string
|
|
|
+ value: string
|
|
|
+ color?: string
|
|
|
+ response?: boolean
|
|
|
+}
|
|
|
+
|
|
|
+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 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
|
|
|
+
|
|
|
+ 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
|
|
|
+
|
|
|
+ dimensionLoading.value = false
|
|
|
+}
|
|
|
+
|
|
|
+// async function updateDimensionValues() {
|
|
|
+// if (!query.id) return
|
|
|
+
|
|
|
+// try {
|
|
|
+// // 1. 并行获取最新数据
|
|
|
+// const [gatewayRes, carRes] = await Promise.all([
|
|
|
+// IotDeviceApi.getIotDeviceTds(Number(query.id)),
|
|
|
+// IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
|
|
|
+// ])
|
|
|
+
|
|
|
+// // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
|
|
|
+// // 这样可以将复杂度从 O(N*M) 降低到 O(N)
|
|
|
+// const newValueMap = new Map<string, any>()
|
|
|
+
|
|
|
+// const addToMap = (data: any[]) => {
|
|
|
+// if (!data) return
|
|
|
+// data.forEach((item) => {
|
|
|
+// if (item.identifier) {
|
|
|
+// newValueMap.set(item.identifier, item.value)
|
|
|
+// }
|
|
|
+// })
|
|
|
+// }
|
|
|
+
|
|
|
+// addToMap(gatewayRes as any[])
|
|
|
+// addToMap(carRes as any[])
|
|
|
+
|
|
|
+// // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
|
|
|
+// dimensions.value.forEach((item) => {
|
|
|
+// if (newValueMap.has(item.identifier)) {
|
|
|
+// item.value = newValueMap.get(item.identifier)
|
|
|
+// }
|
|
|
+// })
|
|
|
+
|
|
|
+// // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
|
|
|
+// // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
|
|
|
+// // 如果它们是独立的对象数组,则需要显式更新)
|
|
|
+
|
|
|
+// // 更新 Gateway 原始列表
|
|
|
+// gatewayDimensions.value.forEach((item) => {
|
|
|
+// if (newValueMap.has(item.identifier)) {
|
|
|
+// item.value = newValueMap.get(item.identifier)
|
|
|
+// }
|
|
|
+// })
|
|
|
+
|
|
|
+// // 更新 Car 原始列表
|
|
|
+// carDimensions.value.forEach((item) => {
|
|
|
+// if (newValueMap.has(item.identifier)) {
|
|
|
+// item.value = newValueMap.get(item.identifier)
|
|
|
+// }
|
|
|
+// })
|
|
|
+// } catch (error) {
|
|
|
+// console.error('Failed to update dimension values:', error)
|
|
|
+// }
|
|
|
+// }
|
|
|
+
|
|
|
+const selectedDate = ref<string[]>([
|
|
|
+ dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
|
|
|
+ dayjs().format('YYYY-MM-DD HH:mm:ss')
|
|
|
+])
|
|
|
+
|
|
|
+interface ChartData {
|
|
|
+ [key: Dimensions['name']]: { ts: number; value: number }[]
|
|
|
+}
|
|
|
+
|
|
|
+const chartData = ref<ChartData>({})
|
|
|
+
|
|
|
+let intervalArr = ref<number[]>([])
|
|
|
+let maxInterval = ref(0)
|
|
|
+let minInterval = ref(0)
|
|
|
+
|
|
|
+const chartRef = ref<HTMLDivElement | null>(null)
|
|
|
+let chart: echarts.ECharts | null = null
|
|
|
+
|
|
|
+// const genderIntervalArrDebounce = useDebounceFn(
|
|
|
+// (init: boolean = false) => genderIntervalArr(init),
|
|
|
+// 300
|
|
|
+// )
|
|
|
+
|
|
|
+function genderIntervalArr(init: boolean = false) {
|
|
|
+ const values: number[] = []
|
|
|
+
|
|
|
+ for (const [key, value] of Object.entries(selectedDimension.value)) {
|
|
|
+ if (value) {
|
|
|
+ values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const maxVal = values.length === 0 ? 10000 : Math.max(...values)
|
|
|
+ const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
|
|
|
+
|
|
|
+ const maxDigits = (Math.floor(maxVal) + '').length
|
|
|
+ const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
|
|
|
+
|
|
|
+ const interval = Math.max(maxDigits, minDigits)
|
|
|
+
|
|
|
+ maxInterval.value = interval
|
|
|
+ minInterval.value = minDigits
|
|
|
+
|
|
|
+ intervalArr.value = [0]
|
|
|
+ for (let i = 1; i <= interval; i++) {
|
|
|
+ intervalArr.value.push(Math.pow(10, i))
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!init) {
|
|
|
+ chart?.setOption({
|
|
|
+ yAxis: {
|
|
|
+ min: -minInterval.value,
|
|
|
+ max: maxInterval.value
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function chartInit() {
|
|
|
+ if (!chart) return
|
|
|
+
|
|
|
+ chart.on('legendselectchanged', (params: any) => {
|
|
|
+ selectedDimension.value = params.selected
|
|
|
+ })
|
|
|
+
|
|
|
+ window.addEventListener('resize', () => {
|
|
|
+ if (chart) chart.resize()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function render() {
|
|
|
+ if (!chartRef.value) return
|
|
|
+
|
|
|
+ if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
|
|
|
+
|
|
|
+ chartInit()
|
|
|
+
|
|
|
+ genderIntervalArr(true)
|
|
|
+
|
|
|
+ chart.setOption({
|
|
|
+ animation: true,
|
|
|
+ animationDuration: 200,
|
|
|
+ animationEasing: 'linear',
|
|
|
+ animationDurationUpdate: 200,
|
|
|
+ animationEasingUpdate: 'linear',
|
|
|
+ grid: {
|
|
|
+ left: '6%',
|
|
|
+ top: '5%',
|
|
|
+ right: '6%',
|
|
|
+ bottom: '12%'
|
|
|
+ },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: {
|
|
|
+ type: 'line'
|
|
|
+ },
|
|
|
+ formatter: (params) => {
|
|
|
+ let d = `${params[0].axisValueLabel}<br>`
|
|
|
+ const exist: string[] = []
|
|
|
+ params = params.filter((el) => {
|
|
|
+ if (exist.includes(el.seriesName)) return false
|
|
|
+ exist.push(el.seriesName)
|
|
|
+ return true
|
|
|
+ })
|
|
|
+ let item = params.map(
|
|
|
+ (el) => `<div class="flex items-center justify-between mt-1">
|
|
|
+ <span>${el.marker} ${el.seriesName}</span>
|
|
|
+ <span>${el.value[2]?.toFixed(2)}</span>
|
|
|
+ </div>`
|
|
|
+ )
|
|
|
+
|
|
|
+ return d + item.join('')
|
|
|
+ }
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'time',
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
|
|
|
+ rotate: 0,
|
|
|
+ align: 'left'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ dataZoom: [
|
|
|
+ { type: 'inside', xAxisIndex: 0 },
|
|
|
+ { type: 'slider', xAxisIndex: 0 }
|
|
|
+ ],
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ min: -minInterval.value,
|
|
|
+ max: maxInterval.value,
|
|
|
+ interval: 1,
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (v) => {
|
|
|
+ const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
|
|
|
+
|
|
|
+ return num.toLocaleString()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ show: false
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ data: dimensions.value.map((item) => item.name),
|
|
|
+ selected: selectedDimension.value,
|
|
|
+ show: false
|
|
|
+ },
|
|
|
+ // series: dimensions.value.map((item) => ({
|
|
|
+ // name: item.name,
|
|
|
+ // type: 'line',
|
|
|
+ // smooth: true,
|
|
|
+ // showSymbol: false,
|
|
|
+ // color: item.color,
|
|
|
+ // data: [] // 占位数组
|
|
|
+ // }))
|
|
|
+ series: dimensions.value.map((item) => ({
|
|
|
+ name: item.name,
|
|
|
+ type: 'line',
|
|
|
+
|
|
|
+ smooth: 0.2,
|
|
|
+
|
|
|
+ showSymbol: false,
|
|
|
+
|
|
|
+ endLabel: {
|
|
|
+ show: true,
|
|
|
+ formatter: (params) => params.value[2]?.toFixed(2),
|
|
|
+ offset: [6, 0],
|
|
|
+ color: item.color,
|
|
|
+ fontSize: 12
|
|
|
+ },
|
|
|
+
|
|
|
+ emphasis: {
|
|
|
+ focus: 'series'
|
|
|
+ },
|
|
|
+
|
|
|
+ lineStyle: {
|
|
|
+ width: 2
|
|
|
+ },
|
|
|
+
|
|
|
+ color: item.color,
|
|
|
+ data: [] // 占位数组
|
|
|
+ }))
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function mapData({ value, ts }) {
|
|
|
+ if (!value) return [ts, 0, 0]
|
|
|
+
|
|
|
+ const isPositive = value > 0
|
|
|
+ const absItem = Math.abs(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
|
|
|
+
|
|
|
+ return [ts, isPositive ? new_value : -new_value, value]
|
|
|
+}
|
|
|
+
|
|
|
+function updateSingleSeries(name: string) {
|
|
|
+ if (!chart) render()
|
|
|
+ if (!chart) return
|
|
|
+
|
|
|
+ const idx = dimensions.value.findIndex((item) => item.name === name)
|
|
|
+ if (idx === -1) return
|
|
|
+
|
|
|
+ const data = chartData.value[name].map((v) => mapData(v))
|
|
|
+
|
|
|
+ chart.setOption({
|
|
|
+ series: [{ name, data }]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
|
|
|
+
|
|
|
+// async function fetchIncrementData() {
|
|
|
+// for (const item of dimensions.value) {
|
|
|
+// const { identifier, name } = item
|
|
|
+
|
|
|
+// const lastTs = lastTsMap.value[name]
|
|
|
+// if (!lastTs) continue
|
|
|
+
|
|
|
+// item.response = true
|
|
|
+
|
|
|
+// IotStatApi.getDeviceInfoChart(
|
|
|
+// data.value.deviceCode,
|
|
|
+// identifier,
|
|
|
+// dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
|
|
|
+// dayjs().format('YYYY-MM-DD HH:mm:ss')
|
|
|
+// )
|
|
|
+// .then((res) => {
|
|
|
+// if (!res.length) return
|
|
|
+
|
|
|
+// const sorted = res
|
|
|
+// .sort((a, b) => a.ts - b.ts)
|
|
|
+// .map((item) => ({ ts: item.ts, value: item.value }))
|
|
|
+// // push 到本地
|
|
|
+// chartData.value[name].push(...sorted)
|
|
|
+// // 更新 lastTs
|
|
|
+// lastTsMap.value[identifier] = sorted.at(-1).ts
|
|
|
+
|
|
|
+// // 更新图表
|
|
|
+// updateSingleSeries(name)
|
|
|
+// })
|
|
|
+// .finally(() => {
|
|
|
+// item.response = false
|
|
|
+// })
|
|
|
+// }
|
|
|
+// }
|
|
|
+
|
|
|
+// const timer = ref<NodeJS.Timeout | null>(null)
|
|
|
+
|
|
|
+// function startAutoFetch() {
|
|
|
+// timer.value = setInterval(() => {
|
|
|
+// updateDimensionValues()
|
|
|
+// fetchIncrementData()
|
|
|
+// }, 10000)
|
|
|
+// }
|
|
|
+
|
|
|
+// function stopAutoFetch() {
|
|
|
+// cancelAllRequests()
|
|
|
+// if (timer.value) clearInterval(timer.value)
|
|
|
+// timer.value = null
|
|
|
+// }
|
|
|
+
|
|
|
+const chartLoading = ref(false)
|
|
|
+
|
|
|
+async function initLoadChartData(real_time: boolean = true) {
|
|
|
+ if (!dimensions.value.length) return
|
|
|
+
|
|
|
+ chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
|
|
|
+
|
|
|
+ chartLoading.value = true
|
|
|
+
|
|
|
+ dimensions.value = dimensions.value.map((item) => {
|
|
|
+ item.response = true
|
|
|
+ return item
|
|
|
+ })
|
|
|
+
|
|
|
+ for (const item of dimensions.value) {
|
|
|
+ const { identifier, name } = item
|
|
|
+ try {
|
|
|
+ const res = await IotStatApi.getDeviceInfoChart(
|
|
|
+ data.value.deviceCode,
|
|
|
+ identifier,
|
|
|
+ selectedDate.value[0],
|
|
|
+ selectedDate.value[1]
|
|
|
+ )
|
|
|
+
|
|
|
+ const sorted = res
|
|
|
+ .sort((a, b) => a.ts - b.ts)
|
|
|
+ .map((item) => ({ ts: item.ts, value: item.value }))
|
|
|
+
|
|
|
+ chartData.value[name] = sorted
|
|
|
+
|
|
|
+ lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
|
|
|
+
|
|
|
+ updateSingleSeries(name)
|
|
|
+
|
|
|
+ chartLoading.value = false
|
|
|
+
|
|
|
+ if (selectedDimension.value[name]) {
|
|
|
+ genderIntervalArr()
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ item.response = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (real_time) {
|
|
|
+ // startAutoFetch()
|
|
|
+ connect()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function initfn(load: boolean = true, real_time: boolean = true) {
|
|
|
+ if (load) await loadDimensions()
|
|
|
+ render()
|
|
|
+ initLoadChartData(real_time)
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ initfn()
|
|
|
+})
|
|
|
+
|
|
|
+function reset() {
|
|
|
+ cancelAllRequests().then(() => {
|
|
|
+ selectedDate.value = [
|
|
|
+ dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
|
|
|
+ dayjs().format('YYYY-MM-DD HH:mm:ss')
|
|
|
+ ]
|
|
|
+
|
|
|
+ close()
|
|
|
+ // stopAutoFetch()
|
|
|
+ if (chart) chart.clear()
|
|
|
+ initfn(false)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function handleDateChange() {
|
|
|
+ cancelAllRequests().then(() => {
|
|
|
+ close()
|
|
|
+ // stopAutoFetch()
|
|
|
+ if (chart) chart.clear()
|
|
|
+ initfn(false, false)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function handleClickSpec(modelName: string) {
|
|
|
+ selectedDimension.value[modelName] = !selectedDimension.value[modelName]
|
|
|
+ chart?.setOption({
|
|
|
+ legend: {
|
|
|
+ selected: selectedDimension.value
|
|
|
+ }
|
|
|
+ })
|
|
|
+ chart?.resize()
|
|
|
+ genderIntervalArr()
|
|
|
+}
|
|
|
+
|
|
|
+const exportChart = () => {
|
|
|
+ if (!chart) return
|
|
|
+ let img = new Image()
|
|
|
+ img.src = chart.getDataURL({
|
|
|
+ type: 'png',
|
|
|
+ pixelRatio: 1,
|
|
|
+ backgroundColor: '#fff'
|
|
|
+ })
|
|
|
+
|
|
|
+ img.onload = function () {
|
|
|
+ let canvas = document.createElement('canvas')
|
|
|
+ canvas.width = img.width
|
|
|
+ canvas.height = img.height
|
|
|
+ let ctx = canvas.getContext('2d')
|
|
|
+ ctx?.drawImage(img, 0, 0)
|
|
|
+ let dataURL = canvas.toDataURL('image/png')
|
|
|
+
|
|
|
+ let a = document.createElement('a')
|
|
|
+
|
|
|
+ let event = new MouseEvent('click')
|
|
|
+
|
|
|
+ a.href = dataURL
|
|
|
+ a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
|
|
|
+ a.dispatchEvent(event)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const maxmin = computed(() => {
|
|
|
+ if (!dimensions.value.length) return []
|
|
|
+ return dimensions.value
|
|
|
+ .filter((v) => selectedDimension.value[v.name])
|
|
|
+ .map((v) => ({
|
|
|
+ name: v.name,
|
|
|
+ color: v.color,
|
|
|
+ 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)
|
|
|
+ }))
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ // stopAutoFetch()
|
|
|
+ close()
|
|
|
+
|
|
|
+ window.removeEventListener('resize', () => {
|
|
|
+ if (chart) chart.resize()
|
|
|
+ })
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
|
|
|
+ id="td-device-info"
|
|
|
+ >
|
|
|
+ <h2 class="flex items-center gap-2">
|
|
|
+ <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
|
|
|
+ </h2>
|
|
|
+ <el-form size="large" label-position="top" class="mt-4 grid grid-cols-3 gap-4">
|
|
|
+ <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-4 gap-4 min-h-30"
|
|
|
+ id="dimension"
|
|
|
+ >
|
|
|
+ <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-4 gap-4" 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)"
|
|
|
+ >
|
|
|
+ <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>
|
|
|
+ </div>
|
|
|
+ <div class="flex flex-1">
|
|
|
+ <div
|
|
|
+ v-loading="chartLoading"
|
|
|
+ element-loading-background="transparent"
|
|
|
+ ref="chartRef"
|
|
|
+ class="flex-1 h-full"
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+:deep(.el-form-item) {
|
|
|
+ margin-bottom: 0;
|
|
|
+
|
|
|
+ .el-form-item__label {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-form-item__content {
|
|
|
+ font-size: 1rem;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.online {
|
|
|
+ .el-form-item__content {
|
|
|
+ height: 2.5rem;
|
|
|
+
|
|
|
+ .el-tag__content {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|