469 lines
13 KiB
Vue
469 lines
13 KiB
Vue
<template>
|
||
<view class="cache-cleanup-detail">
|
||
<!-- 顶部类目信息 -->
|
||
<view class="header">
|
||
<text class="header-label">{{ category.label }}</text>
|
||
<text v-if="category.description" class="header-desc">{{ category.description }}</text>
|
||
</view>
|
||
|
||
<!-- 加载占位 -->
|
||
<view v-if="loading && !category.label" class="loading">
|
||
<text>加载中...</text>
|
||
</view>
|
||
|
||
<!-- 简单模板(preload / progress / sandbox-tmp / others) -->
|
||
<view v-else-if="breakdown === null" class="simple-body">
|
||
<view class="big-stat">
|
||
<text class="big-value">{{ formatSize(category.sizeBytes) }}</text>
|
||
<text class="big-count">共 {{ category.keyCount }} 项缓存</text>
|
||
</view>
|
||
<view class="action-wrap">
|
||
<button
|
||
class="clean-btn"
|
||
:disabled="category.sizeBytes <= 0"
|
||
@click="confirmSimple"
|
||
>
|
||
清 理 缓 存
|
||
</button>
|
||
<text v-if="category.sizeBytes <= 0" class="action-hint">当前无缓存可清理</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 分组模板(draft / guide) -->
|
||
<view v-else class="grouped-body">
|
||
<view v-if="breakdown.length === 0" class="empty">
|
||
<text>✓ 当前无缓存可清理</text>
|
||
</view>
|
||
<view
|
||
v-for="(g, idx) in breakdown"
|
||
:key="String(g.uid) + '-' + idx"
|
||
class="group-card"
|
||
:class="{ 'group-card-global': g.global }"
|
||
>
|
||
<view class="group-main">
|
||
<view class="group-title">
|
||
<text class="group-icon">{{ g.isCurrent ? '👤' : (g.global ? '🌐' : '👥') }}</text>
|
||
<text class="group-name">{{ g.displayUid }}</text>
|
||
</view>
|
||
<text class="group-size">{{ formatSize(g.sizeBytes) }} · {{ g.keyCount }} 项</text>
|
||
<text v-if="g.warning" class="group-warn">⚠</text>
|
||
</view>
|
||
<button
|
||
class="clean-btn small"
|
||
:class="{ 'strong': !g.isCurrent && !g.global }"
|
||
:disabled="!g.canClean"
|
||
@click="confirmGroup(g)"
|
||
>
|
||
{{ g.canClean ? '清 理' : disabledLabel(g.disabledReason) }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 通用确认弹窗 -->
|
||
<ConfirmModal
|
||
:visible="confirmModal.visible"
|
||
:title="confirmModal.title"
|
||
:content="confirmModal.content"
|
||
:confirmText="confirmModal.confirmText"
|
||
@confirm="onConfirmModal"
|
||
@cancel="onCancelModal"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||
import {
|
||
getCacheInfo,
|
||
getCategoryBreakdown,
|
||
cleanCategory,
|
||
cleanCategoryGroup,
|
||
formatSize,
|
||
} from '@/utils/cacheManager'
|
||
import ConfirmModal from '@/components/ConfirmModal.vue'
|
||
|
||
const id = ref(null)
|
||
// 当前类目元信息(label / description / sizeBytes / keyCount / warning)
|
||
const category = ref({ label: '', description: '', sizeBytes: 0, keyCount: 0, warning: false })
|
||
// null = 简单模板;[] / Array = 分组模板
|
||
const breakdown = ref(null)
|
||
const loading = ref(false)
|
||
|
||
// 确认弹窗状态
|
||
const confirmModal = ref({
|
||
visible: false,
|
||
title: '',
|
||
content: '',
|
||
confirmText: '确认',
|
||
pendingType: null, // 'simple' | 'group'
|
||
pendingGroup: null,
|
||
})
|
||
|
||
onLoad((options) => {
|
||
id.value = options?.id || null
|
||
load()
|
||
})
|
||
|
||
async function load() {
|
||
if (!id.value) {
|
||
uni.showToast({ title: '参数错误', icon: 'none' })
|
||
return
|
||
}
|
||
loading.value = true
|
||
// ★ 2026-07-31(M2)改 Promise.all 为串行 await:plus.io 桥是单线程串行的,
|
||
// 并发拖慢而非加速。详情页依次走 getCacheInfo → getCategoryBreakdown。
|
||
// - getCacheInfo 5min TTL 通常命中(用户从列表页进来刚算过),< 50ms
|
||
// - getCategoryBreakdown 1min TTL(M2 新增),二次进入 < 50ms
|
||
// - 首次进入:handler 4s + breakdown walk 4s = 最坏 8s
|
||
// - 撞超时各自走降级路径(合成分组 / 保留 category)
|
||
// 收紧超时 8s → 4s(M3):配合 HANDLER_TIMEOUT_MS 4s 一致
|
||
let info = null
|
||
let br = null
|
||
try {
|
||
info = await Promise.race([
|
||
getCacheInfo(),
|
||
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
|
||
])
|
||
br = await Promise.race([
|
||
getCategoryBreakdown(id.value),
|
||
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
|
||
])
|
||
const meta = (info.categories || []).find((c) => c.id === id.value)
|
||
if (meta) {
|
||
category.value = {
|
||
label: meta.label,
|
||
description: meta.description || '',
|
||
// 允许 sizeBytes === -1(列表页加载失败)继续进详情:
|
||
// 详情页有自己的 getCategoryBreakdown / cleanGroup,能独立重算与清理
|
||
sizeBytes: meta.sizeBytes,
|
||
keyCount: meta.keyCount,
|
||
warning: !!meta.warning,
|
||
}
|
||
}
|
||
breakdown.value = br // null = simple; array = grouped
|
||
} catch (e) {
|
||
console.warn('[cache-cleanup-detail] load failed:', e.message)
|
||
// 加载失败 fallback:不让用户卡在"什么都看不到 / 什么都点不到"的死锁里
|
||
// - 简单型:保留 category 但标记 sizeBytes=-1
|
||
// - 分组型:提供合成的"全局"组,让用户能直接点清理
|
||
// 清理动作(cleanGroup('__global__'))不依赖本次扫描结果,
|
||
// 它会自己走 handler 的 clean 逻辑去删文件
|
||
if (info && id.value && (info.categories || []).find((c) => c.id === id.value)) {
|
||
// 至少有 info;category 已经被赋值,跳过合成
|
||
} else {
|
||
category.value = { label: '', description: '', sizeBytes: -1, keyCount: 0, warning: false }
|
||
}
|
||
// 分组型:breakdown 是数组时合成一个"全部清理"组;breakdown 是 null(简单型)不处理
|
||
if (breakdown.value === null) {
|
||
// 简单型不需要合成
|
||
} else {
|
||
breakdown.value = [{
|
||
uid: '__global__',
|
||
displayUid: '全部文件(加载失败,可尝试清理)',
|
||
isCurrent: false,
|
||
global: true,
|
||
sizeBytes: -1,
|
||
keyCount: 0,
|
||
canClean: true,
|
||
disabledReason: null,
|
||
}]
|
||
}
|
||
uni.showToast({ title: '加载失败,但可尝试清理', icon: 'none' })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onPullDownRefresh(async () => {
|
||
await load()
|
||
uni.stopPullDownRefresh()
|
||
})
|
||
|
||
function confirmSimple() {
|
||
// 允许 sizeBytes === -1(列表页加载失败)继续确认清理:
|
||
// 用户已从列表页强行进到详情页(goDetail 改了条件),意图明确是来清理的,
|
||
// cleanCategory 内部会重算 freedBytes,结果取决于实际 IO
|
||
if (category.value.sizeBytes === 0) {
|
||
uni.showToast({ title: '该分类暂无缓存', icon: 'none' })
|
||
return
|
||
}
|
||
confirmModal.value = {
|
||
visible: true,
|
||
title: '清理缓存',
|
||
content: category.value.warning
|
||
? '将清空该分类缓存,是否继续?'
|
||
: '确认清理该分类缓存?',
|
||
confirmText: '确认清理',
|
||
pendingType: 'simple',
|
||
pendingGroup: null,
|
||
}
|
||
}
|
||
|
||
function confirmGroup(g) {
|
||
if (!g.canClean) {
|
||
uni.showToast({ title: disabledLabel(g.disabledReason), icon: 'none' })
|
||
return
|
||
}
|
||
let content
|
||
if (g.isCurrent) {
|
||
// 普通警告:自己的数据
|
||
content = '将清空你未提交的数据,是否继续?'
|
||
} else if (g.uid === '__legacy__') {
|
||
// 老版本数据:强警告
|
||
content = '将清空老版本数据,是否继续?'
|
||
} else if (g.global) {
|
||
// ★ task #38:全局组也要弹确认(之前 return 静默丢弃是 bug)
|
||
content = '将清空分享图、头像缓存、canvas 合成图、preload 等跨账号共用数据,是否继续?'
|
||
} else {
|
||
// 其他用户:强警告
|
||
content = `将清空 ${g.displayUid} 的数据,对方下次登录不会看到。是否继续?`
|
||
}
|
||
confirmModal.value = {
|
||
visible: true,
|
||
title: '清理缓存',
|
||
content,
|
||
confirmText: '确认清理',
|
||
pendingType: 'group',
|
||
pendingGroup: g,
|
||
}
|
||
}
|
||
|
||
function onConfirmModal() {
|
||
const type = confirmModal.value.pendingType
|
||
const grp = confirmModal.value.pendingGroup
|
||
confirmModal.value.visible = false
|
||
if (type === 'simple') {
|
||
doCleanSimple()
|
||
} else if (type === 'group') {
|
||
doCleanGroup(grp)
|
||
}
|
||
}
|
||
|
||
function onCancelModal() {
|
||
confirmModal.value.visible = false
|
||
}
|
||
|
||
async function doCleanSimple() {
|
||
if (!id.value) return
|
||
uni.showLoading({ title: '清理中...' })
|
||
try {
|
||
const r = await cleanCategory(id.value)
|
||
uni.hideLoading()
|
||
const freed = r?.freedBytes || 0
|
||
const cnt = r?.keyCount || 0
|
||
uni.showToast({ title: `已清理 ${formatSize(freed)} (${cnt} 项)`, icon: 'none' })
|
||
setTimeout(() => uni.navigateBack(), 600)
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.warn('[cache-cleanup-detail] clean failed:', e.message)
|
||
uni.showToast({ title: '清理失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
async function doCleanGroup(g) {
|
||
if (!id.value || !g) {
|
||
console.warn('[cache-cleanup-detail] doCleanGroup skipped: id=' + id.value + ' g=' + !!g)
|
||
return
|
||
}
|
||
// 映射 breakdown.uid → cleanCategoryGroup opts.uid
|
||
// self → 'self'
|
||
// __legacy__ → '__legacy__'
|
||
// 其他 → 直接传具体 uid
|
||
let uidParam
|
||
if (g.isCurrent) {
|
||
uidParam = 'self'
|
||
} else {
|
||
uidParam = g.uid
|
||
}
|
||
console.log(`[cache-cleanup-detail] doCleanGroup START: id=${id.value} uid=${uidParam} g.uid=${g.uid} g.global=${g.global}`)
|
||
uni.showLoading({ title: '清理中...' })
|
||
try {
|
||
const r = await cleanCategoryGroup(id.value, { uid: uidParam })
|
||
uni.hideLoading()
|
||
const freed = r?.freedBytes || 0
|
||
const cnt = r?.keyCount || 0
|
||
console.log(`[cache-cleanup-detail] doCleanGroup DONE: freed=${freed} cnt=${cnt}`)
|
||
uni.showToast({ title: `已清理 ${formatSize(freed)} (${cnt} 项)`, icon: 'none' })
|
||
setTimeout(() => uni.navigateBack(), 600)
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.warn('[cache-cleanup-detail] cleanGroup failed:', e.message)
|
||
uni.showToast({ title: '清理失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
function disabledLabel(reason) {
|
||
switch (reason) {
|
||
case 'logged-out': return '请先登录'
|
||
case 'empty': return '暂无数据'
|
||
case 'guide-current': return '保留中'
|
||
case 'guide-global': return '全局保留'
|
||
default: return '不可清理'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cache-cleanup-detail {
|
||
min-height: 100vh;
|
||
background: #f8f8f8;
|
||
padding-bottom: 80rpx;
|
||
}
|
||
|
||
/* 顶部类目信息 */
|
||
.header {
|
||
background: #fff;
|
||
padding: 32rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
.header-label {
|
||
font-size: 36rpx;
|
||
font-weight: 600;
|
||
color: #333;
|
||
display: block;
|
||
}
|
||
.header-desc {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
margin-top: 8rpx;
|
||
display: block;
|
||
}
|
||
|
||
/* 加载占位 */
|
||
.loading {
|
||
text-align: center;
|
||
padding: 80rpx 0;
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
/* 简单模板 */
|
||
.simple-body {
|
||
background: #fff;
|
||
margin-top: 20rpx;
|
||
padding: 48rpx 32rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
.big-stat {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-bottom: 48rpx;
|
||
}
|
||
.big-value {
|
||
font-size: 72rpx;
|
||
font-weight: 600;
|
||
color: #1890ff;
|
||
line-height: 1.2;
|
||
}
|
||
.big-count {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
margin-top: 12rpx;
|
||
}
|
||
.action-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
width: 100%;
|
||
}
|
||
.clean-btn {
|
||
width: 480rpx;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
background: #1890ff;
|
||
color: #fff;
|
||
font-size: 30rpx;
|
||
border-radius: 44rpx;
|
||
border: 0;
|
||
}
|
||
.clean-btn::after { border: 0; }
|
||
.clean-btn[disabled] {
|
||
background: #d9d9d9;
|
||
color: #fff;
|
||
}
|
||
.action-hint {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
/* 分组模板 */
|
||
.grouped-body {
|
||
background: #fff;
|
||
margin-top: 20rpx;
|
||
padding: 16rpx 24rpx;
|
||
}
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60rpx 0;
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
.group-card {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 28rpx 16rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
background: #fff;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.group-card:last-child {
|
||
border-bottom: 0;
|
||
margin-bottom: 0;
|
||
}
|
||
.group-card-global {
|
||
background: #fafafa;
|
||
}
|
||
.group-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.group-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
.group-icon {
|
||
font-size: 28rpx;
|
||
}
|
||
.group-name {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
.group-size {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
}
|
||
.group-warn {
|
||
font-size: 22rpx;
|
||
color: #faad14;
|
||
}
|
||
|
||
.clean-btn.small {
|
||
width: 160rpx;
|
||
height: 64rpx;
|
||
line-height: 64rpx;
|
||
font-size: 26rpx;
|
||
background: #1890ff;
|
||
color: #fff;
|
||
border-radius: 32rpx;
|
||
margin-left: 16rpx;
|
||
}
|
||
.clean-btn.small.strong {
|
||
background: #ff7a45;
|
||
}
|
||
.clean-btn.small[disabled] {
|
||
background: #d9d9d9;
|
||
color: #fff;
|
||
}
|
||
</style> |