Files
UHD_Media_Tool/docs/plan/06-file-manager-spec.md
T
2026-09-27 22:34:13 +08:00

23 KiB
Raw Blame History

06 文件管理页规格(/console/files)

来源:00-first-version-plan.md §6 追加范围;对照 docs/porting/moviepilot-filemanager.md(下称"MP 账本",痛点编号 P1–P12)。 需求约束:REQ §2.1 五原则、§7.1/§7.1.1(路径绑定固定、媒体级主动整理)、§8.2(扫描与"失败≠不存在")、§8.3(Copy/Move、删除保护)。 本文只定规格,不含实现。实现时 contracts 先行(packages/contracts/src/files.ts)。

1. 目标与边界

目标:覆盖 MP 文件管理的全部操作(浏览、存储/根切换、面包屑、目录树、排序过滤、新建目录、重命名、删除、识别、刮削、整理、批量、预览/下载),并在四个方面做得更好:

  1. 有业务上下文:每一行都显示"已登记资产 / 纳管下载中 / 临时文件 / 待识别",不用挨个点识别(P5)。
  2. 安全:只能浏览登记过的根;删除进回收站并做引用检查;改名同步资产与路径绑定(P1/P2/P3)。
  3. 一条向导走完整理:识别 → 逐项确认作品/季/集/Slot → dryRun 预览前后路径 → 执行 → 跳转整理记录(P7/P8)。
  4. 大目录与离线可用:游标分页、截断提示、挂载离线显式报错而不是显示空目录(P6/P12)。

边界(不做):

  • 整库迁移、底层分支盘/存储池调度(REQ §7.1.1);网盘存储(115/阿里/Alist/SMB/rclone)——只有本机可达路径(含 NFS/FUSE 挂载)。
  • 不提供文件上传、在线编辑、解压;下载只允许非媒体小文件(≤ 20 MB,如 NFO/字幕/图片),视频不经浏览器下载。
  • 浏览器无媒体库写权限原则不变:所有写操作由服务端执行,浏览器只提交意图;服务端做守卫、授权、审计。
  • 硬链接/软链接创建、空目录上溯清理、蓝光原盘整目录操作:列为 W5(与 moviepilot-rust-transfer.md 的 W5 项一起)。
  • 不改元数据自动刷新语义:刷新不改路径;只有本页的"重命名/移动/整理"是显式路径变更。

2. 后端 API

2.1 模块归属

  • 新建子模块 apps/server/src/modules/acquisition-organize/files/(路由 routes/files.ts + 服务 services/files*.ts),复用 organize-guard.ts、planImport、volumes.ts。
  • POST /files/identify 需要身份候选排序,归 sources-identity 模块注册(遵守"模块间不互相 import service",它只读 DB + 调 TMDB 适配器,不落库)。
  • 路径守卫下沉:把 organize-guard.ts 里的 sourceGuard 的纯 IO 部分抽到 packages/adapters/src/fs/guard.ts(输入:根列表;输出:check(p) → realpath),两个模块共用;根列表由各自从 DB 读取。
  • 前缀统一 /files,scope:读 organize.read;写 organize.execute,另加 §2.4 的授权规则。

2.2 读接口

GET /files/roots → FileRoot[]

字段 说明
id lib:<library_root.id> 或 dl:<downloader_instance.id>:<mapping idx>
kind library | download
name / hostPath 显示名 / 本机路径(下载根 = 路径映射的 hostPath;无映射时取 localSavePath)
mediaKind 库根的 movie/tv/mixed;下载根为 null
writable 库根 management_mode='managed' 且进程对目录有 W_OK;register_only 恒 false(只读登记)
health healthy/degraded/offline/unknown + checkedAt,来自 checkRoot(哨兵)实时探测,≤2 s 超时
usage diskUsage 的 total/free,失败为 null

与 allowedSourceRoots 同源,根 / 永不出现。

