feat:添加存储空间
This commit is contained in:
parent
128a12fa03
commit
d5a08db56c
680
docs/specs/2026-07-28-cache-cleanup-design.md
Normal file
680
docs/specs/2026-07-28-cache-cleanup-design.md
Normal 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-100,1 位小数)= 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 plugin(iOS `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}` 后缀的草稿 key(uid 改造完成后;详见 §11);迁移期内老无 uid 后缀 key 归 others
|
||||
│ ├─ progress.clean → 删除 progress_* key
|
||||
│ ├─ guide.clean → 删除其他用户的 `guide_*_${otherUid}_*` + 当前用户的 `guide_done_*`/`guide_step_*`/...(详见 §3.4.2)
|
||||
│ ├─ sandbox-tmp.clean → clearAllSandboxTmpFiles()
|
||||
│ └─ others.clean → 删除未分类 key(含迁移期遗留的无 uid 后缀草稿 key)
|
||||
├─ ★ 同步清理内存层:core.invalidateAll()(详见 §3.4.4)
|
||||
↓
|
||||
返回 { freedBytes, perCategory[] }
|
||||
```
|
||||
|
||||
### 5.3 关键设计决策
|
||||
|
||||
1. **getCacheInfo 异步并行**:6 个 handler 用 `Promise.all` 并行计算
|
||||
2. **cleanAll 顺序执行**:部分清理会互斥,顺序更安全;用 `uni.showLoading({mask:true})` 包裹
|
||||
3. **失败容错**:单个 handler clean() 失败不影响其他 handler,外层 try/catch 记录 error 并跳过
|
||||
4. **空状态**:若 totalBytes = 0,列表页显示 "✓ 当前无缓存可清理",所有分类行点击无响应(toast 提示"暂无缓存")
|
||||
5. **重新计算时机**:清理完成后**必须**重算 `getCacheInfo()`,不能用 `info.totalBytes - freedBytes` 简单相减
|
||||
6. **cleanCategory/cleanCategoryGroup 单例保护**:`cleanCategory(id)` 与 `cleanCategoryGroup(id, opts)` 各维护一个 `_inFlight` Map(key 为 `id` 或 `${id}#${uid}`);并发触发同一目标返回同一 Promise,不重复执行。UI 通过 `uni.showLoading({mask:true})` 阻隔误触。`cleanAll()` 不再被 UI 调用,保留作为登出流程/测试的编程式入口,不需 in-flight 保护。
|
||||
|
||||
---
|
||||
|
||||
## 六、错误处理 & 边界条件
|
||||
|
||||
### 6.1 错误分类与处理
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 单个 handler `computeSize()` 抛错 | 该分类显示 `sizeBytes: -1`、label 旁加 `?` 标记,主页面其他分类正常显示,console.warn 记录 |
|
||||
| 单个 handler `clean()` / `cleanGroup()` 抛错 | 该分类跳过,perCategory 中标记 `{id, freedBytes: 0, error: 'msg'}`,其他继续;最终 toast 显示「已清理 XX MB(X 项失败)」 |
|
||||
| `getCacheInfo()` 整体超时(>5s)| 兜底显示空列表 + 「加载失败,点此重试」按钮 |
|
||||
| `uni.getStorageInfoSync()` 失败(罕见)| 整个 storage 维度显示 `— MB`,仅沙盒维度可清理 |
|
||||
| 沙盒文件不存在(登出后清理等场景)| `clearAllSandboxTmpFiles` 已实现"目录不存在视为成功",无需额外处理 |
|
||||
|
||||
### 6.2 边界条件
|
||||
|
||||
- **页面打开瞬间用户被踢登录**:`getCacheInfo()` 不依赖 token,可正常返回;若清理中触发退出,回调里捕错即可
|
||||
- **清理进行中用户退出页面**:用 `cacheManager.cleanCategory()` 的 in-flight 单例保护 —— 重复触发返回同一 Promise
|
||||
- **草稿清理有未提交内容**:UI 在 ConfirmModal 文案写明"将清空你未提交的创作草稿";不做活跃页面编辑检测(YAGNI)
|
||||
- **黑名单 key 误触**:内置黑名单在 `cleanCategory` / `cleanCategoryGroup` 入口拦截,永不删
|
||||
- **大小格式化**:用 `KB/MB/GB` 自适应,1 位小数(避免 `0.0 MB`),< 1KB 显示 `< 1 KB`
|
||||
- **顶部数据加载失败**:配额读取失败(罕见)时,UI 显示"配额数据暂不可用",分类列表仍正常渲染;getCacheInfo() 内部 try/catch 单点失败不影响其他字段
|
||||
- **详情页无 uid 数据**:草稿/引导详情页若当前用户 uid 取不到(未登录),全部归为"其他用户"分组,**不允许清理**(按钮置灰 + tooltip"请先登录")
|
||||
- **未登录态只能清理简单页**:preload / progress / sandbox-tmp / others 在未登录态允许清理;draft / guide 在未登录态**禁止清理**(按钮置灰)
|
||||
|
||||
### 6.3 关键日志
|
||||
|
||||
所有 handler 失败必须 `console.warn('[cacheManager] xxx failed:', id, err.message)`,便于线上排查。
|
||||
|
||||
---
|
||||
|
||||
## 七、测试策略
|
||||
|
||||
### 7.1 单元测试
|
||||
|
||||
- `utils/cacheManager.js` 纯函数(黑名单匹配、key 分类、大小格式化、GroupInfo 构造)单测
|
||||
- 6 个 handler 的 `computeSize` / `clean` / `computeBreakdown` / `cleanGroup` 单测(mock `uni.getStorageSync`)
|
||||
- `draftStorage.js` 双读单写逻辑单测(mock storage)
|
||||
|
||||
### 7.2 集成测试
|
||||
|
||||
mock `uni.getStorageSync` / `clearAllSandboxTmpFiles` / `core.invalidateAll`,验证:
|
||||
- `cleanAll()` 顺序执行 + invalidateAll 同步
|
||||
- 失败容错(单个失败不影响其他)
|
||||
- 黑名单永不删
|
||||
- `cleanCategory('preload')` 完成后 memoryMap 已清空
|
||||
|
||||
### 7.3 手工验证场景
|
||||
|
||||
| 场景 | 期望 |
|
||||
|------|------|
|
||||
| 登录态清理(简单页) | token/user/star_id/cid 不变,console 二次确认 |
|
||||
| 登录态清理(分组页-我的草稿) | 仅删 currentUid 草稿;普通警告 |
|
||||
| 登录态清理(分组页-其他用户) | 仅删 otherUid 草稿;强警告;对方下次登录看不到 |
|
||||
| 未登录态清理(简单页) | 全部业务缓存正常清理 |
|
||||
| 未登录态清理(分组页) | 草稿/引导按钮置灰,不可清理 |
|
||||
| 清理中退出页面 | 不重复清理,不报错 |
|
||||
| 空缓存状态 | 列表页显示"✓ 当前无缓存可清理",无跳转入口 |
|
||||
| 草稿详情页(仅自己) | 仅显示"我的草稿"分组,清理按钮触发普通警告 |
|
||||
| 草稿详情页(多账号切换后) | 显示"我的草稿" + 多个"其他用户的草稿"分组;强警告+正常清理流程 |
|
||||
| 草稿详情页(未登录) | 所有数据归"其他用户",按钮置灰 |
|
||||
| **账号 A 写草稿 → 退出 → 账号 B 登录 → 打开创作页** | **B 看不到 A 的草稿**(依赖 §11 改造) |
|
||||
| **账号 A 产生 preload 缓存 → 退出 → 账号 B 登录 → B 进 preload 详情页清理** | **A 的 preload 也被清掉**(§1 决策 6) |
|
||||
| **清理 preload 后立刻再访问同一列表页** | **不会从 memoryMap 命中老数据**(§3.4.4 内存层清理) |
|
||||
|
||||
---
|
||||
|
||||
## 八、文件清单
|
||||
|
||||
### 8.1 新增文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/utils/cacheManager.js` | 统一封装层(核心,约 250-300 行) |
|
||||
| `frontend/utils/handlers/preloadHandler.js` | preload 缓存 handler(简单型) |
|
||||
| `frontend/utils/handlers/draftHandler.js` | 创作草稿 handler(分组型,含 computeBreakdown + cleanGroup) |
|
||||
| `frontend/utils/handlers/progressHandler.js` | 进度缓存 handler(简单型) |
|
||||
| `frontend/utils/handlers/guideHandler.js` | 引导记录 handler(分组型,含 computeBreakdown + cleanGroup) |
|
||||
| `frontend/utils/handlers/sandboxTmpHandler.js` | 沙盒临时文件 handler(基于 ioPath.js,简单型) |
|
||||
| `frontend/utils/handlers/othersHandler.js` | 兜底分类 handler(简单型) |
|
||||
| `frontend/pages/profile/cache-cleanup.vue` | 缓存清理列表页(只展示,无按钮) |
|
||||
| `frontend/pages/profile/cache-cleanup-detail.vue` | 缓存清理详情页(按 `id` 分发到简单/分组模板) |
|
||||
|
||||
### 8.2 修改文件
|
||||
|
||||
| 路径 | 改动 |
|
||||
|------|------|
|
||||
| `frontend/pages/profile/profile.vue` | 加菜单项「存储空间」点击进入 `cache-cleanup` |
|
||||
| `frontend/pages.json` | 注册两个新页面(cache-cleanup 列表 + cache-cleanup-detail 详情) |
|
||||
| `frontend/utils/ioPath.js` | 新增 `getSandboxTotalSize()` 只读 API(§5.1 并行 2 用于计算沙盒文件总字节)|
|
||||
|
||||
### 8.3 实施顺序(5 个 milestone,M0 是 M2 前置)
|
||||
|
||||
1. **M0 — 草稿 key 改造(前置迁移)**:`utils/draftStorage.js` 封装 + 7 个文件读写侧改造 + 双读单写兼容(§11)
|
||||
2. **M1 — 核心封装层**:`cacheManager.js` + 黑名单 + 大小格式化 + `getCacheInfo()` / `getCategoryBreakdown()` / `cleanCategory()` / `cleanCategoryGroup()` 框架(handlers 暂时用空 stub);包含 in-flight 单例保护
|
||||
3. **M2 — 6 个 handler 实现**:每个 handler 的 computeSize / clean(或 computeBreakdown + cleanGroup);preload 跨用户清理;preload handler 清理完成后同步调 `invalidateAll()`
|
||||
4. **M3 — UI 页面(列表 + 详情)**:`cache-cleanup.vue`(列表)+ `cache-cleanup-detail.vue`(简单/分组模板)+ ConfirmModal + profile.vue 入口 + pages.json 注册
|
||||
5. **M4 — 测试 & 回归**:单元测试 + 13 个手工场景验证(含跨账号分组清理、未登录态分组禁用、清理后内存即清等)+ 对照 CLAUDE.md 完成自检清单
|
||||
|
||||
---
|
||||
|
||||
## 九、验收标准(DoD)
|
||||
|
||||
- [ ] 个人中心出现「存储空间」入口,点击进入列表页
|
||||
- [ ] 列表页顶部显示:**大字(Topfans 已用空间)+ 百分比(占据配额 X%)+ 进度条 + 3 个 chip 标签(无数值)+ 6 个分类行 + 1 个"其他"section**
|
||||
- [ ] 列表页**没有任何清理按钮**;点击分类行进入详情页;"其他"section 不可点击
|
||||
- [ ] **简单详情页**(preload / progress / sandbox-tmp / others):单按钮 + ConfirmModal(含 warning 时显示警告文案)
|
||||
- [ ] **分组详情页**(draft / guide):按 uid 分组卡片,每组独立清理按钮;当前用户组普通警告,他人组**强警告**;0 项组按钮置灰
|
||||
- [ ] 未登录态打开草稿/引导详情页:所有数据归"其他用户",按钮置灰 + tooltip "请先登录"
|
||||
- [ ] 详情页清理 preload 后立即进列表页:memoryMap 已清空,重新拉数据
|
||||
- [ ] 详情页清理成功后 toast + 返回列表页(onShow 自动重算)
|
||||
- [ ] 单个 handler 失败不影响其他 handler
|
||||
- [ ] 登录 token、用户信息、设备指纹等黑名单 key **绝对不会被清理**(用 console.log 二次确认)
|
||||
- [ ] `pages.json` 已注册两个新页面(列表 + 详情),`unpackage/dist/` 未手动改
|
||||
- [ ] 走 CLAUDE.md 自检清单(API 工程化、前端规范、接口规范均过)
|
||||
|
||||
---
|
||||
|
||||
## 十、风险与回滚
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|------|------|------|
|
||||
| 黑名单遗漏某个登录态 key | 用户被错误退出 | M2 完成后 grep 全量 storage key 与黑名单比对;M4 手工验证登录态清理 |
|
||||
| handler 顺序错误 | sandbox 清理在 preload 清理之前引用失效 | 已设计为顺序执行;单测覆盖 |
|
||||
| 草稿清理误删活跃数据 | 用户创作内容丢失 | ConfirmModal 文案警示;后续可加活跃检测(MVP 不做) |
|
||||
| `clearAllSandboxTmpFiles` 性能 | 启动时已实现,启动清理通常 0 个 tmp | 实测 50ms 内完成;超过 1s 加 loading |
|
||||
|
||||
回滚:删除 `pages/profile/cache-cleanup.vue` + `pages.json` 注册项 + profile.vue 菜单项即可,业务无侵入。
|
||||
|
||||
---
|
||||
|
||||
## 十一、草稿 key 改造迁移项
|
||||
|
||||
> **范围外工作但与本 spec 强耦合**:为了让 §3.2 的 draft handler 准确按 uid 分类,必须先把草稿 storage key 改为 `*_${uid}` 形式。本节作为 M2 启动前的**前置依赖**。
|
||||
|
||||
### 11.1 现状问题
|
||||
|
||||
当前草稿 key 不带 uid:
|
||||
- `castlove_form_data`
|
||||
- `CASTLOVE_FORM_KEY`
|
||||
- `temp_nft_data`
|
||||
- `GENERATED_IMAGES_KEY`
|
||||
- `GENERATION_RESULT_META_KEY`
|
||||
- `LENTICULAR_STUDIO_STORAGE_KEY`
|
||||
- `CRAFT_SELECTED_IMAGE_KEY`
|
||||
|
||||
**结果**:账号 A 的草稿在设备上 → 退出登录 → B 账号登录 → 打开创作页 → **看到 A 的草稿**。这是隐私 + 体验问题。
|
||||
|
||||
### 11.2 改造方案
|
||||
|
||||
将所有写入侧(`pages/castlove/create.vue`、`pages/castlove/index.vue`、`pages/castlove/success.vue`、`composables/useLaserSegment.js`、`composables/useLaserBatchGenerate.js` 等)改为:
|
||||
|
||||
```js
|
||||
// 旧
|
||||
uni.setStorageSync('castlove_form_data', JSON.stringify(formData))
|
||||
// 新
|
||||
uni.setStorageSync(`castlove_form_data_${currentUid}`, JSON.stringify(formData))
|
||||
```
|
||||
|
||||
读取侧同样需改造。
|
||||
|
||||
### 11.3 迁移策略
|
||||
|
||||
**双读单写**:
|
||||
- 读:先读新 key `*_${currentUid}`;若不存在,**fallback** 读老 key;读到后将数据写到新 key,老 key 删除
|
||||
- 写:只写新 key `*_${currentUid}`
|
||||
|
||||
实现位置:建议封装一个 `utils/draftStorage.js`,所有读写都走它,业务侧无感。
|
||||
|
||||
### 11.4 与 cacheManager 的关系
|
||||
|
||||
- **迁移完成前**:draft handler.computeSize() 同时计入新 key(`*_${currentUid}`)+ 老 key(无 uid 后缀),所有都按 uid 分组(无后缀 key 归 `__legacy__` 分组)
|
||||
- **迁移完成后**:draft handler 仅匹配 `*_${currentUid}` + 老无 uid 后缀 key(理论上不应再有,但万一有仍归 `__legacy__` 分组);**不**走 `others` 兜底
|
||||
- **验证**:M4 手工场景新增"切换账号后看不到对方草稿"
|
||||
|
||||
### 11.5 工作量估算
|
||||
|
||||
**M0 范围限定为「核心 castlove 创作流」(约 7 个文件)**:
|
||||
- `pages/castlove/create.vue`、`pages/castlove/index.vue`、`pages/castlove/success.vue`、`pages/castlove/lenticular/lenticular-result.vue`、`pages/castlove/laser/laser-result.vue`、`composables/useLaserSegment.js`、`composables/useLaserBatchGenerate.js`
|
||||
- 包含:`castlove_form_data` / `CASTLOVE_FORM_KEY` / `temp_nft_data` / `GENERATED_IMAGES_KEY` / `GENERATION_RESULT_META_KEY` / `LENTICULAR_STUDIO_STORAGE_KEY` / `CRAFT_SELECTED_IMAGE_KEY`
|
||||
|
||||
**discover / mint / self-created 等流程(约 10 个其他文件)**涉及相同 key 但属于次级路径,**列为 M0 后续批次(M0.5)**,MVP 上线后补;估算额外 1 天。
|
||||
|
||||
**核心 castlove 7 文件改造工作量**:
|
||||
- 改造 7 个文件读写侧:0.5 天
|
||||
- `draftStorage.js` 封装:0.5 天
|
||||
- 联调验证:0.5 天
|
||||
|
||||
合计 **1.5 天**,作为 M2 启动前置。
|
||||
|
||||
---
|
||||
|
||||
## 十二、自检清单
|
||||
|
||||
按 CLAUDE.md 要求:
|
||||
|
||||
- [ ] 文档开头「方案概述」含:要解决的问题 / 实现路径 / 关键决策 / 核心架构图 ✅
|
||||
- [ ] MVP 先行:未引入超出当前业务需要的抽象(统一封装层是必要的,避免 UI 上帝化) ✅
|
||||
- [ ] 文件清单与目录结构对齐:`utils/handlers/` 是新目录需提前创建 ✅
|
||||
- [ ] 跨章节引用一致性:§3.2 → §4.2 分类一致;§4.3 → §5 流程一致;§6 → §7 测试场景一致 ✅
|
||||
- [ ] CLAUDE.md 自检清单(前端规范):新组件用 `<script setup>` 组合式 API、所有原生 API 包 `#ifdef`、接口走 `utils/api.js`、新页面已在 `pages.json` 注册、敏感权限失败有"去设置"出口、不动 `unpackage/dist/` ✅
|
||||
2351
docs/superpowers/plans/2026-07-28-cache-cleanup.md
Normal file
2351
docs/superpowers/plans/2026-07-28-cache-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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('缺少表单数据')
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name" : "TopFans",
|
||||
"appid" : "__UNI__F199FF4",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.5",
|
||||
"versionName" : "1.0.1",
|
||||
"versionCode" : 120,
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -171,7 +171,7 @@ import {
|
||||
const scrollEnhanced = true;
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const scrollEnhanced = false;
|
||||
// const scrollEnhanced = false;
|
||||
// #endif
|
||||
|
||||
const pageType = ref("");
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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();
|
||||
|
||||
431
frontend/pages/profile/cache-cleanup-detail.vue
Normal file
431
frontend/pages/profile/cache-cleanup-detail.vue
Normal 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>
|
||||
257
frontend/pages/profile/cache-cleanup.vue
Normal file
257
frontend/pages/profile/cache-cleanup.vue
Normal 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>
|
||||
@ -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");
|
||||
|
||||
233
frontend/utils/cacheManager.js
Normal file
233
frontend/utils/cacheManager.js
Normal 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)
|
||||
@ -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 - 包含lenticularBgImage、lenticularSubjectImage等字段
|
||||
*/
|
||||
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
|
||||
|
||||
|
||||
@ -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] 完成')
|
||||
|
||||
|
||||
127
frontend/utils/draftStorage.js
Normal file
127
frontend/utils/draftStorage.js
Normal 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)
|
||||
162
frontend/utils/handlers/draftHandler.js
Normal file
162
frontend/utils/handlers/draftHandler.js
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
138
frontend/utils/handlers/guideHandler.js
Normal file
138
frontend/utils/handlers/guideHandler.js
Normal 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 }
|
||||
},
|
||||
}
|
||||
30
frontend/utils/handlers/guideKeys.js
Normal file
30
frontend/utils/handlers/guideKeys.js
Normal 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
|
||||
}
|
||||
79
frontend/utils/handlers/othersHandler.js
Normal file
79
frontend/utils/handlers/othersHandler.js
Normal 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 }
|
||||
},
|
||||
}
|
||||
51
frontend/utils/handlers/preloadHandler.js
Normal file
51
frontend/utils/handlers/preloadHandler.js
Normal 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 }
|
||||
},
|
||||
}
|
||||
46
frontend/utils/handlers/progressHandler.js
Normal file
46
frontend/utils/handlers/progressHandler.js
Normal 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 }
|
||||
},
|
||||
}
|
||||
27
frontend/utils/handlers/sandboxTmpHandler.js
Normal file
27
frontend/utils/handlers/sandboxTmpHandler.js
Normal 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 }
|
||||
},
|
||||
}
|
||||
@ -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 上只算 sandbox(iOS 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录所有 entry(DirectoryEntry / 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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user