316 lines
13 KiB
Markdown
316 lines
13 KiB
Markdown
# 文件管理器重构方案 — 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。
|