# 文件管理器重构方案 — 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 ``` --- ### P3 ── Pinia store + 缓存(2 h) `stores/files.ts`: ```ts export const useFilesStore = defineStore('files', () => { const currentServer = ref(null) const currentPath = ref(DEFAULT_FILES_ROOT) const fileCache = ref(new Map()) // key = `${serverId}:${path}` const clipboard = ref(null) const capability = ref(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` 数据**,加 `` 浮在表头下方; - 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。