# 登录 IP 门禁 + SPA 无感切换 — 设计文档

**日期**: 2026-06-08  
**状态**: 待评审  
**关联**: 功能指南 §5.6 登录 IP 白名单 · 前端 Hash 路由 14 页

---

## 1. 背景与目标

### 1.1 用户诉求

| 诉求 | 说明 |
|------|------|
| **IP 白名单门禁** | 白名单开启且当前 IP 不在名单时，**不展示登录表单**（用户名/密码/TOTP），避免误导用户尝试登录后才看到拒绝 |
| **无感切换** | 登录成功进入后台、以及左侧菜单在各功能页之间切换时，减少「整页白屏 / 重新加载 / 闪烁」感 |

### 1.2 非目标（本设计不做）

- 不改 Agent API Key、安装向导流程
- 不做 SSR / 微前端拆分
- 不要求所有 14 页零请求（实时运维数据仍需刷新，但应 **静默、局部**）

---

## 2. 现状分析

### 2.1 IP 白名单

```mermaid
sequenceDiagram
  participant Browser
  participant LoginPage
  participant API as POST_auth_login

  Browser->>LoginPage: 打开 #/login
  LoginPage->>LoginPage: 展示完整表单
  Browser->>API: 提交账号密码
  API->>API: auth_service 校验 IP
  API-->>Browser: 403 ip_blocked + 当前 IP
```

- 校验位置：[`auth_service.login`](server/application/services/auth_service.py)（`LOGIN_ALLOWLIST_ENABLED` + 手动/订阅 IP）
- 客户端 IP：[`get_client_ip`](server/utils/client_ip.py)（反代信任头）
- **缺口**：登录页无预检；非白名单用户仍看到表单，体验差且暴露登录入口

### 2.2 菜单切换「像刷新」的原因

| 因素 | 位置 | 影响 |
|------|------|------|
| `router-view` 强制 remount | [`App.vue`](frontend/src/App.vue) `:key="route.fullPath"` | 一切换路由（含 query）销毁组件、重建 DOM |
| 每页 `onMounted` 拉数 | 各 `*Page.vue` | 每次进入白屏 loading |
| 路由懒加载 | [`router/index.ts`](frontend/src/router/index.ts) `import()` | 首次进入某页需下载 chunk |
| 无共享缓存 | 无 Pinia 页面缓存层 | 回到上一页重复请求 |
| 登录后跳转 | `LoginPage` `router.push(redirect)` | 整页从 login 布局切到 app 布局，侧栏/顶栏一次性挂载 |

终端页、脚本看板等已有 WebSocket / 自动刷新，**不适合**与列表页同一套 keep-alive 策略，需单独约定。

---

## 3. 方案 A — 登录 IP 门禁（小工程，优先）

### 3.1 API：公开预检端点

**`GET /api/auth/login-access`**（无需 JWT，加入 `PUBLIC_EXACT_PATHS`）

响应示例：

```json
{
  "allowlist_enabled": true,
  "allowed": false,
  "client_ip": "203.0.113.10",
  "message": "当前 IP 不在登录白名单中，请联系管理员"
}
```

| 字段 | 说明 |
|------|------|
| `allowlist_enabled` | 与 `LOGIN_ALLOWLIST_ENABLED` 一致 |
| `allowed` | 未开启白名单 → 恒 `true`；开启后按 `_ip_in_allowlist` |
| `client_ip` | `get_client_ip(request)`，与登录审计一致 |
| `message` | 仅 `allowed=false` 时返回，**不泄露**白名单列表 |

实现要点：

- 复用 [`_ip_in_allowlist`](server/application/services/auth_service.py)，抽到 `server/utils/login_allowlist.py` 供 auth_service 与 auth 路由共用
- **不写入登录失败计数**（仅浏览门禁页，未提交凭证）
- 可选审计：`login_access_denied`（低频，仅预检 `allowed=false`）— 默认 **关闭**，避免扫描噪音；若开启需限流

安全：

