Compare commits
4 Commits
81ee22d2ee
...
407ca10dce
| Author | SHA1 | Date | |
|---|---|---|---|
| 407ca10dce | |||
| 853b9f30d8 | |||
| 7667aadf3a | |||
| 2f672920d9 |
@ -161,6 +161,11 @@ const info = await cacheManager.getCacheInfo()
|
||||
// quotaTotalBytes, // 配额总量 = uni.getStorageInfoSync().limitSize*1024
|
||||
// quotaAvailableBytes, // 配额可用 = quotaTotalBytes - appUsedBytes
|
||||
// usagePercent, // 百分比(0-100,1 位小数)= appUsedBytes / quotaTotalBytes * 100
|
||||
// deviceTotalBytes, // 设备总存储字节:Android 走 StatFs(Environment.getDataDirectory()),iOS 走 NSFileManager.attributesOfFileSystem
|
||||
// deviceFreeBytes, // 设备可用字节,同上
|
||||
// deviceUsagePercent, // 设备占比(0-100,2 位小数)= appUsedBytes / deviceTotalBytes * 100
|
||||
// // 注:deviceTotalBytes/Free 通过 Native.js 在 ioPath.getDeviceStorageInfo() 取得
|
||||
// // 与 getAndroidApkSize / useShare.js 同模式;不算 native plugin
|
||||
// othersBytes, // "其他" section 大小 = 所有黑名单 keys + 不可清理文件大小
|
||||
// categories: [{id, label, description, sizeBytes, keyCount, warning}, ...]
|
||||
// }
|
||||
@ -265,13 +270,18 @@ await invalidateAll() // 清空 memoryMap + inFlightMap
|
||||
│ │
|
||||
│ 49.7 MB │ ← 大字号主指标(主色)
|
||||
│ │
|
||||
│ 占据配额 16% 存储空间 │ ← 百分比副文字(次要色)
|
||||
│ │
|
||||
│ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░ 16% │ ← 进度条(当前用量占配额比例)
|
||||
│ │
|
||||
│ ┌────────────┬─────────────┬────────────┐ │
|
||||
│ │ Topfans 已用空间 │ 内存缓存空间 │ 总内存空间 │ │ ← 一行 3 个 chip 标签(无数值)
|
||||
│ └────────────┴─────────────┴────────────┘ │ 数值已分别显示在:大字 / 缓存分类 sum / 总配额
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ 设备总空间 │ 设备可用 │ │ ← 新增:HTML5+ 设备级信息行
|
||||
│ │ 128.0 GB │ 45.2 GB │ │ (plus.io.getStorageInfo;非 APP-PLUS 显示 "—")
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 占设备 0.04% 存储空间 │ ← 百分比副文字(分母=设备总存储,2 位小数)
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 缓存分类 │ ← section 标题(次要色)
|
||||
├─────────────────────────────────────────────┤
|
||||
@ -314,8 +324,13 @@ await invalidateAll() // 清空 memoryMap + inFlightMap
|
||||
- 该行**不可点击进入详情**(没有可清理内容)
|
||||
|
||||
**关于"磁盘" vs "配额"**:
|
||||
- MVP 用 `uni.getStorageInfoSync().limitSize`(app 自己的 SQLite 配额),文案写"配额"而非"磁盘",不误导用户
|
||||
- 设备级"磁盘总空间/可用空间"需要 native plugin(iOS `NSFileManager` / Android `StatFs`),**MVP 不引入**,列为后续优化(§2)
|
||||
- **进度条**仍以 `uni.getStorageInfoSync().limitSize`(app 自己的 SQLite 配额)为 100%,表达"app 缓存压力"(用户的清理决策依据)
|
||||
- **百分比分母** = 设备总存储(`appUsedBytes / deviceTotalBytes * 100`),即"占设备 X% 存储空间"
|
||||
- **设备信息行**(设备总空间 / 设备可用)通过 **Native.js** 在 `utils/ioPath.js#getDeviceStorageInfo()` 取得:
|
||||
- Android:`plus.android.importClass('android.os.StatFs')` + `Environment.getDataDirectory()`,无需权限
|
||||
- iOS:`plus.ios.invoke('NSFileManager', 'attributesOfFileSystemForPath:error:', '/')` 读 `NSFileSystemSize` / `NSFileSystemFreeSize`,public API
|
||||
- 非 APP-PLUS 平台或失败时返回 `{ totalBytes: 0, freeBytes: 0 }`,前端 `formatSize(0)` 显示 `—`
|
||||
- Native.js **不是** native plugin(不需要写 Java/ObjC、不需要 `nativeplugins/` 目录、不需要 `manifest.json` 改动),与项目已有 `getAndroidApkSize` / `useShare.js` 同模式
|
||||
|
||||
**草稿/引导的二级提示**:行右侧副文字"按账号分组清理",警示用户详情页会有分组。
|
||||
|
||||
@ -416,15 +431,19 @@ cacheManager.getCacheInfo()
|
||||
│ └─ others.computeSize → 上述未匹配的 key 汇总
|
||||
├─ [并行 2] 读取存储配额
|
||||
│ ├─ uni.getStorageInfoSync() → { currentSize (KB), limitSize (KB) }
|
||||
│ └─ ioPath.getSandboxTotalSize() → 沙盒 doc 目录下所有文件总字节数(新增 ioPath 只读 API;与 scanSandboxTmpFiles 不同,前者含白名单 preload/share/image 所有文件,后者仅统计 tmp/)
|
||||
│ ├─ ioPath.getSandboxTotalSize() → 沙盒 doc 目录下所有文件总字节数(新增 ioPath 只读 API;与 scanSandboxTmpFiles 不同,前者含白名单 preload/share/image 所有文件,后者仅统计 tmp/)
|
||||
│ └─ ioPath.getDeviceStorageInfo() → 设备级 { totalBytes, freeBytes }:Android 走 StatFs,iOS 走 NSFileManager(Native.js,非 native plugin);非 APP-PLUS 返回 {0,0}
|
||||
↓
|
||||
聚合:
|
||||
totalBytes = sum(categories.sizeBytes) // 可清理总量(=内存缓存空间 chip)
|
||||
appUsedBytes = currentSize * 1024 + sandboxBytes // 软件占用(大字 + Topfans已用空间 chip)
|
||||
// 注:currentSize 是 uni storage 全量(含黑名单),这是 WeChat "已用空间" 口径
|
||||
quotaTotalBytes = limitSize * 1024 // 配额总量(=总内存空间 chip)
|
||||
quotaTotalBytes = limitSize * 1024 // 配额总量(=总内存空间 chip,进度条 100% 基准)
|
||||
quotaAvailableBytes = quotaTotalBytes - appUsedBytes // 配额可用
|
||||
usagePercent = (appUsedBytes / quotaTotalBytes) * 100 // 百分比
|
||||
usagePercent = (appUsedBytes / quotaTotalBytes) * 100 // 配额百分比(进度条用)
|
||||
deviceTotalBytes = deviceTotalKB * 1024 // 设备总存储(设备信息行"设备总空间")
|
||||
deviceFreeBytes = deviceFreeKB * 1024 // 设备可用(设备信息行"设备可用")
|
||||
deviceUsagePercent = (appUsedBytes / deviceTotalBytes) * 100 // 设备百分比(百分比副文字"占设备 X%"用)
|
||||
othersBytes = sum(黑名单 keys size) + 不可清理文件大小 // "其他" section
|
||||
↓
|
||||
渲染页面(大字 + 百分比 + 进度条 + 3 chip 标签 + 6 缓存分类行 + 1 其他 section)
|
||||
|
||||
@ -414,7 +414,10 @@
|
||||
"path": "pages/profile/cache-cleanup",
|
||||
"style": {
|
||||
"navigationBarTitleText": "存储空间",
|
||||
"enablePullDownRefresh": true
|
||||
"enablePullDownRefresh": true,
|
||||
"app-plus": {
|
||||
"bounce": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -10,62 +10,67 @@
|
||||
<!-- #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 已用空间(蓝)→ 内存缓存空间(紫,叠在蓝内)→ 总内存空间剩余(绿) -->
|
||||
<!-- 嵌套进度条:Topfans 已用空间(蓝)→ 其他 app 使用空间(橙)→ 剩余可用空间(绿) -->
|
||||
<view class="progress-bar">
|
||||
<!-- 绿段:剩余配额(贴在右侧) -->
|
||||
<!-- 绿段:剩余可用空间(贴在右侧) -->
|
||||
<view
|
||||
class="seg-available"
|
||||
:style="{ width: availablePct + '%' }"
|
||||
></view>
|
||||
<!-- 蓝段:完整 Topfans 已用(从左) -->
|
||||
<!-- 蓝段:Topfans 已用空间(从左) -->
|
||||
<view
|
||||
class="seg-app"
|
||||
:style="{ width: appPct + '%' }"
|
||||
class="seg-topfans"
|
||||
:style="{ width: topfansPct + '%' }"
|
||||
></view>
|
||||
<!-- 紫段:缓存(从左、叠在蓝段内;不超过蓝段右边界) -->
|
||||
<!-- 橙段:其他 app 使用空间(从蓝段右边界开始,紧接蓝段) -->
|
||||
<view
|
||||
class="seg-cache"
|
||||
:style="{ width: cachePct + '%' }"
|
||||
class="seg-other"
|
||||
:style="{ left: topfansPct + '%', width: otherPct + '%' }"
|
||||
></view>
|
||||
</view>
|
||||
|
||||
<view class="chips">
|
||||
<view class="chip">
|
||||
<view class="chip-dot dot-app"></view>
|
||||
<view class="chip-dot dot-topfans"></view>
|
||||
<text>Topfans 已用空间</text>
|
||||
</view>
|
||||
<view class="chip">
|
||||
<view class="chip-dot dot-cache"></view>
|
||||
<text>内存缓存空间</text>
|
||||
<view class="chip-dot dot-other"></view>
|
||||
<text>其他 app 使用空间</text>
|
||||
</view>
|
||||
<view class="chip">
|
||||
<view class="chip-dot dot-available"></view>
|
||||
<text>总内存空间</text>
|
||||
<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.usagePercent.toFixed(1) }}% 存储空间</text
|
||||
>占设备 {{ info.deviceUsagePercent.toFixed(2) }}% 存储空间</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<!-- 缓存分类卡片 -->
|
||||
<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"
|
||||
@ -79,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
|
||||
>
|
||||
@ -97,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>
|
||||
|
||||
@ -115,38 +122,49 @@
|
||||
<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,
|
||||
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);
|
||||
|
||||
// ── 进度条三段宽度(基于 quotaTotalBytes = 100%)──
|
||||
// ── 进度条三段宽度(基于 deviceTotalBytes = 100%,设备级分布)──
|
||||
// 三段相加 ≤ 100%,不出现负数;任一字段缺失时安全降级为 0
|
||||
// 语义:蓝 = Topfans 全部已用;紫 = 蓝内的缓存(必须 ≤ 蓝);绿 = 蓝右侧剩余
|
||||
const appPct = computed(() => {
|
||||
const total = info.value.quotaTotalBytes;
|
||||
// 语义:蓝 = Topfans 已用;橙 = 系统+其他 app+已占用的非空闲空间;绿 = 设备剩余可用
|
||||
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 cachePct = computed(() => {
|
||||
const total = info.value.quotaTotalBytes;
|
||||
const otherPct = computed(() => {
|
||||
const total = info.value.deviceTotalBytes;
|
||||
if (!total) return 0;
|
||||
// 缓存必须落在 Topfans 已用区间内(cache 是 used 的子集)
|
||||
const cache = Math.min(info.value.totalBytes, info.value.appUsedBytes);
|
||||
return Math.min(appPct.value, Math.max(0, (cache / total) * 100));
|
||||
// 其他 app + 系统占用 = deviceTotal - Topfans已用 - 设备剩余
|
||||
// 防御:若 appUsedBytes + deviceFreeBytes 超过 deviceTotalBytes(极端情况),钳到非负
|
||||
const otherBytes = Math.max(
|
||||
0,
|
||||
info.value.deviceTotalBytes - info.value.appUsedBytes - info.value.deviceFreeBytes
|
||||
);
|
||||
return Math.min(100 - topfansPct.value, Math.max(0, (otherBytes / total) * 100));
|
||||
});
|
||||
const availablePct = computed(() => {
|
||||
const total = info.value.quotaTotalBytes;
|
||||
const total = info.value.deviceTotalBytes;
|
||||
if (!total) return 0;
|
||||
return Math.max(0, 100 - appPct.value);
|
||||
return Math.max(0, 100 - topfansPct.value - otherPct.value);
|
||||
});
|
||||
|
||||
// ── 平台判断(仅 APP-PLUS 编译时计算)──
|
||||
@ -160,12 +178,29 @@ 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 缓冲
|
||||
setTimeout(() => rej(new Error("timeout")), 5000),
|
||||
),
|
||||
]);
|
||||
@ -173,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,7 +234,8 @@ function goDetail(id) {
|
||||
// 不再使用 onMounted 避免重复触发
|
||||
onShow(load);
|
||||
onPullDownRefresh(async () => {
|
||||
await load();
|
||||
// 下拉刷新:强制绕过 5min 缓存,重新走沙盒遍历
|
||||
await load(true);
|
||||
uni.stopPullDownRefresh();
|
||||
});
|
||||
</script>
|
||||
@ -201,6 +247,8 @@ onPullDownRefresh(async () => {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
/* 关闭 WebView 层回弹(iOS 顶部/底部橡皮筋 + Android 边缘光晕) */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* ── Hero 卡片 ── */
|
||||
@ -231,6 +279,41 @@ onPullDownRefresh(async () => {
|
||||
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;
|
||||
@ -252,10 +335,9 @@ onPullDownRefresh(async () => {
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 三段都用 absolute 定位(蓝、紫从左贴齐;绿从右贴齐),
|
||||
紫叠在蓝之上 → 视觉上"缓存占用 Topfans 已用的一部分" */
|
||||
.seg-app,
|
||||
.seg-cache,
|
||||
/* 三段都用 absolute 定位(蓝、橙从左贴齐→依次拼接;绿从右贴齐) */
|
||||
.seg-topfans,
|
||||
.seg-other,
|
||||
.seg-available {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -263,13 +345,16 @@ onPullDownRefresh(async () => {
|
||||
min-width: 0;
|
||||
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.seg-app {
|
||||
.seg-topfans {
|
||||
left: 0;
|
||||
background: #1890ff;
|
||||
/* Topfans 通常 < 0.1% 设备总空间,强制最小可见宽度 2%(保持视觉存在感) */
|
||||
min-width: 2%;
|
||||
}
|
||||
.seg-cache {
|
||||
.seg-other {
|
||||
/* 防御:left 默认 0,inline style 会用 topfansPct% 覆盖 */
|
||||
left: 0;
|
||||
background: #722ed1;
|
||||
background: #fa8c16;
|
||||
}
|
||||
.seg-available {
|
||||
right: 0;
|
||||
@ -298,16 +383,20 @@ onPullDownRefresh(async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
/* 加大到 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-app {
|
||||
.dot-topfans {
|
||||
background: #1890ff;
|
||||
}
|
||||
.dot-cache {
|
||||
background: #722ed1;
|
||||
.dot-other {
|
||||
background: #fa8c16;
|
||||
}
|
||||
.dot-available {
|
||||
background: #52c41a;
|
||||
@ -397,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); }
|
||||
}
|
||||
|
||||
/* ── 加载失败按钮 ── */
|
||||
@ -435,3 +536,16 @@ onPullDownRefresh(async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 非 scoped:scoped 会给选择器加 [data-v-xxx] 属性,
|
||||
但 ::-webkit-scrollbar 是伪元素没有 DOM 属性,scoped 匹配不上。
|
||||
这里隐藏整页滚动条(不影响下拉刷新) */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -9,7 +9,7 @@ import othersHandler from './handlers/othersHandler'
|
||||
import preloadHandler from './handlers/preloadHandler'
|
||||
import guideHandler from './handlers/guideHandler'
|
||||
import draftHandler from './handlers/draftHandler'
|
||||
import { getSandboxTotalSize } from './ioPath'
|
||||
import { getSandboxTotalSize, getDeviceStorageInfo } from './ioPath'
|
||||
|
||||
// ── 黑名单(§3.4.1)──
|
||||
// 全等匹配
|
||||
@ -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) => {
|
||||
@ -105,6 +178,15 @@ export async function getCacheInfo() {
|
||||
} catch (e) {
|
||||
console.warn('[cacheManager] getSandboxTotalSize failed:', e.message)
|
||||
}
|
||||
let deviceTotalBytes = 0, deviceFreeBytes = 0
|
||||
try {
|
||||
const dev = await getDeviceStorageInfo()
|
||||
// ioPath.getDeviceStorageInfo 返回的 totalBytes/freeBytes 已是字节(Android: blockSize*blocks, iOS: NSFileSystemSize),不要再 * 1024
|
||||
deviceTotalBytes = dev.totalBytes || 0
|
||||
deviceFreeBytes = dev.freeBytes || 0
|
||||
} catch (e) {
|
||||
console.warn('[cacheManager] getDeviceStorageInfo failed:', e.message)
|
||||
}
|
||||
|
||||
const totalBytes = categories.reduce((sum, c) => sum + (c.sizeBytes > 0 ? c.sizeBytes : 0), 0)
|
||||
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes
|
||||
@ -113,12 +195,21 @@ export async function getCacheInfo() {
|
||||
const quotaAvailableBytes = Math.max(0, raw)
|
||||
const quotaExceeded = raw < 0
|
||||
const usagePercent = quotaTotalBytes > 0 ? (appUsedBytes / quotaTotalBytes) * 100 : 0
|
||||
// 设备级数据已在 try 块里取好(ioPath 返回的就是字节,不再 * 1024)
|
||||
const deviceUsagePercent = deviceTotalBytes > 0
|
||||
? (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
|
||||
}
|
||||
|
||||
/**
|
||||
@ -150,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)
|
||||
@ -176,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)
|
||||
|
||||
@ -368,22 +368,50 @@ async function getAndroidApkSize() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算"app 已用空间" = Android APK 安装包大小 + sandbox 运行时数据大小
|
||||
* 在 iOS / H5 上只算 sandbox(iOS bundle 不易从 js 取大小,按 sandbox 估算)
|
||||
* 计算"app 已用空间" = Android APK 安装包大小 + 4 个 sandbox 根目录下所有文件总字节
|
||||
* 在 iOS / H5 上只算 sandbox(iOS .app bundle 沙盒外不可访问,按 sandbox 估算)
|
||||
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes
|
||||
*
|
||||
* 4 个 sandbox 根:
|
||||
* PRIVATE_DOC (2) _doc 私有文档(用户数据/缓存)— 主用,绝大多数占用在此
|
||||
* PRIVATE_WWW (1) _www 私有资源(仅 manifest.json 设 runmode=liberate 时才有内容)
|
||||
* PUBLIC_DOCUMENTS (3) _documents 公共文档(多 5+ App 共享)
|
||||
* PUBLIC_DOWNLOADS (4) _downloads 公共下载(多 5+ App 共享)
|
||||
*
|
||||
* PRIVATE_WWW 行为说明:
|
||||
* - 非 liberate 模式:沙盒根不存在 → requestFileSystem 两个回调都不触发 → 2s 超时降级为 0
|
||||
* - liberate 模式:首次启动资源从 APK 解压到 _www → 可正常遍历求和
|
||||
* (注:liberate 模式下 _www 内容是 APK 资源的解压副本,理论上是双倍计数,但用户明确要求保留 4 个根遍历)
|
||||
*
|
||||
* 单个根失败/超时 → 0(不影响其他根)
|
||||
*/
|
||||
export async function getSandboxTotalSize() {
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
// Android: APK 大小 + sandbox doc 目录运行时数据
|
||||
// Android: APK 大小 + 4 个沙盒根目录运行时数据
|
||||
const apkSize = await getAndroidApkSize()
|
||||
let sandboxBytes = 0
|
||||
try {
|
||||
const root = await getSandboxRootDir()
|
||||
sandboxBytes = await _sumDirSize(root)
|
||||
} catch (e) {
|
||||
console.warn('[ioPath] _sumDirSize failed:', e?.message)
|
||||
}
|
||||
const rootTypes = [
|
||||
plus.io.PRIVATE_DOC,
|
||||
plus.io.PRIVATE_WWW,
|
||||
plus.io.PUBLIC_DOCUMENTS,
|
||||
plus.io.PUBLIC_DOWNLOADS,
|
||||
]
|
||||
// 并行遍历 4 个沙盒根(互不依赖),单根失败/超时 → 0
|
||||
// 2s 超时:防御性,PRIVATE_WWW 在非 liberate 模式下挂死靠它兜底
|
||||
const sizes = await Promise.all(
|
||||
rootTypes.map(async (rootType) =>
|
||||
Promise.race([
|
||||
_sumDirSizeByRootType(rootType),
|
||||
new Promise((_, rej) =>
|
||||
setTimeout(() => rej(new Error('rootType timeout')), 2000)
|
||||
),
|
||||
]).catch((e) => {
|
||||
console.warn(`[ioPath] _sumDirSize failed for rootType=${rootType}:`, e?.message)
|
||||
return 0
|
||||
})
|
||||
)
|
||||
)
|
||||
const sandboxBytes = sizes.reduce((sum, s) => sum + s, 0)
|
||||
return apkSize + sandboxBytes
|
||||
} catch (e) {
|
||||
console.warn('[ioPath] getSandboxTotalSize failed:', e?.message)
|
||||
@ -395,6 +423,31 @@ export async function getSandboxTotalSize() {
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 取指定 sandbox 根类型对应的 DirectoryEntry,递归求总字节
|
||||
* 不 memoize(每次按需取,避免 4 个根互相干扰;只在 getSandboxTotalSize 单次调用)
|
||||
*/
|
||||
function _sumDirSizeByRootType(rootType) {
|
||||
// #ifdef APP-PLUS
|
||||
return new Promise((resolve, reject) => {
|
||||
plus.io.requestFileSystem(
|
||||
rootType,
|
||||
async (fs) => {
|
||||
try {
|
||||
resolve(await _sumDirSize(fs.root))
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
(e) => reject(e)
|
||||
)
|
||||
})
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
return Promise.resolve(0)
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 sandbox doc 下"所有业务子目录的 tmp/ 文件总字节数"(不包含白名单 preload/share/image)
|
||||
* 用于 sandboxTmpHandler.computeSize
|
||||
@ -522,3 +575,73 @@ async function _listDir(dirEntry) {
|
||||
return []
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备级存储信息(Native.js:Android 走 StatFs,iOS 走 NSFileManager)
|
||||
* 不需要任何 native plugin;非 APP-PLUS 平台或失败时返回安全零值
|
||||
* 注:与项目已有 getAndroidApkSize / useShare.js 的 Native.js 模式 1:1 对齐
|
||||
* @returns {Promise<{totalBytes:number, freeBytes:number}>}
|
||||
*/
|
||||
export function getDeviceStorageInfo() {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
if (typeof plus === 'undefined') {
|
||||
return resolve({ totalBytes: 0, freeBytes: 0 })
|
||||
}
|
||||
if (plus.os.name === 'Android') {
|
||||
// Android:用 StatFs 查 /data 分区(即系统"内部存储")
|
||||
// 不需要任何权限;Android 10+ 分区存储不影响 StatFs(StatFs 查的是卷级元数据)
|
||||
const Environment = plus.android.importClass('android.os.Environment')
|
||||
const StatFs = plus.android.importClass('android.os.StatFs')
|
||||
const dataDir = Environment.getDataDirectory()
|
||||
const stat = new StatFs(dataDir.getPath())
|
||||
// getBlockSizeLong 是 API 18+,project minSdkVersion=21 保证可用
|
||||
// plus.android 对 *Long 变体代理有时不稳定 → parseFloat 兜底
|
||||
const blockSize = parseFloat(plus.android.invoke(stat, 'getBlockSizeLong'))
|
||||
const totalBlocks = parseFloat(plus.android.invoke(stat, 'getBlockCountLong'))
|
||||
const freeBlocks = parseFloat(plus.android.invoke(stat, 'getAvailableBlocksLong'))
|
||||
if (blockSize > 0 && totalBlocks > 0) {
|
||||
resolve({
|
||||
totalBytes: blockSize * totalBlocks,
|
||||
freeBytes: blockSize * freeBlocks,
|
||||
})
|
||||
} else {
|
||||
console.warn('[ioPath] getDeviceStorageInfo (Android) got non-positive values')
|
||||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||||
}
|
||||
} else if (plus.os.name === 'iOS') {
|
||||
// iOS:用 NSFileManager.attributesOfFileSystem 查系统卷
|
||||
// NSFileSystemSize / NSFileSystemFreeSize 是 public API(参照 useShare.js 调用风格)
|
||||
const fm = plus.ios.invoke('NSFileManager', 'defaultManager')
|
||||
if (!fm) {
|
||||
console.warn('[ioPath] getDeviceStorageInfo (iOS) fm is null')
|
||||
return resolve({ totalBytes: 0, freeBytes: 0 })
|
||||
}
|
||||
const attrs = plus.ios.invoke(fm, 'attributesOfFileSystemForPath:error:', '/')
|
||||
if (attrs) {
|
||||
const total = parseFloat(attrs.plusGetAttribute('NSFileSystemSize')) || 0
|
||||
const free = parseFloat(attrs.plusGetAttribute('NSFileSystemFreeSize')) || 0
|
||||
// 必须 deleteObject,否则 NSObject proxy 会泄露(参照 useShare.js 清理模式)
|
||||
plus.ios.deleteObject(attrs)
|
||||
plus.ios.deleteObject(fm)
|
||||
resolve({ totalBytes: total, freeBytes: free })
|
||||
} else {
|
||||
plus.ios.deleteObject(fm)
|
||||
console.warn('[ioPath] getDeviceStorageInfo (iOS) attrs is null')
|
||||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||||
}
|
||||
} else {
|
||||
// Harmony / 其它平台:暂不支持,返回 0
|
||||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ioPath] getDeviceStorageInfo failed:', e?.message)
|
||||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||||
}
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
resolve({ totalBytes: 0, freeBytes: 0 })
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user