feat:增加uniapp更新页面

This commit is contained in:
zheng020 2026-07-07 14:43:02 +08:00
parent 139d91d08b
commit ce1ef491f8
35 changed files with 3429 additions and 0 deletions

View File

@ -0,0 +1,593 @@
# uniapp+vue3 API 预加载通用方案 — 设计
- **日期**2026-07-02
- **作者**Claude Fable 5与项目 owner 协作)
- **范围**`frontend/`uniapp + Vue 3 + Vite主要服务于 App 端
---
## 1. 目标与范围
为 uniapp + Vue 3 项目提供一套**通用、可配置、按场景分层的 API 预加载方案**,覆盖 4 类典型场景:
1. **页面切换前预先拉数据**:进入 A 页时按目标页 B 的清单提前拉好 B 的接口
2. **应用启动时批量预热常用数据**`onLaunch` 期间并发拉启动清单
3. **通用响应缓存层**:相同 `url+params` 在 TTL 内复用结果
4. **idle 时机预拉**:首屏渲染完成后用空闲时间拉「可能下一屏」的数据
**非目标**
- 不重写 `request()` 本身(保持 `utils/api.js` 签名和返回值不变,仅新增 `.abort()` 方法)
- 不做请求合并 / dedup beyond 同一 cacheKey避免引入 dispatcher complexity
- 不做离线缓存(断网时不读文件缓存兜底)
---
## 2. 架构与模块
### 2.1 目录结构
```
frontend/
├── utils/preloadApi/
│ ├── core.js # 命令式核心(零 Vue 依赖)
│ ├── config.js # 默认 config 导入与合并
│ ├── storage.js # storage 适配器用户隔离、namespace 隔离)
│ ├── scheduler.js # idle / 启动期调度
│ ├── navigate.js # wrap uni.navigateTo / switchTab / reLaunch
│ └── index.js # 统一导出 preloadApi
├── composables/
│ └── usePreload.js # Vue 3 包装:暴露响应式 state
└── config/
└── preload.config.js # 用户声明key / fetcher / ttl / persistence / 触发时机
```
### 2.2 模块职责
| 模块 | 职责 |
| --- | --- |
| `core.js` | 注册/执行/查询/失效;维护内存 Map + LRU并发去重 | **零外部依赖**(不依赖 Vue / uni / api.js / storefetcher 返回的 Promise 自带 `.abort()`core 仅存储调用)
| `config.js` | 把用户 `preload.config.js` 与内置默认值合并成一个运行时 config |
| `storage.js` | 文件缓存适配器:通过 `plus.io`APP-PLUS/ `uni.getFileSystemManager`(兜底)读写 `_doc/preload/{userId}/` 目录;按 userId + namespace 隔离 |
| `scheduler.js` | `warmStartup()`:跑 startup 清单;`warmIdle()`:用 `requestIdleCallback` 兜底跑 idle 清单 |
| `navigate.js` | 重写 `uni.navigateTo/switchTab/reLaunch`,命中目标页时按 pages 映射触发预拉 |
| `usePreload.js` | 包装 `core.get()`,对组件返回 `{ data, loading, error, refresh }` |
### 2.3 调用关系
```
App.vue onLaunch
└─► scheduler.warmStartup() ─┐
├─► core.run(key) ─► request() ─► api.js
preloadApi.navigateTo('/pages/foo?id=1') ─┘
└─► prefetchFor(targetPath, params) ─┐
├─► core.run(key)
Page onLoad (composable) ─┘
└─► usePreload(key, params) ─► core.get(key)
```
### 2.4 对现有代码的侵入
| 文件 | 改动 |
| --- | --- |
| `App.vue` | ~10 行import + `warmStartup()``onLaunch` + `warmIdle()``onShow` + patch `SET_USER_INFO` / `CLEAR_AUTH` 注入失效调用 |
| `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/*` | **不动** |
### 2.5 wrappedNavigateTo 替换策略(已确定方案 A
**严格 wrap预拉 fire-and-forget 不 await**
- `preloadApi.navigateTo(opts)` 内部:解析 `opts.url` 的 query string → 按目标页路径查 `config.pages` 映射 → **如果命中映射**对每个匹配项调用 `core.run(key, params)`**不 await**)→ 立即调用原生 `uni.navigateTo(opts)
- **如果未命中 `config.pages` 映射**:直接透传调用原生 `uni.navigateTo(opts)`,不做任何预拉(预拉是 opt-in不是 opt-out
- 用户点击 → 立刻跳转;预拉在后台并发进行;目标页 `onLoad``usePreload(key, params)` 时大概率命中缓存
- **绝不**在 `navigateTo` 内 await 预拉结果,否则跳转延迟与设计目标矛盾
- 不保留 `uni.navigateTo` 兼容入口:要么用 `preloadApi.navigateTo`(触发预拉),要么不要预拉;保留兼容会让 "哪些跳转走预拉" 变得不可见
**全量替换规则**:用 ESLint 规则或 PR review checklist 强制 —— 全仓 `uni.navigateTo``preloadApi.navigateTo`(可用 codemod 一次性替换)。
---
## 3. 配置 schema
```js
// frontend/config/preload.config.js
import {
getCastloveConfigApi,
getUserProfileApi,
getHotRankingApi,
getAssetLikersApi,
getActivityDetailApi,
getActivityItemsApi,
// ...按需 import
} from '@/utils/api'
export const preloadConfig = {
// ── 全局默认 ──
defaults: {
ttl: 5 * 60 * 1000, // 5 分钟
persistence: 'memory', // 'memory' | 'file'
concurrency: 4, // 单次批量最多并发数
timeout: 10000, // 单接口超时ms超时后取消预拉
silent: true, // 失败是否静默true = 仅 warn不抛
// ── 上限保护阈值(硬编码默认值,可在下面覆盖)──
limits: {
maxEntrySizeKB: 1024, // 单 key 数据超过此值只写内存不写文件1 MB 上限,防止单文件过大拖慢读取)
maxMemoryEntries: 100, // 内存总条目上限,超出按 LRU 淘汰
maxFileCacheMB: 50 // 文件缓存总容量上限uni.storage 仅 4 MB 且与其他业务共享;本地 _doc/ 容量远超此值50 MB 足够中等规模 API 响应缓存)
}
},
// ── 启动期预热清单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 }
],
// ── 页面切换预拉映射wrappedNavigateTo 命中时触发)──
pages: {
'/pages/asset-detail/asset-detail': [
{ key: 'asset.likers', fetcher: (params) => getAssetLikersApi(Number(params.id)) }
],
'/pages/activity-detail/activity-detail': [
{ key: 'activity.detail', fetcher: (params) => getActivityDetailApi(params.id) },
{ key: 'activity.items', fetcher: (params) => getActivityItemsApi(params.id) }
]
}
}
```
**字段说明:**
- `key`:逻辑 key业务引用缓存的唯一标识
- `fetcher(params)`:返回 Promise 的请求函数
- `ttl`缓存有效期ms
- `persistence``'memory'`(仅内存 Map/ `'file'`(持久化到本地文件缓存 `_doc/preload/`,走 `plus.io`
- `concurrency`:单次批量内最大并发
- `timeout`单接口超时ms
- `silent`:失败是否静默(`run` 时生效,`get` 始终抛错)
- `limits.maxEntrySizeKB / maxMemoryEntries / maxFileCacheMB`:上限保护阈值(详见 §7默认 1024 KB / 100 条 / 50 MB可在 `defaults.limits` 覆盖
---
## 4. 核心 API
### 4.1 命令式 APIutils/preloadApi/index.js 导出)
```js
preloadApi.run(key, params?) // 触发一次预拉(写缓存):先查内存,未过期则跳过;过期/未命中才拉取。401/7/16 静默吞掉。不返回数据
preloadApi.get(key, params?) // 读缓存:命中直接返回;未命中/过期则拉取401/7/16 业务码静默吞掉(不抛给调用方)
preloadApi.refresh(key, params?, force?) // 命令式刷新force=true 跳过 TTL。非组件上下文使用组件上下文用 usePreload.refresh()
preloadApi.prefetchFor(targetPath, params?) // wrappedNavigateTo 内部调用
preloadApi.invalidate(key?) // 失效单 key仅内存
preloadApi.invalidatePrefix(prefix) // 失效某前缀(命中 logicalKey 前缀,仅内存)
preloadApi.invalidateAll() // 清空全部内存缓存(不动文件缓存)
preloadApi.clearForUser(userId) // 删除指定用户的文件缓存目录(`_doc/preload/{userId}/`
preloadApi.clearUser(userId) // 清空全部内存 + 删除指定用户的文件缓存目录(登出用)
preloadApi.warmStartup() // App.vue onLaunch 调用
preloadApi.warmIdle() // App.vue onShow 调用(每次回前台跑一次)
preloadApi.navigateTo(opts) // 替代 uni.navigateTo预拉 fire-and-forget
preloadApi.switchTab(opts) // 替代 uni.switchTab
preloadApi.reLaunch(opts) // 替代 uni.reLaunch
```
### 4.2 key 命名规则与物理 cache key
- 配置里写的 `key` 是"逻辑 key"
- 真正缓存的物理 key
```
${userId || 'guest'}::${namespace}::${logicalKey}::${hash(params)}
```
- 同 key 不同 params 是不同缓存条目
- `namespace`**硬编码为常量 `'preload'`**(与 §6.5 的文件目录名保持一致;预留扩展位,当前不在 schema 暴露)
- `hash(params)`**先对 params 的 key 排序**再 `JSON.stringify` → djb2 → 16 进制key 排序保证 `{a:1,b:2}``{b:2,a:1}` 生成相同 hash避免命中率下降
### 4.3 读缓存流程(`get`
```
get(key, params)
├─ 计算 cacheKey
├─ 查内存 → 命中且未过期 → 同步 resolveLRU touchLRU 标记访问)→ return
├─ 查文件缓存async I/O→ 命中且未过期 → 写回内存 + resolve
├─ 未命中 → 调 fetcher(params)
│ ├─ 成功 → 写内存 + 异步写文件缓存fire-and-forget+ resolve
│ ├─ "已登出"信号 → swallow 错误 + resolve(null)(见 _swallowAuth
│ └─ 其他错误 → reject(原错误)(业务方决定 toast
└─ 注:内存命中是唯一同步路径;文件缓存命中 / fetcher 路径均返回 async Promise
因此 composable §4.5 中只有内存命中能让首次渲染时 data 同步有值
```
**"已登出"信号匹配规则**(见 §5 `_swallowAuth`
- `err.code === 7`(业务 401token 失效)
- `err.code === 16`(业务 403账号被封
- `err.message` 含 "登录已过期"(匹配 `utils/api.js` 第 86-122 行 HTTP 401 reject 时构造的 message
**关键约束**`run` 与 `get` 都必须 swallow "已登出"信号。理由:`utils/api.js` 第 86-122 行在检测到这些码时**已经同步调用 `uni.reLaunch` 跳登录页**,相当于"已处理";预加载层再抛错会造成双重跳转或冷启动被中断。
### 4.3.1 `prefetchFor` 的入参约定
`preloadApi.prefetchFor(targetPath, params)`
- `targetPath`:目标页的完整路径,如 `/pages/asset-detail/asset-detail`
- `params`**只接受基本类型 key-value**string / number / boolean`navigate.js` 从跳转 URL 的 query string 解析得到
- 解析规则:
- 用 `URLSearchParams` 解析 query string
- 每个 value 用 `decodeURIComponent` 解码
- 所有 value 统一为 stringfetcher 内部按需 `Number()` / `Boolean()` 转换)
- 示例:`uni.navigateTo({ url: '/pages/foo/bar?id=123&type=hot' })` → `{ id: '123', type: 'hot' }`
- 数组 / 嵌套对象走 `?arr=1,2,3` 这种字符串协议;不在本次 spec 范围内
- **URL 编码约束**:业务方拼 URL 时必须对 value 做 `encodeURIComponent`(特别是含 `&` / `=` / 中文 的 value
调用链示例:
```js
navigateTo({ url: '/pages/asset-detail/asset-detail?id=123' })
// → prefetchFor('/pages/asset-detail/asset-detail', { id: '123' })
// → core.run('asset.likers', { id: '123' })
// → fetcher({ id: '123' }) = getAssetLikersApi('123')
```
### 4.4 并发去重
**适用范围**`run` 与 `get` 共用同一套 inFlight 去重。同一个 cacheKey in-flight 时,第二个 `run``get` 共享同一个 Promise**不重复发请求**。
**实现**:内部维护 `inFlight: Map<cacheKey, { promise, abort }>`。任何入口run / get / prefetchFor / warmStartup / warmIdle触发 fetch 时,先查 inFlight —— 命中则直接返回共享 Promise未命中则创建新 Promise 并写入 inFlight。
**清理时机**fetch **无论成功失败都在 finally 阶段清掉 inFlight**。否则失败后该 cacheKey 会永久 stuck后续调用永远拿到同一个 reject 的 Promise。401 swallow 路径也是 finally 清掉。
**请求取消abort**uniapp App 端 `uni.request` 返回的 `requestTask` 可调用 `.abort()`。当 composable 组件 unmount 或 `usePreload` 的 key/params 变化时,应 abort 前一个未完成的请求以避免浪费带宽。实现:
- `inFlight` 每个条目存 `{ promise, abort: () => { promise.abort?.(); } }`
- composable 在 `onBeforeUnmount` / watcher 中调 `abort()`
- abort 后清掉 inFlight 条目abort 不走 finally 路径,需显式清理)
**`request()` 改造**(唯一对 `utils/api.js` 的改动,约 3 行):
```js
// utils/api.js — request() 内部
export function request(options) {
const requestTask = uni.request({...}) // 已有
let abortFn = () => requestTask.abort()
const p = new Promise((resolve, reject) => {
// ... 现有 success/fail 逻辑不变 ...
})
p.abort = abortFn // 新增这一行
return p
}
```
所有现有调用方 `await request(...)` 语法不变preload 的 `core.js` 通过 `fetcher` 拿到这个 Promise 后,将其 `.abort` 存入 inFlight 条目。fetcher 无需改造 — fetcher 负责调用 `request()` 并返回其 Promise。当组件 unmount / params 变化时调用 `abort()``requestTask.abort()` 会终止 TCP 连接并释放带宽。
### 4.5 composable APIcomposables/usePreload.js
```js
// 在 <script setup>
const { data, loading, error, refresh } = usePreload('asset.detail', { id: 123 })
```
返回:
- `data: Ref<any | null>`:命中缓存为缓存值;未命中拉到为 fetcher 返回值401 swallow 为 null其他失败为上一次值或 null
- `loading: Ref<boolean>`true 表示当前有 in-flight 请求
- `error: Ref<Error | null>`401 swallow 时为 null其他错误为 Error 对象
- `refresh(force?: boolean)`手动刷新force=true 跳过 TTL 复用)
**初次渲染行为**(关键):
- `core.get()` **命中内存缓存时**返回**已 resolved 的 Promise**。composable 在 `setup()` 同步阶段立即 `data.value = cached.data`,首次渲染时 `data` 就已有值、`loading` 为 false。这依赖 Vue 3 同步赋值而非下一 tick 更新。
- `core.get()` **未命中 / 文件缓存命中 / 未命中**时返回**未 resolved 的 Promise**。composable 在 `setup()` 同步阶段 `data` 初始为 null、`loading` 为 truePromise resolve 后 **用 `.then()` 异步更新 `data.value`**,触发下一次渲染。
- 实现:`get` 内部命中内存路径直接 `return Promise.resolve(cached.data)`,未命中路径返回 `inFlightEntry.promise`
- **硬约束**composable 内 data.value 的赋值必须包裹在 `.then()` 中(不能 `await` 后在 setup 同步代码中赋值),否则未命中路径会阻塞整个 setup 导致白屏。这等同于**不能阻塞 setup 的同步执行**。
**典型用法**
```vue
<script setup>
const { data, loading, error, refresh } = usePreload('asset.detail', { id: route.params.id })
// 模板中v-if="data" / v-else-if="error" / v-else="loading"
</script>
```
---
## 5. 错误处理
| 场景 | 行为 | 日志 |
| --- | --- | --- |
| 启动预热失败 | 静默(启动期不能阻塞) | `console.warn('[preload] startup fail:', key, err.message)` |
| idle 预拉失败 | 静默 | 同上 |
| 页面切换预拉失败 | 静默(用户可能不去目标页) | 同上 |
| `usePreload.get()` 在组件内失败(非 401 类) | 抛错给调用方,组件 `error` ref 更新 | 无(业务自行决定 toast |
| **401 / 业务码 7 / 16"已登出"信号)** | **所有链路run / get / prefetchFor必须 swallowresolve(null)** | `console.warn('[preload] auth-expired, swallowed:', key)` |
| 网络断开 / 请求超时 | 抛错给调用方;**已存在的未过期缓存保持不变**(不会因为 fetch 失败而被清掉);下次 `get` 重新尝试 | `console.warn('[preload] network error:', key, err.message)` |
**核心原则预拉失败绝不能阻塞主链路401 / 业务码 7 / 16 必须 swallow避免与 `request()` 的 reLaunch 行为冲突导致冷启动被中断或双重跳转)。**
**实现位置**`core.js` 的 `run / get` 共享一个内部 helper `_swallowAuth(err)`
```js
function _swallowAuth(err) {
if (err && (err.code === 7 || err.code === 16)) return null
if (err && /登录已过期/.test(err.message || '')) return null
return err
}
```
---
## 6. 失效策略
### 6.1 自动失效
- **TTL 到期**per-key 配置)
- **用户登出**`clearUser(oldUserId)`(清该用户的内存 + 删文件缓存目录)
- **切换粉丝身份**`invalidatePrefix('me.')`(仅内存)
- **App.vue onShow**`warmIdle()` 触发时**只对 TTL 到期的 key 自然过期**,不主动失效任何 cache
### 6.2 手动失效(业务调用)
- 点赞后:`invalidate('ranking.hot')`、`invalidatePrefix('asset.')`
- 提交评论后:`invalidatePrefix('activity.messages')`
- 进入个人页前可选主动失效 `me.*`
### 6.3 关键澄清:`invalidateAll()` vs `clearUser(userId)` vs `clearForUser(userId)`
| API | 内存 | 文件缓存 | 触发场景 |
| --- | --- | --- | --- |
| `invalidateAll()` | 清空全部 | **不动** | 极少使用;保留作为"内存全清"逃生口 |
| `clearForUser(userId)` | 不动 | 仅删指定用户的 `_doc/preload/{userId}/` 目录 | 单独清理某用户文件缓存(如切换账号但保留另一账号会话的边缘场景) |
| `clearUser(userId)` | 清空全部内存缓存(所有 userId 命名空间) + 删指定用户的文件缓存目录 | — | **登出专用** |
**核心约束**
1. `invalidateAll()` **永远不动文件缓存**。理由:避免把当前会话(登录态改变前)的 persistence 缓存白白清掉;文件清理必须显式按 userId 走。
2. `clearUser(userId)``userId` **必须等于当前登录用户**。否则会清空当前用户的内存缓存(与文件删的目标用户不一致)。调用方需保证 userId 正确性;不在 API 层做防御。
### 6.4 数据一致性
```
写:先写内存 → 再异步写文件缓存fire-and-forget不 await
读:先查内存 → 未命中查文件缓存 → 仍未命中才 fetch
清:组合清空(如 clearUser先清内存 → 再删文件目录;仅清内存或仅删文件的 APIinvalidateAll / clearForUser按各自定义执行
```
文件写入失败仅 `console.warn`,不影响内存中的可用数据。文件读取失败(如文件被手动删除、权限异常)视为未命中,走 fetch 路径。
### 6.5 用户隔离
文件缓存目录结构:
```
_doc/preload/
├── guest/ # 未登录用户
│ ├── {hash1}.json
│ └── {hash2}.json
├── 1001/ # userId = 1001
│ ├── {hash1}.json
│ └── {hash2}.json
└── 1002/ # userId = 1002
└── {hash1}.json
```
一个缓存条目 = 一个 JSON 文件,文件名 = `{hash(cacheKey)}.json`,内容 = `{ data, ts, ttl }`
```js
// storage.js — 文件缓存适配器
const BASE_DIR = '_doc/preload'
// 获取某个用户缓存目录下的所有条目
function listUserEntries(userId) {
const dir = `${BASE_DIR}/${userId || 'guest'}`
// plus.io.resolveLocalFileSystemURL 读取目录
// 失败(目录不存在)视为空
}
// 读:解析条目 JSON 文件
async function readEntry(userId, cacheKey) {
const path = `${BASE_DIR}/${userId || 'guest'}/${hash(cacheKey)}.json`
// plus.io.FileReader 或 uni.getFileSystemManager().readFile
try {
const raw = await readFile(path, 'utf-8')
return JSON.parse(raw)
} catch {
return null // 文件不存在 / 损坏 → 视为未命中
}
}
// 写:异步写 JSON 文件fire-and-forget 调用,因此必须内建 .catch
async function writeEntry(userId, cacheKey, data, ts, ttl) {
const dir = `${BASE_DIR}/${userId || 'guest'}`
const path = `${dir}/${hash(cacheKey)}.json`
// 先确保目录存在plus.io.File.createDirectory 或 mkdir
// 再写文件plus.io.File.writeFile
// 注意:调用方不 await因此必须在内部 .catch(e => console.warn(...))
// 否则未捕获的文件写入异常会变成 unhandled promise rejection
}
// 登出时:删除整个用户目录
function clearForUser(userId) {
const dir = `${BASE_DIR}/${userId || 'guest'}`
// plus.io.resolveLocalFileSystemURL(dir, (entry) => { entry.removeRecursively(...) })
// 或 uni.getFileSystemManager().rmdir(dir, { recursive: true })
}
```
**App.vue** 包装 `store/user` mutation按 userId 变化分三种动作:
| 事件 | 动作 |
| --- | --- |
| `onLogin(userId)` | **不**主动清缓存。注意:**memory 总是按 userId 物理隔离**(物理 key 含 userId新用户访问任何 key包括 `ranking.hot` 这种"公共"数据)都需要重新拉取,**不会**自动复用前一会话或 guest 的内存条目。这是用户隔离的 by design 代价。 |
| `onSwitchUser(oldId, newId)` | `invalidatePrefix('me.')` + `clearForUser(oldId)` |
| `onLogout(userId)` | `clearUser(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 清掉之后任何业务发起的请求都会 401preload 缓存如果在 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 不变)。
### 6.6 与 `useBackgroundRefresh` 的协作
两个模块**完全独立**、可并存:
- `useBackgroundRefresh(refreshFn)`:从后台切回时调 `refreshFn`(通常是重新拉当前页数据)
- `preloadApi.invalidate(prefix)`:手动失效缓存
业务页面常用组合:
```js
useBackgroundRefresh(() => {
// 从后台切回时,强制刷当前页 + 失效短期缓存
preloadApi.invalidate('ranking.hot')
preloadApi.invalidatePrefix('asset.')
refresh(true) // usePreload 的 refresh
})
```
---
## 7. 内存与文件缓存上限
内存泄漏防护 + 文件缓存容量控制(文件系统远比 uni.storage 大):
| 限制 | 值 | 处理 |
| --- | --- | --- |
| 单 key 数据体积 | > 1024 KB | 只写内存,不写文件(即使 config 里设了 `persistence: 'file'` 也会降级,避免单 JSON 文件过大拖慢 I/O。不报错、静默降级下次冷启动走 fetch |
| 总内存条目数 | > 100 条 | 按 LRU 淘汰 |
| 总文件缓存容量 | > 50 MB | 按插入顺序淘汰最旧文件FIFO |
### 7.1 LRU 实现约束
**内存与文件缓存淘汰策略不同**
| 层 | 淘汰策略 | 触发时机 |
| --- | --- | --- |
| 内存 | LRU最近访问优先 | 命中时重排delete + set |
| 文件缓存 | FIFO最早写入优先 | 不在读时重排;淘汰时一次性 scan 目录,按文件 `stat.mtime` 排序找出最旧文件删除。文件数不大(≤ 几百scan 开销可接受。也可维护一个轻量 `_index.json` 记录插入顺序,但 scan 方式更简单更健壮 |
JS `Map` 的迭代顺序是**插入顺序**而非**访问顺序**。内存 LRU 正确实现:
```js
// 命中访问时:删除再插入,重排到最新
function touchLRU(map, k, v) {
if (map.has(k)) map.delete(k)
map.set(k, v)
if (map.size > MAX_ENTRIES) {
// Map 迭代顺序 = 插入顺序,最久未访问的是第一个
const oldestKey = map.keys().next().value
map.delete(oldestKey)
}
}
```
### 7.2 并发粒度
`defaults.concurrency`(默认 4按 **scheduler 入口粒度**生效:
| 入口 | 并发限制 |
| --- | --- |
| `warmStartup()` | 整个 startup 数组最多 4 并发 |
| `warmIdle()` | 整个 idle 数组最多 4 并发 |
| `prefetchFor(targetPath)` | 单目标页内的多个 key 最多 4 并发 |
实现:`scheduler.js` 用一个简单的 semaphore计数器 + 等待队列)控制并发。
### 7.3 idle 调度触发点与 App 端 fallback
`warmIdle()` 调用点:**`App.vue` 的 `onShow` 中**(每次从后台回前台都跑一次;首次冷启动 onLaunch 之后 onShow 也会触发,等价于"首屏渲染完后")。
**首次冷启动 onLaunch→onShow 连续触发**onLaunch 里 `warmStartup` 跑 startup 清单,紧接着 onShow 里 `warmIdle` 跑 idle 清单。如果 idle 清单与 startup 清单有重叠 key`run` 的"查内存 → 未过期则跳过"机制自动处理run 看到 startup 已写入的未过期缓存直接返回不重拉。如果无重叠两者并行进行composable 在首次渲染时可能受益于 startup 的预热而 idle 还在后台跑。
**去重 / 防抖**warmIdle 必须**幂等**——同一进程内多次调用,对已在 in-flight 的 key 直接跳过、不重发;对已在内存且未过期的 key 也跳过(靠 `run` 语义)。理由:用户高频切前后台会触发多次 onShowwarmIdle 不应每次都发起新请求。
App 端 fallback`#ifdef APP-PLUS`uniapp App 端**没有** `requestIdleCallback`scheduler 内部统一用:
```js
const idle = typeof requestIdleCallback === 'function'
? requestIdleCallback
: (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
```
---
## 8. 测试策略
### 8.1 层级与覆盖目标
| 层 | 范围 | 工具 |
| --- | --- | --- |
| 核心单元测试 | `core.js` 的 run/get/invalidate/并发去重/LRU/TTL | 手动 mock `uni.*` / `plus.io` |
| 文件缓存适配器测试 | 目录创建 / 读写条目 / 清空逻辑 / 大小阈值 | 同上mock `plus.io` File API |
| navigate 测试 | wrappedNavigateTo 命中/未命中均正确 | mock `uni.navigateTo` |
| composable 测试 | `usePreload` 暴露的响应式 state 正确性 | mock core |
| 集成冒烟 | App.vue 启动预热、wrappedNavigateTo 跳转 | HBuilderX 真机 |
### 8.3 调试与可观测性
**开发者工具**`import.meta.env.DEV` 时启用):
```js
// 实时查看缓存命中率、top-N hot keys
window.__PRELOAD_DEBUG__ = {
dumpMemory: () => Array.from(memoryMap.entries()).map(([k, v]) => ({
key: k, age: Date.now() - v.ts, ttl: v.ttl, persistence: v.persistence
})),
stats: () => ({
hits: hitCount, misses: missCount, memorySize: memoryMap.size, fileCacheBytes: totalFileCacheBytes
}),
// 一键 dump 所有 key + inFlight 当前几条
all: () => ({ ... }, inFlight: Array.from(inFlightMap.keys()))
}
```
**日志规范**(生产环境统一前缀 `[preload]`
- 缓存命中:不记日志(高频、无意义)
- 缓存过期 / 未命中:不记日志(正常路径)
- fetch 完成:`console.log('[preload] fetch done:', key, elapsedMs)`
- fetch 失败swallow`console.warn('[preload] fetch fail (swallowed):', key, err.message)`
- fetch 失败throw不记日志业务方自行处理
- 清理事件:不记日志(正常路径)
- LRU 淘汰:`console.log('[preload] LRU evict:', oldestKey)`
### 8.2 验收清单
- [ ] `preloadApi.run` / `get` / `invalidate*` 单测通过
- [ ] wrappedNavigateTo 命中/未命中均通过单测
- [ ] usePreload composable 暴露响应式 state 通过单测
- [ ] 401 / 业务码 7、16 被 `_swallowAuth` 正确吞掉(核心 + navigate + composable 各 1 个 case
- [ ] query string 含 `encodeURIComponent` 字符(如中文)解析正确
- [ ] App.vue 真机onLaunch 后立即访问 startup 项 key命中内存缓存
- [ ] wrappedNavigateTo 真机:列表页跳详情页,详情页 onLoad 时立即命中预拉缓存loading 闪烁 < 50ms
- [ ] 登出后 `_doc/preload/{userId}/` 目录被删除
- [ ] LRU 淘汰边界:
- 塞 100 个 key第 1 个**不**被淘汰
- 塞 101 个 key第 1 个被淘汰
- [ ] warmIdle 幂等:同进程内连续调 3 次fetcher 只被调用 1 次
- [ ] 用户切换onSwitchUserme.* 前缀全失效、oldUser 的文件缓存目录已删除、newUser 缓存不受影响
---
## 9. 风险与缓解
| 风险 | 缓解 |
| --- | --- |
| 401 链路被 `request()` 同步 reLaunch 中断冷启动 | `core.run / get` 通过 `_swallowAuth` swallow业务码 `code === 7 \|\| 16` **或** message 含 "登录已过期"HTTP 401 走此路径,见 §5 |
| 用户隔离在登出 / 切账号时混乱 | `clearUser / clearForUser / invalidateAll` 三层职责严格分离(见 §6.3 |
| `wrappedNavigateTo` 全量替换回归风险 | 严格方案 A预拉 fire-and-forget 不 await见 §2.5 |
| 预拉时机太早导致请求用户未登录 | 401 swallow§5启动清单里依赖登录态的 key 必须 `silent=true` |
| 启动期批量预拉阻塞冷启动 | `startup` 全部 `silent=true`、并发受 `concurrency` 控制、超时 `timeout` |
| 文件缓存体积膨胀 | 1024 KB / 100 条 / 50 MB 三层上限;登出 `clearUser` 删除目录 |
| `usePreload` 在非 `setup()` 上下文误用 | composable 内部 `getCurrentInstance()` 检查 + 警告 |
| 与 `avatarCache` / `screen-cache` 命名冲突 | 文件缓存统一放在 `_doc/preload/{userId}/` 目录下,与 uni.storage 隔离(见 §6.5 |
| 与 `utils/api.js` 的 mock import 耦合bundle 体积) | `preload.config.js` 的 fetcher 引用按需 import必要时在 vite 分包配置里隔离 |
| `JSON.stringify(params)` key 顺序敏感导致命中率下降 | 实现里对 params key 排序后再 stringify见 §4.2 hash 约定) |
---
## 10. 实施清单(落到 writing-plans 阶段再细化)
0. `frontend/utils/api.js``request()` 返回的 Promise 上挂 `.abort()` 方法(约 3 行改动)
1. `frontend/utils/preloadApi/storage.js`
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 速查表)

View File

@ -0,0 +1,129 @@
## 0.9.102026-04-27
- 修复 uni-app-x 项目编译时 warning
## 0.9.92026-02-03
- 修复 安卓端非强制更新 kotlin 报错 `onClick has not been intialized`
## 0.9.82026-01-05
- 更新 移除 vapor 模式不支持的 class 选择器
## 0.9.72025-07-28
- 修复 使用腾讯云时wgt 更新报错的Bug
- 改进 uni-app-x 平台弹窗该用 script setup 实现
## 0.9.62025-04-01
- 新增 升级中心适配鸿蒙 uni-app x **需要 HBuilderX 4.61+**
## 0.9.52025-02-06
- 新增 完善下载失败时的处理逻辑
## 0.9.42024-12-28
- 修复 腾讯云在使用扩展存储时报错的 Bug
## 0.9.32024-12-23
- 修复 升级中心在大屏上的显示效果
## 0.9.22024-11-06
- 更新 部分 ts 类型
## 0.9.12024-11-01
- 更新 支持 HarmonyOS Next 设备整包更新、wgt 更新。需要 `HBuilderX 4.32+` [详情](https://doc.dcloud.net.cn/uniCloud/upgrade-center.html#uni-upgrade-center-app-harmonyos)
## 0.9.02024-10-30
- **重要更新** 在 uni-app x 项目中弃用之前弹窗方案使用[dialogPage](https://doc.dcloud.net.cn/uni-app-x/api/dialog-page.html)实现,需要 `HBuilderX 4.31+`
## 0.8.52024-10-26
- 优化 去除不必要代码
## 0.8.42024-10-26
- 修复 uni-app x 项目升级到 4.31 alpha 后中间有空隙的Bug
## 0.8.32024-07-31
- 修复 部分类型报错
## 0.8.22024-07-15
- 更新 static 下的静态图片放入 static/app 目录下,防止编译除 app 平台以外的平台时带入
## 0.8.12024-04-28
- 修复 在 HX 4.0.3+ uni-app x 项目运行到 Android 调不起安装的Bug
## 0.8.02024-04-15
- 修复 更新弹窗 data 中新增初始化字段
## 0.7.92024-03-15
- 移除无用代码
- 调整 is_silently 类型为可为 null
## 0.7.82024-01-04
- 新增 移除无用代码
## 0.7.72024-01-04
- 新增 uni-app x 项目中新增 @show 回调
## 0.7.62023-12-21
- 修复 iOS使用升级中心云打包时报错使用新版的 [uts-progressNotification](https://ext.dcloud.net.cn/plugin?name=uts-progressNotification) 插件,如果之前下载过请删除 `uts-progressNotification\utssdk\app-ios` 文件夹)
## 0.7.52023-12-12
- 新增 通知栏进度条使用 uts-progressNotification 插件
- 新增 依赖 uni-installApk、uts-progressNotification。使用前要安装插件三方依赖
## 0.7.42023-11-29
- 修复 uni-app-x 项目中由上版引发的无法升级的Bug
## 0.7.32023-11-27
- 修复 在 uni-app x 中无更新时报错的Bug
## 0.7.22023-11-20
- 新增 插件根目录 utils 文件夹中新增 check-update-nvue.js 文件vue2 的 nvue 页面请引用该文件)
## 0.7.12023-11-17
- 修复 运行至浏览器 ts 语法报错
## 0.7.02023-11-10
- 新增 兼容 uni-app x 项目 [详情](https://uniapp.dcloud.net.cn/uniCloud/upgrade-center.html)
## 0.6.52023-10-27
- 修复 安装 wgt 报错 manifest.json 文件不存在的Bug
## 0.6.42023-09-01
chore: 优化代码结构
## 0.6.32023-08-30
- 修复 下载 wgt 时如果后缀名不正确,重命名后安装
## 0.6.22022-11-21
- 处理 cloudfunctions 目录
## 0.6.12022-08-17
- 修复 后台添加应用市场但都没有启用的情况下报错的Bug (需要 uni-admin 1.9.3+
## 0.6.02022-07-19
- 新增 支持多应用商店配置(需要 uni-admin 1.9.3+
## 0.4.12022-05-27
- 修复 上版引出的报错问题
## 0.4.02022-05-27
- 新增 Android 支持跳转手机自带商店,填写升级包地址时请填写跳转商店链接
- 新增 改为云对象调用方式,使用更直观
## 0.3.32022-04-14
- 修复 调用 check-update当 code 为 0 时没有回调
## 0.3.22022-01-12
- 优化显示逻辑
## 0.3.12021-11-24
- 修复 vue3 上图片不显示的Bug
## 0.3.02021-11-18
- 移除 wgt 安装成功后提示,防止重启过快弹框不消失
## 0.2.22021-08-25
- 兼容vue3.0
## 0.2.12021-07-26
- 修复 使用腾讯云并手动填写地址时导致下载链接失效的bug
## 0.2.02021-07-13
- 更新文档 关于报错local_storage_key 为空请不要将页面路径设置为pages.json中第一项
## 0.1.92021-06-28
- 更新文档
- 修复 wgt安装失败时按钮状态不对
## 0.1.82021-06-16
- 修复 跳转安装时导致上次下载的apk还没安装就被删掉的bug
## 0.1.72021-06-03
- 修改 移除static中的图片
## 0.1.62021-06-03
- 修改 下载更新按钮使用CSS渐变色
## 0.1.52021-04-22
- 更新check-update函数。现在返回一个Promise有更新时成功回调其他情况错误回调
## 0.1.42021-04-13
- 更新文档。明确云函数调用结果
## 0.1.32021-04-13
- 解耦云函数与弹框处理。utils中新增 call-check-version.js可用于单独检测是否有更新
## 0.1.22021-04-07
- 更新版本对比函数 compare
## 0.1.12021-04-07
- 修复 腾讯云空间下载链接不能下载问题
## 0.1.02021-04-07
- 新增使用uni.showModal提示升级示例
- 修改iOS升级提示方式
## 0.0.72021-04-02
- 修复在iOS上打开弹框报错
## 0.0.62021-04-01
- 兼容旧版本安卓
## 0.0.52021-04-01
- 修复低版本安卓上进度条错位
## 0.0.42021-04-01
- 更新readme
- 修复check-update语法错误
## 0.0.32021-04-01
- 新增前台更新弹框详见readme
- 更新前台检查更新方法
## 0.0.22021-03-29
- 更新文档
- 移除 dependencies
## 0.0.12021-03-25
- 升级中心前台检查更新

View File

@ -0,0 +1,122 @@
{
"id": "uni-upgrade-center-app",
"displayName": "升级中心 uni-upgrade-center - App",
"version": "0.9.10",
"description": "uni升级中心 - 客户端检查更新",
"keywords": [
"uniCloud",
"update",
"升级",
"wgt"
],
"repository": "https://gitee.com/dcloud/uni-upgrade-center/tree/master/uni_modules/uni-upgrade-center-app",
"engines": {
"HBuilderX": "^4.31",
"uni-app": "^4.35",
"uni-app-x": "^4.65"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": "",
"type": "unicloud-template-page",
"darkmode": "x",
"i18n": "x",
"widescreen": "√"
},
"uni_modules": {
"dependencies": [
"uts-progressNotification",
"uts-openSchema"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"vue": "√",
"nvue": {
"extVersion": "0.8.3",
"minVersion": ""
},
"android": {
"extVersion": "0.8.3",
"minVersion": "21"
},
"ios": {
"extVersion": "0.8.3",
"minVersion": "12"
},
"harmony": {
"extVersion": "0.9.1",
"minVersion": "12"
}
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x",
"xhs": "-"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "x",
"chrome": "x"
},
"app": {
"android": {
"extVersion": "0.9.0",
"minVersion": "21"
},
"ios": {
"extVersion": "0.9.0",
"minVersion": "12"
},
"harmony": "√"
},
"mp": {
"weixin": "x"
}
}
}
}
}
}

View File

@ -0,0 +1,546 @@
<template>
<view class="mask flex-center">
<view class="content">
<view class="content-top">
<text class="content-top-text">{{title}}</text>
<image class="content-top-image" mode="widthFix"
src="/uni_modules/uni-upgrade-center-app/static/app/bg_top.png"></image>
</view>
<view class="content-space"></view>
<view class="content-body">
<view class="content-body-title">
<text class="text title content-body-title_title">{{subTitle}}</text>
<text class="text version content-body-title_version">v{{version}}</text>
</view>
<view class="body">
<scroll-view class="box-des-scroll">
<text class="text box-des">
{{contents}}
</text>
</scroll-view>
</view>
<view class="footer flex-center">
<template v-if="isiOS || isHarmony">
<button class="content-button" style="border: none;color: #fff;" type="primary" plain
@click="jumpToAppStore">
{{downLoadBtnTextiOS}}
</button>
</template>
<template v-else>
<template v-if="!downloadSuccess">
<view class="progress-box flex-column" v-if="downloading">
<progress class="progress" :percent="downLoadPercent" activeColor="#3DA7FF" :show-info="true"
:stroke-width="10" />
<view style="width:100%;display: flex;justify-content: space-around;flex-direction: row;">
<text class="text" style="font-size: 14px;">{{downLoadingText}}</text>
<text class="text" style="font-size: 14px;">({{downloadedSize}}/{{packageFileSize}}M)</text>
</view>
</view>
<button v-else class="content-button" @click="updateApp">
{{downLoadBtnText}}
</button>
</template>
<button v-else-if="downloadSuccess && !installed" class="content-button" :loading="installing"
:disabled="installing" @click="installPackage">
{{installing ? '正在安装……' : '下载完成,立即安装'}}
</button>
<button v-else-if="installed" class="content-button" @click="installPackage">
安装未完成,点击安装
</button>
</template>
</view>
</view>
<view class="content-bottom">
<image v-if="!is_mandatory" class="close-img" mode="widthFix"
src="/uni_modules/uni-upgrade-center-app/static/app/app_update_close.png" @click="closeUpdate">
</image>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { openSchema as utsOpenSchema } from '@/uni_modules/uts-openSchema'
import { UniUpgradeCenterResult, StoreListItem } from '../../utils/call-check-version'
import { platform_iOS, platform_Android, platform_Harmony } from '../../utils/utils'
// #ifdef APP-ANDROID
import { createNotificationProgress, cancelNotificationProgress, finishNotificationProgress, CreateNotificationProgressOptions, FinishNotificationProgressOptions } from '@/uni_modules/uts-progressNotification'
// #endif
const requiredKey = ['version', 'url', 'type']
let downloadTask : DownloadTask | null = null;
let openSchemePromise : Promise<boolean> | null = null;
const openSchema = (url : string) : Promise<boolean> => new Promise<boolean>((resolve, reject) => {
try {
utsOpenSchema(url)
resolve(true)
} catch (e) {
reject(false)
}
})
// 从之前下载安装
const installForBeforeFilePath = ref<string>('')
// 安装
const installed = ref<boolean>(false)
const installing = ref<boolean>(false)
// 下载
const downloadSuccess = ref<boolean>(false)
const downloading = ref<boolean>(false)
const downLoadPercent = ref<number>(0)
const downloadedSize = ref<number>(0)
const packageFileSize = ref<number>(0)
// 要安装的本地包地址
const tempFilePath = ref<string>('')
// 默认安装包信息
const title = ref<string>('更新日志')
const contents = ref<string>('')
const version = ref<string>('')
const is_mandatory = ref<boolean>(false)
const url = ref<string>("")
const platform = ref<string[]>([])
const store_list = ref<StoreListItem[] | null>(null)
// 可自定义属性
const subTitle = ref<string>('发现新版本')
const downLoadBtnTextiOS = ref<string>('立即跳转更新')
const downLoadBtnText = ref<string>('立即下载更新')
const downLoadingText = ref<string>('安装包下载中,请稍后')
const isiOS = computed(() : boolean => platform.value.includes(platform_iOS))
const isHarmony = computed(() : boolean => platform.value.includes(platform_Harmony))
const isAndroid = computed(() : boolean => platform.value.includes(platform_Android))
const needNotificationProgress = computed(() : boolean => isAndroid.value && !is_mandatory.value)
function getCurrentDialogPage() : UniPage | null {
const pages = getCurrentPages()
if (pages.length > 0) {
const dialogPages = pages[pages.length - 1].getDialogPages()
if (dialogPages.length > 0) {
return dialogPages[dialogPages.length - 1]
}
}
return null
}
function closePopup() {
downloadSuccess.value = false
downloading.value = false
downLoadPercent.value = 0
downloadedSize.value = 0
packageFileSize.value = 0
tempFilePath.value = ''
installing.value = false
installed.value = false
uni.closeDialogPage({
dialogPage: getCurrentDialogPage(),
fail(e) {
console.log('e: ', e);
}
})
}
function askAbortDownload() {
uni.showModal({
title: '是否取消下载?',
cancelText: '否',
confirmText: '是',
success: res => {
if (res.confirm) {
if (downloadTask !== null) downloadTask!.abort()
if (needNotificationProgress.value) {
// #ifdef APP-ANDROID
cancelNotificationProgress();
// #endif
}
closePopup()
}
}
});
}
function closeUpdate() {
if (downloading.value && !needNotificationProgress.value) {
askAbortDownload()
return;
}
closePopup()
}
function jumpToAppStore() {
openSchema(url.value)
}
function show(localPackageInfo : UniUpgradeCenterResult | null) {
if (localPackageInfo === null) return;
for (let key in localPackageInfo) {
if (requiredKey.indexOf(key) != -1 && localPackageInfo[key] === null) {
console.error(`参数 ${key} 必填,请检查后重试`)
closePopup()
return;
}
}
title.value = localPackageInfo.title
url.value = localPackageInfo.url
contents.value = localPackageInfo.contents
is_mandatory.value = localPackageInfo.is_mandatory
platform.value = localPackageInfo.platform
version.value = localPackageInfo.version
store_list.value = localPackageInfo.store_list
}
function checkStoreScheme() : Promise<boolean> | null {
if (store_list.value !== null) {
const storeList : StoreListItem[] = store_list.value!.filter((item : StoreListItem) : boolean => item.enable)
if (storeList.length > 0) {
if (openSchemePromise === null) {
openSchemePromise = Promise.reject() as Promise<boolean>
}
storeList
.sort((cur : StoreListItem, next : StoreListItem) : number => next.priority - cur.priority)
.map((item : StoreListItem) : string => item.scheme)
.reduce((promise : Promise<boolean>, cur : string) : Promise<boolean> => {
openSchemePromise = promise.catch<boolean>(() : Promise<boolean> => openSchema(cur))
return openSchemePromise!
}, openSchemePromise!)
return openSchemePromise!
}
}
return null
}
function installPackage() {
installing.value = true;
// #ifdef APP
uni.installApk({
filePath: tempFilePath.value,
success: _ => {
installing.value = false;
installed.value = true;
},
fail: err => {
console.error('installApk fail', err);
// 安装失败需要重新下载安装包
installing.value = false;
installed.value = false;
uni.showModal({
title: '更新失败,请重新下载',
content: `uni.installApk 错误码 ${err.errCode}`,
showCancel: false
});
}
});
// 安装跳出覆盖安装,此处直接返回上一页
if (!is_mandatory.value) {
uni.navigateBack()
}
// #endif
}
function downloadFail() {
const errMsg = '下载失败,请点击重试'
downloadSuccess.value = false;
downloading.value = false;
downLoadPercent.value = 0;
downloadedSize.value = 0;
packageFileSize.value = 0;
downLoadBtnText.value = errMsg
downloadTask = null;
if (needNotificationProgress.value) {
// #ifdef APP-ANDROID
finishNotificationProgress({
title: '升级包下载失败',
content: '请重新检查更新',
onClick() { }
} as FinishNotificationProgressOptions);
// #endif
}
}
function downLoadComplete() {
downloadSuccess.value = true;
downloading.value = false;
downLoadPercent.value = 0
downloadedSize.value = 0
packageFileSize.value = 0
downloadTask = null;
if (needNotificationProgress.value) {
// #ifdef APP-ANDROID
finishNotificationProgress({
title: "安装升级包",
content: "下载完成",
onClick() { }
} as FinishNotificationProgressOptions)
installPackage();
// #endif
return
}
// 强制更新,直接安装
if (is_mandatory.value) {
installPackage();
}
}
function downloadPackage() {
//下载包
downloadTask = uni.downloadFile({
url: url.value,
success: res => {
if (res.statusCode == 200) {
tempFilePath.value = res.tempFilePath
downLoadComplete()
} else {
console.log('downloadFile err: ', res);
downloadFail()
}
},
fail: err => {
console.log('downloadFile err: ', err);
downloadFail()
}
});
if (downloadTask !== null) {
downloading.value = true;
if (needNotificationProgress.value) {
closePopup()
}
downloadTask!.onProgressUpdate(res => {
downLoadPercent.value = parseFloat(res.progress.toFixed(0));
downloadedSize.value = parseFloat((res.totalBytesWritten / Math.pow(1024, 2)).toFixed(2));
packageFileSize.value = parseFloat((res.totalBytesExpectedToWrite / Math.pow(1024, 2)).toFixed(2));
if (needNotificationProgress.value) {
// #ifdef APP-ANDROID
createNotificationProgress({
title: "升级中心正在下载安装包……",
content: `${downLoadPercent.value}%`,
progress: downLoadPercent.value,
onClick: () => {
if (!downloadSuccess.value) {
askAbortDownload()
}
}
} as CreateNotificationProgressOptions)
// #endif
}
});
}
}
function updateApp() {
const checkStoreSchemeResult = checkStoreScheme()
if (checkStoreSchemeResult !== null) {
checkStoreSchemeResult
.then(_ => { })
.catch(() => { downloadPackage() })
.finally(() => {
openSchemePromise = null
})
} else { downloadPackage() }
}
onUnload(() => {
if (needNotificationProgress.value) {
// #ifdef APP-ANDROID
cancelNotificationProgress()
// #endif
}
})
onLoad((onLoadOptions : OnLoadOptions) => {
const local_storage_key : string | null = onLoadOptions['local_storage_key']
if (local_storage_key == null) {
console.error('local_storage_key为空请检查后重试')
closePopup()
return;
};
const localPackageInfo = uni.getStorageSync(local_storage_key);
if (localPackageInfo == null) {
console.error('安装包信息为空,请检查后重试')
closePopup()
return;
};
show(JSON.parse<UniUpgradeCenterResult>(JSON.stringify(localPackageInfo)) as UniUpgradeCenterResult)
})
onBackPress((options : OnBackPressOptions) : boolean | null => {
if (is_mandatory.value) return true
if (!needNotificationProgress.value) {
if (downloadTask !== null) {
downloadTask!.abort()
}
}
return false
})
</script>
<style>
.flex-center {
/* #ifndef APP-NVUE | UNI-APP-X */
display: flex;
/* #endif */
justify-content: center;
align-items: center;
}
.mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, .65);
}
.content {
position: relative;
top: 0;
width: 600rpx;
background-color: transparent;
}
.text {
font-family: Source Han Sans CN;
}
.content-top {
width: 100%;
border-bottom-color: #fff;
border-bottom-width: 15px;
border-bottom-style: solid;
}
.content-space {
width: 100%;
height: 120px;
background-color: #fff;
position: absolute;
top: 30%;
z-index: -1;
}
.content-top-image {
width: 100%;
position: relative;
bottom: -10%;
}
.content-top-text {
font-size: 22px;
font-weight: bold;
color: #F8F8FA;
position: absolute;
width: 65%;
top: 50%;
left: 25px;
z-index: 1;
}
.content-body {
box-sizing: border-box;
padding: 0 25px;
width: 100%;
background-color: #fff;
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
}
.content-body-title {
flex-direction: row;
align-items: center;
}
.content-body-title_version {
padding-left: 10px;
color: #fff;
font-size: 10px;
margin-left: 5px;
padding: 2px 4px;
border-radius: 10px;
background: #50aefd;
}
.title {
font-size: 16px;
font-weight: bold;
color: #3DA7FF;
line-height: 38px;
}
.footer {
height: 75px;
display: flex;
align-items: center;
justify-content: space-around;
}
.box-des-scroll {
box-sizing: border-box;
padding: 0 15px;
height: 100px;
}
.box-des {
font-size: 13px;
color: #000000;
line-height: 25px;
}
.progress-box {
width: 100%;
}
.progress {
width: 90%;
height: 20px;
}
.content-bottom {
height: 75px;
}
.close-img {
width: 35px;
height: 35px;
z-index: 1000;
position: relative;
bottom: -30%;
left: 50%;
margin-left: -17px;
}
.content-button {
width: 100%;
height: 40px;
line-height: 40px;
font-size: 15px;
font-weight: 400;
border-radius: 20px;
border: none;
color: #fff;
text-align: center;
background-color: #1785ff;
}
.flex-column {
display: flex;
flex-direction: column;
align-items: center;
}
</style>

View File

@ -0,0 +1,679 @@
<template>
<view class="mask flex-center" v-if="shown">
<view class="content botton-radius">
<view class="content-top">
<text class="content-top-text">{{ title }}</text>
<image class="content-top" style="top: 0" width="100%" height="100%" src="/uni_modules/uni-upgrade-center-app/static/app/bg_top.png"></image>
</view>
<view class="content-header"></view>
<view class="content-body">
<view class="title">
<text>{{ subTitle }}</text>
<text class="content-body-version">{{ version }}</text>
</view>
<view class="body">
<scroll-view class="box-des-scroll" scroll-y="true">
<text class="box-des">
{{ contents }}
</text>
</scroll-view>
</view>
<view class="footer flex-center">
<template v-if="isApplicationStore">
<button class="content-button" style="border: none; color: #fff" plain @click="jumpToApplicationStore">
{{ downLoadBtnTextiOS }}
</button>
</template>
<template v-else>
<template v-if="!downloadSuccess">
<view class="progress-box flex-column" v-if="downloading">
<progress class="progress" :percent="downLoadPercent" activeColor="#3DA7FF" show-info stroke-width="10" />
<view style="width: 100%; font-size: 28rpx; display: flex; justify-content: space-around">
<text>{{ downLoadingText }}</text>
<text>({{ downloadedSize }}/{{ packageFileSize }}M)</text>
</view>
</view>
<button v-else class="content-button" style="border: none; color: #fff" plain @click="updateApp">
{{ downLoadBtnText }}
</button>
</template>
<button
v-else-if="downloadSuccess && !installed"
class="content-button"
style="border: none; color: #fff"
plain
:loading="installing"
:disabled="installing"
@click="installPackage"
>
{{ installing ? '正在安装……' : '下载完成,立即安装' }}
</button>
<button
v-else-if="installed && !isWGT"
class="content-button"
style="border: none; color: #fff"
plain
:loading="installing"
:disabled="installing"
@click="installPackage"
>
安装未完成点击安装
</button>
<button v-else-if="installed && isWGT" class="content-button" style="border: none; color: #fff" plain @click="restart">安装完毕点击重启</button>
</template>
</view>
</view>
<image v-if="!is_mandatory" class="close-img" src="/uni_modules/uni-upgrade-center-app/static/app/app_update_close.png" @click.stop="closeUpdate"></image>
</view>
</view>
</template>
<script>
// #ifdef APP-PLUS
import { createNotificationProgress, cancelNotificationProgress, finishNotificationProgress } from '@/uni_modules/uts-progressNotification';
// #endif
import { compare, platform_iOS, platform_Android, platform_Harmony } from '../utils/utils'
const localFilePathKey = 'UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH';
let downloadTask = null;
let openSchemePromise;
export default {
emits: ['close', 'show'],
data() {
return {
//
installForBeforeFilePath: '',
//
installed: false,
installing: false,
//
downloadSuccess: false,
downloading: false,
downLoadPercent: 0,
downloadedSize: 0,
packageFileSize: 0,
tempFilePath: '', //
//
title: '更新日志',
contents: '',
version: '',
is_mandatory: false,
url: '',
platform: [],
store_list: null,
//
subTitle: '发现新版本',
downLoadBtnTextiOS: '立即跳转更新',
downLoadBtnText: '立即下载更新',
downLoadingText: '安装包下载中,请稍后',
// #ifdef APP-PLUS
shown: true,
// #endif
// #ifdef APP-HARMONY
shown: false,
// #endif
};
},
onLoad({ local_storage_key }) {
if (!local_storage_key) {
console.error('local_storage_key为空请检查后重试');
uni.navigateBack();
return;
}
const localPackageInfo = uni.getStorageSync(local_storage_key);
if (!localPackageInfo) {
console.error('安装包信息为空,请检查后重试');
uni.navigateBack();
return;
}
this.setLocalPackageInfo(localPackageInfo)
},
onBackPress() {
//
if (this.is_mandatory) return true;
if (!this.needNotificationProgress) downloadTask && downloadTask.abort();
},
onHide() {
openSchemePromise = null;
},
computed: {
isWGT() {
return this.type === 'wgt';
},
isNativeApp() {
return this.type === 'native_app';
},
isiOS() {
return this.platform.indexOf(platform_iOS) !== -1;
},
isAndroid() {
return this.platform.indexOf(platform_Android) !== -1;
},
isHarmony() {
return this.platform.indexOf(platform_Harmony) !== -1;
},
isApplicationStore() {
return !this.isWGT && this.isNativeApp && (
this.isiOS ||
this.isHarmony
)
// return this.isiOS || (!this.isiOS && !this.isWGT && this.url.indexOf('.apk') === -1);
},
needNotificationProgress() {
return this.platform.indexOf(platform_iOS) === -1 && !this.is_mandatory && !this.isHarmony;
}
},
methods: {
show(shown, localPackageInfo) {
// #ifdef APP-HARMONY
this.$emit('show')
if (localPackageInfo) {
this.shown = shown
this.setLocalPackageInfo(localPackageInfo)
} else {
console.error(`安装包信息为空,请检查后重试`);
}
// #endif
},
setLocalPackageInfo(localPackageInfo) {
const requiredKey = ['version', 'url', 'type'];
for (let key in localPackageInfo) {
if (requiredKey.indexOf(key) !== -1 && !localPackageInfo[key]) {
console.error(`参数 ${key} 必填,请检查后重试`);
// #ifdef APP-PLUS
uni.navigateBack();
// #endif
// #ifdef APP-HARMONY
this.shown = false
// #endif
return;
}
}
Object.assign(this, localPackageInfo);
this.checkLocalStoragePackage();
},
checkLocalStoragePackage() {
//
const localFilePathRecord = uni.getStorageSync(localFilePathKey);
if (localFilePathRecord) {
const { version, savedFilePath, installed } = localFilePathRecord;
//
if (!installed && compare(version, this.version) === 0) {
this.downloadSuccess = true;
this.installForBeforeFilePath = savedFilePath;
this.tempFilePath = savedFilePath;
} else {
//
this.deleteSavedFile(savedFilePath);
}
}
},
askAbortDownload() {
uni.showModal({
title: '是否取消下载?',
cancelText: '否',
confirmText: '是',
success: (res) => {
if (res.confirm) {
downloadTask && downloadTask.abort();
if (this.needNotificationProgress) {
cancelNotificationProgress();
}
uni.navigateBack();
}
}
});
},
async closeUpdate() {
if (this.downloading) {
if (this.is_mandatory) {
return uni.showToast({
title: '下载中,请稍后……',
icon: 'none',
duration: 500
});
}
if (!this.needNotificationProgress) {
this.askAbortDownload();
return;
}
}
if (!this.needNotificationProgress && this.downloadSuccess && this.tempFilePath) {
//
await this.saveFile(this.tempFilePath, this.version);
}
// #ifdef APP-PLUS
uni.navigateBack();
// #endif
// #ifdef APP-HARMONY
this.shown = false
this.$emit('close')
// #endif
},
updateApp() {
this.checkStoreScheme()
.catch(() => {
this.downloadPackage();
})
.finally(() => {
openSchemePromise = null;
});
},
//
checkStoreScheme() {
const storeList = (this.store_list || []).filter((item) => item.enable);
if (storeList && storeList.length) {
storeList
.sort((cur, next) => next.priority - cur.priority)
.map((item) => item.scheme)
.reduce((promise, cur, curIndex) => {
openSchemePromise = (promise || (promise = Promise.reject())).catch(() => {
return new Promise((resolve, reject) => {
plus.runtime.openURL(cur, (err) => {
reject(err);
});
});
});
return openSchemePromise;
}, openSchemePromise);
return openSchemePromise;
}
return Promise.reject();
},
downloadPackage() {
this.downloading = true;
//
downloadTask = uni.downloadFile({
url: this.url,
success: (res) => {
if (res.statusCode == 200) {
// fix: wgt wgt
if (this.isWGT && res.tempFilePath.split('.').slice(-1)[0] !== 'wgt') {
const failCallback = (e) => {
console.log('[FILE RENAME FAIL]', JSON.stringify(e));
};
// #ifndef APP-HARMONY
plus.io.resolveLocalFileSystemURL(
res.tempFilePath,
(entry) => {
entry.getParent((parent) => {
const newName = `new_wgt_${Date.now()}.wgt`;
entry.copyTo(
parent,
newName,
(res) => {
this.tempFilePath = res.fullPath;
this.downLoadComplete();
},
failCallback
);
}, failCallback);
},
failCallback
);
// #endif
// #ifdef APP-HARMONY
failCallback({code: -1, message: 'Download content error, is not wgt.'})
// #endif
} else {
this.tempFilePath = res.tempFilePath;
this.downLoadComplete();
}
} else {
console.log('下载错误:' + JSON.stringify(res))
this.downloadFail()
}
},
fail: (err) => {
console.log('下载错误:' + JSON.stringify(err))
this.downloadFail()
}
});
downloadTask.onProgressUpdate((res) => {
this.downLoadPercent = res.progress;
this.downloadedSize = (res.totalBytesWritten / Math.pow(1024, 2)).toFixed(2);
this.packageFileSize = (res.totalBytesExpectedToWrite / Math.pow(1024, 2)).toFixed(2);
if (this.needNotificationProgress && !this.downloadSuccess) {
createNotificationProgress({
title: '升级中心正在下载安装包……',
content: `${this.downLoadPercent}%`,
progress: this.downLoadPercent,
onClick: () => {
this.askAbortDownload();
}
});
}
});
if (this.needNotificationProgress) {
uni.navigateBack();
}
},
downloadFail() {
const errMsg = '下载失败,请点击重试'
this.downloadSuccess = false;
this.downloading = false;
this.downLoadPercent = 0;
this.downloadedSize = 0;
this.packageFileSize = 0;
this.downLoadBtnText = errMsg
downloadTask = null;
if (this.needNotificationProgress) {
finishNotificationProgress({
title: '升级包下载失败',
content: '请重新检查更新',
onClick: () => {}
});
}
},
downLoadComplete() {
this.downloadSuccess = true;
this.downloading = false;
this.downLoadPercent = 0;
this.downloadedSize = 0;
this.packageFileSize = 0;
downloadTask = null;
if (this.needNotificationProgress) {
finishNotificationProgress({
title: '安装升级包',
content: '下载完成',
onClick: () => {}
});
this.installPackage();
return;
}
//
if (this.is_mandatory) {
this.installPackage();
}
},
installPackage() {
// #ifdef APP-PLUS || APP-HARMONY
// wgt
if (this.isWGT) {
this.installing = true;
}
plus.runtime.install(
this.tempFilePath,
{
force: false
},
async (res) => {
this.installing = false;
this.installed = true;
// wgt
if (this.isWGT) {
//
if (this.is_mandatory) {
// #ifdef APP-PLUS
uni.showLoading({
icon: 'none',
title: '安装成功,正在重启……'
});
// #endif
setTimeout(() => {
// #ifdef APP-PLUS
uni.hideLoading();
// #endif
this.restart();
}, 1000);
}
} else {
const localFilePathRecord = uni.getStorageSync(localFilePathKey);
uni.setStorageSync(localFilePathKey, {
...localFilePathRecord,
installed: true
});
}
},
async (err) => {
//
if (this.installForBeforeFilePath) {
await this.deleteSavedFile(this.installForBeforeFilePath);
this.installForBeforeFilePath = '';
}
//
this.installing = false;
this.installed = false;
uni.showModal({
title: '更新失败,请重新下载',
content: err.message,
showCancel: false
});
}
);
// wgt
if (!this.isWGT && !this.is_mandatory) {
uni.navigateBack();
}
// #endif
},
restart() {
this.installed = false;
// #ifdef APP-HARMONY
uni.showModal({
title: '更新完毕',
content: '请手动重启',
showCancel: false,
success(res) {
plus.runtime.quit()
}
})
// #endif
// #ifdef APP-PLUS
//app
plus.runtime.restart();
// #endif
},
saveFile(tempFilePath, version) {
return new Promise((resolve, reject) => {
uni.saveFile({
tempFilePath,
success({ savedFilePath }) {
uni.setStorageSync(localFilePathKey, {
version,
savedFilePath
});
},
complete() {
resolve();
}
});
});
},
deleteSavedFile(filePath) {
uni.removeStorageSync(localFilePathKey);
return uni.removeSavedFile({
filePath
});
},
jumpToApplicationStore() {
plus.runtime.openURL(this.url);
}
}
};
</script>
<style>
page {
background: transparent;
}
.flex-center {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: center;
align-items: center;
}
.mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.65);
}
.botton-radius {
border-bottom-left-radius: 30rpx;
border-bottom-right-radius: 30rpx;
}
.content {
position: relative;
top: 0;
width: 600rpx;
background-color: #fff;
box-sizing: border-box;
padding: 0 50rpx;
font-family: Source Han Sans CN;
}
.text {
/* #ifndef APP-NVUE */
display: block;
/* #endif */
line-height: 200px;
text-align: center;
color: #ffffff;
}
.content-top {
position: absolute;
top: -195rpx;
left: 0;
width: 600rpx;
height: 270rpx;
}
.content-top-text {
font-size: 45rpx;
font-weight: bold;
color: #f8f8fa;
position: absolute;
top: 120rpx;
left: 50rpx;
z-index: 1;
}
.content-header {
height: 70rpx;
}
.title {
font-size: 33rpx;
font-weight: bold;
color: #3da7ff;
line-height: 38px;
}
.content-body {
width: 100%;
}
.content-body-version {
padding-left: 20rpx;
color: #fff;
font-size: 20rpx;
margin-left: 10rpx;
padding: 4rpx 8rpx;
border-radius: 20rpx;
background: #50aefd;
}
.footer {
height: 150rpx;
display: flex;
align-items: center;
justify-content: space-around;
}
.box-des-scroll {
box-sizing: border-box;
padding: 0 40rpx;
height: 200rpx;
text-align: left;
}
.box-des {
font-size: 26rpx;
color: #000000;
line-height: 50rpx;
}
.progress-box {
width: 100%;
}
.progress {
width: 90%;
height: 40rpx;
/* border-radius: 35px; */
}
.close-img {
width: 70rpx;
height: 70rpx;
z-index: 1000;
position: absolute;
bottom: -120rpx;
left: calc(50% - 70rpx / 2);
}
.content-button {
text-align: center;
flex: 1;
font-size: 30rpx;
font-weight: 400;
color: #ffffff;
border-radius: 40rpx;
margin: 0 18rpx;
height: 80rpx;
line-height: 80rpx;
background: linear-gradient(to right, #1785ff, #3da7ff);
}
.flex-column {
display: flex;
flex-direction: column;
align-items: center;
}
</style>

View File

@ -0,0 +1 @@
文档已移至 [uni-upgrade-center](https://uniapp.dcloud.net.cn/uniCloud/upgrade-center.html)

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,120 @@
export type StoreListItem = {
enable : boolean
id : string
name : string
scheme : string
priority : number // 优先级
}
export type UniUpgradeCenterResult = {
_id : string
appid : string
name : string
title : string
contents : string
url : string // 安装包下载地址
platform : Array<string> // Array<'Android' | 'iOS' | 'Harmony'>
version : string // 版本号 1.0.0
uni_platform : string // "android" | "ios" | 'harmony'
stable_publish : boolean // 是否是稳定版
is_mandatory : boolean // 是否强制更新
is_silently : boolean | null // 是否静默更新
create_env : string // "upgrade-center"
create_date : number
message : string
code : number
type : string // "native_app" | "wgt"
store_list : StoreListItem[] | null
min_uni_version : string | null // 升级 wgt 的最低 uni-app 版本
}
export default function () : Promise<UniUpgradeCenterResult> {
// #ifdef APP
return new Promise<UniUpgradeCenterResult>((resolve, reject) => {
const systemInfo = uni.getSystemInfoSync()
const appId = systemInfo.appId
const appVersion = systemInfo.appVersion //systemInfo.appVersion
// #ifndef UNI-APP-X
if (typeof appId === 'string' && typeof appVersion === 'string' && appId.length > 0 && appVersion.length > 0) {
plus.runtime.getProperty(appId, function (widgetInfo) {
if (widgetInfo.version) {
let data = {
action: 'checkVersion',
appid: appId,
appVersion: appVersion,
wgtVersion: widgetInfo.version
}
uniCloud.callFunction({
name: 'uni-upgrade-center',
data,
success: (e) => {
resolve(e.result as UniUpgradeCenterResult)
},
fail: (error) => {
reject(error)
}
})
} else {
reject('widgetInfo.version is EMPTY')
}
})
} else {
reject('plus.runtime.appid is EMPTY')
}
// #endif
// #ifdef UNI-APP-X
if (typeof appId === 'string' && typeof appVersion === 'string' && appId.length > 0 && appVersion.length > 0) {
let data = {
action: 'checkVersion',
appid: appId,
appVersion: appVersion,
is_uniapp_x: true,
wgtVersion: '0.0.0.0.0.1'
}
try {
uniCloud.callFunction({
name: 'uni-upgrade-center',
data: data
}).then(res => {
const code = res.result['code']
const codeIsNumber = ['Int', 'Long', 'number'].includes(typeof code)
if (codeIsNumber) {
if ((code as number) == 0) {
reject({
code: res.result['code'],
message: res.result['message']
})
} else if ((code as number) < 0) {
reject({
code: res.result['code'],
message: res.result['message']
})
} else {
const result = JSON.parse<UniUpgradeCenterResult>(JSON.stringify(res.result)) as UniUpgradeCenterResult
resolve(result)
}
}
}).catch<void>((err : any | null) => {
const error = err as UniCloudError
if (error.errMsg == '未匹配到云函数[uni-upgrade-center]')
error.errMsg = '【uni-upgrade-center-app】未配置uni-upgrade-center无法升级。参考: https://uniapp.dcloud.net.cn/uniCloud/upgrade-center.html'
reject(error.errMsg)
})
} catch (e) {
reject(e.message)
}
} else {
reject('invalid appid or appVersion')
}
// #endif
})
// #endif
// #ifndef APP
return new Promise((resolve, reject) => {
reject({
message: '请在App中使用'
})
})
// #endif
}

View File

@ -0,0 +1,184 @@
function callCheckVersion() {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
plus.runtime.getProperty(plus.runtime.appid, function(widgetInfo) {
let data = {
action: 'checkVersion',
appid: plus.runtime.appid,
appVersion: plus.runtime.version,
wgtVersion: widgetInfo.version
}
uniCloud.callFunction({
name: 'uni-upgrade-center',
data,
success: (e) => {
resolve(e)
},
fail: (error) => {
reject(error)
}
})
})
})
// #endif
// #ifndef APP-PLUS
return new Promise((resolve, reject) => {})
// #endif
}
// 推荐再App.vue中使用
const PACKAGE_INFO_KEY = '__package_info__'
export default function() {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
callCheckVersion().then(async (e) => {
if (!e.result) return;
const {
code,
message,
is_silently, // 是否静默更新
url, // 安装包下载地址
platform, // 安装包平台
type // 安装包类型
} = e.result;
// 此处逻辑仅为实例,可自行编写
if (code > 0) {
// 腾讯云和阿里云下载链接不同,需要处理一下,阿里云会原样返回
const {
fileList
} = await uniCloud.getTempFileURL({
fileList: [url]
});
if (fileList[0].tempFileURL)
e.result.url = fileList[0].tempFileURL;
resolve(e)
// 静默更新只有wgt有
if (is_silently) {
uni.downloadFile({
url: e.result.url,
success: res => {
if (res.statusCode == 200) {
// 下载好直接安装,下次启动生效
plus.runtime.install(res.tempFilePath, {
force: false
});
}
}
});
return;
}
/**
* 提示升级一
* 使用 uni.showModal
*/
// return updateUseModal(e.result)
/**
* 提示升级二
* 官方适配的升级弹窗可自行替换资源适配UI风格
*/
uni.setStorageSync(PACKAGE_INFO_KEY, e.result)
uni.navigateTo({
url: `/uni_modules/uni-upgrade-center-app/pages/upgrade-popup?local_storage_key=${PACKAGE_INFO_KEY}`,
fail: (err) => {
console.error('更新弹框跳转失败', err)
uni.removeStorageSync(PACKAGE_INFO_KEY)
}
})
return
} else if (code < 0) {
// TODO 云函数报错处理
console.error(message)
return reject(e)
}
return resolve(e)
}).catch(err => {
// TODO 云函数报错处理
console.error(err.message)
reject(err)
})
});
// #endif
}
/**
* 使用 uni.showModal 升级
*/
function updateUseModal(packageInfo) {
const {
title, // 标题
contents, // 升级内容
is_mandatory, // 是否强制更新
url, // 安装包下载地址
platform, // 安装包平台
type // 安装包类型
} = packageInfo;
let isWGT = type === 'wgt'
let isiOS = !isWGT ? platform.includes('iOS') : false;
let confirmText = isiOS ? '立即跳转更新' : '立即下载更新'
return uni.showModal({
title,
content: contents,
showCancel: !is_mandatory,
confirmText,
success: res => {
if (res.cancel) return;
// 安装包下载
if (isiOS) {
plus.runtime.openURL(url);
return;
}
uni.showToast({
title: '后台下载中……',
duration: 1000
});
// wgt 和 安卓下载更新
downloadTask = uni.downloadFile({
url,
success: res => {
if (res.statusCode !== 200) {
console.error('下载安装包失败', err);
return;
}
// 下载好直接安装,下次启动生效
plus.runtime.install(res.tempFilePath, {
force: false
}, () => {
if (is_mandatory) {
//更新完重启app
plus.runtime.restart();
return;
}
uni.showModal({
title: '安装成功是否重启?',
success: res => {
if (res.confirm) {
//更新完重启app
plus.runtime.restart();
}
}
});
}, err => {
uni.showModal({
title: '更新失败',
content: err
.message,
showCancel: false
});
});
}
});
}
});
}

View File

@ -0,0 +1,228 @@
import callCheckVersion, { UniUpgradeCenterResult } from "./call-check-version"
import { platform_iOS } from './utils'
// #ifdef UNI-APP-X
import { openSchema } from '@/uni_modules/uts-openSchema'
// #endif
// 推荐再App.vue中使用
const PACKAGE_INFO_KEY = '__package_info__'
// #ifdef APP-HARMONY
export default function (component?: any) : Promise<UniUpgradeCenterResult> {
// #endif
// #ifndef APP-HARMONY
export default function () : Promise<UniUpgradeCenterResult> {
// #endif
return new Promise<UniUpgradeCenterResult>((resolve, reject) => {
callCheckVersion().then(async (uniUpgradeCenterResult) => {
// NOTE uni-app x 3.96 解构有问题
const code = uniUpgradeCenterResult.code
const message = uniUpgradeCenterResult.message
const url = uniUpgradeCenterResult.url // 安装包下载地址
// 此处逻辑仅为示例,可自行编写
if (code > 0) {
// 腾讯云获取下载链接
if (/^cloud:\/\//.test(url)) {
const tcbRes = await uniCloud.getTempFileURL({ fileList: [url] });
if (typeof tcbRes.fileList[0].tempFileURL !== 'undefined') uniUpgradeCenterResult.url = tcbRes.fileList[0].tempFileURL;
}
/**
*
* 使 uni.showModal
*/
// return updateUseModal(uniUpgradeCenterResult)
// #ifndef UNI-APP-X
// 静默更新只有wgt有
if (uniUpgradeCenterResult.is_silently) {
uni.downloadFile({
url: uniUpgradeCenterResult.url,
success: res => {
if (res.statusCode == 200) {
// 下载好直接安装,下次启动生效
plus.runtime.install(res.tempFilePath, {
force: false
});
}
}
});
return;
}
// #endif
/**
*
* UI风格
*/
// #ifndef UNI-APP-X
// #ifdef APP-PLUS
uni.setStorageSync(PACKAGE_INFO_KEY, uniUpgradeCenterResult)
uni.navigateTo({
url: `/uni_modules/uni-upgrade-center-app/pages/upgrade-popup?local_storage_key=${PACKAGE_INFO_KEY}`,
fail: (err) => {
console.error('更新弹框跳转失败', err)
uni.removeStorageSync(PACKAGE_INFO_KEY)
}
})
// #endif
// #ifdef APP-HARMONY
if (component) {
component.show(true, uniUpgradeCenterResult)
} else {
reject({
code: -1,
message: '在 HarmonyOS Next 平台请传递组件使用'
})
}
// #endif
// #endif
// #ifdef UNI-APP-X
uni.setStorageSync(PACKAGE_INFO_KEY, uniUpgradeCenterResult)
uni.openDialogPage({
url: `/uni_modules/uni-upgrade-center-app/pages/uni-app-x/upgrade-popup?local_storage_key=${PACKAGE_INFO_KEY}`,
disableEscBack: true,
fail: (err) => {
console.error('更新弹框跳转失败', err)
uni.removeStorageSync(PACKAGE_INFO_KEY)
}
})
// #endif
return resolve(uniUpgradeCenterResult)
} else if (code < 0) {
console.error(message)
return reject(uniUpgradeCenterResult)
}
return resolve(uniUpgradeCenterResult)
}).catch((err) => {
reject(err)
})
});
}
/**
* 使 uni.showModal
*/
function updateUseModal(packageInfo : UniUpgradeCenterResult) : void {
// #ifdef APP
const {
title, // 标题
contents, // 升级内容
is_mandatory, // 是否强制更新
url, // 安装包下载地址
type,
platform
} = packageInfo;
let isWGT = type === 'wgt'
let isiOS = !isWGT ? platform.includes(platform_iOS) : false;
// #ifndef UNI-APP-X
let confirmText = isiOS ? '立即跳转更新' : '立即下载更新'
// #endif
// #ifdef UNI-APP-X
let confirmText = '立即下载更新'
// #endif
uni.showModal({
title,
content: contents,
showCancel: !is_mandatory,
confirmText,
success: res => {
if (res.cancel) return;
if (isiOS) {
// iOS 平台跳转 AppStore
// #ifndef UNI-APP-X
plus.runtime.openURL(url);
// #endif
// #ifdef UNI-APP-X
openSchema(url)
// #endif
return;
}
uni.showToast({
title: '后台下载中……',
duration: 1000
});
// wgt 和 安卓下载更新
uni.downloadFile({
url,
success: res => {
if (res.statusCode !== 200) {
console.error('下载安装包失败');
return;
}
// 下载好直接安装,下次启动生效
// uni-app x 项目没有 plus5+ 故使用条件编译
// #ifndef UNI-APP-X
plus.runtime.install(res.tempFilePath, {
force: false
}, () => {
if (is_mandatory) {
//更新完重启app
// #ifdef APP-PLUS
plus.runtime.restart();
// #endif
// #ifdef APP-HARMONY
uni.showModal({
title: '安装成功',
content: '请手动重启应用',
showCancel: false,
success: res => {
plus.runtime.quit();
}
});
// #endif
return;
}
uni.showModal({
title: '安装成功是否重启?',
success: res => {
if (res.confirm) {
//更新完重启app
// #ifdef APP-PLUS
plus.runtime.restart();
// #endif
// #ifdef APP-HARMONY
plus.runtime.quit();
// #endif
}
}
});
}, err => {
uni.showModal({
title: '更新失败',
content: err
.message,
showCancel: false
});
});
// #endif
// #ifdef UNI-APP-X
uni.installApk({
filePath: res.tempFilePath,
success: () => {
uni.showModal({
title: '安装成功请手动重启'
});
},
fail: err => {
uni.showModal({
title: '更新失败',
content: err.errMsg,
showCancel: false
});
}
});
// #endif
}
});
}
});
// #endif
}

View File

@ -0,0 +1,46 @@
export const platform_iOS: string = 'iOS';
export const platform_Android: string = 'Android';
export const platform_Harmony: string = 'Harmony';
/**
*
* ("3.0.0.0.0.1.0.1", "3.0.0.0.0.1") ("3.0.0.1", "3.0") ("3.1.1", "3.1.1.1")
* @param {Object} v1
* @param {Object} v2
* v1 > v2 return 1
* v1 < v2 return -1
* v1 == v2 return 0
*/
export function compare(v_1: string = '0', v_2: string = '0') {
const v1: string[] = String(v_1).split('.');
const v2: string[] = String(v_2).split('.');
const minVersionLens = Math.min(v1.length, v2.length);
let result = 0;
for (let i = 0; i < minVersionLens; i++) {
const curV1 = Number(v1[i]);
const curV2 = Number(v2[i]);
if (curV1 > curV2) {
result = 1;
break;
} else if (curV1 < curV2) {
result = -1;
break;
}
}
if (result === 0 && v1.length !== v2.length) {
const v1BiggerThenv2 = v1.length > v2.length;
const maxLensVersion = v1BiggerThenv2 ? v1 : v2;
for (let i = minVersionLens; i < maxLensVersion.length; i++) {
const curVersion = Number(maxLensVersion[i]);
if (curVersion > 0) {
v1BiggerThenv2 ? (result = 1) : (result = -1);
break;
}
}
}
return result;
}

View File

@ -0,0 +1,13 @@
## 1.1.32025-09-12
- 鸿蒙平台 新增 打开 url 错误时输出
- iOS平台 修复 语法报黄问题
## 1.1.22025-03-20
- 更新 支持鸿蒙
## 1.1.12024-12-16
- 修复 canOpenURL 在安卓端可能会报类型错误的问题
## 1.1.02024-12-06
- 新增 canOpenURL UTS API可用此API判断url是否可以跳转
## 1.0.12024-11-13
- 修复 Android 打开部分 schema 时没有跳转到目标应用的 Bug
## 1.0.02024-04-25
- 更新 在 Android 和 iOS 上打开链接的 UTS API

View File

@ -0,0 +1,124 @@
{
"id": "uts-openSchema",
"displayName": "uts-openSchema",
"version": "1.1.3",
"description": "在 Android、iOS、HarmonyOS 上打开链接的 UTS API",
"keywords": [
"uts-openSchema"
],
"repository": "",
"engines": {
"HBuilderX": "^4.0",
"uni-app": "^4.75",
"uni-app-x": "^4.75"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"darkmode": "x",
"i18n": "x",
"widescreen": "x"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": {
"extVersion": "1.0.0",
"minVersion": ""
},
"vue3": {
"extVersion": "1.0.0",
"minVersion": ""
}
},
"web": {
"safari": "x",
"chrome": "x"
},
"app": {
"vue": {
"extVersion": "1.0.0",
"minVersion": ""
},
"nvue": "-",
"android": {
"extVersion": "1.0.0",
"minVersion": "21"
},
"ios": {
"extVersion": "1.0.0",
"minVersion": "12"
},
"harmony": {
"extVersion": "1.1.2",
"minVersion": "5.0.0"
}
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "x",
"chrome": "x"
},
"app": {
"android": {
"extVersion": "1.0.0",
"minVersion": "21"
},
"ios": {
"extVersion": "1.0.0",
"minVersion": "12"
},
"harmony": {
"extVersion": "1.1.2",
"minVersion": "5.0.0"
}
},
"mp": {
"weixin": "x"
}
}
}
}
}
}