GET /files?root=&path=&cursor=&limit=&sort=&order=&q=&filter=&hidden= → { root, path, entries, nextCursor, total, truncated, scannedAt }

  • path 是根内相对路径("" = 根);服务端 join(root.hostPath, path) 后过守卫。响应里不回显绝对路径以外的东西。
  • 目录读取用 opendir 流式;单次最多读 FILES_LIST_MAX_ENTRIES(默认 20 000)项,超出 truncated=true,前端提示"请用搜索或进入子目录"。
  • 排序 name(自然排序,目录优先)/ mtime / size / status;order=asc|desc;q 为子串或 glob(大小写不敏感);filter 可多选:video|subtitle|registered|unregistered|downloading|temp|unidentified;hidden=false 默认隐藏点文件和 .uhd-partial/.umt-trash/哨兵。
  • 分页:排序在服务端全量(≤上限)完成后按 limit(默认 200,最大 1000)切片,cursor 为不透明偏移 + 目录快照 mtime;目录 mtime 变了返回 stale: true 让前端重载。
  • 每个 entry:
字段 说明
name / type(file|dir|symlink|other)/ size / mtime lstat;符号链接不跟随,仅标记,目标在根外时 escape: true 并禁用所有操作
ext / fileKind video/subtitle/audio/nfo/image/other(同 domain/organize 的分类)
status 见下表,只取一个主状态,按优先级
asset 已登记时:{ assetId, locationId, mediaEntityId, title, year, contentUnit(S01E02), slotId?, slotStatus?, isPrimary }
download 路径落在纳管 download_task.content_path(经 hostPathOf 映射)内:{ taskId, status, progress, hnr: satisfied|unsatisfied|unknown }
media 已登记资产取 media_asset.media_info 摘要(分辨率/HDR/编码/音轨数);未登记不探测,仅在 /files/probe 后缓存
dirSummary 目录项:懒计算,仅列表当前页的目录给 { videoCount, registeredCount },上限 500 项/目录,超出给 partial: true

status 优先级:temp(.uhd-partial、.!qB、.part)> downloading(纳管任务未完成)> registered(有 available 的 asset_location)> seeding(纳管任务已完成未入库/或 Copy 后仍做种)> unidentified(视频且无资产)> plain。

  • 资产关联查询:本页 entries 批量 WHERE library_root_id=? AND relative_path = ANY(?),下载关联按任务 content_path 前缀,不逐行查库。

GET /files/stat?root=&path= → 单项 entry + { realPath, inode, device, nlink, sidecars[], bindings[] }。bindings 为命中该目录的 path_binding(作品/季);nlink>1 标记"共享 inode,禁止原地改写"(REQ §8.3)。

GET /files/download?root=&path=:仅 fileKind ∈ {nfo, subtitle, image} 且 ≤ 20 MB;image 同时作为预览。

挂载离线:根哨兵检查失败或 readdir 报 EIO/ENOTCONN/ESTALE/ETIMEDOUT → 503 root_unavailable,不返回空列表(原则 4,P12)。

2.3 写接口

所有写请求体都带 idempotencyKey(前端生成 UUID);同键重放返回首次结果(存 task.idempotency_key)。所有写操作:守卫 → 授权 → 前置检查 → 执行 → audit_event(action files.<op>,payload 含 root/相对路径/结果)→ SSE files.changed { rootId, dirs[] }。

端点 请求 行为
POST /files/mkdir {root, path, name} sanitizeSegment(name) 后与原值不同则 400;mkdir 非递归,已存在 409;只允许 writable 根
POST /files/rename {root, path, newName, withSidecars=true, dryRun} 同目录改名,见 §2.3.1
POST /files/move {items:[{root,path}], target:{root,path}, mode:"move"|"copy", dryRun, planId?} 见 §2.3.2
POST /files/delete {items:[{root,path}], mode:"trash"|"permanent", dryRun} 见 §2.3.3
POST /files/trash/restore / GET /files/trash / POST /files/trash/purge — 回收站列表/还原/清空
POST /files/identify {items:[{root,path}], hints?:{mediaKind,year,tmdbId}} 见 §2.3.4,只读
POST /files/organize 见 §2.3.5 复用 /organization-records
POST /files/scrape {mediaEntityId, writeNfo:boolean, images:boolean} 见 §2.3.6
POST /files/probe {items:[{root,path}]}(≤ 20 个视频) ffprobe 摘要;已登记资产写回 media_asset.media_info,未登记只缓存 24 h(DATA_DIR/cache/probe)

