feat: 重构前端架构并实现管理系统基础功能

- 添加Element Plus图标库依赖
- 重构路由结构,实现布局组件和嵌套路由
- 实现登录页面动态角色动画效果
- 开发主布局框架,包含侧边栏和面包屑导航
- 新增用户管理、系统设置和首页功能模块
- 移除旧版样式和示例组件
- 优化全局样式和响应式设计
This commit is contained in:
LuRuiqian
2026-04-17 10:19:01 +08:00
parent 47a31a726f
commit 44d19d4eb1
14 changed files with 1890 additions and 404 deletions

View File

@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"element-plus": "^2.9.7", "element-plus": "^2.9.7",
"pinia": "^3.0.1", "pinia": "^3.0.1",

3
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.1
version: 2.3.2(vue@3.5.32(typescript@6.0.3))
axios: axios:
specifier: ^1.8.4 specifier: ^1.8.4
version: 1.15.0 version: 1.15.0

View File

@@ -1,7 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script> </script>
<template> <template>
<HelloWorld /> <router-view />
</template> </template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
}
</style>

360
src/layouts/MainLayout.vue Normal file
View File

@@ -0,0 +1,360 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/userStore'
import {
HomeFilled,
UserFilled,
Setting,
Menu,
ArrowDown,
Grid,
Document,
DataAnalysis
} from '@element-plus/icons-vue'
import Breadcrumb from './components/Breadcrumb.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
const menuItems = [
{ path: '/home', name: '首页', icon: HomeFilled },
{ path: '/user', name: '用户管理', icon: UserFilled },
{ path: '/system/settings', name: '系统设置', icon: Setting },
]
function handleSelect(path: string) {
router.push(path)
}
function handleCommand(command: string) {
if (command === 'logout') {
userStore.clearUser()
router.push('/login')
}
}
function toggleCollapse() {
isCollapse.value = !isCollapse.value
}
</script>
<template>
<div class="main-layout">
<!-- 侧边栏 -->
<aside class="sidebar" :class="{ 'is-collapse': isCollapse }">
<!-- Logo区域 -->
<div class="logo-section">
<div class="logo-icon">
<el-icon :size="28"><Grid /></el-icon>
</div>
<span v-show="!isCollapse" class="logo-text">管理系统</span>
</div>
<!-- 菜单区域 -->
<div class="menu-container">
<el-menu
:default-active="route.path"
:collapse="isCollapse"
:collapse-transition="false"
class="custom-menu"
@select="handleSelect"
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
class="menu-item"
>
<el-icon class="menu-icon">
<component :is="item.icon" />
</el-icon>
<template #title>
<span class="menu-title">{{ item.name }}</span>
</template>
</el-menu-item>
</el-menu>
</div>
<!-- 底部收缩按钮 -->
<div class="collapse-section" @click="toggleCollapse">
<el-icon class="collapse-arrow" :class="{ 'is-rotate': isCollapse }">
<Menu />
</el-icon>
<span v-show="!isCollapse" class="collapse-text">收起菜单</span>
</div>
</aside>
<!-- 右侧内容区 -->
<div class="right-container">
<!-- 顶部导航 -->
<header class="header">
<div class="header-left">
<breadcrumb />
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" :icon="UserFilled" class="user-avatar" />
<span class="user-name">{{ userStore.userInfo?.username || '管理员' }}</span>
<el-icon class="user-arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><UserFilled /></el-icon>个人中心
</el-dropdown-item>
<el-dropdown-item command="settings">
<el-icon><Setting /></el-icon>系统设置
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><Document /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>
<style scoped>
.main-layout {
display: flex;
height: 100vh;
width: 100%;
overflow: hidden;
background-color: #f5f7fa;
}
/* 侧边栏样式 */
.sidebar {
background: linear-gradient(180deg, #1a1f3c 0%, #2d3a5c 100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
display: flex;
flex-direction: column;
height: 100%;
width: 240px;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.1);
}
.sidebar.is-collapse {
width: 72px;
}
/* Logo区域 */
.logo-section {
height: 70px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.logo-text {
color: #fff;
font-size: 18px;
font-weight: 600;
letter-spacing: 1px;
white-space: nowrap;
overflow: hidden;
}
/* 菜单区域 */
.menu-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 12px;
}
.menu-container::-webkit-scrollbar {
width: 4px;
}
.menu-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.custom-menu {
background: transparent !important;
border: none;
}
:deep(.custom-menu .el-menu-item) {
height: 50px;
line-height: 50px;
margin-bottom: 8px;
border-radius: 10px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.3s ease;
}
:deep(.custom-menu .el-menu-item:hover) {
background: rgba(255, 255, 255, 0.1) !important;
color: #fff;
}
:deep(.custom-menu .el-menu-item.is-active) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: #fff;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.menu-icon {
font-size: 20px;
margin-right: 12px;
}
.menu-title {
font-size: 14px;
font-weight: 500;
}
/* 底部收缩按钮 */
.collapse-section {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
transition: all 0.3s ease;
flex-shrink: 0;
}
.collapse-section:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.collapse-arrow {
font-size: 20px;
transition: transform 0.3s ease;
}
.collapse-arrow.is-rotate {
transform: rotate(180deg);
}
.collapse-text {
font-size: 13px;
white-space: nowrap;
}
/* 右侧内容区 */
.right-container {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* 顶部导航 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
flex-shrink: 0;
height: 70px;
padding: 0 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 15px;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 6px 12px;
border-radius: 25px;
transition: all 0.3s ease;
}
.user-info:hover {
background-color: #f5f7fa;
}
.user-avatar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.user-name {
color: #303133;
font-size: 14px;
font-weight: 500;
}
.user-arrow {
color: #909399;
font-size: 12px;
}
/* 主内容区 */
.main-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
}
/* 折叠状态下的菜单样式调整 */
:deep(.custom-menu.el-menu--collapse) {
width: 100%;
}
:deep(.custom-menu.el-menu--collapse .el-menu-item) {
padding: 0 !important;
display: flex;
justify-content: center;
}
:deep(.custom-menu.el-menu--collapse .el-tooltip__trigger) {
display: flex !important;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const breadcrumbs = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title)
return matched.map(item => ({
path: item.path,
title: item.meta.title as string,
}))
})
</script>
<template>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<style scoped>
.el-breadcrumb {
font-size: 14px;
}
</style>

