diff --git a/frontend/pages.json b/frontend/pages.json index 883f75f..7edfa3e 100644 --- a/frontend/pages.json +++ b/frontend/pages.json @@ -414,7 +414,10 @@ "path": "pages/profile/cache-cleanup", "style": { "navigationBarTitleText": "存储空间", - "enablePullDownRefresh": true + "enablePullDownRefresh": true, + "app-plus": { + "bounce": "none" + } } }, { diff --git a/frontend/pages/profile/cache-cleanup.vue b/frontend/pages/profile/cache-cleanup.vue index ace9d81..332646f 100644 --- a/frontend/pages/profile/cache-cleanup.vue +++ b/frontend/pages/profile/cache-cleanup.vue @@ -12,37 +12,37 @@ Topfans 已用空间 {{ formatSize(info.appUsedBytes) }} - + - + - + - + - + Topfans 已用空间 - - 内存缓存空间 + + 其他 app 使用空间 - 总内存空间 + 剩余可用空间 @@ -144,25 +144,29 @@ const info = ref({ }); const loadFailed = 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 编译时计算)── @@ -182,6 +186,8 @@ async function load() { const result = await Promise.race([ getCacheInfo(), new Promise((_, rej) => + // getCacheInfo 正常 < 3s(4 沙盒根 + Native.js + handlers 并行) + // 单根 2s 超时兜底后最长 ~2.1s,5s 留 2x 缓冲 setTimeout(() => rej(new Error("timeout")), 5000), ), ]); @@ -217,6 +223,8 @@ onPullDownRefresh(async () => { background: #f5f7fa; min-height: 100vh; box-sizing: border-box; + /* 关闭 WebView 层回弹(iOS 顶部/底部橡皮筋 + Android 边缘光晕) */ + overscroll-behavior: none; } /* ── Hero 卡片 ── */ @@ -303,10 +311,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; @@ -314,13 +321,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; @@ -349,16 +359,20 @@ onPullDownRefresh(async () => { flex-shrink: 0; } .chip-dot { - width: 8rpx; - height: 8rpx; + /* 加大到 12rpx(原 8rpx 像素密度不同设备上太易丢失); + display: inline-block 防御 uniapp 空 不渲染的极端 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; @@ -486,3 +500,16 @@ onPullDownRefresh(async () => { cursor: pointer; } + + diff --git a/frontend/utils/ioPath.js b/frontend/utils/ioPath.js index 2cc607d..47577c3 100644 --- a/frontend/utils/ioPath.js +++ b/frontend/utils/ioPath.js @@ -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