feat(dashboard): 仪表盘增强 — 新增统计卡片+图表+告警列表

新增: 采集成功率/切削总时/运行机床/数据缺失 统计卡片

新增: 产量趋势折线图(近7天) + 车间产量对比柱状图 + 机床状态分布饼图

新增: 最新告警列表(5条)

新增: 4个Mock API + ECharts PieChart/Legend按需导入

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
main
haoliang 1 week ago
parent 6a381463ec
commit 8b52b3d872

@ -11,6 +11,10 @@ const mock: MockMethod[] = [
totalMachines: 160, totalMachines: 160,
todayProduction: 2847, todayProduction: 2847,
activeAlerts: 3, activeAlerts: 3,
collectSuccessRate: 99.2,
todayCuttingTime: 580,
runningMachines: 128,
dataMissingMachines: 3,
}, },
}, },
}, },
@ -65,6 +69,66 @@ const mock: MockMethod[] = [
}, },
}, },
}, },
// ===== 新增:产量趋势(近7天) =====
{
url: '/mock-api/admin/dashboard/trend',
method: 'get',
response: {
code: 0,
data: {
items: [
{ date: '2026-04-19', quantity: 3120 },
{ date: '2026-04-20', quantity: 2980 },
{ date: '2026-04-21', quantity: 3450 },
{ date: '2026-04-22', quantity: 3310 },
{ date: '2026-04-23', quantity: 3080 },
{ date: '2026-04-24', quantity: 3260 },
{ date: '2026-04-25', quantity: 2847 },
],
},
},
},
// ===== 新增:车间产量对比 =====
{
url: '/mock-api/admin/dashboard/workshop-production',
method: 'get',
response: {
code: 0,
data: {
items: [
{ workshopName: 'A栋', quantity: 1280 },
{ workshopName: 'B栋', quantity: 860 },
{ workshopName: 'C栋', quantity: 707 },
],
},
},
},
// ===== 新增:机床状态分布 =====
{
url: '/mock-api/admin/dashboard/machine-status-distribution',
method: 'get',
response: {
code: 0,
data: { online: 142, offline: 10, disabled: 8 },
},
},
// ===== 新增:最新告警 =====
{
url: '/mock-api/admin/dashboard/recent-alerts',
method: 'get',
response: {
code: 0,
data: {
items: [
{ id: 101, createdAt: '2026-04-25 17:30:00', alertType: 'collect_fail', machineName: '西-1.8', title: '连续采集失败5次', isResolved: false },
{ id: 100, createdAt: '2026-04-25 17:25:00', alertType: 'data_missing', machineName: '东-2.0', title: '数据缺失', isResolved: false },
{ id: 99, createdAt: '2026-04-25 16:50:00', alertType: 'device_offline', machineName: '北-4.1', title: '设备离线超30分钟', isResolved: false },
{ id: 98, createdAt: '2026-04-25 15:10:00', alertType: 'collect_fail', machineName: '东-2.5', title: '采集超时', isResolved: true },
{ id: 97, createdAt: '2026-04-25 14:30:00', alertType: 'new_device', machineName: '未知设备', title: '发现未注册设备device_99', isResolved: false },
],
},
},
},
] ]
export default mock export default mock

@ -188,6 +188,43 @@ export interface DashboardSummary {
totalMachines: number totalMachines: number
todayProduction: number todayProduction: number
activeAlerts: number activeAlerts: number
/** 今日采集成功率(%) */
collectSuccessRate: number
/** 今日切削总时(小时) */
todayCuttingTime: number
/** 运行中机床数(有程序运行) */
runningMachines: number
/** 数据缺失机床数(在线但采集失败) */
dataMissingMachines: number
}
/** 仪表盘产量趋势 */
export interface DashboardTrendItem {
date: string
quantity: number
}
/** 车间产量对比 */
export interface WorkshopProduction {
workshopName: string
quantity: number
}
/** 机床状态分布 */
export interface MachineStatusDistribution {
online: number
offline: number
disabled: number
}
/** 最新告警(仪表盘) */
export interface RecentAlert {
id: number
createdAt: string
alertType: string
machineName: string
title: string
isResolved: boolean
} }
// 机床产量排行榜行数据 // 机床产量排行榜行数据

@ -1,11 +1,11 @@
// ECharts 按需导入工具避免打包整个echarts库提升构建体积 // ECharts 按需导入工具避免打包整个echarts库提升构建体积
// 只注册需要的组件,确保三个页面的图表渲染正常 // 只注册需要的组件,确保三个页面的图表渲染正常
import * as echarts from 'echarts/core' import * as echarts from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts' import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, TitleComponent } from 'echarts/components' import { GridComponent, TooltipComponent, TitleComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
// 注册所需的图表/组件/渲染器 // 注册所需的图表/组件/渲染器
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, TitleComponent, CanvasRenderer]) echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, TitleComponent, LegendComponent, CanvasRenderer])
export default echarts export default echarts

