feat: 架构调整

This commit is contained in:
Disciple_C 2026-07-23 11:27:33 +08:00
parent 6f37264047
commit 3b5a908ee4
25 changed files with 653 additions and 245 deletions

View File

@ -1,4 +1 @@
#!/usr/bin/env sh npx --no -- commitlint --edit $1
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit "$1"

View File

@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged npx lint-staged

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="CPMS 运营平台" /> <meta name="description" content="CPMS 运营平台" />
<title>CPMS 运营平台</title> <title>赛事运营平台</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

78
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "cpms_web_admin_pc", "name": "cpms_operation_platform",
"version": "1.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cpms_web_admin_pc", "name": "cpms_operation_platform",
"version": "1.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.0.0", "ant-design-vue": "^4.0.0",
@ -19,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.4.3", "@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3", "@commitlint/config-conventional": "^20.4.3",
"@rollup/plugin-strip": "^3.0.4",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/qs": "^6.15.0", "@types/qs": "^6.15.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
@ -35,6 +36,7 @@
"prettier": "^3.0.0", "prettier": "^3.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-remove-console": "^2.2.0",
"vue-tsc": "^2.0.0" "vue-tsc": "^2.0.0"
}, },
"engines": { "engines": {
@ -939,6 +941,65 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.2", "version": "4.62.2",
"cpu": [ "cpu": [
@ -2972,6 +3033,8 @@
}, },
"node_modules/express": { "node_modules/express": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "http://192.168.2.127:4873/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "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": { "node_modules/vscode-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",

View File

@ -5,22 +5,19 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite",
"serve": "vite", "serve": "vite",
"build:dev": "vue-tsc --noEmit && vite build --mode development", "build:dev": "vue-tsc --noEmit && vite build --mode development",
"build:test": "vue-tsc --noEmit && vite build --mode test", "build:test": "vue-tsc --noEmit && vite build --mode test",
"build:prod": "vue-tsc --noEmit && vite build --mode production", "build:prod": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"local": "node start-debug.js",
"lint": "eslint src --ext .js,.ts,.tsx", "lint": "eslint src --ext .js,.ts,.tsx",
"lint:fix": "eslint src --ext .js,.ts,.tsx --fix", "lint:fix": "eslint src --ext .js,.ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{js,ts,tsx,json,less,css,md}\"", "format": "prettier --write \"src/**/*.{js,ts,tsx,json,less,css,md}\"",
"prepare": "husky install" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"@yp-component/root": "^0.0.46",
"ant-design-vue": "^4.0.0", "ant-design-vue": "^4.0.0",
"express": "^5.2.1", "express": "^5.2.1",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
@ -31,6 +28,7 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.4.3", "@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3", "@commitlint/config-conventional": "^20.4.3",
"@rollup/plugin-strip": "^3.0.4",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/qs": "^6.15.0", "@types/qs": "^6.15.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
@ -47,6 +45,7 @@
"prettier": "^3.0.0", "prettier": "^3.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-remove-console": "^2.2.0",
"vue-tsc": "^2.0.0" "vue-tsc": "^2.0.0"
}, },
"lint-staged": { "lint-staged": {

16
src/api/menu.ts Normal file
View File

@ -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');
}

View File

@ -1,6 +1,9 @@
// src/hooks/useAuth.ts // src/hooks/useAuth.ts
import { useState } from './useState'; import { useState } from './useState';
import { watch } from 'vue'; 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'; 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); setToken(newToken);
const { loadMenu, homePath } = useMenuStore();
const { loadPermissions } = usePermissionStore();
// 并行拉取菜单和权限
await Promise.all([loadMenu(), loadPermissions()]);
// 跳转到首页(动态路由已注册完成)
router.push(homePath.value);
}; };
/**
* 退出:清除 token +
*/
const logout = () => { const logout = () => {
setToken(null); setToken(null);
const { clearMenu } = useMenuStore();
const { clearPermissions } = usePermissionStore();
clearMenu();
clearPermissions();
router.push('/login');
}; };
return { return {
token, token,
isLoggedIn: () => !!token.value, isLoggedIn,
login, login,
logout, logout,
}; };
} }
export const auth = createAuth(); 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 };
}

View File

