feat(web): 深度定制 Element Plus 主题 — 靛蓝主色/暗色模式/侧栏折叠/面包屑/登录页重绘

- 新增 styles/theme.css:CSS 变量覆盖默认主题(主色 #4F46E5、圆角、阴影、页面背景),html.dark 暗色变量
- 新增 stores/theme.ts:暗色/亮色切换,localStorage 持久化
- main.ts 引入 element-plus 暗色 css-vars 与主题文件,挂载前初始化主题
- index.html 加 SVG favicon 与防暗色闪烁内联脚本
- AdminLayout:深色可折叠侧栏、header 面包屑 + 暗色切换按钮、页面切换过渡
- UserLayout:header 打磨 + 暗色切换按钮
- LoginView/ApplyView:统一品牌渐变背景与卡片,登录页 OTP 输入行内联发送按钮、登录按钮唯一通栏主按钮(修复按钮错位)
This commit is contained in:
2026-08-30 11:35:44 +08:00
parent b305d7b371
commit 472c66ebd8
9 changed files with 2634 additions and 116 deletions
+15
View File
@@ -3,7 +3,22 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234f46e5'/%3E%3Crect x='7' y='7' width='18' height='13' rx='2' fill='none' stroke='white' stroke-width='2.4'/%3E%3Cline x1='9' y1='13' x2='23' y2='13' stroke='white' stroke-width='2'/%3E%3Cline x1='14' y1='20' x2='14' y2='24' stroke='white' stroke-width='2.4'/%3E%3Cline x1='18' y1='20' x2='18' y2='24' stroke='white' stroke-width='2.4'/%3E%3Cline x1='11' y1='24' x2='21' y2='24' stroke='white' stroke-width='2.4'/%3E%3C/svg%3E"
/>
<title>ws_usernode 用户管理</title> <title>ws_usernode 用户管理</title>
<script>
// 在渲染前恢复主题,避免暗色模式闪白
;(function () {
try {
var t = localStorage.getItem('ws-usernode-theme')
if (t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
}
} catch (e) {}
})()
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+2131
View File
File diff suppressed because it is too large Load Diff
+128 -31
View File
@@ -2,14 +2,18 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const theme = useThemeStore()
const collapsed = ref(false)
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
const username = computed(() => auth.me?.username ?? '') const username = computed(() => auth.me?.username ?? '')
const email = computed(() => auth.me?.email ?? '') const email = computed(() => auth.me?.email ?? '')
const pageTitle = computed(() => (route.meta.title as string) ?? '')
async function onLogout() { async function onLogout() {
await auth.logout() await auth.logout()
@@ -19,12 +23,23 @@ async function onLogout() {
<template> <template>
<el-container class="admin-layout"> <el-container class="admin-layout">
<el-aside width="220px" class="aside"> <el-aside :width="collapsed ? '64px' : '220px'" class="aside">
<div class="logo"> <div class="logo">
<el-icon><Monitor /></el-icon> <span class="logo-icon">
<span>用户管理节点</span> <el-icon :size="20"><Monitor /></el-icon>
</span>
<span v-show="!collapsed" class="logo-text">用户管理节点</span>
</div> </div>
<el-menu :default-active="activeMenu" router class="menu"> <el-menu
:default-active="activeMenu"
:collapse="collapsed"
:collapse-transition="false"
router
class="menu"
background-color="transparent"
text-color="rgba(255,255,255,0.72)"
active-text-color="#ffffff"
>
<el-menu-item index="/admin/dashboard"> <el-menu-item index="/admin/dashboard">
<el-icon><Odometer /></el-icon><span>仪表盘</span> <el-icon><Odometer /></el-icon><span>仪表盘</span>
</el-menu-item> </el-menu-item>
@@ -42,25 +57,49 @@ async function onLogout() {
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container>
<el-container class="right">
<el-header class="header"> <el-header class="header">
<span class="page-title">{{ route.meta.title ?? '' }}</span> <div class="header-left">
<el-dropdown @command="onLogout"> <el-button link class="icon-btn" @click="collapsed = !collapsed">
<span class="user-chip"> <el-icon :size="18"><Expand v-if="collapsed" /><Fold v-else /></el-icon>
<el-avatar :size="28" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar> </el-button>
<span>{{ username }}</span> <el-breadcrumb separator="/">
<el-icon><ArrowDown /></el-icon> <el-breadcrumb-item :to="{ path: '/admin/dashboard' }">
</span> <el-icon class="crumb-home"><HomeFilled /></el-icon>
<template #dropdown> <span>首页</span>
<el-dropdown-menu> </el-breadcrumb-item>
<el-dropdown-item disabled>{{ email }}</el-dropdown-item> <el-breadcrumb-item v-if="pageTitle && pageTitle !== '仪表盘'">{{ pageTitle }}</el-breadcrumb-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item> </el-breadcrumb>
</el-dropdown-menu> </div>
</template> <div class="header-right">
</el-dropdown> <el-tooltip :content="theme.isDark ? '切换到亮色' : '切换到暗色'">
<el-button link class="icon-btn" @click="theme.toggle()">
<el-icon :size="17"><Sunny v-if="theme.isDark" /><Moon v-else /></el-icon>
</el-button>
</el-tooltip>
<el-dropdown @command="onLogout">
<span class="user-chip">
<el-avatar :size="28" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
<span>{{ username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>{{ email }}</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header> </el-header>
<el-main class="main"> <el-main class="main">
<router-view /> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main> </el-main>
</el-container> </el-container>
</el-container> </el-container>
@@ -70,33 +109,90 @@ async function onLogout() {
.admin-layout { .admin-layout {
min-height: 100vh; min-height: 100vh;
} }
/* ------- 侧边栏 ------- */
.aside { .aside {
background: var(--el-bg-color); background: var(--app-sidebar-bg);
border-right: 1px solid var(--el-border-color-light); border-right: none;
transition: width 0.2s ease;
overflow: hidden;
display: flex;
flex-direction: column;
} }
.logo { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
height: 60px; height: var(--app-header-height);
padding: 0 16px; padding: 0 14px;
font-weight: 600; flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.logo-icon {
width: 34px;
height: 34px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.logo-text {
font-weight: 650;
font-size: 15px; font-size: 15px;
border-bottom: 1px solid var(--el-border-color-light); color: #fff;
white-space: nowrap;
} }
.menu { .menu {
border-right: none; border-right: none;
flex: 1;
padding-top: 8px;
}
.aside :deep(.el-menu-item) {
margin: 4px 10px;
border-radius: 8px;
--el-menu-item-height: 44px;
}
.aside :deep(.el-menu-item:hover) {
background: rgba(255, 255, 255, 0.08);
}
.aside :deep(.el-menu-item.is-active) {
background: var(--app-sidebar-bg-active);
}
.aside :deep(.el-menu-item.is-active .el-icon) {
color: var(--app-sidebar-text-active);
}
/* ------- 右侧容器 ------- */
.right {
min-width: 0;
} }
.header { .header {
height: var(--app-header-height);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px;
background: var(--el-bg-color); background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light); border-bottom: 1px solid var(--el-border-color-light);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
} }
.page-title { .header-left,
font-size: 15px; .header-right {
font-weight: 600; display: flex;
align-items: center;
gap: 10px;
}
.icon-btn {
color: var(--el-text-color-regular);
padding: 4px;
}
.crumb-home {
margin-right: 4px;
vertical-align: -2px;
} }
.user-chip { .user-chip {
display: flex; display: flex;
@@ -106,10 +202,11 @@ async function onLogout() {
outline: none; outline: none;
} }
.avatar { .avatar {
background: var(--el-color-primary); background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff; color: #fff;
} }
.main { .main {
padding: 20px; padding: 20px;
background: var(--el-bg-color-page);
} }
</style> </style>
+61 -19
View File
@@ -2,10 +2,12 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const theme = useThemeStore()
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
const username = computed(() => auth.me?.username ?? '') const username = computed(() => auth.me?.username ?? '')
@@ -20,29 +22,42 @@ async function onLogout() {
<el-container class="user-layout"> <el-container class="user-layout">
<el-header class="header"> <el-header class="header">
<span class="logo"> <span class="logo">
<el-icon><Monitor /></el-icon> <span class="logo-icon">
<span>服务器用户管理</span> <el-icon :size="18"><Monitor /></el-icon>
</span>
<span class="logo-text">服务器用户管理</span>
</span> </span>
<el-menu :default-active="activeMenu" router mode="horizontal" :ellipsis="false" class="nav"> <el-menu :default-active="activeMenu" router mode="horizontal" :ellipsis="false" class="nav">
<el-menu-item index="/me/profile">账号信息</el-menu-item> <el-menu-item index="/me/profile">账号信息</el-menu-item>
<el-menu-item index="/me/keys">我的密钥</el-menu-item> <el-menu-item index="/me/keys">我的密钥</el-menu-item>
<el-menu-item index="/me/audit">我的审计</el-menu-item> <el-menu-item index="/me/audit">我的审计</el-menu-item>
</el-menu> </el-menu>
<el-dropdown @command="onLogout"> <div class="header-right">
<span class="user-chip"> <el-tooltip :content="theme.isDark ? '切换到亮色' : '切换到暗色'">
<el-avatar :size="26" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar> <el-button link class="icon-btn" @click="theme.toggle()">
<span>{{ username }}</span> <el-icon :size="17"><Sunny v-if="theme.isDark" /><Moon v-else /></el-icon>
<el-icon><ArrowDown /></el-icon> </el-button>
</span> </el-tooltip>
<template #dropdown> <el-dropdown @command="onLogout">
<el-dropdown-menu> <span class="user-chip">
<el-dropdown-item command="logout">退出登录</el-dropdown-item> <el-avatar :size="26" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
</el-dropdown-menu> <span>{{ username }}</span>
</template> <el-icon><ArrowDown /></el-icon>
</el-dropdown> </span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header> </el-header>
<el-main class="main"> <el-main class="main">
<router-view /> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main> </el-main>
</el-container> </el-container>
</template> </template>
@@ -52,23 +67,49 @@ async function onLogout() {
min-height: 100vh; min-height: 100vh;
} }
.header { .header {
height: var(--app-header-height);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 20px;
background: var(--el-bg-color); background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light); border-bottom: 1px solid var(--el-border-color-light);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
} }
.logo { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
font-weight: 600; flex-shrink: 0;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.logo-text {
font-weight: 650;
font-size: 15px; font-size: 15px;
} }
.nav { .nav {
flex: 1; flex: 1;
border-bottom: none; border-bottom: none;
} }
.header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.icon-btn {
color: var(--el-text-color-regular);
padding: 4px;
}
.user-chip { .user-chip {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -77,10 +118,11 @@ async function onLogout() {
outline: none; outline: none;
} }
.avatar { .avatar {
background: var(--el-color-primary); background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff; color: #fff;
} }
.main { .main {
padding: 20px; padding: 20px;
background: var(--el-bg-color-page);
} }
</style> </style>
+6
View File
@@ -4,15 +4,21 @@ import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import '@/styles/theme.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useThemeStore } from '@/stores/theme'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(ElementPlus, { locale: zhCn }) app.use(ElementPlus, { locale: zhCn })
// 应用主题(暗色/亮色)需在挂载前生效,避免闪烁
useThemeStore().init()
// 全局注册 Element Plus 图标 // 全局注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component) app.component(key, component)
+45
View File
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
const STORAGE_KEY = 'ws-usernode-theme'
function systemPrefersDark(): boolean {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
}
/** 主题状态:暗色 / 亮色,持久化到 localStorage。 */
export const useThemeStore = defineStore('theme', () => {
const isDark = ref(false)
function apply() {
document.documentElement.classList.toggle('dark', isDark.value)
}
/** 应用初始化时调用:localStorage 优先,其次跟随系统偏好。 */
function init() {
let saved: string | null = null
try {
saved = localStorage.getItem(STORAGE_KEY)
} catch {
// 隐私模式下可能不可用,忽略
}
if (saved === 'dark' || saved === 'light') {
isDark.value = saved === 'dark'
} else {
isDark.value = systemPrefersDark()
}
apply()
}
function toggle() {
isDark.value = !isDark.value
apply()
try {
localStorage.setItem(STORAGE_KEY, isDark.value ? 'dark' : 'light')
} catch {
// 忽略
}
}
return { isDark, init, toggle }
})
+186
View File
@@ -0,0 +1,186 @@
/* ============================================================
* 全局主题:通过 CSS 变量覆盖 Element Plus 默认主题
* 亮色为默认;html.dark 下为暗色(配合 element-plus dark css-vars
* ============================================================ */
:root {
/* 品牌主色:靛蓝(替代默认 #409EFF) */
--el-color-primary: #4f46e5;
--el-color-primary-light-3: #847eed;
--el-color-primary-light-5: #a7a3f2;
--el-color-primary-light-7: #cac8f7;
--el-color-primary-light-8: #dcdafa;
--el-color-primary-light-9: #edecfc;
--el-color-primary-dark-2: #3f38b7;
/* 圆角 */
--el-border-radius-base: 8px;
--el-border-radius-small: 6px;
/* 页面背景:偏冷灰白 */
--el-bg-color-page: #f4f5fa;
/* 阴影:更柔和 */
--el-box-shadow-light: 0 4px 16px rgba(15, 23, 42, 0.06);
--el-box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
/* 字体栈 */
--el-font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
/* 布局自定义变量 */
--app-sidebar-bg: #1c2333;
--app-sidebar-bg-active: rgba(99, 102, 241, 0.18);
--app-sidebar-text: rgba(255, 255, 255, 0.72);
--app-sidebar-text-active: #ffffff;
--app-sidebar-logo-bg: rgba(99, 102, 241, 0.16);
--app-header-height: 60px;
}
/* ---------------- 暗色模式 ---------------- */
html.dark {
/* 主色在暗色下提亮一档,保证对比度 */
--el-color-primary: #818cf8;
--el-color-primary-light-3: #5a62ae;
--el-color-primary-light-5: #41467c;
--el-color-primary-light-7: #272a4a;
--el-color-primary-light-8: #1a1c32;
--el-color-primary-light-9: #14162b;
--el-color-primary-dark-2: #6366f1;
--el-bg-color-page: #101218;
--app-sidebar-bg: #161a24;
--app-sidebar-logo-bg: rgba(129, 140, 248, 0.16);
}
/* ---------------- 认证页(登录 / 申请)公共样式 ----------------
* 深色品牌背景 + 光晕,卡片浮于其上。
* 亮/暗模式下此背景保持一致,形成品牌页与内部页面的反差。
*/
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
position: relative;
overflow: hidden;
background:
radial-gradient(1200px 600px at 85% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(56, 189, 248, 0.16), transparent 55%),
linear-gradient(135deg, #0f172a 0%, #1e293b 55%, #26224f 100%);
}
.auth-page::before {
content: '';
position: absolute;
width: 560px;
height: 560px;
border-radius: 50%;
background: radial-gradient(circle, rgba(99, 102, 241, 0.22), transparent 70%);
top: -180px;
right: -120px;
pointer-events: none;
}
.auth-page::after {
content: '';
position: absolute;
width: 480px;
height: 480px;
border-radius: 50%;
background: radial-gradient(circle, rgba(34, 211, 238, 0.14), transparent 70%);
bottom: -160px;
left: -100px;
pointer-events: none;
}
.auth-card {
width: 420px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 60px rgba(2, 6, 23, 0.45);
position: relative;
z-index: 1;
}
html.dark .auth-card {
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
}
.auth-card .el-card__body {
padding: 28px 28px 24px;
}
/* 品牌区:图标放渐变圆角块内 */
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.auth-brand .brand-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.35);
}
.auth-brand h2 {
margin: 0;
font-size: 17px;
font-weight: 650;
letter-spacing: 0.5px;
}
.auth-brand .sub {
font-size: 12.5px;
color: var(--el-text-color-secondary);
margin-top: -6px;
}
.auth-foot {
margin-top: 16px;
text-align: center;
font-size: 13px;
color: var(--el-text-color-secondary);
}
/* 表单提交按钮:通栏主按钮 */
.auth-submit {
width: 100%;
}
/* ---------------- 全局细节 ---------------- */
/* 细滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(120, 130, 150, 0.35);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(120, 130, 150, 0.55);
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 文本选中颜色 */
::selection {
background: rgba(99, 102, 241, 0.25);
}
/* 页面切换过渡(避免突兀) */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
+22 -15
View File
@@ -54,10 +54,20 @@ async function onSubmit() {
</script> </script>
<template> <template>
<div class="apply-page"> <div class="auth-page">
<el-card class="apply-card" shadow="always"> <el-card class="auth-card apply-card" shadow="never">
<h2>申请服务器账号</h2> <div class="auth-brand">
<el-text type="info">提交后由管理员审批通过后自动创建账号用户名将带 <code>ext_</code> 前缀结果以邮件通知</el-text> <span class="brand-icon">
<el-icon :size="26"><EditPen /></el-icon>
</span>
<h2>申请服务器账号</h2>
<span class="sub">提交后由管理员审批结果以邮件通知</span>
</div>
<el-alert type="info" :closable="false" show-icon class="apply-tip">
通过后自动创建账号用户名将带 <code>ext_</code> 前缀
</el-alert>
<el-divider /> <el-divider />
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" @submit.prevent="onSubmit"> <el-form ref="formRef" :model="form" :rules="rules" label-width="90px" @submit.prevent="onSubmit">
@@ -74,25 +84,22 @@ async function onSubmit() {
<el-input v-model="form.purpose" type="textarea" :rows="3" placeholder="说明申请用途" /> <el-input v-model="form.purpose" type="textarea" :rows="3" placeholder="说明申请用途" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交申请</el-button> <el-button type="primary" class="auth-submit" :loading="submitting" @click="onSubmit">提交申请</el-button>
<el-button @click="router.push('/login')">返回登录</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="auth-foot">
<router-link to="/login">已有账号返回登录</router-link>
</div>
</el-card> </el-card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.apply-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #2c3e50 100%);
padding: 24px;
}
.apply-card { .apply-card {
width: 520px; width: 520px;
border-radius: 8px; }
.apply-tip {
margin-bottom: 4px;
} }
</style> </style>
+40 -51
View File
@@ -10,7 +10,8 @@ const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const tab = ref<'admin' | 'user'>('admin') const tab = ref<'admin' | 'user'>('admin')
const loading = ref(false) const loading = ref(false) // 登录请求中
const sendingOtp = ref(false) // 发送 OTP 中
// 管理员登录 // 管理员登录
const adminForm = reactive({ username: '', password: '' }) const adminForm = reactive({ username: '', password: '' })
@@ -70,6 +71,7 @@ async function onSendOtp() {
await loadCaptcha() await loadCaptcha()
if (!captcha.value) return if (!captcha.value) return
} }
sendingOtp.value = true
try { try {
await api.otpSend({ await api.otpSend({
username: userForm.username, username: userForm.username,
@@ -84,6 +86,8 @@ async function onSendOtp() {
} catch { } catch {
// 验证码错误等已提示;重新生成一次 // 验证码错误等已提示;重新生成一次
await loadCaptcha() await loadCaptcha()
} finally {
sendingOtp.value = false
} }
} }
@@ -116,11 +120,14 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="login-page"> <div class="auth-page">
<el-card class="login-card" shadow="always"> <el-card class="auth-card" shadow="never">
<div class="brand"> <div class="auth-brand">
<el-icon :size="30"><Monitor /></el-icon> <span class="brand-icon">
<el-icon :size="26"><Monitor /></el-icon>
</span>
<h2>服务器用户管理节点</h2> <h2>服务器用户管理节点</h2>
<span class="sub">账号申请 · SSH 密钥 · 使用审计</span>
</div> </div>
<el-tabs v-model="tab" stretch @tab-change="onTabChange"> <el-tabs v-model="tab" stretch @tab-change="onTabChange">
@@ -140,7 +147,7 @@ onMounted(() => {
@keyup.enter="onAdminLogin" @keyup.enter="onAdminLogin"
/> />
</el-form-item> </el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="onAdminLogin">登录</el-button> <el-button type="primary" class="auth-submit" :loading="loading" @click="onAdminLogin">登录</el-button>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
@@ -151,85 +158,67 @@ onMounted(() => {
<el-input v-model="userForm.username" placeholder="用户名(可带或不带 ext_ 前缀)" autocomplete="username" /> <el-input v-model="userForm.username" placeholder="用户名(可带或不带 ext_ 前缀)" autocomplete="username" />
</el-form-item> </el-form-item>
<el-form-item label="图形验证码"> <el-form-item label="图形验证码">
<div class="captcha-row"> <div class="inline-row">
<el-input v-model="userForm.captcha_code" placeholder="4 位验证码" maxlength="4" @keyup.enter="onSendOtp" /> <el-input v-model="userForm.captcha_code" placeholder="4 位验证码" maxlength="4" @keyup.enter="onSendOtp" />
<el-image <el-image
v-if="captcha" v-if="captcha"
:src="captcha.image" :src="captcha.image"
fit="contain" fit="contain"
class="captcha-img" class="captcha-img"
:title="'点击刷新'" title="点击刷新"
@click="loadCaptcha" @click="loadCaptcha"
/> />
<el-button v-else :loading="captchaLoading" @click="loadCaptcha">获取</el-button> <el-button v-else :loading="captchaLoading" @click="loadCaptcha">获取</el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="登录验证码(OTP"> <el-form-item label="登录验证码(OTP">
<el-input <div class="inline-row otp-row">
v-model="userForm.code" <el-input
placeholder="6 位 OTP(邮件或 CLI 获取)" v-model="userForm.code"
maxlength="6" placeholder="6 位 OTP(邮件或 CLI 获取)"
@keyup.enter="onUserLogin" maxlength="6"
/> class="otp-input"
@keyup.enter="onUserLogin"
/>
<el-button :loading="sendingOtp" :disabled="cooldown > 0" @click="onSendOtp">
{{ cooldown > 0 ? `${cooldown}s 后重发` : '发送验证码' }}
</el-button>
</div>
</el-form-item> </el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="onSendOtp" :disabled="cooldown > 0"> <el-button type="primary" class="auth-submit" :loading="loading" @click="onUserLogin">登录</el-button>
{{ cooldown > 0 ? `重新发送(${cooldown}s` : '发送验证码' }}
</el-button>
<el-button type="success" class="submit" :loading="loading" @click="onUserLogin">登录</el-button>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<div class="foot"> <div class="auth-foot">
<router-link to="/apply">申请新账号</router-link> <router-link to="/apply">还没有账号申请新账号</router-link>
</div> </div>
</el-card> </el-card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.login-page { /* 输入框 + 相邻控件同行对齐 */
min-height: 100vh; .inline-row {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #2c3e50 100%);
}
.login-card {
width: 400px;
border-radius: 8px;
}
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.brand h2 {
margin: 0;
font-size: 18px;
}
.captcha-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
} }
.otp-row .otp-input {
flex: 1;
min-width: 0;
}
.captcha-img { .captcha-img {
width: 110px; width: 110px;
height: 32px; height: 32px;
border: 1px solid var(--el-border-color); border: 1px solid var(--el-border-color);
border-radius: 4px; border-radius: 6px;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
.submit {
width: 100%; /* tab 与品牌区的间距 */
:deep(.el-tabs) {
margin-top: 4px; margin-top: 4px;
} }
.foot {
margin-top: 16px;
text-align: center;
font-size: 13px;
}
</style> </style>