import { defineStore } from 'pinia' import { ref } from 'vue' import axios from 'axios' import { httpGet } from '@/utils/request' import { transformMenu, MENU_LABELS, type ResolvedMenu, type BackendMenuItem } from '@/config/menu-labels' /** * 菜单 Store * W3.7 改用 httpGet 统一封装 * W2 D3 改造:从后端 /sys/menu/nav 拉真实菜单树 * - 后端返回 name=null,改前端 MENU_LABELS 映射表(详见 config/menu-labels.ts) * - 用 transformMenu 转后端结构 -> 前端 ResolvedMenu * - 同 menu store 暴露给 AppLayout 渲染 */ export type MenuItem = ResolvedMenu export const useMenuStore = defineStore('menu', () => { const list = ref([]) const loading = ref(false) const error = ref(null) const collapsed = ref(false) /** 上次拉取时间(毫秒) */ const fetchedAt = ref(0) function toggleCollapsed() { collapsed.value = !collapsed.value } /** * F006 W2 D3:调 /sys/menu/nav 拿后端菜单树 * 走 vite proxy 转发到 gateway,header 带 Bearer(token 由 httpGet 拦截器自动加) * 5 分钟内不重复拉 */ /** * 过滤无效菜单项(测试数据、数字名称等) * super_admin=1 会返回所有菜单,包含很多测试/占位数据 */ function filterInvalidMenus(items: ResolvedMenu[]): ResolvedMenu[] { return items.filter(item => { const title = item.title?.trim() || '' // 过滤纯数字标题(如 "2", "70", "1111111111") if (/^\d+$/.test(title)) return false // 过滤"未知菜单" if (title === '未知菜单') return false // 过滤过长的数字串(超过8位) if (/^\d{8,}$/.test(title)) return false // 递归过滤子菜单 if (item.children?.length) { item.children = filterInvalidMenus(item.children) } return true }) } /** * V2 R2 (方案 B 实施): 后端拉到的菜单, url 不在 MENU_LABELS 里的全砍 * 父菜单(无 url 但有 children): 递归砍子菜单, 若砍光连父也删 * 极致干净: 前端 MENU_LABELS 是真实意图, 后端 super_admin 全集只是临时数据 */ function filterByFrontendTruth(items: ResolvedMenu[]): ResolvedMenu[] { const allowedUrls = new Set(MENU_LABELS.map(m => m.url)) const seenUrls = new Set() // W5.x.x+3.1: 跨父菜单去重 (老 vue 一个 url 可能在多个父菜单下重复) return items .map(item => { // 父菜单: 无 url, 递归处理 children if (!item.url) { if (!item.children?.length) return null const filtered = filterByFrontendTruth(item.children) if (!filtered.length) return null return { ...item, children: filtered } } // F002-W18 修: 后端 url 可能带 / 前缀 (如 /user/enterprise/enterprise), // MENU_LABELS 不带 / (如 user/enterprise/enterprise), 规范化后匹配 const normalizedUrl = item.url.replace(/^\//, '') if (!allowedUrls.has(item.url) && !allowedUrls.has(normalizedUrl)) return null if (seenUrls.has(normalizedUrl)) return null seenUrls.add(normalizedUrl) return item }) .filter((item): item is ResolvedMenu => item !== null) } async function fetchMenu(force = false) { if (loading.value) return if (!force && fetchedAt.value && Date.now() - fetchedAt.value < 5 * 60 * 1000) { return } loading.value = true error.value = null try { const data = await httpGet('/sys/menu/nav') const transformed = transformMenu(Array.isArray(data) ? data : []) const frontendTruth = filterByFrontendTruth(transformed) list.value = filterInvalidMenus(frontendTruth) fetchedAt.value = Date.now() } catch (e: any) { error.value = e?.msg || e?.message || '菜单加载失败' } finally { loading.value = false } } function clear() { list.value = [] fetchedAt.value = 0 error.value = null } return { list, loading, error, collapsed, fetchedAt, toggleCollapsed, fetchMenu, clear } })