app自動更新

This commit is contained in:
owenchen committed 2026-10-23 15:48:08 +08:00
1 parent 31de6c4cd6
commit 699a26371a
7 files changed
+614 -156

No files matched your search

+7
View File
@@ -2,6 +2,9 @@
import { onLaunch } from "@dcloudio/uni-app";
import { setupGlobalErrorBoundary } from "@/common/global-error";
import { setupNavGuard } from "@/router/nav-guard";
// #ifdef APP-PLUS
import { autoCheckAppUpdate } from "@/utils/appUpdate";
// #endif
// 全局错误边界:必须在 setup 顶层注册(uni 的 App 级钩子要绑到 App 实例上),
// 内部同时挂 uni 钩子与 H5 的 window 兜底,原因见 src/md/error-handling.md §2
@@ -10,6 +13,10 @@ setupGlobalErrorBoundary();
// 启动即挂导航拦截器:首次站内跳转就被管住(冷启动深链由页面级 useAuthGuard 复核)
onLaunch(() => {
setupNavGuard();
// #ifdef APP-PLUS
// 自检更新:晚几秒再静默查,避开隐私弹窗与启动首帧;同一会话只自动查一次(逻辑见 utils/appUpdate.ts)
setTimeout(autoCheckAppUpdate, 5000);
// #endif
});
</script>
+18 -1
View File
@@ -115,7 +115,24 @@
"aboutTitle": "About",
"appName": "App",
"versionLabel": "Version",
"platformLabel": "Platform"
"platformLabel": "Platform",
"checkUpdate": "Check Update"
},
"AppUpdate": {
"title": "New version v{version}",
"noDesc": "Bug fixes and new features. Update recommended",
"updateBut": "Update Now",
"laterBut": "Later",
"quitBut": "Exit App",
"forceTip": "This version is no longer supported. You must update to continue",
"checking": "Checking for updates…",
"latest": "You are on the latest version",
"checkFailed": "Update check failed. Check your network or access line and retry",
"downloading": "Downloading installer…",
"downloadFailed": "Download failed, please retry later",
"installHint": "Please finish installing in the system dialog, then reopen the app",
"iosTip": "In-app install is not available on iOS. Please download the latest package from the website",
"gotIt": "Got it"
},
"RegisterPage": {
"title": "Register",
+19 -2
View File
@@ -115,7 +115,24 @@
"aboutTitle": "关于",
"appName": "应用名称",
"versionLabel": "当前版本",
"platformLabel": "运行平台"
"platformLabel": "运行平台",
"checkUpdate": "检查更新"
},
"AppUpdate": {
"title": "发现新版本 v{version}",
"noDesc": "有一些改进和新功能,建议更新",
"updateBut": "立即更新",
"laterBut": "稍后再说",
"quitBut": "退出应用",
"forceTip": "当前版本已停止支持,必须更新后才能继续使用",
"checking": "检查更新中…",
"latest": "已是最新版本",
"checkFailed": "检查更新失败,请检查网络或访问线路后重试",
"downloading": "下载安装包…",
"downloadFailed": "下载失败,请稍后重试",
"installHint": "请在弹出的安装界面完成安装,装好后重开即为新版",
"iosTip": "iOS 暂不支持应用内安装,请前往网页版下载最新安装包",
"gotIt": "知道了"
},
"RegisterPage": {
"title": "注册",
@@ -1008,4 +1025,4 @@
"r404_3": "页面已下线或改名,收藏夹里还是旧地址",
"r404_4": "服务器刚重启、菜单还没加载出来,刷新一次即可"
}
}
}
+154 -153
View File
@@ -1,159 +1,160 @@
{
"name" : "有家相册",
"appid" : "__UNI__F0B0BDF",
"description" : "有家相册:家庭相册、家族树与文档共享的跨端应用",
"versionName" : "1.0.0",
"versionCode" : "1",
"transformPx" : false,
"uniStatistics" : {
"enable" : false
"name": "有家相册",
"appid": "__UNI__F0B0BDF",
"description": "有家相册:家庭相册、家族树与文档共享的跨端应用",
"versionName": "1.0.0",
"versionCode": "1",
"transformPx": false,
"uniStatistics": {
"enable": false
},
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"safearea" : {
"background" : "#ffffff",
"backgroundDark" : "#2f0508",
"bottom" : {
"offset" : "none"
}
},
"modules" : {
"VideoPlayer" : {}
},
"privacy" : {
"prompt" : "template",
"template" : {
"title" : "服务协议和隐私政策",
"message" : "感谢你使用“有家相册”。我们非常重视你的个人信息保护,请你务必审慎阅读、充分理解《服务协议》和《隐私政策》各条款,包括但不限于:为向你提供账号注册登录、家庭相册、家族谱系与文件共享服务,我们会在你授权同意后收集你的手机号、头像、身份证照片(仅实名核验用)、你主动上传的照片/视频/文件以及必要的设备标识与操作日志等信息。<br/>你可阅读<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept" : "同意并接受",
"buttonRefuse" : "暂不同意",
"second" : {
"title" : "确认提示",
"message" : "进入应用前,你需先同意<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>,否则将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
}
}
},
"distribute" : {
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>"
]
},
"ios" : {
"dSYMs" : false,
"privacyDescription" : {
"NSCameraUsageDescription" : "用于拍摄头像、身份证照片及上传家庭相册中的照片和视频",
"NSPhotoLibraryUsageDescription" : "用于从相册选择头像、身份证照片及上传照片和视频",
"NSPhotoLibraryAddUsageDescription" : "用于将照片或视频保存到系统相册"
}
},
"sdkConfigs" : {},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
}
"safearea": {
"background": "#ffffff",
"backgroundDark": "#2f0508",
"bottom": {
"offset": "none"
}
},
"modules": {
"VideoPlayer": {}
},
"privacy": {
"prompt": "template",
"template": {
"title": "服务协议和隐私政策",
"message": "感谢你使用“有家相册”。我们非常重视你的个人信息保护,请你务必审慎阅读、充分理解《服务协议》和《隐私政策》各条款,包括但不限于:为向你提供账号注册登录、家庭相册、家族谱系与文件共享服务,我们会在你授权同意后收集你的手机号、头像、身份证照片(仅实名核验用)、你主动上传的照片/视频/文件以及必要的设备标识与操作日志等信息。<br/>你可阅读<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept": "同意并接受",
"buttonRefuse": "暂不同意",
"second": {
"title": "确认提示",
"message": "进入应用前,你需先同意<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>,否则将退出应用。",
"buttonAccept": "同意并继续",
"buttonRefuse": "退出应用"
}
}
},
"h5" : {
"router" : {
"mode" : "history",
"base" : "/"
},
"devServer" : {
"host" : "::",
"port" : 5173
},
"darkmode" : true,
"themeLocation" : "theme.json"
},
"quickapp" : {},
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true,
"darkmode" : true,
"themeLocation" : "theme.json"
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"vueVersion" : "3",
"app-harmony" : {
"darkmode" : true,
"themeLocation" : "theme.json",
"safearea" : {
"background" : "#ffffff",
"backgroundDark" : "#2f0508",
"bottom" : {
"offset" : "none"
}
},
"distribute" : {
"bundleName" : "com.youjiala.app",
"icons" : {
"foreground" : "unpackage/res/icons/1024x1024.png",
"background" : "unpackage/res/icons/1024x1024.png"
},
"splashScreens" : {
"startWindowBackground" : "#FFFFFF",
"startWindowIcon" : "unpackage/res/icons/1024x1024.png"
},
"signingConfigs" : {}
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>"
]
},
"ios": {
"dSYMs": false,
"privacyDescription": {
"NSCameraUsageDescription": "用于拍摄头像、身份证照片及上传家庭相册中的照片和视频",
"NSPhotoLibraryUsageDescription": "用于从相册选择头像、身份证照片及上传照片和视频",
"NSPhotoLibraryAddUsageDescription": "用于将照片或视频保存到系统相册"
}
},
"sdkConfigs": {},
"icons": {
"android": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"ios": {
"appstore": "unpackage/res/icons/1024x1024.png",
"ipad": {
"app": "unpackage/res/icons/76x76.png",
"app@2x": "unpackage/res/icons/152x152.png",
"notification": "unpackage/res/icons/20x20.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"proapp@2x": "unpackage/res/icons/167x167.png",
"settings": "unpackage/res/icons/29x29.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"spotlight": "unpackage/res/icons/40x40.png",
"spotlight@2x": "unpackage/res/icons/80x80.png"
},
"iphone": {
"app@2x": "unpackage/res/icons/120x120.png",
"app@3x": "unpackage/res/icons/180x180.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"notification@3x": "unpackage/res/icons/60x60.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"settings@3x": "unpackage/res/icons/87x87.png",
"spotlight@2x": "unpackage/res/icons/80x80.png",
"spotlight@3x": "unpackage/res/icons/120x120.png"
}
}
}
}
}
},
"h5": {
"router": {
"mode": "history",
"base": "/"
},
"devServer": {
"host": "::",
"port": 5173
},
"darkmode": true,
"themeLocation": "theme.json"
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true,
"darkmode": true,
"themeLocation": "theme.json"
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"vueVersion": "3",
"app-harmony": {
"darkmode": true,
"themeLocation": "theme.json",
"safearea": {
"background": "#ffffff",
"backgroundDark": "#2f0508",
"bottom": {
"offset": "none"
}
},
"distribute": {
"bundleName": "com.youjiala.app",
"icons": {
"foreground": "unpackage/res/icons/1024x1024.png",
"background": "unpackage/res/icons/1024x1024.png"
},
"splashScreens": {
"startWindowBackground": "#FFFFFF",
"startWindowIcon": "unpackage/res/icons/1024x1024.png"
},
"signingConfigs": {}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
# App 自检更新(静态版本清单 + APK 整包,一期)
适用范围:`src/utils/appUpdate.ts`、`src/App.vue`、`src/pages/tab/mine.vue`、`src/manifest.json`(Android 权限),以及 `D:\Net8\FileServer` 下的 `app-update.json` + `youjiala.apk` 两个发布物。
本文只讲「App 怎么发现自己有新版本并装上」。文件地址怎么变成端上可达的 URL 见 [file-url.md](./file-url.md),接口地址与线路分层见 [environment.md](./environment.md)。
---
## 1. 一句话结论
FileServer 根目录放一份 `app-update.json`(版本清单)和 `youjiala.apk`(整包),**零后端代码改动**:App 启动后延迟静默查一次、「我的 → 检查更新」可手动再查,发现新版走「弹窗 → 下载整包 → 拉起系统安装器」。发版 = 替换 apk + 改 json,两步完成。
## 2. 版本清单格式(`FileServer/app-update.json`)
```json
{
"versionCode": 1,
"versionName": "1.0.0",
"description": "首个正式版本:家庭相册、家族树与文档共享。",
"descriptionEn": "First release: family albums, genealogy trees and document sharing.",
"force": false,
"apk": "youjiala.apk"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `versionCode` | ✅(缺/为 0 视为坏清单,检查直接判失败) | **唯一比较依据**,与 `manifest.json` 的 versionCode 同口径的自增号 |
| `versionName` | 建议 | 弹窗标题展示用;缺失时回落显示 versionCode |
| `description` / `descriptionEn` | 可选 | 更新说明,按当前界面语言取,英文缺失回落中文 |
| `force` | 可选,默认 false | 强更闸门:接口不兼容的老版本置 true |
| `apk` | 可选,默认 `youjiala.apk` | FileServer 根下的安装包文件名 |
## 3. 检查与更新流程(`src/utils/appUpdate.ts`)
```
checkAppUpdate(manual)
├─ uni.request GET {当前线路}/FileServer/app-update.json(8 秒超时)
├─ plus.runtime.getProperty 取本机 versionCode
├─ 清单 ≤ 本机 → manual 时 toast「已是最新版本」,否则静默
├─ 自动检查且该版本被「稍后再说」跳过过 → 静默不再弹(强更不受此限)
├─ uni.showModal:立即更新 / 稍后再说(force 时右钮换「退出应用」,点它 plus.runtime.quit)
├─ iOS → 只弹提示引导去网页下载(应用内装不了 apk)
└─ Android → plus.downloader 下整包(showLoading 刷百分比)→ plus.runtime.install 拉起系统安装器
```
要点:
- **地址口径不另起炉灶**:清单和 apk 都过 `absoluteFileUrl()`,自动跟随「访问线路」——内网直连取内网开发机的包、公网取 uat 的包(归一规则见 [file-url.md](./file-url.md))。
- **文案住语言包**:弹窗/提示全部取 `locale/lang/*.json` 的 `AppUpdate` 段(双语维护规范);非组件上下文拿不到 `useLocale()`,`msg()` 按 `currentUiLocale()` 直接从语言包对象取词并做 `{version}` 占位替换。
- **并发闸门**:模块级 `checking` 标志,自动/手动撞车只跑一次;`autoChecked` 保证一次冷启动只静默查一次。
- **失败静默与提示分工**:自动检查网络失败不打扰用户;手动入口给「检查更新失败,请检查网络或访问线路后重试」。
## 4. 触发点
| 入口 | 位置 | 行为 |
| --- | --- | --- |
| 启动自动 | `App.vue` 的 `onLaunch` 延后 5 秒调 `autoCheckAppUpdate()` | 静默:无新版/被跳过/失败都不吭声,只有发现新版才弹 |
| 手动 | 「我的 → 检查更新」行(仅 `APP-PLUS` 条件编译,右侧显示当前版本号) | 全量反馈:检查中 loading、已最新 toast、失败 toast,无视「稍后再说」记账 |
5 秒延时是为了避开隐私弹窗与启动首帧;若实测仍与隐私弹窗撞车,可改挂到登录成功后。
## 5. 端间差异
| 端 | 行为 |
| --- | --- |
| Android App | 完整流程:弹窗 → 下载 → `plus.runtime.install` 系统安装器 |
| iOS App | 只弹「请前往网页版下载最新安装包」提示(签名分发限制,应用内装不了) |
| H5 / 小程序 | 整个模块空转(`checkAppUpdate` 返回 `unsupported`),入口也不渲染 |
manifest 已补 `android.permission.REQUEST_INSTALL_PACKAGES`;真机首次安装仍需用户在系统里允许「安装未知应用」。
## 6. 强制更新语义
`force: true` 时:
- 弹窗右钮从「稍后再说」换成「退出应用」,点退出即 `plus.runtime.quit()`,没有第三条路;
- 自动检查**不受**「稍后再说」记账限制——跳过的版本只要清单还标着 force 就继续弹。
一期不做「最低支持版本」独立闸门:强与否由发版时改 json 的 `force` 字段人工决定。
## 7. 发版流程(三步)
1. **版本号三处同步**(`common/constant.ts` 注释已有约定):`manifest.json`(versionName + versionCode)、`package.json`(version)、`common/constant.ts` 的 `APP_INFO.version`;
2. HBuilderX 打新包,替换 `D:\Net8\FileServer\youjiala.apk`;
3. 改 `D:\Net8\FileServer\app-update.json` 的 `versionCode` / `versionName` / `description`(必要时 `force`)。
## 8. 验证方法
- 当前清单 `versionCode=1` 与已装包相同 → 手动检查应提示「已是最新版本」;
- 想看完整更新弹窗:把 json 的 `versionCode` 临时改成 `2`(拿现有 apk 也能走完下载 → 系统安装器全流程);
- 「稍后再说」记账存在本地 key `app-update-skipped`(记被跳过的 versionCode),手动检查不受限,清缓存或换更高新版本即恢复弹窗。
## 9. 决策留痕与二期
- **为什么不走后端接口管版本(方案 C)**:要过权限/功能/组 MySQL 登记 + 自动生成链 + 后端重启,一期收益不成比例;静态 json 与 apk 本来就在 FileServer 同一目录,发布动作最轻。
- **wgt 热更新(方案 B)留作二期**:包体大了以后再加「纯前端改动发 wgt 小包、动原生才发整包」的双通道,需要发布流程配合产 wgt 并守版本纪律。
- 若将来引入应用商店分发或需要灰度/最低版本策略,本文口径作废重议。
+26
View File
@@ -14,6 +14,9 @@ import { usePermission } from "@/permission/permission-context";
import { pageUrl } from "@/router/routes";
import authService from "@/service/security/authService";
import { useLoginProfileStore } from "@/stores";
// #ifdef APP-PLUS
import { checkAppUpdate } from "@/utils/appUpdate";
// #endif
const { t } = useLocale();
@@ -83,6 +86,13 @@ function onLineSaved(payload: { changed: boolean }): void {
setTimeout(() => uni.reLaunch({ url: pageUrl("login") }), 1200);
}
// #ifdef APP-PLUS
/** 检查更新(仅 App):拉 FileServer 版本清单比对本机 versionCode,弹窗/下载/安装与失败提示都在 utils/appUpdate 里统一处理 */
function handleCheckUpdate(): void {
void checkAppUpdate(true);
}
// #endif
/** 进入「安全管理」枢纽页:权限体系等基础设定的聚合入口 */
function goSecurity(): void {
uni.navigateTo({ url: pageUrl("securityPage") });
@@ -174,6 +184,22 @@ async function handleLogout(): Promise<void> {
</view>
<!-- #endif -->
<!-- 检查更新:仅 App;自检 FileServer 版本清单,发现新版走整包更新(逻辑见 utils/appUpdate.ts) -->
<!-- #ifdef APP-PLUS -->
<view class="mine-cell" @tap="handleCheckUpdate">
<view class="mine-cell__icon">
<u-icon name="download" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.checkUpdate") }}
</text>
<text class="mine-cell__value">
v{{ APP_INFO.version }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- #endif -->
<!-- 安全管理:进入权限体系基础设定聚合页(/security);仅授 SecurityActor 功能者可见 -->
<view v-if="canSeeSecurity" class="mine-cell" @tap="goSecurity">
<view class="mine-cell__icon">
+292
View File
@@ -0,0 +1,292 @@
import { currentUiLocale, UI_LOCALE_EN } from "@/common/locale-const";
import { enUS, zhCN } from "@/locale";
import { absoluteFileUrl } from "@/utils/fileUrl";
declare const plus: any;
/**
* App 自检更新(一期:整包 APK 方案,零后端改动)。
*
* 发布口径:FileServer 根目录放 app-update.json(版本清单)+ youjiala.apk(整包),
* 每次发版替换 apk 并改 json 里的 versionCode/versionName 即可,无需动后端代码。
* App 启动后延迟自动查一次("稍后再说"过的版本不再骚扰),「我的 → 检查更新」可手动再查;
* 发现新版 → 弹窗(强更时"退出应用"兜底)→ plus.downloader 下整包 → plus.runtime.install 拉起系统安装器。
*
* 地址全部过 absoluteFileUrl:跟「访问线路」走(内网直连取内网包、公网取 uat),与图片/文件同一口径。
* iOS 走不了 apk 自装(签名分发限制),只弹提示引导去网页下载;H5/小程序端整模块不生效。
* 文案住在 locale/lang/*.json 的 AppUpdate 段(双语维护规范),非组件上下文拿不到 useLocale,
* 这里直接按 currentUiLocale() 从语言包对象里取值做 {占位} 替换。
*/
/** 版本清单(app-update.json 结构):versionCode 是唯一比较依据,其余供展示 */
export interface AppUpdateInfo {
versionCode: number;
versionName: string;
/** 更新说明(中文) */
description?: string;
/** 更新说明(英文,缺失回落中文) */
descriptionEn?: string;
/** 强制更新:弹窗不可取消(只有"退出应用"兜底) */
force?: boolean;
/** 安装包文件名(FileServer 根下),默认 youjiala.apk */
apk?: string;
}
/** 自检结果:供调用方(如手动入口)按需补充反馈,弹窗/提示已在内部统一处理 */
export type UpdateCheckResult =
| "latest"
| "updated"
| "skipped"
| "failed"
| "unsupported";
/** 版本清单的公共路径(与 apk 同放 FileServer 根,走 /FileServer 静态挂载) */
const MANIFEST_PATH = "/FileServer/app-update.json";
/** 「稍后再说」记账:记下被跳过的 versionCode,同一版本自动检查不再弹 */
const SKIP_KEY = "app-update-skipped";
/** 非组件上下文的取词:AppUpdate.xxx → 当前语言包词条,缺失回落另一语言 */
function msg(key: string, vars?: Record<string, string>): string {
const primary = currentUiLocale() === UI_LOCALE_EN ? enUS : zhCN;
const fallback = primary === enUS ? zhCN : enUS;
let text = "";
for (const dict of [primary, fallback]) {
let node: unknown = dict;
for (const part of key.split(".")) {
node = (node as Record<string, unknown> | undefined)?.[part];
}
if (typeof node === "string") {
text = node;
break;
}
}
if (!text) {
return key;
}
return text.replace(/\{(\w+)\}/g, (_, name: string) => vars?.[name] ?? "");
}
/** 拉版本清单:8 秒超时;结构不合法(缺/零 versionCode)视为失败,防呆坏 json */
function fetchUpdateInfo(): Promise<AppUpdateInfo> {
return new Promise((resolve, reject) => {
uni.request({
url: absoluteFileUrl(MANIFEST_PATH),
method: "GET",
timeout: 8000,
success: (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`app-update.json HTTP ${res.statusCode}`));
return;
}
const body = res.data as Partial<AppUpdateInfo> | null;
const versionCode = Number(body?.versionCode ?? 0);
if (!Number.isFinite(versionCode) || versionCode <= 0) {
reject(new Error("app-update.json 缺少合法 versionCode"));
return;
}
resolve({
versionCode,
versionName: String(body?.versionName ?? versionCode),
description: body?.description,
descriptionEn: body?.descriptionEn,
force: body?.force === true,
apk: String(body?.apk || "youjiala.apk"),
});
},
fail: (err) => reject(new Error(String(err?.errMsg ?? "network error"))),
});
});
}
/** 本机基线包版本:plus.runtime.getProperty 拿 versionCode(manifest 里的自增号,比字符串版本号可靠) */
function getLocalVersionCode(): Promise<number> {
return new Promise((resolve) => {
plus.runtime.getProperty(
plus.appId,
(info: { versionCode?: string | number }) => {
resolve(Number(info?.versionCode ?? 0));
},
);
});
}
/** 更新弹窗:普通版可「稍后再说」,强更版右钮换「退出应用」(点它即 quit) */
function promptUpdate(info: AppUpdateInfo): Promise<boolean> {
const desc =
currentUiLocale() === UI_LOCALE_EN
? info.descriptionEn || info.description
: info.description || info.descriptionEn;
const content = [desc, info.force ? msg("AppUpdate.forceTip") : ""]
.filter(Boolean)
.join("\n");
return new Promise((resolve) => {
uni.showModal({
title: msg("AppUpdate.title", { version: info.versionName }),
content: content || msg("AppUpdate.noDesc"),
confirmText: msg("AppUpdate.updateBut"),
cancelText: info.force
? msg("AppUpdate.quitBut")
: msg("AppUpdate.laterBut"),
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
}
/** 下整包并拉起系统安装器:showLoading 反复调用即刷进度文案;安装器弹出后由用户完成安装 */
function downloadAndInstall(info: AppUpdateInfo): Promise<void> {
return new Promise((resolve, reject) => {
const url = absoluteFileUrl(`/FileServer/${info.apk ?? "youjiala.apk"}`);
uni.showLoading({ title: msg("AppUpdate.downloading"), mask: true });
const task = plus.downloader.createDownload(
url,
{
filename: `_downloads/update-${info.versionCode}.apk`,
retry: 1,
timeout: 300,
},
(download: { localPath?: string }, status: number) => {
uni.hideLoading();
if (status !== 200 || !download?.localPath) {
reject(new Error(`download HTTP ${status}`));
return;
}
plus.runtime.install(
plus.io.convertLocalFileSystemURL(download.localPath),
{},
() => {
// apk 分支走系统安装界面,装完重开即是新版;这里只兜底提示
uni.showToast({
title: msg("AppUpdate.installHint"),
icon: "none",
duration: 3000,
});
resolve();
},
(error: unknown) => reject(error),
);
},
);
task.addEventListener(
"statechange",
(event: {
state: number;
downloadedSize?: number;
totalSize?: number;
}) => {
const total = Number(event.totalSize ?? 0);
const done = Number(event.downloadedSize ?? 0);
// state=4 下载中:apk 十几 MB,按百分比刷新比转圈更安心
if (event.state === 4 && total > 0) {
uni.showLoading({
title: `${msg("AppUpdate.downloading")} ${Math.min(99, Math.floor((done / total) * 100))}%`,
mask: true,
});
}
},
);
task.start();
});
}
function readSkippedCode(): number {
try {
return Number(uni.getStorageSync(SKIP_KEY) ?? 0);
} catch {
return 0;
}
}
function writeSkippedCode(code: number): void {
try {
uni.setStorageSync(SKIP_KEY, code);
} catch {
// 存不下就下次还问,不阻塞
}
}
/** 并发闸门:自动/手动撞车时只跑一次 */
let checking = false;
/**
* 检查更新主流程(仅 App 生效)。
* @param manual 手动入口:失败/已最新都给提示、无视「稍后再说」记账;自动检查:静默,跳过过的版本不再弹。
*/
export async function checkAppUpdate(
manual: boolean,
): Promise<UpdateCheckResult> {
// #ifndef APP-PLUS
void manual;
return "unsupported";
// #endif
// #ifdef APP-PLUS
if (checking) {
return "skipped";
}
checking = true;
try {
if (manual) {
uni.showLoading({ title: msg("AppUpdate.checking"), mask: true });
}
const info = await fetchUpdateInfo();
const localCode = await getLocalVersionCode();
if (manual) {
uni.hideLoading();
}
if (info.versionCode <= localCode) {
if (manual) {
uni.showToast({ title: msg("AppUpdate.latest"), icon: "none" });
}
return "latest";
}
if (!manual && !info.force && readSkippedCode() === info.versionCode) {
return "skipped";
}
const confirmed = await promptUpdate(info);
if (!confirmed) {
if (info.force) {
plus.runtime.quit();
} else {
writeSkippedCode(info.versionCode);
}
return "skipped";
}
if (uni.getSystemInfoSync().platform === "ios") {
// iOS 分发受签名限制,应用内装不了 apk:提示去网页端取最新包
uni.showModal({
title: msg("AppUpdate.title", { version: info.versionName }),
content: msg("AppUpdate.iosTip"),
showCancel: false,
confirmText: msg("AppUpdate.gotIt"),
});
return "updated";
}
try {
await downloadAndInstall(info);
} catch {
uni.showToast({ title: msg("AppUpdate.downloadFailed"), icon: "none" });
return "failed";
}
return "updated";
} catch {
if (manual) {
uni.hideLoading();
uni.showToast({ title: msg("AppUpdate.checkFailed"), icon: "none" });
}
return "failed";
} finally {
checking = false;
}
// #endif
}
/** 启动自动检查的会话闸门:一次冷启动只静默查一次(手动入口不受此限) */
let autoChecked = false;
export function autoCheckAppUpdate(): void {
if (autoChecked) {
return;
}
autoChecked = true;
void checkAppUpdate(false);
}