feat(home): 新增 MES 首页统计功能及相关数据结构

pull/871/MERGE
YunaiV 2026-04-06 00:50:01 +08:00
parent 163e722e61
commit 91adaff611
1 changed files with 20 additions and 507 deletions

View File

@ -1,154 +1,18 @@
<template>
<!-- TODO @AI有没办法拆分 vue 组件更好理解 -->
<!-- TODO @AI尽量使用 unocss -->
<div class="mes-home">
<div>
<!-- TODO @AIrow 之间的间距太大了 -->
<!-- Row 1: 核心 KPI 汇总卡片 -->
<el-row :gutter="16" class="mb-16px">
<el-col :xl="6" :lg="6" :md="12" :sm="12" :xs="24" class="mb-16px">
<el-card shadow="hover" class="kpi-card kpi-card--production" @click="handleNavigate('/mes/pro/workorder')">
<div class="kpi-card__content">
<div class="kpi-card__icon">
<Icon icon="ep:document" :size="28" />
</div>
<div class="kpi-card__info">
<div class="kpi-card__title">生产工单</div>
<div class="kpi-card__value">
<CountTo :end-val="summary.workOrderActiveCount" :duration="1500" class="kpi-card__number" />
<span class="kpi-card__unit">进行中</span>
</div>
<div class="kpi-card__extra">
<span>待排产 {{ summary.workOrderPrepareCount }}</span>
<el-divider direction="vertical" />
<span>已完成 {{ summary.workOrderFinishedCount }}</span>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xl="6" :lg="6" :md="12" :sm="12" :xs="24" class="mb-16px">
<el-card shadow="hover" class="kpi-card kpi-card--output" @click="handleNavigate('/mes/pro/feedback')">
<div class="kpi-card__content">
<div class="kpi-card__icon">
<Icon icon="ep:data-analysis" :size="28" />
</div>
<div class="kpi-card__info">
<div class="kpi-card__title">今日产量</div>
<div class="kpi-card__value">
<CountTo :end-val="summary.todayOutput" :duration="1500" class="kpi-card__number" />
<span class="kpi-card__unit"></span>
</div>
<div class="kpi-card__extra">
<span>昨日 {{ summary.yesterdayOutput }} </span>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xl="6" :lg="6" :md="12" :sm="12" :xs="24" class="mb-16px">
<el-card shadow="hover" class="kpi-card kpi-card--quality" @click="handleNavigate('/mes/qc/iqc')">
<div class="kpi-card__content">
<div class="kpi-card__icon">
<Icon icon="ep:circle-check" :size="28" />
</div>
<div class="kpi-card__info">
<div class="kpi-card__title">质量合格率</div>
<div class="kpi-card__value">
<CountTo :end-val="qualityRate" :decimals="1" :duration="1500" class="kpi-card__number" />
<span class="kpi-card__unit">%</span>
</div>
<div class="kpi-card__extra">
<span>合格 {{ summary.todayQualifiedQuantity }}</span>
<el-divider direction="vertical" />
<span>不良 {{ summary.todayUnqualifiedQuantity }}</span>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xl="6" :lg="6" :md="12" :sm="12" :xs="24" class="mb-16px">
<el-card shadow="hover" class="kpi-card kpi-card--equipment" @click="handleNavigate('/mes/dv/machinery')">
<div class="kpi-card__content">
<div class="kpi-card__icon">
<Icon icon="ep:cpu" :size="28" />
</div>
<div class="kpi-card__info">
<div class="kpi-card__title">设备状态</div>
<div class="kpi-card__value">
<CountTo :end-val="summary.machineryProducing" :duration="1500" class="kpi-card__number" />
<span class="kpi-card__unit">/ {{ summary.machineryTotal }} 运行中</span>
</div>
<div class="kpi-card__extra">
<span class="text-red-400">停机 {{ summary.machineryStop }}</span>
<el-divider direction="vertical" />
<span class="text-orange-400">维护 {{ summary.machineryMaintenance }}</span>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<HomeKpiCards :summary="summary" @navigate="handleNavigate" />
<!-- Row 2: 生产趋势 + 待办异常 -->
<el-row :gutter="16" class="mb-16px">
<!-- 生产趋势图 -->
<el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-16px">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span class="card-header__title">生产趋势</span>
<el-radio-group v-model="trendDays" size="small" @change="loadProductionTrend">
<el-radio-button :value="7"> 7 </el-radio-button>
<el-radio-button :value="30"> 30 </el-radio-button>
</el-radio-group>
</div>
</template>
<Echart :options="trendChartOptions" :height="320" />
</el-card>
<HomeProductionTrend ref="productionTrendRef" />
</el-col>
<!-- 待办事项 / 异常提醒 -->
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24" class="mb-16px">
<el-card shadow="hover" class="alert-card">
<template #header>
<div class="card-header">
<span class="card-header__title">待办与异常</span>
</div>
</template>
<div class="alert-list">
<div class="alert-item alert-item--danger" @click="handleNavigate('/mes/pro/andon')">
<div class="alert-item__icon">
<Icon icon="ep:warning-filled" :size="20" />
</div>
<div class="alert-item__content">
<span class="alert-item__label">安灯报警</span>
<span class="alert-item__desc">未处置的安灯呼叫</span>
</div>
<el-badge :value="summary.andonActiveCount" :hidden="!summary.andonActiveCount"
class="alert-item__badge" />
</div>
<div class="alert-item alert-item--warning" @click="handleNavigate('/mes/dv/repair')">
<div class="alert-item__icon">
<Icon icon="ep:set-up" :size="20" />
</div>
<div class="alert-item__content">
<span class="alert-item__label">设备维修</span>
<span class="alert-item__desc">待处理的维修工单</span>
</div>
<el-badge :value="summary.repairActiveCount" :hidden="!summary.repairActiveCount"
class="alert-item__badge" />
</div>
<div class="alert-item alert-item--info" @click="handleNavigate('/mes/pro/workorder')">
<div class="alert-item__icon">
<Icon icon="ep:document-checked" :size="20" />
</div>
<div class="alert-item__content">
<span class="alert-item__label">待排产工单</span>
<span class="alert-item__desc">草稿状态的生产工单</span>
</div>
<el-badge :value="summary.workOrderPrepareCount" :hidden="!summary.workOrderPrepareCount"
class="alert-item__badge" />
</div>
</div>
</el-card>
<HomeAlertPanel :summary="summary" @navigate="handleNavigate" />
</el-col>
</el-row>
@ -156,55 +20,29 @@
<el-row :gutter="16">
<!-- 工单状态分布 -->
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24" class="mb-16px">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span class="card-header__title">工单状态分布</span>
</div>
</template>
<Echart :options="workOrderChartOptions" :height="280" />
</el-card>
<HomeWorkOrderChart ref="workOrderChartRef" />
</el-col>
<!-- 快捷入口 -->
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24" class="mb-16px">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span class="card-header__title">快捷入口</span>
</div>
</template>
<el-row :gutter="16">
<el-col v-for="item in shortcuts" :key="item.name" :span="8" class="mb-16px">
<div class="shortcut-item" @click="handleNavigate(item.url)">
<div class="shortcut-item__icon" :style="{ background: item.bgColor }">
<Icon :icon="item.icon" :size="24" color="#fff" />
</div>
<span class="shortcut-item__name">{{ item.name }}</span>
</div>
</el-col>
</el-row>
</el-card>
<HomeShortcuts @navigate="handleNavigate" />
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup>
import { EChartsOption } from 'echarts'
import { useRouter } from 'vue-router'
import {
MesHomeStatisticsApi,
MesHomeSummaryVO,
MesHomeWorkOrderStatusVO,
MesHomeProductionTrendVO
} from '@/api/mes/home'
import { MesHomeStatisticsApi, MesHomeSummaryVO } from '@/api/mes/home'
import HomeKpiCards from './HomeKpiCards.vue'
import HomeAlertPanel from './HomeAlertPanel.vue'
import HomeProductionTrend from './HomeProductionTrend.vue'
import HomeWorkOrderChart from './HomeWorkOrderChart.vue'
import HomeShortcuts from './HomeShortcuts.vue'
defineOptions({ name: 'MesHome' })
const router = useRouter()
// ========== ==========
const summary = ref<MesHomeSummaryVO>({
workOrderActiveCount: 0,
workOrderPrepareCount: 0,
@ -219,347 +57,22 @@ const summary = ref<MesHomeSummaryVO>({
machineryMaintenance: 0,
andonActiveCount: 0,
repairActiveCount: 0
})
const qualityRate = computed(() => {
const total = summary.value.todayQualifiedQuantity + summary.value.todayUnqualifiedQuantity
if (total === 0) return 100
return (summary.value.todayQualifiedQuantity / total) * 100
})
// ========== ==========
const trendDays = ref(7)
const trendChartOptions = reactive<EChartsOption>({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
legend: { data: ['产量', '合格品', '不良品'], bottom: 0 },
grid: { left: 50, right: 20, top: 20, bottom: 40 },
xAxis: { type: 'category', data: [], boundaryGap: false },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '产量',
type: 'line',
smooth: true,
data: [],
itemStyle: { color: '#409EFF' },
areaStyle: { color: 'rgba(64,158,255,0.15)' }
},
{
name: '合格品',
type: 'line',
smooth: true,
data: [],
itemStyle: { color: '#67C23A' }
},
{
name: '不良品',
type: 'line',
smooth: true,
data: [],
itemStyle: { color: '#F56C6C' }
}
]
}) as EChartsOption
const loadProductionTrend = async () => {
const data: MesHomeProductionTrendVO[] = await MesHomeStatisticsApi.getProductionTrend(trendDays.value)
// MM-DD
const dates = data.map((d) => d.date.substring(5))
const quantities = data.map((d) => d.quantity)
const qualified = data.map((d) => d.qualifiedQuantity)
const unqualified = data.map((d) => d.unqualifiedQuantity)
//
;(trendChartOptions as any).xAxis.data = dates
;(trendChartOptions as any).series[0].data = quantities
;(trendChartOptions as any).series[1].data = qualified
;(trendChartOptions as any).series[2].data = unqualified
}
// ========== ==========
const statusColorMap: Record<number, string> = {
0: '#909399', // 稿
1: '#409EFF', //
2: '#67C23A', //
3: '#F56C6C' //
}
const workOrderChartOptions = reactive<EChartsOption>({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, type: 'scroll' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}\n{c}' },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: []
}
]
}) as EChartsOption
const loadWorkOrderStatus = async () => {
const data: MesHomeWorkOrderStatusVO[] = await MesHomeStatisticsApi.getWorkOrderStatusDistribution()
;(workOrderChartOptions as any).series[0].data = data.map((d) => ({
name: d.statusName,
value: d.count,
itemStyle: { color: statusColorMap[d.status] || '#409EFF' }
}))
}
// ========== ==========
const shortcuts = [
{ name: '生产工单', icon: 'ep:document', url: '/mes/pro/workorder', bgColor: '#409EFF' },
{ name: '生产报工', icon: 'ep:edit', url: '/mes/pro/feedback', bgColor: '#67C23A' },
{ name: '质量检验', icon: 'ep:search', url: '/mes/qc/iqc', bgColor: '#E6A23C' },
{ name: '库存查询', icon: 'ep:box', url: '/mes/wm/materialstock', bgColor: '#F56C6C' },
{ name: '设备管理', icon: 'ep:cpu', url: '/mes/dv/machinery', bgColor: '#7c3aed' },
{ name: '库存流水', icon: 'ep:tickets', url: '/mes/wm/transaction', bgColor: '#0ea5e9' }
]
// ========== ==========
}) //
const productionTrendRef = ref<InstanceType<typeof HomeProductionTrend>>()
const workOrderChartRef = ref<InstanceType<typeof HomeWorkOrderChart>>()
// TODO @AI name url name
const handleNavigate = (url: string) => {
router.push(url)
}
// ========== ==========
onMounted(async () => {
const [summaryData] = await Promise.all([
MesHomeStatisticsApi.getHomeSummary(),
loadProductionTrend(),
loadWorkOrderStatus()
// TODO @AIproductionTrendRefworkOrderChartRef 使
productionTrendRef.value?.loadData(),
workOrderChartRef.value?.loadData()
])
summary.value = summaryData
})
</script>
<style lang="scss" scoped>
.mes-home {
padding: 0;
}
// ========== KPI ==========
.kpi-card {
cursor: pointer;
transition: all 0.3s ease;
border: none;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:deep(.el-card__body) {
padding: 20px;
}
&__content {
display: flex;
align-items: center;
gap: 16px;
}
&__icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
&__title {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
&__value {
display: flex;
align-items: baseline;
gap: 4px;
}
&__number {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
&__unit {
font-size: 13px;
color: var(--el-text-color-secondary);
}
&__extra {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 4px;
}
// KPI
&--production .kpi-card__icon {
background: linear-gradient(135deg, #409eff, #66b1ff);
}
&--production .kpi-card__number {
color: #409eff;
}
&--output .kpi-card__icon {
background: linear-gradient(135deg, #67c23a, #85ce61);
}
&--output .kpi-card__number {
color: #67c23a;
}
&--quality .kpi-card__icon {
background: linear-gradient(135deg, #e6a23c, #ebb563);
}
&--quality .kpi-card__number {
color: #e6a23c;
}
&--equipment .kpi-card__icon {
background: linear-gradient(135deg, #7c3aed, #9461f5);
}
&--equipment .kpi-card__number {
color: #7c3aed;
}
}
// ========== Header ==========
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
&__title {
font-size: 16px;
font-weight: 600;
}
}
// ========== ==========
.alert-card {
height: 100%;
:deep(.el-card__body) {
padding: 0;
}
}
.alert-list {
display: flex;
flex-direction: column;
}
.alert-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--el-fill-color-light);
}
&__icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&--danger .alert-item__icon {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
&--warning .alert-item__icon {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
&--info .alert-item__icon {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 14px;
font-weight: 500;
}
&__desc {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
&__badge {
flex-shrink: 0;
}
}
// ========== ==========
.shortcut-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 12px 0;
border-radius: 8px;
transition: all 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
transform: translateY(-2px);
}
&__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
&__name {
font-size: 13px;
color: var(--el-text-color-regular);
}
}
</style>