feat:增加预加载接口

This commit is contained in:
zheng020 2026-07-09 13:27:25 +08:00
parent f6b2b31d76
commit 5625f6ac28
27 changed files with 5525 additions and 369 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,79 @@
--- ---
## ★ 方案概述(必读)
### 要解决的问题
**业务问题**
- 用户进入详情页时白屏 loading 闪烁(网络请求耗时 200-800ms体验差
- 冷启动后首页数据(排行榜、用户配置等公共数据)每次都重新拉取,浪费带宽
- 用户在页面间反复切换时,相同接口被多次调用,服务端压力大
**技术问题**
- uniapp 没有内置的请求缓存层,每个 `uni.request` 都是独立调用
- `uni.navigateTo` 跳转和页面数据加载是串行的(先跳转 → 再 `onLoad` 发请求),无法利用"用户点击到页面渲染"之间的时间窗口
- 没有统一的请求去重机制,同一数据源被多个组件同时请求时产生冗余网络调用
### 整体实现路径
| 阶段 | 内容 | 预估时间 |
|------|------|----------|
| Phase 1核心缓存引擎 | `storage.js` + `core.js`(内存 Map + LRU + inFlight 去重 + TTL | 1 天 |
| Phase 2调度 & 路由集成 | `scheduler.js` + `navigate.js` + `config.js`(启动预热 / idle 预拉 / 页面切换预拉) | 0.5 天 |
| Phase 3Vue 集成 | `usePreload.js` composable + `preload.config.js` 业务配置 + `App.vue` / `store/user.js` 集成 | 0.5 天 |
| Phase 4测试 & 验收 | 单元测试 + 集成冒烟 + 真机验收 | 0.5 天 |
| **合计** | | **2.5 天** |
### 关键决策
| 决策 | 理由 | 详见 |
|------|------|------|
| 核心模块core.js零外部依赖 | 可独立测试、不耦合 uniapp / Vue未来可迁移 | §2.2 |
| wrap navigateTo 不 await 预拉结果 | 点击立刻跳转,预拉后台并发,不增加跳转延迟 | §2.5 |
| 401/业务码7/16 统一 swallow | `api.js` 已同步 `reLaunch` 跳登录页,预加载层再抛错会造成双重跳转 | §5 |
| 内存 LRU + 文件 FIFO 双轨淘汰 | 内存注重热数据命中率LRU文件注重简单可靠FIFO | §7.1 |
| inFlight 去重(同 key 并发共享 Promise | 避免启动期 / 页面切换时同一接口被多次调用 | §4.4 |
| 按 userId 物理隔离文件缓存 | 防止用户 A 读到用户 B 的缓存数据 | §6.5 |
### 核心架构图TL;DR
```
┌──────────────────────────┐
│ preload.config.js │
│ (业务声明 key/fetcher/ttl) │
└──────────┬───────────────┘
┌───────────┐ ┌─────────────▼──────────────┐
│ App.vue │────►│ scheduler.js │
│ onLaunch │ │ warmStartup() / warmIdle() │
│ onShow │ └─────────────┬──────────────┘
└───────────┘ │
┌───────────┐ ┌─────────────▼──────────────┐
│ navigate │────►│ core.js │
│ .js │ │ run / get / invalidate │
│ (wrap │ │ Map<key,Entry> + LRU │
│ uni.nav*)│ │ inFlight dedup │
└───────────┘ └──────┬──────────┬──────────┘
│ │
┌────────▼──┐ ┌────▼──────────┐
│ storage.js│ │ usePreload.js │
│ (_doc/ │ │ (composable: │
│ preload/ │ │ data/loading │
│ {uid}/) │ │ /error/refresh│
└───────────┘ └───────────────┘
```
### 文档说明
- **适用范围**`frontend/` 下所有 API 调用场景(页面数据加载、启动预热、页面切换预拉),以 App 端Android/iOS为主H5 / 小程序降级兼容
- **工作量估算**:约 2.5 天(核心 1 天 + 调度&路由 0.5 天 + Vue 集成 0.5 天 + 测试 0.5 天),约 800-1000 行新代码
- **前置版本/历史**:无。这是项目首个 API 预加载方案
- **目标读者**:前端开发(需了解如何配置新的预拉 key 和使用 `usePreload`)、架构评审
---
## 1. 目标与范围 ## 1. 目标与范围
为 uniapp + Vue 3 项目提供一套**通用、可配置、按场景分层的 API 预加载方案**,覆盖 4 类典型场景: 为 uniapp + Vue 3 项目提供一套**通用、可配置、按场景分层的 API 预加载方案**,覆盖 4 类典型场景:
@ -69,9 +142,10 @@ Page onLoad (composable) ─┘
| 文件 | 改动 | | 文件 | 改动 |
| --- | --- | | --- | --- |
| `App.vue` | ~10 行import + `warmStartup()``onLaunch` + `warmIdle()``onShow` + patch `SET_USER_INFO` / `CLEAR_AUTH` 注入失效调用 | | `App.vue` | ~10 行Options APIimport `preloadApi``onLaunch` 中调用 `warmStartup()``onShow` 中调用 `warmIdle()`。注意 App.vue 使用 Options API`export default { … }`),直接 `import` + 在生命周期方法内调用即可,**不**是 `<script setup>` composable 风格(详见 §6.5.1 App.vue 集成方式) |
| `store/modules/user.js` | ~5 行:在 `SET_USER_INFO` mutation 中注入 `preloadApi.clearForUser(oldUserId)`;在 `CLEAR_AUTH` mutation 中注入 `preloadApi.clearUser(userId)`(调用顺序约束见 §6.5 |
| `pages/*/...vue` | 把原本 `request({...})` 改成 `usePreload('key')`setup / store action / watch 中的请求均适用);把 `uni.navigateTo/switchTab/reLaunch` 全量替换为 `preloadApi.navigateTo/switchTab/reLaunch`(见 §2.5 | | `pages/*/...vue` | 把原本 `request({...})` 改成 `usePreload('key')`setup / store action / watch 中的请求均适用);把 `uni.navigateTo/switchTab/reLaunch` 全量替换为 `preloadApi.navigateTo/switchTab/reLaunch`(见 §2.5 |
| `utils/api.js` | `request()` 返回的 Promise 上挂 `.abort()` 方法(约 3 行改动,不影响现有调用方 | | `utils/api.js` | `request()` 返回的 Promise 上挂 `.abort()` 方法(~8 行改动:捕获 `requestTask` + 挂 `abort()` + `fail` 中识别 abort 信号,详见 §4.4.1 |
| 其余 `utils/*` | **不动** | | 其余 `utils/*` | **不动** |
### 2.5 wrappedNavigateTo 替换策略(已确定方案 A ### 2.5 wrappedNavigateTo 替换策略(已确定方案 A
@ -86,6 +160,14 @@ Page onLoad (composable) ─┘
**全量替换规则**:用 ESLint 规则或 PR review checklist 强制 —— 全仓 `uni.navigateTo``preloadApi.navigateTo`(可用 codemod 一次性替换)。 **全量替换规则**:用 ESLint 规则或 PR review checklist 强制 —— 全仓 `uni.navigateTo``preloadApi.navigateTo`(可用 codemod 一次性替换)。
**替换影响评估**(实施前确认):
- 预计全仓 `uni.navigateTo` / `uni.switchTab` / `uni.reLaunch` 调用点在 40-80 处(分布在 `pages/` 下各 `.vue` 文件 + `App.vue`
- 需手动 review 的特殊场景:
- `App.vue` 第 149 行 `uni.navigateTo`(推送通知点击跳转,在 `setTimeout` 内)——应改为 `preloadApi.navigateTo`(无对应 pages 映射时自动透传,安全)
- `App.vue` 第 92/114/143 行 `uni.reLaunch`(登录页跳转)——应改为 `preloadApi.reLaunch`(同上)
- 任何在 `#ifdef` / `#ifndef` 块内的调用 —— codemod 可能漏掉,需手动检查
- 未命中 `config.pages` 映射时,`preloadApi.navigateTo` **直接透传**调用原生 `uni.navigateTo`§4.1 已定义),因此即使替换出错也不会导致跳转失败,最多丢失预拉机会
--- ---
## 3. 配置 schema ## 3. 配置 schema
@ -153,7 +235,7 @@ export const preloadConfig = {
- `concurrency`:单次批量内最大并发 - `concurrency`:单次批量内最大并发
- `timeout`单接口超时ms - `timeout`单接口超时ms
- `silent`:失败是否静默(`run` 时生效,`get` 始终抛错) - `silent`:失败是否静默(`run` 时生效,`get` 始终抛错)
- `limits.maxEntrySizeKB / maxMemoryEntries / maxFileCacheMB`:上限保护阈值(详见 §7默认 1024 KB / 100 条 / 50 MB可在 `defaults.limits` 覆盖 - `limits.maxEntrySizeKB / maxMemoryEntries / maxFileCacheMB`:上限保护阈值(详见 §7默认 1024 KB / 100 条 / 50 MB**仅在 `defaults.limits` 全局配置,不支持 per-key 覆盖**
--- ---
@ -162,8 +244,8 @@ export const preloadConfig = {
### 4.1 命令式 APIutils/preloadApi/index.js 导出) ### 4.1 命令式 APIutils/preloadApi/index.js 导出)
```js ```js
preloadApi.run(key, params?) // 触发一次预拉(写缓存):先查内存,未过期则跳过;过期/未命中才拉取。401/7/16 静默吞掉。不返回数据 preloadApi.run(key, params?) // 触发一次预拉(写缓存):①先查内存,未过期则跳过;②查 inFlight已在请求中则共享 Promise不重发③过期/未命中/未 inFlight 才发起新请求。401/7/16 静默吞掉。不返回数据
preloadApi.get(key, params?) // 读缓存:命中直接返回;未命中/过期则拉取401/7/16 业务码静默吞掉(不抛给调用方) preloadApi.get(key, params?) // 读缓存:命中直接返回;未命中/过期则拉取(同样走 inFlight 去重)401/7/16 业务码静默吞掉(不抛给调用方)
preloadApi.refresh(key, params?, force?) // 命令式刷新force=true 跳过 TTL。非组件上下文使用组件上下文用 usePreload.refresh() preloadApi.refresh(key, params?, force?) // 命令式刷新force=true 跳过 TTL。非组件上下文使用组件上下文用 usePreload.refresh()
preloadApi.prefetchFor(targetPath, params?) // wrappedNavigateTo 内部调用 preloadApi.prefetchFor(targetPath, params?) // wrappedNavigateTo 内部调用
preloadApi.invalidate(key?) // 失效单 key仅内存 preloadApi.invalidate(key?) // 失效单 key仅内存
@ -245,20 +327,35 @@ navigateTo({ url: '/pages/asset-detail/asset-detail?id=123' })
- composable 在 `onBeforeUnmount` / watcher 中调 `abort()` - composable 在 `onBeforeUnmount` / watcher 中调 `abort()`
- abort 后清掉 inFlight 条目abort 不走 finally 路径,需显式清理) - abort 后清掉 inFlight 条目abort 不走 finally 路径,需显式清理)
**`request()` 改造**(唯一对 `utils/api.js` 的改动,约 3 行): **`request()` 改造**(唯一对 `utils/api.js` 的改动,约 8 行):
```js ```js
// utils/api.js — request() 内部 // utils/api.js — request() 内部
export function request(options) { export function request(options) {
const requestTask = uni.request({...}) // 已有 let _aborted = false // 新增abort 标记
let abortFn = () => requestTask.abort() const requestTask = uni.request({...}) // 改动:捕获 requestTask原来直接调用不赋值
let abortFn = () => {
_aborted = true // 新增:标记已 abort
requestTask.abort()
}
const p = new Promise((resolve, reject) => { const p = new Promise((resolve, reject) => {
// ... 现有 success/fail 逻辑不变 ... // ... 现有 success/fail 逻辑不变 ...
// 改动fail 回调中识别 abort 信号
// fail: (err) => {
// if (_aborted) return // abort 触发的 fail 静默忽略
// reject(new Error(err.errMsg || '网络请求失败'))
// }
}) })
p.abort = abortFn // 新增这一行 p.abort = abortFn // 新增这一行
return p return p
} }
``` ```
所有现有调用方 `await request(...)` 语法不变preload 的 `core.js` 通过 `fetcher` 拿到这个 Promise 后,将其 `.abort` 存入 inFlight 条目。fetcher 无需改造 — fetcher 负责调用 `request()` 并返回其 Promise。当组件 unmount / params 变化时调用 `abort()``requestTask.abort()` 会终止 TCP 连接并释放带宽。
**关键细节**
- `requestTask.abort()` 触发后,`uni.request` 的 `fail` 回调会以 `err.errMsg === 'request:fail abort'` 触发。必须在 `fail` 中通过 `_aborted` 标记识别并静默返回,否则 preload 层会收到一个 "网络请求失败" 的 reject。
- 所有现有调用方 `await request(...)` 语法不变;`abortFn` 仅由 preload 的 `core.js` 在组件 unmount / params 变化时调用。
### 4.4.1 abort 生命周期
### 4.5 composable APIcomposables/usePreload.js ### 4.5 composable APIcomposables/usePreload.js
@ -421,7 +518,30 @@ function clearForUser(userId) {
1. **store/user.js 当前没有事件总线**(只有 action / mutation没有 emit/on。实施方式**包装 mutation**,在 `SET_USER_INFO` mutation 里 patch 一下:如果旧用户存在(`oldUserId != null`),调用 `preloadApi.clearForUser(oldUserId)`;在 `CLEAR_AUTH` mutation 里 patch 一下(注入 `preloadApi.clearUser(userId)` 调用)。注意:**登录场景下 `oldUserId` 为 null/undefined必须 guard 住**,否则会误删 `_doc/preload/undefined/` 1. **store/user.js 当前没有事件总线**(只有 action / mutation没有 emit/on。实施方式**包装 mutation**,在 `SET_USER_INFO` mutation 里 patch 一下:如果旧用户存在(`oldUserId != null`),调用 `preloadApi.clearForUser(oldUserId)`;在 `CLEAR_AUTH` mutation 里 patch 一下(注入 `preloadApi.clearUser(userId)` 调用)。注意:**登录场景下 `oldUserId` 为 null/undefined必须 guard 住**,否则会误删 `_doc/preload/undefined/`
2. **调用顺序约束**重要CLEAR_AUTH 时**先 `clearUser(userId)` 再清 token / user**。原因token 清掉之后任何业务发起的请求都会 401preload 缓存如果在 token 清之前清理完,业务不会有 401 + 缓存不一致的窗口。 2. **调用顺序约束**重要CLEAR_AUTH 时**先 `clearUser(userId)` 再清 token / user**。原因token 清掉之后任何业务发起的请求都会 401preload 缓存如果在 token 清之前清理完,业务不会有 401 + 缓存不一致的窗口。
3. **CLEAR_AUTH 内部已经清了一堆 key**`access_token / user / star_id` 等),与 `clearUser` 职责正交preload 只在 `_doc/preload/` 下操作,不碰 uni.storage。两者互不冲突组合使用即可。 3. **CLEAR_AUTH 内部已经清了一堆 key**`access_token / user / star_id` 等),与 `clearUser` 职责正交preload 只在 `_doc/preload/` 下操作,不碰 uni.storage。两者互不冲突组合使用即可。
4. **API 选型**`#ifdef APP-PLUS` 主路径走 `plus.io`(项目已有先例,`useShare.js` 里用了 `plus.io.copyTo` 操作 `_doc/``#ifdef H5` / 小程序等降级用 `uni.getFileSystemManager`(仅当 persist=file 时走文件memory 不变)。 4. **API 选型**`#ifdef APP-PLUS` 主路径走 `plus.io`(项目已有先例,`useShare.js` 里用了 `plus.io.copyTo` 操作 `_doc/``#ifdef H5` / 小程序等降级用 `uni.getFileSystemManager`(仅当 persist=file 时走文件memory 不变)。注意:`plus.io` API 均为回调风格storage.js 内部需用 `new Promise((resolve, reject) => { plus.io.xxx(..., resolve, reject) })` 包装为 Promise参照 `useShare.js``copyStaticToDoc` 的 promisify 模式)。`uni.getFileSystemManager` 在 APP-PLUS 也可用且支持 Promise优先使用仅在需要递归删目录等 `uni.getFileSystemManager` 不支持的操作时走 `plus.io`
### 6.5.1 App.vue 集成方式Options API
**App.vue 当前使用 Options API**`export default { … }`),与项目中 composable`<script setup>`)风格不同,但集成方式很简单 —— 直接 `import` + 在生命周期方法内调用即可,不需要 ref/wrap 等:
```js
// App.vue <script>
import preloadApi from '@/utils/preloadApi'
export default {
onLaunch: function() {
// ... 现有逻辑 ...
preloadApi.warmStartup() // 新增:启动期预热
},
onShow: function() {
// ... 现有逻辑 ...
preloadApi.warmIdle() // 新增idle 预拉(幂等,多次调用安全)
},
// ...
}
```
**无需将 App.vue 迁移到 Composition API**。`import` + 直接函数调用即可覆盖所有集成需求。
### 6.6 与 `useBackgroundRefresh` 的协作 ### 6.6 与 `useBackgroundRefresh` 的协作
@ -516,6 +636,22 @@ const idle = typeof requestIdleCallback === 'function'
| composable 测试 | `usePreload` 暴露的响应式 state 正确性 | mock core | | composable 测试 | `usePreload` 暴露的响应式 state 正确性 | mock core |
| 集成冒烟 | App.vue 启动预热、wrappedNavigateTo 跳转 | HBuilderX 真机 | | 集成冒烟 | App.vue 启动预热、wrappedNavigateTo 跳转 | HBuilderX 真机 |
### 8.2 验收清单
- [ ] `preloadApi.run` / `get` / `invalidate*` 单测通过
- [ ] wrappedNavigateTo 命中/未命中均通过单测
- [ ] usePreload composable 暴露响应式 state 通过单测
- [ ] 401 / 业务码 7、16 被 `_swallowAuth` 正确吞掉(核心 + navigate + composable 各 1 个 case
- [ ] query string 含 `encodeURIComponent` 字符(如中文)解析正确
- [ ] App.vue 真机onLaunch 后立即访问 startup 项 key命中内存缓存
- [ ] wrappedNavigateTo 真机:列表页跳详情页,详情页 onLoad 时立即命中预拉缓存loading 闪烁 < 50ms
- [ ] 登出后 `_doc/preload/{userId}/` 目录被删除
- [ ] LRU 淘汰边界:
- 塞 100 个 key第 1 个**不**被淘汰
- 塞 101 个 key第 1 个被淘汰
- [ ] warmIdle 幂等:同进程内连续调 3 次fetcher 只被调用 1 次
- [ ] 用户切换onSwitchUserme.* 前缀全失效、oldUser 的文件缓存目录已删除、newUser 缓存不受影响
### 8.3 调试与可观测性 ### 8.3 调试与可观测性
**开发者工具**`import.meta.env.DEV` 时启用): **开发者工具**`import.meta.env.DEV` 时启用):
@ -543,22 +679,6 @@ window.__PRELOAD_DEBUG__ = {
- 清理事件:不记日志(正常路径) - 清理事件:不记日志(正常路径)
- LRU 淘汰:`console.log('[preload] LRU evict:', oldestKey)` - LRU 淘汰:`console.log('[preload] LRU evict:', oldestKey)`
### 8.2 验收清单
- [ ] `preloadApi.run` / `get` / `invalidate*` 单测通过
- [ ] wrappedNavigateTo 命中/未命中均通过单测
- [ ] usePreload composable 暴露响应式 state 通过单测
- [ ] 401 / 业务码 7、16 被 `_swallowAuth` 正确吞掉(核心 + navigate + composable 各 1 个 case
- [ ] query string 含 `encodeURIComponent` 字符(如中文)解析正确
- [ ] App.vue 真机onLaunch 后立即访问 startup 项 key命中内存缓存
- [ ] wrappedNavigateTo 真机:列表页跳详情页,详情页 onLoad 时立即命中预拉缓存loading 闪烁 < 50ms
- [ ] 登出后 `_doc/preload/{userId}/` 目录被删除
- [ ] LRU 淘汰边界:
- 塞 100 个 key第 1 个**不**被淘汰
- 塞 101 个 key第 1 个被淘汰
- [ ] warmIdle 幂等:同进程内连续调 3 次fetcher 只被调用 1 次
- [ ] 用户切换onSwitchUserme.* 前缀全失效、oldUser 的文件缓存目录已删除、newUser 缓存不受影响
--- ---
## 9. 风险与缓解 ## 9. 风险与缓解
@ -580,14 +700,15 @@ window.__PRELOAD_DEBUG__ = {
## 10. 实施清单(落到 writing-plans 阶段再细化) ## 10. 实施清单(落到 writing-plans 阶段再细化)
0. `frontend/utils/api.js``request()` 返回的 Promise 上挂 `.abort()` 方法(约 3 行改动 0. `frontend/utils/api.js``request()` 返回的 Promise 上挂 `.abort()` 方法(~8 行改动:捕获 requestTask + 挂 abort() + fail 中识别 `_aborted` 标记,详见 §4.4.1
1. `frontend/utils/preloadApi/storage.js` 1. `frontend/utils/preloadApi/storage.js`plus.io 回调 promisify 处理,详见 §6.5
2. `frontend/utils/preloadApi/core.js` 2. `frontend/utils/preloadApi/core.js`
3. `frontend/utils/preloadApi/scheduler.js` 3. `frontend/utils/preloadApi/scheduler.js`
4. `frontend/utils/preloadApi/navigate.js` 4. `frontend/utils/preloadApi/navigate.js`
5. `frontend/utils/preloadApi/index.js` 5. `frontend/utils/preloadApi/index.js`
6. `frontend/composables/usePreload.js` 6. `frontend/composables/usePreload.js`
7. `frontend/config/preload.config.js`(初始内容,含 castlove.config / me.profile / ranking.hot / asset-detail / activity-detail 7. `frontend/config/preload.config.js`(初始内容,含 castlove.config / me.profile / ranking.hot / asset-detail / activity-detail
8. `frontend/App.vue`(注入启动 + 登出监听) 8. `frontend/App.vue`Options API 集成import + onLaunch 调用 warmStartup() + onShow 调用 warmIdle(),详见 §6.5.1
9. `frontend/utils/preloadApi/__tests__/`(核心 + 文件缓存适配器 + navigate + composable 9. `frontend/store/modules/user.js`SET_USER_INFO / CLEAR_AUTH mutation patch 注入 preload 缓存失效,详见 §6.5
10. README 文档片段(放在 `frontend/utils/preloadApi/README.md`,供业务开发查阅 config schema + API 速查表) 10. `frontend/utils/preloadApi/__tests__/`(核心 + 文件缓存适配器 + navigate + composable
11. README 文档片段(放在 `frontend/utils/preloadApi/README.md`,供业务开发查阅 config schema + API 速查表)

View File

@ -3,6 +3,8 @@ import { getGlobalSocket } from "@/utils/socket";
import { emitAppReturnFromBackground } from "@/utils/backgroundRefreshBus.js"; import { emitAppReturnFromBackground } from "@/utils/backgroundRefreshBus.js";
import { registerDeviceApi, unregisterDeviceApi } from "@/utils/api.js"; import { registerDeviceApi, unregisterDeviceApi } from "@/utils/api.js";
import { getDeviceFingerprint } from "@/utils/deviceFingerprint.js"; import { getDeviceFingerprint } from "@/utils/deviceFingerprint.js";
import { initPreloadApi, setPreloadApi, getPreloadApi } from '@/utils/preloadApi/index'
import { preloadConfig } from '@/config/preload.config'
// storage key // storage key
const HIDE_TIME_KEY = "app_last_hide_time"; const HIDE_TIME_KEY = "app_last_hide_time";
@ -48,6 +50,16 @@ export default {
// getDeviceFingerprint setStorageSync // getDeviceFingerprint setStorageSync
getDeviceFingerprint(); getDeviceFingerprint();
// AI Chat // AI Chat
// API
try {
const api = initPreloadApi(preloadConfig)
setPreloadApi(api)
api.warmStartup()
} catch (e) {
console.warn('[preload] init failed:', e.message)
}
this.setPermissions(); this.setPermissions();
}, },
onShow: function () { onShow: function () {
@ -69,6 +81,14 @@ export default {
this.handleBackgroundReturn(); this.handleBackgroundReturn();
// idle
try {
const api = getPreloadApi()
if (api) api.warmIdle()
} catch (e) {
// silent
}
// WebSocketonHide // WebSocketonHide
this.initWebSocket(); this.initWebSocket();

View File

@ -0,0 +1,91 @@
// frontend/composables/usePreload.js
// Vue 3 组合式 API包装 core.get(),暴露响应式 state { data, loading, error, refresh }
import { ref, getCurrentInstance, onBeforeUnmount, watch } from 'vue'
import { get, refresh as coreRefresh, abortRequest } from '@/utils/preloadApi/core'
/**
* @param {string|Ref<string>} key - 逻辑 key
* @param {object|Ref<object>} [params] - 请求参数
* @returns {{ data: Ref, loading: Ref, error: Ref, refresh: Function }}
*
* @example
* const { data, loading, error, refresh } = usePreload('asset.detail', { id: 123 })
* // With reactive params:
* const { data, loading, error } = usePreload('asset.detail', () => ({ id: route.params.id }))
*/
export function usePreload(key, params) {
// 校验上下文
if (!getCurrentInstance()) {
console.warn('[preload] usePreload must be called in setup()')
}
const data = ref(null)
const loading = ref(true)
const error = ref(null)
let mounted = true
let fetchVersion = 0
/**
* 执行获取不阻塞 setup
* @param {boolean} [force=false] - 跳过 TTL 缓存
*/
function doFetch(force = false) {
// 先清理上一轮 in-flight 请求
abortRequest(key, params)
const version = ++fetchVersion
loading.value = true
error.value = null
// 用 .then() 异步更新 data不阻塞 setup
const promise = force
? coreRefresh(key, params, true)
: get(key, params)
promise
.then((result) => {
if (!mounted || version !== fetchVersion) return
data.value = result
loading.value = false
})
.catch((err) => {
if (!mounted || version !== fetchVersion) return
error.value = err
loading.value = false
})
return promise
}
// 监听 params 变化key 通常是静态字符串params 动态时传入 getter
// 普通值包装为 getter 确保 Vue watch source 合法
const resolvedParams = typeof params === 'function' ? params : () => params
watch(
resolvedParams,
() => {
if (mounted) doFetch()
},
{ deep: true }
)
// 初始加载
doFetch()
// 组件卸载时清理
onBeforeUnmount(() => {
mounted = false
abortRequest(key, params)
})
/**
* 手动刷新
* @param {boolean} [force=false] - 跳过 TTL
*/
function refresh(force = false) {
return doFetch(force)
}
return { data, loading, error, refresh }
}

View File

@ -0,0 +1,262 @@
// frontend/config/preload.config.js
// API 预加载业务配置
// 声明每个预拉 key 的fetcher / ttl / persistence / 触发时机
import {
getCastloveConfigApi,
getUserProfileApi,
getHotRankingApi,
getAssetLikersApi,
getActivityDetailApi,
getActivityItemsApi,
getInspirationFlowApi,
getActivityListApi,
getMintingActivitiesApi,
dashboardApi,
getStarbookHomeApi,
getMyGalleriesApi,
getMyExhibitedAssetsApi,
getMyLikedAssetsApi,
getStarbookItemsApi,
getUserGalleriesApi,
getUserLikedAssetsApi,
getEarningsSummaryApi,
listActivityMessagesApi,
getActivityTopRankingApi,
getActivityProgressApi,
} from "@/utils/api";
export const preloadConfig = {
// ── 全局默认 ──
defaults: {
ttl: 5 * 60 * 1000,
persistence: "memory",
concurrency: 4,
timeout: 10000,
silent: true,
limits: {
maxEntrySizeKB: 1024,
maxMemoryEntries: 100,
maxFileCacheMB: 50,
},
},
// ── 启动期预热清单App.vue onLaunch 跑)──
startup: [
{
key: "castlove.config",
fetcher: getCastloveConfigApi,
ttl: 60 * 60 * 1000,
persistence: "file",
},
{
key: "me.profile",
fetcher: getUserProfileApi,
ttl: 10 * 60 * 1000,
},
],
// ── idle 预拉清单(首屏渲染完后跑)──
idle: [
{
key: "ranking.hot",
fetcher: () => getHotRankingApi("total", null, 1, 10),
ttl: 10 * 60 * 1000,
},
// banner
{
key: "banner.activities",
fetcher: () =>
getActivityListApi(uni.getStorageSync("star_id") || null, 1, 10),
ttl: 5 * 60 * 1000,
},
{
key: "banner.minting",
fetcher: () => getMintingActivitiesApi(null, 1, 10),
ttl: 5 * 60 * 1000,
},
// square 页面三个 tab 的首屏数据
{
key: "ranking.xinghe",
fetcher: () => getHotRankingApi("displaying", null, 1, 12),
ttl: 5 * 60 * 1000,
},
{
key: "ranking.xingbang",
fetcher: () => getHotRankingApi("displaying", null, 1, 10),
ttl: 5 * 60 * 1000,
},
{
key: "inspiration.flow",
fetcher: () =>
getInspirationFlowApi({ limit: 20, type: "hot", cursor: "" }),
ttl: 3 * 60 * 1000,
},
],
// ── 页面切换预拉映射wrappedNavigateTo 命中时触发)──
pages: {
"/pages/asset-detail/asset-detail": [
{
key: "asset.likers",
fetcher: (params) => getAssetLikersApi(Number(params.asset_id)),
},
],
"/pages/activity-detail/activity-detail": [
{
key: "activity.detail",
fetcher: (params) => getActivityDetailApi(params.id),
},
{
key: "activity.items",
fetcher: (params) => getActivityItemsApi(params.id),
},
],
"/pages/support-activity/index": [
{
key: "activity.detail",
fetcher: (params) => getActivityDetailApi(params.id),
},
{
key: "activity.items",
fetcher: (params) => getActivityItemsApi(params.id),
},
{
key: "activity.earnings",
fetcher: () => getEarningsSummaryApi(),
ttl: 5 * 60 * 1000,
},
{
key: "activity.messages",
fetcher: (params) => listActivityMessagesApi(params.id, 1, 20),
ttl: 30 * 1000,
},
{
key: "activity.topRanking",
fetcher: (params) =>
getActivityTopRankingApi(
params.id,
uni.getStorageSync("star_id") || null,
),
ttl: 3 * 60 * 1000,
},
{
key: "activity.progress",
fetcher: (params) => getActivityProgressApi(params.id),
ttl: 30 * 1000,
},
],
"/pages/support-activity/center": [
{
key: "activity.centerList",
fetcher: () =>
getActivityListApi(uni.getStorageSync("star_id") || null, 1, 50),
ttl: 5 * 60 * 1000,
},
],
"/pages/profile/profile": [
{
key: "me.profile",
fetcher: () => getUserProfileApi(),
ttl: 10 * 60 * 1000,
},
],
"/pages/starbook/index": [
{
key: "starbook.home",
fetcher: () => getStarbookHomeApi(),
ttl: 10 * 60 * 1000,
},
],
"/pages/starbook/items": [
{
key: "starbook.items",
fetcher: (params) =>
getStarbookItemsApi(
params.type || "regular",
params.category || "castlove",
params.grade ? Number(params.grade) : null,
1,
20,
),
ttl: 5 * 60 * 1000,
},
],
"/pages/profile/hisWorks": [
{
key: "hisWorks.galleries",
fetcher: (params) => getUserGalleriesApi(params.userId),
ttl: 5 * 60 * 1000,
},
{
key: "hisWorks.liked",
fetcher: (params) => getUserLikedAssetsApi(params.userId, 1, 20),
ttl: 3 * 60 * 1000,
},
],
"/pages/profile/myWorks": [
{
key: "myWorks.galleries",
fetcher: () => getMyGalleriesApi(),
ttl: 5 * 60 * 1000,
},
{
key: "myWorks.exhibited",
fetcher: () => getMyExhibitedAssetsApi(1, 20),
ttl: 3 * 60 * 1000,
},
{
key: "myWorks.liked",
fetcher: () => getMyLikedAssetsApi(1, 20),
ttl: 3 * 60 * 1000,
},
],
"/pages/dashboard/dashboard": [
{
key: "dashboard.today",
fetcher: () =>
dashboardApi.getTodayOverview(uni.getStorageSync("star_id") || null),
},
{
key: "dashboard.curve",
fetcher: () =>
dashboardApi.get7DayIncomeCurve(
uni.getStorageSync("star_id") || null,
),
},
{
key: "dashboard.exhibition",
fetcher: () =>
dashboardApi.getExhibitionSummary(
uni.getStorageSync("star_id") || null,
),
},
{
key: "dashboard.likeIncome",
fetcher: () =>
dashboardApi.getLikeIncomeByLevel(
uni.getStorageSync("star_id") || null,
),
},
{
key: "dashboard.topAssets",
fetcher: () =>
dashboardApi.getTopAssets(uni.getStorageSync("star_id") || null),
},
{
key: "dashboard.levels",
fetcher: () =>
dashboardApi.getLevelDistribution(
uni.getStorageSync("star_id") || null,
),
},
{
key: "dashboard.upgrades",
fetcher: () =>
dashboardApi.getUpgradeProgress(
uni.getStorageSync("star_id") || null,
),
},
],
},
};

View File

@ -242,6 +242,7 @@
import { ref, computed, onUnmounted } from 'vue'; import { ref, computed, onUnmounted } from 'vue';
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'; import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app';
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi, getAssetMaterialsApi, getAssetLikersApi } from '@/utils/api.js'; import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi, getAssetMaterialsApi, getAssetLikersApi } from '@/utils/api.js';
import { getPreloadApi } from '@/utils/preloadApi/index';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js'; import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
import LikeUsersModal from '@/pages/components/LikeUsersModal.vue'; import LikeUsersModal from '@/pages/components/LikeUsersModal.vue';
import ShareReportButtons from '@/pages/components/ShareReportButtons.vue'; import ShareReportButtons from '@/pages/components/ShareReportButtons.vue';
@ -472,9 +473,10 @@ const handleLikeUsersTabChange = ({ tabIndex }) => {
likeUsersActiveTab.value = tabIndex; likeUsersActiveTab.value = tabIndex;
}; };
// 访 // 访
const handleVisit = (user) => { const handleVisit = (user) => {
uni.navigateTo({ const api = getPreloadApi();
(api || uni).navigateTo({
url: `/pages/profile/hisWorks?userId=${user.userId}&nickname=${encodeURIComponent(user.nickname)}` url: `/pages/profile/hisWorks?userId=${user.userId}&nickname=${encodeURIComponent(user.nickname)}`
}); });
}; };

View File

@ -143,6 +143,7 @@ import { useBanner } from "@/pages/square/composables/useBanner.js";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js"; import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js";
import { reportEvent } from "@/utils/task-api.js"; import { reportEvent } from "@/utils/task-api.js";
import { getEarningsSummaryApi } from "@/utils/api.js"; import { getEarningsSummaryApi } from "@/utils/api.js";
import { getPreloadApi } from "@/utils/preloadApi/index";
// square useBanner // square useBanner
const { bannerActivities, loadBannerActivities } = useBanner(); const { bannerActivities, loadBannerActivities } = useBanner();
@ -363,9 +364,10 @@ const handleBack = () => {
} }
} }
}; };
// // 7
const handleClick = () => { const handleClick = () => {
uni.navigateTo({ const api = getPreloadApi();
(api || uni).navigateTo({
url: "/pages/dashboard/dashboard", url: "/pages/dashboard/dashboard",
}); });
}; };
@ -377,8 +379,9 @@ const handleTaskClick = () => {
// //
const handleActivityClick = (activity) => { const handleActivityClick = (activity) => {
// activity // API
uni.navigateTo({ const api = getPreloadApi();
(api || uni).navigateTo({
url: "/pages/support-activity/center", url: "/pages/support-activity/center",
fail: (err) => { fail: (err) => {
console.error("[Header] 跳转活动中心失败:", err); console.error("[Header] 跳转活动中心失败:", err);
@ -453,7 +456,8 @@ const handleAvatarClick = () => {
return; return;
} }
} }
uni.navigateTo({ const api = getPreloadApi();
(api || uni).navigateTo({
url: "/pages/profile/profile", url: "/pages/profile/profile",
}); });
}; };

View File

@ -194,6 +194,7 @@ import { ref, computed, onMounted, onActivated, onDeactivated, nextTick, watch }
import { onShow } from '@dcloudio/uni-app'; import { onShow } from '@dcloudio/uni-app';
import { getStarbookHomeApi } from '@/utils/api.js'; import { getStarbookHomeApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js'; import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
import { getPreloadApi } from '@/utils/preloadApi/index';
// //
const screenWidth = ref(0); const screenWidth = ref(0);
@ -316,10 +317,11 @@ const processGroupsWithValidUrls = async (groups) => {
processedData.value = processed; processedData.value = processed;
}; };
// //
const handleCardClick = (item) => { const handleCardClick = (item) => {
if (item.asset_id) { if (item.asset_id) {
uni.navigateTo({ const api = getPreloadApi();
(api || uni).navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${item.asset_id}` url: `/pages/asset-detail/asset-detail?asset_id=${item.asset_id}`
}); });
} }
@ -337,7 +339,8 @@ const goToMore = (group, grade) => {
const queryString = Object.entries(params) const queryString = Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`) .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&'); .join('&');
uni.navigateTo({ const api = getPreloadApi();
(api || uni).navigateTo({
url: `/pages/starbook/items?${queryString}` url: `/pages/starbook/items?${queryString}`
}); });
}; };

View File

@ -601,6 +601,7 @@ import {
} from "@/utils/api"; } from "@/utils/api";
import { validateNickname, validatePassword } from "@/utils/validator.js"; import { validateNickname, validatePassword } from "@/utils/validator.js";
import { clearAvatarCache } from "@/utils/avatarCache"; import { clearAvatarCache } from "@/utils/avatarCache";
import { getPreloadApi } from "@/utils/preloadApi/index";
import GuideModal from "@/pages/tasks/GuideModal.vue"; import GuideModal from "@/pages/tasks/GuideModal.vue";
import GuideOverlay from "@/components/GuideOverlay.vue"; import GuideOverlay from "@/components/GuideOverlay.vue";
import FeedbackModal from "../components/FeedbackModal.vue"; import FeedbackModal from "../components/FeedbackModal.vue";
@ -885,18 +886,13 @@ const copyAddress = () => {
}); });
}; };
// // API
const handleAssetClick = (type) => { const handleAssetClick = (type) => {
const api = getPreloadApi();
if (type === "myWorks") { if (type === "myWorks") {
// (api || uni).navigateTo({ url: "/pages/profile/myWorks" });
uni.navigateTo({
url: "/pages/profile/myWorks",
});
} else if (type === "starbook") { } else if (type === "starbook") {
// (api || uni).navigateTo({ url: "/pages/starbook/index" });
uni.navigateTo({
url: "/pages/starbook/index",
});
} }
}; };

View File

@ -111,48 +111,30 @@ import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { onShow, onHide } from "@dcloudio/uni-app"; import { onShow, onHide } from "@dcloudio/uni-app";
import { getInspirationFlowApi } from '@/utils/api.js' import { getInspirationFlowApi } from '@/utils/api.js'
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js' import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js'
import { usePreload } from "@/composables/usePreload.js";
// prop/
const emit = defineEmits(['cardClick', 'loaded']) const emit = defineEmits(['cardClick', 'loaded'])
// ========== ========== // ========== ==========
const creationList = ref([]) const creationList = ref([])
const cursor = ref('') const cursor = ref('')
const isLoading = ref(false) const isLoading = ref(true)
const noMore = ref(false) const noMore = ref(false)
const likingMap = ref({}) const likingMap = ref({})
const activeCategory = ref('hot') // const activeCategory = ref('hot')
const isComponentActive = ref(true) // const isComponentActive = ref(true)
// ========== ATab ========== // usePreload + tab
const { data: firstPageData, loading: firstPageLoading } = usePreload("inspiration.flow");
// ========== ATab ==========
const mainTabs = [ const mainTabs = [
{ { name: '星卡', type: 'star_card', icon: '/static/square/xingka.png', width: 96, height: 96, rotate: -15 },
name: '星卡', { name: '吧唧', type: 'badge', icon: '/static/square/baji.png', width: 100, height: 100, rotate: 0 },
type: 'star_card', { name: '海报', type: 'poster', icon: '/static/square/haibao.png', width: 100, height: 108, rotate: -15 },
icon: '/static/square/xingka.png',
width: 96,
height: 96,
rotate: -15,
},
{
name: '吧唧',
type: 'badge',
icon: '/static/square/baji.png',
width: 100,
height: 100,
rotate: 0,
},
{
name: '海报',
type: 'poster',
icon: '/static/square/haibao.png',
width: 100,
height: 108,
rotate: -15,
},
] ]
// ========== B ========== // ========== B ==========
const categories = [ const categories = [
{ label: '热门作品', value: 'hot' }, { label: '热门作品', value: 'hot' },
{ label: '最新作品', value: 'new' }, { label: '最新作品', value: 'new' },
@ -161,47 +143,30 @@ const categories = [
{ label: '海报', value: 'poster' }, { label: '海报', value: 'poster' },
] ]
// ========== refcategoryRef ==========
const categoryRef = ref(null) const categoryRef = ref(null)
// ========== fixed ==========
const categoryOffsetTop = ref(null) const categoryOffsetTop = ref(null)
const categoryHeight = ref(0) const categoryHeight = ref(0)
const fixedTopPx = ref(50) // CSS top: 96rpx const fixedTopPx = ref(50)
const isFixed = ref(false) const isFixed = ref(false)
// ========== ========== // Tab
// Tab -
const handleMainTabClick = (tab) => { const handleMainTabClick = (tab) => {
uni.navigateTo({ uni.navigateTo({ url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}` })
url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}`,
})
} }
// - activeCategory
const handleCategoryChange = (value) => { const handleCategoryChange = (value) => {
if (activeCategory.value === value) return if (activeCategory.value === value) return
activeCategory.value = value activeCategory.value = value
} }
// ========== fixed ==========
/**
* 父级在 onScroll 时调用本方法根据 scrollTop 决定是否切到 fixed
* @param {Number} scrollTop 当前页面滚动距离px
*/
function updateScroll(scrollTop) { function updateScroll(scrollTop) {
if (categoryOffsetTop.value === null) return if (categoryOffsetTop.value === null) return
isFixed.value = scrollTop + fixedTopPx.value >= categoryOffsetTop.value isFixed.value = scrollTop + fixedTopPx.value >= categoryOffsetTop.value
} }
// rAF touchstart / touchmove function update() {}
function update() {
if (categoryOffsetTop.value === null) return
// hook updateScroll
}
// /
function remeasure() { function remeasure() {
nextTick(() => { nextTick(() => {
if (categoryRef.value) { if (categoryRef.value) {
@ -211,7 +176,6 @@ function remeasure() {
}) })
} }
// ========== ==========
const formatCount = (count) => { const formatCount = (count) => {
if (!count) return '0' if (!count) return '0'
if (count >= 10000) return (count / 10000).toFixed(1) + 'w' if (count >= 10000) return (count / 10000).toFixed(1) + 'w'
@ -223,7 +187,6 @@ const handleCardClick = (item) => {
emit('cardClick', item) emit('cardClick', item)
} }
// cover_url 访 URLOSS
async function resolveItemImage(item) { async function resolveItemImage(item) {
if (!item) return item if (!item) return item
const cover = item.cover_url || item.cover_image || '' const cover = item.cover_url || item.cover_image || ''
@ -233,6 +196,24 @@ async function resolveItemImage(item) {
return item return item
} }
// items
async function transformItems(rawItems) {
return await Promise.all(
rawItems.map(async (item) => {
const resolved = await resolveItemImage({ ...item })
return {
id: item.asset_id,
certificate_id: item.asset_id,
cover_image: resolved.cover_image,
creator_avatar: item.owner_avatar || '',
creator_name: item.owner_nickname || item.name || '',
like_count: item.likes || item.like_count || 0,
is_liked: item.is_liked || false,
}
}),
)
}
const loadUsers = async () => { const loadUsers = async () => {
cursor.value = '' cursor.value = ''
isLoading.value = true isLoading.value = true
@ -240,23 +221,8 @@ const loadUsers = async () => {
try { try {
const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: '' }) const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: '' })
if (res.code === 0 && res.data?.items && res.data.items.length > 0) { if (res.code === 0 && res.data?.items && res.data.items.length > 0) {
const items = res.data.items
cursor.value = res.data.cursor || '' cursor.value = res.data.cursor || ''
// cover_url 访 URL creationList.value = await transformItems(res.data.items)
creationList.value = await Promise.all(
items.map(async (item) => {
const resolved = await resolveItemImage({ ...item })
return {
id: item.asset_id,
certificate_id: item.asset_id,
cover_image: resolved.cover_image,
creator_avatar: item.owner_avatar || '',
creator_name: item.owner_nickname || item.name || '',
like_count: item.likes || item.like_count || 0,
is_liked: item.is_liked || false,
}
}),
)
} else { } else {
noMore.value = true noMore.value = true
} }
@ -274,22 +240,8 @@ const loadMore = async () => {
try { try {
const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: cursor.value }) const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: cursor.value })
if (res.code === 0 && res.data?.items && res.data.items.length > 0) { if (res.code === 0 && res.data?.items && res.data.items.length > 0) {
const items = res.data.items
cursor.value = res.data.cursor || '' cursor.value = res.data.cursor || ''
const newItems = await Promise.all( const newItems = await transformItems(res.data.items)
items.map(async (item) => {
const resolved = await resolveItemImage({ ...item })
return {
id: item.asset_id,
certificate_id: item.asset_id,
cover_image: resolved.cover_image,
creator_avatar: item.owner_avatar || '',
creator_name: item.owner_nickname || item.name || '',
like_count: item.likes || item.like_count || 0,
is_liked: item.is_liked || false,
}
}),
)
creationList.value = [...creationList.value, ...newItems] creationList.value = [...creationList.value, ...newItems]
} else { } else {
noMore.value = true noMore.value = true
@ -302,12 +254,40 @@ const loadMore = async () => {
} }
} }
// // watch usePreload
let firstPageConsumed = false;
watch(firstPageLoading, (val) => { isLoading.value = val });
watch(firstPageData, async (newData) => {
if (!newData || newData.code !== 0 || firstPageConsumed) return;
firstPageConsumed = true;
isLoading.value = false;
try {
const items = newData.data?.items || [];
if (items.length > 0) {
creationList.value = await transformItems(items);
} else {
noMore.value = true;
}
} catch (e) {
console.error('[CreationGrid] transform 失败', e?.message ?? e);
}
emit('loaded', creationList.value.length);
}, { immediate: true });
// fallback1.5s usePreload
let fallbackTimer = null;
onMounted(() => {
fallbackTimer = setTimeout(() => {
if (firstPageConsumed) return;
loadUsers();
}, 1500);
});
//
watch(activeCategory, () => { watch(activeCategory, () => {
loadUsers() loadUsers()
}) })
//
watch(isComponentActive, (active) => { watch(isComponentActive, (active) => {
if (active && creationList.value.length === 0) { if (active && creationList.value.length === 0) {
loadUsers() loadUsers()
@ -315,8 +295,6 @@ watch(isComponentActive, (active) => {
}) })
onMounted(() => { onMounted(() => {
loadUsers()
uni.$on('assetLiked', ({ asset_id, data }) => { uni.$on('assetLiked', ({ asset_id, data }) => {
likingMap.value = { ...likingMap.value, [asset_id]: true } likingMap.value = { ...likingMap.value, [asset_id]: true }
setTimeout(() => { setTimeout(() => {
@ -334,7 +312,6 @@ onMounted(() => {
} }
}) })
// rpx px +
const info = uni.getSystemInfoSync() const info = uni.getSystemInfoSync()
const sw = info.windowWidth || 750 const sw = info.windowWidth || 750
fixedTopPx.value = (96 * sw) / 750 fixedTopPx.value = (96 * sw) / 750
@ -346,25 +323,16 @@ onMounted(() => {
}) })
}) })
onShow(() => { onShow(() => { isComponentActive.value = true })
isComponentActive.value = true onHide(() => { isComponentActive.value = false })
})
onHide(() => {
isComponentActive.value = false
})
onUnmounted(() => { onUnmounted(() => {
uni.$off('assetLiked') uni.$off('assetLiked')
if (fallbackTimer) clearTimeout(fallbackTimer);
}) })
// ========== ==========
defineExpose({ defineExpose({
loadMore, loadMore, categoryRef, updateScroll, update, remeasure,
categoryRef,
updateScroll,
update,
remeasure,
}) })
</script> </script>

View File

@ -161,9 +161,12 @@ import {
getAssetCoverRealUrl, getAssetCoverRealUrl,
getInstantAssetCoverUrl, getInstantAssetCoverUrl,
} from "@/utils/assetImageHelper.js"; } from "@/utils/assetImageHelper.js";
import { usePreload } from "@/composables/usePreload.js";
// usePreload + tab
const { data: firstPageData, loading: firstPageLoading } = usePreload("ranking.xingbang");
// cover_url / cover_image 访 URL // cover_url / cover_image 访 URL
// 3 /static/... () ( presign) URL ()
async function resolveItemUrls(item) { async function resolveItemUrls(item) {
if (!item) return item; if (!item) return item;
const cover = item.cover_url || item.cover_image || ""; const cover = item.cover_url || item.cover_image || "";
@ -176,30 +179,21 @@ async function resolveItemUrls(item) {
const emit = defineEmits(["cardClick"]); const emit = defineEmits(["cardClick"]);
const items = ref([]); const items = ref([]);
const loading = ref(false); const loading = ref(true);
const likingMap = ref({}); const likingMap = ref({});
const activeTabKey = ref(""); const activeTabKey = ref("");
// scroll-view :refresher-triggered
const refreshing = ref(false); const refreshing = ref(false);
// ===== ===== // ===== =====
// currentPage : 1
// hasMore : < PAGE_SIZE >= total false
// loadingMore : scrolltolower
const currentPage = ref(1); const currentPage = ref(1);
const hasMore = ref(true); const hasMore = ref(true);
const loadingMore = ref(false); const loadingMore = ref(false);
// scroll-into-view id tab 'grid-top' scroll-view
// nextTick id 使 id
const scrollIntoView = ref(""); const scrollIntoView = ref("");
//
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
// Tab // Tab
// tab push { key, label, icon, iconWidth, iconHeight, fetch }
// fetch page
const tabs = [ const tabs = [
{ {
key: "hot", key: "hot",
@ -253,7 +247,7 @@ const activeTab = computed(
() => tabs.find((t) => t.key === activeTabKey.value) || tabs[0], () => tabs.find((t) => t.key === activeTabKey.value) || tabs[0],
); );
// activeTabKey tab // activeTabKey
watch( watch(
() => tabs, () => tabs,
(newTabs) => { (newTabs) => {
@ -267,6 +261,65 @@ watch(
{ immediate: true }, { immediate: true },
); );
// watch usePreload items
let firstPageConsumed = false;
watch(firstPageLoading, (val) => { loading.value = val }); //
watch(firstPageData, (newData) => {
if (!newData || newData.code !== 0 || firstPageConsumed) return;
firstPageConsumed = true;
loading.value = false;
processRawItems(newData.data?.items || [], false);
}, { immediate: true });
// fallback1.5s usePreload fetch
let fallbackTimer = null;
onMounted(() => {
fallbackTimer = setTimeout(() => {
if (firstPageConsumed) return;
loadData({ append: false });
}, 1500);
});
onUnmounted(() => {
if (fallbackTimer) clearTimeout(fallbackTimer);
});
// items
function processRawItems(rawItems, append) {
const instant = rawItems.map((it) => {
const id = it.id || it.asset_id;
const rawCover = it.cover_url || it.cover_image || "";
return {
...it,
id,
_rawCover: rawCover,
cover_url: getInstantAssetCoverUrl(rawCover),
};
});
const baseOffset = append ? items.value.length : 0;
if (append) {
items.value = [...items.value, ...instant];
} else {
items.value = instant;
}
// URL
instant.forEach((it, idx) => {
const raw = it._rawCover;
if (!raw) return;
getAssetCoverRealUrl(raw)
.then((realUrl) => {
const targetIdx = baseOffset + idx;
const target = items.value[targetIdx];
if (target && target.id === it.id && realUrl) {
target.cover_url = realUrl;
}
})
.catch(() => {});
});
}
// tab // tab
const handleTabClick = (e) => { const handleTabClick = (e) => {
const key = e.currentTarget.dataset.key; const key = e.currentTarget.dataset.key;
@ -275,12 +328,11 @@ const handleTabClick = (e) => {
} }
}; };
// activeTab // activeTab fetch
watch(activeTab, () => { watch(activeTab, () => {
resetAndLoad(); resetAndLoad();
}); });
//
const formatCount = (count) => { const formatCount = (count) => {
if (!count) return "0"; if (!count) return "0";
if (count >= 10000) return (count / 10000).toFixed(1) + "w"; if (count >= 10000) return (count / 10000).toFixed(1) + "w";
@ -292,7 +344,7 @@ const handleCardClick = (item) => {
emit("cardClick", item); emit("cardClick", item);
}; };
// //
const onAssetLiked = ({ asset_id, data }) => { const onAssetLiked = ({ asset_id, data }) => {
const index = items.value.findIndex( const index = items.value.findIndex(
(item) => (item.asset_id || item.id) === asset_id, (item) => (item.asset_id || item.id) === asset_id,
@ -306,7 +358,6 @@ const onAssetLiked = ({ asset_id, data }) => {
data?.new_like_count ?? (updatedItems[index].like_count || 0) + 1, data?.new_like_count ?? (updatedItems[index].like_count || 0) + 1,
}; };
items.value = updatedItems; items.value = updatedItems;
//
likingMap.value = { ...likingMap.value, [asset_id]: true }; likingMap.value = { ...likingMap.value, [asset_id]: true };
setTimeout(() => { setTimeout(() => {
likingMap.value = { ...likingMap.value, [asset_id]: false }; likingMap.value = { ...likingMap.value, [asset_id]: false };
@ -314,14 +365,11 @@ const onAssetLiked = ({ asset_id, data }) => {
} }
}; };
// 1
// tab items currentPage 1hasMore true
const resetAndLoad = () => { const resetAndLoad = () => {
currentPage.value = 1; currentPage.value = 1;
hasMore.value = true; hasMore.value = true;
loadingMore.value = false; loadingMore.value = false;
items.value = []; items.value = [];
// id 'grid-top'
scrollIntoView.value = ""; scrollIntoView.value = "";
nextTick(() => { nextTick(() => {
scrollIntoView.value = "grid-top"; scrollIntoView.value = "grid-top";
@ -329,23 +377,13 @@ const resetAndLoad = () => {
loadData({ append: false }); loadData({ append: false });
}; };
// const PLACEHOLDER_IMAGE = "/static/nft/collection.png";
// append=false itemsappend=true items
// // fetch tab / / 使
// await
// 1) getInstantAssetCoverUrl URL/URL/
// 2) set items.value 线
// 3) getAssetCoverRealUrl URL patch items.value[i].cover_url
// Vue 3 ref reactive
const loadData = async ({ append = false, silent = false } = {}) => { const loadData = async ({ append = false, silent = false } = {}) => {
const tab = activeTab.value; const tab = activeTab.value;
if (!tab || typeof tab.fetch !== "function") { if (!tab || typeof tab.fetch !== "function") return false;
console.warn("[HotCategoryBlock] 当前 tab 未配置 fetch:", tab);
if (!append && !silent) items.value = [];
return false;
}
// loading loadingMore
// silent=true items/
if (append) { if (append) {
loadingMore.value = true; loadingMore.value = true;
} else if (!silent) { } else if (!silent) {
@ -354,60 +392,13 @@ const loadData = async ({ append = false, silent = false } = {}) => {
try { try {
const res = await tab.fetch(currentPage.value); const res = await tab.fetch(currentPage.value);
if (res && res.code === 0 && res.data?.items) { if (res && res.code === 0 && res.data?.items) {
// items processRawItems(res.data.items, append);
const rawItems = res.data.items.map((it) => {
const id = it.id || it.asset_id;
const rawCover = it.cover_url || it.cover_image || "";
return {
...it,
id,
_rawCover: rawCover, // URL
cover_url: getInstantAssetCoverUrl(rawCover),
};
});
//
const baseOffset = append ? items.value.length : 0;
if (append) {
items.value = [...items.value, ...rawItems];
} else {
items.value = rawItems;
}
// URL patch
// await
rawItems.forEach((it, idx) => {
const raw = it._rawCover;
if (!raw) return;
// URL /
const instant = it.cover_url;
if (
instant &&
instant !== PLACEHOLDER_IMAGE &&
instant === getInstantAssetCoverUrl(raw)
) {
// URLgetAssetCoverRealUrl
}
getAssetCoverRealUrl(raw)
.then((realUrl) => {
const targetIdx = baseOffset + idx;
const target = items.value[targetIdx];
// tab / patch
if (target && target.id === it.id && realUrl) {
target.cover_url = realUrl;
}
})
.catch(() => {
/* 单张失败不影响其它图 */
});
});
//
const total = Number(res.data.total ?? 0); const total = Number(res.data.total ?? 0);
if (total > 0) { if (total > 0) {
hasMore.value = items.value.length < total; hasMore.value = items.value.length < total;
} else { } else {
hasMore.value = rawItems.length >= PAGE_SIZE; hasMore.value = res.data.items.length >= PAGE_SIZE;
} }
return true; return true;
} else if (!append) { } else if (!append) {
@ -426,22 +417,12 @@ const loadData = async ({ append = false, silent = false } = {}) => {
} }
}; };
// helper DEFAULT_IMAGE
const PLACEHOLDER_IMAGE = "/static/nft/collection.png";
//
const handleScrollToLower = () => { const handleScrollToLower = () => {
if (loading.value || loadingMore.value || !hasMore.value) return; if (loading.value || loadingMore.value || !hasMore.value) return;
currentPage.value += 1; currentPage.value += 1;
loadData({ append: true }); loadData({ append: true });
}; };
//
//
// - items.value
// - scrollIntoView
// - silent=true loadData loading
// - items.valuescroll-view
const handleRefresh = async () => { const handleRefresh = async () => {
if (refreshing.value || loading.value) return; if (refreshing.value || loading.value) return;
refreshing.value = true; refreshing.value = true;
@ -451,25 +432,13 @@ const handleRefresh = async () => {
loadingMore.value = false; loadingMore.value = false;
const success = await loadData({ append: false, silent: true }); const success = await loadData({ append: false, silent: true });
if (success) { if (success) {
uni.showToast({ uni.showToast({ title: "刷新成功", icon: "success", duration: 1200 });
title: "刷新成功",
icon: "success",
duration: 1200,
});
} else { } else {
uni.showToast({ uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 });
title: "刷新失败",
icon: "none",
duration: 1500,
});
} }
} catch (e) { } catch (e) {
console.error("[HotCategoryBlock] 刷新失败", e?.message ?? e); console.error("[HotCategoryBlock] 刷新失败", e?.message ?? e);
uni.showToast({ uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 });
title: "刷新失败",
icon: "none",
duration: 1500,
});
} finally { } finally {
setTimeout(() => { setTimeout(() => {
refreshing.value = false; refreshing.value = false;
@ -479,7 +448,8 @@ const handleRefresh = async () => {
onMounted(() => { onMounted(() => {
uni.$on("assetLiked", onAssetLiked); uni.$on("assetLiked", onAssetLiked);
loadData(); // usePreload loadData()
// fallback timer 1.5s
}); });
onShow(() => { onShow(() => {
@ -488,6 +458,7 @@ onShow(() => {
onUnmounted(() => { onUnmounted(() => {
uni.$off("assetLiked", onAssetLiked); uni.$off("assetLiked", onAssetLiked);
if (fallbackTimer) clearTimeout(fallbackTimer);
}); });
</script> </script>

View File

@ -9,8 +9,8 @@
<!-- 标题 --> <!-- 标题 -->
<!-- <view class="title"> 星河 </view> --> <!-- <view class="title"> 星河 </view> -->
<!-- Loading 骨架 --> <!-- Loading 骨架API 请求 + URL 解析 -->
<view v-if="loading" class="skeleton-grid"> <view v-if="loading || resolvingUrls" class="skeleton-grid">
<view v-for="i in 3" :key="'p' + i" class="skeleton-podium"></view> <view v-for="i in 3" :key="'p' + i" class="skeleton-podium"></view>
<view v-for="i in 9" :key="'s' + i" class="skeleton-ring"></view> <view v-for="i in 9" :key="'s' + i" class="skeleton-ring"></view>
</view> </view>
@ -18,7 +18,7 @@
<!-- Error --> <!-- Error -->
<view v-else-if="error" class="error-state"> <view v-else-if="error" class="error-state">
<text class="error-text">加载失败点击重试</text> <text class="error-text">加载失败点击重试</text>
<view class="retry-btn" @click="loadData">重试</view> <view class="retry-btn" @click="refresh(true)">重试</view>
</view> </view>
<!-- Empty数据为空 --> <!-- Empty数据为空 -->
@ -53,65 +53,60 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from "vue"; import { ref, computed, watch, onUnmounted } from "vue";
import PodiumCard from "./PodiumCard.vue"; import PodiumCard from "./PodiumCard.vue";
import ScatteredRanks from "./ScatteredRanks.vue"; import ScatteredRanks from "./ScatteredRanks.vue";
import { generateRingPositions } from "./config.js"; import { generateRingPositions } from "./config.js";
import { getHotRankingApi } from "@/utils/api.js";
import { getAssetCoverRealUrl } from "@/utils/assetImageHelper.js"; import { getAssetCoverRealUrl } from "@/utils/assetImageHelper.js";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js"; import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js";
import { usePreload } from "@/composables/usePreload.js";
const emit = defineEmits(["cardClick"]); const emit = defineEmits(["cardClick"]);
const items = ref([]);
const loading = ref(true);
const error = ref(false);
const ringPositions = generateRingPositions(); const ringPositions = generateRingPositions();
// usePreload fetch + tab
const { data: rawData, loading, error, refresh } = usePreload("ranking.xinghe");
const items = ref([]);
const resolvingUrls = ref(false);
// URL
watch(rawData, async (newData) => {
if (!newData || newData.code !== 0) {
items.value = [];
return;
}
resolvingUrls.value = true;
try {
const rawItems = newData.data?.items || [];
items.value = await Promise.all(
rawItems.map(async (item) => {
const cover = item.cover_url || item.cover_image || "";
const resolved = { ...item, id: item.id || item.asset_id };
if (cover) {
resolved.cover_url = await getAssetCoverRealUrl(cover);
}
return resolved;
}),
);
} catch (e) {
console.error("[StarGalaxy] URL 解析失败", e?.message ?? e);
} finally {
resolvingUrls.value = false;
}
}, { immediate: true });
const podiumItems = computed(() => items.value.slice(0, 3)); const podiumItems = computed(() => items.value.slice(0, 3));
const scatteredItems = computed(() => items.value.slice(3, 12)); const scatteredItems = computed(() => items.value.slice(3, 12));
async function resolveUrl(item) {
const cover = item.cover_url || item.cover_image || "";
if (cover) {
item.cover_url = await getAssetCoverRealUrl(cover);
}
return item;
}
async function loadData() {
loading.value = true;
error.value = false;
try {
const res = await getHotRankingApi("displaying", null, 1, 12);
if (res && res.code === 0 && res.data?.items) {
items.value = await Promise.all(
res.data.items.map(async (item) => {
return await resolveUrl({ ...item, id: item.id || item.asset_id });
}),
);
} else {
items.value = [];
}
} catch (e) {
console.error("[StarGalaxy] 加载失败", e?.message ?? e);
error.value = true;
} finally {
loading.value = false;
}
}
function handleCardClick(item) { function handleCardClick(item) {
emit("cardClick", item); emit("cardClick", item);
} }
onMounted(() => { // skip TTL
loadData();
});
// App
useBackgroundRefresh(() => { useBackgroundRefresh(() => {
loadData(); refresh(true);
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@ -1,5 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { getActivityListApi, getMintingActivitiesApi } from '@/utils/api.js' import { getActivityListApi, getMintingActivitiesApi } from '@/utils/api.js'
import { getPreloadApi } from '@/utils/preloadApi/index'
// 默认 banner(接口失败/无数据时兜底) // 默认 banner(接口失败/无数据时兜底)
const FALLBACK_BANNER = [ const FALLBACK_BANNER = [
@ -13,58 +14,61 @@ const FALLBACK_BANNER = [
/** /**
* 模块级共享状态单例 * 模块级共享状态单例
*
* 背景square.vue pages/components/Header.vue 都会调 useBanner()
* 旧实现每次调用都 new ref导致两个组件各持一份独立的 bannerActivities
* 状态可能不一致
*
* 改单例后所有调用 useBanner() 的地方拿到的是同一对 ref
* 任一处调 loadBannerActivities() / loadBanners() 都会同步刷新所有订阅者
*
* 不会把数据搬到 Vuex原因是 banner 数据目前只有这两个使用方没必要全局 store
*/ */
const bannerActivities = ref([]) const bannerActivities = ref([])
const banners = ref([]) const banners = ref([])
// 内部:优先走预拉缓存,未命中/过期则手动 fetch
async function _tryPreloadThenFetch(key, fallbackFetch, processFn) {
const api = getPreloadApi()
if (api) {
try {
const cached = await api.get(key)
if (cached && cached.code === 0) {
processFn(cached)
return
}
} catch (e) { /* 未命中/过期,走手动 fetch */ }
}
const res = await fallbackFetch()
if (res && res.code === 0) processFn(res)
}
const loadBannerActivities = async () => { const loadBannerActivities = async () => {
try { try {
const starId = uni.getStorageSync('star_id') || null await _tryPreloadThenFetch('banner.activities',
const res = await getActivityListApi(starId, 1, 10) () => getActivityListApi(uni.getStorageSync('star_id') || null, 1, 10),
(res) => {
if (res.code === 0 && res.data?.activities) { if (res.data?.activities) {
const activities = res.data.activities bannerActivities.value = res.data.activities.filter(item => item.status !== 'expired')
// 过滤掉已过期的活动 }
bannerActivities.value = activities.filter(item => item.status !== 'expired') }
} )
} catch (e) { } catch (e) {
console.error('[useBanner] 加载 banner 活动失败', e?.message ?? e) console.error('[useBanner] 加载 banner 活动失败', e?.message ?? e)
} }
} }
// 加载运营 banner(铸造活动)
const loadBanners = async () => { const loadBanners = async () => {
try { try {
const res = await getMintingActivitiesApi(null, 1, 10) await _tryPreloadThenFetch('banner.minting',
() => getMintingActivitiesApi(null, 1, 10),
if (res.code === 0 && res.data?.activities) { (res) => {
// 将后端数据映射为 BannerCarousel 约定的字段 if (res.data?.activities) {
banners.value = res.data.activities.map(activity => ({ banners.value = res.data.activities.map(activity => ({
id: activity.id, id: activity.id,
image_url: activity.cover_image, image_url: activity.cover_image,
title: activity.title, title: activity.title,
link_type: 'activity', link_type: 'activity',
link_value: String(activity.id), link_value: String(activity.id),
description: activity.description, description: activity.description,
route: activity.route, route: activity.route,
// 后端路由参数 JSON 字符串,前端在 handleBannerClick 中拼到 url 上 params: activity.params
params: activity.params }))
})) }
if (banners.value.length === 0) banners.value = [...FALLBACK_BANNER]
} }
)
// 无数据兜底
if (banners.value.length === 0) {
banners.value = [...FALLBACK_BANNER]
}
} catch (e) { } catch (e) {
console.error('[useBanner] 加载运营 banner 失败', e?.message ?? e) console.error('[useBanner] 加载运营 banner 失败', e?.message ?? e)
banners.value = [...FALLBACK_BANNER] banners.value = [...FALLBACK_BANNER]
@ -72,7 +76,6 @@ const loadBanners = async () => {
} }
export function useBanner() { export function useBanner() {
// 返回模块级单例的引用API 不变,调用方零改动)
return { return {
bannerActivities, bannerActivities,
banners, banners,

View File

@ -110,6 +110,7 @@ import TopfansWelcome from "@/components/TopfansWelcome.vue";
import { useBanner } from "./composables/useBanner.js"; import { useBanner } from "./composables/useBanner.js";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js"; import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js";
import { doubleTapLike } from "@/utils/likeHelper.js"; import { doubleTapLike } from "@/utils/likeHelper.js";
import { getPreloadApi } from "@/utils/preloadApi/index";
import checkUpdate from "@/uni_modules/uni-upgrade-center-app/utils/check-update"; import checkUpdate from "@/uni_modules/uni-upgrade-center-app/utils/check-update";
@ -134,9 +135,14 @@ const showWelcome = ref(
const handleEnterTopfans = () => { const handleEnterTopfans = () => {
showWelcome.value = false; showWelcome.value = false;
// square
// needs_welcome app
getApp().globalData.welcomeShownThisSession = true; getApp().globalData.welcomeShownThisSession = true;
// idle
// App.vue onShow quickLogin returnwarmIdle
// tab + banner
try {
const api = getPreloadApi();
if (api) api.warmIdle();
} catch (e) {}
}; };
const navExpanded = ref(false); const navExpanded = ref(false);
const showRankingModal = ref(false); const showRankingModal = ref(false);
@ -188,7 +194,7 @@ const handleCardClick = (card) => {
if (card.id) { if (card.id) {
cardTapTimers[card.id] = setTimeout(() => { cardTapTimers[card.id] = setTimeout(() => {
delete cardTapTimers[card.id]; delete cardTapTimers[card.id];
uni.navigateTo({ const navApi = getPreloadApi(); (navApi || uni).navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${card.id}`, url: `/pages/asset-detail/asset-detail?asset_id=${card.id}`,
}); });
}, 300); }, 300);
@ -203,7 +209,7 @@ const handleScrollToLower = () => {
}; };
const handleActivityClick = (item) => { const handleActivityClick = (item) => {
uni.navigateTo({ const navApi = getPreloadApi(); (navApi || uni).navigateTo({
url: `/pages/support-activity/index?id=${item.id}`, url: `/pages/support-activity/index?id=${item.id}`,
}); });
}; };
@ -211,9 +217,10 @@ const handleActivityClick = (item) => {
// banner () // banner ()
const handleBannerClick = (banner) => { const handleBannerClick = (banner) => {
console.log("[square] banner click", banner); console.log("[square] banner click", banner);
// 使 route // 使 route
if (banner.route) { if (banner.route) {
return uni.navigateTo({ url: buildBannerUrl(banner) }); const api = getPreloadApi();
return (api || uni).navigateTo({ url: buildBannerUrl(banner) });
} }
}; };
@ -240,7 +247,7 @@ const buildBannerUrl = (banner) => {
const handleRankingVisit = ({ userId, nickname }) => { const handleRankingVisit = ({ userId, nickname }) => {
showRankingModal.value = false; showRankingModal.value = false;
uni.navigateTo({ const navApi = getPreloadApi(); (navApi || uni).navigateTo({
url: `/pages/profile/hisWorks?userId=${userId}&nickname=${encodeURIComponent(nickname)}`, url: `/pages/profile/hisWorks?userId=${userId}&nickname=${encodeURIComponent(nickname)}`,
}); });
}; };
@ -267,7 +274,7 @@ const handleTabChange = (newTab) => {
]; ];
if (newTab >= 0 && newTab < routes.length) { if (newTab >= 0 && newTab < routes.length) {
uni.navigateTo({ const navApi = getPreloadApi(); (navApi || uni).navigateTo({
url: routes[newTab], url: routes[newTab],
}); });
} }

View File

@ -193,6 +193,7 @@ import { ref, computed, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app"; import { onShow } from "@dcloudio/uni-app";
import Header from "../components/Header.vue"; import Header from "../components/Header.vue";
import { getActivityListApi, getActivityRankingApi } from "@/utils/api.js"; import { getActivityListApi, getActivityRankingApi } from "@/utils/api.js";
import { getPreloadApi } from "@/utils/preloadApi/index";
import screenCache from "@/utils/screen-cache"; import screenCache from "@/utils/screen-cache";
const statusBarHeight = ref(0); const statusBarHeight = ref(0);
@ -372,8 +373,9 @@ function onScrollToLower() {
} }
function handleCardTap(item) { function handleCardTap(item) {
// // +
uni.navigateTo({ const api = getPreloadApi();
(api || uni).navigateTo({
url: `/pages/support-activity/index?id=${item.id}`, url: `/pages/support-activity/index?id=${item.id}`,
fail: (err) => { fail: (err) => {
console.error("[center] 跳转活动详情失败:", err); console.error("[center] 跳转活动详情失败:", err);

View File

@ -70,7 +70,7 @@ export function useContributionPolling(activityId, isPageActive, options = {}) {
// 处理 code 不为 200 的情况(静默忽略) // 处理 code 不为 200 的情况(静默忽略)
if (res.code !== 0) return if (res.code !== 0) return
const newRecords = res.data?.records || [] const newRecords = (res.data?.records || []).filter(r => r && r.created_at != null)
if (newRecords.length === 0) return if (newRecords.length === 0) return
// API 返回正序ASC用 reverse 反转成"新→旧",便于复用 [0] 作最新游标 + 前插 // API 返回正序ASC用 reverse 反转成"新→旧",便于复用 [0] 作最新游标 + 前插

View File

@ -10,7 +10,7 @@
<text class="nav-back-icon"></text> <text class="nav-back-icon"></text>
</view> </view>
<!-- 左上角水晶余额fixed 悬浮与右上角 TopRanking 对称 --> <!-- 左上角水晶余额fixed 悬浮与右上角 TopRanking 对称 -->
<view class="crystal-balance-new"> <view class="crystal-balance-new" @click="handleClick">
<!-- 1. 左侧钻石图标层级最高盖住右侧背景 --> <!-- 1. 左侧钻石图标层级最高盖住右侧背景 -->
<view class="crystal-icon-box"> <view class="crystal-icon-box">
<image <image
@ -208,6 +208,7 @@ import TopRanking from "./components/TopRanking.vue";
import MessageBoard from "./components/MessageBoard.vue"; import MessageBoard from "./components/MessageBoard.vue";
import MessageInput from "./components/MessageInput.vue"; import MessageInput from "./components/MessageInput.vue";
import { getEarningsSummaryApi } from "@/utils/api.js"; import { getEarningsSummaryApi } from "@/utils/api.js";
import { getPreloadApi } from "@/utils/preloadApi/index";
import { getActivitySocket, closeActivitySocket } from "@/utils/socket/ActivitySocket.js"; import { getActivitySocket, closeActivitySocket } from "@/utils/socket/ActivitySocket.js";
import { useMessageRealtime } from "./composables/useMessageRealtime.js"; import { useMessageRealtime } from "./composables/useMessageRealtime.js";
import { useContributionRealtime } from "./composables/useContributionRealtime.js"; import { useContributionRealtime } from "./composables/useContributionRealtime.js";
@ -263,12 +264,20 @@ const goBack = () => {
uni.navigateBack(); uni.navigateBack();
} else { } else {
// square // square
uni.reLaunch({ const api = getPreloadApi(); (api || uni).reLaunch({
url: "/pages/support-activity/center", url: "/pages/support-activity/center",
}); });
} }
}; };
// 7
const handleClick = () => {
const api = getPreloadApi();
(api || uni).navigateTo({
url: "/pages/dashboard/dashboard",
});
};
// //
function openRankingModal() { function openRankingModal() {
currentActivityTitle.value = config.value?.title || "活动排名"; currentActivityTitle.value = config.value?.title || "活动排名";
@ -277,7 +286,7 @@ function openRankingModal() {
// 访 // 访
function handleVisitUser(user) { function handleVisitUser(user) {
uni.navigateTo({ const api = getPreloadApi(); (api || uni).navigateTo({
url: `/pages/profile/hisWorks?userId=${user.userId}&nickname=${encodeURIComponent(user.nickname)}` url: `/pages/profile/hisWorks?userId=${user.userId}&nickname=${encodeURIComponent(user.nickname)}`
}); });
// console.log("访:", data); // console.log("访:", data);
@ -354,7 +363,7 @@ function handleTabChange(newTab) {
]; ];
if (newTab >= 0 && newTab < tabRoutes.length) { if (newTab >= 0 && newTab < tabRoutes.length) {
uni.navigateTo({ const api = getPreloadApi(); (api || uni).navigateTo({
url: tabRoutes[newTab], url: tabRoutes[newTab],
fail: (err) => { fail: (err) => {
console.error("页面跳转失败:", err); console.error("页面跳转失败:", err);

View File

@ -1,5 +1,6 @@
import { loginApi, registerApi } from '@/utils/api' import { loginApi, registerApi } from '@/utils/api'
import { resetAllGuides } from '@/utils/guideConfig' import { resetAllGuides } from '@/utils/guideConfig'
import { getPreloadApi } from '@/utils/preloadApi/index'
const state = { const state = {
token: uni.getStorageSync('access_token') || '', token: uni.getStorageSync('access_token') || '',
@ -34,6 +35,20 @@ const mutations = {
} }
}, },
SET_USER_INFO(state, userInfo) { SET_USER_INFO(state, userInfo) {
// ★ 新增:检查用户切换,清理旧用户缓存
const oldUserStr = uni.getStorageSync('user')
let oldUserId = null
if (oldUserStr) {
try { const old = JSON.parse(oldUserStr); oldUserId = old?.uid || null } catch (e) {}
}
const newUserId = userInfo?.uid || null
if (oldUserId && newUserId && oldUserId !== newUserId) {
try {
const api = getPreloadApi()
if (api) { api.invalidatePrefix('me.'); api.clearForUser(oldUserId) }
} catch (e) {}
}
state.userInfo = userInfo state.userInfo = userInfo
// 缓存到 storage // 缓存到 storage
if (userInfo) { if (userInfo) {
@ -53,19 +68,24 @@ const mutations = {
} }
}, },
CLEAR_AUTH(state) { CLEAR_AUTH(state) {
state.token = '' // ★ 新增:先清理 preload 缓存(在清 token 之前spec §6.5 constraint #2
state.userInfo = null
state.starId = null
// 获取当前 userId 用于清除引导数据(需要在清除 user 数据之前获取)
const userStr = uni.getStorageSync('user') const userStr = uni.getStorageSync('user')
let userId = null let userId = null
if (userStr) { if (userStr) {
try { const user = JSON.parse(userStr); userId = user?.uid || null } catch (e) {}
}
if (userId) {
try { try {
const user = JSON.parse(userStr) const api = getPreloadApi()
userId = user?.uid || null if (api) api.clearUser(userId)
} catch (e) {} } catch (e) {}
} }
state.token = ''
state.userInfo = null
state.starId = null
// 清除所有引导相关存储(传入 userId 确保正确清除) // 清除所有引导相关存储(传入 userId 确保正确清除)
// 注userId 已在上面 preload 清理块中从 storage 读取,此处复用
resetAllGuides(userId) resetAllGuides(userId)
// 清除 storage // 清除 storage

View File

@ -53,7 +53,9 @@ function mockDelay(ms = 800) {
// 请求封装 // 请求封装
export function request(options) { export function request(options) {
return new Promise((resolve, reject) => { let _aborted = false
let requestTask = null
const p = new Promise((resolve, reject) => {
// 构建请求头 // 构建请求头
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -75,7 +77,7 @@ export function request(options) {
} }
} }
uni.request({ requestTask = uni.request({
url: baseURL + options.url, url: baseURL + options.url,
method: options.method || 'GET', method: options.method || 'GET',
data: options.data || {}, data: options.data || {},
@ -138,10 +140,16 @@ export function request(options) {
} }
}, },
fail: (err) => { fail: (err) => {
if (_aborted) return
reject(new Error(err.errMsg || '网络请求失败')) reject(new Error(err.errMsg || '网络请求失败'))
} }
}) })
}) })
p.abort = () => {
_aborted = true
if (requestTask) requestTask.abort()
}
return p
} }
// 登录接口 // 登录接口

View File

@ -0,0 +1,85 @@
# preloadApi — API 预加载模块
## 快速开始
### 1. 在页面中使用缓存数据
```vue
<script setup>
import { usePreload } from '@/composables/usePreload'
const { data, loading, error, refresh } = usePreload('asset.detail', { id: route.params.id })
</script>
<template>
<view v-if="data">{{ data.name }}</view>
<view v-else-if="loading">加载中...</view>
<view v-else-if="error">加载失败: {{ error.message }}</view>
</template>
```
### 2. 手动失效缓存
```js
import { getPreloadApi } from '@/utils/preloadApi/index'
const api = getPreloadApi()
// 失效单个 key
api.invalidate('ranking.hot')
// 失效某个前缀的所有 key
api.invalidatePrefix('asset.')
// 清空全部内存缓存
api.invalidateAll()
```
### 3. 添加新的预拉配置
`frontend/config/preload.config.js` 中:
```js
pages: {
'/pages/new-page/new-page': [
{ key: 'new.data', fetcher: (params) => getNewDataApi(params.id) }
]
}
```
### 4. 替换页面跳转
```js
// 旧写法
uni.navigateTo({ url: '/pages/detail/detail?id=123' })
// 新写法(自动触发预拉)
import { getPreloadApi } from '@/utils/preloadApi/index'
const api = getPreloadApi()
api.navigateTo({ url: '/pages/detail/detail?id=123' })
```
## API 速查
| 方法 | 说明 |
|------|------|
| `preloadApi.run(key, params?)` | 触发预拉fire-and-forget不返回数据 |
| `preloadApi.get(key, params?)` | 读缓存,未命中则拉取 |
| `preloadApi.invalidate(key, params?)` | 失效单个 key内存 |
| `preloadApi.invalidatePrefix(prefix)` | 失效前缀匹配的所有 key内存 |
| `preloadApi.invalidateAll()` | 清空全部内存缓存 |
| `preloadApi.clearUser(userId)` | 登出:清空内存 + 删文件缓存目录 |
| `preloadApi.navigateTo(opts)` | 替代 uni.navigateTo |
| `preloadApi.switchTab(opts)` | 替代 uni.switchTab |
| `preloadApi.reLaunch(opts)` | 替代 uni.reLaunch |
## 配置字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `key` | string | (必填) | 逻辑 key业务引用缓存的唯一标识 |
| `fetcher` | (params) => Promise | (必填) | 请求函数 |
| `ttl` | number | 300000 | 缓存有效期 (ms) |
| `persistence` | 'memory'\|'file' | 'memory' | 缓存存储方式 |
| `timeout` | number | 10000 | 单接口超时 (ms) |
| `silent` | boolean | true | 失败是否静默 |

View File

@ -0,0 +1,64 @@
// frontend/utils/preloadApi/config.js
// 配置加载器:合并用户配置与内置默认值,提取 fetcher 映射表
/**
* 内置默认值与设计文档 §3 defaults 一致
*/
const BUILTIN_DEFAULTS = {
ttl: 5 * 60 * 1000,
persistence: 'memory',
concurrency: 4,
timeout: 10000,
silent: true,
limits: {
maxEntrySizeKB: 1024,
maxMemoryEntries: 100,
maxFileCacheMB: 50
}
}
/**
* 加载并合并配置
* @param {object} userConfig - 用户提供的 preload.config.js export
* @returns {{ config: object, fetchers: object }}
*/
export function loadConfig(userConfig) {
if (!userConfig) {
throw new Error('[preload] config is required')
}
// 合并 defaults
const mergedDefaults = {
...BUILTIN_DEFAULTS,
...(userConfig.defaults || {}),
limits: {
...BUILTIN_DEFAULTS.limits,
...((userConfig.defaults && userConfig.defaults.limits) || {})
}
}
const config = {
defaults: mergedDefaults,
startup: userConfig.startup || [],
idle: userConfig.idle || [],
pages: userConfig.pages || {}
}
// 提取 fetcher 映射表
const fetchers = {}
const allEntries = [
...(config.startup || []),
...(config.idle || [])
]
for (const entries of Object.values(config.pages || {})) {
if (Array.isArray(entries)) allEntries.push(...entries)
}
for (const entry of allEntries) {
if (entry.key && typeof entry.fetcher === 'function') {
fetchers[entry.key] = entry.fetcher
}
}
return { config, fetchers }
}

View File

@ -0,0 +1,508 @@
// frontend/utils/preloadApi/core.js
// 核心缓存引擎 — 零外部依赖(不依赖 Vue / uni API / api.js / store
// 内存 Map + LRU + inFlight 去重 + TTL
// 依赖关系内模块import { readEntry, writeEntry, clearForUser as storageClearForUser, getTotalCacheSize, evictOldest } from './storage'
import {
readEntry,
writeEntry,
clearForUser as storageClearForUser,
getTotalCacheSize,
evictOldest
} from './storage'
// ── 常量 ──
const NAMESPACE = 'preload'
const DEFAULT_MAX_MEMORY_ENTRIES = 100
const DEFAULT_MAX_ENTRY_SIZE_KB = 1024
const DEFAULT_MAX_FILE_CACHE_MB = 50
// ── 内部状态 ──
const memoryMap = new Map() // Map<cacheKey, {data, ts, ttl, persistence}>
const inFlightMap = new Map() // Map<cacheKey, {promise, abort}>
// 统计
let hitCount = 0
let missCount = 0
// 运行时配置(由外部 setConfig 写入)
let _config = {
defaults: {
ttl: 5 * 60 * 1000,
persistence: 'memory',
concurrency: 4,
timeout: 10000,
silent: true,
limits: {
maxEntrySizeKB: DEFAULT_MAX_ENTRY_SIZE_KB,
maxMemoryEntries: DEFAULT_MAX_MEMORY_ENTRIES,
maxFileCacheMB: DEFAULT_MAX_FILE_CACHE_MB
}
}
}
// fetcher 注册表:{ [logicalKey]: fetcherFunction }
let _fetchers = {}
// userId 获取函数(由外部注入)
let _getUserId = () => {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
const user = JSON.parse(userStr)
return user?.uid || null
}
} catch (e) { /* ignore */ }
return null
}
// ── 并发控制 semaphore ──
function createSemaphore(max) {
let running = 0
const queue = []
return {
acquire: () => new Promise(resolve => {
if (running < max) { running++; resolve() }
else { queue.push(resolve) }
}),
release: () => {
running--
const next = queue.shift()
if (next) { running++; next() }
}
}
}
let _semaphore = createSemaphore(_config.defaults.concurrency)
// ── hash 工具 ──
function djb2(str) {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
}
return (hash >>> 0).toString(16)
}
function hashParams(params) {
if (!params || Object.keys(params).length === 0) return ''
const sorted = {}
Object.keys(params).sort().forEach(k => { sorted[k] = params[k] })
return djb2(JSON.stringify(sorted))
}
function buildCacheKey(userId, logicalKey, params) {
const uid = userId || 'guest'
const paramHash = hashParams(params)
return `${uid}::${NAMESPACE}::${logicalKey}::${paramHash}`
}
// ── LRU touch ──
function touchLRU(map, key, value) {
if (map.has(key)) map.delete(key)
map.set(key, value)
if (map.size > _config.defaults.limits.maxMemoryEntries) {
const oldestKey = map.keys().next().value
map.delete(oldestKey)
if (typeof console !== 'undefined') {
console.log('[preload] LRU evict:', oldestKey)
}
}
}
// ── 401/7/16 swallow ──
function _swallowAuth(err) {
if (err && (err.code === 7 || err.code === 16)) return null
if (err && /登录已过期/.test(err.message || '')) return null
return err
}
// ── 数据大小检查 ──
function getDataSizeKB(data) {
try {
return new Blob([JSON.stringify(data)]).size / 1024
} catch (e) {
return JSON.stringify(data || '').length / 1024
}
}
// ── 配置 API ──
/**
* 设置运行时配置 + fetcher 注册表
* config.js 在初始化时调用
*/
export function setConfig(config, fetchers) {
if (config) {
_config = {
defaults: {
..._config.defaults,
...(config.defaults || {}),
limits: {
..._config.defaults.limits,
...((config.defaults && config.defaults.limits) || {})
}
},
startup: config.startup || _config.startup || [],
idle: config.idle || _config.idle || [],
pages: config.pages || _config.pages || {}
}
_semaphore = createSemaphore(_config.defaults.concurrency)
}
if (fetchers) {
_fetchers = { ..._fetchers, ...fetchers }
}
}
/**
* 设置 userId 获取函数用于测试注入
*/
export function setUserIdGetter(fn) {
if (typeof fn === 'function') _getUserId = fn
}
// ── 核心 API ──
/**
* 获取缓存值组件使用
* 命中内存 同步 resolve文件缓存命中 / fetch async resolve
*/
export function get(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// 1. 查内存
const memEntry = memoryMap.get(cacheKey)
if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
touchLRU(memoryMap, cacheKey, memEntry)
hitCount++
return Promise.resolve(memEntry.data)
}
// 2. inFlight 去重
const inFlight = inFlightMap.get(cacheKey)
if (inFlight) {
return inFlight.promise.then(result => result.data)
}
// 3. 发起 fetch含文件缓存回退
return _doFetch(logicalKey, params, cacheKey, userId, false)
}
/**
* 触发预拉fire-and-forget
* 查内存 inFlight 发起 fetch
*/
export function run(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// 1. 查内存
const memEntry = memoryMap.get(cacheKey)
if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
return // 未过期,跳过
}
// 2. inFlight 去重
if (inFlightMap.has(cacheKey)) {
return // 已在请求中
}
// 3. 发起 fetchfire-and-forget不返回 Promise
_doFetch(logicalKey, params, cacheKey, userId, true)
}
/**
* 命令式刷新
*/
export function refresh(logicalKey, params, force = false) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// force=true 时先删除内存缓存 + 跳过文件缓存
if (force) {
memoryMap.delete(cacheKey)
}
return _doFetch(logicalKey, params, cacheKey, userId, false, force)
}
/**
* 失效单个 key仅内存
*/
export function invalidate(logicalKey, params) {
const cacheKey = buildCacheKey(_getUserId(), logicalKey, params)
memoryMap.delete(cacheKey)
}
/**
* 按前缀失效仅内存
*/
export function invalidatePrefix(prefix) {
const fullPrefix = `${_getUserId() || 'guest'}::${NAMESPACE}::${prefix}`
for (const key of memoryMap.keys()) {
if (key.startsWith(fullPrefix)) {
memoryMap.delete(key)
}
}
}
/**
* 清空全部内存缓存不动文件缓存
*/
export function invalidateAll() {
memoryMap.clear()
}
/**
* 删除指定用户的文件缓存目录不动内存
*/
export function clearForUser(userId) {
storageClearForUser(userId)
}
/**
* 登出专用清空内存 + 删除文件缓存目录
*/
export function clearUser(userId) {
memoryMap.clear()
storageClearForUser(userId)
}
/**
* 按目标页路径触发预拉navigate.js 内部调用
*/
export function prefetchFor(targetPath, params) {
const pages = _config.pages || {}
const entries = pages[targetPath]
if (!entries || !Array.isArray(entries)) return
for (const entry of entries) {
run(entry.key, params)
}
}
/**
* 取消指定 key in-flight 请求 composable unmount / params 变化时使用
*/
export function abortRequest(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
const entry = inFlightMap.get(cacheKey)
if (entry) {
entry.abort()
inFlightMap.delete(cacheKey)
}
}
/**
* 获取调试统计
*/
export function getStats() {
return {
hits: hitCount,
misses: missCount,
memorySize: memoryMap.size,
inFlightSize: inFlightMap.size,
fileCacheBytes: _lastFileCacheSize
}
}
// 上次文件缓存大小(由 checkAndEvict 异步更新)
let _lastFileCacheSize = 0
// 内部:更新文件缓存大小跟踪
function _updateFileCacheSize() {
const userId = _getUserId()
if (userId) {
getTotalCacheSize(userId).then(size => {
_lastFileCacheSize = size
}).catch(() => {})
}
}
/**
* dump 内存缓存调试用
*/
export function dumpMemory() {
const result = []
for (const [key, entry] of memoryMap.entries()) {
result.push({
key,
age: Date.now() - entry.ts,
ttl: entry.ttl,
persistence: entry.persistence
})
}
return result
}
// ── 内部:执行 fetch ──
async function _doFetch(logicalKey, params, cacheKey, userId, isRun, skipFileCache = false) {
const fetcher = _fetchers[logicalKey]
if (!fetcher) {
if (!isRun) throw new Error(`[preload] unknown key: ${logicalKey}`)
console.warn(`[preload] unknown key: ${logicalKey}`)
return
}
const cfg = _resolveEntryConfig(logicalKey)
const ttl = cfg.ttl || _config.defaults.ttl
const persistence = cfg.persistence || _config.defaults.persistence
const silent = cfg.silent !== undefined ? cfg.silent : _config.defaults.silent
const timeout = cfg.timeout || _config.defaults.timeout
// 3. 先查本地文件缓存(非 run 路径,且未强制跳过)
if (!isRun && !skipFileCache && persistence === 'file') {
try {
const fileEntry = await readEntry(userId, cacheKey)
if (fileEntry && (Date.now() - fileEntry.ts) < fileEntry.ttl) {
// 文件命中 → 写回内存
touchLRU(memoryMap, cacheKey, {
data: fileEntry.data,
ts: fileEntry.ts,
ttl: fileEntry.ttl,
persistence: 'file'
})
hitCount++
return fileEntry.data
}
} catch (e) {
// 文件读取失败 → 走 fetch
}
}
// 4. 创建 inFlight 条目
let resolveInFlight, rejectInFlight
const sharedPromise = new Promise((res, rej) => {
resolveInFlight = res
rejectInFlight = rej
})
let abortFn = () => {}
const inFlightEntry = {
promise: sharedPromise.then(data => ({ data })),
abort: () => abortFn()
}
inFlightMap.set(cacheKey, inFlightEntry)
const cleanup = () => {
inFlightMap.delete(cacheKey)
}
// 5. 并发控制 + 超时
await _semaphore.acquire()
try {
const startTime = Date.now()
const fetchPromise = fetcher(params)
// 设置 abort
abortFn = () => {
if (fetchPromise && typeof fetchPromise.abort === 'function') {
fetchPromise.abort()
}
cleanup()
}
// 超时控制
let timeoutId
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('timeout')), timeout)
})
const result = await Promise.race([fetchPromise, timeoutPromise])
clearTimeout(timeoutId)
const elapsed = Date.now() - startTime
console.log('[preload] fetch done:', logicalKey, elapsed + 'ms')
// 6. 写内存缓存
const entry = { data: result, ts: Date.now(), ttl, persistence }
touchLRU(memoryMap, cacheKey, entry)
// 7. 异步写文件缓存fire-and-forget不阻塞 fetch 返回)
if (persistence === 'file') {
const sizeKB = getDataSizeKB(result)
if (sizeKB <= _config.defaults.limits.maxEntrySizeKB) {
writeEntry(userId, cacheKey, result, entry.ts, ttl)
// fire-and-forget 容量检查:延迟到下一 tick 确保 writeEntry 已启动
setTimeout(() => {
checkAndEvict(userId)
}, 0)
}
}
missCount++
resolveInFlight(result)
return result
} catch (err) {
// 8. 错误处理
const swallowed = _swallowAuth(err)
if (swallowed === null) {
// 401/7/16 → swallow
console.warn('[preload] auth-expired, swallowed:', logicalKey)
resolveInFlight(null)
return null
}
if (silent || isRun) {
// run / silent → 静默
console.warn('[preload] fetch fail (swallowed):', logicalKey, err.message)
resolveInFlight(null)
return null
}
// get 路径 → 抛错给调用方
rejectInFlight(err)
throw err
} finally {
cleanup()
_semaphore.release()
}
}
// ── 内部fire-and-forget 容量检查 + 淘汰 ──
async function checkAndEvict(userId) {
try {
const totalSize = await getTotalCacheSize(userId)
_lastFileCacheSize = totalSize
const maxBytes = _config.defaults.limits.maxFileCacheMB * 1024 * 1024
if (totalSize > maxBytes) {
await evictOldest(userId, maxBytes)
// 淘汰后更新大小
const newSize = await getTotalCacheSize(userId)
_lastFileCacheSize = newSize
}
} catch (e) {
console.warn('[preload] eviction check failed:', e.message)
}
}
// ── 内部:解析 per-key 配置 ──
function _resolveEntryConfig(logicalKey) {
// 从 _config 中查找该 key 的配置startup/idle/pages 任一数组)
const all = [
...(_config.startup || []),
...(_config.idle || []),
]
if (_config.pages) {
for (const entries of Object.values(_config.pages)) {
if (Array.isArray(entries)) all.push(...entries)
}
}
const found = all.find(e => e.key === logicalKey)
return found || {}
}
// ── 开发调试 ──
if (typeof window !== 'undefined' && (typeof import.meta === 'undefined' || import.meta.env?.DEV)) {
window.__PRELOAD_DEBUG__ = {
dumpMemory,
stats: getStats,
all: () => ({
memory: dumpMemory(),
inFlight: Array.from(inFlightMap.keys())
})
}
}

View File

@ -0,0 +1,74 @@
// frontend/utils/preloadApi/index.js
// 统一导出 preloadApi命令式 API
import { loadConfig } from './config'
import { setConfig, setUserIdGetter } from './core'
import {
get,
run,
refresh,
abortRequest,
invalidate,
invalidatePrefix,
invalidateAll,
clearForUser,
clearUser,
prefetchFor,
getStats,
dumpMemory
} from './core'
import { warmStartup, warmIdle } from './scheduler'
import { navigateTo, switchTab, reLaunch } from './navigate'
/**
* 初始化 preloadApi
* @param {object} userConfig - preload.config.js 导出的配置
* @returns {object} preloadApi 实例
*/
export function initPreloadApi(userConfig) {
const { config, fetchers } = loadConfig(userConfig)
setConfig(config, fetchers)
return {
// 核心 API
get,
run,
refresh,
abortRequest,
invalidate,
invalidatePrefix,
invalidateAll,
clearForUser,
clearUser,
prefetchFor,
// 调度
warmStartup: () => warmStartup(config.startup),
warmIdle: () => warmIdle(config.idle),
// 路由
navigateTo,
switchTab,
reLaunch,
// 调试
getStats,
dumpMemory,
// 配置引用
config
}
}
// 默认单例(由 App.vue 初始化)
let _instance = null
export function getPreloadApi() {
return _instance
}
export function setPreloadApi(api) {
_instance = api
}
export { setUserIdGetter }

View File

@ -0,0 +1,97 @@
// frontend/utils/preloadApi/navigate.js
// 包装 uni.navigateTo / switchTab / reLaunch
// 跳转前 fire-and-forget 预拉目标页数据,不 await
import { prefetchFor } from './core'
/**
* URL 中解析 query string params 对象
* '/pages/foo/bar?id=123&type=hot' { id: '123', type: 'hot' }
*/
function parseQueryParams(url) {
const idx = url.indexOf('?')
if (idx === -1) return {}
const qs = url.substring(idx + 1)
const params = {}
// 使用 URLSearchParamsuniapp 环境支持)
try {
const usp = new URLSearchParams(qs)
for (const [k, v] of usp) {
// URLSearchParams 已自动解码,不需要再 decodeURIComponent
params[k] = v
}
} catch (e) {
// fallback手动解析
for (const pair of qs.split('&')) {
const eqIdx = pair.indexOf('=')
if (eqIdx === -1) continue
const k = decodeURIComponent(pair.substring(0, eqIdx))
const v = decodeURIComponent(pair.substring(eqIdx + 1))
if (k) params[k] = v
}
}
return params
}
/**
* URL 中提取目标页路径去掉 query string
*/
function extractPath(url) {
const idx = url.indexOf('?')
return idx === -1 ? url : url.substring(0, idx)
}
/**
* 替代 uni.navigateTo
* 内部解析目标页 触发预拉fire-and-forget 立即跳转
*/
export function navigateTo(opts) {
const url = typeof opts === 'string' ? opts : opts.url
const targetPath = extractPath(url)
const params = parseQueryParams(url)
// 触发预拉fire-and-forget不 await
prefetchFor(targetPath, params)
// 立即跳转
if (typeof opts === 'string') {
uni.navigateTo({ url: opts })
} else {
uni.navigateTo(opts)
}
}
/**
* 替代 uni.switchTab
*/
export function switchTab(opts) {
const url = typeof opts === 'string' ? opts : opts.url
const targetPath = extractPath(url)
const params = parseQueryParams(url)
prefetchFor(targetPath, params)
if (typeof opts === 'string') {
uni.switchTab({ url: opts })
} else {
uni.switchTab(opts)
}
}
/**
* 替代 uni.reLaunch
*/
export function reLaunch(opts) {
const url = typeof opts === 'string' ? opts : opts.url
const targetPath = extractPath(url)
const params = parseQueryParams(url)
prefetchFor(targetPath, params)
if (typeof opts === 'string') {
uni.reLaunch({ url: opts })
} else {
uni.reLaunch(opts)
}
}

View File

@ -0,0 +1,40 @@
// frontend/utils/preloadApi/scheduler.js
// 调度器:启动期预热 + idle 预拉
// 依赖 core.js 的 run()
import { run } from './core'
// App 端 fallback没有 requestIdleCallback用 setTimeout
const idle =
typeof requestIdleCallback === 'function'
? requestIdleCallback
: (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
/**
* 启动期预热
* @param {Array} startupList - config.startup 数组
*/
export function warmStartup(startupList) {
if (!startupList || !Array.isArray(startupList)) return
console.log('[preload] warmStartup:', startupList.length, 'keys')
for (const entry of startupList) {
// fire-and-forget不 await并发由 core 内部 semaphore 控制
run(entry.key, entry.params)
}
}
/**
* idle 预拉幂等run 内部处理去重和缓存命中
* @param {Array} idleList - config.idle 数组
*/
export function warmIdle(idleList) {
if (!idleList || !Array.isArray(idleList)) return
idle(() => {
console.log('[preload] warmIdle:', idleList.length, 'keys')
for (const entry of idleList) {
run(entry.key, entry.params)
}
})
}

View File

@ -0,0 +1,368 @@
// frontend/utils/preloadApi/storage.js
// 文件缓存适配器 — _doc/preload/{userId}/ 目录下的 JSON 文件读写
// APP-PLUS: 优先 plus.iopromisify降级 uni.getFileSystemManager
// H5/小程序: uni.getFileSystemManager
const BASE_DIR = '_doc/preload'
const NAMESPACE = 'preload'
// ── djb2 hash与 core.js 共用逻辑,此处独立一份避免循环依赖)──
function hashStr(str) {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
}
return (hash >>> 0).toString(16)
}
// ── 路径工具 ──
function getUserDir(userId) {
return `${BASE_DIR}/${userId || 'guest'}`
}
function getFilePath(userId, cacheKey) {
return `${getUserDir(userId)}/${hashStr(cacheKey)}.json`
}
// ── plus.io promisify 工具 ──
function promisifyPlusIO(fn) {
return new Promise((resolve, reject) => {
try {
fn(resolve, reject)
} catch (e) {
reject(e)
}
})
}
// ── 确保目录存在 ──
async function ensureDir(dirPath) {
// #ifdef APP-PLUS
return promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
`_doc/`,
(docEntry) => {
// 逐级创建 preload/{userId}
const parts = dirPath.replace('_doc/', '').split('/')
let currentEntry = docEntry
const createNext = (idx) => {
if (idx >= parts.length) return resolve()
currentEntry.getDirectory(
parts[idx],
{ create: true },
(dirEntry) => {
currentEntry = dirEntry
createNext(idx + 1)
},
(err) => reject(err)
)
}
createNext(0)
},
(err) => reject(err)
)
})
// #endif
// #ifndef APP-PLUS
try {
const fs = uni.getFileSystemManager()
// uni.getFileSystemManager 的 mkdir 需要父目录已存在,逐级创建
const parts = dirPath.replace('_doc/', '').split('/')
let current = '_doc'
for (const part of parts) {
current += '/' + part
try { fs.accessSync(current) } catch (e) { fs.mkdirSync(current) }
}
} catch (e) {
// 目录已存在或创建失败,静默
}
// #endif
}
// ── 公共 API ──
/**
* 读文件缓存条目
* @returns {Promise<{data, ts, ttl}|null>} null = 未命中
*/
export async function readEntry(userId, cacheKey) {
const filePath = getFilePath(userId, cacheKey)
try {
// #ifdef APP-PLUS
const content = await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(fileEntry) => {
fileEntry.file(
(file) => {
const reader = new plus.io.FileReader()
reader.onloadend = (e) => resolve(e.target.result)
reader.onerror = (e) => reject(e)
reader.readAsText(file, 'utf-8')
},
(err) => reject(err)
)
},
(err) => reject(err) // 文件不存在 = 未命中
)
})
return JSON.parse(content)
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
const raw = fs.readFileSync(filePath, 'utf-8')
return JSON.parse(raw)
// #endif
} catch (e) {
return null // 文件不存在 / 损坏 → 未命中
}
}
/**
* 写文件缓存条目fire-and-forget调用方不 await
* 内部自建 .catch 防止 unhandled rejection
*/
export function writeEntry(userId, cacheKey, data, ts, ttl) {
const dirPath = getUserDir(userId)
const filePath = getFilePath(userId, cacheKey)
const content = JSON.stringify({ data, ts, ttl })
ensureDir(dirPath).then(() => {
// #ifdef APP-PLUS
return promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
dirEntry.getFile(
hashStr(cacheKey) + '.json',
{ create: true },
(fileEntry) => {
fileEntry.createWriter(
(writer) => {
writer.onwriteend = () => resolve()
writer.onerror = (e) => reject(e)
writer.write(content)
},
(err) => reject(err)
)
},
(err) => reject(err)
)
},
(err) => reject(err)
)
})
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
fs.writeFileSync(filePath, content, 'utf-8')
// #endif
}).catch((err) => {
console.warn('[preload] storage write failed:', filePath, err.message)
})
}
/**
* 删除指定用户的文件缓存目录
*/
export async function clearForUser(userId) {
const dirPath = getUserDir(userId)
try {
// #ifdef APP-PLUS
await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
dirEntry.removeRecursively(
() => resolve(),
(err) => reject(err)
)
},
// 目录不存在不算错误
() => resolve()
)
})
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
try { fs.rmdirSync(dirPath, true) } catch (e) { /* absent = ok */ }
// #endif
} catch (e) {
console.warn('[preload] clearForUser failed:', userId, e.message)
}
}
/**
* 获取用户缓存目录总大小字节
* 用于 FIFO 容量检查
* 注意累加所有文件的 file.size异步回调全部完成后才 resolve
*/
export async function getTotalCacheSize(userId) {
const dirPath = getUserDir(userId)
let totalSize = 0
try {
// #ifdef APP-PLUS
await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
const reader = dirEntry.createReader()
let pending = 0
let done = false
const readAll = () => {
reader.readEntries(
(entries) => {
if (entries.length === 0) {
done = true
if (pending === 0) resolve()
return
}
for (const entry of entries) {
if (entry.isFile) {
pending++
entry.file(
(f) => {
totalSize += (f.size || 0)
pending--
if (done && pending === 0) resolve()
},
() => {
pending--
if (done && pending === 0) resolve()
}
)
}
}
readAll() // 递归读下一批
},
(err) => reject(err)
)
}
readAll()
},
() => resolve() // 目录不存在 → size = 0
)
})
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
try {
const files = fs.readdirSync(dirPath)
for (const f of files) {
try {
const stat = fs.statSync(dirPath + '/' + f)
totalSize += stat.size || 0
} catch (e) { /* skip */ }
}
} catch (e) { /* absent = ok */ }
// #endif
} catch (e) {
// ignore
}
return totalSize
}
/**
* FIFO 淘汰最旧文件直到总大小 < maxSize 字节
* APP-PLUS递归 readEntries 收集所有文件 mtime 排序 从最旧的开始删除
* APP-PLUSreaddir + stat mtime 排序 删除最旧的
* getTotalCacheSize 的异步回调方式不适用于"删除后重算"循环
* 此处改为一次 scan 出文件列表 排序 按需删除
*/
export async function evictOldest(userId, maxSize) {
const dirPath = getUserDir(userId)
try {
// #ifdef APP-PLUS
// 1. 收集所有文件及其 mtime 和 size
const files = await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
const reader = dirEntry.createReader()
const collected = []
let pending = 0
let done = false
const readAll = () => {
reader.readEntries(
(entries) => {
if (entries.length === 0) {
done = true
if (pending === 0) resolve(collected)
return
}
for (const entry of entries) {
if (entry.isFile) {
pending++
entry.file(
(f) => {
// plus.io File 的 modificationTime 或直接用 lastModified
const mtime = f.lastModified || f.lastModifiedDate?.getTime?.() || 0
collected.push({ name: entry.name, entry, size: f.size || 0, mtime })
pending--
if (done && pending === 0) resolve(collected)
},
() => {
pending--
if (done && pending === 0) resolve(collected)
}
)
}
}
readAll()
},
(err) => reject(err)
)
}
readAll()
},
() => resolve([]) // 目录不存在 → 空列表
)
})
// 2. 按 mtime 升序排列(最旧的在前)
files.sort((a, b) => a.mtime - b.mtime)
// 3. 计算当前总大小,按 FIFO 删除直到 < maxSize * 0.8
let totalSize = files.reduce((sum, f) => sum + f.size, 0)
for (const f of files) {
if (totalSize <= maxSize * 0.8) break
f.entry.remove(() => {}, () => {})
totalSize -= f.size
}
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
try {
const fileNames = fs.readdirSync(dirPath)
const files = fileNames.map(name => {
try {
const stat = fs.statSync(dirPath + '/' + name)
return { name, size: stat.size || 0, mtime: stat.lastModified || stat.lastModifiedTime || 0 }
} catch (e) {
return { name, size: 0, mtime: 0 }
}
})
// 按 mtime 升序(最旧的在前)
files.sort((a, b) => a.mtime - b.mtime)
let totalSize = files.reduce((sum, f) => sum + f.size, 0)
for (const f of files) {
if (totalSize <= maxSize * 0.8) break
try { fs.unlinkSync(dirPath + '/' + f.name) } catch (e) { /* skip */ }
totalSize -= f.size
}
} catch (e) { /* absent = ok */ }
// #endif
} catch (e) {
console.warn('[preload] evictOldest failed:', userId, e.message)
}
}