2.3.1 rename

  • newName 过 sanitizeSegment;不得含 /;目标已存在 → 409(不覆盖,修 P3)。实现用 link+unlink 或 renameat2(RENAME_NOREPLACE) 语义,不能用会覆盖的裸 rename;Deno 下用"先 lstat 再 rename + 事后 inode 比对"并在 asset_operation 记账。
  • withSidecars:视频改名时同 stem 的字幕/NFO/图片一起改(复用 domain/organize/sidecars.ts 的匹配规则),dryRun 返回全部前后名。
  • 已登记资产:同一事务里更新 asset_location.relative_path(含 sidecar 的 location),写 asset_operation 账本(commit 阶段);发 organization_record(trigger=manual, action=rename)(枚举已有 rename)让外部程序增量感知。
  • 目录改名命中 path_binding:这是显式路径变更(REQ §7.1.1),插入新绑定并把旧绑定置 superseded、superseded_by_id 指向新行,bound_by = actor;该目录下所有 asset_location 的 relative_path 前缀批量替换。仅译名刷新不触发,只有本端点和整理会改。
  • 目录内有纳管下载任务(做种中):拒绝 409 —— 改名会让 qB 找不到文件;提示先完成/移交。
  • 递归自动改名(MP 的 recursive)不照搬:改为 §3.4 向导里"按模板重命名"的一种模式,先预览全部,再一次执行,单项失败不影响其他项并列出失败清单(修 P9)。

2.3.2 move / copy

  • 目标必须是 writable 根下的已存在目录;源 ≠ 目标、目标不在源内。
  • 已登记资产或含已登记资产的目录:不走裸 FS,转成 organize plan(记录 action 用已有的 relocate/copy):planImport({sourcePaths, action, targetLibraryRootId, ...}) 的"原样搬运"变体(保持文件名,目标目录由用户指定)——需要在 ImportRequest 加 targetDir(绝对路径,须在库根下)与 keepNames: true。计划照常检查冲突、下载器引用、可用空间、H&R;执行走现有 verifyAndImport → 逐文件可恢复、跨卷 Copy→核验→删源、EXDEV 不降级。完成后 asset_location/path_binding 更新由整理流程负责。
  • 未登记文件(如下载根里的散文件、临时整理):同样走 organize plan,但 registerAssets=false,只产生 organization_record,不建资产。这样搬运都是可恢复的,不存在第二套搬运代码。
  • dryRun=true 返回 planId + 前后路径表 + 冲突/空间/H&R 结论;执行必须带 dryRun 返回的 planId,服务端校验计划内容哈希与当前请求一致,且源文件 size/mtime 与计划冻结时一致,否则 409 plan_stale(dryRun 一致性)。
  • mode=move 授权同 /organization-records:admin 会话,或 API Key + grant/approval;H&R 未满足 → 409 + 待处理,不静默改 Copy。

2.3.3 delete(原则 5)

  • 默认 mode=trash:同卷移入 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>(rename,O(1));根不可写或跨卷时移入 DATA_DIR/trash/ 需 Copy,超过 FILES_TRASH_CROSS_MAX_BYTES(默认 2 GiB)拒绝并提示用永久删除。trash_item 表记录原根、原相对路径、actor、时间、关联资产快照。
  • 保留 FILES_TRASH_RETENTION_DAYS(默认 7);清理由持久任务 purgeTrash 每日执行,删前复核 trash_item 仍存在。还原:原路径被占用 → 409,可选"还原到…"。
  • mode=permanent:设置项 files.allowPermanentDelete(默认 false)开启后才可用,且仅 admin 会话;前端二次确认输入文件名。
  • 引用检查(两种模式都做,dryRun 可预览):
    情况 处理
    命中纳管 download_task 的 content_path(未完成或仍在做种/H&R 未满足) 拒绝 409,列出任务
    已登记资产(asset_location available) 允许,但必须勾选"同时退役资产":asset_location→removed,media_asset→retired,若为 Slot 满足资产则 Slot 退回 attention 并建待处理;写 organization_record(action=delete)
    目录是 library_root 本身、path_binding 绑定目录 根本身拒绝;绑定目录需勾选"解除绑定"(绑定置 superseded),否则 409
    共享 inode(nlink>1) 允许,提示"只删除这一处链接"
    根离线/stat 失败 拒绝(原则 4),不当作已删除
  • 删除永不连带 qB 任务;qB 侧清理仍只走 retention 流程。

