diff --git a/index.html b/index.html
index a49a778..f21f19f 100644
--- a/index.html
+++ b/index.html
@@ -7,7 +7,7 @@
兜爸心选 · 孕育陪伴
-
+
diff --git a/js/app.js b/js/app.js
index 4a6f31e..387345d 100644
--- a/js/app.js
+++ b/js/app.js
@@ -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 ? '
' : '' + name.slice(0, 1) + '';
- return '';
+ return '';
}).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):