feat:添加存储空间

This commit is contained in:
zheng020 2026-07-29 16:03:24 +08:00
parent 128a12fa03
commit d5a08db56c
32 changed files with 5045 additions and 103 deletions

View File

@ -0,0 +1,680 @@
# 缓存清理功能设计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<Array<GroupInfo>>} [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-1001 位小数)= appUsedBytes / quotaTotalBytes * 100
// othersBytes, // "其他" section 大小 = 所有黑名单 keys + 不可清理文件大小
// categories: [{id, label, description, sizeBytes, keyCount, warning}, ...]
// }
// 详情页读取(分组详情;简单 handler 返回 null
const groups = await cacheManager.getCategoryBreakdown(id)
// → Array<GroupInfo> | 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% 存储空间 │ ← 百分比副文字(次要色)
│ │
│ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░ 16% │ ← 进度条(当前用量占配额比例)
│ │
│ ┌────────────┬─────────────┬────────────┐ │
│ │ Topfans 已用空间 │ 内存缓存空间 │ 总内存空间 │ │ ← 一行 3 个 chip 标签(无数值)
│ └────────────┴─────────────┴────────────┘ │ 数值已分别显示在:大字 / 缓存分类 sum / 总配额
├─────────────────────────────────────────────┤
│ 缓存分类 │ ← 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 "配额"**
- MVP 用 `uni.getStorageInfoSync().limitSize`app 自己的 SQLite 配额),文案写"配额"而非"磁盘",不误导用户
- 设备级"磁盘总空间/可用空间"需要 native pluginiOS `NSFileManager` / Android `StatFs`**MVP 不引入**列为后续优化§2
**草稿/引导的二级提示**:行右侧副文字"按账号分组清理",警示用户详情页会有分组。
### 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<GroupInfo>`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/
聚合:
totalBytes = sum(categories.sizeBytes) // 可清理总量(=内存缓存空间 chip
appUsedBytes = currentSize * 1024 + sandboxBytes // 软件占用(大字 + Topfans已用空间 chip
// 注currentSize 是 uni storage 全量(含黑名单),这是 WeChat "已用空间" 口径
quotaTotalBytes = limitSize * 1024 // 配额总量(=总内存空间 chip
quotaAvailableBytes = quotaTotalBytes - appUsedBytes // 配额可用
usagePercent = (appUsedBytes / quotaTotalBytes) * 100 // 百分比
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}` 后缀的草稿 keyuid 改造完成后;详见 §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` Mapkey 为 `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 MBX 项失败)」 |
| `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 个 milestoneM0 是 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 + cleanGrouppreload 跨用户清理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 自检清单(前端规范):新组件用 `<script setup>` 组合式 API、所有原生 API 包 `#ifdef`、接口走 `utils/api.js`、新页面已在 `pages.json` 注册、敏感权限失败有"去设置"出口、不动 `unpackage/dist/`

File diff suppressed because it is too large Load Diff

View File

@ -4,13 +4,13 @@
import { ref } from 'vue'
import {
CASTLOVE_FORM_KEY,
consumeGenerationFlowPayload,
persistLaserPreviewImages,
} from '@/utils/castloveGenerationFlow.js'
import { generateLaserVariantBatch } from '@/utils/laser-card/laserBatchExport.js'
import { segmentPortrait } from '@/composables/useLaserSegment.js'
import { useLaserDifyGenerate } from '@/composables/useLaserDifyGenerate.js'
import { readDraft } from '@/utils/draftStorage'
const DEFAULT_CANVAS_ID = 'laserBatchCanvas'
const DEFAULT_MIN_DURATION_MS = 1600
@ -71,7 +71,7 @@ export function useLaserBatchGenerate(options = {}) {
}
const readFormData = () => {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY)
const formStr = readDraft('castlove_form_data')
if (!formStr) {
throw new Error('缺少表单数据')
}

View File

