This commit is contained in:
2026-01-14 14:49:08 +08:00
parent 2ce76820da
commit 7065e5329a
23 changed files with 2236 additions and 413 deletions

View File

@@ -5,4 +5,4 @@ indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
max_line_length = 260

View File

@@ -14,7 +14,9 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2",
"element-plus": "^2.13.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"vue": "^3.5.26",

154
src/assets/css/auth.css Normal file
View File

@@ -0,0 +1,154 @@
/* 认证页面统一样式 */
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
position: relative;
overflow: hidden;
}
.auth-container::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 50%);
animation: rotate 20s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.auth-card {
width: 100%;
max-width: 420px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
padding: 40px;
position: relative;
z-index: 1;
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.auth-header .logo {
font-size: 48px;
margin-bottom: 16px;
color: #667eea;
}
.auth-header h1 {
font-size: 28px;
font-weight: 600;
color: #2c3e50;
margin: 0 0 8px 0;
}
.auth-header p {
font-size: 14px;
color: #7f8c8d;
margin: 0;
}
.auth-form {
margin-bottom: 24px;
}
.auth-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.auth-actions .forgot-link {
color: #667eea;
text-decoration: none;
font-size: 14px;
transition: color 0.3s ease;
}
.auth-actions .forgot-link:hover {
color: #764ba2;
}
.auth-footer {
text-align: center;
padding-top: 24px;
border-top: 1px solid #eaeaea;
}
.auth-footer .link {
color: #667eea;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.3s ease;
}
.auth-footer .link:hover {
color: #764ba2;
}
.auth-footer .text {
color: #7f8c8d;
font-size: 14px;
margin: 0 4px;
}
.code-button {
width: 100%;
height: 44px;
border-radius: 8px;
border: 1px solid #667eea;
background: transparent;
color: #667eea;
font-size: 14px;
transition: all 0.3s ease;
}
.code-button:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.05);
color: #764ba2;
border-color: #764ba2;
}
.code-button:disabled {
border-color: #dcdfe6;
color: #c0c4cc;
cursor: not-allowed;
}
.language-switcher {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
/* 响应式设计 */
@media (max-width: 768px) {
.auth-card {
padding: 30px 20px;
}
.auth-header h1 {
font-size: 24px;
}
}

View File

@@ -0,0 +1,76 @@
/* 自定义 NProgress 样式 */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: linear-gradient(90deg, #00d4ff 0%, #7c4dff 100%);
position: fixed;
z-index: 9999;
top: 0;
left: 0;
width: 100%;
height: 3px;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
/* 螺旋加载器样式 */
#nprogress .peg {
display: block;
position: absolute;
right: 0;
width: 100px;
height: 100%;
box-shadow:
0 0 10px #00d4ff,
0 0 5px #7c4dff;
opacity: 1;
transform: rotate(3deg) translate(0px, -4px);
}
/* 进度条动画 */
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 如果启用加载器时的样式 */
#nprogress .spinner {
display: block;
position: fixed;
z-index: 9999;
top: 15px;
right: 15px;
}
#nprogress .spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: #00d4ff;
border-left-color: #7c4dff;
border-radius: 50%;
animation: nprogress-spinner 400ms linear infinite;
}
/* 进度条闪烁效果 */
@keyframes nprogress-flicker {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
#nprogress .bar {
animation: nprogress-flicker 0.5s ease-in-out;
}

View File

@@ -1,4 +1,5 @@
export default {
app_title: 'vueadmin',
DASHBOARD_URL: '/home',
baseURL: ''
}

18
src/config/routes.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* 用户静态路由配置
* 这些路由会根据用户角色进行过滤后添加到路由中
*/
const userRoutes = [
{
path: '/home',
name: 'home',
component: 'home',
meta: {
title: 'dashboard',
icon: 'el-icon-odometer',
role: ['admin']
}
}
]
export default userRoutes

View File