View File

@@ -1,7 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue' import App from './App.vue'
import { pinia } from '@/stores' import { pinia } from '@/stores'
import router from '@/router' import router from '@/router'

View File

@@ -3,16 +3,35 @@ import { useUserStore } from '@/stores/userStore'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/login',
name: 'Home', name: 'Login',
component: () => import('@/views/HomeView.vue'), component: () => import('@/views/login/index.vue'),
meta: { requiresAuth: false }, meta: { requiresAuth: false },
}, },
{ {
path: '/login', path: '/',
name: 'Login', component: () => import('@/layouts/MainLayout.vue'),
component: () => import('@/views/LoginView.vue'), redirect: '/home',
meta: { requiresAuth: false }, children: [
{
path: 'home',
name: 'Home',
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 },
},
],
}, },
] ]
@@ -22,13 +41,7 @@ const router = createRouter({
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const userStore = useUserStore() next()
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
next('/login')
} else {
next()
}
}) })
export default router export default router

View File

@@ -1,296 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('欢迎使用 Vue 项目模板')
</script>
<template>
<div class="home">
<h1>{{ message }}</h1>
<el-button type="primary">Element Plus 按钮</el-button>
</div>
</template>
<style scoped>
.home {
padding: 20px;
text-align: center;
}
</style>

View File