@ -4,7 +4,7 @@
*/
import { segmentPortraitApi } from '@/utils/api.js'
import { CASTLOVE_FORM_KEY } from '@/utils/castloveGenerationFlow.js'
import { readDraft, writeDraft } from '@/utils/draftStorage'
async function downloadToLocal(url) {
const target = String(url || '').trim()
@ -43,14 +43,18 @@ async function downloadToLocal(url) {
/** 将抠图本地路径写入 castlove 表单,供 craftMintSubmit 上传 cutout 层 */
export function persistCutoutToForm(cutoutLocalPath, cutoutOssKey = '') {
try {
const raw = uni.getStorageSync(CASTLOVE_FORM_KEY)
const raw = readDraft('castlove_form_data')
if (!raw) return
const form = typeof raw === 'string' ? JSON.parse(raw) : { ...raw }
form.cutoutImage = cutoutLocalPath
if (cutoutOssKey) {
form.cutout_oss_key = cutoutOssKey
}
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(form))
const saved = writeDraft('castlove_form_data', JSON.stringify(form))
if (!saved) {
console.error('[useLaserSegment] persistCutoutToForm: 保存表单数据失败')
return
}
} catch (e) {
console.warn('[useLaserSegment] persistCutoutToForm failed:', e)
}
@ -58,12 +62,16 @@ export function persistCutoutToForm(cutoutLocalPath, cutoutOssKey = '') {
export function clearCutoutFromForm() {
try {
const raw = uni.getStorageSync(CASTLOVE_FORM_KEY)
const raw = readDraft('castlove_form_data')
if (!raw) return
const form = typeof raw === 'string' ? JSON.parse(raw) : { ...raw }
delete form.cutoutImage
delete form.cutout_oss_key
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(form))
const saved = writeDraft('castlove_form_data', JSON.stringify(form))
if (!saved) {
console.error('[useLaserSegment] clearCutoutFromForm: 保存表单数据失败')
return
}
} catch (e) {
/* noop */
}

View File

@ -2,7 +2,7 @@
"name" : "TopFans",
"appid" : "__UNI__F199FF4",
"description" : "",
"versionName" : "1.0.5",
"versionName" : "1.0.1",
"versionCode" : 120,
"transformPx" : false,
/* 5+App */

View File

@ -409,6 +409,20 @@
"bounce": "none"
}
}
},
{
"path": "pages/profile/cache-cleanup",
"style": {
"navigationBarTitleText": "存储空间",
"enablePullDownRefresh": true
}
},
{
"path": "pages/profile/cache-cleanup-detail",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {

View File

@ -444,11 +444,10 @@ import {
CRAFT_TAG_LENTICULAR,
} from '@/utils/castloveMintForm.js';
import {
CASTLOVE_FORM_KEY,
CRAFT_SELECTED_IMAGE_KEY,
STUDIO_LENTICULAR,
STUDIO_LASER,
} from '@/utils/castloveGenerationFlow.js';
import { readDraft } from '@/utils/draftStorage';
import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js';
import { composeStickers, relationsToStickers } from '@/utils/sticker-compositor.js';
import { getStoredUser } from '@/utils/getStoredUser.js';
@ -1154,11 +1153,11 @@ const loadCraftConfirm = () => {
loading.value = false;
loadError.value = '';
try {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
const formStr = readDraft('castlove_form_data');
if (formStr) {
craftFormData.value = JSON.parse(formStr);
}
const selected = uni.getStorageSync(CRAFT_SELECTED_IMAGE_KEY) || '';
const selected = readDraft('craft_selected_image') || '';
craftCoverUrl.value = selected;
if (isCraftLenticular.value) {
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY);

View File

@ -175,6 +175,7 @@ import {
STUDIO_LENTICULAR,
STUDIO_LASER,
} from '@/utils/castloveGenerationFlow.js';
import { readDraft, writeDraft, removeDraft } from '@/utils/draftStorage';
// H5 onLoad query onMounted + getCurrentPages().options undefined
const pageType = ref('');
@ -834,7 +835,11 @@ const handleGenerate = async () => {
};
try {
uni.setStorageSync('castlove_form_data', JSON.stringify(formData));
const saved = writeDraft('castlove_form_data', JSON.stringify(formData));
if (!saved) {
console.error('保存表单数据失败');
return;
}
} catch (e) {
console.error('保存表单数据失败:', e);
return;
@ -845,7 +850,7 @@ const handleGenerate = async () => {
// Loading
const sendToBackend = (aiDescription) => {
const formDataStr = uni.getStorageSync('castlove_form_data');
const formDataStr = readDraft('castlove_form_data');
const formData = formDataStr ? JSON.parse(formDataStr) : {};
startAiImageGenerationFlow({ prompt: aiDescription, formData });
};

View File

@ -106,6 +106,7 @@ import ConfirmModal from '@/components/ConfirmModal.vue';
import BottomNav from "../components/BottomNav.vue";
import { getOssSignatureApi, deleteMintOrderApi } from '@/utils/api.js';
import { useAliveGuard } from '@/composables/useAliveGuard.js';
import { readDraft, writeDraft, removeDraft } from '@/utils/draftStorage';
const navExpanded = ref(false);
@ -583,7 +584,15 @@ const handleConfirm = async () => {
};
try {
uni.setStorageSync('castlove_form_data', JSON.stringify(formData));
const saved = writeDraft('castlove_form_data', JSON.stringify(formData));
if (!saved) {
console.error('保存表单数据失败: writeDraft 返回 false未登录或存储失败');
uni.showToast({
title: '保存数据失败',
icon: 'none'
});
return;
}
console.log('[CastloveContent] 表单数据已保存Base64长度:', uploadedImageBase64.value.length);
} catch (e) {
console.error('保存表单数据失败:', e);

View File

@ -55,7 +55,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import ConfirmModal from '@/components/ConfirmModal.vue'
import LaserVariantPyramid from '@/components/laser/LaserVariantPyramid.vue'
import { useLaserMint } from '@/composables/useLaserMint.js'
import { CASTLOVE_FORM_KEY, GENERATED_IMAGES_KEY, GENERATION_RESULT_META_KEY } from '@/utils/castloveGenerationFlow.js'
import { CASTLOVE_FORM_KEY } from '@/utils/castloveGenerationFlow.js'
import { readDraft } from '@/utils/draftStorage'
const generatedImages = ref([])
const selectedIndex = ref(-1)
@ -127,13 +128,13 @@ onMounted(() => {
try {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY)
if (formStr) craftFormData.value = JSON.parse(formStr)
const imagesStr = uni.getStorageSync(GENERATED_IMAGES_KEY)
if (imagesStr) {
generatedImages.value = JSON.parse(imagesStr)
const imagesRaw = readDraft('generated_images')
if (imagesRaw) {
generatedImages.value = typeof imagesRaw === 'string' ? JSON.parse(imagesRaw) : imagesRaw
}
const metaStr = uni.getStorageSync(GENERATION_RESULT_META_KEY)
if (metaStr) {
const meta = JSON.parse(metaStr)
const metaRaw = readDraft('generation_result_meta')
if (metaRaw) {
const meta = typeof metaRaw === 'string' ? JSON.parse(metaRaw) : metaRaw
instanceNo.value = meta.instanceNo || ''
}
} catch (e) {

View File

@ -171,7 +171,7 @@ import {
const scrollEnhanced = true;
// #endif
// #ifndef MP-WEIXIN
const scrollEnhanced = false;
// const scrollEnhanced = false;
// #endif
const pageType = ref("");

View File

@ -126,18 +126,14 @@ import { useAliveGuard } from "@/composables/useAliveGuard.js";
import LenticularCard from "@/components/lenticular/LenticularCard.vue";
import ConfirmModal from "@/components/ConfirmModal.vue";
import { useLenticularCraftTiltPreview } from "@/composables/useLenticularCraftTiltPreview.js";
import {
buildLenticularLayersTwo,
LENTICULAR_STUDIO_STORAGE_KEY,
} from "@/utils/castloveMintForm.js";
import { buildLenticularLayersTwo } from "@/utils/castloveMintForm.js";
import {
CASTLOVE_FORM_KEY,
GENERATED_IMAGES_KEY,
GENERATION_RESULT_META_KEY,
completeSelectionAndOpenDetail,
isDetailAfterSelect,
STUDIO_LENTICULAR,
} from "@/utils/castloveGenerationFlow.js";
import { readDraft, removeDraft } from "@/utils/draftStorage";
import { submitCraftMintFromPath } from "@/utils/craftMintSubmit.js";
import { getStoredUser } from "@/utils/getStoredUser.js";
@ -841,7 +837,7 @@ const handleCancelMint = () => {
const initLenticularPreview = () => {
try {
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY);
const raw = readDraft("lenticular_studio_payload");
if (!raw) {
return;
}
@ -861,17 +857,17 @@ onMounted(() => {
if (formDataStr) {
craftFormData.value = JSON.parse(formDataStr);
}
const metaStr = uni.getStorageSync(GENERATION_RESULT_META_KEY);
const metaStr = readDraft("generation_result_meta");
if (metaStr) {
resultMeta.value = JSON.parse(metaStr);
uni.removeStorageSync(GENERATION_RESULT_META_KEY);
removeDraft("generation_result_meta");
}
} catch (e) {
console.error("[GenerationResult] 读取表单失败:", e);
}
try {
const imagesData = uni.getStorageSync(GENERATED_IMAGES_KEY);
const imagesData = readDraft("generated_images");
if (!imagesData) {
uni.showToast({ title: "未找到生成的图片", icon: "none" });
setTimeout(
@ -884,7 +880,7 @@ onMounted(() => {
return;
}
const parsed = JSON.parse(imagesData);
uni.removeStorageSync(GENERATED_IMAGES_KEY);
removeDraft("generated_images");
if (
isLenticularDisplay.value ||

View File

@ -25,9 +25,9 @@
import { ref, onMounted } from 'vue'
import {
consumeGenerationFlowPayload,
CASTLOVE_FORM_KEY,
persistLenticularPreviewMeta,
} from '@/utils/castloveGenerationFlow.js'
import { readDraft } from '@/utils/draftStorage'
const progress = ref(0)
let progressTimer = null
@ -87,7 +87,7 @@ const waitMinDuration = async (minMs) => {
}
const runLenticularFlow = async (flow) => {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY)
const formStr = readDraft('castlove_form_data')
if (!formStr) throw new Error('缺少表单数据')
const formData = JSON.parse(formStr)
await waitMinDuration(flow?.minDurationMs ?? 1600)

View File

@ -72,6 +72,7 @@ import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftT
import { buildLenticularLayersTwo } from '@/utils/castloveMintForm.js';
import { getAssetDetailApi, getAssetMaterialsApi } from '@/utils/api.js';
import { getLaserPresetByIndex } from '@/utils/laser-card/laserPresets.js';
import { readDraft, writeDraft, removeDraft } from '@/utils/draftStorage';
//
const nftData = ref({
@ -121,7 +122,7 @@ const nftCardStyle = {
onMounted(async () => {
//
try {
const tempNftData = uni.getStorageSync('temp_nft_data');
const tempNftData = readDraft('temp_nft_data');
if (tempNftData) {
const data = JSON.parse(tempNftData);
@ -244,7 +245,7 @@ const handleViewDetails = () => {
let orderId = '';
let assetId = '';
try {
const raw = uni.getStorageSync('temp_nft_data');
const raw = readDraft('temp_nft_data');
if (raw) {
const parsed = JSON.parse(raw);
orderId = parsed.order_id || '';
@ -269,7 +270,7 @@ const handleViewDetails = () => {
onUnload(() => {
try {
uni.removeStorageSync('temp_nft_data');
removeDraft('temp_nft_data');
} catch (e) {
/* noop */
}

View File

@ -25,11 +25,11 @@
<script setup>
import { ref, onMounted } from 'vue';
import { imageGenerationApi } from '@/utils/api.js';
import { writeDraft } from '@/utils/draftStorage';
import {
consumeGenerationFlowPayload,
FLOW_MODE_PREFILLED,
FLOW_MODE_LENTICULAR,
GENERATED_IMAGES_KEY,
GENERATION_REQUEST_KEY,
CASTLOVE_FORM_KEY,
persistLenticularPreviewMeta,
@ -111,7 +111,14 @@ const runPrefilledFlow = async (flow) => {
const images = flow.images || [];
if (!images.length) throw new Error('无候选图');
await waitMinDuration(flow.minDurationMs ?? 1400);
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(images));
try {
const saved = writeDraft('generated_images', JSON.stringify(images));
if (!saved) {
console.error('保存数据失败');
}
} catch (e) {
console.error('保存数据抛出异常:', e);
}
completeProgress();
} catch (err) {
console.error('[GenerationLoading] prefilled', err);
@ -139,7 +146,14 @@ const callImageGeneration = async () => {
try {
const res = await imageGenerationApi(generationData);
if (res.data?.images?.length > 0) {
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(res.data.images));
try {
const saved = writeDraft('generated_images', JSON.stringify(res.data.images));
if (!saved) {
console.error('保存数据失败');
}
} catch (e) {
console.error('保存数据抛出异常:', e);
}
completeProgress();
} else {
uni.showToast({ title: '未生成图片', icon: 'none' });

View File

@ -74,14 +74,12 @@ import {
LENTICULAR_STUDIO_STORAGE_KEY,
} from '@/utils/castloveMintForm.js';
import {
CASTLOVE_FORM_KEY,
GENERATED_IMAGES_KEY,
GENERATION_RESULT_META_KEY,
completeSelectionAndOpenDetail,
isDetailAfterSelect,
isLenticularKind,
STUDIO_LENTICULAR,
} from '@/utils/castloveGenerationFlow.js';
import { readDraft, removeDraft } from '@/utils/draftStorage';
const generatedImages = ref([]);
const selectedIndex = ref(-1);
@ -682,7 +680,7 @@ const selectAsset = async () => {
return;
}
const formDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
const formDataStr = readDraft('castlove_form_data');
const orderValue = formDataStr ? JSON.parse(formDataStr) : craftFormData.value || {};
const selectedImage = getSelectedImageUrl();
console.log('1')
@ -736,7 +734,7 @@ const selectAsset = async () => {
if (response.code !== 0) {
throw new Error(response.message || '创建订单失败');
}
uni.removeStorageSync(CASTLOVE_FORM_KEY);
removeDraft('castlove_form_data');
// temp_nft_data
const nftData = {
image: imageUrl,
@ -779,28 +777,28 @@ const initLenticularPreview = () => {
onMounted(() => {
try {
const formDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
const formDataStr = readDraft('castlove_form_data');
if (formDataStr) {
craftFormData.value = JSON.parse(formDataStr);
}
const metaStr = uni.getStorageSync(GENERATION_RESULT_META_KEY);
const metaStr = readDraft('generation_result_meta');
if (metaStr) {
resultMeta.value = JSON.parse(metaStr);
uni.removeStorageSync(GENERATION_RESULT_META_KEY);
removeDraft('generation_result_meta');
}
} catch (e) {
console.error('[GenerationResult] 读取表单失败:', e);
}
try {
const imagesData = uni.getStorageSync(GENERATED_IMAGES_KEY);
const imagesData = readDraft('generated_images');
if (!imagesData) {
uni.showToast({ title: '未找到生成的图片', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
return;
}
const parsed = JSON.parse(imagesData);
uni.removeStorageSync(GENERATED_IMAGES_KEY);
removeDraft('generated_images');
if (isLenticularDisplay.value || (parsed[0] && parsed[0].type === 'lenticular')) {
initLenticularPreview();

View File

@ -0,0 +1,431 @@
<template>
<view class="cache-cleanup-detail">
<!-- 顶部类目信息 -->
<view class="header">
<text class="header-label">{{ category.label }}</text>
<text v-if="category.description" class="header-desc">{{ category.description }}</text>
</view>
<!-- 加载占位 -->
<view v-if="loading && !category.label" class="loading">
<text>加载中...</text>
</view>
<!-- 简单模板preload / progress / sandbox-tmp / others -->
<view v-else-if="breakdown === null" class="simple-body">
<view class="big-stat">
<text class="big-value">{{ formatSize(category.sizeBytes) }}</text>
<text class="big-count"> {{ category.keyCount }} 项缓存</text>
</view>
<view class="action-wrap">
<button
class="clean-btn"
:disabled="category.sizeBytes <= 0"
@click="confirmSimple"
>
</button>
<text v-if="category.sizeBytes <= 0" class="action-hint">当前无缓存可清理</text>
</view>
</view>
<!-- 分组模板draft / guide -->
<view v-else class="grouped-body">
<view v-if="breakdown.length === 0" class="empty">
<text> 当前无缓存可清理</text>
</view>
<view
v-for="(g, idx) in breakdown"
:key="String(g.uid) + '-' + idx"
class="group-card"
:class="{ 'group-card-global': g.global }"
>
<view class="group-main">
<view class="group-title">
<text class="group-icon">{{ g.isCurrent ? '👤' : (g.global ? '🌐' : '👥') }}</text>
<text class="group-name">{{ g.displayUid }}</text>
</view>
<text class="group-size">{{ formatSize(g.sizeBytes) }} · {{ g.keyCount }} </text>
<text v-if="g.warning" class="group-warn"></text>
</view>
<button
class="clean-btn small"
:class="{ 'strong': !g.isCurrent && !g.global }"
:disabled="!g.canClean"
@click="confirmGroup(g)"
>
{{ g.canClean ? '清 理' : disabledLabel(g.disabledReason) }}
</button>
</view>
</view>
<!-- 通用确认弹窗 -->
<ConfirmModal
:visible="confirmModal.visible"
:title="confirmModal.title"
:content="confirmModal.content"
:confirmText="confirmModal.confirmText"
@confirm="onConfirmModal"
@cancel="onCancelModal"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import {
getCacheInfo,
getCategoryBreakdown,
cleanCategory,
cleanCategoryGroup,
formatSize,
} from '@/utils/cacheManager'
import ConfirmModal from '@/components/ConfirmModal.vue'
const id = ref(null)
// label / description / sizeBytes / keyCount / warning
const category = ref({ label: '', description: '', sizeBytes: 0, keyCount: 0, warning: false })
// null = [] / Array =
const breakdown = ref(null)
const loading = ref(false)
//
const confirmModal = ref({
visible: false,
title: '',
content: '',
confirmText: '确认',
pendingType: null, // 'simple' | 'group'
pendingGroup: null,
})
onLoad((options) => {
id.value = options?.id || null
load()
})
async function load() {
if (!id.value) {
uni.showToast({ title: '参数错误', icon: 'none' })
return
}
loading.value = true
try {
const [info, br] = await Promise.all([
Promise.race([
getCacheInfo(),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
]),
Promise.race([
getCategoryBreakdown(id.value),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
]),
])
const meta = (info.categories || []).find((c) => c.id === id.value)
if (meta) {
category.value = {
label: meta.label,
description: meta.description || '',
sizeBytes: meta.sizeBytes,
keyCount: meta.keyCount,
warning: !!meta.warning,
}
}
breakdown.value = br // null = simple; array = grouped
} catch (e) {
console.warn('[cache-cleanup-detail] load failed:', e.message)
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
} finally {
loading.value = false
}
}
onPullDownRefresh(async () => {
await load()
uni.stopPullDownRefresh()
})
function confirmSimple() {
if (category.value.sizeBytes <= 0) {
uni.showToast({ title: '该分类暂无缓存', icon: 'none' })
return
}
confirmModal.value = {
visible: true,
title: '清理缓存',
content: category.value.warning
? '将清空该分类缓存,是否继续?'
: '确认清理该分类缓存?',
confirmText: '确认清理',
pendingType: 'simple',
pendingGroup: null,
}
}
function confirmGroup(g) {
if (!g.canClean) {
uni.showToast({ title: disabledLabel(g.disabledReason), icon: 'none' })
return
}
let content
if (g.isCurrent) {
//
content = '将清空你未提交的数据,是否继续?'
} else if (g.uid === '__legacy__') {
//
content = '将清空老版本数据,是否继续?'
} else if (g.global) {
// canClean=false
return
} else {
//
content = `将清空 ${g.displayUid} 的数据,对方下次登录不会看到。是否继续?`
}
confirmModal.value = {
visible: true,
title: '清理缓存',
content,
confirmText: '确认清理',
pendingType: 'group',
pendingGroup: g,
}
}
function onConfirmModal() {
const type = confirmModal.value.pendingType
const grp = confirmModal.value.pendingGroup
confirmModal.value.visible = false
if (type === 'simple') {
doCleanSimple()
} else if (type === 'group') {
doCleanGroup(grp)
}
}
function onCancelModal() {
confirmModal.value.visible = false
}
async function doCleanSimple() {
if (!id.value) return
uni.showLoading({ title: '清理中...' })
try {
const r = await cleanCategory(id.value)
uni.hideLoading()
const freed = r?.freedBytes || 0
const cnt = r?.keyCount || 0
uni.showToast({ title: `已清理 ${formatSize(freed)} (${cnt} 项)`, icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
uni.hideLoading()
console.warn('[cache-cleanup-detail] clean failed:', e.message)
uni.showToast({ title: '清理失败', icon: 'none' })
}
}
async function doCleanGroup(g) {
if (!id.value || !g) return
// breakdown.uid cleanCategoryGroup opts.uid
// self 'self'
// __legacy__ '__legacy__'
// uid
let uidParam
if (g.isCurrent) {
uidParam = 'self'
} else if (g.uid === '__global__') {
// canClean=false
uni.showToast({ title: '全局数据不可清理', icon: 'none' })
return
} else {
uidParam = g.uid
}
uni.showLoading({ title: '清理中...' })
try {
const r = await cleanCategoryGroup(id.value, { uid: uidParam })
uni.hideLoading()
const freed = r?.freedBytes || 0
const cnt = r?.keyCount || 0
uni.showToast({ title: `已清理 ${formatSize(freed)} (${cnt} 项)`, icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
uni.hideLoading()
console.warn('[cache-cleanup-detail] cleanGroup failed:', e.message)
uni.showToast({ title: '清理失败', icon: 'none' })
}
}
function disabledLabel(reason) {
switch (reason) {
case 'logged-out': return '请先登录'
case 'empty': return '暂无数据'
case 'guide-current': return '保留中'
case 'guide-global': return '全局保留'
default: return '不可清理'
}
}
</script>
<style scoped>
.cache-cleanup-detail {
min-height: 100vh;
background: #f8f8f8;
padding-bottom: 80rpx;
}
/* 顶部类目信息 */
.header {
background: #fff;
padding: 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.header-label {
font-size: 36rpx;
font-weight: 600;
color: #333;
display: block;
}
.header-desc {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
display: block;
}
/* 加载占位 */
.loading {
text-align: center;
padding: 80rpx 0;
color: #999;
font-size: 28rpx;
}
/* 简单模板 */
.simple-body {
background: #fff;
margin-top: 20rpx;
padding: 48rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.big-stat {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 48rpx;
}
.big-value {
font-size: 72rpx;
font-weight: 600;
color: #1890ff;
line-height: 1.2;
}
.big-count {
font-size: 26rpx;
color: #999;
margin-top: 12rpx;
}
.action-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
width: 100%;
}
.clean-btn {
width: 480rpx;
height: 88rpx;
line-height: 88rpx;
background: #1890ff;
color: #fff;
font-size: 30rpx;
border-radius: 44rpx;
border: 0;
}
.clean-btn::after { border: 0; }
.clean-btn[disabled] {
background: #d9d9d9;
color: #fff;
}
.action-hint {
font-size: 22rpx;
color: #999;
}
/* 分组模板 */
.grouped-body {
background: #fff;
margin-top: 20rpx;
padding: 16rpx 24rpx;
}
.empty {
text-align: center;
padding: 60rpx 0;
color: #999;
font-size: 28rpx;
}
.group-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 16rpx;
border-bottom: 1rpx solid #f0f0f0;
background: #fff;
border-radius: 12rpx;
margin-bottom: 16rpx;
}
.group-card:last-child {
border-bottom: 0;
margin-bottom: 0;
}
.group-card-global {
background: #fafafa;
}
.group-main {
display: flex;
flex-direction: column;
gap: 8rpx;
flex: 1;
min-width: 0;
}
.group-title {
display: flex;
align-items: center;
gap: 8rpx;
}
.group-icon {
font-size: 28rpx;
}
.group-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.group-size {
font-size: 24rpx;
color: #999;
}
.group-warn {
font-size: 22rpx;
color: #faad14;
}
.clean-btn.small {
width: 160rpx;
height: 64rpx;
line-height: 64rpx;
font-size: 26rpx;
background: #1890ff;
color: #fff;
border-radius: 32rpx;
margin-left: 16rpx;
}
.clean-btn.small.strong {
background: #ff7a45;
}
.clean-btn.small[disabled] {
background: #d9d9d9;
color: #fff;
}
</style>

View File

@ -0,0 +1,257 @@
<template>
<view class="cache-cleanup">
<!-- 顶部 WeChat 风格 -->
<view class="hero">
<text class="hero-label">Topfans 已用空间</text>
<text class="hero-value">{{ formatSize(info.appUsedBytes) }}</text>
<text class="hero-percent"
>占据配额 {{ info.usagePercent.toFixed(1) }}% 存储空间</text
>
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: Math.min(100, info.usagePercent) + '%' }"
></view>
</view>
<view class="chips">
<view class="chip">Topfans 已用空间</view>
<view class="chip">内存缓存空间</view>
<view class="chip">总内存空间</view>
</view>
</view>
<!-- 缓存分类 -->
<view class="section">
<text class="section-title">缓存分类</text>
<!-- 全局空状态失败时不显示避免冲突显示 -->
<view
v-if="
!loadFailed &&
info.totalBytes === 0 &&
info.categories.every((c) => c.sizeBytes <= 0)
"
class="empty"
>
<text> 当前无缓存可清理</text>
</view>
<view
v-for="cat in info.categories"
:key="cat.id"
class="row"
@click="goDetail(cat.id)"
>
<view class="row-main">
<text class="row-label">{{ cat.label }}</text>
<text v-if="cat.warning" class="row-warn"> 按账号分组清理</text>
</view>
<view class="row-sub">
<text v-if="cat.sizeBytes === -1" class="error">? · 加载失败</text>
<text v-else class="size"
>{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} </text
>
<text class="chevron"></text>
</view>
</view>
</view>
<!-- "其他" section -->
<view class="section others-section">
<text class="section-title">其他</text>
<view class="row others-row">
<view class="row-main">
<text class="row-label">其他</text>
<text class="row-desc"
>包含运行 Topfans 的必要数据账号会话其他账号的数据等</text
>
</view>
<text class="size">{{ formatSize(info.othersBytes) }}</text>
</view>
</view>
<!-- 整体加载失败状态reviewer P0 修正加点击重试 -->
<view
v-if="loadFailed && info.totalBytes === 0"
class="load-failed"
@click="load"
>
<text>加载失败点此重试</text>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
import { getCacheInfo, formatSize } from "@/utils/cacheManager";
const info = ref({
appUsedBytes: 0,
quotaTotalBytes: 0,
quotaAvailableBytes: 0,
usagePercent: 0,
othersBytes: 0,
totalBytes: 0,
categories: [],
});
const loadFailed = ref(false);
async function load() {
loadFailed.value = false;
try {
const result = await Promise.race([
getCacheInfo(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 5000),
),
]);
info.value = result;
} catch (e) {
console.warn("[cache-cleanup] load failed:", e.message);
loadFailed.value = true;
}
}
function goDetail(id) {
const cat = info.value.categories.find((c) => c.id === id);
if (!cat || cat.sizeBytes <= 0) {
uni.showToast({ title: "该分类暂无缓存", icon: "none" });
return;
}
uni.navigateTo({ url: `/pages/profile/cache-cleanup-detail?id=${id}` });
}
// reviewer P0使 onShow load +
// 使 onMounted
onShow(load);
onPullDownRefresh(async () => {
await load();
uni.stopPullDownRefresh();
});
</script>
<style scoped>
.cache-cleanup {
padding-bottom: 40rpx;
}
.hero {
background: #fff;
padding: 40rpx 32rpx 32rpx;
}
.hero-label {
font-size: 26rpx;
color: #666;
}
.hero-value {
font-size: 72rpx;
font-weight: 600;
color: #1890ff;
display: block;
margin: 12rpx 0;
}
.hero-percent {
font-size: 24rpx;
color: #999;
}
.progress-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
margin: 16rpx 0 24rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #1890ff;
}
.chips {
display: flex;
gap: 16rpx;
}
.chip {
flex: 1;
padding: 12rpx;
background: #f5f7fa;
border-radius: 8rpx;
font-size: 22rpx;
color: #666;
text-align: center;
}
.section {
background: #fff;
margin-top: 20rpx;
padding: 0 32rpx;
}
.section-title {
font-size: 24rpx;
color: #999;
padding: 20rpx 0;
display: block;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.row-main {
display: flex;
flex-direction: column;
gap: 6rpx;
flex: 1;
min-width: 0;
padding-right: 24rpx;
}
.row-label {
font-size: 30rpx;
color: #333;
}
.row-warn {
font-size: 22rpx;
color: #faad14;
}
.row-sub {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.row-sub .size {
font-size: 26rpx;
color: #999;
}
.row-sub .chevron {
font-size: 32rpx;
color: #ccc;
}
.row-desc {
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
}
.others-row {
align-items: flex-start;
}
.others-row .size {
padding-top: 4rpx;
flex-shrink: 0;
white-space: nowrap;
}
.error {
color: #ff4d4f;
font-size: 26rpx;
}
.empty {
text-align: center;
padding: 60rpx 0;
color: #999;
font-size: 28rpx;
}
.load-failed {
text-align: center;
padding: 40rpx 0;
color: #1890ff;
font-size: 28rpx;
cursor: pointer;
}
</style>

View File

@ -229,6 +229,10 @@
inboxUnread
}}</text>
</view>
<view class="service-button" @tap="goCacheCleanup">
<text class="service-icon-emoji">💾</text>
<text class="service-text">存储空间</text>
</view>
<view class="service-button" @tap="handleShowFeedback">
<image
class="service-icon"
@ -680,6 +684,10 @@ function goMailbox() {
uni.navigateTo({ url: "/pages/mailbox/index" });
}
function goCacheCleanup() {
uni.navigateTo({ url: "/pages/profile/cache-cleanup" });
}
//
const handleGuideUpdated = () => {
console.log("[Profile] Guide updated");

View File

@ -0,0 +1,233 @@
// frontend/utils/cacheManager.js
// 缓存清理统一封装层
// 详见 docs/superpowers/specs/2026-07-28-cache-cleanup-design.md
import { invalidateAll } from './preloadApi/core'
import sandboxTmpHandler from './handlers/sandboxTmpHandler'
import progressHandler from './handlers/progressHandler'
import othersHandler from './handlers/othersHandler'
import preloadHandler from './handlers/preloadHandler'
import guideHandler from './handlers/guideHandler'
import draftHandler from './handlers/draftHandler'
// ── 黑名单§3.4.1)──
// 全等匹配
const PROTECTED_EXACT = new Set([
'access_token', 'user', 'star_id', 'login_mobile', 'cid',
'deviceFp', 'pending_scan_url', 'gallery_owner_id',
'needs_welcome', 'has_seen_welcome', 'is_new_user',
'liked_assets_exhibition',
])
// 前缀匹配
const PROTECTED_PREFIX = [
'daily_login_completed_',
'avatar_file_',
'temp_register_',
]
/**
* 判断 key 是否在黑名单中
*/
export function isProtectedKey(key) {
if (typeof key !== 'string') return false
if (PROTECTED_EXACT.has(key)) return true
return PROTECTED_PREFIX.some((p) => key.startsWith(p))
}
// ── 大小格式化§6.2)──
export function formatSize(bytes) {
if (typeof bytes !== 'number' || bytes < 0) return '—'
if (bytes < 1024) return '< 1 KB'
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
if (mb < 1024) return `${mb.toFixed(1)} MB`
return `${(mb / 1024).toFixed(2)} GB`
}
// ── 内部状态 ──
const handlers = new Map() // handlerId → handler
const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise
// ── 注册 API ──
export function registerCategory(handler) {
if (!handler?.id) throw new Error('[cacheManager] handler.id is required')
handlers.set(handler.id, handler)
}
function getHandler(id) {
const h = handlers.get(id)
if (!h) throw new Error(`[cacheManager] unknown handler: ${id}`)
return h
}
// ── 公共 API ──
/**
* 列表页读取汇总 + 存储配额 + 其他 section 数据
*/
export async function getCacheInfo() {
// [并行 1] 所有 handler 的 computeSize
const categories = await Promise.all(
Array.from(handlers.values()).map(async (h) => {
try {
const info = await h.computeSize()
return { id: h.id, label: h.label, description: h.description || '', sizeBytes: info?.sizeBytes ?? 0, keyCount: info?.keyCount ?? 0, warning: !!h.warning }
} catch (e) {
console.warn(`[cacheManager] computeSize failed: ${h.id}`, e.message)
return { id: h.id, label: h.label, description: h.description || '', sizeBytes: -1, keyCount: 0, warning: !!h.warning, error: e.message }
}
})
)
// [串行] 配额 + 黑名单 + 沙盒
let currentSizeKB = 0, limitSizeKB = 0, sandboxBytes = 0, blacklistBytes = 0
try {
const info = uni.getStorageInfoSync()
currentSizeKB = info.currentSize || 0
limitSizeKB = info.limitSize || 0
const allKeys = info.keys || []
// 黑名单 key 大小
for (const k of allKeys) {
if (isProtectedKey(k)) {
try {
const v = uni.getStorageSync(k)
if (v != null) blacklistBytes += JSON.stringify(v).length
} catch (e) { /* skip */ }
}
}
} catch (e) {
console.warn('[cacheManager] getStorageInfoSync failed:', e.message)
}
try {
const { getSandboxTotalSize } = await import('./ioPath')
sandboxBytes = await getSandboxTotalSize()
} catch (e) {
console.warn('[cacheManager] getSandboxTotalSize failed:', e.message)
}
const totalBytes = categories.reduce((sum, c) => sum + (c.sizeBytes > 0 ? c.sizeBytes : 0), 0)
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes
const quotaTotalBytes = limitSizeKB * 1024
const raw = quotaTotalBytes - appUsedBytes
const quotaAvailableBytes = Math.max(0, raw)
const quotaExceeded = raw < 0
const usagePercent = quotaTotalBytes > 0 ? (appUsedBytes / quotaTotalBytes) * 100 : 0
return {
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent,
othersBytes: blacklistBytes + sandboxBytes,
categories,
}
}
/**
* 详情页读取分组详情简单 handler 返回 null
*/
export async function getCategoryBreakdown(id) {
const h = getHandler(id)
if (typeof h.computeBreakdown !== 'function') return null
try {
return await h.computeBreakdown()
} catch (e) {
console.warn(`[cacheManager] computeBreakdown failed: ${id}`, e.message)
return []
}
}
/**
* 简单页清理无分组维度
*/
export function cleanCategory(id) {
return _runWithInFlight(`${id}`, async () => {
const h = getHandler(id)
if (typeof h.clean !== 'function') {
throw new Error(`[cacheManager] handler ${id} has no clean() (use cleanCategoryGroup)`)
}
try {
const result = await h.clean()
// preload 清理同步清内存§3.4.4
if (id === 'preload') {
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
}
return result
} catch (e) {
console.warn(`[cacheManager] clean failed: ${id}`, e.message)
throw e
}
})
}
/**
* 分组页清理 uid 维度
* @param {string} id
* @param {object} opts
* @param {string|null} opts.uid 'self' = 当前用户null = 其他用户聚合其他 = 具体 uid 字符串
*/
export function cleanCategoryGroup(id, opts = {}) {
const uidKey = opts.uid === undefined ? 'self' : String(opts.uid)
return _runWithInFlight(`${id}#${uidKey}`, async () => {
const h = getHandler(id)
if (typeof h.cleanGroup !== 'function') {
throw new Error(`[cacheManager] handler ${id} has no cleanGroup() (use cleanCategory)`)
}
try {
const result = await h.cleanGroup({ uid: opts.uid === undefined ? 'self' : opts.uid })
if (id === 'preload') {
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
}
return result
} catch (e) {
console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message)
throw e
}
})
}
/**
* 编程式清理所有UI 不调用登出流程/测试用
* 对每个 handler 调用其支持的清理方法
* - 优先 clean()简单型
* - 否则调 cleanGroup({ uid: 'self' })仅清当前用户的分组数据符合"登出前清自己"的语义
*/
export async function cleanAll() {
const results = []
for (const h of handlers.values()) {
try {
let r
if (typeof h.clean === 'function') {
r = await h.clean()
} else if (typeof h.cleanGroup === 'function') {
r = await h.cleanGroup({ uid: 'self' })
} else {
continue
}
results.push({ id: h.id, ...r, error: null })
} catch (e) {
console.warn(`[cacheManager] cleanAll failed for ${h.id}:`, e.message)
results.push({ id: h.id, freedBytes: 0, keyCount: 0, error: e.message })
}
}
try { invalidateAll() } catch (e) { console.warn('[cacheManager] cleanAll invalidateAll failed:', e.message) }
return { freedBytes: results.reduce((s, r) => s + (r.freedBytes || 0), 0), perCategory: results }
}
// NOTE: 必须非 async —— async 会把返回的 Promise 再包一层,破坏
// `p1 === p2` 同一性断言(参见 plan §Task 12 Step 2 自测)。
// 这里需要直接返回存储在 cleanInFlight 中的 Promise 引用本身。
function _runWithInFlight(key, fn) {
if (cleanInFlight.has(key)) return cleanInFlight.get(key)
const p = (async () => {
try { return await fn() } finally { cleanInFlight.delete(key) }
})()
cleanInFlight.set(key, p)
return p
}
// ── Handler 注册(模块加载时执行;新增 handler 在此追加 registerCategory 调用)──
registerCategory(sandboxTmpHandler)
registerCategory(progressHandler)
registerCategory(othersHandler)
registerCategory(preloadHandler)
registerCategory(guideHandler)
registerCategory(draftHandler)

View File

@ -5,7 +5,7 @@
* 支持四种模式API生成预填充选择光栅卡流程lenticular/*镭射工作室
*/
import { LENTICULAR_STUDIO_STORAGE_KEY } from '@/utils/castloveMintForm.js'
import { writeDraft } from '@/utils/draftStorage'
// ========== Storage Keys ==========
/** 生成流程的完整Payload包含mode、images等 */
@ -90,18 +90,9 @@ export function padImagesForSelection(images, minCount = 4) {
function persistFormData(formData) {
if (formData != null) {
try {
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))
} catch (e) {
console.error('persistFormData failed:', e)
if (e.name === 'QuotaExceededError' || e.message?.includes('quota')) {
return {
success: false,
error: {
title: '存储空间不足',
content: '图片数据过大,请尝试压缩图片后重新提交'
}
}
} else {
const saved = writeDraft('castlove_form_data', JSON.stringify(formData))
if (!saved) {
console.error('[castloveGenerationFlow] persistFormData: 保存失败(未登录或存储异常)')
return {
success: false,
error: {
@ -110,6 +101,15 @@ function persistFormData(formData) {
}
}
}
} catch (e) {
console.error('[castloveGenerationFlow] persistFormData 抛出异常:', e)
return {
success: false,
error: {
title: '存储失败',
content: '表单数据保存失败,请重试'
}
}
}
}
return { success: true }
@ -326,23 +326,44 @@ export function startPrefilledSelectionFlow({
* @param {Object} formData - 包含lenticularBgImagelenticularSubjectImage等字段
*/
export function persistLenticularPreviewMeta(formData) {
uni.setStorageSync(
LENTICULAR_STUDIO_STORAGE_KEY,
JSON.stringify({
bgPath: formData.lenticularBgImage || '',
subjectPath: formData.lenticularSubjectImage || '',
bgBase64: formData.lenticularBgBase64 || '',
subjectBase64: formData.lenticularSubjectBase64 || '',
nftInfo: formData.info || formData.nftInfo || '',
materialTypeIndex: formData.materialTypeIndex ?? 0,
aiDescription: formData.aiDescription || '',
})
)
uni.setStorageSync(
GENERATION_RESULT_META_KEY,
JSON.stringify({ displayMode: STUDIO_LENTICULAR, imageCount: 1 })
)
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify([{ type: 'lenticular' }]))
try {
const saved = writeDraft(
'lenticular_studio_payload',
JSON.stringify({
bgPath: formData.lenticularBgImage || '',
subjectPath: formData.lenticularSubjectImage || '',
bgBase64: formData.lenticularBgBase64 || '',
subjectBase64: formData.lenticularSubjectBase64 || '',
nftInfo: formData.info || formData.nftInfo || '',
materialTypeIndex: formData.materialTypeIndex ?? 0,
aiDescription: formData.aiDescription || '',
})
)
if (!saved) {
console.error('[castloveGenerationFlow] persistLenticularPreviewMeta: lenticular_studio_payload 保存失败')
}
} catch (e) {
console.error('[castloveGenerationFlow] persistLenticularPreviewMeta 抛出异常:', e)
}
try {
const saved = writeDraft(
'generation_result_meta',
JSON.stringify({ displayMode: STUDIO_LENTICULAR, imageCount: 1 })
)
if (!saved) {
console.error('[castloveGenerationFlow] persistLenticularPreviewMeta: generation_result_meta 保存失败')
}
} catch (e) {
console.error('[castloveGenerationFlow] persistLenticularPreviewMeta 抛出异常:', e)
}
try {
const saved = writeDraft('generated_images', JSON.stringify([{ type: 'lenticular' }]))
if (!saved) {
console.error('[castloveGenerationFlow] persistLenticularPreviewMeta: generated_images 保存失败')
}
} catch (e) {
console.error('[castloveGenerationFlow] persistLenticularPreviewMeta 抛出异常:', e)
}
}
/**
@ -353,11 +374,25 @@ export function persistLenticularPreviewMeta(formData) {
* @param {string} [instanceNo] - 镭射卡实例编号
*/
export function persistLaserPreviewImages(paths, instanceNo) {
uni.setStorageSync(
GENERATION_RESULT_META_KEY,
JSON.stringify({ displayMode: STUDIO_LASER, imageCount: paths.length, instanceNo: instanceNo || '' })
)
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(paths))
try {
const saved = writeDraft(
'generation_result_meta',
JSON.stringify({ displayMode: STUDIO_LASER, imageCount: paths.length, instanceNo: instanceNo || '' })
)
if (!saved) {
console.error('[castloveGenerationFlow] persistLaserPreviewImages: generation_result_meta 保存失败')
}
} catch (e) {
console.error('[castloveGenerationFlow] persistLaserPreviewImages 抛出异常:', e)
}
try {
const saved = writeDraft('generated_images', JSON.stringify(paths))
if (!saved) {
console.error('[castloveGenerationFlow] persistLaserPreviewImages: generated_images 保存失败')
}
} catch (e) {
console.error('[castloveGenerationFlow] persistLaserPreviewImages 抛出异常:', e)
}
}
/**
@ -379,24 +414,38 @@ export async function completeSelectionAndOpenDetail({
}) {
const img = await materializeImageRef(selectedImage)
const storedImage = img.path || img.base64 || selectedImage
uni.setStorageSync(CRAFT_SELECTED_IMAGE_KEY, storedImage)
try {
const saved = writeDraft('craft_selected_image', storedImage)
if (!saved) {
console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail: craft_selected_image 保存失败')
}
} catch (e) {
console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail 抛出异常:', e)
}
uni.setStorageSync(CRAFT_SELECTED_INDEX_KEY, String(selectedIndex ?? 0))
// 根据工作室类型保存特定的预览元数据
if (formData?.studio_kind === STUDIO_LENTICULAR) {
const subjectRef = storedImage
uni.setStorageSync(
LENTICULAR_STUDIO_STORAGE_KEY,
JSON.stringify({
bgPath: formData.lenticularBgImage || '',
subjectPath: subjectRef,
bgBase64: formData.lenticularBgBase64 || '',
subjectBase64: img.base64 || formData.lenticularSubjectBase64 || '',
nftInfo: formData.info || formData.nftInfo || '',
materialTypeIndex: formData.materialTypeIndex ?? 0,
aiDescription: formData.aiDescription || '',
})
)
try {
const saved = writeDraft(
'lenticular_studio_payload',
JSON.stringify({
bgPath: formData.lenticularBgImage || '',
subjectPath: subjectRef,
bgBase64: formData.lenticularBgBase64 || '',
subjectBase64: img.base64 || formData.lenticularSubjectBase64 || '',
nftInfo: formData.info || formData.nftInfo || '',
materialTypeIndex: formData.materialTypeIndex ?? 0,
aiDescription: formData.aiDescription || '',
})
)
if (!saved) {
console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail: lenticular_studio_payload 保存失败')
}
} catch (e) {
console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail 抛出异常:', e)
}
}
// 镭射studio_kind === laser选卡与铸造在 laser-result + useLaserMint不在此写入工坊 entry

View File

@ -1,6 +1,7 @@
import { getOssSignatureApi, createMintOrderApi, uploadMaterialApi, bindAssetMaterialsApi } from '@/utils/api.js'
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'
import { buildCastloveFormSnapshot, CRAFT_TAG_LASER, CRAFT_TAG_LENTICULAR } from '@/utils/castloveMintForm.js'
import { writeDraft } from '@/utils/draftStorage'
function uploadFileToOss(tempFilePath, ossData) {
return new Promise((resolve, reject) => {
@ -722,7 +723,14 @@ export async function submitCraftMintFromPath({ imagePath, bgImagePath, laserBac
nftData.laser_cutout_path = cutoutPath
}
}
uni.setStorageSync('temp_nft_data', JSON.stringify(nftData))
try {
const saved = writeDraft('temp_nft_data', JSON.stringify(nftData))
if (!saved) {
console.error('[craftMintSubmit] 保存 NFT 数据失败(未登录或存储异常)')
}
} catch (e) {
console.error('[craftMintSubmit] 保存 NFT 数据失败:', e)
}
uni.removeStorageSync('castlove_form_data')
console.log('[craftMintSubmit] 完成')

View File

@ -0,0 +1,127 @@
// frontend/utils/draftStorage.js
// 草稿 storage 双读单写封装
// 目的:账号隔离 + 迁移兼容(老无 uid 后缀 key 过渡到 *_${uid} 形式)
// 注意BASE_KEYS 是**实际存储 key 值**(不是 JS 变量名)
// 'CASTLOVE_FORM_KEY' 是 export const 名,其值是 'castlove_form_data',因此不要写 'CASTLOVE_FORM_KEY'
const BASE_KEYS = [
'castlove_form_data',
'temp_nft_data',
'generated_images',
'generation_result_meta',
'lenticular_studio_payload',
'craft_selected_image',
]
/**
* 取当前用户 uid用于拼 key 后缀
* @returns {string|null}
*/
export function getCurrentUid() {
try {
const userStr = uni.getStorageSync('user')
if (!userStr) return null
const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
return user?.uid ? String(user.uid) : null
} catch (e) {
return null
}
}
/**
* 构造带 uid 后缀的 key uid fallback 到原 key用于未登录态的兼容性读
*/
function buildKey(baseKey, uid) {
return uid ? `${baseKey}_${uid}` : baseKey
}
/**
* 读取草稿双读先新后旧命中后迁移
* **返回原始 storage **字符串或对象取决于写入方式由调用方决定是否 JSON.parse
* 这样保持与原 `uni.getStorageSync(...)` 后接 `JSON.parse(...)` 的调用模式完全兼容
* @param {string} baseKey
* @returns {any|null} 原始值可能是 JSON 字符串或已 stringify 的对象null = 未命中
*/
export function readDraft(baseKey) {
const uid = getCurrentUid()
// 1. 优先读新 key
const newKey = buildKey(baseKey, uid)
try {
const val = uni.getStorageSync(newKey)
if (val !== '' && val !== null && val !== undefined) return val
} catch (e) { /* 继续 fallback */ }
// 2. fallback 读老 key仅登录态有意义
if (uid) {
try {
const oldVal = uni.getStorageSync(baseKey)
if (oldVal !== '' && oldVal !== null && oldVal !== undefined) {
// 3. 迁移:写到新 key删老 key
try {
uni.setStorageSync(newKey, oldVal)
uni.removeStorageSync(baseKey)
} catch (e) { /* 迁移失败也返回数据 */ }
return oldVal
}
} catch (e) { /* ignore */ }
}
return null
}
/**
* 写入草稿仅写新 key未登录态拒绝写
* @param {string} baseKey
* @param {string|object} value 通常是 JSON.stringify 后的字符串
* @returns {boolean} 是否写入成功
*/
export function writeDraft(baseKey, value) {
const uid = getCurrentUid()
if (!uid) return false
const newKey = buildKey(baseKey, uid)
try {
uni.setStorageSync(newKey, typeof value === 'string' ? value : JSON.stringify(value))
return true
} catch (e) {
console.warn('[draftStorage] writeDraft failed:', baseKey, e.message)
return false
}
}
/**
* 删除草稿仅删当前 uid 的新 key + key
*/
export function removeDraft(baseKey) {
const uid = getCurrentUid()
const newKey = buildKey(baseKey, uid)
try { uni.removeStorageSync(newKey) } catch (e) { /* skip */ }
try { uni.removeStorageSync(baseKey) } catch (e) { /* skip */ }
}
/**
* 获取所有 base key用于 handler 遍历
*/
export function getAllBaseKeys() {
return BASE_KEYS.slice()
}
/**
* 列出当前 uid 的所有草稿 key含老 key 兜底
*/
export function listCurrentDraftKeys() {
const uid = getCurrentUid()
try {
const info = uni.getStorageInfoSync()
const allKeys = info.keys || []
if (uid) {
return allKeys.filter((k) => {
return BASE_KEYS.some((base) => k === `${base}_${uid}` || k === base)
})
} else {
return allKeys.filter((k) => BASE_KEYS.includes(k))
}
} catch (e) {
return []
}
}
// safeParse 移除readDraft 现在返回原值,调用方自行 JSON.parse

View File

@ -0,0 +1,162 @@
// frontend/utils/handlers/draftHandler.js
// 创作草稿 handler分组型按 uid 分组;草稿 key 已通过 M0 改造为 *_${uid} 形式)
import { getCurrentUid, getAllBaseKeys } from '@/utils/draftStorage'
function bytesOf(keys) {
let total = 0
for (const k of keys) {
try { const v = uni.getStorageSync(k); if (v != null) total += JSON.stringify(v).length } catch (e) {}
}
return total
}
function listAllDraftLikeKeys() {
try {
const info = uni.getStorageInfoSync()
const all = info.keys || []
const baseKeys = getAllBaseKeys()
return all.filter((k) => baseKeys.some((base) => k === base || k.startsWith(`${base}_`)))
} catch (e) { return [] }
}
function extractUidFromDraftKey(key, baseKey) {
const prefix = `${baseKey}_`
if (!key.startsWith(prefix)) return null
return key.slice(prefix.length)
}
/**
* Remove the listed storage keys, counting only successful removals.
* Reads each value first to capture accurate freedBytes before deletion.
* @param {string[]} targets
* @returns {{freedBytes: number, keyCount: number}}
*/
function removeAndCount(targets) {
let freed = 0
let count = 0
for (const k of targets) {
try {
let bytes = 0
try { const v = uni.getStorageSync(k); if (v != null) bytes = JSON.stringify(v).length } catch (e) {}
uni.removeStorageSync(k)
freed += bytes
count++
} catch (e) {
console.warn('[draftHandler] remove failed:', k, e.message)
}
}
return { freedBytes: freed, keyCount: count }
}
export default {
id: 'draft',
label: '创作中的草稿',
description: '未提交的创作表单/生成结果',
warning: true,
warningText: '将清空你未提交的创作草稿,是否继续?',
strongWarningText: '将清空该账号的草稿,对方下次登录不会看到。是否继续?',
async computeSize() {
const keys = listAllDraftLikeKeys()
return { sizeBytes: bytesOf(keys), keyCount: keys.length }
},
async computeBreakdown() {
const currentUid = getCurrentUid()
const allKeys = listAllDraftLikeKeys()
const groups = []
const baseKeys = getAllBaseKeys()
const isLoggedIn = !!currentUid
// 按 uid 聚合
const byUid = new Map()
for (const k of allKeys) {
const matchedBase = baseKeys.find((b) => k === b || k.startsWith(`${b}_`))
if (!matchedBase) continue
const uid = extractUidFromDraftKey(k, matchedBase)
const groupUid = uid || '__legacy__'
if (!byUid.has(groupUid)) byUid.set(groupUid, [])
byUid.get(groupUid).push(k)
}
for (const [uid, keys] of byUid) {
const isCurrent = uid === currentUid
const isLegacy = uid === '__legacy__'
let canClean, disabledReason
if (!isLoggedIn) {
canClean = false
disabledReason = 'logged-out'
} else if (isLegacy) {
// 老无 uid 后缀 key 单独分组(不与他人草稿混合)
canClean = keys.length > 0
disabledReason = keys.length === 0 ? 'empty' : null
} else {
canClean = keys.length > 0
disabledReason = keys.length === 0 ? 'empty' : null
}
groups.push({
uid,
displayUid: isLegacy ? '老版本数据' : (isCurrent ? `我的 (${uid})` : `其他用户 (${uid})`),
isCurrent,
sizeBytes: bytesOf(keys),
keyCount: keys.length,
canClean,
disabledReason,
})
}
return groups.sort((a, b) => b.sizeBytes - a.sizeBytes)
},
async cleanGroup({ uid }) {
const currentUid = getCurrentUid()
const allKeys = listAllDraftLikeKeys()
const baseKeys = getAllBaseKeys()
const reserved = new Set()
if (currentUid) {
// 保留当前 uid 的 keys
for (const k of allKeys) {
const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`))
if (!base) continue
if (extractUidFromDraftKey(k, base) === currentUid) reserved.add(k)
}
}
if (uid === 'self') {
const targets = allKeys.filter((k) => {
const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`))
if (!base) return false
return extractUidFromDraftKey(k, base) === currentUid
})
return removeAndCount(targets)
} else if (uid === '__legacy__') {
// 单独清老无 uid 后缀 key不删其他用户的
const targets = allKeys.filter((k) => {
const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`))
if (!base) return false
return k === base
})
return removeAndCount(targets)
} else if (uid === 'others') {
// 其他用户聚合(不含 legacy
const targets = allKeys.filter((k) => {
const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`))
if (!base) return false
const u = extractUidFromDraftKey(k, base)
return u && u !== currentUid
})
return removeAndCount(targets)
} else {
// 具体其他 uid
const targets = allKeys.filter((k) => {
const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`))
if (!base) return false
if (reserved.has(k)) return false // defensive
return extractUidFromDraftKey(k, base) === uid
})
return removeAndCount(targets)
}
},
}

View File

@ -0,0 +1,138 @@
// frontend/utils/handlers/guideHandler.js
// 引导记录 handler分组型含保留规则
// spec §3.4.2: 保留当前 uid 的 + 全局 key其他用户组仅在登录态可清
import { getCurrentUid } from '@/utils/draftStorage' // 复用
import { listGuideKeys, extractUidFromGuideKey } from './guideKeys'
const GLOBAL_PROTECTED_KEYS = new Set(['guide_debug_mode', 'guide_first_show'])
function isGlobalKey(key) {
// guide_shown_xxx 是无 uid 段(但有 configKey
if (GLOBAL_PROTECTED_KEYS.has(key)) return true
if (key.startsWith('guide_shown_')) return true
return false
}
function bytesOf(keys) {
let total = 0
for (const k of keys) {
try { const v = uni.getStorageSync(k); if (v != null) total += JSON.stringify(v).length } catch (e) {}
}
return total
}
export default {
id: 'guide',
label: '引导记录',
description: '新手引导完成标记',
warning: false,
async computeSize() {
const keys = listGuideKeys()
return { sizeBytes: bytesOf(keys), keyCount: keys.length }
},
async computeBreakdown() {
const currentUid = getCurrentUid()
const allKeys = listGuideKeys()
const groups = []
const isLoggedIn = !!currentUid
// 1. 我的currentUid— 保留不可清
if (currentUid) {
const myKeys = allKeys.filter((k) => extractUidFromGuideKey(k) === currentUid)
groups.push({
uid: currentUid, displayUid: currentUid, isCurrent: true,
sizeBytes: bytesOf(myKeys), keyCount: myKeys.length,
canClean: false, disabledReason: 'guide-current',
})
}
// 2. 其他用户(按 uid 聚合)— 仅登录态可清;未登录态保留展示但按钮置灰
const otherUidKeys = new Map()
for (const k of allKeys) {
const uid = extractUidFromGuideKey(k)
if (!uid || uid === currentUid) continue
if (!otherUidKeys.has(uid)) otherUidKeys.set(uid, [])
otherUidKeys.get(uid).push(k)
}
for (const [uid, keys] of otherUidKeys) {
groups.push({
uid, displayUid: uid, isCurrent: false,
sizeBytes: bytesOf(keys), keyCount: keys.length,
canClean: isLoggedIn && keys.length > 0,
disabledReason: isLoggedIn ? (keys.length === 0 ? 'empty' : null) : 'logged-out',
})
}
// 3. 全局 guide_*(无 uid 段)— 永远不可清
const globalKeys = allKeys.filter(isGlobalKey)
if (globalKeys.length > 0) {
groups.push({
uid: '__global__', displayUid: '全局标记', isCurrent: false,
sizeBytes: bytesOf(globalKeys), keyCount: globalKeys.length, global: true,
canClean: false, disabledReason: 'guide-global',
})
}
// 按 sizeBytes 降序spec §4.3.2 要求)
return groups.sort((a, b) => b.sizeBytes - a.sizeBytes)
},
async cleanGroup({ uid }) {
const currentUid = getCurrentUid()
const allKeys = listGuideKeys()
// 保留:当前 uid 的 + 全局 key
const reserved = new Set()
if (currentUid) {
for (const k of allKeys) if (extractUidFromGuideKey(k) === currentUid) reserved.add(k)
}
for (const k of allKeys) if (isGlobalKey(k)) reserved.add(k)
let targetKeys = []
if (uid === 'self') {
// 不允许清自己的(保留规则)
return { freedBytes: 0, keyCount: 0 }
} else if (uid === null) {
// 其他用户聚合(不含全局)
targetKeys = allKeys.filter((k) => {
if (reserved.has(k)) return false
const u = extractUidFromGuideKey(k)
return u && u !== currentUid
})
} else if (uid === '__global__') {
// 全局 key 也不允许清
return { freedBytes: 0, keyCount: 0 }
} else {
// 具体其他 uid
targetKeys = allKeys.filter((k) => {
if (reserved.has(k)) return false // defensive
return extractUidFromGuideKey(k) === uid
})
}
// 逐 key先量后删per-key 精确值,避免 approximation
let freed = 0
let count = 0
for (const k of targetKeys) {
let valueBytes = 0
try {
const v = uni.getStorageSync(k)
if (v != null) valueBytes = JSON.stringify(v).length
} catch (e) {
console.warn('[guideHandler] read failed:', k, e.message)
continue
}
try {
uni.removeStorageSync(k)
freed += valueBytes
count++
} catch (e) {
console.warn('[guideHandler] remove failed:', k, e.message)
}
}
return { freedBytes: freed, keyCount: count }
},
}

View File

@ -0,0 +1,30 @@
// frontend/utils/handlers/guideKeys.js
// guide 分类工具函数(供 guideHandler 和 othersHandler 共用)
const GUIDE_PREFIX = 'guide_'
/**
* 列出所有 guide_* key
*/
export function listGuideKeys() {
try {
const info = uni.getStorageInfoSync()
return (info.keys || []).filter((k) => k.startsWith(GUIDE_PREFIX))
} catch (e) { return [] }
}
/**
* 解析 key 中包含的 uid如果有
* 支持两种 owner
* - 数字 uid 'guide_done_10001_intro'
* - 'default' 'guide_done_default_intro'guideConfig.js 在未登录时使用
*/
export function extractUidFromGuideKey(key) {
// 先试数字
const m = key.match(/_(\d+)(?:_|$)/)
if (m) return m[1]
// 再试 default
const d = key.match(/_(default)(?:_|$)/)
if (d) return 'default'
return null
}

View File

@ -0,0 +1,79 @@
// frontend/utils/handlers/othersHandler.js
// 兜底分类:未匹配其他规则的 key且不在黑名单
// 注意:草稿 keys无论 currentUid / otherUid / legacy**全部**归 draftHandler
// 否则会双重计入 totalBytes 且强警告无法生效
//
// 安全白名单reviewer P0以下 key 属于其他模块的工作流关键状态,
// 严禁被 othersHandler 清理:
import { isProtectedKey } from '@/utils/cacheManager'
import { getAllBaseKeys } from '@/utils/draftStorage'
const PRELOAD_PREFIX = 'preload:'
const PROGRESS_PREFIX = 'progress_'
const GUIDE_PREFIX = 'guide_'
const DRAFT_BASE_KEYS = getAllBaseKeys()
// 工作流关键 key 白名单reviewer P0 修正:原"全收"策略会破坏这些)
const WORKFLOW_CRITICAL_KEYS = new Set([
'generation_flow_payload',
'generation_request_data',
'craft_selected_index',
'__package_info__',
'UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH',
])
function isClaimed(key) {
if (isProtectedKey(key)) return false
if (WORKFLOW_CRITICAL_KEYS.has(key)) return false
if (key.startsWith('topfans_pending_actions_')) return false
if (key.startsWith(PRELOAD_PREFIX)) return false
if (key.startsWith(PROGRESS_PREFIX)) return false
if (key.startsWith(GUIDE_PREFIX)) return false
if (DRAFT_BASE_KEYS.some((base) => key === base || key.startsWith(`${base}_`))) return false
return true
}
export default {
id: 'others',
label: '其他业务缓存',
description: '未归类的少量业务数据',
warning: false,
async computeSize() {
try {
const info = uni.getStorageInfoSync()
const keys = (info.keys || []).filter(isClaimed)
let bytes = 0
for (const k of keys) {
try { const v = uni.getStorageSync(k); if (v != null) bytes += JSON.stringify(v).length } catch (e) {}
}
return { sizeBytes: bytes, keyCount: keys.length }
} catch (e) { return { sizeBytes: 0, keyCount: 0 } }
},
async clean() {
// Top-level errors propagate to cacheManager (per spec §6.1)
const info = uni.getStorageInfoSync()
const keys = (info.keys || []).filter(isClaimed)
let freed = 0
let count = 0
for (const k of keys) {
let valueBytes = 0
try {
const v = uni.getStorageSync(k)
if (v != null) valueBytes = JSON.stringify(v).length
} catch (e) {
console.warn('[othersHandler] read failed:', k, e.message)
continue // skip this key, don't try to delete
}
try {
uni.removeStorageSync(k)
freed += valueBytes
count++
} catch (e) {
console.warn('[othersHandler] remove failed:', k, e.message)
// don't count as freed
}
}
return { freedBytes: freed, keyCount: count }
},
}

View File

@ -0,0 +1,51 @@
// frontend/utils/handlers/preloadHandler.js
// preload 缓存 handler简单型但跨用户清理 + invalidateAll
// 跨用户:删除所有 preload:xxx:* 不依赖 currentUid
// 内存清理:由 cacheManager.cleanCategory 触发 invalidateAll
const PREFIX = 'preload:'
function listAllPreloadKeys() {
try {
const info = uni.getStorageInfoSync()
return (info.keys || []).filter((k) => k.startsWith(PREFIX))
} catch (e) { return [] }
}
export default {
id: 'preload',
label: '预加载数据缓存',
description: '列表页提前拉取的数据',
warning: false,
async computeSize() {
const keys = listAllPreloadKeys()
let bytes = 0
for (const k of keys) {
try { const v = uni.getStorageSync(k); if (v != null) bytes += JSON.stringify(v).length } catch (e) {}
}
return { sizeBytes: bytes, keyCount: keys.length }
},
async clean() {
const keys = listAllPreloadKeys()
let freed = 0
let count = 0
for (const k of keys) {
let valueBytes = 0
try {
const v = uni.getStorageSync(k)
if (v != null) valueBytes = JSON.stringify(v).length
} catch (e) {
console.warn('[preloadHandler] read failed:', k, e.message)
continue
}
try {
uni.removeStorageSync(k)
freed += valueBytes
count++
} catch (e) {
console.warn('[preloadHandler] remove failed:', k, e.message)
}
}
return { freedBytes: freed, keyCount: count }
},
}

View File

@ -0,0 +1,46 @@
// frontend/utils/handlers/progressHandler.js
// 活动进度缓存 handler简单型
import { isProtectedKey } from '@/utils/cacheManager'
const PREFIX = 'progress_'
function listProgressKeys() {
try {
const info = uni.getStorageInfoSync()
return (info.keys || []).filter((k) => k.startsWith(PREFIX) && !isProtectedKey(k))
} catch (e) { return [] }
}
function computeSizeOf(keys) {
let total = 0
for (const k of keys) {
try {
const v = uni.getStorageSync(k)
if (v != null) total += JSON.stringify(v).length
} catch (e) { /* skip */ }
}
return total
}
export default {
id: 'progress',
label: '活动进度缓存',
description: '支持活动页断网浏览',
warning: false,
async computeSize() {
const keys = listProgressKeys()
return { sizeBytes: computeSizeOf(keys), keyCount: keys.length }
},
async clean() {
const keys = listProgressKeys()
let freed = 0
for (const k of keys) {
try {
const v = uni.getStorageSync(k)
if (v != null) freed += JSON.stringify(v).length
uni.removeStorageSync(k)
} catch (e) { /* skip */ }
}
return { freedBytes: freed, keyCount: keys.length }
},
}

View File

@ -0,0 +1,27 @@
// frontend/utils/handlers/sandboxTmpHandler.js
// 沙盒临时文件 handler简单型
// reviewer P0 修正:不再依赖私有 ioPath 内部函数,全部走 ioPath 导出 API
import { scanSandboxTmpFiles, countSandboxTmpFiles, clearAllSandboxTmpFiles } from '@/utils/ioPath'
export default {
id: 'sandbox-tmp',
label: '临时文件',
description: '上传/分享过程产生的临时文件',
warning: false,
async computeSize() {
const [bytes, count] = await Promise.all([
scanSandboxTmpFiles(),
countSandboxTmpFiles(), // 真实文件数reviewer P0 修正)
])
return { sizeBytes: bytes, keyCount: count }
},
async clean() {
const [before, beforeCount] = await Promise.all([
scanSandboxTmpFiles(),
countSandboxTmpFiles(),
])
await clearAllSandboxTmpFiles()
const after = await scanSandboxTmpFiles()
return { freedBytes: Math.max(0, before - after), keyCount: beforeCount }
},
}

View File

@ -329,4 +329,195 @@ export async function clearAllSandboxTmpFiles() {
// #ifndef APP-PLUS
return Promise.resolve({ scanned: 0, deleted: 0, dirs: [] })
// #endif
}
}
/**
* 通过 plus.android 读取 APK 安装包大小Android 专用绕开 sandbox 限制
* iOS / H5 / 小程序上返回 0调用方应改用 sandbox 大小
* @returns {Promise<number>} APK 字节数
*/
async function getAndroidApkSize() {
// #ifdef APP-PLUS
try {
// 仅 Android
if (typeof plus === 'undefined' || !plus.android) return 0
const main = plus.android.runtimeMainActivity()
if (!main) return 0
const Context = plus.android.importClass('android.content.Context')
const FLAG_PKG_DATA = Context.PM_FLAGS || 0 // 不一定存在
const pm = main.getPackageManager()
const pkg = main.getPackageName()
// getPackageSizeInfo 是异步回调,这里改用 sourceDir 同步读
const appInfo = pm.getApplicationInfo(pkg, 0)
const sourceDir = appInfo.sourceDir
if (!sourceDir) return 0
const File = plus.android.importClass('java.io.File')
const f = new File(sourceDir)
const size = f.length()
if (typeof size === 'number' && size > 0) return size
return 0
} catch (e) {
console.warn('[ioPath] getAndroidApkSize failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 计算"app 已用空间" = Android APK 安装包大小 + sandbox 运行时数据大小
* iOS / H5 上只算 sandboxiOS bundle 不易从 js 取大小 sandbox 估算
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes
*/
export async function getSandboxTotalSize() {
// #ifdef APP-PLUS
try {
// Android: APK 大小 + sandbox doc 目录运行时数据
const apkSize = await getAndroidApkSize()
let sandboxBytes = 0
try {
const root = await getSandboxRootDir()
sandboxBytes = await _sumDirSize(root)
} catch (e) {
console.warn('[ioPath] _sumDirSize failed:', e?.message)
}
return apkSize + sandboxBytes
} catch (e) {
console.warn('[ioPath] getSandboxTotalSize failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 扫描 sandbox doc "所有业务子目录的 tmp/ 文件总字节数"不包含白名单 preload/share/image
* 用于 sandboxTmpHandler.computeSize
*
* getSandboxTotalSize 的区别
* - getSandboxTotalSize: 沙盒 doc 下所有文件 preload/share/image 白名单
* - scanSandboxTmpFiles: 只统计 tmp/ 子目录用于清理项 clearAllSandboxTmpFiles 一致
*/
export async function scanSandboxTmpFiles() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const topEntries = await _listDir(root)
let total = 0
for (const top of topEntries) {
if (!top.isDirectory) continue
if (PROTECTED_SUBDIRS.has(top.name)) continue
const tmpEntry = await new Promise((resolve) => {
top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null))
})
if (!tmpEntry) continue
total += await _sumDirSize(tmpEntry)
}
return total
} catch (e) {
console.warn('[ioPath] scanSandboxTmpFiles failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 计数 sandbox doc tmp/ 子目录的文件总数reviewer P0修正导出供 sandboxTmpHandler 使用
* scanSandboxTmpFiles 范围一致 tmp/不含白名单 preload/share/image
*/
export async function countSandboxTmpFiles() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const topEntries = await _listDir(root)
let count = 0
const walk = async (dir) => {
const entries = await _listDir(dir)
for (const e of entries) {
if (e.isFile) count++
else if (e.isDirectory) await walk(e)
}
}
for (const top of topEntries) {
if (!top.isDirectory) continue
if (PROTECTED_SUBDIRS.has(top.name)) continue
const tmpEntry = await new Promise((resolve) => {
top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null))
})
if (!tmpEntry) continue
await walk(tmpEntry)
}
return count
} catch (e) {
console.warn('[ioPath] countSandboxTmpFiles failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 递归求和目录下所有文件大小bytes
*/
async function _sumDirSize(dirEntry) {
// #ifdef APP-PLUS
let total = 0
let entries
try {
entries = await _listDir(dirEntry)
} catch (e) {
// 单个目录读失败 → 跳过整个目录(不影响兄弟目录)
console.warn('[ioPath] _sumDirSize read failed for a dir, skipping:', e?.message)
return total
}
for (const entry of entries) {
if (entry.isDirectory) {
total += await _sumDirSize(entry)
} else if (entry.isFile) {
try {
const file = await new Promise((res, rej) => entry.file(res, rej))
total += (file.size || 0)
} catch (e) { /* skip individual file */ }
}
}
return total
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 列出目录所有 entryDirectoryEntry / FileEntry
*/
async function _listDir(dirEntry) {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
const reader = dirEntry.createReader()
const collected = []
const readAll = () => {
reader.readEntries(
(es) => {
if (es.length === 0) return resolve(collected)
collected.push(...es)
readAll()
},
(err) => reject(err)
)
}
readAll()
})
// #endif
// #ifndef APP-PLUS
return []
// #endif
}

View File

@ -27,6 +27,9 @@ const inFlightMap = new Map() // Map<cacheKey, {promise, abort}>
let hitCount = 0
let missCount = 0
// 清理代际计数器invalidateAll() 增加_doFetch 启动时捕获,提交结果前比对,避免清理期间写入过期数据
let _cleanupGeneration = 0
// 运行时配置(由外部 setConfig 写入)
let _config = {
defaults: {
@ -247,10 +250,26 @@ export function invalidatePrefix(prefix) {
}
/**
* 清空全部内存缓存不动文件缓存
* 清空全部内存缓存 + 取消所有 in-flight 请求不动文件缓存
* 写入路径会在 _doFetch 内通过 _cleanupGeneration 比对自动失效
*/
export function invalidateAll() {
// 1. 标记清理事件in-flight fetch 在写入前会比对 generation
_cleanupGeneration++
// 2. 清空内存缓存
memoryMap.clear()
// 3. 取消所有 in-flight 请求 + 清空 inFlightMap
for (const [key, entry] of inFlightMap) {
try { entry.abort() } catch (e) { /* skip */ }
}
inFlightMap.clear()
}
/**
* 获取当前清理代际调试 / 测试用
*/
export function getCleanupGeneration() {
return _cleanupGeneration
}
/**
@ -389,6 +408,9 @@ async function _doFetch(logicalKey, params, cacheKey, userId, isRun, skipFileCac
inFlightMap.delete(cacheKey)
}
// 捕获启动时的清理代际,提交结果前比对,避免在 invalidateAll() 之后写入过期数据
const genAtStart = _cleanupGeneration
// 5. 并发控制 + 超时
await _semaphore.acquire()
@ -416,6 +438,13 @@ async function _doFetch(logicalKey, params, cacheKey, userId, isRun, skipFileCac
const elapsed = Date.now() - startTime
console.log('[preload] fetch done:', logicalKey, elapsed + 'ms')
// Generation check若 invalidateAll() 在 fetch 期间已触发,丢弃本次结果不写入
if (genAtStart !== _cleanupGeneration) {
console.warn('[preload] fetch stale, cleanup generation changed:', logicalKey)
resolveInFlight(null)
return null
}
// 6. 写内存缓存
const entry = { data: result, ts: Date.now(), ttl, persistence }
touchLRU(memoryMap, cacheKey, entry)