feat: 架构调整
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { get } from '@/utils/request';
|
||||
import type { MenuNode, PermissionCode } from '@/types';
|
||||
|
||||
/**
|
||||
* 获取当前用户的菜单树(后端下发)
|
||||
*/
|
||||
export function fetchMenuTree(): Promise<MenuNode[]> {
|
||||
return get('/menu/tree');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的权限编码列表
|
||||
*/
|
||||
export function fetchPermissions(): Promise<PermissionCode[]> {
|
||||
return get('/permission/list');
|
||||
}
|
||||
+61
-2
@@ -1,6 +1,9 @@
|
||||
// src/hooks/useAuth.ts
|
||||
import { useState } from './useState';
|
||||
import { watch } from 'vue';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import router, { WHITE_LIST } from '@/router';
|
||||
|
||||
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
||||
|
||||
@@ -15,20 +18,76 @@ function createAuth() {
|
||||
}
|
||||
});
|
||||
|
||||
const login = (newToken: string) => {
|
||||
const isLoggedIn = () => !!token.value;
|
||||
|
||||
/**
|
||||
* 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
|
||||
*/
|
||||
const login = async (newToken: string) => {
|
||||
setToken(newToken);
|
||||
|
||||
const { loadMenu, homePath } = useMenuStore();
|
||||
const { loadPermissions } = usePermissionStore();
|
||||
|
||||
// 并行拉取菜单和权限
|
||||
await Promise.all([loadMenu(), loadPermissions()]);
|
||||
|
||||
// 跳转到首页(动态路由已注册完成)
|
||||
router.push(homePath.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 退出:清除 token → 清除菜单+权限 → 清除动态路由 → 跳转登录页
|
||||
*/
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
|
||||
const { clearMenu } = useMenuStore();
|
||||
const { clearPermissions } = usePermissionStore();
|
||||
|
||||
clearMenu();
|
||||
clearPermissions();
|
||||
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return {
|
||||
token,
|
||||
isLoggedIn: () => !!token.value,
|
||||
isLoggedIn,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuth();
|
||||
|
||||
/**
|
||||
* 路由守卫逻辑(由 router/guard.ts 使用)
|
||||
* 判断逻辑:
|
||||
* 1. 白名单路径 → 直接放行
|
||||
* 2. 已登录但菜单未加载 → 加载菜单后放行
|
||||
* 3. 已登录且菜单已加载 → 直接放行
|
||||
* 4. 未登录 → 跳转 /login
|
||||
*/
|
||||
export async function handleRouteGuard(
|
||||
toPath: string,
|
||||
): Promise<{ allow: boolean; redirect?: string }> {
|
||||
// 白名单直接放行
|
||||
if (WHITE_LIST.includes(toPath) || toPath.match(/^\/:pathMatch/)) {
|
||||
return { allow: true };
|
||||
}
|
||||
|
||||
// 未登录
|
||||
if (!auth.isLoggedIn()) {
|
||||
return { allow: false, redirect: '/login' };
|
||||
}
|
||||
|
||||
// 已登录但菜单未加载 → 先加载
|
||||
const { loadMenu, loaded } = useMenuStore();
|
||||
if (!loaded.value) {
|
||||
const { loadPermissions } = usePermissionStore();
|
||||
await Promise.all([loadMenu(), loadPermissions()]);
|
||||
}
|
||||
|
||||
return { allow: true };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import { useRouter, useRoute, RouterView } from 'vue-router';
|
||||
import { Layout, Menu } from 'ant-design-vue';
|
||||
import type { MenuProps } from 'ant-design-vue';
|
||||
@@ -7,13 +7,50 @@ import {
|
||||
InfoCircleOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import styles from './BasicLayout.module.less';
|
||||
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
|
||||
/**
|
||||
* 基础布局:左侧导航 + 顶部栏 + 内容区 + 页脚
|
||||
* 图标名 → 组件映射
|
||||
* 后端下发的 icon 字符串在此映射为 antd icon 组件
|
||||
* 扩展新图标只需在此添加一行
|
||||
*/
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
DashboardOutlined,
|
||||
InfoCircleOutlined,
|
||||
SettingOutlined,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
||||
* 递归处理 iconName → icon VNode
|
||||
*/
|
||||
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
||||
return items.map((item) => {
|
||||
const resolved: any = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
};
|
||||
|
||||
// iconName 字符串转为 VNode
|
||||
if (item.iconName && ICON_MAP[item.iconName]) {
|
||||
resolved.icon = () => h(ICON_MAP[item.iconName]);
|
||||
}
|
||||
|
||||
if (item.children?.length) {
|
||||
resolved.children = resolveMenuItems(item.children);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础布局:左侧导航(后端菜单驱动) + 顶部栏 + 内容区 + 页脚
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'BasicLayout',
|
||||
@@ -22,12 +59,11 @@ export default defineComponent({
|
||||
const router = useRouter();
|
||||
const collapsed = ref(false);
|
||||
|
||||
const { menuItems } = useMenuStore();
|
||||
|
||||
const selectedKeys = computed<string[]>(() => [route.path]);
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{ key: '/dashboard', icon: () => <DashboardOutlined />, label: '工作台' },
|
||||
{ key: '/about', icon: () => <InfoCircleOutlined />, label: '关于' },
|
||||
];
|
||||
const resolvedItems = computed(() => resolveMenuItems(menuItems.value));
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
router.push(key as string);
|
||||
@@ -47,7 +83,7 @@ export default defineComponent({
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys.value}
|
||||
items={menuItems}
|
||||
items={resolvedItems.value}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import App from './App';
|
||||
import router from './router';
|
||||
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
|
||||
import './assets/styles/index.less';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import styles from './About.module.less';
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface TechItem {
|
||||
name: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { Card, Col, Row, Statistic } from 'ant-design-vue';
|
||||
import styles from './Dashboard.module.less';
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface StatItem {
|
||||
title: string;
|
||||
+13
-10
@@ -1,7 +1,6 @@
|
||||
import { defineComponent, reactive, ref } from 'vue';
|
||||
import { Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { auth } from '@/hooks/useAuth';
|
||||
import styles from './index.module.less';
|
||||
|
||||
@@ -12,11 +11,11 @@ interface LoginForm {
|
||||
|
||||
/**
|
||||
* 登录页
|
||||
* auth.login() 内部会自动:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'LoginPage',
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
|
||||
const form = reactive<LoginForm>({
|
||||
@@ -37,16 +36,20 @@ export default defineComponent({
|
||||
|
||||
loading.value = true;
|
||||
|
||||
// TODO: 替换为真实登录接口
|
||||
// const res = await post<LoginResult>('/login', { ...form });
|
||||
// auth.login(res.token);
|
||||
try {
|
||||
// TODO: 替换为真实登录接口
|
||||
// const res = await post<LoginResult>('/auth/login', { username: form.username, password: form.password });
|
||||
// await auth.login(res.token);
|
||||
|
||||
setTimeout(() => {
|
||||
auth.login('mock_token_' + Date.now());
|
||||
// Mock:模拟登录
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await auth.login('mock_token_' + Date.now());
|
||||
message.success('登录成功');
|
||||
} catch (err) {
|
||||
message.error('登录失败,请重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
router.push('/dashboard');
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
return () => (
|
||||
@@ -71,7 +74,7 @@ export default defineComponent({
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" html-type="submit" size="large" block loading={loading.value}>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading.value}>
|
||||
登录
|
||||
</Button>
|
||||
</FormItem>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Vite import.meta.glob 动态收集所有页面组件
|
||||
*
|
||||
* TS 不理解 import.meta.glob 的 glob 模式语法(含 **),
|
||||
* 所以整个文件禁用类型检查。
|
||||
* Vite 在构建时会正确处理 glob 模式并生成对应的动态 import。
|
||||
*
|
||||
* 返回格式: { '/src/pages/dashboard/index.tsx': () => Promise<Module> }
|
||||
* 新增页面只需在 src/pages/ 下创建目录和 index.tsx,无需修改映射表。
|
||||
*/
|
||||
const pageModules = import.meta.glob('/src/pages/**/index.tsx');
|
||||
|
||||
export { pageModules };
|
||||
@@ -0,0 +1,30 @@
|
||||
import router from '@/router';
|
||||
import { auth, handleRouteGuard } from '@/hooks/useAuth';
|
||||
|
||||
/**
|
||||
* 全局路由守卫
|
||||
* 核心流程:
|
||||
* 1. 白名单(/login 等) → 直接放行
|
||||
* 2. 已登录 → 加载菜单权限(首次) → 放行
|
||||
* 3. 未登录 → 跳转 /login
|
||||
* 4. 页面标题自动设置
|
||||
*/
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// 设置页面标题
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
|
||||
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
|
||||
|
||||
const { allow, redirect } = await handleRouteGuard(to.path);
|
||||
|
||||
if (allow) {
|
||||
// 已登录访问 /login → 跳首页
|
||||
if (to.path === '/login' && auth.isLoggedIn()) {
|
||||
const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
|
||||
next(homePath.value);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} else {
|
||||
next(redirect || '/login');
|
||||
}
|
||||
});
|
||||
+20
-28
@@ -1,40 +1,32 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { routes } from './routes';
|
||||
import { auth } from '@/hooks/useAuth';
|
||||
import { constantRoutes } from './routes';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.VITE_BASE_URL || '/'),
|
||||
routes,
|
||||
routes: constantRoutes,
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
});
|
||||
|
||||
// 路由守卫:登录鉴权 + 页面标题
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const isLoggedIn = auth.isLoggedIn();
|
||||
/**
|
||||
* 路由白名单:不需要登录即可访问的路径
|
||||
*/
|
||||
const WHITE_LIST = ['/login', '/404'];
|
||||
|
||||
// 设置页面标题
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
|
||||
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
|
||||
/**
|
||||
* 重置路由:清除动态添加的子路由,恢复到只包含静态路由的状态
|
||||
* 用于退出登录时清理
|
||||
*/
|
||||
export function resetRouter() {
|
||||
const currentRoutes = router.getRoutes();
|
||||
const protectedNames = new Set(['Login', 'BasicLayout', 'NotFound']);
|
||||
|
||||
// 如果访问的是登录页
|
||||
if (to.path === '/login') {
|
||||
if (isLoggedIn) {
|
||||
// 已登录则重定向到首页
|
||||
next('/dashboard');
|
||||
} else {
|
||||
// 未登录则允许访问登录页
|
||||
next();
|
||||
currentRoutes.forEach((route) => {
|
||||
// 保留静态路由(Login, BasicLayout, NotFound),移除动态添加的子路由
|
||||
if (!protectedNames.has(route.name as string)) {
|
||||
router.removeRoute(route.name!);
|
||||
}
|
||||
} else {
|
||||
// 访问其他页面
|
||||
if (isLoggedIn) {
|
||||
// 已登录则允许访问
|
||||
next();
|
||||
} else {
|
||||
// 未登录则重定向到登录页
|
||||
next('/login');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
export { WHITE_LIST };
|
||||
|
||||
+12
-44
@@ -1,34 +1,13 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { createRoute } from './utils';
|
||||
|
||||
/**
|
||||
* ====== 业务路由模块 ======
|
||||
* 实际项目中按模块拆分,如 eventsRoutes、walletRoutes 等,
|
||||
* 然后在下方 routes 数组的布局 children 中引入。
|
||||
* 参考示例(嵌套路由):
|
||||
*
|
||||
* const eventsRoutes = {
|
||||
* path: '/events',
|
||||
* name: 'EventsModule',
|
||||
* redirect: '/events/list',
|
||||
* meta: { title: '我的赛事', activeMenu: '/events' },
|
||||
* children: [
|
||||
* createRoute('list', () => import('@/pages/events/list'), { title: '赛事列表' }),
|
||||
* {
|
||||
* path: 'bracket',
|
||||
* name: 'BracketPage',
|
||||
* component: () => import('@/pages/events/bracket'),
|
||||
* meta: { title: '赛事详情' },
|
||||
* children: [
|
||||
* createRoute('player-management', () => import('@/pages/events/bracket/player-management'), { title: '选手管理' }),
|
||||
* createRoute('match-result', () => import('@/pages/events/bracket/match-result'), { title: '比赛结果' }),
|
||||
* ],
|
||||
* },
|
||||
* ],
|
||||
* };
|
||||
* ====== 静态路由(不需要后端下发) ======
|
||||
* 登录页、404 等不依赖权限的页面写在这里。
|
||||
* 动态路由由 menuStore 在登录后通过 router.addRoute 注入,
|
||||
* 后端下发 MenuNode 包含 component 字段,
|
||||
* 前端通过 import.meta.glob 动态匹配 src/pages 下的组件。
|
||||
*/
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
export const constantRoutes: RouteRecordRaw[] = [
|
||||
// ── 登录页(不经过布局)──
|
||||
{
|
||||
path: '/login',
|
||||
@@ -37,32 +16,21 @@ export const routes: RouteRecordRaw[] = [
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
|
||||
// ── 主布局(需要登录)──
|
||||
// ── 主布局(动态路由的父容器) ──
|
||||
// name 为 'BasicLayout',动态路由通过 router.addRoute('BasicLayout', child) 挂载到此布局下
|
||||
{
|
||||
path: '/',
|
||||
name: 'BasicLayout',
|
||||
component: () => import('@/layouts/BasicLayout'),
|
||||
children: [
|
||||
// 业务模块路由在此添加,例如:eventsRoutes, walletRoutes
|
||||
|
||||
createRoute('/dashboard', () => import('@/views/Dashboard'), {
|
||||
title: '工作台',
|
||||
icon: 'DashboardOutlined',
|
||||
}),
|
||||
createRoute('/about', () => import('@/views/About'), {
|
||||
title: '关于',
|
||||
icon: 'InfoCircleOutlined',
|
||||
}),
|
||||
|
||||
// 默认重定向
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
],
|
||||
children: [],
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
|
||||
// ── 404 ──
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound'),
|
||||
component: () => import('@/pages/not-found'),
|
||||
meta: { title: '页面不存在' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { reactive, computed, toRefs } from 'vue';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import type { MenuNode, MenuItemRaw } from '@/types';
|
||||
import router, { resetRouter } from '@/router';
|
||||
import { fetchMenuTree } from '@/api/menu';
|
||||
import { pageModules } from '@/router/glob';
|
||||
|
||||
/**
|
||||
* 使用 import.meta.glob 动态收集 src/pages/ 下所有页面组件
|
||||
* (实际 glob 逻辑在 @/router/glob.ts 中,因为 TS 不支持 glob 语法)
|
||||
*
|
||||
* 后端下发 component 字段(如 'dashboard')时,
|
||||
* 前端自动匹配到 '/src/pages/dashboard/index.tsx' 的懒加载函数。
|
||||
*
|
||||
* 扩展新页面时只需在 src/pages/ 下创建目录和 index.tsx,
|
||||
* 不需要修改任何映射表。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将后端的 component 字段解析为 vite 动态 import 函数
|
||||
* 'dashboard' → 匹配 '/src/pages/dashboard/index.tsx'
|
||||
* 'events/list' → 匹配 '/src/pages/events/list/index.tsx'
|
||||
*/
|
||||
function resolveComponent(component?: string): RouteRecordRaw['component'] | undefined {
|
||||
if (!component) return undefined;
|
||||
|
||||
// 构建 glob key
|
||||
const globKey = `/src/pages/${component}/index.tsx`;
|
||||
|
||||
// 直接匹配
|
||||
if (pageModules[globKey]) {
|
||||
return pageModules[globKey];
|
||||
}
|
||||
|
||||
// 兜底:尝试带 @ 前缀匹配(Vite alias)
|
||||
const aliasKey = `@/pages/${component}/index.tsx`;
|
||||
if (pageModules[aliasKey]) {
|
||||
return pageModules[aliasKey];
|
||||
}
|
||||
|
||||
console.warn(`[路由] 未找到页面组件: ${component} (globKey: ${globKey})`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
/** 后端下发的原始菜单树 */
|
||||
menuTree: MenuNode[];
|
||||
/** 转换后的 antd Menu items */
|
||||
menuItems: MenuItemRaw[];
|
||||
/** 是否已加载(登录后只加载一次) */
|
||||
loaded: boolean;
|
||||
/** 首页路径(第一个可见菜单的 path) */
|
||||
homePath: string;
|
||||
}
|
||||
|
||||
const state = reactive<MenuState>({
|
||||
menuTree: [],
|
||||
menuItems: [],
|
||||
loaded: false,
|
||||
homePath: '/dashboard',
|
||||
});
|
||||
|
||||
/**
|
||||
* 将后端 MenuNode 递归转为 antd Menu 的 items 格式
|
||||
*/
|
||||
function transformMenuNode(nodes: MenuNode[]): MenuItemRaw[] {
|
||||
return nodes
|
||||
.filter((node) => !node.hideInMenu && !node.externalLink)
|
||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
.map((node) => {
|
||||
const item: MenuItemRaw = {
|
||||
key: node.path,
|
||||
label: node.name,
|
||||
iconName: node.icon,
|
||||
};
|
||||
|
||||
if (node.children?.length) {
|
||||
item.children = transformMenuNode(node.children);
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 MenuNode 递归转为 vue-router RouteRecordRaw
|
||||
* component 字段通过 resolveComponent() 动态解析
|
||||
*/
|
||||
function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
|
||||
const routes: RouteRecordRaw[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
// 外链节点不注册路由
|
||||
if (node.externalLink) continue;
|
||||
|
||||
const componentLoader = resolveComponent(node.component);
|
||||
|
||||
const route: RouteRecordRaw = {
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
meta: {
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
hideInMenu: node.hideInMenu,
|
||||
activeMenu: node.activeMenu,
|
||||
externalLink: node.externalLink,
|
||||
},
|
||||
} as RouteRecordRaw;
|
||||
|
||||
if (componentLoader) {
|
||||
route.component = componentLoader;
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
route.children = transformMenuToRoutes(node.children);
|
||||
// 没有组件的父节点自动重定向到第一个子节点
|
||||
if (!componentLoader && node.children[0]) {
|
||||
route.redirect = node.children[0].path;
|
||||
}
|
||||
}
|
||||
|
||||
routes.push(route);
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个可见菜单的路径(用作默认首页)
|
||||
*/
|
||||
function getFirstMenuPath(nodes: MenuNode[]): string {
|
||||
for (const node of nodes) {
|
||||
if (!node.hideInMenu && !node.externalLink && !node.children?.length) {
|
||||
return node.path;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const childPath = getFirstMenuPath(node.children);
|
||||
if (childPath) return childPath;
|
||||
}
|
||||
}
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
export function useMenuStore() {
|
||||
const loadMenu = async () => {
|
||||
if (state.loaded) return;
|
||||
|
||||
try {
|
||||
const menuTree = await fetchMenuTree();
|
||||
state.menuTree = menuTree;
|
||||
state.menuItems = transformMenuNode(menuTree);
|
||||
state.homePath = getFirstMenuPath(menuTree);
|
||||
|
||||
// 动态注册路由:将后端路由配置注入到 BasicLayout 布局下
|
||||
const dynamicRoutes = transformMenuToRoutes(menuTree);
|
||||
dynamicRoutes.forEach((route) => {
|
||||
router.addRoute('BasicLayout', route);
|
||||
});
|
||||
|
||||
// 默认重定向到首页(BasicLayout 的根路径)
|
||||
router.addRoute('BasicLayout', { path: '', redirect: state.homePath });
|
||||
|
||||
state.loaded = true;
|
||||
} catch (err) {
|
||||
console.error('加载菜单失败:', err);
|
||||
// 降级:使用兜底菜单
|
||||
state.menuItems = [
|
||||
{ key: '/dashboard', label: '工作台', iconName: 'DashboardOutlined' },
|
||||
{ key: '/about', label: '关于', iconName: 'InfoCircleOutlined' },
|
||||
];
|
||||
state.homePath = '/dashboard';
|
||||
|
||||
// 兜底路由(挂载到 BasicLayout 下)
|
||||
router.addRoute('BasicLayout', {
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/pages/dashboard'),
|
||||
meta: { title: '工作台', icon: 'DashboardOutlined' },
|
||||
});
|
||||
router.addRoute('BasicLayout', {
|
||||
path: '/about',
|
||||
name: 'About',
|
||||
component: () => import('@/pages/about'),
|
||||
meta: { title: '关于', icon: 'InfoCircleOutlined' },
|
||||
});
|
||||
router.addRoute('BasicLayout', { path: '', redirect: '/dashboard' });
|
||||
|
||||
state.loaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMenu = () => {
|
||||
resetRouter();
|
||||
state.menuTree = [];
|
||||
state.menuItems = [];
|
||||
state.loaded = false;
|
||||
state.homePath = '/dashboard';
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
menuTree: computed(() => state.menuTree),
|
||||
menuItems: computed(() => state.menuItems),
|
||||
loaded: computed(() => state.loaded),
|
||||
homePath: computed(() => state.homePath),
|
||||
loadMenu,
|
||||
clearMenu,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { reactive, computed, toRefs } from 'vue';
|
||||
import type { PermissionCode } from '@/types';
|
||||
import { fetchPermissions } from '@/api/menu';
|
||||
|
||||
interface PermissionState {
|
||||
/** 权限编码集合 */
|
||||
codes: Set<PermissionCode>;
|
||||
/** 是否已加载 */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state = reactive<PermissionState>({
|
||||
codes: new Set(),
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
export function usePermissionStore() {
|
||||
/**
|
||||
* 从后端加载权限编码
|
||||
*/
|
||||
const loadPermissions = async () => {
|
||||
if (state.loaded) return;
|
||||
|
||||
try {
|
||||
const codes = await fetchPermissions();
|
||||
state.codes = new Set(codes);
|
||||
state.loaded = true;
|
||||
} catch (err) {
|
||||
console.error('加载权限失败:', err);
|
||||
state.codes = new Set();
|
||||
state.loaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除权限(退出登录时调用)
|
||||
*/
|
||||
const clearPermissions = () => {
|
||||
state.codes = new Set();
|
||||
state.loaded = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否拥有某个权限
|
||||
* @param code 权限编码,如 'user:delete'
|
||||
*/
|
||||
const hasPermission = (code: PermissionCode): boolean => {
|
||||
return state.codes.has(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否拥有任意一个权限
|
||||
*/
|
||||
const hasAnyPermission = (codes: PermissionCode[]): boolean => {
|
||||
return codes.some((code) => state.codes.has(code));
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
loaded: computed(() => state.loaded),
|
||||
loadPermissions,
|
||||
clearPermissions,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,61 @@ export interface SelectOption {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ── 菜单 & 权限 ──
|
||||
|
||||
/**
|
||||
* 后端下发的路由/菜单节点
|
||||
* 后端接口返回完整的路由配置树,包含 path、component、meta 等,
|
||||
* 前端根据 component 字段通过 import.meta.glob 动态加载对应页面组件,
|
||||
* 不需要前端维护组件映射表。
|
||||
*/
|
||||
export interface MenuNode {
|
||||
/** 菜单唯一标识 */
|
||||
id: number | string;
|
||||
/** 菜单/路由名称(同时作为 route.name) */
|
||||
name: string;
|
||||
/** 路由路径(如 /dashboard、/events/list) */
|
||||
path: string;
|
||||
/**
|
||||
* 组件路径(对应 src/pages/ 下的目录名)
|
||||
* 例如: 'dashboard' → import('@/pages/dashboard')
|
||||
* 例如: 'events/list' → import('@/pages/events/list')
|
||||
* 留空或省略 → 纯布局节点(自动重定向到第一个子路由)
|
||||
*/
|
||||
component?: string;
|
||||
/** 菜单图标(antd icon 名,如 DashboardOutlined) */
|
||||
icon?: string;
|
||||
/** 排序权重(越小越靠前) */
|
||||
sort?: number;
|
||||
/** 是否在菜单中隐藏(如详情页、编辑页不需要在侧边栏显示) */
|
||||
hideInMenu?: boolean;
|
||||
/** 外链地址(有值时点击跳转外部 URL,不注册内部路由) */
|
||||
externalLink?: string;
|
||||
/** 高亮的菜单路径(用于详情页高亮父级菜单) */
|
||||
activeMenu?: string;
|
||||
/** 子菜单/子路由 */
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单项(用于 antd Menu 组件渲染)
|
||||
*/
|
||||
export interface MenuItemRaw {
|
||||
key: string;
|
||||
icon?: any;
|
||||
/** antd icon 名称字符串,用于动态解析图标 */
|
||||
iconName?: string;
|
||||
label: string;
|
||||
children?: MenuItemRaw[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限编码
|
||||
* 后端下发的权限标识列表,用于按钮/操作级权限控制
|
||||
* 使用方式: v-if="hasPermission('user:delete')"
|
||||
*/
|
||||
export type PermissionCode = string;
|
||||
|
||||
/** 路由 meta 扩展(与 router/utils.ts 的 AppMeta 保持一致) */
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
@@ -38,5 +93,7 @@ declare module 'vue-router' {
|
||||
activeMenu?: string;
|
||||
/** 权限标识 */
|
||||
permission?: string;
|
||||
/** 外链地址 */
|
||||
externalLink?: string;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user