@@ -1,72 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/userStore'
import { login } from '@/api/userApi'
const router = useRouter()
const userStore = useUserStore()
const loginForm = ref({
username: '',
password: '',
})
const loading = ref(false)
async function handleLogin() {
if (!loginForm.value.username || !loginForm.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const res = await login(loginForm.value)
userStore.setToken(res.token)
userStore.setUserInfo(res.userInfo)
ElMessage.success('登录成功')
router.push('/')
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login">
<el-card class="login-card">
<h2>登录</h2>
<el-form :model="loginForm" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="loginForm.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleLogin">
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.login {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
}
.login-card {
width: 400px;
}
</style>

361
src/views/home/index.vue Normal file
View File

@@ -0,0 +1,361 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '@/stores/userStore'
import {
User,
Document,
Setting,
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 },
])
const recentActivities = ref([
{ time: '2024-01-15 10:30', content: '管理员登录系统', type: 'success' },
{ time: '2024-01-15 09:15', content: '新增用户: user123', type: 'info' },
{ time: '2024-01-14 16:45', content: '修改系统配置', type: 'warning' },
{ time: '2024-01-14 14:20', content: '删除用户: test_user', type: 'danger' },
{ time: '2024-01-14 11:00', content: '备份数据库', type: 'success' },
])
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' },
])
</script>
<template>
<div class="home-page">
<!-- 欢迎卡片 -->
<el-card class="welcome-card" shadow="hover">
<div class="welcome-content">
<div class="welcome-text">
<h2>欢迎回来{{ userStore.userInfo?.username || '管理员' }}</h2>
<p>今天是 {{ new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }) }}</p>
</div>
<el-button type="primary" plain>查看个人中心</el-button>
</div>
</el-card>
<!-- 统计卡片 -->
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :lg="6" v-for="item in stats" :key="item.title">
<el-card class="stat-card" shadow="hover">
<div class="stat-content">
<div class="stat-icon" :style="{ backgroundColor: item.color + '15', color: item.color }">
<el-icon :size="28">
<component :is="item.icon" />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ item.value }}</div>
<div class="stat-title">{{ item.title }}</div>
</div>
</div>
<div class="stat-footer">
<span class="trend" :class="item.up ? 'up' : 'down'">
<el-icon><component :is="item.up ? ArrowUp : ArrowDown" /></el-icon>
{{ item.trend }}
</span>
<span class="trend-label">较昨日</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 内容区域 -->
<el-row :gutter="20" class="content-row">
<el-col :xs="24" :lg="16">
<el-card shadow="hover" class="chart-card">
<template #header>
<div class="card-header">
<span class="card-title">数据概览</span>
<el-radio-group v-model="chartType" size="small">
<el-radio-button label="week">本周</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="year">全年</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="chart-placeholder">
<el-empty description="图表区域 - 可接入 ECharts">
<el-button type="primary">配置图表</el-button>
</el-empty>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card shadow="hover" class="activity-card">
<template #header>
<div class="card-header">
<span class="card-title">最近活动</span>
<el-link type="primary" :underline="false">查看更多</el-link>
</div>
</template>
<el-timeline>
<el-timeline-item
v-for="(activity, index) in recentActivities"
:key="index"
:type="activity.type"
:timestamp="activity.time"
:hollow="true"
>
{{ activity.content }}
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
</el-row>
<!-- 快捷入口 -->
<el-card shadow="hover" class="quick-links-card">
<template #header>
<div class="card-header">
<span class="card-title">快捷入口</span>
</div>
</template>
<el-row :gutter="20">
<el-col :xs="12" :sm="8" :md="6" :lg="4" v-for="link in quickLinks" :key="link.name">
<router-link :to="link.path" class="quick-item" :style="{ '--hover-color': link.color }">
<div class="quick-icon" :style="{ backgroundColor: link.color + '15', color: link.color }">
<el-icon :size="24">
<component :is="link.icon" />
</el-icon>
</div>
<span class="quick-name">{{ link.name }}</span>
</router-link>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script lang="ts">
const chartType = ref('week')
</script>
<style scoped>
.home-page {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 欢迎卡片 */
.welcome-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.welcome-card :deep(.el-card__body) {
padding: 24px;
}
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-text {
color: #fff;
}
.welcome-text h2 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
}
.welcome-text p {
margin: 0;
opacity: 0.9;
font-size: 14px;
}
/* 统计卡片 */
.stats-row {
margin: 0 !important;
}
.stat-card {
margin-bottom: 0;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #1f2937;
line-height: 1;
margin-bottom: 6px;
}
.stat-title {
font-size: 14px;
color: #6b7280;
}
.stat-footer {
display: flex;
align-items: center;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}
.trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
}
.trend.up {
color: #10b981;
}
.trend.down {
color: #ef4444;
}
.trend-label {
font-size: 12px;
color: #9ca3af;
}
/* 内容区域 */
.content-row {
margin: 0 !important;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.chart-card {
margin-bottom: 0;
}
.chart-placeholder {
height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.activity-card {
margin-bottom: 0;
}
.activity-card :deep(.el-card__body) {
padding: 20px;
}
/* 快捷入口 */
.quick-links-card {
margin-bottom: 0;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px 16px;
text-decoration: none;
border-radius: 12px;
transition: all 0.3s ease;
background-color: #f9fafb;
}
.quick-item:hover {
background-color: var(--hover-color);
transform: translateY(-4px);
}
.quick-item:hover .quick-icon {
background-color: #fff !important;
}
.quick-item:hover .quick-name {
color: #fff;
}
.quick-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.quick-name {
font-size: 14px;
color: #4b5563;
font-weight: 500;
transition: all 0.3s ease;
}
/* 响应式 */
@media (max-width: 768px) {
.welcome-content {
flex-direction: column;
gap: 16px;
text-align: center;
}
.stat-value {
font-size: 22px;
}
}
</style>

656
src/views/login/index.vue Normal file
View File

@@ -0,0 +1,656 @@
<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>
<!-- 黑色角色 -->
<div
ref="blackRef"
class="black-character absolute bottom-0 transition-all duration-300 ease-out"
:style="{
left: '240px',
width: '120px',
height: '310px',
backgroundColor: 'rgb(45, 45, 45)',
borderRadius: '8px 8px 0px 0px',
zIndex: 2,
transform: (dataForm.password.length > 0 && showPassword)
? 'skewX(0deg)'
: isLookingAtEachOther
? `skewX(${(calculatePosition(blackRef)?.bodySkew || 0) * 1.5 + 10}deg) translateX(20px)`
: (isTyping || (dataForm.password.length > 0 && !showPassword))
? `skewX(${(calculatePosition(blackRef)?.bodySkew || 0) * 1.5}deg)`
: `skewX(${calculatePosition(blackRef)?.bodySkew || 0}deg)`,
transformOrigin: 'center bottom'
}"
>
<div
class="absolute flex gap-6 transition-all duration-700 ease-in-out"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '10px' : isLookingAtEachOther ? '32px' : `${26 + (calculatePosition(blackRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '28px' : isLookingAtEachOther ? '12px' : `${32 + (calculatePosition(blackRef)?.faceY || 0)}px`
}"
>
<div
class="rounded-full flex items-center justify-center transition-all duration-150 overflow-hidden"
:style="{
width: '24px',
height: isBlackBlinking ? '2px' : '24px',
backgroundColor: 'white'
}"
>
<div
v-if="!isBlackBlinking"
class="rounded-full login-eyes-dot"
:style="{
width: '12px',
height: '12px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '0px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
<div
class="rounded-full flex items-center justify-center transition-all duration-150 overflow-hidden"
:style="{
width: '24px',
height: isBlackBlinking ? '2px' : '24px',
backgroundColor: 'white'
}"
>
<div
v-if="!isBlackBlinking"
class="rounded-full login-eyes-dot"
:style="{
width: '12px',
height: '12px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '0px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
</div>
</div>
<!-- 橙色角色 -->
<div
ref="orangeRef"
class="orange-character absolute bottom-0 transition-all duration-300 ease-out"
:style="{
left: '0px',
width: '240px',
height: '200px',
zIndex: 3,
backgroundColor: 'rgb(255, 155, 107)',
borderRadius: '120px 120px 0px 0px',
transform: (dataForm.password.length > 0 && showPassword) ? 'skewX(0deg)' : `skewX(${calculatePosition(orangeRef)?.bodySkew || 0}deg)`,
transformOrigin: 'center bottom'
}"
>
<div
class="absolute flex gap-8 transition-all duration-200 ease-out"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '50px' : `${82 + (calculatePosition(orangeRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '85px' : `${90 + (calculatePosition(orangeRef)?.faceY || 0)}px`
}"
>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
</div>
<!-- 黄色角色 -->
<div
ref="yellowRef"
class="yellow-character absolute bottom-0 transition-all duration-300 ease-out"
:style="{
left: '310px',
width: '140px',
height: '230px',
backgroundColor: 'rgb(232, 215, 84)',
borderRadius: '70px 70px 0px 0px',
zIndex: 4,
transform: (dataForm.password.length > 0 && showPassword) ? 'skewX(0deg)' : `skewX(${calculatePosition(yellowRef)?.bodySkew || 0}deg)`,
transformOrigin: 'center bottom'
}"
>
<div
class="absolute flex gap-6 transition-all duration-200 ease-out"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '20px' : `${52 + (calculatePosition(yellowRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '35px' : `${40 + (calculatePosition(yellowRef)?.faceY || 0)}px`
}"
>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
<div
class="absolute w-20 h-1 bg-#2D2D2D rounded-full transition-all duration-200 ease-out login-mouth-line"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '10px' : `${40 + (calculatePosition(yellowRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '88px' : `${88 + (calculatePosition(yellowRef)?.faceY || 0)}px`
}"
></div>
</div>
</div>
</div>
</div>
<!-- 右侧登录表单 -->
<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
>
<el-form-item prop="userName">
<el-input
v-model="dataForm.userName"
placeholder="帐号"
@focus="handleInputFocus"
@blur="handleInputBlur"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="dataForm.password"
:type="showPassword ? 'text' : 'password'"
placeholder="密码"
@focus="handleInputFocus"
@blur="handleInputBlur"
>
<template #append>
<el-button @click="togglePasswordVisibility" class="password-toggle-btn">
<el-icon v-if="showPassword"><View /></el-icon>
<el-icon v-else><Hide /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="captcha">
<el-row :gutter="20">
<el-col :span="14">
<el-input
v-model="dataForm.captcha"
placeholder="验证码"
@focus="handleInputFocus"
@blur="handleInputBlur"
></el-input>
</el-col>
<el-col :span="10" class="overflow-hidden">
<img
:src="captchaPath"
@click="getCaptcha()"
alt=""
class="w-full cursor-pointer rounded-xl"
style="box-shadow: 0 0 0 1px #e5e7eb"
/>
</el-col>
</el-row>
</el-form-item>
<div
v-if="error"
class="px-3 py-3 my-3 rounded-lg text-sm text-#ef4444"
style="background-color: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); animation: error-fade-in 0.3s ease-out"
>
{{ error }}
</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)"
@click="dataFormSubmit()"
:loading="isLoading"
:disabled="isLoading"
>
{{ isLoading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } 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'
const router = useRouter()
const userStore = useUserStore()
const dataFormRef = ref()
const mouseX = ref(0)
const mouseY = ref(0)
const dataForm = ref({
userName: 'admin',
password: '123456',
uuid: '',
captcha: ''
})
const dataRule = {
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) => {
if (valid) {
isLoading.value = true
error.value = ''
setTimeout(() => {
userStore.setToken('mock_token_' + Date.now())
userStore.setUserInfo({
username: dataForm.value.userName,
nickname: '管理员',
avatar: ''
})
ElMessage.success('登录成功')
router.replace({ path: '/home' })
isLoading.value = false
}, 800)
}
})
}
function getCaptcha() {
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>`)}`
}
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)
})
</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);
}
}
@keyframes error-fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Element Plus 样式覆盖 */
:deep(.el-input) {
transition: all 0.3s ease;
}
:deep(.el-input:focus-within) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
}
:deep(.el-button) {
transition: all 0.3s ease;
}
:deep(.el-button:hover:not(:disabled)) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
:deep(.el-button:active:not(:disabled)) {
transform: translateY(0);
}
/* 自定义尺寸类 */
.max-w-1120px {
max-width: 1120px;
}
.h-500px {
height: 500px;
}
.w-105 {
width: 420px;
}
.h-46px {
height: 46px;
}
.text-24px {
font-size: 24px;
}
.text-20px {
font-size: 20px;
}
.text-15px {
font-size: 15px;
}
.rounded-24px {
border-radius: 24px;
}
.bg-#2D2D2D {
background-color: #2d2d2d;
}
.text-#0f172a {
color: #0f172a;
}
.text-#ef4444 {
color: #ef4444;
}
</style>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Message, Lock, Monitor } from '@element-plus/icons-vue'
const activeName = ref('basic')
const basicForm = ref({
siteName: '管理系统',
logo: '',
copyright: '© 2024 版权所有',
icp: '',
})
const emailForm = ref({
smtpServer: '',
smtpPort: 587,
username: '',
password: '',
enableSsl: true,
})
const securityForm = ref({
loginFailCount: 5,
lockTime: 30,
passwordExpire: 90,
enableCaptcha: true,
})
function saveBasic() {
ElMessage.success('基础设置保存成功')
}
function saveEmail() {
ElMessage.success('邮件设置保存成功')
}
function saveSecurity() {
ElMessage.success('安全设置保存成功')
}
function testEmail() {
ElMessage.success('测试邮件已发送')
}
</script>
<template>
<div class="settings-page">
<el-card shadow="hover">
<el-tabs v-model="activeName" type="border-card">
<!-- 基础设置 -->
<el-tab-pane name="basic">
<template #label>
<span class="tab-label">
<el-icon><Monitor /></el-icon>
<span>基础设置</span>
</span>
</template>
<el-form :model="basicForm" label-width="120px" class="settings-form">
<el-form-item label="站点名称">
<el-input v-model="basicForm.siteName" placeholder="请输入站点名称" />
</el-form-item>
<el-form-item label="站点Logo">
<el-upload
class="avatar-uploader"
action="#"
:auto-upload="false"
:show-file-list="false"
>
<img v-if="basicForm.logo" :src="basicForm.logo" class="avatar">
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
<span class="upload-tip">建议尺寸200x200px支持 jpgpng 格式</span>
</el-form-item>
<el-form-item label="版权信息">
<el-input
v-model="basicForm.copyright"
type="textarea"
:rows="2"
placeholder="请输入版权信息"
/>
</el-form-item>
<el-form-item label="备案号">
<el-input v-model="basicForm.icp" placeholder="请输入ICP备案号" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveBasic">保存设置</el-button>
<el-button @click="basicForm = { siteName: '', logo: '', copyright: '', icp: '' }">重置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 邮件设置 -->
<el-tab-pane name="email">
<template #label>
<span class="tab-label">
<el-icon><Message /></el-icon>
<span>邮件设置</span>
</span>
</template>
<el-form :model="emailForm" label-width="120px" class="settings-form">
<el-form-item label="SMTP服务器">
<el-input v-model="emailForm.smtpServer" placeholder="如: smtp.example.com" />
</el-form-item>
<el-form-item label="SMTP端口">
<el-input-number v-model="emailForm.smtpPort" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="发件人邮箱">
<el-input v-model="emailForm.username" placeholder="请输入发件人邮箱" />
</el-form-item>
<el-form-item label="授权码">
<el-input v-model="emailForm.password" type="password" placeholder="请输入邮箱授权码" />
</el-form-item>
<el-form-item label="启用SSL">
<el-switch v-model="emailForm.enableSsl" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveEmail">保存设置</el-button>
<el-button @click="testEmail">测试连接</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 安全设置 -->
<el-tab-pane name="security">
<template #label>
<span class="tab-label">
<el-icon><Lock /></el-icon>
<span>安全设置</span>
</span>
</template>
<el-form :model="securityForm" label-width="160px" class="settings-form">
<el-form-item label="登录失败次数限制">
<el-input-number v-model="securityForm.loginFailCount" :min="3" :max="10" />
<span class="form-tip">次后锁定账号</span>
</el-form-item>
<el-form-item label="账号锁定时间">
<el-input-number v-model="securityForm.lockTime" :min="5" :max="60" />
<span class="form-tip">分钟</span>
</el-form-item>
<el-form-item label="密码有效期">
<el-input-number v-model="securityForm.passwordExpire" :min="30" :max="180" />
<span class="form-tip">天后需修改密码</span>
</el-form-item>
<el-form-item label="启用验证码">
<el-switch v-model="securityForm.enableCaptcha" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveSecurity">保存设置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<style scoped>
.settings-page {
max-width: 100%;
}
.tab-label {
display: flex;
align-items: center;
gap: 6px;
}
.settings-form {
max-width: 600px;
padding: 20px 0;
}
.avatar-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 8px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
display: inline-flex;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
width: 120px;
height: 120px;
display: block;
object-fit: cover;
}
.upload-tip {
margin-left: 12px;
color: #909399;
font-size: 13px;
}
.form-tip {
margin-left: 12px;
color: #909399;
font-size: 13px;
}
</style>

