Files
opsx-vpn/frontend/src/views/Layout.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

152 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-container class="layout">
<el-aside width="220px" class="aside">
<div class="logo">
<el-icon :size="22" color="#7dd3fc"><Lock /></el-icon>
<span class="logo-text">VPN 管理平台</span>
</div>
<el-menu :default-active="activeMenu" router :background-color="sideBg" text-color="#a8b8d8"
active-text-color="#ffffff" class="menu">
<el-menu-item-group title="专线运维">
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>总览</el-menu-item>
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>专线列表</el-menu-item>
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>在线会话</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="auth.isAdmin()" title="资源管理">
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>节点管理</el-menu-item>
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>VPN 拨号用户</el-menu-item>
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>协议插件</el-menu-item>
</el-menu-item-group>
<el-menu-item-group title="系统">
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>用户管理</el-menu-item>
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>系统设置</el-menu-item>
</el-menu-item-group>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span class="page-title">{{ $route.meta.title }}</span>
<el-dropdown @command="onCommand">
<span class="user" role="button" aria-label="账户菜单" tabindex="0">
<el-icon><Avatar /></el-icon> {{ auth.username }}
<el-tag size="small" style="margin-left:6px" :type="roleTagType">
{{ roleLabel }}
</el-tag>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
<!-- 右上角修改密码弹窗(不必跳转整页) -->
<el-dialog v-model="pwdDialog" title="修改密码" width="420px" align-center>
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
title="首次登录或密码已被重置,请先修改密码" style="margin-bottom:16px" />
<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 位" />
<div class="pwd-hint" v-if="pwd.new_password">强度:{{ pwdStrength }}</div>
</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-form>
<template #footer>
<el-button @click="pwdDialog = false">取消</el-button>
<el-button type="primary" :loading="pwdLoading" @click="changePwd">确认修改</el-button>
</template>
</el-dialog>
</el-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor } from '@element-plus/icons-vue'
import api, { auth } from '../api'
const sideBg = 'var(--bg-sidebar)'
const router = useRouter()
const route = useRoute()
const roleLabel = computed(() => ({ admin: '管理员', operator: '操作员', viewer: '只读' }[auth.role] || auth.role))
const roleTagType = computed(() => auth.role === 'admin' ? 'danger' : auth.role === 'operator' ? 'warning' : 'info')
// 子路由(创建/编辑专线)归一到 /tunnels 保持高亮
const activeMenu = computed(() => {
if (route.path.startsWith('/tunnels')) return '/tunnels'
return route.path
})
// ---- 右上角修改密码弹窗 ----
const pwdDialog = ref(false)
const pwdLoading = ref(false)
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
const pwdStrength = computed(() => {
const p = pwd.value.new_password
if (!p) return ''
let score = 0
if (p.length >= 8) score++
if (p.length >= 12) score++
if (/[A-Z]/.test(p)) score++
if (/[0-9]/.test(p)) score++
if (/[^A-Za-z0-9]/.test(p)) score++
return ['很弱', '弱', '一般', '较强', '强'][Math.min(score - 1, 4)] || '很弱'
})
function onCommand(cmd) {
if (cmd === 'logout') { auth.logout(); router.push('/login') }
if (cmd === 'password') { pwd.value = { old_password: '', new_password: '', confirm: '' }; pwdDialog.value = true }
}
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', { old_password: pwd.value.old_password, new_password: pwd.value.new_password })
auth.clearMustChange()
ElMessage.success('密码已修改')
pwdDialog.value = false
} catch (e) {
ElMessage.error(e.response?.data?.error || '修改失败')
} finally { pwdLoading.value = false }
}
</script>
<style scoped>
.layout { height: 100dvh; }
.aside { background: var(--bg-sidebar); }
.logo { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-5); color: #fff; font-size: 17px; font-weight: 700; }
.menu { border-right: none; }
.menu :deep(.el-menu-item-group__title) {
color: rgba(255,255,255,0.35); font-size: 11px; padding: 12px 20px 4px;
letter-spacing: 1px; text-transform: uppercase;
}
.header {
background: var(--bg-card); display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--border-base); box-shadow: var(--shadow-sm);
}
.page-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.user { cursor: pointer; color: var(--text-regular); display: flex; align-items: center; gap: var(--space-1); }
.user:focus-visible { outline: 2px solid var(--brand-primary); border-radius: var(--radius-sm); }
.main { background: var(--bg-page); padding: var(--space-5); overflow-y: auto; }
.pwd-hint { font-size: 12px; color: var(--text-secondary); margin-top: var(--space-1); }
</style>