You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

465 lines
14 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<el-row :gutter="20">
<el-col :span="6" :xs="24">
<ContentWrap class="h-1/1">
<el-input v-model="keyword" clearable placeholder="搜索设备或参数" class="!w-1/1" @input="handleKeywordChange" />
<div class="mt-12px">
<el-tree ref="treeRef" v-loading="treeLoading" :data="treeData" :props="treeProps" node-key="id"
highlight-current @node-click="handleTreeNodeClick" />
</div>
</ContentWrap>
</el-col>
<el-col :span="18" :xs="24">
<ContentWrap>
<el-form class="-mb-15px" :inline="true">
<el-form-item label="时间">
<el-date-picker v-model="dateRange" type="daterange" start-placeholder="开始日期" end-placeholder="结束日期"
value-format="YYYY-MM-DD" :shortcuts="dateShortcuts"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-360px" />
</el-form-item>
<el-form-item>
<el-button :disabled="!selectedParam" @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button :disabled="!selectedParam" @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-alert v-if="selectedParam" :title="selectedParamTitle" type="info" :closable="false" class="mb-12px" />
<div v-loading="chartLoading">
<el-empty v-if="chartState === 'empty'" description="暂无数据" />
<el-empty v-else-if="!selectedParam" description="请选择左侧节点" />
<EChart v-else-if="chartState === 'ready'" :key="chartRenderKey" :options="chartOption" height="70vh" />
</div>
</ContentWrap>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import dayjs from 'dayjs'
import { Echart as EChart } from '@/components/Echart'
import { OrganizationApi } from '@/api/mes/organization'
import { DeviceModelAttributeApi } from '@/api/iot/devicemodelattribute'
import { handleTree } from '@/utils/tree'
defineOptions({ name: 'DeviceParamAnalysis' })
type TreeNodeType = 'device' | 'param'
type DeviceTreeNode = {
id: string
label: string
type: TreeNodeType
children?: DeviceTreeNode[]
deviceId?: number
modelId?: number
paramKey?: string
unit?: string
paramCount?: number
}
type ApiTreeParameter = {
id: number
name: string
code?: string
unit?: string | null
}
type ApiTreeEquipment = {
id: number
name: string
parameters?: ApiTreeParameter[]
}
type ApiTreeOrg = {
id: number
name: string
orgClass?: string
parentId?: number | null
equipments?: ApiTreeEquipment[]
children?: ApiTreeOrg[]
}
const message = useMessage()
const treeRef = ref()
const treeLoading = ref(false)
const keyword = ref('')
const treeProps = { children: 'children', label: 'label' }
const treeData = ref<DeviceTreeNode[]>([])
const selectedDeviceId = ref<number | undefined>(undefined)
const selectedModelId = ref<number | undefined>(undefined)
const selectedParam = ref<DeviceTreeNode | null>(null)
const chartLoading = ref(false)
const chartState = ref<'idle' | 'loading' | 'empty' | 'ready'>('idle')
const chartXAxis = ref<string[]>([])
const chartSeries = ref<{ name: string; data: Array<number | null> }[]>([])
const chartRenderKey = ref(0)
const buildDefaultDateRange = (): [string, string] => {
const end = dayjs().endOf('day')
const start = end.subtract(6, 'day').startOf('day')
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]
}
const dateRange = ref<[string, string]>(buildDefaultDateRange())
const dateShortcuts = [
{
text: '最近 7 天',
value: () => {
const end = dayjs().endOf('day').toDate()
const start = dayjs().subtract(6, 'day').startOf('day').toDate()
return [start, end]
}
},
{
text: '上周',
value: () => {
const start = dayjs().subtract(1, 'week').startOf('week').toDate()
const end = dayjs().subtract(1, 'week').endOf('week').toDate()
return [start, end]
}
},
{
text: '上个月',
value: () => {
const start = dayjs().subtract(1, 'month').startOf('month').toDate()
const end = dayjs().subtract(1, 'month').endOf('month').toDate()
return [start, end]
}
},
{
text: '三个月内',
value: () => {
const end = dayjs().endOf('day').toDate()
const start = dayjs().subtract(3, 'month').startOf('day').toDate()
return [start, end]
}
}
]
const selectedParamTitle = computed(() => {
const param = selectedParam.value
if (!param) return ''
const unitText = param.unit ? `${param.unit}` : ''
return `节点:${param.label}${unitText}`
})
const chartOption = computed<EChartsOption>(() => {
const unit = selectedParam.value?.unit
return {
tooltip: { trigger: 'axis' },
legend: { top: 10, left: 'center', type: 'scroll' },
grid: { left: 50, right: 60, top: 80, bottom: 40, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: chartXAxis.value
},
yAxis: {
type: 'value',
name: unit || ''
},
series: chartSeries.value.map((s) => ({
type: 'line',
name: s.name,
smooth: false,
showSymbol: false,
data: s.data
}))
}
})
let keywordTimer: number | undefined
const handleKeywordChange = () => {
if (keywordTimer) window.clearTimeout(keywordTimer)
keywordTimer = window.setTimeout(() => {
keywordTimer = undefined
loadTree()
}, 300)
}
const toFiniteId = (value: any): number | undefined => {
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
if (typeof value === 'string') {
if (/^\d+$/.test(value.trim())) return Number(value)
const n = Number(value)
return Number.isFinite(n) ? n : undefined
}
return undefined
}
const buildTreeFromApi = (orgs: ApiTreeOrg[]): DeviceTreeNode[] => {
const normalizeOrg = (org: any): ApiTreeOrg => {
const children = Array.isArray(org?.children) ? org.children.map(normalizeOrg) : undefined
const equipments = Array.isArray(org?.equipments) ? org.equipments : undefined
const parentId = typeof org?.parentId === 'number' ? org.parentId : Number(org?.parentId ?? 0) || 0
return {
id: Number(org?.id ?? 0) || 0,
name: String(org?.name ?? ''),
orgClass: org?.orgClass ? String(org.orgClass) : undefined,
parentId,
equipments,
children
}
}
const normalizedOrgs: ApiTreeOrg[] = Array.isArray(orgs) ? orgs.map(normalizeOrg) : []
const isNestedTree = normalizedOrgs.some((o) => typeof (o as any)?.children !== 'undefined')
const orgTree = isNestedTree ? normalizedOrgs : (handleTree(normalizedOrgs, 'id', 'parentId', 'children') as ApiTreeOrg[])
const toOrgNode = (org: ApiTreeOrg): DeviceTreeNode => {
const orgChildren = Array.isArray(org?.children) ? org.children.map(toOrgNode) : []
const equipments = Array.isArray(org?.equipments) ? org.equipments : []
const equipmentNodes: DeviceTreeNode[] = equipments.map((eq) => {
const params = Array.isArray(eq?.parameters) ? eq.parameters : []
const paramNodes: DeviceTreeNode[] = params.map((p) => ({
id: `param-${eq.id}-${p.id}`,
label: p?.name ?? p?.code ?? String(p?.id ?? ''),
type: 'param',
deviceId: toFiniteId(eq.id),
modelId: toFiniteId(p.id),
paramKey: p?.code ? String(p.code) : undefined,
unit: p?.unit ? String(p.unit) : undefined
}))
return {
id: `equipment-${org.id}-${eq.id}`,
label: eq?.name ?? String(eq?.id ?? ''),
type: 'device',
deviceId: toFiniteId(eq.id),
paramCount: params.length,
children: paramNodes.length ? paramNodes : undefined
}
})
const children = [...orgChildren, ...equipmentNodes]
return {
id: `org-${org.id}`,
label: org?.name ?? String(org?.id ?? ''),
type: 'device',
children: children.length ? children : undefined
}
}
return Array.isArray(orgTree) ? orgTree.map(toOrgNode) : []
}
const extractApiOrgs = (res: any): ApiTreeOrg[] => {
if (Array.isArray(res)) return res as ApiTreeOrg[]
if (Array.isArray(res?.data)) return res.data as ApiTreeOrg[]
if (Array.isArray(res?.result)) return res.result as ApiTreeOrg[]
if (Array.isArray(res?.list)) return res.list as ApiTreeOrg[]
return []
}
const loadTree = async () => {
treeLoading.value = true
try {
const res = await OrganizationApi.deviceParameterAnalysis({ keyword: keyword.value || undefined, showDevices: 1 })
treeData.value = buildTreeFromApi(extractApiOrgs(res))
if (keyword.value) {
treeRef.value?.setCurrentKey?.(undefined)
selectedParam.value = null
selectedDeviceId.value = undefined
selectedModelId.value = undefined
chartState.value = 'idle'
chartXAxis.value = []
chartSeries.value = []
}
} catch {
message.error('获取树数据失败')
} finally {
treeLoading.value = false
}
}
const ensureDateRange = () => {
if (!dateRange.value || dateRange.value.length !== 2) {
dateRange.value = buildDefaultDateRange()
}
}
const resetChartData = () => {
chartXAxis.value = []
chartSeries.value = []
chartRenderKey.value++
}
const toNumber = (value: any) => {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
if (Array.isArray(value)) return 0
if (value && typeof value === 'object') {
const maybe = value.value ?? value.val ?? value.count ?? value.amount
const n = Number(maybe)
return Number.isFinite(n) ? n : 0
}
return 0
}
const fetchChart = async () => {
const param = selectedParam.value
if (typeof selectedDeviceId.value !== 'number' || !Number.isFinite(selectedDeviceId.value)) return
ensureDateRange()
const [start, end] = dateRange.value
if (!start || !end) return
const buildDateTime = (date: string, suffix: string) => {
return date.includes(':') ? date : `${date} ${suffix}`
}
chartState.value = 'loading'
chartLoading.value = true
resetChartData()
try {
const req: Record<string, any> = {
deviceId: selectedDeviceId.value,
collectionStartTime: buildDateTime(start, '00:00:00'),
collectionEndTime: buildDateTime(end, '23:59:59')
}
if (typeof selectedModelId.value === 'number' && Number.isFinite(selectedModelId.value)) {
req.modelId = selectedModelId.value
}
const data = await DeviceModelAttributeApi.operationAnalysisDetails(req as any)
const rows: Record<string, any>[] = Array.isArray(data) ? data : []
if (!rows.length) {
chartXAxis.value = []
chartSeries.value = []
chartState.value = 'empty'
message.warning('该节点下暂无参数')
return
}
const firstRow = rows[0] || {}
const keys = Object.keys(firstRow)
const xKey = keys.find((k) => /time|date|时间|日期/i.test(k))
if (xKey) {
const sortedRows = [...rows].sort((a, b) =>
String(a?.[xKey] ?? '').localeCompare(String(b?.[xKey] ?? ''))
)
chartXAxis.value = sortedRows.map((r) => String(r?.[xKey] ?? ''))
const seriesKeys = keys.filter((k) => k !== xKey)
const seriesMap = new Map<string, Array<number | null>>()
const ensureSeries = (name: string) => {
if (!seriesMap.has(name)) {
seriesMap.set(name, Array(sortedRows.length).fill(null))
}
return seriesMap.get(name)!
}
sortedRows.forEach((row, rowIndex) => {
seriesKeys.forEach((k) => {
const v = row?.[k]
if (Array.isArray(v)) {
v.forEach((item) => {
const name = String(item?.attributeName ?? k)
const series = ensureSeries(name)
series[rowIndex] = toNumber(item?.addressValue ?? item?.value ?? item?.val)
})
return
}
const series = ensureSeries(k)
series[rowIndex] = toNumber(v)
})
})
chartSeries.value = Array.from(seriesMap.entries()).map(([name, data]) => ({ name, data }))
} else {
chartXAxis.value = keys
const values = keys.map((k) => toNumber(firstRow[k]))
chartSeries.value = [{ name: param?.label || '参数', data: values }]
}
chartState.value = chartXAxis.value.length && chartSeries.value.length ? 'ready' : 'empty'
} catch {
message.error('获取图表数据失败')
chartState.value = 'idle'
} finally {
chartLoading.value = false
}
}
const handleTreeNodeClick = async (data: DeviceTreeNode) => {
if (keywordTimer) {
window.clearTimeout(keywordTimer)
keywordTimer = undefined
}
const hasChildren = Array.isArray(data?.children) && data.children.length > 0
if (hasChildren) {
return
}
const isEmptyOrgNode = typeof data?.id === 'string' && data.id.startsWith('org-')
const isEquipmentNode = typeof data?.id === 'string' && data.id.startsWith('equipment-')
if (isEquipmentNode && (data.paramCount ?? 0) <= 0 || isEmptyOrgNode) {
selectedParam.value = data
selectedDeviceId.value = undefined
selectedModelId.value = undefined
chartState.value = 'empty'
resetChartData()
message.warning('该设备下没有参数')
return
}
const toNodeIds = (node: DeviceTreeNode): { deviceId?: number; modelId?: number } => {
const modelId = typeof node.modelId === 'number' && Number.isFinite(node.modelId) ? node.modelId : undefined
const deviceId = typeof node.deviceId === 'number' && Number.isFinite(node.deviceId) ? node.deviceId : undefined
if (typeof deviceId === 'number') return { deviceId, modelId }
const parts = String(node.id ?? '').split('-').filter(Boolean)
const last = parts.length ? parts[parts.length - 1] : undefined
const parsed = toFiniteId(last)
if (typeof parsed !== 'number' || !Number.isFinite(parsed) || parsed <= 0) {
return { modelId }
}
return { deviceId: parsed, modelId }
}
const { deviceId, modelId } = toNodeIds(data)
selectedParam.value = data
selectedDeviceId.value = deviceId
selectedModelId.value = modelId
dateRange.value = buildDefaultDateRange()
await fetchChart()
}
const handleQuery = async () => {
await fetchChart()
}
const resetQuery = async () => {
dateRange.value = buildDefaultDateRange()
await fetchChart()
}
onMounted(async () => {
await loadTree()
})
</script>
<style scoped lang="scss">
:deep(.el-tree) {
max-height: calc(100vh - 280px);
overflow: auto;
}
</style>