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) => {
|
||||
const userStore = useUserStore()
|
||||
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
|
||||
},
|
||||
(error) => {
|
||||
@@ -26,11 +29,12 @@ request.interceptors.request.use(
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const { data } = response
|
||||
if (data.code !== 200) {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message))
|
||||
// code 为 0 或 200 表示成功
|
||||
if (data.code !== 0 && data.code !== 200) {
|
||||
ElMessage.error(data.msg || '请求失败')
|
||||
return Promise.reject(new Error(data.msg || '请求失败'))
|
||||
}
|
||||
return data
|
||||
return data.data
|
||||
},
|
||||
(error) => {
|
||||
const { response } = error
|
||||
@@ -39,7 +43,7 @@ request.interceptors.response.use(
|
||||
userStore.clearUser()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
ElMessage.error(response?.data?.message || '网络错误')
|
||||
ElMessage.error(response?.data?.msg || '网络错误')
|
||||
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">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { logout } from '@/api/system/loginApi'
|
||||
import {
|
||||
HomeFilled,
|
||||
UserFilled,
|
||||
@@ -10,21 +12,58 @@ import {
|
||||
ArrowDown,
|
||||
Grid,
|
||||
Document,
|
||||
DataAnalysis
|
||||
DataAnalysis,
|
||||
Folder,
|
||||
List,
|
||||
User,
|
||||
OfficeBuilding,
|
||||
Avatar,
|
||||
CircleCheck,
|
||||
Unlock,
|
||||
Files,
|
||||
Medal,
|
||||
Timer,
|
||||
Upload,
|
||||
DocumentChecked,
|
||||
Notebook,
|
||||
} from '@element-plus/icons-vue'
|
||||
import Breadcrumb from './components/Breadcrumb.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const isCollapse = ref(false)
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/home', name: '首页', icon: HomeFilled },
|
||||
{ path: '/user', name: '用户管理', icon: UserFilled },
|
||||
{ path: '/system/settings', name: '系统设置', icon: Setting },
|
||||
]
|
||||
// 图标映射(后端返回的 icon 字段格式为 icon-xxx,映射到 Element Plus 图标组件)
|
||||
const iconMap: Record<string, any> = {
|
||||
'icon-home': HomeFilled,
|
||||
'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) {
|
||||
router.push(path)
|
||||
@@ -33,6 +72,7 @@ function handleSelect(path: string) {
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'logout') {
|
||||
userStore.clearUser()
|
||||
menuStore.clearMenu()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
@@ -63,12 +103,26 @@ function toggleCollapse() {
|
||||
class="custom-menu"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<template v-for="item in menuItems" :key="item.id">
|
||||
<!-- 有子菜单 -->
|
||||
<el-sub-menu v-if="item.children && item.children.length > 0" :index="item.id">
|
||||
<template #title>
|
||||
<el-icon class="menu-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="menu-title">{{ item.name }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:index="item.path"
|
||||
v-for="child in item.children"
|
||||
:key="child.id"
|
||||
: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>
|
||||
@@ -76,6 +130,7 @@ function toggleCollapse() {
|
||||
<span class="menu-title">{{ item.name }}</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +267,7 @@ function toggleCollapse() {
|
||||
line-height: 50px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -227,6 +282,48 @@ function toggleCollapse() {
|
||||
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 {
|
||||
font-size: 20px;
|
||||
margin-right: 12px;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
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[] = [
|
||||
{
|
||||
@@ -10,6 +13,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Layout',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/home',
|
||||
children: [
|
||||
@@ -19,20 +23,14 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
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({
|
||||
@@ -40,7 +38,50 @@ const router = createRouter({
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
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">
|
||||
import { ref } from 'vue'
|
||||
import { ref, markRaw } from 'vue'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import {
|
||||
User,
|
||||
@@ -8,16 +8,15 @@ import {
|
||||
TrendCharts,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
MoreFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const stats = ref([
|
||||
{ title: '用户总数', value: '1,234', icon: User, color: '#667eea', trend: '+12%', up: true },
|
||||
{ title: '订单数量', value: '5,678', icon: Document, color: '#10b981', trend: '+8%', up: true },
|
||||
{ title: '系统配置', value: '12', icon: Setting, color: '#f59e0b', trend: '0%', up: true },
|
||||
{ title: '今日访问', value: '2,456', icon: TrendCharts, color: '#ef4444', trend: '-3%', up: false },
|
||||
{ title: '用户总数', value: '1,234', icon: markRaw(User), color: '#667eea', trend: '+12%', up: true },
|
||||
{ title: '订单数量', value: '5,678', icon: markRaw(Document), color: '#10b981', trend: '+8%', up: true },
|
||||
{ title: '系统配置', value: '12', icon: markRaw(Setting), color: '#f59e0b', trend: '0%', up: true },
|
||||
{ title: '今日访问', value: '2,456', icon: markRaw(TrendCharts), color: '#ef4444', trend: '-3%', up: false },
|
||||
])
|
||||
|
||||
const recentActivities = ref([
|
||||
@@ -29,11 +28,13 @@ const recentActivities = ref([
|
||||
])
|
||||
|
||||
const quickLinks = ref([
|
||||
{ name: '用户管理', path: '/user', icon: User, color: '#667eea' },
|
||||
{ name: '系统设置', path: '/system/settings', icon: Setting, color: '#10b981' },
|
||||
{ name: '数据报表', path: '#', icon: TrendCharts, color: '#f59e0b' },
|
||||
{ name: '日志管理', path: '#', icon: Document, color: '#ef4444' },
|
||||
{ name: '用户管理', path: '/user', icon: markRaw(User), color: '#667eea' },
|
||||
{ name: '系统设置', path: '/system/settings', icon: markRaw(Setting), color: '#10b981' },
|
||||
{ name: '数据报表', path: '#', icon: markRaw(TrendCharts), color: '#f59e0b' },
|
||||
{ name: '日志管理', path: '#', icon: markRaw(Document), color: '#ef4444' },
|
||||
])
|
||||
|
||||
const chartType = ref('week')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,7 +67,7 @@ const quickLinks = ref([
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<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 }}
|
||||
</span>
|
||||
<span class="trend-label">较昨日</span>
|
||||
@@ -143,10 +144,6 @@ const quickLinks = ref([
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const chartType = ref('week')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
display: flex;
|
||||
|
||||
@@ -1,358 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 overflow-hidden"
|
||||
style="background: radial-gradient(circle at 0% 0%, #1f2937 0, #020617 55%, #000000 100%)"
|
||||
@mousemove="onMouseMove"
|
||||
>
|
||||
<!-- 背景光晕 -->
|
||||
<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="w-full max-w-1120px flex items-center justify-between gap-10 px-8 py-10 box-border"
|
||||
>
|
||||
<!-- 左侧品牌区域 -->
|
||||
<div class="flex-1 flex items-center justify-center text-white">
|
||||
<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 class="admin-login">
|
||||
<!-- 简洁背景 -->
|
||||
<div class="login-bg">
|
||||
<div class="bg-pattern"></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 class="login-container">
|
||||
<div class="login-box">
|
||||
<!-- 头部 -->
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<el-icon size="40"><Management /></el-icon>
|
||||
</div>
|
||||
<h1 class="system-title">后台管理系统</h1>
|
||||
<p class="system-subtitle">Admin Management System</p>
|
||||
</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>
|
||||
|
||||
<!-- 右侧登录表单 -->
|
||||
<div
|
||||
class="flex-shrink-0 w-105 px-8 pt-8 pb-7 bg-white rounded-24px text-#0f172a"
|
||||
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"
|
||||
>
|
||||
<h3
|
||||
class="text-24px font-bold mb-2 text-center"
|
||||
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)"
|
||||
>
|
||||
管理系统
|
||||
</h3>
|
||||
<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
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item prop="userName">
|
||||
<el-input
|
||||
v-model="dataForm.userName"
|
||||
placeholder="帐号"
|
||||
@focus="handleInputFocus"
|
||||
@blur="handleInputBlur"
|
||||
></el-input>
|
||||
placeholder="请输入账号"
|
||||
size="large"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</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"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
class="login-input"
|
||||
>
|
||||
<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 #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<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="20">
|
||||
<el-col :span="14">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="15">
|
||||
<el-input
|
||||
v-model="dataForm.captcha"
|
||||
placeholder="验证码"
|
||||
@focus="handleInputFocus"
|
||||
@blur="handleInputBlur"
|
||||
></el-input>
|
||||
size="large"
|
||||
class="login-input"
|
||||
maxlength="5"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Key /></el-icon>
|
||||
</template>
|
||||
</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 :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="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 }}
|
||||
<div v-if="error" class="error-message">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="w-full mt-3 h-46px text-15px font-semibold rounded-full border-0"
|
||||
style="background: linear-gradient(135deg, #4f46e5, #6366f1)"
|
||||
size="large"
|
||||
class="login-btn"
|
||||
@click="dataFormSubmit()"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
{{ isLoading ? '登录中...' : '登录' }}
|
||||
{{ isLoading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="login-footer">
|
||||
<p>© 2024 后台管理系统 版权所有</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,175 +114,52 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 userStore = useUserStore()
|
||||
const dataFormRef = ref()
|
||||
|
||||
const mouseX = ref(0)
|
||||
const mouseY = ref(0)
|
||||
const dataForm = ref({
|
||||
userName: 'admin',
|
||||
password: '123456',
|
||||
userName: '',
|
||||
password: '',
|
||||
uuid: '',
|
||||
captcha: ''
|
||||
})
|
||||
|
||||
const dataRule = {
|
||||
userName: [{ required: true, message: '帐号不能为空', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '密码不能为空', trigger: 'blur' }],
|
||||
captcha: [{ required: true, message: '验证码不能为空', trigger: 'blur' }]
|
||||
userName: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
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 isLoading = ref(false)
|
||||
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() {
|
||||
showPassword.value = !showPassword.value
|
||||
if (showPassword.value && dataForm.value.password.length > 0) {
|
||||
schedulePurplePeek()
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePurplePeek() {
|
||||
if (!showPassword.value || dataForm.value.password.length === 0) {
|
||||
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) => {
|
||||
async function dataFormSubmit() {
|
||||
dataFormRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
setTimeout(() => {
|
||||
userStore.setToken('mock_token_' + Date.now())
|
||||
try {
|
||||
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({
|
||||
username: dataForm.value.userName,
|
||||
nickname: '管理员',
|
||||
@@ -536,121 +167,229 @@ function dataFormSubmit() {
|
||||
})
|
||||
ElMessage.success('登录成功')
|
||||
router.replace({ path: '/home' })
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '登录失败'
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getCaptcha() {
|
||||
function refreshCaptcha() {
|
||||
dataForm.value.uuid = Date.now().toString()
|
||||
const code = Math.random().toString(36).substring(2, 6).toUpperCase()
|
||||
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>`)}`
|
||||
captchaPath.value = getCaptchaUrl(dataForm.value.uuid)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getCaptcha()
|
||||
initBlinkingAnimations()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (purpleBlinkTimeout) clearTimeout(purpleBlinkTimeout)
|
||||
if (blackBlinkTimeout) clearTimeout(blackBlinkTimeout)
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
if (peekTimeout) clearTimeout(peekTimeout)
|
||||
if (animationFrameId.value) cancelAnimationFrame(animationFrameId.value)
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 动画关键帧 */
|
||||
@keyframes login-card-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 20px, 0) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
.admin-login {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
}
|
||||
|
||||
@keyframes error-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
/* 背景图案 */
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
:deep(.el-input) {
|
||||
transition: all 0.3s ease;
|
||||
.bg-pattern {
|
||||
position: absolute;
|
||||
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);
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
|
||||
/* 登录容器 */
|
||||
.login-container {
|
||||
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);
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
/* 头部 */
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
:deep(.el-button:active:not(:disabled)) {
|
||||
transform: translateY(0);
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 自定义尺寸类 */
|
||||
.max-w-1120px {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
.h-500px {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.w-105 {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
.h-46px {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.text-24px {
|
||||
.system-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.text-20px {
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.login-footer {
|
||||
margin-top: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 480px) {
|
||||
.login-box {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.system-title {
|
||||
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>
|
||||
|
||||
@@ -13,5 +13,12 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080/',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user