topfans/frontend/pages/profile/cache-cleanup-detail.vue

469 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<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-31M2改 Promise.all 为串行 awaitplus.io 桥是单线程串行的,
// 并发拖慢而非加速。详情页依次走 getCacheInfo → getCategoryBreakdown。
// - getCacheInfo 5min TTL 通常命中(用户从列表页进来刚算过),< 50ms
// - getCategoryBreakdown 1min TTLM2 新增),二次进入 < 50ms
// - 首次进入handler 4s + breakdown walk 4s = 最坏 8s
// - 撞超时各自走降级路径(合成分组 / 保留 category
// 收紧超时 8s → 4sM3配合 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)) {
// 至少有 infocategory 已经被赋值,跳过合成
} 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>