@@ -70,7 +70,47 @@ export default {
usernamePlaceholder: 'Please enter username',
passwordPlaceholder: 'Please enter password',
noAccount: "Don't have an account?",
registerNow: 'Register Now'
registerNow: 'Register Now',
forgotPassword: 'Forgot Password?',
rememberMe: 'Remember Me'
},
register: {
title: 'User Registration',
subtitle: 'Create your account and get started',
registerButton: 'Register',
registerSuccess: 'Registration Successful',
registerFailed: 'Registration Failed',
usernamePlaceholder: 'Please enter username',
emailPlaceholder: 'Please enter email address',
passwordPlaceholder: 'Please enter password',
confirmPasswordPlaceholder: 'Please enter password again',
usernameRule: 'Username length between 3 to 20 characters',
emailRule: 'Please enter a valid email address',
passwordRule: 'Password length between 6 to 20 characters',
agreeRule: 'Please agree to the user agreement',
agreeTerms: 'I have read and agree to the',
terms: 'User Agreement',
hasAccount: 'Already have an account?',
loginNow: 'Login Now'
},
resetPassword: {
title: 'Reset Password',
subtitle: 'Reset your password via email verification code',
resetButton: 'Reset Password',
resetSuccess: 'Password reset successful',
resetFailed: 'Reset failed',
emailPlaceholder: 'Please enter email address',
codePlaceholder: 'Please enter verification code',
newPasswordPlaceholder: 'Please enter new password',
confirmPasswordPlaceholder: 'Please enter new password again',
emailRule: 'Please enter a valid email address',
codeRule: 'Verification code must be 6 characters',
passwordRule: 'Password length between 6 to 20 characters',
sendCode: 'Send Code',
codeSent: 'Verification code has been sent to your email',
resendCode: 'Resend in {seconds} seconds',
sendCodeFirst: 'Please enter email address first',
backToLogin: 'Back to Login'
},
layout: {
toggleSidebar: 'Toggle Sidebar',

View File

@@ -70,7 +70,47 @@ export default {
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码',
noAccount: '还没有账户?',
registerNow: '立即注册'
registerNow: '立即注册',
forgotPassword: '忘记密码?',
rememberMe: '记住我'
},
register: {
title: '用户注册',
subtitle: '创建您的账户,开始使用',
registerButton: '注册',
registerSuccess: '注册成功',
registerFailed: '注册失败',
usernamePlaceholder: '请输入用户名',
emailPlaceholder: '请输入邮箱地址',
passwordPlaceholder: '请输入密码',
confirmPasswordPlaceholder: '请再次输入密码',
usernameRule: '用户名长度在 3 到 20 个字符',
emailRule: '请输入正确的邮箱地址',
passwordRule: '密码长度在 6 到 20 个字符',
agreeRule: '请同意用户协议',
agreeTerms: '我已阅读并同意',
terms: '用户协议',
hasAccount: '已有账户?',
loginNow: '立即登录'
},
resetPassword: {
title: '重置密码',
subtitle: '通过邮箱验证码重置您的密码',
resetButton: '重置密码',
resetSuccess: '密码重置成功',
resetFailed: '重置失败',
emailPlaceholder: '请输入邮箱地址',
codePlaceholder: '请输入验证码',
newPasswordPlaceholder: '请输入新密码',
confirmPasswordPlaceholder: '请再次输入新密码',
emailRule: '请输入正确的邮箱地址',
codeRule: '验证码长度为6位',
passwordRule: '密码长度在 6 到 20 个字符',
sendCode: '发送验证码',
codeSent: '验证码已发送到您的邮箱',
resendCode: '{seconds}秒后重新发送',
sendCodeFirst: '请先输入邮箱地址',
backToLogin: '返回登录'
},
layout: {
toggleSidebar: '切换侧边栏',

401
src/layouts/other/404.vue Normal file
View File

@@ -0,0 +1,401 @@
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
const goBack = () => {
router.back()
}
</script>
<template>
<div class="not-found">
<div class="content-wrapper">
<div class="error-code">404</div>
<div class="error-text">
<h1>页面未找到</h1>
<p>抱歉您访问的页面不存在或已被移除</p>
</div>
<div class="error-illustration">
<div class="planet"></div>
<div class="star star-1"></div>
<div class="star star-2"></div>
<div class="star star-3"></div>
<div class="rocket">
<div class="rocket-body"></div>
<div class="rocket-window"></div>
<div class="rocket-flame"></div>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary" @click="goHome">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
返回首页
</button>
<button class="btn btn-secondary" @click="goBack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"></path>
</svg>
返回上一页
</button>
</div>
</div>
</div>
</template>
<style scoped>
.not-found {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0c1929 0%, #1a237e 50%, #0d47a1 100%);
padding: 20px;
position: relative;
overflow: hidden;
}
/* 星空背景 */
.not-found::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(2px 2px at 20px 30px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(2px 2px at 40px 70px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(1px 1px at 90px 40px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(2px 2px at 160px 120px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(1px 1px at 230px 80px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(2px 2px at 300px 150px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(1px 1px at 370px 200px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(2px 2px at 450px 50px, #ffffff, rgba(0, 0, 0, 0)),
radial-gradient(1px 1px at 520px 180px, #ffffff, rgba(0, 0, 0, 0));
background-size: 550px 250px;
animation: stars 50s linear infinite;
opacity: 0.3;
}
@keyframes stars {
0% {
background-position: 0 0;
}
100% {
background-position: 550px 250px;
}
}
.content-wrapper {
text-align: center;
position: relative;
z-index: 1;
max-width: 600px;
animation: fadeIn 0.8s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.error-code {
font-size: 180px;
font-weight: 900;
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 20px;
position: relative;
animation: float 3s ease-in-out infinite;
text-shadow: 0 0 60px rgba(0, 212, 255, 0.5);
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
.error-text {
margin-bottom: 40px;
}
.error-text h1 {
font-size: 36px;
font-weight: 700;
color: white;
margin: 0 0 12px;
letter-spacing: 1px;
}
.error-text p {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.error-illustration {
position: relative;
width: 200px;
height: 200px;
margin: 0 auto 40px;
}
.planet {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
0 0 60px rgba(102, 126, 234, 0.6),
inset -10px -10px 20px rgba(0, 0, 0, 0.2);
animation: rotatePlanet 20s linear infinite;
}
.planet::before {
content: '';
position: absolute;
width: 160px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotateX(75deg);
}
.planet::after {
content: '';
position: absolute;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
top: 20%;
left: 30%;
}
@keyframes rotatePlanet {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.star {
position: absolute;
background: white;
border-radius: 50%;
animation: twinkle 2s ease-in-out infinite;
}
.star-1 {
width: 6px;
height: 6px;
top: 20%;
right: 10%;
animation-delay: 0s;
}
.star-2 {
width: 4px;
height: 4px;
top: 60%;
left: 5%;
animation-delay: 0.5s;
}
.star-3 {
width: 5px;
height: 5px;
bottom: 10%;
right: 20%;
animation-delay: 1s;
}
@keyframes twinkle {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(0.8);
}
}
.rocket {
position: absolute;
top: 30%;
right: 0;
animation: flyRocket 2s ease-in-out infinite;
}
.rocket-body {
width: 20px;
height: 40px;
background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%);
border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%;
position: relative;
}
.rocket-window {
width: 10px;
height: 10px;
background: white;
border-radius: 50%;
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
box-shadow: inset -2px -2px 4px rgba(0, 0, 0, 0.2);
}
.rocket-flame {
width: 12px;
height: 20px;
background: linear-gradient(to bottom, #feca57, #ff6b6b, transparent);
position: absolute;
bottom: -18px;
left: 50%;
transform: translateX(-50%);
border-radius: 50% 50% 20% 20%;
animation: flame 0.3s ease-in-out infinite alternate;
}
@keyframes flame {
0% {
height: 20px;
opacity: 1;
}
100% {
height: 30px;
opacity: 0.8;
}
}
@keyframes flyRocket {
0%,
100% {
transform: translateY(0) rotate(-15deg);
}
50% {
transform: translateY(-15px) rotate(-10deg);
}
}
.action-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
letter-spacing: 0.5px;
}
.btn svg {
flex-shrink: 0;
}
.btn-primary {
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%);
color: white;
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.4);
}
.btn-primary:hover {
transform: translateY(-3px);
box-shadow: 0 6px 25px rgba(0, 212, 255, 0.6);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 2px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-3px);
}
@media (max-width: 768px) {
.error-code {
font-size: 120px;
}
.error-text h1 {
font-size: 28px;
}
.error-text p {
font-size: 14px;
}
.error-illustration {
width: 150px;
height: 150px;
}
.action-buttons {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
</style>

288
src/layouts/other/empty.vue Normal file
View File

@@ -0,0 +1,288 @@
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({
title: {
type: String,
default: '暂无数据'
},
description: {
type: String,
default: '当前页面暂无相关数据,您可以稍后再来查看'
},
showAction: {
type: Boolean,
default: true
},
actionText: {
type: String,
default: '刷新页面'
}
})
const handleAction = () => {
router.go(0)
}
</script>
<template>
<div class="empty-state">
<div class="empty-content">
<div class="empty-illustration">
<div class="illustration-wrapper">
<svg class="empty-icon" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="boxGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: #667eea; stop-opacity: 0.2" />
<stop offset="100%" style="stop-color: #764ba2; stop-opacity: 0.2" />
</linearGradient>
<linearGradient id="searchGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: #00d4ff; stop-opacity: 1" />
<stop offset="100%" style="stop-color: #7c4dff; stop-opacity: 1" />
</linearGradient>
</defs>
<!-- 背景圆 -->
<circle cx="100" cy="100" r="80" fill="url(#boxGradient)" />
<!-- 搜索放大镜 -->
<g transform="translate(70, 70)">
<circle cx="30" cy="30" r="25" stroke="url(#searchGradient)" stroke-width="4" fill="none" />
<line x1="50" y1="50" x2="70" y2="70" stroke="url(#searchGradient)" stroke-width="4"
stroke-linecap="round" />
</g>
<!-- 小装饰元素 -->
<circle cx="50" cy="60" r="4" fill="#667eea" opacity="0.6">
<animate attributeName="cy" values="60;55;60" dur="2s" repeatCount="indefinite" />
</circle>
<circle cx="150" cy="80" r="3" fill="#764ba2" opacity="0.6">
<animate attributeName="cy" values="80;75;80" dur="1.5s" repeatCount="indefinite" />
</circle>
<circle cx="60" cy="140" r="5" fill="#00d4ff" opacity="0.5">
<animate attributeName="cy" values="140;145;140" dur="2.5s" repeatCount="indefinite" />
</circle>
</svg>
</div>
</div>
<div class="empty-text">
<h3 class="empty-title">{{ title }}</h3>
<p class="empty-description">{{ description }}</p>
</div>
<div v-if="showAction" class="empty-action">
<button class="action-btn" @click="handleAction">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6"></path>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
{{ actionText }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.empty-state {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
border-radius: 16px;
position: relative;
overflow: hidden;
}
/* 装饰性背景 */
.empty-state::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(2px 2px at 20px 30px, rgba(102, 126, 234, 0.3), transparent),
radial-gradient(2px 2px at 40px 70px, rgba(118, 75, 162, 0.3), transparent),
radial-gradient(1px 1px at 90px 40px, rgba(0, 212, 255, 0.3), transparent),
radial-gradient(2px 2px at 160px 120px, rgba(102, 126, 234, 0.3), transparent);
background-size: 200px 150px;
animation: floatBg 20s linear infinite;
opacity: 0.5;
}
@keyframes floatBg {
0% {
background-position: 0 0;
}
100% {
background-position: 200px 150px;
}
}
.empty-content {
text-align: center;
position: relative;
z-index: 1;
max-width: 480px;
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.empty-illustration {
margin-bottom: 32px;
position: relative;
}
.illustration-wrapper {
display: inline-block;
position: relative;
}
.empty-icon {
width: 180px;
height: 180px;
animation: floatIcon 3s ease-in-out infinite;
}
@keyframes floatIcon {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.empty-text {
margin-bottom: 32px;
}
.empty-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 12px;
letter-spacing: 0.5px;
}
.empty-description {
font-size: 15px;
color: #666;
margin: 0;
line-height: 1.6;
}
.empty-action {
display: flex;
justify-content: center;
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
letter-spacing: 0.5px;
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.4);
}
.action-btn:active {
transform: translateY(0);
}
.action-btn svg {
flex-shrink: 0;
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.empty-title {
color: #fff;
}
.empty-description {
color: rgba(255, 255, 255, 0.7);
}
}
/* 响应式适配 */
@media (max-width: 768px) {
.empty-state {
min-height: 300px;
padding: 40px 20px;
}
.empty-icon {
width: 140px;
height: 140px;
}
.empty-title {
font-size: 20px;
}
.empty-description {
font-size: 14px;
}
.action-btn {
padding: 10px 24px;
font-size: 14px;
}
}
@media (max-width: 480px) {
.empty-state {
padding: 30px 16px;
min-height: 250px;
}
.empty-icon {
width: 120px;
height: 120px;
}
.empty-title {
font-size: 18px;
}
.empty-description {
font-size: 13px;
}
}
</style>

View File

@@ -1,5 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import pinia from './stores'
@@ -8,6 +10,7 @@ import { useI18nStore } from './stores/modules/i18n'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.use(i18n)

View File

@@ -1,399 +1,147 @@
<script setup>
import { ref } from 'vue'
import { ref, reactive, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from '@/hooks/useI18n'
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import LanguageSwitcher from '@/layouts/components/LanguageSwitcher.vue'
import '@/assets/css/auth.css'
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const loginForm = ref({
username: '',
password: '',
})
const loginFormRef = ref(null)
const loading = ref(false)
const handleLogin = () => {
loading.value = true
const loginForm = reactive({
username: '',
password: '',
remember: false
})
// 模拟登录请求
setTimeout(() => {
console.log('登录信息:', loginForm.value)
loading.value = false
// 这里可以添加实际的登录逻辑
alert(t('login.loginSuccess') + '(模拟)')
}, 1000)
const loginRules = {
username: [
{ required: true, message: t('login.usernamePlaceholder'), trigger: 'blur' },
{ min: 3, max: 20, message: t('form.minLength', { min: 3, max: 20 }), trigger: 'blur' }
],
password: [
{ required: true, message: t('login.passwordPlaceholder'), trigger: 'blur' },
{ min: 6, max: 20, message: t('form.minLength', { min: 6 }), trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
// 模拟登录请求
await new Promise((resolve) => setTimeout(resolve, 1000))
// 模拟登录成功
const mockUserInfo = {
id: 1,
username: loginForm.username,
nickname: '管理员',
email: 'admin@example.com',
role: ['admin'],
avatar: ''
}
const mockToken = 'mock-token-' + Date.now()
const mockRefreshToken = 'mock-refresh-token-' + Date.now()
userStore.setToken(mockToken)
userStore.setRefreshToken(mockRefreshToken)
userStore.setUserInfo(mockUserInfo)
ElMessage.success(t('login.loginSuccess'))
// 跳转到首页或重定向页面
const redirect = router.currentRoute.value.query.redirect || '/'
router.push(redirect)
} catch (error) {
ElMessage.error(error.message || t('login.loginFailed'))
} finally {
loading.value = false
}
}
})
}
const goToRegister = () => {
router.push('/register')
}
const goToResetPassword = () => {
router.push('/reset-password')
}
onBeforeUnmount(() => {
if (loginFormRef.value) {
loginFormRef.value.clearValidate()
}
})
</script>
<template>
<div class="login-container">
<div class="language-switcher-wrapper">
<div class="auth-container">
<div class="language-switcher">
<LanguageSwitcher />
</div>
<div class="login-card">
<div class="login-header">
<h2>{{ t('login.title') }}</h2>
<div class="auth-card">
<div class="auth-header">
<div class="logo">
<el-icon :size="48">
<User />
</el-icon>
</div>
<h1>{{ t('login.title') }}</h1>
<p>{{ t('login.subtitle') }}</p>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username">{{ t('common.username') }}</label>
<input id="username" v-model="loginForm.username" type="text"
:placeholder="t('login.usernamePlaceholder')" required />
</div>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="auth-form" size="large">
<el-form-item prop="username">
<el-input v-model="loginForm.username" :placeholder="t('login.usernamePlaceholder')"
:prefix-icon="User" />
</el-form-item>
<div class="form-group">
<label for="password">{{ t('common.password') }}</label>
<input id="password" v-model="loginForm.password" type="password"
:placeholder="t('login.passwordPlaceholder')" required />
</div>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" :placeholder="t('login.passwordPlaceholder')"
:prefix-icon="Lock" show-password @keyup.enter="handleLogin" />
</el-form-item>
<div class="form-options">
<label class="remember-me">
<input type="checkbox" />
<span>{{ t('common.rememberMe') }}</span>
</label>
<a href="#" class="forgot-password">{{ t('common.forgotPassword') }}</a>
</div>
<el-form-item>
<el-checkbox v-model="loginForm.remember">
{{ t('login.rememberMe') }}
</el-checkbox>
</el-form-item>
<button type="submit" class="login-btn" :disabled="loading">
<span v-if="!loading">{{ t('login.loginButton') }}</span>
<span v-else>{{ t('common.loading') }}</span>
</button>
</form>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleLogin">
{{ t('login.loginButton') }}
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>{{ t('login.noAccount') }} <a href="#">{{ t('login.registerNow') }}</a></p>
<div class="auth-actions">
<el-link type="primary" @click="goToResetPassword">
{{ t('login.forgotPassword') }}
</el-link>
</div>
<div class="auth-footer">
<span class="text">{{ t('login.noAccount') }}</span>
<router-link to="/register" class="link">
{{ t('login.registerNow') }}
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0c1929 0%, #1a237e 50%, #0d47a1 100%);
padding: 20px;
position: relative;
overflow: hidden;
}
.language-switcher-wrapper {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
/* 网格背景 */
.login-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
/* 科技光效 */
.login-container::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(0, 212, 255, 0.1) 0%, transparent 50%);
animation: rotate 30s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.login-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
border-radius: 20px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
0 0 40px rgba(0, 212, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
max-width: 420px;
overflow: hidden;
position: relative;
z-index: 1;
animation: fadeInUp 0.8s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
padding: 40px 40px 20px;
position: relative;
}
.login-header::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 4px;
background: linear-gradient(90deg, transparent, #00d4ff, #7c4dff, transparent);
border-radius: 2px;
animation: lineGlow 2s ease-in-out infinite;
}
@keyframes lineGlow {
0%,
100% {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
50% {
box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(124, 77, 255, 0.5);
}
}
.login-header h2 {
margin: 0 0 10px;
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
letter-spacing: 1px;
}
.login-header p {
margin: 0;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 300;
}
.login-form {
padding: 20px 40px 40px;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
letter-spacing: 0.5px;
}
.form-group input {
width: 100%;
padding: 14px 20px;
border: 2px solid rgba(0, 212, 255, 0.2);
border-radius: 12px;
font-size: 14px;
transition: all 0.3s ease;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.05);
color: white;
backdrop-filter: blur(10px);
}
.form-group input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 20px rgba(0, 212, 255, 0.3), inset 0 0 10px rgba(0, 212, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
}
.form-group input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.form-group input:focus::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.remember-me {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.3s ease;
}
.remember-me:hover {
color: rgba(255, 255, 255, 0.9);
}
.remember-me input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #00d4ff;
transition: all 0.3s ease;
}
.remember-me input[type='checkbox']:checked {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
.forgot-password {
font-size: 14px;
color: #00d4ff;
text-decoration: none;
transition: all 0.3s ease;
font-weight: 500;
}
.forgot-password:hover {
color: #7c4dff;
text-shadow: 0 0 10px rgba(124, 77, 255, 0.5);
}
.login-btn {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
letter-spacing: 1px;
}
.login-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.5s ease;
}
.login-btn:hover:not(:disabled)::before {
left: 100%;
}
.login-btn:hover:not(:disabled) {
transform: translateY(-3px);
box-shadow:
0 10px 30px rgba(0, 212, 255, 0.4),
0 0 20px rgba(124, 77, 255, 0.3);
}
.login-btn:active:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(0, 212, 255, 0.4);
}
.login-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-footer {
text-align: center;
padding: 20px 40px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.login-footer p {
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
.login-footer a {
color: #00d4ff;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
}
.login-footer a:hover {
color: #7c4dff;
text-shadow: 0 0 10px rgba(124, 77, 255, 0.5);
}
@media (max-width: 480px) {
.login-card {
max-width: 100%;
}
.login-header,
.login-form,
.login-footer {
padding-left: 20px;
padding-right: 20px;
}
.login-header h2 {
font-size: 24px;
}
}
</style>

View File

@@ -0,0 +1,196 @@
<script setup>
import { ref, reactive, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from '@/hooks/useI18n'
import { ElMessage } from 'element-plus'
import { Lock, Message } from '@element-plus/icons-vue'
import LanguageSwitcher from '@/layouts/components/LanguageSwitcher.vue'
import '@/assets/css/auth.css'
const { t } = useI18n()
const router = useRouter()
const resetFormRef = ref(null)
const loading = ref(false)
const countdown = ref(0)
const timer = ref(null)
const resetForm = reactive({
email: '',
code: '',
newPassword: '',
confirmPassword: ''
})
const validatePass2 = (rule, value, callback) => {
if (value === '') {
callback(new Error(t('form.passwordMismatch')))
} else if (value !== resetForm.newPassword) {
callback(new Error(t('form.passwordMismatch')))
} else {
callback()
}
}
const resetRules = {
email: [
{ required: true, message: t('resetPassword.emailPlaceholder'), trigger: 'blur' },
{ type: 'email', message: t('resetPassword.emailRule'), trigger: 'blur' }
],
code: [
{ required: true, message: t('resetPassword.codePlaceholder'), trigger: 'blur' },
{ len: 6, message: t('resetPassword.codeRule'), trigger: 'blur' }
],
newPassword: [
{ required: true, message: t('resetPassword.newPasswordPlaceholder'), trigger: 'blur' },
{ min: 6, max: 20, message: t('resetPassword.passwordRule'), trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validatePass2, trigger: 'blur' }
]
}
const sendCode = () => {
if (!resetForm.email) {
ElMessage.warning(t('resetPassword.sendCodeFirst'))
return
}
if (!/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(resetForm.email)) {
ElMessage.warning(t('resetPassword.emailRule'))
return
}
// 模拟发送验证码
countdown.value = 60
timer.value = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer.value)
timer.value = null
}
}, 1000)
ElMessage.success(t('resetPassword.codeSent'))
}
const handleReset = async () => {
if (!resetFormRef.value) return
await resetFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
// 模拟重置密码请求
await new Promise((resolve) => setTimeout(resolve, 1500))
ElMessage.success(t('resetPassword.resetSuccess'))
// 清除定时器
if (timer.value) {
clearInterval(timer.value)
timer.value = null
countdown.value = 0
}
// 跳转到登录页
router.push('/login')
} catch (error) {
ElMessage.error(error.message || t('resetPassword.resetFailed'))
} finally {
loading.value = false
}
}
})
}
const goToLogin = () => {
// 清除定时器
if (timer.value) {
clearInterval(timer.value)
timer.value = null
countdown.value = 0
}
router.push('/login')
}
onBeforeUnmount(() => {
if (resetFormRef.value) {
resetFormRef.value.clearValidate()
}
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
</script>
<template>
<div class="auth-container">
<div class="language-switcher">
<LanguageSwitcher />
</div>
<div class="auth-card">
<div class="auth-header">
<div class="logo">
<el-icon :size="48">
<Lock />
</el-icon>
</div>
<h1>{{ t('resetPassword.title') }}</h1>
<p>{{ t('resetPassword.subtitle') }}</p>
</div>
<el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" class="auth-form" size="large">
<el-form-item prop="email">
<el-input v-model="resetForm.email" :placeholder="t('resetPassword.emailPlaceholder')"
:prefix-icon="Message" />
</el-form-item>
<el-form-item prop="code">
<el-row :gutter="10">
<el-col :span="14">
<el-input v-model="resetForm.code" :placeholder="t('resetPassword.codePlaceholder')"
:prefix-icon="Lock" maxlength="6" />
</el-col>
<el-col :span="10">
<el-button class="code-button" :disabled="countdown > 0" @click="sendCode">
{{
countdown > 0
? t('resetPassword.resendCode', { seconds: countdown })
: t('resetPassword.sendCode')
}}
</el-button>
</el-col>
</el-row>
</el-form-item>
<el-form-item prop="newPassword">
<el-input v-model="resetForm.newPassword" type="password"
:placeholder="t('resetPassword.newPasswordPlaceholder')" :prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="resetForm.confirmPassword" type="password"
:placeholder="t('resetPassword.confirmPasswordPlaceholder')" :prefix-icon="Lock" show-password
@keyup.enter="handleReset" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleReset">
{{ t('resetPassword.resetButton') }}
</el-button>
</el-form-item>
</el-form>
<div class="auth-footer">
<span class="text">{{ t('common.back') }}</span>
<el-link type="primary" @click="goToLogin">
{{ t('resetPassword.backToLogin') }}
</el-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,162 @@
<script setup>
import { ref, reactive, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from '@/hooks/useI18n'
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'
import { User, Lock, Message } from '@element-plus/icons-vue'
import LanguageSwitcher from '@/layouts/components/LanguageSwitcher.vue'
import '@/assets/css/auth.css'
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const registerFormRef = ref(null)
const loading = ref(false)
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
agree: false
})
const validatePass2 = (rule, value, callback) => {
if (value === '') {
callback(new Error(t('form.passwordMismatch')))
} else if (value !== registerForm.password) {
callback(new Error(t('form.passwordMismatch')))
} else {
callback()
}
}
const registerRules = {
username: [
{ required: true, message: t('register.usernamePlaceholder'), trigger: 'blur' },
{ min: 3, max: 20, message: t('register.usernameRule'), trigger: 'blur' }
],
email: [
{ required: true, message: t('register.emailPlaceholder'), trigger: 'blur' },
{ type: 'email', message: t('register.emailRule'), trigger: 'blur' }
],
password: [
{ required: true, message: t('register.passwordPlaceholder'), trigger: 'blur' },
{ min: 6, max: 20, message: t('register.passwordRule'), trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validatePass2, trigger: 'blur' }
],
agree: [
{
validator: (rule, value, callback) => {
if (!value) {
callback(new Error(t('register.agreeRule')))
} else {
callback()
}
},
trigger: 'change'
}
]
}
const handleRegister = async () => {
if (!registerFormRef.value) return
await registerFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
// 模拟注册请求
await new Promise((resolve) => setTimeout(resolve, 1500))
ElMessage.success(t('register.registerSuccess'))
// 跳转到登录页
router.push('/login')
} catch (error) {
ElMessage.error(error.message || t('register.registerFailed'))
} finally {
loading.value = false
}
}
})
}
const goToLogin = () => {
router.push('/login')
}
onBeforeUnmount(() => {
if (registerFormRef.value) {
registerFormRef.value.clearValidate()
}
})
</script>
<template>
<div class="auth-container">
<div class="language-switcher">
<LanguageSwitcher />
</div>
<div class="auth-card">
<div class="auth-header">
<div class="logo">
<el-icon :size="48">
<User />
</el-icon>
</div>
<h1>{{ t('register.title') }}</h1>
<p>{{ t('register.subtitle') }}</p>
</div>
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" class="auth-form" size="large">
<el-form-item prop="username">
<el-input v-model="registerForm.username" :placeholder="t('register.usernamePlaceholder')"
:prefix-icon="User" />
</el-form-item>
<el-form-item prop="email">
<el-input v-model="registerForm.email" :placeholder="t('register.emailPlaceholder')"
:prefix-icon="Message" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="registerForm.password" type="password"
:placeholder="t('register.passwordPlaceholder')" :prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="registerForm.confirmPassword" type="password"
:placeholder="t('register.confirmPasswordPlaceholder')" :prefix-icon="Lock" show-password
@keyup.enter="handleRegister" />
</el-form-item>
<el-form-item prop="agree">
<el-checkbox v-model="registerForm.agree">
<span v-html="t('register.agreeTerms')"></span>
<el-link type="primary">{{ t('register.terms') }}</el-link>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleRegister">
{{ t('register.registerButton') }}
</el-button>
</el-form-item>
</el-form>
<div class="auth-footer">
<span class="text">{{ t('register.hasAccount') }}</span>
<router-link to="/login" class="link">
{{ t('register.loginNow') }}
</router-link>
</div>
</div>
</div>
</template>

