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.
509 lines
16 KiB
Vue
509 lines
16 KiB
Vue
<template>
|
|
<div
|
|
ref="fullscreenTargetRef"
|
|
class="run-overview-page"
|
|
:class="{ 'is-fullscreen': isFullscreenLayout }"
|
|
>
|
|
<div class="run-overview-page__floating-tools">
|
|
<el-tooltip
|
|
:content="
|
|
isFullscreen
|
|
? t('DataCollection.RunOverview.exitFullscreen')
|
|
: t('DataCollection.RunOverview.enterFullscreen')
|
|
"
|
|
>
|
|
<button class="run-overview-page__screenfull" type="button" @click="toggleFullscreen">
|
|
<Icon :icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'" color="#344054" />
|
|
</button>
|
|
</el-tooltip>
|
|
</div>
|
|
|
|
<OverviewFilterBar
|
|
v-model="queryParams"
|
|
:group-options="groupOptions"
|
|
:device-options="deviceOptions"
|
|
:quick-ranges="quickRanges"
|
|
@quick-range-change="handleQuickRangeChange"
|
|
@query="handleQuery"
|
|
@reset="resetQuery"
|
|
/>
|
|
|
|
<OverviewMetricCards :metrics="overviewData.metrics" />
|
|
|
|
<StatusDistributionChart
|
|
:hourly-status="overviewData.hourlyStatus"
|
|
:summary="overviewData.summary"
|
|
:summary-total-hours="overviewData.summaryTotalHours"
|
|
:statistics-start="queryParams.timeRange[0]"
|
|
:statistics-end="queryParams.timeRange[1]"
|
|
/>
|
|
|
|
<OperationTimelineChart
|
|
:rows="overviewData.timelineRows"
|
|
:total="overviewData.totalDevices"
|
|
:page-no="pageNo"
|
|
:page-size="pageSize"
|
|
:statistics-start="queryParams.timeRange[0]"
|
|
:statistics-end="queryParams.timeRange[1]"
|
|
@update:page-no="pageNo = $event"
|
|
@update:page-size="handlePageSizeChange"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import dayjs from 'dayjs'
|
|
import { useFullscreen } from '@vueuse/core'
|
|
import { useRoute } from 'vue-router'
|
|
import { DeviceOperationOverviewApi } from '@/api/iot/deviceOperationOverview'
|
|
import { DeviceLedgerApi, DeviceLedgerVO } from '@/api/mes/deviceledger'
|
|
import { OrganizationApi } from '@/api/mes/organization'
|
|
import type { DeviceParameterAnalysisNodeVO } from '@/api/mes/organization'
|
|
import { handleTree } from '@/utils/tree'
|
|
import OverviewFilterBar from './components/OverviewFilterBar.vue'
|
|
import OverviewMetricCards from './components/OverviewMetricCards.vue'
|
|
import OperationTimelineChart from './components/OperationTimelineChart.vue'
|
|
import StatusDistributionChart from './components/StatusDistributionChart.vue'
|
|
import { buildDefaultQueryParams } from './mock'
|
|
import type {
|
|
OrganizationTreeOption,
|
|
OverviewOption,
|
|
QuickRangeKey,
|
|
RunOverviewData,
|
|
RunOverviewQueryParams
|
|
} from './components/types'
|
|
|
|
defineOptions({ name: 'IotRunOverview' })
|
|
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const fullscreenTargetRef = ref()
|
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(fullscreenTargetRef)
|
|
const deviceLedgerList = ref<DeviceLedgerVO[]>([])
|
|
const groupOptions = ref<OrganizationTreeOption[]>([])
|
|
let deviceLedgerRequestSeq = 0
|
|
|
|
const isEnabledQueryValue = (value: unknown) => {
|
|
const rawValue = Array.isArray(value) ? value[0] : value
|
|
return ['1', 'true', 'yes', 'on'].includes(String(rawValue ?? '').toLowerCase())
|
|
}
|
|
|
|
const isFullscreenLayout = computed(
|
|
() => isFullscreen.value || isEnabledQueryValue(route.query.fullscreen ?? route.query.full)
|
|
)
|
|
|
|
const createDateRangeByQuickKey = (key: QuickRangeKey): [string, string] => {
|
|
const format = 'YYYY-MM-DD HH:mm:ss'
|
|
const now = dayjs()
|
|
if (key === 'today')
|
|
return [now.startOf('day').format(format), now.format(format)]
|
|
if (key === 'yesterday') {
|
|
return [
|
|
dayjs().subtract(1, 'day').startOf('day').format(format),
|
|
dayjs().subtract(1, 'day').endOf('day').format(format)
|
|
]
|
|
}
|
|
if (key === 'last7Days')
|
|
return [
|
|
now.subtract(6, 'day').startOf('day').format(format),
|
|
now.format(format)
|
|
]
|
|
if (key === 'last30Days') {
|
|
return [
|
|
now.subtract(29, 'day').startOf('day').format(format),
|
|
now.format(format)
|
|
]
|
|
}
|
|
return [now.startOf('day').format(format), now.format(format)]
|
|
}
|
|
|
|
const quickRanges = computed(() => [
|
|
{ label: t('DataCollection.RunOverview.quickRange.today'), value: 'today' as const },
|
|
{ label: t('DataCollection.RunOverview.quickRange.yesterday'), value: 'yesterday' as const },
|
|
{ label: t('DataCollection.RunOverview.quickRange.last7Days'), value: 'last7Days' as const },
|
|
{ label: t('DataCollection.RunOverview.quickRange.last30Days'), value: 'last30Days' as const }
|
|
/* { label: t('DataCollection.RunOverview.quickRange.custom'), value: 'custom' as const }*/
|
|
])
|
|
|
|
const queryParams = ref<RunOverviewQueryParams>(buildDefaultQueryParams())
|
|
const pageNo = ref(1)
|
|
const pageSize = ref(10)
|
|
|
|
const extractDeviceParameterAnalysisNodes = (res: any): DeviceParameterAnalysisNodeVO[] => {
|
|
if (Array.isArray(res)) return res
|
|
if (Array.isArray(res?.data)) return res.data
|
|
if (Array.isArray(res?.result)) return res.result
|
|
if (Array.isArray(res?.list)) return res.list
|
|
return []
|
|
}
|
|
|
|
const normalizeDeviceParameterAnalysisTree = (
|
|
nodes: DeviceParameterAnalysisNodeVO[] = []
|
|
): OrganizationTreeOption[] => {
|
|
const normalizeNode = (node: DeviceParameterAnalysisNodeVO): any => {
|
|
const rawId = node?.id
|
|
const parentId =
|
|
node?.parentId == null || node?.parentId === ''
|
|
? 0
|
|
: node.parentId
|
|
|
|
return {
|
|
...node,
|
|
id: rawId,
|
|
name: String(node?.name ?? ''),
|
|
parentId,
|
|
children: Array.isArray(node?.children) ? node.children.map(normalizeNode) : undefined
|
|
}
|
|
}
|
|
|
|
const normalizedNodes = Array.isArray(nodes) ? nodes.map(normalizeNode) : []
|
|
const isNestedTree = normalizedNodes.some(
|
|
(node) => Array.isArray(node?.children) && node.children.length > 0
|
|
)
|
|
const orgTree = isNestedTree
|
|
? normalizedNodes
|
|
: (handleTree(normalizedNodes, 'id', 'parentId', 'children') as any[])
|
|
|
|
const toOption = (node: any): OrganizationTreeOption => {
|
|
const orgChildren = Array.isArray(node.children) ? node.children.map(toOption) : []
|
|
const equipmentChildren = Array.isArray(node.equipments)
|
|
? node.equipments.map((equipment: any) => {
|
|
const parameterChildren = Array.isArray(equipment.parameters)
|
|
? equipment.parameters.map((param: any) => ({
|
|
id: `param-${equipment.id}-${param.id}`,
|
|
name: String(param?.name ?? param?.code ?? param?.id ?? ''),
|
|
parentId: `equipment-${node.id}-${equipment.id}`,
|
|
lineId: String(node.id),
|
|
filterId: String(param.id),
|
|
deviceId: String(equipment.id),
|
|
paramId: String(param.id),
|
|
nodeType: 'param' as const
|
|
}))
|
|
: []
|
|
|
|
return {
|
|
id: `equipment-${node.id}-${equipment.id}`,
|
|
name: String(equipment?.name ?? equipment?.id ?? ''),
|
|
parentId: `org-${node.id}`,
|
|
lineId: String(node.id),
|
|
filterId: String(equipment.id),
|
|
deviceId: String(equipment.id),
|
|
nodeType: 'equipment' as const,
|
|
children: parameterChildren.length ? parameterChildren : undefined
|
|
}
|
|
})
|
|
: []
|
|
const children = [...orgChildren, ...equipmentChildren]
|
|
|
|
return {
|
|
id: `org-${node.id}`,
|
|
name: node.name,
|
|
parentId: node.parentId != null && node.parentId !== '' && node.parentId !== 0 ? `org-${node.parentId}` : node.parentId,
|
|
lineId: String(node.id),
|
|
filterId: String(node.id),
|
|
nodeType: 'org',
|
|
children: children.length ? children : undefined
|
|
}
|
|
}
|
|
|
|
return Array.isArray(orgTree) ? orgTree.map(toOption) : []
|
|
}
|
|
|
|
const findGroupNode = (groupId?: string): OrganizationTreeOption | undefined => {
|
|
if (!groupId) return undefined
|
|
const stack = [...groupOptions.value]
|
|
while (stack.length > 0) {
|
|
const node = stack.pop()!
|
|
if (String(node.id) === String(groupId)) return node
|
|
if (Array.isArray(node.children)) stack.push(...node.children)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const getGroupLineId = (groupId?: string) => {
|
|
if (!groupId) return undefined
|
|
const node = findGroupNode(groupId)
|
|
if (node?.nodeType === 'org' && node.filterId != null) return String(node.filterId)
|
|
const matched = /^org-(.+)$/.exec(String(groupId))
|
|
return matched?.[1] || groupId
|
|
}
|
|
|
|
const getDeviceLedgerRequestParams = (groupId?: string) => {
|
|
const node = findGroupNode(groupId)
|
|
if (!node) return groupId ? { deviceLine: getGroupLineId(groupId) } : {}
|
|
if (node.nodeType === 'equipment' && node.filterId != null) return { dvId: node.filterId }
|
|
if (node.nodeType === 'param' && node.deviceId != null) return { dvId: node.deviceId }
|
|
if (node.filterId != null) return { deviceLine: node.filterId }
|
|
return {}
|
|
}
|
|
|
|
const collectGroupIds = (groupId?: string) => {
|
|
if (!groupId) return undefined
|
|
const selectedNode = findGroupNode(groupId)
|
|
if (selectedNode?.nodeType === 'equipment' && selectedNode.filterId != null) {
|
|
return new Set([String(selectedNode.filterId)])
|
|
}
|
|
if (selectedNode?.nodeType === 'param' && selectedNode.deviceId != null) {
|
|
return new Set([String(selectedNode.deviceId)])
|
|
}
|
|
|
|
const ids = new Set<string>()
|
|
const lineId = getGroupLineId(groupId)
|
|
const stack = [...groupOptions.value]
|
|
|
|
while (stack.length > 0) {
|
|
const node = stack.pop()!
|
|
if (String(node.id) === groupId || String(node.filterId ?? '') === lineId) {
|
|
const collectStack = [node]
|
|
while (collectStack.length > 0) {
|
|
const current = collectStack.pop()!
|
|
if (current.nodeType === 'org' && current.filterId != null) ids.add(String(current.filterId))
|
|
if (Array.isArray(current.children)) collectStack.push(...current.children)
|
|
}
|
|
break
|
|
}
|
|
if (Array.isArray(node.children)) stack.push(...node.children)
|
|
}
|
|
|
|
return ids
|
|
}
|
|
|
|
const hasIotDeviceId = (item: DeviceLedgerVO) => String(item.dvId ?? '').trim() !== ''
|
|
|
|
const getDeviceOptionLabel = (item: DeviceLedgerVO) => {
|
|
const deviceName = String(item.deviceName ?? '').trim()
|
|
const deviceCode = String(item.deviceCode ?? '').trim()
|
|
return deviceName || deviceCode || String(item.iotDeviceName ?? item.iotDeviceCode ?? '').trim()
|
|
}
|
|
|
|
const collectDeviceOptionsByGroup = (groupId?: string) => {
|
|
const deviceMap = new Map<string, OverviewOption>()
|
|
const groupIds = collectGroupIds(groupId)
|
|
const selectedNode = findGroupNode(groupId)
|
|
deviceLedgerList.value.forEach((item) => {
|
|
if (groupIds) {
|
|
const targetId =
|
|
selectedNode?.nodeType === 'equipment' || selectedNode?.nodeType === 'param'
|
|
? String(item.dvId ?? '')
|
|
: String(item.deviceLine ?? '')
|
|
if (!groupIds.has(targetId)) return
|
|
}
|
|
const deviceId = String(item.dvId ?? '').trim()
|
|
if (!deviceId || deviceMap.has(deviceId)) return
|
|
deviceMap.set(deviceId, {
|
|
label: getDeviceOptionLabel(item),
|
|
value: deviceId
|
|
})
|
|
})
|
|
return Array.from(deviceMap.values())
|
|
}
|
|
const deviceOptions = computed(() => collectDeviceOptionsByGroup(queryParams.value.groupId))
|
|
const overviewData = ref<RunOverviewData>({
|
|
metrics: [],
|
|
hourlyStatus: [],
|
|
summary: [],
|
|
summaryTotalHours: 0,
|
|
timelineRows: [],
|
|
totalDevices: 0
|
|
})
|
|
|
|
const currentDeviceIds = computed(() =>
|
|
queryParams.value.deviceId.length > 0
|
|
? queryParams.value.deviceId
|
|
: deviceOptions.value.map((item) => item.value)
|
|
)
|
|
|
|
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
|
|
const IOT_DEVICE_TYPE = 2
|
|
|
|
const normalizeDateTime = (value: string, fallback: string) => {
|
|
const parsed = dayjs(value)
|
|
if (!parsed.isValid()) return fallback
|
|
const now = dayjs()
|
|
return (parsed.isAfter(now) ? now : parsed).format(DATE_TIME_FORMAT)
|
|
}
|
|
|
|
const normalizeTimeRange = (timeRange: [string, string]): [string, string] => {
|
|
const nowText = dayjs().format(DATE_TIME_FORMAT)
|
|
let start = normalizeDateTime(timeRange?.[0], dayjs().startOf('day').format(DATE_TIME_FORMAT))
|
|
let end = normalizeDateTime(timeRange?.[1], nowText)
|
|
if (dayjs(start).isAfter(dayjs(end))) start = end
|
|
return [start, end]
|
|
}
|
|
|
|
const syncSafeTimeRange = () => {
|
|
const safeTimeRange = normalizeTimeRange(queryParams.value.timeRange)
|
|
if (
|
|
safeTimeRange[0] !== queryParams.value.timeRange[0] ||
|
|
safeTimeRange[1] !== queryParams.value.timeRange[1]
|
|
) {
|
|
queryParams.value = {
|
|
...queryParams.value,
|
|
timeRange: safeTimeRange
|
|
}
|
|
}
|
|
return safeTimeRange
|
|
}
|
|
|
|
const buildOverviewRequestParams = () => {
|
|
const [startTime, endTime] = syncSafeTimeRange()
|
|
return {
|
|
ids: currentDeviceIds.value.join(','),
|
|
deviceType: IOT_DEVICE_TYPE,
|
|
startTime,
|
|
endTime,
|
|
timelinePageNo: pageNo.value,
|
|
timelinePageSize: pageSize.value
|
|
}
|
|
}
|
|
|
|
const refreshData = async () => {
|
|
const response = await DeviceOperationOverviewApi.getRunOverview(buildOverviewRequestParams())
|
|
overviewData.value = {
|
|
metrics: response?.metrics || [],
|
|
hourlyStatus: response?.hourlyStatus || [],
|
|
summary: response?.summary || [],
|
|
summaryTotalHours: response?.summaryTotalHours || 0,
|
|
timelineRows: response?.timelineRows || [],
|
|
totalDevices: response?.totalDevices || 0
|
|
}
|
|
const maxPage = Math.max(1, Math.ceil(overviewData.value.totalDevices / pageSize.value))
|
|
if (pageNo.value > maxPage) pageNo.value = maxPage
|
|
}
|
|
|
|
const resetToFirstPageAndRefresh = () => {
|
|
if (pageNo.value !== 1) {
|
|
pageNo.value = 1
|
|
return
|
|
}
|
|
void refreshData()
|
|
}
|
|
|
|
const handleQuickRangeChange = (key: QuickRangeKey) => {
|
|
queryParams.value = {
|
|
...queryParams.value,
|
|
quickRange: key,
|
|
timeRange: createDateRangeByQuickKey(key)
|
|
}
|
|
pageNo.value = 1
|
|
}
|
|
|
|
const handleQuery = () => {
|
|
resetToFirstPageAndRefresh()
|
|
}
|
|
|
|
const resetQuery = () => {
|
|
queryParams.value = buildDefaultQueryParams()
|
|
pageSize.value = 10
|
|
resetToFirstPageAndRefresh()
|
|
}
|
|
|
|
const handlePageSizeChange = (size: number) => {
|
|
pageSize.value = size
|
|
pageNo.value = 1
|
|
void refreshData()
|
|
}
|
|
|
|
watch(pageNo, () => {
|
|
void refreshData()
|
|
})
|
|
|
|
const getDeviceLedgerOptions = async (groupId?: string) => {
|
|
const requestSeq = ++deviceLedgerRequestSeq
|
|
const ledgerList = await DeviceLedgerApi.getDeviceLedgerList(getDeviceLedgerRequestParams(groupId))
|
|
if (requestSeq !== deviceLedgerRequestSeq) return
|
|
deviceLedgerList.value = Array.isArray(ledgerList) ? ledgerList.filter(hasIotDeviceId) : []
|
|
}
|
|
|
|
const getFilterOptions = async () => {
|
|
const deviceLineTree = await OrganizationApi.deviceParameterAnalysis({
|
|
keyword: undefined,
|
|
deviceType: IOT_DEVICE_TYPE,
|
|
showDevices: 1,
|
|
filterNoDeviceLine: 1
|
|
})
|
|
groupOptions.value = normalizeDeviceParameterAnalysisTree(
|
|
extractDeviceParameterAnalysisNodes(deviceLineTree)
|
|
)
|
|
await getDeviceLedgerOptions(queryParams.value.groupId)
|
|
}
|
|
|
|
watch(
|
|
() => queryParams.value.groupId,
|
|
(groupId) => {
|
|
void getDeviceLedgerOptions(groupId)
|
|
}
|
|
)
|
|
watch(
|
|
deviceOptions,
|
|
(options) => {
|
|
if (queryParams.value.deviceId.length === 0) return
|
|
const validDeviceIds = queryParams.value.deviceId.filter((deviceId) =>
|
|
options.some((item) => item.value === deviceId)
|
|
)
|
|
if (validDeviceIds.length !== queryParams.value.deviceId.length) {
|
|
queryParams.value = {
|
|
...queryParams.value,
|
|
deviceId: validDeviceIds
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
onMounted(async () => {
|
|
await getFilterOptions()
|
|
await refreshData()
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.run-overview-page {
|
|
position: relative;
|
|
min-width: 0;
|
|
//padding-top: 8px;
|
|
}
|
|
|
|
.run-overview-page.is-fullscreen {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 2000;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
padding: 16px;
|
|
overflow: auto;
|
|
background: #f5f7fa;
|
|
}
|
|
|
|
.run-overview-page__floating-tools {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 12px;
|
|
z-index: 20;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.run-overview-page__screenfull {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 34px;
|
|
height: 34px;
|
|
padding: 0;
|
|
background: #fff;
|
|
border: 1px solid #e4e7ec;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.run-overview-page__screenfull:hover {
|
|
background: #f8fbff;
|
|
border-color: #bfd3ff;
|
|
box-shadow: 0 8px 18px rgba(61, 132, 255, 0.12);
|
|
}
|
|
</style>
|