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); +}