feat: 重构登录页面和API结构,添加动态菜单和路由权限控制
- 重构登录页面为简洁风格,移除动画角色,优化表单验证和错误提示 - 重新组织API模块结构,拆分为system和user子模块 - 添加动态菜单获取和路由权限控制功能 - 实现404页面和错误处理 - 更新环境配置文件,区分开发、测试和生产环境 - 优化请求拦截器,处理token和错误响应 - 添加菜单状态管理,支持动态路由生成和权限验证 - 更新主布局,支持动态菜单渲染和响应式设计 - 优化首页样式和组件结构
This commit is contained in:
11
.env.development
Normal file
11
.env.development
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# 后端API基础地址(代理用)
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
|
||||||
|
# 系统名称
|
||||||
|
VITE_APP_TITLE=后台管理系统(开发版)
|
||||||
|
|
||||||
|
# 是否开启mock数据
|
||||||
|
VITE_USE_MOCK=true
|
||||||
11
.env.production
Normal file
11
.env.production
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# 后端API基础地址
|
||||||
|
VITE_API_BASE_URL=https://api.example.com
|
||||||
|
|
||||||
|
# 系统名称
|
||||||
|
VITE_APP_TITLE=后台管理系统
|
||||||
|
|
||||||
|
# 是否开启mock数据
|
||||||
|
VITE_USE_MOCK=false
|
||||||
11
.env.test
Normal file
11
.env.test
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 测试环境配置
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# 后端API基础地址
|
||||||
|
VITE_API_BASE_URL=http://test-api.example.com
|
||||||
|
|
||||||
|
# 系统名称
|
||||||
|
VITE_APP_TITLE=后台管理系统(测试版)
|
||||||
|
|
||||||
|
# 是否开启mock数据
|
||||||
|
VITE_USE_MOCK=false
|
||||||
4
src/api/index.ts
Normal file
4
src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// API 统一出口
|
||||||
|
export * from './system/loginApi'
|
||||||
|
export * from './system/menuApi'
|
||||||
|
export * from './user/userApi'
|
||||||
@@ -14,8 +14,11 @@ request.interceptors.request.use(
|
|||||||
(config) => {
|
(config) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
if (userStore.token) {
|
if (userStore.token) {
|
||||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
config.headers.token = userStore.token
|
||||||
}
|
}
|
||||||
|
// 删除 Cookie 和 Authorization
|
||||||
|
delete config.headers.Cookie
|
||||||
|
delete config.headers.Authorization
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -26,11 +29,12 @@ request.interceptors.request.use(
|
|||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
const { data } = response
|
const { data } = response
|
||||||
if (data.code !== 200) {
|
// code 为 0 或 200 表示成功
|
||||||
ElMessage.error(data.message || '请求失败')
|
if (data.code !== 0 && data.code !== 200) {
|
||||||
return Promise.reject(new Error(data.message))
|
ElMessage.error(data.msg || '请求失败')
|
||||||
|
return Promise.reject(new Error(data.msg || '请求失败'))
|
||||||
}
|
}
|
||||||
return data
|
return data.data
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
const { response } = error
|
const { response } = error
|
||||||
@@ -39,7 +43,7 @@ request.interceptors.response.use(
|
|||||||
userStore.clearUser()
|
userStore.clearUser()
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
ElMessage.error(response?.data?.message || '网络错误')
|
ElMessage.error(response?.data?.msg || '网络错误')
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
24
src/api/system/loginApi.ts
Normal file
24
src/api/system/loginApi.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import request from '../request'
|
||||||
|
|
||||||
|
// 登录接口
|
||||||
|
export function login(data: { username: string; password: string; captcha: string; uuid: string }) {
|
||||||
|
return request({
|
||||||
|
url: '/login',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取验证码
|
||||||
|
export function getCaptchaUrl(uuid: string) {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
|
return `${baseUrl}/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
export function logout() {
|
||||||
|
return request({
|
||||||
|
url: '/sys/logout',
|
||||||
|
method: 'post',
|
||||||
|
})
|
||||||
|
}
|
||||||
9
src/api/system/menuApi.ts
Normal file
9
src/api/system/menuApi.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import request from '../request'
|
||||||
|
|
||||||
|
// 获取动态菜单
|
||||||
|
export function getNavMenu() {
|
||||||
|
return request({
|
||||||
|
url: '/sys/menu/nav',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
45
src/api/user/userApi.ts
Normal file
45
src/api/user/userApi.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import request from '../request'
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
export function getUserInfo() {
|
||||||
|
return request({
|
||||||
|
url: '/sys/user/info',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表
|
||||||
|
export function getUserList(params: { page: number; limit: number; username?: string }) {
|
||||||
|
return request({
|
||||||
|
url: '/sys/user/list',
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增用户
|
||||||
|
export function addUser(data: Record<string, any>) {
|
||||||
|
return request({
|
||||||
|
url: '/sys/user/save',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改用户
|
||||||
|
export function updateUser(data: Record<string, any>) {
|
||||||
|
return request({
|
||||||
|
url: '/sys/user/update',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
export function deleteUser(ids: number[]) {
|
||||||
|
return request({
|
||||||
|
url: '/sys/user/delete',
|
||||||
|
method: 'post',
|
||||||
|
data: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import request from './request'
|
|
||||||
|
|
||||||
export interface LoginParams {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResult {
|
|
||||||
token: string
|
|
||||||
userInfo: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function login(data: LoginParams) {
|
|
||||||
return request.post<LoginResult>('/user/login', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUserInfo() {
|
|
||||||
return request.get('/user/info')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logout() {
|
|
||||||
return request.post('/user/logout')
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/userStore'
|
import { useUserStore } from '@/stores/userStore'
|
||||||
|
import { useMenuStore } from '@/stores/menuStore'
|
||||||
|
import { logout } from '@/api/system/loginApi'
|
||||||
import {
|
import {
|
||||||
HomeFilled,
|
HomeFilled,
|
||||||
UserFilled,
|
UserFilled,
|
||||||
@@ -10,21 +12,58 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
Grid,
|
Grid,
|
||||||
Document,
|
Document,
|
||||||
DataAnalysis
|
DataAnalysis,
|
||||||
|
Folder,
|
||||||
|
List,
|
||||||
|
User,
|
||||||
|
OfficeBuilding,
|
||||||
|
Avatar,
|
||||||
|
CircleCheck,
|
||||||
|
Unlock,
|
||||||
|
Files,
|
||||||
|
Medal,
|
||||||
|
Timer,
|
||||||
|
Upload,
|
||||||
|
DocumentChecked,
|
||||||
|
Notebook,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import Breadcrumb from './components/Breadcrumb.vue'
|
import Breadcrumb from './components/Breadcrumb.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const menuStore = useMenuStore()
|
||||||
|
|
||||||
const isCollapse = ref(false)
|
const isCollapse = ref(false)
|
||||||
|
|
||||||
const menuItems = [
|
// 图标映射(后端返回的 icon 字段格式为 icon-xxx,映射到 Element Plus 图标组件)
|
||||||
{ path: '/home', name: '首页', icon: HomeFilled },
|
const iconMap: Record<string, any> = {
|
||||||
{ path: '/user', name: '用户管理', icon: UserFilled },
|
'icon-home': HomeFilled,
|
||||||
{ path: '/system/settings', name: '系统设置', icon: Setting },
|
'icon-user': User,
|
||||||
]
|
'icon-apartment': OfficeBuilding,
|
||||||
|
'icon-team': Avatar,
|
||||||
|
'icon-safetycertificate': CircleCheck,
|
||||||
|
'icon-unlock': Unlock,
|
||||||
|
'icon-setting': Setting,
|
||||||
|
'icon-unorderedlist': List,
|
||||||
|
'icon-fileprotect': Files,
|
||||||
|
'icon-golden-fill': Medal,
|
||||||
|
'icon-dashboard': Timer,
|
||||||
|
'icon-upload': Upload,
|
||||||
|
'icon-filedone': DocumentChecked,
|
||||||
|
'icon-solution': Notebook,
|
||||||
|
'icon-file': Document,
|
||||||
|
'icon-folder': Folder,
|
||||||
|
'icon-data': DataAnalysis,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归生成菜单组件
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
return menuStore.sidebarMenus.map(menu => ({
|
||||||
|
...menu,
|
||||||
|
icon: iconMap[menu.icon || ''] || Document
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
function handleSelect(path: string) {
|
function handleSelect(path: string) {
|
||||||
router.push(path)
|
router.push(path)
|
||||||
@@ -33,6 +72,7 @@ function handleSelect(path: string) {
|
|||||||
function handleCommand(command: string) {
|
function handleCommand(command: string) {
|
||||||
if (command === 'logout') {
|
if (command === 'logout') {
|
||||||
userStore.clearUser()
|
userStore.clearUser()
|
||||||
|
menuStore.clearMenu()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,19 +103,34 @@ function toggleCollapse() {
|
|||||||
class="custom-menu"
|
class="custom-menu"
|
||||||
@select="handleSelect"
|
@select="handleSelect"
|
||||||
>
|
>
|
||||||
<el-menu-item
|
<template v-for="item in menuItems" :key="item.id">
|
||||||
v-for="item in menuItems"
|
<!-- 有子菜单 -->
|
||||||
:key="item.path"
|
<el-sub-menu v-if="item.children && item.children.length > 0" :index="item.id">
|
||||||
:index="item.path"
|
<template #title>
|
||||||
class="menu-item"
|
<el-icon class="menu-icon">
|
||||||
>
|
<component :is="item.icon" />
|
||||||
<el-icon class="menu-icon">
|
</el-icon>
|
||||||
<component :is="item.icon" />
|
<span class="menu-title">{{ item.name }}</span>
|
||||||
</el-icon>
|
</template>
|
||||||
<template #title>
|
<el-menu-item
|
||||||
<span class="menu-title">{{ item.name }}</span>
|
v-for="child in item.children"
|
||||||
</template>
|
:key="child.id"
|
||||||
</el-menu-item>
|
:index="child.url"
|
||||||
|
class="menu-item"
|
||||||
|
>
|
||||||
|
<span class="menu-title">{{ child.name }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
<!-- 无子菜单 -->
|
||||||
|
<el-menu-item v-else :index="item.url" class="menu-item">
|
||||||
|
<el-icon class="menu-icon">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>
|
||||||
|
<span class="menu-title">{{ item.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</template>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,7 +267,7 @@ function toggleCollapse() {
|
|||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.85) !important;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +282,48 @@ function toggleCollapse() {
|
|||||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 子菜单样式 */
|
||||||
|
:deep(.custom-menu .el-sub-menu__title) {
|
||||||
|
height: 50px;
|
||||||
|
line-height: 50px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.custom-menu .el-sub-menu__title:hover) {
|
||||||
|
background: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.custom-menu .el-sub-menu.is-active .el-sub-menu__title) {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子菜单展开后的背景 */
|
||||||
|
:deep(.custom-menu .el-sub-menu .el-menu) {
|
||||||
|
background: rgba(0, 0, 0, 0.2) !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子菜单项样式 */
|
||||||
|
:deep(.custom-menu .el-sub-menu .el-menu-item) {
|
||||||
|
color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.custom-menu .el-sub-menu .el-menu-item:hover) {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/userStore'
|
import { useUserStore } from '@/stores/userStore'
|
||||||
|
import { useMenuStore } from '@/stores/menuStore'
|
||||||
|
import { getNavMenu } from '@/api/system/menuApi'
|
||||||
|
import type { RawMenuItem } from '@/stores/menuStore'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -10,6 +13,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
name: 'Layout',
|
||||||
component: () => import('@/layouts/MainLayout.vue'),
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
redirect: '/home',
|
redirect: '/home',
|
||||||
children: [
|
children: [
|
||||||
@@ -19,20 +23,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/home/index.vue'),
|
component: () => import('@/views/home/index.vue'),
|
||||||
meta: { title: '首页', requiresAuth: true },
|
meta: { title: '首页', requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'user',
|
|
||||||
name: 'User',
|
|
||||||
component: () => import('@/views/user/index.vue'),
|
|
||||||
meta: { title: '用户管理', requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'system/settings',
|
|
||||||
name: 'Settings',
|
|
||||||
component: () => import('@/views/system/settings.vue'),
|
|
||||||
meta: { title: '系统设置', requiresAuth: true },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: () => import('@/views/error/404.vue'),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -40,7 +38,50 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
// 动态路由是否已添加
|
||||||
|
let isRoutesAdded = false
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const menuStore = useMenuStore()
|
||||||
|
|
||||||
|
// 需要登录但未登录
|
||||||
|
if (to.meta.requiresAuth && !userStore.token) {
|
||||||
|
next('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录但访问登录页,重定向到首页
|
||||||
|
if (userStore.token && to.path === '/login') {
|
||||||
|
next('/home')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录但未获取菜单
|
||||||
|
if (userStore.token && !isRoutesAdded && to.path !== '/login') {
|
||||||
|
try {
|
||||||
|
// 获取动态菜单(request拦截器已处理code,返回的是data.data)
|
||||||
|
const menuData = await getNavMenu() as RawMenuItem[]
|
||||||
|
menuStore.setRawMenuList(menuData || [])
|
||||||
|
|
||||||
|
// 添加动态路由
|
||||||
|
const dynamicRoutes = menuStore.dynamicRoutes
|
||||||
|
for (const route of dynamicRoutes) {
|
||||||
|
router.addRoute('Layout', route)
|
||||||
|
}
|
||||||
|
|
||||||
|
isRoutesAdded = true
|
||||||
|
next({ ...to, replace: true })
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取菜单失败', error)
|
||||||
|
userStore.clearUser()
|
||||||
|
menuStore.clearMenu()
|
||||||
|
next('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
143
src/stores/menuStore.ts
Normal file
143
src/stores/menuStore.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
// 后端返回的原始菜单项类型
|
||||||
|
export interface RawMenuItem {
|
||||||
|
id: string
|
||||||
|
pid: string
|
||||||
|
name: string
|
||||||
|
url: string | null
|
||||||
|
icon?: string
|
||||||
|
menuType: number // 0-目录/菜单 1-按钮
|
||||||
|
sort: number
|
||||||
|
permissions?: string | null
|
||||||
|
children?: RawMenuItem[]
|
||||||
|
createDate?: string
|
||||||
|
parentName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端使用的菜单项类型
|
||||||
|
export interface MenuItem {
|
||||||
|
id: string
|
||||||
|
parentId: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
type: number // 0-目录 1-菜单 2-按钮
|
||||||
|
sort: number
|
||||||
|
permissions?: string
|
||||||
|
children?: MenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端菜单响应数据
|
||||||
|
export interface MenuResponse {
|
||||||
|
code: number
|
||||||
|
data: RawMenuItem[]
|
||||||
|
permissions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMenuStore = defineStore('menu', () => {
|
||||||
|
// 原始菜单数据
|
||||||
|
const menuList = ref<MenuItem[]>([])
|
||||||
|
// 权限列表
|
||||||
|
const permissions = ref<string[]>([])
|
||||||
|
|
||||||
|
// 转换为路由配置
|
||||||
|
const dynamicRoutes = computed<RouteRecordRaw[]>(() => {
|
||||||
|
return generateRoutes(menuList.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 侧边栏菜单(过滤掉按钮类型,只保留有url的菜单项)
|
||||||
|
const sidebarMenus = computed<MenuItem[]>(() => {
|
||||||
|
return filterSidebarMenus(menuList.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 将后端原始数据转换为前端格式
|
||||||
|
function convertRawMenus(rawMenus: RawMenuItem[]): MenuItem[] {
|
||||||
|
return rawMenus.map(raw => ({
|
||||||
|
id: raw.id,
|
||||||
|
parentId: raw.pid,
|
||||||
|
name: raw.name,
|
||||||
|
url: raw.url || '',
|
||||||
|
icon: raw.icon,
|
||||||
|
type: raw.menuType === 0 ? (raw.url ? 1 : 0) : 2, // 0-目录(无url) 1-菜单(有url) 2-按钮
|
||||||
|
sort: raw.sort,
|
||||||
|
permissions: raw.permissions || undefined,
|
||||||
|
children: raw.children ? convertRawMenus(raw.children) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMenuList(list: MenuItem[]) {
|
||||||
|
menuList.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置原始菜单数据(自动转换)
|
||||||
|
function setRawMenuList(rawList: RawMenuItem[]) {
|
||||||
|
menuList.value = convertRawMenus(rawList)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPermissions(perms: string[]) {
|
||||||
|
permissions.value = perms
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMenu() {
|
||||||
|
menuList.value = []
|
||||||
|
permissions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归生成路由配置
|
||||||
|
function generateRoutes(menus: MenuItem[]): RouteRecordRaw[] {
|
||||||
|
const routes: RouteRecordRaw[] = []
|
||||||
|
|
||||||
|
for (const menu of menus) {
|
||||||
|
// type === 1 表示菜单类型(有url的)
|
||||||
|
if (menu.type === 1 && menu.url) {
|
||||||
|
const route: RouteRecordRaw = {
|
||||||
|
path: menu.url,
|
||||||
|
name: menu.name,
|
||||||
|
component: () => import(`@/views/${menu.url}/index.vue`).catch(() => import('@/views/error/404.vue')),
|
||||||
|
meta: {
|
||||||
|
title: menu.name,
|
||||||
|
icon: menu.icon,
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
routes.push(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
routes.push(...generateRoutes(menu.children))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤侧边栏菜单(只显示目录和有url的菜单,过滤按钮)
|
||||||
|
function filterSidebarMenus(menus: MenuItem[]): MenuItem[] {
|
||||||
|
return menus
|
||||||
|
.filter(menu => menu.type !== 2) // 过滤按钮
|
||||||
|
.map(menu => ({
|
||||||
|
...menu,
|
||||||
|
children: menu.children ? filterSidebarMenus(menu.children) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有权限
|
||||||
|
function hasPermission(permission: string): boolean {
|
||||||
|
return permissions.value.includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
menuList,
|
||||||
|
permissions,
|
||||||
|
dynamicRoutes,
|
||||||
|
sidebarMenus,
|
||||||
|
setMenuList,
|
||||||
|
setRawMenuList,
|
||||||
|
setPermissions,
|
||||||
|
clearMenu,
|
||||||
|
hasPermission,
|
||||||
|
}
|
||||||
|
})
|
||||||
76
src/views/error/404.vue
Normal file
76
src/views/error/404.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-content">
|
||||||
|
<div class="error-code">404</div>
|
||||||
|
<div class="error-title">页面不存在</div>
|
||||||
|
<div class="error-desc">抱歉,您访问的页面已经失效或不存在</div>
|
||||||
|
<el-button type="primary" size="large" @click="goHome">
|
||||||
|
<el-icon><HomeFilled /></el-icon>
|
||||||
|
<span>返回首页</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { HomeFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
router.push('/home')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-size: 150px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #409eff 0%, #1677ff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 30px rgba(64, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-desc {
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-size: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, markRaw } from 'vue'
|
||||||
import { useUserStore } from '@/stores/userStore'
|
import { useUserStore } from '@/stores/userStore'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
@@ -8,16 +8,15 @@ import {
|
|||||||
TrendCharts,
|
TrendCharts,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
MoreFilled
|
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const stats = ref([
|
const stats = ref([
|
||||||
{ title: '用户总数', value: '1,234', icon: User, color: '#667eea', trend: '+12%', up: true },
|
{ title: '用户总数', value: '1,234', icon: markRaw(User), color: '#667eea', trend: '+12%', up: true },
|
||||||
{ title: '订单数量', value: '5,678', icon: Document, color: '#10b981', trend: '+8%', up: true },
|
{ title: '订单数量', value: '5,678', icon: markRaw(Document), color: '#10b981', trend: '+8%', up: true },
|
||||||
{ title: '系统配置', value: '12', icon: Setting, color: '#f59e0b', trend: '0%', up: true },
|
{ title: '系统配置', value: '12', icon: markRaw(Setting), color: '#f59e0b', trend: '0%', up: true },
|
||||||
{ title: '今日访问', value: '2,456', icon: TrendCharts, color: '#ef4444', trend: '-3%', up: false },
|
{ title: '今日访问', value: '2,456', icon: markRaw(TrendCharts), color: '#ef4444', trend: '-3%', up: false },
|
||||||
])
|
])
|
||||||
|
|
||||||
const recentActivities = ref([
|
const recentActivities = ref([
|
||||||
@@ -29,11 +28,13 @@ const recentActivities = ref([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const quickLinks = ref([
|
const quickLinks = ref([
|
||||||
{ name: '用户管理', path: '/user', icon: User, color: '#667eea' },
|
{ name: '用户管理', path: '/user', icon: markRaw(User), color: '#667eea' },
|
||||||
{ name: '系统设置', path: '/system/settings', icon: Setting, color: '#10b981' },
|
{ name: '系统设置', path: '/system/settings', icon: markRaw(Setting), color: '#10b981' },
|
||||||
{ name: '数据报表', path: '#', icon: TrendCharts, color: '#f59e0b' },
|
{ name: '数据报表', path: '#', icon: markRaw(TrendCharts), color: '#f59e0b' },
|
||||||
{ name: '日志管理', path: '#', icon: Document, color: '#ef4444' },
|
{ name: '日志管理', path: '#', icon: markRaw(Document), color: '#ef4444' },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const chartType = ref('week')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -66,7 +67,7 @@ const quickLinks = ref([
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-footer">
|
<div class="stat-footer">
|
||||||
<span class="trend" :class="item.up ? 'up' : 'down'">
|
<span class="trend" :class="item.up ? 'up' : 'down'">
|
||||||
<el-icon><component :is="item.up ? ArrowUp : ArrowDown" /></el-icon>
|
<el-icon><ArrowUp v-if="item.up" /><ArrowDown v-else /></el-icon>
|
||||||
{{ item.trend }}
|
{{ item.trend }}
|
||||||
</span>
|
</span>
|
||||||
<span class="trend-label">较昨日</span>
|
<span class="trend-label">较昨日</span>
|
||||||
@@ -143,10 +144,6 @@ const quickLinks = ref([
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
const chartType = ref('week')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home-page {
|
.home-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,358 +1,112 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="admin-login">
|
||||||
class="fixed inset-0 overflow-hidden"
|
<!-- 简洁背景 -->
|
||||||
style="background: radial-gradient(circle at 0% 0%, #1f2937 0, #020617 55%, #000000 100%)"
|
<div class="login-bg">
|
||||||
@mousemove="onMouseMove"
|
<div class="bg-pattern"></div>
|
||||||
>
|
</div>
|
||||||
<!-- 背景光晕 -->
|
|
||||||
<div
|
|
||||||
class="fixed pointer-events-none -inset-20 blur-sm opacity-85"
|
|
||||||
style="background: radial-gradient(circle at 10% 20%, rgba(96, 165, 250, 0.16) 0, transparent 55%), radial-gradient(circle at 80% 10%, rgba(244, 114, 182, 0.18) 0, transparent 55%), radial-gradient(circle at 50% 90%, rgba(52, 211, 153, 0.12) 0, transparent 60%); transform: translate3d(0, 12px, 0) scale(1.02)"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- 主容器 -->
|
<!-- 登录卡片 -->
|
||||||
<div class="absolute inset-0 flex items-center justify-center p-0 m-0 overflow-hidden">
|
<div class="login-container">
|
||||||
<div
|
<div class="login-box">
|
||||||
class="w-full max-w-1120px flex items-center justify-between gap-10 px-8 py-10 box-border"
|
<!-- 头部 -->
|
||||||
>
|
<div class="login-header">
|
||||||
<!-- 左侧品牌区域 -->
|
<div class="logo">
|
||||||
<div class="flex-1 flex items-center justify-center text-white">
|
<el-icon size="40"><Management /></el-icon>
|
||||||
<div class="relative z-20 flex items-end justify-center h-500px">
|
|
||||||
<div class="relative" style="width: 550px; height: 400px">
|
|
||||||
<!-- 紫色角色 -->
|
|
||||||
<div
|
|
||||||
ref="purpleRef"
|
|
||||||
class="purple-character absolute bottom-0 transition-all duration-300 ease-out"
|
|
||||||
:style="{
|
|
||||||
left: '70px',
|
|
||||||
width: '180px',
|
|
||||||
height: (isTyping || (dataForm.password.length > 0 && !showPassword)) ? '440px' : '400px',
|
|
||||||
backgroundColor: 'rgb(108, 63, 245)',
|
|
||||||
borderRadius: '10px 10px 0px 0px',
|
|
||||||
zIndex: 1,
|
|
||||||
transform: (dataForm.password.length > 0 && showPassword)
|
|
||||||
? 'skewX(0deg)'
|
|
||||||
: (isTyping || (dataForm.password.length > 0 && !showPassword))
|
|
||||||
? `skewX(${(calculatePosition(purpleRef)?.bodySkew || 0) - 12}deg) translateX(40px)`
|
|
||||||
: `skewX(${calculatePosition(purpleRef)?.bodySkew || 0}deg)`,
|
|
||||||
transformOrigin: 'center bottom'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute flex gap-8 transition-all duration-700 ease-in-out"
|
|
||||||
:style="{
|
|
||||||
left: (dataForm.password.length > 0 && showPassword) ? '20px' : isLookingAtEachOther ? '55px' : `${45 + (calculatePosition(purpleRef)?.faceX || 0)}px`,
|
|
||||||
top: (dataForm.password.length > 0 && showPassword) ? '35px' : isLookingAtEachOther ? '65px' : `${40 + (calculatePosition(purpleRef)?.faceY || 0)}px`
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="rounded-full flex items-center justify-center transition-all duration-150 overflow-hidden"
|
|
||||||
:style="{
|
|
||||||
width: '22px',
|
|
||||||
height: isPurpleBlinking ? '2px' : '22px',
|
|
||||||
backgroundColor: 'white'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!isPurpleBlinking"
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '11px',
|
|
||||||
height: '11px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '4px' : '-4px') : isLookingAtEachOther ? '3px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '5px' : '-4px') : isLookingAtEachOther ? '4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="rounded-full flex items-center justify-center transition-all duration-150 overflow-hidden"
|
|
||||||
:style="{
|
|
||||||
width: '22px',
|
|
||||||
height: isPurpleBlinking ? '2px' : '22px',
|
|
||||||
backgroundColor: 'white'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!isPurpleBlinking"
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '11px',
|
|
||||||
height: '11px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '4px' : '-4px') : isLookingAtEachOther ? '3px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '5px' : '-4px') : isLookingAtEachOther ? '4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 黑色角色 -->
|
|
||||||
<div
|
|
||||||
ref="blackRef"
|
|
||||||
class="black-character absolute bottom-0 transition-all duration-300 ease-out"
|
|
||||||
:style="{
|
|
||||||
left: '240px',
|
|
||||||
width: '120px',
|
|
||||||
height: '310px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
borderRadius: '8px 8px 0px 0px',
|
|
||||||
zIndex: 2,
|
|
||||||
transform: (dataForm.password.length > 0 && showPassword)
|
|
||||||
? 'skewX(0deg)'
|
|
||||||
: isLookingAtEachOther
|
|
||||||
? `skewX(${(calculatePosition(blackRef)?.bodySkew || 0) * 1.5 + 10}deg) translateX(20px)`
|
|
||||||
: (isTyping || (dataForm.password.length > 0 && !showPassword))
|
|
||||||
? `skewX(${(calculatePosition(blackRef)?.bodySkew || 0) * 1.5}deg)`
|
|
||||||
: `skewX(${calculatePosition(blackRef)?.bodySkew || 0}deg)`,
|
|
||||||
transformOrigin: 'center bottom'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute flex gap-6 transition-all duration-700 ease-in-out"
|
|
||||||
:style="{
|
|
||||||
left: (dataForm.password.length > 0 && showPassword) ? '10px' : isLookingAtEachOther ? '32px' : `${26 + (calculatePosition(blackRef)?.faceX || 0)}px`,
|
|
||||||
top: (dataForm.password.length > 0 && showPassword) ? '28px' : isLookingAtEachOther ? '12px' : `${32 + (calculatePosition(blackRef)?.faceY || 0)}px`
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="rounded-full flex items-center justify-center transition-all duration-150 overflow-hidden"
|
|
||||||
:style="{
|
|
||||||
width: '24px',
|
|
||||||
height: isBlackBlinking ? '2px' : '24px',
|
|
||||||
backgroundColor: 'white'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!isBlackBlinking"
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '12px',
|
|
||||||
height: '12px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '0px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '-4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="rounded-full flex items-center justify-center transition-all duration-150 overflow-hidden"
|
|
||||||
:style="{
|
|
||||||
width: '24px',
|
|
||||||
height: isBlackBlinking ? '2px' : '24px',
|
|
||||||
backgroundColor: 'white'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!isBlackBlinking"
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '12px',
|
|
||||||
height: '12px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '0px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '-4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 橙色角色 -->
|
|
||||||
<div
|
|
||||||
ref="orangeRef"
|
|
||||||
class="orange-character absolute bottom-0 transition-all duration-300 ease-out"
|
|
||||||
:style="{
|
|
||||||
left: '0px',
|
|
||||||
width: '240px',
|
|
||||||
height: '200px',
|
|
||||||
zIndex: 3,
|
|
||||||
backgroundColor: 'rgb(255, 155, 107)',
|
|
||||||
borderRadius: '120px 120px 0px 0px',
|
|
||||||
transform: (dataForm.password.length > 0 && showPassword) ? 'skewX(0deg)' : `skewX(${calculatePosition(orangeRef)?.bodySkew || 0}deg)`,
|
|
||||||
transformOrigin: 'center bottom'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute flex gap-8 transition-all duration-200 ease-out"
|
|
||||||
:style="{
|
|
||||||
left: (dataForm.password.length > 0 && showPassword) ? '50px' : `${82 + (calculatePosition(orangeRef)?.faceX || 0)}px`,
|
|
||||||
top: (dataForm.password.length > 0 && showPassword) ? '85px' : `${90 + (calculatePosition(orangeRef)?.faceY || 0)}px`
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '14px',
|
|
||||||
height: '14px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '14px',
|
|
||||||
height: '14px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 黄色角色 -->
|
|
||||||
<div
|
|
||||||
ref="yellowRef"
|
|
||||||
class="yellow-character absolute bottom-0 transition-all duration-300 ease-out"
|
|
||||||
:style="{
|
|
||||||
left: '310px',
|
|
||||||
width: '140px',
|
|
||||||
height: '230px',
|
|
||||||
backgroundColor: 'rgb(232, 215, 84)',
|
|
||||||
borderRadius: '70px 70px 0px 0px',
|
|
||||||
zIndex: 4,
|
|
||||||
transform: (dataForm.password.length > 0 && showPassword) ? 'skewX(0deg)' : `skewX(${calculatePosition(yellowRef)?.bodySkew || 0}deg)`,
|
|
||||||
transformOrigin: 'center bottom'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute flex gap-6 transition-all duration-200 ease-out"
|
|
||||||
:style="{
|
|
||||||
left: (dataForm.password.length > 0 && showPassword) ? '20px' : `${52 + (calculatePosition(yellowRef)?.faceX || 0)}px`,
|
|
||||||
top: (dataForm.password.length > 0 && showPassword) ? '35px' : `${40 + (calculatePosition(yellowRef)?.faceY || 0)}px`
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '14px',
|
|
||||||
height: '14px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="rounded-full login-eyes-dot"
|
|
||||||
:style="{
|
|
||||||
width: '14px',
|
|
||||||
height: '14px',
|
|
||||||
backgroundColor: 'rgb(45, 45, 45)',
|
|
||||||
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
|
|
||||||
transition: 'transform 0.1s ease-out'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute w-20 h-1 bg-#2D2D2D rounded-full transition-all duration-200 ease-out login-mouth-line"
|
|
||||||
:style="{
|
|
||||||
left: (dataForm.password.length > 0 && showPassword) ? '10px' : `${40 + (calculatePosition(yellowRef)?.faceX || 0)}px`,
|
|
||||||
top: (dataForm.password.length > 0 && showPassword) ? '88px' : `${88 + (calculatePosition(yellowRef)?.faceY || 0)}px`
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="system-title">后台管理系统</h1>
|
||||||
|
<p class="system-subtitle">Admin Management System</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧登录表单 -->
|
<!-- 登录表单 -->
|
||||||
<div
|
<el-form
|
||||||
class="flex-shrink-0 w-105 px-8 pt-8 pb-7 bg-white rounded-24px text-#0f172a"
|
:model="dataForm"
|
||||||
style="box-shadow: 0 24px 60px rgba(15, 23, 42, 0.55); animation: login-card-in 0.9s cubic-bezier(0.19, 0.64, 0.34, 1.01) 0.1s forwards"
|
:rules="dataRule"
|
||||||
|
ref="dataFormRef"
|
||||||
|
@keyup.enter="dataFormSubmit()"
|
||||||
|
status-icon
|
||||||
|
class="login-form"
|
||||||
>
|
>
|
||||||
<h3
|
<el-form-item prop="userName">
|
||||||
class="text-24px font-bold mb-2 text-center"
|
<el-input
|
||||||
style="background: linear-gradient(135deg, #4f46e5, #6366f1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 2px 4px rgba(79, 70, 229, 0.2)"
|
v-model="dataForm.userName"
|
||||||
>
|
placeholder="请输入账号"
|
||||||
管理系统
|
size="large"
|
||||||
</h3>
|
class="login-input"
|
||||||
<h3
|
|
||||||
class="text-20px font-bold mb-6 text-center"
|
|
||||||
style="background: linear-gradient(135deg, #f59e0b, #ef4444); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 2px 4px rgba(245, 158, 11, 0.2)"
|
|
||||||
>
|
|
||||||
用户登录
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<el-form
|
|
||||||
:model="dataForm"
|
|
||||||
:rules="dataRule"
|
|
||||||
ref="dataFormRef"
|
|
||||||
@keyup.enter="dataFormSubmit()"
|
|
||||||
status-icon
|
|
||||||
>
|
|
||||||
<el-form-item prop="userName">
|
|
||||||
<el-input
|
|
||||||
v-model="dataForm.userName"
|
|
||||||
placeholder="帐号"
|
|
||||||
@focus="handleInputFocus"
|
|
||||||
@blur="handleInputBlur"
|
|
||||||
></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item prop="password">
|
|
||||||
<el-input
|
|
||||||
v-model="dataForm.password"
|
|
||||||
:type="showPassword ? 'text' : 'password'"
|
|
||||||
placeholder="密码"
|
|
||||||
@focus="handleInputFocus"
|
|
||||||
@blur="handleInputBlur"
|
|
||||||
>
|
|
||||||
<template #append>
|
|
||||||
<el-button @click="togglePasswordVisibility" class="password-toggle-btn">
|
|
||||||
<el-icon v-if="showPassword"><View /></el-icon>
|
|
||||||
<el-icon v-else><Hide /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item prop="captcha">
|
|
||||||
<el-row :gutter="20">
|
|
||||||
<el-col :span="14">
|
|
||||||
<el-input
|
|
||||||
v-model="dataForm.captcha"
|
|
||||||
placeholder="验证码"
|
|
||||||
@focus="handleInputFocus"
|
|
||||||
@blur="handleInputBlur"
|
|
||||||
></el-input>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="10" class="overflow-hidden">
|
|
||||||
<img
|
|
||||||
:src="captchaPath"
|
|
||||||
@click="getCaptcha()"
|
|
||||||
alt=""
|
|
||||||
class="w-full cursor-pointer rounded-xl"
|
|
||||||
style="box-shadow: 0 0 0 1px #e5e7eb"
|
|
||||||
/>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="error"
|
|
||||||
class="px-3 py-3 my-3 rounded-lg text-sm text-#ef4444"
|
|
||||||
style="background-color: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); animation: error-fade-in 0.3s ease-out"
|
|
||||||
>
|
>
|
||||||
{{ error }}
|
<template #prefix>
|
||||||
</div>
|
<el-icon><User /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item prop="password">
|
||||||
<el-button
|
<el-input
|
||||||
type="primary"
|
v-model="dataForm.password"
|
||||||
class="w-full mt-3 h-46px text-15px font-semibold rounded-full border-0"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
style="background: linear-gradient(135deg, #4f46e5, #6366f1)"
|
placeholder="请输入密码"
|
||||||
@click="dataFormSubmit()"
|
size="large"
|
||||||
:loading="isLoading"
|
class="login-input"
|
||||||
:disabled="isLoading"
|
>
|
||||||
>
|
<template #prefix>
|
||||||
{{ isLoading ? '登录中...' : '登录' }}
|
<el-icon><Lock /></el-icon>
|
||||||
</el-button>
|
</template>
|
||||||
</el-form-item>
|
<template #suffix>
|
||||||
</el-form>
|
<el-icon
|
||||||
|
class="password-eye"
|
||||||
|
@click="togglePasswordVisibility"
|
||||||
|
>
|
||||||
|
<View v-if="showPassword" />
|
||||||
|
<Hide v-else />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="captcha">
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :span="15">
|
||||||
|
<el-input
|
||||||
|
v-model="dataForm.captcha"
|
||||||
|
placeholder="验证码"
|
||||||
|
size="large"
|
||||||
|
class="login-input"
|
||||||
|
maxlength="5"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="9">
|
||||||
|
<div class="captcha-box" @click="refreshCaptcha">
|
||||||
|
<img :src="captchaPath" alt="验证码" />
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
<el-icon><Warning /></el-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="login-btn"
|
||||||
|
@click="dataFormSubmit()"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
{{ isLoading ? '登录中...' : '登 录' }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 底部 -->
|
||||||
|
<div class="login-footer">
|
||||||
|
<p>© 2024 后台管理系统 版权所有</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,175 +114,52 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/userStore'
|
import { useUserStore } from '@/stores/userStore'
|
||||||
import { View, Hide } from '@element-plus/icons-vue'
|
import { login, getCaptchaUrl } from '@/api/system/loginApi'
|
||||||
|
import { User, Lock, Key, View, Hide, Warning, Management } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const dataFormRef = ref()
|
const dataFormRef = ref()
|
||||||
|
|
||||||
const mouseX = ref(0)
|
|
||||||
const mouseY = ref(0)
|
|
||||||
const dataForm = ref({
|
const dataForm = ref({
|
||||||
userName: 'admin',
|
userName: '',
|
||||||
password: '123456',
|
password: '',
|
||||||
uuid: '',
|
uuid: '',
|
||||||
captcha: ''
|
captcha: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const dataRule = {
|
const dataRule = {
|
||||||
userName: [{ required: true, message: '帐号不能为空', trigger: 'blur' }],
|
userName: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||||
password: [{ required: true, message: '密码不能为空', trigger: 'blur' }],
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
captcha: [{ required: true, message: '验证码不能为空', trigger: 'blur' }]
|
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const captchaPath = ref('')
|
const captchaPath = ref('')
|
||||||
const isPurpleBlinking = ref(false)
|
|
||||||
const isBlackBlinking = ref(false)
|
|
||||||
const isTyping = ref(false)
|
|
||||||
const isLookingAtEachOther = ref(false)
|
|
||||||
const isPurplePeeking = ref(false)
|
|
||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const animationFrameId = ref<number | null>(null)
|
|
||||||
|
|
||||||
const purpleRef = ref<HTMLElement | null>(null)
|
|
||||||
const blackRef = ref<HTMLElement | null>(null)
|
|
||||||
const yellowRef = ref<HTMLElement | null>(null)
|
|
||||||
const orangeRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
let purpleBlinkTimeout: NodeJS.Timeout | null = null
|
|
||||||
let blackBlinkTimeout: NodeJS.Timeout | null = null
|
|
||||||
let typingTimeout: NodeJS.Timeout | null = null
|
|
||||||
let peekTimeout: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
|
||||||
mouseX.value = e.clientX
|
|
||||||
mouseY.value = e.clientY
|
|
||||||
if (!animationFrameId.value) {
|
|
||||||
animationFrameId.value = requestAnimationFrame(() => {
|
|
||||||
updateEyeOffsets()
|
|
||||||
updateCharacterPositions()
|
|
||||||
animationFrameId.value = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initBlinkingAnimations() {
|
|
||||||
schedulePurpleBlink()
|
|
||||||
scheduleBlackBlink()
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePurpleBlink() {
|
|
||||||
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000
|
|
||||||
purpleBlinkTimeout = setTimeout(() => {
|
|
||||||
isPurpleBlinking.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
isPurpleBlinking.value = false
|
|
||||||
schedulePurpleBlink()
|
|
||||||
}, 150)
|
|
||||||
}, getRandomBlinkInterval())
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleBlackBlink() {
|
|
||||||
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000
|
|
||||||
blackBlinkTimeout = setTimeout(() => {
|
|
||||||
isBlackBlinking.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
isBlackBlinking.value = false
|
|
||||||
scheduleBlackBlink()
|
|
||||||
}, 150)
|
|
||||||
}, getRandomBlinkInterval())
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculatePosition(ref: HTMLElement | null) {
|
|
||||||
if (!ref) return { faceX: 0, faceY: 0, bodySkew: 0 }
|
|
||||||
const rect = ref.getBoundingClientRect()
|
|
||||||
const centerX = rect.left + rect.width / 2
|
|
||||||
const centerY = rect.top + rect.height / 3
|
|
||||||
const deltaX = mouseX.value - centerX
|
|
||||||
const deltaY = mouseY.value - centerY
|
|
||||||
const faceX = Math.max(-15, Math.min(15, deltaX / 20))
|
|
||||||
const faceY = Math.max(-10, Math.min(10, deltaY / 30))
|
|
||||||
const bodySkew = Math.max(-6, Math.min(6, -deltaX / 120))
|
|
||||||
return { faceX, faceY, bodySkew }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCharacterPositions() {
|
|
||||||
// 角色位置更新逻辑
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputFocus() {
|
|
||||||
isTyping.value = true
|
|
||||||
isLookingAtEachOther.value = true
|
|
||||||
typingTimeout = setTimeout(() => {
|
|
||||||
isLookingAtEachOther.value = false
|
|
||||||
}, 800)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputBlur() {
|
|
||||||
isTyping.value = false
|
|
||||||
isLookingAtEachOther.value = false
|
|
||||||
if (typingTimeout) {
|
|
||||||
clearTimeout(typingTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePasswordVisibility() {
|
function togglePasswordVisibility() {
|
||||||
showPassword.value = !showPassword.value
|
showPassword.value = !showPassword.value
|
||||||
if (showPassword.value && dataForm.value.password.length > 0) {
|
|
||||||
schedulePurplePeek()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePurplePeek() {
|
async function dataFormSubmit() {
|
||||||
if (!showPassword.value || dataForm.value.password.length === 0) {
|
dataFormRef.value.validate(async (valid: boolean) => {
|
||||||
isPurplePeeking.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
peekTimeout = setTimeout(() => {
|
|
||||||
isPurplePeeking.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
isPurplePeeking.value = false
|
|
||||||
}, 800)
|
|
||||||
}, Math.random() * 3000 + 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEyeOffsets() {
|
|
||||||
const dots = document.querySelectorAll('.login-eyes-dot')
|
|
||||||
const mouths = document.querySelectorAll('.login-mouth-line')
|
|
||||||
if (!dots.length) return
|
|
||||||
const vw = window.innerWidth || 1
|
|
||||||
const vh = window.innerHeight || 1
|
|
||||||
const relX = (mouseX.value - vw / 2) / (vw / 2)
|
|
||||||
const relY = (mouseY.value - vh / 2) / (vh / 2)
|
|
||||||
const eyeMoveX = relX * 14
|
|
||||||
const eyeMoveY = relY * 7
|
|
||||||
dots.forEach((dot) => {
|
|
||||||
if (dot) {
|
|
||||||
;(dot as HTMLElement).style.transform = `translate(${eyeMoveX}px, ${eyeMoveY}px)`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (mouths.length) {
|
|
||||||
const mouthX = relX * 10
|
|
||||||
const mouthY = relY * 3
|
|
||||||
mouths.forEach((m) => {
|
|
||||||
if (m) {
|
|
||||||
;(m as HTMLElement).style.transform = `translate(${mouthX}px, ${mouthY}px)`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataFormSubmit() {
|
|
||||||
dataFormRef.value.validate((valid: boolean) => {
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
setTimeout(() => {
|
try {
|
||||||
userStore.setToken('mock_token_' + Date.now())
|
const res = await login({
|
||||||
|
username: dataForm.value.userName,
|
||||||
|
password: dataForm.value.password,
|
||||||
|
captcha: dataForm.value.captcha,
|
||||||
|
uuid: dataForm.value.uuid
|
||||||
|
})
|
||||||
|
userStore.setToken(res.token)
|
||||||
userStore.setUserInfo({
|
userStore.setUserInfo({
|
||||||
username: dataForm.value.userName,
|
username: dataForm.value.userName,
|
||||||
nickname: '管理员',
|
nickname: '管理员',
|
||||||
@@ -536,121 +167,229 @@ function dataFormSubmit() {
|
|||||||
})
|
})
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
router.replace({ path: '/home' })
|
router.replace({ path: '/home' })
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || '登录失败'
|
||||||
|
refreshCaptcha()
|
||||||
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}, 800)
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCaptcha() {
|
function refreshCaptcha() {
|
||||||
dataForm.value.uuid = Date.now().toString()
|
dataForm.value.uuid = Date.now().toString()
|
||||||
const code = Math.random().toString(36).substring(2, 6).toUpperCase()
|
captchaPath.value = getCaptchaUrl(dataForm.value.uuid)
|
||||||
captchaPath.value = `data:image/svg+xml;base64,${btoa(`<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><rect fill="#f0f0f0" width="100" height="40"/><text x="50" y="28" font-size="20" text-anchor="middle" fill="#666">${code}</text></svg>`)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getCaptcha()
|
refreshCaptcha()
|
||||||
initBlinkingAnimations()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (purpleBlinkTimeout) clearTimeout(purpleBlinkTimeout)
|
|
||||||
if (blackBlinkTimeout) clearTimeout(blackBlinkTimeout)
|
|
||||||
if (typingTimeout) clearTimeout(typingTimeout)
|
|
||||||
if (peekTimeout) clearTimeout(peekTimeout)
|
|
||||||
if (animationFrameId.value) cancelAnimationFrame(animationFrameId.value)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 动画关键帧 */
|
.admin-login {
|
||||||
@keyframes login-card-in {
|
position: fixed;
|
||||||
0% {
|
inset: 0;
|
||||||
opacity: 0;
|
display: flex;
|
||||||
transform: translate3d(0, 20px, 0) scale(0.98);
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
100% {
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
opacity: 1;
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes error-fade-in {
|
/* 背景图案 */
|
||||||
from {
|
.login-bg {
|
||||||
opacity: 0;
|
position: absolute;
|
||||||
transform: translateY(-10px);
|
inset: 0;
|
||||||
}
|
overflow: hidden;
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Element Plus 样式覆盖 */
|
.bg-pattern {
|
||||||
:deep(.el-input) {
|
position: absolute;
|
||||||
transition: all 0.3s ease;
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(64, 158, 255, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(64, 158, 255, 0.05) 0%, transparent 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-input:focus-within) {
|
/* 登录容器 */
|
||||||
transform: translateY(-2px);
|
.login-container {
|
||||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-button) {
|
/* 登录卡片 */
|
||||||
transition: all 0.3s ease;
|
.login-box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-button:hover:not(:disabled)) {
|
/* 头部 */
|
||||||
transform: translateY(-2px);
|
.login-header {
|
||||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-button:active:not(:disabled)) {
|
.logo {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
background: linear-gradient(135deg, #409eff 0%, #1677ff 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式 */
|
||||||
|
.login-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form :deep(.el-form-item:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input :deep(.el-input__wrapper) {
|
||||||
|
background: #f5f7fa;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input :deep(.el-input__wrapper:hover) {
|
||||||
|
background: #eef1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input :deep(.el-input__wrapper.is-focus) {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #409eff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input :deep(.el-input__inner) {
|
||||||
|
height: 44px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input :deep(.el-input__inner::placeholder) {
|
||||||
|
color: #a8abb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input :deep(.el-input__prefix) {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-eye {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #909399;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-eye:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 验证码 */
|
||||||
|
.captcha-box {
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-box:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-box img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误信息 */
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: #fef0f0;
|
||||||
|
border: 1px solid #fde2e2;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录按钮 */
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(135deg, #409eff 0%, #1677ff 100%);
|
||||||
|
border: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自定义尺寸类 */
|
/* 底部 */
|
||||||
.max-w-1120px {
|
.login-footer {
|
||||||
max-width: 1120px;
|
margin-top: 25px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-500px {
|
.login-footer p {
|
||||||
height: 500px;
|
font-size: 12px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-105 {
|
/* 响应式 */
|
||||||
width: 420px;
|
@media (max-width: 480px) {
|
||||||
}
|
.login-box {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.h-46px {
|
.system-title {
|
||||||
height: 46px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-24px {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-20px {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-15px {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rounded-24px {
|
|
||||||
border-radius: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-#2D2D2D {
|
|
||||||
background-color: #2d2d2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-#0f172a {
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-#ef4444 {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,5 +13,12 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
open: true,
|
open: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080/',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user