feat: 运营平台基建 搭建
This commit is contained in:
parent
3b5a908ee4
commit
5523695518
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<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="六个羽友赛事运营平台" />
|
||||||
<title>赛事运营平台</title>
|
<title>六个羽友赛事运营平台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default defineComponent({
|
||||||
const theme = {
|
const theme = {
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: '#1677ff',
|
colorPrimary: '#1677ff',
|
||||||
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useState } from './useState';
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { useMenuStore } from '@/stores/menuStore';
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
import { usePermissionStore } from '@/stores/permissionStore';
|
import { usePermissionStore } from '@/stores/permissionStore';
|
||||||
import router, { WHITE_LIST } from '@/router';
|
import router from '@/router';
|
||||||
|
|
||||||
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
||||||
|
|
||||||
|
|
@ -60,34 +60,3 @@ function createAuth() {
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export default defineComponent({
|
||||||
<Layout class={styles.container}>
|
<Layout class={styles.container}>
|
||||||
<Sider class={styles.sider} collapsed={collapsed.value} trigger={null} collapsible>
|
<Sider class={styles.sider} collapsed={collapsed.value} trigger={null} collapsible>
|
||||||
<div class={styles.logo}>
|
<div class={styles.logo}>
|
||||||
<span class={styles.logoText}>{collapsed.value ? 'CPMS' : 'CPMS 运营平台'}</span>
|
<span class={styles.logoText}>{collapsed.value ? '6羽' : '六个羽友赛事'}</span>
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
|
|
@ -99,7 +99,7 @@ export default defineComponent({
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
<Footer class={styles.footer}>CPMS 运营平台 ©{new Date().getFullYear()}</Footer>
|
<Footer class={styles.footer}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,806 @@
|
||||||
|
// ── 登录页 — 左右分栏 — 动画背景版 ──
|
||||||
|
// 左侧:深色品牌展示区,含浮动光球、网格呼吸、脉冲涟漪、粒子点阵等多层动画
|
||||||
|
// 右侧:白色登录表单区,入场渐显动画
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
overflow: hidden;
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
// ━━━━ 左侧品牌展示区 ━━━━
|
||||||
width: 400px;
|
.brand {
|
||||||
max-width: 90vw;
|
flex: 1;
|
||||||
border-radius: 12px;
|
display: flex;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
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) {
|
// ── 动画层1: 浮动光球 ──
|
||||||
text-align: center;
|
.orb1 {
|
||||||
font-size: 18px;
|
position: absolute;
|
||||||
font-weight: 600;
|
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,7 +1,8 @@
|
||||||
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, Checkbox, 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 { auth } from '@/hooks/useAuth';
|
import { auth } from '@/hooks/useAuth';
|
||||||
|
import { useEffect } from '@/hooks/useEffect';
|
||||||
import styles from './index.module.less';
|
import styles from './index.module.less';
|
||||||
|
|
||||||
interface LoginForm {
|
interface LoginForm {
|
||||||
|
|
@ -9,20 +10,31 @@ interface LoginForm {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录页
|
|
||||||
* auth.login() 内部会自动:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
|
|
||||||
*/
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'LoginPage',
|
name: 'LoginPage',
|
||||||
setup() {
|
setup() {
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const rememberMe = ref(false);
|
||||||
|
|
||||||
const form = reactive<LoginForm>({
|
const form = reactive<LoginForm>({
|
||||||
username: 'admin',
|
username: '',
|
||||||
password: '123456',
|
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 = {
|
const rules = {
|
||||||
username: [{ required: true, message: '请输入用户名' }],
|
username: [{ required: true, message: '请输入用户名' }],
|
||||||
password: [{ required: true, message: '请输入密码' }],
|
password: [{ required: true, message: '请输入密码' }],
|
||||||
|
|
@ -37,14 +49,21 @@ export default defineComponent({
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 替换为真实登录接口
|
|
||||||
// const res = await post<LoginResult>('/auth/login', { username: form.username, password: form.password });
|
|
||||||
// await auth.login(res.token);
|
|
||||||
|
|
||||||
// Mock:模拟登录
|
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
await auth.login('mock_token_' + Date.now());
|
await auth.login('mock_token_' + Date.now());
|
||||||
message.success('登录成功');
|
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) {
|
} catch (err) {
|
||||||
message.error('登录失败,请重试');
|
message.error('登录失败,请重试');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -52,34 +71,174 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 () => (
|
return () => (
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<Card class={styles.card} title="CPMS 运营平台">
|
<div class={styles.brand}>
|
||||||
<Form model={form} rules={rules} layout="vertical" onFinish={handleSubmit}>
|
<div class={styles.orb1} />
|
||||||
<FormItem name="username">
|
<div class={styles.orb2} />
|
||||||
<Input
|
<div class={styles.orb3} />
|
||||||
v-model={[form.username, 'value']}
|
<div class={styles.pulse} />
|
||||||
placeholder="用户名"
|
<div class={styles.brandGrid} />
|
||||||
size="large"
|
<div class={styles.scanLine} />
|
||||||
v-slots={{ prefix: () => <UserOutlined /> }}
|
<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">
|
</div>
|
||||||
<Input
|
|
||||||
v-model={[form.password, 'value']}
|
<div class={styles.brandInner}>
|
||||||
type="password"
|
<div class={styles.logoRow}>
|
||||||
placeholder="密码"
|
<div class={styles.logoMark}>6</div>
|
||||||
size="large"
|
<span class={styles.logoText}>羽友赛事</span>
|
||||||
v-slots={{ prefix: () => <LockOutlined /> }}
|
</div>
|
||||||
/>
|
<h1 class={styles.brandTitle}>六个羽友赛事运营平台</h1>
|
||||||
</FormItem>
|
<p class={styles.brandSubtitle}>
|
||||||
<FormItem>
|
专业的赛事管理、报名运营与数据分析一站式解决方案,助力每一场精彩赛事
|
||||||
<Button type="primary" htmlType="submit" size="large" block loading={loading.value}>
|
</p>
|
||||||
登录
|
<div class={styles.statsRow}>
|
||||||
</Button>
|
<div class={styles.statItem}>
|
||||||
</FormItem>
|
<span class={styles.statValue}>
|
||||||
</Form>
|
<span class={styles.statHighlight}>1,200</span>+
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const goHome = () => router.push('/');
|
const goHome = () => router.push('/login');
|
||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
<Result
|
<Result
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,53 @@
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { auth, handleRouteGuard } from '@/hooks/useAuth';
|
import { auth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局路由守卫
|
* 全局路由守卫
|
||||||
* 核心流程:
|
* 核心流程:
|
||||||
* 1. 白名单(/login 等) → 直接放行
|
* 1. /login → 已登录则跳首页,未登录则放行
|
||||||
* 2. 已登录 → 加载菜单权限(首次) → 放行
|
* 2. 未登录 → 跳转 /login
|
||||||
* 3. 未登录 → 跳转 /login
|
* 3. 已登录但菜单未加载 → 加载菜单+权限 → 重新导航(确保动态路由生效)
|
||||||
* 4. 页面标题自动设置
|
* 4. 已登录且菜单已加载 → 放行
|
||||||
|
*
|
||||||
|
* 关键:当 loadMenu 动态注册路由后,必须用 next({ path, replace: true })
|
||||||
|
* 重新触发导航,否则当前导航仍使用旧路由表,会匹配到 404。
|
||||||
|
* 注意:不能用 next({ ...to, replace: true }),因为展开 to 会把旧的
|
||||||
|
* matched 信息带过去,导致仍然匹配到 /:pathMatch(.*)*。
|
||||||
*/
|
*/
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
// 设置页面标题
|
// 设置页面标题
|
||||||
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
|
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
|
||||||
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
|
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
|
||||||
|
|
||||||
const { allow, redirect } = await handleRouteGuard(to.path);
|
// ── /login 特殊处理 ──
|
||||||
|
if (to.path === '/login') {
|
||||||
if (allow) {
|
if (auth.isLoggedIn()) {
|
||||||
// 已登录访问 /login → 跳首页
|
|
||||||
if (to.path === '/login' && auth.isLoggedIn()) {
|
|
||||||
const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
|
const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
|
||||||
next(homePath.value);
|
next(homePath.value);
|
||||||
return;
|
} else {
|
||||||
|
next();
|
||||||
}
|
}
|
||||||
next();
|
return;
|
||||||
} else {
|
|
||||||
next(redirect || '/login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 未登录 → 跳转登录页 ──
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,22 @@ const router = createRouter({
|
||||||
const WHITE_LIST = ['/login', '/404'];
|
const WHITE_LIST = ['/login', '/404'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置路由:清除动态添加的子路由,恢复到只包含静态路由的状态
|
* 静态路由名称集合(这些路由不会被 resetRouter 移除)
|
||||||
* 用于退出登录时清理
|
*/
|
||||||
|
const STATIC_ROUTE_NAMES = new Set(['Login', 'NotFound']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置路由:清除动态添加的路由(包括 BasicLayout 和其子路由),
|
||||||
|
* 恢复到只包含静态路由的状态。用于退出登录时清理。
|
||||||
*/
|
*/
|
||||||
export function resetRouter() {
|
export function resetRouter() {
|
||||||
const currentRoutes = router.getRoutes();
|
const currentRoutes = router.getRoutes();
|
||||||
const protectedNames = new Set(['Login', 'BasicLayout', 'NotFound']);
|
|
||||||
|
|
||||||
currentRoutes.forEach((route) => {
|
currentRoutes.forEach((route) => {
|
||||||
// 保留静态路由(Login, BasicLayout, NotFound),移除动态添加的子路由
|
const routeName = route.name as string;
|
||||||
if (!protectedNames.has(route.name as string)) {
|
// 保留静态路由(Login, NotFound),移除所有动态路由(含 BasicLayout 及其子路由)
|
||||||
router.removeRoute(route.name!);
|
if (routeName && !STATIC_ROUTE_NAMES.has(routeName)) {
|
||||||
|
router.removeRoute(routeName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import type { RouteRecordRaw } from 'vue-router';
|
||||||
* 动态路由由 menuStore 在登录后通过 router.addRoute 注入,
|
* 动态路由由 menuStore 在登录后通过 router.addRoute 注入,
|
||||||
* 后端下发 MenuNode 包含 component 字段,
|
* 后端下发 MenuNode 包含 component 字段,
|
||||||
* 前端通过 import.meta.glob 动态匹配 src/pages 下的组件。
|
* 前端通过 import.meta.glob 动态匹配 src/pages 下的组件。
|
||||||
|
*
|
||||||
|
* 注意: BasicLayout 路由不在此注册,而是由 menuStore 动态添加,
|
||||||
|
* 这样可以避免空 children 导致的路由匹配问题。
|
||||||
*/
|
*/
|
||||||
export const constantRoutes: RouteRecordRaw[] = [
|
export const constantRoutes: RouteRecordRaw[] = [
|
||||||
// ── 登录页(不经过布局)──
|
// ── 登录页(不经过布局)──
|
||||||
|
|
@ -16,17 +19,8 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||||
meta: { title: '登录' },
|
meta: { title: '登录' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 主布局(动态路由的父容器) ──
|
|
||||||
// name 为 'BasicLayout',动态路由通过 router.addRoute('BasicLayout', child) 挂载到此布局下
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'BasicLayout',
|
|
||||||
component: () => import('@/layouts/BasicLayout'),
|
|
||||||
children: [],
|
|
||||||
meta: { title: '首页' },
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 404 ──
|
// ── 404 ──
|
||||||
|
// 注意: 通配路由必须放在最后,且不能被动态路由覆盖
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'NotFound',
|
name: 'NotFound',
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,44 @@ function getFirstMenuPath(nodes: MenuNode[]): string {
|
||||||
return '/dashboard';
|
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() {
|
export function useMenuStore() {
|
||||||
const loadMenu = async () => {
|
const loadMenu = async () => {
|
||||||
if (state.loaded) return;
|
if (state.loaded) return;
|
||||||
|
|
@ -151,6 +189,9 @@ export function useMenuStore() {
|
||||||
state.menuItems = transformMenuNode(menuTree);
|
state.menuItems = transformMenuNode(menuTree);
|
||||||
state.homePath = getFirstMenuPath(menuTree);
|
state.homePath = getFirstMenuPath(menuTree);
|
||||||
|
|
||||||
|
// 确保 BasicLayout 布局路由已注册
|
||||||
|
ensureLayoutRoute();
|
||||||
|
|
||||||
// 动态注册路由:将后端路由配置注入到 BasicLayout 布局下
|
// 动态注册路由:将后端路由配置注入到 BasicLayout 布局下
|
||||||
const dynamicRoutes = transformMenuToRoutes(menuTree);
|
const dynamicRoutes = transformMenuToRoutes(menuTree);
|
||||||
dynamicRoutes.forEach((route) => {
|
dynamicRoutes.forEach((route) => {
|
||||||
|
|
@ -170,20 +211,8 @@ export function useMenuStore() {
|
||||||
];
|
];
|
||||||
state.homePath = '/dashboard';
|
state.homePath = '/dashboard';
|
||||||
|
|
||||||
// 兜底路由(挂载到 BasicLayout 下)
|
// 兜底路由
|
||||||
router.addRoute('BasicLayout', {
|
registerFallbackRoutes();
|
||||||
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;
|
state.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue