refactor: OTA 热更新抽离为公共模块 utils/ota.js

原先 checkUpdate / parseVersionCode / downloadAndInstall / downloadWgt /
installWgt / otaFailed 整个长在 App.vue 的 methods 里,个人中心「设置 →
检查更新」想手动触发一次根本做不到。抽到 utils/ota.js 后两处共用同一份
实现,改版本比对规则或下载策略只需改一处。

- 节流状态从 this._lastUpdateCheck 改为模块级单例
- 新增 checkAppUpdate({ manual }),manual 模式跳过节流、显示「检查中」、
  并在「已是最新 / 检查失败」时给出明确反馈 —— 用户主动点的按钮必须有回音,
  自动检查才静默收场
- App.vue 的 onLaunch/onShow 改为调用同一函数,自动模式行为不变

同时 App.vue 的全局样式新增 .success-badge-wrapper / .success-badge
(上传成功绿勾角标,供各页面图片列表复用):
- 直角三角形用 border-top(实色) + border-left(透明),直角落在右上;
  写成 border-top + border-right 会得到左上角的三角,方向是反的
- 白勾用两条边框旋转 -45° 画成,不用 "✓" 字符 —— 该字形在安卓与 iOS 上
  的字形和基线差异很大,对不齐
- 容器 overflow:hidden 只包图片本身,不能包住单元格:各页面的删除按钮
  .img-del 定位在 -12rpx 处(单元格之外),包进去会被一并裁掉
This commit is contained in:
2026-09-15 15:51:38 +08:00
parent 44ca09ae22
commit eef4e5b72d
2 changed files with 456 additions and 111 deletions

View File

