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:
@@ -3,7 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+2131
File diff suppressed because it is too large
Load Diff
+128
-31
@@ -2,14 +2,18 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const activeMenu = computed(() => route.path)
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
const email = computed(() => auth.me?.email ?? '')
|
||||
const pageTitle = computed(() => (route.meta.title as string) ?? '')
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
@@ -19,12 +23,23 @@ async function onLogout() {
|
||||
|
||||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<el-aside width="220px" class="aside">
|
||||
<el-aside :width="collapsed ? '64px' : '220px'" class="aside">
|
||||
<div class="logo">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>用户管理节点</span>
|
||||
<span class="logo-icon">
|
||||
<el-icon :size="20"><Monitor /></el-icon>
|
||||
</span>
|
||||
<span v-show="!collapsed" class="logo-text">用户管理节点</span>
|
||||
</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-icon><Odometer /></el-icon><span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
@@ -42,25 +57,49 @@ async function onLogout() {
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
|
||||
<el-container class="right">
|
||||
<el-header class="header">
|
||||
<span class="page-title">{{ route.meta.title ?? '' }}</span>
|
||||
<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 class="header-left">
|
||||
<el-button link class="icon-btn" @click="collapsed = !collapsed">
|
||||
<el-icon :size="18"><Expand v-if="collapsed" /><Fold v-else /></el-icon>
|
||||
</el-button>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/admin/dashboard' }">
|
||||
<el-icon class="crumb-home"><HomeFilled /></el-icon>
|
||||
<span>首页</span>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="pageTitle && pageTitle !== '仪表盘'">{{ pageTitle }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<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-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-container>
|
||||
</el-container>
|
||||
@@ -70,33 +109,90 @@ async function onLogout() {
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ------- 侧边栏 ------- */
|
||||
.aside {
|
||||
background: var(--el-bg-color);
|
||||
border-right: 1px solid var(--el-border-color-light);
|
||||
background: var(--app-sidebar-bg);
|
||||
border-right: none;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
font-weight: 600;
|
||||
gap: 10px;
|
||||
height: var(--app-header-height);
|
||||
padding: 0 14px;
|
||||
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;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu {
|
||||
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 {
|
||||
height: var(--app-header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.page-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
.header-left,
|
||||
.header-right {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -106,10 +202,11 @@ async function onLogout() {
|
||||
outline: none;
|
||||
}
|
||||
.avatar {
|
||||
background: var(--el-color-primary);
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.main {
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
@@ -20,29 +22,42 @@ async function onLogout() {
|
||||
<el-container class="user-layout">
|
||||
<el-header class="header">
|
||||
<span class="logo">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>服务器用户管理</span>
|
||||
<span class="logo-icon">
|
||||
<el-icon :size="18"><Monitor /></el-icon>
|
||||
</span>
|
||||
<span class="logo-text">服务器用户管理</span>
|
||||
</span>
|
||||
<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/keys">我的密钥</el-menu-item>
|
||||
<el-menu-item index="/me/audit">我的审计</el-menu-item>
|
||||
</el-menu>
|
||||
<el-dropdown @command="onLogout">
|
||||
<span class="user-chip">
|
||||
<el-avatar :size="26" 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 command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<div class="header-right">
|
||||
<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="26" 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 command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
<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-container>
|
||||
</template>
|
||||
@@ -52,23 +67,49 @@ async function onLogout() {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
height: var(--app-header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: 20px;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
gap: 10px;
|
||||
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;
|
||||
}
|
||||
.nav {
|
||||
flex: 1;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -77,10 +118,11 @@ async function onLogout() {
|
||||
outline: none;
|
||||
}
|
||||
.avatar {
|
||||
background: var(--el-color-primary);
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.main {
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,15 +4,21 @@ import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
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 router from './router'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
// 应用主题(暗色/亮色)需在挂载前生效,避免闪烁
|
||||
useThemeStore().init()
|
||||
|
||||
// 全局注册 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -54,10 +54,20 @@ async function onSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="apply-page">
|
||||
<el-card class="apply-card" shadow="always">
|
||||
<h2>申请服务器账号</h2>
|
||||
<el-text type="info">提交后由管理员审批;通过后自动创建账号(用户名将带 <code>ext_</code> 前缀),结果以邮件通知。</el-text>
|
||||
<div class="auth-page">
|
||||
<el-card class="auth-card apply-card" shadow="never">
|
||||
<div class="auth-brand">
|
||||
<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-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-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交申请</el-button>
|
||||
<el-button @click="router.push('/login')">返回登录</el-button>
|
||||
<el-button type="primary" class="auth-submit" :loading="submitting" @click="onSubmit">提交申请</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="auth-foot">
|
||||
<router-link to="/login">已有账号?返回登录</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
width: 520px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.apply-tip {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+40
-51
@@ -10,7 +10,8 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tab = ref<'admin' | 'user'>('admin')
|
||||
const loading = ref(false)
|
||||
const loading = ref(false) // 登录请求中
|
||||
const sendingOtp = ref(false) // 发送 OTP 中
|
||||
|
||||
// 管理员登录
|
||||
const adminForm = reactive({ username: '', password: '' })
|
||||
@@ -70,6 +71,7 @@ async function onSendOtp() {
|
||||
await loadCaptcha()
|
||||
if (!captcha.value) return
|
||||
}
|
||||
sendingOtp.value = true
|
||||
try {
|
||||
await api.otpSend({
|
||||
username: userForm.username,
|
||||
@@ -84,6 +86,8 @@ async function onSendOtp() {
|
||||
} catch {
|
||||
// 验证码错误等已提示;重新生成一次
|
||||
await loadCaptcha()
|
||||
} finally {
|
||||
sendingOtp.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +120,14 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-card" shadow="always">
|
||||
<div class="brand">
|
||||
<el-icon :size="30"><Monitor /></el-icon>
|
||||
<div class="auth-page">
|
||||
<el-card class="auth-card" shadow="never">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-icon">
|
||||
<el-icon :size="26"><Monitor /></el-icon>
|
||||
</span>
|
||||
<h2>服务器用户管理节点</h2>
|
||||
<span class="sub">账号申请 · SSH 密钥 · 使用审计</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="tab" stretch @tab-change="onTabChange">
|
||||
@@ -140,7 +147,7 @@ onMounted(() => {
|
||||
@keyup.enter="onAdminLogin"
|
||||
/>
|
||||
</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-tab-pane>
|
||||
|
||||
@@ -151,85 +158,67 @@ onMounted(() => {
|
||||
<el-input v-model="userForm.username" placeholder="用户名(可带或不带 ext_ 前缀)" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<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-image
|
||||
v-if="captcha"
|
||||
:src="captcha.image"
|
||||
fit="contain"
|
||||
class="captcha-img"
|
||||
:title="'点击刷新'"
|
||||
title="点击刷新"
|
||||
@click="loadCaptcha"
|
||||
/>
|
||||
<el-button v-else :loading="captchaLoading" @click="loadCaptcha">获取</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录验证码(OTP)">
|
||||
<el-input
|
||||
v-model="userForm.code"
|
||||
placeholder="6 位 OTP(邮件或 CLI 获取)"
|
||||
maxlength="6"
|
||||
@keyup.enter="onUserLogin"
|
||||
/>
|
||||
<div class="inline-row otp-row">
|
||||
<el-input
|
||||
v-model="userForm.code"
|
||||
placeholder="6 位 OTP(邮件或 CLI 获取)"
|
||||
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-button type="primary" class="submit" :loading="loading" @click="onSendOtp" :disabled="cooldown > 0">
|
||||
{{ cooldown > 0 ? `重新发送(${cooldown}s)` : '发送验证码' }}
|
||||
</el-button>
|
||||
<el-button type="success" class="submit" :loading="loading" @click="onUserLogin">登录</el-button>
|
||||
<el-button type="primary" class="auth-submit" :loading="loading" @click="onUserLogin">登录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="foot">
|
||||
<router-link to="/apply">申请新账号</router-link>
|
||||
<div class="auth-foot">
|
||||
<router-link to="/apply">还没有账号?申请新账号</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
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 {
|
||||
/* 输入框 + 相邻控件同行对齐 */
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.otp-row .otp-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.captcha-img {
|
||||
width: 110px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.submit {
|
||||
width: 100%;
|
||||
|
||||
/* tab 与品牌区的间距 */
|
||||
:deep(.el-tabs) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.foot {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user