Files
Nexus/docs/design/specs/2026-06-02-files-refactor-design.md
2026-07-08 22:31:31 +08:00

13 KiB
Raw Permalink Blame History

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

1 背景与根因

1.1 已知症状

  • 进目录有"双刷新":每次点击目录,前端发 2 次 /api/sync/browse(实测间隔 ~1.3s),表格视觉上经历"显示空 → 显示数据"两段。
  • FilesPage.vue 单文件 1500+ 行:路径、文件、剪贴板、权限、editor、对话框、上传 全部扁平在顶层;onMountedroute.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/browse401fetchAuthedfrontend/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.tsfetchAuthed 调用前主动检查 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 最终仅:

<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 + 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}

ETagbrowse 响应头加 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.pyPlaywright):

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 算错或服务器拒绝);
    • 不影响 downloadPostapi() 上层。
  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=8hook window.fetch 监控 /sync/browse 调用次数;进 5 个目录 → 必须 ≤ 5 次(不是 10 次)
  4. 截图保存到 docs/reports/2026-06-02-token-refresh-verification.md

完成标志:进目录单次 HTTP 请求 + 控制台无 401。