2.3.4 identify(只读)

  • 对每个选中项:parseReleasePath(文件路径含父目录上下文)→ 本地实体优先召回(title_alias pg_trgm + external_identity)→ 不足时查 TMDB(走缓存)→ rankCandidates。目录项先展开为其中视频(上限 200)。
  • 返回每项:{ parsed(标题/年份/季集/分辨率/来源/发布组), candidates:[{mediaEntityId?, provider, externalId, title, year, kind, level, reasons[]}] (≤5), suggestion:{mediaEntityId?, contentUnit?, slotId?}, existing:{assetId?} }。
  • 不写库:不建 media_entity、不写 identity_revision、不进待识别池;用户在向导里确认后才由 organize 落库。resolveRelease 需要拆出纯排序函数(rankForPath)供这里调用。

2.3.5 organize

  • 请求:{ items:[{root, path, mediaEntityId, contentUnitId?, season?, episodes?, slotId?}], action:"copy"|"move", targetLibraryRootId?, dryRun, planId? }。
  • 服务端按 mediaEntityId 分组,每组调用一次 POST /organization-records 同一实现(planImport),因此自动与手动整理产出同格式记录(REQ §7.1.1)。
  • 缺口:现有 OrganizeRequest 只接受整批一个 mediaEntityId/contentUnitId,没有逐文件季集覆写。需扩 contracts:fileOverrides: [{path, contentUnitId?, season?, episodes?}],planImport 在 facts 生成时优先用覆写值;slotId 用于指定满足哪个 Slot(缺省由 Slot 评估选)。
  • dryRun 返回每组 planId;执行请求携带全部 planId,逐组执行,任一组 plan_stale 只让该组失败。执行返回 recordIds,前端跳 /console/organize?ids=。

2.3.6 scrape

  • 只对已登记作品:等价于 POST /media-entities/{id}/refresh(字段级、用户锁优先、不动路径),可选 writeNfo/images 把最终字段投影成 NFO/海报写到绑定目录。
  • 写 NFO 规则(REQ §7.3):临时文件 + 原子提交;目标 NFO 若非本系统生成(无 <!-- umt --> 标记)且字段已被用户锁定来源为"NFO 导入",不覆盖,返回 skipped;不写媒体容器字节。NFO 投影本体目前未实现,列入后端工作量。
  • 未登记文件点"刮削" → 前端引导先走识别/整理,不提供"直接按路径刮削写 NFO"(避免对未登记内容写文件,修 P10)。

2.4 授权矩阵

操作 admin 会话 API Key
roots/list/stat/download/identify/probe(只读缓存) ✓ organize.read
mkdir / rename / copy / organize(copy) ✓ organize.execute
move / organize(move) ✓ organize.execute + automation_grant 覆盖目标作品,或一次性 approval(绑定 planId 哈希)
delete(trash) ✓ organize.execute + approval(每次)
delete(permanent)、trash purge 仅 admin 会话 ✗

Agent 侧暂不暴露 files.* 能力;若加,只开放 files.list/identify(只读)。

3. 前端规格 /console/files

3.1 布局

  • 桌面(≥ lg):左栏 pg-chrome 贴边 260 px(可拖宽,存 localStorage):根列表(按 库/下载 分组,每根带健康 StatusBadge 和剩余空间)+ 当前根的懒加载目录树;右侧 pg-slab:工具栏(面包屑、搜索、筛选、排序、视图、显示隐藏文件开关、新建目录、刷新)+ 列表 + 底部状态栏(项数、选中数/总大小、截断提示)。
  • 选中单项时右侧抽屉 DetailSheet:文件信息、资产/作品/Slot 链接、下载任务链接、sidecars、ffprobe 摘要、路径绑定;图片/NFO/字幕文本预览。
  • 移动端(< md):根和树收进 Sheet,列表单列卡片(名称 + 状态徽标 + 大小/时间一行),长按进入多选,操作放底部操作栏;面包屑折叠为"…/父/当前"。
  • URL 状态:?root=&path=&q=&sort=&sel= 可分享、可回退;刷新保持位置。