@ -1,7 +1,7 @@
<template> <template>
<div class="dashboard-page"> <div class="dashboard-page">
<!-- 统计卡片 --> <!-- 统计卡片 第1行 -->
<el-row :gutter="20" class="stat-row"> <el-row :gutter="16" class="stat-row">
<el-col :span="6"> <el-col :span="6">
<el-card shadow="hover"> <el-card shadow="hover">
<div class="stat-card"> <div class="stat-card">
@ -29,20 +29,10 @@
</div> </div>
<div class="stat-sub" v-if="collectorStatus.status === 'running'"> {{ formatUptime(collectorStatus.uptimeSeconds) }}</div> <div class="stat-sub" v-if="collectorStatus.status === 'running'"> {{ formatUptime(collectorStatus.uptimeSeconds) }}</div>
</div> </div>
<!-- 采集服务控制按钮 --> <div class="collector-actions">
<div class="collector-actions" style="margin-top: 12px; display: flex; gap: 8px; justify-content: flex-start; flex-wrap: wrap;"> <el-button v-if="collectorStatus.status !== '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 v-if="collectorStatus.status !== 'running'" size="small" type="success" :loading="startLoading" @click="startCollector"> <el-button size="small" type="warning" :loading="refreshLoading" @click="refreshCollectorConfig"></el-button>
启动采集
</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> </div>
</el-card> </el-card>
</el-col> </el-col>
@ -58,22 +48,108 @@
</el-col> </el-col>
</el-row> </el-row>
<!-- 统计卡片 第2行 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">采集成功率</div>
<div class="stat-value">{{ summary.collectSuccessRate }}<span class="stat-unit">%</span></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">今日切削总时</div>
<div class="stat-value">{{ summary.todayCuttingTime }}<span class="stat-unit"> h</span></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">运行机床</div>
<div class="stat-value">{{ summary.runningMachines }}<span class="stat-unit"> </span></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">数据缺失</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天</span></template>
<div ref="trendChartRef" style="height: 260px"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover">
<template #header><span class="card-title">车间产量对比今日</span></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">机床状态分布</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">最新告警</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="20" style="margin-top: 20px;"> <el-row :gutter="16" class="rank-row">
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover"> <el-card shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>机床产量排行 TOP10</span> <span class="card-title">机床产量排行 TOP10</span>
<span class="card-sub">今日</span> <span class="card-sub">今日</span>
</div> </div>
</template> </template>
<el-table :data="machineRank" stripe size="small" style="width: 100%"> <el-table :data="machineRank" stripe size="small" style="width: 100%">
<el-table-column prop="rank" label="排名" width="60" align="center" /> <el-table-column prop="rank" label="排名" width="60" align="center" />
<!-- 机床名称跳转链接mock模式跳转到/mock/machine/{id} -->
<el-table-column label="机床名称" width="120"> <el-table-column label="机床名称" width="120">
<template #default="{ row }"> <template #default="{ row }">
<router-link :to="isMock ? '/mock/machine/' + row.id : '/machine/' + row.id" class="machine-link">{{ row.machineName }}</router-link> <router-link :to="isMock ? '/mock/machine/' + row.machineId : '/machine/' + row.machineId" class="machine-link">{{ row.machineName }}</router-link>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="program" label="当前程序" show-overflow-tooltip /> <el-table-column prop="program" label="当前程序" show-overflow-tooltip />
@ -90,7 +166,7 @@
<el-card shadow="hover"> <el-card shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>工人产量排行 TOP10</span> <span class="card-title">工人产量排行 TOP10</span>
<span class="card-sub">今日</span> <span class="card-sub">今日</span>
</div> </div>
</template> </template>
@ -107,61 +183,53 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import request from '@/utils/request' import request from '@/utils/request'
import { useMockMode } from '@/composables/useMockMode' import { useMockMode } from '@/composables/useMockMode'
import type { ApiResponse, DashboardSummary, CollectorStatus, MachineRankRow, WorkerRankRow } from '@/types' 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 { isMock } = useMockMode()
const summary = ref<DashboardSummary>({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0 } as DashboardSummary) const summary = ref<DashboardSummary>({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0, collectSuccessRate: 0, todayCuttingTime: 0, runningMachines: 0, dataMissingMachines: 0 })
const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0 }) const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0 })
const machineRank = ref<MachineRankRow[]>([]) const machineRank = ref<MachineRankRow[]>([])
const workerRank = ref<WorkerRankRow[]>([]) 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 startLoading = ref(false)
const stopLoading = ref(false) const stopLoading = ref(false)
const refreshLoading = ref(false) const refreshLoading = ref(false)
let refreshTimer: number | undefined let refreshTimer: number | 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() { async function startCollector() {
if (startLoading.value) return if (startLoading.value) return
startLoading.value = true startLoading.value = true
try { try { await request.post('/admin/collector/start'); await loadData() } catch { /* */ } finally { startLoading.value = false }
await request.post('/admin/collector/start')
await loadData()
} catch (e) {
//
} finally {
startLoading.value = false
}
} }
//
async function stopCollector() { async function stopCollector() {
if (stopLoading.value) return if (stopLoading.value) return
stopLoading.value = true stopLoading.value = true
try { try { await request.post('/admin/collector/stop'); await loadData() } catch { /* */ } finally { stopLoading.value = false }
await request.post('/admin/collector/stop')
await loadData()
} catch {
} finally {
stopLoading.value = false
}
} }
//
async function refreshCollectorConfig() { async function refreshCollectorConfig() {
if (refreshLoading.value) return if (refreshLoading.value) return
refreshLoading.value = true refreshLoading.value = true
try { try { await request.post('/admin/collector/refresh'); await loadData() } catch { /* */ } finally { refreshLoading.value = false }
await request.post('/admin/collector/refresh')
await loadData()
} catch {
} finally {
refreshLoading.value = false
}
} }
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
@ -172,40 +240,127 @@ function formatUptime(seconds: number): string {
return `${hours}` return `${hours}`
} }
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 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' },
}],
})
}
//
if (workshopChartRef.value && workshopData.value.length) {
workshopChart = echarts.init(workshopChartRef.value)
workshopChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: workshopData.value.map(i => i.workshopName), axisLabel: { fontSize: 12 } },
yAxis: { type: 'value', axisLabel: { fontSize: 12 } },
series: [{
type: 'bar', data: workshopData.value.map(i => i.quantity),
itemStyle: { color: '#67C23A', borderRadius: [4, 4, 0, 0] }, barWidth: '40%',
}],
})
}
//
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 loadData() { async function loadData() {
const [summaryRes, collectorRes, machineRankRes, workerRankRes]: [ApiResponse<DashboardSummary>, ApiResponse<CollectorStatus>, ApiResponse<{ items: MachineRankRow[] }>, ApiResponse<{ items: WorkerRankRow[] }> ] = await Promise.all([ try {
const [summaryRes, collectorRes, machineRankRes, workerRankRes, trendRes, workshopRes, statusRes, alertsRes]: [
ApiResponse<DashboardSummary>, ApiResponse<CollectorStatus>, ApiResponse<{ items: MachineRankRow[] }>,
ApiResponse<{ items: WorkerRankRow }>, ApiResponse<{ items: DashboardTrendItem[] }>,
ApiResponse<{ items: WorkshopProduction }>, ApiResponse<MachineStatusDistribution>,
ApiResponse<{ items: RecentAlert[] }>
] = await Promise.all([
request.get('/admin/dashboard/summary'), request.get('/admin/dashboard/summary'),
request.get('/admin/collector/status'), request.get('/admin/collector/status'),
request.get('/admin/dashboard/machine-rank'), request.get('/admin/dashboard/machine-rank'),
request.get('/admin/dashboard/worker-rank'), request.get('/admin/dashboard/worker-rank'),
request.get('/admin/dashboard/trend'),
request.get('/admin/dashboard/workshop-production'),
request.get('/admin/dashboard/machine-status-distribution'),
request.get('/admin/dashboard/recent-alerts'),
]) ])
summary.value = summaryRes.data || summary.value summary.value = summaryRes.data || summary.value
collectorStatus.value = collectorRes.data || collectorStatus.value collectorStatus.value = collectorRes.data || collectorStatus.value
machineRank.value = machineRankRes.data?.items || [] machineRank.value = machineRankRes.data?.items || []
workerRank.value = workerRankRes.data?.items || [] workerRank.value = workerRankRes.data?.items || []
trendData.value = trendRes.data?.items || []
workshopData.value = workshopRes.data?.items || []
statusDist.value = statusRes.data || statusDist.value
recentAlerts.value = alertsRes.data?.items || []
disposeCharts()
await nextTick()
initCharts()
} catch {
//
}
} }
//
onMounted(() => { onMounted(() => {
loadData() loadData()
// 30
refreshTimer = window.setInterval(loadData, 30000) as unknown as number refreshTimer = window.setInterval(loadData, 30000) as unknown as number
}) })
onUnmounted(() => { onUnmounted(() => {
if (typeof refreshTimer === 'number') { if (typeof refreshTimer === 'number') clearInterval(refreshTimer)
clearInterval(refreshTimer) disposeCharts()
}
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.dashboard-page { .dashboard-page {
.stat-row { .stat-row {
.el-card { margin-bottom: 16px;
height: 100%;
} .el-card { height: 100%; }
}
.stat-card { .stat-card {
text-align: center; text-align: center;
@ -241,6 +396,26 @@ onUnmounted(() => {
} }
} }
.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;
}
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -251,6 +426,7 @@ onUnmounted(() => {
color: #909399; color: #909399;
} }
} }
.machine-link { .machine-link {
color: var(--el-color-primary); color: var(--el-color-primary);
cursor: pointer; cursor: pointer;

Loading…
Cancel
Save