Align management UI with web design guide

This commit is contained in:
opsx committed 2026-10-22 21:46:28 +08:00
1 parent 373a0904be
commit 347799cb1e
18 files changed
+366 -164

No files matched your search

+2
View File
@@ -56,6 +56,8 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
./run.sh stop # 停止服务
```
管理后台的视觉和交互规范见 [管理端 UI 实施说明](docs/specs/SDD-008-web-ui-guide-alignment.md)。前端沿用 Vue 3 + Element Plus:路由元数据统一生成侧栏与页标题,颜色和间距集中在 `frontend/src/styles/tokens.css`,列表与图表明确展示加载、空数据和失败重试。单独预览前端可在 `frontend/` 运行 `npm run dev`;默认代理 `/api` 到本机 `8080`,需要启动后端才能测试登录及真实数据。
### Linux 服务器部署
现有服务器的构建、升级、备份与 OIDC 入口配置参见 [部署手册](docs/deploy.md)。
@@ -0,0 +1,27 @@
# SDD-008:管理端 UI 指南落地
| 字段 | 内容 |
|---|---|
| 级别 | S3(导航、会话错误处理与日志导出凭据传递方式) |
| 状态 | 本轮已实施,真实浏览器业务链路待联调 |
| 日期 | 2026-10-22 |
## 现状与目标
当前 Vue 3 / Element Plus 后台已有基本响应式布局与 CSS 变量,但菜单与路由双份维护、局部样式仍有散落数值,图表无数据时使用假的占位切片,部分请求失败只留下空白或抛出未处理错误。按 `~/Documents/WEB-UI-Development-Guide` 的 02/04/05/06/07/08 章,以现有 Vue 栈完善全站视觉和交互,不更换框架、不修改后台授权模型与业务 API。
## 设计与兼容
- 路由 meta 提供菜单分组、顺序、图标与子页父路径;布局按 meta 渲染。保留已有 URL、管理员显隐规则和路由守卫;不能把客户端导航权限误认为服务器授权。
- 统一品牌、表面、文本、边框、间距、侧栏和动效 CSS 变量,沿用 Element Plus;页面卡片统一描边。移动端侧栏覆盖主内容,表格保持横向滚动,表单弹窗不越界。
- 概览、专线列表、拨号用户、设备列表、在线会话和流量图明确首屏加载、空数据、请求失败与重试;轮询失败保留已有可信数据,禁止绘制虚构切片。统计卡片使用可键盘聚焦的导航链接,图表按需注册。
- 登录不预填账号或展示默认口令;仅真实会话失效时统一清除本地登录态并跳转,不把登录表单的 401 当作过期会话,更不从 401 处理器递归注销。
- 日志 CSV 使用现有 Axios Authorization 请求并从 Blob 下载;筛选参数与原请求保持一致,不把 token 放入 URL。失败时保留原日志并明确显示错误,下载完成后释放 Blob URL。后端现有导出接口不变。
## 风险与验收
风险:菜单 meta 遗漏导致入口消失;颜色对比、窄屏表格或图表实例切换出错;登录错误处理误跳转;大文件 CSV 进入浏览器内存。验收:所有原有入口均可从对应角色看到,子页可返回专线;中文/英文页面可构建;桌面与小屏下菜单和操作按钮可点;登录 401 留在登录表单,业务 401 只触发一次跳转;接口错误时有重试且不伪造数据;日志导出请求 URL 不含 token,HTTP Authorization 携带凭据并成功下载 CSV。旧版后端接口保持兼容。提交前执行前端构建与 `git diff --check`,生产发布需另行确认可访问性与当前在线 VPN 连接;回退只需切回上一版前端嵌入二进制。
## 后续拆分点
`Settings.vue`、`Tunnels.vue`、`VPNUsers.vue` 目前超过指南的 400 行视图上限。下一轮按业务边界分别将身份/网关设置、专线详情/日志、拨号用户的 MFA 弹层移至页面私有组件和 composable。避免本次视觉统一同时改变这些敏感业务表单的状态及 API 调用顺序。
+14 -6
View File
@@ -23,8 +23,7 @@ export const auth = reactive({
},
isAdmin() { return this.role === 'admin' },
canManage() { return this.role === 'admin' || this.role === 'operator' },
logout() {
if (this.token) api.post('/logout').catch(() => {})
clearSession() {
this.token = ''
this.username = ''
this.role = 'viewer'
@@ -33,13 +32,19 @@ export const auth = reactive({
localStorage.removeItem('vpn_user')
localStorage.removeItem('vpn_role')
localStorage.removeItem('vpn_must_change')
},
logout() {
const token = this.token
this.clearSession()
if (token) api.post('/logout', null, { headers: { Authorization: `Bearer ${token}` }, skipAuth: true, skipUnauthorized: true }).catch(() => {})
}
})
const api = axios.create({ baseURL: '/api', timeout: 30000 })
let redirectingToLogin = false
api.interceptors.request.use(cfg => {
if (auth.token) cfg.headers.Authorization = `Bearer ${auth.token}`
if (auth.token && !cfg.skipAuth) cfg.headers.Authorization = `Bearer ${auth.token}`
return cfg
})
@@ -47,9 +52,12 @@ api.interceptors.response.use(
res => res,
err => {
const status = err.response?.status
if (status === 401) {
auth.logout()
if (location.pathname !== '/login') location.href = '/login'
if (status === 401 && !err.config?.skipUnauthorized && !['/login', '/mfa/verify'].includes(err.config?.url)) {
auth.clearSession()
if (!redirectingToLogin && location.pathname !== '/login') {
redirectingToLogin = true
location.replace('/login')
}
} else if (status === 403 && err.response?.data?.must_change_password) {
auth.mustChange = true
localStorage.setItem('vpn_must_change', '1')
+17 -5
View File
@@ -9,24 +9,31 @@
<el-radio-button :value="168">7d</el-radio-button>
</el-radio-group>
</div>
<div ref="chartRef" style="height:220px" v-loading="loading"></div>
<el-empty v-if="!loading && samples.length === 0" :description="$t('tunnels.noTrafficData')" :image-size="60" />
<el-alert v-if="error" type="error" :closable="false" :title="$t('common.loadFailed')"><el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button></el-alert>
<div v-show="samples.length" ref="chartRef" style="height:220px" v-loading="loading"></div>
<el-empty v-if="!loading && !error && samples.length === 0" :description="$t('tunnels.noTrafficData')" :image-size="60" />
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import api from '../api'
import { fmtBytes } from '../utils/format'
const props = defineProps({ tunnelId: { type: Number, required: true } })
echarts.use([LineChart, TooltipComponent, LegendComponent, GridComponent, CanvasRenderer])
const chartRef = ref(null)
const loading = ref(false)
const hours = ref(24)
const samples = ref([])
const error = ref(false)
let chart = null
let requestRevision = 0
function granularity(h) {
if (h <= 1) return 'minute'
@@ -36,17 +43,21 @@ function granularity(h) {
async function load() {
if (!props.tunnelId) return
const revision = ++requestRevision
loading.value = true
try {
const { data } = await api.get(`/tunnels/${props.tunnelId}/traffic/history`, {
params: { hours: hours.value, granularity: granularity(hours.value) }
})
if (revision !== requestRevision) return
samples.value = data.samples || []
error.value = false
await nextTick()
renderChart()
} catch (e) {
samples.value = []
if (revision === requestRevision) { error.value = true; samples.value = []; chart?.clear() }
} finally {
loading.value = false
if (revision === requestRevision) loading.value = false
}
}
@@ -89,6 +100,7 @@ onMounted(() => {
window.addEventListener('resize', resizeHandler)
})
onBeforeUnmount(() => {
requestRevision++
window.removeEventListener('resize', resizeHandler)
chart?.dispose()
})
+1
View File
@@ -559,6 +559,7 @@
"startDate": "Start Date",
"endDate": "End Date",
"exportCsv": "Export CSV",
"exportFailed": "Could not export logs. Please retry.",
"eventType": "Event Type",
"tunnelId": "Tunnel ID",
"auditCount": "Audit Logs",
+1
View File
@@ -559,6 +559,7 @@
"startDate": "开始日期",
"endDate": "结束日期",
"exportCsv": "导出 CSV",
"exportFailed": "导出日志失败,请重试",
"eventType": "事件类型",
"tunnelId": "隧道ID",
"auditCount": "审计日志",
+19 -11
View File
@@ -9,17 +9,17 @@ const routes = [
component: () => import('./views/Layout.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { titleKey: 'router.dashboard' } },
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { titleKey: 'router.tunnels' } },
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { titleKey: 'router.sessions' } },
{ path: 'logs', component: () => import('./views/Logs.vue'), meta: { titleKey: 'router.logs' } },
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } },
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } },
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true } },
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true } },
{ path: 'client-devices', component: () => import('./views/ClientDevices.vue'), meta: { titleKey: 'router.clientDevices', requiresAdmin: true } },
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } },
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { titleKey: 'router.dashboard', navGroup: 'workspace', navOrder: 1, navIcon: 'dashboard' } },
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { titleKey: 'router.tunnels', navGroup: 'ops', navOrder: 1, navIcon: 'tunnels' } },
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { titleKey: 'router.sessions', navGroup: 'ops', navOrder: 2, navIcon: 'sessions' } },
{ path: 'logs', component: () => import('./views/Logs.vue'), meta: { titleKey: 'router.logs', navGroup: 'ops', navOrder: 3, navIcon: 'logs' } },
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel', parentPath: '/tunnels' } },
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel', parentPath: '/tunnels' } },
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer', navGroup: 'ops', navOrder: 4, navIcon: 'peer' } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true, navGroup: 'resource', navOrder: 3, navIcon: 'users' } },
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true, navGroup: 'resource', navOrder: 1, navIcon: 'vpnUsers' } },
{ path: 'client-devices', component: () => import('./views/ClientDevices.vue'), meta: { titleKey: 'router.clientDevices', requiresAdmin: true, navGroup: 'resource', navOrder: 2, navIcon: 'devices' } },
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings', navGroup: 'system', navOrder: 1, navIcon: 'settings' } },
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } }
]
}
@@ -27,6 +27,14 @@ const routes = [
const router = createRouter({ history: createWebHistory(), routes })
// The same route metadata drives menu visibility, order, title and child breadcrumbs.
export const navigationGroups = ['workspace', 'ops', 'resource', 'system']
export function menuFor(group, isAdmin) {
return router.getRoutes()
.filter(r => r.meta.navGroup === group && (!r.meta.requiresAdmin || isAdmin))
.sort((a, b) => a.meta.navOrder - b.meta.navOrder)
}
router.beforeEach(to => {
if (!to.meta.public && !auth.token) return '/login'
if (to.path === '/login' && auth.token) return '/'
+25 -2
View File
@@ -1,12 +1,35 @@
* { box-sizing: border-box; }
html, body, #app { height: 100%; margin: 0; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; }
html, body, #app { height: 100%; margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; }
body { background: var(--bg-page); }
button, input, select, textarea { font: inherit; }
button:disabled { cursor: not-allowed; opacity: .6; }
a { color: var(--brand-primary); }
.stat-card { border-radius: var(--radius-md); padding: var(--space-5); color: #fff; position: relative; overflow: hidden; }
.stat-card .num { font-size: 32px; font-weight: 700; margin-top: var(--space-2); }
.stat-card .label { opacity: .85; font-size: 14px; }
.page-card { border-radius: var(--radius-md); }
.page-card { border-radius: var(--radius-md); border-color: var(--border-base); overflow: hidden; }
.page-card + .page-card { margin-top: var(--space-4); }
.page-card > .el-card__header { padding: var(--space-4) var(--space-6); border-bottom-color: var(--border-light); }
.page-card > .el-card__body { padding: var(--space-6); }
.el-table { --el-table-border-color: var(--border-light); --el-table-header-bg-color: var(--bg-inset); --el-table-row-hover-bg-color: var(--bg-hover); }
.el-table th.el-table__cell { color: var(--text-secondary); font-weight: 600; }
.el-table .cell { line-height: 1.5; }
.el-table .el-table__body .cell { font-variant-numeric: tabular-nums; }
.el-dialog { max-width: calc(100vw - 32px); border-radius: var(--radius-lg); }
.el-drawer { max-width: 100vw; }
.el-button { transition: background-color var(--duration-fast), border-color var(--duration-fast), color var(--duration-fast); }
.el-input__wrapper:focus-within, .el-select__wrapper:focus-within { box-shadow: 0 0 0 1px var(--brand-primary) inset; }
.row-between { gap: var(--space-3); flex-wrap: wrap; }
.el-card__header .row-between > :last-child { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
.el-table__body-wrapper { scrollbar-width: thin; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } }
@media (max-width: 640px) {
.page-card > .el-card__header, .page-card > .el-card__body { padding: var(--space-4); }
.el-dialog { width: calc(100vw - 32px) !important; }
.el-drawer { width: 100vw !important; }
}
.mono { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
+48 -23
View File
@@ -6,45 +6,60 @@
:root {
/* ---- 品牌色(沿用深蓝系,统一为可引用变量)---- */
--brand: #1a2a4a;
--brand-accent: #2b5876;
--brand-primary: #409eff;
--brand: #102b4d;
--brand-accent: #205386;
--brand-primary: #2879c7;
--brand-grad: linear-gradient(135deg, var(--brand) 0%, var(--brand-accent) 100%);
--brand-mark: linear-gradient(145deg, #5fb1f3, #2879c7);
/* ---- 语义色 ---- */
--color-success: #67c23a;
--color-warning: #e6a23c;
--color-danger: #f56c6c;
--color-info: #909399;
--color-success: #16855a;
--color-warning: #bd7415;
--color-danger: #d34b4b;
--color-info: #64748b;
/* ---- 文本层级(修对比度:placeholder 不再用 #c0c4cc)---- */
--text-primary: #303133;
--text-regular: #606266;
--text-secondary: #909399;
--text-placeholder: #a8abb2;
--text-primary: #1c3049;
--text-regular: #3f536c;
--text-secondary: #61748a;
--text-placeholder: #8191a2;
--text-disabled: #9aa8b6;
--text-inverse: #fff;
/* ---- 表面/背景 ---- */
--bg-page: #f5f7fa;
--bg-page: #f4f7fb;
--bg-card: #ffffff;
--bg-inset: #f6f8fa;
--bg-sidebar: #1a2a4a;
--bg-inset: #f6f9fd;
--bg-muted: #eef3f9;
--bg-sidebar: #122b49;
--sidebar-text: #bdd0e5;
--sidebar-muted: #90a9c4;
--bg-sidebar-hover: rgba(255, 255, 255, 0.08);
--bg-sidebar-active: rgba(64, 158, 255, 0.15);
--bg-hover: #f5f7fa;
--bg-sidebar-active: rgba(79, 174, 250, 0.2);
--bg-hover: #edf4fc;
--overlay: rgba(8, 24, 46, .58);
/* ---- 边框 ---- */
--border-base: #e8eaee;
--border-light: #f0f2f5;
--border-base: #dce6f0;
--border-light: #e9eff6;
--border-strong: #b9c9d9;
/* ---- 圆角(统一为 3 级)---- */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius: var(--radius-md);
/* ---- 阴影(统一为 3 级)---- */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 2px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-1: var(--shadow-sm);
--shadow-2: var(--shadow-md);
--sidebar-width: 220px;
--sidebar-collapsed: 64px;
--duration-fast: 150ms ease;
--duration-panel: 240ms cubic-bezier(.4,0,.2,1);
/* ---- 间距(8px 网格)---- */
--space-1: 4px;
@@ -53,13 +68,23 @@
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* ---- 覆盖 Element Plus 关键变量,使组件库与品牌统一 ---- */
--el-color-primary: #409eff;
--el-color-success: #67c23a;
--el-color-warning: #e6a23c;
--el-color-danger: #f56c6c;
--el-border-radius-base: 6px;
--el-color-primary: var(--brand-primary);
--el-color-primary-light-3: color-mix(in srgb, var(--brand-primary) 70%, white);
--el-color-primary-light-5: color-mix(in srgb, var(--brand-primary) 50%, white);
--el-color-primary-light-7: color-mix(in srgb, var(--brand-primary) 30%, white);
--el-color-primary-light-8: color-mix(in srgb, var(--brand-primary) 20%, white);
--el-color-primary-light-9: color-mix(in srgb, var(--brand-primary) 10%, white);
--el-color-primary-dark-2: color-mix(in srgb, var(--brand-primary) 80%, black);
--el-color-success: var(--color-success);
--el-color-warning: var(--color-warning);
--el-color-danger: var(--color-danger);
--el-text-color-primary: var(--text-primary);
--el-text-color-regular: var(--text-regular);
--el-border-color: var(--border-base);
--el-border-radius-base: var(--radius-sm);
--el-font-size-base: 14px;
}
+14 -6
View File
@@ -8,16 +8,17 @@
{{ $t('clientDevices.hint') }}
</span>
</div>
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @click="load" />
</div>
</template>
<el-table :data="devices" size="small" v-loading="loading">
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="load-error"><el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button></el-alert>
<el-table :data="devices" size="default" v-loading="loading">
<el-table-column prop="username" :label="$t('common.username')" width="140" />
<el-table-column prop="name" :label="$t('clientDevices.deviceName')" min-width="150" />
<el-table-column prop="platform" :label="$t('clientDevices.platform')" width="100" />
<el-table-column prop="tunnel_name" :label="$t('clientDevices.tunnel')" min-width="140" />
<el-table-column prop="client_address" :label="$t('clientDevices.address')" width="130" />
<el-table-column :label="$t('common.traffic')" width="170">
<el-table-column :label="$t('common.traffic')" width="170" align="right" header-align="right">
<template #default="{ row }">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</template>
</el-table-column>
<el-table-column :label="$t('clientDevices.lastHandshake')" width="170">
@@ -40,7 +41,7 @@
<span v-else style="color:#c0c4cc">—</span>
</template>
</el-table-column>
<template #empty>{{ $t('clientDevices.empty') }}</template>
<template #empty><el-empty :description="$t('clientDevices.empty')" /></template>
</el-table>
</el-card>
</template>
@@ -56,22 +57,29 @@ import { fmtBytes } from '../utils/format'
const { t } = useI18n()
const devices = ref([])
const loading = ref(false)
const loadError = ref('')
async function load() {
loading.value = true
try {
const { data } = await api.get('/client-devices')
devices.value = data.devices || []
loadError.value = ''
} catch (e) {
loadError.value = e.response?.data?.error || t('common.loadFailed')
} finally {
loading.value = false
}
}
async function revoke(row) {
try {
await api.post(`/client-devices/${row.id}/revoke`)
ElMessage.success(t('clientDevices.revokeDone'))
load()
await load()
} catch (e) { ElMessage.error(e.response?.data?.error || t('common.operationFailed')) }
}
onMounted(load)
</script>
<style scoped>.load-error { margin-bottom: var(--space-4); }</style>
+43 -21
View File
@@ -1,7 +1,11 @@
<template>
<div>
<el-alert v-if="loadError" class="dashboard-error" type="error" :closable="false" :title="$t('common.loadFailed')">
<el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button>
</el-alert>
<el-skeleton v-if="loadingProto && !readinessLoaded" :rows="3" animated class="dashboard-skeleton" />
<!-- 空状态引导:0 条专线时展示 -->
<el-card v-if="ov.total === 0" class="page-card welcome-card fade-in" shadow="never">
<el-card v-if="readinessLoaded && ov.total === 0" class="page-card welcome-card fade-in" shadow="never">
<div class="welcome-inner">
<div class="welcome-icon"><el-icon :size="32" color="var(--brand-primary)"><Connection /></el-icon></div>
<div class="welcome-text">
@@ -14,17 +18,16 @@
<!-- 统计卡片:渐变 icon 块 + 数值 -->
<el-row :gutter="16">
<el-col :xs="12" :sm="12" :lg="6" v-for="(card, i) in statCards" :key="i">
<div class="stat-card fade-in" :class="{ clickable: card.to }" :style="{ animationDelay: i * 0.05 + 's' }"
@click="card.to && $router.push(card.to)">
<div class="stat-icon" :style="{ background: card.iconBg }">
<el-icon :size="26" color="#fff"><component :is="card.icon" /></el-icon>
<el-col :xs="24" :sm="12" :lg="6" v-for="card in statCards" :key="card.label">
<component :is="card.to ? RouterLink : 'div'" :to="card.to || undefined" class="stat-card fade-in" :class="{ clickable: card.to }">
<div class="stat-icon" :class="card.tone">
<el-icon :size="24"><component :is="card.icon" /></el-icon>
</div>
<div class="stat-body">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-num" :style="card.numStyle || {}">{{ card.value }}</div>
</div>
<div class="stat-num" :class="{ 'stat-traffic': card.tone === 'traffic' }">{{ card.value }}</div>
</div>
</component>
</el-col>
</el-row>
@@ -43,7 +46,8 @@
<el-col :xs="24" :lg="10">
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('dashboard.protocolDistribution') }}</b></template>
<div ref="pieRef" style="height:280px"></div>
<div v-show="hasDistribution" ref="pieRef" class="distribution-chart" role="img" :aria-label="$t('dashboard.protocolDistribution')"></div>
<el-empty v-if="!hasDistribution && !loadingProto" :description="$t('dashboard.noTunnels')" />
</el-card>
</el-col>
<el-col :xs="24" :lg="14">
@@ -54,7 +58,7 @@
<span class="dep-hint">{{ $t('dashboard.depCheckResult') }}</span>
</div>
</template>
<el-table :data="protocols" size="default" v-loading="loadingProto">
<el-table :data="protocols" size="default" v-loading="loadingProto" table-layout="auto">
<el-table-column :label="$t('common.protocol')" width="180">
<template #default="{ row }">
<span class="protocol-tag" :style="{ color: protocolColor(row.name) }">{{ row.display_name }}</span>
@@ -77,6 +81,7 @@
</template>
</template>
</el-table-column>
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
</el-card>
</el-col>
@@ -115,8 +120,12 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { RouterLink } from 'vue-router'
import * as echarts from 'echarts/core'
import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useI18n } from 'vue-i18n'
import { Connection, Monitor, Share, DataLine, Plus } from '@element-plus/icons-vue'
import api from '../api'
@@ -125,13 +134,16 @@ import { protocolColor, protocolDisplay } from '../protocols'
import { ElMessage } from 'element-plus'
const { t } = useI18n()
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
const ov = ref({})
const protocols = ref([])
const readiness = ref({})
const readinessLoaded = ref(false)
const loadingProto = ref(true)
const loadError = ref(false)
const pieRef = ref(null)
let chart = null
const hasDistribution = computed(() => (ov.value.protocol_distribution || []).some(d => Number(d.Count || d.count) > 0))
// ---- 协议部署 ----
const deployableProtocols = ['frp', 'openvpn']
@@ -203,10 +215,10 @@ function copyText(text) { navigator.clipboard.writeText(text).then(() => ElMessa
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
const statCards = computed(() => [
{ label: t('dashboard.totalTunnels'), value: ov.value.total ?? '-', icon: Connection, iconBg: 'linear-gradient(135deg,#4a6fa5,#2b4a7a)', to: '/tunnels' },
{ label: t('dashboard.running'), value: ov.value.running ?? '-', icon: Monitor, iconBg: 'linear-gradient(135deg,#4caf7d,#2e7d5b)', to: '/tunnels?state=running' },
{ label: t('dashboard.onlineUsers'), value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' },
{ label: t('dashboard.totalTraffic'), value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } },
{ label: t('dashboard.totalTunnels'), value: ov.value.total ?? '—', icon: Connection, tone: 'tunnels', to: '/tunnels' },
{ label: t('dashboard.running'), value: ov.value.running ?? '—', icon: Monitor, tone: 'running', to: '/tunnels?state=running' },
{ label: t('dashboard.onlineUsers'), value: ov.value.online_sessions ?? '—', icon: Share, tone: 'users', to: '/sessions' },
{ label: t('dashboard.totalTraffic'), value: readinessLoaded.value ? `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}` : '—', icon: DataLine, tone: 'traffic' },
])
const hostReady = computed(() => readiness.value.root && readiness.value.ipv4_forwarding && readiness.value.default_route_ready)
const readinessHint = computed(() => {
@@ -226,12 +238,14 @@ async function load() {
protocols.value = p
readiness.value = r || {}
readinessLoaded.value = true
loadError.value = false
await nextTick()
renderPie()
} catch { /* 忽略轮询错误 */ } finally { loadingProto.value = false }
} catch { loadError.value = true } finally { loadingProto.value = false }
}
function renderPie() {
if (!pieRef.value) return
if (!pieRef.value || !hasDistribution.value) { chart?.clear(); return }
if (!chart) chart = echarts.init(pieRef.value)
const dist = (ov.value.protocol_distribution || []).map(d => ({
name: t(protocolDisplay(d.Protocol || d.protocol)),
@@ -244,7 +258,7 @@ function renderPie() {
series: [{
type: 'pie', radius: ['45%', '70%'], center: ['50%', '45%'],
label: { formatter: '{b}: {c}' },
data: dist.length ? dist : [{ name: t('dashboard.noTunnels'), value: 1, itemStyle: { color: '#e0e3e8' } }]
data: dist
}]
})
}
@@ -274,8 +288,10 @@ onUnmounted(() => {
display: flex; align-items: center; gap: var(--space-4);
transition: transform 0.2s, box-shadow 0.2s;
margin-bottom: var(--space-4);
text-decoration: none; color: inherit;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.stat-card.clickable:hover { border-color: var(--brand-primary); transform: translateY(-2px); }
.stat-card.clickable:active { transform: translateY(0); }
.stat-card.clickable { cursor: pointer; }
.welcome-card { margin-bottom: var(--space-4); }
@@ -293,11 +309,17 @@ onUnmounted(() => {
.stat-icon {
width: 56px; height: 56px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
box-shadow: var(--shadow-sm);
color: var(--brand-primary); background: var(--bg-hover);
}
.stat-icon.running { color: var(--color-success); background: #e9f7ef; }
.stat-icon.users { color: var(--color-warning); background: #fff5e8; }
.stat-icon.traffic { color: #7652aa; background: #f2ecfa; }
.stat-body { min-width: 0; flex: 1; }
.stat-label { font-size: 13px; color: var(--text-secondary); margin-bottom: var(--space-1); }
.stat-num { font-size: 26px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.stat-traffic { font-size: 18px; overflow-wrap: anywhere; }
.distribution-chart { height: 280px; width: 100%; }
.dashboard-error, .dashboard-skeleton { margin-bottom: var(--space-4); }
.dep-hint { font-size: 12px; color: var(--text-secondary); font-weight: normal; }
.readiness-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 4px; }
+40 -43
View File
@@ -1,33 +1,20 @@
<template>
<el-container class="layout" :class="{ 'is-collapsed': collapsed }">
<div v-if="mobileOpen" class="nav-backdrop" @click="mobileOpen = false"></div>
<button v-if="mobileOpen" type="button" class="nav-backdrop" :aria-label="$t('router.closeNavigation')" @click="mobileOpen = false"></button>
<aside class="aside" :class="{ 'mobile-open': mobileOpen }" :aria-label="$t('router.navigation')">
<div class="logo">
<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>
<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>
<nav class="navigation" :aria-label="$t('router.navigation')">
<div v-for="group in visibleGroups" :key="group.key" class="nav-group">
<div class="nav-heading">{{ $t(`router.${group.titleKey}`) }}</div>
<router-link v-for="item in group.items" :key="item.path" :to="item.path" class="nav-item"
:class="{ active: activeMenu === item.path }" :title="$t(item.meta.titleKey)" :aria-label="$t(item.meta.titleKey)"
:aria-current="activeMenu === item.path ? 'page' : undefined">
<el-icon><component :is="menuIcons[item.meta.navIcon]" /></el-icon><span>{{ $t(item.meta.titleKey) }}</span>
</router-link>
</div>
</nav>
<div class="sidebar-footer">
@@ -38,14 +25,17 @@
</aside>
<el-container class="content-shell">
<el-header class="header">
<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-title"><button class="mobile-menu" type="button" :aria-label="$t('router.openNavigation')" @click="mobileOpen = true"><el-icon><Operation /></el-icon></button>
<router-link v-if="parentRoute" class="header-parent" :to="parentRoute.path">{{ $t(parentRoute.meta.titleKey) }}</router-link><span v-if="parentRoute" class="header-divider">/</span>
<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">
<button type="button" class="lang-switch" aria-label="Language / 语言">
<el-icon><Promotion /></el-icon>
{{ locale === 'zh-CN' ? '中文' : 'EN' }}
<el-icon><ArrowDown /></el-icon>
</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN" :class="{ 'is-active': locale === 'zh-CN' }">中文</el-dropdown-item>
@@ -54,13 +44,13 @@
</template>
</el-dropdown>
<el-dropdown @command="onCommand">
<span class="user" role="button" :aria-label="$t('settings.changePassword')" tabindex="0">
<button type="button" class="user" :aria-label="auth.username">
<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>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="password">{{ $t('settings.changePassword') }}</el-dropdown-item>
@@ -108,12 +98,17 @@ import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
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'
import { menuFor, navigationGroups } from '../router'
const router = useRouter()
const route = useRoute()
const { t, locale } = useI18n()
const collapsed = ref(localStorage.getItem('vpn_nav_collapsed') === '1')
const mobileOpen = ref(false)
const menuIcons = { dashboard: DataAnalysis, tunnels: Connection, sessions: Monitor, logs: Document, peer: Link, users: UserFilled, vpnUsers: Iphone, devices: Monitor, settings: Setting }
const groupTitles = { workspace: 'sidebarWorkspace', ops: 'sidebarOps', resource: 'sidebarResource', system: 'sidebarSystem' }
const visibleGroups = computed(() => navigationGroups.map(key => ({ key, titleKey: groupTitles[key], items: menuFor(key, auth.isAdmin()) })).filter(group => group.items.length))
const parentRoute = computed(() => route.meta.parentPath && router.getRoutes().find(r => r.path === route.meta.parentPath))
function toggleCollapsed() {
collapsed.value = !collapsed.value
localStorage.setItem('vpn_nav_collapsed', collapsed.value ? '1' : '0')
@@ -126,10 +121,7 @@ const roleLabel = computed(() => ({ admin: t('users.roleAdmin'), operator: t('us
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 activeMenu = computed(() => route.meta.parentPath || route.path)
// ---- 语言切换 ----
function switchLang(lang) {
@@ -179,23 +171,23 @@ async function changePwd() {
<style scoped>
.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; }
.aside { display: flex; flex-direction: column; flex: 0 0 var(--sidebar-width); width: var(--sidebar-width); min-height: 0; background: var(--bg-sidebar); color: var(--sidebar-text); transition: width var(--duration-panel), flex-basis var(--duration-panel); 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-mark { display: grid; flex: 0 0 38px; height: 38px; place-items: center; border-radius: var(--radius-md); background: var(--brand-mark); }
.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; }
.brand-copy span { font-size: 11px; color: var(--sidebar-muted); 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-heading { color: var(--sidebar-muted); 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: var(--radius-md); display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--sidebar-text); font-size: 13px; white-space: nowrap; transition: background var(--duration-fast),color var(--duration-fast); }
.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; }
.nav-item:hover { background: var(--bg-sidebar-hover); color: #fff; }
.nav-item.active { background: var(--bg-sidebar-active); color: #fff; box-shadow: inset 3px 0 var(--brand-primary); 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; }
.collapse-control:hover { color: #fff; background: var(--bg-sidebar-hover); }
.is-collapsed .aside { width: var(--sidebar-collapsed); flex-basis: var(--sidebar-collapsed); }
.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; }
@@ -206,21 +198,26 @@ async function changePwd() {
}
.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); }
.header-parent { color: var(--text-secondary); text-decoration: none; font-size: 13px; white-space: nowrap; }
.header-parent:hover { color: var(--brand-primary); }
.header-divider { color: var(--text-placeholder); }
.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; }
.user, .lang-switch { border: 0; background: transparent; padding: var(--space-2); border-radius: var(--radius-sm); font: inherit; }
.user:hover, .lang-switch:hover { background: var(--bg-hover); color: var(--brand-primary); }
.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; }
.aside { width: var(--sidebar-collapsed); flex-basis: var(--sidebar-collapsed); }
.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, .is-collapsed .aside { position: fixed; inset: 0 auto 0 0; width: min(var(--sidebar-width), 85vw); transform: translateX(-100%); transition: transform var(--duration-panel); box-shadow: var(--shadow-lg); }
.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; }
@@ -228,7 +225,7 @@ async function changePwd() {
.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; }
.nav-backdrop { display: block; position: fixed; inset: 0; z-index: 19; width: 100%; height: 100%; padding: 0; border: 0; background: var(--overlay); cursor: pointer; }
.header { padding: 0 14px; }
.header-actions { gap: 8px; }
.user .el-tag { display: none; }
+7 -7
View File
@@ -45,6 +45,7 @@
<!-- 右面板:登录表单 -->
<div class="login-right">
<div class="login-form-wrapper fade-in">
<div class="mobile-brand"><img src="/favicon.svg" alt="" width="32" height="32" /><span>OPSX VPN</span></div>
<div class="form-header">
<h2 class="form-title">{{ $t('login.welcome') }}</h2>
<p class="form-subtitle">{{ $t('login.welcomeHint') }}</p>
@@ -71,11 +72,6 @@
</el-button>
</form>
<div class="login-footer">
<i18n-t keypath="login.defaultAccount" tag="p">
<template #account><strong>admin / admin123</strong></template>
</i18n-t>
</div>
</div>
</div>
</div>
@@ -90,7 +86,7 @@ import { User, Lock, CircleCheck } from '@element-plus/icons-vue'
import api, { auth } from '../api'
const { t } = useI18n()
const username = ref('admin')
const username = ref('')
const password = ref('')
const loading = ref(false)
const loginError = ref('')
@@ -106,7 +102,6 @@ async function doLogin() {
try {
const { data } = await api.post('/login', { username: username.value, password: password.value })
auth.login(data.token, data.username, data.role, data.must_change_password)
ElMessage.success(t('login.loginSuccess'))
if (data.must_change_password) {
ElMessage.warning(t('login.changePasswordFirst'))
router.push('/settings')
@@ -168,6 +163,7 @@ async function doLogin() {
padding: var(--space-6); background: var(--bg-card);
}
.login-form-wrapper { width: min(100%, 400px); }
.mobile-brand { display: none; align-items: center; gap: var(--space-2); color: var(--brand-primary); font-weight: 700; margin-bottom: var(--space-8); }
.form-header { margin-bottom: var(--space-6); }
.form-title { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 var(--space-1); }
.form-subtitle { font-size: 14px; color: var(--text-secondary); margin: 0; }
@@ -186,5 +182,9 @@ async function doLogin() {
@media (max-width: 900px) {
.login-container { grid-template-columns: 1fr; }
.login-left { display: none; }
.login-right { background: var(--bg-page); }
.login-form-wrapper { padding: var(--space-6); border: 1px solid var(--border-base); border-radius: var(--radius-lg); background: var(--bg-card); }
.mobile-brand { display: flex; }
}
@media (max-width: 480px) { .login-right { padding: var(--space-4); } .login-form-wrapper { padding: var(--space-5); } }
</style>
+40 -12
View File
@@ -14,11 +14,12 @@
<el-button type="primary" size="small" @click="loadAudit">{{ $t('common.search') }}</el-button>
</div>
<div style="display:flex;gap:8px">
<el-button size="small" @click="exportAudit">{{ $t('logs.exportCsv') }}</el-button>
<el-button size="small" :loading="auditExporting" @click="exportAudit">{{ $t('logs.exportCsv') }}</el-button>
</div>
</div>
</template>
<el-table :data="auditLogs" size="small" v-loading="auditLoading" max-height="560">
<el-alert v-if="auditError" type="error" :closable="false" :title="auditError" class="log-error"><el-button size="small" @click="loadAudit">{{ $t('common.refresh') }}</el-button></el-alert>
<el-table :data="auditLogs" size="default" v-loading="auditLoading" max-height="560">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
@@ -31,6 +32,7 @@
</el-table-column>
<el-table-column prop="target" :label="$t('common.target')" min-width="160" show-overflow-tooltip />
<el-table-column prop="detail" :label="$t('common.detail')" min-width="200" show-overflow-tooltip />
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
<div class="pagination-bar">
<el-pagination
@@ -64,11 +66,12 @@
<el-button type="primary" size="small" @click="loadConn">{{ $t('common.search') }}</el-button>
</div>
<div style="display:flex;gap:8px">
<el-button size="small" @click="exportConn">{{ $t('logs.exportCsv') }}</el-button>
<el-button size="small" :loading="connExporting" @click="exportConn">{{ $t('logs.exportCsv') }}</el-button>
</div>
</div>
</template>
<el-table :data="connLogs" size="small" v-loading="connLoading" max-height="560">
<el-alert v-if="connError" type="error" :closable="false" :title="connError" class="log-error"><el-button size="small" @click="loadConn">{{ $t('common.refresh') }}</el-button></el-alert>
<el-table :data="connLogs" size="default" v-loading="connLoading" max-height="560">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
@@ -82,6 +85,7 @@
</el-table-column>
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
<el-table-column prop="detail" :label="$t('common.detail')" min-width="200" show-overflow-tooltip />
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
<div class="pagination-bar">
<el-pagination
@@ -168,6 +172,8 @@ const activeTab = ref('audit')
// ---- 审计日志 ----
const auditLogs = ref([])
const auditLoading = ref(false)
const auditError = ref('')
const auditExporting = ref(false)
const auditPage = ref(1)
const auditPageSize = ref(20)
const auditTotal = ref(0)
@@ -189,24 +195,42 @@ async function loadAudit() {
const { data } = await api.get('/audit-logs', { params })
auditLogs.value = data.items || []
auditTotal.value = data.total || 0
auditError.value = ''
} catch (e) {
ElMessage.error(t('logs.loadFailed'))
auditError.value = e.response?.data?.error || t('logs.loadFailed')
} finally { auditLoading.value = false }
}
function exportAudit() {
async function downloadCSV(path, params, name) {
const { data } = await api.get(path, { params, responseType: 'blob' })
const url = URL.createObjectURL(data)
try {
const link = document.createElement('a')
link.href = url
link.download = `${name}-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
} finally { setTimeout(() => URL.revokeObjectURL(url), 1000) }
}
async function exportAudit() {
auditExporting.value = true
const params = { ...auditFilters }
if (auditDateRange.value) {
params.start = auditDateRange.value[0]
params.end = auditDateRange.value[1]
}
const qs = new URLSearchParams(params).toString()
window.open(`/api/audit-logs/export?token=${auth.token}&${qs}`, '_blank')
try { await downloadCSV('/audit-logs/export', params, 'audit-logs') }
catch (e) { ElMessage.error(t('logs.exportFailed')) }
finally { auditExporting.value = false }
}
// ---- 连接日志 ----
const connLogs = ref([])
const connLoading = ref(false)
const connError = ref('')
const connExporting = ref(false)
const connPage = ref(1)
const connPageSize = ref(20)
const connTotal = ref(0)
@@ -232,12 +256,14 @@ async function loadConn() {
const { data } = await api.get('/connection-logs', { params })
connLogs.value = data.items || []
connTotal.value = data.total || 0
connError.value = ''
} catch (e) {
ElMessage.error(t('logs.loadFailed'))
connError.value = e.response?.data?.error || t('logs.loadFailed')
} finally { connLoading.value = false }
}
function exportConn() {
async function exportConn() {
connExporting.value = true
const params = { username: connFilters.username, keyword: connFilters.keyword }
if (connFilters.event && connFilters.event.length > 0) {
params.event = connFilters.event.join(',')
@@ -246,8 +272,9 @@ function exportConn() {
params.start = connDateRange.value[0]
params.end = connDateRange.value[1]
}
const qs = new URLSearchParams(params).toString()
window.open(`/api/connection-logs/export?token=${auth.token}&${qs}`, '_blank')
try { await downloadCSV('/connection-logs/export', params, 'connection-logs') }
catch (e) { ElMessage.error(t('logs.exportFailed')) }
finally { connExporting.value = false }
}
// ---- 流量告警 ----
@@ -335,6 +362,7 @@ onMounted(() => {
.filter-bar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.log-error { margin-bottom: var(--space-4); }
.pagination-bar {
display: flex; justify-content: flex-end; margin-top: 12px;
}
+7 -2
View File
@@ -1,5 +1,6 @@
<template>
<div>
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="session-error"><el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button></el-alert>
<!-- 在线会话 -->
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header>
@@ -23,7 +24,7 @@
<el-table-column :label="$t('sessions.connectedDuration')" width="120">
<template #default="{ row }">{{ fmtDuration(row.connected_at) }}</template>
</el-table-column>
<el-table-column :label="$t('common.traffic')" width="140">
<el-table-column :label="$t('common.traffic')" width="160" align="right" header-align="right">
<template #default="{ row }">
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</template>
@@ -89,6 +90,7 @@ const sessions = ref([])
const logs = ref([])
const alerts = ref([])
const loading = ref(false)
const loadError = ref('')
function fmtDuration(dur) {
if (!dur) return '-'
@@ -115,8 +117,9 @@ async function load() {
sessions.value = s.data || []
logs.value = l.data || []
alerts.value = a.data || []
loadError.value = ''
} catch (e) {
ElMessage.error(t('sessions.loadDataFailed'))
loadError.value = e.response?.data?.error || t('sessions.loadDataFailed')
} finally { loading.value = false }
}
@@ -133,3 +136,5 @@ let timer
onMounted(() => { load(); timer = setInterval(load, 15000) })
onUnmounted(() => clearInterval(timer))
</script>
<style scoped>.session-error { margin-bottom: var(--space-4); }</style>
+2 -4
View File
@@ -1,7 +1,7 @@
<template>
<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>
<p>{{ $t('settings.pageDescription') }}</p>
</div>
<el-tabs v-model="activeTab" class="settings-tabs" @tab-change="changeTab">
<el-tab-pane :label="$t('settings.securityTab')" name="security">
@@ -545,9 +545,7 @@ function copyBackup() {
<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 { padding: 0 4px var(--space-4); }
.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; }
+25 -11
View File
@@ -1,27 +1,31 @@
<template>
<el-card class="page-card" shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable style="width:200px" @change="load" />
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable style="width:160px;margin-left:10px" @change="load">
<div class="tunnel-toolbar">
<div class="tunnel-filters">
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable class="tunnel-search" @change="load" />
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable class="tunnel-filter" @change="load">
<el-option v-for="p in protocolOptions" :key="p" :label="$t(protocolDisplay(p))" :value="p" />
</el-select>
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable style="width:140px;margin-left:10px" @change="load">
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable class="tunnel-filter" @change="load">
<el-option :label="$t('tunnels.stateRunning')" value="running" />
<el-option :label="$t('tunnels.stateStopped')" value="stopped" />
<el-option :label="$t('tunnels.stateError')" value="error" />
</el-select>
</div>
<div style="display:flex;align-items:center;gap:8px">
<div class="tunnel-actions">
<el-tooltip :content="$t('common.refresh')" placement="top">
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @click="load" />
</el-tooltip>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
</div>
</div>
</template>
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="tunnel-error">
<el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button>
</el-alert>
<!-- 批量操作栏 -->
<div v-if="selectedRows.length > 0" class="batch-bar">
<span class="batch-info">{{ $t('tunnels.selected', { count: selectedRows.length }) }}</span>
@@ -30,7 +34,7 @@
<el-button type="danger" size="small" @click="batchAction('delete')">{{ $t('tunnels.batchDelete') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="large" @selection-change="handleSelectionChange">
<el-table :data="list" v-loading="loading" size="default" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column prop="name" :label="$t('common.name')" min-width="140">
<template #default="{ row }">
@@ -62,7 +66,7 @@
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('common.traffic')" width="140">
<el-table-column :label="$t('common.traffic')" width="160" align="right" header-align="right">
<template #default="{ row }">
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</template>
@@ -251,6 +255,7 @@ const { t } = useI18n()
const router = useRouter()
const list = ref([])
const loading = ref(false)
const loadError = ref('')
const changingIds = ref(new Set())
let loadRevision = 0
const keyword = ref('')
@@ -340,7 +345,9 @@ async function load() {
if (stateFilter.value) {
result = result.filter(r => stateOf(r) === stateFilter.value)
}
if (revision === loadRevision) list.value = result
if (revision === loadRevision) { list.value = result; loadError.value = '' }
} catch (e) {
if (revision === loadRevision) loadError.value = e.response?.data?.error || t('common.loadFailed')
} finally { if (revision === loadRevision) loading.value = false }
}
@@ -403,7 +410,7 @@ async function resumeLogStream() {
}
}
} catch (e) {
if (e.name !== 'AbortError') console.error('log stream error', e)
if (e.name !== 'AbortError') logContent.value += `\n${t('common.loadFailed')}\n`
}
logStreaming.value = false
}
@@ -627,6 +634,13 @@ onUnmounted(() => clearInterval(timer))
</script>
<style scoped>
.tunnel-toolbar, .tunnel-filters, .tunnel-actions { display: flex; align-items: center; gap: var(--space-2); }
.tunnel-toolbar { justify-content: space-between; flex-wrap: wrap; }
.tunnel-filters { flex: 1; min-width: 0; flex-wrap: wrap; }
.tunnel-search { width: 200px; }
.tunnel-filter { width: 150px; }
.tunnel-error { margin-bottom: var(--space-4); }
@media (max-width: 640px) { .tunnel-search { flex: 1 1 100%; width: 100%; } .tunnel-filter { flex: 1 1 135px; width: auto; } .tunnel-actions { width: 100%; justify-content: flex-end; } }
.remark { font-size: 12px; color: var(--text-secondary); }
.proto-cell { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
+34 -11
View File
@@ -9,7 +9,7 @@
</span>
</div>
<div class="vpn-header-actions">
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @click="load" />
<el-button @click="openBulkImport">{{ $t('vpnUsers.bulkImport') }}</el-button>
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
</div>
@@ -25,7 +25,8 @@
<span>{{ $t('vpnUsers.selectedUsers', { count: selected.length }) }}</span>
<el-button type="danger" size="small" @click="batchRemove">{{ $t('vpnUsers.batchDelete') }}</el-button>
</div>
<el-table :data="filteredUsers" size="small" v-loading="loading" @selection-change="rows => selected = rows">
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="vpn-load-error"><el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button></el-alert>
<el-table :data="filteredUsers" size="default" v-loading="loading" @selection-change="rows => selected = rows">
<el-table-column type="selection" width="44" />
<el-table-column prop="username" :label="$t('common.username')" />
<el-table-column prop="display_name" :label="$t('vpnUsers.displayName')" />
@@ -48,7 +49,7 @@
<span v-else style="color:#c0c4cc">—</span>
</template>
</el-table-column>
<el-table-column :label="$t('common.traffic')" width="170">
<el-table-column :label="$t('common.traffic')" width="170" align="right" header-align="right">
<template #default="{ row }">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</template>
</el-table-column>
<el-table-column :label="$t('vpnUsers.lastOnline')" width="180">
@@ -59,14 +60,20 @@
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? $t('common.enabled') : $t('common.disabled') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="280">
<el-table-column :label="$t('common.operation')" width="175" fixed="right">
<template #default="{ row }">
<el-button v-if="tunnels.find(x => x.id === row.tunnel_id)?.protocol !== 'wireguard'" link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
<el-button v-if="supportsMFA(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">{{ $t('vpnUsers.otpManagement') }}</el-button>
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-button>
<el-button type="danger" link size="small" @click="remove(row)">{{ $t('common.delete') }}</el-button>
<el-dropdown trigger="click" @command="command => userCommand(command, row)">
<el-button link size="small">{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="supportsMFA(row.tunnel_id)" command="otp">{{ $t('vpnUsers.otpManagement') }}</el-dropdown-item>
<el-dropdown-item command="toggle">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-dropdown-item>
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</template>
</el-table-column>
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
</el-card>
@@ -89,7 +96,7 @@
</el-form>
<template #footer>
<el-button @click="visible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="create">{{ $t('vpnUsers.create') }}</el-button>
<el-button type="primary" :loading="creating" @click="create">{{ $t('vpnUsers.create') }}</el-button>
</template>
</el-dialog>
@@ -159,7 +166,7 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckFilled, Key, Refresh } from '@element-plus/icons-vue'
import { CircleCheckFilled, Key, Refresh, ArrowDown } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api from '../api'
@@ -170,6 +177,8 @@ const { t } = useI18n()
const users = ref([])
const tunnels = ref([])
const loading = ref(false)
const loadError = ref('')
const creating = ref(false)
const keyword = ref('')
const tunnelFilter = ref(null)
const selected = ref([])
@@ -214,9 +223,18 @@ async function load() {
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
users.value = u.data || []
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2', 'wireguard'].includes(tun.protocol))
loadError.value = ''
} catch (e) {
loadError.value = e.response?.data?.error || t('common.loadFailed')
} finally { loading.value = false }
}
function userCommand(command, row) {
if (command === 'otp') openOTPManager(row)
else if (command === 'toggle') toggle(row)
else if (command === 'delete') remove(row)
}
function openCreate() {
form.value = {
tunnel_id: tunnels.value[0]?.id || null,
@@ -248,6 +266,8 @@ async function importPlatformUsers() {
}
async function create() {
if (creating.value) return
creating.value = true
try {
await api.post('/vpn-users', form.value)
visible.value = false
@@ -255,7 +275,7 @@ async function create() {
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.createFailed'))
}
} finally { creating.value = false }
}
async function toggle(row) {
@@ -447,8 +467,11 @@ onMounted(load)
<style scoped>
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
.vpn-header, .vpn-header-actions, .vpn-toolbar, .vpn-batch-bar { display:flex; align-items:center; gap:8px; }
.vpn-header { justify-content:space-between; }
.vpn-header { justify-content:space-between; flex-wrap:wrap; }
.vpn-header-actions { flex-wrap:wrap; }
.vpn-toolbar { padding: 0 0 12px; }
.vpn-load-error { margin-bottom: var(--space-4); }
@media (max-width: 640px) { .vpn-toolbar { flex-wrap: wrap; } .vpn-toolbar > * { flex: 1 1 180px; max-width: 100%; } }
.vpn-batch-bar { justify-content:space-between; margin: 0 0 10px; padding: 8px 10px; background:#fef3c7; color:#92400e; border:1px solid #fde68a; font-size:13px; }
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }