# 文件管理器重构方案 — 2026-06-02

## 1 背景与根因

### 1.1 已知症状
- **进目录有"双刷新"**：每次点击目录，前端发 2 次 `/api/sync/browse`（实测间隔 ~1.3s），表格视觉上经历"显示空 → 显示数据"两段。
- **`FilesPage.vue` 单文件 1500+ 行**：路径、文件、剪贴板、权限、editor、对话框、上传 全部扁平在顶层；`onMounted`、`route.query.server_id` watch、`onServerChange` 三处都调 `browse()`，副作用难追踪。
- **没有缓存 / 没有目录树**：每次 navigate 都是 round-trip，用户体感慢。
- **POST /sync/browse 不符合 REST**：路径在 body，无法用 HTTP 缓存 / ETag。

### 1.2 双刷新根因（CDP 实测）
1. SPA 启动后 access token 过期未提前刷新；
2. 第 1 次 `/api/sync/browse` → **401**（`fetchAuthed` 见 `frontend/src/api/index.ts:88-105`）；
3. 自动 `POST /auth/refresh` 刷新；
4. 第 2 次重试 → 200。

序号保护（`_browseSeq`）只能防"旧响应覆盖新响应"的视觉错乱，**无法消除两次 HTTP**。彻底解决必须从 token 续期机制 + 数据流入手。

### 1.3 验收标准（DoD）
| 项 | 标准 |
|---|---|
| 进目录 HTTP 请求次数 | **≤ 1 次**（缓存命中 0 次） |
| 视觉刷新次数 | **≤ 1 次**（无"空 → 有"两段） |
| 进目录响应（缓存命中） | < 50 ms |
| 进目录响应（未命中） | < 800 ms |
| `FilesPage.vue` | **≤ 250 行**（仅布局 + 路由同步） |
| Token 续期 | 距过期 < 5 min 主动刷新，业务请求遇 401 概率 = 0 |
| 大目录 5000 项 | 虚拟滚动，60fps |
| 现有功能 | 全部保留（上传/下载/编辑/复制粘贴/删除/重命名/新建/sudo 一键配置/权限提示） |

---

## 2 目标架构

### 2.1 目录结构

```
frontend/src/
├── pages/
│   └── FilesPage.vue                     # ≤ 250 行：仅布局 + 路由参数同步
├── stores/
│   └── files.ts                          # Pinia：当前服务器/路径/缓存/剪贴板/权限
├── composables/files/
│   ├── useFilesNavigation.ts             # 路径解析、面包屑、上下导航
│   ├── useFilesBrowse.ts                 # 浏览（带缓存 + 序号保护 + abort）
│   ├── useFilesSelection.ts              # 勾选/取消/全选/反选
│   ├── useFilesClipboard.ts              # 复制/剪切/粘贴（沿用现有，迁入 store）
│   ├── useFilesActions.ts                # 删除/重命名/新建/上传/下载
│   ├── useFilesPermissions.ts            # capability + sudo 自动配置
│   └── useFilesEditor.ts                 # 编辑器开/关/保存
├── components/files/
│   ├── FilesToolbar.vue                  # 顶栏（服务器选择 / 路径 / 类型筛选 / 操作按钮）
│   ├── FilesBreadcrumb.vue               # 面包屑（含家目录、上级链接）
│   ├── FilesList.vue                     # 文件列表表格（虚拟滚动）
│   ├── FilesPermissionAlert.vue          # 权限横幅（仅在真有错误时显示）
│   ├── FilesUploadDialog.vue             # 上传对话框
│   ├── FilesRenameDialog.vue             # 重命名对话框
│   ├── FilesNewDialog.vue                # 新建文件/目录对话框
│   └── FileEditorWorkbench.vue           # （沿用现有）
└── utils/
    ├── remotePath.ts                     # （沿用）
    ├── fileBrowse.ts                     # （沿用）
    ├── fileMode.ts                       # （沿用）
    └── filePreload.ts                    # （沿用）

server/api/
├── sync_v2.py                            # 保留旧 POST /sync/browse 兼容期
└── files.py                              # 新增 RESTful：GET /files/browse 等
```

### 2.2 数据流（单一真相）

```
URL hash (server_id, path) ── 唯一真相
        │
        ▼
useFilesNavigation (watch route.query) ── 防抖 0ms（hash 已防抖）
        │
        ▼
useFilesBrowse.fetch(server_id, path)
        │
        ├── 缓存命中 (TTL 5s) ──→ 立即返回 store.files
        │
        └── 缓存未命中 ──→ 发请求（带 AbortController）
                          │
                          ├── 成功 → 写缓存 + 更新 store
                          └── 失败 → 错误状态
        │
        ▼
FilesList.vue 响应式渲染
```

**关键约束**：
- 任何 `browse` 入口（点击目录/面包屑/手动输入路径/刷新）**只改 URL**，不直接调 `browse()`；
- `useFilesNavigation` 监听 URL，统一驱动 `useFilesBrowse.fetch`；
- 三处 `browse()` 调用 → 收敛到 1 处。

---

## 3 阶段计划

### P0 ── Token 提前续期（立即消除双刷新，30 min）

**问题**：401 → refresh → 重试，业务请求被打了两次。

**方案**：在 `api/index.ts` 的 `fetchAuthed` 调用前**主动检查 token exp**：
- 若 token 距过期 < 5 min → 先 `await tryRefreshSession()` 再发请求；
- 用 JWT decode（不验签，仅取 `exp`）；并发请求合并使用现有 `refreshInFlight` 锁。

**改动文件**：
- `frontend/src/api/index.ts`（核心）
- `frontend/src/stores/auth.ts`（如有 exp 字段则复用，否则新增 `tokenExp` getter）

**验收**：
- DevTools Network：从仪表盘进 Files 后点击 10 个不同目录，**只有 10 次 `/sync/browse`**（不是 20 次）。
- 控制台 0 次 `/auth/refresh`（如有，说明 token 续期触发，正常）。

---

### P1 ── 拆分 composable（4 h）

把 `FilesPage.vue` 中的逻辑按域拆分到 `composables/files/`。

| 文件 | 暴露 |
|---|---|
| `useFilesNavigation.ts` | `currentPath`, `breadcrumbs`, `navigateTo(path)`, `goUp()`, `goHome()` |
| `useFilesBrowse.ts` | `files`, `loading`, `error`, `fetch(force?)`, `invalidateCache(path?)` |
| `useFilesSelection.ts` | `selected`, `selectAll()`, `clear()`, `toggle(item)` |
| `useFilesActions.ts` | `rename`, `remove`, `mkdir`, `mkfile`, `upload`, `download`, `compress` |
| `useFilesPermissions.ts` | `capability`, `accessHint`, `setupSudo()`, `dismissHint()` |
| `useFilesEditor.ts` | `editorOpen`, `editingFile`, `openFor(item)`, `close()`, `onSaved()` |

**约束**：
- 每个 composable 独立可测；
- 通过 `useFilesStore()` 共享跨 composable 的状态；
- `FilesPage.vue` 只 import composable + 模板。

---

### P2 ── 拆分组件（3 h）

把模板拆到 `components/files/`：

- `FilesToolbar.vue`：服务器下拉、路径输入、类型/排序、所有操作按钮
- `FilesBreadcrumb.vue`：面包屑链
- `FilesList.vue`：表格 + 行点击/双击/上下文菜单 + 选择
- `FilesPermissionAlert.vue`：权限横幅 + 一键 sudo 按钮
- `FilesUploadDialog.vue` / `FilesRenameDialog.vue` / `FilesNewDialog.vue`

**`FilesPage.vue`** 最终仅：

