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.
1099 lines
29 KiB
Vue
1099 lines
29 KiB
Vue
<template>
|
|
<div ref="dashboardRef" class="run-dashboard">
|
|
<div class="bg-grid"></div>
|
|
|
|
<header class="dashboard-header">
|
|
<div class="brand">
|
|
<div class="brand-icon">
|
|
<!-- <Icon icon="ep:cpu" />-->
|
|
<img alt="" class="mr-10px h-40px w-40px" src="@/assets/imgs/logo.png" />
|
|
</div>
|
|
<div class="brand-text">{{ t('Header.subTitle') }}</div>
|
|
</div>
|
|
<h1>{{ t('Header.title') }}</h1>
|
|
<div class="header-meta">
|
|
<div><span>{{ t('Header.currentTime') }}</span>{{ currentTime }}</div>
|
|
<div><span>{{ t('Header.lastUpdate') }}</span>{{ lastUpdateTime || '-' }}</div>
|
|
<div class="refresh-row">
|
|
<span>{{ t('Header.refreshRate') }}</span>{{ t('Header.refreshRateValue', { seconds: 15 }) }}
|
|
<button type="button" class="screen-btn" @click="toggleFullscreen">
|
|
<Icon :icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="dashboard-main">
|
|
<section class="top-row">
|
|
<div
|
|
v-for="card in statusCards"
|
|
:key="card.key"
|
|
class="metric-card"
|
|
:class="`metric-card--${card.tone}`"
|
|
>
|
|
<div class="metric-title">
|
|
<Icon :icon="card.icon" />
|
|
<span>{{ card.label }}</span>
|
|
</div>
|
|
<div class="metric-value">
|
|
<strong>{{ card.value }}</strong>
|
|
<span v-if="card.percent !== undefined">{{ formatPercent(card.percent) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alarm-board panel">
|
|
<div class="alarm-head">
|
|
<span>{{ t('AlarmBoard.currentDevice') }}</span>
|
|
<span>{{ t('AlarmBoard.todayAlarmDuration') }}</span>
|
|
<span>{{ t('AlarmBoard.lastReportTime') }}</span>
|
|
</div>
|
|
<div v-if="alarmDevices.length" class="alarm-list">
|
|
<div v-for="item in alarmDevices" :key="item.id" class="alarm-item">
|
|
<strong :title="item.name">{{ item.name }}</strong>
|
|
<span>{{ t('AlarmBoard.alarmPrefix') }} {{ formatHours(item.alarmTime) }}</span>
|
|
<span>{{ item.lastReportTime }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-line">{{ t('AlarmBoard.empty') }}</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel detail-panel">
|
|
<div class="panel-title">
|
|
<h2>{{ t('Detail.title') }}</h2>
|
|
<span>{{ t('Detail.carouselText', { page: detailPageText }) }}</span>
|
|
</div>
|
|
<div class="detail-table">
|
|
<div class="table-header table-row">
|
|
<span>{{ t('Detail.columns.deviceName') }}</span>
|
|
<span>{{ t('Detail.columns.currentStatus') }}</span>
|
|
<span>运行时间(小时)</span>
|
|
<span>待机时间(小时)</span>
|
|
<span>故障时间(小时)</span>
|
|
<span>稼动率</span>
|
|
</div>
|
|
<div v-if="pagedRows.length" class="table-body">
|
|
<div
|
|
v-for="row in pagedRows"
|
|
:key="row.id"
|
|
class="table-row"
|
|
:class="`table-row--${row.status}`"
|
|
>
|
|
<strong :title="row.name">{{ row.name }}</strong>
|
|
<span>
|
|
<em class="status-pill" :class="`status-pill--${row.status}`">{{ statusText[row.status] }}</em>
|
|
</span>
|
|
<span>{{ formatHours(row.runningTime) }}</span>
|
|
<span>{{ formatHours(row.standbyTime) }}</span>
|
|
<span>{{ formatHours(row.alarmTime) }}</span>
|
|
<span class="rate">{{ formatPercentText(row.utilizationRate) }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-table">{{ t('Detail.empty') }}</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="chart-row">
|
|
<div class="panel chart-panel">
|
|
<div class="panel-title">
|
|
<h2>{{ t('Charts.last24Hours') }}</h2>
|
|
<span>{{ t('Charts.averageRate', { value: formatPercent(todayAverageRate) }) }}</span>
|
|
</div>
|
|
<div ref="hourChartRef" class="chart"></div>
|
|
</div>
|
|
<div class="panel chart-panel">
|
|
<div class="panel-title">
|
|
<h2>{{ t('Charts.last7Days') }}</h2>
|
|
<span>{{ t('Charts.averageRate', { value: formatPercent(sevenDayAverageRate) }) }}</span>
|
|
</div>
|
|
<div ref="weekChartRef" class="chart"></div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import dayjs from 'dayjs'
|
|
import { useFullscreen } from '@vueuse/core'
|
|
import * as echarts from 'echarts'
|
|
import type { EChartsOption } from 'echarts'
|
|
import { DeviceOperationOverviewApi } from '@/api/iot/deviceOperationOverview'
|
|
import type {
|
|
DeviceOperationOverviewHourlyStatusVO,
|
|
DeviceOperationOverviewRespVO,
|
|
DeviceOperationOverviewTimelineRowVO
|
|
} from '@/api/iot/deviceOperationOverview'
|
|
import {
|
|
DeviceOperationRecordApi,
|
|
type DeviceOperationRecordVO
|
|
} from '@/api/iot/deviceOperationRecord'
|
|
import { DeviceApi, type DeviceVO } from '@/api/iot/device'
|
|
import { OrganizationApi } from '@/api/mes/organization'
|
|
|
|
defineOptions({ name: 'IotReportDashboard3' })
|
|
|
|
const { t } = useI18n('Dashboard3')
|
|
|
|
type RunStatus = 'running' | 'standby' | 'fault' | 'offline'
|
|
|
|
interface DeviceStatusStatistics {
|
|
total: number
|
|
running: number
|
|
standby: number
|
|
fault: number
|
|
offline: number
|
|
}
|
|
|
|
interface OrganizationFilterItem {
|
|
id: string
|
|
parentId?: string | null
|
|
dvId?: number | string | null
|
|
machineName?: string
|
|
}
|
|
|
|
interface DetailRow {
|
|
id: string
|
|
name: string
|
|
status: RunStatus
|
|
runningTime: number
|
|
standbyTime: number
|
|
alarmTime: number
|
|
utilizationRate: string
|
|
lastReportTime: string
|
|
}
|
|
|
|
const route = useRoute()
|
|
const dashboardRef = ref<HTMLElement>()
|
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(dashboardRef)
|
|
const currentTime = ref('')
|
|
const lastUpdateTime = ref('')
|
|
const overviewData = ref<DeviceOperationOverviewRespVO>({
|
|
metrics: [],
|
|
hourlyStatus: [],
|
|
summary: [],
|
|
summaryTotalHours: 0,
|
|
timelineRows: [],
|
|
totalDevices: 0
|
|
})
|
|
const operationRecordRows = ref<DeviceOperationRecordVO[]>([])
|
|
const last24HourlyStatus = ref<DeviceOperationOverviewHourlyStatusVO[]>([])
|
|
const deviceStatusStatistics = ref<DeviceStatusStatistics>({
|
|
total: 0,
|
|
running: 0,
|
|
standby: 0,
|
|
fault: 0,
|
|
offline: 0
|
|
})
|
|
const deviceOperatingStatusMap = ref<Record<string, RunStatus>>({})
|
|
const weekTrend = ref<{ date: string; rate: number }[]>([])
|
|
const detailPage = ref(1)
|
|
const detailPageSize = 8
|
|
const organizationList = ref<OrganizationFilterItem[]>([])
|
|
const hourChartRef = ref<HTMLElement | null>(null)
|
|
const weekChartRef = ref<HTMLElement | null>(null)
|
|
let hourChart: echarts.ECharts | null = null
|
|
let weekChart: echarts.ECharts | null = null
|
|
let clockTimer: number | undefined
|
|
let refreshTimer: number | undefined
|
|
let carouselTimer: number | undefined
|
|
|
|
const statusText = computed<Record<RunStatus, string>>(() => ({
|
|
running: t('Status.running'),
|
|
standby: t('Status.standby'),
|
|
fault: t('Status.fault'),
|
|
offline: t('Status.offline')
|
|
}))
|
|
|
|
const calcDeviceRate = (count: number, total: number) => {
|
|
if (!total) return 0
|
|
return Number(((count / total) * 100).toFixed(1))
|
|
}
|
|
|
|
const statusCards = computed(() => [
|
|
{
|
|
key: 'total',
|
|
label: t('Metrics.total'),
|
|
icon: 'ep:connection',
|
|
value: totalDeviceCount.value,
|
|
tone: 'total'
|
|
},
|
|
{
|
|
key: 'online',
|
|
label: t('Metrics.online'),
|
|
icon: 'ep:cpu',
|
|
value: onlineDeviceCount.value,
|
|
percent: calcDeviceRate(onlineDeviceCount.value, totalDeviceCount.value),
|
|
tone: 'online'
|
|
},
|
|
{
|
|
key: 'offline',
|
|
label: t('Metrics.offline'),
|
|
icon: 'ep:link',
|
|
value: offlineDeviceCount.value,
|
|
percent: calcDeviceRate(offlineDeviceCount.value, totalDeviceCount.value),
|
|
tone: 'offline'
|
|
},
|
|
{
|
|
key: 'running',
|
|
label: t('Metrics.running'),
|
|
icon: 'ep:video-play',
|
|
value: runningDeviceCount.value,
|
|
percent: calcDeviceRate(runningDeviceCount.value, totalDeviceCount.value),
|
|
tone: 'running'
|
|
},
|
|
{
|
|
key: 'standby',
|
|
label: t('Metrics.standby'),
|
|
icon: 'ep:video-pause',
|
|
value: standbyDeviceCount.value,
|
|
percent: calcDeviceRate(standbyDeviceCount.value, totalDeviceCount.value),
|
|
tone: 'standby'
|
|
},
|
|
{
|
|
key: 'fault',
|
|
label: t('Metrics.fault'),
|
|
icon: 'ep:bell',
|
|
value: faultDeviceCount.value,
|
|
percent: calcDeviceRate(faultDeviceCount.value, totalDeviceCount.value),
|
|
tone: 'fault'
|
|
}
|
|
])
|
|
|
|
const detailRows = computed<DetailRow[]>(() => {
|
|
return operationRecordRows.value.map((row) => {
|
|
const id = String(row.deviceId ?? row.id)
|
|
const status =
|
|
deviceOperatingStatusMap.value[id] ||
|
|
overviewStatusMap.value[id] ||
|
|
'offline'
|
|
return {
|
|
id,
|
|
name: row.deviceName || '-',
|
|
status,
|
|
runningTime: Number(row.totalRunningTime) || 0,
|
|
standbyTime: Number(row.totalStandbyTime) || 0,
|
|
alarmTime: Number(row.totalFaultTime) || 0,
|
|
utilizationRate: row.utilizationRate || '0%',
|
|
lastReportTime: status === 'fault' || Number(row.totalFaultTime) > 0 ? lastUpdateTime.value || '-' : '-'
|
|
}
|
|
})
|
|
})
|
|
|
|
const totalDetailPages = computed(() => Math.max(1, Math.ceil(detailRows.value.length / detailPageSize)))
|
|
const runningDeviceCount = computed(() => deviceStatusStatistics.value.running)
|
|
const standbyDeviceCount = computed(() => deviceStatusStatistics.value.standby)
|
|
const faultDeviceCount = computed(() => deviceStatusStatistics.value.fault)
|
|
const offlineDeviceCount = computed(() => deviceStatusStatistics.value.offline)
|
|
const onlineDeviceCount = computed(
|
|
() => runningDeviceCount.value + standbyDeviceCount.value + faultDeviceCount.value
|
|
)
|
|
const totalDeviceCount = computed(() => deviceStatusStatistics.value.total)
|
|
const pagedRows = computed(() => {
|
|
const start = (detailPage.value - 1) * detailPageSize
|
|
return detailRows.value.slice(start, start + detailPageSize)
|
|
})
|
|
const detailPageText = computed(() => `${detailPage.value} / ${totalDetailPages.value}`)
|
|
const alarmDevices = computed(() => {
|
|
return detailRows.value
|
|
.filter((item) => item.status === 'fault' || item.alarmTime > 0)
|
|
.sort((a, b) => b.alarmTime - a.alarmTime)
|
|
.slice(0, 2)
|
|
})
|
|
const hourlyRates = computed(() => {
|
|
const rateMap = last24HourlyStatus.value.reduce<Record<string, number>>((acc, item) => {
|
|
const rate = getHourlyRunningRate(item)
|
|
getHourlyKeyCandidates(item.hour).forEach((key) => {
|
|
acc[key] = rate
|
|
})
|
|
return acc
|
|
}, {})
|
|
|
|
return getLast24HourBuckets().map((time) => ({
|
|
hour: time.format('MM-DD HH:00'),
|
|
rate: getHourlyKeyCandidates(time.format('YYYY-MM-DD HH:00:00'))
|
|
.map((key) => rateMap[key])
|
|
.find((rate) => rate !== undefined) ?? 0
|
|
}))
|
|
})
|
|
const todayAverageRate = computed(() => average(hourlyRates.value.map((item) => item.rate)))
|
|
const sevenDayAverageRate = computed(() => average(weekTrend.value.map((item) => item.rate)))
|
|
|
|
const formatPercent = (value?: number) => `${Number(value || 0).toFixed(1)}%`
|
|
|
|
const formatPercentText = (value?: string | number) => {
|
|
const text = String(value ?? '').trim()
|
|
if (!text) return '0%'
|
|
return text.includes('%') ? text : `${Number(text || 0).toFixed(1)}%`
|
|
}
|
|
|
|
const formatHours = (hours: number) => {
|
|
const value = Number(hours || 0)
|
|
return `${Number(value.toFixed(2))}${t('Time.hour', { hour: '' }).trim()}`
|
|
}
|
|
|
|
const average = (values: number[]) => {
|
|
const valid = values.filter((item) => Number.isFinite(item))
|
|
if (!valid.length) return 0
|
|
return Number((valid.reduce((sum, item) => sum + item, 0) / valid.length).toFixed(1))
|
|
}
|
|
|
|
const getHourlyRunningRate = (item: DeviceOperationOverviewHourlyStatusVO) => {
|
|
const total = item.running + item.standby + item.fault + item.offline
|
|
return total > 0 ? Number(((item.running / total) * 100).toFixed(1)) : 0
|
|
}
|
|
|
|
const getHourlyKeyCandidates = (hour: string) => {
|
|
const text = String(hour || '').trim()
|
|
const candidates = new Set<string>()
|
|
if (text) candidates.add(text)
|
|
|
|
const parsed = dayjs(text)
|
|
if (parsed.isValid()) {
|
|
candidates.add(parsed.format('YYYY-MM-DD HH:00:00'))
|
|
candidates.add(parsed.format('YYYY-MM-DD HH:00'))
|
|
candidates.add(parsed.format('MM-DD HH:00'))
|
|
candidates.add(parsed.format('HH:00'))
|
|
}
|
|
|
|
const hourMatch = text.match(/(\d{1,2}):00/)
|
|
if (hourMatch) {
|
|
candidates.add(`${hourMatch[1].padStart(2, '0')}:00`)
|
|
}
|
|
|
|
return Array.from(candidates)
|
|
}
|
|
|
|
const normalizeDeviceOperatingStatus = (value: string | number | undefined): RunStatus => {
|
|
const text = String(value ?? '').trim().toLowerCase()
|
|
if (['运行', '运行中', 'running', 'run', '1'].includes(text)) return 'running'
|
|
if (['待机', '待机中', 'standby', 'idle', '2'].includes(text)) return 'standby'
|
|
if (['故障', '故障中', 'fault', 'error', '3'].includes(text)) return 'fault'
|
|
return 'offline'
|
|
}
|
|
|
|
const setDeviceStatusStatistics = (rows: DeviceVO[] = []) => {
|
|
const nextStats: DeviceStatusStatistics = {
|
|
total: rows.length,
|
|
running: 0,
|
|
standby: 0,
|
|
fault: 0,
|
|
offline: 0
|
|
}
|
|
const nextStatusMap: Record<string, RunStatus> = {}
|
|
rows.forEach((row) => {
|
|
const status = normalizeDeviceOperatingStatus(row.operatingStatus)
|
|
nextStats[status] += 1
|
|
if (row.id != null) {
|
|
nextStatusMap[String(row.id)] = status
|
|
}
|
|
})
|
|
deviceStatusStatistics.value = nextStats
|
|
deviceOperatingStatusMap.value = nextStatusMap
|
|
}
|
|
|
|
const getDevicePageRows = (data: any): DeviceVO[] => {
|
|
return Array.isArray(data) ? data : Array.isArray(data?.list) ? data.list : []
|
|
}
|
|
|
|
const refreshDeviceStatusStatistics = async () => {
|
|
const ids = getDeviceIds().join(',')
|
|
const baseParams = {
|
|
...(ids ? { ids } : {})
|
|
}
|
|
|
|
try {
|
|
const firstPage = await DeviceApi.getDevicePage({
|
|
...baseParams,
|
|
pageNo: 1,
|
|
pageSize: 1
|
|
})
|
|
const firstRows = getDevicePageRows(firstPage)
|
|
const total = Number((firstPage as any)?.total) || firstRows.length
|
|
const pageSize = Math.max(total, firstRows.length, 1)
|
|
const data = pageSize === firstRows.length
|
|
? firstPage
|
|
: await DeviceApi.getDevicePage({
|
|
...baseParams,
|
|
pageNo: 1,
|
|
pageSize
|
|
})
|
|
|
|
setDeviceStatusStatistics(getDevicePageRows(data))
|
|
} catch {
|
|
setDeviceStatusStatistics([])
|
|
}
|
|
}
|
|
|
|
const getCurrentStatus = (row: DeviceOperationOverviewTimelineRowVO): RunStatus => {
|
|
const currentHour = dayjs().hour() + dayjs().minute() / 60
|
|
const currentSegment = row.segments?.find((item) => currentHour >= item.startHour && currentHour < item.endHour)
|
|
return currentSegment?.status || row.segments?.[row.segments.length - 1]?.status || 'offline'
|
|
}
|
|
|
|
const overviewStatusMap = computed(() => {
|
|
return overviewData.value.timelineRows.reduce<Record<string, RunStatus>>((acc, row) => {
|
|
acc[String(row.id)] = getCurrentStatus(row)
|
|
return acc
|
|
}, {})
|
|
})
|
|
|
|
const calcMinutes = (row: DeviceOperationOverviewTimelineRowVO) => {
|
|
return (row.segments || []).reduce<Record<RunStatus, number>>(
|
|
(acc, segment) => {
|
|
const minutes = Math.max(0, (Number(segment.endHour) - Number(segment.startHour)) * 60)
|
|
acc[segment.status] += minutes
|
|
return acc
|
|
},
|
|
{ running: 0, standby: 0, fault: 0, offline: 0 }
|
|
)
|
|
}
|
|
|
|
const updateClock = () => {
|
|
currentTime.value = dayjs().format('YYYY/M/D HH:mm:ss')
|
|
}
|
|
|
|
const childrenByParentId = computed(() => {
|
|
return organizationList.value.reduce<Record<string, OrganizationFilterItem[]>>((acc, item) => {
|
|
const parentKey = String(item.parentId ?? 0)
|
|
if (!acc[parentKey]) acc[parentKey] = []
|
|
acc[parentKey].push(item)
|
|
return acc
|
|
}, {})
|
|
})
|
|
|
|
const collectDeviceIdsByOrg = (orgId?: string) => {
|
|
const deviceIds = new Set<string>()
|
|
const pushDevice = (node?: OrganizationFilterItem) => {
|
|
if (node?.dvId) deviceIds.add(String(node.dvId))
|
|
}
|
|
if (!orgId) {
|
|
organizationList.value.forEach(pushDevice)
|
|
return Array.from(deviceIds)
|
|
}
|
|
const queue = [orgId]
|
|
while (queue.length) {
|
|
const currentId = queue.shift() as string
|
|
const current = organizationList.value.find((item) => item.id === currentId)
|
|
pushDevice(current)
|
|
;(childrenByParentId.value[currentId] || []).forEach((child) => queue.push(child.id))
|
|
}
|
|
return Array.from(deviceIds)
|
|
}
|
|
|
|
const getDeviceIds = () => {
|
|
const routeIds = String(route.query.ids || route.query.deviceIds || '')
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
if (routeIds.length) return routeIds
|
|
return collectDeviceIdsByOrg(route.query.orgId ? String(route.query.orgId) : undefined)
|
|
}
|
|
|
|
const buildParams = (startTime: string, endTime: string, pageSize = 100) => ({
|
|
ids: getDeviceIds().join(','),
|
|
startTime,
|
|
endTime,
|
|
timelinePageNo: 1,
|
|
timelinePageSize: pageSize
|
|
})
|
|
|
|
const getDayRange = (date = dayjs()) => ({
|
|
start: date.startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
|
end: date.add(1, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss')
|
|
})
|
|
|
|
const getLast24HoursRange = () => {
|
|
const end = dayjs().startOf('hour')
|
|
return {
|
|
start: end.subtract(24, 'hour').format('YYYY-MM-DD HH:mm:ss'),
|
|
end: end.format('YYYY-MM-DD HH:mm:ss')
|
|
}
|
|
}
|
|
|
|
const getLast24HourBuckets = () => {
|
|
const end = dayjs().startOf('hour')
|
|
return Array.from({ length: 24 }, (_, index) => end.subtract(23 - index, 'hour'))
|
|
}
|
|
|
|
const loadOrganizationList = async () => {
|
|
const data = await OrganizationApi.getOrganizationList()
|
|
organizationList.value = Array.isArray(data)
|
|
? data.map((item: any) => ({
|
|
id: String(item.id),
|
|
parentId: item.parentId != null ? String(item.parentId) : item.parentId,
|
|
dvId: item.dvId,
|
|
machineName: item.machineName
|
|
}))
|
|
: []
|
|
}
|
|
|
|
const refreshTodayData = async () => {
|
|
const { start, end } = getDayRange()
|
|
const response = await DeviceOperationOverviewApi.getRunOverview(buildParams(start, end))
|
|
overviewData.value = {
|
|
metrics: response?.metrics || [],
|
|
hourlyStatus: response?.hourlyStatus || [],
|
|
summary: response?.summary || [],
|
|
summaryTotalHours: response?.summaryTotalHours || 0,
|
|
timelineRows: response?.timelineRows || [],
|
|
totalDevices: response?.totalDevices || 0
|
|
}
|
|
lastUpdateTime.value = dayjs().format('YYYY/M/D HH:mm:ss')
|
|
}
|
|
|
|
const getOperationRecordRows = (data: any): DeviceOperationRecordVO[] => {
|
|
return Array.isArray(data) ? data : Array.isArray(data?.list) ? data.list : []
|
|
}
|
|
|
|
const refreshOperationRecordRows = async () => {
|
|
const { start, end } = getDayRange()
|
|
const response = await DeviceOperationRecordApi.getDeviceOperationRecordPage({
|
|
pageNo: 1,
|
|
pageSize: 100,
|
|
ids: getDeviceIds().join(','),
|
|
startTime: start,
|
|
endTime: end
|
|
})
|
|
operationRecordRows.value = getOperationRecordRows(response)
|
|
}
|
|
|
|
const refreshLast24HourTrend = async () => {
|
|
const { start, end } = getLast24HoursRange()
|
|
const response = await DeviceOperationOverviewApi.getRunOverview(buildParams(start, end))
|
|
last24HourlyStatus.value = response?.hourlyStatus || []
|
|
}
|
|
|
|
const refreshWeekTrend = async () => {
|
|
const requests = Array.from({ length: 7 }).map((_, index) => {
|
|
const date = dayjs().subtract(6 - index, 'day')
|
|
const { start, end } = getDayRange(date)
|
|
return DeviceOperationOverviewApi.getRunOverview(
|
|
buildParams(start, end, 1)
|
|
).then((res) => {
|
|
const running = res?.summary?.find((item) => item.status === 'running')?.percent || 0
|
|
return {
|
|
date: date.format('MM-DD'),
|
|
rate: Number(Number(running).toFixed(1))
|
|
}
|
|
})
|
|
})
|
|
weekTrend.value = await Promise.all(requests)
|
|
}
|
|
|
|
const refreshData = async () => {
|
|
await Promise.all([
|
|
refreshTodayData(),
|
|
refreshOperationRecordRows(),
|
|
refreshDeviceStatusStatistics(),
|
|
refreshLast24HourTrend()
|
|
])
|
|
await refreshWeekTrend()
|
|
renderCharts()
|
|
}
|
|
|
|
const getLineChartOption = (labels: string[], values: number[]): EChartsOption => ({
|
|
backgroundColor: 'transparent',
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
valueFormatter: (value) => `${value}%`
|
|
},
|
|
grid: { left: 42, right: 24, top: 26, bottom: 32 },
|
|
xAxis: {
|
|
type: 'category',
|
|
boundaryGap: false,
|
|
data: labels,
|
|
axisLine: { lineStyle: { color: 'rgba(105, 165, 235, 0.55)' } },
|
|
axisTick: { show: false },
|
|
axisLabel: { color: '#a9c8ef', fontSize: 12 }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
min: 0,
|
|
max: 100,
|
|
axisLabel: { color: '#a9c8ef', formatter: '{value}%' },
|
|
splitLine: { lineStyle: { color: 'rgba(76, 144, 219, 0.18)' } }
|
|
},
|
|
series: [
|
|
{
|
|
type: 'line',
|
|
smooth: true,
|
|
symbol: 'circle',
|
|
symbolSize: 5,
|
|
data: values,
|
|
lineStyle: { width: 3, color: '#4692ff' },
|
|
itemStyle: { color: '#54a7ff' },
|
|
areaStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: 'rgba(70, 146, 255, 0.46)' },
|
|
{ offset: 1, color: 'rgba(70, 146, 255, 0.04)' }
|
|
])
|
|
}
|
|
}
|
|
]
|
|
})
|
|
|
|
const renderCharts = () => {
|
|
if (hourChartRef.value && !hourChart) hourChart = echarts.init(hourChartRef.value, 'dark', { renderer: 'canvas' })
|
|
if (weekChartRef.value && !weekChart) weekChart = echarts.init(weekChartRef.value, 'dark', { renderer: 'canvas' })
|
|
hourChart?.setOption(
|
|
getLineChartOption(
|
|
hourlyRates.value.map((item) => item.hour),
|
|
hourlyRates.value.map((item) => item.rate)
|
|
)
|
|
)
|
|
weekChart?.setOption(
|
|
getLineChartOption(
|
|
weekTrend.value.map((item) => item.date),
|
|
weekTrend.value.map((item) => item.rate)
|
|
)
|
|
)
|
|
}
|
|
|
|
const resizeCharts = () => {
|
|
hourChart?.resize()
|
|
weekChart?.resize()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
updateClock()
|
|
clockTimer = window.setInterval(updateClock, 100)
|
|
await loadOrganizationList()
|
|
await refreshData()
|
|
refreshTimer = window.setInterval(() => {
|
|
void refreshData()
|
|
}, 15000)
|
|
carouselTimer = window.setInterval(() => {
|
|
detailPage.value = detailPage.value >= totalDetailPages.value ? 1 : detailPage.value + 1
|
|
}, 5000)
|
|
window.addEventListener('resize', resizeCharts)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (clockTimer) window.clearInterval(clockTimer)
|
|
if (refreshTimer) window.clearInterval(refreshTimer)
|
|
if (carouselTimer) window.clearInterval(carouselTimer)
|
|
window.removeEventListener('resize', resizeCharts)
|
|
hourChart?.dispose()
|
|
weekChart?.dispose()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.run-dashboard {
|
|
--bg: #03142d;
|
|
--panel: rgb(10 44 88 / 82%);
|
|
--panel-deep: rgb(6 27 61 / 88%);
|
|
--line: rgb(47 129 222 / 62%);
|
|
--text: #eef6ff;
|
|
--muted: #91b7e5;
|
|
--blue: #49a0ff;
|
|
--green: #40f06d;
|
|
--red: #ff4d5b;
|
|
--gray: #aec0d7;
|
|
|
|
position: relative;
|
|
width: 100%;
|
|
min-width: 1366px;
|
|
min-height: 100vh;
|
|
overflow: hidden;
|
|
font-family: "Microsoft Yahei", "PingFang SC", Arial, sans-serif;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(circle at 50% 0, rgb(21 88 157 / 38%), transparent 34%),
|
|
linear-gradient(180deg, #061a38 0%, #041329 48%, #031026 100%);
|
|
}
|
|
|
|
.bg-grid {
|
|
position: absolute;
|
|
pointer-events: none;
|
|
background-image:
|
|
linear-gradient(rgb(42 122 204 / 16%) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgb(42 122 204 / 16%) 1px, transparent 1px);
|
|
background-size: 96px 40px;
|
|
inset: 0;
|
|
}
|
|
|
|
.dashboard-header {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: grid;
|
|
height: 86px;
|
|
padding: 12px 24px 8px;
|
|
grid-template-columns: 360px 1fr 360px;
|
|
align-items: start;
|
|
}
|
|
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
color: #c7dcf6;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.brand-icon {
|
|
display: grid;
|
|
width: 40px;
|
|
height: 40px;
|
|
color: #8cd3ff;
|
|
background: rgb(78 157 255 / 16%);
|
|
border: 1px solid rgb(93 177 255 / 40%);
|
|
border-radius: 6px;
|
|
place-items: center;
|
|
}
|
|
|
|
.dashboard-header h1 {
|
|
margin: 0;
|
|
color: #fff;
|
|
font-size: 42px;
|
|
font-weight: 800;
|
|
line-height: 1.15;
|
|
text-align: center;
|
|
text-shadow: 0 0 12px rgb(105 178 255 / 86%);
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.header-meta {
|
|
justify-self: end;
|
|
color: #b6d0ef;
|
|
font-size: 14px;
|
|
line-height: 1.75;
|
|
}
|
|
|
|
.header-meta span {
|
|
color: #fff;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.refresh-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.screen-btn {
|
|
display: inline-grid;
|
|
width: 26px;
|
|
height: 26px;
|
|
padding: 0;
|
|
color: #8fc7ff;
|
|
cursor: pointer;
|
|
background: rgb(16 62 116 / 80%);
|
|
border: 1px solid rgb(77 151 232 / 70%);
|
|
border-radius: 4px;
|
|
place-items: center;
|
|
}
|
|
|
|
.dashboard-main {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
height: calc(100vh - 86px);
|
|
min-height: 820px;
|
|
padding: 8px 24px 16px;
|
|
box-sizing: border-box;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.top-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, minmax(150px, 1fr)) 2fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
.panel,
|
|
.metric-card {
|
|
background: linear-gradient(135deg, var(--panel), var(--panel-deep));
|
|
border: 1px solid var(--line);
|
|
border-radius: 5px;
|
|
box-shadow: inset 0 0 24px rgb(34 113 203 / 18%);
|
|
}
|
|
|
|
.metric-card {
|
|
height: 120px;
|
|
padding: 16px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.metric-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
color: #d7e9ff;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.metric-title .iconify {
|
|
font-size: 22px;
|
|
}
|
|
|
|
.metric-value {
|
|
display: flex;
|
|
margin-top: 16px;
|
|
align-items: baseline;
|
|
gap: 18px;
|
|
}
|
|
|
|
.metric-value strong {
|
|
font-size: 40px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.metric-value span {
|
|
color: #a8bfdd;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.metric-card--online .metric-value strong,
|
|
.metric-card--running .metric-value strong,
|
|
.metric-card--online .metric-title,
|
|
.metric-card--running .metric-title {
|
|
color: var(--green);
|
|
}
|
|
|
|
.metric-card--offline .metric-value strong,
|
|
.metric-card--offline .metric-title {
|
|
color: var(--gray);
|
|
}
|
|
|
|
.metric-card--standby .metric-value strong,
|
|
.metric-card--standby .metric-title {
|
|
color: var(--blue);
|
|
}
|
|
|
|
.metric-card--fault {
|
|
border-color: rgb(255 67 84 / 86%);
|
|
}
|
|
|
|
.metric-card--fault .metric-value strong,
|
|
.metric-card--fault .metric-title {
|
|
color: var(--red);
|
|
}
|
|
|
|
.alarm-board {
|
|
height: 120px;
|
|
padding: 12px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.alarm-head,
|
|
.alarm-item {
|
|
display: grid;
|
|
grid-template-columns: 1.1fr 1fr 1fr;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.alarm-head {
|
|
color: #fff;
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.alarm-list {
|
|
display: flex;
|
|
margin-top: 8px;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.alarm-item {
|
|
height: 30px;
|
|
padding: 0 8px;
|
|
overflow: hidden;
|
|
color: #e8f2ff;
|
|
background: linear-gradient(90deg, rgb(102 25 56 / 76%), rgb(33 31 69 / 52%));
|
|
border: 1px solid rgb(255 64 82 / 52%);
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.alarm-item strong {
|
|
overflow: hidden;
|
|
color: #fff;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.alarm-item span:nth-child(2) {
|
|
color: #ff5365;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
}
|
|
|
|
.alarm-item span:nth-child(3) {
|
|
color: #d7aab4;
|
|
text-align: right;
|
|
}
|
|
|
|
.empty-line,
|
|
.empty-table {
|
|
display: grid;
|
|
height: 100%;
|
|
color: var(--muted);
|
|
place-items: center;
|
|
}
|
|
|
|
.detail-panel {
|
|
min-height: 0;
|
|
padding: 14px 16px;
|
|
flex: 1.25;
|
|
}
|
|
|
|
.panel-title {
|
|
display: flex;
|
|
height: 30px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.panel-title h2 {
|
|
margin: 0;
|
|
color: #fff;
|
|
font-size: 22px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.panel-title span {
|
|
color: #89bdf5;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.detail-table {
|
|
display: flex;
|
|
height: calc(100% - 34px);
|
|
min-height: 0;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.table-row {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr 1.4fr 1.4fr 1.4fr 1.4fr;
|
|
min-height: 43px;
|
|
padding: 0 14px;
|
|
align-items: center;
|
|
gap: 12px;
|
|
border-bottom: 1px solid rgb(60 134 212 / 35%);
|
|
}
|
|
|
|
.table-header {
|
|
min-height: 44px;
|
|
color: #98bee8;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.table-body {
|
|
display: flex;
|
|
min-height: 0;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.table-body .table-row {
|
|
color: #dcecff;
|
|
background: rgb(12 52 98 / 42%);
|
|
border: 1px solid rgb(60 134 212 / 48%);
|
|
border-radius: 5px;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.table-row strong {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.table-row--fault {
|
|
color: #ff5768;
|
|
background: linear-gradient(90deg, rgb(108 27 58 / 82%), rgb(11 39 83 / 46%)) !important;
|
|
border-color: rgb(255 64 82 / 80%) !important;
|
|
}
|
|
|
|
.table-row--running strong,
|
|
.table-row--running .rate {
|
|
color: var(--green);
|
|
}
|
|
|
|
.table-row--offline strong {
|
|
color: #96adca;
|
|
}
|
|
|
|
.rate {
|
|
color: #54a7ff;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.status-pill {
|
|
display: inline-flex;
|
|
min-width: 42px;
|
|
height: 24px;
|
|
padding: 0 10px;
|
|
font-style: normal;
|
|
border: 1px solid currentcolor;
|
|
border-radius: 13px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.status-pill--running {
|
|
color: var(--green);
|
|
background: rgb(34 197 94 / 12%);
|
|
}
|
|
|
|
.status-pill--standby {
|
|
color: var(--blue);
|
|
background: rgb(59 130 246 / 12%);
|
|
}
|
|
|
|
.status-pill--fault {
|
|
color: var(--red);
|
|
background: rgb(239 68 68 / 14%);
|
|
}
|
|
|
|
.status-pill--offline {
|
|
color: #b5c4d9;
|
|
background: rgb(148 163 184 / 14%);
|
|
}
|
|
|
|
.chart-row {
|
|
display: grid;
|
|
min-height: 280px;
|
|
flex: 0.9;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.chart-panel {
|
|
min-height: 0;
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
.chart {
|
|
width: 100%;
|
|
height: calc(100% - 34px);
|
|
min-height: 220px;
|
|
}
|
|
|
|
@media (width <= 1600px) {
|
|
.dashboard-header h1 {
|
|
font-size: 34px;
|
|
}
|
|
|
|
.dashboard-header {
|
|
grid-template-columns: 310px 1fr 320px;
|
|
}
|
|
|
|
.metric-card {
|
|
padding: 14px 12px;
|
|
}
|
|
|
|
.metric-value strong {
|
|
font-size: 34px;
|
|
}
|
|
}
|
|
</style>
|