feat:增加预加载接口
This commit is contained in:
parent
f6b2b31d76
commit
5625f6ac28
1198
docs/specs/2026-07-08-app-download-page-design.md
Normal file
1198
docs/specs/2026-07-08-app-download-page-design.md
Normal file
File diff suppressed because it is too large
Load Diff
2240
docs/superpowers/plans/2026-07-08-preload-api-implementation.md
Normal file
2240
docs/superpowers/plans/2026-07-08-preload-api-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 3:Vue 集成 | `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. 目标与范围
|
||||
|
||||
为 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 API):import `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) |
|
||||
| `utils/api.js` | `request()` 返回的 Promise 上挂 `.abort()` 方法(约 3 行改动,不影响现有调用方) |
|
||||
| `utils/api.js` | `request()` 返回的 Promise 上挂 `.abort()` 方法(~8 行改动:捕获 `requestTask` + 挂 `abort()` + `fail` 中识别 abort 信号,详见 §4.4.1) |
|
||||
| 其余 `utils/*` | **不动** |
|
||||
|
||||
### 2.5 wrappedNavigateTo 替换策略(已确定方案 A)
|
||||
@ -86,6 +160,14 @@ Page onLoad (composable) ─┘
|
||||
|
||||
**全量替换规则**:用 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
|
||||
@ -153,7 +235,7 @@ export const preloadConfig = {
|
||||
- `concurrency`:单次批量内最大并发
|
||||
- `timeout`:单接口超时(ms)
|
||||
- `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 命令式 API(utils/preloadApi/index.js 导出)
|
||||
|
||||
```js
|
||||
preloadApi.run(key, params?) // 触发一次预拉(写缓存):先查内存,未过期则跳过;过期/未命中才拉取。401/7/16 静默吞掉。不返回数据
|
||||
preloadApi.get(key, params?) // 读缓存:命中直接返回;未命中/过期则拉取;401/7/16 业务码静默吞掉(不抛给调用方)
|
||||
preloadApi.run(key, params?) // 触发一次预拉(写缓存):①先查内存,未过期则跳过;②查 inFlight,已在请求中则共享 Promise(不重发);③过期/未命中/未 inFlight 才发起新请求。401/7/16 静默吞掉。不返回数据
|
||||
preloadApi.get(key, params?) // 读缓存:命中直接返回;未命中/过期则拉取(同样走 inFlight 去重);401/7/16 业务码静默吞掉(不抛给调用方)
|
||||
preloadApi.refresh(key, params?, force?) // 命令式刷新:force=true 跳过 TTL。非组件上下文使用;组件上下文用 usePreload.refresh()
|
||||
preloadApi.prefetchFor(targetPath, params?) // wrappedNavigateTo 内部调用
|
||||
preloadApi.invalidate(key?) // 失效单 key(仅内存)
|
||||
@ -245,20 +327,35 @@ navigateTo({ url: '/pages/asset-detail/asset-detail?id=123' })
|
||||
- composable 在 `onBeforeUnmount` / watcher 中调 `abort()`
|
||||
- abort 后清掉 inFlight 条目(abort 不走 finally 路径,需显式清理)
|
||||
|
||||
**`request()` 改造**(唯一对 `utils/api.js` 的改动,约 3 行):
|
||||
**`request()` 改造**(唯一对 `utils/api.js` 的改动,约 8 行):
|
||||
|
||||
```js
|
||||
// utils/api.js — request() 内部
|
||||
export function request(options) {
|
||||
const requestTask = uni.request({...}) // 已有
|
||||
let abortFn = () => requestTask.abort()
|
||||
let _aborted = false // 新增:abort 标记
|
||||
const requestTask = uni.request({...}) // 改动:捕获 requestTask(原来直接调用不赋值)
|
||||
let abortFn = () => {
|
||||
_aborted = true // 新增:标记已 abort
|
||||
requestTask.abort()
|
||||
}
|
||||
const p = new Promise((resolve, reject) => {
|
||||
// ... 现有 success/fail 逻辑不变 ...
|
||||
// 改动:fail 回调中识别 abort 信号
|
||||
// fail: (err) => {
|
||||
// if (_aborted) return // abort 触发的 fail 静默忽略
|
||||
// reject(new Error(err.errMsg || '网络请求失败'))
|
||||
// }
|
||||
})
|
||||
p.abort = abortFn // 新增这一行
|
||||
p.abort = abortFn // 新增这一行
|
||||
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 API(composables/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/`。
|
||||
2. **调用顺序约束**(重要):CLEAR_AUTH 时**先 `clearUser(userId)` 再清 token / user**。原因:token 清掉之后任何业务发起的请求都会 401;preload 缓存如果在 token 清之前清理完,业务不会有 401 + 缓存不一致的窗口。
|
||||
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` 的协作
|
||||
|
||||
@ -516,6 +636,22 @@ const idle = typeof requestIdleCallback === 'function'
|
||||
| composable 测试 | `usePreload` 暴露的响应式 state 正确性 | mock core |
|
||||
| 集成冒烟 | 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 次
|
||||
- [ ] 用户切换(onSwitchUser):me.* 前缀全失效、oldUser 的文件缓存目录已删除、newUser 缓存不受影响
|
||||
|
||||
### 8.3 调试与可观测性
|
||||
|
||||
**开发者工具**(`import.meta.env.DEV` 时启用):
|
||||
@ -543,22 +679,6 @@ window.__PRELOAD_DEBUG__ = {
|
||||
- 清理事件:不记日志(正常路径)
|
||||
- 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 次
|
||||
- [ ] 用户切换(onSwitchUser):me.* 前缀全失效、oldUser 的文件缓存目录已删除、newUser 缓存不受影响
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与缓解
|
||||
@ -580,14 +700,15 @@ window.__PRELOAD_DEBUG__ = {
|
||||
|
||||
## 10. 实施清单(落到 writing-plans 阶段再细化)
|
||||
|
||||
0. `frontend/utils/api.js` — `request()` 返回的 Promise 上挂 `.abort()` 方法(约 3 行改动)
|
||||
1. `frontend/utils/preloadApi/storage.js`
|
||||
0. `frontend/utils/api.js` — `request()` 返回的 Promise 上挂 `.abort()` 方法(~8 行改动:捕获 requestTask + 挂 abort() + fail 中识别 `_aborted` 标记,详见 §4.4.1)
|
||||
1. `frontend/utils/preloadApi/storage.js`(plus.io 回调 promisify 处理,详见 §6.5)
|
||||
2. `frontend/utils/preloadApi/core.js`
|
||||
3. `frontend/utils/preloadApi/scheduler.js`
|
||||
4. `frontend/utils/preloadApi/navigate.js`
|
||||
5. `frontend/utils/preloadApi/index.js`
|
||||
6. `frontend/composables/usePreload.js`
|
||||
7. `frontend/config/preload.config.js`(初始内容,含 castlove.config / me.profile / ranking.hot / asset-detail / activity-detail)
|
||||
8. `frontend/App.vue`(注入启动 + 登出监听)
|
||||
9. `frontend/utils/preloadApi/__tests__/`(核心 + 文件缓存适配器 + navigate + composable)
|
||||
10. README 文档片段(放在 `frontend/utils/preloadApi/README.md`,供业务开发查阅 config schema + API 速查表)
|
||||
8. `frontend/App.vue`(Options API 集成:import + onLaunch 调用 warmStartup() + onShow 调用 warmIdle(),详见 §6.5.1)
|
||||
9. `frontend/store/modules/user.js`(SET_USER_INFO / CLEAR_AUTH mutation patch 注入 preload 缓存失效,详见 §6.5)
|
||||
10. `frontend/utils/preloadApi/__tests__/`(核心 + 文件缓存适配器 + navigate + composable)
|
||||
11. README 文档片段(放在 `frontend/utils/preloadApi/README.md`,供业务开发查阅 config schema + API 速查表)
|
||||
@ -3,6 +3,8 @@ import { getGlobalSocket } from "@/utils/socket";
|
||||
import { emitAppReturnFromBackground } from "@/utils/backgroundRefreshBus.js";
|
||||
import { registerDeviceApi, unregisterDeviceApi } from "@/utils/api.js";
|
||||
import { getDeviceFingerprint } from "@/utils/deviceFingerprint.js";
|
||||
import { initPreloadApi, setPreloadApi, getPreloadApi } from '@/utils/preloadApi/index'
|
||||
import { preloadConfig } from '@/config/preload.config'
|
||||
|
||||
// 记录上次隐藏时间的 storage key
|
||||
const HIDE_TIME_KEY = "app_last_hide_time";
|
||||
@ -48,6 +50,16 @@ export default {
|
||||
// getDeviceFingerprint 内部自包含 setStorageSync,但显式调一次可避免首次网络请求走临时生成
|
||||
getDeviceFingerprint();
|
||||
// 不在这里初始化 AI Chat 连接,由各页面自行管理
|
||||
|
||||
// ★ 新增:初始化预加载 API
|
||||
try {
|
||||
const api = initPreloadApi(preloadConfig)
|
||||
setPreloadApi(api)
|
||||
api.warmStartup()
|
||||
} catch (e) {
|
||||
console.warn('[preload] init failed:', e.message)
|
||||
}
|
||||
|
||||
this.setPermissions();
|
||||
},
|
||||
onShow: function () {
|
||||
@ -69,6 +81,14 @@ export default {
|
||||
|
||||
this.handleBackgroundReturn();
|
||||
|
||||
// ★ 新增:idle 预拉
|
||||
try {
|
||||
const api = getPreloadApi()
|
||||
if (api) api.warmIdle()
|
||||
} catch (e) {
|
||||
// silent
|
||||
}
|
||||
|
||||
// 从后台切回前台时重新初始化 WebSocket(onHide 中已关闭)
|
||||
this.initWebSocket();
|
||||
|
||||
|
||||
91
frontend/composables/usePreload.js
Normal file
91
frontend/composables/usePreload.js
Normal 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 }
|
||||
}
|
||||
262
frontend/config/preload.config.js
Normal file
262
frontend/config/preload.config.js
Normal 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,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@ -242,6 +242,7 @@
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app';
|
||||
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi, getAssetMaterialsApi, getAssetLikersApi } from '@/utils/api.js';
|
||||
import { getPreloadApi } from '@/utils/preloadApi/index';
|
||||
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
|
||||
import LikeUsersModal from '@/pages/components/LikeUsersModal.vue';
|
||||
import ShareReportButtons from '@/pages/components/ShareReportButtons.vue';
|
||||
@ -472,9 +473,10 @@ const handleLikeUsersTabChange = ({ tabIndex }) => {
|
||||
likeUsersActiveTab.value = tabIndex;
|
||||
};
|
||||
|
||||
// 拜访按钮
|
||||
// 拜访按钮(预拉他人作品数据)
|
||||
const handleVisit = (user) => {
|
||||
uni.navigateTo({
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: `/pages/profile/hisWorks?userId=${user.userId}&nickname=${encodeURIComponent(user.nickname)}`
|
||||
});
|
||||
};
|
||||
|
||||
@ -143,6 +143,7 @@ import { useBanner } from "@/pages/square/composables/useBanner.js";
|
||||
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js";
|
||||
import { reportEvent } from "@/utils/task-api.js";
|
||||
import { getEarningsSummaryApi } from "@/utils/api.js";
|
||||
import { getPreloadApi } from "@/utils/preloadApi/index";
|
||||
|
||||
// 获取星援活动数据(复用 square 的 useBanner)
|
||||
const { bannerActivities, loadBannerActivities } = useBanner();
|
||||
@ -363,9 +364,10 @@ const handleBack = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
// 处理水晶余额点击 → 跳转数据看板
|
||||
// 处理水晶余额点击 → 跳转数据看板(预拉 7 个接口)
|
||||
const handleClick = () => {
|
||||
uni.navigateTo({
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: "/pages/dashboard/dashboard",
|
||||
});
|
||||
};
|
||||
@ -377,8 +379,9 @@ const handleTaskClick = () => {
|
||||
|
||||
// 处理星援活动图标点击 → 跳到星援活动中心
|
||||
const handleActivityClick = (activity) => {
|
||||
// 始终先进入活动中心列表(不论是否传了单个 activity 对象)
|
||||
uni.navigateTo({
|
||||
// 始终先进入活动中心列表(预拉活动列表 API)
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: "/pages/support-activity/center",
|
||||
fail: (err) => {
|
||||
console.error("[Header] 跳转活动中心失败:", err);
|
||||
@ -453,7 +456,8 @@ const handleAvatarClick = () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
uni.navigateTo({
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: "/pages/profile/profile",
|
||||
});
|
||||
};
|
||||
|
||||
@ -194,6 +194,7 @@ import { ref, computed, onMounted, onActivated, onDeactivated, nextTick, watch }
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { getStarbookHomeApi } from '@/utils/api.js';
|
||||
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
|
||||
import { getPreloadApi } from '@/utils/preloadApi/index';
|
||||
|
||||
// 屏幕宽度
|
||||
const screenWidth = ref(0);
|
||||
@ -316,10 +317,11 @@ const processGroupsWithValidUrls = async (groups) => {
|
||||
processedData.value = processed;
|
||||
};
|
||||
|
||||
// 点击卡片跳转到详情页
|
||||
// 点击卡片跳转到详情页(预拉点赞列表)
|
||||
const handleCardClick = (item) => {
|
||||
if (item.asset_id) {
|
||||
uni.navigateTo({
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: `/pages/asset-detail/asset-detail?asset_id=${item.asset_id}`
|
||||
});
|
||||
}
|
||||
@ -337,7 +339,8 @@ const goToMore = (group, grade) => {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&');
|
||||
uni.navigateTo({
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: `/pages/starbook/items?${queryString}`
|
||||
});
|
||||
};
|
||||
|
||||
@ -601,6 +601,7 @@ import {
|
||||
} from "@/utils/api";
|
||||
import { validateNickname, validatePassword } from "@/utils/validator.js";
|
||||
import { clearAvatarCache } from "@/utils/avatarCache";
|
||||
import { getPreloadApi } from "@/utils/preloadApi/index";
|
||||
import GuideModal from "@/pages/tasks/GuideModal.vue";
|
||||
import GuideOverlay from "@/components/GuideOverlay.vue";
|
||||
import FeedbackModal from "../components/FeedbackModal.vue";
|
||||
@ -885,18 +886,13 @@ const copyAddress = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 处理资产按钮点击
|
||||
// 处理资产按钮点击(预拉目标页 API)
|
||||
const handleAssetClick = (type) => {
|
||||
const api = getPreloadApi();
|
||||
if (type === "myWorks") {
|
||||
// 跳转到展馆页面
|
||||
uni.navigateTo({
|
||||
url: "/pages/profile/myWorks",
|
||||
});
|
||||
(api || uni).navigateTo({ url: "/pages/profile/myWorks" });
|
||||
} else if (type === "starbook") {
|
||||
// 跳转到星册页面
|
||||
uni.navigateTo({
|
||||
url: "/pages/starbook/index",
|
||||
});
|
||||
(api || uni).navigateTo({ url: "/pages/starbook/index" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -111,48 +111,30 @@ import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { onShow, onHide } from "@dcloudio/uni-app";
|
||||
import { getInspirationFlowApi } from '@/utils/api.js'
|
||||
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js'
|
||||
import { usePreload } from "@/composables/usePreload.js";
|
||||
|
||||
// 组件不接收任何 prop,所有数据/状态内部管理
|
||||
const emit = defineEmits(['cardClick', 'loaded'])
|
||||
|
||||
// ========== 内部状态 ==========
|
||||
const creationList = ref([])
|
||||
const cursor = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const noMore = ref(false)
|
||||
const likingMap = ref({})
|
||||
const activeCategory = ref('hot') // 当前选中的分类
|
||||
const isComponentActive = ref(true) // 组件是否处于激活态
|
||||
const activeCategory = ref('hot')
|
||||
const isComponentActive = ref(true)
|
||||
|
||||
// ========== 区域 A:主Tab 配置(内化) ==========
|
||||
// ★ 首屏数据走 usePreload(自动缓存 + 切换 tab 回来自动命中)
|
||||
const { data: firstPageData, loading: firstPageLoading } = usePreload("inspiration.flow");
|
||||
|
||||
// ========== 区域 A:主Tab 配置 ==========
|
||||
const mainTabs = [
|
||||
{
|
||||
name: '星卡',
|
||||
type: 'star_card',
|
||||
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,
|
||||
},
|
||||
{ name: '星卡', type: 'star_card', 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 = [
|
||||
{ label: '热门作品', value: 'hot' },
|
||||
{ label: '最新作品', value: 'new' },
|
||||
@ -161,47 +143,30 @@ const categories = [
|
||||
{ label: '海报', value: 'poster' },
|
||||
]
|
||||
|
||||
// ========== 模板 ref(categoryRef 用于内部测量分类标签位置) ==========
|
||||
const categoryRef = ref(null)
|
||||
|
||||
// ========== 滚动 fixed 行为(内部状态) ==========
|
||||
const categoryOffsetTop = ref(null)
|
||||
const categoryHeight = ref(0)
|
||||
const fixedTopPx = ref(50) // 近似对应 CSS top: 96rpx
|
||||
const fixedTopPx = ref(50)
|
||||
const isFixed = ref(false)
|
||||
|
||||
// ========== 内部事件处理(不再冒泡给父组件) ==========
|
||||
|
||||
// 主Tab点击 - 内部直接跳转铸造页
|
||||
// 主Tab点击
|
||||
const handleMainTabClick = (tab) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}`,
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}` })
|
||||
}
|
||||
|
||||
// 分类切换 - 内部更新 activeCategory
|
||||
const handleCategoryChange = (value) => {
|
||||
if (activeCategory.value === value) return
|
||||
activeCategory.value = value
|
||||
}
|
||||
|
||||
// ========== 父级调用的方法(滚动 → fixed 切换) ==========
|
||||
/**
|
||||
* 父级在 onScroll 时调用本方法,根据 scrollTop 决定是否切到 fixed
|
||||
* @param {Number} scrollTop 当前页面滚动距离(px)
|
||||
*/
|
||||
function updateScroll(scrollTop) {
|
||||
if (categoryOffsetTop.value === null) return
|
||||
isFixed.value = scrollTop + fixedTopPx.value >= categoryOffsetTop.value
|
||||
}
|
||||
|
||||
// 兼容移动端 rAF 节流:父级可监听 touchstart / touchmove 后调用
|
||||
function update() {
|
||||
if (categoryOffsetTop.value === null) return
|
||||
// 预留 hook,行为同 updateScroll 但语义更轻
|
||||
}
|
||||
function update() {}
|
||||
|
||||
// 重新测量(适用于页面尺寸变化 / 内容更新后)
|
||||
function remeasure() {
|
||||
nextTick(() => {
|
||||
if (categoryRef.value) {
|
||||
@ -211,7 +176,6 @@ function remeasure() {
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 创作网格逻辑(保留) ==========
|
||||
const formatCount = (count) => {
|
||||
if (!count) return '0'
|
||||
if (count >= 10000) return (count / 10000).toFixed(1) + 'w'
|
||||
@ -223,7 +187,6 @@ const handleCardClick = (item) => {
|
||||
emit('cardClick', item)
|
||||
}
|
||||
|
||||
// 把后端返回的 cover_url 转成真实可访问的 URL(OSS 预签名前端实现)
|
||||
async function resolveItemImage(item) {
|
||||
if (!item) return item
|
||||
const cover = item.cover_url || item.cover_image || ''
|
||||
@ -233,6 +196,24 @@ async function resolveItemImage(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 () => {
|
||||
cursor.value = ''
|
||||
isLoading.value = true
|
||||
@ -240,23 +221,8 @@ const loadUsers = async () => {
|
||||
try {
|
||||
const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: '' })
|
||||
if (res.code === 0 && res.data?.items && res.data.items.length > 0) {
|
||||
const items = res.data.items
|
||||
cursor.value = res.data.cursor || ''
|
||||
// 逐个把 cover_url 转成真实可访问的 URL
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
creationList.value = await transformItems(res.data.items)
|
||||
} else {
|
||||
noMore.value = true
|
||||
}
|
||||
@ -274,22 +240,8 @@ const loadMore = async () => {
|
||||
try {
|
||||
const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: cursor.value })
|
||||
if (res.code === 0 && res.data?.items && res.data.items.length > 0) {
|
||||
const items = res.data.items
|
||||
cursor.value = res.data.cursor || ''
|
||||
const newItems = 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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const newItems = await transformItems(res.data.items)
|
||||
creationList.value = [...creationList.value, ...newItems]
|
||||
} else {
|
||||
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 });
|
||||
|
||||
// fallback:1.5s 后如果 usePreload 还没数据
|
||||
let fallbackTimer = null;
|
||||
onMounted(() => {
|
||||
fallbackTimer = setTimeout(() => {
|
||||
if (firstPageConsumed) return;
|
||||
loadUsers();
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
// 分类变化 → 重新加载(不走缓存)
|
||||
watch(activeCategory, () => {
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
// 激活态变化 → 首次激活时拉数据
|
||||
watch(isComponentActive, (active) => {
|
||||
if (active && creationList.value.length === 0) {
|
||||
loadUsers()
|
||||
@ -315,8 +295,6 @@ watch(isComponentActive, (active) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
|
||||
uni.$on('assetLiked', ({ asset_id, data }) => {
|
||||
likingMap.value = { ...likingMap.value, [asset_id]: true }
|
||||
setTimeout(() => {
|
||||
@ -334,7 +312,6 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测量分类标签位置和高度(rpx → px 换算 + 启动时定位)
|
||||
const info = uni.getSystemInfoSync()
|
||||
const sw = info.windowWidth || 750
|
||||
fixedTopPx.value = (96 * sw) / 750
|
||||
@ -346,25 +323,16 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
isComponentActive.value = true
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
isComponentActive.value = false
|
||||
})
|
||||
onShow(() => { isComponentActive.value = true })
|
||||
onHide(() => { isComponentActive.value = false })
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.$off('assetLiked')
|
||||
if (fallbackTimer) clearTimeout(fallbackTimer);
|
||||
})
|
||||
|
||||
// ========== 对外暴露(仅滚动需要的方法) ==========
|
||||
defineExpose({
|
||||
loadMore,
|
||||
categoryRef,
|
||||
updateScroll,
|
||||
update,
|
||||
remeasure,
|
||||
loadMore, categoryRef, updateScroll, update, remeasure,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -161,9 +161,12 @@ import {
|
||||
getAssetCoverRealUrl,
|
||||
getInstantAssetCoverUrl,
|
||||
} 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
|
||||
// 处理 3 种形态:/static/... (本地)、相对路径 (需 presign)、完整 URL (可能过期)
|
||||
async function resolveItemUrls(item) {
|
||||
if (!item) return item;
|
||||
const cover = item.cover_url || item.cover_image || "";
|
||||
@ -176,30 +179,21 @@ async function resolveItemUrls(item) {
|
||||
const emit = defineEmits(["cardClick"]);
|
||||
|
||||
const items = ref([]);
|
||||
const loading = ref(false);
|
||||
const loading = ref(true);
|
||||
const likingMap = ref({});
|
||||
const activeTabKey = ref("");
|
||||
// 下拉刷新状态:与 scroll-view 的 :refresher-triggered 双向绑定
|
||||
const refreshing = ref(false);
|
||||
|
||||
// ===== 分页状态 =====
|
||||
// currentPage : 已加载到的页码(从 1 开始)
|
||||
// hasMore : 是否还有下一页(接口返回数量 < PAGE_SIZE 或累计已 >= total 时置 false)
|
||||
// loadingMore : 是否正在加载下一页(防止 scrolltolower 重复触发)
|
||||
const currentPage = ref(1);
|
||||
const hasMore = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
|
||||
// scroll-into-view 目标元素 id —— 切换 tab 时设为 'grid-top' 把 scroll-view 滚回顶部
|
||||
// 通过先置空 → nextTick 设回目标 id 来强制触发(即使上一次已经是同一个 id 也能生效)
|
||||
const scrollIntoView = ref("");
|
||||
|
||||
// 每页数量
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// Tab 配置(直接写死在组件内)
|
||||
// 新增 tab 在这里 push 一项即可:{ key, label, icon, iconWidth, iconHeight, fetch }
|
||||
// fetch 接受 page 参数,由组件内部分页逻辑统一传入。
|
||||
// Tab 配置
|
||||
const tabs = [
|
||||
{
|
||||
key: "hot",
|
||||
@ -253,7 +247,7 @@ const activeTab = computed(
|
||||
() => tabs.find((t) => t.key === activeTabKey.value) || tabs[0],
|
||||
);
|
||||
|
||||
// 初始化 activeTabKey:默认选第一个 tab
|
||||
// 初始化 activeTabKey
|
||||
watch(
|
||||
() => tabs,
|
||||
(newTabs) => {
|
||||
@ -267,6 +261,65 @@ watch(
|
||||
{ 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 });
|
||||
|
||||
// fallback:1.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
|
||||
const handleTabClick = (e) => {
|
||||
const key = e.currentTarget.dataset.key;
|
||||
@ -275,12 +328,11 @@ const handleTabClick = (e) => {
|
||||
}
|
||||
};
|
||||
|
||||
// activeTab 变化时重新加载数据(重置分页)
|
||||
// activeTab 变化时重新加载(重置分页,走手动 fetch)
|
||||
watch(activeTab, () => {
|
||||
resetAndLoad();
|
||||
});
|
||||
|
||||
// 格式化数量
|
||||
const formatCount = (count) => {
|
||||
if (!count) return "0";
|
||||
if (count >= 10000) return (count / 10000).toFixed(1) + "w";
|
||||
@ -292,7 +344,7 @@ const handleCardClick = (item) => {
|
||||
emit("cardClick", item);
|
||||
};
|
||||
|
||||
// 监听全局点赞事件,更新状态
|
||||
// 监听全局点赞事件
|
||||
const onAssetLiked = ({ asset_id, data }) => {
|
||||
const index = items.value.findIndex(
|
||||
(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,
|
||||
};
|
||||
items.value = updatedItems;
|
||||
// 触发动画
|
||||
likingMap.value = { ...likingMap.value, [asset_id]: true };
|
||||
setTimeout(() => {
|
||||
likingMap.value = { ...likingMap.value, [asset_id]: false };
|
||||
@ -314,14 +365,11 @@ const onAssetLiked = ({ asset_id, data }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 重置分页并重新加载第 1 页
|
||||
// 切换 tab 时调用:清空 items、把 currentPage 拉回 1、hasMore 设回 true、滚动到顶。
|
||||
const resetAndLoad = () => {
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
loadingMore.value = false;
|
||||
items.value = [];
|
||||
// 强制滚回顶部:先清空再设回 id,避免上一次值就是 'grid-top' 时不触发
|
||||
scrollIntoView.value = "";
|
||||
nextTick(() => {
|
||||
scrollIntoView.value = "grid-top";
|
||||
@ -329,23 +377,13 @@ const resetAndLoad = () => {
|
||||
loadData({ append: false });
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
// append=false:首页加载,覆盖 items;append=true:分页追加,拼接到 items 末尾。
|
||||
//
|
||||
// 性能关键:不再 await 预签名!流程:
|
||||
// 1) 接口返回后立即用 getInstantAssetCoverUrl 同步拿到「能马上渲染的 URL」(命中缓存/未过期URL/占位图)
|
||||
// 2) 立刻 set items.value → 列表瞬时出现,骨架屏立即下线
|
||||
// 3) 后台并行跑 getAssetCoverRealUrl 拿到精确的预签名 URL,逐个 patch 回 items.value[i].cover_url
|
||||
// (Vue 3 ref 数组里的对象是 reactive 代理,单个属性变更会触发该卡片重渲染)
|
||||
const PLACEHOLDER_IMAGE = "/static/nft/collection.png";
|
||||
|
||||
// 手动 fetch(切换 tab / 分页 / 下拉刷新时使用)
|
||||
const loadData = async ({ append = false, silent = false } = {}) => {
|
||||
const tab = activeTab.value;
|
||||
if (!tab || typeof tab.fetch !== "function") {
|
||||
console.warn("[HotCategoryBlock] 当前 tab 未配置 fetch:", tab);
|
||||
if (!append && !silent) items.value = [];
|
||||
return false;
|
||||
}
|
||||
// 首页用 loading 整屏骨架;分页用 loadingMore 底部小指示器
|
||||
// silent=true 时跳过骨架屏、保留旧 items(用于下拉刷新,避免列表闪烁/抖动)
|
||||
if (!tab || typeof tab.fetch !== "function") return false;
|
||||
|
||||
if (append) {
|
||||
loadingMore.value = true;
|
||||
} else if (!silent) {
|
||||
@ -354,60 +392,13 @@ const loadData = async ({ append = false, silent = false } = {}) => {
|
||||
try {
|
||||
const res = await tab.fetch(currentPage.value);
|
||||
if (res && res.code === 0 && res.data?.items) {
|
||||
// ① 立刻准备好「能马上渲染的」items —— 不等任何网络
|
||||
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),
|
||||
};
|
||||
});
|
||||
processRawItems(res.data.items, append);
|
||||
|
||||
// ② 立即上屏
|
||||
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)
|
||||
) {
|
||||
// 已是精确 URL;仍然异步校准一次以处理过期(getAssetCoverRealUrl 内部命中缓存就是同步快速返回)
|
||||
}
|
||||
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);
|
||||
if (total > 0) {
|
||||
hasMore.value = items.value.length < total;
|
||||
} else {
|
||||
hasMore.value = rawItems.length >= PAGE_SIZE;
|
||||
hasMore.value = res.data.items.length >= PAGE_SIZE;
|
||||
}
|
||||
return true;
|
||||
} 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 = () => {
|
||||
if (loading.value || loadingMore.value || !hasMore.value) return;
|
||||
currentPage.value += 1;
|
||||
loadData({ append: true });
|
||||
};
|
||||
|
||||
// 处理下拉刷新
|
||||
// 设计原则(避免抖动):
|
||||
// - 不清空 items.value:保留旧列表让用户视觉连续,避免内容高度突变
|
||||
// - 不重置 scrollIntoView:用户下拉时已经在顶部,强制滚动会和手势冲突
|
||||
// - silent=true 跳过骨架屏:loadData 内部不会触发 loading 状态切换
|
||||
// - 新数据回来后整体替换 items.value,scroll-view 平滑过渡到新列表
|
||||
const handleRefresh = async () => {
|
||||
if (refreshing.value || loading.value) return;
|
||||
refreshing.value = true;
|
||||
@ -451,25 +432,13 @@ const handleRefresh = async () => {
|
||||
loadingMore.value = false;
|
||||
const success = await loadData({ append: false, silent: true });
|
||||
if (success) {
|
||||
uni.showToast({
|
||||
title: "刷新成功",
|
||||
icon: "success",
|
||||
duration: 1200,
|
||||
});
|
||||
uni.showToast({ title: "刷新成功", icon: "success", duration: 1200 });
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: "刷新失败",
|
||||
icon: "none",
|
||||
duration: 1500,
|
||||
});
|
||||
uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[HotCategoryBlock] 刷新失败", e?.message ?? e);
|
||||
uni.showToast({
|
||||
title: "刷新失败",
|
||||
icon: "none",
|
||||
duration: 1500,
|
||||
});
|
||||
uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 });
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
refreshing.value = false;
|
||||
@ -479,7 +448,8 @@ const handleRefresh = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("assetLiked", onAssetLiked);
|
||||
loadData();
|
||||
// 首屏由 usePreload 提供,不再手动 loadData()
|
||||
// fallback timer 在 1.5s 后触发
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
@ -488,6 +458,7 @@ onShow(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.$off("assetLiked", onAssetLiked);
|
||||
if (fallbackTimer) clearTimeout(fallbackTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
<!-- 标题 -->
|
||||
<!-- <view class="title">★ 星河 ★</view> -->
|
||||
|
||||
<!-- Loading 骨架 -->
|
||||
<view v-if="loading" class="skeleton-grid">
|
||||
<!-- Loading 骨架(API 请求 + URL 解析) -->
|
||||
<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 9" :key="'s' + i" class="skeleton-ring"></view>
|
||||
</view>
|
||||
@ -18,7 +18,7 @@
|
||||
<!-- Error -->
|
||||
<view v-else-if="error" class="error-state">
|
||||
<text class="error-text">加载失败,点击重试</text>
|
||||
<view class="retry-btn" @click="loadData">重试</view>
|
||||
<view class="retry-btn" @click="refresh(true)">重试</view>
|
||||
</view>
|
||||
|
||||
<!-- Empty:数据为空 -->
|
||||
@ -53,65 +53,60 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, watch, onUnmounted } from "vue";
|
||||
import PodiumCard from "./PodiumCard.vue";
|
||||
import ScatteredRanks from "./ScatteredRanks.vue";
|
||||
import { generateRingPositions } from "./config.js";
|
||||
import { getHotRankingApi } from "@/utils/api.js";
|
||||
import { getAssetCoverRealUrl } from "@/utils/assetImageHelper.js";
|
||||
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js";
|
||||
import { usePreload } from "@/composables/usePreload.js";
|
||||
|
||||
const emit = defineEmits(["cardClick"]);
|
||||
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
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 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) {
|
||||
emit("cardClick", item);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// App 从后台切回前台时刷新星河榜单
|
||||
// 后台切回时强制刷新(skip TTL)
|
||||
useBackgroundRefresh(() => {
|
||||
loadData();
|
||||
refresh(true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { getActivityListApi, getMintingActivitiesApi } from '@/utils/api.js'
|
||||
import { getPreloadApi } from '@/utils/preloadApi/index'
|
||||
|
||||
// 默认 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 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 () => {
|
||||
try {
|
||||
const starId = uni.getStorageSync('star_id') || null
|
||||
const res = await getActivityListApi(starId, 1, 10)
|
||||
|
||||
if (res.code === 0 && res.data?.activities) {
|
||||
const activities = res.data.activities
|
||||
// 过滤掉已过期的活动
|
||||
bannerActivities.value = activities.filter(item => item.status !== 'expired')
|
||||
}
|
||||
await _tryPreloadThenFetch('banner.activities',
|
||||
() => getActivityListApi(uni.getStorageSync('star_id') || null, 1, 10),
|
||||
(res) => {
|
||||
if (res.data?.activities) {
|
||||
bannerActivities.value = res.data.activities.filter(item => item.status !== 'expired')
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[useBanner] 加载 banner 活动失败', e?.message ?? e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载运营 banner(铸造活动)
|
||||
const loadBanners = async () => {
|
||||
try {
|
||||
const res = await getMintingActivitiesApi(null, 1, 10)
|
||||
|
||||
if (res.code === 0 && res.data?.activities) {
|
||||
// 将后端数据映射为 BannerCarousel 约定的字段
|
||||
banners.value = res.data.activities.map(activity => ({
|
||||
id: activity.id,
|
||||
image_url: activity.cover_image,
|
||||
title: activity.title,
|
||||
link_type: 'activity',
|
||||
link_value: String(activity.id),
|
||||
description: activity.description,
|
||||
route: activity.route,
|
||||
// 后端路由参数 JSON 字符串,前端在 handleBannerClick 中拼到 url 上
|
||||
params: activity.params
|
||||
}))
|
||||
await _tryPreloadThenFetch('banner.minting',
|
||||
() => getMintingActivitiesApi(null, 1, 10),
|
||||
(res) => {
|
||||
if (res.data?.activities) {
|
||||
banners.value = res.data.activities.map(activity => ({
|
||||
id: activity.id,
|
||||
image_url: activity.cover_image,
|
||||
title: activity.title,
|
||||
link_type: 'activity',
|
||||
link_value: String(activity.id),
|
||||
description: activity.description,
|
||||
route: activity.route,
|
||||
params: activity.params
|
||||
}))
|
||||
}
|
||||
if (banners.value.length === 0) banners.value = [...FALLBACK_BANNER]
|
||||
}
|
||||
|
||||
// 无数据兜底
|
||||
if (banners.value.length === 0) {
|
||||
banners.value = [...FALLBACK_BANNER]
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[useBanner] 加载运营 banner 失败', e?.message ?? e)
|
||||
banners.value = [...FALLBACK_BANNER]
|
||||
@ -72,7 +76,6 @@ const loadBanners = async () => {
|
||||
}
|
||||
|
||||
export function useBanner() {
|
||||
// 返回模块级单例的引用(API 不变,调用方零改动)
|
||||
return {
|
||||
bannerActivities,
|
||||
banners,
|
||||
|
||||
@ -110,6 +110,7 @@ import TopfansWelcome from "@/components/TopfansWelcome.vue";
|
||||
import { useBanner } from "./composables/useBanner.js";
|
||||
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.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";
|
||||
|
||||
@ -134,9 +135,14 @@ const showWelcome = ref(
|
||||
|
||||
const handleEnterTopfans = () => {
|
||||
showWelcome.value = false;
|
||||
// 标记本次会话已展示过,避免后续回到 square 时再次触发。
|
||||
// needs_welcome 不在这里焚,按需保留到 app 销毁。
|
||||
getApp().globalData.welcomeShownThisSession = true;
|
||||
// ★ 欢迎页关闭后立即触发 idle 预拉
|
||||
// App.vue onShow 在 quickLogin 流程中提前 return,warmIdle 未执行
|
||||
// 此处补偿:三个 tab + banner 的预热请求,在组件挂载前就开始跑
|
||||
try {
|
||||
const api = getPreloadApi();
|
||||
if (api) api.warmIdle();
|
||||
} catch (e) {}
|
||||
};
|
||||
const navExpanded = ref(false);
|
||||
const showRankingModal = ref(false);
|
||||
@ -188,7 +194,7 @@ const handleCardClick = (card) => {
|
||||
if (card.id) {
|
||||
cardTapTimers[card.id] = setTimeout(() => {
|
||||
delete cardTapTimers[card.id];
|
||||
uni.navigateTo({
|
||||
const navApi = getPreloadApi(); (navApi || uni).navigateTo({
|
||||
url: `/pages/asset-detail/asset-detail?asset_id=${card.id}`,
|
||||
});
|
||||
}, 300);
|
||||
@ -203,7 +209,7 @@ const handleScrollToLower = () => {
|
||||
};
|
||||
|
||||
const handleActivityClick = (item) => {
|
||||
uni.navigateTo({
|
||||
const navApi = getPreloadApi(); (navApi || uni).navigateTo({
|
||||
url: `/pages/support-activity/index?id=${item.id}`,
|
||||
});
|
||||
};
|
||||
@ -211,9 +217,10 @@ const handleActivityClick = (item) => {
|
||||
// 运营 banner 点击(铸造活动)
|
||||
const handleBannerClick = (banner) => {
|
||||
console.log("[square] banner click", banner);
|
||||
// 优先使用自定义 route
|
||||
// 优先使用自定义 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 }) => {
|
||||
showRankingModal.value = false;
|
||||
uni.navigateTo({
|
||||
const navApi = getPreloadApi(); (navApi || uni).navigateTo({
|
||||
url: `/pages/profile/hisWorks?userId=${userId}&nickname=${encodeURIComponent(nickname)}`,
|
||||
});
|
||||
};
|
||||
@ -267,7 +274,7 @@ const handleTabChange = (newTab) => {
|
||||
];
|
||||
|
||||
if (newTab >= 0 && newTab < routes.length) {
|
||||
uni.navigateTo({
|
||||
const navApi = getPreloadApi(); (navApi || uni).navigateTo({
|
||||
url: routes[newTab],
|
||||
});
|
||||
}
|
||||
|
||||
@ -193,6 +193,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Header from "../components/Header.vue";
|
||||
import { getActivityListApi, getActivityRankingApi } from "@/utils/api.js";
|
||||
import { getPreloadApi } from "@/utils/preloadApi/index";
|
||||
import screenCache from "@/utils/screen-cache";
|
||||
|
||||
const statusBarHeight = ref(0);
|
||||
@ -372,8 +373,9 @@ function onScrollToLower() {
|
||||
}
|
||||
|
||||
function handleCardTap(item) {
|
||||
// 跳转到对应活动详情页
|
||||
uni.navigateTo({
|
||||
// 跳转到对应活动详情页(预拉详情 + 道具列表)
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: `/pages/support-activity/index?id=${item.id}`,
|
||||
fail: (err) => {
|
||||
console.error("[center] 跳转活动详情失败:", err);
|
||||
|
||||
@ -70,7 +70,7 @@ export function useContributionPolling(activityId, isPageActive, options = {}) {
|
||||
// 处理 code 不为 200 的情况(静默忽略)
|
||||
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
|
||||
|
||||
// API 返回正序(ASC),用 reverse 反转成"新→旧",便于复用 [0] 作最新游标 + 前插
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<text class="nav-back-icon">←</text>
|
||||
</view>
|
||||
<!-- 左上角水晶余额(fixed 悬浮,与右上角 TopRanking 对称) -->
|
||||
<view class="crystal-balance-new">
|
||||
<view class="crystal-balance-new" @click="handleClick">
|
||||
<!-- 1. 左侧钻石图标:层级最高,盖住右侧背景 -->
|
||||
<view class="crystal-icon-box">
|
||||
<image
|
||||
@ -208,6 +208,7 @@ import TopRanking from "./components/TopRanking.vue";
|
||||
import MessageBoard from "./components/MessageBoard.vue";
|
||||
import MessageInput from "./components/MessageInput.vue";
|
||||
import { getEarningsSummaryApi } from "@/utils/api.js";
|
||||
import { getPreloadApi } from "@/utils/preloadApi/index";
|
||||
import { getActivitySocket, closeActivitySocket } from "@/utils/socket/ActivitySocket.js";
|
||||
import { useMessageRealtime } from "./composables/useMessageRealtime.js";
|
||||
import { useContributionRealtime } from "./composables/useContributionRealtime.js";
|
||||
@ -263,12 +264,20 @@ const goBack = () => {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
// 没有上一页,跳转到square页面
|
||||
uni.reLaunch({
|
||||
const api = getPreloadApi(); (api || uni).reLaunch({
|
||||
url: "/pages/support-activity/center",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理水晶余额点击 → 跳转数据看板(预拉 7 个接口)
|
||||
const handleClick = () => {
|
||||
const api = getPreloadApi();
|
||||
(api || uni).navigateTo({
|
||||
url: "/pages/dashboard/dashboard",
|
||||
});
|
||||
};
|
||||
|
||||
// 打开排行榜弹窗
|
||||
function openRankingModal() {
|
||||
currentActivityTitle.value = config.value?.title || "活动排名";
|
||||
@ -277,7 +286,7 @@ function openRankingModal() {
|
||||
|
||||
// 处理拜访用户
|
||||
function handleVisitUser(user) {
|
||||
uni.navigateTo({
|
||||
const api = getPreloadApi(); (api || uni).navigateTo({
|
||||
url: `/pages/profile/hisWorks?userId=${user.userId}&nickname=${encodeURIComponent(user.nickname)}`
|
||||
});
|
||||
// console.log("拜访用户:", data);
|
||||
@ -354,7 +363,7 @@ function handleTabChange(newTab) {
|
||||
];
|
||||
|
||||
if (newTab >= 0 && newTab < tabRoutes.length) {
|
||||
uni.navigateTo({
|
||||
const api = getPreloadApi(); (api || uni).navigateTo({
|
||||
url: tabRoutes[newTab],
|
||||
fail: (err) => {
|
||||
console.error("页面跳转失败:", err);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loginApi, registerApi } from '@/utils/api'
|
||||
import { resetAllGuides } from '@/utils/guideConfig'
|
||||
import { getPreloadApi } from '@/utils/preloadApi/index'
|
||||
|
||||
const state = {
|
||||
token: uni.getStorageSync('access_token') || '',
|
||||
@ -34,6 +35,20 @@ const mutations = {
|
||||
}
|
||||
},
|
||||
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
|
||||
// 缓存到 storage
|
||||
if (userInfo) {
|
||||
@ -53,19 +68,24 @@ const mutations = {
|
||||
}
|
||||
},
|
||||
CLEAR_AUTH(state) {
|
||||
state.token = ''
|
||||
state.userInfo = null
|
||||
state.starId = null
|
||||
// 获取当前 userId 用于清除引导数据(需要在清除 user 数据之前获取)
|
||||
// ★ 新增:先清理 preload 缓存(在清 token 之前,spec §6.5 constraint #2)
|
||||
const userStr = uni.getStorageSync('user')
|
||||
let userId = null
|
||||
if (userStr) {
|
||||
try { const user = JSON.parse(userStr); userId = user?.uid || null } catch (e) {}
|
||||
}
|
||||
if (userId) {
|
||||
try {
|
||||
const user = JSON.parse(userStr)
|
||||
userId = user?.uid || null
|
||||
const api = getPreloadApi()
|
||||
if (api) api.clearUser(userId)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
state.token = ''
|
||||
state.userInfo = null
|
||||
state.starId = null
|
||||
// 清除所有引导相关存储(传入 userId 确保正确清除)
|
||||
// 注:userId 已在上面 preload 清理块中从 storage 读取,此处复用
|
||||
resetAllGuides(userId)
|
||||
|
||||
// 清除 storage
|
||||
|
||||
@ -53,7 +53,9 @@ function mockDelay(ms = 800) {
|
||||
|
||||
// 请求封装
|
||||
export function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let _aborted = false
|
||||
let requestTask = null
|
||||
const p = new Promise((resolve, reject) => {
|
||||
// 构建请求头
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@ -75,7 +77,7 @@ export function request(options) {
|
||||
}
|
||||
}
|
||||
|
||||
uni.request({
|
||||
requestTask = uni.request({
|
||||
url: baseURL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
@ -138,10 +140,16 @@ export function request(options) {
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
if (_aborted) return
|
||||
reject(new Error(err.errMsg || '网络请求失败'))
|
||||
}
|
||||
})
|
||||
})
|
||||
p.abort = () => {
|
||||
_aborted = true
|
||||
if (requestTask) requestTask.abort()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// 登录接口
|
||||
|
||||
85
frontend/utils/preloadApi/README.md
Normal file
85
frontend/utils/preloadApi/README.md
Normal 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 | 失败是否静默 |
|
||||
64
frontend/utils/preloadApi/config.js
Normal file
64
frontend/utils/preloadApi/config.js
Normal 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 }
|
||||
}
|
||||
508
frontend/utils/preloadApi/core.js
Normal file
508
frontend/utils/preloadApi/core.js
Normal 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. 发起 fetch(fire-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())
|
||||
})
|
||||
}
|
||||
}
|
||||
74
frontend/utils/preloadApi/index.js
Normal file
74
frontend/utils/preloadApi/index.js
Normal 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 }
|
||||
97
frontend/utils/preloadApi/navigate.js
Normal file
97
frontend/utils/preloadApi/navigate.js
Normal 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 = {}
|
||||
// 使用 URLSearchParams(uniapp 环境支持)
|
||||
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)
|
||||
}
|
||||
}
|
||||
40
frontend/utils/preloadApi/scheduler.js
Normal file
40
frontend/utils/preloadApi/scheduler.js
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
368
frontend/utils/preloadApi/storage.js
Normal file
368
frontend/utils/preloadApi/storage.js
Normal file
@ -0,0 +1,368 @@
|
||||
// frontend/utils/preloadApi/storage.js
|
||||
// 文件缓存适配器 — _doc/preload/{userId}/ 目录下的 JSON 文件读写
|
||||
// APP-PLUS: 优先 plus.io(promisify),降级 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-PLUS:readdir + 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user