feat(mes): 迁移 home 首页

pull/350/head
YunaiV 2026-05-30 13:22:16 +08:00
parent b325db0450
commit a237758516
7 changed files with 597 additions and 0 deletions

View File

@ -0,0 +1,89 @@
import type { MesHomeApi } from '#/api/mes/home';
import { MesProWorkOrderStatusEnum } from '#/views/mes/utils/constants';
/** 首页汇总统计默认值 */
export const defaultSummary: MesHomeApi.Summary = {
andonActiveCount: 0,
machineryMaintenance: 0,
machineryProducing: 0,
machineryStop: 0,
machineryTotal: 0,
repairActiveCount: 0,
todayOutput: 0,
todayQualifiedQuantity: 0,
todayUnqualifiedQuantity: 0,
workOrderActiveCount: 0,
workOrderFinishedCount: 0,
workOrderPrepareCount: 0,
yesterdayOutput: 0,
};
/** 工单状态对应的颜色映射 */
export const WORK_ORDER_STATUS_COLOR_MAP: Record<number, string> = {
[MesProWorkOrderStatusEnum.PREPARE]: '#909399', // 草稿
[MesProWorkOrderStatusEnum.CONFIRMED]: '#409EFF', // 已确认
[MesProWorkOrderStatusEnum.FINISHED]: '#67C23A', // 已完成
[MesProWorkOrderStatusEnum.CANCELED]: '#F56C6C', // 已取消
};
/** 生产趋势折线图配置 */
export function getProductionTrendChartOptions(
dates: string[],
quantities: number[],
qualified: number[],
unqualified: number[],
): any {
return {
grid: { bottom: 40, left: 50, right: 20, top: 20 },
legend: { bottom: 0, data: ['产量', '合格品', '不良品'] },
series: [
{
areaStyle: { color: 'rgba(64,158,255,0.15)' },
data: quantities,
itemStyle: { color: '#409EFF' },
name: '产量',
smooth: true,
type: 'line',
},
{
data: qualified,
itemStyle: { color: '#67C23A' },
name: '合格品',
smooth: true,
type: 'line',
},
{
data: unqualified,
itemStyle: { color: '#F56C6C' },
name: '不良品',
smooth: true,
type: 'line',
},
],
tooltip: { axisPointer: { type: 'cross' }, trigger: 'axis' },
xAxis: { boundaryGap: false, data: dates, type: 'category' },
yAxis: { minInterval: 1, type: 'value' },
};
}
/** 工单状态分布饼图配置 */
export function getWorkOrderStatusChartOptions(
data: Array<{ itemStyle: { color: string }; name: string; value: number }>,
): any {
return {
legend: { bottom: 0, type: 'scroll' },
series: [
{
avoidLabelOverlap: true,
data,
emphasis: { label: { fontSize: 14, fontWeight: 'bold', show: true } },
itemStyle: { borderColor: '#fff', borderRadius: 6, borderWidth: 2 },
label: { formatter: '{b}\n{c}', show: true },
radius: ['40%', '70%'],
type: 'pie',
},
],
tooltip: { formatter: '{b}: {c} ({d}%)', trigger: 'item' },
};
}

View File

@ -0,0 +1,70 @@
<script lang="ts" setup>
import type { MesHomeApi } from '#/api/mes/home';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page } from '@vben/common-ui';
import { ElCol, ElRow } from 'element-plus';
import { getHomeSummary } from '#/api/mes/home';
import { defaultSummary } from './data';
import AlertPanel from './modules/alert-panel.vue';
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
import Shortcuts from './modules/shortcuts.vue';
import WorkOrderChart from './modules/work-order-chart.vue';
const router = useRouter();
const summary = ref<MesHomeApi.Summary>(defaultSummary); //
/** 跳转到目标页面(按路由 name */
function handleNavigate(name: string) {
router.push({ name });
}
/** 加载首页汇总统计 */
async function loadSummary() {
summary.value = await getHomeSummary();
}
onMounted(() => {
loadSummary();
});
</script>
<template>
<Page>
<template #doc>
<DocAlert
title="MES 手册(功能开启)"
url="https://doc.iocoder.cn/mes/build/"
/>
</template>
<!-- 第一行核心 KPI 汇总卡片 -->
<KpiCards :summary="summary" class="mb-4" @navigate="handleNavigate" />
<!-- 第二行生产趋势 + 待办异常 -->
<ElRow :gutter="16" class="mb-4">
<ElCol :lg="16" :md="24" :sm="24" :xl="16" :xs="24" class="mb-4">
<ProductionTrend />
</ElCol>
<ElCol :lg="8" :md="24" :sm="24" :xl="8" :xs="24" class="mb-4">
<AlertPanel :summary="summary" @navigate="handleNavigate" />
</ElCol>
</ElRow>
<!-- 第三行工单分布 + 快捷入口 -->
<ElRow :gutter="16">
<ElCol :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
<WorkOrderChart />
</ElCol>
<ElCol :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
<Shortcuts @navigate="handleNavigate" />
</ElCol>
</ElRow>
</Page>
</template>