- 不返回白名单条目、订阅 URL
- 与 `POST /login` 相同 IP 解析逻辑，避免「预检通过、登录拒绝」不一致
- 限流：同 IP 60s 内最多 30 次（复用现有 middleware 或轻量 Redis 计数）

### 3.2 前端：LoginPage 门禁 UI

[`LoginPage.vue`](frontend/src/pages/LoginPage.vue) 挂载流程：

```mermaid
stateDiagram-v2
  [*] --> Checking: onMounted
  Checking --> ShowForm: allowed_or_disabled
  Checking --> Blocked: not_allowed
  ShowForm --> [*]: 现有表单
  Blocked --> [*]: 静态提示卡_无输入框
```

**Blocked 态 UI**（保留壁纸背景，与现有一致）：

- 标题：「无法访问管理后台」
- 文案：`message` + 显示 `client_ip`
- **无**用户名/密码/TOTP/登录按钮
- 可选：「返回」无意义，不提供跳转服务器列表（未登录）

**ShowForm 态**：与现网一致；`allowlist_enabled=true` 且 `allowed=true` 时表单正常。

加载态：预检完成前显示 `v-progress-circular`，避免表单闪现（FOUC）。

### 3.3 验收（A）

- [ ] 白名单关闭：预检 `allowed=true`，表单正常
- [ ] 白名单开启 + IP 不在名单：仅提示卡，**无**登录输入框
- [ ] 白名单开启 + IP 在名单：表单正常，登录成功
- [ ] 反代后 `client_ip` 与登录失败提示 IP 一致
- [ ] 单测：`test_login_access.py` 覆盖开/关/命中/未命中

---

## 4. 方案 B — SPA 无感切换（大工程，分阶段）

### 4.1 设计原则

1. **壳不动、内容换**：`v-app-bar` + `v-navigation-drawer` 常驻，只换 `router-view` 区域
2. **先显后刷**：有缓存则立即展示，后台静默 `silent` 刷新（与现有 `usePageAutoRefresh` 一致）
3. **重页隔离**：终端（WebSocket/xterm）、安装向导不参与 keep-alive
4. **可度量**：首屏切换 < 100ms 感知（无全屏 loading）；数据刷新不阻断操作

### 4.2 分阶段路线图

```mermaid
flowchart LR
  P0[P0_登录门禁] --> P1[P1_路由壳优化]
  P1 --> P2[P2_keep_alive]
  P2 --> P3[P3_数据缓存层]
  P3 --> P4[P4_预取与骨架屏]
```

#### P0 — 登录 IP 门禁（见方案 A）

工作量：约 0.5～1 人日。

#### P1 — 路由与登录过渡（小）

| 项 | 做法 |
|----|------|
| 去掉过激 remount | `App.vue` 将 `:key="route.fullPath"` 改为 `:key="route.name"` 或移除 key；query 变化由页内 `watch(route.query)` 处理 |
| 登录成功过渡 | `router.replace(redirect)`；`auth.login` 成功后预 `import('@/pages/DashboardPage.vue')` |
| 路由守卫 | `beforeEach` 已 `tryRestoreSession`；登录页在 P0 预检后再渲染表单 |

风险：`ScriptRunDetail` 与 `/script-runs` 同组件 — 用 `route.name` 作 key 时详情与列表可能共享实例，需在 `ScriptRunsPage` 内 `watch(() => route.params.id)` 处理。

#### P2 — Keep-alive 页面缓存（中）

```vue
<router-view v-slot="{ Component, route }">
  <keep-alive :include="cachedPageNames" :max="10">
    <component :is="Component" :key="route.name" />
  </keep-alive>
</router-view>
```

| 策略 | 页面 |
|------|------|
| **缓存** | Dashboard, Servers, Scripts, ScriptRuns, Push, Files, Credentials, Schedules, Retries, Commands, Alerts, Audit, Settings |
| **不缓存** | Terminal（xterm 会话）, Login |
| **激活钩子** | 缓存页实现 `onActivated` → `loadXxx({ silent: true })` 替代仅 `onMounted` |

需为各页 `defineOptions({ name: 'ServersPage' })` 与 `include` 字符串对齐。

