|
|
<template>
|
|
|
<div class="dashboard-page">
|
|
|
<!-- 统计卡片 第1行 -->
|
|
|
<el-row :gutter="16" class="stat-row">
|
|
|
<el-col :span="6">
|
|
|
<el-card shadow="hover">
|
|
|
<div class="stat-card">
|
|
|
<div class="stat-label">
|
|
|
在线机床数
|
|
|
<el-tooltip content="当前网络连通的机床数量占所有已启用机床总数的比例。系统通过Ping检测机床网络,连通即为在线。" placement="top">
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip>
|
|
|
</div>
|
|
|
<div class="stat-value">{{ summary.onlineCount }}<span class="stat-unit"> / {{ summary.totalMachines }}</span></div>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="6">
|
|
|
<el-card shadow="hover">
|
|
|
<div class="stat-card">
|
|
|
<div class="stat-label">
|
|
|
今日总产量
|
|
|
<el-tooltip content="今日所有机床加工完成的零件总数,包含手工修正后的数值。系统以NC程序名区分零件,切换程序时自动结算上一段产量。" placement="top">
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip>
|
|
|
</div>
|
|
|
<div class="stat-value">{{ summary.todayProduction?.toLocaleString() }}</div>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="6">
|
|
|
<el-card shadow="hover">
|
|
|
<div class="stat-card">
|
|
|
<div class="stat-label">
|
|
|
采集服务
|
|
|
<el-tooltip content="数据采集服务的运行状态。服务每30秒向系统上报一次心跳,超过90秒未上报则判定为停止。" placement="top">
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip>
|
|
|
</div>
|
|
|
<div class="stat-value">
|
|
|
<el-tag :type="collectorTagType" size="small">
|
|
|
{{ collectorStatusText }}
|
|
|
</el-tag>
|
|
|
</div>
|
|
|
<div class="stat-sub" v-if="collectorStatus.serviceStatus === 'Running' && collectorStatus.status === 'running'">运行 {{ formatUptime(collectorStatus.uptimeSeconds) }}</div>
|
|
|
</div>
|
|
|
<div class="collector-actions">
|
|
|
<el-button v-if="collectorStatus.serviceStatus !== 'Running'" size="small" type="success" :loading="startLoading" @click="startCollector">启动采集</el-button>
|
|
|
<el-button v-if="collectorStatus.status === 'running'" size="small" type="danger" :loading="stopLoading" @click="stopCollector">停止采集</el-button>
|
|
|
<el-button size="small" type="warning" :loading="refreshLoading" @click="refreshCollectorConfig">刷新配置</el-button>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="6">
|
|
|
<el-card shadow="hover">
|
|
|
<div class="stat-card">
|
|
|
<div class="stat-label">
|
|
|
活跃告警
|
|
|
<el-tooltip content="当前未处理的告警总数。包括:采集失败、数据缺失、设备离线、发现未注册设备。" placement="top">
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip>
|
|
|
</div>
|
|
|
<div class="stat-value alert-value" @click="$router.push(isMock ? '/mock/alert' : '/alert')">
|
|
|
{{ summary.activeAlerts }}
|
|
|
</div>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
</el-row>
|
|
|
|
|
|
<!-- 统计卡片 第2行 -->
|
|
|
<el-row :gutter="16" class="stat-row">
|
|
|
<el-col :span="8">
|
|
|
<el-card shadow="hover">
|
|
|
<div class="stat-card">
|
|
|
<div class="stat-label">
|
|
|
采集成功率
|
|
|
<el-tooltip content="今日采集成功的次数占总采集次数的百分比。每次采集一个地址算一次,一个地址可能包含多台机床的数据。" placement="top">
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip>
|
|
|
</div>
|
|
|
<div class="stat-value">{{ formatNumber(summary.collectSuccessRate) }}<span class="stat-unit">%</span></div>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="8">
|
|
|
<el-card shadow="hover">
|
|
|
<div class="stat-card">
|
|
|
<div class="stat-label">
|
|
|
运行机床
|
|
|
<el-tooltip content="当前正在执行加工程序的机床数量。与「在线」的区别:在线表示网络连通,运行表示正在加工零件。" placement="top">
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip>
|
|
|
</div>
|
|
|
<div class="stat-value">{{ summary.runningMachines }}<span class="stat-unit"> 台</span></div>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="8">
|
|
|
<el-card shadow="hover">
|
|
|
<div class="stat-card">
|
|
|
<div class="stat-label">
|
|
|
数据缺失
|
|
|
<el-tooltip content="网络连通但未能成功采集到数据的机床数量。这些机床的产量在报表中显示为「-」而不是0,不计入日产量汇总。" placement="top">
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip>
|
|
|
</div>
|
|
|
<div class="stat-value" :class="{ 'alert-value': summary.dataMissingMachines > 0 }">{{ summary.dataMissingMachines }}<span class="stat-unit"> 台</span></div>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
</el-row>
|
|
|
|
|
|
<!-- 图表区:产量趋势 + 车间对比 -->
|
|
|
<el-row :gutter="16" class="chart-row">
|
|
|
<el-col :span="12">
|
|
|
<el-card shadow="hover">
|
|
|
<template #header><span class="card-title">产量趋势(近7天)<el-tooltip content="最近7天每天的零件总产量变化趋势。" placement="top"><span class="info-icon">ⓘ</span></el-tooltip></span></template>
|
|
|
<div ref="trendChartRef" style="height: 260px"></div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="12">
|
|
|
<el-card shadow="hover">
|
|
|
<template #header>
|
|
|
<div class="card-header">
|
|
|
<span class="card-title">车间平均单机产量({{ workshopDateLabel }})<el-tooltip placement="top">
|
|
|
<template #content>
|
|
|
<div style="max-width: 360px; line-height: 1.6">
|
|
|
<b>统计规则:</b>车间总产量 ÷ 车间机床数 ÷ 天数<br/><br/>
|
|
|
<b>数据来源:</b>系统每日凌晨1:00自动执行日终汇总,将每台机床当天所有产量分段按程序名合并计算,生成日产量记录。多天范围取日均单机产量。<br/><br/>
|
|
|
<b>产量计算:</b>以NC程序名标识零件,程序切换时自动结算上一段产量。同程序多次出现时分段记录、日汇总合并。含手工修正值。
|
|
|
</div>
|
|
|
</template>
|
|
|
<span class="info-icon">ⓘ</span>
|
|
|
</el-tooltip></span>
|
|
|
<div class="date-filter">
|
|
|
<el-radio-group v-model="workshopDateType" size="small" @change="onWorkshopDateChange">
|
|
|
<el-radio-button value="today">今日</el-radio-button>
|
|
|
<el-radio-button value="yesterday">昨日</el-radio-button>
|
|
|
<el-radio-button value="last3">近3天</el-radio-button>
|
|
|
<el-radio-button value="last7">近7天</el-radio-button>
|
|
|
<el-radio-button value="custom">自定义</el-radio-button>
|
|
|
</el-radio-group>
|
|
|
<el-date-picker v-if="workshopDateType === 'custom'" v-model="workshopDateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d > new Date()" style="margin-left: 8px" @change="loadWorkshopData" />
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
<div ref="workshopChartRef" style="height: 260px"></div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
</el-row>
|
|
|
|
|
|
<!-- 信息区:机床状态分布 + 最新告警 -->
|
|
|
<el-row :gutter="16" class="info-row">
|
|
|
<el-col :span="8">
|
|
|
<el-card shadow="hover">
|
|
|
<template #header><span class="card-title">机床状态分布<el-tooltip content="在线:网络Ping检测通过;离线:网络Ping检测不通;停用:已手动关闭的机床。" placement="top"><span class="info-icon">ⓘ</span></el-tooltip></span></template>
|
|
|
<div ref="statusPieRef" style="height: 260px"></div>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="16">
|
|
|
<el-card shadow="hover">
|
|
|
<template #header>
|
|
|
<div class="card-header">
|
|
|
<span class="card-title">最新告警<el-tooltip content="最近5条未处理的告警记录,按时间倒序排列。类型包括:采集失败、数据缺失、设备离线、发现未注册设备。" placement="top"><span class="info-icon">ⓘ</span></el-tooltip></span>
|
|
|
<el-button link type="primary" @click="$router.push(isMock ? '/mock/alert' : '/alert')">查看全部</el-button>
|
|
|
</div>
|
|
|
</template>
|
|
|
<el-table :data="recentAlerts" stripe size="small" style="width: 100%">
|
|
|
<el-table-column prop="createdAt" label="时间" width="160" />
|
|
|
<el-table-column label="类型" width="100" align="center">
|
|
|
<template #default="{ row }">
|
|
|
<el-tag :type="alertTypeTag(row.alertType)" size="small">{{ alertTypeLabel(row.alertType) }}</el-tag>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column prop="machineName" label="机床" width="100" />
|
|
|
<el-table-column prop="title" label="告警内容" show-overflow-tooltip />
|
|
|
<el-table-column label="状态" width="80" align="center">
|
|
|
<template #default="{ row }">
|
|
|
<el-tag :type="row.isResolved ? 'success' : 'danger'" size="small">{{ row.isResolved ? '已处理' : '未处理' }}</el-tag>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
</el-row>
|
|
|
|
|
|
<!-- 排行表格 -->
|
|
|
<el-row :gutter="16" class="rank-row">
|
|
|
<el-col :span="12">
|
|
|
<el-card shadow="hover">
|
|
|
<template #header>
|
|
|
<div class="card-header">
|
|
|
<span class="card-title">机床产量排行 TOP
|
|
|
<el-select v-model="machineTopN" size="small" style="width: 72px" @change="loadMachineRankData">
|
|
|
<el-option :value="5" label="5" />
|
|
|
<el-option :value="10" label="10" />
|
|
|
<el-option :value="20" label="20" />
|
|
|
<el-option :value="50" label="50" />
|
|
|
<el-option :value="100" label="100" />
|
|
|
</el-select>
|
|
|
<el-tooltip content="单台机床加工零件数排列,同时显示当前正在执行的程序名。" placement="top"><span class="info-icon">ⓘ</span></el-tooltip>
|
|
|
</span>
|
|
|
<div class="date-filter">
|
|
|
<el-radio-group v-model="machineSortOrder" size="small" @change="loadMachineRankData" style="margin-right: 8px">
|
|
|
<el-radio-button value="asc">正序</el-radio-button>
|
|
|
<el-radio-button value="desc">倒序</el-radio-button>
|
|
|
</el-radio-group>
|
|
|
<el-radio-group v-model="machineDateType" size="small" @change="onMachineDateChange">
|
|
|
<el-radio-button value="today">今日</el-radio-button>
|
|
|
<el-radio-button value="yesterday">昨日</el-radio-button>
|
|
|
<el-radio-button value="last3">近3天</el-radio-button>
|
|
|
<el-radio-button value="last7">近7天</el-radio-button>
|
|
|
<el-radio-button value="custom">自定义</el-radio-button>
|
|
|
</el-radio-group>
|
|
|
<el-date-picker v-if="machineDateType === 'custom'" v-model="machineDateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d > new Date()" style="margin-left: 8px" @change="loadMachineRankData" />
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
<el-table :data="machineRank" stripe size="small" style="width: 100%">
|
|
|
<el-table-column prop="rank" label="排名" width="60" align="center" />
|
|
|
<el-table-column label="机床名称" width="120">
|
|
|
<template #default="{ row }">
|
|
|
<router-link :to="isMock ? '/mock/machine/' + row.machineId : '/machine/' + row.machineId" class="machine-link">{{ row.machineName }}</router-link>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column prop="program" label="当前程序" show-overflow-tooltip />
|
|
|
<el-table-column label="产量" width="80" align="center">
|
|
|
<template #default="{ row }">{{ formatNumber(row.quantity) }}</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column label="状态" width="70" align="center">
|
|
|
<template #default="{ row }">
|
|
|
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '在线' : '离线' }}</el-tag>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
<el-col :span="12">
|
|
|
<el-card shadow="hover">
|
|
|
<template #header>
|
|
|
<div class="card-header">
|
|
|
<span class="card-title">工人产量排行 TOP
|
|
|
<el-select v-model="workerTopN" size="small" style="width: 72px" @change="loadWorkerRankData">
|
|
|
<el-option :value="5" label="5" />
|
|
|
<el-option :value="10" label="10" />
|
|
|
<el-option :value="20" label="20" />
|
|
|
<el-option :value="50" label="50" />
|
|
|
<el-option :value="100" label="100" />
|
|
|
</el-select>
|
|
|
<el-tooltip content="工人所绑定的所有机床产量合计。" placement="top"><span class="info-icon">ⓘ</span></el-tooltip>
|
|
|
</span>
|
|
|
<div class="date-filter">
|
|
|
<el-radio-group v-model="workerSortOrder" size="small" @change="loadWorkerRankData" style="margin-right: 8px">
|
|
|
<el-radio-button value="asc">正序</el-radio-button>
|
|
|
<el-radio-button value="desc">倒序</el-radio-button>
|
|
|
</el-radio-group>
|
|
|
<el-radio-group v-model="workerDateType" size="small" @change="onWorkerDateChange">
|
|
|
<el-radio-button value="today">今日</el-radio-button>
|
|
|
<el-radio-button value="yesterday">昨日</el-radio-button>
|
|
|
<el-radio-button value="last3">近3天</el-radio-button>
|
|
|
<el-radio-button value="last7">近7天</el-radio-button>
|
|
|
<el-radio-button value="custom">自定义</el-radio-button>
|
|
|
</el-radio-group>
|
|
|
<el-date-picker v-if="workerDateType === 'custom'" v-model="workerDateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d > new Date()" style="margin-left: 8px" @change="loadWorkerRankData" />
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
<el-table :data="workerRank" stripe size="small" style="width: 100%">
|
|
|
<el-table-column prop="rank" label="排名" width="60" align="center" />
|
|
|
<el-table-column prop="workerName" label="工人姓名" />
|
|
|
<el-table-column prop="machineCount" label="绑定机床" width="100" align="center" />
|
|
|
<el-table-column label="总产量" width="100" align="center">
|
|
|
<template #default="{ row }">{{ formatNumber(row.totalQuantity) }}</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
</el-card>
|
|
|
</el-col>
|
|
|
</el-row>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
import request from '@/utils/request'
|
|
|
import { useMockMode } from '@/composables/useMockMode'
|
|
|
import echarts from '@/utils/echarts'
|
|
|
import type { ECharts } from 'echarts/core'
|
|
|
import type { ApiResponse, DashboardSummary, CollectorStatus, MachineRankRow, WorkerRankRow, DashboardTrendItem, WorkshopProduction, MachineStatusDistribution, RecentAlert } from '@/types'
|
|
|
|
|
|
const { isMock } = useMockMode()
|
|
|
|
|
|
const summary = ref<DashboardSummary>({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0, collectSuccessRate: 0, runningMachines: 0, dataMissingMachines: 0 })
|
|
|
const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0, serviceStatus: 'NotInstalled', serviceName: '' })
|
|
|
const machineRank = ref<MachineRankRow[]>([])
|
|
|
const workerRank = ref<WorkerRankRow[]>([])
|
|
|
const trendData = ref<DashboardTrendItem[]>([])
|
|
|
const workshopData = ref<WorkshopProduction[]>([])
|
|
|
const statusDist = ref<MachineStatusDistribution>({ online: 0, offline: 0, disabled: 0 })
|
|
|
const recentAlerts = ref<RecentAlert[]>([])
|
|
|
|
|
|
const startLoading = ref(false)
|
|
|
const stopLoading = ref(false)
|
|
|
const refreshLoading = ref(false)
|
|
|
|
|
|
// 日期筛选状态
|
|
|
type DateType = 'today' | 'yesterday' | 'last3' | 'last7' | 'custom'
|
|
|
const workshopDateType = ref<DateType>('today')
|
|
|
const workshopDateRange = ref<[string, string]>()
|
|
|
const machineDateType = ref<DateType>('today')
|
|
|
const machineDateRange = ref<[string, string]>()
|
|
|
const workerDateType = ref<DateType>('today')
|
|
|
const workerDateRange = ref<[string, string]>()
|
|
|
|
|
|
// TOP N 和排序
|
|
|
const machineTopN = ref(10)
|
|
|
const machineSortOrder = ref<'asc' | 'desc'>('asc')
|
|
|
const workerTopN = ref(10)
|
|
|
const workerSortOrder = ref<'asc' | 'desc'>('asc')
|
|
|
|
|
|
const dateLabels: Record<DateType, string> = { today: '今日', yesterday: '昨日', last3: '近3天', last7: '近7天', custom: '自定义' }
|
|
|
const workshopDateLabel = computed(() => dateLabels[workshopDateType.value])
|
|
|
const machineDateLabel = computed(() => dateLabels[machineDateType.value])
|
|
|
const workerDateLabel = computed(() => dateLabels[workerDateType.value])
|
|
|
|
|
|
// 车间平均单机产量的日均单位
|
|
|
const workshopDays = computed(() => {
|
|
|
const { startDate, endDate } = getDateRange(workshopDateType.value, workshopDateRange.value)
|
|
|
return Math.max(1, Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000) + 1)
|
|
|
})
|
|
|
const workshopUnit = computed(() => workshopDays.value > 1 ? '件/台/天' : '件/台')
|
|
|
const workshopUnitLabel = computed(() => workshopDays.value > 1 ? '日均' : '平均')
|
|
|
|
|
|
function getDateRange(type: DateType, customRange?: [string, string]): { startDate: string; endDate: string } {
|
|
|
const today = new Date()
|
|
|
const fmt = (d: Date) => d.toISOString().slice(0, 10)
|
|
|
const endDate = fmt(today)
|
|
|
switch (type) {
|
|
|
case 'today': return { startDate: endDate, endDate }
|
|
|
case 'yesterday': { const y = new Date(today); y.setDate(y.getDate() - 1); const ys = fmt(y); return { startDate: ys, endDate: ys } }
|
|
|
case 'last3': { const d = new Date(today); d.setDate(d.getDate() - 2); return { startDate: fmt(d), endDate } }
|
|
|
case 'last7': { const d = new Date(today); d.setDate(d.getDate() - 6); return { startDate: fmt(d), endDate } }
|
|
|
case 'custom': return { startDate: customRange?.[0] || endDate, endDate: customRange?.[1] || endDate }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function onWorkshopDateChange() { workshopDateType.value !== 'custom' ? loadWorkshopData() : undefined }
|
|
|
function onMachineDateChange() { machineDateType.value !== 'custom' ? loadMachineRankData() : undefined }
|
|
|
function onWorkerDateChange() { workerDateType.value !== 'custom' ? loadWorkerRankData() : undefined }
|
|
|
|
|
|
|
|
|
// ECharts refs
|
|
|
const trendChartRef = ref<HTMLElement>()
|
|
|
const workshopChartRef = ref<HTMLElement>()
|
|
|
const statusPieRef = ref<HTMLElement>()
|
|
|
let trendChart: ECharts | null = null
|
|
|
let workshopChart: ECharts | null = null
|
|
|
let statusPie: ECharts | null = null
|
|
|
|
|
|
async function startCollector() {
|
|
|
if (startLoading.value) return
|
|
|
startLoading.value = true
|
|
|
try {
|
|
|
// 在发起启动前,若已安装且正在运行,前端应给出友好提示
|
|
|
if (collectorStatus.value.serviceStatus && collectorStatus.value.serviceStatus === 'Running') {
|
|
|
ElMessage.info('采集服务已在运行中');
|
|
|
return;
|
|
|
}
|
|
|
if (collectorStatus.value.serviceStatus && collectorStatus.value.serviceStatus === 'NotInstalled') {
|
|
|
ElMessage.warning('采集服务未安装,请运行 install.ps1 安装脚本');
|
|
|
return;
|
|
|
}
|
|
|
await request.post('/admin/collector/start');
|
|
|
ElMessage.success('采集服务已启动');
|
|
|
await loadData();
|
|
|
} catch { /* request拦截器已显示错误 */ } finally { startLoading.value = false }
|
|
|
}
|
|
|
|
|
|
async function stopCollector() {
|
|
|
if (stopLoading.value) return
|
|
|
stopLoading.value = true
|
|
|
try { await request.post('/admin/collector/stop'); ElMessage.success('采集服务已停止'); await loadData() } catch { /* request拦截器已显示错误 */ } finally { stopLoading.value = false }
|
|
|
}
|
|
|
|
|
|
async function refreshCollectorConfig() {
|
|
|
if (refreshLoading.value) return
|
|
|
refreshLoading.value = true
|
|
|
try { await request.post('/admin/collector/refresh'); ElMessage.success('配置已刷新'); await loadData() } catch { /* request拦截器已显示错误 */ } finally { refreshLoading.value = false }
|
|
|
}
|
|
|
|
|
|
function formatUptime(seconds: number | undefined): string {
|
|
|
if (!seconds) return '-'
|
|
|
const days = Math.floor(seconds / 86400)
|
|
|
const hours = Math.floor((seconds % 86400) / 3600)
|
|
|
if (days > 0) return `${days}天${hours}时`
|
|
|
return `${hours}时`
|
|
|
}
|
|
|
|
|
|
function formatNumber(val: number | undefined | null): string {
|
|
|
if (val == null) return '-'
|
|
|
return Number(val).toFixed(2)
|
|
|
}
|
|
|
|
|
|
// 采集服务状态:综合心跳 + Windows服务状态
|
|
|
const collectorTagType = computed(() => {
|
|
|
const { serviceStatus, status } = collectorStatus.value
|
|
|
if (serviceStatus === 'Running' && status === 'running') return 'success'
|
|
|
if (serviceStatus === 'Running' && status !== 'running') return 'warning' // 进程在但心跳超时
|
|
|
if (serviceStatus === 'NotInstalled') return 'danger'
|
|
|
if (serviceStatus === 'StartFailed') return 'danger'
|
|
|
return 'warning'
|
|
|
})
|
|
|
|
|
|
const collectorStatusText = computed(() => {
|
|
|
const { serviceStatus, status } = collectorStatus.value
|
|
|
if (serviceStatus === 'Running' && status === 'running') return '运行中'
|
|
|
if (serviceStatus === 'Running' && status !== 'running') return '心跳超时'
|
|
|
if (serviceStatus === 'NotInstalled') return '未安装'
|
|
|
if (serviceStatus === 'Stopped') return '已停止'
|
|
|
if (serviceStatus === 'Starting') return '启动中'
|
|
|
if (serviceStatus === 'StartFailed') return '启动失败'
|
|
|
return serviceStatus || '-'
|
|
|
})
|
|
|
|
|
|
function alertTypeTag(type: string): string {
|
|
|
const map: Record<string, string> = { collect_fail: 'danger', data_missing: 'warning', device_offline: 'danger', new_device: 'info' }
|
|
|
return map[type] || 'warning'
|
|
|
}
|
|
|
|
|
|
function alertTypeLabel(type: string): string {
|
|
|
const map: Record<string, string> = { collect_fail: '采集失败', data_missing: '数据缺失', device_offline: '设备离线', new_device: '新设备' }
|
|
|
return map[type] || type
|
|
|
}
|
|
|
|
|
|
function initWorkshopChart() {
|
|
|
if (workshopChartRef.value && workshopData.value.length) {
|
|
|
const unit = workshopUnit.value
|
|
|
const unitLabel = workshopUnitLabel.value
|
|
|
workshopChart = echarts.init(workshopChartRef.value)
|
|
|
workshopChart.setOption({
|
|
|
tooltip: {
|
|
|
trigger: 'axis',
|
|
|
formatter: (params: any) => {
|
|
|
const d = workshopData.value[params[0].dataIndex]
|
|
|
return `${d.workshopName}<br/>${unitLabel}产量: ${params[0].value} ${unit}<br/>总产量: ${d.quantity} 件<br/>机床数: ${d.machineCount} 台`
|
|
|
},
|
|
|
},
|
|
|
grid: { left: 60, right: 20, top: 20, bottom: 30 },
|
|
|
xAxis: { type: 'category', data: workshopData.value.map(i => i.workshopName), axisLabel: { fontSize: 12 } },
|
|
|
yAxis: { type: 'value', name: unit, axisLabel: { fontSize: 12 } },
|
|
|
series: [{
|
|
|
type: 'bar', data: workshopData.value.map(i => i.avgQuantity),
|
|
|
itemStyle: { color: '#67C23A', borderRadius: [4, 4, 0, 0] }, barWidth: '40%',
|
|
|
label: { show: true, position: 'top', formatter: `{c} ${unit}`, fontSize: 12 },
|
|
|
}],
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function initCharts() {
|
|
|
// 产量趋势折线图
|
|
|
if (trendChartRef.value && trendData.value.length) {
|
|
|
trendChart = echarts.init(trendChartRef.value)
|
|
|
trendChart.setOption({
|
|
|
tooltip: { trigger: 'axis' },
|
|
|
grid: { left: 50, right: 20, top: 20, bottom: 30 },
|
|
|
xAxis: { type: 'category', data: trendData.value.map(i => i.date.slice(5)), axisLabel: { fontSize: 12 } },
|
|
|
yAxis: { type: 'value', axisLabel: { fontSize: 12 } },
|
|
|
series: [{
|
|
|
type: 'line', data: trendData.value.map(i => i.quantity), smooth: true,
|
|
|
areaStyle: { opacity: 0.15 }, itemStyle: { color: '#409EFF' },
|
|
|
}],
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// 车间平均单机产量柱状图
|
|
|
initWorkshopChart()
|
|
|
|
|
|
// 机床状态饼图
|
|
|
if (statusPieRef.value) {
|
|
|
const d = statusDist.value
|
|
|
statusPie = echarts.init(statusPieRef.value)
|
|
|
statusPie.setOption({
|
|
|
tooltip: { trigger: 'item', formatter: '{b}: {c}台 ({d}%)' },
|
|
|
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
|
|
|
series: [{
|
|
|
type: 'pie', radius: ['40%', '65%'], center: ['50%', '45%'],
|
|
|
label: { show: true, formatter: '{b}\n{c}台', fontSize: 12 },
|
|
|
data: [
|
|
|
{ value: d.online, name: '在线', itemStyle: { color: '#67C23A' } },
|
|
|
{ value: d.offline, name: '离线', itemStyle: { color: '#909399' } },
|
|
|
{ value: d.disabled, name: '停用', itemStyle: { color: '#F56C6C' } },
|
|
|
],
|
|
|
}],
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function disposeCharts() {
|
|
|
trendChart?.dispose()
|
|
|
workshopChart?.dispose()
|
|
|
statusPie?.dispose()
|
|
|
trendChart = null
|
|
|
workshopChart = null
|
|
|
statusPie = null
|
|
|
}
|
|
|
|
|
|
async function loadWorkshopData() {
|
|
|
try {
|
|
|
const { startDate, endDate } = getDateRange(workshopDateType.value, workshopDateRange.value)
|
|
|
const res = await request.get<{ items: WorkshopProduction[] }>('/admin/dashboard/workshop-production', { params: { startDate, endDate } })
|
|
|
workshopData.value = res.data?.items || []
|
|
|
workshopChart?.dispose(); workshopChart = null
|
|
|
await nextTick(); initWorkshopChart()
|
|
|
} catch { /* */ }
|
|
|
}
|
|
|
|
|
|
async function loadMachineRankData() {
|
|
|
try {
|
|
|
const { startDate, endDate } = getDateRange(machineDateType.value, machineDateRange.value)
|
|
|
const res: ApiResponse<{ items: MachineRankRow[] }> = await request.get('/admin/dashboard/machine-rank', { params: { startDate, endDate, top: machineTopN.value, sortOrder: machineSortOrder.value } })
|
|
|
machineRank.value = res.data?.items || []
|
|
|
} catch { /* */ }
|
|
|
}
|
|
|
|
|
|
async function loadWorkerRankData() {
|
|
|
try {
|
|
|
const { startDate, endDate } = getDateRange(workerDateType.value, workerDateRange.value)
|
|
|
const res: ApiResponse<{ items: WorkerRankRow[] }> = await request.get('/admin/dashboard/worker-rank', { params: { startDate, endDate, top: workerTopN.value, sortOrder: workerSortOrder.value } })
|
|
|
workerRank.value = res.data?.items || []
|
|
|
} catch { /* */ }
|
|
|
}
|
|
|
|
|
|
async function loadData() {
|
|
|
try {
|
|
|
const [summaryRes, collectorRes, trendRes, statusRes, alertsRes] = await Promise.all([
|
|
|
request.get<DashboardSummary>('/admin/dashboard/summary'),
|
|
|
request.get<CollectorStatus>('/admin/collector/status'),
|
|
|
request.get<{ items: DashboardTrendItem[] }>('/admin/dashboard/trend'),
|
|
|
request.get<MachineStatusDistribution>('/admin/dashboard/machine-status-distribution'),
|
|
|
request.get<{ items: RecentAlert[] }>('/admin/dashboard/recent-alerts'),
|
|
|
])
|
|
|
summary.value = summaryRes.data || summary.value
|
|
|
collectorStatus.value = collectorRes.data || collectorStatus.value
|
|
|
trendData.value = trendRes.data?.items || []
|
|
|
statusDist.value = statusRes.data || statusDist.value
|
|
|
recentAlerts.value = alertsRes.data?.items || []
|
|
|
|
|
|
disposeCharts()
|
|
|
await nextTick()
|
|
|
initCharts()
|
|
|
|
|
|
// 日期筛选的三个区域独立加载
|
|
|
await Promise.all([loadWorkshopData(), loadMachineRankData(), loadWorkerRankData()])
|
|
|
} catch {
|
|
|
// 保持容错
|
|
|
}
|
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
|
loadData()
|
|
|
})
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
disposeCharts()
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
.dashboard-page {
|
|
|
.stat-row {
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
.el-card { height: 100%; }
|
|
|
|
|
|
.stat-card {
|
|
|
text-align: center;
|
|
|
padding: 10px 0;
|
|
|
|
|
|
.stat-label {
|
|
|
font-size: 14px;
|
|
|
color: #909399;
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
.info-icon {
|
|
|
margin-left: 2px;
|
|
|
font-size: 13px;
|
|
|
color: #c0c4cc;
|
|
|
cursor: help;
|
|
|
vertical-align: middle;
|
|
|
|
|
|
&:hover {
|
|
|
color: #409EFF;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.stat-value {
|
|
|
font-size: 28px;
|
|
|
font-weight: bold;
|
|
|
color: #303133;
|
|
|
|
|
|
.stat-unit {
|
|
|
font-size: 14px;
|
|
|
font-weight: normal;
|
|
|
color: #909399;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.stat-sub {
|
|
|
font-size: 12px;
|
|
|
color: #909399;
|
|
|
margin-top: 4px;
|
|
|
}
|
|
|
|
|
|
.alert-value {
|
|
|
color: #e6a23c;
|
|
|
cursor: pointer;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.collector-actions {
|
|
|
margin-top: 12px;
|
|
|
display: flex;
|
|
|
gap: 8px;
|
|
|
justify-content: flex-start;
|
|
|
flex-wrap: wrap;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.chart-row,
|
|
|
.info-row,
|
|
|
.rank-row {
|
|
|
margin-bottom: 16px;
|
|
|
}
|
|
|
|
|
|
.card-title {
|
|
|
font-size: 15px;
|
|
|
font-weight: 500;
|
|
|
|
|
|
.info-icon {
|
|
|
margin-left: 4px;
|
|
|
font-size: 14px;
|
|
|
color: #b0b5bd;
|
|
|
cursor: help;
|
|
|
|
|
|
&:hover {
|
|
|
color: #409EFF;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.card-header {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
|
flex-wrap: wrap;
|
|
|
gap: 8px;
|
|
|
|
|
|
.card-sub {
|
|
|
font-size: 12px;
|
|
|
color: #909399;
|
|
|
}
|
|
|
|
|
|
.date-filter {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
flex-wrap: wrap;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.machine-link {
|
|
|
color: var(--el-color-primary);
|
|
|
cursor: pointer;
|
|
|
text-decoration: none;
|
|
|
}
|
|
|
}
|
|
|
</style>
|