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

557 lines
16 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">
<!-- Hero 卡片核心占用展示 -->
<view class="hero-card">
<!-- iOS 受沙盒限制无法读 .app bundle 大小提示用户到系统设置查看真实值 -->
<!-- #ifdef APP-PLUS -->
<view v-if="isIos" class="hero-tip"
>准确值请查看 iPhone「设置-通用-iPhone 储存空间」</view
>
<!-- #endif -->
<text class="hero-label">Topfans 已用空间</text>
<!-- hero-value 焦点位loading 时显示 spinner加载完显示真实数字 -->
<view v-if="loading" class="value-spinner spinner-hero"></view>
<text v-else class="hero-value">{{ formatSize(info.appUsedBytes) }}</text>
<!-- 嵌套进度条Topfans 已用空间(蓝)→ 其他 app 使用空间(橙)→ 剩余可用空间(绿) -->
<view class="progress-bar">
<!-- 绿段:剩余可用空间(贴在右侧) -->
<view
class="seg-available"
:style="{ width: availablePct + '%' }"
></view>
<!-- 蓝段Topfans 已用空间(从左) -->
<view
class="seg-topfans"
:style="{ width: topfansPct + '%' }"
></view>
<!-- 橙段:其他 app 使用空间(从蓝段"视觉右边界"开始:用视觉宽度而非真实百分比,
避免 0.04%2% 区间被橙段覆盖导致蓝段消失) -->
<view
class="seg-other"
:style="{ left: topfansVisualPct + '%', width: otherPct + '%' }"
></view>
</view>
<view class="chips">
<view class="chip">
<view class="chip-dot dot-topfans"></view>
<text>Topfans 已用空间</text>
</view>
<view class="chip">
<view class="chip-dot dot-other"></view>
<text>其他 app 使用空间</text>
</view>
<view class="chip">
<view class="chip-dot dot-available"></view>
<text>剩余可用空间</text>
</view>
</view>
<!-- 设备级信息:总空间 / 可用空间(来自 plus.io.getStorageInfo -->
<view class="device-info">
<view class="device-info-item">
<text class="device-info-label">设备总空间</text>
<view v-if="loading" class="value-spinner spinner-md"></view>
<text v-else class="device-info-value">{{ formatSize(info.deviceTotalBytes) }}</text>
</view>
<view class="device-info-divider"></view>
<view class="device-info-item">
<text class="device-info-label">设备可用</text>
<view v-if="loading" class="value-spinner spinner-md"></view>
<text v-else class="device-info-value">{{ formatSize(info.deviceFreeBytes) }}</text>
</view>
</view>
<text class="hero-percent"
>占设备 {{ info.deviceUsagePercent.toFixed(2) }}% 存储空间</text
>
</view>
<!-- 缓存分类卡片 -->
<view class="section-card">
<text class="section-title">缓存分类</text>
<view
v-for="(cat, index) in info.categories"
:key="cat.id"
class="row"
:class="{ 'row-last': index === info.categories.length - 1 }"
@click="goDetail(cat.id)"
>
<view class="row-main">
<text class="row-label">{{ cat.label }}</text>
<text v-if="cat.warning" class="row-warn">⚠ 按账号分组清理</text>
</view>
<view class="row-sub">
<text v-if="cat.sizeBytes === -1" class="error">? · 加载失败</text>
<view v-else-if="loading" class="value-spinner spinner-sm"></view>
<text v-else class="size"
>{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} 项</text
>
<text class="chevron"></text>
</view>
</view>
</view>
<!-- "其他" 卡片 -->
<view class="section-card others-section">
<text class="section-title">其他</text>
<view class="row others-row row-last">
<view class="row-main">
<text class="row-label">其他</text>
<text class="row-desc"
>包含运行 Topfans 的必要数据、账号会话、其他账号的数据等</text
>
</view>
<view v-if="loading" class="value-spinner spinner-sm"></view>
<text v-else 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>
</view>
</template>
<script setup>
import { ref, computed } from "vue";
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
import { getCacheInfo, formatSize } from "@/utils/cacheManager";
// info 初值load() 开头会重置到这里,避免从详情页返回时显示旧数据
const INITIAL_INFO = Object.freeze({
appUsedBytes: 0,
quotaTotalBytes: 0,
quotaAvailableBytes: 0,
usagePercent: 0,
deviceTotalBytes: 0,
deviceFreeBytes: 0,
deviceUsagePercent: 0,
othersBytes: 0,
totalBytes: 0,
categories: [],
});
const info = ref({ ...INITIAL_INFO });
const loadFailed = ref(false);
// hero-value 焦点位 loading 状态getCacheInfo 含 4 沙盒根遍历 + Native.js最坏 ~2.1s
const loading = ref(false);
// ── 进度条三段宽度(基于 deviceTotalBytes = 100%,设备级分布)──
// 三段相加 ≤ 100%,不出现负数;任一字段缺失时安全降级为 0
// 语义:蓝 = Topfans 已用;橙 = 系统+其他 app+已占用的非空闲空间;绿 = 设备剩余可用
//
// Topfans 通常只占设备总空间的 < 0.1%128GB 设备 50MB ≈ 0.04%CSS 强制 min-width: 2%
// 保持视觉存在感。橙色段从蓝段"视觉右边界"= max(topfansPct, 2%))开始,否则 0.04%2%
// 区间会被 orange 覆盖div 绝对定位按 DOM 顺序绘制,无 z-index出现蓝段"消失"现象。
const MIN_TOPFANS_PCT = 2
const topfansPct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
return Math.min(100, Math.max(0, (info.value.appUsedBytes / total) * 100));
});
const topfansVisualPct = computed(() => Math.max(topfansPct.value, MIN_TOPFANS_PCT))
const otherPct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
// 其他 app + 系统占用 = deviceTotal - Topfans已用 - 设备剩余
// 防御:若 appUsedBytes + deviceFreeBytes 超过 deviceTotalBytes极端情况钳到非负
const otherBytes = Math.max(
0,
info.value.deviceTotalBytes - info.value.appUsedBytes - info.value.deviceFreeBytes
);
// 上限用 max(topfansPct, MIN_TOPFANS_PCT)而不是 topfansPct与蓝段视觉占位对齐
// 避免蓝段min-width 强制 2%与橙段起点topfansPct%)之间出现"绿段提前出现"的空隙
return Math.min(100 - topfansVisualPct.value, Math.max(0, (otherBytes / total) * 100));
});
const availablePct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
return Math.max(0, 100 - topfansVisualPct.value - otherPct.value);
});
// ── 平台判断(仅 APP-PLUS 编译时计算)──
// #ifdef APP-PLUS
const isIos = (() => {
try {
return uni.getSystemInfoSync().platform === "ios";
} catch (e) {
return false;
}
})();
// #endif
async function load(force = false) {
loadFailed.value = false;
// ★ task #40无缓存每次都重算
// 缓存命中且不强制刷新 → 同步用缓存,无 spinner、无 await
// 避免每次进页面都看到 loading 动画(即便缓存有效也要走 500ms spinner 体验差)
// peekCache 已删除,强制每次重算)
// 缓存未命中或过期或强制刷新 → 走 spinner 流程
info.value = { ...INITIAL_INFO };
loading.value = true;
const t0 = Date.now();
try {
const result = await Promise.race([
getCacheInfo(force),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 8000),
),
]);
info.value = result;
} catch (e) {
console.warn("[cache-cleanup] load failed:", e.message);
loadFailed.value = true;
} finally {
// 无论成功/失败都关掉 loading失败时 loadFailed=true 会显示重试提示)
// 保证 spinner 至少显示 500ms避免加载太快看不到转圈
const elapsed = Date.now() - t0;
const remaining = 500 - elapsed;
if (remaining > 0) {
setTimeout(() => { loading.value = false }, remaining);
} else {
loading.value = false;
}
}
}
function goDetail(id) {
const cat = info.value.categories.find((c) => c.id === id);
if (!cat) return;
// 允许 sizeBytes === -1加载失败时仍可进入详情页
// 详情页有自己的 computeBreakdown / cleanGroup 逻辑,会重新扫描并展示真实数据。
// 不让用户卡在"加载失败→进不去→无法清理"的死循环里。
if (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 () => {
// 下拉刷新:强制绕过 5min 缓存,重新走沙盒遍历
await load(true);
uni.stopPullDownRefresh();
});
</script>
<style scoped>
/* ── 容器 ── */
.cache-cleanup {
padding: 24rpx 24rpx 40rpx;
background: #f5f7fa;
min-height: 100vh;
box-sizing: border-box;
/* 关闭 WebView 层回弹iOS 顶部/底部橡皮筋 + Android 边缘光晕) */
overscroll-behavior: none;
}
/* ── Hero 卡片 ── */
.hero-card {
background: #fff;
border-radius: 16rpx;
padding: 40rpx 32rpx 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.hero-label {
font-size: 28rpx;
color: #8c8c8c;
display: block;
}
.hero-value {
font-size: 88rpx;
font-weight: 700;
color: #1890ff;
letter-spacing: -1rpx;
display: block;
margin: 8rpx 0 24rpx;
line-height: 1.1;
}
.hero-percent {
font-size: 24rpx;
color: #8c8c8c;
display: block;
margin-top: 24rpx;
}
/* ── 设备级信息行(总空间 / 可用空间)── */
.device-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding: 16rpx 20rpx;
background: #fafbfc;
border-radius: 12rpx;
box-sizing: border-box;
}
.device-info-item {
display: flex;
flex-direction: column;
gap: 4rpx;
flex: 1;
min-width: 0;
}
.device-info-divider {
width: 1rpx;
height: 32rpx;
background: #e8e8e8;
margin: 0 16rpx;
flex-shrink: 0;
}
.device-info-label {
font-size: 22rpx;
color: #8c8c8c;
}
.device-info-value {
font-size: 28rpx;
font-weight: 500;
color: #262626;
}
.hero-tip {
display: flex;
align-items: center;
font-size: 24rpx;
color: #faad14;
background: #fffbe6;
border-left: 4rpx solid #faad14;
border-radius: 8rpx;
padding: 16rpx 20rpx;
margin-bottom: 24rpx;
line-height: 1.5;
}
/* ── 进度条 ── */
.progress-bar {
position: relative;
height: 16rpx;
background: #f5f7fa;
border-radius: 8rpx;
overflow: hidden;
}
/* 三段都用 absolute 定位(蓝、橙从左贴齐→依次拼接;绿从右贴齐) */
.seg-topfans,
.seg-other,
.seg-available {
position: absolute;
top: 0;
height: 100%;
min-width: 0;
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.2);
}
.seg-topfans {
left: 0;
background: #1890ff;
/* Topfans 通常 < 0.1% 设备总空间,强制最小可见宽度 2%(保持视觉存在感) */
min-width: 2%;
}
.seg-other {
/* 防御left 默认 0inline style 会用 topfansPct% 覆盖 */
left: 0;
background: #fa8c16;
}
.seg-available {
right: 0;
background: #52c41a;
}
/* ── 图例 chips ── */
.chips {
display: flex;
gap: 12rpx;
margin-top: 20rpx;
}
.chip {
/* 不再 flex: 1按内容自适应宽度white-space 防中文被挤压换行 */
padding: 12rpx 16rpx;
background: #fafbfc;
border: 1rpx solid #f0f0f0;
border-radius: 12rpx;
font-size: 18rpx;
color: #595959;
display: inline-flex;
align-items: center;
gap: 8rpx;
box-sizing: border-box;
white-space: nowrap;
flex-shrink: 0;
}
.chip-dot {
/* 加大到 12rpx原 8rpx 像素密度不同设备上太易丢失);
display: inline-block 防御 uniapp 空 <view> 不渲染的极端 case */
display: inline-block;
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
vertical-align: middle;
}
.dot-topfans {
background: #1890ff;
}
.dot-other {
background: #fa8c16;
}
.dot-available {
background: #52c41a;
}
/* ── 分组卡片(缓存分类、其他) ── */
.section-card {
background: #fff;
border-radius: 16rpx;
padding: 0 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
}
.section-title {
font-size: 24rpx;
color: #8c8c8c;
font-weight: 500;
padding: 24rpx 0 16rpx;
display: block;
}
/* ── Row ── */
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx 0;
border-bottom: 1rpx solid #f5f5f5;
transition: background-color 0.15s ease;
}
.row:active {
background: #fafafa;
}
.row-last {
border-bottom: none;
}
.row-main {
display: flex;
flex-direction: column;
gap: 6rpx;
flex: 1;
min-width: 0;
padding-right: 24rpx;
}
.row-label {
font-size: 30rpx;
font-weight: 500;
color: #262626;
}
.row-warn {
font-size: 22rpx;
color: #faad14;
}
.row-sub {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.row-sub .size {
font-size: 26rpx;
color: #8c8c8c;
}
.row-sub .chevron {
font-size: 28rpx;
color: #bfbfbf;
line-height: 1;
}
.row-desc {
font-size: 22rpx;
color: #8c8c8c;
margin-top: 6rpx;
line-height: 1.5;
}
.others-row {
align-items: flex-start;
}
.others-row .size {
padding-top: 4rpx;
flex-shrink: 0;
white-space: nowrap;
color: #8c8c8c;
font-size: 26rpx;
}
.error {
color: #ff4d4f;
font-size: 26rpx;
}
/* ── 加载 spinner5 处数字位置共用loading 时转圈loaded 后切真实数字) ── */
.value-spinner {
display: inline-block;
border-style: solid;
border-color: #e6e6e6;
border-top-color: #1890ff;
border-radius: 50%;
vertical-align: middle;
animation: value-spin 0.8s linear infinite;
-webkit-animation: value-spin 0.8s linear infinite;
}
.spinner-hero {
/* hero-value占位高度 ≈ 88rpx 字号56 + margin 8+24避免布局抖动 */
width: 56rpx;
height: 56rpx;
border-width: 6rpx;
margin: 8rpx 0 24rpx;
}
.spinner-md {
/* device-info-value内联在 28rpx 文字旁 */
width: 24rpx;
height: 24rpx;
border-width: 3rpx;
}
.spinner-sm {
/* row .size / others内联在 26rpx 文字旁 */
width: 20rpx;
height: 20rpx;
border-width: 2rpx;
}
@keyframes value-spin {
to { transform: rotate(360deg); }
}
@-webkit-keyframes value-spin {
to { -webkit-transform: rotate(360deg); }
}
/* ── 加载失败按钮 ── */
.load-failed {
margin: 0 24rpx;
text-align: center;
padding: 24rpx 0;
background: #e6f7ff;
color: #1890ff;
font-size: 28rpx;
font-weight: 500;
border-radius: 16rpx;
cursor: pointer;
}
</style>
<style>
/* 非 scopedscoped 会给选择器加 [data-v-xxx] 属性,
但 ::-webkit-scrollbar 是伪元素没有 DOM 属性scoped 匹配不上。
这里隐藏整页滚动条(不影响下拉刷新) */
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
appearance: none;
}
</style>