```vue
<template>
  <v-container fluid class="pa-6">
    <FilesToolbar />
    <FilesPermissionAlert />
    <FilesBreadcrumb />
    <FilesList @open="openItem" @edit="editItem" @download="downloadItem" />
    <FilesUploadDialog v-model="uploadOpen" />
    <FilesRenameDialog v-model="renameOpen" :item="renameItem" />
    <FilesNewDialog v-model="newOpen" :kind="newKind" />
    <FileEditorWorkbench v-model="editorOpen" :file="editingFile" />
  </v-container>
</template>
```

---

### P3 ── Pinia store + 缓存（2 h）

`stores/files.ts`：

```ts
export const useFilesStore = defineStore('files', () => {
  const currentServer = ref<number | null>(null)
  const currentPath = ref<string>(DEFAULT_FILES_ROOT)
  const fileCache = ref(new Map<string, CacheEntry>())  // key = `${serverId}:${path}`
  const clipboard = ref<Clipboard | null>(null)
  const capability = ref<FilesCapability | null>(null)

  // TTL 5s + LRU 50 项
  function getCached(serverId: number, path: string): FileEntry[] | null { ... }
  function setCached(serverId: number, path: string, entries: FileEntry[]) { ... }
  function invalidate(serverId: number, path?: string) { ... }  // path=null 清空该服务器全部

  return { currentServer, currentPath, fileCache, clipboard, capability, getCached, setCached, invalidate }
})
```

**写操作（删除/重命名/新建/上传）后立即 `invalidate(serverId, parentPath)`** —— 下次 navigate 强制刷新。

---

### P4 ── 后端 RESTful + ETag（3 h）

新增 `server/api/files.py`（保留旧 `sync_v2.py` 兼容期）：

| 旧 | 新 |
|---|---|
| `POST /api/sync/browse` body=`{server_id, path}` | `GET /api/files/browse?server_id=&path=` |
| `POST /api/sync/download-file` | `GET /api/files/read?server_id=&path=` |
| 内嵌于 `sync_v2` 的 write/mkdir/rename/delete | `POST /api/files/{action}` |

**ETag**：`browse` 响应头加 `ETag: W/"sha256(name|mtime|size 串接)"`；客户端发 `If-None-Match`，命中 304 → 后端跳过 SSH。

**前端**：`useFilesBrowse` 在缓存有 etag 时带 `If-None-Match`。

---

### P5 ── 视觉零闪烁（1 h）

`FilesList.vue`：
- `loading=true` 时**保留旧 `files` 数据**，加 `<v-progress-linear indeterminate>` 浮在表头下方；
- 200ms 防抖：请求 < 200ms 完成不显示加载条；
- `#no-data` 仅在 `!loading && files.length === 0` 时显示。

---

### P6 ── 虚拟滚动（大目录优化，2 h）

引入 `vue-virtual-scroller`（或 Vuetify 4 内置 `virtual-items`），文件 > 200 项启用。

---

### P7 ── 验收测试（2 h）

`tests/test_files_page_e2e.py`（Playwright）：

```python
def test_no_double_browse_request():
    """进入 10 个目录，HTTP 请求恰好 10 次"""
def test_visual_no_flicker():
    """快速点击目录，文件列表无闪烁（屏幕录像帧分析）"""
def test_token_proactive_refresh():
    """token 距过期 4 min 时主动刷新，无 401"""
def test_cache_hit():
    """重复进入同一目录，第二次 0 网络请求"""
```

---

## 4 兼容与迁移

- 后端旧路由 `POST /sync/browse` 保留（标 deprecated 注释），3 个版本后删除。
- 前端 token 续期机制对所有 API 通用，受益的不仅是 Files 页。
- `web/app/assets/FilesPage-*.js` 旧 bundle 部署后清理（脚本：`deploy/clean-stale-bundles.sh`）。

---

## 5 风险与回滚

