UI/UX 优化: - 登录页分屏布局 + 可访问性修复 + 内联错误提示 - 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮 - Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击 - 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标 - 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流 - 协议品牌SVG图标(ProtocolIcon组件)替换emoji - 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影 功能增强: - 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志 - 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置 - 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录 - 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储 - 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf) - 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示 - 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户 - 404路由 + IPSec对接页可达性修复 - MFA备用码持久化弹窗(替代Toast) - 轮询可见性守卫 + ECharts resize修复 后端: - 3张新表: VPNSession/ConnectionLog/TrafficPolicy - monitor增强: 会话同步+配额检查+异常告警+到期审计 - changePassword统一8位+新旧不同校验 - openvpn-otp文案专业化(移除抄袭感) - SSE日志流(token降级query参数)
179 lines
5.2 KiB
Vue
179 lines
5.2 KiB
Vue
<template>
|
|
<div class="mfa-container">
|
|
<div class="mfa-card fade-in">
|
|
<div class="mfa-header">
|
|
<div class="mfa-icon">
|
|
<el-icon :size="28" color="var(--brand-primary)"><Key /></el-icon>
|
|
</div>
|
|
<h2>双因素认证</h2>
|
|
<p>请输入 Authenticator 应用中显示的 6 位动态验证码</p>
|
|
</div>
|
|
|
|
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" style="margin-bottom:20px" />
|
|
|
|
<!-- 6 位验证码输入 -->
|
|
<div class="code-inputs">
|
|
<el-input
|
|
v-for="(_, i) in 6" :key="i"
|
|
:ref="el => setDigitRef(el, i)"
|
|
v-model="digits[i]"
|
|
class="code-digit"
|
|
size="large"
|
|
maxlength="1"
|
|
inputmode="numeric"
|
|
:data-index="i"
|
|
@input="onInput(i)"
|
|
@keydown.delete="onDelete(i)"
|
|
@paste.prevent="onPaste"
|
|
/>
|
|
</div>
|
|
|
|
<el-button type="primary" size="large" style="width:100%;margin-top:24px"
|
|
:loading="loading" :disabled="code.length < 6" @click="verify">
|
|
验证并登录
|
|
</el-button>
|
|
|
|
<div class="mfa-footer">
|
|
<el-button link @click="backToLogin">返回登录</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Key } from '@element-plus/icons-vue'
|
|
import api, { auth } from '../api'
|
|
|
|
const router = useRouter()
|
|
const digits = ref(['', '', '', '', '', ''])
|
|
const loading = ref(false)
|
|
const errorMsg = ref('')
|
|
const digitRefs = []
|
|
|
|
const code = computed(() => digits.value.join(''))
|
|
|
|
function setDigitRef(el, i) {
|
|
if (el) digitRefs[i] = el
|
|
}
|
|
|
|
// 输入处理:只允许数字,自动跳到下一格
|
|
function onInput(i) {
|
|
errorMsg.value = ''
|
|
const val = digits.value[i]
|
|
// 过滤非数字
|
|
if (val && !/^\d$/.test(val)) {
|
|
digits.value[i] = val.replace(/\D/g, '').slice(-1)
|
|
}
|
|
// 输入后自动跳到下一格
|
|
if (digits.value[i] && i < 5) {
|
|
nextTick(() => digitRefs[i + 1]?.focus?.())
|
|
}
|
|
// 全部填满自动验证
|
|
if (code.value.length === 6) {
|
|
verify()
|
|
}
|
|
}
|
|
|
|
// 删除处理:当前格空时跳到上一格
|
|
function onDelete(i) {
|
|
if (!digits.value[i] && i > 0) {
|
|
digitRefs[i - 1]?.focus?.()
|
|
}
|
|
}
|
|
|
|
// 粘贴处理:粘贴 6 位码自动分散到各格
|
|
function onPaste(e) {
|
|
const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '').slice(0, 6)
|
|
if (text) {
|
|
for (let i = 0; i < 6; i++) {
|
|
digits.value[i] = text[i] || ''
|
|
}
|
|
if (text.length === 6) verify()
|
|
}
|
|
}
|
|
|
|
async function verify() {
|
|
if (code.value.length < 6) return ElMessage.warning('请输入完整的 6 位验证码')
|
|
|
|
// 从 sessionStorage 读取暂存的登录凭据
|
|
const pending = sessionStorage.getItem('pendingLogin')
|
|
if (!pending) {
|
|
ElMessage.warning('登录会话已过期,请重新登录')
|
|
router.push('/login')
|
|
return
|
|
}
|
|
|
|
const { username, password } = JSON.parse(pending)
|
|
loading.value = true
|
|
errorMsg.value = ''
|
|
try {
|
|
// 重新提交完整登录请求(含 mfa_code),参考 opsx-web-lite 两步登录模式
|
|
const { data } = await api.post('/login', { username, password, mfa_code: code.value })
|
|
// 验证成功,清除暂存凭据
|
|
sessionStorage.removeItem('pendingLogin')
|
|
auth.login(data.token, data.username, data.role, data.must_change_password)
|
|
ElMessage.success('验证成功')
|
|
if (data.must_change_password) {
|
|
router.push('/settings')
|
|
} else {
|
|
router.push('/')
|
|
}
|
|
} catch (e) {
|
|
errorMsg.value = e.response?.data?.error || '验证码错误,请重试'
|
|
// 清空输入,聚焦第一格
|
|
digits.value = ['', '', '', '', '', '']
|
|
nextTick(() => digitRefs[0]?.focus?.())
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function backToLogin() {
|
|
sessionStorage.removeItem('pendingLogin')
|
|
router.push('/login')
|
|
}
|
|
|
|
onMounted(() => {
|
|
// 没有暂存凭据则跳回登录页
|
|
if (!sessionStorage.getItem('pendingLogin')) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
nextTick(() => digitRefs[0]?.focus?.())
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mfa-container {
|
|
height: 100dvh; display: flex; align-items: center; justify-content: center;
|
|
background: var(--brand-grad); padding: var(--space-4);
|
|
}
|
|
.mfa-card {
|
|
width: min(100%, 420px); background: var(--bg-card); border-radius: var(--radius-lg);
|
|
padding: 40px 36px; box-shadow: var(--shadow-lg);
|
|
}
|
|
.mfa-header { text-align: center; margin-bottom: var(--space-6); }
|
|
.mfa-icon {
|
|
width: 56px; height: 56px; border-radius: var(--radius-md); margin: 0 auto var(--space-4);
|
|
background: var(--bg-inset); border: 1px solid var(--border-base);
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.mfa-header h2 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin: 0 0 var(--space-2); }
|
|
.mfa-header p { font-size: 14px; color: var(--text-secondary); margin: 0; }
|
|
|
|
.code-inputs {
|
|
display: flex; justify-content: center; gap: var(--space-3);
|
|
}
|
|
.code-digit {
|
|
width: 48px; flex-shrink: 0;
|
|
}
|
|
.code-digit :deep(.el-input__inner) {
|
|
text-align: center; font-size: 22px; font-weight: 700; padding: 0;
|
|
}
|
|
|
|
.mfa-footer { text-align: center; margin-top: var(--space-5); }
|
|
</style>
|