#### P3 — Pinia 数据缓存层（大）

新增 composable `useCachedQuery(key, fetcher, options)`：

- 内存 TTL（如 30s）+ `staleWhileRevalidate`
- 多页共享：`useServerStatsStore`、`useServerCategoriesStore`（部分已有，需统一）
- 列表页离开再回来：先展示 cache，再静默刷新

优先接入：**仪表盘统计**、**服务器分类 Chip**、**侧栏角标**（在线数/告警数）。

#### P4 — 预取与骨架屏（中）

- 侧栏 `v-list-item` `@mouseenter` → 预加载 route chunk + 预取关键 API（debounce 200ms）
- 各页 `loading` 时显示 **骨架屏**（`v-skeleton-loader`）替代空白 `v-main`
- 全局 **不** 使用全屏 blocking overlay

### 4.3 登录成功「无感」定义

| 阶段 | 体验 |
|------|------|
| 当前 | Login 全屏 → 跳转后 App 栏 + 侧栏 + Dashboard 依次出现 + 表格 loading |
| P1 后 | 同一 `v-app` 下 `isLoggedIn` 切换；侧栏/顶栏 CSS 过渡 150ms |
| P2+P3 后 | Dashboard 若曾访问过则瞬间展示缓存数字，后台刷新 |

### 4.4 不宜无感化的场景

- 终端：必须保留独立布局与会话生命周期
- 强制登出 / token 失效：仍跳转 `#/login` 并清缓存
- 部署后 chunk 失效：保留现有 `router.onError` 一次 reload 策略

---

## 5. 安全与性能约束

| 项 | 约束 |
|----|------|
| 预检 API | 不泄露白名单内容；限流 |
| keep-alive | 登出时 `clearCache()` 或重置 Pinia，防下一用户见上一用户数据 |
| 缓存 TTL | 运维数据不宜过长（建议 30～60s）；敏感页（凭据）可缩短或不缓存列表明文 |
| 终端 | 禁止 keep-alive；避免 WebSocket 与 DOM 泄漏 |

---

## 6. 涉及文件（预估）

### P0

| 层 | 文件 |
|----|------|
| 后端 | `server/utils/login_allowlist.py`（新）、`server/api/auth.py`、`server/api/auth_jwt.py`、`server/application/services/auth_service.py` |
| 前端 | `frontend/src/pages/LoginPage.vue` |
| 测试 | `tests/test_login_access.py` |

### P1～P4

| 层 | 文件 |
|----|------|
| 壳 | `frontend/src/App.vue`、`frontend/src/router/index.ts` |
| 缓存 | `frontend/src/composables/useCachedQuery.ts`（新）、各 `*Page.vue`、Pinia stores |
| 文档 | 本文件 + 各阶段 changelog |

---

## 7. 验收标准（整体）

### P0 必须

- 非白名单 IP 看不到登录框
- 白名单 IP / 关闭白名单行为与现网一致

### P1～P2 建议

- 菜单切换 5 个高频页（仪表盘/服务器/脚本/推送/设置）无白屏 remount
- 返回上一页表格滚动位置可选保留（keep-alive 自带）

### P3～P4 可选增强

- 重复进入服务器列表 30s 内无全屏 loading
- 侧栏悬停预取后首次进入该页 chunk 等待 < 200ms（弱网除外）

---

## 8. 推荐实施顺序

1. **先做 P0**（独立、低风险、用户明确诉求）→ 可单独部署
2. **P1** 与终端/脚本看板小修复并行时注意 `route.name` key 回归
3. **P2** 逐页启用 keep-alive + `onActivated`，每页验证
4. **P3/P4** 按仪表盘 → 服务器列表 → 其余页推广

---

## 9. 回滚

| 阶段 | 回滚 |
|------|------|
| P0 | 删除 `login-access` 端点；LoginPage 恢复常显表单 |
| P1 | 恢复 `route.fullPath` key |
| P2 | 移除 keep-alive 包装 |
| P3 | 停用 cached stores，页内仍 `onMounted` 拉数 |