View File

@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { MesHomeApi } from '#/api/mes/home';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { ElBadge, ElCard } from 'element-plus';
defineOptions({ name: 'MesHomeAlertPanel' });
const props = defineProps<{
summary: MesHomeApi.Summary;
}>();
const emit = defineEmits<{
navigate: [name: string];
}>();
/** 待办提醒列表:标签、描述、图标、目标路由名称、数量 */
const alertItems = computed(() => [
{
count: props.summary.andonActiveCount,
desc: '未处置的安灯呼叫',
icon: 'lucide:bell-ring',
iconClass: 'bg-red-50 text-red-500',
label: '安灯报警',
routeName: 'MesProAndonRecord',
},
{
count: props.summary.repairActiveCount,
desc: '待处理的维修工单',
icon: 'lucide:wrench',
iconClass: 'bg-orange-50 text-orange-500',
label: '设备维修',
routeName: 'MesDvRepair',
},
{
count: props.summary.workOrderPrepareCount,
desc: '草稿状态的生产工单',
icon: 'lucide:clipboard-list',
iconClass: 'bg-blue-50 text-blue-500',
label: '待排产工单',
routeName: 'MesProWorkOrder',
},
]);
</script>
<template>
<ElCard header="待办与异常" class="h-full">
<div class="flex flex-col">
<div
v-for="item in alertItems"
:key="item.label"
class="hover:bg-accent flex cursor-pointer items-center gap-3 border-b px-2 py-4 transition-colors last:border-b-0"
@click="emit('navigate', item.routeName)"
>
<div
class="flex size-10 flex-shrink-0 items-center justify-center rounded-lg"
:class="item.iconClass"
>
<IconifyIcon class="size-5" :icon="item.icon" />
</div>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium">{{ item.label }}</span>
<span class="text-muted-foreground text-xs">{{ item.desc }}</span>
</div>
<ElBadge :value="item.count" :hidden="!item.count" />
</div>
</div>
</ElCard>
</template>

View File