206
src/views/user/index.vue Normal file
View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete } from '@element-plus/icons-vue'
const tableData = ref([
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: '启用', createTime: '2024-01-15' },
{ id: 2, username: 'user1', email: 'user1@example.com', phone: '13800138001', status: '启用', createTime: '2024-01-14' },
{ id: 3, username: 'user2', email: 'user2@example.com', phone: '13800138002', status: '禁用', createTime: '2024-01-13' },
])
const searchForm = ref({
username: '',
status: '',
})
const loading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('新增用户')
const formRef = ref()
const userForm = ref({
id: null,
username: '',
email: '',
phone: '',
status: '启用',
})
function handleSearch() {
loading.value = true
setTimeout(() => {
loading.value = false
ElMessage.success('查询成功')
}, 500)
}
function handleReset() {
searchForm.value = { username: '', status: '' }
handleSearch()
}
function handleAdd() {
dialogTitle.value = '新增用户'
userForm.value = { id: null, username: '', email: '', phone: '', status: '启用' }
dialogVisible.value = true
}
function handleEdit(row: any) {
dialogTitle.value = '编辑用户'
userForm.value = { ...row }
dialogVisible.value = true
}
function handleDelete(row: any) {
ElMessageBox.confirm(`确定删除用户 "${row.username}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
ElMessage.success('删除成功')
})
}
function handleSubmit() {
formRef.value?.validate((valid: boolean) => {
if (valid) {
ElMessage.success(dialogTitle.value === '新增用户' ? '新增成功' : '修改成功')
dialogVisible.value = false
}
})
}
function handleStatusChange(row: any) {
ElMessage.success(`用户状态已${row.status === '启用' ? '启用' : '禁用'}`)
}
</script>
<template>
<div class="user-page">
<!-- 搜索区域 -->
<el-card class="search-card" shadow="hover">
<el-form :model="searchForm" inline>
<el-form-item label="用户名">
<el-input
v-model="searchForm.username"
placeholder="请输入用户名"
clearable
:prefix-icon="Search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格区域 -->
<el-card class="table-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="title">用户列表</span>
<el-button type="primary" :icon="Plus" @click="handleAdd">新增用户</el-button>
</div>
</template>
<el-table :data="tableData" border v-loading="loading" stripe>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="phone" label="手机号" min-width="120" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-model="row.status"
active-value="启用"
inactive-value="禁用"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="150" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="100"
:page-sizes="[10, 20, 50, 100]"
/>
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="userForm" label-width="80px">
<el-form-item label="用户名" prop="username" required>
<el-input v-model="userForm.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="userForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="userForm.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="userForm.status">
<el-radio label="启用">启用</el-radio>
<el-radio label="禁用">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.user-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.search-card {
margin-bottom: 0;
}
.table-card {
margin-bottom: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header .title {
font-size: 16px;
font-weight: 600;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>