gaoluyang
14 小时以前 5c1e0a47eea514f59f697133fe8a01bcbf6f7a78
refactor(首页): 展示模块跟随菜单权限自动隐藏

权限少的账号打开首页会挨个弹「没有该操作权限」:卡片不看权限就发请求,
403 被全局拦截器弹成 toast,取不到数的卡片还留着 0 的假数字。

- 新增共享判据 src/utils/use-home-access.ts:canQuery(接口权限码,带超管
兜底,与后端 @ss.hasPermission 对超管放行的口径一致)、canSeeMenu(菜单
开着;后端菜单 visible=0 只是 meta.hideInMenu,路由照样注册,所以要连祖先
一起看,和侧边栏剪枝一个口径)
- /analytics:生产 KPI 四张卡、经营 KPI 四张卡、生产趋势、工单状态/维修预警、
AI 智能分析块、快捷入口各按自己的权限与菜单显隐;没权限的接口直接不发请求,
列宽随可见卡片数自适应
- 顺手修正对不上九鸿任何菜单的路由名:MesQcRecord→MesQcIqc、
MesProScheduling→MesProTask、WlsMaterialStock→MesWmMaterialStock、
MesPdRouting→MesProcessDesignRoute、MesProRepair→MesDvRepair;删掉指向
已不存在的 MesHomeIndex 的「数据报表」入口