View File

@@ -1,57 +1,316 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import Layout from '@/layouts/index.vue'
import { ElNotification } from 'element-plus'
import systemRouter from './systemRouter'
import userRoutes from '@/config/routes'
import NProgress from 'nprogress'
import tool from '@/utils/tool'
import i18n from '@/i18n'
import { beforeEach, afterEach } from './scrollBehavior'
import '@/assets/css/nprogress.css'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/pages/login/index.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
name: 'Dashboard',
component: Layout,
redirect: '/home',
meta: { requiresAuth: true },
children: [
{
path: '/home',
name: 'Home',
component: () => import('@/pages/home/Dashboard.vue'),
meta: { requiresAuth: true },
},
],
},
]
// 配置 NProgress
NProgress.configure({
easing: 'ease',
speed: 500,
showSpinner: false,
trickleSpeed: 200,
minimum: 0.3
})
// 匹配pages里面所有的.vue文件
const modules = import.meta.glob('@/pages/**/*.vue')
// 特殊路由模块
const otherModules = {
'404': () => import('@/layouts/other/404.vue'),
empty: () => import('@/layouts/other/empty.vue')
}
// 系统路由
const routes = systemRouter
// 是否已加载过动态/静态路由
let isGetRouter = false
// 404路由移除函数
let routes_404_r = null
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes,
routes: routes
})
/**
* 设置页面标题
* @param {Object} meta - 路由元信息
*/
const setPageTitle = (meta) => {
const title = 'VueAdmin'
if (meta?.title) {
try {
const translatedTitle = i18n.global.te(meta.title) ? i18n.global.t(meta.title) : meta.title
document.title = `${translatedTitle} - ${title}`
} catch (error) {
document.title = `${meta.title} - ${title}`
}
} else {
document.title = title
}
}
/**
* 检查路由是否需要认证
* @param {Object} to - 目标路由
* @returns {boolean}
*/
const checkAuthRequired = (to) => {
return to.matched.some((record) => record.meta.requiresAuth !== false)
}
/**
* 移除404路由
*/
const remove404Route = () => {
if (routes_404_r) {
router.removeRoute('404')
routes_404_r = null
}
}
/**
* 添加404路由
*/
const add404Route = () => {
if (!routes_404_r) {
routes_404_r = router.addRoute({
path: '/:pathMatch(.*)*',
name: '404',
hidden: true,
component: otherModules['404']
})
}
}
/**
* 加载动态路由
* @param {Object} to - 目标路由对象
* @returns {boolean} 是否加载成功
*/
const loadDynamicRoutes = async (to) => {
try {
// 从 store 获取菜单和用户信息
const userStore = useUserStore()
const apiMenu = userStore.getMenu() || []
const userInfo = userStore.userInfo
// 如果没有用户信息,不做处理
if (!userInfo) {
return false
}
// 根据用户角色过滤静态路由
const userMenu = treeFilter(userRoutes, (node) => {
return node.meta.role
? node.meta.role.some((item) => userInfo.role?.includes(item))
: true
})
// 合并静态路由和API菜单
const menu = [...userMenu, ...apiMenu]
// 转换异步路由
const menuRouter = filterAsyncRouter(menu)
// 将树形路由转平铺
const flatMenuRouter = tool.tree_to_list(menuRouter)
// 添加所有路由
flatMenuRouter.forEach((item) => {
router.addRoute('layout', item)
})
// 添加404路由必须在所有路由之后
add404Route()
isGetRouter = true
// 检查目标路由是否存在
const hasRoute = router.hasRoute(to.name) || to.matched.length > 0
if (!hasRoute && to.name !== '404') {
return false
}
return true
} catch (error) {
console.error('加载动态路由失败:', error)
return false
}
}
// 路由拦截器 - 全局前置守卫
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
// 开始进度条
NProgress.start()
// 设置页面标题
setPageTitle(to.meta)
const userStore = useUserStore()
const isLoggedIn = userStore.isLoggedIn()
const requiresAuth = checkAuthRequired(to)
// 检查路由是否需要认证
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth !== false)
// 处理404页面
if (to.name === '404') {
next()
return
}
// 处理登录页
if (to.path === '/login') {
isGetRouter = false
remove404Route()
next()
return
}
// 如果是系统路由,直接放行
if (routes.some((r) => r.path === to.path)) {
next()
return
}
// 需要认证但未登录,重定向到登录页
if (requiresAuth && !isLoggedIn) {
// 需要认证但未登录,重定向到登录页
NProgress.done()
isGetRouter = false
userStore.clearMenu()
next({
path: '/login',
query: { redirect: to.fullPath }, // 保存目标路径,登录后可以跳转回去
query: { redirect: to.fullPath }
})
} else if (to.path === '/login' && isLoggedIn) {
// 已登录但访问登录页,重定向到首页
next({ path: '/' })
} else {
// 其他情况正常跳转
next()
return
}
// 整页路由处理
if (to.meta.fullpage) {
to.matched = [to.matched[to.matched.length - 1]]
}
// 调用前置守卫
beforeEach(to, from)
// 加载动态/静态路由
if (!isGetRouter) {
const loadSuccess = await loadDynamicRoutes(to)
// 如果路由加载失败跳转到404
if (!loadSuccess) {
next({ name: '404', replace: true })
return
}
}
// 检查路由是否存在(动态路由已加载后)
if (isGetRouter) {
const hasRoute = router.hasRoute(to.name)
// 如果路由不存在且不是404页面跳转到404
if (!hasRoute && to.name !== '404' && to.matched.length === 0) {
next({ name: '404', replace: true })
return
}
}
next()
})
// 全局后置钩子
router.afterEach((to, from) => {
// 调用后置钩子
afterEach(to, from)
// 结束进度条
NProgress.done()
})
// 路由错误处理
router.onError((error) => {
NProgress.done()
ElNotification.error({
title: '路由错误',
message: error.message
})
})
/**
* 动态加载组件
* @param {string} component - 组件路径
* @returns {Function}
*/
function loadComponent(component) {
if (!component) {
return otherModules.empty
}
for (const path in modules) {
const dir = path.split('pages/')[1]?.split('.vue')[0]
if (dir === component || dir === `${component}/index`) {
return () => modules[path]()
}
}
return otherModules.empty
}
/**
* 转换异步路由
* @param {Array} routerMap - 路由映射
* @returns {Array} 转换后的路由数组
*/
function filterAsyncRouter(routerMap) {
if (!routerMap || !Array.isArray(routerMap)) {
return []
}
return routerMap.map((item) => {
const meta = item.meta || {}
// 处理外部链接特殊路由
if (meta.type === 'iframe') {
meta.url = item.path
item.path = `/i/${item.name}`
}
return {
path: item.path,
name: item.name,
meta: meta,
redirect: item.redirect,
children: item.children ? filterAsyncRouter(item.children) : undefined,
component: loadComponent(item.component)
}
})
}
/**
* 过滤树结构
* @param {Array} tree - 树结构数据
* @param {Function} func - 过滤函数
* @returns {Array} 过滤后的树结构
*/
function treeFilter(tree, func) {
if (!tree || !Array.isArray(tree)) {
return []
}
return tree
.map((node) => ({ ...node }))
.filter((node) => {
node.children = node.children ? treeFilter(node.children, func) : undefined
return func(node) || (node.children && node.children.length > 0)
})
}
export default router