| 风险 | 影响 | 缓解 |
|---|---|---|
| Token 主动续期错误导致频繁 refresh | 性能下降 | 仅在 `exp - now < 300s` 触发；保留 401 重试兜底 |
| 缓存导致用户看到过期数据 | UX 混淆 | TTL 5s + 写操作主动 invalidate + 刷新按钮强制 force=true |
| 拆分引入回归 | 功能丢失 | 每个 composable 提取后立刻跑 E2E；保留旧 `FilesPage.vue` 备份 |
| 后端新路由破坏 Agent | 子服务器同步中断 | 新路由在 `/files/*`，旧 `/sync/*` 保留 |

回滚：`git revert` 重构 commit；旧 bundle 仍在服务器上，前端可立即回退。

---

## 6 工时与里程碑

| 阶段 | 工时 | 累计 | 部署 |
|---|---|---|---|
| P0 Token 续期 | 0.5 h | 0.5 h | ✅ 立即（消除双刷新） |
| P1 拆 composable | 4 h | 4.5 h | 内部 |
| P2 拆组件 | 3 h | 7.5 h | ✅（功能等价版本） |
| P3 Store + 缓存 | 2 h | 9.5 h | ✅ |
| P4 后端 REST + ETag | 3 h | 12.5 h | ✅ |
| P5 零闪烁 | 1 h | 13.5 h | ✅ |
| P6 虚拟滚动 | 2 h | 15.5 h | ✅ |
| P7 E2E 验收 | 2 h | 17.5 h | — |

**最小可发布版本 = P0 + P5（共 1.5 h）**：双刷新立即消除 + 视觉零闪烁。其余阶段渐进迭代。

---

## 7 文档与 changelog

- 设计文档：本文档（`docs/design/specs/2026-06-02-files-refactor-design.md`）
- 技术文档：每阶段开始前补 `docs/design/plans/2026-06-02-files-refactor-Px.md`
- changelog：每次部署写 `docs/changelog/2026-06-02-files-refactor-Px.md`
- 完成后更新 `CLAUDE.md` / `AGENTS.md` 中 Files 模块描述

---

## 8 立即执行项（让 Composer 2.5 做的第 1 步）

> **任务标题**：P0 — 实现 JWT access token 提前续期，消除文件管理器进目录双刷新
>
> **必读**：本文档（`docs/design/specs/2026-06-02-files-refactor-design.md`）"§1.2 双刷新根因" 与 "§3 P0"。
>
> **改动清单**：
> 1. `frontend/src/stores/auth.ts`
>    - 新增 `tokenExp` computed：从当前 `token` JWT 解码 `exp`（秒）
>    - 新增 `isTokenExpiringSoon(thresholdSec = 300)`：返回 `exp - nowSec < thresholdSec`
> 2. `frontend/src/api/index.ts`
>    - `fetchAuthed`：发请求**前**检查 `auth.isTokenExpiringSoon(300)` 且非 `AUTH_SKIP_REFRESH` 路径 → `await tryRefreshSession()` 再发；
>    - 保留现有 401 兜底重试不变（万一 exp 算错或服务器拒绝）；
>    - 不影响 `downloadPost` 与 `api()` 上层。
> 3. `frontend/src/pages/FilesPage.vue`
>    - 删除 `_browseSeq` 已经够用，**保留**作为防抖兜底；
>    - 删除 `console.trace`（已删，确认无残留）。
> 4. `docs/changelog/2026-06-02-files-token-proactive-refresh.md`：完整记录变更
> 5. 部署：`bash deploy/deploy-frontend.sh`（或现有部署流程），清理旧 bundle
>
> **验收（必跑）**：
> 1. `tsc --noEmit` 通过
> 2. `npx vite build` 通过
> 3. 部署后用 cursor-ide-browser 打开 `/app/#/files?server_id=8`，hook `window.fetch` 监控 `/sync/browse` 调用次数；进 5 个目录 → 必须 ≤ 5 次（不是 10 次）
> 4. 截图保存到 `docs/reports/2026-06-02-token-refresh-verification.md`
>
> **完成标志**：进目录单次 HTTP 请求 + 控制台无 401。
