236 lines
6.5 KiB
Vue
236 lines
6.5 KiB
Vue
<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 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
|
||
}
|
||
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()
|
||
}
|
||
}
|
||
|
||
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="login-page">
|
||
<el-card class="login-card" shadow="always">
|
||
<div class="brand">
|
||
<el-icon :size="30"><Monitor /></el-icon>
|
||
<h2>服务器用户管理节点</h2>
|
||
</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="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="captcha-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)">
|
||
<el-input
|
||
v-model="userForm.code"
|
||
placeholder="6 位 OTP(邮件或 CLI 获取)"
|
||
maxlength="6"
|
||
@keyup.enter="onUserLogin"
|
||
/>
|
||
</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-form>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
|
||
<div class="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 {
|
||
display: flex;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
.captcha-img {
|
||
width: 110px;
|
||
height: 32px;
|
||
border: 1px solid var(--el-border-color);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
.submit {
|
||
width: 100%;
|
||
margin-top: 4px;
|
||
}
|
||
.foot {
|
||
margin-top: 16px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
}
|
||
</style>
|