Align management UI with web design guide
This commit is contained in:
1 parent
373a0904be
commit
347799cb1e
18 files changed
+367
-165
No files matched your search
@@ -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
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"exportCsv": "导出 CSV",
|
||||
"exportFailed": "导出日志失败,请重试",
|
||||
"eventType": "事件类型",
|
||||
"tunnelId": "隧道ID",
|
||||
"auditCount": "审计日志",
|
||||
|
||||
+19
-11
@@ -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
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
await api.post(`/client-devices/${row.id}/revoke`)
|
||||
ElMessage.success(t('clientDevices.revokeDone'))
|
||||
load()
|
||||
try {
|
||||
await api.post(`/client-devices/${row.id}/revoke`)
|
||||
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>
|
||||
@@ -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 class="stat-num" :class="{ 'stat-traffic': card.tone === 'traffic' }">{{ card.value }}</div>
|
||||
</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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user