feat(宿主恢复): 换应用上报 last_app + 首屏恢复上次 H5 应用 (v26092201)

需求:小程序里进入某个 H5 应用后退出小程序,再次冷启动要恢复那个应用。

- jumpApp / goArchiveH5 跳转前 reportLastApp():navigator.sendBeacon 上报**净地址**
  (实时、由浏览器保证投递,不依赖微信 postMessage 的延迟批量派发)
- init() 首屏 resumeLastApp():sessionStorage 一次性闸门内 GET /api/mp/public/last-app,
  命中就带当前 openid/unionid location.replace 过去 —— 冷启动才恢复,
  同一会话切回首页 tab / 宿主 ?t= 重载都不会再跳;replace 不留 history、back 不会来回弹
- 宫格 / 工具卡 / 宝宝来信 / 孕检日历入口补 identifier,便于服务端在架校验
- 抽出 withIdentity(),jumpApp 行为不变(身份仍走 query,不追加 hash)
- 版本 v26092009 → v26092201
This commit is contained in:
2026-09-22 05:11:24 +08:00
parent 3b70f63dce
commit 8c6b18c5e1
2 changed files with 90 additions and 13 deletions

View File

@@ -7,7 +7,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>兜爸心选 · 孕育陪伴</title>
<!-- V:26092009 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 -->
<!-- V:26092201 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 -->
<link rel="stylesheet" href="mama-box.css">
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<!-- 共享助手:授权/跳商城/位置上报(分享卡片同步,见 docs/h5-auth-standard.md 第四节) -->

101
js/app.js
View File