3.2 列表

  • 列:选择框 | 图标 + 名称(已登记显示作品标题副行)| 状态徽标 | 季集 | 大小(目录显示视频数/已登记数)| 修改时间 | 行操作(⋯)。
  • 状态徽标(StatusBadge + 文字,不只靠颜色):已登记(success)/ 下载中 n%(neutral)/ 做种中·H&R 未满足(warning)/ 临时文件(neutral)/ 待识别(warning)/ 链接逃逸(destructive)。
  • 虚拟滚动(新增依赖 @tanstack/react-virtual,需登记许可 MIT);服务端分页 200 条,滚到底自动取下一页。
  • 快捷筛选 chips:全部 / 视频 / 待识别 / 已登记 / 下载中 / 临时文件;搜索框支持子串与 glob,300 ms 防抖,服务端过滤。
  • 空态:空目录(EmptyState plain,提供"新建目录")、过滤无结果(提供"清除筛选")、根离线(destructive 提示 + 上次健康时间 + "重试")、截断("只显示前 20 000 项,请搜索")、无权限。

3.3 操作

  • 入口三处一致:行 ⋯ 菜单、右键菜单(桌面)、多选后的浮动操作栏。菜单按条件禁用并在 tooltip 说明原因(例如"做种中,不能改名")。
  • 单项:打开 / 重命名(F2)/ 移动到… / 复制到… / 整理到… / 识别 / 刮削(已登记)/ 探测 / 删除(Del)/ 复制路径(本机路径 + 下载器路径 + 媒体服务器路径三种,取路径映射)/ 在整理记录中查看。
  • 多选:整理向导 / 识别 / 移动到 / 复制到 / 删除 / 探测。
  • 重命名:行内编辑;右侧"建议名"按钮(按模板渲染,来自 identify 结果);显示将一起改名的 sidecars;已登记/绑定目录时提示"将同步资产与路径绑定"。
  • 移动/复制到:目标选择器复用目录树,只列 writable 根;先显示 dryRun 预览(前后路径、冲突、空间、H&R),再确认。
  • 删除:对话框列出引用检查结果,默认"移入回收站(7 天后清除)";需要额外勾选的项逐条列出。
  • 拖拽:把列表项拖到树节点或面包屑段 = "移动到…",松手后一律打开 dryRun 预览对话框,不直接执行;按住 Alt 为复制。
  • 键盘:↑↓ 移动焦点,Enter 进入/打开,Backspace 上一级,Space 勾选,Shift+点击/Shift+↑↓ 区间选,Ctrl/Cmd+A 全选,F2 改名,Del 删除,/ 聚焦搜索,Esc 清除选择;列表为 role="grid",焦点可见。
  • 长任务(整理/移动)提交后 toast 带任务链接,列表通过 SSE files.changed 与 organization.* 事件自动刷新受影响目录。

3.4 批量整理向导(Sheet 全高,4 步)

  1. 识别:对选中项调 /files/identify,表格:源文件 | 解析(标题/年/季集/质量)| 候选作品(下拉,显示等级与理由,可搜索改选)| 季/集(可编辑,支持批量"集号 +N 偏移")| Slot(下拉,默认自动)。同一目录的项默认共享作品选择;"应用到同组"按钮。未决项(无候选/低置信)高亮,必须处理或排除才能下一步。
  2. 选项:动作 Copy/Move(Move 在有 H&R 未满足项时禁用并说明)、目标库根(按作品类型过滤)、是否按模板重命名(默认是)、已存在目标处理(跳过/冲突待处理)。
  3. 预览:调用 dryRun,按作品分组显示前后路径对照(差异高亮)、新建目录、将创建/沿用的路径绑定(已绑定显示"沿用 旧目录名",体现原则 2)、冲突、空间、截断警告。任何组失败可返回第 1 步修改。
  4. 执行:提交 planId,显示每组状态;完成后按钮"查看整理记录"跳 /console/organize?ids=…。

3.5 与 MP 的对比(更好用的具体点)

MP 我们
从 / 浏览整个文件系统(P1) 只列登记的库根与下载根,带健康与空间
列表无业务状态(P5) 每行显示已登记/下载中/做种/待识别,并链接作品、Slot、任务
删除直接 rmtree(P2) 回收站 + 引用检查 + 资产退役联动
改名不同步历史、可能覆盖(P3) 不覆盖;同步 asset_location、path_binding 修订、sidecars;发整理记录
无通用移动/复制(P4) 移动/复制到 + 拖拽,统一走可恢复的 organize plan
识别单结果只读,与整理分离(P7) 候选 Top5 可改选,结果直接进入向导
批量整理只能一组参数(P8) 逐项作品/季/集/Slot,按作品分组预览
递归改名失败即中止(P9) 全量预览,逐项结果,失败不影响其他
刮削恒覆盖 NFO(P10) 字段锁 + 仅对已登记作品 + 非本系统 NFO 不覆盖
无键盘/拖拽/右键/隐藏文件开关(P11) 全部提供
离线显示空目录、大目录全量返回(P6/P12) 503 显式离线;游标分页 + 截断提示