View File

@@ -0,0 +1,30 @@
import { useLayoutStore } from '@/stores/modules/layout'
import { nextTick } from 'vue'
export function beforeEach(to, from) {
const adminMain = document.querySelector('#adminui-main')
if (!adminMain) {
return false
}
const layoutStore = useLayoutStore()
layoutStore.updateViewTags({
fullPath: from.fullPath,
scrollTop: adminMain.scrollTop
})
}
export function afterEach(to) {
const adminMain = document.querySelector('#adminui-main')
if (!adminMain) {
return false
}
nextTick(() => {
const layoutStore = useLayoutStore()
const beforeRoute = layoutStore.viewTags.find((v) => v.fullPath === to.fullPath)
if (beforeRoute) {
adminMain.scrollTop = beforeRoute.scrollTop || 0
}
})
}

View File

@@ -0,0 +1,36 @@
import config from "@/config";
//系统路由
const routes = [
{
name: "layout",
path: "/",
component: () => import("@/layouts/index.vue"),
redirect: config.DASHBOARD_URL || "/dashboard",
children: [],
},
{
path: "/login",
component: () =>
import("@/pages/login/index.vue"),
meta: {
title: "登录",
},
},
{
path: "/register",
component: () => import("@/pages/login/userRegister.vue"),
meta: {
title: "注册",
},
},
{
path: "/reset-password",
component: () => import("@/pages/login/resetPassword.vue"),
meta: {
title: "重置密码",
},
},
];
export default routes;

