From 3b5a908ee412a6181fa4c191ab6f84f087df120e Mon Sep 17 00:00:00 2001 From: Disciple_C Date: Thu, 23 Jul 2026 11:27:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9E=B6=E6=9E=84=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/commit-msg | 5 +- .husky/pre-commit | 3 - commitlint.config.js => commitlint.config.cjs | 0 index.html | 2 +- package-lock.json | 78 ++++++- package.json | 7 +- src/api/menu.ts | 16 ++ src/hooks/useAuth.ts | 63 +++++- src/layouts/BasicLayout.tsx | 50 ++++- src/main.ts | 1 + .../about/index.module.less} | 0 .../About.tsx => pages/about/index.tsx} | 2 +- .../dashboard/index.module.less} | 0 .../dashboard/index.tsx} | 2 +- src/pages/login/index.tsx | 23 +- .../not-found/index.tsx} | 0 src/router/glob.ts | 14 ++ src/router/guard.ts | 30 +++ src/router/index.ts | 48 ++-- src/router/routes.ts | 56 +---- src/stores/menuStore.ts | 209 ++++++++++++++++++ src/stores/permissionStore.ts | 66 ++++++ src/types/index.ts | 57 +++++ start-debug.js | 131 ----------- vite.config.ts | 35 ++- 25 files changed, 653 insertions(+), 245 deletions(-) rename commitlint.config.js => commitlint.config.cjs (100%) create mode 100644 src/api/menu.ts rename src/{views/About.module.less => pages/about/index.module.less} (100%) rename src/{views/About.tsx => pages/about/index.tsx} (96%) rename src/{views/Dashboard.module.less => pages/dashboard/index.module.less} (100%) rename src/{views/Dashboard.tsx => pages/dashboard/index.tsx} (97%) rename src/{views/NotFound.tsx => pages/not-found/index.tsx} (100%) create mode 100644 src/router/glob.ts create mode 100644 src/router/guard.ts create mode 100644 src/stores/menuStore.ts create mode 100644 src/stores/permissionStore.ts delete mode 100644 start-debug.js diff --git a/.husky/commit-msg b/.husky/commit-msg index b567676..0a4b97d 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -npx --no -- commitlint --edit "$1" +npx --no -- commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit index d24fdfc..2312dc5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - npx lint-staged diff --git a/commitlint.config.js b/commitlint.config.cjs similarity index 100% rename from commitlint.config.js rename to commitlint.config.cjs diff --git a/index.html b/index.html index 0dedaa8..5ff9342 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - CPMS 运营平台 + 赛事运营平台
diff --git a/package-lock.json b/package-lock.json index f43d517..d6e5eff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "cpms_web_admin_pc", - "version": "1.0.0", + "name": "cpms_operation_platform", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cpms_web_admin_pc", - "version": "1.0.0", + "name": "cpms_operation_platform", + "version": "0.0.0", "dependencies": { "@ant-design/icons-vue": "^7.0.1", "ant-design-vue": "^4.0.0", @@ -19,6 +19,7 @@ "devDependencies": { "@commitlint/cli": "^20.4.3", "@commitlint/config-conventional": "^20.4.3", + "@rollup/plugin-strip": "^3.0.4", "@types/node": "^20.10.0", "@types/qs": "^6.15.0", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -35,6 +36,7 @@ "prettier": "^3.0.0", "typescript": "^5.3.0", "vite": "^5.0.0", + "vite-plugin-remove-console": "^2.2.0", "vue-tsc": "^2.0.0" }, "engines": { @@ -939,6 +941,65 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-strip": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@rollup/plugin-strip/-/plugin-strip-3.0.4.tgz", + "integrity": "sha512-LDRV49ZaavxUo2YoKKMQjCxzCxugu1rCPQa0lDYBOWLj6vtzBMr8DcoJjsmg+s450RbKbe3qI9ZLaSO+O1oNbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.62.2", "cpu": [ @@ -2972,6 +3033,8 @@ }, "node_modules/express": { "version": "5.2.1", + "resolved": "http://192.168.2.127:4873/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -5721,6 +5784,13 @@ } } }, + "node_modules/vite-plugin-remove-console": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/vite-plugin-remove-console/-/vite-plugin-remove-console-2.2.0.tgz", + "integrity": "sha512-qgjh5pz75MdE9Kzs8J0kBwaCfifHV0ezRbB9rpGsIOxam+ilcGV7WOk91vFJXquzRmiKrFh3Hxlh0JJWAmXTbQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", diff --git a/package.json b/package.json index f91651a..85b4782 100644 --- a/package.json +++ b/package.json @@ -5,22 +5,19 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", "serve": "vite", "build:dev": "vue-tsc --noEmit && vite build --mode development", "build:test": "vue-tsc --noEmit && vite build --mode test", "build:prod": "vue-tsc --noEmit && vite build --mode production", "preview": "vite preview", "type-check": "vue-tsc --noEmit", - "local": "node start-debug.js", "lint": "eslint src --ext .js,.ts,.tsx", "lint:fix": "eslint src --ext .js,.ts,.tsx --fix", "format": "prettier --write \"src/**/*.{js,ts,tsx,json,less,css,md}\"", - "prepare": "husky install" + "prepare": "husky" }, "dependencies": { "@ant-design/icons-vue": "^7.0.1", - "@yp-component/root": "^0.0.46", "ant-design-vue": "^4.0.0", "express": "^5.2.1", "html2canvas": "^1.4.1", @@ -31,6 +28,7 @@ "devDependencies": { "@commitlint/cli": "^20.4.3", "@commitlint/config-conventional": "^20.4.3", + "@rollup/plugin-strip": "^3.0.4", "@types/node": "^20.10.0", "@types/qs": "^6.15.0", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -47,6 +45,7 @@ "prettier": "^3.0.0", "typescript": "^5.3.0", "vite": "^5.0.0", + "vite-plugin-remove-console": "^2.2.0", "vue-tsc": "^2.0.0" }, "lint-staged": { diff --git a/src/api/menu.ts b/src/api/menu.ts new file mode 100644 index 0000000..feaac2e --- /dev/null +++ b/src/api/menu.ts @@ -0,0 +1,16 @@ +import { get } from '@/utils/request'; +import type { MenuNode, PermissionCode } from '@/types'; + +/** + * 获取当前用户的菜单树(后端下发) + */ +export function fetchMenuTree(): Promise { + return get('/menu/tree'); +} + +/** + * 获取当前用户的权限编码列表 + */ +export function fetchPermissions(): Promise { + return get('/permission/list'); +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index a59e367..8a18967 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -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 }; +} diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index f01ba2a..784e918 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -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 = { + 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(() => [route.path]); - const menuItems: MenuProps['items'] = [ - { key: '/dashboard', icon: () => , label: '工作台' }, - { key: '/about', icon: () => , 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} /> diff --git a/src/main.ts b/src/main.ts index 2ed0b1f..d723be9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/views/About.module.less b/src/pages/about/index.module.less similarity index 100% rename from src/views/About.module.less rename to src/pages/about/index.module.less diff --git a/src/views/About.tsx b/src/pages/about/index.tsx similarity index 96% rename from src/views/About.tsx rename to src/pages/about/index.tsx index f167c8c..b4e7084 100644 --- a/src/views/About.tsx +++ b/src/pages/about/index.tsx @@ -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; diff --git a/src/views/Dashboard.module.less b/src/pages/dashboard/index.module.less similarity index 100% rename from src/views/Dashboard.module.less rename to src/pages/dashboard/index.module.less diff --git a/src/views/Dashboard.tsx b/src/pages/dashboard/index.tsx similarity index 97% rename from src/views/Dashboard.tsx rename to src/pages/dashboard/index.tsx index cf3c62a..489e275 100644 --- a/src/views/Dashboard.tsx +++ b/src/pages/dashboard/index.tsx @@ -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; diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 3f7ea8a..6eb3069 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -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({ @@ -37,16 +36,20 @@ export default defineComponent({ loading.value = true; - // TODO: 替换为真实登录接口 - // const res = await post('/login', { ...form }); - // auth.login(res.token); + try { + // TODO: 替换为真实登录接口 + // const res = await post('/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({ /> - diff --git a/src/views/NotFound.tsx b/src/pages/not-found/index.tsx similarity index 100% rename from src/views/NotFound.tsx rename to src/pages/not-found/index.tsx diff --git a/src/router/glob.ts b/src/router/glob.ts new file mode 100644 index 0000000..5160b4c --- /dev/null +++ b/src/router/glob.ts @@ -0,0 +1,14 @@ +// @ts-nocheck +/** + * Vite import.meta.glob 动态收集所有页面组件 + * + * TS 不理解 import.meta.glob 的 glob 模式语法(含 **), + * 所以整个文件禁用类型检查。 + * Vite 在构建时会正确处理 glob 模式并生成对应的动态 import。 + * + * 返回格式: { '/src/pages/dashboard/index.tsx': () => Promise } + * 新增页面只需在 src/pages/ 下创建目录和 index.tsx,无需修改映射表。 + */ +const pageModules = import.meta.glob('/src/pages/**/index.tsx'); + +export { pageModules }; diff --git a/src/router/guard.ts b/src/router/guard.ts new file mode 100644 index 0000000..f8dcf89 --- /dev/null +++ b/src/router/guard.ts @@ -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'); + } +}); diff --git a/src/router/index.ts b/src/router/index.ts index ae9e49e..0cccd85 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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 }; diff --git a/src/router/routes.ts b/src/router/routes.ts index af168c7..2b711fc 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -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: '页面不存在' }, }, ]; diff --git a/src/stores/menuStore.ts b/src/stores/menuStore.ts new file mode 100644 index 0000000..2c8628c --- /dev/null +++ b/src/stores/menuStore.ts @@ -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({ + 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, + }; +} diff --git a/src/stores/permissionStore.ts b/src/stores/permissionStore.ts new file mode 100644 index 0000000..003cff9 --- /dev/null +++ b/src/stores/permissionStore.ts @@ -0,0 +1,66 @@ +import { reactive, computed, toRefs } from 'vue'; +import type { PermissionCode } from '@/types'; +import { fetchPermissions } from '@/api/menu'; + +interface PermissionState { + /** 权限编码集合 */ + codes: Set; + /** 是否已加载 */ + loaded: boolean; +} + +const state = reactive({ + 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, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 9182e7d..7c314c0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; } } diff --git a/start-debug.js b/start-debug.js deleted file mode 100644 index 1b9f491..0000000 --- a/start-debug.js +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require('child_process'); -const path = require('path'); -const fs = require('fs'); -const readline = require('readline'); - -function loadEnv(filePath) { - if (!fs.existsSync(filePath)) return {}; - const content = fs.readFileSync(filePath, 'utf8'); - const env = {}; - content.split('\n').forEach((line) => { - const match = line.match(/^([^=:#]+?)=(.*)$/); - if (match) { - env[match[1].trim()] = match[2].trim().replace(/^['"](.*)['"]$/, '$1'); - } - }); - return env; -} - -function getComponentLibs(env) { - const libs = []; - - libs.push({ - name: 'cpms_web_component (默认)', - path: path.resolve(__dirname, '../cpms_web_component'), - useKey: null, - usePath: null, - }); - - for (const key in env) { - if (key.startsWith('COMPONENT_LIB_') && key !== 'COMPONENT_LIB_PATH') { - libs.push({ - name: key.replace('COMPONENT_LIB_', ''), - path: path.resolve(__dirname, env[key]), - useKey: key.replace('COMPONENT_LIB_', ''), - usePath: null, - }); - } - } - - if (env.COMPONENT_LIB_PATH) { - libs.push({ - name: '自定义路径', - path: path.resolve(__dirname, env.COMPONENT_LIB_PATH), - useKey: null, - usePath: env.COMPONENT_LIB_PATH, - }); - } - - return libs; -} - -async function selectLib(libs) { - if (libs.length === 1) return libs[0]; - - console.log('📦 请选择要使用的组件库:'); - libs.forEach((lib, i) => { - const exists = fs.existsSync(lib.path) ? '✅' : '❌'; - console.log(` ${i + 1}. ${lib.name} ${exists}`); - console.log(` ${lib.path}`); - }); - console.log(' 0. 使用 npm 版本'); - - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - return new Promise((resolve) => { - rl.question(`\n请输入选项 (0-${libs.length},回车默认): `, (answer) => { - rl.close(); - if (answer === '0') resolve(null); - else if (answer && /^\d+$/.test(answer)) { - const idx = parseInt(answer) - 1; - resolve(idx >= 0 && idx < libs.length ? libs[idx] : libs[0]); - } else { - resolve(libs[0]); - } - }); - }); -} - -async function main() { - const localEnv = loadEnv(path.resolve(__dirname, '.env.local')); - const libs = getComponentLibs(localEnv); - - console.log('🚀 启动本地调试模式...\n'); - const selected = await selectLib(libs); - - const mergedEnv = { ...process.env, ...localEnv, LOCAL_DEBUG: 'true' }; - - if (!selected) { - console.log('\n📦 使用 npm 版本'); - delete mergedEnv.COMPONENT_LIB; - delete mergedEnv.COMPONENT_LIB_PATH; - } else { - const exists = fs.existsSync(selected.path); - if (!exists) { - console.warn(`\n⚠️ 组件库不存在: ${selected.name}`); - console.warn(` ${selected.path}`); - console.warn(' 将使用 npm 版本'); - delete mergedEnv.COMPONENT_LIB; - delete mergedEnv.COMPONENT_LIB_PATH; - } else { - console.log(`\n📦 ${selected.name}`); - console.log(`📍 ${selected.path}`); - if (selected.useKey) { - mergedEnv.COMPONENT_LIB = selected.useKey; - delete mergedEnv.COMPONENT_LIB_PATH; - } else if (selected.usePath) { - mergedEnv.COMPONENT_LIB_PATH = selected.usePath; - delete mergedEnv.COMPONENT_LIB; - } else { - delete mergedEnv.COMPONENT_LIB; - delete mergedEnv.COMPONENT_LIB_PATH; - } - } - } - - console.log('\n⏳ 启动 Vite...\n'); - - try { - execSync('npx vite --port 3000 --open --strictPort false', { - stdio: 'inherit', - cwd: process.cwd(), - env: mergedEnv, - }); - } catch (error) { - console.error('❌ 启动失败:', error.message); - process.exit(1); - } -} - -main(); diff --git a/vite.config.ts b/vite.config.ts index 21e6065..6e7821e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,15 +1,28 @@ import { defineConfig, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; +import removeConsole from 'vite-plugin-remove-console'; +import strip from '@rollup/plugin-strip'; import { resolve } from 'path'; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); + const isProd = mode === 'production'; return { base: env.VITE_BASE_URL || '/', - plugins: [vue(), vueJsx()], + plugins: [ + vue(), + vueJsx(), + // 生产构建:移除 console.log / console.warn / console.error + isProd && removeConsole(), + // 生产构建:移除代码中的注释 + isProd && + strip({ + include: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + }), + ].filter(Boolean), resolve: { alias: { '@': resolve(__dirname, 'src'), @@ -27,7 +40,8 @@ export default defineConfig(({ mode }) => { }, server: { host: '0.0.0.0', - port: Number(env.VITE_PORT) || 5173, + port: 3000, + strictPort: false, open: true, proxy: { '/api': { @@ -40,12 +54,23 @@ export default defineConfig(({ mode }) => { build: { outDir: 'dist', sourcemap: mode !== 'production', + // 生产构建移除 console 和注释(esbuild minify 阶段) + minify: isProd ? 'esbuild' : false, chunkSizeWarningLimit: 1500, rollupOptions: { output: { - manualChunks: { - vue: ['vue', 'vue-router'], - antd: ['ant-design-vue'], + // 更细粒度的分包,确保按需加载 + manualChunks(id) { + if (id.includes('node_modules')) { + if (id.includes('ant-design-vue') || id.includes('@ant-design')) { + return 'antd'; + } + if (id.includes('vue') || id.includes('@vue') || id.includes('vue-router')) { + return 'vue-vendor'; + } + // 其他第三方依赖单独打包,未使用的不会进入 + return 'vendor'; + } }, }, },