2351 lines
78 KiB
Markdown
2351 lines
78 KiB
Markdown
# 缓存清理功能实施计划
|
||
|
||
> **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 核心**
|
||
|
||
```js
|
||
// frontend/utils/draftStorage.js
|
||
// 草稿 storage 双读单写封装
|
||
// 目的:账号隔离 + 迁移兼容(老无 uid 后缀 key 过渡到 *_${uid} 形式)
|
||
|
||
// 注意:BASE_KEYS 是**实际存储 key 值**(不是 JS 变量名)
|
||
// 'CASTLOVE_FORM_KEY' 是 export const 名,其值是 'castlove_form_data',因此不要写 'CASTLOVE_FORM_KEY'
|
||
const BASE_KEYS = [
|
||
'castlove_form_data',
|
||
'temp_nft_data',
|
||
'generated_images',
|
||
'generation_result_meta',
|
||
'lenticular_studio_payload',
|
||
'craft_selected_image',
|
||
]
|
||
|
||
/**
|
||
* 取当前用户 uid(用于拼 key 后缀)
|
||
* @returns {string|null}
|
||
*/
|
||
export function getCurrentUid() {
|
||
try {
|
||
const userStr = uni.getStorageSync('user')
|
||
if (!userStr) return null
|
||
const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
|
||
return user?.uid ? String(user.uid) : null
|
||
} catch (e) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 构造带 uid 后缀的 key(无 uid 时 fallback 到原 key,用于未登录态的兼容性读)
|
||
*/
|
||
function buildKey(baseKey, uid) {
|
||
return uid ? `${baseKey}_${uid}` : baseKey
|
||
}
|
||
|
||
/**
|
||
* 读取草稿(双读:先新后旧,命中后迁移)
|
||
* **返回原始 storage 值**(字符串或对象,取决于写入方式),由调用方决定是否 JSON.parse。
|
||
* 这样保持与原 `uni.getStorageSync(...)` 后接 `JSON.parse(...)` 的调用模式完全兼容。
|
||
* @param {string} baseKey
|
||
* @returns {any|null} 原始值(可能是 JSON 字符串或已 stringify 的对象);null = 未命中
|
||
*/
|
||
export function readDraft(baseKey) {
|
||
const uid = getCurrentUid()
|
||
// 1. 优先读新 key
|
||
const newKey = buildKey(baseKey, uid)
|
||
try {
|
||
const val = uni.getStorageSync(newKey)
|
||
if (val !== '' && val !== null && val !== undefined) return val
|
||
} catch (e) { /* 继续 fallback */ }
|
||
|
||
// 2. fallback 读老 key(仅登录态有意义)
|
||
if (uid) {
|
||
try {
|
||
const oldVal = uni.getStorageSync(baseKey)
|
||
if (oldVal !== '' && oldVal !== null && oldVal !== undefined) {
|
||
// 3. 迁移:写到新 key,删老 key
|
||
try {
|
||
uni.setStorageSync(newKey, oldVal)
|
||
uni.removeStorageSync(baseKey)
|
||
} catch (e) { /* 迁移失败也返回数据 */ }
|
||
return oldVal
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 写入草稿(仅写新 key;未登录态拒绝写)
|
||
* @param {string} baseKey
|
||
* @param {string|object} value 通常是 JSON.stringify 后的字符串
|
||
* @returns {boolean} 是否写入成功
|
||
*/
|
||
export function writeDraft(baseKey, value) {
|
||
const uid = getCurrentUid()
|
||
if (!uid) return false
|
||
const newKey = buildKey(baseKey, uid)
|
||
try {
|
||
uni.setStorageSync(newKey, typeof value === 'string' ? value : JSON.stringify(value))
|
||
return true
|
||
} catch (e) {
|
||
console.warn('[draftStorage] writeDraft failed:', baseKey, e.message)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除草稿(仅删当前 uid 的新 key + 老 key)
|
||
*/
|
||
export function removeDraft(baseKey) {
|
||
const uid = getCurrentUid()
|
||
const newKey = buildKey(baseKey, uid)
|
||
try { uni.removeStorageSync(newKey) } catch (e) { /* skip */ }
|
||
try { uni.removeStorageSync(baseKey) } catch (e) { /* skip */ }
|
||
}
|
||
|
||
/**
|
||
* 获取所有 base key(用于 handler 遍历)
|
||
*/
|
||
export function getAllBaseKeys() {
|
||
return BASE_KEYS.slice()
|
||
}
|
||
|
||
/**
|
||
* 列出当前 uid 的所有草稿 key(含老 key 兜底)
|
||
*/
|
||
export function listCurrentDraftKeys() {
|
||
const uid = getCurrentUid()
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
const allKeys = info.keys || []
|
||
if (uid) {
|
||
return allKeys.filter((k) => {
|
||
return BASE_KEYS.some((base) => k === `${base}_${uid}` || k === base)
|
||
})
|
||
} else {
|
||
return allKeys.filter((k) => BASE_KEYS.includes(k))
|
||
}
|
||
} catch (e) {
|
||
return []
|
||
}
|
||
}
|
||
|
||
// safeParse 移除(readDraft 现在返回原值,调用方自行 JSON.parse)
|
||
// 如未来需要便捷方法,请单独导出
|
||
```
|
||
|
||
- [ ] **Step 2: 自测(浏览器 console / 临时 node 脚本)**
|
||
|
||
打开 HBuilderX 运行前端项目,进入任意 .vue 页面 console 执行:
|
||
|
||
```js
|
||
// 模拟未登录态读
|
||
uni.removeStorageSync('castlove_form_data')
|
||
const v = readDraft('castlove_form_data') // 应返回 null
|
||
|
||
// 模拟登录后写
|
||
uni.setStorageSync('user', JSON.stringify({ uid: 10001 }))
|
||
writeDraft('castlove_form_data', { foo: 'bar' })
|
||
const keys = uni.getStorageInfoSync().keys.filter(k => k.includes('castlove_form_data'))
|
||
// 期望: ['castlove_form_data_10001']
|
||
```
|
||
|
||
预期:新 key 写入成功,老 key 未被污染。
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/draftStorage.js
|
||
git commit -m "feat(draft): add draftStorage wrapper with uid-scoped keys"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 改造 pages/castlove/create.vue
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/castlove/create.vue`
|
||
|
||
- [ ] **Step 1: 替换 import**
|
||
|
||
文件顶部,从:
|
||
```js
|
||
// import { ... } from '...'
|
||
```
|
||
改为:
|
||
```js
|
||
import { readDraft, writeDraft, removeDraft } from '@/utils/draftStorage'
|
||
```
|
||
|
||
- [ ] **Step 2: 替换所有 `castlove_form_data` 读写**
|
||
|
||
查找:
|
||
- `uni.setStorageSync('castlove_form_data'` → 替换为 `writeDraft('castlove_form_data', `
|
||
- `uni.getStorageSync('castlove_form_data'` → 替换为 `readDraft('castlove_form_data')`
|
||
- `uni.removeStorageSync('castlove_form_data'` → 替换为 `removeDraft('castlove_form_data')`
|
||
|
||
预期:所有出现 castlove_form_data 的地方都迁移到 draftStorage 函数。
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
Run: HBuilderX → 运行 → 真机/模拟器(**不要**用 `npm run build:verify`,它只做 verify.html 替换,**不验证 JS/Vue 编译**)
|
||
预期:编译成功,无 `uni.setStorageSync is not a function` 之类的错误。
|
||
|
||
- [ ] **Step 4: 手工验证(未登录态保护)**
|
||
|
||
未登录时进入 castlove/create,尝试填写表单并保存。console 应打印:
|
||
```
|
||
[draftStorage] writeDraft failed: castlove_form_data no uid
|
||
```
|
||
且 `uni.getStorageInfoSync().keys` 不应出现 `castlove_form_data` 无后缀 key。
|
||
|
||
- [ ] **Step 5:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/castlove/create.vue
|
||
git commit -m "refactor(castlove): use draftStorage for form_data"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 改造 pages/castlove/index.vue
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/castlove/index.vue`
|
||
|
||
- [ ] **Step 1-5: 同 Task 2,但目标 key 是 `'castlove_form_data'`(reviewer 第 3 轮 P0 修正:实际写入是字符串字面量 `'castlove_form_data'`,不是 JS 常量名 `CASTLOVE_FORM_KEY`)**
|
||
|
||
- 同样替换 `uni.setStorageSync('castlove_form_data'` → `writeDraft('castlove_form_data', `
|
||
- 同样替换 `uni.getStorageSync('castlove_form_data'` → `readDraft('castlove_form_data')`
|
||
- 同样替换 `uni.removeStorageSync('castlove_form_data'` → `removeDraft('castlove_form_data')`
|
||
|
||
- [ ] **Step 6:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/castlove/index.vue
|
||
git commit -m "refactor(castlove): use draftStorage for castlove_form_data"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 改造 pages/castlove/success.vue
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/castlove/success.vue`
|
||
|
||
- [ ] **Step 1-5: 同 Task 2,但目标 key 是 `temp_nft_data`**
|
||
|
||
同样替换读写调用。
|
||
|
||
- [ ] **Step 6:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/castlove/success.vue
|
||
git commit -m "refactor(castlove): use draftStorage for temp_nft_data"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: 改造 pages/castlove/lenticular/lenticular-result.vue
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/castlove/lenticular/lenticular-result.vue`
|
||
|
||
- [ ] **Step 1-5: 同 Task 2,但目标 key 包含 3 个**
|
||
|
||
- `GENERATED_IMAGES_KEY`
|
||
- `GENERATION_RESULT_META_KEY`
|
||
- `LENTICULAR_STUDIO_STORAGE_KEY`
|
||
|
||
按 Task 2 的步骤逐一替换。
|
||
|
||
- [ ] **Step 6:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/castlove/lenticular/lenticular-result.vue
|
||
git commit -m "refactor(castlove): use draftStorage for lenticular result keys"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 改造 pages/castlove/laser/laser-result.vue
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/castlove/laser/laser-result.vue`
|
||
|
||
- [ ] **Step 1-5: 同 Task 5,目标 key 相同**
|
||
|
||
- `GENERATED_IMAGES_KEY`
|
||
- `GENERATION_RESULT_META_KEY`
|
||
- `LENTICULAR_STUDIO_STORAGE_KEY`
|
||
|
||
- [ ] **Step 6:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/castlove/laser/laser-result.vue
|
||
git commit -m "refactor(castlove): use draftStorage for laser result keys"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 改造 composables/useLaserSegment.js + useLaserBatchGenerate.js
|
||
|
||
**Files:**
|
||
- Modify: `frontend/composables/useLaserSegment.js`
|
||
- Modify: `frontend/composables/useLaserBatchGenerate.js`
|
||
|
||
- [ ] **Step 1: 改造 useLaserSegment.js**
|
||
|
||
同上 Task 2 流程,目标 key `'castlove_form_data'`(reviewer P0:实际是字面量字符串)
|
||
|
||
- [ ] **Step 2: 改造 useLaserBatchGenerate.js**
|
||
|
||
同上 Task 2 流程,目标 key `'castlove_form_data'`(reviewer P0)
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/composables/useLaserSegment.js frontend/composables/useLaserBatchGenerate.js
|
||
git commit -m "refactor(castlove): use draftStorage in laser composables"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7.5: 改造生产者 utils/castloveGenerationFlow.js
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/castloveGenerationFlow.js`
|
||
|
||
- [ ] **Step 1: 修改所有 `uni.setStorageSync` 与 `CASTLOVE_FORM_KEY` 等常量调用**
|
||
|
||
**Workflow key scope 决策(reviewer P0 修正:必须明确原子决策)**:
|
||
|
||
| Key 常量名 | 实际 storage 值 | 归 uid-scoped? | 加入 BASE_KEYS |
|
||
|----------|----------------|--------------|--------------|
|
||
| `CASTLOVE_FORM_KEY` | `'castlove_form_data'` | ✅ 是 | ✅ |
|
||
| `GENERATED_IMAGES_KEY` | `'generated_images'` | ✅ 是 | ✅ |
|
||
| `GENERATION_RESULT_META_KEY` | `'generation_result_meta'` | ✅ 是 | ✅ |
|
||
| `LENTICULAR_STUDIO_STORAGE_KEY` | `'lenticular_studio_payload'` | ✅ 是 | ✅ |
|
||
| `CRAFT_SELECTED_IMAGE_KEY` | `'craft_selected_image'` | ✅ 是 | ✅ |
|
||
| `GENERATION_FLOW_KEY` | `'generation_flow_payload'` | ❌ 否(device-level 一次性 token) | ❌ |
|
||
| `GENERATION_REQUEST_KEY` | `'generation_request_data'` | ❌ 否(同上) | ❌ |
|
||
| `CRAFT_SELECTED_INDEX_KEY` | `'craft_selected_index'` | ❌ 否(同上) | ❌ |
|
||
|
||
**device-level 理由**:这三个 key 是 castlove 流程的"一次性 token"(generation payload 用完即删,不跨账号复用);强制 uid-scoped 会破坏进行中的流程。
|
||
|
||
将以下调用替换为 `writeDraft(key, jsonString)` 形式:
|
||
- `persistFormData` 内的 `uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))` → `writeDraft('castlove_form_data', JSON.stringify(formData))`
|
||
- `persistLenticularPreviewMeta` 内的 `LENTICULAR_STUDIO_STORAGE_KEY` / `GENERATION_RESULT_META_KEY` / `GENERATED_IMAGES_KEY` → 全部改为 `writeDraft`
|
||
- `persistLaserPreviewImages` 同上
|
||
- `completeSelectionAndOpenDetail` 内的 `CRAFT_SELECTED_IMAGE_KEY` → `writeDraft`(CRAFT_SELECTED_INDEX_KEY 保持直写)
|
||
|
||
以下 key **保持直写**(不通过 draftStorage):
|
||
- `GENERATION_FLOW_KEY` / `GENERATION_REQUEST_KEY` / `CRAFT_SELECTED_INDEX_KEY`(device-level,理由见上表)
|
||
|
||
- [ ] **Step 2:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/castloveGenerationFlow.js
|
||
git commit -m "refactor(castlove): use draftStorage for uid-scoped keys only"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7.6: 改造 utils/craftMintSubmit.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**
|
||
|
||
```bash
|
||
git add frontend/utils/craftMintSubmit.js
|
||
git commit -m "refactor(castlove): use draftStorage for temp_nft_data"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7.7: 改造 pages/discover/generation-loading.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**
|
||
|
||
```bash
|
||
git add frontend/pages/discover/generation-loading.vue
|
||
git commit -m "refactor(discover): use draftStorage in generation-loading"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7.8: 迁移剩余消费方到 draftStorage(消费者侧改造)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/discover/generation-result.vue`
|
||
- Modify: `frontend/pages/asset-detail/asset-detail.vue`
|
||
- Modify: `frontend/pages/castlove/lenticular/lenticular-thinking.vue`
|
||
|
||
- [ ] **Step 1: generation-result.vue**
|
||
|
||
替换 `uni.getStorageSync('castlove_form_data'` / `CASTLOVE_FORM_KEY` / `GENERATED_IMAGES_KEY` / `GENERATION_RESULT_META_KEY` 为 `readDraft`,`removeStorageSync` 为 `removeDraft`。
|
||
**保留**:`GENERATION_FLOW_KEY` / `GENERATION_REQUEST_KEY` / `CRAFT_SELECTED_INDEX_KEY` 的读写(device-level)。
|
||
|
||
- [ ] **Step 2: asset-detail.vue**
|
||
|
||
替换 `CASTLOVE_FORM_KEY` / `CRAFT_SELECTED_IMAGE_KEY` 为 `readDraft` / `writeDraft`。
|
||
|
||
- [ ] **Step 3: lenticular-thinking.vue**
|
||
|
||
替换 `CASTLOVE_FORM_KEY` 为 `readDraft`。
|
||
|
||
- [ ] **Step 4: 编译验证**
|
||
|
||
HBuilderX → 真机/模拟器;走完 castlove / discover / lenticular 三个流程,确认无断流。
|
||
|
||
- [ ] **Step 5:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/discover/generation-result.vue frontend/pages/asset-detail/asset-detail.vue frontend/pages/castlove/lenticular/lenticular-thinking.vue
|
||
git commit -m "refactor: migrate draft key consumers to draftStorage"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: M0 联调验证
|
||
|
||
**Files:**
|
||
- 无(仅验证)
|
||
|
||
- [ ] **Step 1: 手工场景 A — 同一账号写读**
|
||
|
||
1. 登录账号 A (uid=10001)
|
||
2. 进 castlove/create 填写表单并触发保存(找到自动保存时机或点保存)
|
||
3. console 执行:`uni.getStorageInfoSync().keys.filter(k => k.includes('castlove_form_data'))`
|
||
4. 期望:`['castlove_form_data_10001']`
|
||
|
||
- [ ] **Step 2: 手工场景 B — 切换账号读**
|
||
|
||
1. 退出账号 A
|
||
2. 登录账号 B (uid=10002)
|
||
3. 进 castlove/create,**不应看到账号 A 的表单内容**
|
||
4. console 检查:不应有 `castlove_form_data_10001` 被读到
|
||
|
||
- [ ] **Step 3: 手工场景 C — 老 key 迁移**
|
||
|
||
1. 手动 console:`uni.setStorageSync('castlove_form_data', JSON.stringify({legacy: true}))`(模拟老版本用户)
|
||
2. 登录任意账号,调用 `readDraft('castlove_form_data')`
|
||
3. 期望:返回 `{legacy: true}`;console 不应再出现 `castlove_form_data` 无后缀 key
|
||
|
||
- [ ] **Step 4:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/castlove/create.vue
|
||
git commit -m "refactor(castlove): use draftStorage for form_data"
|
||
# 联调报告不创建独立文件,在 PR description 中描述验证结果即可
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 2: M1 — 核心封装层 cacheManager
|
||
|
||
> **目标**:实现 `utils/cacheManager.js`,包含黑名单、大小格式化、6 个 handler 注册机制、4 个公共 API(getCacheInfo / getCategoryBreakdown / cleanCategory / cleanCategoryGroup)。
|
||
> **前置**:M0 完成(draftStorage.js 已建)。
|
||
|
||
## Task 9: 创建 utils/cacheManager.js 骨架 + 大小格式化工具
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/cacheManager.js`
|
||
|
||
- [ ] **Step 1: 实现骨架(含大小格式化 + 黑名单常量)**
|
||
|
||
```js
|
||
// frontend/utils/cacheManager.js
|
||
// 缓存清理统一封装层
|
||
// 详见 docs/superpowers/specs/2026-07-28-cache-cleanup-design.md
|
||
|
||
import { invalidateAll } from './preloadApi/core'
|
||
|
||
// ── 黑名单(§3.4.1)──
|
||
// 全等匹配
|
||
const PROTECTED_EXACT = new Set([
|
||
'access_token', 'user', 'star_id', 'login_mobile', 'cid',
|
||
'deviceFp', 'pending_scan_url', 'gallery_owner_id',
|
||
'needs_welcome', 'has_seen_welcome', 'is_new_user',
|
||
'liked_assets_exhibition',
|
||
])
|
||
// 前缀匹配
|
||
const PROTECTED_PREFIX = [
|
||
'daily_login_completed_',
|
||
'avatar_file_',
|
||
'temp_register_',
|
||
]
|
||
|
||
/**
|
||
* 判断 key 是否在黑名单中
|
||
*/
|
||
export function isProtectedKey(key) {
|
||
if (typeof key !== 'string') return false
|
||
if (PROTECTED_EXACT.has(key)) return true
|
||
return PROTECTED_PREFIX.some((p) => key.startsWith(p))
|
||
}
|
||
|
||
// ── 大小格式化(§6.2)──
|
||
export function formatSize(bytes) {
|
||
if (typeof bytes !== 'number' || bytes < 0) return '—'
|
||
if (bytes < 1024) return '< 1 KB'
|
||
const kb = bytes / 1024
|
||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||
const mb = kb / 1024
|
||
if (mb < 1024) return `${mb.toFixed(1)} MB`
|
||
return `${(mb / 1024).toFixed(2)} GB`
|
||
}
|
||
|
||
// ── 内部状态 ──
|
||
const handlers = new Map() // handlerId → handler
|
||
const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise
|
||
|
||
// ── 注册 API ──
|
||
export function registerCategory(handler) {
|
||
if (!handler?.id) throw new Error('[cacheManager] handler.id is required')
|
||
handlers.set(handler.id, handler)
|
||
}
|
||
|
||
function getHandler(id) {
|
||
const h = handlers.get(id)
|
||
if (!h) throw new Error(`[cacheManager] unknown handler: ${id}`)
|
||
return h
|
||
}
|
||
|
||
// ... 后续 task 补充 getCacheInfo / getCategoryBreakdown / cleanCategory / cleanCategoryGroup
|
||
```
|
||
|
||
- [ ] **Step 2: 自测(浏览器 console)**
|
||
|
||
```js
|
||
import { isProtectedKey, formatSize } from '@/utils/cacheManager'
|
||
|
||
isProtectedKey('access_token') // true
|
||
isProtectedKey('avatar_file_123') // true
|
||
isProtectedKey('castlove_form_data_10001') // false
|
||
isProtectedKey('preload:10001:abc') // false
|
||
|
||
formatSize(0) // '< 1 KB'
|
||
formatSize(500) // '< 1 KB'
|
||
formatSize(2048) // '2.0 KB'
|
||
formatSize(1024*1024) // '1.0 MB'
|
||
formatSize(2.5*1024*1024*1024) // '2.50 GB'
|
||
```
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/cacheManager.js
|
||
git commit -m "feat(cacheManager): skeleton with blacklist + formatSize"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: 实现 getCacheInfo(列表页汇总 API)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/cacheManager.js`(追加)
|
||
|
||
- [ ] **Step 1: 在 cacheManager.js 追加 getCacheInfo 实现**
|
||
|
||
```js
|
||
// 接 Task 9 代码末尾追加
|
||
|
||
/**
|
||
* 列表页读取(汇总 + 存储配额 + 其他 section 数据)
|
||
*/
|
||
export async function getCacheInfo() {
|
||
// [并行 1] 所有 handler 的 computeSize
|
||
const categories = await Promise.all(
|
||
Array.from(handlers.values()).map(async (h) => {
|
||
try {
|
||
const info = await h.computeSize()
|
||
return { id: h.id, label: h.label, description: h.description || '', sizeBytes: info?.sizeBytes ?? 0, keyCount: info?.keyCount ?? 0, warning: !!h.warning }
|
||
} catch (e) {
|
||
console.warn(`[cacheManager] computeSize failed: ${h.id}`, e.message)
|
||
return { id: h.id, label: h.label, description: h.description || '', sizeBytes: -1, keyCount: 0, warning: !!h.warning, error: e.message }
|
||
}
|
||
})
|
||
)
|
||
|
||
// [并行 2] 配额 + 沙盒 + 黑名单
|
||
let currentSizeKB = 0, limitSizeKB = 0, sandboxBytes = 0, blacklistBytes = 0
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
currentSizeKB = info.currentSize || 0
|
||
limitSizeKB = info.limitSize || 0
|
||
const allKeys = info.keys || []
|
||
// 黑名单 key 大小
|
||
for (const k of allKeys) {
|
||
if (isProtectedKey(k)) {
|
||
try {
|
||
const v = uni.getStorageSync(k)
|
||
if (v != null) blacklistBytes += JSON.stringify(v).length
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[cacheManager] getStorageInfoSync failed:', e.message)
|
||
}
|
||
try {
|
||
const { getSandboxTotalSize } = await import('./ioPath')
|
||
sandboxBytes = await getSandboxTotalSize()
|
||
} catch (e) {
|
||
console.warn('[cacheManager] getSandboxTotalSize failed:', e.message)
|
||
}
|
||
|
||
const totalBytes = categories.reduce((sum, c) => sum + (c.sizeBytes > 0 ? c.sizeBytes : 0), 0)
|
||
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes
|
||
const quotaTotalBytes = limitSizeKB * 1024
|
||
const quotaAvailableBytes = Math.max(0, quotaTotalBytes - appUsedBytes)
|
||
const usagePercent = quotaTotalBytes > 0 ? (appUsedBytes / quotaTotalBytes) * 100 : 0
|
||
|
||
return {
|
||
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, usagePercent,
|
||
othersBytes: blacklistBytes + sandboxBytes,
|
||
categories,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 自测(带 mock handler)**
|
||
|
||
```js
|
||
// 在某个 .vue 的 setup() 里临时注册一个测试 handler
|
||
import { registerCategory, getCacheInfo } from '@/utils/cacheManager'
|
||
registerCategory({
|
||
id: 'test',
|
||
label: '测试',
|
||
computeSize: async () => ({ sizeBytes: 100, keyCount: 1 }),
|
||
})
|
||
const info = await getCacheInfo()
|
||
// 期望:info.categories 至少包含 test;info.totalBytes >= 100
|
||
```
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/cacheManager.js
|
||
git commit -m "feat(cacheManager): getCacheInfo with quota + sandbox"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: 实现 getCategoryBreakdown(详情页分组 API)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/cacheManager.js`(追加)
|
||
|
||
- [ ] **Step 1: 在 cacheManager.js 追加**
|
||
|
||
```js
|
||
// 接 Task 10 代码末尾追加
|
||
|
||
/**
|
||
* 详情页读取(分组详情;简单 handler 返回 null)
|
||
*/
|
||
export async function getCategoryBreakdown(id) {
|
||
const h = getHandler(id)
|
||
if (typeof h.computeBreakdown !== 'function') return null
|
||
try {
|
||
return await h.computeBreakdown()
|
||
} catch (e) {
|
||
console.warn(`[cacheManager] computeBreakdown failed: ${id}`, e.message)
|
||
return []
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 自测**
|
||
|
||
注册一个返回固定分组数组的 handler,调用 getCategoryBreakdown 应返回原数组。
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/cacheManager.js
|
||
git commit -m "feat(cacheManager): getCategoryBreakdown for detail page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11.5: 修改 preloadApi/core.js invalidateAll(清 inFlightMap + abort + cancellation token)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/preloadApi/core.js`
|
||
|
||
- [ ] **Step 1: 扩展 invalidateAll 实现 + 加 cancellation token(reviewer P0:仅清 memoryMap + abort 不够,必须加 token 防止 race condition)**
|
||
|
||
现有实现(`frontend/utils/preloadApi/core.js:253`):
|
||
```js
|
||
export function invalidateAll() {
|
||
memoryMap.clear()
|
||
}
|
||
```
|
||
|
||
**问题**:现有 in-flight 请求即使 abort,仍可能在 abort 后写入 memoryMap(race condition)。
|
||
|
||
扩展为:
|
||
|
||
```js
|
||
// 模块级 generation 计数器
|
||
let _cleanupGeneration = 0
|
||
|
||
export function invalidateAll() {
|
||
// 1. 标记一次"清理发生",所有后续 in-flight 写入必须先 check 自己的 generation 是否仍是当前 generation
|
||
_cleanupGeneration++
|
||
// 2. 清内存缓存
|
||
memoryMap.clear()
|
||
// 3. 清 inFlight 中的 promise 并 abort
|
||
for (const [key, entry] of inFlightMap) {
|
||
try { entry.abort() } catch (e) { /* skip */ }
|
||
}
|
||
inFlightMap.clear()
|
||
}
|
||
|
||
export function getCleanupGeneration() {
|
||
return _cleanupGeneration
|
||
}
|
||
```
|
||
|
||
`_doFetch` 函数(在同一个文件)需修改:捕获 generation 快照,写入 memoryMap 前对比:
|
||
|
||
```js
|
||
const genAtStart = _cleanupGeneration
|
||
const result = await Promise.race([fetchPromise, timeoutPromise])
|
||
// ... 写入前检查
|
||
if (genAtStart !== _cleanupGeneration) {
|
||
// 本次清理已发生过,丢弃结果(fetch 已完成但不应写入)
|
||
resolveInFlight(null)
|
||
return null
|
||
}
|
||
// ... 写入 memoryMap / file cache
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
Run: HBuilderX → 运行 → 真机/模拟器
|
||
预期:编译通过;进入 cache-cleanup 列表页 → 点 preload 详情清理 → 返回列表页 → 数字更新且无 stale 数据回填。
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/preloadApi/core.js
|
||
git commit -m "fix(preload): invalidateAll now clears inFlightMap + aborts + cancellation token"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: 实现 cleanCategory / cleanCategoryGroup + in-flight 单例保护
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/cacheManager.js`(追加)
|
||
|
||
- [ ] **Step 1: 在 cacheManager.js 追加**
|
||
|
||
```js
|
||
// 接 Task 11 代码末尾追加
|
||
|
||
/**
|
||
* 简单页清理(无分组维度)
|
||
*/
|
||
export function cleanCategory(id) {
|
||
return _runWithInFlight(`${id}`, async () => {
|
||
const h = getHandler(id)
|
||
if (typeof h.clean !== 'function') {
|
||
throw new Error(`[cacheManager] handler ${id} has no clean() (use cleanCategoryGroup)`)
|
||
}
|
||
try {
|
||
const result = await h.clean()
|
||
// preload 清理同步清内存(§3.4.4)
|
||
if (id === 'preload') {
|
||
try { await invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
|
||
}
|
||
return result
|
||
} catch (e) {
|
||
console.warn(`[cacheManager] clean failed: ${id}`, e.message)
|
||
throw e
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 分组页清理(按 uid 维度)
|
||
* @param {string} id
|
||
* @param {object} opts
|
||
* @param {string|null} opts.uid 'self' = 当前用户,null = 其他用户聚合,其他 = 具体 uid 字符串
|
||
*/
|
||
export function cleanCategoryGroup(id, opts = {}) {
|
||
const uidKey = opts.uid === undefined ? 'self' : String(opts.uid)
|
||
return _runWithInFlight(`${id}#${uidKey}`, async () => {
|
||
const h = getHandler(id)
|
||
if (typeof h.cleanGroup !== 'function') {
|
||
throw new Error(`[cacheManager] handler ${id} has no cleanGroup() (use cleanCategory)`)
|
||
}
|
||
try {
|
||
const result = await h.cleanGroup({ uid: opts.uid === undefined ? 'self' : opts.uid })
|
||
if (id === 'preload') {
|
||
try { await invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
|
||
}
|
||
return result
|
||
} catch (e) {
|
||
console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message)
|
||
throw e
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 编程式清理所有(UI 不调用;登出流程/测试用)
|
||
* 对每个 handler 调用其支持的清理方法:
|
||
* - 优先 clean()(简单型)
|
||
* - 否则调 cleanGroup({ uid: 'self' })(仅清当前用户的分组数据,符合"登出前清自己"的语义)
|
||
*/
|
||
export async function cleanAll() {
|
||
const results = []
|
||
for (const h of handlers.values()) {
|
||
try {
|
||
let r
|
||
if (typeof h.clean === 'function') {
|
||
r = await h.clean()
|
||
} else if (typeof h.cleanGroup === 'function') {
|
||
r = await h.cleanGroup({ uid: 'self' })
|
||
} else {
|
||
continue
|
||
}
|
||
results.push({ id: h.id, ...r, error: null })
|
||
} catch (e) {
|
||
console.warn(`[cacheManager] cleanAll failed for ${h.id}:`, e.message)
|
||
results.push({ id: h.id, freedBytes: 0, keyCount: 0, error: e.message })
|
||
}
|
||
}
|
||
try { await invalidateAll() } catch (e) { console.warn('[cacheManager] cleanAll invalidateAll failed:', e.message) }
|
||
return { freedBytes: results.reduce((s, r) => s + (r.freedBytes || 0), 0), perCategory: results }
|
||
}
|
||
|
||
async function _runWithInFlight(key, fn) {
|
||
if (cleanInFlight.has(key)) return cleanInFlight.get(key)
|
||
const p = (async () => {
|
||
try { return await fn() } finally { cleanInFlight.delete(key) }
|
||
})()
|
||
cleanInFlight.set(key, p)
|
||
return p
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 自测 in-flight 保护**
|
||
|
||
```js
|
||
// 在 setup() 里
|
||
let resolveClean
|
||
const fakeHandler = {
|
||
id: 'test',
|
||
label: '测试',
|
||
computeSize: async () => ({ sizeBytes: 100, keyCount: 1 }),
|
||
clean: () => new Promise((r) => { resolveClean = () => r({ freedBytes: 50, keyCount: 1 }) }),
|
||
}
|
||
registerCategory(fakeHandler)
|
||
const p1 = cleanCategory('test')
|
||
const p2 = cleanCategory('test') // 应返回同一 Promise
|
||
console.log(p1 === p2) // true
|
||
resolveClean()
|
||
await Promise.all([p1, p2])
|
||
```
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/cacheManager.js
|
||
git commit -m "feat(cacheManager): cleanCategory/Group with in-flight + preload invalidate"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: 在 ioPath.js 新增只读 API
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/ioPath.js`
|
||
|
||
- [ ] **Step 1: 在文件底部追加两个新函数**
|
||
|
||
```js
|
||
// 接 ioPath.js 末尾追加
|
||
|
||
/**
|
||
* 计算沙盒 doc 目录下所有文件总字节数(含白名单 preload/share/image)
|
||
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes
|
||
*/
|
||
export async function getSandboxTotalSize() {
|
||
// #ifdef APP-PLUS
|
||
try {
|
||
const root = await getSandboxRootDir()
|
||
return await _sumDirSize(root)
|
||
} catch (e) {
|
||
return 0
|
||
}
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
return 0
|
||
// #endif
|
||
}
|
||
|
||
/**
|
||
* 扫描 sandbox doc 下"所有业务子目录的 tmp/" 文件总字节数(不包含白名单 preload/share/image)
|
||
* 用于 sandboxTmpHandler.computeSize
|
||
*
|
||
* 与 getSandboxTotalSize 的区别:
|
||
* - getSandboxTotalSize: 沙盒 doc 下所有文件(含 preload/share/image 白名单)
|
||
* - scanSandboxTmpFiles: 只统计 tmp/ 子目录(用于清理项;与 clearAllSandboxTmpFiles 一致)
|
||
*/
|
||
export async function scanSandboxTmpFiles() {
|
||
// #ifdef APP-PLUS
|
||
try {
|
||
const root = await getSandboxRootDir()
|
||
const topEntries = await _listDir(root)
|
||
let total = 0
|
||
for (const top of topEntries) {
|
||
if (!top.isDirectory) continue
|
||
if (PROTECTED_SUBDIRS.has(top.name)) continue
|
||
// 只看 tmp/ 子目录
|
||
const tmpEntry = await new Promise((resolve) => {
|
||
top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null))
|
||
})
|
||
if (!tmpEntry) continue
|
||
total += await _sumDirSize(tmpEntry)
|
||
}
|
||
return total
|
||
} catch (e) {
|
||
return 0
|
||
}
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
return 0
|
||
// #endif
|
||
}
|
||
|
||
// _listDir 工具(与 clearAllSandboxTmpFiles 内部逻辑一致)
|
||
async function _listDir(dirEntry) {
|
||
// #ifdef APP-PLUS
|
||
return new Promise((resolve) => {
|
||
const reader = dirEntry.createReader()
|
||
const collected = []
|
||
const readAll = () => {
|
||
reader.readEntries(
|
||
(es) => { if (es.length === 0) return resolve(collected); collected.push(...es); readAll() },
|
||
() => resolve(collected)
|
||
)
|
||
}
|
||
readAll()
|
||
})
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
return []
|
||
// #endif
|
||
}
|
||
|
||
async function _sumDirSize(dirEntry) {
|
||
// #ifdef APP-PLUS
|
||
let total = 0
|
||
try {
|
||
const reader = dirEntry.createReader()
|
||
const entries = await new Promise((resolve) => {
|
||
const collected = []
|
||
const readAll = () => {
|
||
reader.readEntries(
|
||
(es) => { if (es.length === 0) return resolve(collected); collected.push(...es); readAll() },
|
||
() => resolve(collected)
|
||
)
|
||
}
|
||
readAll()
|
||
})
|
||
for (const entry of entries) {
|
||
if (entry.isDirectory) {
|
||
total += await _sumDirSize(entry)
|
||
} else if (entry.isFile) {
|
||
try {
|
||
const file = await new Promise((res, rej) => entry.file(res, rej))
|
||
total += (file.size || 0)
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
}
|
||
} catch (e) { /* skip */ }
|
||
return total
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
return 0
|
||
// #endif
|
||
}
|
||
|
||
/**
|
||
* 计数 sandbox doc 下 tmp/ 子目录的文件总数(reviewer P0 修正:导出供 sandboxTmpHandler 使用)
|
||
* 与 scanSandboxTmpFiles 范围一致(仅 tmp/,不含白名单 preload/share/image)
|
||
*/
|
||
export async function countSandboxTmpFiles() {
|
||
// #ifdef APP-PLUS
|
||
try {
|
||
const root = await getSandboxRootDir()
|
||
const topEntries = await _listDir(root)
|
||
let count = 0
|
||
const walk = async (dir) => {
|
||
const entries = await _listDir(dir)
|
||
for (const e of entries) {
|
||
if (e.isFile) count++
|
||
else if (e.isDirectory) await walk(e)
|
||
}
|
||
}
|
||
for (const top of topEntries) {
|
||
if (!top.isDirectory) continue
|
||
if (PROTECTED_SUBDIRS.has(top.name)) continue
|
||
const tmpEntry = await new Promise((resolve) => {
|
||
top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null))
|
||
})
|
||
if (!tmpEntry) continue
|
||
await walk(tmpEntry)
|
||
}
|
||
return count
|
||
} catch (e) { return 0 }
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
return 0
|
||
// #endif
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
Run: HBuilderX → 运行 → 真机/模拟器(reviewer P0 修正:`npm run build:verify` 不验证 JS/Vue 编译)
|
||
预期:无语法错误;uni-app 编译过。
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/ioPath.js
|
||
git commit -m "feat(ioPath): add getSandboxTotalSize and scanSandboxTmpFiles"
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 3: M2 — 6 个 handler 实现
|
||
|
||
> **目标**:实现 6 个 handler,顺序按从简单到复杂:sandboxTmp → progress → others → preload → guide → draft。
|
||
|
||
## Task 14: 创建 utils/handlers/ 目录 + sandboxTmpHandler(最简单)
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/handlers/sandboxTmpHandler.js`
|
||
|
||
- [ ] **Step 1: 实现 handler**
|
||
|
||
```js
|
||
// frontend/utils/handlers/sandboxTmpHandler.js
|
||
// 沙盒临时文件 handler(简单型)
|
||
// reviewer P0 修正:不再依赖私有 ioPath 内部函数,全部走 ioPath 导出 API
|
||
import { scanSandboxTmpFiles, countSandboxTmpFiles, clearAllSandboxTmpFiles } from '@/utils/ioPath'
|
||
|
||
export default {
|
||
id: 'sandbox-tmp',
|
||
label: '临时文件',
|
||
description: '上传/分享过程产生的临时文件',
|
||
warning: false,
|
||
async computeSize() {
|
||
const [bytes, count] = await Promise.all([
|
||
scanSandboxTmpFiles(),
|
||
countSandboxTmpFiles(), // 真实文件数(reviewer P0 修正)
|
||
])
|
||
return { sizeBytes: bytes, keyCount: count }
|
||
},
|
||
async clean() {
|
||
const [before, beforeCount] = await Promise.all([
|
||
scanSandboxTmpFiles(),
|
||
countSandboxTmpFiles(),
|
||
])
|
||
await clearAllSandboxTmpFiles()
|
||
const after = await scanSandboxTmpFiles()
|
||
return { freedBytes: Math.max(0, before - after), keyCount: beforeCount }
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 cacheManager.js 末尾注册**
|
||
|
||
```js
|
||
// cacheManager.js 末尾追加(应用启动时执行)
|
||
import sandboxTmpHandler from './handlers/sandboxTmpHandler'
|
||
registerCategory(sandboxTmpHandler)
|
||
```
|
||
|
||
- [ ] **Step 3: 编译 + 简单验证**
|
||
|
||
Run: HBuilderX → 运行 → 真机/模拟器,看是否有未注册 handler 报错。
|
||
|
||
- [ ] **Step 4:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/handlers/sandboxTmpHandler.js frontend/utils/cacheManager.js
|
||
git commit -m "feat(handler): add sandboxTmpHandler (simple)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: 实现 progressHandler
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/handlers/progressHandler.js`
|
||
|
||
- [ ] **Step 1: 实现**
|
||
|
||
```js
|
||
// frontend/utils/handlers/progressHandler.js
|
||
// 活动进度缓存 handler(简单型)
|
||
import { isProtectedKey } from '@/utils/cacheManager'
|
||
|
||
const PREFIX = 'progress_'
|
||
|
||
function listProgressKeys() {
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
return (info.keys || []).filter((k) => k.startsWith(PREFIX) && !isProtectedKey(k))
|
||
} catch (e) { return [] }
|
||
}
|
||
|
||
function computeSizeOf(keys) {
|
||
let total = 0
|
||
for (const k of keys) {
|
||
try {
|
||
const v = uni.getStorageSync(k)
|
||
if (v != null) total += JSON.stringify(v).length
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
return total
|
||
}
|
||
|
||
export default {
|
||
id: 'progress',
|
||
label: '活动进度缓存',
|
||
description: '支持活动页断网浏览',
|
||
warning: false,
|
||
async computeSize() {
|
||
const keys = listProgressKeys()
|
||
return { sizeBytes: computeSizeOf(keys), keyCount: keys.length }
|
||
},
|
||
async clean() {
|
||
const keys = listProgressKeys()
|
||
let freed = 0
|
||
for (const k of keys) {
|
||
try {
|
||
const v = uni.getStorageSync(k)
|
||
if (v != null) freed += JSON.stringify(v).length
|
||
uni.removeStorageSync(k)
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
return { freedBytes: freed, keyCount: keys.length }
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 注册**
|
||
|
||
在 cacheManager.js 末尾追加 `registerCategory(progressHandler)`。
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/handlers/progressHandler.js frontend/utils/cacheManager.js
|
||
git commit -m "feat(handler): add progressHandler (simple)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: 实现 othersHandler(兜底)
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/handlers/othersHandler.js`
|
||
|
||
- [ ] **Step 1: 实现**
|
||
|
||
```js
|
||
// frontend/utils/handlers/othersHandler.js
|
||
// 兜底分类:未匹配其他规则的 key(且不在黑名单)
|
||
// 注意:草稿 keys(无论 currentUid / otherUid / legacy)**全部**归 draftHandler,
|
||
// 否则会双重计入 totalBytes 且强警告无法生效
|
||
//
|
||
// 安全白名单(reviewer P0):以下 key 属于其他模块的工作流关键状态,
|
||
// 严禁被 othersHandler 清理:
|
||
import { isProtectedKey } from '@/utils/cacheManager'
|
||
import { getAllBaseKeys } from '@/utils/draftStorage'
|
||
|
||
const PRELOAD_PREFIX = 'preload:'
|
||
const PROGRESS_PREFIX = 'progress_'
|
||
const GUIDE_PREFIX = 'guide_'
|
||
const DRAFT_BASE_KEYS = getAllBaseKeys()
|
||
|
||
// 工作流关键 key 白名单(reviewer P0 修正:原"全收"策略会破坏这些)
|
||
// 这些 key 即使不在黑名单也不归 others 清理
|
||
const WORKFLOW_CRITICAL_KEYS = new Set([
|
||
'generation_flow_payload', // castloveGenerationFlow.js
|
||
'generation_request_data', // castloveGenerationFlow.js
|
||
'craft_selected_index', // castloveGenerationFlow.js(选中索引)
|
||
'__package_info__', // uni-upgrade-center-app
|
||
'UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH', // uni-upgrade-center-app
|
||
// 注意:base 形式的 'castlove_form_data' / 'temp_nft_data' 等由 DRAFT_BASE_KEYS 覆盖
|
||
])
|
||
|
||
function isClaimed(key) {
|
||
if (isProtectedKey(key)) return false
|
||
if (WORKFLOW_CRITICAL_KEYS.has(key)) return false
|
||
if (key.startsWith(PRELOAD_PREFIX)) return false
|
||
if (key.startsWith(PROGRESS_PREFIX)) return false
|
||
if (key.startsWith(GUIDE_PREFIX)) return false
|
||
if (DRAFT_BASE_KEYS.some((base) => key === base || key.startsWith(`${base}_`))) return false
|
||
return true
|
||
}
|
||
|
||
export default {
|
||
id: 'others',
|
||
label: '其他业务缓存',
|
||
description: '未归类的少量业务数据',
|
||
warning: false,
|
||
async computeSize() {
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
const keys = (info.keys || []).filter(isClaimed)
|
||
let bytes = 0
|
||
for (const k of keys) {
|
||
try { const v = uni.getStorageSync(k); if (v != null) bytes += JSON.stringify(v).length } catch (e) {}
|
||
}
|
||
return { sizeBytes: bytes, keyCount: keys.length }
|
||
} catch (e) { return { sizeBytes: 0, keyCount: 0 } }
|
||
},
|
||
async clean() {
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
const keys = (info.keys || []).filter(isClaimed)
|
||
let freed = 0
|
||
for (const k of keys) {
|
||
try {
|
||
const v = uni.getStorageSync(k); if (v != null) freed += JSON.stringify(v).length
|
||
uni.removeStorageSync(k)
|
||
} catch (e) {}
|
||
}
|
||
return { freedBytes: freed, keyCount: keys.length }
|
||
} catch (e) { return { freedBytes: 0, keyCount: 0 } }
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 注册**
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/handlers/othersHandler.js frontend/utils/cacheManager.js
|
||
git commit -m "feat(handler): add othersHandler (simple, fallback)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: 实现 preloadHandler(跨用户清理 + invalidateAll)
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/handlers/preloadHandler.js`
|
||
|
||
- [ ] **Step 1: 实现**
|
||
|
||
```js
|
||
// frontend/utils/handlers/preloadHandler.js
|
||
// preload 缓存 handler(简单型,但跨用户清理 + invalidateAll)
|
||
// 跨用户:删除所有 preload:xxx:* 不依赖 currentUid
|
||
// 内存清理:由 cacheManager.cleanCategory 触发 invalidateAll
|
||
|
||
const PREFIX = 'preload:'
|
||
|
||
function listAllPreloadKeys() {
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
return (info.keys || []).filter((k) => k.startsWith(PREFIX))
|
||
} catch (e) { return [] }
|
||
}
|
||
|
||
export default {
|
||
id: 'preload',
|
||
label: '预加载数据缓存',
|
||
description: '列表页提前拉取的数据',
|
||
warning: false,
|
||
async computeSize() {
|
||
const keys = listAllPreloadKeys()
|
||
let bytes = 0
|
||
for (const k of keys) {
|
||
try { const v = uni.getStorageSync(k); if (v != null) bytes += JSON.stringify(v).length } catch (e) {}
|
||
}
|
||
return { sizeBytes: bytes, keyCount: keys.length }
|
||
},
|
||
async clean() {
|
||
const keys = listAllPreloadKeys()
|
||
let freed = 0
|
||
for (const k of keys) {
|
||
try {
|
||
const v = uni.getStorageSync(k); if (v != null) freed += JSON.stringify(v).length
|
||
uni.removeStorageSync(k)
|
||
} catch (e) {}
|
||
}
|
||
return { freedBytes: freed, keyCount: keys.length }
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 注册**
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/handlers/preloadHandler.js frontend/utils/cacheManager.js
|
||
git commit -m "feat(handler): add preloadHandler (cross-user)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: 实现 guideKeys 工具函数 + guideHandler(分组型 + 保留规则)
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/handlers/guideKeys.js`
|
||
- Create: `frontend/utils/handlers/guideHandler.js`
|
||
|
||
- [ ] **Step 1: 实现 guideKeys.js**
|
||
|
||
```js
|
||
// frontend/utils/handlers/guideKeys.js
|
||
// guide 分类工具函数(供 guideHandler 和 othersHandler 共用)
|
||
|
||
const GUIDE_PREFIX = 'guide_'
|
||
|
||
/**
|
||
* 列出所有 guide_* key
|
||
*/
|
||
export function listGuideKeys() {
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
return (info.keys || []).filter((k) => k.startsWith(GUIDE_PREFIX))
|
||
} catch (e) { return [] }
|
||
}
|
||
|
||
/**
|
||
* 列出所有无 uid 段的 guide_* key
|
||
* 包括: guide_shown_${configKey}, guide_debug_mode, guide_first_show
|
||
*/
|
||
export function listGuideGlobalKeys() {
|
||
return listGuideKeys().filter((k) => !/_[0-9]+(_|$)/.test(k))
|
||
}
|
||
|
||
/**
|
||
* 列出属于某 uid 的 guide_* key
|
||
*/
|
||
export function listGuideKeysByUid(uid) {
|
||
if (!uid) return []
|
||
const pattern = new RegExp(`_${uid}(_|$)`)
|
||
return listGuideKeys().filter((k) => pattern.test(k))
|
||
}
|
||
|
||
/**
|
||
* 解析 key 中包含的 uid(如果有)
|
||
* 支持两种 owner 段:
|
||
* - 数字 uid(如 'guide_done_10001_intro')
|
||
* - 'default'(如 'guide_done_default_intro',guideConfig.js 在未登录时使用)
|
||
*/
|
||
export function extractUidFromGuideKey(key) {
|
||
// 先试数字
|
||
const m = key.match(/_(\d+)(?:_|$)/)
|
||
if (m) return m[1]
|
||
// 再试 default
|
||
const d = key.match(/_(default)(?:_|$)/)
|
||
if (d) return 'default'
|
||
return null
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 实现 guideHandler.js**
|
||
|
||
```js
|
||
// frontend/utils/handlers/guideHandler.js
|
||
// 引导记录 handler(分组型,含保留规则)
|
||
|
||
import { getCurrentUid as _getCurrentUid } from '@/utils/draftStorage' // 复用
|
||
import { listGuideKeys, listGuideGlobalKeys, extractUidFromGuideKey } from './guideKeys'
|
||
|
||
function getCurrentUid() {
|
||
try {
|
||
const userStr = uni.getStorageSync('user')
|
||
if (!userStr) return null
|
||
const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
|
||
return user?.uid ? String(user.uid) : null
|
||
} catch (e) { return null }
|
||
}
|
||
|
||
const GLOBAL_PROTECTED_KEYS = new Set(['guide_debug_mode', 'guide_first_show'])
|
||
|
||
function isGlobalKey(key) {
|
||
// guide_shown_xxx 是无 uid 段(但有 configKey)
|
||
if (GLOBAL_PROTECTED_KEYS.has(key)) return true
|
||
if (key.startsWith('guide_shown_')) return true
|
||
return false
|
||
}
|
||
|
||
function bytesOf(keys) {
|
||
let total = 0
|
||
for (const k of keys) {
|
||
try { const v = uni.getStorageSync(k); if (v != null) total += JSON.stringify(v).length } catch (e) {}
|
||
}
|
||
return total
|
||
}
|
||
|
||
export default {
|
||
id: 'guide',
|
||
label: '引导记录',
|
||
description: '新手引导完成标记',
|
||
warning: false,
|
||
|
||
async computeSize() {
|
||
const keys = listGuideKeys()
|
||
return { sizeBytes: bytesOf(keys), keyCount: keys.length }
|
||
},
|
||
|
||
async computeBreakdown() {
|
||
const currentUid = getCurrentUid()
|
||
const allKeys = listGuideKeys()
|
||
const groups = []
|
||
const isLoggedIn = !!currentUid
|
||
|
||
// 1. 我的(currentUid)— 保留不可清
|
||
if (currentUid) {
|
||
const myKeys = allKeys.filter((k) => extractUidFromGuideKey(k) === currentUid)
|
||
groups.push({
|
||
uid: currentUid, displayUid: currentUid, isCurrent: true,
|
||
sizeBytes: bytesOf(myKeys), keyCount: myKeys.length,
|
||
canClean: false, disabledReason: 'guide-current',
|
||
})
|
||
}
|
||
|
||
// 2. 其他用户(按 uid 聚合)— 仅登录态可清;未登录态保留展示但按钮置灰
|
||
const otherUidKeys = new Map()
|
||
for (const k of allKeys) {
|
||
const uid = extractUidFromGuideKey(k)
|
||
if (!uid || uid === currentUid) continue
|
||
if (!otherUidKeys.has(uid)) otherUidKeys.set(uid, [])
|
||
otherUidKeys.get(uid).push(k)
|
||
}
|
||
for (const [uid, keys] of otherUidKeys) {
|
||
groups.push({
|
||
uid, displayUid: uid, isCurrent: false,
|
||
sizeBytes: bytesOf(keys), keyCount: keys.length,
|
||
canClean: isLoggedIn && keys.length > 0,
|
||
disabledReason: isLoggedIn ? (keys.length === 0 ? 'empty' : null) : 'logged-out',
|
||
})
|
||
}
|
||
|
||
// 3. 全局 guide_*(无 uid 段)— 永远不可清
|
||
const globalKeys = allKeys.filter(isGlobalKey)
|
||
if (globalKeys.length > 0) {
|
||
groups.push({
|
||
uid: '__global__', displayUid: '全局标记', isCurrent: false,
|
||
sizeBytes: bytesOf(globalKeys), keyCount: globalKeys.length, global: true,
|
||
canClean: false, disabledReason: 'guide-global',
|
||
})
|
||
}
|
||
|
||
// 按 sizeBytes 降序(spec §4.3.2 要求)
|
||
return groups.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
||
},
|
||
|
||
async cleanGroup({ uid }) {
|
||
const currentUid = getCurrentUid()
|
||
const allKeys = listGuideKeys()
|
||
|
||
// 保留:当前 uid 的 + 全局 key
|
||
const reserved = new Set()
|
||
if (currentUid) {
|
||
for (const k of allKeys) if (extractUidFromGuideKey(k) === currentUid) reserved.add(k)
|
||
}
|
||
for (const k of allKeys) if (isGlobalKey(k)) reserved.add(k)
|
||
|
||
let targetKeys = []
|
||
if (uid === 'self') {
|
||
// 不允许清自己的(保留规则)
|
||
return { freedBytes: 0, keyCount: 0 }
|
||
} else if (uid === null) {
|
||
// 其他用户聚合(不含全局)
|
||
targetKeys = allKeys.filter((k) => {
|
||
if (reserved.has(k)) return false
|
||
const u = extractUidFromGuideKey(k)
|
||
return u && u !== currentUid
|
||
})
|
||
} else if (uid === '__global__') {
|
||
// 全局 key 也不允许清
|
||
return { freedBytes: 0, keyCount: 0 }
|
||
} else {
|
||
// 具体其他 uid
|
||
targetKeys = allKeys.filter((k) => extractUidFromGuideKey(k) === uid)
|
||
}
|
||
|
||
const before = bytesOf(targetKeys)
|
||
for (const k of targetKeys) {
|
||
try { uni.removeStorageSync(k) } catch (e) {}
|
||
}
|
||
return { freedBytes: before, keyCount: targetKeys.length }
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 注册**
|
||
|
||
- [ ] **Step 4:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/handlers/guideKeys.js frontend/utils/handlers/guideHandler.js frontend/utils/cacheManager.js
|
||
git commit -m "feat(handler): add guideHandler (grouped, with preserve rules)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: 实现 draftHandler(分组型 + uid 绑定)
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/handlers/draftHandler.js`
|
||
|
||
- [ ] **Step 1: 实现**
|
||
|
||
```js
|
||
// frontend/utils/handlers/draftHandler.js
|
||
// 创作草稿 handler(分组型,按 uid 分组;草稿 key 已通过 M0 改造为 *_${uid} 形式)
|
||
|
||
import { getCurrentUid } from '@/utils/draftStorage'
|
||
import { getAllBaseKeys, listCurrentDraftKeys } from '@/utils/draftStorage'
|
||
|
||
function bytesOf(keys) {
|
||
let total = 0
|
||
for (const k of keys) {
|
||
try { const v = uni.getStorageSync(k); if (v != null) total += JSON.stringify(v).length } catch (e) {}
|
||
}
|
||
return total
|
||
}
|
||
|
||
function listAllDraftLikeKeys() {
|
||
try {
|
||
const info = uni.getStorageInfoSync()
|
||
const all = info.keys || []
|
||
const baseKeys = getAllBaseKeys()
|
||
return all.filter((k) => baseKeys.some((base) => k === base || k.startsWith(`${base}_`)))
|
||
} catch (e) { return [] }
|
||
}
|
||
|
||
function extractUidFromDraftKey(key, baseKey) {
|
||
const prefix = `${baseKey}_`
|
||
if (!key.startsWith(prefix)) return null
|
||
return key.slice(prefix.length) // 剩余部分就是 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**
|
||
|
||
```bash
|
||
git add frontend/utils/handlers/draftHandler.js frontend/utils/cacheManager.js
|
||
git commit -m "feat(handler): add draftHandler (grouped, uid-scoped)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: M2 整体联调验证
|
||
|
||
**Files:**
|
||
- 无(仅验证)
|
||
|
||
- [ ] **Step 1: 在 cache-cleanup 列表页临时加 console.log 验证**
|
||
|
||
```js
|
||
// 在临时入口(如 main.js)加:
|
||
import { getCacheInfo } from '@/utils/cacheManager'
|
||
getCacheInfo().then(info => console.log('[M2 verify] info:', JSON.stringify(info, null, 2)))
|
||
```
|
||
|
||
预期输出:
|
||
- `categories` 含 6 项(preload/draft/progress/guide/sandboxTmp/others)
|
||
- `totalBytes` 是 6 项 sizeBytes 之和
|
||
- `appUsedBytes` / `quotaTotalBytes` / `usagePercent` 都有合理值
|
||
|
||
- [ ] **Step 2: 验证简单页清理**
|
||
|
||
```js
|
||
import { cleanCategory } from '@/utils/cacheManager'
|
||
const r = await cleanCategory('sandbox-tmp')
|
||
console.log('[M2 verify] sandbox-tmp clean:', r)
|
||
// 期望: { freedBytes: X, keyCount: 0 }(freedBytes > 0)
|
||
```
|
||
|
||
- [ ] **Step 3: 验证分组清理**
|
||
|
||
```js
|
||
import { cleanCategoryGroup } from '@/utils/cacheManager'
|
||
const r = await cleanCategoryGroup('draft', { uid: 'self' })
|
||
console.log('[M2 verify] draft self clean:', r)
|
||
// 期望: { freedBytes: X, keyCount: Y }(清掉当前 uid 的所有草稿)
|
||
```
|
||
|
||
- [ ] **Step 4: 验证黑名单不可删**
|
||
|
||
```js
|
||
import { cleanCategory } from '@/utils/cacheManager'
|
||
// 1. 写测试黑名单 key
|
||
uni.setStorageSync('access_token', 'test_token')
|
||
const before = uni.getStorageSync('access_token')
|
||
// 2. 调清理(应不影响)
|
||
await cleanCategory('others')
|
||
const after = uni.getStorageSync('access_token')
|
||
console.log('[M2 verify] access_token protected:', before === after) // true
|
||
```
|
||
|
||
- [ ] **Step 5: 移除临时 console.log**
|
||
|
||
- [ ] **Step 6:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/handlers/othersHandler.js frontend/utils/cacheManager.js
|
||
git commit -m "feat(handler): add othersHandler (simple, fallback)"
|
||
# 验证结果在 PR description 中描述即可,不创建独立 report 文件
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 4: M3 — UI 页面(列表 + 详情)
|
||
|
||
## Task 21: 创建 pages/profile/cache-cleanup.vue 骨架 + 顶部 WeChat 风格
|
||
|
||
**Files:**
|
||
- Create: `frontend/pages/profile/cache-cleanup.vue`
|
||
|
||
- [ ] **Step 1: 在 pages.json 注册新页面**
|
||
|
||
```json
|
||
{
|
||
"path": "pages/profile/cache-cleanup",
|
||
"style": {
|
||
"navigationBarTitleText": "存储空间",
|
||
"enablePullDownRefresh": true
|
||
}
|
||
}
|
||
```
|
||
|
||
在 `pages.json` 的 pages 数组末尾追加(不要动现有项)。
|
||
|
||
- [ ] **Step 2: 创建 cache-cleanup.vue 骨架**
|
||
|
||
```vue
|
||
<template>
|
||
<view class="cache-cleanup">
|
||
<!-- 顶部 WeChat 风格 -->
|
||
<view class="hero">
|
||
<text class="hero-label">Topfans 已用空间</text>
|
||
<text class="hero-value">{{ formatSize(info.appUsedBytes) }}</text>
|
||
<text class="hero-percent">占据配额 {{ info.usagePercent.toFixed(1) }}% 存储空间</text>
|
||
<view class="progress-bar">
|
||
<view class="progress-fill" :style="{ width: Math.min(100, info.usagePercent) + '%' }"></view>
|
||
</view>
|
||
<view class="chips">
|
||
<view class="chip">Topfans 已用空间</view>
|
||
<view class="chip">内存缓存空间</view>
|
||
<view class="chip">总内存空间</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 缓存分类 -->
|
||
<view class="section">
|
||
<text class="section-title">缓存分类</text>
|
||
<!-- 全局空状态:6 个分类全空时显示(reviewer P0 修正:失败时不显示,避免与"加载失败"冲突) -->
|
||
<view v-if="!loadFailed && info.totalBytes === 0 && info.categories.every(c => c.sizeBytes <= 0)" class="empty">
|
||
<text>✓ 当前无缓存可清理</text>
|
||
</view>
|
||
<view v-for="cat in info.categories" :key="cat.id" class="row" @click="goDetail(cat.id)">
|
||
<view class="row-main">
|
||
<text class="row-label">{{ cat.label }}</text>
|
||
<text v-if="cat.id === 'draft' || cat.id === 'guide'" class="row-warn">⚠ 按账号分组清理</text>
|
||
</view>
|
||
<view class="row-sub">
|
||
<text v-if="cat.sizeBytes === -1" class="error">? · 加载失败</text>
|
||
<text v-else class="size">{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} 项</text>
|
||
<text class="chevron">›</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- "其他" section -->
|
||
<view class="section others-section">
|
||
<text class="section-title">其他</text>
|
||
<view class="row others-row">
|
||
<view class="row-main">
|
||
<text class="row-label">其他</text>
|
||
<text class="row-desc">包含运行 Topfans 的必要数据、账号会话、其他账号的数据等</text>
|
||
</view>
|
||
<text class="size">{{ formatSize(info.othersBytes) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 整体加载失败状态(reviewer P0 修正:加点击重试 + 抑制空状态) -->
|
||
<view v-if="loadFailed && info.totalBytes === 0" class="load-failed" @click="load">
|
||
<text>加载失败,点此重试</text>
|
||
</view>
|
||
<!-- 失败时不显示空状态(reviewer P0:避免冲突显示) -->
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||
import { getCacheInfo, formatSize } from '@/utils/cacheManager'
|
||
|
||
const info = ref({
|
||
appUsedBytes: 0, quotaTotalBytes: 0, quotaAvailableBytes: 0,
|
||
usagePercent: 0, othersBytes: 0, totalBytes: 0,
|
||
categories: [],
|
||
})
|
||
const loadFailed = ref(false)
|
||
|
||
async function load() {
|
||
loadFailed.value = false
|
||
try {
|
||
const result = await Promise.race([
|
||
getCacheInfo(),
|
||
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
|
||
])
|
||
info.value = result
|
||
} catch (e) {
|
||
console.warn('[cache-cleanup] load failed:', e.message)
|
||
loadFailed.value = true
|
||
}
|
||
}
|
||
|
||
function goDetail(id) {
|
||
// 仅在该分类 sizeBytes > 0 时允许进入
|
||
const cat = info.value.categories.find((c) => c.id === id)
|
||
if (!cat || cat.sizeBytes <= 0) {
|
||
uni.showToast({ title: '该分类暂无缓存', icon: 'none' })
|
||
return
|
||
}
|
||
uni.navigateTo({ url: `/pages/profile/cache-cleanup-detail?id=${id}` })
|
||
}
|
||
|
||
// reviewer P0:使用 onShow 统一触发 load(进页面 + 从详情页返回时都触发)
|
||
// 不再使用 onMounted 避免重复触发
|
||
onShow(load)
|
||
onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cache-cleanup { padding-bottom: 40rpx; }
|
||
.hero { background: #fff; padding: 40rpx 32rpx 32rpx; }
|
||
.hero-label { font-size: 26rpx; color: #666; }
|
||
.hero-value { font-size: 72rpx; font-weight: 600; color: #1890ff; display: block; margin: 12rpx 0; }
|
||
.hero-percent { font-size: 24rpx; color: #999; }
|
||
.progress-bar { height: 8rpx; background: #f0f0f0; border-radius: 4rpx; margin: 16rpx 0 24rpx; overflow: hidden; }
|
||
.progress-fill { height: 100%; background: #1890ff; }
|
||
.chips { display: flex; gap: 16rpx; }
|
||
.chip { flex: 1; padding: 12rpx; background: #f5f7fa; border-radius: 8rpx; font-size: 22rpx; color: #666; text-align: center; }
|
||
.section { background: #fff; margin-top: 20rpx; padding: 0 32rpx; }
|
||
.section-title { font-size: 24rpx; color: #999; padding: 20rpx 0; display: block; }
|
||
.row { display: flex; justify-content: space-between; align-items: center; padding: 28rpx 0; border-bottom: 1rpx solid #f0f0f0; }
|
||
.row-main { display: flex; flex-direction: column; gap: 6rpx; }
|
||
.row-label { font-size: 30rpx; color: #333; }
|
||
.row-warn { font-size: 22rpx; color: #faad14; }
|
||
.row-sub { display: flex; align-items: center; gap: 12rpx; }
|
||
.row-sub .size { font-size: 26rpx; color: #999; }
|
||
.row-sub .chevron { font-size: 32rpx; color: #ccc; }
|
||
.row-desc { font-size: 22rpx; color: #999; margin-top: 6rpx; }
|
||
.others-row { align-items: flex-start; }
|
||
.others-row .size { padding-top: 4rpx; }
|
||
.error { color: #ff4d4f; font-size: 26rpx; }
|
||
.empty { text-align: center; padding: 60rpx 0; color: #999; font-size: 28rpx; }
|
||
.load-failed { text-align: center; padding: 40rpx 0; color: #1890ff; font-size: 28rpx; cursor: pointer; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: 编译验证 + 实机查看**
|
||
|
||
Run: HBuilderX → 运行 → 真机/模拟器 → 进入 cache-cleanup 页面
|
||
预期:顶部大字显示、进度条显示、6 个分类行 + "其他" section 正常渲染
|
||
|
||
- [ ] **Step 4:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/profile/cache-cleanup.vue frontend/pages.json
|
||
git commit -m "feat(ui): add cache-cleanup list page with WeChat-style top"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: 创建 pages/profile/cache-cleanup-detail.vue 简单页模板
|
||
|
||
**Files:**
|
||
- Create: `frontend/pages/profile/cache-cleanup-detail.vue`
|
||
|
||
- [ ] **Step 1: 在 pages.json 注册**
|
||
|
||
```json
|
||
{
|
||
"path": "pages/profile/cache-cleanup-detail",
|
||
"style": {
|
||
"navigationBarTitleText": "",
|
||
"enablePullDownRefresh": true
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 实现详情页(含简单/分组双模板)**
|
||
|
||
```vue
|
||
<template>
|
||
<view class="detail">
|
||
<view v-if="loading" class="loading">加载中…</view>
|
||
<template v-else>
|
||
<!-- 简单页模板 -->
|
||
<view v-if="!groups" class="simple">
|
||
<text class="desc">{{ category?.description }}</text>
|
||
<view class="usage">
|
||
<text class="usage-value">{{ formatSize(simpleSize) }}</text>
|
||
<text class="usage-count">共 {{ simpleCount }} 项缓存</text>
|
||
</view>
|
||
<button class="btn" :disabled="simpleCount === 0" @click="onSimpleClean">
|
||
清理缓存
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 分组页模板 -->
|
||
<view v-else-if="groups.length > 0" class="grouped">
|
||
<text class="desc">{{ category?.description }}</text>
|
||
<view v-for="g in groups" :key="(g.uid ?? '__global__') + '_' + g.displayUid" class="group-card">
|
||
<view class="group-header">
|
||
<text class="group-title">
|
||
<text v-if="g.global">📌 全局引导标记(不可清)</text>
|
||
<text v-else-if="g.isCurrent">👤 我的 ({{ g.displayUid }})</text>
|
||
<text v-else>👤 其他用户 ({{ g.displayUid }})</text>
|
||
</text>
|
||
</view>
|
||
<view class="group-stats">
|
||
<text>{{ formatSize(g.sizeBytes) }} · {{ g.keyCount }} 项</text>
|
||
</view>
|
||
<button
|
||
class="btn-group"
|
||
:class="{ 'btn-warn': !g.isCurrent && !g.global }"
|
||
:disabled="!g.canClean"
|
||
@click="onGroupClean(g)"
|
||
>
|
||
{{ g.disabledReason === 'logged-out' ? '请先登录' : '清理' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 分组页空状态 -->
|
||
<view v-else class="empty">
|
||
<text>✓ 当前分类无缓存</text>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||
import { getCacheInfo, getCategoryBreakdown, cleanCategory, cleanCategoryGroup, formatSize } from '@/utils/cacheManager'
|
||
|
||
const id = ref('')
|
||
const loading = ref(true)
|
||
const groups = ref(null)
|
||
const category = ref(null)
|
||
const simpleSize = ref(0)
|
||
const simpleCount = ref(0)
|
||
|
||
onLoad(async (q) => {
|
||
id.value = q.id || ''
|
||
uni.setNavigationBarTitle({ title: getCategoryLabel(id.value) })
|
||
await load()
|
||
})
|
||
|
||
onShow(load)
|
||
onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh() })
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
// 取 category meta
|
||
const info = await getCacheInfo()
|
||
category.value = info.categories.find((c) => c.id === id.value)
|
||
// 取 breakdown(简单 handler 返回 null)
|
||
const result = await getCategoryBreakdown(id.value)
|
||
if (result === null) {
|
||
groups.value = null
|
||
simpleSize.value = category.value?.sizeBytes ?? 0
|
||
simpleCount.value = category.value?.keyCount ?? 0
|
||
} else {
|
||
groups.value = result
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function getCategoryLabel(id) {
|
||
// 简单映射(M2 完成 handler 后从 category 取)
|
||
const map = { preload: '预加载数据缓存', draft: '创作中的草稿', progress: '活动进度缓存', guide: '引导记录', 'sandbox-tmp': '临时文件', others: '其他业务缓存' }
|
||
return map[id] || '缓存详情'
|
||
}
|
||
|
||
async function onSimpleClean() {
|
||
// 二次确认弹窗(用 ConfirmModal 组件或 uni.showModal)
|
||
const confirmed = await new Promise((resolve) => {
|
||
uni.showModal({
|
||
title: '清理确认',
|
||
content: category.value?.warning ? '清理后无法恢复,是否继续?' : '将清理该类缓存,是否继续?',
|
||
success: (res) => resolve(res.confirm),
|
||
fail: () => resolve(false),
|
||
})
|
||
})
|
||
if (!confirmed) return
|
||
uni.showLoading({ title: '清理中…', mask: true })
|
||
try {
|
||
const r = await cleanCategory(id.value)
|
||
uni.hideLoading()
|
||
uni.showToast({ title: `已清理 ${formatSize(r.freedBytes)}`, icon: 'success' })
|
||
setTimeout(() => uni.navigateBack(), 600)
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '清理失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
async function onGroupClean(g) {
|
||
// canClean 由 handler 决定,UI 不重复判断
|
||
if (!g.canClean) {
|
||
if (g.disabledReason === 'logged-out') {
|
||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||
} else if (g.disabledReason === 'guide-current' || g.disabledReason === 'guide-global') {
|
||
uni.showToast({ title: '引导记录不可清理', icon: 'none' })
|
||
}
|
||
return
|
||
}
|
||
const isCurrent = g.isCurrent
|
||
const confirmed = await new Promise((resolve) => {
|
||
uni.showModal({
|
||
title: isCurrent ? '清理确认' : '⚠ 强警告',
|
||
content: isCurrent
|
||
? (category.value?.warningText || '将清空你未提交的草稿,是否继续?')
|
||
: (category.value?.strongWarningText || `将清空账号 ${g.displayUid} 的数据,对方下次登录不会看到。是否继续?`),
|
||
confirmText: isCurrent ? '清理' : '仍要清理',
|
||
confirmColor: isCurrent ? '#1890ff' : '#ff4d4f',
|
||
success: (res) => resolve(res.confirm),
|
||
fail: () => resolve(false),
|
||
})
|
||
})
|
||
if (!confirmed) return
|
||
uni.showLoading({ title: '清理中…', mask: true })
|
||
try {
|
||
const uidParam = g.uid === '__global__' ? null : g.uid
|
||
const r = await cleanCategoryGroup(id.value, { uid: uidParam })
|
||
uni.hideLoading()
|
||
uni.showToast({ title: `已清理 ${formatSize(r.freedBytes)}`, icon: 'success' })
|
||
setTimeout(() => uni.navigateBack(), 600)
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '清理失败', icon: 'none' })
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.detail { padding: 32rpx; background: #f5f5f5; min-height: 100vh; }
|
||
.loading { text-align: center; padding: 60rpx 0; color: #999; }
|
||
.simple, .grouped { background: #fff; border-radius: 16rpx; padding: 32rpx; }
|
||
.desc { font-size: 26rpx; color: #666; display: block; margin-bottom: 32rpx; }
|
||
.usage { text-align: center; padding: 40rpx 0; }
|
||
.usage-value { font-size: 60rpx; font-weight: 600; color: #1890ff; display: block; }
|
||
.usage-count { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
|
||
.btn { width: 100%; background: #1890ff; color: #fff; border-radius: 12rpx; font-size: 30rpx; padding: 24rpx 0; margin-top: 32rpx; }
|
||
.btn[disabled] { background: #ccc; }
|
||
.group-card { padding: 24rpx 0; border-bottom: 1rpx solid #f0f0f0; display: flex; flex-direction: column; gap: 12rpx; }
|
||
.group-card:last-child { border-bottom: none; }
|
||
.group-title { font-size: 28rpx; color: #333; font-weight: 500; }
|
||
.group-stats { font-size: 26rpx; color: #999; }
|
||
.btn-group { align-self: flex-end; padding: 12rpx 32rpx; border-radius: 8rpx; background: #1890ff; color: #fff; font-size: 26rpx; }
|
||
.btn-warn { background: #ff4d4f; }
|
||
.btn-group[disabled] { background: #ccc; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: 编译 + 实机验证两个模板**
|
||
|
||
预期:
|
||
- 进入 preload / progress / sandbox-tmp / others → 简单页(单按钮)
|
||
- 进入 draft / guide → 分组页(多卡片 + 各自的清理按钮)
|
||
|
||
- [ ] **Step 4:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/profile/cache-cleanup-detail.vue frontend/pages.json
|
||
git commit -m "feat(ui): add cache-cleanup-detail page (simple + grouped templates)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 23: pages/profile/profile.vue 加「存储空间」菜单项
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/profile/profile.vue`
|
||
|
||
- [ ] **Step 1: 找到 settings/menu list 区域**
|
||
|
||
查找 profile.vue 里的菜单项数组(如 `menuItems` 或类似的循环),加一项:
|
||
|
||
```js
|
||
{ id: 'storage', icon: '💾', label: '存储空间', url: '/pages/profile/cache-cleanup' }
|
||
```
|
||
|
||
- [ ] **Step 2: 实机验证**
|
||
|
||
进 profile 页面,能看到「存储空间」菜单项,点击进入 cache-cleanup 列表页正常显示。
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/profile/profile.vue
|
||
git commit -m "feat(profile): add '存储空间' menu item"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 24: M3 联调(端到端)
|
||
|
||
**Files:**
|
||
- 无(仅验证)
|
||
|
||
- [ ] **Step 1: 端到端流程验证(已登录态)**
|
||
|
||
1. 进 profile → 点「存储空间」→ cache-cleanup 列表页正常
|
||
2. 大字 + 百分比 + 进度条 + 3 chip + 6 分类 + "其他" section 全部显示
|
||
3. 点「预加载数据缓存」→ cache-cleanup-detail 简单页 → 点清理 → ConfirmModal → 确认 → loading → toast → 返回列表页自动刷新
|
||
4. 大字 / 进度条数字更新
|
||
|
||
- [ ] **Step 2: 分组清理验证**
|
||
|
||
1. 账号 A 登录,castlove 创作流写一些草稿(draftStorage 写入)
|
||
2. 退出账号 A,登录账号 B(uid 不同)
|
||
3. 进 cache-cleanup → 点「创作中的草稿」→ 分组页应显示 "我的草稿(uid=B)" + "其他用户的草稿(uid=A)"
|
||
4. 点 "其他用户的草稿" 卡片清理 → 强警告弹窗 → 确认 → toast
|
||
5. 返回列表页,"创作中的草稿" 大小应减少
|
||
|
||
- [ ] **Step 3: 未登录态分组页验证**
|
||
|
||
1. 退出登录
|
||
2. 进 cache-cleanup → 点「创作中的草稿」→ 分组页所有数据归"其他用户",按钮置灰
|
||
|
||
- [ ] **Step 4: 内存清理验证**
|
||
|
||
1. 列表页进 preload 详情页 → 清理 → 立刻返回列表页 → 数字更新(说明 memoryMap 已清)
|
||
2. console 检查:用导出的 `getStats()`(不是 memoryMap 字段,reviewer P0 修正):
|
||
```js
|
||
import('@/utils/preloadApi/core').then(c => console.log(c.getStats()))
|
||
// 期望: { hits: X, misses: Y, memorySize: 0, inFlightSize: 0, ... }
|
||
```
|
||
清理后 `memorySize` 和 `inFlightSize` 都应为 0
|
||
|
||
- [ ] **Step 5:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/pages/profile/profile.vue frontend/pages/profile/cache-cleanup.vue frontend/pages/profile/cache-cleanup-detail.vue frontend/pages.json
|
||
git commit -m "feat(profile): add cache cleanup UI (list + detail pages)"
|
||
# E2E 验证在 PR description 中描述
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 5: M4 — 13 个手工场景回归
|
||
|
||
## Task 25: 完整场景回归清单
|
||
|
||
**Files:**
|
||
- 无(仅验证)
|
||
|
||
- [ ] **Step 1: 13 个手工场景逐项验证**
|
||
|
||
完整场景列表(与 spec §7.3 对应):
|
||
|
||
| # | 场景 | 期望 | 验证 |
|
||
|---|------|------|------|
|
||
| 1 | 登录态清理简单页 | token/user/star_id/cid 不变 | ☐ |
|
||
| 2 | 登录态清理分组页-我的草稿 | 仅删 currentUid 草稿;普通警告 | ☐ |
|
||
| 3 | 登录态清理分组页-其他用户 | 仅删 otherUid 草稿;强警告;对方下次登录看不到 | ☐ |
|
||
| 4 | 未登录态清理简单页 | 全部业务缓存正常清理 | ☐ |
|
||
| 5 | 未登录态清理分组页 | 草稿/引导按钮置灰,不可清理 | ☐ |
|
||
| 6 | 清理中退出页面 | 不重复清理,不报错 | ☐ |
|
||
| 7 | 空缓存状态 | 列表页显示"✓ 当前无缓存可清理",无跳转入口 | ☐ |
|
||
| 8 | 草稿详情页(仅自己) | 仅显示"我的草稿"分组,清理按钮触发普通警告 | ☐ |
|
||
| 9 | 草稿详情页(多账号切换后) | 显示"我的草稿" + 多个"其他用户的草稿"分组;强警告+正常清理流程 | ☐ |
|
||
| 10 | 草稿详情页(未登录) | 所有数据归"其他用户",按钮置灰 | ☐ |
|
||
| 11 | 跨账号草稿隔离(A 写 → B 登录) | B 看不到 A 的草稿(依赖 §11 M0 改造) | ☐ |
|
||
| 12 | 跨账号 preload 清理 | A 的 preload 被 B 清理时一并清掉 | ☐ |
|
||
| 13 | 清理后立刻进列表页 | 不会从 memoryMap 命中老数据 | ☐ |
|
||
|
||
- [ ] **Step 2: CLAUDE.md 自检清单过一遍**
|
||
|
||
- [ ] 新组件用 `<script setup>` 组合式 API
|
||
- [ ] 所有原生 API 包 `#ifdef APP-PLUS`
|
||
- [ ] 接口调用走 `utils/api.js`(本功能不需要后端 API,OK)
|
||
- [ ] 新页面已在 `pages.json` 注册(cache-cleanup + cache-cleanup-detail)
|
||
- [ ] 未改动 `unpackage/dist/`
|
||
|
||
- [ ] **Step 3:(如用户授权)commit**
|
||
|
||
```bash
|
||
git add frontend/utils/ frontend/utils/handlers/ frontend/pages/profile/cache-cleanup.vue frontend/pages/profile/cache-cleanup-detail.vue frontend/pages/profile/profile.vue frontend/pages.json
|
||
git commit -m "feat(cache-cleanup): MVP complete - 13 manual scenarios + CLAUDE.md checklist passed"
|
||
# 验证结果在 PR description 中描述,不创建独立 report 文件
|
||
```
|
||
|
||
---
|
||
|
||
## 完成标准(DoD)
|
||
|
||
- [ ] Phase 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` 直写
|
||
|
||
**完全无侵入**,业务侧代码可一键还原。 |