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):