Compare commits
2 Commits
feature/cs
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
5523695518 | |
|
|
3b5a908ee4 |
|
|
@ -1,4 +1 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit "$1"
|
||||
npx --no -- commitlint --edit $1
|
||||
|
|
|
|||
|
|
@ -1,4 +1 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="CPMS 运营平台" />
|
||||
<title>CPMS 运营平台</title>
|
||||
<meta name="description" content="六个羽友赛事运营平台" />
|
||||
<title>六个羽友赛事运营平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export default defineComponent({
|
|||
const theme = {
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 8,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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 from '@/router';
|
||||
|
||||
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
||||
|
||||
|
|
@ -15,17 +18,42 @@ 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import { useRouter, useRoute, RouterView } from 'vue-router';
|
||||
import { Layout, Menu } from 'ant-design-vue';
|
||||
import type { MenuProps } from 'ant-design-vue';
|
||||
|
|
@ -7,13 +7,50 @@ import {
|
|||
InfoCircleOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import styles from './BasicLayout.module.less';
|
||||
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
|
||||
/**
|
||||
* 基础布局:左侧导航 + 顶部栏 + 内容区 + 页脚
|
||||
* 图标名 → 组件映射
|
||||
* 后端下发的 icon 字符串在此映射为 antd icon 组件
|
||||
* 扩展新图标只需在此添加一行
|
||||
*/
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
DashboardOutlined,
|
||||
InfoCircleOutlined,
|
||||
SettingOutlined,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
||||
* 递归处理 iconName → icon VNode
|
||||
*/
|
||||
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
||||
return items.map((item) => {
|
||||
const resolved: any = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
};
|
||||
|
||||
// iconName 字符串转为 VNode
|
||||
if (item.iconName && ICON_MAP[item.iconName]) {
|
||||
resolved.icon = () => h(ICON_MAP[item.iconName]);
|
||||
}
|
||||
|
||||
if (item.children?.length) {
|
||||
resolved.children = resolveMenuItems(item.children);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础布局:左侧导航(后端菜单驱动) + 顶部栏 + 内容区 + 页脚
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'BasicLayout',
|
||||
|
|
@ -22,12 +59,11 @@ export default defineComponent({
|
|||
const router = useRouter();
|
||||
const collapsed = ref(false);
|
||||
|
||||
const { menuItems } = useMenuStore();
|
||||
|
||||
const selectedKeys = computed<string[]>(() => [route.path]);
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{ key: '/dashboard', icon: () => <DashboardOutlined />, label: '工作台' },
|
||||
{ key: '/about', icon: () => <InfoCircleOutlined />, label: '关于' },
|
||||
];
|
||||
const resolvedItems = computed(() => resolveMenuItems(menuItems.value));
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
router.push(key as string);
|
||||
|
|
@ -41,13 +77,13 @@ export default defineComponent({
|
|||
<Layout class={styles.container}>
|
||||
<Sider class={styles.sider} collapsed={collapsed.value} trigger={null} collapsible>
|
||||
<div class={styles.logo}>
|
||||
<span class={styles.logoText}>{collapsed.value ? 'CPMS' : 'CPMS 运营平台'}</span>
|
||||
<span class={styles.logoText}>{collapsed.value ? '6羽' : '六个羽友赛事'}</span>
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys.value}
|
||||
items={menuItems}
|
||||
items={resolvedItems.value}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
</Sider>
|
||||
|
|
@ -63,7 +99,7 @@ export default defineComponent({
|
|||
<RouterView />
|
||||
</Content>
|
||||
|
||||
<Footer class={styles.footer}>CPMS 运营平台 ©{new Date().getFullYear()}</Footer>
|
||||
<Footer class={styles.footer}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'ant-design-vue/dist/reset.css';
|
|||
|
||||
import App from './App';
|
||||
import router from './router';
|
||||
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
|
||||
import './assets/styles/index.less';
|
||||
|
||||
const app = createApp(App);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import styles from './About.module.less';
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface TechItem {
|
||||
name: string;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import { Card, Col, Row, Statistic } from 'ant-design-vue';
|
||||
import styles from './Dashboard.module.less';
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface StatItem {
|
||||
title: string;
|
||||
|
|
@ -1,20 +1,806 @@
|
|||
// ── 登录页 — 左右分栏 — 动画背景版 ──
|
||||
// 左侧:深色品牌展示区,含浮动光球、网格呼吸、脉冲涟漪、粒子点阵等多层动画
|
||||
// 右侧:白色登录表单区,入场渐显动画
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
// ━━━━ 左侧品牌展示区 ━━━━
|
||||
.brand {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: linear-gradient(160deg, #0c1e3a 0%, #132744 50%, #0a1628 100%);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
padding: 40px 56px 40px 64px;
|
||||
}
|
||||
|
||||
:global(.ant-card-head-title) {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
// ── 动画层1: 浮动光球 ──
|
||||
.orb1 {
|
||||
position: absolute;
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(22, 119, 255, 0.14) 0%,
|
||||
rgba(22, 119, 255, 0.04) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
top: -100px;
|
||||
right: 20%;
|
||||
z-index: 0;
|
||||
animation: orbFloat1 12s ease-in-out infinite;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.orb2 {
|
||||
position: absolute;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(82, 196, 26, 0.1) 0%,
|
||||
rgba(82, 196, 26, 0.03) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
bottom: 40px;
|
||||
left: 10%;
|
||||
z-index: 0;
|
||||
animation: orbFloat2 15s ease-in-out infinite;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.orb3 {
|
||||
position: absolute;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(250, 173, 20, 0.08) 0%,
|
||||
rgba(250, 173, 20, 0.02) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
top: 50%;
|
||||
left: 55%;
|
||||
z-index: 0;
|
||||
animation: orbFloat3 18s ease-in-out infinite;
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
// ── 动画层2: 脉冲涟漪 ──
|
||||
.pulse {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(22, 119, 255, 0.15);
|
||||
top: 30%;
|
||||
right: 15%;
|
||||
z-index: 0;
|
||||
animation: pulseExpand 4s ease-out infinite;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 30px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(22, 119, 255, 0.08);
|
||||
animation: pulseExpand 4s ease-out infinite 1s;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 60px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(22, 119, 255, 0.04);
|
||||
animation: pulseExpand 4s ease-out infinite 2s;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 动画层3: 网格呼吸 ──
|
||||
.brandGrid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
animation: gridBreathe 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// ── 动画层4: 粒子点阵 ──
|
||||
.particles {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: rgba(22, 119, 255, 0.5);
|
||||
animation: particleFloat 8s ease-in-out infinite;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: rgba(105, 177, 255, 0.3);
|
||||
top: -8px;
|
||||
left: 3px;
|
||||
animation: particleFloat 6s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 动画层5: 扫描线 ──
|
||||
.scanLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(22, 119, 255, 0.3), transparent);
|
||||
z-index: 1;
|
||||
animation: scanDown 8s linear infinite;
|
||||
}
|
||||
|
||||
// ━━━━ 动画定义 ━━━━
|
||||
@keyframes orbFloat1 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(30px, 20px) scale(1.05);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-20px, 40px) scale(0.95);
|
||||
}
|
||||
75% {
|
||||
transform: translate(10px, -30px) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orbFloat2 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(-40px, -20px) scale(1.08);
|
||||
}
|
||||
66% {
|
||||
transform: translate(30px, 30px) scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orbFloat3 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50px, -40px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseExpand {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gridBreathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes particleFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-30px) translateX(10px);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-60px) translateX(-5px);
|
||||
opacity: 0.3;
|
||||
}
|
||||
75% {
|
||||
transform: translateY(-30px) translateX(15px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanDown {
|
||||
0% {
|
||||
top: -2px;
|
||||
}
|
||||
100% {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// ━━━━ 内容层 ━━━━
|
||||
.brandInner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
animation: fadeInUp 0.8s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logo ──
|
||||
.logoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.logoMark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #1677ff, #69b1ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(22, 119, 255, 0.45);
|
||||
animation: logoPulse 3s ease-in-out infinite;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 18px;
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 20px rgba(22, 119, 255, 0.45);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 4px 30px rgba(22, 119, 255, 0.7),
|
||||
0 0 60px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
// ── 大标题 ──
|
||||
.brandTitle {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 12px;
|
||||
animation: fadeInUp 0.8s ease-out 0.1s both;
|
||||
}
|
||||
|
||||
.brandSubtitle {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin-bottom: 40px;
|
||||
max-width: 400px;
|
||||
animation: fadeInUp 0.8s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
// ── 数据统计条 ──
|
||||
.statsRow {
|
||||
display: flex;
|
||||
gap: 36px;
|
||||
margin-bottom: 44px;
|
||||
animation: fadeInUp 0.8s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.statHighlight {
|
||||
color: #69b1ff;
|
||||
}
|
||||
|
||||
// ── 赛事模拟卡片 ──
|
||||
.eventCards {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 44px;
|
||||
animation: fadeInUp 0.8s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 内部光效装饰
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(22, 119, 255, 0.06) 0%, transparent 50%);
|
||||
animation: cardShimmer 5s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(22, 119, 255, 0.3);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(22, 119, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardShimmer {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.eventIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&.blue {
|
||||
background: linear-gradient(135deg, #1677ff, #4096ff);
|
||||
box-shadow: 0 2px 12px rgba(22, 119, 255, 0.3);
|
||||
}
|
||||
|
||||
&.green {
|
||||
background: linear-gradient(135deg, #52c41a, #73d13d);
|
||||
box-shadow: 0 2px 12px rgba(82, 196, 26, 0.3);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background: linear-gradient(135deg, #fa8c16, #ffa940);
|
||||
box-shadow: 0 2px 12px rgba(250, 140, 22, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.eventDesc {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// ── 步骤流程 ──
|
||||
.flowSteps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
animation: fadeInUp 0.8s ease-out 0.5s both;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ── 星点效果 ──
|
||||
// 中心发光点 + 四条十字光芒 + 缓慢旋转 + 闪烁呼吸
|
||||
.stepDot {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
// 中心核心圆点
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 4px 2px rgba(22, 119, 255, 0.8),
|
||||
0 0 12px 4px rgba(22, 119, 255, 0.5),
|
||||
0 0 24px 8px rgba(22, 119, 255, 0.2);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: starCorePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// 十字光芒线(横+纵)
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
animation:
|
||||
starSpin 6s linear infinite,
|
||||
starGlowPulse 2s ease-in-out infinite;
|
||||
// 横向光芒
|
||||
background:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
transparent 45%,
|
||||
rgba(22, 119, 255, 0.9) 48%,
|
||||
#fff 50%,
|
||||
rgba(22, 119, 255, 0.9) 52%,
|
||||
transparent 55%
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 45%,
|
||||
rgba(22, 119, 255, 0.9) 48%,
|
||||
#fff 50%,
|
||||
rgba(22, 119, 255, 0.9) 52%,
|
||||
transparent 55%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤之间的连线
|
||||
.stepLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
min-width: 20px;
|
||||
background: linear-gradient(90deg, rgba(22, 119, 255, 0.5), rgba(22, 119, 255, 0.15));
|
||||
align-self: center;
|
||||
margin-top: -6px; // 微调与星点中心对齐(因为label占空间)
|
||||
}
|
||||
|
||||
@keyframes starCorePulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 4px 2px rgba(22, 119, 255, 0.8),
|
||||
0 0 12px 4px rgba(22, 119, 255, 0.5),
|
||||
0 0 24px 8px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 6px 3px rgba(22, 119, 255, 1),
|
||||
0 0 20px 6px rgba(22, 119, 255, 0.7),
|
||||
0 0 40px 12px rgba(22, 119, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes starSpin {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes starGlowPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// ── 左侧底部版权 ──
|
||||
.brandFooter {
|
||||
margin-top: auto;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
// ━━━━ 右侧登录区 ━━━━
|
||||
.loginSide {
|
||||
width: 460px;
|
||||
min-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 48px;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loginWrapper {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
animation: fadeInRight 0.6s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.loginTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.loginDesc {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
:global(.ant-input-affix-wrapper) {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border-color: #d9d9d9;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-input-prefix) {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
:global(.ant-form-item) {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.loginBtn {
|
||||
height: 46px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 3px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 记住密码行 ──
|
||||
.rememberRow {
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global(.ant-checkbox-wrapper) {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.ant-checkbox-checked .ant-checkbox-inner) {
|
||||
background-color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.loginFooter {
|
||||
margin-top: 28px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
// ━━━━ 响应式 — PC 端自适应 ━━━━
|
||||
|
||||
// ── 大屏 (>1200px): 默认样式,无需额外处理 ──
|
||||
|
||||
// ── 中屏 (≤1200px): 缩减间距、光球尺寸、字号 ──
|
||||
@media screen and (max-width: 1200px) {
|
||||
.brand {
|
||||
padding: 32px 36px 32px 40px;
|
||||
}
|
||||
|
||||
.brandTitle {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.brandSubtitle {
|
||||
font-size: 14px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.eventCards {
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eventIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.eventDesc {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.orb1 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.orb2 {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.orb3 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.loginSide {
|
||||
width: 400px;
|
||||
min-width: 340px;
|
||||
padding: 36px 40px;
|
||||
}
|
||||
|
||||
.loginWrapper {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.loginTitle {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.loginDesc {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { defineComponent, reactive, ref } from 'vue';
|
||||
import { Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
|
||||
import { Button, Checkbox, 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 { useEffect } from '@/hooks/useEffect';
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface LoginForm {
|
||||
|
|
@ -10,20 +10,31 @@ interface LoginForm {
|
|||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录页
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'LoginPage',
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const rememberMe = ref(false);
|
||||
|
||||
const form = reactive<LoginForm>({
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('cpms_login_remember');
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved);
|
||||
form.username = data.username || '';
|
||||
form.password = data.password || '';
|
||||
rememberMe.value = true;
|
||||
} catch {
|
||||
localStorage.removeItem('cpms_login_remember');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名' }],
|
||||
password: [{ required: true, message: '请输入密码' }],
|
||||
|
|
@ -37,46 +48,197 @@ export default defineComponent({
|
|||
|
||||
loading.value = true;
|
||||
|
||||
// TODO: 替换为真实登录接口
|
||||
// const res = await post<LoginResult>('/login', { ...form });
|
||||
// auth.login(res.token);
|
||||
|
||||
setTimeout(() => {
|
||||
auth.login('mock_token_' + Date.now());
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await auth.login('mock_token_' + Date.now());
|
||||
message.success('登录成功');
|
||||
|
||||
if (rememberMe.value) {
|
||||
localStorage.setItem(
|
||||
'cpms_login_remember',
|
||||
JSON.stringify({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem('cpms_login_remember');
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('登录失败,请重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
router.push('/dashboard');
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
const particles = [
|
||||
{ top: '8%', left: '15%', delay: '0s', duration: '8s' },
|
||||
{ top: '22%', left: '65%', delay: '1.5s', duration: '7s' },
|
||||
{ top: '45%', left: '25%', delay: '0.8s', duration: '9s' },
|
||||
{ top: '60%', left: '75%', delay: '2s', duration: '6.5s' },
|
||||
{ top: '75%', left: '40%', delay: '3s', duration: '8.5s' },
|
||||
{ top: '15%', left: '80%', delay: '1s', duration: '7.5s' },
|
||||
{ top: '35%', left: '50%', delay: '2.5s', duration: '6s' },
|
||||
{ top: '85%', left: '20%', delay: '0.5s', duration: '9.5s' },
|
||||
{ top: '50%', left: '90%', delay: '3.5s', duration: '8s' },
|
||||
{ top: '92%', left: '55%', delay: '1.2s', duration: '7s' },
|
||||
];
|
||||
|
||||
return () => (
|
||||
<div class={styles.container}>
|
||||
<Card class={styles.card} title="CPMS 运营平台">
|
||||
<Form model={form} rules={rules} layout="vertical" onFinish={handleSubmit}>
|
||||
<FormItem name="username">
|
||||
<Input
|
||||
v-model={[form.username, 'value']}
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
v-slots={{ prefix: () => <UserOutlined /> }}
|
||||
<div class={styles.brand}>
|
||||
<div class={styles.orb1} />
|
||||
<div class={styles.orb2} />
|
||||
<div class={styles.orb3} />
|
||||
<div class={styles.pulse} />
|
||||
<div class={styles.brandGrid} />
|
||||
<div class={styles.scanLine} />
|
||||
<div class={styles.particles}>
|
||||
{particles.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
class={styles.particle}
|
||||
style={{
|
||||
top: p.top,
|
||||
left: p.left,
|
||||
animationDelay: p.delay,
|
||||
animationDuration: p.duration,
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem name="password">
|
||||
<Input
|
||||
v-model={[form.password, 'value']}
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
v-slots={{ prefix: () => <LockOutlined /> }}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" html-type="submit" size="large" block loading={loading.value}>
|
||||
登录
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class={styles.brandInner}>
|
||||
<div class={styles.logoRow}>
|
||||
<div class={styles.logoMark}>6</div>
|
||||
<span class={styles.logoText}>羽友赛事</span>
|
||||
</div>
|
||||
<h1 class={styles.brandTitle}>六个羽友赛事运营平台</h1>
|
||||
<p class={styles.brandSubtitle}>
|
||||
专业的赛事管理、报名运营与数据分析一站式解决方案,助力每一场精彩赛事
|
||||
</p>
|
||||
<div class={styles.statsRow}>
|
||||
<div class={styles.statItem}>
|
||||
<span class={styles.statValue}>
|
||||
<span class={styles.statHighlight}>1,200</span>+
|
||||
</span>
|
||||
<span class={styles.statLabel}>累计赛事场次</span>
|
||||
</div>
|
||||
<div class={styles.statItem}>
|
||||
<span class={styles.statValue}>
|
||||
<span class={styles.statHighlight}>50,000</span>+
|
||||
</span>
|
||||
<span class={styles.statLabel}>参赛选手</span>
|
||||
</div>
|
||||
<div class={styles.statItem}>
|
||||
<span class={styles.statValue}>
|
||||
<span class={styles.statHighlight}>100</span>%
|
||||
</span>
|
||||
<span class={styles.statLabel}>运营满意度</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.eventCards}>
|
||||
<div class={styles.eventCard}>
|
||||
<div class={[styles.eventIcon, styles.blue]}>📋</div>
|
||||
<div class={styles.eventTitle}>赛事管理</div>
|
||||
<div class={styles.eventDesc}>创建发布、赛事审核、赛程编排全流程在线化</div>
|
||||
</div>
|
||||
<div class={styles.eventCard}>
|
||||
<div class={[styles.eventIcon, styles.green]}>🏆</div>
|
||||
<div class={styles.eventTitle}>对阵分组</div>
|
||||
<div class={styles.eventDesc}>智能分组算法、自动生成对阵表与淘汰树</div>
|
||||
</div>
|
||||
<div class={styles.eventCard}>
|
||||
<div class={[styles.eventIcon, styles.orange]}>📊</div>
|
||||
<div class={styles.eventTitle}>数据看板</div>
|
||||
<div class={styles.eventDesc}>参赛统计、实时进度、成绩排名可视化呈现</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.flowSteps}>
|
||||
<div class={styles.step}>
|
||||
<span class={styles.stepDot} />
|
||||
<span class={styles.stepLabel}>创建赛事</span>
|
||||
</div>
|
||||
<div class={styles.stepLine} />
|
||||
<div class={styles.step}>
|
||||
<span class={styles.stepDot} />
|
||||
<span class={styles.stepLabel}>赛事审核</span>
|
||||
</div>
|
||||
<div class={styles.stepLine} />
|
||||
<div class={styles.step}>
|
||||
<span class={styles.stepDot} />
|
||||
<span class={styles.stepLabel}>分组对阵</span>
|
||||
</div>
|
||||
<div class={styles.stepLine} />
|
||||
<div class={styles.step}>
|
||||
<span class={styles.stepDot} />
|
||||
<span class={styles.stepLabel}>成绩录入</span>
|
||||
</div>
|
||||
<div class={styles.stepLine} />
|
||||
<div class={styles.step}>
|
||||
<span class={styles.stepDot} />
|
||||
<span class={styles.stepLabel}>数据复盘</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.brandFooter}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.loginSide}>
|
||||
<div class={styles.loginWrapper}>
|
||||
<h2 class={styles.loginTitle}>欢迎回来</h2>
|
||||
<p class={styles.loginDesc}>登录六个羽友赛事运营平台</p>
|
||||
|
||||
<Form
|
||||
class={styles.loginForm}
|
||||
model={form}
|
||||
rules={rules}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<FormItem name="username">
|
||||
<Input
|
||||
v-model={[form.username, 'value']}
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
v-slots={{ prefix: () => <UserOutlined /> }}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem name="password">
|
||||
<Input
|
||||
v-model={[form.password, 'value']}
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
v-slots={{ prefix: () => <LockOutlined /> }}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
{/* 记住密码 */}
|
||||
<div class={styles.rememberRow}>
|
||||
<Checkbox v-model={[rememberMe.value, 'checked']}>记住密码</Checkbox>
|
||||
</div>
|
||||
|
||||
<FormItem>
|
||||
<Button
|
||||
class={styles.loginBtn}
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={loading.value}
|
||||
>
|
||||
登 录
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<div class={styles.loginFooter}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export default defineComponent({
|
|||
setup() {
|
||||
const router = useRouter();
|
||||
|
||||
const goHome = () => router.push('/');
|
||||
const goHome = () => router.push('/login');
|
||||
|
||||
return () => (
|
||||
<Result
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* Vite import.meta.glob 动态收集所有页面组件
|
||||
*
|
||||
* TS 不理解 import.meta.glob 的 glob 模式语法(含 **),
|
||||
* 所以整个文件禁用类型检查。
|
||||
* Vite 在构建时会正确处理 glob 模式并生成对应的动态 import。
|
||||
*
|
||||
* 返回格式: { '/src/pages/dashboard/index.tsx': () => Promise<Module> }
|
||||
* 新增页面只需在 src/pages/ 下创建目录和 index.tsx,无需修改映射表。
|
||||
*/
|
||||
const pageModules = import.meta.glob('/src/pages/**/index.tsx');
|
||||
|
||||
export { pageModules };
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import router from '@/router';
|
||||
import { auth } from '@/hooks/useAuth';
|
||||
|
||||
/**
|
||||
* 全局路由守卫
|
||||
* 核心流程:
|
||||
* 1. /login → 已登录则跳首页,未登录则放行
|
||||
* 2. 未登录 → 跳转 /login
|
||||
* 3. 已登录但菜单未加载 → 加载菜单+权限 → 重新导航(确保动态路由生效)
|
||||
* 4. 已登录且菜单已加载 → 放行
|
||||
*
|
||||
* 关键:当 loadMenu 动态注册路由后,必须用 next({ path, replace: true })
|
||||
* 重新触发导航,否则当前导航仍使用旧路由表,会匹配到 404。
|
||||
* 注意:不能用 next({ ...to, replace: true }),因为展开 to 会把旧的
|
||||
* matched 信息带过去,导致仍然匹配到 /:pathMatch(.*)*。
|
||||
*/
|
||||
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;
|
||||
|
||||
// ── /login 特殊处理 ──
|
||||
if (to.path === '/login') {
|
||||
if (auth.isLoggedIn()) {
|
||||
const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
|
||||
next(homePath.value);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 未登录 → 跳转登录页 ──
|
||||
if (!auth.isLoggedIn()) {
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 已登录:确保菜单和动态路由已加载 ──
|
||||
const { loadMenu, loaded } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
|
||||
if (!loaded.value) {
|
||||
const { loadPermissions } = await import('@/stores/permissionStore').then((m) =>
|
||||
m.usePermissionStore(),
|
||||
);
|
||||
await Promise.all([loadMenu(), loadPermissions()]);
|
||||
|
||||
// 动态路由刚注册,需要用新路由表重新匹配当前路径
|
||||
next({ path: to.fullPath, replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
|
@ -1,40 +1,37 @@
|
|||
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;
|
||||
/**
|
||||
* 静态路由名称集合(这些路由不会被 resetRouter 移除)
|
||||
*/
|
||||
const STATIC_ROUTE_NAMES = new Set(['Login', 'NotFound']);
|
||||
|
||||
// 如果访问的是登录页
|
||||
if (to.path === '/login') {
|
||||
if (isLoggedIn) {
|
||||
// 已登录则重定向到首页
|
||||
next('/dashboard');
|
||||
} else {
|
||||
// 未登录则允许访问登录页
|
||||
next();
|
||||
/**
|
||||
* 重置路由:清除动态添加的路由(包括 BasicLayout 和其子路由),
|
||||
* 恢复到只包含静态路由的状态。用于退出登录时清理。
|
||||
*/
|
||||
export function resetRouter() {
|
||||
const currentRoutes = router.getRoutes();
|
||||
|
||||
currentRoutes.forEach((route) => {
|
||||
const routeName = route.name as string;
|
||||
// 保留静态路由(Login, NotFound),移除所有动态路由(含 BasicLayout 及其子路由)
|
||||
if (routeName && !STATIC_ROUTE_NAMES.has(routeName)) {
|
||||
router.removeRoute(routeName);
|
||||
}
|
||||
} else {
|
||||
// 访问其他页面
|
||||
if (isLoggedIn) {
|
||||
// 已登录则允许访问
|
||||
next();
|
||||
} else {
|
||||
// 未登录则重定向到登录页
|
||||
next('/login');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
export { WHITE_LIST };
|
||||
|
|
|
|||
|
|
@ -1,34 +1,16 @@
|
|||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { createRoute } from './utils';
|
||||
|
||||
/**
|
||||
* ====== 业务路由模块 ======
|
||||
* 实际项目中按模块拆分,如 eventsRoutes、walletRoutes 等,
|
||||
* 然后在下方 routes 数组的布局 children 中引入。
|
||||
* 参考示例(嵌套路由):
|
||||
* ====== 静态路由(不需要后端下发) ======
|
||||
* 登录页、404 等不依赖权限的页面写在这里。
|
||||
* 动态路由由 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: '比赛结果' }),
|
||||
* ],
|
||||
* },
|
||||
* ],
|
||||
* };
|
||||
* 注意: BasicLayout 路由不在此注册,而是由 menuStore 动态添加,
|
||||
* 这样可以避免空 children 导致的路由匹配问题。
|
||||
*/
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
export const constantRoutes: RouteRecordRaw[] = [
|
||||
// ── 登录页(不经过布局)──
|
||||
{
|
||||
path: '/login',
|
||||
|
|
@ -37,32 +19,12 @@ export const routes: RouteRecordRaw[] = [
|
|||
meta: { title: '登录' },
|
||||
},
|
||||
|
||||
// ── 主布局(需要登录)──
|
||||
{
|
||||
path: '/',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── 404 ──
|
||||
// 注意: 通配路由必须放在最后,且不能被动态路由覆盖
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound'),
|
||||
component: () => import('@/pages/not-found'),
|
||||
meta: { title: '页面不存在' },
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 BasicLayout 布局路由(如果不存在)
|
||||
* 动态路由作为其子路由挂载
|
||||
*/
|
||||
function ensureLayoutRoute() {
|
||||
const existing = router.hasRoute('BasicLayout');
|
||||
if (!existing) {
|
||||
router.addRoute({
|
||||
path: '/',
|
||||
name: 'BasicLayout',
|
||||
component: () => import('@/layouts/BasicLayout'),
|
||||
children: [],
|
||||
meta: { title: '首页' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册兜底菜单和路由(后端接口不可用时使用)
|
||||
*/
|
||||
function registerFallbackRoutes() {
|
||||
ensureLayoutRoute();
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
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 布局路由已注册
|
||||
ensureLayoutRoute();
|
||||
|
||||
// 动态注册路由:将后端路由配置注入到 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';
|
||||
|
||||
// 兜底路由
|
||||
registerFallbackRoutes();
|
||||
|
||||
state.loaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMenu = () => {
|
||||
resetRouter();
|
||||
state.menuTree = [];
|
||||
state.menuItems = [];
|
||||
state.loaded = false;
|
||||
state.homePath = '/dashboard';
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
menuTree: computed(() => state.menuTree),
|
||||
menuItems: computed(() => state.menuItems),
|
||||
loaded: computed(() => state.loaded),
|
||||
homePath: computed(() => state.homePath),
|
||||
loadMenu,
|
||||
clearMenu,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { reactive, computed, toRefs } from 'vue';
|
||||
import type { PermissionCode } from '@/types';
|
||||
import { fetchPermissions } from '@/api/menu';
|
||||
|
||||
interface PermissionState {
|
||||
/** 权限编码集合 */
|
||||
codes: Set<PermissionCode>;
|
||||
/** 是否已加载 */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state = reactive<PermissionState>({
|
||||
codes: new Set(),
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
export function usePermissionStore() {
|
||||
/**
|
||||
* 从后端加载权限编码
|
||||
*/
|
||||
const loadPermissions = async () => {
|
||||
if (state.loaded) return;
|
||||
|
||||
try {
|
||||
const codes = await fetchPermissions();
|
||||
state.codes = new Set(codes);
|
||||
state.loaded = true;
|
||||
} catch (err) {
|
||||
console.error('加载权限失败:', err);
|
||||
state.codes = new Set();
|
||||
state.loaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除权限(退出登录时调用)
|
||||
*/
|
||||
const clearPermissions = () => {
|
||||
state.codes = new Set();
|
||||
state.loaded = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否拥有某个权限
|
||||
* @param code 权限编码,如 'user:delete'
|
||||
*/
|
||||
const hasPermission = (code: PermissionCode): boolean => {
|
||||
return state.codes.has(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否拥有任意一个权限
|
||||
*/
|
||||
const hasAnyPermission = (codes: PermissionCode[]): boolean => {
|
||||
return codes.some((code) => state.codes.has(code));
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
loaded: computed(() => state.loaded),
|
||||
loadPermissions,
|
||||
clearPermissions,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
};
|
||||
}
|
||||
|
|
@ -23,6 +23,61 @@ export interface SelectOption {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ── 菜单 & 权限 ──
|
||||
|
||||
/**
|
||||
* 后端下发的路由/菜单节点
|
||||
* 后端接口返回完整的路由配置树,包含 path、component、meta 等,
|
||||
* 前端根据 component 字段通过 import.meta.glob 动态加载对应页面组件,
|
||||
* 不需要前端维护组件映射表。
|
||||
*/
|
||||
export interface MenuNode {
|
||||
/** 菜单唯一标识 */
|
||||
id: number | string;
|
||||
/** 菜单/路由名称(同时作为 route.name) */
|
||||
name: string;
|
||||
/** 路由路径(如 /dashboard、/events/list) */
|
||||
path: string;
|
||||
/**
|
||||
* 组件路径(对应 src/pages/ 下的目录名)
|
||||
* 例如: 'dashboard' → import('@/pages/dashboard')
|
||||
* 例如: 'events/list' → import('@/pages/events/list')
|
||||
* 留空或省略 → 纯布局节点(自动重定向到第一个子路由)
|
||||
*/
|
||||
component?: string;
|
||||
/** 菜单图标(antd icon 名,如 DashboardOutlined) */
|
||||
icon?: string;
|
||||
/** 排序权重(越小越靠前) */
|
||||
sort?: number;
|
||||
/** 是否在菜单中隐藏(如详情页、编辑页不需要在侧边栏显示) */
|
||||
hideInMenu?: boolean;
|
||||
/** 外链地址(有值时点击跳转外部 URL,不注册内部路由) */
|
||||
externalLink?: string;
|
||||
/** 高亮的菜单路径(用于详情页高亮父级菜单) */
|
||||
activeMenu?: string;
|
||||
/** 子菜单/子路由 */
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单项(用于 antd Menu 组件渲染)
|
||||
*/
|
||||
export interface MenuItemRaw {
|
||||
key: string;
|
||||
icon?: any;
|
||||
/** antd icon 名称字符串,用于动态解析图标 */
|
||||
iconName?: string;
|
||||
label: string;
|
||||
children?: MenuItemRaw[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限编码
|
||||
* 后端下发的权限标识列表,用于按钮/操作级权限控制
|
||||
* 使用方式: v-if="hasPermission('user:delete')"
|
||||
*/
|
||||
export type PermissionCode = string;
|
||||
|
||||
/** 路由 meta 扩展(与 router/utils.ts 的 AppMeta 保持一致) */
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
|
|
@ -38,5 +93,7 @@ declare module 'vue-router' {
|
|||
activeMenu?: string;
|
||||
/** 权限标识 */
|
||||
permission?: string;
|
||||
/** 外链地址 */
|
||||
externalLink?: string;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
131
start-debug.js
131
start-debug.js
|
|
@ -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();
|
||||
|
|
@ -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';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue