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

78 KiB
Raw Blame History

缓存清理功能实施计划

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> 组合式 APIthis
  • 所有原生 API 包 #ifdef APP-PLUS 或平台分支
  • 接口调用走 utils/api.js,组件层不直接 uni.request
  • 新页面先在 pages.json 注册

测试约定: 项目当前未配置 vitest/jestfrontend/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.setStorageSyncCASTLOVE_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_KEYwriteDraftCRAFT_SELECTED_INDEX_KEY 保持直写)

以下 key 保持直写(不通过 draftStorage

  • GENERATION_FLOW_KEY / GENERATION_REQUEST_KEY / CRAFT_SELECTED_INDEX_KEYdevice-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.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
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
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_KEYreadDraftremoveStorageSyncremoveDraft保留GENERATION_FLOW_KEY / GENERATION_REQUEST_KEY / CRAFT_SELECTED_INDEX_KEY 的读写device-level

  • Step 2: asset-detail.vue

替换 CASTLOVE_FORM_KEY / CRAFT_SELECTED_IMAGE_KEYreadDraft / writeDraft

  • Step 3: lenticular-thinking.vue

替换 CASTLOVE_FORM_KEYreadDraft

  • 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 — 同一账号写读

  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. 手动 consoleuni.setStorageSync('castlove_form_data', JSON.stringify({legacy: true}))(模拟老版本用户)
  2. 登录任意账号,调用 readDraft('castlove_form_data')
  3. 期望:返回 {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 个公共 APIgetCacheInfo / 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 至少包含 testinfo.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 tokenreviewer P0仅清 memoryMap + abort 不够,必须加 token 防止 race condition

现有实现(frontend/utils/preloadApi/core.js:253

export function invalidateAll() {
  memoryMap.clear()
}

问题:现有 in-flight 请求即使 abort仍可能在 abort 后写入 memoryMaprace 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) // 剩余部分就是 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

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: 端到端流程验证(已登录态)

  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 修正):
    import('@/utils/preloadApi/core').then(c => console.log(c.getStats()))
    // 期望: { hits: X, misses: Y, memorySize: 0, inFlightSize: 0, ... }
    
    清理后 memorySizeinFlightSize 都应为 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(本功能不需要后端 APIOK

  • 新页面已在 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 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 直写

完全无侵入,业务侧代码可一键还原。