feat: 重构登录页面和API结构,添加动态菜单和路由权限控制

- 重构登录页面为简洁风格,移除动画角色,优化表单验证和错误提示
- 重新组织API模块结构,拆分为system和user子模块
- 添加动态菜单获取和路由权限控制功能
- 实现404页面和错误处理
- 更新环境配置文件,区分开发、测试和生产环境
- 优化请求拦截器,处理token和错误响应
- 添加菜单状态管理,支持动态路由生成和权限验证
- 更新主布局,支持动态菜单渲染和响应式设计
- 优化首页样式和组件结构
This commit is contained in:
LuRuiqian
2026-04-17 11:44:50 +08:00
parent 44d19d4eb1
commit 39542b378f
16 changed files with 847 additions and 651 deletions

11
.env.development Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
// API 统一出口
export * from './system/loginApi'
export * from './system/menuApi'
export * from './user/userApi'

View File

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

View 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',
})
}

View 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
View 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,
})
}

View File

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

View File

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

View File

@@ -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
View 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
View 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>

View File

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

View File

@@ -1,350 +1,100 @@
<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"
@@ -353,6 +103,10 @@
</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>

View File

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