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参数)
152 lines
6.9 KiB
Vue
152 lines
6.9 KiB
Vue
<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>
|