78 KiB
缓存清理功能实施计划
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 个独立 handler(preload / 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 核心
// 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 执行:
// 模拟未登录态读
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
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
文件顶部,从:
// import { ... } from '...'
改为:
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
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
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
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
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
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
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→ 全部改为writeDraftpersistLaserPreviewImages同上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
git add frontend/utils/castloveGenerationFlow.js
git commit -m "refactor(castlove): use draftStorage for uid-scoped keys only"
Task 7.6: 改造 utils/craftMintSubmit.js(temp_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
git add frontend/utils/craftMintSubmit.js
git commit -m "refactor(castlove): use draftStorage for temp_nft_data"
Task 7.7: 改造 pages/discover/generation-loading.vue(generated_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
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
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 — 同一账号写读
- 登录账号 A (uid=10001)
- 进 castlove/create 填写表单并触发保存(找到自动保存时机或点保存)
- console 执行:
uni.getStorageInfoSync().keys.filter(k => k.includes('castlove_form_data')) - 期望:
['castlove_form_data_10001']
- Step 2: 手工场景 B — 切换账号读
- 退出账号 A
- 登录账号 B (uid=10002)
- 进 castlove/create,不应看到账号 A 的表单内容
- console 检查:不应有
castlove_form_data_10001被读到
- Step 3: 手工场景 C — 老 key 迁移
- 手动 console:
uni.setStorageSync('castlove_form_data', JSON.stringify({legacy: true}))(模拟老版本用户) - 登录任意账号,调用
readDraft('castlove_form_data') - 期望:返回
{legacy: true};console 不应再出现castlove_form_data无后缀 key
- Step 4:(如用户授权)commit
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 个公共 API(getCacheInfo / getCategoryBreakdown / cleanCategory / cleanCategoryGroup)。 前置:M0 完成(draftStorage.js 已建)。
Task 9: 创建 utils/cacheManager.js 骨架 + 大小格式化工具
Files:
-
Create:
frontend/utils/cacheManager.js -
Step 1: 实现骨架(含大小格式化 + 黑名单常量)
// 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)
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
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 实现
// 接 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)
// 在某个 .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 至少包含 test;info.totalBytes >= 100
- Step 3:(如用户授权)commit
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 追加
// 接 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
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 token(reviewer P0:仅清 memoryMap + abort 不够,必须加 token 防止 race condition)
现有实现(frontend/utils/preloadApi/core.js:253):
export function invalidateAll() {
memoryMap.clear()
}
问题:现有 in-flight 请求即使 abort,仍可能在 abort 后写入 memoryMap(race condition)。
扩展为:
// 模块级 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 前对比:
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
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 追加
// 接 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 保护
// 在 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
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: 在文件底部追加两个新函数
// 接 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
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
// 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 末尾注册
// cacheManager.js 末尾追加(应用启动时执行)
import sandboxTmpHandler from './handlers/sandboxTmpHandler'
registerCategory(sandboxTmpHandler)
- Step 3: 编译 + 简单验证
Run: HBuilderX → 运行 → 真机/模拟器,看是否有未注册 handler 报错。
- Step 4:(如用户授权)commit
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: 实现
// 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
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: 实现
// 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
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: 实现
// 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
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
// 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
// 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
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: 实现
// 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) // 剩余部分就是 uid(uid 是字符串)
}
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
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 验证
// 在临时入口(如 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: 验证简单页清理
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: 验证分组清理
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: 验证黑名单不可删
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
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 注册新页面
{
"path": "pages/profile/cache-cleanup",
"style": {
"navigationBarTitleText": "存储空间",
"enablePullDownRefresh": true
}
}
在 pages.json 的 pages 数组末尾追加(不要动现有项)。
- Step 2: 创建 cache-cleanup.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
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 注册
{
"path": "pages/profile/cache-cleanup-detail",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": true
}
}
- Step 2: 实现详情页(含简单/分组双模板)
<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
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 或类似的循环),加一项:
{ id: 'storage', icon: '💾', label: '存储空间', url: '/pages/profile/cache-cleanup' }
- Step 2: 实机验证
进 profile 页面,能看到「存储空间」菜单项,点击进入 cache-cleanup 列表页正常显示。
- Step 3:(如用户授权)commit
git add frontend/pages/profile/profile.vue
git commit -m "feat(profile): add '存储空间' menu item"
Task 24: M3 联调(端到端)
Files:
-
无(仅验证)
-
Step 1: 端到端流程验证(已登录态)
- 进 profile → 点「存储空间」→ cache-cleanup 列表页正常
- 大字 + 百分比 + 进度条 + 3 chip + 6 分类 + "其他" section 全部显示
- 点「预加载数据缓存」→ cache-cleanup-detail 简单页 → 点清理 → ConfirmModal → 确认 → loading → toast → 返回列表页自动刷新
- 大字 / 进度条数字更新
- Step 2: 分组清理验证
- 账号 A 登录,castlove 创作流写一些草稿(draftStorage 写入)
- 退出账号 A,登录账号 B(uid 不同)
- 进 cache-cleanup → 点「创作中的草稿」→ 分组页应显示 "我的草稿(uid=B)" + "其他用户的草稿(uid=A)"
- 点 "其他用户的草稿" 卡片清理 → 强警告弹窗 → 确认 → toast
- 返回列表页,"创作中的草稿" 大小应减少
- Step 3: 未登录态分组页验证
- 退出登录
- 进 cache-cleanup → 点「创作中的草稿」→ 分组页所有数据归"其他用户",按钮置灰
- Step 4: 内存清理验证
- 列表页进 preload 详情页 → 清理 → 立刻返回列表页 → 数字更新(说明 memoryMap 已清)
- console 检查:用导出的
getStats()(不是 memoryMap 字段,reviewer P0 修正):
清理后import('@/utils/preloadApi/core').then(c => console.log(c.getStats())) // 期望: { hits: X, misses: Y, memorySize: 0, inFlightSize: 0, ... }memorySize和inFlightSize都应为 0
- Step 5:(如用户授权)commit
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(本功能不需要后端 API,OK) -
新页面已在
pages.json注册(cache-cleanup + cache-cleanup-detail) -
未改动
unpackage/dist/ -
Step 3:(如用户授权)commit
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 1(M0):7 个 castlove 文件改造完成,草稿按 uid 隔离
- Phase 2(M1):cacheManager.js + ioPath.js 新 API 完成,黑名单 + 配额 + 大小格式化 + 4 个 API + in-flight 保护
- Phase 3(M2):6 个 handler 实现,简单型/分组型接口完整,跨用户清理 + invalidateAll 同步
- Phase 4(M3):列表页 + 详情页 + profile 菜单项 + pages.json 注册完成
- Phase 5(M4):13 个手工场景 + 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直写
完全无侵入,业务侧代码可一键还原。