topfans/docs/superpowers/plans/2026-07-28-cache-cleanup.md
2026-07-29 16:03:33 +08:00

2351 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 缓存清理功能实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 topfans 移动端增加「存储空间」功能WeChat 风格),按账号分组的可清理缓存管理,含黑名单保护 + 跨账号清理 + 内存层清理
**Architecture:**
- 统一封装层 `utils/cacheManager.js` 聚合 6 个 handler + 黑名单 + 配额统计
- 6 个独立 handlerpreload / draft / progress / guide / sandboxTmp / others分组型 handler 支持按 uid 分组清理
- UI 双页面:`cache-cleanup.vue`(列表 + WeChat 风格顶部)+ `cache-cleanup-detail.vue`(简单/分组模板按 id 分发)
- 前置迁移 M0将草稿 storage key 从 `*` 改为 `*_${currentUid}`,解决跨账号草稿泄露
**Tech Stack:** Vue 3 组合式 API, uni-app 3.x, uni-storage (SQLite), plus.io (Android 10+ PRIVATE_DOC sandbox), preloadApi/core
**重要前置约束CLAUDE.md 强制):**
- **AI 不自动 commit**:每个 commit 步骤前必须确认用户明确指示「帮我 commit」
- 不修改 `unpackage/dist/` 编译产物
- 新组件用 `<script setup>` 组合式 API`this`
- 所有原生 API 包 `#ifdef APP-PLUS` 或平台分支
- 接口调用走 `utils/api.js`,组件层不直接 `uni.request`
- 新页面先在 `pages.json` 注册
**测试约定:** 项目当前未配置 vitest/jest`frontend/package.json`,无 test runner。本计划对纯函数黑名单匹配、key 分类、大小格式化)走轻量自测(浏览器 console + 临时 node 脚本),其他靠 uni-app 编译验证 + App 真机/模拟器手工验证。**spec §7 要求的"单元测试 + 集成测试"在 MVP 阶段被简化为 console 自测 + 手工回归**;如后续项目接入 vitest应补回专门测试文件manager 拆单测 / handlers 各一个 / draftStorage 拆单测)。
---
## 文件结构
```
frontend/
├── utils/
│ ├── cacheManager.js # 新增:统一封装层核心
│ ├── draftStorage.js # 新增M0 草稿读写封装(双读单写)
│ ├── handlers/ # 新增目录
│ │ ├── preloadHandler.js
│ │ ├── draftHandler.js # 分组型
│ │ ├── progressHandler.js
│ │ ├── guideHandler.js # 分组型
│ │ ├── sandboxTmpHandler.js
│ │ └── othersHandler.js
│ ├── ioPath.js # 修改:新增 getSandboxTotalSize() + scanSandboxTmpFiles()
│ └── preloadApi/core.js # 修改invalidateAll 增加清 inFlightMap + abort inflight
├── pages/
│ ├── castlove/
│ │ ├── create.vue # M0 修改:用 draftStorage
│ │ ├── index.vue # M0 修改
│ │ ├── success.vue # M0 修改
│ │ ├── lenticular/lenticular-result.vue # M0 修改
│ │ └── laser/laser-result.vue # M0 修改
│ └── profile/
│ ├── profile.vue # 修改:加「存储空间」菜单项
│ ├── cache-cleanup.vue # 新增:列表页
│ └── cache-cleanup-detail.vue # 新增:详情页
├── composables/
│ ├── useLaserSegment.js # M0 修改:用 draftStorage
│ └── useLaserBatchGenerate.js # M0 修改:用 draftStorage
└── pages.json # 修改:注册两个新页面
docs/
└── superpowers/
└── plans/
└── 2026-07-28-cache-cleanup.md # 本文件
```
**M0 不涉及文件**discover / mint / self-created 流程,约 10 个文件):列为 M0.5 后续批次MVP 上线后补。
---
## 里程碑总览
| Milestone | 内容 | 任务数 | 工作量 |
|-----------|------|--------|--------|
| **M0** | 草稿 key 改造(前置迁移) | 7 tasks | 1.5 天 |
| **M1** | 核心封装层 cacheManager | 5 tasks | 0.5 天 |
| **M2** | 6 个 handler 实现 | 8 tasks | 1 天 |
| **M3** | UI 页面(列表 + 详情) | 8 tasks | 1.5 天 |
| **M4** | 13 个手工场景回归 | 1 task | 0.5 天 |
| **合计** | | **29 tasks** | **5 人天** |
---
# Phase 1: M0 — 草稿 key 改造(前置迁移)
> **目标**:将 7 个核心 castlove 文件中的草稿 storage key 从 `castlove_form_data` 等改为 `castlove_form_data_${currentUid}`,实现账号隔离。
> **策略**:双读单写 — 新 key 不存在时 fallback 老 key读到后写到新 key 并清老 key。
> **前置**utils/draftStorage.js 封装先建好。
## Task 1: 创建 utils/draftStorage.js 封装
**Files:**
- Create: `frontend/utils/draftStorage.js`
- [ ] **Step 1: 实现 draftStorage.js 核心**
```js
// 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
// 如未来需要便捷方法,请单独导出
```
- [ ] **Step 2: 自测(浏览器 console / 临时 node 脚本)**
打开 HBuilderX 运行前端项目,进入任意 .vue 页面 console 执行:
```js
// 模拟未登录态读
uni.removeStorageSync('castlove_form_data')
const v = readDraft('castlove_form_data') // 应返回 null
// 模拟登录后写
uni.setStorageSync('user', JSON.stringify({ uid: 10001 }))
writeDraft('castlove_form_data', { foo: 'bar' })
const keys = uni.getStorageInfoSync().keys.filter(k => k.includes('castlove_form_data'))
// 期望: ['castlove_form_data_10001']
```
预期:新 key 写入成功,老 key 未被污染。
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/draftStorage.js
git commit -m "feat(draft): add draftStorage wrapper with uid-scoped keys"
```
---
## Task 2: 改造 pages/castlove/create.vue
**Files:**
- Modify: `frontend/pages/castlove/create.vue`
- [ ] **Step 1: 替换 import**
文件顶部,从:
```js
// import { ... } from '...'
```
改为:
```js
import { readDraft, writeDraft, removeDraft } from '@/utils/draftStorage'
```
- [ ] **Step 2: 替换所有 `castlove_form_data` 读写**
查找:
- `uni.setStorageSync('castlove_form_data'` → 替换为 `writeDraft('castlove_form_data', `
- `uni.getStorageSync('castlove_form_data'` → 替换为 `readDraft('castlove_form_data')`
- `uni.removeStorageSync('castlove_form_data'` → 替换为 `removeDraft('castlove_form_data')`
预期:所有出现 castlove_form_data 的地方都迁移到 draftStorage 函数。
- [ ] **Step 3: 编译验证**
Run: HBuilderX → 运行 → 真机/模拟器(**不要**用 `npm run build:verify`,它只做 verify.html 替换,**不验证 JS/Vue 编译**
预期:编译成功,无 `uni.setStorageSync is not a function` 之类的错误。
- [ ] **Step 4: 手工验证(未登录态保护)**
未登录时进入 castlove/create尝试填写表单并保存。console 应打印:
```
[draftStorage] writeDraft failed: castlove_form_data no uid
```
`uni.getStorageInfoSync().keys` 不应出现 `castlove_form_data` 无后缀 key。
- [ ] **Step 5:如用户授权commit**
```bash
git add frontend/pages/castlove/create.vue
git commit -m "refactor(castlove): use draftStorage for form_data"
```
---
## Task 3: 改造 pages/castlove/index.vue
**Files:**
- Modify: `frontend/pages/castlove/index.vue`
- [ ] **Step 1-5: 同 Task 2但目标 key 是 `'castlove_form_data'`reviewer 第 3 轮 P0 修正:实际写入是字符串字面量 `'castlove_form_data'`,不是 JS 常量名 `CASTLOVE_FORM_KEY`**
- 同样替换 `uni.setStorageSync('castlove_form_data'``writeDraft('castlove_form_data', `
- 同样替换 `uni.getStorageSync('castlove_form_data'``readDraft('castlove_form_data')`
- 同样替换 `uni.removeStorageSync('castlove_form_data'``removeDraft('castlove_form_data')`
- [ ] **Step 6:如用户授权commit**
```bash
git add frontend/pages/castlove/index.vue
git commit -m "refactor(castlove): use draftStorage for castlove_form_data"
```
---
## Task 4: 改造 pages/castlove/success.vue
**Files:**
- Modify: `frontend/pages/castlove/success.vue`
- [ ] **Step 1-5: 同 Task 2但目标 key 是 `temp_nft_data`**
同样替换读写调用。
- [ ] **Step 6:如用户授权commit**
```bash
git add frontend/pages/castlove/success.vue
git commit -m "refactor(castlove): use draftStorage for temp_nft_data"
```
---
## Task 5: 改造 pages/castlove/lenticular/lenticular-result.vue
**Files:**
- Modify: `frontend/pages/castlove/lenticular/lenticular-result.vue`
- [ ] **Step 1-5: 同 Task 2但目标 key 包含 3 个**
- `GENERATED_IMAGES_KEY`
- `GENERATION_RESULT_META_KEY`
- `LENTICULAR_STUDIO_STORAGE_KEY`
按 Task 2 的步骤逐一替换。
- [ ] **Step 6:如用户授权commit**
```bash
git add frontend/pages/castlove/lenticular/lenticular-result.vue
git commit -m "refactor(castlove): use draftStorage for lenticular result keys"
```
---
## Task 6: 改造 pages/castlove/laser/laser-result.vue
**Files:**
- Modify: `frontend/pages/castlove/laser/laser-result.vue`
- [ ] **Step 1-5: 同 Task 5目标 key 相同**
- `GENERATED_IMAGES_KEY`
- `GENERATION_RESULT_META_KEY`
- `LENTICULAR_STUDIO_STORAGE_KEY`
- [ ] **Step 6:如用户授权commit**
```bash
git add frontend/pages/castlove/laser/laser-result.vue
git commit -m "refactor(castlove): use draftStorage for laser result keys"
```
---
## Task 7: 改造 composables/useLaserSegment.js + useLaserBatchGenerate.js
**Files:**
- Modify: `frontend/composables/useLaserSegment.js`
- Modify: `frontend/composables/useLaserBatchGenerate.js`
- [ ] **Step 1: 改造 useLaserSegment.js**
同上 Task 2 流程,目标 key `'castlove_form_data'`reviewer P0实际是字面量字符串
- [ ] **Step 2: 改造 useLaserBatchGenerate.js**
同上 Task 2 流程,目标 key `'castlove_form_data'`reviewer P0
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/composables/useLaserSegment.js frontend/composables/useLaserBatchGenerate.js
git commit -m "refactor(castlove): use draftStorage in laser composables"
```
---
## Task 7.5: 改造生产者 utils/castloveGenerationFlow.js
**Files:**
- Modify: `frontend/utils/castloveGenerationFlow.js`
- [ ] **Step 1: 修改所有 `uni.setStorageSync` 与 `CASTLOVE_FORM_KEY` 等常量调用**
**Workflow key scope 决策reviewer P0 修正:必须明确原子决策)**
| Key 常量名 | 实际 storage 值 | 归 uid-scoped? | 加入 BASE_KEYS |
|----------|----------------|--------------|--------------|
| `CASTLOVE_FORM_KEY` | `'castlove_form_data'` | ✅ 是 | ✅ |
| `GENERATED_IMAGES_KEY` | `'generated_images'` | ✅ 是 | ✅ |
| `GENERATION_RESULT_META_KEY` | `'generation_result_meta'` | ✅ 是 | ✅ |
| `LENTICULAR_STUDIO_STORAGE_KEY` | `'lenticular_studio_payload'` | ✅ 是 | ✅ |
| `CRAFT_SELECTED_IMAGE_KEY` | `'craft_selected_image'` | ✅ 是 | ✅ |
| `GENERATION_FLOW_KEY` | `'generation_flow_payload'` | ❌ 否device-level 一次性 token | ❌ |
| `GENERATION_REQUEST_KEY` | `'generation_request_data'` | ❌ 否(同上) | ❌ |
| `CRAFT_SELECTED_INDEX_KEY` | `'craft_selected_index'` | ❌ 否(同上) | ❌ |
**device-level 理由**:这三个 key 是 castlove 流程的"一次性 token"generation payload 用完即删,不跨账号复用);强制 uid-scoped 会破坏进行中的流程。
将以下调用替换为 `writeDraft(key, jsonString)` 形式:
- `persistFormData` 内的 `uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))``writeDraft('castlove_form_data', JSON.stringify(formData))`
- `persistLenticularPreviewMeta` 内的 `LENTICULAR_STUDIO_STORAGE_KEY` / `GENERATION_RESULT_META_KEY` / `GENERATED_IMAGES_KEY` → 全部改为 `writeDraft`
- `persistLaserPreviewImages` 同上
- `completeSelectionAndOpenDetail` 内的 `CRAFT_SELECTED_IMAGE_KEY``writeDraft`CRAFT_SELECTED_INDEX_KEY 保持直写)
以下 key **保持直写**(不通过 draftStorage
- `GENERATION_FLOW_KEY` / `GENERATION_REQUEST_KEY` / `CRAFT_SELECTED_INDEX_KEY`device-level理由见上表
- [ ] **Step 2:如用户授权commit**
```bash
git add frontend/utils/castloveGenerationFlow.js
git commit -m "refactor(castlove): use draftStorage for uid-scoped keys only"
```
---
## Task 7.6: 改造 utils/craftMintSubmit.jstemp_nft_data 生产者)
**Files:**
- Modify: `frontend/utils/craftMintSubmit.js`
- [ ] **Step 1: 替换 `temp_nft_data` 写入为 writeDraft**
查找 `uni.setStorageSync('temp_nft_data'``uni.getStorageSync('temp_nft_data'`,替换为 `writeDraft` / `readDraft`
- [ ] **Step 2:如用户授权commit**
```bash
git add frontend/utils/craftMintSubmit.js
git commit -m "refactor(castlove): use draftStorage for temp_nft_data"
```
---
## Task 7.7: 改造 pages/discover/generation-loading.vuegenerated_images 生产者)
**Files:**
- Modify: `frontend/pages/discover/generation-loading.vue`
- [ ] **Step 1: 替换 `GENERATED_IMAGES_KEY` 等写入为 writeDraft**
注意:`GENERATION_FLOW_KEY` / `GENERATION_REQUEST_KEY` 保持 device-level不 uid-scoped仅 uid-scoped 的 keys 走 `writeDraft`
- [ ] **Step 2:如用户授权commit**
```bash
git add frontend/pages/discover/generation-loading.vue
git commit -m "refactor(discover): use draftStorage in generation-loading"
```
---
## Task 7.8: 迁移剩余消费方到 draftStorage消费者侧改造
**Files:**
- Modify: `frontend/pages/discover/generation-result.vue`
- Modify: `frontend/pages/asset-detail/asset-detail.vue`
- Modify: `frontend/pages/castlove/lenticular/lenticular-thinking.vue`
- [ ] **Step 1: generation-result.vue**
替换 `uni.getStorageSync('castlove_form_data'` / `CASTLOVE_FORM_KEY` / `GENERATED_IMAGES_KEY` / `GENERATION_RESULT_META_KEY``readDraft``removeStorageSync` 为 `removeDraft`
**保留**`GENERATION_FLOW_KEY` / `GENERATION_REQUEST_KEY` / `CRAFT_SELECTED_INDEX_KEY` 的读写device-level
- [ ] **Step 2: asset-detail.vue**
替换 `CASTLOVE_FORM_KEY` / `CRAFT_SELECTED_IMAGE_KEY``readDraft` / `writeDraft`
- [ ] **Step 3: lenticular-thinking.vue**
替换 `CASTLOVE_FORM_KEY``readDraft`
- [ ] **Step 4: 编译验证**
HBuilderX → 真机/模拟器;走完 castlove / discover / lenticular 三个流程,确认无断流。
- [ ] **Step 5:如用户授权commit**
```bash
git add frontend/pages/discover/generation-result.vue frontend/pages/asset-detail/asset-detail.vue frontend/pages/castlove/lenticular/lenticular-thinking.vue
git commit -m "refactor: migrate draft key consumers to draftStorage"
```
---
## Task 8: M0 联调验证
**Files:**
- 无(仅验证)
- [ ] **Step 1: 手工场景 A — 同一账号写读**
1. 登录账号 A (uid=10001)
2. 进 castlove/create 填写表单并触发保存(找到自动保存时机或点保存)
3. console 执行:`uni.getStorageInfoSync().keys.filter(k => k.includes('castlove_form_data'))`
4. 期望:`['castlove_form_data_10001']`
- [ ] **Step 2: 手工场景 B — 切换账号读**
1. 退出账号 A
2. 登录账号 B (uid=10002)
3. 进 castlove/create**不应看到账号 A 的表单内容**
4. console 检查:不应有 `castlove_form_data_10001` 被读到
- [ ] **Step 3: 手工场景 C — 老 key 迁移**
1. 手动 console`uni.setStorageSync('castlove_form_data', JSON.stringify({legacy: true}))`(模拟老版本用户)
2. 登录任意账号,调用 `readDraft('castlove_form_data')`
3. 期望:返回 `{legacy: true}`console 不应再出现 `castlove_form_data` 无后缀 key
- [ ] **Step 4:如用户授权commit**
```bash
git add frontend/pages/castlove/create.vue
git commit -m "refactor(castlove): use draftStorage for form_data"
# 联调报告不创建独立文件,在 PR description 中描述验证结果即可
```
---
# Phase 2: M1 — 核心封装层 cacheManager
> **目标**:实现 `utils/cacheManager.js`包含黑名单、大小格式化、6 个 handler 注册机制、4 个公共 APIgetCacheInfo / getCategoryBreakdown / cleanCategory / cleanCategoryGroup
> **前置**M0 完成draftStorage.js 已建)。
## Task 9: 创建 utils/cacheManager.js 骨架 + 大小格式化工具
**Files:**
- Create: `frontend/utils/cacheManager.js`
- [ ] **Step 1: 实现骨架(含大小格式化 + 黑名单常量)**
```js
// frontend/utils/cacheManager.js
// 缓存清理统一封装层
// 详见 docs/superpowers/specs/2026-07-28-cache-cleanup-design.md
import { invalidateAll } from './preloadApi/core'
// ── 黑名单§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
}
// ... 后续 task 补充 getCacheInfo / getCategoryBreakdown / cleanCategory / cleanCategoryGroup
```
- [ ] **Step 2: 自测(浏览器 console**
```js
import { isProtectedKey, formatSize } from '@/utils/cacheManager'
isProtectedKey('access_token') // true
isProtectedKey('avatar_file_123') // true
isProtectedKey('castlove_form_data_10001') // false
isProtectedKey('preload:10001:abc') // false
formatSize(0) // '< 1 KB'
formatSize(500) // '< 1 KB'
formatSize(2048) // '2.0 KB'
formatSize(1024*1024) // '1.0 MB'
formatSize(2.5*1024*1024*1024) // '2.50 GB'
```
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/cacheManager.js
git commit -m "feat(cacheManager): skeleton with blacklist + formatSize"
```
---
## Task 10: 实现 getCacheInfo列表页汇总 API
**Files:**
- Modify: `frontend/utils/cacheManager.js`(追加)
- [ ] **Step 1: 在 cacheManager.js 追加 getCacheInfo 实现**
```js
// 接 Task 9 代码末尾追加
/**
* 列表页读取(汇总 + 存储配额 + 其他 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 }
}
})
)
// [并行 2] 配额 + 沙盒 + 黑名单
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 quotaAvailableBytes = Math.max(0, quotaTotalBytes - appUsedBytes)
const usagePercent = quotaTotalBytes > 0 ? (appUsedBytes / quotaTotalBytes) * 100 : 0
return {
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, usagePercent,
othersBytes: blacklistBytes + sandboxBytes,
categories,
}
}
```
- [ ] **Step 2: 自测(带 mock handler**
```js
// 在某个 .vue 的 setup() 里临时注册一个测试 handler
import { registerCategory, getCacheInfo } from '@/utils/cacheManager'
registerCategory({
id: 'test',
label: '测试',
computeSize: async () => ({ sizeBytes: 100, keyCount: 1 }),
})
const info = await getCacheInfo()
// 期望info.categories 至少包含 testinfo.totalBytes >= 100
```
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/cacheManager.js
git commit -m "feat(cacheManager): getCacheInfo with quota + sandbox"
```
---
## Task 11: 实现 getCategoryBreakdown详情页分组 API
**Files:**
- Modify: `frontend/utils/cacheManager.js`(追加)
- [ ] **Step 1: 在 cacheManager.js 追加**
```js
// 接 Task 10 代码末尾追加
/**
* 详情页读取(分组详情;简单 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 []
}
}
```
- [ ] **Step 2: 自测**
注册一个返回固定分组数组的 handler调用 getCategoryBreakdown 应返回原数组。
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/cacheManager.js
git commit -m "feat(cacheManager): getCategoryBreakdown for detail page"
```
---
## Task 11.5: 修改 preloadApi/core.js invalidateAll清 inFlightMap + abort + cancellation token
**Files:**
- Modify: `frontend/utils/preloadApi/core.js`
- [ ] **Step 1: 扩展 invalidateAll 实现 + 加 cancellation tokenreviewer P0仅清 memoryMap + abort 不够,必须加 token 防止 race condition**
现有实现(`frontend/utils/preloadApi/core.js:253`
```js
export function invalidateAll() {
memoryMap.clear()
}
```
**问题**:现有 in-flight 请求即使 abort仍可能在 abort 后写入 memoryMaprace condition
扩展为:
```js
// 模块级 generation 计数器
let _cleanupGeneration = 0
export function invalidateAll() {
// 1. 标记一次"清理发生",所有后续 in-flight 写入必须先 check 自己的 generation 是否仍是当前 generation
_cleanupGeneration++
// 2. 清内存缓存
memoryMap.clear()
// 3. 清 inFlight 中的 promise 并 abort
for (const [key, entry] of inFlightMap) {
try { entry.abort() } catch (e) { /* skip */ }
}
inFlightMap.clear()
}
export function getCleanupGeneration() {
return _cleanupGeneration
}
```
`_doFetch` 函数(在同一个文件)需修改:捕获 generation 快照,写入 memoryMap 前对比:
```js
const genAtStart = _cleanupGeneration
const result = await Promise.race([fetchPromise, timeoutPromise])
// ... 写入前检查
if (genAtStart !== _cleanupGeneration) {
// 本次清理已发生过丢弃结果fetch 已完成但不应写入)
resolveInFlight(null)
return null
}
// ... 写入 memoryMap / file cache
```
- [ ] **Step 2: 编译验证**
Run: HBuilderX → 运行 → 真机/模拟器
预期:编译通过;进入 cache-cleanup 列表页 → 点 preload 详情清理 → 返回列表页 → 数字更新且无 stale 数据回填。
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/preloadApi/core.js
git commit -m "fix(preload): invalidateAll now clears inFlightMap + aborts + cancellation token"
```
---
## Task 12: 实现 cleanCategory / cleanCategoryGroup + in-flight 单例保护
**Files:**
- Modify: `frontend/utils/cacheManager.js`(追加)
- [ ] **Step 1: 在 cacheManager.js 追加**
```js
// 接 Task 11 代码末尾追加
/**
* 简单页清理(无分组维度)
*/
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 { await 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 { await 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 { await invalidateAll() } catch (e) { console.warn('[cacheManager] cleanAll invalidateAll failed:', e.message) }
return { freedBytes: results.reduce((s, r) => s + (r.freedBytes || 0), 0), perCategory: results }
}
async 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
}
```
- [ ] **Step 2: 自测 in-flight 保护**
```js
// 在 setup() 里
let resolveClean
const fakeHandler = {
id: 'test',
label: '测试',
computeSize: async () => ({ sizeBytes: 100, keyCount: 1 }),
clean: () => new Promise((r) => { resolveClean = () => r({ freedBytes: 50, keyCount: 1 }) }),
}
registerCategory(fakeHandler)
const p1 = cleanCategory('test')
const p2 = cleanCategory('test') // 应返回同一 Promise
console.log(p1 === p2) // true
resolveClean()
await Promise.all([p1, p2])
```
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/cacheManager.js
git commit -m "feat(cacheManager): cleanCategory/Group with in-flight + preload invalidate"
```
---
## Task 13: 在 ioPath.js 新增只读 API
**Files:**
- Modify: `frontend/utils/ioPath.js`
- [ ] **Step 1: 在文件底部追加两个新函数**
```js
// 接 ioPath.js 末尾追加
/**
* 计算沙盒 doc 目录下所有文件总字节数(含白名单 preload/share/image
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes
*/
export async function getSandboxTotalSize() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
return await _sumDirSize(root)
} catch (e) {
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
// 只看 tmp/ 子目录
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) {
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
// _listDir 工具(与 clearAllSandboxTmpFiles 内部逻辑一致)
async function _listDir(dirEntry) {
// #ifdef APP-PLUS
return new Promise((resolve) => {
const reader = dirEntry.createReader()
const collected = []
const readAll = () => {
reader.readEntries(
(es) => { if (es.length === 0) return resolve(collected); collected.push(...es); readAll() },
() => resolve(collected)
)
}
readAll()
})
// #endif
// #ifndef APP-PLUS
return []
// #endif
}
async function _sumDirSize(dirEntry) {
// #ifdef APP-PLUS
let total = 0
try {
const reader = dirEntry.createReader()
const entries = await new Promise((resolve) => {
const collected = []
const readAll = () => {
reader.readEntries(
(es) => { if (es.length === 0) return resolve(collected); collected.push(...es); readAll() },
() => resolve(collected)
)
}
readAll()
})
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 */ }
}
}
} catch (e) { /* skip */ }
return total
// #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) { return 0 }
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
```
- [ ] **Step 2: 编译验证**
Run: HBuilderX → 运行 → 真机/模拟器reviewer P0 修正:`npm run build:verify` 不验证 JS/Vue 编译)
预期无语法错误uni-app 编译过。
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/ioPath.js
git commit -m "feat(ioPath): add getSandboxTotalSize and scanSandboxTmpFiles"
```
---
# Phase 3: M2 — 6 个 handler 实现
> **目标**:实现 6 个 handler顺序按从简单到复杂sandboxTmp → progress → others → preload → guide → draft。
## Task 14: 创建 utils/handlers/ 目录 + sandboxTmpHandler最简单
**Files:**
- Create: `frontend/utils/handlers/sandboxTmpHandler.js`
- [ ] **Step 1: 实现 handler**
```js
// 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 }
},
}
```
- [ ] **Step 2: 在 cacheManager.js 末尾注册**
```js
// cacheManager.js 末尾追加(应用启动时执行)
import sandboxTmpHandler from './handlers/sandboxTmpHandler'
registerCategory(sandboxTmpHandler)
```
- [ ] **Step 3: 编译 + 简单验证**
Run: HBuilderX → 运行 → 真机/模拟器,看是否有未注册 handler 报错。
- [ ] **Step 4:如用户授权commit**
```bash
git add frontend/utils/handlers/sandboxTmpHandler.js frontend/utils/cacheManager.js
git commit -m "feat(handler): add sandboxTmpHandler (simple)"
```
---
## Task 15: 实现 progressHandler
**Files:**
- Create: `frontend/utils/handlers/progressHandler.js`
- [ ] **Step 1: 实现**
```js
// 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 }
},
}
```
- [ ] **Step 2: 注册**
在 cacheManager.js 末尾追加 `registerCategory(progressHandler)`
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/handlers/progressHandler.js frontend/utils/cacheManager.js
git commit -m "feat(handler): add progressHandler (simple)"
```
---
## Task 16: 实现 othersHandler兜底
**Files:**
- Create: `frontend/utils/handlers/othersHandler.js`
- [ ] **Step 1: 实现**
```js
// 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 修正:原"全收"策略会破坏这些)
// 这些 key 即使不在黑名单也不归 others 清理
const WORKFLOW_CRITICAL_KEYS = new Set([
'generation_flow_payload', // castloveGenerationFlow.js
'generation_request_data', // castloveGenerationFlow.js
'craft_selected_index', // castloveGenerationFlow.js选中索引
'__package_info__', // uni-upgrade-center-app
'UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH', // uni-upgrade-center-app
// 注意base 形式的 'castlove_form_data' / 'temp_nft_data' 等由 DRAFT_BASE_KEYS 覆盖
])
function isClaimed(key) {
if (isProtectedKey(key)) return false
if (WORKFLOW_CRITICAL_KEYS.has(key)) 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() {
try {
const info = uni.getStorageInfoSync()
const keys = (info.keys || []).filter(isClaimed)
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) {}
}
return { freedBytes: freed, keyCount: keys.length }
} catch (e) { return { freedBytes: 0, keyCount: 0 } }
},
}
```
- [ ] **Step 2: 注册**
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/handlers/othersHandler.js frontend/utils/cacheManager.js
git commit -m "feat(handler): add othersHandler (simple, fallback)"
```
---
## Task 17: 实现 preloadHandler跨用户清理 + invalidateAll
**Files:**
- Create: `frontend/utils/handlers/preloadHandler.js`
- [ ] **Step 1: 实现**
```js
// 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
for (const k of keys) {
try {
const v = uni.getStorageSync(k); if (v != null) freed += JSON.stringify(v).length
uni.removeStorageSync(k)
} catch (e) {}
}
return { freedBytes: freed, keyCount: keys.length }
},
}
```
- [ ] **Step 2: 注册**
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/handlers/preloadHandler.js frontend/utils/cacheManager.js
git commit -m "feat(handler): add preloadHandler (cross-user)"
```
---
## Task 18: 实现 guideKeys 工具函数 + guideHandler分组型 + 保留规则)
**Files:**
- Create: `frontend/utils/handlers/guideKeys.js`
- Create: `frontend/utils/handlers/guideHandler.js`
- [ ] **Step 1: 实现 guideKeys.js**
```js
// 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 [] }
}
/**
* 列出所有无 uid 段的 guide_* key
* 包括: guide_shown_${configKey}, guide_debug_mode, guide_first_show
*/
export function listGuideGlobalKeys() {
return listGuideKeys().filter((k) => !/_[0-9]+(_|$)/.test(k))
}
/**
* 列出属于某 uid 的 guide_* key
*/
export function listGuideKeysByUid(uid) {
if (!uid) return []
const pattern = new RegExp(`_${uid}(_|$)`)
return listGuideKeys().filter((k) => pattern.test(k))
}
/**
* 解析 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
}
```
- [ ] **Step 2: 实现 guideHandler.js**
```js
// frontend/utils/handlers/guideHandler.js
// 引导记录 handler分组型含保留规则
import { getCurrentUid as _getCurrentUid } from '@/utils/draftStorage' // 复用
import { listGuideKeys, listGuideGlobalKeys, extractUidFromGuideKey } from './guideKeys'
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 }
}
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) => extractUidFromGuideKey(k) === uid)
}
const before = bytesOf(targetKeys)
for (const k of targetKeys) {
try { uni.removeStorageSync(k) } catch (e) {}
}
return { freedBytes: before, keyCount: targetKeys.length }
},
}
```
- [ ] **Step 3: 注册**
- [ ] **Step 4:如用户授权commit**
```bash
git add frontend/utils/handlers/guideKeys.js frontend/utils/handlers/guideHandler.js frontend/utils/cacheManager.js
git commit -m "feat(handler): add guideHandler (grouped, with preserve rules)"
```
---
## Task 19: 实现 draftHandler分组型 + uid 绑定)
**Files:**
- Create: `frontend/utils/handlers/draftHandler.js`
- [ ] **Step 1: 实现**
```js
// frontend/utils/handlers/draftHandler.js
// 创作草稿 handler分组型按 uid 分组;草稿 key 已通过 M0 改造为 *_${uid} 形式)
import { getCurrentUid } from '@/utils/draftStorage'
import { getAllBaseKeys, listCurrentDraftKeys } 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) // 剩余部分就是 uiduid 是字符串)
}
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__' // 老无 uid 后缀 key 归 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 — 强警告reviewer P0用 '__legacy__' 而非 null 避免与其他用户组冲突)
canClean = keys.length > 0
disabledReason = keys.length === 0 ? 'empty' : null
} else {
canClean = keys.length > 0
disabledReason = keys.length === 0 ? 'empty' : null
}
groups.push({
uid, // 始终传字符串currentUid / '__legacy__' / 具体其他 uid不再传 null
displayUid: isLegacy ? '老版本数据' : (isCurrent ? `我的 (${uid})` : `其他用户 (${uid})`),
isCurrent,
sizeBytes: bytesOf(keys),
keyCount: keys.length,
canClean,
disabledReason,
})
}
// 按 sizeBytes 降序spec §4.3.2 要求)
return groups.sort((a, b) => b.sizeBytes - a.sizeBytes)
},
async cleanGroup({ uid }) {
const currentUid = getCurrentUid()
const allKeys = listAllDraftLikeKeys()
const baseKeys = getAllBaseKeys()
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
})
const before = bytesOf(targets)
for (const k of targets) { try { uni.removeStorageSync(k) } catch (e) {} }
return { freedBytes: before, keyCount: targets.length }
} 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 // 老 key 是精确等于 baseKey无 _uid 后缀)
})
const before = bytesOf(targets)
for (const k of targets) { try { uni.removeStorageSync(k) } catch (e) {} }
return { freedBytes: before, keyCount: targets.length }
} 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 // 仅其他用户,不含 legacy
})
const before = bytesOf(targets)
for (const k of targets) { try { uni.removeStorageSync(k) } catch (e) {} }
return { freedBytes: before, keyCount: targets.length }
} else {
// 具体其他 uid
const targets = allKeys.filter((k) => {
const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`))
if (!base) return false
return extractUidFromDraftKey(k, base) === uid
})
const before = bytesOf(targets)
for (const k of targets) { try { uni.removeStorageSync(k) } catch (e) {} }
return { freedBytes: before, keyCount: targets.length }
}
},
}
```
- [ ] **Step 2: 注册**
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/handlers/draftHandler.js frontend/utils/cacheManager.js
git commit -m "feat(handler): add draftHandler (grouped, uid-scoped)"
```
---
## Task 20: M2 整体联调验证
**Files:**
- 无(仅验证)
- [ ] **Step 1: 在 cache-cleanup 列表页临时加 console.log 验证**
```js
// 在临时入口(如 main.js
import { getCacheInfo } from '@/utils/cacheManager'
getCacheInfo().then(info => console.log('[M2 verify] info:', JSON.stringify(info, null, 2)))
```
预期输出:
- `categories` 含 6 项preload/draft/progress/guide/sandboxTmp/others
- `totalBytes` 是 6 项 sizeBytes 之和
- `appUsedBytes` / `quotaTotalBytes` / `usagePercent` 都有合理值
- [ ] **Step 2: 验证简单页清理**
```js
import { cleanCategory } from '@/utils/cacheManager'
const r = await cleanCategory('sandbox-tmp')
console.log('[M2 verify] sandbox-tmp clean:', r)
// 期望: { freedBytes: X, keyCount: 0 }freedBytes > 0
```
- [ ] **Step 3: 验证分组清理**
```js
import { cleanCategoryGroup } from '@/utils/cacheManager'
const r = await cleanCategoryGroup('draft', { uid: 'self' })
console.log('[M2 verify] draft self clean:', r)
// 期望: { freedBytes: X, keyCount: Y }(清掉当前 uid 的所有草稿)
```
- [ ] **Step 4: 验证黑名单不可删**
```js
import { cleanCategory } from '@/utils/cacheManager'
// 1. 写测试黑名单 key
uni.setStorageSync('access_token', 'test_token')
const before = uni.getStorageSync('access_token')
// 2. 调清理(应不影响)
await cleanCategory('others')
const after = uni.getStorageSync('access_token')
console.log('[M2 verify] access_token protected:', before === after) // true
```
- [ ] **Step 5: 移除临时 console.log**
- [ ] **Step 6:如用户授权commit**
```bash
git add frontend/utils/handlers/othersHandler.js frontend/utils/cacheManager.js
git commit -m "feat(handler): add othersHandler (simple, fallback)"
# 验证结果在 PR description 中描述即可,不创建独立 report 文件
```
---
# Phase 4: M3 — UI 页面(列表 + 详情)
## Task 21: 创建 pages/profile/cache-cleanup.vue 骨架 + 顶部 WeChat 风格
**Files:**
- Create: `frontend/pages/profile/cache-cleanup.vue`
- [ ] **Step 1: 在 pages.json 注册新页面**
```json
{
"path": "pages/profile/cache-cleanup",
"style": {
"navigationBarTitleText": "存储空间",
"enablePullDownRefresh": true
}
}
```
`pages.json` 的 pages 数组末尾追加(不要动现有项)。
- [ ] **Step 2: 创建 cache-cleanup.vue 骨架**
```vue
<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>
<!-- 全局空状态6 个分类全空时显示reviewer P0 修正失败时不显示避免与"加载失败"冲突 -->
<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.id === 'draft' || cat.id === 'guide'" 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>
<!-- 失败时不显示空状态reviewer P0避免冲突显示 -->
</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) {
// 仅在该分类 sizeBytes > 0 时允许进入
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; }
.row-label { font-size: 30rpx; color: #333; }
.row-warn { font-size: 22rpx; color: #faad14; }
.row-sub { display: flex; align-items: center; gap: 12rpx; }
.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; }
.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>
```
- [ ] **Step 3: 编译验证 + 实机查看**
Run: HBuilderX → 运行 → 真机/模拟器 → 进入 cache-cleanup 页面
预期顶部大字显示、进度条显示、6 个分类行 + "其他" section 正常渲染
- [ ] **Step 4:如用户授权commit**
```bash
git add frontend/pages/profile/cache-cleanup.vue frontend/pages.json
git commit -m "feat(ui): add cache-cleanup list page with WeChat-style top"
```
---
## Task 22: 创建 pages/profile/cache-cleanup-detail.vue 简单页模板
**Files:**
- Create: `frontend/pages/profile/cache-cleanup-detail.vue`
- [ ] **Step 1: 在 pages.json 注册**
```json
{
"path": "pages/profile/cache-cleanup-detail",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": true
}
}
```
- [ ] **Step 2: 实现详情页(含简单/分组双模板)**
```vue
<template>
<view class="detail">
<view v-if="loading" class="loading">加载中…</view>
<template v-else>
<!-- 简单页模板 -->
<view v-if="!groups" class="simple">
<text class="desc">{{ category?.description }}</text>
<view class="usage">
<text class="usage-value">{{ formatSize(simpleSize) }}</text>
<text class="usage-count">共 {{ simpleCount }} 项缓存</text>
</view>
<button class="btn" :disabled="simpleCount === 0" @click="onSimpleClean">
清理缓存
</button>
</view>
<!-- 分组页模板 -->
<view v-else-if="groups.length > 0" class="grouped">
<text class="desc">{{ category?.description }}</text>
<view v-for="g in groups" :key="(g.uid ?? '__global__') + '_' + g.displayUid" class="group-card">
<view class="group-header">
<text class="group-title">
<text v-if="g.global">📌 全局引导标记(不可清)</text>
<text v-else-if="g.isCurrent">👤 我的 ({{ g.displayUid }})</text>
<text v-else>👤 其他用户 ({{ g.displayUid }})</text>
</text>
</view>
<view class="group-stats">
<text>{{ formatSize(g.sizeBytes) }} · {{ g.keyCount }} 项</text>
</view>
<button
class="btn-group"
:class="{ 'btn-warn': !g.isCurrent && !g.global }"
:disabled="!g.canClean"
@click="onGroupClean(g)"
>
{{ g.disabledReason === 'logged-out' ? '请先登录' : '清理' }}
</button>
</view>
</view>
<!-- 分组页空状态 -->
<view v-else class="empty">
<text> 当前分类无缓存</text>
</view>
</template>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { getCacheInfo, getCategoryBreakdown, cleanCategory, cleanCategoryGroup, formatSize } from '@/utils/cacheManager'
const id = ref('')
const loading = ref(true)
const groups = ref(null)
const category = ref(null)
const simpleSize = ref(0)
const simpleCount = ref(0)
onLoad(async (q) => {
id.value = q.id || ''
uni.setNavigationBarTitle({ title: getCategoryLabel(id.value) })
await load()
})
onShow(load)
onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh() })
async function load() {
loading.value = true
try {
// 取 category meta
const info = await getCacheInfo()
category.value = info.categories.find((c) => c.id === id.value)
// 取 breakdown简单 handler 返回 null
const result = await getCategoryBreakdown(id.value)
if (result === null) {
groups.value = null
simpleSize.value = category.value?.sizeBytes ?? 0
simpleCount.value = category.value?.keyCount ?? 0
} else {
groups.value = result
}
} finally {
loading.value = false
}
}
function getCategoryLabel(id) {
// 简单映射M2 完成 handler 后从 category 取)
const map = { preload: '预加载数据缓存', draft: '创作中的草稿', progress: '活动进度缓存', guide: '引导记录', 'sandbox-tmp': '临时文件', others: '其他业务缓存' }
return map[id] || '缓存详情'
}
async function onSimpleClean() {
// 二次确认弹窗(用 ConfirmModal 组件或 uni.showModal
const confirmed = await new Promise((resolve) => {
uni.showModal({
title: '清理确认',
content: category.value?.warning ? '清理后无法恢复,是否继续?' : '将清理该类缓存,是否继续?',
success: (res) => resolve(res.confirm),
fail: () => resolve(false),
})
})
if (!confirmed) return
uni.showLoading({ title: '清理中…', mask: true })
try {
const r = await cleanCategory(id.value)
uni.hideLoading()
uni.showToast({ title: `已清理 ${formatSize(r.freedBytes)}`, icon: 'success' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '清理失败', icon: 'none' })
}
}
async function onGroupClean(g) {
// canClean 由 handler 决定UI 不重复判断
if (!g.canClean) {
if (g.disabledReason === 'logged-out') {
uni.showToast({ title: '请先登录', icon: 'none' })
} else if (g.disabledReason === 'guide-current' || g.disabledReason === 'guide-global') {
uni.showToast({ title: '引导记录不可清理', icon: 'none' })
}
return
}
const isCurrent = g.isCurrent
const confirmed = await new Promise((resolve) => {
uni.showModal({
title: isCurrent ? '清理确认' : '⚠ 强警告',
content: isCurrent
? (category.value?.warningText || '将清空你未提交的草稿,是否继续?')
: (category.value?.strongWarningText || `将清空账号 ${g.displayUid} 的数据,对方下次登录不会看到。是否继续?`),
confirmText: isCurrent ? '清理' : '仍要清理',
confirmColor: isCurrent ? '#1890ff' : '#ff4d4f',
success: (res) => resolve(res.confirm),
fail: () => resolve(false),
})
})
if (!confirmed) return
uni.showLoading({ title: '清理中…', mask: true })
try {
const uidParam = g.uid === '__global__' ? null : g.uid
const r = await cleanCategoryGroup(id.value, { uid: uidParam })
uni.hideLoading()
uni.showToast({ title: `已清理 ${formatSize(r.freedBytes)}`, icon: 'success' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '清理失败', icon: 'none' })
}
}
</script>
<style scoped>
.detail { padding: 32rpx; background: #f5f5f5; min-height: 100vh; }
.loading { text-align: center; padding: 60rpx 0; color: #999; }
.simple, .grouped { background: #fff; border-radius: 16rpx; padding: 32rpx; }
.desc { font-size: 26rpx; color: #666; display: block; margin-bottom: 32rpx; }
.usage { text-align: center; padding: 40rpx 0; }
.usage-value { font-size: 60rpx; font-weight: 600; color: #1890ff; display: block; }
.usage-count { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
.btn { width: 100%; background: #1890ff; color: #fff; border-radius: 12rpx; font-size: 30rpx; padding: 24rpx 0; margin-top: 32rpx; }
.btn[disabled] { background: #ccc; }
.group-card { padding: 24rpx 0; border-bottom: 1rpx solid #f0f0f0; display: flex; flex-direction: column; gap: 12rpx; }
.group-card:last-child { border-bottom: none; }
.group-title { font-size: 28rpx; color: #333; font-weight: 500; }
.group-stats { font-size: 26rpx; color: #999; }
.btn-group { align-self: flex-end; padding: 12rpx 32rpx; border-radius: 8rpx; background: #1890ff; color: #fff; font-size: 26rpx; }
.btn-warn { background: #ff4d4f; }
.btn-group[disabled] { background: #ccc; }
</style>
```
- [ ] **Step 3: 编译 + 实机验证两个模板**
预期:
- 进入 preload / progress / sandbox-tmp / others → 简单页(单按钮)
- 进入 draft / guide → 分组页(多卡片 + 各自的清理按钮)
- [ ] **Step 4:如用户授权commit**
```bash
git add frontend/pages/profile/cache-cleanup-detail.vue frontend/pages.json
git commit -m "feat(ui): add cache-cleanup-detail page (simple + grouped templates)"
```
---
## Task 23: pages/profile/profile.vue 加「存储空间」菜单项
**Files:**
- Modify: `frontend/pages/profile/profile.vue`
- [ ] **Step 1: 找到 settings/menu list 区域**
查找 profile.vue 里的菜单项数组(如 `menuItems` 或类似的循环),加一项:
```js
{ id: 'storage', icon: '💾', label: '存储空间', url: '/pages/profile/cache-cleanup' }
```
- [ ] **Step 2: 实机验证**
进 profile 页面,能看到「存储空间」菜单项,点击进入 cache-cleanup 列表页正常显示。
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/pages/profile/profile.vue
git commit -m "feat(profile): add '存储空间' menu item"
```
---
## Task 24: M3 联调(端到端)
**Files:**
- 无(仅验证)
- [ ] **Step 1: 端到端流程验证(已登录态)**
1. 进 profile → 点「存储空间」→ cache-cleanup 列表页正常
2. 大字 + 百分比 + 进度条 + 3 chip + 6 分类 + "其他" section 全部显示
3. 点「预加载数据缓存」→ cache-cleanup-detail 简单页 → 点清理 → ConfirmModal → 确认 → loading → toast → 返回列表页自动刷新
4. 大字 / 进度条数字更新
- [ ] **Step 2: 分组清理验证**
1. 账号 A 登录castlove 创作流写一些草稿draftStorage 写入)
2. 退出账号 A登录账号 Buid 不同)
3. 进 cache-cleanup → 点「创作中的草稿」→ 分组页应显示 "我的草稿uid=B" + "其他用户的草稿uid=A"
4. 点 "其他用户的草稿" 卡片清理 → 强警告弹窗 → 确认 → toast
5. 返回列表页,"创作中的草稿" 大小应减少
- [ ] **Step 3: 未登录态分组页验证**
1. 退出登录
2. 进 cache-cleanup → 点「创作中的草稿」→ 分组页所有数据归"其他用户",按钮置灰
- [ ] **Step 4: 内存清理验证**
1. 列表页进 preload 详情页 → 清理 → 立刻返回列表页 → 数字更新(说明 memoryMap 已清)
2. console 检查:用导出的 `getStats()`(不是 memoryMap 字段reviewer P0 修正):
```js
import('@/utils/preloadApi/core').then(c => console.log(c.getStats()))
// 期望: { hits: X, misses: Y, memorySize: 0, inFlightSize: 0, ... }
```
清理后 `memorySize``inFlightSize` 都应为 0
- [ ] **Step 5:如用户授权commit**
```bash
git add frontend/pages/profile/profile.vue frontend/pages/profile/cache-cleanup.vue frontend/pages/profile/cache-cleanup-detail.vue frontend/pages.json
git commit -m "feat(profile): add cache cleanup UI (list + detail pages)"
# E2E 验证在 PR description 中描述
```
---
# Phase 5: M4 — 13 个手工场景回归
## Task 25: 完整场景回归清单
**Files:**
- 无(仅验证)
- [ ] **Step 1: 13 个手工场景逐项验证**
完整场景列表(与 spec §7.3 对应):
| # | 场景 | 期望 | 验证 |
|---|------|------|------|
| 1 | 登录态清理简单页 | token/user/star_id/cid 不变 | ☐ |
| 2 | 登录态清理分组页-我的草稿 | 仅删 currentUid 草稿;普通警告 | ☐ |
| 3 | 登录态清理分组页-其他用户 | 仅删 otherUid 草稿;强警告;对方下次登录看不到 | ☐ |
| 4 | 未登录态清理简单页 | 全部业务缓存正常清理 | ☐ |
| 5 | 未登录态清理分组页 | 草稿/引导按钮置灰,不可清理 | ☐ |
| 6 | 清理中退出页面 | 不重复清理,不报错 | ☐ |
| 7 | 空缓存状态 | 列表页显示"✓ 当前无缓存可清理",无跳转入口 | ☐ |
| 8 | 草稿详情页(仅自己) | 仅显示"我的草稿"分组,清理按钮触发普通警告 | ☐ |
| 9 | 草稿详情页(多账号切换后) | 显示"我的草稿" + 多个"其他用户的草稿"分组;强警告+正常清理流程 | ☐ |
| 10 | 草稿详情页(未登录) | 所有数据归"其他用户",按钮置灰 | ☐ |
| 11 | 跨账号草稿隔离A 写 → B 登录) | B 看不到 A 的草稿(依赖 §11 M0 改造) | ☐ |
| 12 | 跨账号 preload 清理 | A 的 preload 被 B 清理时一并清掉 | ☐ |
| 13 | 清理后立刻进列表页 | 不会从 memoryMap 命中老数据 | ☐ |
- [ ] **Step 2: CLAUDE.md 自检清单过一遍**
- [ ] 新组件用 `<script setup>` 组合式 API
- [ ] 所有原生 API 包 `#ifdef APP-PLUS`
- [ ] 接口调用走 `utils/api.js`(本功能不需要后端 APIOK
- [ ] 新页面已在 `pages.json` 注册cache-cleanup + cache-cleanup-detail
- [ ] 未改动 `unpackage/dist/`
- [ ] **Step 3:如用户授权commit**
```bash
git add frontend/utils/ frontend/utils/handlers/ frontend/pages/profile/cache-cleanup.vue frontend/pages/profile/cache-cleanup-detail.vue frontend/pages/profile/profile.vue frontend/pages.json
git commit -m "feat(cache-cleanup): MVP complete - 13 manual scenarios + CLAUDE.md checklist passed"
# 验证结果在 PR description 中描述,不创建独立 report 文件
```
---
## 完成标准DoD
- [ ] Phase 1M07 个 castlove 文件改造完成,草稿按 uid 隔离
- [ ] Phase 2M1cacheManager.js + ioPath.js 新 API 完成,黑名单 + 配额 + 大小格式化 + 4 个 API + in-flight 保护
- [ ] Phase 3M26 个 handler 实现,简单型/分组型接口完整,跨用户清理 + invalidateAll 同步
- [ ] Phase 4M3列表页 + 详情页 + profile 菜单项 + pages.json 注册完成
- [ ] Phase 5M413 个手工场景 + CLAUDE.md 自检全部通过
**MVP 上线条件**:以上 5 个 Phase 全部完成 + 每次 commit 都经过用户授权。
---
## 风险与回滚
| 风险 | 缓解 |
|------|------|
| M0 改造时漏改一个文件导致草稿跨账号泄露 | Task 8 联调手工验证 + grep 全量确认 |
| 黑名单遗漏某个登录态 key | Task 20 M2 验证 + 13 场景回归 |
| preload 内存清理忘了 invalidateAll | Task 12 + Task 24 验证 |
| UI 清理按钮未做二次确认 | Task 22 用了 uni.showModal 二次确认 |
**回滚**
- 删除 `utils/cacheManager.js` + `utils/handlers/` + `utils/draftStorage.js`
- 还原 `utils/ioPath.js` 末尾新增 API
- 删除 `pages/profile/cache-cleanup.vue` + `pages/profile/cache-cleanup-detail.vue`
- 还原 `pages/profile/profile.vue` 菜单项 + `pages.json` 注册
- 还原 7 个 castlove 文件的 `uni.setStorageSync` 直写
**完全无侵入**,业务侧代码可一键还原。