@ -0,0 +1,178 @@
<script lang="ts" setup>
import type { MesHomeApi } from '#/api/mes/home';
import { computed } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { ElCard, ElCol, ElDivider, ElRow } from 'element-plus';
defineOptions({ name: 'MesHomeKpiCards' });
const props = defineProps<{
summary: MesHomeApi.Summary;
}>();
const emit = defineEmits<{
navigate: [name: string];
}>();
/** 是否有质量数据(合格品 + 不良品 > 0 */
const hasQualityData = computed(
() =>
props.summary.todayQualifiedQuantity +
props.summary.todayUnqualifiedQuantity >
0,
);
/** 质量合格率 = 合格品 / (合格品 + 不良品) * 100无数据时为 0 */
const qualityRate = computed(() => {
const total =
props.summary.todayQualifiedQuantity +
props.summary.todayUnqualifiedQuantity;
if (total === 0) {
return 0;
}
return (props.summary.todayQualifiedQuantity / total) * 100;
});
</script>
<template>
<ElRow :gutter="16">
<ElCol :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<ElCard
shadow="hover"
class="kpi-card cursor-pointer transition-all hover:-translate-y-1"
@click="emit('navigate', 'MesProWorkOrder')"
>
<div class="flex items-center gap-4">
<div
class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white"
style="background: linear-gradient(135deg, #409eff, #66b1ff)"
>
<IconifyIcon class="size-7" icon="lucide:file-text" />
</div>
<div>
<div class="text-muted-foreground mb-1 text-sm">生产工单</div>
<div class="flex items-baseline gap-1">
<CountTo
class="text-2xl font-bold leading-tight text-[#409eff]"
:end-val="summary.workOrderActiveCount"
:duration="1500"
/>
<span class="text-muted-foreground text-xs">进行中</span>
</div>
<div class="text-muted-foreground mt-1 text-xs">
<span>待排产 {{ summary.workOrderPrepareCount }}</span>
<ElDivider direction="vertical" />
<span>已完成 {{ summary.workOrderFinishedCount }}</span>
</div>
</div>
</div>
</ElCard>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<ElCard
shadow="hover"
class="kpi-card cursor-pointer transition-all hover:-translate-y-1"
@click="emit('navigate', 'MesProFeedback')"
>
<div class="flex items-center gap-4">
<div
class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white"
style="background: linear-gradient(135deg, #67c23a, #85ce61)"
>
<IconifyIcon class="size-7" icon="lucide:bar-chart-3" />
</div>
<div>
<div class="text-muted-foreground mb-1 text-sm">今日产量</div>
<div class="flex items-baseline gap-1">
<CountTo
class="text-2xl font-bold leading-tight text-[#67c23a]"
:end-val="summary.todayOutput"
:duration="1500"
/>
<span class="text-muted-foreground text-xs"></span>
</div>
<div class="text-muted-foreground mt-1 text-xs">
<span>昨日 {{ summary.yesterdayOutput }} </span>
</div>
</div>
</div>
</ElCard>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<ElCard
shadow="hover"
class="kpi-card cursor-pointer transition-all hover:-translate-y-1"
@click="emit('navigate', 'MesProFeedback')"
>
<div class="flex items-center gap-4">
<div
class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white"
style="background: linear-gradient(135deg, #e6a23c, #ebb563)"
>
<IconifyIcon class="size-7" icon="lucide:circle-check" />
</div>
<div>
<div class="text-muted-foreground mb-1 text-sm">质量合格率</div>
<div class="flex items-baseline gap-1">
<CountTo
class="text-2xl font-bold leading-tight text-[#e6a23c]"
:decimals="1"
:end-val="qualityRate"
:duration="1500"
/>
<span class="text-muted-foreground text-xs">%</span>
</div>
<div class="text-muted-foreground mt-1 text-xs">
<template v-if="hasQualityData">
<span>合格 {{ summary.todayQualifiedQuantity }}</span>
<ElDivider direction="vertical" />
<span>不良 {{ summary.todayUnqualifiedQuantity }}</span>
</template>
<span v-else></span>
</div>
</div>
</div>
</ElCard>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<ElCard
shadow="hover"
class="kpi-card cursor-pointer transition-all hover:-translate-y-1"
@click="emit('navigate', 'MesDvMachinery')"
>
<div class="flex items-center gap-4">
<div
class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white"
style="background: linear-gradient(135deg, #7c3aed, #9461f5)"
>
<IconifyIcon class="size-7" icon="lucide:cpu" />
</div>
<div>
<div class="text-muted-foreground mb-1 text-sm">设备状态</div>
<div class="flex items-baseline gap-1">
<CountTo
class="text-2xl font-bold leading-tight text-[#7c3aed]"
:end-val="summary.machineryProducing"
:duration="1500"
/>
<span class="text-muted-foreground text-xs">
/ {{ summary.machineryTotal }}
</span>
</div>
<div class="text-muted-foreground mt-1 text-xs">
<span class="text-red-400">停机 {{ summary.machineryStop }}</span>
<ElDivider direction="vertical" />
<span class="text-orange-400">
维护 {{ summary.machineryMaintenance }}
</span>
</div>
</div>
</div>
</ElCard>
</ElCol>
</ElRow>
</template>

View File

