# Web 端本地缓存与最近文件优化(开发计划 · 细化版) > 状态:P0~P7 已实施。与 [API-PAGINATION.md](API-PAGINATION.md)「Web 虚拟 key」交叉引用。 --- ## 0. 背景与约束 ### 0.1 问题(已核实代码) | 现象 | 根因 | 代码位置 | |------|------|----------| | `GET /templates/{id}` 20~30s | PHP 二次 `json_encode` + 大响应 + 客户端双次 parse | `TemplateService::fetchPublicJson`;`web.js` ~268 行 | | 重复打开仍打源站 | 无 IndexedDB | 全库无 `indexedDB` | | 首页最近文件慢 | 每卡 `readJsonFile` 全量 | `index.js` ~381 行 | | 保存后不显示 | Web `onCloudWriteDone` 未写 history | `design1/output.js` ~1266;仅 fs 分支 `saveHistory` | | session 易超配额 | 大 JSON 写 session/localStorage | `cloud-files.js` `soonPutSoonSession` | ### 0.2 硬约束 - **不改** `.soon` 格式;**保留** base64。 - **不上 CDN**;靠本机 IDB + 传输路径精简 + L1 元数据。 - 保存仍 `POST/PUT /files` 传完整 `json`;**仅读**本地优先。 - 配置:`frontend-web/config/local.js`、`backend-web/config/local.php`。 ### 0.3 三层架构 | 层 | 存储 | 内容 | 体积 | |----|------|------|------| | L1 | `localStorage` `soondesign_recent` | key/name/type/kind/fileId/thumbRef/time | <50KB | | L2 | IndexedDB `soondesign_local` | 完整 `.soon` 对象 + thumbs | ≤20 条 blob | | L3 | HTTP API | miss / 保存 / 可选元数据对账 | 按需 | ### 0.4 脚本加载顺序(三页统一) `index.web.html` / `design1.web.html` / `design2.web.html`: ``` config/local.js → auth → cloud-files.js → soon-local-store.js → soon-recent.js → web.js → 页面逻辑 ``` --- ## 1. 数据契约 ### 1.1 L1:`soondesign_recent` ```json { "version": 1, "items": [ { "key": "soondesign_file:12:v3", "name": "我的卡片.soon", "type": 1, "kind": "cloud", "fileId": 12, "thumbRef": "soondesign_file:12:v3", "time": "2026-06-08 14:30:00" } ] } ``` - `thumbRef`:与 L2 `thumbs` 的 cacheKey 相同(非独立字符串前缀)。 - 上限 **20**;`soonRecentUpsert` 去重后 unshift,截断尾部。 - **首页以 L1 为展示主数据源**;`GET /files` 仅后台补 `name`/`version`,不默认注入未打开过的云端文件。 ### 1.2 L2:IndexedDB `soondesign_local` v1 | Store | keyPath | value | |-------|---------|--------| | `blobs` | `cacheKey` | `{ json, name, type, source, updatedAt, savedAt, bytes }` | | `thumbs` | `cacheKey` | `{ dataUrl, savedAt }` | **cacheKey** | 来源 | cacheKey | 失效 | |------|----------|------| | 云文件 | `soondesign_file:{id}:v{ver}` | PUT 后 version+1 → 新 key;旧 blob 可 `soonLocalRemove` | | 模板 | `soondesign_template:{id}` | 见 §1.4 | | 会话 | `soondesign_session:...` | 迁入云 key 后 `soonLocalRenameKey` | **LRU**:`soonLocalEvictLRU(20)` 在每次 `put` 后执行;仅删 blob+thumb,**不删 L1**。 ### 1.3 L3:读取端点 | 场景 | 端点 | 禁止 | |------|------|------| | 模板首次 | `GET /api/v1/templates/{id}/file` | 门户默认不用 `GET /templates/{id}` 包装 | | 云文件 miss | `GET /api/v1/files/{id}` | — | | thumb 兜底 | `GET /templates/{id}/thumb`;P6 `GET /files/{id}/thumb` | 禁止为 thumb 拉全量 json | | 保存 | `POST`/`PUT /files` | 不变 | ### 1.4 模板缓存失效策略(细化) 1. 首页点模板前,`index.js` `openTemplateItem` 写入 `window._soonTemplateMeta = { id, name, type, updated_at }`(列表已有 `updated_at`)。 2. IDB `blobs` 的 `meta.updatedAt` 存该 `updated_at`。 3. `readJsonFile` IDB hit 时:若 `_soonTemplateMeta.updated_at` 存在且与 blob.meta.updatedAt 不一致 → 删 blob,`miss` 走网络。 4. 网络:`/templates/{id}/file` + parse;`soonLocalPut` 带最新 `updatedAt`。 5. 未经过首页的深链打开:无 meta 比较,信任 IDB(可接受;刷新列表后校正)。 ### 1.5 共享工具 `soonExtractThumbFromJson(json)` - 位置:`soon-local-store.js` 或 `soon-recent.js`。 - 取 `json.frontDisplayPic`;须 `data:image/` 开头。 - 超过 **102400** 字节则返回 `''`(不写 thumbs,首页用 type 默认图)。 - 打开/保存/导入成功后统一调用。 --- ## 2. 核心流程 ### 2.1 打开(readJsonFile) ```mermaid sequenceDiagram participant UI as design_page participant IDB as L2_IDB participant L1 as L1_recent participant API as L3_server UI->>IDB: soonLocalGet(cacheKey) alt hit_and_valid IDB-->>UI: json else miss_or_stale UI->>API: GET file_or_files API-->>UI: soon_bytes UI->>IDB: soonLocalPut + PutThumb UI->>L1: soonRecentUpsert end UI->>UI: openFile_loadFromJSON ``` **补充接入点**(P3):`doOpenWithJson` 完成画布加载后 `soonRecentUpsert`(模板/云文件/会话均记录,kind 由 key 推断)。 ### 2.2 保存(writeFile + onCloudWriteDone) **模板首次保存**:`soondesign_template:*` 或空 key 时弹窗,默认文件名 `design` + `yyMMddHHmmss` + `.soon`(如 `design260610103010.soon`);保存成功后 L1 用 cloud/local 条目替换原 template 项(`soonRecentOnCloudSave` + `prevOpenKey`)。 ```mermaid sequenceDiagram participant UI as save participant IDB as L2 participant L1 as L1 participant API as server UI->>IDB: soonLocalPut当前json UI->>L1: soonRecentUpsert alt cloud_key UI->>API: PUT API-->>UI: new_version UI->>IDB: RenameKey_or_new_put UI->>L1: upsert新key else new_or_template UI->>API: POST end ``` ### 2.3 首页最近文件 ```mermaid sequenceDiagram participant Index as index participant L1 as L1 participant IDB as thumbs participant API as metadata_optional Index->>L1: soonRecentList loop each_card Index->>IDB: soonLocalGetThumb(key) alt no_thumb Index->>Index: soonAsset默认图或thumb_URL end end opt logged_in Index->>API: listCloudFiles元数据 Index->>L1: 仅补name/version不增项 end ``` ### 2.4 删除卡片 | 操作 | L1 | L2 | 服务器 | |------|----|----|--------| | 删本地项 | `soonRecentRemove` | `soonLocalRemove` | — | | 删云文件 | `soonRecentRemove` | `soonLocalRemove` | `DELETE /files/{id}` | | 旧 `removeLocalHistoryPath` | 改为调上述 API | 同上 | — | --- ## 3. 分阶段任务(可独立验收) ### Phase 0:契约文档 — 已完成 - [x] 本文档 - [x] `API-PAGINATION.md` 链接与 `/file` 推荐说明 --- ### Phase 1:IndexedDB 模块(1.5h) **新建** [`frontend-web/js/common/soon-local-store.js`](frontend-web/js/common/soon-local-store.js) | 任务 ID | 任务 | 细节 | |---------|------|------| | P1.1 | `soonLocalOpen` | `indexedDB.open('soondesign_local', 1)`;`onupgradeneeded` 建 `blobs`/`thumbs` | | P1.2 | `soonLocalGet/Put` | `json` 存对象;`bytes` 估算 `JSON.stringify` 长度 | | P1.3 | `soonLocalPutThumb/GetThumb` | thumbs store | | P1.4 | `soonLocalRemove` | 同时删 blobs+thumbs | | P1.5 | `soonLocalRenameKey` | 读旧→写新→删旧(session→cloud) | | P1.6 | `soonLocalEvictLRU` | 按 `savedAt` 升序删至 ≤20 | | P1.7 | `soonExtractThumbFromJson` | §1.5 | | P1.8 | 降级 | `indexedDB` 不可用 → 所有 get 返回 null,put 静默失败 | | P1.9 | HTML 引入 | 三页在 `web.js` 前引入 | **验收** - [ ] DevTools Application → IDB 可见 stores - [ ] put/get 往返;第 21 条触发 evict - [ ] 隐私模式不抛未捕获异常 --- ### Phase 2:web.js 读写穿透(2h) **改** [`frontend-web/js/platform/web.js`](frontend-web/js/platform/web.js) | 任务 ID | 任务 | 细节 | |---------|------|------| | P2.1 | `resolveCacheKey(key)` | 模板/云/ session 统一;云用完整 `soondesign_file:id:vN` | | P2.2 | read 模板 stale | §1.4;miss 走 `GET .../templates/{id}/file` + `text()` + `JSON.parse` | | P2.3 | read 云文件 | IDB miss → 现有 `authedFetch files/{id}`;成功后 put | | P2.4 | read session | IDB → sessionStorage 兜底(过渡期) | | P2.5 | read 后写 thumb | `soonExtractThumbFromJson` + `soonLocalPutThumb` | | P2.6 | writeFile 成功 | `soonLocalPut`;云 POST/PUT 成功后若 key 变化 `soonLocalRenameKey` | | P2.7 | write 未登录 | session key + IDB 双写;**停止** `localStorage.setItem(sessionKey, 大JSON)` | **验收** - [ ] 模板首次:Network 仅 `/templates/{id}/file`;二次 0 请求 - [ ] 云文件二次打开无 `GET /files/{id}` - [ ] PUT 后 `openAs.name` 与新 version key 一致且 IDB 可命中 --- ### Phase 3:L1 最近列表 + 保存/打开写 L1(2h) **新建** [`frontend-web/js/common/soon-recent.js`](frontend-web/js/common/soon-recent.js) | 任务 ID | 任务 | 细节 | |---------|------|------| | P3.1 | `soonRecentUpsert` | 按 `key` 去重;字段 §1.1;`getDate()` 时间格式与现 `saveHistory` 一致 | | P3.2 | `soonRecentList/Remove` | — | | P3.3 | `soonRecentKindFromKey` | `cloud`/`template`/`local` | | P3.4 | `soonRecentMigrateFromHistory` | 读 `soondesign_history`;`path`→`key`;无 thumb;**仅执行一次**(`soondesign_recent_migrated` 标记) | | P3.5 | `onCloudWriteDone` | design1/2 `output.js`:upsert(`res.fileKey`,`res.name`, soonType) | | P3.6 | `saveHistory` | Web:`soonRecentUpsert`;Electron fs 路径保留 | | P3.7 | `cloud-files` | `soonOpenSoonJsonLocally`、import 成功 upsert | | P3.8 | `openFile` 加载成功 | design1 `doOpenWithJson` 末尾;design2 `saveInitialState` 末尾 upsert | | P3.9 | `index` 开模板 | `openTemplateItem` 设置 `_soonTemplateMeta`(§1.4) | **验收** - [ ] 云端 Ctrl+S 后回首页立即见卡片 - [ ] 打开模板/云文件后 L1 有对应项 - [ ] 旧 history 用户升级后 L1 有条目(可无 thumb) --- ### Phase 4:首页 loadHistory 改造(1.5h) **改** [`frontend-web/js/index.js`](frontend-web/js/index.js) | 任务 ID | 任务 | 细节 | |---------|------|------| | P4.1 | 数据源 | `soonRecentList()` 为主;启动时 `soonRecentMigrateFromHistory` | | P4.2 | 删除 merge 云端追加 | 移除 `mergeRecentItems` 中「云端未见过则 push」逻辑;改为 `enrichFromCloud(items, cloudMeta)` 只补 name/version | | P4.3 | 渲染 thumb | `soonLocalGetThumb(item.key)` → fallback `soonAsset(bg_1/2)`;模板项可用 `/thumb` URL(`updated_at` 作 `?v=`) | | P4.4 | 删除 readJsonFile 循环 | 移除 ~381 行;**删除** `fileExists` 灰显逻辑(改为始终可点;打开时 miss 再拉+toast) | | P4.5 | 删除 | `removeLocalHistoryPath` → `soonRecentRemove` + `soonLocalRemove` | | P4.6 | 刷新 | `pageshow`/`visibilitychange` 调 `loadHistory`(60s 节流 `_recentLoadedAt`) | | P4.7 | 分页 | 保留 `fileListState` 对 L1 items 分页 | **验收** - [ ] 首页 Network:0× 全量 `files/{id}` / `templates/{id}` - [ ] 12 卡仅 thumb 图或 0 额外请求(IDB 命中) - [ ] 从设计页返回可见新保存项 --- ### Phase 5:session 迁移与清理(1h) | 任务 ID | 任务 | 细节 | |---------|------|------| | P5.1 | `soonPutSoonSession` | 正文 IDB;sessionStorage 仅存 `{key}` 标记或短指针 | | P5.2 | `readJsonFile` session | 优先 IDB | | P5.3 | `soonTryConsumeCloudImport` | 成功:`soonLocalRenameKey` + `soonRecentUpsert` + 删 session | | P5.4 | 清理 | 移除 `cloud-files`/`index` 对大 JSON 的 localStorage 写入 | | P5.5 | `index` 删卡 | 清 sessionStorage 旧 key(保留现有 769 行逻辑并接 IDB) | **验收** - [ ] 本地 .soon → 编辑 → 保存 → key 为 `soondesign_file:*` - [ ] sessionStorage 无 >1MB 字符串 --- ### Phase 6:后端轻量增强(可选,1h) | 任务 ID | 文件 | 细节 | |---------|------|------| | P6.1 | `TemplateService::outputFile` | `Cache-Control: public, max-age=86400, must-revalidate` | | P6.2 | `FileController` + 路由 | `GET /api/v1/files/{id}/thumb` | | P6.3 | `FileService` | 上传/更新时 `extractThumb` 入 DB(可复用 `TemplateService` 逻辑);`list` 增 `has_thumb` | | P6.4 | `docs/API-PAGINATION.md` | 登记 thumb 端点 | **验收** - [ ] `curl -I .../templates/1/file` 含 `max-age=86400` - [ ] 云 thumb 返回 `image/jpeg` --- ### Phase 7:全链路验收(0.5h) | # | 场景 | Network | UI | |---|------|---------|-----| | 1 | 首次开模板 | 1× `/templates/{id}/file` | 画布 OK | | 2 | 再次开同模板 | 0 全量 | <1s | | 3 | 模板改 updated_at 后再开 | 1× `/file` | 新内容 | | 4 | 保存新文件(登录) | POST | 回首页有卡 | | 5 | 再次开云文件 | 0 全量 | PUT 可保存 | | 6 | 首页 12 最近 | 0 全量 json | thumb OK | | 7 | 未登录保存 | 无 API | L1+IDB | | 8 | 删云文件卡 | DELETE | L1+IDB 清除 | | 9 | IDB 满 20 再开第 21 | 1× 网络 | 最旧 blob 淘汰 | | 10 | 模板列表 | 1× `/templates` 元数据 | 不变 | **部署**:前端 P1~P5 同批;强刷 `soon-local-store.js`/`soon-recent.js`/`web.js`/`index.js`;P6 可稍后。 --- ## 4. 改动文件清单 | 阶段 | 文件 | |------|------| | P1 | `soon-local-store.js`;`pages/index|design1|design2.web.html` | | P2 | `platform/web.js` | | P3 | `soon-recent.js`;`design1/2/output.js`;`cloud-files.js`;`index.js`(meta) | | P4 | `index.js` | | P5 | `cloud-files.js`;`web.js` | | P6 | `TemplateService.php`;`FileController.php`;`FileService.php`;`public/index.php`;`API-PAGINATION.md` | **不改动**:`.soon` schema;`design*-back.js`;Electron(后续可选对齐)。 --- ## 5. 依赖关系 ```mermaid flowchart TD P0[P0_done] P1[P1_IDB] P2[P2_web_bridge] P3[P3_recent_L1] P4[P4_index] P5[P5_session] P6[P6_backend_opt] P7[P7_QA] P0 --> P1 --> P2 --> P3 --> P4 --> P5 --> P7 P2 --> P6 --> P7 ``` **顺序**:P1 → P2 → P3 → P4 → P5 → P7;P6 与 P3/P4 并行。 --- ## 6. 风险与缓解 | 风险 | 缓解 | |------|------| | IDB 配额 | LRU 20;thumb ≤100KB;evict 失败 toast | | 多设备 | 云 version 为准;409 提示刷新 | | 模板 stale 漏检 | 首页列表带 `updated_at`;§1.4 | | PUT 后双 version 缓存 | `onCloudWriteDone` rename + 删旧 key | | 隐私模式 | 降级 session+网络,与现网一致 | | 旧用户无 recent | `soonRecentMigrateFromHistory` 一次 | --- ## 7. 不在 scope - CDN;`.soon` 剥离 base64;Electron IDB;CRDT 离线合并。 --- **确认后**按 P1→P7 实施;每阶段勾选任务 ID 验收后再进入下一阶段。