View File

@ -0,0 +1,59 @@
# uts-openSchema
打开链接,支持:
1. 打开外部 App
2. 使用浏览器打开链接
3. 打开地图到指定地点
4. ...
## 使用
1. 安装此插件
2. 在要使用的地方 `import` 导入
```ts
import { openSchema, canOpenURL } from '@/uni_modules/uts-openSchema'
```
3. 直接调用 `openSchema` 方法:
```ts
// #ifdef UNI-APP-X
// 使用外部浏览器打开指定URL
openSchema('https://uniapp.dcloud.io/uni-app-x')
// #ifdef APP-ANDROID
// Android 使用应用商店打开指定App
openSchema('market://details?id=com.tencent.mm')
// Android 打开地图坐标
// 可以先用canOpenURL判断是否安装了地图软件
if (canOpenURL('androidamap://')) {
openSchema('androidamap://viewMap?sourceApplication=Hello%20uni-app&poiname=DCloud&lat=39.9631018208&lon=116.3406135236&dev=0')
} else {
console.log('未安装高德地图')
}
// #endif -->
// #ifdef APP-IOS
// 打开 AppStore 到搜索页
openSchema('itms-apps://search.itunes.apple.com//WebObjects//MZSearch.woa/wa/search?media=software&lterm=')
// 打开 iOS 地图坐标
openSchema('http://maps.apple.com/?q=Mexican+Restaurant&sll=50.894967,4.341626&z=10&t=s')
// #endif -->
// #endif -->
```
### 参数
- openSchema(url: string) // `url`:要打开的链接 `必填` `不为空字符串`
## 相关开发文档
[UTS 语法](https://uniapp.dcloud.net.cn/tutorial/syntax-uts.html)
[UTS API插件](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html)
[UTS 组件插件](https://uniapp.dcloud.net.cn/plugin/uts-component.html)
[Hello UTS](https://gitcode.net/dcloud/hello-uts)

View File

@ -0,0 +1,3 @@
{
"minSdkVersion": "21"
}

View File

@ -0,0 +1,27 @@
import Intent from 'android.content.Intent'
import Uri from 'android.net.Uri'
import { OpenSchema, CanOpenURL } from '../interface.uts'
export const openSchema : OpenSchema = function (url : string) {
if (canOpenURL(url)) {
const context = UTSAndroid.getUniActivity()!
const uri = Uri.parse(url)
const intent = new Intent(Intent.ACTION_VIEW, uri)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.setData(uri)
context.startActivity(intent)
} else {
console.error('[uts-openSchema] url param Error', JSON.stringify(url))
}
}
export const canOpenURL : CanOpenURL = function (url : string) : boolean {
if (typeof url === 'string' && url.length > 0) {
const context = UTSAndroid.getUniActivity()!
const uri = Uri.parse(url)
const intent = new Intent(Intent.ACTION_VIEW, uri)
return intent.resolveActivity(context.packageManager) != null ? true : false
} else {
return false
}
}

View File

@ -0,0 +1,21 @@
import { bundleManager, common } from '@kit.AbilityKit';
import OpenLinkOptions from '@ohos.app.ability.OpenLinkOptions'
import { getAbilityContext } from '@dcloudio/uni-runtime'
import { OpenSchema, CanOpenURL } from '../interface.uts'
export const openSchema : OpenSchema = function (url : string) : void {
(getAbilityContext() as common.UIAbilityContext)?.openLink(url, {
appLinkingOnly: false
} as OpenLinkOptions)
}
export const canOpenURL : CanOpenURL = function (url : string) : boolean {
try {
return bundleManager.canOpenLink(url)
} catch (error) {
console.error('[uts-openSchema] url param Error', JSON.stringify(url))
return false
}
}

View File

@ -0,0 +1,3 @@
{
"deploymentTarget": "12.0"
}

View File

@ -0,0 +1,22 @@
import { UIApplication } from 'UIKit'
import { URL } from 'Foundation'
import { OpenSchema, CanOpenURL } from '../interface.uts'
export const openSchema : OpenSchema = function (url : string) : void {
if (canOpenURL(url)) {
let uri = new URL(string = url)
UIApplication.shared.open(uri!)
} else {
console.error('[uts-openSchema] url param Error: ', url)
}
}
export const canOpenURL : CanOpenURL = function (url : string) : boolean {
if (typeof url == 'string' && url.length > 0) {
let uri = new URL(string = url)
if (uri != null && UIApplication.shared.canOpenURL(uri!)) {
return true
}
}
return false
}

View File

@ -0,0 +1,2 @@
export type OpenSchema = (url : string) => void
export type CanOpenURL = (url : string) => boolean

View File

@ -0,0 +1,12 @@
import { OpenSchema, CanOpenURL } from '../interface.uts'
export const openSchema : OpenSchema = function (url : string) : void {
location.href = url;
}
export const canOpenURL : CanOpenURL = function (url : string) : boolean {
if (url != "") {
return true;
}
return false;
}

View File

@ -0,0 +1,28 @@
## 1.1.22025-02-10
修复某些情况通过点击通知消息无法拉起App的bug
## 1.1.12024-09-03
去除TypeScript警告
## 1.1.02024-03-08
修复uniapp打包报错问题
## 1.0.92024-02-29
去除代码过时警告
## 1.0.82023-12-21
去除app-ios目录
## 1.0.72023-12-11
去除无用代码
## 1.0.62023-12-11
修改文档
## 1.0.52023-12-11
1.修改插件名称
2.修改插件引入方式为import导入
## 1.0.42023-11-30
1. createNotificationProgress增加`onClick`回调
2.修复在小米部分系统上通知消息会归类于不重要通知的bug
## 1.0.32023-11-28
更新截图
## 1.0.22023-11-28
修改资源的包名
## 1.0.12023-11-28
更新文档
## 1.0.02023-11-28
Android通知栏显示进度插件

View File

@ -0,0 +1,85 @@
{
"id": "uts-progressNotification",
"displayName": "uts-progressNotification",
"version": "1.1.2",
"description": "uts-progressNotification",
"keywords": [
"progressNotification"
],
"repository": "",
"engines": {
"HBuilderX": "^3.91"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "TargetSDKVersion33以上时需配置\n`android.permission.POST_NOTIFICATIONS`"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-android": {
"minVersion": "19"
},
"app-ios": "n",
"app-harmony": "u"
},
"H5-mobile": {
"Safari": "n",
"Android Browser": "n",
"微信浏览器(Android)": "n",
"QQ浏览器(Android)": "n"
},
"H5-pc": {
"Chrome": "n",
"IE": "n",
"Edge": "n",
"Firefox": "n",
"Safari": "n"
},
"小程序": {
"微信": "n",
"阿里": "n",
"百度": "n",
"字节跳动": "n",
"QQ": "n",
"钉钉": "n",
"快手": "n",
"飞书": "n",
"京东": "n"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}

View File

@ -0,0 +1,71 @@
# uts-progressNotification
## 使用说明
Android平台创建显示进度的通知栏消息
**注意: 需要自定义基座,否则点击通知栏消息不会拉起应用**
### 导入
需要import导入插件
### createNotificationProgress(options : CreateNotificationProgressOptions) : void,
创建显示进度的通知栏消息
参数说明
```
export type CreateNotificationProgressOptions = {
/**
* 通知标题
* @defaultValue 应用名称
*/
title ?: string | null
/**
* 通知内容
*/
content : string,
/**
* 进度
*/
progress : number,
/**
* 点击通知消息回调
* @defaultValue null
*/
onClick? : (() => void) | null
}
```
### finishNotificationProgress(options: FinishNotificationProgressOptions) : void
完成时调用的API比如下载完成后需要显示下载完成并隐藏进度时调用。
参数说明
```
export type FinishNotificationProgressOptions = {
/**
* 通知标题
* @defaultValue 应用名称
*/
title ?: string | null
/**
* 通知内容
*/
content : string,
/**
* 点击通知消息回调
*/
onClick : () => void
}
```
### cancelNotificationProgress() : void
取消通知消息显示

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
package="uts.sdk.modules.utsProgressNotification">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application>
<activity android:name="uts.sdk.modules.utsProgressNotification.TransparentActivity"
android:theme="@style/DCNotificationProgressTranslucentTheme" android:hardwareAccelerated="true"
android:screenOrientation="user" android:exported="true">
</activity>
</application>
</manifest>

View File

@ -0,0 +1,62 @@
import Activity from "android.app.Activity";
import Bundle from 'android.os.Bundle';
import Build from 'android.os.Build';
import View from 'android.view.View';
import Color from 'android.graphics.Color';
import WindowManager from 'android.view.WindowManager';
import { getGlobalNotificationProgressCallBack, getGlobalNotificationProgressFinishCallBack, setGlobalNotificationProgressCallBack, setGlobalNotificationProgressFinishCallBack} from './callbacks.uts';
import { ACTION_DOWNLOAD_FINISH, ACTION_DOWNLOAD_PROGRESS } from "./constant.uts"
export class TransparentActivity extends Activity {
constructor() {
super()
}
@Suppress("DEPRECATION")
override onCreate(savedInstanceState : Bundle | null) {
super.onCreate(savedInstanceState)
this.fullScreen(this)
const action = this.getIntent().getAction()
if (action == ACTION_DOWNLOAD_FINISH) {
setTimeout(() => {
getGlobalNotificationProgressFinishCallBack()?.()
setGlobalNotificationProgressFinishCallBack(() => { })
}, 100)
this.overridePendingTransition(0, 0)
}
if (action == ACTION_DOWNLOAD_PROGRESS) {
setTimeout(() => {
getGlobalNotificationProgressCallBack()?.()
setGlobalNotificationProgressCallBack(() => { })
}, 100)
this.overridePendingTransition(0, 0)
}
setTimeout(() => {
this.finish()
}, 20)
}
@Suppress("DEPRECATION")
private fullScreen(activity : Activity) {
if (Build.VERSION.SDK_INT >= 19) {
if (Build.VERSION.SDK_INT >= 21) {
const window = activity.getWindow();
const decorView = window.getDecorView();
const option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView.setSystemUiVisibility(option);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
window.setStatusBarColor(Color.TRANSPARENT);
} else {
const window = activity.getWindow();
const attributes = window.getAttributes();
const flagTranslucentStatus = WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS;
attributes.flags |= flagTranslucentStatus;
window.setAttributes(attributes);
}
}
}
}

View File

@ -0,0 +1,19 @@
let globalNotificationProgressCallBack : (() => void) | null = () => { }
let globalNotificationProgressFinishCallBack : (() => void) | null = () => { }
export function setGlobalNotificationProgressCallBack(callBack : (() => void) | null) : void {
globalNotificationProgressCallBack = callBack
}
export function getGlobalNotificationProgressCallBack() : (() => void) | null {
return globalNotificationProgressCallBack
}
export function setGlobalNotificationProgressFinishCallBack(callBack : (() => void) | null) : void {
globalNotificationProgressFinishCallBack = callBack
}
export function getGlobalNotificationProgressFinishCallBack() : (() => void) | null {
return globalNotificationProgressFinishCallBack
}

View File

@ -0,0 +1,3 @@
{
"minSdkVersion": "19"
}

View File

@ -0,0 +1,2 @@
export const ACTION_DOWNLOAD_FINISH = "ACTION_DOWNLOAD_FINISH"
export const ACTION_DOWNLOAD_PROGRESS = "ACTION_DOWNLOAD_PROGRESS"

View File

@ -0,0 +1,156 @@
import Build from 'android.os.Build';
import Context from 'android.content.Context';
import NotificationManager from 'android.app.NotificationManager';
import NotificationChannel from 'android.app.NotificationChannel';
import Notification from 'android.app.Notification';
import Intent from 'android.content.Intent';
import ComponentName from 'android.content.ComponentName';
import PendingIntent from 'android.app.PendingIntent';
import { CreateNotificationProgressOptions, FinishNotificationProgressOptions } from '../interface.uts';
import { ACTION_DOWNLOAD_FINISH, ACTION_DOWNLOAD_PROGRESS } from "./constant.uts"
import { setGlobalNotificationProgressCallBack, setGlobalNotificationProgressFinishCallBack } from './callbacks.uts';
export { TransparentActivity } from './TransparentActivity.uts';
const DOWNLOAD_PROGRESS_NOTIFICATION_ID : Int = 7890
const DC_DOWNLOAD_CHANNEL_ID = "下载文件"
const DC_DOWNLOAD_CHANNEL_NAME = "用于显示现在进度的渠道"
let notificationBuilder : Notification.Builder | null = null
let timeId = -1
let histroyProgress = 0
let isProgress = false
export function createNotificationProgress(options : CreateNotificationProgressOptions) : void {
const { content, progress, onClick } = options
if (progress == 100) {
clearTimeout(timeId)
const context = UTSAndroid.getAppContext() as Context
realCreateNotificationProgress(options.title ?? getAppName(context), content, progress, onClick)
reset()
return
}
histroyProgress = progress
if (timeId != -1) {
return
}
const context = UTSAndroid.getAppContext() as Context
if (!isProgress) {
realCreateNotificationProgress(options.title ?? getAppName(context), content, histroyProgress, onClick)
isProgress = true
} else {
timeId = setTimeout(() => {
realCreateNotificationProgress(options.title ?? getAppName(context), content, histroyProgress, onClick)
timeId = -1
}, 1000)
}
}
export function cancelNotificationProgress() : void {
const context = UTSAndroid.getAppContext() as Context
const notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(DOWNLOAD_PROGRESS_NOTIFICATION_ID)
reset()
}
function realCreateNotificationProgress(title : string, content : string, progress : number, cb : (() => void) | null) : void {
setGlobalNotificationProgressCallBack(cb)
const context = UTSAndroid.getAppContext() as Context
const notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
createDownloadChannel(notificationManager)
const builder = createNotificationBuilder(context)
builder.setProgress(100, progress.toInt(), false)
builder.setContentTitle(title)
builder.setContentText(content)
builder.setContentIntent(createPendingIntent(context, ACTION_DOWNLOAD_PROGRESS));
notificationManager.notify(DOWNLOAD_PROGRESS_NOTIFICATION_ID, builder.build())
}
export function finishNotificationProgress(options : FinishNotificationProgressOptions) {
setGlobalNotificationProgressFinishCallBack(options.onClick)
const context = UTSAndroid.getAppContext() as Context
const notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
createDownloadChannel(notificationManager)
const builder = createNotificationBuilder(context)
builder.setProgress(0, 0, false)
builder.setContentTitle(options.title ?? getAppName(context))
builder.setContentText(options.content)
//小米rom setOngoing未false的时候会被通知管理器归为不重要通知
// builder.setOngoing(false)
builder.setAutoCancel(true);
builder.setContentIntent(createPendingIntent(context, ACTION_DOWNLOAD_FINISH));
notificationManager.notify(DOWNLOAD_PROGRESS_NOTIFICATION_ID, builder.build())
reset()
}
function reset() {
isProgress = false
notificationBuilder = null
histroyProgress = 0
if (timeId != -1) {
clearTimeout(timeId)
timeId = -1
}
}
function createPendingIntent(context : Context, action : string) : PendingIntent {
const intent = new Intent(action);
intent.setComponent(new ComponentName(context.getPackageName(), "uts.sdk.modules.utsProgressNotification.TransparentActivity"));
let flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= 23) {
flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
}
return PendingIntent.getActivity(context, DOWNLOAD_PROGRESS_NOTIFICATION_ID, intent, flags);
}
function createDownloadChannel(notificationManager : NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
const channel = new NotificationChannel(
DC_DOWNLOAD_CHANNEL_ID,
DC_DOWNLOAD_CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
}
}
@Suppress("DEPRECATION")
function createNotificationBuilder(context : Context) : Notification.Builder {
if (notificationBuilder == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationBuilder = new Notification.Builder(context, DC_DOWNLOAD_CHANNEL_ID)
} else {
notificationBuilder = new Notification.Builder(context)
}
notificationBuilder!.setSmallIcon(context.getApplicationInfo().icon)
notificationBuilder!.setOngoing(true)
notificationBuilder!.setSound(null)
}
return notificationBuilder!
}
@Suppress("DEPRECATION")
function getAppName(context : Context) : string {
let appName = ""
try {
const packageManager = context.getPackageManager()
const applicationInfo = packageManager.getApplicationInfo(context.getPackageName(), 0)
appName = packageManager.getApplicationLabel(applicationInfo) as string
} catch (e : Exception) {
e.printStackTrace()
}
return appName
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="DCNotificationProgressTranslucentTheme">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@android:style/Animation</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>

View File

@ -0,0 +1,46 @@
export type CreateNotificationProgressOptions = {
/**
* 通知标题
* @defaultValue 应用名称
*/
title ?: string | null
/**
* 通知内容
*/
content : string,
/**
* 进度
*/
progress : number,
/**
* 点击通知消息回调
* @defaultValue null
*/
onClick? : (() => void) | null
}
export type FinishNotificationProgressOptions = {
/**
* 通知标题
* @defaultValue 应用名称
*/
title ?: string | null
/**
* 通知内容
*/
content : string,
/**
* 点击通知消息回调
*/
onClick : () => void
}
export type CreateNotificationProgress = (options : CreateNotificationProgressOptions) => void;
export type CancelNotificationProgress = () => void;
export type FinishNotificationProgress = (options: FinishNotificationProgressOptions) => void