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:
1 parent
58c036c34c
commit
1e2f8ef24f
43 files changed
+6783
-460
No files matched your search
+110
-21
@@ -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>
|
||||
Reference in new issue
Block a user