diff --git a/README.md b/README.md
index df22502..63e0ac0 100644
--- a/README.md
+++ b/README.md
@@ -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)。
diff --git a/docs/specs/SDD-008-web-ui-guide-alignment.md b/docs/specs/SDD-008-web-ui-guide-alignment.md
new file mode 100644
index 0000000..6aee399
--- /dev/null
+++ b/docs/specs/SDD-008-web-ui-guide-alignment.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 调用顺序。
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 2dbc141..70bec7d 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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')
diff --git a/frontend/src/components/TrafficChart.vue b/frontend/src/components/TrafficChart.vue
index 128dfe5..24591f5 100644
--- a/frontend/src/components/TrafficChart.vue
+++ b/frontend/src/components/TrafficChart.vue
@@ -9,24 +9,31 @@
{{ $t('settings.pageDescription') }}
{{ $t('settings.pageDescription') }}