@ -0,0 +1,59 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { ElCard, ElRadioButton, ElRadioGroup } from 'element-plus';
import { getProductionTrend } from '#/api/mes/home';
import { getProductionTrendChartOptions } from '../data';
defineOptions({ name: 'MesHomeProductionTrend' });
const trendDays = ref(7); //
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 加载生产趋势数据并渲染图表 */
async function loadData() {
const data = await getProductionTrend(trendDays.value);
const dates = data.map((d) => d.date.slice(5));
const quantities = data.map((d) => d.quantity);
const qualified = data.map((d) => d.qualifiedQuantity);
const unqualified = data.map((d) => d.unqualifiedQuantity);
await renderEcharts(
getProductionTrendChartOptions(dates, quantities, qualified, unqualified),
);
}
/** 切换天数范围 */
function handleDaysChange() {
loadData();
}
onMounted(() => {
loadData();
});
</script>
<template>
<ElCard class="h-full">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">生产趋势</span>
<ElRadioGroup
v-model="trendDays"
size="small"
@change="handleDaysChange"
>
<ElRadioButton :value="7"> 7 </ElRadioButton>
<ElRadioButton :value="30"> 30 </ElRadioButton>
</ElRadioGroup>
</div>
</template>
<EchartsUI ref="chartRef" class="h-[320px] w-full" />
</ElCard>
</template>

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import { IconifyIcon } from '@vben/icons';
import { ElCard, ElCol, ElRow } from 'element-plus';
defineOptions({ name: 'MesHomeShortcuts' });
const emit = defineEmits<{
navigate: [name: string];
}>();
/** 快捷入口列表3×3 网格布局与工单状态分布面板等高 */
const shortcuts = [
{
bgColor: '#409EFF',
icon: 'lucide:file-text',
name: '生产工单',
routeName: 'MesProWorkOrder',
},
{
bgColor: '#67C23A',
icon: 'lucide:edit',
name: '生产报工',
routeName: 'MesProFeedback',
},
{
bgColor: '#E6A23C',
icon: 'lucide:search',
name: '质量检验',
routeName: 'MesQcIqc',
},
{
bgColor: '#F56C6C',
icon: 'lucide:box',
name: '库存查询',
routeName: 'MesWmMaterialStock',
},
{
bgColor: '#7c3aed',
icon: 'lucide:cpu',
name: '设备管理',
routeName: 'MesDvMachinery',
},
{
bgColor: '#0ea5e9',
icon: 'lucide:list',
name: '生产任务',
routeName: 'MesProTask',
},
{
bgColor: '#14b8a6',
icon: 'lucide:truck',
name: '到货通知',
routeName: 'MesWmArrivalNotice',
},
{
bgColor: '#f59e0b',
icon: 'lucide:settings-2',
name: '设备维修',
routeName: 'MesDvRepair',
},
{
bgColor: '#ec4899',
icon: 'lucide:tickets',
name: '流转卡',
routeName: 'MesProCard',
},
];
</script>
<template>
<ElCard header="快捷入口" class="h-full">
<ElRow :gutter="16">
<ElCol v-for="item in shortcuts" :key="item.name" :span="8" class="mb-4">
<div
class="hover:bg-accent flex cursor-pointer flex-col items-center gap-2 rounded-lg py-3 transition-all hover:-translate-y-0.5"
@click="emit('navigate', item.routeName)"
>
<div
class="flex size-12 items-center justify-center rounded-xl text-white"
:style="{ background: item.bgColor }"
>
<IconifyIcon class="size-6" :icon="item.icon" />
</div>
<span class="text-sm">{{ item.name }}</span>
</div>
</ElCol>
</ElRow>
</ElCard>
</template>

View File

@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { ElCard } from 'element-plus';
import { getWorkOrderStatusDistribution } from '#/api/mes/home';
import { getWorkOrderStatusChartOptions, WORK_ORDER_STATUS_COLOR_MAP } from '../data';
defineOptions({ name: 'MesHomeWorkOrderChart' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 加载工单状态分布数据并渲染饼图 */
async function loadData() {
const data = await getWorkOrderStatusDistribution();
const chartData = data.map((d) => ({
itemStyle: { color: WORK_ORDER_STATUS_COLOR_MAP[d.status] || '#409EFF' },
name: d.statusName,
value: d.count,
}));
await renderEcharts(getWorkOrderStatusChartOptions(chartData));
}
onMounted(() => {
loadData();
});
</script>
<template>
<ElCard header="工单状态分布" class="h-full">
<EchartsUI ref="chartRef" class="h-[280px] w-full" />
</ElCard>
</template>