13 KiB
文件管理器重构方案 — 2026-06-02
1 背景与根因
1.1 已知症状
- 进目录有"双刷新":每次点击目录,前端发 2 次
/api/sync/browse(实测间隔 ~1.3s),表格视觉上经历"显示空 → 显示数据"两段。 FilesPage.vue单文件 1500+ 行:路径、文件、剪贴板、权限、editor、对话框、上传 全部扁平在顶层;onMounted、route.query.server_idwatch、onServerChange三处都调browse(),副作用难追踪。- 没有缓存 / 没有目录树:每次 navigate 都是 round-trip,用户体感慢。
- POST /sync/browse 不符合 REST:路径在 body,无法用 HTTP 缓存 / ETag。
1.2 双刷新根因(CDP 实测)
- SPA 启动后 access token 过期未提前刷新;
- 第 1 次
/api/sync/browse→ 401(fetchAuthed见frontend/src/api/index.ts:88-105); - 自动
POST /auth/refresh刷新; - 第 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 字段则复用,否则新增tokenExpgetter)
验收:
- 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 最终仅:
<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:
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):
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"。改动清单:
frontend/src/stores/auth.ts
- 新增
tokenExpcomputed:从当前tokenJWT 解码exp(秒)- 新增
isTokenExpiringSoon(thresholdSec = 300):返回exp - nowSec < thresholdSecfrontend/src/api/index.ts
fetchAuthed:发请求前检查auth.isTokenExpiringSoon(300)且非AUTH_SKIP_REFRESH路径 →await tryRefreshSession()再发;- 保留现有 401 兜底重试不变(万一 exp 算错或服务器拒绝);
- 不影响
downloadPost与api()上层。frontend/src/pages/FilesPage.vue
- 删除
_browseSeq已经够用,保留作为防抖兜底;- 删除
console.trace(已删,确认无残留)。docs/changelog/2026-06-02-files-token-proactive-refresh.md:完整记录变更- 部署:
bash deploy/deploy-frontend.sh(或现有部署流程),清理旧 bundle验收(必跑):
tsc --noEmit通过npx vite build通过- 部署后用 cursor-ide-browser 打开
/app/#/files?server_id=8,hookwindow.fetch监控/sync/browse调用次数;进 5 个目录 → 必须 ≤ 5 次(不是 10 次)- 截图保存到
docs/reports/2026-06-02-token-refresh-verification.md完成标志:进目录单次 HTTP 请求 + 控制台无 401。