# 缓存清理功能设计(2026-07-28) > 配套前置分析:本次会话中已扫描 `frontend/` 全量代码,识别出 4 大类存储(uni 本地持久化、内存缓存、临时文件、沙盒文件系统)。本文把"统一清理入口"这一需求转化为**可执行的 MVP 实施方案**。 --- ## 一、方案概述(必读) ### 要解决的问题 **业务问题** - 用户遇到"App 占用过大"、"切换账号后旧账号残留"、"创作草稿一直清不掉"、"活动进度缓存过期"等场景时,**没有统一的清理入口**。 - 当前只能在登出时被动清一部分(`store/modules/user.js#CLEAR_AUTH`),普通用户无法主动触发。 - 多个 utils 各自实现清理逻辑(`avatarCache.js`、`likeHelper.js`、`preloadApi/storage.js`、`ioPath.js`),但**没有一个聚合的 UI 入口**告知用户"我占了多少、可以清哪些"。 **技术问题** - 清理逻辑分散在 7+ 个文件,UI 层如果要"一键清理"必须串联调用 7 个函数,缺乏统一抽象。 - 新增缓存类型时需要改 UI(如未来加 `eventCache`),违反"加 handler 不动 UI"的原则。 - 没有 size 计算的统一口径:部分用 `JSON.stringify(v).length`,部分用 `uni.getStorageInfoSync().currentSize`,用户看到的"总占用"无来源解释。 ### 整体实现路径(MVP 阶段,1 周可落地) | Milestone | 主题 | 目标 | 工作量 | |-----------|------|------|--------| | **M0**(前置迁移) | 草稿 key 改造 | `utils/draftStorage.js` 封装 + 7 个文件读写改造(§11) | 1.5 天 | | **M1** | 核心封装层 | `cacheManager.js` + 黑名单 + 大小格式化 + `getCacheInfo` / `getCategoryBreakdown` / `cleanCategory` / `cleanCategoryGroup` 框架 | 0.5 天 | | **M2** | 6 个 handler | preload / draft / progress / guide / sandbox-tmp / others + 内存层清理接入 | 1 天 | | **M3** | UI 页面(列表 + 详情) | `cache-cleanup.vue` + `cache-cleanup-detail.vue` + ConfirmModal + profile.vue 入口 + pages.json | 1.5 天 | | **M4** | 测试 & 回归 | 单元测试 + 7 个手工场景(含跨账号分组清理)+ CLAUDE.md 自检 | 0.5 天 | 合计:约 **5 人天**(含 M0 前置迁移 + 详情页)。 ### 关键决策 1. **统一封装层模式(方案 A)**——新建 `frontend/utils/cacheManager.js`,UI 只调 manager,**永远不直接**调 `uni.removeStorageSync` 等底层 API。这与项目里 `frontend/utils/preloadApi/core.js` 已采用的封装模式一致。 2. **必须有二级详情页(用户决策)**——MVP 阶段**没有一键清理按钮**,每类缓存必须进详情页才能清理。理由: - 草稿(draft)和引导(guide)天然有"我的 / 其他用户的"分组(一台设备多账号切换常见),一键清理会把前任账号数据也清掉,体验突兀 - 用户进入详情页可以看到每个分组的占用与项数,**明确知道清的是什么** - 清理"其他用户的草稿"需要单独更强的警告(避免误操作),而清理"自己的草稿"只需要普通警告 - 其他分类(preload / progress / sandbox-tmp / others)详情页可简化(一组 + 单按钮) 3. **永远不清的 key 黑名单**——登录态(`access_token`/`user`/`star_id`/`login_mobile`/`cid`/`deviceFp`/`pending_scan_url`/`gallery_owner_id`)、设备态(`needs_welcome`/`has_seen_welcome`/`is_new_user`/`daily_login_completed_*`)、用户行为(`liked_assets_exhibition`)、外部资源(`avatar_file_*` 元数据)、注册中状态(`temp_register_*` 含明文密码)**五类** key 由 manager 内置黑名单拦截,**任何清理路径都不会触碰**。详见 §3.4.1。 4. **范围不包含头像/图片缓存**——`avatarCache.js` 的 `avatar_file_*` storage key 与 `uni.saveFile` 的 `savedFilePath` 均纳入黑名单(§3.4.1)。原因:用户误清会导致头像全部重新下载,体验差;且与本设计目标(清理临时性业务缓存)不符。**MVP 不纳入清理,仅作黑名单保护**。 5. **范围包含创作草稿(带警告)**——草稿 key 改造(§11 迁移项)后形式为 `*_${currentUid}`。**所有**草稿 key(无论当前 uid / 其他 uid / legacy 无后缀)**统一归 `draft` handler**,通过 `computeBreakdown()` 按 uid 分组展示;强警告只在清理"其他用户/legacy"组时触发。UI ConfirmModal 在清理自己草稿时弹普通警告(`handler.warning = true`),清理他人时弹强警告(`handler.strongWarning = true`)。**不**走 `others` handler 兜底,避免双重计入 totalBytes 且强警告无法生效。 6. **preload handler 清所有用户**——`preload:${oldUid}:*` 是跨账号的真正垃圾。spec 的 `preload.clean()` 通过遍历所有 `preload:` 前缀 key 一次性删,不依赖 currentUid。 7. **cleanAll 同步调 `invalidateAll()`**——`preloadApi/core.js` 的 `memoryMap` 是进程级共享,切账号不清会残留老用户数据。`cacheManager.cleanAll()` 在 storage/sandbox 清理完成后调 `core.invalidateAll()` 清空整个内存层(含 `inFlightMap`)。 8. **草稿 key 改造为 uid 绑定(新增迁移项)**——将 `castlove_form_data` 等草稿 key 改为 `castlove_form_data_${currentUid}` 等 uid 后缀形式(详见 §11 迁移项),账号切换后看不到对方草稿(隐私 + 体验)。**所有**草稿 key(无论 currentUid / 其他 uid / legacy 无后缀)**统一归 `draft` handler**,通过 `computeBreakdown()` 按 uid 分组展示;强警告只在清理"其他用户/legacy"组时触发。**不**走 `others` 兜底(避免双重计入 totalBytes 且强警告无法生效)。 ### 核心架构图(TL;DR) ``` ┌──────────────────────────────────────────┐ │ pages/profile/cache-cleanup.vue │ ← 列表页(无清理按钮,只跳转) │ - 顶部:大字 + 百分比 + 进度条 + 3 chip │ │ - 缓存分类(每行 → 进详情) │ │ - "其他" section(不可点击) │ └──────────────┬───────────────────────────┘ │ uni.navigateTo ┌──────────────▼───────────────────────────┐ │ pages/profile/cache-cleanup-detail.vue │ ← 详情页(按 id 分发) │ - 简单页:单按钮 │ │ - 分组页:按 uid 分组卡片 + 每组单按钮 │ └──────────────┬───────────────────────────┘ │ 调用 ┌──────────────▼───────────────────────────┐ │ utils/cacheManager.js │ ← 统一封装层(核心) │ - registerCategory(handler) │ │ - getCacheInfo() │ │ - getCategoryBreakdown(id) │ │ - cleanCategory(id) / cleanCategoryGroup(id, {uid}) │ │ - cleanAll() — 编程式入口,UI 不调用 │ │ - PROTECTED_KEYS(内置黑名单) │ └──────────────┬───────────────────────┘ │ 委托 ┌───────────┼─────────────┬─────────────────────┐ ▼ ▼ ▼ ▼ preload draftHandler sandboxTmp 各种 handler Handler (含警告) Handler (基于 (每个独立模块) (基于 ioPath.js) preloadApi) ``` --- ## 二、文档说明 - **适用范围**:`frontend/` 全部业务代码(不含 `uni_modules/` 第三方模块);uni-app app-plus 端为主,H5/小程序兼容性按现有项目规范。 - **工作量估算**:5 人天(MVP,包含列表页 + 详情页 + 跨账号分组清理)。 - **前置版本**:基线 commit `ea39ee1`(feat:修改图片尺寸和uni配置)。 - **目标读者**:前端工程师、测试、产品。 - **后续优化(不在 MVP)**: - ~~二级详情页~~(MVP 包含) - 头像/图片缓存清理(需评估用户体验) - 清理历史记录(用户看到"上次清理于 X 分钟前") - 自动清理策略(按 LRU 自动触发) --- ## 三、Handler 接口契约 ### 3.1 类型定义 ```js /** * @typedef {Object} CacheCategoryHandler * @property {string} id // 唯一 ID,如 'preload' / 'draft' / 'sandbox-tmp' * @property {string} label // UI 展示名(中文) * @property {string} description // 副标题/说明(可选) * @property {() => Promise<{sizeBytes: number, keyCount: number}>} computeSize * // 列表页汇总展示用 * @property {() => Promise>} [computeBreakdown] * // 详情页按 uid 分组(draft/guide 用,其他可选) * @property {() => Promise<{freedBytes: number, keyCount: number}>} [clean] * // 简单页清理(无分组):清理整类 * @property {(params: {uid: string|null}) => Promise<{freedBytes: number, keyCount: number}>} [cleanGroup] * // 分组页清理:按 uid 维度清理,uid=null 表示"其他用户" * @property {boolean} [warning] // 是否需要"草稿会丢失"警告 * @property {string} [warningText] // 警告文案(如 draft: "将清空你未提交的创作草稿,是否继续?") * @property {boolean} [strongWarning] // 是否需要强警告(用于清理他人数据) * @property {string} [strongWarningText] // 强警告文案(如 draft-other: "将清空 uid 10002 的草稿,对方下次登录不会看到,是否继续?") */ /** * @typedef {Object} GroupInfo * @property {string} uid // 'self' = currentUid,'others' = 其他用户聚合,'__legacy__' = 老无 uid 后缀 key,其他 = 具体 uid * @property {string} displayUid // UI 展示的 uid 字符串(如 "10001") * @property {boolean} isCurrent // 是否当前用户 * @property {number} sizeBytes * @property {number} keyCount * @property {boolean} [canClean] // 该分组是否可清理(handler 决定,UI 据此 enable/disable 按钮) * @property {string} [disabledReason] // 不可清理的原因('empty' / 'logged-out' / 'guide-current' / 'guide-global' / 'draft-legacy') */ ``` **Handler 接口选择规则**: - **简单 handler**(preload / progress / sandbox-tmp / others):实现 `computeSize` + `clean`,不实现 `computeBreakdown`/`cleanGroup` - **分组 handler**(draft / guide):实现 `computeSize` + `computeBreakdown` + `cleanGroup`;不实现 `clean`(分组页不提供"一键清整类"按钮,避免误清他人数据) ### 3.2 内置 Handler 列表 | Handler ID | label | description | warning | 说明 | |----------|------|-------------|---------|------| | `preload` | "预加载数据缓存" | "列表页提前拉取的数据" | false | `preloadApi/storage.js` 的所有 `preload:*` key | | `draft` | "创作中的草稿" | "未提交的创作表单/生成结果" | **true** | 草稿 key 改造(§11)后:`*_${currentUid}` 后缀形式。**实现方式**:handler 内部维护一个 7 个 base key 的静态数组(`['castlove_form_data', 'CASTLOVE_FORM_KEY', 'temp_nft_data', 'GENERATED_IMAGES_KEY', 'GENERATION_RESULT_META_KEY', 'LENTICULAR_STUDIO_STORAGE_KEY', 'CRAFT_SELECTED_IMAGE_KEY']`),运行时取 `currentUid` 拼成 `baseKey_${currentUid}`,遍历 `uni.getStorageInfoSync` 找匹配项。新 key 形式:`castlove_form_data_${currentUid}` / `CASTLOVE_FORM_KEY_${currentUid}` / ...(同上) | | `progress` | "活动进度缓存" | "支持活动页断网浏览" | false | 全部 `progress_${activityId}` key(实现方式:遍历 `uni.getStorageInfoSync` 中所有以 `progress_` 开头的 key 全部删除) | | `guide` | "引导记录" | "新手引导完成标记" | false | `guide_*` 系列(仅清非当前用户/会话残留;详见 §3.4) | | `sandbox-tmp` | "临时文件" | "上传/分享过程产生的临时文件" | false | `clearAllSandboxTmpFiles`(保留 `preload/share/image` 白名单) | | `others` | "其他业务缓存" | "未归类的少量业务数据" | false | 兜底分类:未匹配上述任一规则**且不在黑名单**的 key 汇总。**不**包含草稿 keys(任何 uid 后缀 + legacy),那些全部归 `draft` handler。包含需保护的工作流关键 key(`generation_flow_payload`、`__package_info__` 等)的**白名单排除**——这些 key 即使不在黑名单也不归 others 清理(避免破坏进行中的生成流程 / 升级包)。 | ### 3.3 cacheManager 公共 API ```js // 注册(通常在 manager 模块底部 import 时一次性注册) cacheManager.registerCategory(handler) // 列表页读取(汇总 + 存储配额 + 其他 section 数据) const info = await cacheManager.getCacheInfo() // → { // totalBytes, // 可清理总量(=所有 category.sizeBytes 之和,不含黑名单) // appUsedBytes, // 软件占用(WeChat 口径,包含黑名单)= uni.getStorageInfoSync().currentSize*1024 + 沙盒文件 // // 注:currentSize 是 SQLite 全量(含黑名单),这是有意的 —— 表达"app 真实占用"而非"可清理" // quotaTotalBytes, // 配额总量 = uni.getStorageInfoSync().limitSize*1024 // quotaAvailableBytes, // 配额可用 = quotaTotalBytes - appUsedBytes // usagePercent, // 百分比(0-100,1 位小数)= appUsedBytes / quotaTotalBytes * 100 // deviceTotalBytes, // 设备总存储 = plus.io.getStorageInfo().totalSize * 1024(HTML5+ API,MVP 已支持) // deviceFreeBytes, // 设备可用存储 = plus.io.getStorageInfo().availableSize * 1024 // deviceUsagePercent, // 设备占比(0-100,2 位小数)= appUsedBytes / deviceTotalBytes * 100 // othersBytes, // "其他" section 大小 = 所有黑名单 keys + 不可清理文件大小 // categories: [{id, label, description, sizeBytes, keyCount, warning}, ...] // } // 详情页读取(分组详情;简单 handler 返回 null) const groups = await cacheManager.getCategoryBreakdown(id) // → Array | null // 简单页清理(无分组维度) await cacheManager.cleanCategory(id) // → { freedBytes, keyCount } // 分组页清理(按 uid 维度,uid=null 表示其他用户聚合) await cacheManager.cleanCategoryGroup(id, { uid: '10001' }) // → { freedBytes, keyCount } // 编程式一键清理(UI 不调用;供登出流程/测试用,按 handler 顺序依次执行) await cacheManager.cleanAll() // → { freedBytes, perCategory: [{id, freedBytes, keyCount, error?}, ...] } ``` ### 3.4 关键设计决策 #### 3.4.1 永远不清的 key(黑名单,由 manager 内置) **精确匹配(全等字符串)**: - `access_token` / `user` / `star_id` / `login_mobile` / `cid` / `deviceFp` / `pending_scan_url` / `gallery_owner_id` / `needs_welcome` / `has_seen_welcome` / `is_new_user` **前缀匹配**: - `daily_login_completed_` —— 每日登录打卡 - `avatar_file_` —— 头像文件元数据(`avatarCache.js`,与 `uni.saveFile` 配对;MVP 不清理 `uni.saveFile` 的 savedFilePath,由 OS 缓存淘汰负责) - `liked_assets_exhibition` —— 用户点赞记录(点赞是用户行为,不是临时缓存) - `temp_register_` —— 注册中状态(**含 `temp_register_password` 明文密码**)。原因:用户注册途中点清理会导致注册流程不可恢复;密码明文也不应在 cache 层有任何存活窗口风险。 不论怎么点这些 key **绝对不会被清理**,保证: 1. 不会误踢登录(token/user/star_id/cid) 2. 不会误清点赞(用户主动行为,有业务价值) 3. 不会误清头像文件(避免全部重新下载,**这是 MVP 范围外的关键回归点**) 4. 不会误清注册中状态导致注册流程断掉 + 避免密码明文暴露风险 #### 3.4.2 引导记录(guide handler)保留规则 - **当前用户判定**:handler 启动时 `uni.getStorageSync('user')`,parse 出 `uid`。**取不到(未登录)则视为无当前用户**。 - **保留规则**(**登录态与未登录态一致**): - `guide_*_${currentUid}_*`(当前用户的引导进度)→ 全部保留 - 无 userId 段的 `guide_*`(`guide_shown_${configKey}`、`guide_debug_mode`、`guide_first_show`)→ **全部保留**(与登录态无关) - 其他用户的 `guide_done_${otherUid}_*` / `guide_step_${otherUid}_*` / `guide_rewards_claimed_${otherUid}` / `guide_completed_steps_${otherUid}_*` → 仅登录态可清理(**未登录态禁止清理**,按钮置灰 + tooltip"请先登录") - **未登录态**:currentUid = null,全部 `guide_*_${anyUid}_*` 都视作"其他用户"(按"其他用户的引导记录"清理规则处理,但**未登录时不可清理**);无 userId 段 `guide_*` 仍保留。 - **owner 段识别**:支持数字 uid(`guide_done_10001_*`)和 `default`(`guide_done_default_*`,guideConfig.js 在未登录场景下使用)两种。 - 实现方式:handler 拿到 currentUid 后,分类处理 —— 含 currentUid 段的跳过;无 userId 段但属于 `guide_shown_*`/`guide_debug_mode`/`guide_first_show` 的跳过;其余 `guide_*` 仅在登录态下可清理。 #### 3.4.3 分类分区规则(避免 others 误吞) 每个 key 的归属判定 **必须按以下优先级**: 1. 黑名单前缀(§3.4.1)→ **永不删**(不计入 totalBytes,不计入"其他"section,仅用于计算 `othersBytes`) 2. `preload:` 前缀 → `preload` handler 3. 创作草稿白名单(§3.2 draft) → `draft` handler 4. `progress_` 前缀 → `progress` handler 5. `guide_` 前缀 → `guide` handler(含 §3.4.2 的保留判断) 6. 其余非黑名单 key → `others` handler **关键**:§5.1 的 `getCacheInfo()` 必须先过滤黑名单 key,再分类计算。否则黑名单 key 既不会展示也不会被删,但会被错误计入 totalBytes 误导用户。 #### 3.4.4 内存层清理(详情页 + cleanAll 同步触发) `preloadApi/core.js` 的 `memoryMap` 是进程级单例 Map —— **所有用户的 preload 数据都在同一个 Map 里**。切账号不清会导致老用户条目挤占内存。 **`cacheManager.cleanAll()`** 在 storage/sandbox handler 全部完成后,**同步**调: ```js import { invalidateAll } from '@/utils/preloadApi/core' // ... await invalidateAll() // 清空 memoryMap + inFlightMap ``` **详情页 `cleanCategory('preload')` / `cleanCategoryGroup('preload', ...)`** 完成后,**同样**同步调 `invalidateAll()`。原因:用户从详情页清 preload 后,下一次进列表页如果 preload handler 仍命中 `memoryMap` 老数据,用户感知不到清理效果。 注意:`invalidateAll()` 不动 storage,只清内存层。storage 清理由 `preload.clean()` / `preload.cleanGroup()`(§5.2)负责。 #### 3.4.5 活跃创作页面保护(MVP 不做) 用户编辑中点清理导致草稿丢失,仅靠 UI ConfirmModal 文案警示,不做活跃检测(YAGNI)。后续优化可加页面级 dirty 标记。 --- ## 四、UI 页面结构 ### 4.1 入口 `pages/profile/profile.vue` 加菜单项「存储空间」点击进入 `cache-cleanup` 列表页。 ### 4.2 列表页(pages/profile/cache-cleanup.vue) **采用 WeChat 风格:顶部大字 + 百分比 + 进度条 + 一行 3 标签 + 缓存分类 + 其他 section**。**没有任何清理按钮**。 ``` ┌─────────────────────────────────────────────┐ │ ← 返回 存储空间 │ ← navbar ├─────────────────────────────────────────────┤ │ │ │ Topfans 已用空间 │ ← 小字 label │ │ │ 49.7 MB │ ← 大字号主指标(主色) │ │ │ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░ 16% │ ← 进度条(当前用量占配额比例) │ │ │ ┌────────────┬─────────────┬────────────┐ │ │ │ Topfans 已用空间 │ 内存缓存空间 │ 总内存空间 │ │ ← 一行 3 个 chip 标签(无数值) │ └────────────┴─────────────┴────────────┘ │ 数值已分别显示在:大字 / 缓存分类 sum / 总配额 │ │ │ ┌──────────────────────────────────────┐ │ │ │ 设备总空间 │ 设备可用 │ │ ← 新增:HTML5+ 设备级信息行 │ │ 128.0 GB │ 45.2 GB │ │ (plus.io.getStorageInfo;非 APP-PLUS 显示 "—") │ └──────────────────────────────────────┘ │ │ │ │ 占设备 0.04% 存储空间 │ ← 百分比副文字(分母=设备总存储,2 位小数) ├─────────────────────────────────────────────┤ │ 缓存分类 │ ← section 标题(次要色) ├─────────────────────────────────────────────┤ │ 预加载数据缓存 > │ ← 分类行(点击进详情) │ 15.2 MB · 142 项 │ ├─────────────────────────────────────────────┤ │ 创作中的草稿 > │ │ 2.4 MB · 3 项 ⚠ 按账号分组清理 │ ├─────────────────────────────────────────────┤ │ 活动进度缓存 > │ │ 0.4 MB · 5 项 │ ├─────────────────────────────────────────────┤ │ 引导记录 > │ │ 0.2 MB · 8 项 │ ├─────────────────────────────────────────────┤ │ 临时文件 > │ │ 1.5 MB · 15 项 │ ├─────────────────────────────────────────────┤ │ 其他业务缓存 > │ │ 0.7 MB · 12 项 │ ├─────────────────────────────────────────────┤ │ 其他 > │ ← 新增:黑名单/不可清理数据 │ 4.1 MB │ │ 包含运行 Topfans 的必要数据、账号会话、 │ │ 其他账号的数据等 │ └─────────────────────────────────────────────┘ ``` **3 个标签的语义对应**(与 WeChat 同款:"微信已用空间/磁盘已用空间/磁盘可用空间"): - **Topfans 已用空间** = 大字 `appUsedBytes`(顶部已显示) - **内存缓存空间** = 下方"缓存分类"的总和 = `totalBytes`(可清理部分) - **总内存空间** = `uni.getStorageInfoSync().limitSize` × 1024 = `quotaTotalBytes`(配额上限) 3 个标签**无数据值**(数据值已分别在:大字、缓存分类、总配额三处展示),纯起"标签导航"作用。 **"其他" section 的来源**: - 数据 = 黑名单 keys 大小 + 黑名单沙盒文件大小(**所有不该/不能清理的数据**) - 包括:登录会话(access_token/user/star_id/cid/deviceFp 等)、设备状态(needs_welcome/has_seen_welcome/is_new_user/daily_login_completed_*)、用户行为(liked_assets_exhibition)、头像缓存(avatar_file_* + savedFilePath)、注册中状态(temp_register_*)、其他账号的草稿/引导残留等 - 文案(仿 WeChat):"包含运行 Topfans 的必要数据、账号会话、其他账号的数据等" - 该行**不可点击进入详情**(没有可清理内容) **关于"磁盘" vs "配额"**: - **进度条**仍以 `uni.getStorageInfoSync().limitSize`(app 自己的 SQLite 配额)为 100%,表达"app 缓存压力"(用户的清理决策依据) - **百分比分母** = `plus.io.getStorageInfo().totalSize`(设备总存储),即"占设备 X% 存储空间"——HTML5+ 标准 API,Android/iOS 均原生支持,**不需要 native plugin** - **设备信息行** = `plus.io.getStorageInfo()` 直接拿 `{ totalSize, availableSize }`,无权限要求;非 APP-PLUS 平台或调用失败时显示 `—` **草稿/引导的二级提示**:行右侧副文字"按账号分组清理",警示用户详情页会有分组。 ### 4.3 详情页(pages/profile/cache-cleanup-detail.vue?id=xxx) **通用结构**:每个分类的详情页头部展示分类说明,中间展示分组(按需),底部展示清理按钮。 #### 4.3.1 简单详情页(preload / progress / sandbox-tmp / others) ``` ┌─────────────────────────────────────────────┐ │ ← 返回 预加载数据缓存 │ ├─────────────────────────────────────────────┤ │ 列表页提前拉取的数据 │ ← description ├─────────────────────────────────────────────┤ │ │ │ 占用 18.2 MB │ ← 大字号 │ 共 142 项缓存 │ │ │ ├─────────────────────────────────────────────┤ │ │ │ [ 清 理 缓 存 ] │ ← 主按钮 │ │ └─────────────────────────────────────────────┘ ``` 点击「清理缓存」→ ConfirmModal(带 warning 时显示警告)→ 确认 → 执行 → toast + 返回列表页(自动刷新)。 #### 4.3.2 分组详情页(draft / guide) **按 uid 分组展示,每组独立清理按钮**。 ``` ┌─────────────────────────────────────────────┐ │ ← 返回 创作中的草稿 │ ├─────────────────────────────────────────────┤ │ 未提交的创作表单/生成结果 │ ├─────────────────────────────────────────────┤ │ │ │ 👤 我的草稿(uid: 10001) │ │ 1.2 MB · 2 项 │ │ [ 清 理 ] │ │ ───────────────────────────────── │ │ 👤 其他用户的草稿(uid: 10002) │ │ 0.8 MB · 1 项 │ │ [ 清 理 ] ⚠ │ ← 更强警告 │ ───────────────────────────────── │ │ 👤 其他用户的草稿(uid: 10003) │ │ 0.4 MB · 0 项 │ ← 0 项时按钮置灰 │ [ 清 理 ] │ │ │ └─────────────────────────────────────────────┘ ``` **关键行为**: - 「我的草稿」按钮 → ConfirmModal 普通警告("将清空你未提交的草稿") - 「其他用户的草稿」按钮 → ConfirmModal **更强警告**("将清空 uid 10002 的草稿,对方下次登录不会看到。是否继续?") - 0 项分组的按钮置灰禁用 - 列表按 `sizeBytes` 降序排序,大占用在前 **引导详情页同结构**,但 description 与分组标题文案不同。 ### 4.4 交互流程 1. **列表页 onLoad**:调 `cacheManager.getCacheInfo()` → 渲染分类列表(带 loading skeleton) 2. **点击分类行**:`uni.navigateTo` 进 `cache-cleanup-detail?id=xxx` 3. **详情页 onLoad**:调 `cacheManager.getCategoryBreakdown(id)`(简单 handler 返回 `null`,UI 走简单页模板;分组 handler 返回 `Array`,UI 走分组页模板) 4. **详情页点清理按钮**(分组页每组一个,简单页一个):弹 ConfirmModal(按需 warning/strongWarning)→ 确认 → - 简单页:调 `cacheManager.cleanCategory(id)` - 分组页:调 `cacheManager.cleanCategoryGroup(id, { uid: 'self' | '具体uid' | null })`(uid='self' 表示当前用户,uid=null 表示"其他用户聚合") - → `uni.showLoading` → toast → 返回列表页(`uni.navigateBack`,列表页 `onShow` 自动重算 `getCacheInfo`) 5. **下拉刷新**(列表页):onPullDownRefresh 重新调 `getCacheInfo()` 刷新(页面级 enablePullDownRefresh) 6. **下拉刷新**(详情页):重新调 `cacheManager.getCategoryBreakdown(id)` ### 4.5 视觉规范 - 复用项目已有 `components/ConfirmModal.vue` - 分类行用 `pages/components/Header.vue` 同款 row 风格 - 警示文字用 `#FAAD14`(项目里的 warning 色);强警告用 `#FF4D4F` - 分组卡片用 `border-radius: 12rpx` 圆角,与列表项视觉区分 --- ## 五、数据流与加载策略 ### 5.1 首次加载 ``` pages/profile/cache-cleanup.vue onLoad ↓ cacheManager.getCacheInfo() ├─ [并行 1] 遍历所有注册 handler 调 computeSize() Promise.all │ ├─ preload.computeSize → 遍历所有 preload:* key │ ├─ draft.computeSize → 遍历白名单内 `*_${currentUid}` key(详见 §3.2 实现说明) │ ├─ progress.computeSize → 遍历 progress_* │ ├─ guide.computeSize → 遍历 guide_* │ ├─ sandbox-tmp.computeSize → 调用 ioPath.scanSandboxTmpFiles()(新增只读 API,返回 tmp 文件大小总和) │ └─ others.computeSize → 上述未匹配的 key 汇总 ├─ [并行 2] 读取存储配额 │ ├─ uni.getStorageInfoSync() → { currentSize (KB), limitSize (KB) } │ ├─ ioPath.getSandboxTotalSize() → 沙盒 doc 目录下所有文件总字节数(新增 ioPath 只读 API;与 scanSandboxTmpFiles 不同,前者含白名单 preload/share/image 所有文件,后者仅统计 tmp/) │ └─ ioPath.getDeviceStorageInfo() → 设备级 { totalSize (KB), availableSize (KB) },HTML5+ API,非 APP-PLUS 返回 {0,0} ↓ 聚合: totalBytes = sum(categories.sizeBytes) // 可清理总量(=内存缓存空间 chip) appUsedBytes = currentSize * 1024 + sandboxBytes // 软件占用(大字 + Topfans已用空间 chip) // 注:currentSize 是 uni storage 全量(含黑名单),这是 WeChat "已用空间" 口径 quotaTotalBytes = limitSize * 1024 // 配额总量(=总内存空间 chip,进度条 100% 基准) quotaAvailableBytes = quotaTotalBytes - appUsedBytes // 配额可用 usagePercent = (appUsedBytes / quotaTotalBytes) * 100 // 配额百分比(进度条用) deviceTotalBytes = deviceTotalKB * 1024 // 设备总存储(设备信息行"设备总空间") deviceFreeBytes = deviceFreeKB * 1024 // 设备可用(设备信息行"设备可用") deviceUsagePercent = (appUsedBytes / deviceTotalBytes) * 100 // 设备百分比(百分比副文字"占设备 X%"用) othersBytes = sum(黑名单 keys size) + 不可清理文件大小 // "其他" section ↓ 渲染页面(大字 + 百分比 + 进度条 + 3 chip 标签 + 6 缓存分类行 + 1 其他 section) ``` ### 5.2 清理流程 **用户从详情页清理(主要路径)**: ``` 用户在详情页点清理按钮(简单页单按钮 / 分组页每组按钮) ↓ ConfirmModal 显示预估 freedBytes(含 warning/strongWarning 时显示对应警告文案) ↓ 用户确认 ↓ cacheManager.cleanCategory(id) ← 简单页 或 cacheManager.cleanCategoryGroup(id, {uid}) ← 分组页(uid='self' 或具体 uid 或 null=其他用户聚合) ├─ 调对应 handler.clean() 或 handler.cleanGroup() ├─ 错误容错(单 handler 失败不影响) ↓ 返回 { freedBytes, keyCount } ↓ UI toast + uni.navigateBack(列表页 onShow 自动重算 getCacheInfo) ``` **编程式 cleanAll(登出/测试用,UI 不调用)**: ``` cacheManager.cleanAll() ├─ 顺序执行每个 handler.clean()(非 Promise.all,更安全) │ ├─ preload.clean → 遍历所有 `preload:` 前缀 key 删除(**所有用户**,不限于 currentUid;详见 §1 决策 6) │ ├─ draft.clean → 删除 `*_${currentUid}` 后缀的草稿 key(uid 改造完成后;详见 §11);迁移期内老无 uid 后缀 key 归 others │ ├─ progress.clean → 删除 progress_* key │ ├─ guide.clean → 删除其他用户的 `guide_*_${otherUid}_*` + 当前用户的 `guide_done_*`/`guide_step_*`/...(详见 §3.4.2) │ ├─ sandbox-tmp.clean → clearAllSandboxTmpFiles() │ └─ others.clean → 删除未分类 key(含迁移期遗留的无 uid 后缀草稿 key) ├─ ★ 同步清理内存层:core.invalidateAll()(详见 §3.4.4) ↓ 返回 { freedBytes, perCategory[] } ``` ### 5.3 关键设计决策 1. **getCacheInfo 异步并行**:6 个 handler 用 `Promise.all` 并行计算 2. **cleanAll 顺序执行**:部分清理会互斥,顺序更安全;用 `uni.showLoading({mask:true})` 包裹 3. **失败容错**:单个 handler clean() 失败不影响其他 handler,外层 try/catch 记录 error 并跳过 4. **空状态**:若 totalBytes = 0,列表页显示 "✓ 当前无缓存可清理",所有分类行点击无响应(toast 提示"暂无缓存") 5. **重新计算时机**:清理完成后**必须**重算 `getCacheInfo()`,不能用 `info.totalBytes - freedBytes` 简单相减 6. **cleanCategory/cleanCategoryGroup 单例保护**:`cleanCategory(id)` 与 `cleanCategoryGroup(id, opts)` 各维护一个 `_inFlight` Map(key 为 `id` 或 `${id}#${uid}`);并发触发同一目标返回同一 Promise,不重复执行。UI 通过 `uni.showLoading({mask:true})` 阻隔误触。`cleanAll()` 不再被 UI 调用,保留作为登出流程/测试的编程式入口,不需 in-flight 保护。 --- ## 六、错误处理 & 边界条件 ### 6.1 错误分类与处理 | 场景 | 行为 | |------|------| | 单个 handler `computeSize()` 抛错 | 该分类显示 `sizeBytes: -1`、label 旁加 `?` 标记,主页面其他分类正常显示,console.warn 记录 | | 单个 handler `clean()` / `cleanGroup()` 抛错 | 该分类跳过,perCategory 中标记 `{id, freedBytes: 0, error: 'msg'}`,其他继续;最终 toast 显示「已清理 XX MB(X 项失败)」 | | `getCacheInfo()` 整体超时(>5s)| 兜底显示空列表 + 「加载失败,点此重试」按钮 | | `uni.getStorageInfoSync()` 失败(罕见)| 整个 storage 维度显示 `— MB`,仅沙盒维度可清理 | | 沙盒文件不存在(登出后清理等场景)| `clearAllSandboxTmpFiles` 已实现"目录不存在视为成功",无需额外处理 | ### 6.2 边界条件 - **页面打开瞬间用户被踢登录**:`getCacheInfo()` 不依赖 token,可正常返回;若清理中触发退出,回调里捕错即可 - **清理进行中用户退出页面**:用 `cacheManager.cleanCategory()` 的 in-flight 单例保护 —— 重复触发返回同一 Promise - **草稿清理有未提交内容**:UI 在 ConfirmModal 文案写明"将清空你未提交的创作草稿";不做活跃页面编辑检测(YAGNI) - **黑名单 key 误触**:内置黑名单在 `cleanCategory` / `cleanCategoryGroup` 入口拦截,永不删 - **大小格式化**:用 `KB/MB/GB` 自适应,1 位小数(避免 `0.0 MB`),< 1KB 显示 `< 1 KB` - **顶部数据加载失败**:配额读取失败(罕见)时,UI 显示"配额数据暂不可用",分类列表仍正常渲染;getCacheInfo() 内部 try/catch 单点失败不影响其他字段 - **详情页无 uid 数据**:草稿/引导详情页若当前用户 uid 取不到(未登录),全部归为"其他用户"分组,**不允许清理**(按钮置灰 + tooltip"请先登录") - **未登录态只能清理简单页**:preload / progress / sandbox-tmp / others 在未登录态允许清理;draft / guide 在未登录态**禁止清理**(按钮置灰) ### 6.3 关键日志 所有 handler 失败必须 `console.warn('[cacheManager] xxx failed:', id, err.message)`,便于线上排查。 --- ## 七、测试策略 ### 7.1 单元测试 - `utils/cacheManager.js` 纯函数(黑名单匹配、key 分类、大小格式化、GroupInfo 构造)单测 - 6 个 handler 的 `computeSize` / `clean` / `computeBreakdown` / `cleanGroup` 单测(mock `uni.getStorageSync`) - `draftStorage.js` 双读单写逻辑单测(mock storage) ### 7.2 集成测试 mock `uni.getStorageSync` / `clearAllSandboxTmpFiles` / `core.invalidateAll`,验证: - `cleanAll()` 顺序执行 + invalidateAll 同步 - 失败容错(单个失败不影响其他) - 黑名单永不删 - `cleanCategory('preload')` 完成后 memoryMap 已清空 ### 7.3 手工验证场景 | 场景 | 期望 | |------|------| | 登录态清理(简单页) | token/user/star_id/cid 不变,console 二次确认 | | 登录态清理(分组页-我的草稿) | 仅删 currentUid 草稿;普通警告 | | 登录态清理(分组页-其他用户) | 仅删 otherUid 草稿;强警告;对方下次登录看不到 | | 未登录态清理(简单页) | 全部业务缓存正常清理 | | 未登录态清理(分组页) | 草稿/引导按钮置灰,不可清理 | | 清理中退出页面 | 不重复清理,不报错 | | 空缓存状态 | 列表页显示"✓ 当前无缓存可清理",无跳转入口 | | 草稿详情页(仅自己) | 仅显示"我的草稿"分组,清理按钮触发普通警告 | | 草稿详情页(多账号切换后) | 显示"我的草稿" + 多个"其他用户的草稿"分组;强警告+正常清理流程 | | 草稿详情页(未登录) | 所有数据归"其他用户",按钮置灰 | | **账号 A 写草稿 → 退出 → 账号 B 登录 → 打开创作页** | **B 看不到 A 的草稿**(依赖 §11 改造) | | **账号 A 产生 preload 缓存 → 退出 → 账号 B 登录 → B 进 preload 详情页清理** | **A 的 preload 也被清掉**(§1 决策 6) | | **清理 preload 后立刻再访问同一列表页** | **不会从 memoryMap 命中老数据**(§3.4.4 内存层清理) | --- ## 八、文件清单 ### 8.1 新增文件 | 路径 | 说明 | |------|------| | `frontend/utils/cacheManager.js` | 统一封装层(核心,约 250-300 行) | | `frontend/utils/handlers/preloadHandler.js` | preload 缓存 handler(简单型) | | `frontend/utils/handlers/draftHandler.js` | 创作草稿 handler(分组型,含 computeBreakdown + cleanGroup) | | `frontend/utils/handlers/progressHandler.js` | 进度缓存 handler(简单型) | | `frontend/utils/handlers/guideHandler.js` | 引导记录 handler(分组型,含 computeBreakdown + cleanGroup) | | `frontend/utils/handlers/sandboxTmpHandler.js` | 沙盒临时文件 handler(基于 ioPath.js,简单型) | | `frontend/utils/handlers/othersHandler.js` | 兜底分类 handler(简单型) | | `frontend/pages/profile/cache-cleanup.vue` | 缓存清理列表页(只展示,无按钮) | | `frontend/pages/profile/cache-cleanup-detail.vue` | 缓存清理详情页(按 `id` 分发到简单/分组模板) | ### 8.2 修改文件 | 路径 | 改动 | |------|------| | `frontend/pages/profile/profile.vue` | 加菜单项「存储空间」点击进入 `cache-cleanup` | | `frontend/pages.json` | 注册两个新页面(cache-cleanup 列表 + cache-cleanup-detail 详情) | | `frontend/utils/ioPath.js` | 新增 `getSandboxTotalSize()` 只读 API(§5.1 并行 2 用于计算沙盒文件总字节)| ### 8.3 实施顺序(5 个 milestone,M0 是 M2 前置) 1. **M0 — 草稿 key 改造(前置迁移)**:`utils/draftStorage.js` 封装 + 7 个文件读写侧改造 + 双读单写兼容(§11) 2. **M1 — 核心封装层**:`cacheManager.js` + 黑名单 + 大小格式化 + `getCacheInfo()` / `getCategoryBreakdown()` / `cleanCategory()` / `cleanCategoryGroup()` 框架(handlers 暂时用空 stub);包含 in-flight 单例保护 3. **M2 — 6 个 handler 实现**:每个 handler 的 computeSize / clean(或 computeBreakdown + cleanGroup);preload 跨用户清理;preload handler 清理完成后同步调 `invalidateAll()` 4. **M3 — UI 页面(列表 + 详情)**:`cache-cleanup.vue`(列表)+ `cache-cleanup-detail.vue`(简单/分组模板)+ ConfirmModal + profile.vue 入口 + pages.json 注册 5. **M4 — 测试 & 回归**:单元测试 + 13 个手工场景验证(含跨账号分组清理、未登录态分组禁用、清理后内存即清等)+ 对照 CLAUDE.md 完成自检清单 --- ## 九、验收标准(DoD) - [ ] 个人中心出现「存储空间」入口,点击进入列表页 - [ ] 列表页顶部显示:**大字(Topfans 已用空间)+ 百分比(占据配额 X%)+ 进度条 + 3 个 chip 标签(无数值)+ 6 个分类行 + 1 个"其他"section** - [ ] 列表页**没有任何清理按钮**;点击分类行进入详情页;"其他"section 不可点击 - [ ] **简单详情页**(preload / progress / sandbox-tmp / others):单按钮 + ConfirmModal(含 warning 时显示警告文案) - [ ] **分组详情页**(draft / guide):按 uid 分组卡片,每组独立清理按钮;当前用户组普通警告,他人组**强警告**;0 项组按钮置灰 - [ ] 未登录态打开草稿/引导详情页:所有数据归"其他用户",按钮置灰 + tooltip "请先登录" - [ ] 详情页清理 preload 后立即进列表页:memoryMap 已清空,重新拉数据 - [ ] 详情页清理成功后 toast + 返回列表页(onShow 自动重算) - [ ] 单个 handler 失败不影响其他 handler - [ ] 登录 token、用户信息、设备指纹等黑名单 key **绝对不会被清理**(用 console.log 二次确认) - [ ] `pages.json` 已注册两个新页面(列表 + 详情),`unpackage/dist/` 未手动改 - [ ] 走 CLAUDE.md 自检清单(API 工程化、前端规范、接口规范均过) --- ## 十、风险与回滚 | 风险 | 影响 | 缓解 | |------|------|------| | 黑名单遗漏某个登录态 key | 用户被错误退出 | M2 完成后 grep 全量 storage key 与黑名单比对;M4 手工验证登录态清理 | | handler 顺序错误 | sandbox 清理在 preload 清理之前引用失效 | 已设计为顺序执行;单测覆盖 | | 草稿清理误删活跃数据 | 用户创作内容丢失 | ConfirmModal 文案警示;后续可加活跃检测(MVP 不做) | | `clearAllSandboxTmpFiles` 性能 | 启动时已实现,启动清理通常 0 个 tmp | 实测 50ms 内完成;超过 1s 加 loading | 回滚:删除 `pages/profile/cache-cleanup.vue` + `pages.json` 注册项 + profile.vue 菜单项即可,业务无侵入。 --- ## 十一、草稿 key 改造迁移项 > **范围外工作但与本 spec 强耦合**:为了让 §3.2 的 draft handler 准确按 uid 分类,必须先把草稿 storage key 改为 `*_${uid}` 形式。本节作为 M2 启动前的**前置依赖**。 ### 11.1 现状问题 当前草稿 key 不带 uid: - `castlove_form_data` - `CASTLOVE_FORM_KEY` - `temp_nft_data` - `GENERATED_IMAGES_KEY` - `GENERATION_RESULT_META_KEY` - `LENTICULAR_STUDIO_STORAGE_KEY` - `CRAFT_SELECTED_IMAGE_KEY` **结果**:账号 A 的草稿在设备上 → 退出登录 → B 账号登录 → 打开创作页 → **看到 A 的草稿**。这是隐私 + 体验问题。 ### 11.2 改造方案 将所有写入侧(`pages/castlove/create.vue`、`pages/castlove/index.vue`、`pages/castlove/success.vue`、`composables/useLaserSegment.js`、`composables/useLaserBatchGenerate.js` 等)改为: ```js // 旧 uni.setStorageSync('castlove_form_data', JSON.stringify(formData)) // 新 uni.setStorageSync(`castlove_form_data_${currentUid}`, JSON.stringify(formData)) ``` 读取侧同样需改造。 ### 11.3 迁移策略 **双读单写**: - 读:先读新 key `*_${currentUid}`;若不存在,**fallback** 读老 key;读到后将数据写到新 key,老 key 删除 - 写:只写新 key `*_${currentUid}` 实现位置:建议封装一个 `utils/draftStorage.js`,所有读写都走它,业务侧无感。 ### 11.4 与 cacheManager 的关系 - **迁移完成前**:draft handler.computeSize() 同时计入新 key(`*_${currentUid}`)+ 老 key(无 uid 后缀),所有都按 uid 分组(无后缀 key 归 `__legacy__` 分组) - **迁移完成后**:draft handler 仅匹配 `*_${currentUid}` + 老无 uid 后缀 key(理论上不应再有,但万一有仍归 `__legacy__` 分组);**不**走 `others` 兜底 - **验证**:M4 手工场景新增"切换账号后看不到对方草稿" ### 11.5 工作量估算 **M0 范围限定为「核心 castlove 创作流」(约 7 个文件)**: - `pages/castlove/create.vue`、`pages/castlove/index.vue`、`pages/castlove/success.vue`、`pages/castlove/lenticular/lenticular-result.vue`、`pages/castlove/laser/laser-result.vue`、`composables/useLaserSegment.js`、`composables/useLaserBatchGenerate.js` - 包含:`castlove_form_data` / `CASTLOVE_FORM_KEY` / `temp_nft_data` / `GENERATED_IMAGES_KEY` / `GENERATION_RESULT_META_KEY` / `LENTICULAR_STUDIO_STORAGE_KEY` / `CRAFT_SELECTED_IMAGE_KEY` **discover / mint / self-created 等流程(约 10 个其他文件)**涉及相同 key 但属于次级路径,**列为 M0 后续批次(M0.5)**,MVP 上线后补;估算额外 1 天。 **核心 castlove 7 文件改造工作量**: - 改造 7 个文件读写侧:0.5 天 - `draftStorage.js` 封装:0.5 天 - 联调验证:0.5 天 合计 **1.5 天**,作为 M2 启动前置。 --- ## 十二、自检清单 按 CLAUDE.md 要求: - [ ] 文档开头「方案概述」含:要解决的问题 / 实现路径 / 关键决策 / 核心架构图 ✅ - [ ] MVP 先行:未引入超出当前业务需要的抽象(统一封装层是必要的,避免 UI 上帝化) ✅ - [ ] 文件清单与目录结构对齐:`utils/handlers/` 是新目录需提前创建 ✅ - [ ] 跨章节引用一致性:§3.2 → §4.2 分类一致;§4.3 → §5 流程一致;§6 → §7 测试场景一致 ✅ - [ ] CLAUDE.md 自检清单(前端规范):新组件用 `