feat:添加内存缓存数据
This commit is contained in:
parent
853b9f30d8
commit
407ca10dce
@ -10,7 +10,9 @@
|
||||
<!-- #endif -->
|
||||
|
||||
<text class="hero-label">Topfans 已用空间</text>
|
||||
<text class="hero-value">{{ formatSize(info.appUsedBytes) }}</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">
|
||||
@ -50,12 +52,14 @@
|
||||
<view class="device-info">
|
||||
<view class="device-info-item">
|
||||
<text class="device-info-label">设备总空间</text>
|
||||
<text class="device-info-value">{{ formatSize(info.deviceTotalBytes) }}</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>
|
||||
<text class="device-info-value">{{ formatSize(info.deviceFreeBytes) }}</text>
|
||||
<view v-if="loading" class="value-spinner spinner-md"></view>
|
||||
<text v-else class="device-info-value">{{ formatSize(info.deviceFreeBytes) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -67,18 +71,6 @@
|
||||
<!-- 缓存分类卡片 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">缓存分类</text>
|
||||
<!-- 全局空状态:失败时不显示(避免冲突显示) -->
|
||||
<view
|
||||
v-if="
|
||||
!loadFailed &&
|
||||
info.totalBytes === 0 &&
|
||||
info.categories.every((c) => c.sizeBytes <= 0)
|
||||
"
|
||||
class="empty"
|
||||
>
|
||||
<view class="empty-icon">✓</view>
|
||||
<text class="empty-text">当前无缓存可清理</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(cat, index) in info.categories"
|
||||
:key="cat.id"
|
||||
@ -92,6 +84,7 @@
|
||||
</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
|
||||
>
|
||||
@ -110,7 +103,8 @@
|
||||
>包含运行 Topfans 的必要数据、账号会话、其他账号的数据等</text
|
||||
>
|
||||
</view>
|
||||
<text class="size">{{ formatSize(info.othersBytes) }}</text>
|
||||
<view v-if="loading" class="value-spinner spinner-sm"></view>
|
||||
<text v-else class="size">{{ formatSize(info.othersBytes) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -128,9 +122,10 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
|
||||
import { getCacheInfo, formatSize } from "@/utils/cacheManager";
|
||||
import { getCacheInfo, formatSize, peekCache } from "@/utils/cacheManager";
|
||||
|
||||
const info = ref({
|
||||
// info 初值:load() 开头会重置到这里,避免从详情页返回时显示旧数据
|
||||
const INITIAL_INFO = Object.freeze({
|
||||
appUsedBytes: 0,
|
||||
quotaTotalBytes: 0,
|
||||
quotaAvailableBytes: 0,
|
||||
@ -142,7 +137,10 @@ const info = ref({
|
||||
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
|
||||
@ -180,11 +178,26 @@ const isIos = (() => {
|
||||
})();
|
||||
// #endif
|
||||
|
||||
async function load() {
|
||||
async function load(force = false) {
|
||||
loadFailed.value = false;
|
||||
|
||||
// 缓存命中且不强制刷新 → 同步用缓存,无 spinner、无 await
|
||||
// 避免每次进页面都看到 loading 动画(即便缓存有效也要走 500ms spinner 体验差)
|
||||
if (!force) {
|
||||
const cached = peekCache();
|
||||
if (cached) {
|
||||
info.value = cached;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中或过期或强制刷新 → 走 spinner 流程
|
||||
info.value = { ...INITIAL_INFO };
|
||||
loading.value = true;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
getCacheInfo(),
|
||||
getCacheInfo(force),
|
||||
new Promise((_, rej) =>
|
||||
// getCacheInfo 正常 < 3s(4 沙盒根 + Native.js + handlers 并行)
|
||||
// 单根 2s 超时兜底后最长 ~2.1s,5s 留 2x 缓冲
|
||||
@ -195,6 +208,16 @@ async function load() {
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,7 +234,8 @@ function goDetail(id) {
|
||||
// 不再使用 onMounted 避免重复触发
|
||||
onShow(load);
|
||||
onPullDownRefresh(async () => {
|
||||
await load();
|
||||
// 下拉刷新:强制绕过 5min 缓存,重新走沙盒遍历
|
||||
await load(true);
|
||||
uni.stopPullDownRefresh();
|
||||
});
|
||||
</script>
|
||||
@ -462,29 +486,41 @@ onPullDownRefresh(async () => {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
/* ── 空状态 ── */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 0;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.empty-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
/* ── 加载 spinner(5 处数字位置共用:loading 时转圈,loaded 后切真实数字) ── */
|
||||
.value-spinner {
|
||||
display: inline-block;
|
||||
border-style: solid;
|
||||
border-color: #e6e6e6;
|
||||
border-top-color: #1890ff;
|
||||
border-radius: 50%;
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
font-size: 48rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
animation: value-spin 0.8s linear infinite;
|
||||
-webkit-animation: value-spin 0.8s linear infinite;
|
||||
}
|
||||
.empty-text {
|
||||
color: #8c8c8c;
|
||||
font-size: 28rpx;
|
||||
.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); }
|
||||
}
|
||||
|
||||
/* ── 加载失败按钮 ── */
|
||||
|
||||
@ -50,6 +50,72 @@ export function formatSize(bytes) {
|
||||
const handlers = new Map() // handlerId → handler
|
||||
const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise
|
||||
|
||||
// ── 内存缓存:避免每次进 cache-cleanup 页都走 4 沙盒根遍历(最坏 ~2s) ──
|
||||
// 5min TTL:
|
||||
// - 默认命中返回缓存(瞬时显示)
|
||||
// - 清理动作完成后调 deductFromCache() 局部扣减(不清空),下次返回列表仍命中
|
||||
// - invalidateCache() 仅用于特殊场景(如调试 / 用户主动重置)
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
let _cachedInfo = null
|
||||
let _cachedAt = 0
|
||||
|
||||
/** 清理缓存:清理动作完成后必须调用,下次 getCacheInfo 必重算 */
|
||||
export function invalidateCache() {
|
||||
_cachedInfo = null
|
||||
_cachedAt = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理后局部扣减缓存(不重算):
|
||||
* - 对应 category 的 sizeBytes / keyCount 扣减
|
||||
* - totalBytes / appUsedBytes 同步扣减
|
||||
* - usagePercent / deviceUsagePercent 重新计算
|
||||
* 不动 othersBytes(黑名单 + sandboxBytes 不直接减少;5min 内下次进页面会被 force=下拉刷新校正)
|
||||
*
|
||||
* 关键:必须创建新对象赋给 _cachedInfo,而不是 in-place 修改属性
|
||||
* 否则 cache-cleanup.vue 里 `info.value = cached` 检测到引用未变(Object.is 相等),
|
||||
* 不会触发 Vue 响应式更新 → 对应 category 的 sizeBytes 显示仍是旧值
|
||||
*/
|
||||
function deductFromCache(id, freedBytes, freedKeyCount) {
|
||||
if (!_cachedInfo || !Array.isArray(_cachedInfo.categories)) return
|
||||
|
||||
const newCategories = _cachedInfo.categories.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
sizeBytes: Math.max(0, (c.sizeBytes || 0) - freedBytes),
|
||||
keyCount: Math.max(0, (c.keyCount || 0) - (freedKeyCount || 0)),
|
||||
}
|
||||
: c
|
||||
)
|
||||
const newAppUsedBytes = Math.max(0, (_cachedInfo.appUsedBytes || 0) - freedBytes)
|
||||
|
||||
_cachedInfo = {
|
||||
..._cachedInfo,
|
||||
categories: newCategories,
|
||||
totalBytes: Math.max(0, (_cachedInfo.totalBytes || 0) - freedBytes),
|
||||
appUsedBytes: newAppUsedBytes,
|
||||
usagePercent: _cachedInfo.quotaTotalBytes > 0
|
||||
? (newAppUsedBytes / _cachedInfo.quotaTotalBytes) * 100
|
||||
: 0,
|
||||
deviceUsagePercent: _cachedInfo.deviceTotalBytes > 0
|
||||
? (newAppUsedBytes / _cachedInfo.deviceTotalBytes) * 100
|
||||
: 0,
|
||||
}
|
||||
_cachedAt = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步检查缓存:命中且未过期 → 直接返回缓存对象(不走 spinner)
|
||||
* 否则返回 null(需要走 getCacheInfo 异步计算)
|
||||
*/
|
||||
export function peekCache() {
|
||||
if (_cachedInfo && Date.now() - _cachedAt < CACHE_TTL_MS) {
|
||||
return _cachedInfo
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── 注册 API ──
|
||||
export function registerCategory(handler) {
|
||||
if (!handler?.id) throw new Error('[cacheManager] handler.id is required')
|
||||
@ -66,8 +132,15 @@ function getHandler(id) {
|
||||
|
||||
/**
|
||||
* 列表页读取(汇总 + 存储配额 + 其他 section 数据)
|
||||
* @param {boolean} force 强制重算(跳过 30s 缓存);下拉刷新/清理后用
|
||||
*/
|
||||
export async function getCacheInfo() {
|
||||
export async function getCacheInfo(force = false) {
|
||||
// 缓存命中:30s 内且不强制刷新 → 同步返回,避免重复走沙盒遍历
|
||||
const now = Date.now()
|
||||
if (!force && _cachedInfo && now - _cachedAt < CACHE_TTL_MS) {
|
||||
return _cachedInfo
|
||||
}
|
||||
// 缓存未命中或过期或强制刷新 → 重新计算
|
||||
// [并行 1] 所有 handler 的 computeSize
|
||||
const categories = await Promise.all(
|
||||
Array.from(handlers.values()).map(async (h) => {
|
||||
@ -127,12 +200,16 @@ export async function getCacheInfo() {
|
||||
? (appUsedBytes / deviceTotalBytes) * 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
const result = {
|
||||
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent,
|
||||
deviceTotalBytes, deviceFreeBytes, deviceUsagePercent,
|
||||
othersBytes: blacklistBytes + sandboxBytes,
|
||||
categories,
|
||||
}
|
||||
// 写入缓存(即便部分字段失败/降级也缓存,避免反复重算;清理动作会主动 invalidate)
|
||||
_cachedInfo = result
|
||||
_cachedAt = Date.now()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,6 +241,9 @@ export function cleanCategory(id) {
|
||||
if (id === 'preload') {
|
||||
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
|
||||
}
|
||||
// 局部扣减缓存(不清空):返回列表页时 cache-cleanup 还能命中缓存,瞬时显示新数据
|
||||
const { freedBytes = 0, keyCount = 0 } = result || {}
|
||||
deductFromCache(id, freedBytes, keyCount)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn(`[cacheManager] clean failed: ${id}`, e.message)
|
||||
@ -190,6 +270,9 @@ export function cleanCategoryGroup(id, opts = {}) {
|
||||
if (id === 'preload') {
|
||||
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
|
||||
}
|
||||
// 局部扣减缓存(不清空)
|
||||
const { freedBytes = 0, keyCount = 0 } = result || {}
|
||||
deductFromCache(id, freedBytes, keyCount)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user