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

316 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 文件管理器重构方案 — 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。