@ -1,5 +1,6 @@
<script>
import { getNotifications } from "./api/notification";
import { checkAppUpdate } from "./utils/ota";
export default {
onLaunch() {
@ -14,9 +15,9 @@ export default {
}
// 已登录 → 原地渲染 scan 页,不需任何跳转
// 🚀 OTA 热更新检测(仅 App 端生效)
// 🚀 OTA 热更新检测(仅 App 端生效;实现见 utils/ota.js,设置页手动检查共用同一份)
// #ifdef APP-PLUS
this.checkUpdate();
checkAppUpdate();
// #endif
},
onShow() {
@ -24,121 +25,13 @@ export default {
this.updateTabBarBadge();
// 🚀 每次回到前台也检查更新,用户无需杀后台就能感知新版本
// #ifdef APP-PLUS
this.checkUpdate();
checkAppUpdate();
// #endif
},
onHide() {
console.log("App 隐藏");
},
methods: {
// ==========================================================
// OTA 热更新雷达 — 版本检测 + WGT 下载 + 静默安装
// ==========================================================
checkUpdate() {
// 🚀 节流:5分钟内不重复检查,避免 onShow 频繁触发弹窗骚扰
const now = Date.now();
if (this._lastUpdateCheck && now - this._lastUpdateCheck < 5 * 60 * 1000) {
console.log("[OTA] 距上次检查不足5分钟,跳过");
return;
}
this._lastUpdateCheck = now;
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
const sysInfo = uni.getSystemInfoSync();
const wgtVer = sysInfo.appWgtVersion || sysInfo.appVersion || "0";
const currentVersionCode = this.parseVersionCode(wgtVer);
const baseUrl = uni.getStorageSync("env_base_url") || "http://track_back.iris-rs.cn/api/v1";
console.log("[OTA] 本地版本:", wgtVer, "→ 数字:", currentVersionCode);
uni.request({
url: `${baseUrl}/app/check-update`,
method: "GET",
timeout: 8000,
success: (res) => {
if (res.statusCode !== 200) return;
const data = res.data;
if (!data || !data.wgt_url) {
console.log("[OTA] 服务端无可用更新包");
return;
}
const serverVersionCode = data.version_code || 0;
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
// 客户端自行对比:服务端 > 本地 = 需要更新
if (serverVersionCode <= currentVersionCode) {
console.log("[OTA] 已是最新版本,无需更新");
return;
}
console.log("[OTA] 发现新版本:", data.version);
this.downloadAndInstall(data.wgt_url, data.version, data.description);
},
fail: () => {
console.log("[OTA] 版本检测网络失败,跳过");
},
});
},
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
parseVersionCode(v) {
if (!v) return 0;
const nums = v.match(/\d+/g);
if (!nums || nums.length < 2) return 0;
return parseInt(nums[0], 10) * 100 + parseInt(nums[nums.length - 1], 10);
},
downloadAndInstall(wgtUrl, newVersion, description) {
if (!wgtUrl) {
console.log("[OTA] 无 WGT 下载地址");
return;
}
const content = description
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
: `发现新版本 ${newVersion},是否立即更新?`;
uni.showModal({
title: "版本更新",
content,
confirmText: "立即更新",
cancelText: "稍后再说",
success: (modalRes) => {
if (!modalRes.confirm) return;
// 🚀 静默更新:无进度条、无 toast、不监听 onProgressUpdate
uni.downloadFile({
url: wgtUrl,
success: (downloadRes) => {
if (downloadRes.statusCode !== 200) {
console.error("[OTA] 下载失败, statusCode:", downloadRes.statusCode);
return;
}
plus.runtime.install(
downloadRes.tempFilePath,
{ force: true },
() => {
console.log("[OTA] 安装成功,3秒后自动重启");
setTimeout(() => {
plus.runtime.restart();
}, 3000);
},
(err) => {
console.error("[OTA] 安装失败:", err.message);
}
);
},
fail: (err) => {
console.error("[OTA] 下载失败:", err.errMsg);
},
});
},
});
},
// ==========================================================
// TabBar 消息红点
// ==========================================================
@ -177,4 +70,56 @@ page {
font-size: 14px;
color: #1f2937;
}
/* ============================================================
上传成功角标 —— 图片缩略图右上角「绿底白勾」
============================================================
用法:给图片的【直接父容器】加 .success-badge-wrapper,
容器内放 <image> 和一个空的 <view class="success-badge" />,
后者仅在确认该图已上传成功时才渲染。
⚠️ 容器只能包住图片本身,不要连删除按钮一起包 —— overflow:hidden 会把
定位在单元格外的 ✕ 按钮一并裁掉(各页面的 .img-del 都在 -12rpx 处)。
============================================================ */
.success-badge-wrapper {
position: relative;
overflow: hidden;
border-radius: 12rpx; /* 与缩略图圆角一致,负责裁剪贴边的角标 */
}
/* 直角三角形本体:border-top 实色 + border-left 透明,直角落在右上角。
注意别写成 border-top + border-right —— 那样得到的直角在左上,方向是反的。 */
.success-badge {
position: absolute;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
z-index: 2;
pointer-events: none; /* 不遮挡图片本身的点击(预览) */
}
.success-badge::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-top: 40rpx solid #67c23a;
border-left: 40rpx solid transparent;
}
/* 白色对勾:用两条边框旋转 -45° 画出来,不依赖字体。
直接用 "✓" 字符的话,安卓与 iOS 的字形和基线差异很大,对不齐。 */
.success-badge::after {
content: "";
position: absolute;
top: 9rpx;
right: 7rpx;
width: 12rpx;
height: 7rpx;
border-left: 3rpx solid #ffffff;
border-bottom: 3rpx solid #ffffff;
transform: rotate(-45deg);
}
</style>

View File

@ -0,0 +1,400 @@
/**
* OTA 热更新 — 版本检测 + WGT 下载 + 安装。App 端专属能力的唯一来源。
*
* 原先这套逻辑整个长在 App.vue 的 methods 里(checkUpdate / parseVersionCode /
* downloadAndInstall / downloadWgt / installWgt / otaFailed),个人中心的
*「设置 → 检查更新」想手动触发一次就得把 App.vue 的方法捞出来,做不到。
* 抽到这里后两处共用同一份实现,改版本比对规则或下载策略只需改这里。
*
* 调用方:
* - App.vue onLaunch / onShow 静默自动检查(受 5 分钟节流)
* - settings.vue 「检查更新」手动检查(manual = true,跳过节流并给出明确反馈)
*/
import { getBaseUrl } from "./request";
/** 自动检查的节流窗口:5 分钟内不重复检查,避免 onShow 频繁触发弹窗骚扰 */
const THROTTLE_MS = 5 * 60 * 1000;
/** 上次检查时间戳(模块级单例,等价于原 App.vue 的 this._lastUpdateCheck) */
let lastCheckAt = 0;
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
export function parseVersionCode(v) {
if (!v) return 0;
const nums = v.match(/\d+/g);
if (!nums || nums.length < 2) return 0;
return parseInt(nums[0], 10) * 100 + parseInt(nums[nums.length - 1], 10);
}
/**
* 检查是否有新版本。
*
* @param {{manual?: boolean}} [opts]
* manual = true 时(设置页手动触发):跳过节流、显示「检查中」、
* 并在「已是最新 / 检查失败」时给出明确提示 —— 用户主动点的按钮
* 必须有个回音,不能像自动检查那样静默收场。
* @returns {Promise<"latest"|"available"|"skipped"|"unsupported"|"error">}
*/
export async function checkAppUpdate({ manual = false } = {}) {
// #ifndef APP-PLUS
// 非 App 环境(H5 / 小程序)没有 plus.runtime,热更新无从谈起
if (manual) {
uni.showToast({ title: "当前环境不支持热更新", icon: "none", duration: 2500 });
}
return "unsupported";
// #endif
// #ifdef APP-PLUS
const now = Date.now();
if (!manual && lastCheckAt && now - lastCheckAt < THROTTLE_MS) {
console.log("[OTA] 距上次检查不足5分钟,跳过");
return "skipped";
}
lastCheckAt = now;
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
//
// ⚠️ 这两个字段必须分开看,别只看合并后的值:
// appWgtVersion = 已安装的 wgt 资源版本(热更新成功后会变)
// appVersion = APK 基座版本(重装多少次热更新都不会变)
// 若 appWgtVersion 为空,说明热更新从未装成功过,此时取到的是 APK 的
// 静态版本号,表现就是"更新提示反复弹、版本号永远不动"。
const sysInfo = uni.getSystemInfoSync();
const wgtVersion = sysInfo.appWgtVersion || "";
const apkVersion = sysInfo.appVersion || "";
const wgtVer = wgtVersion || apkVersion || "0";
const currentVersionCode = parseVersionCode(wgtVer);
// 🔧 复用 getBaseUrl():与业务请求永远指向同一个后端(含 App 内动态切换的环境)
const baseUrl = getBaseUrl();
console.log(
"[OTA] wgt版本:", wgtVersion || "(空,说明热更新从未装成功)",
"| APK版本:", apkVersion,
"→ 取用:", wgtVer, "=", currentVersionCode
);
if (manual) uni.showLoading({ title: "检查更新中...", mask: true });
return new Promise((resolve) => {
uni.request({
url: `${baseUrl}/app/check-update`,
method: "GET",
timeout: 8000,
success: (res) => {
if (res.statusCode !== 200) {
if (manual) {
uni.hideLoading();
uni.showToast({ title: `检查更新失败 (${res.statusCode})`, icon: "none" });
}
resolve("error");
return;
}
const data = res.data;
if (!data || !data.wgt_url) {
console.log("[OTA] 服务端无可用更新包");
if (manual) {
uni.hideLoading();
uni.showToast({ title: "当前已是最新版本", icon: "none" });
}
resolve("latest");
return;
}
const serverVersionCode = data.version_code || 0;
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
// 客户端自行对比:服务端 > 本地 = 需要更新
if (serverVersionCode <= currentVersionCode) {
console.log("[OTA] 已是最新版本,无需更新");
if (manual) {
uni.hideLoading();
uni.showToast({ title: "当前已是最新版本", icon: "none" });
}
resolve("latest");
return;
}
console.log("[OTA] 发现新版本:", data.version);
if (manual) uni.hideLoading();
downloadAndInstall(data.wgt_url, data.version, data.description);
resolve("available");
},
fail: (err) => {
console.log("[OTA] 版本检测网络失败,跳过");
if (manual) {
uni.hideLoading();
uni.showToast({
title: err && err.errMsg && /timeout/i.test(err.errMsg) ? "检查更新超时" : "网络连接失败",
icon: "none",
});
}
resolve("error");
},
});
});
// #endif
}
function downloadAndInstall(wgtUrl, newVersion, description) {
if (!wgtUrl) {
console.log("[OTA] 无 WGT 下载地址");
return;
}
if (typeof plus === "undefined") {
console.log("[OTA] 非 App 环境,跳过安装");
return;
}
const content = description
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
: `发现新版本 ${newVersion},是否立即更新?`;
uni.showModal({
title: "版本更新",
content,
confirmText: "立即更新",
cancelText: "稍后再说",
success: (modalRes) => {
if (!modalRes.confirm) return;
downloadWgt(wgtUrl, newVersion);
},
});
}
/**
* 下载 WGT 更新包。
*
* 为什么不用 uni.downloadFile(旧实现的坑):
* plus.runtime.install 是**靠文件扩展名**区分 wgt / apk 的,而
* uni.downloadFile 返回的 tempFilePath 不保证带 `.wgt` 后缀,路径不对时
* 安装会被直接拒绝 —— 且旧代码失败只打 console.error,工人只看到
* 「点了立即更新没反应」。这里改用 plus.downloader 并显式指定带后缀的
* 落盘路径;写到 _doc/(应用私有目录)也顺带避开 Android 10+ 分区存储限制。
* (注意:本函数走的是 plus.downloader,不是 uni.downloadFile —— 后者没有
* 可用的落盘路径控制,正是当初被换掉的原因。)
*
* ⚠️ 两道内容校验的由来("invalid LOC header" 事故):
* plus.runtime.install 拿到文件就按 ZIP 解压,文件不是合法 wgt 时抛的是
* "invalid LOC header (bad signature)" 这种底层报错,工人只看到一句看不懂的
* 英文。而拿到非 wgt 内容的情形并不罕见:静态服务器把 404 配成返回首页、
* 反向代理吐 JSON 错误体、SSO 把请求 302 到登录页 —— 这些**都可能带着
* HTTP 200 回来**,只判断 status === 200 根本拦不住。
* 故:下载前探测响应头,下载后校验 ZIP 魔数,两道都过了才交给 install。
*/
async function downloadWgt(wgtUrl, newVersion) {
// ── 第一道:下载前探测(状态码 / Content-Type / 体积)──
// 目的是在浪费流量之前就拦下明显的错误页,并给出人话错误
const probeError = await probeWgtUrl(wgtUrl);
if (probeError) {
otaFailed(probeError);
return;
}
// 文件名必须带 .wgt 后缀 —— 这是 install 能识别为 wgt 资源包的前提。
// 从下载地址取文件名(去掉 query),拿不到合法后缀时退回默认名。
const rawName = String(wgtUrl).split("?")[0].split("/").pop() || "";
const fileName = /\.wgt$/i.test(rawName) ? rawName : "__UNI__B572616.wgt";
const savePath = "_doc/" + fileName;
console.log("[OTA] 开始下载:", wgtUrl, "→", savePath);
let lastPct = -1;
let task = null;
const onFinished = async (d, status) => {
uni.hideLoading();
console.log("[OTA] 下载结束, status =", status, "| 文件:", d && d.filename);
// 非 2xx 一律当失败:404=包没传上去,401/403=下载地址需要鉴权
if (status !== 200) {
const hint =
status === 404 ? "更新包不存在,请联系管理员确认是否已上传"
: status === 401 || status === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
: "请稍后重试";
otaFailed(`更新包下载失败(HTTP ${status}):${hint}`);
return;
}
// 没拿到落盘路径就没法安装 —— 明确报出来,别让它以"没反应"收场
if (!d || !d.filename) {
otaFailed("下载已完成但未生成本地文件,请重试");
return;
}
// 截断检测:服务端中途断开时 status 也可能是 200,但文件是不完整的
if (d.totalSize > 0 && d.downloadedSize > 0 && d.downloadedSize < d.totalSize) {
otaFailed(`更新包下载不完整(${d.downloadedSize}/${d.totalSize} 字节),请重试`);
return;
}
// ── 第二道:ZIP 魔数校验(拦下所有「HTTP 200 但内容不是 wgt」的情况)──
const contentError = await verifyWgtFile(d.filename);
if (contentError) {
otaFailed(contentError);
return;
}
installWgt(d.filename, newVersion);
};
try {
task = plus.downloader.createDownload(wgtUrl, { filename: savePath }, onFinished);
// 进度反馈:每跨 10% 刷新一次提示(过于频繁会卡顿)
task.addEventListener("statechanged", (d) => {
if (!d || d.state !== 3) return; // 3 = 下载中
if (!d.totalSize || d.totalSize <= 0) return;
const pct = Math.floor((d.downloadedSize / d.totalSize) * 100);
if (pct >= lastPct + 10) {
lastPct = pct - (pct % 10);
uni.showLoading({ title: `下载中 ${pct}%`, mask: true });
}
});
} catch (e) {
console.error("[OTA] 创建下载任务异常:", e);
otaFailed("无法创建下载任务:" + ((e && e.message) || "未知原因"));
return;
}
uni.showLoading({ title: "下载中 0%", mask: true });
task.start();
}
/**
* 下载前探测更新包地址 —— 在消耗流量之前拦下「错误页」。
*
* 用 HEAD 只取响应头,不下载正文。核对三件事:
* 1. statusCode 必须是 200(404 / 401 / 403 直接判失败)
* 2. Content-Type 不能是 JSON / HTML —— 那说明拿回来的是错误页而不是安装包
* 3. Content-Length 不能小得离谱(合法 wgt 不可能只有几百字节)
*
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
*/
function probeWgtUrl(wgtUrl) {
return new Promise((resolve) => {
uni.request({
url: wgtUrl,
method: "HEAD",
timeout: 8000,
success: (res) => {
const header = res.header || {};
const contentType = String(header["Content-Type"] || header["content-type"] || "");
const contentLength = Number(header["Content-Length"] || header["content-length"] || 0);
if (res.statusCode !== 200) {
resolve(
res.statusCode === 404 ? "更新包不存在,请联系管理员确认是否已上传"
: res.statusCode === 401 || res.statusCode === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
: `更新包地址不可访问(HTTP ${res.statusCode})`
);
return;
}
if (/json|html|text\/plain/i.test(contentType)) {
resolve(`更新包地址返回的是 ${contentType},不是安装包 —— 请检查服务端配置`);
return;
}
if (contentLength > 0 && contentLength < 1024) {
resolve(`更新包体积异常(仅 ${contentLength} 字节),疑似错误页面`);
return;
}
// Content-Type / Content-Length 都可能被服务端省略,此处只做「明显不对」的拦截
console.log("[OTA] 预检通过 | Content-Type:", contentType || "(未返回)", "| 长度:", contentLength || "(未返回)");
resolve("");
},
fail: (err) => {
// HEAD 不被支持(部分静态服务器返回 405)或探测请求本身超时:
// 不阻断下载 —— 还有第二道魔数校验兜底,没必要因此卡死更新
console.warn("[OTA] 预检请求失败,跳过预检,改由下载后校验兜底:", err && err.errMsg);
resolve("");
},
});
});
}
/**
* 校验已下载的文件确实是 WGT —— 读取文件头 4 字节比对 ZIP 魔数。
*
* WGT 就是 ZIP 包,合法文件必定以 "50 4B 03 04" 开头(ASCII 即 "PK\x03\x04")。
* 这正是 install 报 "invalid LOC header (bad signature)" 时会去校验的东西,
* 我们提前自己验一遍,就能把底层报错换成一句人话。
*
* 实现上用 readAsDataURL 而非读二进制:base64 后 4 字节是 "UEsD",比对前缀
* 即可,避开了 plus.io.FileReader 对二进制读取支持不一致的问题。
*
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
*/
function verifyWgtFile(filePath) {
return new Promise((resolve) => {
try {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry) => {
entry.file(
(file) => {
// slice 不可用的老机型上放弃校验(不阻断更新),交给 install 自己报错
if (typeof file.slice !== "function") {
console.warn("[OTA] 当前环境不支持文件切片,跳过魔数校验");
resolve("");
return;
}
const reader = new plus.io.FileReader();
reader.onloadend = (e) => {
const result = String((e.target && e.target.result) || "");
if (/;base64,UEsD/.test(result)) {
console.log("[OTA] 更新包文件头校验通过 (ZIP signature OK)");
resolve("");
} else {
resolve("下载到的文件不是有效的更新包(文件头异常),请联系管理员重新打包上传");
}
};
reader.onerror = () => {
console.warn("[OTA] 读取更新包文件头失败,跳过校验");
resolve("");
};
reader.readAsDataURL(file.slice(0, 4));
},
() => resolve("")
);
},
() => resolve("")
);
} catch (e) {
console.warn("[OTA] 魔数校验异常,跳过:", e);
resolve("");
}
});
}
/** 安装已下载的 WGT —— 成败都要在界面上说清楚 */
function installWgt(filePath, newVersion) {
console.log("[OTA] 开始安装:", filePath);
uni.showLoading({ title: "安装中...", mask: true });
plus.runtime.install(
filePath,
{ force: true },
() => {
uni.hideLoading();
console.log("[OTA] 安装成功:", newVersion, "→ 即将重启");
uni.showToast({ title: "更新完成,即将重启", icon: "none", duration: 2500 });
setTimeout(() => {
plus.runtime.restart();
}, 2500);
},
(err) => {
uni.hideLoading();
console.error("[OTA] 安装失败:", JSON.stringify(err));
otaFailed(
`更新包安装失败:${(err && (err.message || err.code)) || "未知原因"}`
);
}
);
}
/** 更新失败必须在界面上说出来 —— 只打日志等于让工人对着「点了没反应」干瞪眼 */
function otaFailed(msg) {
uni.showModal({
title: "更新失败",
content: `${msg}\n\n请确认网络正常后重试;若反复失败请联系管理员。`,
showCancel: false,
confirmText: "知道了",
});
}