feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user