View File

@@ -8,6 +8,9 @@ export const useLayoutStore = defineStore('layout', () => {
// 侧边栏折叠状态
const sidebarCollapsed = ref(false)
// 视图标签页(用于记录页面滚动位置)
const viewTags = ref([])
// 切换侧边栏折叠
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
@@ -18,10 +21,37 @@ export const useLayoutStore = defineStore('layout', () => {
layoutMode.value = mode
}
// 更新视图标签
const updateViewTags = (tag) => {
const index = viewTags.value.findIndex((item) => item.fullPath === tag.fullPath)
if (index !== -1) {
viewTags.value[index] = tag
} else {
viewTags.value.push(tag)
}
}
// 移除视图标签
const removeViewTags = (fullPath) => {
const index = viewTags.value.findIndex((item) => item.fullPath === fullPath)
if (index !== -1) {
viewTags.value.splice(index, 1)
}
}
// 清空视图标签
const clearViewTags = () => {
viewTags.value = []
}
return {
layoutMode,
sidebarCollapsed,
viewTags,
toggleSidebar,
setLayoutMode,
updateViewTags,
removeViewTags,
clearViewTags,
}
})

View File

@@ -5,6 +5,7 @@ export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const refreshToken = ref(localStorage.getItem('refreshToken') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const menu = ref(JSON.parse(localStorage.getItem('MENU') || '[]'))
// 设置 token
function setToken(newToken) {
@@ -24,14 +25,33 @@ export const useUserStore = defineStore('user', () => {
localStorage.setItem('userInfo', JSON.stringify(info))
}
// 设置菜单
function setMenu(newMenu) {
menu.value = newMenu
localStorage.setItem('MENU', JSON.stringify(newMenu))
}
// 获取菜单
function getMenu() {
return menu.value
}
// 清除菜单
function clearMenu() {
menu.value = []
localStorage.removeItem('MENU')
}
// 登出
function logout() {
token.value = ''
refreshToken.value = ''
userInfo.value = null
menu.value = []
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('userInfo')
localStorage.removeItem('MENU')
}
// 检查是否已登录
@@ -43,9 +63,13 @@ export const useUserStore = defineStore('user', () => {
token,
refreshToken,
userInfo,
menu,
setToken,
setRefreshToken,
setUserInfo,
setMenu,
getMenu,
clearMenu,
logout,
isLoggedIn,
}

View File

@@ -132,7 +132,8 @@ async function refreshToken() {
// 这里需要根据实际的刷新 token 接口进行修改
// 假设刷新接口是 /auth/refresh
const refreshUrl = config.baseURL + '/auth/refresh'
const refreshTokenValue = localStorage.getItem('refreshToken')
const userStore = useUserStore()
const refreshTokenValue = userStore.refreshToken
const response = await axios.post(refreshUrl, {
refreshToken: refreshTokenValue

180
src/utils/tool.js Normal file
View File

@@ -0,0 +1,180 @@
/**
* 工具类
*/
const tool = {
/**
* 本地存储操作
*/
data: {
/**
* 设置本地存储
* @param {string} key - 键名
* @param {*} value - 值
*/
set(key, value) {
if (typeof value === 'object') {
localStorage.setItem(key, JSON.stringify(value))
} else {
localStorage.setItem(key, value)
}
},
/**
* 获取本地存储
* @param {string} key - 键名
* @param {*} defaultValue - 默认值
* @returns {*}
*/
get(key, defaultValue = null) {
const value = localStorage.getItem(key)
if (!value) {
return defaultValue
}
try {
return JSON.parse(value)
} catch (e) {
return value
}
},
/**
* 删除本地存储
* @param {string} key - 键名
*/
remove(key) {
localStorage.removeItem(key)
},
/**
* 清空本地存储
*/
clear() {
localStorage.clear()
}
},
/**
* 树形结构转列表
* @param {Array} tree - 树形结构数据
* @param {string} childrenKey - 子节点键名
* @returns {Array} 扁平化后的数组
*/
tree_to_list(tree, childrenKey = 'children') {
if (!tree || !Array.isArray(tree)) {
return []
}
const result = []
const traverse = (nodes) => {
if (!nodes || !Array.isArray(nodes)) {
return
}
nodes.forEach((node) => {
result.push(node)
if (node[childrenKey] && node[childrenKey].length > 0) {
traverse(node[childrenKey])
}
})
}
traverse(tree)
return result
},
/**
* 列表转树形结构
* @param {Array} list - 列表数据
* @param {string} idKey - ID键名
* @param {string} parentIdKey - 父ID键名
* @param {string} childrenKey - 子节点键名
* @returns {Array} 树形结构数据
*/
list_to_tree(list, idKey = 'id', parentIdKey = 'parentId', childrenKey = 'children') {
if (!list || !Array.isArray(list)) {
return []
}
const map = {}
const roots = []
// 创建映射表
list.forEach((item) => {
map[item[idKey]] = { ...item, [childrenKey]: [] }
})
// 构建树形结构
list.forEach((item) => {
const node = map[item[idKey]]
const parentId = item[parentIdKey]
if (parentId && map[parentId]) {
map[parentId][childrenKey].push(node)
} else {
roots.push(node)
}
})
return roots
},
/**
* 深拷贝
* @param {*} obj - 要拷贝的对象
* @returns {*} 拷贝后的对象
*/
deepClone(obj) {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map((item) => this.deepClone(item))
}
const cloned = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = this.deepClone(obj[key])
}
}
return cloned
},
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} wait - 等待时间
* @returns {Function}
*/
debounce(func, wait = 300) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => {
func.apply(this, args)
}, wait)
}
},
/**
* 节流函数
* @param {Function} func - 要节流的函数
* @param {number} wait - 等待时间
* @returns {Function}
*/
throttle(func, wait = 300) {
let timeout
return function (...args) {
if (!timeout) {
timeout = setTimeout(() => {
func.apply(this, args)
timeout = null
}, wait)
}
}
}
}
export default tool

134
yarn.lock
View File

@@ -253,6 +253,16 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
"@ctrl/tinycolor@^3.4.1":
version "3.6.1"
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31"
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
"@element-plus/icons-vue@^2.3.2":
version "2.3.2"
resolved "https://registry.yarnpkg.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz#7e9cb231fb738b2056f33e22c3a29e214b538dcf"
integrity sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==
"@esbuild/aix-ppc64@0.27.2":
version "0.27.2"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c"
@@ -451,6 +461,26 @@
"@eslint/core" "^0.17.0"
levn "^0.4.1"
"@floating-ui/core@^1.7.3":
version "1.7.3"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7"
integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==
dependencies:
"@floating-ui/utils" "^0.2.10"
"@floating-ui/dom@^1.0.1":
version "1.7.4"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.4.tgz#ee667549998745c9c3e3e84683b909c31d6c9a77"
integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==
dependencies:
"@floating-ui/core" "^1.7.3"
"@floating-ui/utils" "^0.2.10"
"@floating-ui/utils@^0.2.10":
version "0.2.10"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
"@humanfs/core@^0.19.1":
version "0.19.1"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77"
@@ -539,6 +569,11 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
"@popperjs/core@npm:@sxzz/popperjs-es@^2.11.7":
version "2.11.7"
resolved "https://registry.yarnpkg.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz#a7f69e3665d3da9b115f9e71671dae1b97e13671"
integrity sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==
"@rolldown/pluginutils@1.0.0-beta.53":
version "1.0.0-beta.53"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz#c57a5234ae122671aff6fe72e673a7ed90f03f87"
@@ -679,6 +714,23 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/lodash-es@^4.17.12":
version "4.17.12"
resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
dependencies:
"@types/lodash" "*"
"@types/lodash@*", "@types/lodash@^4.17.20":
version "4.17.23"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.23.tgz#c1bb06db218acc8fc232da0447473fc2fb9d9841"
integrity sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==
"@types/web-bluetooth@^0.0.20":
version "0.0.20"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
"@vitejs/plugin-vue@^6.0.3":
version "6.0.3"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz#b857c5dcbc5cfb30bf5d7f9d6e274afcca2d46d1"
@@ -869,6 +921,28 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.26.tgz#1e02ef2d64aced818cd31d81ce5175711dc90a9f"
integrity sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==
"@vueuse/core@^10.11.0":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6"
integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==
dependencies:
"@types/web-bluetooth" "^0.0.20"
"@vueuse/metadata" "10.11.1"
"@vueuse/shared" "10.11.1"
vue-demi ">=0.14.8"
"@vueuse/metadata@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==
"@vueuse/shared@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.11.1.tgz#62b84e3118ae6e1f3ff38f4fbe71b0c5d0f10938"
integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==
dependencies:
vue-demi ">=0.14.8"
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -906,6 +980,11 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
async-validator@^4.2.5:
version "4.2.5"
resolved "https://registry.yarnpkg.com/async-validator/-/async-validator-4.2.5.tgz#c96ea3332a521699d0afaaceed510a54656c6339"
integrity sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
@@ -1047,6 +1126,11 @@ csstype@^3.2.3:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
dayjs@^1.11.19:
version "1.11.19"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938"
integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.1:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
@@ -1096,6 +1180,26 @@ electron-to-chromium@^1.5.263:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7"
integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
element-plus@^2.13.1:
version "2.13.1"
resolved "https://registry.yarnpkg.com/element-plus/-/element-plus-2.13.1.tgz#2cc6059da0f0f217f27d657f5140a45ecb0fd221"
integrity sha512-eG4BDBGdAsUGN6URH1PixzZb0ngdapLivIk1meghS1uEueLvQ3aljSKrCt5x6sYb6mUk8eGtzTQFgsPmLavQcA==
dependencies:
"@ctrl/tinycolor" "^3.4.1"
"@element-plus/icons-vue" "^2.3.2"
"@floating-ui/dom" "^1.0.1"
"@popperjs/core" "npm:@sxzz/popperjs-es@^2.11.7"
"@types/lodash" "^4.17.20"
"@types/lodash-es" "^4.17.12"
"@vueuse/core" "^10.11.0"
async-validator "^4.2.5"
dayjs "^1.11.19"
lodash "^4.17.21"
lodash-es "^4.17.21"
lodash-unified "^1.0.3"
memoize-one "^6.0.0"
normalize-wheel-es "^1.2.0"
entities@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.0.tgz#2ae4e443f3f17d152d3f5b0f79b932c1e59deb7a"
@@ -1578,11 +1682,26 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash-es@^4.17.21:
version "4.17.22"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.22.tgz#eb7d123ec2470d69b911abe34f85cb694849b346"
integrity sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==
lodash-unified@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/lodash-unified/-/lodash-unified-1.0.3.tgz#80b1eac10ed2eb02ed189f08614a29c27d07c894"
integrity sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -1602,6 +1721,11 @@ math-intrinsics@^1.1.0:
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
memoize-one@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
@@ -1656,6 +1780,11 @@ node-releases@^2.0.27:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
normalize-wheel-es@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz#0fa2593d619f7245a541652619105ab076acf09e"
integrity sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==
nprogress@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1"
@@ -2035,6 +2164,11 @@ vite@^7.3.0:
optionalDependencies:
fsevents "~2.3.3"
vue-demi@>=0.14.8:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-i18n@^11.2.8:
version "11.2.8"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-11.2.8.tgz#f431b583134776dcf59e59250c5231e4eaed8404"