Redesign console navigation and settings tabs

This commit is contained in:
opsx committed 2026-10-22 08:09:41 +08:00
1 parent ea9c885a15
commit 16955c3126
5 files changed
+205 -74

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# SDD-005:管理导航与设置页分区
| 字段 | 内容 |
|---|---|
| 级别 | S2(跨页面导航与 URL 状态) |
| 状态 | 已实现,待上线验证 |
| 参考 | opsx-sec 的固定侧栏、移动抽屉与设置标签页;保留 opsx-vpn 的现有路由和权限 |
## 现状与选择
原 `Layout.vue` 固定 220px 侧栏,移动端无抽屉;用户/设备散列在多个标题下,`/ipsec-peer` 有页面但无导航入口。原 `Settings.vue` 把密码、MFA、服务器环境、域名证书拼为左右长列,难以定位。仅更改导航信息结构与呈现,不改业务 API 或角色权限。
- 工作台:总览独立;线路运维:专线列表、在线会话、日志中心,IPSec 第三方对接为该组的专业入口。
- 身份与设备(仅管理员):VPN 拨号用户、客户端设备、后台用户。身份对象不同,保留各自独立页面,不将账号表合并。
- 系统:系统设置独立入口。设置内分个人安全、管理员公网入口、管理员服务器环境。日志中心保留独立页面,不重复出现在设置。
- `/settings?tab=security|gateway|host` 标记当前标签,可复制链接;普通用户及非法标签一律回到个人安全。右上角修改密码弹窗保留。
- 桌面侧栏可折叠,中屏自动窄栏,手机使用菜单按钮和可关闭的遮罩。页面切换关闭手机抽屉;导航项和按钮具备键盘焦点与可读名称。
## 风险、发布与验收
路由路径不变,旧书签可继续访问。管理员专属菜单仅影响可见性,现有路由守卫仍负责权限校验。浏览器刷新标签时根据查询参数恢复;首次进入只加载原有配置接口,不触发系统更改。回退仅需恢复前端版本,无数据迁移。
| 验收 | 预期 |
|---|---|
| 桌面与手机导航 | 选中状态正确,创建/编辑专线仍高亮专线,折叠与遮罩可操作 |
| 角色隔离 | viewer/operator 不出现后台用户及管理员设置标签;直接访问后台路由继续被守卫拦截 |
| 设置链接 | 刷新与分享 `?tab=gateway` 保持标签,非法标签与非管理员回到个人安全 |
| 功能完整 | 密码/MFA、域名/证书、通用环境和部署说明都保留,专线/日志/IPSec 入口可达 |
| 检查 | 前端构建、主要路由和页面烟测通过;不改后端契约 |
前端构建与差异检查已通过。页面交互和发布后的线上烟测需在部署后另行核验。
+7 -2
View File
@@ -566,6 +566,8 @@
"noAlerts": "No traffic alerts"
},
"settings": {
"pageDescription": "Manage account security, the public gateway, and server setup.",
"securityTab": "Account Security", "gatewayTab": "Public Gateway", "hostTab": "Server Setup",
"mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.",
"changePassword": "Change Password",
"oldPassword": "Current Password",
@@ -747,8 +749,11 @@
"logs": "Log Center",
"notFound": "Page Not Found",
"sidebarOps": "Tunnel Ops",
"sidebarResource": "Resource Mgmt",
"sidebarSystem": "System"
"sidebarResource": "Identity & Devices",
"sidebarSystem": "System",
"sidebarWorkspace": "Workspace", "console": "Admin Console",
"navigation": "Main navigation", "openNavigation": "Open navigation", "closeNavigation": "Close navigation",
"collapseNavigation": "Collapse sidebar", "expandNavigation": "Expand sidebar"
},
"protocolIcon": {
"custom": "Custom Protocol"
+7 -2
View File
@@ -566,6 +566,8 @@
"noAlerts": "暂无流量告警"
},
"settings": {
"pageDescription": "管理个人安全、公网认证入口与服务器环境。",
"securityTab": "个人安全", "gatewayTab": "公网入口", "hostTab": "服务器环境",
"mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能",
"changePassword": "修改密码",
"oldPassword": "原密码",
@@ -747,8 +749,11 @@
"logs": "日志中心",
"notFound": "页面不存在",
"sidebarOps": "专线运维",
"sidebarResource": "资源管理",
"sidebarSystem": "系统"
"sidebarResource": "身份与设备",
"sidebarSystem": "系统",
"sidebarWorkspace": "工作台", "console": "管理控制台",
"navigation": "主导航", "openNavigation": "打开导航", "closeNavigation": "关闭导航",
"collapseNavigation": "收起侧栏", "expandNavigation": "展开侧栏"
},
"protocolIcon": {
"custom": "自定义协议"
+99 -38
View File
@@ -1,32 +1,45 @@
<template>
<el-container class="layout">
<el-aside width="220px" class="aside">
<el-container class="layout" :class="{ 'is-collapsed': collapsed }">
<div v-if="mobileOpen" class="nav-backdrop" @click="mobileOpen = false"></div>
<aside class="aside" :class="{ 'mobile-open': mobileOpen }" :aria-label="$t('router.navigation')">
<div class="logo">
<el-icon :size="22" color="#7dd3fc"><Lock /></el-icon>
<span class="logo-text">{{ $t('common.appName') }}</span>
<div class="brand-mark"><el-icon :size="21"><Lock /></el-icon></div>
<div class="brand-copy"><strong>OPSX VPN</strong><span>{{ $t('router.console') }}</span></div>
<button class="mobile-close" type="button" :aria-label="$t('router.closeNavigation')" @click="mobileOpen = false"><el-icon><Close /></el-icon></button>
</div>
<el-menu :default-active="activeMenu" router :background-color="sideBg" text-color="#a8b8d8"
active-text-color="#ffffff" class="menu">
<el-menu-item-group :title="$t('router.sidebarOps')">
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>{{ $t('router.dashboard') }}</el-menu-item>
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>{{ $t('router.tunnels') }}</el-menu-item>
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>{{ $t('router.sessions') }}</el-menu-item>
<el-menu-item index="/logs"><el-icon><Document /></el-icon>{{ $t('router.logs') }}</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>{{ $t('router.vpnUsers') }}</el-menu-item>
<el-menu-item index="/client-devices"><el-icon><Monitor /></el-icon>{{ $t('router.clientDevices') }}</el-menu-item>
</el-menu-item-group>
<el-menu-item-group :title="$t('router.sidebarSystem')">
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>{{ $t('router.users') }}</el-menu-item>
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>{{ $t('router.settings') }}</el-menu-item>
</el-menu-item-group>
</el-menu>
</el-aside>
<el-container>
<nav class="navigation">
<div class="nav-group">
<div class="nav-heading">{{ $t('router.sidebarWorkspace') }}</div>
<router-link to="/dashboard" class="nav-item" :class="{ active: activeMenu === '/dashboard' }" :title="$t('router.dashboard')" :aria-label="$t('router.dashboard')"><el-icon><DataAnalysis /></el-icon><span>{{ $t('router.dashboard') }}</span></router-link>
</div>
<div class="nav-group">
<div class="nav-heading">{{ $t('router.sidebarOps') }}</div>
<router-link to="/tunnels" class="nav-item" :class="{ active: activeMenu === '/tunnels' }" :title="$t('router.tunnels')" :aria-label="$t('router.tunnels')"><el-icon><Connection /></el-icon><span>{{ $t('router.tunnels') }}</span></router-link>
<router-link to="/sessions" class="nav-item" :class="{ active: activeMenu === '/sessions' }" :title="$t('router.sessions')" :aria-label="$t('router.sessions')"><el-icon><Monitor /></el-icon><span>{{ $t('router.sessions') }}</span></router-link>
<router-link to="/logs" class="nav-item" :class="{ active: activeMenu === '/logs' }" :title="$t('router.logs')" :aria-label="$t('router.logs')"><el-icon><Document /></el-icon><span>{{ $t('router.logs') }}</span></router-link>
<router-link to="/ipsec-peer" class="nav-item" :class="{ active: activeMenu === '/ipsec-peer' }" :title="$t('router.ipsecPeer')" :aria-label="$t('router.ipsecPeer')"><el-icon><Link /></el-icon><span>{{ $t('router.ipsecPeer') }}</span></router-link>
</div>
<div v-if="auth.isAdmin()" class="nav-group">
<div class="nav-heading">{{ $t('router.sidebarResource') }}</div>
<router-link to="/vpn-users" class="nav-item" :class="{ active: activeMenu === '/vpn-users' }" :title="$t('router.vpnUsers')" :aria-label="$t('router.vpnUsers')"><el-icon><Iphone /></el-icon><span>{{ $t('router.vpnUsers') }}</span></router-link>
<router-link to="/client-devices" class="nav-item" :class="{ active: activeMenu === '/client-devices' }" :title="$t('router.clientDevices')" :aria-label="$t('router.clientDevices')"><el-icon><Monitor /></el-icon><span>{{ $t('router.clientDevices') }}</span></router-link>
<router-link to="/users" class="nav-item" :class="{ active: activeMenu === '/users' }" :title="$t('router.users')" :aria-label="$t('router.users')"><el-icon><UserFilled /></el-icon><span>{{ $t('router.users') }}</span></router-link>
</div>
<div class="nav-group">
<div class="nav-heading">{{ $t('router.sidebarSystem') }}</div>
<router-link to="/settings" class="nav-item" :class="{ active: activeMenu === '/settings' }" :title="$t('router.settings')" :aria-label="$t('router.settings')"><el-icon><Setting /></el-icon><span>{{ $t('router.settings') }}</span></router-link>
</div>
</nav>
<div class="sidebar-footer">
<button class="collapse-control" type="button" :aria-label="$t(collapsed ? 'router.expandNavigation' : 'router.collapseNavigation')" @click="toggleCollapsed">
<el-icon><Expand v-if="collapsed" /><Fold v-else /></el-icon><span>{{ $t('router.collapseNavigation') }}</span>
</button>
</div>
</aside>
<el-container class="content-shell">
<el-header class="header">
<span class="page-title">{{ $route.meta.titleKey ? $t($route.meta.titleKey) : '' }}</span>
<div style="display:flex;align-items:center;gap:12px">
<div class="header-title"><button class="mobile-menu" type="button" :aria-label="$t('router.openNavigation')" @click="mobileOpen = true"><el-icon><Operation /></el-icon></button><span class="page-title">{{ $route.meta.titleKey ? $t($route.meta.titleKey) : '' }}</span></div>
<div class="header-actions">
<el-dropdown @command="switchLang" trigger="click">
<span class="lang-switch" role="button">
<el-icon><Promotion /></el-icon>
@@ -89,14 +102,23 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue'
import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document, Link, Expand, Fold, Close, Operation } from '@element-plus/icons-vue'
import api, { auth } from '../api'
const sideBg = 'var(--bg-sidebar)'
const collapsed = ref(localStorage.getItem('vpn_nav_collapsed') === '1')
const mobileOpen = ref(false)
function toggleCollapsed() {
collapsed.value = !collapsed.value
localStorage.setItem('vpn_nav_collapsed', collapsed.value ? '1' : '0')
}
watch(() => route.fullPath, () => { mobileOpen.value = false })
function onEscape(event) { if (event.key === 'Escape') mobileOpen.value = false }
onMounted(() => window.addEventListener('keydown', onEscape))
onUnmounted(() => window.removeEventListener('keydown', onEscape))
const router = useRouter()
const route = useRoute()
const { t, locale } = useI18n()
@@ -157,22 +179,61 @@ async function changePwd() {
</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;
}
.layout { height: 100dvh; overflow: hidden; }
.aside { display: flex; flex-direction: column; flex: 0 0 232px; width: 232px; min-height: 0; background: #14243c; color: #a9bbd4; transition: width .2s ease, flex-basis .2s ease; z-index: 20; }
.logo { display: flex; align-items: center; gap: 11px; min-height: 83px; padding: 18px 19px; color: #fff; border-bottom: 1px solid rgba(255,255,255,.07); }
.brand-mark { display: grid; flex: 0 0 38px; height: 38px; place-items: center; border-radius: 11px; background: linear-gradient(145deg,#4caaf3,#2666ba); box-shadow: 0 5px 15px #08172e55; }
.brand-copy { display: flex; flex-direction: column; gap: 3px; white-space: nowrap; }
.brand-copy strong { font-size: 15px; letter-spacing: .5px; }
.brand-copy span { font-size: 11px; color: #8197b5; letter-spacing: 1px; }
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 11px; scrollbar-width: thin; }
.nav-group + .nav-group { margin-top: 16px; }
.nav-heading { color: #7188a7; font-size: 11px; font-weight: 600; letter-spacing: 1.4px; padding: 8px 12px; white-space: nowrap; }
.nav-item { height: 42px; padding: 0 12px; margin: 2px 0; border-radius: 9px; display: flex; align-items: center; gap: 12px; text-decoration: none; color: #adbed4; font-size: 13px; white-space: nowrap; transition: background .15s,color .15s; }
.nav-item .el-icon { flex: 0 0 18px; font-size: 18px; }
.nav-item:hover { background: #ffffff12; color: #fff; }
.nav-item.active { background: #397dc02e; color: #fff; box-shadow: inset 3px 0 #55b0ff; font-weight: 600; }
.sidebar-footer { border-top: 1px solid rgba(255,255,255,.08); padding: 10px 11px; }
.collapse-control { display: flex; align-items: center; gap: 12px; width: 100%; height: 38px; padding: 0 12px; border: 0; border-radius: 8px; background: none; color: #9cafc8; cursor: pointer; text-align: left; white-space: nowrap; }
.collapse-control:hover { color: #fff; background: #ffffff12; }
.is-collapsed .aside { width: 72px; flex-basis: 72px; }
.is-collapsed .brand-copy, .is-collapsed .nav-heading, .is-collapsed .nav-item span, .is-collapsed .collapse-control span { display: none; }
.is-collapsed .logo { padding: 18px 17px; }
.is-collapsed .nav-item, .is-collapsed .collapse-control { justify-content: center; padding: 0; }
.content-shell { min-width: 0; }
.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);
background: var(--bg-card); display: flex; align-items: center; justify-content: space-between; gap: 12px;
border-bottom: 1px solid var(--border-base); box-shadow: var(--shadow-sm); padding: 0 24px;
}
.header-title, .header-actions { display: flex; align-items: center; gap: 12px; min-width: 0; }
.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); }
.lang-switch { cursor: pointer; color: var(--text-regular); display: flex; align-items: center; gap: 4px; font-size: 13px; }
.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); }
.mobile-menu, .mobile-close { display: none; align-items: center; justify-content: center; border: 0; background: transparent; cursor: pointer; }
.nav-backdrop { display: none; }
@media (max-width: 980px) and (min-width: 641px) {
.aside { width: 72px; flex-basis: 72px; }
.brand-copy, .nav-heading, .nav-item span, .collapse-control { display: none; }
.logo { padding: 18px 17px; }
.nav-item { justify-content: center; padding: 0; }
}
@media (max-width: 640px) {
.aside, .is-collapsed .aside { position: fixed; inset: 0 auto 0 0; width: 244px; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 12px 0 30px #091a3233; }
.aside.mobile-open { transform: translateX(0); }
.is-collapsed .brand-copy, .is-collapsed .nav-heading, .is-collapsed .nav-item span { display: flex; }
.is-collapsed .logo { padding: 18px 19px; }
.is-collapsed .nav-item { justify-content: flex-start; padding: 0 12px; }
.sidebar-footer { display: none; }
.mobile-menu, .mobile-close { display: flex; width: 32px; height: 32px; font-size: 20px; color: inherit; }
.mobile-close { margin-left: auto; color: #c5d4e7; }
.nav-backdrop { display: block; position: fixed; inset: 0; z-index: 19; background: #08162988; }
.header { padding: 0 14px; }
.header-actions { gap: 8px; }
.user .el-tag { display: none; }
.main { padding: 14px; }
}
@media (prefers-reduced-motion: reduce) { .aside, .nav-item { transition: none; } }
</style>
+61 -32
View File
@@ -1,6 +1,11 @@
<template>
<el-row :gutter="16">
<el-col :xs="24" :lg="10">
<section class="settings-page">
<div class="settings-intro">
<div><span class="settings-eyebrow">OPSX / SETTINGS</span><h2>{{ $t('router.settings') }}</h2><p>{{ $t('settings.pageDescription') }}</p></div>
</div>
<el-tabs v-model="activeTab" class="settings-tabs" @tab-change="changeTab">
<el-tab-pane :label="$t('settings.securityTab')" name="security">
<div class="settings-column">
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
:title="$t('settings.mustChangePassword')"
style="margin-bottom:16px" />
@@ -30,31 +35,13 @@
<el-button type="success" style="width:100%;margin-top:8px" :loading="mfaLoading" @click="enableMFA">{{ $t('settings.verifyAndEnable') }}</el-button>
</div>
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('settings.deployTitle') }}</b></template>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item :label="$t('settings.deliveryForm')">{{ $t('settings.deliveryDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.dataDir')">{{ $t('settings.dataDirDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.listenAddr')">{{ $t('settings.listenAddrDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.keyStorage')">{{ $t('settings.keyStorageDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.systemDeps')">{{ $t('settings.systemDepsDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.prodSuggestion')">{{ $t('settings.prodSuggestionDesc') }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
</div>
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.description')" style="margin-bottom:16px" />
<el-card shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.commonSetupTitle') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.commonSetupHint')" style="margin-bottom:12px" />
<template v-if="hostReadiness">
<p class="tls-hint">{{ $t('tls.hostRoot') }}:{{ hostReadiness.root ? '✓' : '—' }} · {{ $t('tls.hostRoute') }}:{{ hostReadiness.default_route_ready ? '✓' : '—' }} · {{ $t('tls.hostForward') }}:{{ hostReadiness.ipv4_forwarding ? '✓' : '—' }}</p>
<el-button :loading="hostFixing" :disabled="!hostReadiness.root || hostReadiness.ipv4_forwarding" @click="enableForwarding">{{ $t('tls.enableForwarding') }}</el-button>
</template>
<el-button :loading="hostLoading" @click="loadHostReadiness">{{ $t('tls.refresh') }}</el-button>
</el-card>
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
<el-step :title="$t('tls.setupStepCheck')" />
<el-step :title="$t('tls.setupStepInstall')" />
@@ -119,13 +106,33 @@
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
</template>
</el-card>
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
<el-empty :description="$t('settings.auditMigrated')">
<el-button type="primary" @click="$router.push('/logs')">{{ $t('router.logs') }}</el-button>
</el-empty>
</div>
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.hostTab')" name="host">
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.commonSetupTitle') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.commonSetupHint')" style="margin-bottom:12px" />
<template v-if="hostReadiness">
<p class="tls-hint">{{ $t('tls.hostRoot') }}:{{ hostReadiness.root ? '✓' : '—' }} · {{ $t('tls.hostRoute') }}:{{ hostReadiness.default_route_ready ? '✓' : '—' }} · {{ $t('tls.hostForward') }}:{{ hostReadiness.ipv4_forwarding ? '✓' : '—' }}</p>
<el-button :loading="hostFixing" :disabled="!hostReadiness.root || hostReadiness.ipv4_forwarding" @click="enableForwarding">{{ $t('tls.enableForwarding') }}</el-button>
</template>
<el-button :loading="hostLoading" @click="loadHostReadiness">{{ $t('tls.refresh') }}</el-button>
</el-card>
</el-col>
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('settings.deployTitle') }}</b></template>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item :label="$t('settings.deliveryForm')">{{ $t('settings.deliveryDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.dataDir')">{{ $t('settings.dataDirDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.listenAddr')">{{ $t('settings.listenAddrDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.keyStorage')">{{ $t('settings.keyStorageDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.systemDeps')">{{ $t('settings.systemDepsDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.prodSuggestion')">{{ $t('settings.prodSuggestionDesc') }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
<!-- MFA 备用码持久化弹窗 -->
<el-dialog v-model="backupVisible" :title="$t('settings.mfaEnabledTitle')" width="480px" align-center
@@ -140,17 +147,26 @@
<el-button type="primary" @click="backupVisible = false">{{ $t('settings.savedConfirm') }}</el-button>
</template>
</el-dialog>
</el-row>
</section>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { ref, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api, { auth } from '../api'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const activeTab = ref('security')
const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host'].includes(name) ? name : 'security'
watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true })
function changeTab(name) {
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
}
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
const pwdLoading = ref(false)
const mfa = ref({ enabled: false, uri: '', code: '' })
@@ -391,6 +407,19 @@ function copyBackup() {
</script>
<style scoped>
.settings-page { max-width: 1160px; margin: 0 auto; padding-bottom: 32px; }
.settings-intro { padding: 8px 4px 22px; }
.settings-eyebrow { color: #287cc6; font-size: 11px; font-weight: 700; letter-spacing: 2px; }
.settings-intro h2 { margin: 9px 0 6px; color: #1b2b43; font-size: 25px; letter-spacing: -.4px; }
.settings-intro p { margin: 0; color: var(--text-regular); line-height: 1.6; font-size: 13px; }
.settings-tabs { background: white; border: 1px solid var(--border-base); border-radius: 14px; box-shadow: var(--shadow-sm); padding: 0 22px 22px; min-height: 380px; }
.settings-tabs :deep(.el-tabs__header) { margin-bottom: 22px; }
.settings-tabs :deep(.el-tabs__item) { height: 55px; font-weight: 600; }
.settings-column { max-width: 620px; }
.settings-column-wide { max-width: 850px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
@media (max-width: 640px) { .settings-tabs { padding: 0 14px 16px; } .settings-tabs :deep(.el-tabs__item) { font-size: 12px; padding: 0 10px; } }
.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);