Files

316 lines
13 KiB
Markdown
Raw Permalink Normal View History

# 文件管理器重构方案 — 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 # 新增 RESTfulGET /files/browse 等
```
### 2.2 数据流(单一真相)
```
URL hash (server_id, path) ── 唯一真相
useFilesNavigation (watch route.query) ── 防抖 0mshash 已防抖)
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 ── 拆分 composable4 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 + ETag3 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。