feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档

This commit is contained in:
2026-08-30 10:50:46 +08:00
parent c0e2ff975a
commit b305d7b371
67 changed files with 3209 additions and 171 deletions
+235
View File
@@ -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('验证码已发送(邮件或 CLIusernode 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>