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参数)
This commit is contained in:
Logan.Li committed 2026-07-23 01:42:36 +08:00
1 parent 58c036c34c
commit 1e2f8ef24f
43 files changed
+6783 -460

No files matched your search

+110 -21
View File
@@ -1,46 +1,75 @@
<template>
<el-row :gutter="16">
<el-col :span="10">
<el-col :xs="24" :lg="10">
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
title="首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能"
style="margin-bottom:16px" />
<el-card class="page-card" shadow="never">
<template #header><b>修改密码</b></template>
<el-form label-width="90px">
<el-form-item label="原密码"><el-input v-model="pwd.old_password" type="password" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="pwd.new_password" type="password" show-password /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="pwd.confirm" type="password" show-password /></el-form-item>
<el-button type="primary" style="width:100%" @click="changePwd">修改</el-button>
<el-form label-width="90px" @submit.prevent="changePwd">
<el-form-item label="原密码">
<el-input v-model="pwd.old_password" type="password" show-password autocomplete="current-password" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwd.new_password" type="password" show-password autocomplete="new-password" placeholder="至少 8 位" />
</el-form-item>
<el-form-item label="确认新密码">
<el-input v-model="pwd.confirm" type="password" show-password autocomplete="new-password" @keyup.enter="changePwd" />
</el-form-item>
<el-button type="primary" style="width:100%" :loading="pwdLoading" @click="changePwd">修改</el-button>
</el-form>
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>双因素认证(MFA)</b></template>
<el-alert type="info" :closable="false" title="使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。" style="margin-bottom:10px" />
<el-alert type="info" :closable="false" title="使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。备用码可在丢失手机时登录。" style="margin-bottom:10px" />
<el-button v-if="!mfa.enabled" type="primary" @click="setupMFA">设置 MFA</el-button>
<el-button v-else type="danger" @click="disableMFA">关闭 MFA</el-button>
<div v-if="mfa.uri" style="margin-top:12px"><canvas ref="mfaCanvas"></canvas><el-input v-model="mfa.code" placeholder="输入 App 显示的 6 位验证码" style="margin-top:8px" /><el-button type="success" style="margin-top:8px" @click="enableMFA">验证并启用</el-button></div>
<div v-if="mfa.uri" style="margin-top:12px">
<canvas ref="mfaCanvas" style="display:block;margin:0 auto"></canvas>
<el-input v-model="mfa.code" placeholder="输入 App 显示的 6 位验证码" style="margin-top:8px" maxlength="6" />
<el-button type="success" style="width:100%;margin-top:8px" :loading="mfaLoading" @click="enableMFA">验证并启用</el-button>
</div>
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>部署说明</b></template>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="交付形态">单二进制(前端已内嵌)或 Docker</el-descriptions-item>
<el-descriptions-item label="交付形态">单二进制(前端已内嵌),无需 Docker</el-descriptions-item>
<el-descriptions-item label="数据目录">./data(可用 VPN_DATA_DIR 覆盖)</el-descriptions-item>
<el-descriptions-item label="监听地址">:8080(VPN_LISTEN_ADDR 覆盖)</el-descriptions-item>
<el-descriptions-item label="密钥存储">PSK / 私钥 AES-GCM 加密落库,配置文件 0600 权限</el-descriptions-item>
<el-descriptions-item label="系统依赖">wireguard-tools / strongswan / xl2tpd / openvpn / frp(缺失时标记为未部署,无法启动)</el-descriptions-item>
<el-descriptions-item label="系统依赖">wireguard-tools / strongswan / xl2tpd / openvpn / frp</el-descriptions-item>
<el-descriptions-item label="生产建议">经 Nginx/Caddy 反向代理终止 TLS 后暴露</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="14">
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
<el-card class="page-card" shadow="never">
<template #header><b>操作审计(最近 100 条)</b></template>
<el-table :data="logs" size="small" max-height="560">
<el-table :data="logs" size="small" max-height="560" v-loading="auditLoading">
<el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
</el-table-column>
<el-table-column prop="username" label="用户" width="90" />
<el-table-column prop="action" label="动作" width="140" />
<el-table-column prop="target" label="对象" show-overflow-tooltip />
<template #empty><el-empty description="暂无审计日志" /></template>
</el-table>
</el-card>
</el-col>
<!-- MFA 备用码持久化弹窗(P0 修复:不再用会消失的 Toast) -->
<el-dialog v-model="backupVisible" title="MFA 已启用 — 请保存备用码" width="480px" align-center
:close-on-click-modal="false" :close-on-press-escape="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px"
title="这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!" />
<div class="backup-codes">
<code v-for="(c, i) in backupCodes" :key="i">{{ c }}</code>
</div>
<template #footer>
<el-button @click="copyBackup">复制全部</el-button>
<el-button type="primary" @click="backupVisible = false">我已保存</el-button>
</template>
</el-dialog>
</el-row>
</template>
@@ -48,29 +77,89 @@
import { ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import api from '../api'
import api, { auth } from '../api'
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
const pwdLoading = ref(false)
const logs = ref([])
const auditLoading = ref(false)
const mfa = ref({ enabled: false, uri: '', code: '' })
const mfaCanvas = ref(null)
const mfaLoading = ref(false)
const backupVisible = ref(false)
const backupCodes = ref([])
async function changePwd() {
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning('两次新密码不一致')
if (pwd.value.new_password.length < 8) return ElMessage.warning('新密码至少 8 位')
pwdLoading.value = true
try {
await api.post('/password', pwd.value)
await api.post('/password', { old_password: pwd.value.old_password, new_password: pwd.value.new_password })
auth.clearMustChange()
ElMessage.success('密码已修改')
pwd.value = { old_password: '', new_password: '', confirm: '' }
} catch (e) {
ElMessage.error(e.response?.data?.error || '修改失败')
}
} finally { pwdLoading.value = false }
}
onMounted(async () => {
const [audit, status] = await Promise.all([api.get('/audit'), api.get('/mfa/status')])
logs.value = audit.data || []
mfa.value.enabled = status.data.enabled
try {
const status = await api.get('/mfa/status')
mfa.value.enabled = status.data.enabled
} catch { /* ignore */ }
if (auth.isAdmin()) {
auditLoading.value = true
try {
const audit = await api.get('/audit')
logs.value = audit.data || []
} catch { /* viewer */ } finally { auditLoading.value = false }
}
})
async function setupMFA() { const { data } = await api.post('/mfa/setup'); mfa.value.uri = data.otpauth_url; await nextTick(); QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 }) }
async function enableMFA() { try { const { data } = await api.post('/mfa/enable', { code: mfa.value.code }); mfa.value = { enabled: true, uri: '', code: '' }; ElMessage.success(`MFA 已启用,请保存备用码:${data.backup_codes.join(' ')}`) } catch (e) { ElMessage.error(e.response?.data?.error || '验证失败') } }
async function disableMFA() { await api.post('/mfa/disable'); mfa.value = { enabled: false, uri: '', code: '' }; ElMessage.success('MFA 已关闭') }
async function setupMFA() {
try {
const { data } = await api.post('/mfa/setup')
mfa.value.uri = data.otpauth_url
await nextTick()
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 })
} catch (e) { ElMessage.error(e.response?.data?.error || '设置失败') }
}
async function enableMFA() {
mfaLoading.value = true
try {
const { data } = await api.post('/mfa/enable', { code: mfa.value.code })
mfa.value = { enabled: true, uri: '', code: '' }
// P0 修复:备用码用持久弹窗展示,不再用会消失的 Toast
backupCodes.value = data.backup_codes || []
backupVisible.value = true
} catch (e) {
ElMessage.error(e.response?.data?.error || '验证失败')
} finally { mfaLoading.value = false }
}
async function disableMFA() {
try {
await api.post('/mfa/disable')
mfa.value = { enabled: false, uri: '', code: '' }
ElMessage.success('MFA 已关闭')
} catch (e) { ElMessage.error(e.response?.data?.error || '关闭失败') }
}
function copyBackup() {
navigator.clipboard.writeText(backupCodes.value.join('\n')).then(() => ElMessage.success('已复制到剪贴板'))
}
</script>
<style scoped>
.backup-codes {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px;
background: var(--bg-inset); border-radius: var(--radius-sm); border: 1px solid var(--border-base);
}
.backup-codes code {
font-family: 'JetBrains Mono', Consolas, monospace; font-size: 14px; font-weight: 600;
color: var(--text-primary); text-align: center; padding: 6px;
background: var(--bg-card); border-radius: 4px;
}
</style>