diff --git a/uniapp/youjiala/src/App.vue b/uniapp/youjiala/src/App.vue
index 95f10a4..f492538 100644
--- a/uniapp/youjiala/src/App.vue
+++ b/uniapp/youjiala/src/App.vue
@@ -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
});
diff --git a/uniapp/youjiala/src/locale/lang/en-US.json b/uniapp/youjiala/src/locale/lang/en-US.json
index e121a83..8647d48 100644
--- a/uniapp/youjiala/src/locale/lang/en-US.json
+++ b/uniapp/youjiala/src/locale/lang/en-US.json
@@ -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",
diff --git a/uniapp/youjiala/src/locale/lang/zh-CN.json b/uniapp/youjiala/src/locale/lang/zh-CN.json
index 3df3e1f..c77ede2 100644
--- a/uniapp/youjiala/src/locale/lang/zh-CN.json
+++ b/uniapp/youjiala/src/locale/lang/zh-CN.json
@@ -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": "服务器刚重启、菜单还没加载出来,刷新一次即可"
}
-}
+}
\ No newline at end of file
diff --git a/uniapp/youjiala/src/manifest.json b/uniapp/youjiala/src/manifest.json
index b5106d2..9ee035c 100644
--- a/uniapp/youjiala/src/manifest.json
+++ b/uniapp/youjiala/src/manifest.json
@@ -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" : "感谢你使用“有家相册”。我们非常重视你的个人信息保护,请你务必审慎阅读、充分理解《服务协议》和《隐私政策》各条款,包括但不限于:为向你提供账号注册登录、家庭相册、家族谱系与文件共享服务,我们会在你授权同意后收集你的手机号、头像、身份证照片(仅实名核验用)、你主动上传的照片/视频/文件以及必要的设备标识与操作日志等信息。
你可阅读《服务协议》和《隐私政策》了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
- "buttonAccept" : "同意并接受",
- "buttonRefuse" : "暂不同意",
- "second" : {
- "title" : "确认提示",
- "message" : "进入应用前,你需先同意《服务协议》和《隐私政策》,否则将退出应用。",
- "buttonAccept" : "同意并继续",
- "buttonRefuse" : "退出应用"
- }
- }
- },
- "distribute" : {
- "android" : {
- "permissions" : [
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- ""
- ]
- },
- "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": "感谢你使用“有家相册”。我们非常重视你的个人信息保护,请你务必审慎阅读、充分理解《服务协议》和《隐私政策》各条款,包括但不限于:为向你提供账号注册登录、家庭相册、家族谱系与文件共享服务,我们会在你授权同意后收集你的手机号、头像、身份证照片(仅实名核验用)、你主动上传的照片/视频/文件以及必要的设备标识与操作日志等信息。
你可阅读《服务协议》和《隐私政策》了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
+ "buttonAccept": "同意并接受",
+ "buttonRefuse": "暂不同意",
+ "second": {
+ "title": "确认提示",
+ "message": "进入应用前,你需先同意《服务协议》和《隐私政策》,否则将退出应用。",
+ "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": [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+ },
+ "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": {}
+ }
+ }
+}
\ No newline at end of file
diff --git a/uniapp/youjiala/src/md/app-update.md b/uniapp/youjiala/src/md/app-update.md
new file mode 100644
index 0000000..ff2536b
--- /dev/null
+++ b/uniapp/youjiala/src/md/app-update.md
@@ -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 并守版本纪律。
+- 若将来引入应用商店分发或需要灰度/最低版本策略,本文口径作废重议。
diff --git a/uniapp/youjiala/src/pages/tab/mine.vue b/uniapp/youjiala/src/pages/tab/mine.vue
index ccffd61..3202478 100644
--- a/uniapp/youjiala/src/pages/tab/mine.vue
+++ b/uniapp/youjiala/src/pages/tab/mine.vue
@@ -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 {
+
+
+
+
+
+
+
+ {{ t("MinePage.checkUpdate") }}
+
+
+ v{{ APP_INFO.version }}
+
+
+
+
+
diff --git a/uniapp/youjiala/src/utils/appUpdate.ts b/uniapp/youjiala/src/utils/appUpdate.ts
new file mode 100644
index 0000000..1c0fa9c
--- /dev/null
+++ b/uniapp/youjiala/src/utils/appUpdate.ts
@@ -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 {
+ 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 | 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 {
+ 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 | 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 {
+ return new Promise((resolve) => {
+ plus.runtime.getProperty(
+ plus.appId,
+ (info: { versionCode?: string | number }) => {
+ resolve(Number(info?.versionCode ?? 0));
+ },
+ );
+ });
+}
+
+/** 更新弹窗:普通版可「稍后再说」,强更版右钮换「退出应用」(点它即 quit) */
+function promptUpdate(info: AppUpdateInfo): Promise {
+ 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 {
+ 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 {
+ // #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);
+}