3.6 设计纪律

遵守 docs/design/README.md:只用语义 token;材质:左栏 pg-chrome,列表 pg-slab,菜单/对话框 pg-float,卡片内空态用 EmptyState plain;复用 PageHeader、FilterBar、DataTable(虚拟化版本新增 virtual 选项而非另造表格)、DetailSheet、ConfirmDialog、StatusBadge、EmptyState;新增共享件 DirTree、PathBreadcrumb、ContextMenu(基于 ui/popover)放 components/console/。导航:nav.ts 加"文件"工作区。deno task check:tokens 必须绿。

4. 不变量、测试与实施拆分

4.1 数据与安全不变量

  1. 任何请求解析出的 realpath 必须在某个登记根之下;..、绝对 name、符号链接逃逸、根本身写操作一律拒绝;遍历中每个文件复核(TOCTOU:执行阶段再次 realpath)。
  2. 浏览器永远不直接写文件;所有写操作有 audit_event 与幂等键。
  3. 路径变更只来自显式操作(rename/move/organize),且同事务更新 asset_location 与 path_binding 修订;元数据刷新不改路径。
  4. 删除不影响 qB 任务;纳管做种文件不可改名/删除/Move(除非 H&R 满足且有授权)。
  5. 失败≠不存在:离线/超时不改变任何资产状态、不显示为空。
  6. 执行只接受 dryRun 产出的 planId,且源文件事实未变。
  7. 不覆盖已存在文件;replace 只在整理流程的 replace_with_verify 策略下发生。

4.2 测试要求

  • 路径逃逸:.. 段、URL 编码、符号链接指向根外(文件与目录)、name 含 /、根为 / 的配置被过滤;执行期把文件换成符号链接(TOCTOU)。
  • 删除保护:纳管做种文件 409;已登记资产未勾选退役 409、勾选后 location=removed/Slot→attention;根本身拒绝;回收站还原到原位、原位被占 409;purge 只删过期项;离线根拒绝。
  • rename 同步:文件改名 → asset_location 与 sidecar 同步;目录改名命中绑定 → 新绑定 active、旧绑定 superseded 且链接;之后新集入库进入新目录;同名存在 409 且原文件未动;做种目录 409。
  • dryRun 一致性:dryRun 与执行对同一输入产出相同文件对;dryRun 后改动源(size/mtime)执行返回 plan_stale;planId 与请求体不匹配 403/409;幂等键重放不重复执行。
  • 列表:分页稳定性、超大目录截断(生成 25 000 个空文件)、离线 503、状态徽标优先级、批量资产关联只发常数次查询。
  • 前端:键盘导航、多选区间、向导步骤门禁(未决项不能下一步)、移动端布局快照;e2e:下载根散文件 → 识别 → 向导 Copy 入库 → 整理记录出现 → 列表状态变"已登记"。

4.3 实施拆分(两路并行,contracts 先合并)

路 范围 估计
0. contracts(后端路先做,半天) files.ts schema、OrganizeRequest 扩 fileOverrides/targetDir/keepNames/registerAssets、organization action 加 delete(已有 rename/relocate)、trash_item 迁移 0.5 d
A. 后端 guard 下沉到 adapters;roots/list/stat/download(含资产/下载关联与分页);mkdir/rename(同步资产+绑定);move/copy/organize 适配 planImport;回收站 + purgeTrash 任务 + 引用检查;identify(sources-identity 拆 rankForPath);probe;scrape(refresh 包装 + NFO 投影);授权矩阵、审计、幂等;§4.2 后端测试 5–6 d
B. 前端 路由与布局、根/树/面包屑、虚拟列表与筛选、详情抽屉、行/右键/批量操作、重命名/移动/删除对话框(含 dryRun 预览)、4 步整理向导、拖拽与键盘、移动端、空态/错误态;前端测试 + e2e 5 d

B 可在 contracts 合并后用 MSW/mock 先行;联调 1 d。NFO 投影若排期紧可拆到 W5,scrape 先只做字段刷新。