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';
+ }
},
},
},