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