@@ -3,7 +3,7 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26092009';
var VER = '26092201';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -150,6 +150,65 @@
if (window.wxAuth && window.wxAuth.reportLocation) window.wxAuth.reportLocation({ href: url });
}
/* ================= H5 宿主恢复:冷启动回到上次进入的 H5 应用(2026-09-22) =================
需求:在小程序里进入某个 H5 应用后退出小程序,再次进入要恢复那个应用。
数据源:box_app GET /api/mp/public/last-app?openid=(Redis,per-openid,7 天 TTL)
· 写入 last_app:jumpApp / goArchiveH5 换应用前 reportLastApp()(sendBeacon,实时可靠)
· 读取恢复:resumeLastApp() 在 init() 首屏跑一次,命中就 location.replace 过去
闸门:sessionStorage 一次 —— 冷启动(web-view 上下文新建)才恢复;
同一会话内宿主 onShow 的 ?t= 重载 / 用户切回首页 tab 都不会再跳。
⚠️ 用 replace 不用 href:不留 history 条目,back 不会来回弹。
⚠️ 只上报/恢复「净地址」(不带 openid/unionid):身份必须按**当前**用户重新注入,
绝不能把记录那一刻的身份带过去(服务端也会再 sanitize 一遍兜底)。
⚠️ 服务端有 App 表在架校验,所以恢复不到已下架的应用;Redis 挂掉时读到空 → 留在首页。 */
const LAST_APP_API = '/api/mp/public/last-app';
const RESUME_DONE_KEY = 'dsh_resume_done';
function urlBase(u) {
return String(u || '').split('#')[0].split('?')[0].replace(/\/+$/, '');
}
/* 换应用前上报「即将进入的 H5 应用」。
⚠️ sendBeacon:这一步后面紧跟 location.href 整页跳转,普通 fetch 会被取消;
sendBeacon 不阻塞导航且由浏览器保证投递(同源 POST,参数走 query,
因为 sendBeacon 发的是 text/plain,带 JSON body 会被 FastAPI 拒)。 */
function reportLastApp(url, identifier) {
try {
if (!url) return;
const oid = hashParam('openid');
if (!oid) return; // 没有身份键,服务端无法落到 per-openid 记录:跳过(不影响跳转)
const qs = ['openid=' + encodeURIComponent(oid), 'url=' + encodeURIComponent(url)];
if (identifier) qs.push('identifier=' + encodeURIComponent(identifier));
const target = LAST_APP_API + '?' + qs.join('&');
if (navigator.sendBeacon && navigator.sendBeacon(target)) return;
if (window.fetch) { fetch(target, { method: 'POST', keepalive: true }).catch(function () {}); return; }
} catch (e) { try { console.warn('[resume] reportLastApp fail:', e); } catch (e2) {} }
}
/* 首屏恢复:读一次 last_app,命中就带着当前身份 replace 过去。 */
function resumeLastApp() {
try {
if (sessionStorage.getItem(RESUME_DONE_KEY)) return; // 同一 web-view 会话只尝试一次
const oid = hashParam('openid');
if (!oid) return; // 无身份不跳(宿主拿不到 openid 时也不会加载本页)
/* 先置闸门再请求:GET 失败/超时也不在本次会话里重试 ——
宁可少恢复一次,也不要「用户切回首页 tab 时被突然跳走」。 */
sessionStorage.setItem(RESUME_DONE_KEY, '1');
fetch(LAST_APP_API + '?openid=' + encodeURIComponent(oid))
.then(r => (r.ok ? r.json() : null))
.then(d => {
const rec = d && d.result;
if (!rec || !rec.url) return;
if (urlBase(rec.url) === urlBase(location.href)) return; // 防呆:记录指向本页自身就不跳
const target = withIdentity(rec.url); // 用当前 openid/unionid 重新注入
console.log('[resume] 恢复上次 H5 应用:', rec.identifier || rec.url);
reportHostLocation(target); // 让宿主分享卡片同步到目标地址
location.replace(target);
})
.catch(function () {});
} catch (e) {}
}
function goArchiveH5() {
const oid = hashParam('openid');
const uid = hashParam('unionid');
@@ -158,6 +217,7 @@
if (oid) q.push('openid=' + encodeURIComponent(oid));
if (uid) q.push('unionid=' + encodeURIComponent(uid));
if (q.length) u += '?' + q.join('&');
reportLastApp('https://mp.yizhezhe.com/chanjian-rili/', 'chanjianrili');
reportHostLocation(u);
location.href = u;
}
@@ -181,12 +241,11 @@
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
/* 跳转到其它 H5 应用:身份只走 query(openid/unionid,原 URL 有 query 用 & 续接)。
/* 给目标 H5 拼上当前身份(openid/unionid,query 形态)。
⚠️ 不再追加 #openid —— 部分应用自带 hash 路由(如 /baobao-yingji),追加 hash 会被当
非法路由 → 页面未找到。宿主每次加载/onShow 会重新注入身份兜底;
hash 仅保留作为「授权回传结果」(#phone/nickname/avatar)的通道。 */
function jumpApp(url) {
haptic();
function withIdentity(url) {
const oid = hashParam('openid');
const uid = hashParam('unionid');
const q = [];
@@ -198,9 +257,16 @@
· nginx/后端日志里 __no_identity=1 一眼可查(排查 openid 为空时很快)。
排查背景见 docs/mama-box-change-list-20260919.md 与 showIdNotice() 的注释。 */
if (!oid && !uid) q.push('__no_identity=1');
if (q.length) url += (url.indexOf('?') >= 0 ? '&' : '?') + q.join('&');
reportHostLocation(url);
location.href = url;
return q.length ? url + (url.indexOf('?') >= 0 ? '&' : '?') + q.join('&') : url;
}
/* 跳转到其它 H5 应用。identifier 可选(来自 App 表),用于 last_app 上报的服务端在架校验;
缺省时服务端按 URL 前缀反查,效果一致。 */
function jumpApp(url, identifier) {
haptic();
const target = withIdentity(url);
reportLastApp(url, identifier); // 净地址:恢复时身份按当前用户重新注入
reportHostLocation(target);
location.href = target;
}
function renderAppGrid(apps) {
const grid = $('#appsGrid');
@@ -212,12 +278,12 @@
⚠️ colorTheme 目前全是 'blue' 颜色名 —— 有图时别拿它当底色,否则清一色蓝。 */
const bg = ico ? 'background:#fff' : 'background:' + (a.colorTheme || appTileColor(name));
const img = ico ? '<img class="a-ico" src="' + ico + '" alt="" loading="lazy">' : '<span class="a-ico a-ico-letter" style="background:' + bg + '">' + name.slice(0, 1) + '</span>';
return '<button class="app ripple" data-url="' + encodeURIComponent(a.url) + '">' + img + name + '</button>';
return '<button class="app ripple" data-url="' + encodeURIComponent(a.url) + '" data-id="' + encodeURIComponent(a.identifier || '') + '">' + img + name + '</button>';
}).join('');
/* 磁贴点击(事件委托,innerHTML 重建后不用重绑) */
grid.onclick = function (e) {
const t = e.target.closest('.app');
if (t && t.dataset.url) jumpApp(decodeURIComponent(t.dataset.url));
if (t && t.dataset.url) jumpApp(decodeURIComponent(t.dataset.url), decodeURIComponent(t.dataset.id || ''));
};
/* 角标「共 N 个」同步真实数量 */
const cnt = $('.apps-head.for-all .sec-more');
@@ -504,7 +570,7 @@
$$('[data-mini]').forEach(b => b.addEventListener('click', () => {
const map = { kick: '数胎动', weight: '孕期体重', cal: '孕检日历', curve: '数宫缩', fweight: '胎儿估重' };
const app = APP_BY_ID[MINI_APP_MAP[b.dataset.mini]];
if (app && app.url) { jumpApp(app.url); return; }
if (app && app.url) { jumpApp(app.url, app.identifier); return; }
toast('「' + map[b.dataset.mini] + '」即将上线');
}));
@@ -2425,6 +2491,9 @@
/* ================= 初始化 ================= */
function init() {
/* 冷启动恢复:读一次 last_app,命中就跳到上次进入的 H5 应用(会话内只尝试一次,
见 resumeLastApp)。放在最前面,尽量少闪一下首页。 */
resumeLastApp();
/* 首屏向小程序宿主上报当前位置:宿主据此解析分享卡片(见 reportHostLocation 说明)。 */
if (window.wxAuth && window.wxAuth.reportLocation) window.wxAuth.reportLocation();
/* 默认预产期是「今天 + 3 个月」现算出来的,标记里那些
@@ -2549,6 +2618,14 @@
return;
}
const base = '/api/mp/public/mama-box/';
/* ⚠️ profile 拉取要带上 unionid(2026-09-21 排查「首页个人信息为空」):
服务端「先取用户中心」需要 unionid —— 只有 openid 时走本地 openid→unionid
映射(Member),映射缺失的老用户(资料全靠 CRMEB 同步来的)会先取失败,
预产期/孕周永远预填不上,而第三方应用因为 URL 自带 unionid 一直有数据。
与 footprint/today 同款:hash 里有就带上,服务端成功后会回写映射。 */
const _pfUnionid = hashParam('unionid');
const _pfQs = 'openid=' + encodeURIComponent(openid) +
(_pfUnionid ? '&unionid=' + encodeURIComponent(_pfUnionid) : '');
/* 并行拉 个人档案 + 孕期状态:
- profile:babyName/userName/babySex/dueDate(身份 + 建档字段)
- pregnancy:week/day/due 以服务端权威派生为准(避免客户端重复算、口径统一)
@@ -2556,7 +2633,7 @@
/* 走到这里说明这次加载是带身份的(openid 非空)→ 把上一次的提示收掉 */
hideIdNotice();
Promise.all([
fetch(base + 'profile?openid=' + encodeURIComponent(openid)).then(r => (r.ok ? r.json() : null)),
fetch(base + 'profile?' + _pfQs).then(r => (r.ok ? r.json() : null)),
fetch(base + 'pregnancy?openid=' + encodeURIComponent(openid)).then(r => (r.ok ? r.json() : null))
]).then(function (rs) {
const pj = rs[0], pg = rs[1];
@@ -2809,7 +2886,7 @@
const app = APP_BY_ID['laixin'];
const url = (app && app.url) ? app.url : LAIXIN_FALLBACK_URL;
if (!url) { toast('「宝宝来信」即将上线'); return; }
jumpApp(url);
jumpApp(url, (app && app.identifier) || 'laixin');
}
$('#tabLetter').addEventListener('click', openLetterApp);
/* 首页那张「宝宝来信」卡与底 tab 是**同一个入口**(2026-09-20):