gaoluyang
6 小时以前 8151c3253dc287a79914bc08414af17dfb380c96
refactor(首页): 展示模块改为跟随菜单权限自动隐藏

两套首页(/analytics 与 MES 首页)原先用 canNavigate 判断模块开关,它只看路由
是否注册过;而后端菜单 visible=0 只是被前端转成 meta.hideInMenu,路由依旧注册,
只在 generateMenus 剪枝时连同子树从侧边栏移除,所以菜单已关闭的模块照旧显示。

- 新增共享判据 canSeeMenu:路由存在且从根到该路由的祖先链上无一级被隐藏,与
侧边栏剪枝口径一致;use-home-access.ts 移到 src/utils 供两个首页共用
- /analytics:MES 生产 KPI、AI 智能分析、生产趋势、工单状态分布、经营 KPI 各卡
按各自菜单显隐,列宽随可见性自适应(欢迎栏、快捷操作保持常驻)
- MES 首页:KPI 卡、生产趋势、订单状态分布、待办异常、快捷入口同判据显隐
- 修正对不上任何菜单的路由名(MesQcRecord→MesQcIqc、MesProRepair→MesDvRepair、
BpmTaskTodo→BpmTodoTask、WlsMaterialStock→MesWmMaterialStock 等),
并删除无对应页面的「数据报表」入口

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加1个文件
已修改13个文件
已删除1个文件
603 ■■■■■ 文件已修改
src/utils/use-home-access.ts 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/alert-panel.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/biz-cards.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/kpi-cards.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/production-trend.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/use-home-access.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/data.ts 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/index.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/modules/alert-panel.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/modules/kpi-cards.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/modules/production-trend.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/modules/shortcuts.vue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/modules/work-order-chart.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/use-home-access.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
import type { RouteRecordRaw } from 'vue-router';
import { computed } from 'vue';
import { useAccess } from '@vben/access';
import { useAccessStore } from '@vben/stores';
/** é¦–页常驻,业务数据和入口仍需遵循当前用户的菜单权限。 */
export function useHomeAccess() {
  const accessStore = useAccessStore();
  const { hasAccessByCodes } = useAccess();
  function canQuery(permission: string) {
    return hasAccessByCodes([permission, '*:*:*']);
  }
  function canNavigate(name: string) {
    function contains(routes: RouteRecordRaw[]): boolean {
      return routes.some((route) => {
        if (route.meta?.menuVisibleWithForbidden) return false;
        return route.name === name || contains(route.children ?? []);
      });
    }
    return contains(accessStore.accessRoutes);
  }
  /**
   * è¯¥èœå•对当前用户是否「开着」—— ç”¨äºŽé¦–页模块开关。
   *
   * åŽç«¯èœå• `visible = 0` ä¼šè¢«å‰ç«¯è½¬æˆè·¯ç”±çš„ `meta.hideInMenu`,这类路由仍然注册在
   * router é‡Œï¼ˆ`canNavigate` ä¸º true,页面可以直接访问),只是整棵子树不会出现在侧边栏。
   * æ‰€ä»¥åˆ¤æ–­ã€Œè¿™ä¸ªæ¨¡å—有没有开」不能只看路由是否存在,还要看它和它的每一级祖先是否可见。
   */
  function canSeeMenu(name: string) {
    function find(
      routes: RouteRecordRaw[],
      ancestorHidden: boolean,
    ): boolean | undefined {
      for (const route of routes) {
        const hidden =
          ancestorHidden ||
          route.meta?.hideInMenu === true ||
          route.meta?.menuVisibleWithForbidden === true;
        if (route.name === name) return !hidden;
        const found = find(route.children ?? [], hidden);
        if (found !== undefined) return found;
      }
      return undefined;
    }
    return find(accessStore.accessRoutes, false) === true;
  }
  const canViewHome = computed(() => canQuery('mes:home:query'));
  return { canNavigate, canQuery, canSeeMenu, canViewHome };
}
src/views/dashboard/analytics/index.vue
@@ -8,6 +8,8 @@
import { getHomeSummary } from '#/api/mes/home';
import { useHomeAccess } from '#/utils/use-home-access';
import { defaultSummary } from './data';
import AiInsights from './modules/ai-insights.vue';
import AlertPanel from './modules/alert-panel.vue';
@@ -15,13 +17,34 @@
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
import QuickActions from './modules/quick-actions.vue';
import { useHomeAccess } from './use-home-access';
defineOptions({ name: 'DashboardAnalytics' });
const router = useRouter();
const summary = ref(defaultSummary);
const { canNavigate, canViewHome } = useHomeAccess();
const { canNavigate, canSeeMenu, canViewHome } = useHomeAccess();
// é¦–页模块跟随侧边栏菜单:菜单没开的模块整块不展示
/** MES ç”Ÿäº§ KPI:任意一张卡的菜单开着就渲染整行 */
const showKpi = computed(
  () =>
    canViewHome.value &&
    ['MesProWorkOrder', 'MesProFeedback', 'MesQcIqc', 'MesDvMachinery'].some(
      (name) => canSeeMenu(name),
    ),
);
/** AI æ™ºèƒ½åˆ†æžï¼šAI æ¨¡å—的菜单开着才渲染 */
const showAiInsights = computed(
  () => canViewHome.value && canSeeMenu('AiChat'),
);
/** ç”Ÿäº§è¶‹åŠ¿ï¼šæ•°æ®æ¥è‡ªç”Ÿäº§æŠ¥å·¥ */
const showTrend = computed(
  () => canViewHome.value && canSeeMenu('MesProFeedback'),
);
/** å·¥å•状态分布:数据来自生产订单 */
const showWorkOrderStatus = computed(
  () => canViewHome.value && canSeeMenu('MesProWorkOrder'),
);
const greeting = computed(() => {
  const hour = new Date().getHours();
@@ -76,21 +99,29 @@
    </div>
    <!-- MES ç”Ÿäº§ KPI -->
    <KpiCards v-if="canViewHome" :summary="summary" @navigate="handleNavigate" />
    <KpiCards v-if="showKpi" :summary="summary" @navigate="handleNavigate" />
    <!-- ERP/WMS/BPM ç»è¥ KPI -->
    <BizCards @navigate="handleNavigate" />
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      v-if="canViewHome && canNavigate('MesProWorkOrder')"
      v-if="showAiInsights"
      :active-order-count="summary.workOrderActiveCount"
      @navigate="handleNavigate"
    />
    <!-- ç”Ÿäº§è¶‹åŠ¿ + é¢„警面板 -->
    <Row v-if="canViewHome" :gutter="16" class="mb-4">
      <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4">
    <Row v-if="showTrend || showWorkOrderStatus" :gutter="16" class="mb-4">
      <Col
        v-if="showTrend"
        :lg="showWorkOrderStatus ? 14 : 24"
        :md="24"
        :sm="24"
        :xl="showWorkOrderStatus ? 14 : 24"
        :xs="24"
        class="mb-4"
      >
        <div class="glass-card rounded-2xl p-5">
          <h3 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-100">
            ç”Ÿäº§è¶‹åŠ¿ï¼ˆè¿‘7天)
@@ -98,7 +129,15 @@
          <ProductionTrend />
        </div>
      </Col>
      <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4">
      <Col
        v-if="showWorkOrderStatus"
        :lg="showTrend ? 10 : 24"
        :md="24"
        :sm="24"
        :xl="showTrend ? 10 : 24"
        :xs="24"
        class="mb-4"
      >
        <AlertPanel
          :repair-active-count="summary.repairActiveCount"
          @navigate="handleNavigate"
src/views/dashboard/analytics/modules/alert-panel.vue
@@ -12,7 +12,7 @@
import { getWorkOrderStatusDistribution } from '#/api/mes/home';
import { useHomeAccess } from '../use-home-access';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardAlertPanel' });
@@ -27,10 +27,11 @@
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(false);
const { canNavigate, canViewHome } = useHomeAccess();
const { canSeeMenu } = useHomeAccess();
async function loadStatus() {
  if (!canViewHome.value) return;
  // å·¥å•状态分布取自生产订单,订单菜单没开就不请求
  if (!canSeeMenu('MesProWorkOrder')) return;
  loading.value = true;
  try {
    const data = await getWorkOrderStatusDistribution();
@@ -72,9 +73,9 @@
    <!-- é¢„警卡片行 -->
    <div class="grid grid-cols-1 gap-3">
      <div
        v-if="canNavigate('MesProRepair')"
        v-if="canSeeMenu('MesDvRepair')"
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
        @click="emit('navigate', 'MesProRepair')"
        @click="emit('navigate', 'MesDvRepair')"
      >
        <div class="relative z-10 flex items-center gap-3">
          <div class="flex size-10 items-center justify-center rounded-lg bg-amber-50 text-amber-600 shadow-sm dark:bg-amber-500/15 dark:text-amber-400">
@@ -92,7 +93,7 @@
    </div>
    <!-- å·¥å•状态图 -->
    <div class="glass-tilt rounded-2xl p-5">
    <div v-if="canSeeMenu('MesProWorkOrder')" class="glass-tilt rounded-2xl p-5">
      <h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-100">
        å·¥å•状态分布
      </h4>
src/views/dashboard/analytics/modules/biz-cards.vue
@@ -11,7 +11,7 @@
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import { getTaskTodoPage } from '#/api/bpm/task';
import { useHomeAccess } from '../use-home-access';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardBizCards' });
@@ -23,11 +23,11 @@
const saleCount = ref(0);
const stockCount = ref(0);
const bpmTodoCount = ref(0);
const { canNavigate, canQuery } = useHomeAccess();
const canViewPurchase = computed(() => canQuery('erp:purchase-order:query') && canNavigate('ErpPurchaseOrder'));
const canViewSale = computed(() => canQuery('erp:sale-order:query') && canNavigate('ErpSaleOrder'));
const canViewStock = computed(() => canQuery('mes:wm-material-stock:query') && canNavigate('MesWmMaterialStock'));
const canViewBpmTodo = computed(() => canQuery('bpm:task:query') && canNavigate('BpmTaskTodo'));
const { canSeeMenu, canQuery } = useHomeAccess();
const canViewPurchase = computed(() => canQuery('erp:purchase-order:query') && canSeeMenu('ErpPurchaseOrder'));
const canViewSale = computed(() => canQuery('erp:sale-order:query') && canSeeMenu('ErpSaleOrder'));
const canViewStock = computed(() => canQuery('mes:wm-material-stock:query') && canSeeMenu('MesWmMaterialStock'));
const canViewBpmTodo = computed(() => canQuery('bpm:task:query') && canSeeMenu('BpmTodoTask'));
async function loadPurchase() {
  if (!canViewPurchase.value) return;
@@ -183,7 +183,7 @@
        :style="tiltStyle('bpm')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'bpm')"
        @mouseleave="onMouseLeave('bpm')"
        @click="emit('navigate', 'BpmTaskTodo')"
        @click="emit('navigate', 'BpmTodoTask')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-red-500 to-rose-400 shadow-lg shadow-red-500/25 transition-transform duration-300 group-hover:scale-110">
src/views/dashboard/analytics/modules/kpi-cards.vue
@@ -8,11 +8,11 @@
import { Col, Row } from 'ant-design-vue';
import { useHomeAccess } from '../use-home-access';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardKpiCards' });
const { canNavigate } = useHomeAccess();
const { canSeeMenu } = useHomeAccess();
const props = defineProps<{
  summary: MesHomeApi.Summary;
@@ -63,14 +63,14 @@
<template>
  <Row :gutter="16">
    <!-- ç”Ÿäº§è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canSeeMenu('MesProWorkOrder')" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesProWorkOrder') }"
        :class="{ 'cursor-pointer': canSeeMenu('MesProWorkOrder') }"
        :style="tiltStyle('order')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'order')"
        @mouseleave="onMouseLeave('order')"
        @click="canNavigate('MesProWorkOrder') && emit('navigate', 'MesProWorkOrder')"
        @click="canSeeMenu('MesProWorkOrder') && emit('navigate', 'MesProWorkOrder')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 shadow-lg shadow-blue-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -95,14 +95,14 @@
    </Col>
    <!-- ä»Šæ—¥äº§é‡ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canSeeMenu('MesProFeedback')" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesProFeedback') }"
        :class="{ 'cursor-pointer': canSeeMenu('MesProFeedback') }"
        :style="tiltStyle('output')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'output')"
        @mouseleave="onMouseLeave('output')"
        @click="canNavigate('MesProFeedback') && emit('navigate', 'MesProFeedback')"
        @click="canSeeMenu('MesProFeedback') && emit('navigate', 'MesProFeedback')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-green-400 shadow-lg shadow-emerald-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -127,14 +127,14 @@
    </Col>
    <!-- è´¨é‡åˆæ ¼çއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canSeeMenu('MesProFeedback')" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesQcRecord') }"
        :class="{ 'cursor-pointer': canSeeMenu('MesProFeedback') }"
        :style="tiltStyle('quality')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'quality')"
        @mouseleave="onMouseLeave('quality')"
        @click="canNavigate('MesQcRecord') && emit('navigate', 'MesQcRecord')"
        @click="canSeeMenu('MesProFeedback') && emit('navigate', 'MesProFeedback')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -161,14 +161,14 @@
    </Col>
    <!-- è®¾å¤‡è¿è¡Œçއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canSeeMenu('MesDvMachinery')" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesDvMachinery') }"
        :class="{ 'cursor-pointer': canSeeMenu('MesDvMachinery') }"
        :style="tiltStyle('equip')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'equip')"
        @mouseleave="onMouseLeave('equip')"
        @click="canNavigate('MesDvMachinery') && emit('navigate', 'MesDvMachinery')"
        @click="canSeeMenu('MesDvMachinery') && emit('navigate', 'MesDvMachinery')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-400 shadow-lg shadow-violet-500/25 transition-transform duration-300 group-hover:scale-110">
src/views/dashboard/analytics/modules/production-trend.vue
@@ -8,16 +8,17 @@
import { getProductionTrend } from '#/api/mes/home';
import { useHomeAccess } from '../use-home-access';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardProductionTrend' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const { canViewHome } = useHomeAccess();
const { canSeeMenu } = useHomeAccess();
async function loadTrend() {
  if (!canViewHome.value) return;
  // ç”Ÿäº§è¶‹åŠ¿å–è‡ªç”Ÿäº§æŠ¥å·¥ï¼ŒæŠ¥å·¥èœå•æ²¡å¼€å°±ä¸è¯·æ±‚
  if (!canSeeMenu('MesProFeedback')) return;
  const data = await getProductionTrend(7);
  const dates = data.map((d: MesHomeApi.ProductionTrend) => d.date.slice(5));
  const quantities = data.map((d: MesHomeApi.ProductionTrend) => d.quantity);
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -3,7 +3,7 @@
import { IconifyIcon } from '@vben/icons';
import { useHomeAccess } from '../use-home-access';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardQuickActions' });
@@ -22,16 +22,17 @@
const actions: QuickAction[] = [
  { title: '生产工单', icon: 'lucide:file-text', route: 'MesProWorkOrder', gradient: 'from-blue-500 to-cyan-500', shadow: 'shadow-blue-500/15' },
  { title: '生产报工', icon: 'lucide:check-square', route: 'MesProFeedback', gradient: 'from-emerald-500 to-green-500', shadow: 'shadow-emerald-500/15' },
  { title: '质检管理', icon: 'lucide:clipboard-check', route: 'MesQcRecord', gradient: 'from-orange-500 to-amber-500', shadow: 'shadow-orange-500/15' },
  { title: '质检管理', icon: 'lucide:clipboard-check', route: 'MesQcIqc', gradient: 'from-orange-500 to-amber-500', shadow: 'shadow-orange-500/15' },
  { title: '设备点检', icon: 'lucide:settings', route: 'MesDvMachinery', gradient: 'from-violet-500 to-purple-500', shadow: 'shadow-violet-500/15' },
  { title: '生产排程', icon: 'lucide:calendar-range', route: 'MesProScheduling', gradient: 'from-cyan-500 to-teal-500', shadow: 'shadow-cyan-500/15' },
  { title: '物料库存', icon: 'lucide:package-search', route: 'WlsMaterialStock', gradient: 'from-indigo-500 to-blue-500', shadow: 'shadow-indigo-500/15' },
  { title: '工艺路线', icon: 'lucide:route', route: 'MesPdRouting', gradient: 'from-teal-500 to-emerald-500', shadow: 'shadow-teal-500/15' },
  { title: '数据报表', icon: 'lucide:bar-chart-big', route: 'MesHomeIndex', gradient: 'from-pink-500 to-rose-500', shadow: 'shadow-pink-500/15' },
  { title: '生产排程', icon: 'lucide:calendar-range', route: 'MesProTask', gradient: 'from-cyan-500 to-teal-500', shadow: 'shadow-cyan-500/15' },
  { title: '物料库存', icon: 'lucide:package-search', route: 'MesWmMaterialStock', gradient: 'from-indigo-500 to-blue-500', shadow: 'shadow-indigo-500/15' },
  { title: '工艺路线', icon: 'lucide:route', route: 'MesProcessDesignRoute', gradient: 'from-teal-500 to-emerald-500', shadow: 'shadow-teal-500/15' },
];
const { canNavigate } = useHomeAccess();
const accessibleActions = computed(() => actions.filter((action) => canNavigate(action.route)));
const { canSeeMenu } = useHomeAccess();
const accessibleActions = computed(() =>
  actions.filter((action) => canSeeMenu(action.route)),
);
</script>
<template>
src/views/dashboard/analytics/use-home-access.ts
ÎļþÒÑɾ³ý
src/views/mes/home/data.ts
@@ -2,6 +2,107 @@
import { MesProWorkOrderStatusEnum } from '@vben/constants';
/** é¦–页各模块依赖的菜单(route.name = åŽç«¯èœå•çš„ component_name):菜单没开的模块整块不展示 */
export const HOME_BLOCK_MENUS = {
  /** å®‰ç¯å‘¼å« */
  andon: 'MesProAndonRecord',
  /** åˆ°è´§é€šçŸ¥ */
  arrivalNotice: 'MesWmArrivalNotice',
  /** ç”Ÿäº§æŠ¥å·¥ */
  feedback: 'MesProFeedback',
  /** è®¾å¤‡å°è´¦ */
  machinery: 'MesDvMachinery',
  /** åº“存现有量 */
  materialStock: 'MesWmMaterialStock',
  /** å·¥åºæµè½¬ï¼ˆæµè½¬å¡ï¼‰ */
  processCard: 'MesProCard',
  /** ç”Ÿäº§æŽ’产(生产任务) */
  proTask: 'MesProTask',
  /** æ¥æ–™æ£€éªŒ */
  qcIqc: 'MesQcIqc',
  /** ç»´ä¿®å• */
  repair: 'MesDvRepair',
  /** ç”Ÿäº§è®¢å• */
  workOrder: 'MesProWorkOrder',
} as const;
/** ç”Ÿäº§ KPI å¡ç‰‡ä¾èµ–的菜单 */
export const KPI_MENUS: string[] = [
  HOME_BLOCK_MENUS.workOrder,
  HOME_BLOCK_MENUS.feedback,
  HOME_BLOCK_MENUS.machinery,
];
/** å¾…办与异常条目依赖的菜单 */
export const ALERT_MENUS: string[] = [
  HOME_BLOCK_MENUS.andon,
  HOME_BLOCK_MENUS.repair,
  HOME_BLOCK_MENUS.workOrder,
];
/** å¿«æ·å…¥å£ï¼šåç§°ã€å›¾æ ‡ã€åº•色与依赖的菜单 */
export const HOME_SHORTCUTS: {
  bgColor: string;
  icon: string;
  menu: string;
  name: string;
}[] = [
  {
    bgColor: '#409EFF',
    icon: 'lucide:file-text',
    menu: HOME_BLOCK_MENUS.workOrder,
    name: '生产订单',
  },
  {
    bgColor: '#67C23A',
    icon: 'lucide:edit',
    menu: HOME_BLOCK_MENUS.feedback,
    name: '生产报工',
  },
  {
    bgColor: '#E6A23C',
    icon: 'lucide:search',
    menu: HOME_BLOCK_MENUS.qcIqc,
    name: '质量检验',
  },
  {
    bgColor: '#F56C6C',
    icon: 'lucide:box',
    menu: HOME_BLOCK_MENUS.materialStock,
    name: '库存查询',
  },
  {
    bgColor: '#7c3aed',
    icon: 'lucide:cpu',
    menu: HOME_BLOCK_MENUS.machinery,
    name: '设备管理',
  },
  {
    bgColor: '#0ea5e9',
    icon: 'lucide:list',
    menu: HOME_BLOCK_MENUS.proTask,
    name: '生产任务',
  },
  {
    bgColor: '#14b8a6',
    icon: 'lucide:truck',
    menu: HOME_BLOCK_MENUS.arrivalNotice,
    name: '到货通知',
  },
  {
    bgColor: '#f59e0b',
    icon: 'lucide:settings-2',
    menu: HOME_BLOCK_MENUS.repair,
    name: '设备维修',
  },
  {
    bgColor: '#ec4899',
    icon: 'lucide:tickets',
    menu: HOME_BLOCK_MENUS.processCard,
    name: '流转卡',
  },
];
/** é¦–页汇总统计默认值 */
export const defaultSummary: MesHomeApi.Summary = {
  andonActiveCount: 0,
src/views/mes/home/index.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { MesHomeApi } from '#/api/mes/home';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
@@ -9,8 +9,14 @@
import { Col, Row } from 'ant-design-vue';
import { getHomeSummary } from '#/api/mes/home';
import { useHomeAccess } from '#/utils/use-home-access';
import { defaultSummary } from './data';
import {
  ALERT_MENUS,
  defaultSummary,
  HOME_BLOCK_MENUS,
  HOME_SHORTCUTS,
} from './data';
import AlertPanel from './modules/alert-panel.vue';
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
@@ -19,6 +25,19 @@
const router = useRouter();
const summary = ref<MesHomeApi.Summary>(defaultSummary); // é¦–页汇总统计
// é¦–页模块跟随侧边栏菜单:菜单没开的模块整块不展示
const { canSeeMenu } = useHomeAccess();
/** ç”Ÿäº§è¶‹åŠ¿ï¼ˆå–è‡ªç”Ÿäº§æŠ¥å·¥ï¼‰ */
const showTrend = computed(() => canSeeMenu(HOME_BLOCK_MENUS.feedback));
/** å¾…办与异常:任意一条的菜单开着就渲染这一列 */
const showAlert = computed(() => ALERT_MENUS.some((menu) => canSeeMenu(menu)));
/** è®¢å•状态分布(取自生产订单) */
const showChart = computed(() => canSeeMenu(HOME_BLOCK_MENUS.workOrder));
/** å¿«æ·å…¥å£ï¼šä»»æ„ä¸€ä¸ªå…¥å£çš„菜单开着就渲染这一列 */
const showShortcuts = computed(() =>
  HOME_SHORTCUTS.some((item) => canSeeMenu(item.menu)),
);
/** è·³è½¬åˆ°ç›®æ ‡é¡µé¢ï¼ˆæŒ‰è·¯ç”± name) */
function handleNavigate(name: string) {
@@ -40,21 +59,53 @@
    <KpiCards :summary="summary" class="mb-4" @navigate="handleNavigate" />
    <!-- ç¬¬äºŒè¡Œï¼šç”Ÿäº§è¶‹åŠ¿ + å¾…办异常 -->
    <Row :gutter="16" class="mb-4">
      <Col :lg="16" :md="24" :sm="24" :xl="16" :xs="24" class="mb-4">
    <Row v-if="showTrend || showAlert" :gutter="16" class="mb-4">
      <Col
        v-if="showTrend"
        :lg="showAlert ? 16 : 24"
        :md="24"
        :sm="24"
        :xl="showAlert ? 16 : 24"
        :xs="24"
        class="mb-4"
      >
        <ProductionTrend />
      </Col>
      <Col :lg="8" :md="24" :sm="24" :xl="8" :xs="24" class="mb-4">
      <Col
        v-if="showAlert"
        :lg="showTrend ? 8 : 24"
        :md="24"
        :sm="24"
        :xl="showTrend ? 8 : 24"
        :xs="24"
        class="mb-4"
      >
        <AlertPanel :summary="summary" @navigate="handleNavigate" />
      </Col>
    </Row>
    <!-- ç¬¬ä¸‰è¡Œï¼šè®¢å•分布 + å¿«æ·å…¥å£ -->
    <Row :gutter="16">
      <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
    <Row v-if="showChart || showShortcuts" :gutter="16">
      <Col
        v-if="showChart"
        :lg="showShortcuts ? 12 : 24"
        :md="24"
        :sm="24"
        :xl="showShortcuts ? 12 : 24"
        :xs="24"
        class="mb-4"
      >
        <WorkOrderChart />
      </Col>
      <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
      <Col
        v-if="showShortcuts"
        :lg="showChart ? 12 : 24"
        :md="24"
        :sm="24"
        :xl="showChart ? 12 : 24"
        :xs="24"
        class="mb-4"
      >
        <Shortcuts @navigate="handleNavigate" />
      </Col>
    </Row>
src/views/mes/home/modules/alert-panel.vue
@@ -7,6 +7,10 @@
import { Badge, Card } from 'ant-design-vue';
import { useHomeAccess } from '#/utils/use-home-access';
import { HOME_BLOCK_MENUS } from '../data';
defineOptions({ name: 'MesHomeAlertPanel' });
const props = defineProps<{
@@ -17,37 +21,41 @@
  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',
  },
]);
const { canSeeMenu } = useHomeAccess();
/** å¾…办提醒列表:标签、描述、图标、目标路由名称、数量;菜单没开的条目不展示 */
const alertItems = computed(() =>
  [
    {
      count: props.summary.andonActiveCount,
      desc: '未处置的安灯呼叫',
      icon: 'lucide:bell-ring',
      iconClass: 'bg-red-50 text-red-500',
      label: '安灯报警',
      routeName: HOME_BLOCK_MENUS.andon,
    },
    {
      count: props.summary.repairActiveCount,
      desc: '待处理的维修订单',
      icon: 'lucide:wrench',
      iconClass: 'bg-orange-50 text-orange-500',
      label: '设备维修',
      routeName: HOME_BLOCK_MENUS.repair,
    },
    {
      count: props.summary.workOrderPrepareCount,
      desc: '草稿状态的生产订单',
      icon: 'lucide:clipboard-list',
      iconClass: 'bg-blue-50 text-blue-500',
      label: '待排产订单',
      routeName: HOME_BLOCK_MENUS.workOrder,
    },
  ].filter((item) => canSeeMenu(item.routeName)),
);
</script>
<template>
  <Card title="待办与异常" class="h-full">
  <Card v-if="alertItems.length" title="待办与异常" class="h-full">
    <div class="flex flex-col">
      <div
        v-for="item in alertItems"
src/views/mes/home/modules/kpi-cards.vue
@@ -8,6 +8,10 @@
import { Card, Col, Divider, Row } from 'ant-design-vue';
import { useHomeAccess } from '#/utils/use-home-access';
import { HOME_BLOCK_MENUS } from '../data';
defineOptions({ name: 'MesHomeKpiCards' });
const props = defineProps<{
@@ -17,6 +21,9 @@
const emit = defineEmits<{
  navigate: [name: string];
}>();
/** èœå•没开的模块,对应 KPI å¡ä¸å±•示 */
const { canSeeMenu } = useHomeAccess();
/** æ˜¯å¦æœ‰è´¨é‡æ•°æ®ï¼ˆåˆæ ¼å“ + ä¸è‰¯å“ > 0) */
const hasQualityData = computed(
@@ -40,10 +47,18 @@
<template>
  <Row :gutter="16">
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canSeeMenu(HOME_BLOCK_MENUS.workOrder)"
      :lg="6"
      :md="12"
      :sm="12"
      :xl="6"
      :xs="24"
      class="mb-4"
    >
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesProWorkOrder')"
        @click="emit('navigate', HOME_BLOCK_MENUS.workOrder)"
      >
        <div class="flex items-center gap-4">
          <div
@@ -71,10 +86,18 @@
        </div>
      </Card>
    </Col>
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canSeeMenu(HOME_BLOCK_MENUS.feedback)"
      :lg="6"
      :md="12"
      :sm="12"
      :xl="6"
      :xs="24"
      class="mb-4"
    >
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesProFeedback')"
        @click="emit('navigate', HOME_BLOCK_MENUS.feedback)"
      >
        <div class="flex items-center gap-4">
          <div
@@ -100,10 +123,18 @@
        </div>
      </Card>
    </Col>
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canSeeMenu(HOME_BLOCK_MENUS.feedback)"
      :lg="6"
      :md="12"
      :sm="12"
      :xl="6"
      :xs="24"
      class="mb-4"
    >
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesProFeedback')"
        @click="emit('navigate', HOME_BLOCK_MENUS.feedback)"
      >
        <div class="flex items-center gap-4">
          <div
@@ -135,10 +166,18 @@
        </div>
      </Card>
    </Col>
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canSeeMenu(HOME_BLOCK_MENUS.machinery)"
      :lg="6"
      :md="12"
      :sm="12"
      :xl="6"
      :xs="24"
      class="mb-4"
    >
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesDvMachinery')"
        @click="emit('navigate', HOME_BLOCK_MENUS.machinery)"
      >
        <div class="flex items-center gap-4">
          <div
src/views/mes/home/modules/production-trend.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
@@ -9,16 +9,24 @@
import { getProductionTrend } from '#/api/mes/home';
import { useHomeAccess } from '#/utils/use-home-access';
import { getProductionTrendChartOptions } from '../chart-options';
import { HOME_BLOCK_MENUS } from '../data';
defineOptions({ name: 'MesHomeProductionTrend' });
const trendDays = ref(7); // å½“前选中的天数范围
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const { canSeeMenu } = useHomeAccess();
/** ç”Ÿäº§è¶‹åŠ¿å–è‡ªç”Ÿäº§æŠ¥å·¥ï¼ŒæŠ¥å·¥èœå•æ²¡å¼€å°±ä¸å±•ç¤ºã€ä¸è¯·æ±‚ */
const showTrend = computed(() => canSeeMenu(HOME_BLOCK_MENUS.feedback));
/** åŠ è½½ç”Ÿäº§è¶‹åŠ¿æ•°æ®å¹¶æ¸²æŸ“å›¾è¡¨ */
async function loadData() {
  if (!showTrend.value) return;
  const data = await getProductionTrend(trendDays.value);
  const dates = data.map((d) => d.date.slice(5));
  const quantities = data.map((d) => d.quantity);
@@ -40,7 +48,7 @@
</script>
<template>
  <Card title="生产趋势" class="h-full">
  <Card v-if="showTrend" title="生产趋势" class="h-full">
    <template #extra>
      <RadioGroup
        v-model:value="trendDays"
src/views/mes/home/modules/shortcuts.vue
@@ -1,7 +1,13 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Card, Col, Row } from 'ant-design-vue';
import { useHomeAccess } from '#/utils/use-home-access';
import { HOME_SHORTCUTS } from '../data';
defineOptions({ name: 'MesHomeShortcuts' });
@@ -9,72 +15,21 @@
  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',
  },
];
const { canSeeMenu } = useHomeAccess();
/** å¿«æ·å…¥å£åˆ—表,3×3 ç½‘格布局与订单状态分布面板等高;菜单没开的不展示 */
const shortcuts = computed(() =>
  HOME_SHORTCUTS.filter((item) => canSeeMenu(item.menu)),
);
</script>
<template>
  <Card title="快捷入口" class="h-full">
  <Card v-if="shortcuts.length" title="快捷入口" class="h-full">
    <Row :gutter="16">
      <Col 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)"
          @click="emit('navigate', item.menu)"
        >
          <div
            class="flex size-12 items-center justify-center rounded-xl text-white"
src/views/mes/home/modules/work-order-chart.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
@@ -9,16 +9,23 @@
import { getWorkOrderStatusDistribution } from '#/api/mes/home';
import { useHomeAccess } from '#/utils/use-home-access';
import { getWorkOrderStatusChartOptions } from '../chart-options';
import { WORK_ORDER_STATUS_COLOR_MAP } from '../data';
import { HOME_BLOCK_MENUS, WORK_ORDER_STATUS_COLOR_MAP } from '../data';
defineOptions({ name: 'MesHomeWorkOrderChart' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const { canSeeMenu } = useHomeAccess();
/** èœå•没开就不展示、不请求 */
const showChart = computed(() => canSeeMenu(HOME_BLOCK_MENUS.workOrder));
/** åŠ è½½è®¢å•çŠ¶æ€åˆ†å¸ƒæ•°æ®å¹¶æ¸²æŸ“é¥¼å›¾ */
async function loadData() {
  if (!showChart.value) return;
  const data = await getWorkOrderStatusDistribution();
  const chartData = data.map((d) => ({
    itemStyle: { color: WORK_ORDER_STATUS_COLOR_MAP[d.status] || '#409EFF' },
@@ -34,7 +41,7 @@
</script>
<template>
  <Card title="订单状态分布" class="h-full">
  <Card v-if="showChart" title="订单状态分布" class="h-full">
    <EchartsUI ref="chartRef" class="h-[280px] w-full" />
  </Card>
</template>