同一套判据先在万通 8a2832d0 落地,九鸿按自己的首页布局(生产 KPI/生产趋势/
预警面板)套用。九鸿的菜单目录节点是停用状态,普通角色拿不到这些权限码,
卡片会跟着菜单一起隐藏。

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加1个文件
已修改5个文件
314 ■■■■■ 文件已修改
src/utils/use-home-access.ts 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/alert-panel.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/biz-cards.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/kpi-cards.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/use-home-access.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
import type { RouteRecordRaw } from 'vue-router';
import { useAccess } from '@vben/access';
import { useAccessStore, useUserStore } from '@vben/stores';
/** é¦–页常驻,业务数据和入口仍需遵循当前用户的菜单权限。 */
export function useHomeAccess() {
  const accessStore = useAccessStore();
  const userStore = useUserStore();
  const { hasAccessByCodes } = useAccess();
  /**
   * æŽ¥å£æƒé™ï¼šæ²¡æœ‰å¯¹åº”权限码就取不到数,请求会直接 403,所以取数前先问一句。
   *
   * åŽç«¯ `@ss.hasPermission` å¯¹è¶…管是直接放行的(不看权限码),前端这里保持一致,
   * å¦åˆ™è¶…管会因为权限码没在菜单里配而少看到卡片。
   */
  function canQuery(permission: string) {
    return (
      userStore.userRoles.includes('super_admin') ||
      hasAccessByCodes([permission, '*:*:*'])
    );
  }
  /**
   * è¯¥èœå•对当前用户是否「开着」—— ç”¨äºŽé¦–页模块开关。
   *
   * åŽç«¯èœå• `visible = 0` ä¼šè¢«å‰ç«¯è½¬æˆè·¯ç”±çš„ `meta.hideInMenu`,这类路由仍然注册在
   * router é‡Œï¼ˆé¡µé¢å¯ä»¥ç›´æŽ¥è®¿é—®ï¼‰ï¼Œåªæ˜¯æ•´æ£µå­æ ‘不会出现在侧边栏。所以判断「这个模块有
   * æ²¡æœ‰å¼€ã€ä¸èƒ½åªçœ‹è·¯ç”±æ˜¯å¦å­˜åœ¨ï¼Œè¿˜è¦çœ‹å®ƒå’Œå®ƒçš„æ¯ä¸€çº§ç¥–先是否可见。
   *
   * @param name è·¯ç”± name,即后端菜单的 component_name
   */
  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;
  }
  return { canQuery, canSeeMenu };
}
src/views/dashboard/analytics/index.vue
@@ -4,9 +4,11 @@
import { Page } from '@vben/common-ui';
import { Card, Col, Row } from 'ant-design-vue';
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 AiInsights from './modules/ai-insights.vue';
@@ -20,6 +22,38 @@
const router = useRouter();
const summary = ref(defaultSummary);
const { canQuery, canSeeMenu } = useHomeAccess();
/** é¦–页各模块的接口权限都是 mes:home:query,取不到数就先别请求(否则弹「没有该操作权限」) */
const canViewSummary = computed(() => canQuery('mes:home:query'));
/** MES ç”Ÿäº§ KPI:数据取自首页汇总接口,且对应菜单开着才渲染 */
const showKpi = computed(
  () =>
    canViewSummary.value &&
    ['MesProWorkOrder', 'MesProFeedback', 'MesQcIqc', 'MesDvMachinery'].some(
      (name) => canSeeMenu(name),
    ),
);
/** ç”Ÿäº§è¶‹åŠ¿ï¼šæ•°æ®æ¥è‡ªç”Ÿäº§æŠ¥å·¥ */
const showTrend = computed(
  () => canViewSummary.value && canSeeMenu('MesProFeedback'),
);
/** å·¥å•状态分布 + ç»´ä¿®é¢„è­¦ */
const showAlert = computed(
  () =>
    canViewSummary.value &&
    (canSeeMenu('MesProWorkOrder') || canSeeMenu('MesDvRepair')),
);
/** ç”Ÿäº§è¶‹åŠ¿å’Œé¢„è­¦é¢æ¿åŒä¸€è¡Œï¼Œä¸¤å—éƒ½æ²¡å¼€å°±æ•´è¡Œä¸æ¸²æŸ“ */
const showMonitorRow = computed(() => showTrend.value || showAlert.value);
/** AI åˆ†æžå—里「N ä¸ªå·¥å•可分析」取的也是首页汇总接口,取不到数就别显示这个假数字 */
const showAiInsights = computed(() => canViewSummary.value);
const greeting = computed(() => {
  const hour = new Date().getHours();
@@ -42,6 +76,7 @@
}
async function loadSummary() {
  if (!canViewSummary.value) return;
  summary.value = await getHomeSummary();
}
@@ -72,20 +107,29 @@
    </div>
    <!-- MES ç”Ÿäº§ KPI -->
    <KpiCards :summary="summary" @navigate="handleNavigate" />
    <KpiCards v-if="showKpi" :summary="summary" @navigate="handleNavigate" />
    <!-- ERP/WMS/BPM ç»è¥ KPI -->
    <BizCards @navigate="handleNavigate" />
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      v-if="showAiInsights"
      :active-order-count="summary.workOrderActiveCount"
      @navigate="handleNavigate"
    />
    <!-- ç”Ÿäº§è¶‹åŠ¿ + é¢„警面板 -->
    <Row :gutter="16" class="mb-4">
      <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4">
    <Row v-if="showMonitorRow" :gutter="16" class="mb-4">
      <Col
        v-if="showTrend"
        :lg="showAlert ? 14 : 24"
        :md="24"
        :sm="24"
        :xl="showAlert ? 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天)
@@ -93,7 +137,15 @@
          <ProductionTrend />
        </div>
      </Col>
      <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4">
      <Col
        v-if="showAlert"
        :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
@@ -2,7 +2,7 @@
import type { MesHomeApi } from '#/api/mes/home';
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
@@ -11,6 +11,8 @@
import { Spin } from 'ant-design-vue';
import { getWorkOrderStatusDistribution } from '#/api/mes/home';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardAlertPanel' });
@@ -22,11 +24,19 @@
  navigate: [name: string];
}>();
const { canQuery, canSeeMenu } = useHomeAccess();
/** å·¥å•状态分布取自生产订单,接口权限和菜单都要有 */
const canViewStatus = computed(
  () => canQuery('mes:home:query') && canSeeMenu('MesProWorkOrder'),
);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(false);
async function loadStatus() {
  if (!canViewStatus.value) return;
  loading.value = true;
  try {
    const data = await getWorkOrderStatusDistribution();
@@ -68,8 +78,9 @@
    <!-- é¢„警卡片行 -->
    <div class="grid grid-cols-1 gap-3">
      <div
        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">
@@ -87,7 +98,7 @@
    </div>
    <!-- å·¥å•状态图 -->
    <div class="glass-tilt rounded-2xl p-5">
    <div v-if="canViewStatus" 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
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
@@ -11,11 +11,42 @@
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import { getTaskTodoPage } from '#/api/bpm/task';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardBizCards' });
const emit = defineEmits<{
  navigate: [name: string];
}>();
const { canQuery, canSeeMenu } = 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'),
);
/** å¯è§å¡ç‰‡æ•°é‡å†³å®šåˆ—宽,避免只剩一两张时挤在左上角 */
const colSpan = computed(() => {
  const count = [
    canViewPurchase.value,
    canViewSale.value,
    canViewStock.value,
    canViewBpmTodo.value,
  ].filter(Boolean).length;
  if (count >= 4) return 6;
  if (count === 3) return 8;
  if (count === 2) return 12;
  return 24;
});
const purchaseCount = ref(0);
const saleCount = ref(0);
@@ -23,6 +54,7 @@
const bpmTodoCount = ref(0);
async function loadPurchase() {
  if (!canViewPurchase.value) return;
  try {
    const result = await getPurchaseOrderPage({ pageNo: 1, pageSize: 1 });
    purchaseCount.value = result.total ?? 0;
@@ -30,6 +62,7 @@
}
async function loadSale() {
  if (!canViewSale.value) return;
  try {
    const result = await getSaleOrderPage({ pageNo: 1, pageSize: 1 });
    saleCount.value = result.total ?? 0;
@@ -37,6 +70,7 @@
}
async function loadStock() {
  if (!canViewStock.value) return;
  try {
    const result = await getMaterialStockPage({ pageNo: 1, pageSize: 1 });
    stockCount.value = result.total ?? 0;
@@ -44,6 +78,7 @@
}
async function loadBpmTodo() {
  if (!canViewBpmTodo.value) return;
  try {
    const result = await getTaskTodoPage({ pageNo: 1, pageSize: 1 });
    bpmTodoCount.value = result.total ?? 0;
@@ -85,7 +120,15 @@
<template>
  <Row :gutter="16">
    <!-- é‡‡è´­è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canViewPurchase"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('purchase')"
@@ -112,7 +155,15 @@
    </Col>
    <!-- é”€å”®è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canViewSale"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('sale')"
@@ -139,7 +190,15 @@
    </Col>
    <!-- ç‰©æ–™åº“å­˜ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canViewStock"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('stock')"
@@ -166,13 +225,21 @@
    </Col>
    <!-- BPM å¾…办 -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="canViewBpmTodo"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :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,6 +8,8 @@
import { Col, Row } from 'ant-design-vue';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardKpiCards' });
const props = defineProps<{
@@ -17,6 +19,38 @@
const emit = defineEmits<{
  navigate: [name: string];
}>();
const { canQuery, canSeeMenu } = useHomeAccess();
/** å››å¼ å¡çš„æ•°æ®éƒ½æ¥è‡ªé¦–页汇总接口,没有该权限时取下的是空值 */
const canViewSummary = computed(() => canQuery('mes:home:query'));
const showOrder = computed(
  () => canViewSummary.value && canSeeMenu('MesProWorkOrder'),
);
const showOutput = computed(
  () => canViewSummary.value && canSeeMenu('MesProFeedback'),
);
const showQuality = computed(
  () => canViewSummary.value && canSeeMenu('MesQcIqc'),
);
const showEquipment = computed(
  () => canViewSummary.value && canSeeMenu('MesDvMachinery'),
);
const visibleCards = computed(
  () =>
    [showOrder, showOutput, showQuality, showEquipment].filter(
      (card) => card.value,
    ).length,
);
/** å¯è§å¡ç‰‡æ•°é‡å†³å®šåˆ—宽,避免只剩一两张时挤在左上角 */
const colSpan = computed(() => {
  if (visibleCards.value >= 4) return 6;
  if (visibleCards.value === 3) return 8;
  if (visibleCards.value === 2) return 12;
  return 24;
});
const qualityRate = computed(() => {
  const total =
@@ -57,9 +91,17 @@
</script>
<template>
  <Row :gutter="16">
  <Row v-if="visibleCards > 0" :gutter="16">
    <!-- ç”Ÿäº§è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="showOrder"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('order')"
@@ -90,7 +132,15 @@
    </Col>
    <!-- ä»Šæ—¥äº§é‡ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="showOutput"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('output')"
@@ -121,13 +171,21 @@
    </Col>
    <!-- è´¨é‡åˆæ ¼çއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="showQuality"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('quality')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'quality')"
        @mouseleave="onMouseLeave('quality')"
        @click="emit('navigate', 'MesQcRecord')"
        @click="emit('navigate', 'MesQcIqc')"
      >
        <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">
@@ -154,7 +212,15 @@
    </Col>
    <!-- è®¾å¤‡è¿è¡Œçއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-if="showEquipment"
      :lg="colSpan"
      :md="colSpan"
      :sm="colSpan"
      :xl="colSpan"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('equip')"
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -1,5 +1,9 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useHomeAccess } from '#/utils/use-home-access';
defineOptions({ name: 'DashboardQuickActions' });
@@ -15,24 +19,30 @@
  shadow: string;
}
const { canSeeMenu } = useHomeAccess();
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' },
];
/** èœå•没开的入口不展示,避免点进去 403 */
const accessibleActions = computed(() =>
  actions.filter((action) => canSeeMenu(action.route)),
);
</script>
<template>
  <div class="glass-card rounded-2xl p-5">
  <div v-if="accessibleActions.length > 0" class="glass-card rounded-2xl p-5">
    <h3 class="mb-4 text-sm font-semibold text-gray-800 dark:text-gray-100">快捷操作</h3>
    <div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
      <div
        v-for="action in actions"
        v-for="action in accessibleActions"
        :key="action.route"
        class="group flex cursor-pointer flex-col items-center gap-2 rounded-xl p-3 transition-all duration-300 hover:-translate-y-1"
        @click="emit('navigate', action.route)"