Files
usernode/web/src/views/LoginView.vue
T
caowangrenbo 472c66ebd8 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 输入行内联发送按钮、登录按钮唯一通栏主按钮(修复按钮错位)
2026-08-30 11:35:44 +08:00

225 lines
6.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as api from '@/api'
import { useAuthStore } from '@/stores/auth'
import type { Captcha } from '@/api/types'
const router = useRouter()
const auth = useAuthStore()
const tab = ref<'admin' | 'user'>('admin')
const loading = ref(false) // 登录请求中
const sendingOtp = ref(false) // 发送 OTP 中
// 管理员登录
const adminForm = reactive({ username: '', password: '' })
// 外部用户 OTP 登录
const userForm = reactive({ username: '', code: '', captcha_id: '', captcha_code: '' })
const captcha = ref<Captcha | null>(null)
const captchaLoading = ref(false)
const cooldown = ref(0) // OTP 发送冷却倒计时(秒)
let cooldownTimer: number | undefined
async function loadCaptcha() {
captchaLoading.value = true
try {
captcha.value = await api.getCaptcha()
} catch {
captcha.value = null
} finally {
captchaLoading.value = false
}
}
function startCooldown(secs: number) {
cooldown.value = secs
cooldownTimer = window.setInterval(() => {
cooldown.value -= 1
if (cooldown.value <= 0) {
window.clearInterval(cooldownTimer)
cooldown.value = 0
}
}, 1000)
}
async function onAdminLogin() {
if (!adminForm.username || !adminForm.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
await api.adminLogin(adminForm)
await auth.fetchMe()
router.push('/admin/dashboard')
} catch {
// 错误已由拦截器提示
} finally {
loading.value = false
}
}
async function onSendOtp() {
if (!userForm.username || !userForm.captcha_code) {
ElMessage.warning('请输入用户名和图形验证码')
return
}
if (!captcha.value) {
await loadCaptcha()
if (!captcha.value) return
}
sendingOtp.value = true
try {
await api.otpSend({
username: userForm.username,
captcha_id: captcha.value.captcha_id,
captcha_code: userForm.captcha_code,
})
ElMessage.success('验证码已发送(邮件或 CLI:usernode user otp)')
startCooldown(60)
// 验证码一次性,重新生成
userForm.captcha_code = ''
await loadCaptcha()
} catch {
// 验证码错误等已提示;重新生成一次
await loadCaptcha()
} finally {
sendingOtp.value = false
}
}
async function onUserLogin() {
if (!userForm.username || !userForm.code) {
ElMessage.warning('请输入用户名和 OTP 验证码')
return
}
loading.value = true
try {
await api.otpLogin({ username: userForm.username, code: userForm.code })
await auth.fetchMe()
router.push('/me/profile')
} catch {
// 已提示
} finally {
loading.value = false
}
}
function onTabChange() {
if (tab.value === 'user' && !captcha.value) {
loadCaptcha()
}
}
onMounted(() => {
if (tab.value === 'user') loadCaptcha()
})
</script>
<template>
<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">
<!-- 管理员登录 -->
<el-tab-pane label="管理员登录" name="admin">
<el-form label-position="top" @submit.prevent="onAdminLogin">
<el-form-item label="用户名">
<el-input v-model="adminForm.username" placeholder="管理员用户名" autocomplete="username" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="adminForm.password"
type="password"
placeholder="密码"
show-password
autocomplete="current-password"
@keyup.enter="onAdminLogin"
/>
</el-form-item>
<el-button type="primary" class="auth-submit" :loading="loading" @click="onAdminLogin">登录</el-button>
</el-form>
</el-tab-pane>
<!-- 外部用户 OTP 登录 -->
<el-tab-pane label="外部用户登录" name="user">
<el-form label-position="top" @submit.prevent="onUserLogin">
<el-form-item label="用户名">
<el-input v-model="userForm.username" placeholder="用户名(可带或不带 ext_ 前缀)" autocomplete="username" />
</el-form-item>
<el-form-item label="图形验证码">
<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="点击刷新"
@click="loadCaptcha"
/>
<el-button v-else :loading="captchaLoading" @click="loadCaptcha">获取</el-button>
</div>
</el-form-item>
<el-form-item label="登录验证码(OTP)">
<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="auth-submit" :loading="loading" @click="onUserLogin">登录</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
<div class="auth-foot">
<router-link to="/apply">还没有账号?申请新账号</router-link>
</div>
</el-card>
</div>
</template>
<style scoped>
/* 输入框 + 相邻控件同行对齐 */
.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: 6px;
cursor: pointer;
flex-shrink: 0;
}
/* tab 与品牌区的间距 */
:deep(.el-tabs) {
margin-top: 4px;
}
</style>