@ -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 { useRouter, useRoute, RouterView } from 'vue-router';
import { Layout, Menu } from 'ant-design-vue'; import { Layout, Menu } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue'; import type { MenuProps } from 'ant-design-vue';
@ -7,13 +7,50 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useMenuStore } from '@/stores/menuStore';
import styles from './BasicLayout.module.less'; import styles from './BasicLayout.module.less';
const { Header, Sider, Content, Footer } = Layout; 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({ export default defineComponent({
name: 'BasicLayout', name: 'BasicLayout',
@ -22,12 +59,11 @@ export default defineComponent({
const router = useRouter(); const router = useRouter();
const collapsed = ref(false); const collapsed = ref(false);
const { menuItems } = useMenuStore();
const selectedKeys = computed<string[]>(() => [route.path]); const selectedKeys = computed<string[]>(() => [route.path]);
const menuItems: MenuProps['items'] = [ const resolvedItems = computed(() => resolveMenuItems(menuItems.value));
{ key: '/dashboard', icon: () => <DashboardOutlined />, label: '工作台' },
{ key: '/about', icon: () => <InfoCircleOutlined />, label: '关于' },
];
const handleMenuClick: MenuProps['onClick'] = ({ key }) => { const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
router.push(key as string); router.push(key as string);
@ -47,7 +83,7 @@ export default defineComponent({
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={selectedKeys.value} selectedKeys={selectedKeys.value}
items={menuItems} items={resolvedItems.value}
onClick={handleMenuClick} onClick={handleMenuClick}
/> />
</Sider> </Sider>

View File

@ -4,6 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import App from './App'; import App from './App';
import router from './router'; import router from './router';
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
import './assets/styles/index.less'; import './assets/styles/index.less';
const app = createApp(App); const app = createApp(App);

View File

@ -1,6 +1,6 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { Card } from 'ant-design-vue'; import { Card } from 'ant-design-vue';
import styles from './About.module.less'; import styles from './index.module.less';
interface TechItem { interface TechItem {
name: string; name: string;

View File

@ -1,6 +1,6 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { Card, Col, Row, Statistic } from 'ant-design-vue'; import { Card, Col, Row, Statistic } from 'ant-design-vue';
import styles from './Dashboard.module.less'; import styles from './index.module.less';
interface StatItem { interface StatItem {
title: string; title: string;

View File

@ -1,7 +1,6 @@
import { defineComponent, reactive, ref } from 'vue'; import { defineComponent, reactive, ref } from 'vue';
import { Button, Card, Form, FormItem, Input, message } from 'ant-design-vue'; import { Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'; import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { auth } from '@/hooks/useAuth'; import { auth } from '@/hooks/useAuth';
import styles from './index.module.less'; import styles from './index.module.less';
@ -12,11 +11,11 @@ interface LoginForm {
/** /**
* *
* auth.login() 内部会自动:保存 token +
*/ */
export default defineComponent({ export default defineComponent({
name: 'LoginPage', name: 'LoginPage',
setup() { setup() {
const router = useRouter();
const loading = ref(false); const loading = ref(false);
const form = reactive<LoginForm>({ const form = reactive<LoginForm>({
@ -37,16 +36,20 @@ export default defineComponent({
loading.value = true; loading.value = true;
try {
// TODO: 替换为真实登录接口 // TODO: 替换为真实登录接口
// const res = await post<LoginResult>('/login', { ...form }); // const res = await post<LoginResult>('/auth/login', { username: form.username, password: form.password });
// auth.login(res.token); // await auth.login(res.token);
setTimeout(() => { // Mock:模拟登录
auth.login('mock_token_' + Date.now()); await new Promise((r) => setTimeout(r, 600));
await auth.login('mock_token_' + Date.now());
message.success('登录成功'); message.success('登录成功');
} catch (err) {
message.error('登录失败,请重试');
} finally {
loading.value = false; loading.value = false;
router.push('/dashboard'); }
}, 600);
}; };
return () => ( return () => (
@ -71,7 +74,7 @@ export default defineComponent({
/> />
</FormItem> </FormItem>
<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> </Button>
</FormItem> </FormItem>

14
src/router/glob.ts Normal file
View File

@ -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 };

30
src/router/guard.ts Normal file
View File

@ -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');
}
});

View File

@ -1,40 +1,32 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { routes } from './routes'; import { constantRoutes } from './routes';
import { auth } from '@/hooks/useAuth';
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_URL || '/'), history: createWebHistory(import.meta.env.VITE_BASE_URL || '/'),
routes, routes: constantRoutes,
scrollBehavior: () => ({ left: 0, top: 0 }), 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']);
// 如果访问的是登录页 currentRoutes.forEach((route) => {
if (to.path === '/login') { // 保留静态路由(Login, BasicLayout, NotFound),移除动态添加的子路由
if (isLoggedIn) { if (!protectedNames.has(route.name as string)) {
// 已登录则重定向到首页 router.removeRoute(route.name!);
next('/dashboard');
} else {
// 未登录则允许访问登录页
next();
}
} else {
// 访问其他页面
if (isLoggedIn) {
// 已登录则允许访问
next();
} else {
// 未登录则重定向到登录页
next('/login');
}
} }
}); });
}
export default router; export default router;
export { WHITE_LIST };

View File

@ -1,34 +1,13 @@
import type { RouteRecordRaw } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router';
import { createRoute } from './utils';
/** /**
* ====== ====== * ====== () ======
* , eventsRouteswalletRoutes , * 404
* routes children * menuStore router.addRoute ,
* (): * MenuNode component ,
* * import.meta.glob src/pages
* 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: '比赛结果' }),
* ],
* },
* ],
* };
*/ */
export const constantRoutes: RouteRecordRaw[] = [
export const routes: RouteRecordRaw[] = [
// ── 登录页(不经过布局)── // ── 登录页(不经过布局)──
{ {
path: '/login', path: '/login',
@ -37,32 +16,21 @@ export const routes: RouteRecordRaw[] = [
meta: { title: '登录' }, meta: { title: '登录' },
}, },
// ── 主布局(需要登录)── // ── 主布局(动态路由的父容器) ──
// name 为 'BasicLayout',动态路由通过 router.addRoute('BasicLayout', child) 挂载到此布局下
{ {
path: '/', path: '/',
name: 'BasicLayout',
component: () => import('@/layouts/BasicLayout'), component: () => import('@/layouts/BasicLayout'),
children: [ children: [],
// 业务模块路由在此添加,例如:eventsRoutes, walletRoutes meta: { title: '首页' },
createRoute('/dashboard', () => import('@/views/Dashboard'), {
title: '工作台',
icon: 'DashboardOutlined',
}),
createRoute('/about', () => import('@/views/About'), {
title: '关于',
icon: 'InfoCircleOutlined',
}),
// 默认重定向
{ path: '/', redirect: '/dashboard' },
],
}, },
// ── 404 ── // ── 404 ──
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: 'NotFound', name: 'NotFound',
component: () => import('@/views/NotFound'), component: () => import('@/pages/not-found'),
meta: { title: '页面不存在' }, meta: { title: '页面不存在' },
}, },
]; ];

209
src/stores/menuStore.ts Normal file
View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -23,6 +23,61 @@ export interface SelectOption {
disabled?: boolean; disabled?: boolean;
} }
// ── 菜单 & 权限 ──
/**
* /
* , pathcomponentmeta ,
* 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 保持一致) */ /** 路由 meta 扩展(与 router/utils.ts 的 AppMeta 保持一致) */
declare module 'vue-router' { declare module 'vue-router' {
interface RouteMeta { interface RouteMeta {
@ -38,5 +93,7 @@ declare module 'vue-router' {
activeMenu?: string; activeMenu?: string;
/** 权限标识 */ /** 权限标识 */
permission?: string; permission?: string;
/** 外链地址 */
externalLink?: string;
} }
} }

View File

@ -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();

View File

@ -1,15 +1,28 @@
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx'; import vueJsx from '@vitejs/plugin-vue-jsx';
import removeConsole from 'vite-plugin-remove-console';
import strip from '@rollup/plugin-strip';
import { resolve } from 'path'; import { resolve } from 'path';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ''); const env = loadEnv(mode, process.cwd(), '');
const isProd = mode === 'production';
return { return {
base: env.VITE_BASE_URL || '/', 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: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'src'), '@': resolve(__dirname, 'src'),
@ -27,7 +40,8 @@ export default defineConfig(({ mode }) => {
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: Number(env.VITE_PORT) || 5173, port: 3000,
strictPort: false,
open: true, open: true,
proxy: { proxy: {
'/api': { '/api': {
@ -40,12 +54,23 @@ export default defineConfig(({ mode }) => {
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: mode !== 'production', sourcemap: mode !== 'production',
// 生产构建移除 console 和注释(esbuild minify 阶段)
minify: isProd ? 'esbuild' : false,
chunkSizeWarningLimit: 1500, chunkSizeWarningLimit: 1500,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { // 更细粒度的分包,确保按需加载
vue: ['vue', 'vue-router'], manualChunks(id) {
antd: ['ant-design-vue'], 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';
}
}, },
}, },
}, },