feat:添加内存缓存数据
This commit is contained in:
parent
853b9f30d8
commit
407ca10dce
@ -10,7 +10,9 @@
|
|||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
|
||||||
<text class="hero-label">Topfans 已用空间</text>
|
<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 使用空间(橙)→ 剩余可用空间(绿) -->
|
<!-- 嵌套进度条:Topfans 已用空间(蓝)→ 其他 app 使用空间(橙)→ 剩余可用空间(绿) -->
|
||||||
<view class="progress-bar">
|
<view class="progress-bar">
|
||||||
@ -50,12 +52,14 @@
|
|||||||
<view class="device-info">
|
<view class="device-info">
|
||||||
<view class="device-info-item">
|
<view class="device-info-item">
|
||||||
<text class="device-info-label">设备总空间</text>
|
<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>
|
||||||
<view class="device-info-divider"></view>
|
<view class="device-info-divider"></view>
|
||||||
<view class="device-info-item">
|
<view class="device-info-item">
|
||||||
<text class="device-info-label">设备可用</text>
|
<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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -67,18 +71,6 @@
|
|||||||
<!-- 缓存分类卡片 -->
|
<!-- 缓存分类卡片 -->
|
||||||
<view class="section-card">
|
<view class="section-card">
|
||||||
<text class="section-title">缓存分类</text>
|
<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
|
<view
|
||||||
v-for="(cat, index) in info.categories"
|
v-for="(cat, index) in info.categories"
|
||||||
:key="cat.id"
|
:key="cat.id"
|
||||||
@ -92,6 +84,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="row-sub">
|
<view class="row-sub">
|
||||||
<text v-if="cat.sizeBytes === -1" class="error">? · 加载失败</text>
|
<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"
|
<text v-else class="size"
|
||||||
>{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} 项</text
|
>{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} 项</text
|
||||||
>
|
>
|
||||||
@ -110,7 +103,8 @@
|
|||||||
>包含运行 Topfans 的必要数据、账号会话、其他账号的数据等</text
|
>包含运行 Topfans 的必要数据、账号会话、其他账号的数据等</text
|
||||||
>
|
>
|
||||||
</view>
|
</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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -128,9 +122,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
|
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,
|
appUsedBytes: 0,
|
||||||
quotaTotalBytes: 0,
|
quotaTotalBytes: 0,
|
||||||
quotaAvailableBytes: 0,
|
quotaAvailableBytes: 0,
|
||||||
@ -142,7 +137,10 @@ const info = ref({
|
|||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
categories: [],
|
categories: [],
|
||||||
});
|
});
|
||||||
|
const info = ref({ ...INITIAL_INFO });
|
||||||
const loadFailed = ref(false);
|
const loadFailed = ref(false);
|
||||||
|
// hero-value 焦点位 loading 状态:getCacheInfo 含 4 沙盒根遍历 + Native.js,最坏 ~2.1s
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
// ── 进度条三段宽度(基于 deviceTotalBytes = 100%,设备级分布)──
|
// ── 进度条三段宽度(基于 deviceTotalBytes = 100%,设备级分布)──
|
||||||
// 三段相加 ≤ 100%,不出现负数;任一字段缺失时安全降级为 0
|
// 三段相加 ≤ 100%,不出现负数;任一字段缺失时安全降级为 0
|
||||||
@ -180,11 +178,26 @@ const isIos = (() => {
|
|||||||
})();
|
})();
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
async function load() {
|
async function load(force = false) {
|
||||||
loadFailed.value = 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 {
|
try {
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
getCacheInfo(),
|
getCacheInfo(force),
|
||||||
new Promise((_, rej) =>
|
new Promise((_, rej) =>
|
||||||
// getCacheInfo 正常 < 3s(4 沙盒根 + Native.js + handlers 并行)
|
// getCacheInfo 正常 < 3s(4 沙盒根 + Native.js + handlers 并行)
|
||||||
// 单根 2s 超时兜底后最长 ~2.1s,5s 留 2x 缓冲
|
// 单根 2s 超时兜底后最长 ~2.1s,5s 留 2x 缓冲
|
||||||
@ -195,6 +208,16 @@ async function load() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[cache-cleanup] load failed:", e.message);
|
console.warn("[cache-cleanup] load failed:", e.message);
|
||||||
loadFailed.value = true;
|
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 避免重复触发
|
// 不再使用 onMounted 避免重复触发
|
||||||
onShow(load);
|
onShow(load);
|
||||||
onPullDownRefresh(async () => {
|
onPullDownRefresh(async () => {
|
||||||
await load();
|
// 下拉刷新:强制绕过 5min 缓存,重新走沙盒遍历
|
||||||
|
await load(true);
|
||||||
uni.stopPullDownRefresh();
|
uni.stopPullDownRefresh();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -462,29 +486,41 @@ onPullDownRefresh(async () => {
|
|||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 空状态 ── */
|
/* ── 加载 spinner(5 处数字位置共用:loading 时转圈,loaded 后切真实数字) ── */
|
||||||
.empty {
|
.value-spinner {
|
||||||
display: flex;
|
display: inline-block;
|
||||||
flex-direction: column;
|
border-style: solid;
|
||||||
align-items: center;
|
border-color: #e6e6e6;
|
||||||
padding: 80rpx 0;
|
border-top-color: #1890ff;
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
.empty-icon {
|
|
||||||
width: 96rpx;
|
|
||||||
height: 96rpx;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f6ffed;
|
vertical-align: middle;
|
||||||
color: #52c41a;
|
animation: value-spin 0.8s linear infinite;
|
||||||
font-size: 48rpx;
|
-webkit-animation: value-spin 0.8s linear infinite;
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
.empty-text {
|
.spinner-hero {
|
||||||
color: #8c8c8c;
|
/* hero-value:占位高度 ≈ 88rpx 字号(56 + margin 8+24),避免布局抖动 */
|
||||||
font-size: 28rpx;
|
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 handlers = new Map() // handlerId → handler
|
||||||
const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise
|
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 ──
|
// ── 注册 API ──
|
||||||
export function registerCategory(handler) {
|
export function registerCategory(handler) {
|
||||||
if (!handler?.id) throw new Error('[cacheManager] handler.id is required')
|
if (!handler?.id) throw new Error('[cacheManager] handler.id is required')
|
||||||
@ -66,8 +132,15 @@ function getHandler(id) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表页读取(汇总 + 存储配额 + 其他 section 数据)
|
* 列表页读取(汇总 + 存储配额 + 其他 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
|
// [并行 1] 所有 handler 的 computeSize
|
||||||
const categories = await Promise.all(
|
const categories = await Promise.all(
|
||||||
Array.from(handlers.values()).map(async (h) => {
|
Array.from(handlers.values()).map(async (h) => {
|
||||||
@ -127,12 +200,16 @@ export async function getCacheInfo() {
|
|||||||
? (appUsedBytes / deviceTotalBytes) * 100
|
? (appUsedBytes / deviceTotalBytes) * 100
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent,
|
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent,
|
||||||
deviceTotalBytes, deviceFreeBytes, deviceUsagePercent,
|
deviceTotalBytes, deviceFreeBytes, deviceUsagePercent,
|
||||||
othersBytes: blacklistBytes + sandboxBytes,
|
othersBytes: blacklistBytes + sandboxBytes,
|
||||||
categories,
|
categories,
|
||||||
}
|
}
|
||||||
|
// 写入缓存(即便部分字段失败/降级也缓存,避免反复重算;清理动作会主动 invalidate)
|
||||||
|
_cachedInfo = result
|
||||||
|
_cachedAt = Date.now()
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -164,6 +241,9 @@ export function cleanCategory(id) {
|
|||||||
if (id === 'preload') {
|
if (id === 'preload') {
|
||||||
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
|
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
|
return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[cacheManager] clean failed: ${id}`, e.message)
|
console.warn(`[cacheManager] clean failed: ${id}`, e.message)
|
||||||
@ -190,6 +270,9 @@ export function cleanCategoryGroup(id, opts = {}) {
|
|||||||
if (id === 'preload') {
|
if (id === 'preload') {
|
||||||
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
|
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
|
||||||
}
|
}
|
||||||
|
// 局部扣减缓存(不清空)
|
||||||
|
const { freedBytes = 0, keyCount = 0 } = result || {}
|
||||||
|
deductFromCache(id, freedBytes, keyCount)
|
||||||
return result
|
return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message)
|
console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user