Files
opsx-vpn/frontend/src/views/MfaVerify.vue
T
Logan.Li 1e2f8ef24f feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署
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参数)
2026-07-23 01:42:36 +08:00

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>