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>
|
<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>
|
||||||
|
|||||||
Generated
+2131
File diff suppressed because it is too large
Load Diff
+128
-31
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
</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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user