/* ================= 兜爸心选 H5 · 交互 ================= */ (function () { 'use strict'; /* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */ var VER = '26110237'; const $ = (s, c) => (c || document).querySelector(s); const $$ = (s, c) => Array.from((c || document).querySelectorAll(s)); /* ---------- 孕期日期算术 ---------- 这一小块原来长在下面「孕期信息」那一节里(DOM 事件旁边)。挪到最前面, 是因为默认预产期和孕周要在脚本一加载就算出来(下面「孕期信息」和 AI 小结 的日期推算都在用)。 它们全是纯数学,一个 DOM 都不碰,放最前面不会有副作用; 留在原地则 state.week 只能先塞个占位数,等 init() 再补 —— 中间那段时间 任何按孕周算日期的代码拿到的都是假数据。 */ const DAY_MS = 86400000, TERM = 280; // 足月 40 周 = 280 天 const midnight = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }; const clampGA = g => Math.max(0, Math.min(42 * 7, g)); const gaFromDue = due => clampGA(TERM - Math.round((midnight(due) - midnight(new Date())) / DAY_MS)); const dueFromGA = ga => new Date(midnight(new Date()).getTime() + (TERM - clampGA(ga)) * DAY_MS); function addMonths(d, n) { const x = new Date(d.getFullYear(), d.getMonth() + n, d.getDate()); /* 8月31日 +3 个月 = 11月31日,JS 会溢出到 12月1日,退回当月最后一天 */ if (x.getDate() !== d.getDate()) x.setDate(0); return x; } /* 日期显示:fmtWhen 给首页「宝宝来信」提醒卡用(未读接口只给时间戳,当天就写「今天」) */ const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const pad2 = n => (n < 10 ? '0' : '') + n; function fmtWhen(ts) { const d = new Date(ts), now = new Date(); const hm = d.getHours() + ':' + pad2(d.getMinutes()); return d.toDateString() === now.toDateString() ? '今天 ' + hm : (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + hm; } /* ---------- 默认预产期:打开这一刻往后推 3 个月 ---------- 原来写死 2024-12-22,换个年份进来就是一个早已过去的日期, 孕周会被 clampGA 顶到 42 周封顶,等于一进门就「超预产期」了。 改成相对今天推 3 个月:无论哪天第一次打开,默认都落在孕 27 周上下, 是个说得通的起点。用户自己选过预产期之后,这里就不再参与。 */ const DEFAULT_DUE = addMonths(midnight(new Date()), 3); const DEFAULT_GA = gaFromDue(DEFAULT_DUE); /* ---------- 全局状态 ---------- */ const state = { screen: 'home', kickToday: 0, /* ⚠️ 原为演示值 3:访客态/接口失败时它会直接落到首页数胎动卡上, 现已改 0 且 HTML 默认渲染 '--'(真实值由 __applyRecords 写) */ kickQuick: 1, todayCount: 4, favCount: 0, /* ⚠️ 原为演示值 18(与屏幕上的星标数不符); 启动时按 DOM 里 .star.is-on 的实际个数回填,见「珍贵时刻收藏」那段 */ resCat: 'all', resKey: 'book', resFav: [], // 已收藏的资源 key,数组顺序即收藏顺序 babyName: '小汤圆', /* 这三项一进门就是真值(默认预产期 = 今天 +3 个月,见上面 DEFAULT_DUE)。 以前它们是写死的占位 24/3/null,等 init() 才补 —— 但那之前就有按孕周 现算的展示(首页孕周环、档案页),拿到的就全是假的。 */ week: Math.floor(DEFAULT_GA / 7), day: DEFAULT_GA % 7, due: DEFAULT_DUE, babySex: 'unknown', /* 产后态(2026-09-22,来自上游 journey_stage=postpartum / baby_birth_date): 已生产的用户不能再按预产期算孕周(会得到「孕 42 周」这种荒谬值), 改显示「宝宝已出生 N 天」。读写方:applyRemoteProfile 的 /pregnancy 分支 + syncPregnancy。 */ postpartum: false, babyAgeTxt: '', // 「宝宝已出生 25 天」/「宝宝已出生 3 个月」(服务端算好) babyAgeDays: null, // 出生天数(没有出生日期时为 null) babyBirthDate: '', /* ⚠️ 这里原来还有一条 babyAvatar(来信列表那颗宝宝头像的 data URI)。 它的读写方全在内置来信屏里,随该屏一起删除 —— 别再只加字段不加读写方, 那正是下面 userBio 那条注释警告过的「最难发现的死代码」。 将来要恢复它,除了 state 字段,还得补上「选图 → 压缩(canvas 缩到 ~128px 再 toDataURL,原图可能几 MB)→ localStorage 落盘」这几步。 */ userName: '小满妈妈', /* ⚠️ 这里原来还有一条 userBio(一句话签名)。整块已按用户要求删除, 连同 #pfBio 输入框、#meBio 展示行、syncBio()。别只加回这一条 —— 一个没人读的 state 字段是最难发现的死代码。 */ /* ⚠️ 这里原来还有一条 wxUnlocked(兑换码解锁态)。2026-09-19(9.19 需求) 「资料改由顾问直接发」之后解锁概念整体取消,它连同 WXOK_KEY / saveWxUnlocked / syncUnlockCopy 一起删了,别再加回来。 */ }; /* ---------- 资源的收藏(跨会话保留) ---------- 只存 key,资源本身仍从 RES 读,改文案不会让旧记录失效。 ⚠️ 原来还有一整套「已领」:state.resGot / RESGOT_KEY / isGot() / 列表页的 「已领 ✓」标与筛选 chip / 详情页的「再拿一次链接」。9.19 需求取消了 「点立即领取就到手」这个动作(资料由顾问在微信里直接发),H5 侧不再记录 「领过哪几份」,整块连同 localStorage 'mmbox.resgot.v1' 一起删除。 要恢复得先回问需求方。 */ const RESFAV_KEY = 'mmbox.resfav.v1'; const loadKeys = (k, into) => { try { const arr = JSON.parse(localStorage.getItem(k) || 'null'); if (Array.isArray(arr)) state[into] = arr.filter(x => typeof x === 'string'); } catch (e) { /* 隐私模式下会抛错,忽略即可,只是不跨会话 */ } }; loadKeys(RESFAV_KEY, 'resFav'); function saveResFav() { try { localStorage.setItem(RESFAV_KEY, JSON.stringify(state.resFav)); } catch (e) {} } /* ---------- Toast ---------- */ const toastEl = $('#toast'); let toastTimer; function toast(msg) { if (!msg) return; toastEl.textContent = msg; toastEl.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800); } function haptic() { if (navigator.vibrate) navigator.vibrate(8); } /* ---------- 涟漪 ---------- */ document.addEventListener('pointerdown', e => { const t = e.target.closest('.ripple'); if (!t) return; const r = t.getBoundingClientRect(); const size = Math.max(r.width, r.height) * 2.1; const sp = document.createElement('span'); sp.className = 'rp'; sp.style.cssText = `width:${size}px;height:${size}px;left:${e.clientX - r.left}px;top:${e.clientY - r.top}px`; t.appendChild(sp); setTimeout(() => sp.remove(), 600); }); /* ---------- 通用 data-toast ---------- */ document.addEventListener('click', e => { const t = e.target.closest('[data-toast]'); if (t) { toast(t.dataset.toast); haptic(); } }); /* ================= 底部导航 / 页面切换 ================= */ const screens = $$('.screen'); const tabs = $$('.tab[data-tab]'); /* 平滑滚顶:老旧 WebView 不支持 Element.scrollTo(options),降级为直接赋值 */ function scrollTop(el) { if (!el) return; try { el.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { el.scrollTop = 0; } } /* 「档案」tab 一期跳独立 H5。第九批(2026-09-16)按用户要求把落点从 「孕期档案管理 yunqi-dangan」改成「孕检日历 chanjian-rili」。 ⚠️ 地址必须走业务域名 mp.yizhezhe.com(小程序 web-view 只放行业务域名, taidong.douba.top 直连会被拦),所以这里用的是 nginx 反代入口,不是源站直连。 ⚠️ 内置档案屏(#screen-archive)依旧屏蔽:数据来源未确认,不放进一期给用户。 屏的代码/样式全部保留,后续确认数据源后把这里改回 go('archive') 原逻辑即可。 身份转发:把当前 openid/unionid 带过去(query + hash 双写,对齐身份注入惯例)。 */ /* 换页/换应用前向小程序宿主上报「即将进入的地址」(分享卡片状态同步)。 背景:web-view 里的 H5 不能自己拉起分享面板,宿主只能靠 onShareAppMessage(options).webViewUrl 拿到当前地址,而 onShareTimeline 没有回调参数;首页壳也不会随 H5 换页重新 onLoad。 postMessage 非实时(微信在「分享 / 后退」等时机才派发),届时 web-view 已停在该地址。 协议见 box_app/docs/h5-auth-standard.md 第四节。 */ function reportHostLocation(url) { 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; url = new URL(url, location.origin).href; // 2026-10-09 入口改相对路径后,上报仍用完整地址(服务端按 URL 前缀反查 App 表) 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 会话只尝试一次 /* 预览页(/mama-box-preview/)是直接发链接给人看的,不在小程序里冷启动 —— 不恢复上次应用, 否则一打开就被送去上次进过的应用(2026-10-01 用户反馈:打开预览链接直接跳到孕检日历)。 */ if (location.pathname.indexOf('/mama-box-preview/') === 0) return; const oid = hashParam('openid'); if (!oid) return; // 无身份不跳(宿主拿不到 openid 时也不会加载本页) /* 先置闸门再请求/再判 norestore: · #norestore=1 = 小程序首页壳**已经自己决策过**了(它直接把用户送进了目标应用, 本页只是它留在身后的「返回落点」)—— 这里既不跳,也要用闸门占住, 免得后续宿主 ?t= 重载(不带该标记)时又跳一次,把返回落点也换成目标应用。 · 浏览器直访 / 首页壳取数失败后回落加载本页时,没有该标记 → 照旧自行恢复(兜底)。 */ sessionStorage.setItem(RESUME_DONE_KEY, '1'); if (hashParam('norestore')) return; 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) {} } /* ================= 返回首页 → 清除 last_app(2026-09-22) ================= 问题:用户进入 H5 应用后又退回 mama-box 首页,但记录还停在那个应用上 —— 下次冷启动(普通入口)会把他又送回刚退出的应用(用户实测)。 记录的是「最后进入的 H5 应用」,用户已经回到首页,那首页才是他最后待的地方 → 必须清掉。 ⚠️ 不能用「本页每次加载都清」:宿主恢复时会把 mama-box 当返回落点加载(#norestore=1), 那时清掉会把刚用上的记录毁掉。 所以只认「这次是返回回到本页」的三种情形: ① bfcache 恢复:pageshow 事件 persisted === true(脚本不重跑,只有这个事件) ② 后退导致整页重载:navigation.type === 'back_forward' ③ 从别的 H5 应用页以 location.href 回本页:document.referrer 是 https://mp.yizhezhe.com// 宿主恢复/返回落点那两次加载都是 navigate + referrer 空 → 不会误清。 */ let _lastAppCleared = false; function isBackToThisPage() { try { const nav = window.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; if (nav && nav.type === 'back_forward') return true; } catch (e) {} try { const ref = document.referrer || ''; if (ref.indexOf('https://mp.yizhezhe.com/') === 0 && ref.indexOf('/mama-box') === -1) return true; } catch (e) {} return false; } function clearLastAppOnBackHome(persisted) { try { if (_lastAppCleared) return; if (!persisted && !isBackToThisPage()) return; const oid = hashParam('openid'); if (!oid) return; _lastAppCleared = true; console.log('[enter-app] 返回 mama-box 首页 → 清除 last_app(persisted=' + !!persisted + ',下次冷启动不再回上次那个应用)'); if (window.fetch) { fetch(LAST_APP_API + '?openid=' + encodeURIComponent(oid), { method: 'DELETE', keepalive: true }) .catch(function () {}); } } catch (e) {} } /* ② 后退重载:脚本会重跑,init() 里调;① bfcache:整页不重载,挂在文件末尾已有的 pageshow 上 */ /* ================= 示例模式(2026-10-05) ================= 用户:「把首页的示例生成出来,用 29 周;所有该呈现的都呈现;脚印、底 tab 宝宝来信的圈都加示例;兜豆默认 2000; 档案页现有的示例(含 40 周旅程图)都保留」。用途:演示 / 截图 / 给人看完整首页。 ⚠️ 只在**预览路径 + 网址显式带 demo=1**(?demo=1 或 #…&demo=1)时打开。正式首页 /mama-box/ 永远走不到 —— AGENTS §4.2 不造假数据,2026-09-22 出过写死演示值被所有用户看到的事故;示例绝不能靠「接口没回来就兜底」触发。 打开后覆盖的只有展示:孕 29 周 0 天(预产期按今天倒推)、脚印 4 条 + 宝宝气泡、来信角标 2、兜豆 2000、档案 Tab 带 ?demo=1。 其余模块(工具卡示例、此刻、悄悄话、小知识、商城商品、福利)本来就有数据或预览示例,照常显示。 */ const DEMO = location.pathname.indexOf('/mama-box-preview/') === 0 && (/[?&]demo=1(&|$)/.test(location.search) || hashParam('demo') === '1'); const DEMO_WEEK = 29; /* 2026-10-01 用户要求「档案」Tab 改跳孕期档案 H5(会话 19875b1b 提供)。 ⚠️ 对方说明:目前是设计演示版(写死演示数据),还不读 URL 参数;接真实数据时按 ?openid=&unionid= 读, 所以这里照旧拼上。无 return_url,返回靠宿主自带返回。原来跳的是 /chanjian-rili/(孕检日历)。 */ const ARCHIVE_H5 = '/chengzhang-dangan/v10/'; // 2026-10-04 v7 → v8(会话 19875b1b 要求;正式入口不带 ?demo=1,带了只出示例数据);2026-10-09 改同源相对路径(mp nginx 反代);2026-10-22 v8 → v10(10-16 定版,与育儿首页一致) function goArchiveH5() { const oid = hashParam('openid'); const uid = hashParam('unionid'); let u = ARCHIVE_H5; const q = []; if (oid) q.push('openid=' + encodeURIComponent(oid)); if (uid) q.push('unionid=' + encodeURIComponent(uid)); /* 2026-10-05 成长档案会话(19875b1b)要求带 return_url:档案页左上角返回键回到这里(它只收 https:// 或 / 开头,要编码)。 */ q.push('return_url=' + encodeURIComponent(location.href)); /* demo=1:**预览路径一律带**(2026-10-05 用户:「档案页里的示例数据没有了」—— 预览是给人看效果的,要看到档案自己的示例,含 40 周旅程图); 正式首页 /mama-box/ 永远不带 —— 档案页带了会忽略用户自己的数据(成长档案会话 19875b1b 明确要求正式入口不带)。 */ /* 2026-10-08 用户:「把正式版对接后端接口都同步到预览版」→ 预览默认也看真实档案,只有显式 demo=1 才带。 */ if (DEMO) q.push('demo=1'); if (q.length) u += '?' + q.join('&'); reportLastApp(ARCHIVE_H5, 'chengzhangdangan'); reportHostLocation(u); location.href = u; } /* ================= 「记一下」弹层 · 全部孕期工具 = 已上架应用列表 ================= 数据:box_app /api/mp/apps(应用管理统一维护图标/名称/链接)。 范围:isPublished=1 且有 url。**前端不做任何额外过滤** —— 谁能出现在这里,完全由后台「应用管理」的上架/下架(isPublished)人工决定。 ⚠️ 2026-09-20 按需求去掉了原来那条 `!/wx-h5-demo/i` 过滤(它单独屏蔽「H5应用授权示例」)。 去掉的两个理由,别再加回来: 1)同一个应用在别的端(商城小程序「我的应用」直接铺全量)是可见的, 只在 mama-box 屏蔽会造成「同一份数据、两个端不一样」; 2)mama-box 端要**用这个应用排查「openid 为空」**的场景(授权示例页本身就是干这个的), 前端把它藏掉就没法从宫格进去。 真要让某个应用不出现,去后台下架它,别在前端写名单 —— 名单会漂。 ⚠️ 那个过滤是按 URL 正则匹配的,本身就是脆的:demo 换个域名就自动失效。 交互:全量铺进弹层宫格(沿用 .apps-grid/.app 样式),点击带身份跳转对应 H5。 */ function appTileColor(name) { const PALETTE = ['#F2917A', '#E8A2B8', '#8FB4E3', '#7FC8A9', '#E3B771', '#A99BD1', '#73BFB2', '#DE8F6E']; let h = 0; for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return PALETTE[h % PALETTE.length]; } /* 给目标 H5 拼上当前身份(openid/unionid,query 形态)。 ⚠️ 不再追加 #openid —— 部分应用自带 hash 路由(如 /baobao-yingji),追加 hash 会被当 非法路由 → 页面未找到。宿主每次加载/onShow 会重新注入身份兜底; hash 仅保留作为「授权回传结果」(#phone/nickname/avatar)的通道。 */ function withIdentity(url) { const oid = hashParam('openid'); const uid = hashParam('unionid'); const q = []; if (oid) q.push('openid=' + encodeURIComponent(oid)); if (uid) q.push('unionid=' + encodeURIComponent(uid)); /* ⚠️ 2026-09-20:一个身份都没有时**显式标记**,别让目标应用/日志去猜 「是宿主没注入身份」还是「参数在路上被平台丢了」。 · 被跳的 H5 可以据此显示自己的身份提示(或走自己的兜底); · nginx/后端日志里 __no_identity=1 一眼可查(排查 openid 为空时很快)。 排查背景见 docs/mama-box-change-list-20260919.md 与 showIdNotice() 的注释。 */ if (!oid && !uid) q.push('__no_identity=1'); return q.length ? url + (url.indexOf('?') >= 0 ? '&' : '?') + q.join('&') : url; } /* ================= 孕周开放门槛(2026-10-11 用户)================= 「数胎动 16 周、数宫缩 20 周、胎儿估重 15 周以后对外开放;选了没到的孕周(比如 10 周), 这三个都不开放,点的时候告诉她几周后开放、现在还用不到,给一个定制感受。」 · 只在**已知孕周且没到门槛**时拦:访客 / 未建档 / 产后都照常放行(拿不准就别拦人)。 · 统一拦在 jumpApp() 里 —— 记一下宫格、首页工具卡、今日待办、盲盒任务……所有入口都走它,一处管全部。 · 按 identifier 认,认不到再按 URL 目录认(今日待办的上游 url 可能不带 identifier)。 */ const STAGE_GATE = [ { id: 'taidong', slug: '/taidong/', wk: 16, name: '数胎动', why: nm => nm + '现在还小,胎动要到孕 16 周以后才慢慢感觉得到。到时候每天数一数,就能知道' + nm + '今天好不好。' }, { id: 'gongsuopinlv', slug: '/gongsuo-pinlv/', wk: 20, name: '数宫缩', why: () => '宫缩计时主要用在孕中晚期,孕 20 周以后记下来才更有参考意义。现在先安心养着,不用急着用它。' }, { id: 'taierguzhong', slug: '/taier-guzhong/', wk: 15, name: '胎儿估重', why: nm => '估重要用到 B 超量出来的头围、股骨长这些数据,孕 15 周以后算出来才靠谱。等下次 B 超拿到报告,就能看看' + nm + '有多重啦。' }, ]; function stageGateOf(url, identifier) { if (identifier) { const g = STAGE_GATE.find(x => x.id === identifier); if (g) return g; } let path = ''; try { path = new URL(url, location.href).pathname; } catch (e) { path = String(url || ''); } return STAGE_GATE.find(x => path.indexOf(x.slug) === 0) || null; } /* 现在孕几天;拿不准(访客、未建档、产后)返回 null → 不拦 */ function knownGA() { if (state.postpartum || state.pregEmpty || state.week == null) return null; return state.week * 7 + (state.day || 0); } function stageLocked(g) { const ga = knownGA(); return !!g && ga != null && ga < g.wk * 7; } function openStageGate(g) { const ga = knownGA(), left = g.wk * 7 - ga, nm = state.babyName || '宝宝'; const app = APP_BY_ID[g.id], ico = app && (app.logo || app.icon); $('#sgIco').innerHTML = ico ? '' : '' + g.name.slice(0, 1) + ''; $('#sgT').textContent = g.name + ' · 孕 ' + g.wk + ' 周后开放'; $('#sgNow').textContent = '你现在 ' + gaTxt(ga).replace('孕', '孕 ').replace('周', ' 周'); $('#sgP').textContent = g.why(nm); $('#sgFrom').textContent = gaTxt(ga); $('#sgTo').textContent = '孕' + g.wk + '周'; $('#sgBar').style.width = Math.max(6, Math.min(100, ga / (g.wk * 7) * 100)).toFixed(1) + '%'; $('#sgLeft').textContent = (left >= 7 ? '还有 ' + Math.ceil(left / 7) + ' 周' : '还有 ' + left + ' 天') + ',大约 ' + mdTxt(gaToDate(g.wk * 7)) + ' 开放'; openModal('modalStageGate'); } /* 记一下宫格上的小标签「孕16周开放」(图标右上角):没到门槛的灰一点,到了自动去掉;首页工具卡改成直接换替补(swapHomeCards) */ function markStageLocks() { const tiles = $$('#appsGrid .app'); tiles.forEach(b => { const g = stageGateOf(decodeURIComponent(b.dataset.url || ''), decodeURIComponent(b.dataset.id || '')); const on = stageLocked(g); b.classList.toggle('is-locked', on); if (on) b.dataset.lock = '孕' + g.wk + '周开放'; else delete b.dataset.lock; }); /* 2026-10-11 用户:没开放的排到后面,不放最前面。能用的保持后台排序(data-ord),没开放的整体挪到末尾; 孕周到了解锁,就按 data-ord 回到原位。只挪 DOM 顺序,点击走事件委托不受影响。 */ const grid = $('#appsGrid'); /* 有标签时行距拉开(标签探出图标上沿 9px,原 14px 行距会贴着上一行的名字) */ if (grid) grid.classList.toggle('has-locked', tiles.some(t => t.classList.contains('is-locked'))); if (grid && tiles.length) { tiles.slice().sort((a, b) => (a.classList.contains('is-locked') - b.classList.contains('is-locked')) || (+a.dataset.ord - +b.dataset.ord)) .forEach(t => grid.appendChild(t)); } swapHomeCards(); } /* 首页工具卡按孕周自动换(2026-10-11 用户:「孕育工具卡片替换成其他几个能让用户用的,否则首页体验特别差, 系统根据用户填写的孕周自动调用」)。 · 数胎动 / 胎儿估重 / 数宫缩 三个坑位,孕周没到门槛就换成替补,到了自动换回原工具。 · 替补按 SWAP_POOL 顺序依次填:能不能吃 → 补水补剂 → 血压 → 血糖 → 待产清单(孕早期最用得上的排前面), 同一个替补不会出现两次;体重、孕检日历本来就在首页,不进池子。 · 只换卡里的内容,坑位的尺寸和底色不动(五张卡的高度账、配色都是排好的,见 CSS .bento)。 · 原内容存在 data 里,换回时原样放回,再用上一次卡片接口的数据补一遍(数胎动次数等)。 */ const SWAP_SLOTS = [['kick', 'taidong'], ['fweight', 'taierguzhong'], ['curve', 'gongsuopinlv']]; const SWAP_ART = { /* 白色线稿,和其余卡片插画同一套笔触 */ yunqinengbuchi: '', bushui: '', yun: '', yunqixuetang: '', daichanqingdan: '', }; const SWAP_POOL = [ { id: 'yunqinengbuchi', name: '能不能吃', sub: '馋了先查一下', btn: '去查查' }, { id: 'bushui', name: '补水补剂', sub: '叶酸吃了吗', btn: '去记录' }, /* 2026-10-31:血压、血糖已在宫格里常驻,不再进替补池 */ { id: 'daichanqingdan', name: '待产清单', sub: '提前备起来', btn: '去看看' }, ]; let lastCards = null; /* 上一次 tool-cards 的结果:换回原卡后重放 */ function swapArt(id) { return ''; } /* ================= 首页「我的孕期工具」:数据卡 + 图标宫格,用户自选(2026-11-01) ================= 用户:「我的孕期数据和上面那个工具结合在一起时,是一个模块……上面所有的工具,用户可以去选择放哪一个,放到什么位置上。」 · 一个模块两层,二者完全解耦(同日二改,用户看过预览后定): ① #vitals 数据卡 = 「用过哪个记录类工具」:有真实记录就出,最近记录的在前,最多露 4 张、多的折叠(renderVitals); 一条真实记录都没有 → 胎动 / 体重 / 血压 / 血糖 4 张示例数卡。访客 / 产后不出。 ② #htGrid 图标宫格 = 「编辑」列表:显示哪些、什么顺序,最多 HT_MAX=8 个,不满时末尾「添加」格,记录类工具也以图标出现。 「全部工具」不占格子,在标题右侧「全部 ›」。 · 顺序 = localStorage HT_KEY(identifier 数组)。没存过 = HT_DEFAULT + 孕周替补(原 swapHomeCards 规则:没到门槛的坑位按 SWAP_POOL 换); 存过 = 完全以用户为准,没到门槛的不替换,显示「孕N周开放」、点了由 jumpApp 弹原提示。 · 能放哪些工具:App 表 category=孕期工具 的已上架应用(APP_BY_ID)。应用列表回来前,用 HT_META_KEY 里上次缓存的名字 / logo / url 先画, 回来后下架的自动不出现(不在前端写名单)。 · 7 个老格子(data-mini)和 6 张数据卡的 DOM 一直都在,只挪位置 / 显隐 —— 卡片接口回填、此刻 tapMini 点它们照旧能用。 */ const HT_KEY = 'mmbox.homeTools.v1', HT_META_KEY = 'mmbox.homeTools.meta.v1'; const HT_MAX = 8, HT_MIN = 1, VT_SHOW = 4; /* 2026-11-01 用户:默认放 6 个,编辑里最多放到 8 个;不满 8 个时末尾有「添加」格(点开编辑),满了隐藏 */ const HT_DEFAULT = ['taidong', 'yunqitizhong', 'chanjianrili', 'taierguzhong', 'gongsuopinlv', 'yun']; const VT_ORDER = ['kick', 'weight', 'bp', 'glu', 'curve', 'fweight']; const VT_TOOL = { kick: 'taidong', weight: 'yunqitizhong', bp: 'yun', glu: 'yunqixuetang', curve: 'gongsuopinlv', fweight: 'taierguzhong' }; /* 拿不到记录时间时的兜底顺序;前 4 个也是「没有任何记录」时的示例卡 */ const HT_DATA = { taidong: 'kick', yunqitizhong: 'weight', yun: 'bp', yunqixuetang: 'glu', gongsuopinlv: 'curve', taierguzhong: 'fweight' }; let htAppsLoaded = false; let htMeta = {}; try { htMeta = JSON.parse(localStorage.getItem(HT_META_KEY) || '{}') || {}; } catch (e) { htMeta = {}; } function htApps() { try { return APP_BY_ID; } catch (e) { return null; } } /* APP_BY_ID 定义在下面,早于它调用时返回 null */ function htInfo(id) { const A = htApps(), a = A && A[id], m = htMeta[id] || {}; const sw = SWAP_POOL.find(x => x.id === id); return { id: id, name: (a && a.name) || m.n || (sw && sw.name) || '', short: (sw && sw.name) || (a && a.name) || m.n || '', logo: (a && (a.logo || a.icon)) || m.l || '', url: (a && a.url) || m.u || '', btn: (sw && sw.btn) || '去看看', }; } function htCacheMeta(apps) { htAppsLoaded = true; htMeta = {}; apps.forEach(a => { htMeta[a.identifier] = { n: a.name || '', l: a.logo || a.icon || '', u: a.url || '' }; }); try { localStorage.setItem(HT_META_KEY, JSON.stringify(htMeta)); } catch (e) {} } function htLoad() { try { const a = JSON.parse(localStorage.getItem(HT_KEY) || 'null'); if (Array.isArray(a)) { const r = a.filter((x, i) => typeof x === 'string' && x && a.indexOf(x) === i).slice(0, HT_MAX); if (r.length) return r; } } catch (e) {} return null; } function htStore(a) { try { if (a) localStorage.setItem(HT_KEY, JSON.stringify(a)); else localStorage.removeItem(HT_KEY); } catch (e) {} } /* 默认顺序 + 孕周替补(替补顺序 / 不重复的规则同原 swapHomeCards)。 SWAP_POOL 用完了(能不能吃已在默认里)再从 App 表里挑一个没放上来、也没被孕周锁住的孕期工具顶上,免得留个锁着的格子。 */ function htDefault() { const A = htApps() || {}, used = new Set(HT_DEFAULT); return HT_DEFAULT.map(id => { if (!stageLocked(STAGE_GATE.find(x => x.id === id))) return id; const sub = SWAP_POOL.find(x => !used.has(x.id) && A[x.id] && A[x.id].url) || Object.keys(A).map(k => ({ id: k })).find(x => !used.has(x.id) && A[x.id].url && !stageLocked(STAGE_GATE.find(g => g.id === x.id))) || SWAP_POOL.find(x => !used.has(x.id)); if (!sub) return id; used.add(sub.id); return sub.id; }); } /* 当前该显示的列表:用户的 / 默认的,去掉认不出来的(应用列表回来后以 App 表为准) */ function htKnown(id) { const A = htApps(); if (htAppsLoaded && A) return !!(A[id] && A[id].url); return !!($('#htGrid .tg[data-tool="' + id + '"]') || htMeta[id] || SWAP_POOL.some(x => x.id === id)); } function htCurrent() { return (htLoad() || htDefault()).filter(htKnown); } function htVtOn() { return !!hashParam('openid') && !state.postpartum; } function htRel(u) { /* 同源相对路径(AGENTS §13):本站或 mp 业务域名的绝对地址 → //… */ try { const x = new URL(u, location.href); if (x.host === location.host || x.host === 'mp.yizhezhe.com') return x.pathname + x.search + x.hash; } catch (e) {} return u; } /* 不是 data-mini 的普通应用格(能不能吃、补水补剂、待产清单、宝宝相册……) */ function htMakeCell(id) { const f = htInfo(id); if (!f.short && !f.url) return null; const b = document.createElement('button'); b.type = 'button'; b.className = 'tg tg-app ripple'; b.dataset.tool = id; b.setAttribute('aria-label', f.name || f.short); b.innerHTML = '' + swapArt(id) + ''; b.querySelector('.tg-n').textContent = f.short; b.querySelector('.tg-s').textContent = f.btn; b.addEventListener('click', () => { const g = htInfo(id); if (g.url) { jumpApp(htRel(g.url), id); return; } toast('「' + (g.short || '这个工具') + '」即将上线'); }); return b; } function toolLogo(id) { return htInfo(id).logo; } /* 首页宫格 / 数据卡图标 = 各应用在 App 表里的正式 logo(2026-10-31 用户定);拿不到就保留现有图形 */ function applyToolLogos() { $$('#htGrid .tg[data-tool], #vitals .vt-it[data-tool]').forEach(b => { const u = toolLogo(b.dataset.tool); const ic = b.querySelector('.tg-ic, .vt-ic'); if (!u || !ic) return; let im = ic.querySelector('img'); if (!im || ic.querySelector('svg')) { ic.innerHTML = ''; im = ic.querySelector('img'); } if (ic.classList.contains('tg-ic')) ic.classList.add('lg'); if (im.getAttribute('src') !== u) im.src = u; }); } function htLockMark(el, g) { const on = stageLocked(g); el.classList.toggle('is-locked', on); if (on) el.dataset.lock = '孕' + g.wk + '周开放'; else delete el.dataset.lock; return on; } /* 图标宫格:完全按编辑列表(最多 8 个)排 */ function renderHomeTools() { const grid = $('#htGrid'); if (!grid) return; const list = htCurrent().slice(0, HT_MAX); const shown = new Set(); list.forEach(id => { const t = grid.querySelector('.tg[data-tool="' + id + '"]') || htMakeCell(id); if (!t) return; shown.add(t); grid.appendChild(t); }); $$('.tg[data-tool]', grid).forEach(t => t.classList.toggle('hide', !shown.has(t))); /* 末尾「添加」格:始终在最后,点开编辑弹层(同「编辑」按钮) */ let add = grid.querySelector('.tg-add'); if (!add) { add = document.createElement('button'); add.type = 'button'; add.className = 'tg tg-add ripple'; add.setAttribute('aria-label', '添加首页工具'); add.innerHTML = '添加编辑'; add.addEventListener('click', e => { e.stopPropagation(); const b = $('#htEdit'); if (b) b.click(); }); } grid.appendChild(add); /* 2026-11-01 用户删掉了标题栏「编辑」,「添加」格是唯一编辑入口 → 始终显示(放满 8 个时它在第三行) */ /* 孕周门槛角标:只有用户自己放上来的才会锁着出现(默认列表里锁着的已经换成替补) */ let anyLock = false; $$('.tg[data-tool]', grid).forEach(el => { const on = htLockMark(el, STAGE_GATE.find(x => x.id === el.dataset.tool)); if (on && shown.has(el)) anyLock = true; }); grid.classList.toggle('has-locked', anyLock); renderVitals(); applyToolLogos(); } /* 数据卡:只看「用过哪个记录类工具」,和编辑列表无关。 · 有真实记录(vtSet 写的 data-has)就出;按最近一次记录时间(data-ts,毫秒)新的在前,拿不到时间的排在有时间的后面、按 VT_ORDER。 · 直接露 VT_SHOW=4 张,多的折叠,「展开全部 N 项数据」点开(展开状态不记忆)。 · 一条真实记录都没有 → VT_ORDER 前 4 个(胎动 / 体重 / 血压 / 血糖)带示例数;孕周没到的工具没记录自然不出(示例卡里也去掉)。 · 访客 / 产后整块不出。 */ let vtOpen = false; function renderVitals() { const box = $('#vitals'), more = $('#vtMore'); if (!box) return; if (!htVtOn()) { box.classList.add('hide'); return; } const its = {}; $$('.vt-it[data-vt]', box).forEach(it => { its[it.dataset.vt] = it; }); const ord = k => VT_ORDER.indexOf(k); let keys = VT_ORDER.filter(k => its[k] && its[k].dataset.has); const real = keys.length > 0; if (real) { keys.sort((a, b) => { const ta = +its[a].dataset.ts || 0, tb = +its[b].dataset.ts || 0; return (tb - ta) || (ord(a) - ord(b)); }); } else { keys = VT_ORDER.slice(0, 4).filter(k => !stageLocked(STAGE_GATE.find(g => g.id === VT_TOOL[k]))); } /* 11-01 数据卡改成一行横滑小卡 → 不再折叠,有几项出几项 */ const fold = false; const vis = fold && !vtOpen ? keys.slice(0, VT_SHOW) : keys; keys.forEach(k => box.insertBefore(its[k], more)); Object.keys(its).forEach(k => its[k].classList.toggle('hide', vis.indexOf(k) < 0)); vis.forEach(k => its[k].classList.remove('span2')); if (more) { more.classList.toggle('hide', !fold); more.setAttribute('aria-expanded', vtOpen ? 'true' : 'false'); more.innerHTML = (vtOpen ? '收起' : '展开全部 ' + keys.length + ' 项数据') + ''; } box.classList.toggle('hide', !vis.length); } (function () { const m = $('#vtMore'); if (m) m.addEventListener('click', e => { e.stopPropagation(); vtOpen = !vtOpen; renderVitals(); haptic(); }); })(); /* 原「首页工具卡按孕周换替补」入口(markStageLocks 调):现在替补并进 htDefault,这里只重排 */ function swapHomeCards() { renderHomeTools(); } /* 跳转到其它 H5 应用。identifier 可选(来自 App 表),用于 last_app 上报的服务端在架校验; 缺省时服务端按 URL 前缀反查,效果一致。 */ function jumpApp(url, identifier) { const sg = stageGateOf(url, identifier); if (stageLocked(sg)) { openStageGate(sg); return; } // 孕周没到:弹提示,不跳(见 STAGE_GATE) haptic(); const target = withIdentity(url); reportLastApp(url, identifier); // 净地址:恢复时身份按当前用户重新注入 reportHostLocation(target); location.href = target; } function renderAppGrid(apps) { const grid = $('#appsGrid'); if (!grid) return; grid.innerHTML = apps.map((a, i) => { const ico = a.logo || a.icon || ''; const name = a.name || a.identifier || '应用'; /* 有 logo:白底圆角让图盖满;无 logo:colorTheme/调色板 + 首字彩色块。 ⚠️ colorTheme 目前全是 'blue' 颜色名 —— 有图时别拿它当底色,否则清一色蓝。 */ const bg = ico ? 'background:#fff' : 'background:' + (a.colorTheme || appTileColor(name)); const img = ico ? '' : '' + name.slice(0, 1) + ''; return ''; }).join(''); /* 磁贴点击(事件委托,innerHTML 重建后不用重绑) */ grid.onclick = function (e) { const t = e.target.closest('.app'); if (t && t.dataset.url) jumpApp(decodeURIComponent(t.dataset.url), decodeURIComponent(t.dataset.id || '')); }; /* 角标「共 N 个」同步真实数量。 ⚠️ 顺带写 data-toast:点它弹的那句原来由 dedupeApps 拼「已使用的 N 个已置顶」, 而快捷区现在被临时隐藏(dedupeApps 直接 return,见那里的注释), 若这里不写,就会退化成 index.html 里写死的「共 16 个」—— 数字比实际多。 */ const cnt = $('.apps-head.for-all .sec-more'); if (cnt) { cnt.textContent = '共 ' + apps.length + ' 个 ›'; cnt.dataset.toast = '共 ' + apps.length + ' 个孕期工具'; } markStageLocks(); } function loadPublishedApps() { fetch('/api/mp/apps?isPublished=1&pageSize=200') .then(r => (r.ok ? r.json() : null)) .then(d => { const items = (d && d.result && d.result.items) || []; /* ⚠️ 只要求有 url(没配链接的点了没反应);**不再按应用名/URL 过滤** —— 「H5应用授权示例(wx-h5-demo)」曾经在这里被单独屏蔽,2026-09-20 按需求去掉: mama-box 要用它排查 openid 为空的场景,且它的可见性该由后台上下架决定。 详见上面那段注释;别在前端重新引入名单。 */ /* 2026-10-21 用户定:孕期首页只出孕期工具,育儿工具(category=育儿工具)就算上架了也不在这里出现 */ const apps = items .filter(a => a.url && a.category === '孕期工具') .sort((x, y) => (y.seq || 0) - (x.seq || 0) || (y.no || 0) - (x.no || 0)); apps.forEach(a => { APP_BY_ID[a.identifier] = a; }); /* 供首页工具卡按 identifier 取 URL */ htCacheMeta(apps); /* 首页自选工具:记下名字 / logo / url,下次应用列表回来前先用(renderAppGrid → markStageLocks 会重排首页) */ applyToolLogos(); renderAppGrid(apps); }) .catch(function () { /* 拉不到就保留弹层原有占位,不影响其余页面 */ }); } loadPublishedApps(); function go(name) { /* 「档案」先跳孕期档案 H5(一期屏蔽内置档案屏,原因见 goArchiveH5 注释) */ if (name === 'archive') { goArchiveH5(); return; } if (state.screen === name) { scrollTop($(`#screen-${name}`)); // 再次点击当前 tab 回到顶部 return; } state.screen = name; screens.forEach(s => s.classList.toggle('is-active', s.dataset.screen === name)); tabs.forEach(t => t.classList.toggle('is-active', t.dataset.tab === name)); const cur = $(`#screen-${name}`); if (cur) { cur.scrollTop = 0; updateStatusbar(cur); } /* 档案页的孕程已经从圆环换成细条了,这里跟着换成「归零再长回去」。 归零和写目标值必须隔一帧,同一帧里改两次浏览器只会看到最后那个值, transition 无从触发(和 animateRings 里那次强制重绘是一个道理)。 */ if (name === 'archive') { requestAnimationFrame(() => { replayArBar(); }); } if (name === 'home') { requestAnimationFrame(() => { animateRings(cur); }); } haptic(); } tabs.forEach(t => t.addEventListener('click', () => go(t.dataset.tab))); $$('[data-goto]').forEach(b => { const fire = () => { /* ⚠️ 原来这里有个 letter-detail 分支(首页来信卡 → 内置来信详情)。 2026-09-20 卡改跳 laixin 之后没人再用它,连同 openDetail() 一起删除。 */ go(b.dataset.goto); }; b.addEventListener('click', fire); /* 真 ' + icoHtml(f) + '' + esc(nm) + '' + (lk ? '孕' + g.wk + '周开放' : '') + '' + '' + '' + ''; }).join(''); const rest = pool().filter(id => draft.indexOf(id) < 0); const full = draft.length >= HT_MAX; more.innerHTML = rest.length ? rest.map(id => { const f = htInfo(id); return ''; }).join('') : '
孕期工具都已经放在首页啦
'; $('#htsCnt').textContent = draft.length + ' / ' + HT_MAX; $('#htsSub').textContent = '长按拖动排序,首页最多放 ' + HT_MAX + ' 个'; } function change(next) { draft = next; dirty = true; resetAsked = false; render(); } function open() { draft = htCurrent().slice(0, HT_MAX); dirty = false; resetAsked = false; render(); box.scrollTop = 0; scr.classList.add('show'); sh.classList.add('show'); sh.setAttribute('aria-hidden', 'false'); haptic(); } function close() { if (!sh.classList.contains('show')) return; scr.classList.remove('show'); sh.classList.remove('show'); sh.setAttribute('aria-hidden', 'true'); if (resetAsked) { htStore(null); renderHomeTools(); toast('已恢复默认工具'); return; } if (!dirty) return; htStore(draft.slice()); renderHomeTools(); toast('首页工具已更新'); } openBtn.addEventListener('click', e => { e.stopPropagation(); open(); }); $('#htsDone').addEventListener('click', close); $('#htsX').addEventListener('click', close); $('#htsGrip').addEventListener('click', close); scr.addEventListener('click', close); $('#htsReset').addEventListener('click', () => { /* 只换草稿;真正删 HT_KEY 在 close 里 */ draft = htDefault().filter(htKnown); dirty = false; resetAsked = true; render(); toast('已换回默认顺序,点完成生效'); }); list.addEventListener('click', e => { const btn = e.target.closest('button[data-act]'); if (!btn) return; const id = btn.closest('.hts-row').dataset.id, i = draft.indexOf(id), d = draft.slice(); if (btn.dataset.act === 'rm') { if (d.length <= HT_MIN) { toast('首页至少留 ' + HT_MIN + ' 个工具'); return; } d.splice(i, 1); change(d); haptic(); return; } const j = btn.dataset.act === 'up' ? i - 1 : i + 1; if (j < 0 || j >= d.length) return; d[i] = d[j]; d[j] = id; change(d); haptic(); }); more.addEventListener('click', e => { const b = e.target.closest('.hts-add'); if (!b) return; if (draft.length >= HT_MAX) { toast('首页最多放 ' + HT_MAX + ' 个工具,先移掉一个吧'); return; } change(draft.concat(b.dataset.id)); haptic(); }); /* —— 拖动排序 —— */ const GAP = 6; function dStart() { if (!hd) return; hd.on = true; hd.st0 = box.scrollTop; hd.row.classList.add('is-drag'); list.classList.add('is-dragging'); try { hd.row.setPointerCapture(hd.pid); } catch (e) {} haptic(); } function dEnd() { if (!hd) return; clearTimeout(hd.timer); const was = hd.on, row = hd.row; hd = null; if (!was) return; row.style.transform = ''; row.classList.remove('is-drag'); list.classList.remove('is-dragging'); const next = $$('.hts-row', list).map(r => r.dataset.id); if (next.join() !== draft.join()) change(next); else render(); } list.addEventListener('pointerdown', e => { const row = e.target.closest('.hts-row'); if (!row || e.target.closest('button') || hd) return; if (e.pointerType === 'mouse' && e.button !== 0) return; hd = { row: row, pid: e.pointerId, x0: e.clientX, y0: e.clientY, on: false, timer: 0 }; if (e.target.closest('.hts-hd')) { e.preventDefault(); dStart(); } else hd.timer = setTimeout(dStart, 300); }); list.addEventListener('pointermove', e => { if (!hd || e.pointerId !== hd.pid) return; if (!hd.on) { /* 还没到长按就动了 = 在滚列表,放弃 */ if (Math.abs(e.clientY - hd.y0) > 8 || Math.abs(e.clientX - hd.x0) > 8) { clearTimeout(hd.timer); hd = null; } return; } e.preventDefault(); /* 贴近滚动区上下沿时顺带滚一下 */ const br = box.getBoundingClientRect(); if (e.clientY < br.top + 36) box.scrollTop -= 8; else if (e.clientY > br.bottom - 36) box.scrollTop += 8; /* ⚠️ 只挪「旁边那行」,不挪正在拖的这行:被拖的节点一离开 DOM 就会丢 pointer capture,拖到一半断掉 */ const rows = $$('.hts-row', list), i = rows.indexOf(hd.row), step = hd.row.offsetHeight + GAP; let dy = e.clientY - hd.y0 + (box.scrollTop - hd.st0); if (dy > step / 2 && i < rows.length - 1) { list.insertBefore(rows[i + 1], hd.row); hd.y0 += step; dy -= step; haptic(); } else if (dy < -step / 2 && i > 0) { list.insertBefore(rows[i - 1], hd.row.nextSibling); hd.y0 -= step; dy += step; haptic(); } hd.row.style.transform = 'translateY(' + dy + 'px) scale(1.02)'; }); list.addEventListener('pointerup', dEnd); list.addEventListener('pointercancel', dEnd); list.addEventListener('lostpointercapture', () => { if (hd && hd.on) dEnd(); }); list.addEventListener('contextmenu', e => e.preventDefault()); /* 长按触发后拦掉滚动;弹层里滚动区以外的地方也不让带着整页动(禁止下拉回弹规则) */ sh.addEventListener('touchmove', e => { if (hd && hd.on) { if (e.cancelable) e.preventDefault(); return; } if (!e.target.closest('.hts-scroll') && e.cancelable) e.preventDefault(); }, { passive: false }); scr.addEventListener('touchmove', e => { if (e.cancelable) e.preventDefault(); }, { passive: false }); })(); /* 记一下弹层只保留「全部孕期工具」,不再提供输入框 */ /* ================= 滑入详情页(栈式,可多层返回) ================= */ const panelStack = []; function openPanel(id, trigger) { const el = document.getElementById(id); if (!el) return; /* 这一页此刻在栈里的位置:-1 = 没开过,length-1 = 正停在最上层,其余 = 开着但被压住了 */ const at = panelStack.indexOf(el); if (id === 'detailAI') renderAI(0); if (id === 'detailResList') renderResList(state.resCat); if (id === 'detailRes') fillRes((trigger && trigger.dataset.res) || 'book'); if (id === 'detailSetting') renderSetting((trigger && trigger.dataset.set) || 'privacy'); haptic(); /* 已经停在最上层(例:内容页里点「相关推荐」)→ 只换内容,不动层级。 ⚠️ `at >= 0` 这半句不能省:栈空时 at 和 length-1 都是 -1, 只写 `at === panelStack.length - 1` 会把**第一次打开任何一页**也判成 「已在最上层」而早退 —— 整个 App 的详情页全部打不开。写这一版时就栽在这儿, 是 smoke 里那条「列表和详情页显示的是同一句」先崩的(#dlThread 根本没渲染)。 */ if (at >= 0 && at === panelStack.length - 1) return; /* ⚠️ 这里原来是一句 `if (reopen) return;`,把「在最上层」和「被压住」当成了同一回事, 后者会**静默失败**: 首页书架 → detailRes(栈 = [detailRes])→ 点「其他资源」→ 栈 = [detailRes, detailResList], 此时点资源库里任何一份资源 → detailRes 已在栈内 → fillRes() 把**底下那页**的内容悄悄换掉 → haptic() 照常震一下 → return。资源库纹丝不动,整列资源看着像全坏了; 而且底下那页的内容已经被掉包,返回时看到的不是当初点进去的那一份。 下面 #rdLib 那段注释早就写过同一个陷阱(「它会静默 return,页面纹丝不动,看着就像按钮坏了」), 但当时只堵了「从详情页去资源库」这一个方向,反方向漏了。 所以「开着但被压住」不能早退,要把它挪到栈顶重新亮出来。 */ if (at >= 0) panelStack.splice(at, 1); panelStack.push(el); /* ⚠️ 必须整栈重铺,不能只给挪上来的这一个赋值: splice 之后原栈顶的下标变了,单独算一个必然和它撞成同一层(80+1*2 对 80+1*2), 撞层时谁在上面由 DOM 顺序决定 —— 而 detailResList 在 HTML 里正好排在 detailRes 前面, 结果就是「代码以为挪上来了,屏幕上还是被压着」,比现在这个 bug 更难查。 */ panelStack.forEach((p, i) => { p.style.zIndex = 80 + i * 2; }); el.classList.add('show'); } function closePanel() { const el = panelStack.pop(); if (el) el.classList.remove('show'); } function closeAllPanels() { while (panelStack.length) closePanel(); } $$('[data-open]').forEach(b => b.addEventListener('click', () => openPanel(b.dataset.open, b))); /* ---------- 「宝宝」这两个字的统一出口 ---------- 用户在「我的 → 编辑孕期信息」里填了小名,首页那张「宝宝来信」提醒卡的 标题 / 摘要 / 链接文案都得跟着换。没填就回落到「宝宝」,每句文案照样通顺。 写成 function 而不是箭头常量:声明会提升,谁先谁后都不会踩到暂时性死区。 */ function babyWord(suffix) { return (state.babyName || '宝宝') + (suffix || ''); } /* ⚠️ 这里原来还有一个 fillBaby(str)(把 {baby} 占位符换成小名),和 babyWord() 一对。 它的调用点全在内置来信屏的渲染里,随该屏一起删除。 将来若要用,直接调 babyWord() 拼,别只留一个空函数在这儿。 */ /* back(几天前)+ time(几点几分)→ 那一刻的时间戳。back 只影响「哪一天」, 时分是当天的绝对钟点,所以先归零到 0 点再往回数,避免夏令时/跨月自己算。 ⚠️ 现在唯一调用方是 stampAiLogs()(AI 小结的往期日期)。内置来信屏删除后 信件那一层语义没了,但 AI 小结还在靠它推日期 —— 别顺手删。 */ function backAt(back, time) { const d = midnight(new Date()); d.setDate(d.getDate() - back); d.setHours(time[0], time[1], 0, 0); return d.getTime(); } /* ---------- 小工具 ---------- */ function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt != null) n.textContent = txt; return n; } /* 首页那张「宝宝来信」提醒卡:**数据源是 laixin(第三方宝宝来信应用)**。 ⚠️⚠️ 2026-09-20 —— 理顺一处数据源不一致: 底 tab 早已改跳 laixin、角标读 /api/mp/public/laixin/unread, 但这张卡原来由 pushLatestToHome() 按本地 LETTERS(box_app 的 /mama-box/letters)驱动, 而那条线对绝大多数用户是 0 封 —— 于是同一屏上「红点说有新信、卡里什么都没有」。 现在两者**共用同一个 laixin 未读结果**,而且**只有一个渲染入口**: · 拉数:syncLaixinReminder()(首屏 + 每次 visibilitychange) · 渲染:renderLetterReminder() —— 卡与底 tab 红点由它**一次判定**同时落, 不会再出现「一边有一边没有」的屏 · 判据(2026-09-22 产品口径):**未建档(pregEmpty)= 一律不提示**; 建档后看 unread > 0;归零则一起收;上游降级(ok:false)保持现状 · 点击:跳 laixin(与底 tab 同落点) ⚠️ 判据翻转过一次(旧版「故意不看 pregEmpty」已被产品否掉),细节与理由写在 renderLetterReminder() 上方,改之前先读那段。 ⚠️ 卡里只有数字与时间(接口就给这些),所以文案是**通用提醒**,不做单封预览。 将来第三方上 SSE 的 event: letter 之后,才有标题/摘要可显示 (见 docs/laixin-sse-requirement.md)。 ⚠️ 2026-09-20:内置来信屏(#screen-letter)及其全部功能代码已整体删除, 这里没有「本地信件数据」可以接回去了 —— 这张卡只认 laixin。 */ function hideLetterCard() { const card = $('#homeLetter'), body = $('#homeBody'); if (card) { card.classList.add('hide'); card.classList.remove('has-new'); } if (body) body.classList.add('no-letter'); // 头图跟着切:无来信 → 平底让工具卡压上来;有来信 → 回到弧底(见 mama-box.css「头图 ↔ 第一块卡片的衔接」) const hero = $('.home-hero'); if (hero) hero.classList.add('no-letter'); } /* 来信提示(首页卡 + 底 tab 红点)的**唯一渲染入口**。 两个触发方:syncLaixinReminder()(拿到新未读结果)与 syncPregnancy()(建档状态变了)。 卡与红点永远由这**一次判定**同时决定 —— 之前它们各写一份判据,结果就是 「红点有、卡没有」那种自相矛盾的屏(2026-09-22 定时复现过)。 判定(2026-09-22 产品口径): ① **未建档(pregEmpty,即没填孕产期)→ 一律不提示**:卡与红点一起收。 口径翻转过一次,说明白免得被改回去: · 旧注释写的是「来信与建档是两件事,故意不看 pregEmpty」; · 产品实测后确认**不合理** —— 没填孕产期的用户本来就不该被「宝宝来信」打扰 (那时连宝宝档案都没有),所以现在 pregEmpty 是**硬门槛**; · 别再按「unread>0 就该提示」把它改回去。 ② 建档了 → 只看未读:unread>0 才出(卡 + 红点),归零则一起收。 ③ 上游降级(ok:false)→ 保持现状不动(不误导)。 */ let _laixinUnread = null; // 最近一次未读结果;建档状态变化时靠它重算 function renderLetterReminder() { const badge = $('#tabBadge'); const r = DEMO ? { ok: true, unread: 2, unreadLetters: 1, unreadReplies: 1 } : _laixinUnread; // 示例模式:圈 2 if (state.pregEmpty || !r || r.ok === false || !(Number(r.unread) > 0)) { if (badge) badge.classList.remove('show'); hideLetterCard(); return; } /* 2026-10-04 用户:「宝宝来信不在首页卡片上展示了,有来信只在 tab 上给个明显的小提示, 一封就圈 1、两封就圈 2」→ 首页卡永远收起,未读数(信 + 回信)直接写进底 tab 的红圈里。 */ hideLetterCard(); const n = (Number(r.unreadLetters) || 0) + (Number(r.unreadReplies) || 0) || Number(r.unread) || 0; /* 2026-10-04 「宝宝来信(新)」会话转产品:「一封信圈一,两封信圈二」→ 1~10 画成圈数字(①~⑩ 的样子: 白底红圈红字,CSS .t-badge.is-ring 画圈,不用 ① 字符本身 —— 各家字体里大小/粗细不一,叠在 tab 上会糊); >10 换成实心红胶囊「10+」。读屏读 aria-label。 */ if (badge) { badge.textContent = n > 10 ? '10+' : String(n); badge.classList.toggle('is-ring', n <= 10); badge.classList.toggle('d2', n === 10); // ⑩ 两位数,圈里字缩小 badge.setAttribute('aria-label', n + ' 封未读来信'); badge.classList.add('show'); } } /* 2026-10-02 底部导航:「宝宝来信」tab 的信封在有红点时轻轻晃(CSS .has-new)。 红点的 owner 仍是 renderLetterReminder,这里只跟着它的 .show 走,不另立判据。 */ (function () { const badge = $('#tabBadge'), tab = $('#tabLetter'); if (!badge || !tab || !window.MutationObserver) return; const sync = () => tab.classList.toggle('has-new', badge.classList.contains('show')); new MutationObserver(sync).observe(badge, { attributes: true, attributeFilter: ['class'] }); sync(); })(); /* 「我的」tab 图标换成妈妈自己的头像:跟随 #meAvatarImg 的 src(资料接口 / 换头像 都写它),没有就回到线稿小人 */ (function () { const src = $('#meAvatarImg'), ava = $('#tabAva'); if (!src || !ava || !window.MutationObserver) return; const sync = () => { const u = src.getAttribute('src') || ''; if (u) ava.src = u; else ava.removeAttribute('src'); ava.closest('.tab').classList.toggle('has-ava', !!u); }; ava.addEventListener('error', () => ava.closest('.tab').classList.remove('has-ava')); new MutationObserver(sync).observe(src, { attributes: true, attributeFilter: ['src'] }); sync(); })(); $$('[data-detail="close"]').forEach(b => b.addEventListener('click', closePanel)); /* ================= 通用弹窗 ================= 不复用「记一下」那套 sheet/scrim(那层是底部弹层专用的), 弹窗单独用一层 z-index 更高的 #modalScrim */ const modalScrim = $('#modalScrim'); let openedModal = null; function openModal(id) { const m = document.getElementById(id); if (!m) return; closeModal(); openedModal = m; modalScrim.classList.add('show'); m.classList.add('show'); haptic(); /* 「我的资料」打开时按服务端档案回填表单(新用户全空,不残留演示默认值) */ if (id === 'modalProfile') { fillProfileForm(); refreshPregnancy().then(function (changed) { if (changed && openedModal === m) fillProfileForm(); }); } /* 有些弹窗的主角不是输入框。企业微信那个弹窗正中间是一张二维码, 用户要做的第一件事是**举起另一台手机去扫它**;自动聚焦输入框会把 手机键盘顶上来,恰好盖住二维码的下半截。所以给一个显式的退出口, 写在标记上而不是在这里 if id ——「这个弹窗不要自动聚焦」是弹窗自己的 属性,让它随弹窗一起被读到,别攒成一张要来这儿查的 id 名单。 */ if (m.dataset.autofocus === 'off') return; /* 自动聚焦第一个可输入项;排除 type=file(资料弹窗里那个是隐藏的选图入口, 聚焦它既没意义,还会让「点大圆换头像」的焦点态乱掉) */ const first = $('.modal-ta, input:not([type="file"])', m); if (first) setTimeout(() => first.focus({ preventScroll: true }), 300); } function closeModal() { if (!openedModal) return; openedModal.classList.remove('show'); modalScrim.classList.remove('show'); openedModal = null; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } $$('[data-modal]').forEach(b => b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); const t = b.dataset.modal; if (t === 'close') closeModal(); else openModal(t); })); modalScrim.addEventListener('click', closeModal); /* ================= 星座宝宝弹窗(2026-10-04) ================= 用户:「点开『巨蟹宝宝』之后弹窗:宝宝幸运色(珍珠白、湖水蓝……)、宝宝的性格(说几句好话)、宝宝适合的职业」。 头图小牌在 js/hero-fx.js,点了派发 mb-zodiac {names:['巨蟹'] 或交界时 ['巨蟹','狮子']},这里填内容并打开。 文案只往好处说、不下结论(「可能会喜欢」),底部写明是星座小趣味。 */ const ZODIAC = { 水瓶: ['1.20 – 2.18', '冰川蓝', '#7FB8D6', '像晴天里的冰川,清透又有灵气', ['聪明', '有主见', '点子多'], '这个时候出生的宝宝,小脑袋里总装着新奇的想法,对世界充满好奇。待人真诚友善,长大后会有很多好朋友,也常常是那个让大家眼前一亮的人。', ['科学家', '发明家', '程序员', '产品设计师', '天文学家']], 双鱼: ['2.19 – 3.20', '薰衣草紫', '#A88BC9', '温柔又梦幻,像一首轻轻的摇篮曲', ['温柔', '有想象力', '善解人意'], '这个时候出生的宝宝,心思细腻、特别会体贴人,常常能察觉到你的小情绪。想象力丰富,喜欢画画、听故事,是天生的小艺术家。', ['画家', '音乐人', '作家', '心理咨询师', '医生']], 白羊: ['3.21 – 4.19', '珊瑚红', '#F2685A', '像初升的太阳,热乎乎、亮堂堂', ['活泼', '勇敢', '热情'], '这个时候出生的宝宝,天生活泼开朗、精力满满,想到就去做,什么都愿意试一试。笑起来特别有感染力,走到哪儿都能把快乐带到哪儿。', ['运动员', '飞行员', '创业者', '主持人', '外科医生']], 金牛: ['4.20 – 5.20', '抹茶绿', '#9BBF7A', '像春天的草地,安心又治愈', ['踏实', '温和', '有耐心'], '这个时候出生的宝宝,性格温和、做事稳稳当当,是让爸爸妈妈特别省心的小可爱。很会享受生活里的美好,对好吃的、好看的东西格外敏感。', ['美食家', '建筑师', '珠宝设计师', '园艺设计师', '金融分析师']], 双子: ['5.21 – 6.21', '柠檬黄', '#F4D35E', '像一颗跳跳糖,明快又俏皮', ['机灵', '好奇', '会表达'], '这个时候出生的宝宝,反应快、小嘴甜,学说话往往比别的宝宝早。对什么都好奇,十万个为什么能问个不停,和谁都能很快玩到一起。', ['记者', '主持人', '翻译', '作家', '新媒体创作者']], 巨蟹: ['6.22 – 7.22', '珍珠白', '#F3EDE3', '像月光一样柔和,温润有光泽', ['温柔', '贴心', '重感情'], '这个时候出生的宝宝,特别恋家、黏爸爸妈妈,是暖暖的小棉袄。心地柔软、懂得照顾别人,长大后会是家人和朋友最安心的依靠。', ['医生', '教师', '营养师', '室内设计师', '幼教老师']], 狮子: ['7.23 – 8.22', '琥珀金', '#E8A33D', '像夏天的阳光,明亮又大方', ['阳光', '自信', '大方'], '这个时候出生的宝宝,天生自带光芒,笑容灿烂、落落大方,从小就不怕生。有主见、讲义气,常常是小伙伴里的「孩子王」。', ['演员', '导演', '企业家', '律师', '主持人']], 处女: ['8.23 – 9.22', '奶油杏', '#F1D3B3', '像刚出炉的面包,温暖又干净', ['细心', '认真', '聪明'], '这个时候出生的宝宝,观察力特别好,一点点小变化都逃不过他的眼睛。做事认真有条理,学东西又快又扎实,是让人放心的小学霸。', ['医生', '科研人员', '编辑', '药剂师', '会计师']], 天秤: ['9.23 – 10.23', '樱花粉', '#F4B6C2', '像四月的樱花,温柔又优雅', ['优雅', '友善', '审美好'], '这个时候出生的宝宝,长得秀气、性格随和,特别讨人喜欢。天生懂礼貌、爱和平,审美也好,小小年纪就知道什么是好看。', ['设计师', '外交官', '律师', '策展人', '时尚买手']], 天蝎: ['10.24 – 11.22', '玫瑰酒红', '#B0485C', '像一颗红宝石,深邃又有力量', ['专注', '坚韧', '重情义'], '这个时候出生的宝宝,眼神里有一股专注劲儿,认定的事情会坚持到底。外表安静、内心热烈,对家人朋友特别真心,是最靠得住的那个人。', ['心理学家', '科研人员', '外科医生', '投资人', '侦探小说家']], 射手: ['11.23 – 12.21', '落日橙', '#F29B4B', '像旅途中的落日,自由又温暖', ['乐观', '爱自由', '幽默'], '这个时候出生的宝宝,天性乐观、笑点很低,是家里的开心果。喜欢跑跑跳跳、到处探索,对远方充满向往,长大后会去看很大的世界。', ['旅行家', '探险家', '大学老师', '体育教练', '外交官']], 摩羯: ['12.22 – 1.19', '松石绿', '#3E9A88', '像山间的松林,沉稳又有力量', ['踏实', '有责任心', '目标感强'], '这个时候出生的宝宝,小小年纪就很懂事,做事有始有终、不轻易放弃。安静里藏着大大的志向,一步一个脚印,未来一定会很了不起。', ['工程师', '建筑师', '科学家', '管理者', '法官']] }; (function () { const M = $('#modalZodiac'); if (!M) return; function fill(n) { const z = ZODIAC[n]; if (!z) return; const el_ = (window.ZODIAC_ELEMENT || {})[n] || 'fire', th = (window.ZODIAC_THEME || {})[el_] || {}; M.className = M.className.replace(/\bel-\w+/g, '').trim() + ' el-' + el_; M.style.setProperty('--zc', z[2]); if (th.band) { M.style.setProperty('--zb', th.band); M.style.setProperty('--zbody', th.body); M.style.setProperty('--zi', th.ink); } $('#zdGlyph').innerHTML = window.zodiacIcon ? window.zodiacIcon(n, 30, 1.7) : ''; $('#zdName').textContent = n + '宝宝'; $('#zdEl').textContent = th.name || ''; $('#zdRange').textContent = z[0] + ' · 按预产期推算'; $('#zdWords').textContent = th.words || ''; // 星图 const st = (window.ZODIAC_STARS || {})[n], sky = $('#zdSky'); sky.innerHTML = st ? st[1].map(([a, b]) => '').join('') + st[0].map((p, i) => '').join('') : ''; const mo = (window.ZODIAC_MOTIF || {})[el_]; $('#zdMotif').innerHTML = mo ? mo.map(d => '').join('') : ''; $('#zdColor').textContent = z[1]; $('#zdColorTip').textContent = z[3]; const T = $('#zdTraits'); T.innerHTML = ''; z[4].forEach(t => T.appendChild(el('span', '', t))); $('#zdPara').textContent = z[5]; const J = $('#zdJobs'); J.innerHTML = ''; z[6].forEach(t => J.appendChild(el('span', '', t))); M.scrollTop = 0; } window.addEventListener('mb-zodiac', e => { const names = (e.detail && e.detail.names || []).filter(n => ZODIAC[n]); if (!names.length) return; const sw = $('#zdSwitch'); sw.innerHTML = ''; sw.hidden = names.length < 2; if (names.length > 1) names.forEach((n, i) => { const b = el('button', i ? '' : 'on'); b.type = 'button'; b.innerHTML = (window.zodiacIcon ? window.zodiacIcon(n, 12, 2.2) : '') + '' + n + '宝宝'; b.addEventListener('click', () => { $$('button', sw).forEach(x => x.classList.toggle('on', x === b)); fill(n); }); sw.appendChild(b); }); fill(names[0]); openModal('modalZodiac'); }); })(); /* ================= 意见反馈(2026-10-04) ================= 用户:「在我的页面里加一个客服反馈,功能上的反馈、功能上的建议」。 入口:「我的」常用服务 #meFeedback;「帮助与反馈」里的「提交反馈或建议」行(data-fb)。 提交:POST /api/mp/public/mama-box/feedback?openid=&unionid= {kind,text,images,contact,ctx} 服务端规则(box_app FeedbackService):5~500 字、截图只认本站 /api/upload 地址、每人每天 10 条。 截图:前端先压到长边 1600 的 JPEG 再传(手机原图动辄 5MB+),最多 3 张。 */ (function () { const M = $('#modalFeedback'); if (!M) return; const ta = $('#fbText'), cnt = $('#fbCount'), add = $('#fbAdd'), file = $('#fbFile'), box = $('#fbImgs'), btn = $('#fbSubmit'); let kind = 'idea', imgs = [], busy = 0; // imgs: [{el, url|null}] const API = '/api/mp/public/mama-box/feedback'; const ident = () => { const o = hashParam('openid'), u = hashParam('unionid'); return o ? 'openid=' + encodeURIComponent(o) + (u ? '&unionid=' + encodeURIComponent(u) : '') : ''; }; $$('.fb-kind', M).forEach(b => b.addEventListener('click', () => { kind = b.dataset.kind; $$('.fb-kind', M).forEach(x => { x.classList.toggle('on', x === b); x.setAttribute('aria-checked', x === b ? 'true' : 'false'); }); ta.placeholder = kind === 'bug' ? '哪个功能、怎么操作的时候出了问题?截个图会更快定位哦' : kind === 'idea' ? '想要什么新功能,或者哪里可以做得更好?' : '想对我们说的话…'; })); ta.addEventListener('input', () => { cnt.textContent = ta.value.length + '/500'; }); const syncAdd = () => add.classList.toggle('hide', imgs.length >= 3); function shrink(f) { return new Promise(res => { const u = URL.createObjectURL(f), im = new Image(); im.onload = () => { const k = Math.min(1, 1600 / Math.max(im.width, im.height)), c = document.createElement('canvas'); c.width = Math.round(im.width * k); c.height = Math.round(im.height * k); c.getContext('2d').drawImage(im, 0, 0, c.width, c.height); URL.revokeObjectURL(u); c.toBlob(b => res(b || f), 'image/jpeg', .85); }; im.onerror = () => { URL.revokeObjectURL(u); res(f); }; im.src = u; }); } file.addEventListener('change', () => { const f = file.files && file.files[0]; file.value = ''; if (!f || imgs.length >= 3) return; const it = { url: null, el: el('span', 'fb-thumb up') }; const pv = document.createElement('img'); pv.alt = ''; pv.src = URL.createObjectURL(f); it.el.appendChild(pv); const x = el('button', '', '×'); x.type = 'button'; x.setAttribute('aria-label', '删除这张'); x.addEventListener('click', () => { imgs = imgs.filter(i => i !== it); it.el.remove(); syncAdd(); }); it.el.appendChild(x); box.insertBefore(it.el, add); imgs.push(it); syncAdd(); busy++; shrink(f).then(b => { const fd = new FormData(); fd.append('file', b, 'feedback.jpg'); return fetch('/api/upload', { method: 'POST', body: fd }); }) .then(r => r.json()).then(j => { it.url = j && j.result && j.result.url || null; if (!it.url) throw 0; it.el.classList.remove('up'); }) .catch(() => { toast('截图上传失败,可以删掉重新选'); it.el.classList.remove('up'); it.el.classList.add('bad'); }) .finally(() => { busy--; }); }); function renderMine(list) { const wrap = $('#fbMine'), L = $('#fbMineList'); if (!list || !list.length) { wrap.hidden = true; return; } wrap.hidden = false; L.innerHTML = ''; list.slice(0, 5).forEach(f => { const d = el('div', 'fb-item'), h = el('div', 'fb-item-h'); h.appendChild(el('b', '', f.kindName)); h.appendChild(el('span', '', fmtWhen(f.at))); h.appendChild(el('span', 'fb-st' + (f.status === 'done' ? ' done' : ''), f.status === 'done' ? '已处理' : '已收到')); d.appendChild(h); d.appendChild(el('div', '', f.text)); if (f.reply) d.appendChild(el('div', 'fb-reply', '兜爸心选回复:' + f.reply)); L.appendChild(d); }); } function loadMine() { const q = ident(); if (!q) return; fetch(API + '?' + q).then(r => r.json()).then(j => renderMine(j && j.result && j.result.list)).catch(() => {}); } function open() { openModal('modalFeedback'); loadMine(); } const entry = $('#meFeedback'); if (entry) entry.addEventListener('click', open); document.addEventListener('click', e => { const r = e.target.closest && e.target.closest('[data-fb]'); if (r) { e.preventDefault(); open(); } }); btn.addEventListener('click', () => { const text = ta.value.trim(); if (text.length < 5) { toast('再多写几个字吧(至少 5 个字)'); ta.focus(); return; } if (busy) { toast('截图还在上传,稍等一下'); return; } const q = ident(); if (!q) { toast('请从小程序里打开后再提交'); return; } btn.disabled = true; btn.textContent = '提交中…'; fetch(API + '?' + q, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, text, images: imgs.map(i => i.url).filter(Boolean), contact: $('#fbContact').value.trim(), ctx: { ver: VER, ua: navigator.userAgent.slice(0, 300), from: 'me' } }) }) .then(r => r.json()).then(j => { if (!j || j.code !== 200) { toast((j && j.message) || '提交失败,请稍后再试'); return; } toast('收到啦,谢谢你的反馈 💛'); ta.value = ''; cnt.textContent = '0/500'; $('#fbContact').value = ''; imgs.forEach(i => i.el.remove()); imgs = []; syncAdd(); loadMine(); }) .catch(() => toast('网络不太好,请稍后再试')) .finally(() => { btn.disabled = false; btn.textContent = '提交'; }); }); })(); /* ================= 孕期信息 ================= 预产期是唯一的「源」:填了它就按今天倒推出孕周 + 额外天数; 没填时那两格 disabled(置灰不可编辑)。算出来之后用户可以微调, 微调会反推回预产期,保证「孕 N 周」和「预产期 X · 还有 N 天」永远自洽。 「今日体重」已按需求删除。 注:DAY_MS / TERM / midnight / clampGA / gaFromDue / dueFromGA / addMonths / DEFAULT_DUE 都提到文件最前面去了 —— 孕期计算和 AI 小结的日期推算要用,那边比这里早。 */ /* 孕周 → 这周宝宝大概多大,每项 [emoji, 名字, 克数]。 第 24 周写「玉米 / 600 克」,和 AI 小结里那句「差不多一根玉米那么长了…600g」 是同一套说法,两处不会打架。 只列到 40 周,不足 4 周或超过 40 周的都夹到两端。 emoji 一律挑 Unicode 12 以前就有的,新版 emoji 在旧安卓上会变豆腐块。 */ const SIZE = { 4: ['🌱', '一颗小芽', 1], 5: ['🌱', '一粒苹果籽', 1], 6: ['🍇', '一颗小豌豆', 1], 7: ['🍇', '一颗蓝莓', 2], 8: ['🍇', '一颗覆盆子', 3], 9: ['🍇', '一颗葡萄', 4], 10: ['🍓', '一颗草莓', 7], 11: ['🍋', '一个青柠', 14], 12: ['🍑', '一颗李子', 18], 13: ['🍋', '一个柠檬', 23], 14: ['🍑', '一个桃子', 43], 15: ['🍎', '一个苹果', 70], 16: ['🥑', '一个牛油果', 100], 17: ['🍐', '一只洋梨', 140], 18: ['🍅', '一个番茄', 190], 19: ['🥭', '一个芒果', 240], 20: ['🍌', '一根香蕉', 300], 21: ['🥕', '一根胡萝卜', 360], 22: ['🥭', '一个木瓜', 430], 23: ['🍆', '一根小茄子', 500], 24: ['🌽', '一根玉米', 600], 25: ['🥬', '一棵小白菜', 660], 26: ['🥒', '一根西葫芦', 760], 27: ['🥦', '一朵花菜', 875], 28: ['🍆', '一个大茄子', 1000], 29: ['🥥', '一个椰子', 1150], 30: ['🥬', '一棵卷心菜', 1320], 31: ['🍍', '一个菠萝', 1500], 32: ['🥬', '一棵大白菜', 1700], 33: ['🍈', '一个哈密瓜', 1920], 34: ['🍈', '一个蜜瓜', 2150], 35: ['🥥', '一个大椰子', 2380], 36: ['🥬', '一棵生菜', 2620], 37: ['🥒', '一根冬瓜', 2860], 38: ['🎃', '一个小南瓜', 3080], 39: ['🍉', '一个小西瓜', 3290], 40: ['🍉', '一个大西瓜', 3460] }; /* 克数上千就改说「公斤」——「约 3290 克」没人这么讲话 */ function babySize(w) { const s = SIZE[Math.max(4, Math.min(40, w))] || SIZE[24]; return { emoji: s[0], name: s[1], /* g = 原始克数。分享卡的「比大小」模板要按体积开三次方算半径, 从 txt 里反解「1.5 公斤」太别扭,直接把数字带出来 */ g: s[2], txt: s[2] >= 1000 ? (s[2] / 1000).toFixed(1) + ' 公斤' : s[2] + ' 克' }; } let pickedAvatar = ''; /* 孕期档案里从头像面板选中的正式 URL,见 openAvatarPanel */ const pgName = $('#pgName'), pgDue = $('#pgDue'), pgWeek = $('#pgWeek'), pgDay = $('#pgDay'), pgHint = $('#pgHint'); /* 不用 new Date('2024-12-22') —— 那是 UTC 解析,东八区会掉到前一天 */ function parseDay(v) { if (!/^\d{4}-\d{2}-\d{2}$/.test(v || '')) return null; const p = v.split('-'), d = new Date(+p[0], +p[1] - 1, +p[2]); return isNaN(d.getTime()) ? null : d; } function toISO(d) { const p = n => (n < 10 ? '0' : '') + n; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); } /* 2026-10-01 资料弹窗改版后:孕周两格不再锁(「按当前孕周」那页本来就是让人从零填的), 这里只负责没预产期时把两格清空。函数名留着,调用点不用动。 */ function lockWeekFields(locked) { if (locked) { pgWeek.value = ''; pgDay.value = ''; } } /* 缓存戳重载:只换查询串(?t=…),两处都不能丢: ① URL 已有 query 时必须用 & 拼接 —— 直接拼 '?t=' 会把第二个 ? 吃进已有参数值里 (?openid=abc001?t=123 → openid 变脏); ② **hash 必须原样拼回去** —— openid/unionid 只活在 hash 里(宿主注入的形态是 …/mama-box/#openid=…&unionid=…),丢掉 hash 再重载 = 把身份一起丢了 → applyRemoteProfile 走访客分支:误报「未获取到微信身份」、孕周清空、 昵称/头像也拉不到(后端其实都保留着)。 调用它的两个场景都要求身份还在:① 退出授权登录(只翻授权状态,资料保留) ② 新用户首次完善资料(重载后要按服务端孕周显示)。 */ function bustReload() { const b = location.href.split('#')[0]; location.replace(b + (b.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now() + location.hash); } /* state.due / week / day 在 fetch 返回前是内置默认 —— 不能在脚本加载时 直接把默认值写进表单(新用户会看到「小汤圆/孕27周」假数据)。 改为:打开「我的资料」弹窗时按**当前档案**回填(fillProfileForm)。 */ lockWeekFields(true); /* 打开资料弹窗时的表单回填:全部以服务端档案为准(最后写入者胜)。 新用户(未完善资料):昵称/小名/预产期/孕周全空 + 孕周锁定,只留 placeholder。 */ let filledDue = ''; function fillProfileForm() { pickedAvatar = ''; $('#pfName').value = state.userName || ''; /* 方案A(2026-10-10):未授权 → 妈妈栏换成授权提示,旧昵称/头像不露出 */ var _ua = state.authorized === false; ['#pfMe', '#pfMeTip'].forEach(function (sel) { var el = $(sel); if (el) el.style.display = _ua ? 'none' : ''; }); var _uh = $('#pfUnauth'); if (_uh) _uh.style.display = _ua ? '' : 'none'; pgName.value = state.babyName || ''; $$('#pgSex .pick').forEach(b => b.classList.toggle('is-active', (b.dataset.sex || 'unknown') === (state.babySex || 'unknown'))); if (state.postpartum) { /* 产后态:预产期已过,**不要**再按它算出「孕 42 周」回填进表单(那是荒谬值)。 预产期本身照常显示/可改;孕周两格留空并锁住。 */ pgDue.value = state.due ? toISO(state.due) : ''; pgWeek.value = ''; pgDay.value = ''; lockWeekFields(true); } else if (state.due) { pgDue.value = toISO(state.due); const ga = gaFromDue(state.due); pgWeek.value = Math.floor(ga / 7); pgDay.value = ga % 7; lockWeekFields(false); } else { pgDue.value = ''; pgWeek.value = ''; pgDay.value = ''; lockWeekFields(true); } filledDue = pgDue.value || ''; syncDuePh(); setPgMode('due'); if (pgLmp) { pgLmp.value = ''; pgLmp.dispatchEvent(new Event('change')); } /* 头像预览跟随卡片(有正式头像才显示;新用户空圆 + +号) */ const _me = $('#meAvatar'); if (_me && _me.classList.contains('has-img') && meAvatarImg && meAvatarImg.src) { avPreview.src = meAvatarImg.src; avBig.classList.add('has-img'); } else { avPreview.removeAttribute('src'); avBig.classList.remove('has-img'); } } /* 预产期按钮上的文字 + 顶部结果条,都从 #pgDue 现算(唯一数据源)。 函数名沿用 syncDuePh(原来只管「- 年 - 月 - 日」占位层),调用点不用动。 */ const WK = '日一二三四五六'; function fmtDay(d) { return d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日 周' + WK[d.getDay()]; } function syncDuePh() { const due = parseDay(pgDue.value); const t = $('#pgDueTxt'); /* 孕期档案两行(2026-10-11 改版):右侧值都从 #pgDue 现算 */ if (t) { t.textContent = due ? due.getFullYear() + ' 年 ' + (due.getMonth() + 1) + ' 月 ' + due.getDate() + ' 日' : '去填写'; t.classList.toggle('is-empty', !due); } const wt = $('#pgWeekTxt'); if (wt) { const lf = due ? Math.round((midnight(due) - midnight(new Date())) / DAY_MS) : null; const g = due ? gaFromDue(due) : 0; wt.textContent = !due ? '去填写' : lf < 0 ? '宝宝已出生' : '孕 ' + Math.floor(g / 7) + ' 周' + (g % 7 ? ' +' + (g % 7) + ' 天' : ''); wt.classList.toggle('is-empty', !due); } const sum = $('#pgSum'); if (!sum) return; sum.classList.toggle('is-set', !!due); if (!due) { $('#pgSumT').textContent = '还没填预产期'; $('#pgSumS').textContent = '选一种方式填一下,马上就好'; return; } const left = Math.round((midnight(due) - midnight(new Date())) / DAY_MS); if (left < 0) { $('#pgSumT').textContent = '宝宝已经出生啦'; $('#pgSumS').textContent = '预产期 ' + fmtDay(due); return; } const ga = gaFromDue(due); $('#pgSumT').textContent = '孕 ' + Math.floor(ga / 7) + ' 周' + (ga % 7 ? ' +' + (ga % 7) + ' 天' : ''); $('#pgSumS').textContent = (left ? '还有 ' + left + ' 天见面' : '就是今天啦') + ' · 预产期 ' + (due.getMonth() + 1) + '月' + due.getDate() + '日'; } /* 三种填法切换(只切面板;数据都落在 #pgDue) */ function setPgMode(m) { $$('#pgMode .seg-b').forEach(b => b.classList.toggle('is-active', b.dataset.mode === m)); $$('#modalProfile .pg-pane').forEach(p => p.classList.toggle('is-on', p.dataset.pane === m)); } $$('#pgMode .seg-b').forEach(b => b.addEventListener('click', () => { setPgMode(b.dataset.mode); haptic(); })); /* 孕周步进:空着时从 12 周起步(一般建档时间),天数 6 再 + 进位到下一周 */ $$('#modalProfile .stp-b').forEach(b => b.addEventListener('click', () => { let w = parseInt(pgWeek.value, 10), d = parseInt(pgDay.value, 10); if (isNaN(w)) w = 12; if (isNaN(d)) d = 0; const k = +b.dataset.d; if (b.dataset.stp === 'w') w += k; else { d += k; if (d > 6) { d = 0; w += 1; } if (d < 0) { d = w > 0 ? 6 : 0; if (w > 0) w -= 1; } } pgWeek.value = Math.min(42, Math.max(0, w)); pgDay.value = d; pgWeek.dispatchEvent(new Event('change')); haptic(); })); /* 末次月经第一天 + 280 天 = 预产期 */ const pgLmp = $('#pgLmp'); if (pgLmp) pgLmp.addEventListener('change', () => { const l = parseDay(pgLmp.value); const t = $('#pgLmpTxt'); if (t) { t.textContent = l ? fmtDay(l) : '点这里选末次月经第一天'; t.parentNode.classList.toggle('is-set', !!l); } if (!l) return; pgDue.value = toISO(new Date(l.getFullYear(), l.getMonth(), l.getDate() + 280)); pgDue.dispatchEvent(new Event('change')); }); /* 小名建议:点一下填进去 */ $$('#pgNameChips .nm-c').forEach(b => b.addEventListener('click', () => { pgName.value = b.textContent; haptic(); })); /* 预产期一变,孕周 / 额外天数自动算出来 */ pgDue.addEventListener('change', () => { syncDuePh(); const due = parseDay(pgDue.value); if (!due) { lockWeekFields(true); return; } const ga = gaFromDue(due); pgWeek.value = Math.floor(ga / 7); pgDay.value = ga % 7; lockWeekFields(false); }); /* 微调孕周 / 天数 → 反推预产期 */ [pgWeek, pgDay].forEach(el => el.addEventListener('change', () => { const w = Math.min(42, Math.max(0, parseInt(pgWeek.value, 10) || 0)); const d = Math.min(6, Math.max(0, parseInt(pgDay.value, 10) || 0)); pgWeek.value = w; pgDay.value = d; pgDue.value = toISO(dueFromGA(w * 7 + d)); syncDuePh(); })); /* 性别三选一 */ /* 性别:男孩 / 女孩二选一,再点一下已选的就取消(= 还不知道,保存成 unknown) */ $$('#pgSex .pick').forEach(b => b.addEventListener('click', () => { const on = !b.classList.contains('is-active'); $$('#pgSex .pick').forEach(x => x.classList.toggle('is-active', on && x === b)); haptic(); })); /* ---------- 孕期档案 · 滚轮选择(2026-10-11,照数胎动的孕期档案) ---------- week:孕 0–42 周 × 0–6 天,下面实时显示对应预产期;due:年 / 月 / 日,下面显示对应孕周。 确定后写 #pgWeek/#pgDay 或 #pgDue 并 dispatch change —— 换算、行内文字都走原来那两个监听,不另算一套。 */ const PK_H = 40; /* 每行高度,CSS .pk-col i 同值 */ const pkSheet = $('#pkSheet'), pkScrim = $('#pkScrim'), pkWheels = $('#pkWheels'); let pkMode = 'week'; function pkCol(key, items, sel) { return '
' + items.map((t, i) => '' + t + '').join('') + '
'; } function pkIdx(col) { return Math.max(0, Math.min(col.children.length - 1, Math.round(col.scrollTop / PK_H))); } function pkSet(col, i) { col.scrollTop = i * PK_H; $$('i', col).forEach((x, k) => x.classList.toggle('on', k === i)); } function pkGet(k) { const c = $('.pk-col[data-k="' + k + '"]', pkWheels); return c ? pkIdx(c) : 0; } const pkYears = () => { const y = new Date().getFullYear(); return [y - 1, y, y + 1]; }; function pkDayCol(keep) { const ys = pkYears(), y = ys[pkGet('y')], m = pkGet('m'); const n = new Date(y, m + 1, 0).getDate(); const old = $('.pk-col[data-k="d"]', pkWheels); const tmp = document.createElement('div'); tmp.innerHTML = pkCol('d', Array.from({ length: n }, (_, i) => (i + 1) + ' 日')); const col = tmp.firstChild; old.replaceWith(col); pkBind(col); pkSet(col, Math.min(keep, n - 1)); } function pkResult() { if (pkMode === 'week') { const due = dueFromGA(pkGet('w') * 7 + pkGet('dd')); $('#pkResL').textContent = '预产期'; $('#pkResV').textContent = toISO(due); } else { const ys = pkYears(), due = new Date(ys[pkGet('y')], pkGet('m'), pkGet('d') + 1); const left = Math.round((midnight(due) - midnight(new Date())) / DAY_MS), g = gaFromDue(due); $('#pkResL').textContent = '对应孕周'; $('#pkResV').textContent = left < 0 ? '已过预产期' : left > TERM ? '还没怀上哦' : '孕 ' + Math.floor(g / 7) + ' 周' + (g % 7 ? ' +' + (g % 7) + ' 天' : ''); } } function pkBind(col) { let tm = 0; col.addEventListener('scroll', () => { clearTimeout(tm); tm = setTimeout(() => { const i = pkIdx(col); $$('i', col).forEach((x, k) => x.classList.toggle('on', k === i)); if (col.dataset.k === 'y' || col.dataset.k === 'm') pkDayCol(pkGet('d')); pkResult(); }, 90); }, { passive: true }); col.addEventListener('click', e => { const it = e.target.closest('i'); if (it) col.scrollTo({ top: +it.dataset.i * PK_H, behavior: 'smooth' }); }); } function openPicker(mode) { pkMode = mode; const cur = parseDay(pgDue.value); if (mode === 'week') { $('#pkT').textContent = '你现在怀孕多久了?'; $('#pkSub').textContent = '按孕周推算预产期'; $('#pkAlt').textContent = '知道预产期?直接选日期'; pkWheels.innerHTML = pkCol('w', Array.from({ length: 43 }, (_, i) => '孕 ' + i + ' 周')) + pkCol('dd', Array.from({ length: 7 }, (_, i) => i + ' 天')); const g = cur ? gaFromDue(cur) : 12 * 7; $$('.pk-col', pkWheels).forEach(pkBind); pkSet($('.pk-col[data-k="w"]', pkWheels), Math.floor(g / 7)); pkSet($('.pk-col[data-k="dd"]', pkWheels), g % 7); } else { $('#pkT').textContent = '预产期是哪一天?'; $('#pkSub').textContent = '以产检单 / B 超单上写的为准'; $('#pkAlt').textContent = '不知道预产期?按孕周填'; const d = cur || dueFromGA(12 * 7), ys = pkYears(); pkWheels.innerHTML = pkCol('y', ys.map(y => y + ' 年')) + pkCol('m', Array.from({ length: 12 }, (_, i) => (i + 1) + ' 月')) + pkCol('d', ['1 日']); $$('.pk-col', pkWheels).forEach(pkBind); pkSet($('.pk-col[data-k="y"]', pkWheels), Math.max(0, ys.indexOf(d.getFullYear()))); pkSet($('.pk-col[data-k="m"]', pkWheels), d.getMonth()); pkDayCol(d.getDate() - 1); } pkWheels.className = 'pk-wheels n' + $$('.pk-col', pkWheels).length; pkResult(); pkScrim.classList.add('show'); pkSheet.classList.add('show'); haptic(); } function closePicker() { pkScrim.classList.remove('show'); pkSheet.classList.remove('show'); } $('#pfWeekRow').addEventListener('click', () => openPicker('week')); $('#pfDueRow').addEventListener('click', () => openPicker('due')); $('#pkAlt').addEventListener('click', () => openPicker(pkMode === 'week' ? 'due' : 'week')); pkScrim.addEventListener('click', closePicker); $('#pkOk').addEventListener('click', () => { if (pkMode === 'week') { pgWeek.value = pkGet('w'); pgDay.value = pkGet('dd'); pgWeek.dispatchEvent(new Event('change')); } else { const ys = pkYears(); pgDue.value = toISO(new Date(ys[pkGet('y')], pkGet('m'), pkGet('d') + 1)); pgDue.dispatchEvent(new Event('change')); } closePicker(); haptic(); }); /* 环的目标值改了要顺手清掉上一轮动画的终值,否则 animateRings 不会重跑 */ function setRing(el, p) { if (!el) return; el.dataset.ring = Math.max(0, Math.min(1, p)).toFixed(3); el.style.strokeDashoffset = ''; } /* 首页头部 2026-10-01 改版:见面倒计时 / 孕程条(骑着本周水果)/ 大小一句话。三种状态都走这里,别各写各的。 进度条先归 0 再隔一帧写目标值,才有「长出来」的动画(同 replayArBar 的道理)。 */ function setHero(o) { const bar = $('#heroBar'); if (!bar) return; $('#heroDueT').innerHTML = o.due; /* 2026-10-12 用户:「首页最上面像卷心菜,图标没用统一的」→ 进度条上的小水果也走手绘图标库,库没加载退回 emoji */ setHandIco($('#heroFruit'), o.hi, o.emoji || ''); $('#heroSize').textContent = o.size || ''; $('#heroWeekU').textContent = o.unit == null ? '周' : o.unit; $('.hero-pane').classList.toggle('is-born', !!o.born); bar.style.width = '0%'; requestAnimationFrame(() => requestAnimationFrame(() => { bar.style.width = Math.max(0, Math.min(100, o.pct || 0)) + '%'; })); } /* ---------- 页面里那些写死的示例日期 ---------- 时光轴、珍贵时刻、首页两张小卡、指南标题、「记一下」抬头、搜索热词, 原来全是设计稿留下的固定字符串(「8月15日 · 孕20周」「孕24周」「9月3日 周二」)。 孕周是按打开当天现算的,这些一天都对不上,档案打印出来最露馅。 现在 HTML 里只留一个标记,文字全由这里生成: data-ga="20" 这件事发生在孕 20 周 → 日期按预产期倒推 data-ga="now" 就是今天 data-wk="n" 只要一个孕周数字(如「孕 26 周胎动多少正常」) data-wk="cn" 要「孕 26 周」这种带字的写法 时光轴写「日期 · 孕周」,珍贵时刻写「孕周 · 日期」—— 两边顺序本来就是反的, 按 class 分流,别为了统一去改设计。 */ function gaToDate(ga) { const d = midnight(new Date()); d.setDate(d.getDate() - ((state.week * 7 + state.day) - ga)); return d; } const mdTxt = d => (d.getMonth() + 1) + '月' + d.getDate() + '日'; const gaTxt = g => '孕' + Math.floor(g / 7) + '周' + (g % 7 ? '+' + (g % 7) : ''); /* 常规产检节奏:28 周前四周一次,之后两周一次,36 周后每周一次。 取第一个还没到的孕周当「下次产检」;都过了就显示预产期当天。 */ const CHECKUPS = [12, 16, 20, 24, 28, 30, 32, 34, 36, 37, 38, 39, 40]; function syncStaticDates() { const gaNow = state.week * 7 + state.day; $$('[data-ga]').forEach(n => { const raw = n.dataset.ga; const g = raw === 'now' ? gaNow : +raw * 7; const day = raw === 'now' ? '今天' : mdTxt(gaToDate(g)); n.textContent = n.classList.contains('mo-meta') ? gaTxt(g) + ' · ' + day : day + ' · ' + gaTxt(g); }); $$('[data-wk]').forEach(n => { n.textContent = n.dataset.wk === 'cn' ? '孕 ' + state.week + ' 周' : state.week; }); /* ⚠️ #calNext 归**卡片接口**优先(与孕检日历应用同一套排期、还支持"改过预约日以预约日为准"); 本地按预产期推算只在接口没数据时兜底。两者并行返回、先后不定,所以看 cardsOwned.cal。 (curve 卡的 [data-wk] 由 applyRemoteCards 摘掉,这里自然跳过,无需额外判断。) */ if (!cardsOwned.cal) { const next = CHECKUPS.find(w => w * 7 > gaNow); $('#calNext').textContent = mdTxt(next ? gaToDate(next * 7) : state.due); } const t = new Date(); $('#noteWhen').textContent = mdTxt(t) + ' ' + WEEKDAY[t.getDay()] + ' · ' + gaTxt(gaNow); } /* 把 state 里的孕期信息刷到「我的」孕期卡 + 首页头部 + 档案页顶部 */ /* 成长数据面板的空态开关(CSS 见 mama-box.css 的 .data-empty 段): 未建档 / 产后 = 面板里那些数字(58.2kg、日均胎动 31 次、双顶径 6.1cm、体重曲线) **一个都没有接口数据源**,对这类用户是纯假数据 → 整块隐藏改显示引导。 ⚠️ 有真实孕周的用户仍看演示值:这是与「AI 小结演示文案」同一批的遗留, 要一起清得先有数据源,别只在这里删。 */ function setGrowthDataEmpty(on) { const p = $('.seg-panel[data-panel="data"]'); if (p) p.classList.toggle('is-empty', !!on); } /* 「我的」页头部名字下面那枚小标签:孕周 / 宝宝月龄;未完善时隐藏 */ function setMeTag(t) { const el = $('#meTag'); if (!el) return; el.textContent = t || ''; el.classList.toggle('hide', !t); } /* 把当前孕期状态广播给首页彩蛋 js/bump.js(宝宝在肚皮底下动按孕周分阶段,2026-10-04)。 syncPregnancy 有好几个提前 return 的分支,所以用微任务等它整个跑完再发,拿到的是最终状态。 */ function pubPreg() { const d = { week: (state.postpartum || state.pregEmpty) ? null : state.week, day: state.day, postpartum: !!state.postpartum, empty: !!state.pregEmpty, due: (state.due && !state.pregEmpty) ? [state.due.getFullYear(), state.due.getMonth() + 1, state.due.getDate()] : null }; // 2026-10-04 头图星座牌按预产期算 window.__mbPreg = d; /* 11-02 孕妈广场页(guangchang/)按孕周段排帖子,它不加载 app.js,靠这个键拿孕周:{w 孕周|0, pp 产后} */ try { localStorage.setItem('mmbox.gcwk.v1', JSON.stringify({ w: d.week || 0, pp: d.postpartum ? 1 : 0 })); } catch (e) {} window.dispatchEvent(new CustomEvent('mb-preg', { detail: d })); } function syncPregnancy() { Promise.resolve().then(pubPreg); markStageLocks(); /* 孕周变了 → 「16周+」角标跟着变 */ /* 孕期小知识:只认真实孕周(自己判显隐,见 renderTips) */ renderTips(); nurseryRender(); /* ---- 产后态:宝宝已出生 → 一律不显示孕周数字(2026-09-22) ---- ⚠️ 必须放在 pregEmpty 之前:产后返回体的 week 也是 null, 落到空态分支会给**已经生产**的用户弹「完善孕期信息」引导。 文案集中在这里,要改只改这一段: · 首页 hero:已出生 / N 天 / 「已经生活了 N 天」 · 我的孕期卡:宝宝已出生 N 天 · 记录宝宝的成长时光 · 档案页:宝宝已出生 N 天 · 孕期陪伴已结束,继续记录宝宝的成长 ⚠️ hero 的「孕周」两个字是 #heroWeek **紧邻的兄弟 span**,JS 只写 #heroWeek, 所以这里要顺手把它清空,否则会读成「已出生 孕周」。 */ if (state.postpartum) { const nm = state.babyName || '宝宝'; const ageTxt = state.babyAgeTxt || '宝宝已出生'; const ageDays = state.babyAgeDays; /* ① 我的 · 孕期卡 */ const pcT = $('#pcTitle'), pcS = $('#pcSub'), pcB = $('#pcBar'), pcR = $('#pcRing'); if (pcT) pcT.textContent = ageTxt; setMeTag(ageTxt); if (pcS) pcS.textContent = '记录宝宝的成长时光'; if (pcB) pcB.style.setProperty('--p', '100%'); if (pcR) { const C = 2 * Math.PI * parseFloat(pcR.getAttribute('r')); pcR.setAttribute('stroke-dasharray', C.toFixed(2) + ' ' + C.toFixed(2)); } $$('.pc-stage span').forEach((s) => { s.textContent = s.textContent.split(' · ')[0]; s.classList.remove('cur'); }); /* ② 首页 hero */ const hw = $('#heroWeek'); if (hw) hw.textContent = '已出生'; setHero({ due: nm + '已经来到身边', emoji: '👶', hi: 'baby', size: '', unit: '', pct: 100, born: true }); const hp = $('#heroPlus'); if (hp) hp.textContent = (ageDays == null ? '' : ageDays + '天'); const hd = $('#heroDays'); if (hd) hd.textContent = (ageDays == null ? '--' : ageDays); const hwho = $('#heroWho'); if (hwho) hwho.textContent = nm; const hwhere = document.querySelector('.hd-where'); if (hwhere) hwhere.textContent = '来到这个世界'; /* ③ 档案页 */ const arN = $('#arTitleName'); if (arN) arN.textContent = nm; const arL1 = $('#arLine1'); if (arL1) arL1.textContent = ageTxt; const arD = $('#arDays'); if (arD) arD.textContent = (ageDays == null ? '--' : ageDays); setHandIco($('#arSizeArt'), 'baby', '👶'); const arL2 = $('#arLine2'); if (arL2) arL2.textContent = '孕期陪伴已结束,继续记录宝宝的成长'; const arP = $('#arPct'); if (arP) arP.textContent = '100%'; setArBar(100); const gwt = $('#genWeekTag'); if (gwt) gwt.textContent = '产后'; $$('[data-ga]').forEach(n => { n.textContent = '--'; }); $$('[data-wk]').forEach(n => { n.textContent = '--'; }); /* ⚠️ #calNext(首页「孕检日历」卡)**没有 data-wk / data-ga 钩子**,上面两条循环抓不到它。 而 init 早已按内置默认(孕 27 周)把它写成「9月29日」这类演示日期, 产后分支之后没人再改 → 会以假日期留在首页。这里显式归占位。 (2026-09-22 新用户实测:孕检日历 9月29日 / 孕期体重 58.2kg 两处漏网。) */ const _cnPost = $('#calNext'); if (_cnPost) _cnPost.textContent = '--'; setGrowthDataEmpty(true); /* 产后同样没有这些数据的来源,别留演示数字 */ syncBabyWords(); return; } /* ---- 新用户空态:未完善资料(无预产期)→ 全部显示引导占位,不读演示数据 ---- me 页已有两条引导(授权卡 + 孕期信息必填卡);这里把首页/档案页的 孕周数字换成引导文案,避免新用户看到"孕 27 周"这类假数据。 */ if (state.pregEmpty) { const nm = state.babyName || '宝宝'; /* 脚印时间轴:未建档整块隐藏 —— 交给 fpRender() 统一判(它同时看 footprint 的结论), 别再直接改 display:与本模块的另一个 owner 撞竞态(2026-09-22 实测漏过 degraded 场景)。 */ fpRender(); /* ① 我的 · 孕期卡 → 引导 */ $('#pcTitle').textContent = '待完善孕期信息'; setMeTag(''); $('#pcSub').textContent = '填好预产期,开启孕期陪伴'; $('#pcBar').style.setProperty('--p', '0%'); const rg = $('#pcRing'); if (rg) { const C = 2 * Math.PI * parseFloat(rg.getAttribute('r')); rg.setAttribute('stroke-dasharray', '0 ' + C.toFixed(2)); } $$('.pc-stage span').forEach((s) => { s.textContent = s.textContent.split(' · ')[0]; s.classList.remove('cur'); }); /* ② 首页 hero → 占位 */ $('#heroWeek').textContent = '--'; $('#heroPlus').textContent = ''; $('#heroDays').textContent = '--'; $('#heroWho').textContent = nm; setHero({ due: '填好预产期,开启倒计时', emoji: '🌱', hi: 'sprout', size: '', pct: 0 }); /* ③ 档案页 → 占位 */ $('#arTitleName').textContent = nm; $('#arLine1').textContent = '完善孕期信息后开启'; $('#arDays').textContent = '--'; $('#arLine2').textContent = '先去「孕期档案」填预产期'; $('#arPct').textContent = '0%'; setArBar(0); $('#genWeekTag').textContent = '--'; /* ⑤ 用户卡记录行:未完善 → 引导文案(数据源未定,先不给演示数字) */ const _rl = $('#meRecordLine'); if (_rl) _rl.textContent = '完善孕期信息,记录宝宝的成长记忆'; /* ⑥ 孕期衍生的静态演示位(生长曲线周数/孕检日历/时光轴日期)一并归中性占位, 不保留 HTML 里的演示默认值(孕27周/9月15日…) */ $$('[data-ga]').forEach(n => { n.textContent = '--'; }); $$('[data-wk]').forEach(n => { n.textContent = '--'; }); /* ⚠️ 上面注释里的「孕检日历」**并不被那两条循环覆盖**:#calNext 没有 data-wk / data-ga 属性,选择器抓不到。而 init 早已按内置默认(孕 27 周)把它写成「9月29日」, 且 applyRemoteProfile 在 pregEmpty 时提前 return —— 于是新用户首页上 只剩这一个孕周派生的假日期(2026-09-22 实测)。这里显式归占位。 */ const _cn = $('#calNext'); if (_cn) _cn.textContent = '--'; setGrowthDataEmpty(true); const _t = new Date(); $('#noteWhen').textContent = mdTxt(_t); syncBabyWords(); /* 未建档 → 不给「宝宝来信」提示(卡 + 红点一起收)。 ⚠️ 这里**必须**重算一次,不能只写 hideLetterCard():来信提示的唯一渲染入口是 renderLetterReminder(),它同时管卡和底 tab 红点。只藏卡会留下「红点有、卡没有」 (2026-09-22 定时复现:laixin 先回来亮红点、/profile 后回来藏卡,就是那个不一致)。 现在两处判据合成一条(pregEmpty 是硬门槛),谁先回来都收敛到同一结果。 */ renderLetterReminder(); return; } const week = state.week, day = state.day, name = state.babyName; setGrowthDataEmpty(false); /* 有真实孕周了:恢复面板(数据源接入前仍是演示值,见函数注释) */ /* 刚建档/填了孕产期 → 按新状态重算来信提示:此前因未建档被压住的未读,这时该出现 */ renderLetterReminder(); /* 脚印时间轴:建档状态变了要重算 —— 同样只经 fpRender(footprint 还没回来时它保持「加载中」) */ fpRender(); const days = week * 7 + day; // 一起生活了多少天 const weekTxt = '孕 ' + week + ' 周' + (day ? ' +' + day + ' 天' : ''); /* 剩余天数按 40 周足月倒推,比直接用 date 差值更贴合「孕周」这个语境 */ const left = Math.max(0, (40 - week) * 7 - day); const pct = Math.max(0, Math.min(100, Math.round(days / TERM * 100))); /* ⚠️ 这里原来还算了一个短格式 dueShort(「12月22日」), 专供「我的」页头像旁那行用。那行改成签名后 dueShort 就没人读了, 已一并删除 —— 只写不读的变量留着,下一个人会以为还有别处在用它。 */ let dueTxt = ''; if (state.due) { const d = state.due; dueTxt = '预产期 ' + d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日'; } /* ① 我的 · 孕期卡 */ $('#pcTitle').textContent = weekTxt; setMeTag(weekTxt); $('#pcSub').textContent = (dueTxt ? dueTxt + ' · ' : '') + '还有 ' + left + ' 天'; $('#pcBar').style.setProperty('--p', pct + '%'); /* 卡片左上角那个图标是一圈进度环,和这条进度条同源(同一个 pct), 不会出现「环走了一半、条走了三分之二」这种各说各话。 ⚠️ 周长按元素上的 r 现算,不写死常数 —— 哪天调了环的半径这里自动跟上; 写死的话只会静默画错一个角度,谁也看不出来。 ⚠️ 用 getAttribute('r') 而不是 ring.r.baseVal.value:后者 jsdom 没实现, 会让整个 smoke 直接炸在这一行。 */ const ring = $('#pcRing'); if (ring) { const C = 2 * Math.PI * parseFloat(ring.getAttribute('r')); ring.setAttribute('stroke-dasharray', (C * pct / 100).toFixed(2) + ' ' + C.toFixed(2)); } /* 进度条下方三段固定是早/中/晚期,高亮那一段跟着孕周走, 所以每次都把三段全部重写一遍,避免出现两个「孕中期」 */ const stages = ['孕早期', '孕中期', '孕晚期']; const cur = week <= 13 ? 0 : week <= 27 ? 1 : 2; $$('.pc-stage span').forEach((s, i) => { s.textContent = stages[i] + (i === cur ? ' · 当前' : ''); s.classList.toggle('cur', i === cur); }); /* ⚠️ 这里原来还有一行 if (dueShort) $('.me-info p').textContent = weekTxt + ' · 预产期 ' + dueShort; 已删除,别加回来。孕周信息没丢:本函数上面的 ① 孕期卡一直在显示, 那行本来就是重复的(同一屏往下滚一点就是孕期卡)。 ⚠️ 而且 $('.me-info p') 选的是**第一个**

。曾经那是一句话签名(已删), 现在第一个

是「已连续记录 42 天」—— 加回这行会把连续记录天数 静默冲成孕周,页面不报错,只是那个数字再也不对。 */ /* ② 首页头部:孕周环、+N 天、「小名,我们已经一起生活了 N 天」 */ $('#heroWeek').textContent = week; $('#heroPlus').textContent = '+' + day + '天'; $('#heroDays').textContent = days; $('#heroWho').textContent = name; { const sz = babySize(week); setHero({ due: left > 0 ? '距离见面还有 ' + left + ' 天' : '宝宝随时会来见你', emoji: sz.emoji, hi: window.handIcons && (handIcons.week(week) || {}).id, size: '像' + sz.name.replace(/^一[个颗根只串把粒条棵颗]/, ''), pct: pct }); } animateRings($('#screen-home')); /* ③ 档案页顶部:页面名、孕周、大小图标、孕程条 */ /* 只写小名那一格。#arTitle 本身现在是一枚牌子(图标 + 小名 + 「的成长档案」), 整句写回 #arTitle.textContent 会把图标和后半句一起冲掉。 */ $('#arTitleName').textContent = name; /* 「已陪伴 N 天」原来拿个「·」接在孕周后面,孕周和天数是两个单位, 连在一起读像一句话。头部重排后它挪到孕程条右下角,单独成条。 */ $('#arLine1').textContent = weekTxt; $('#arDays').textContent = days; /* 第二行原来写「预产期 X · 还有 N 天」,和孕程条说的是同一件事。 换成「这周宝宝多大了」——「还有 110 天」是抽象数字,「像一根玉米」摸得着。 预产期没丢:我的页 #pcSub 和海报上仍是全写法。 */ const S = babySize(week); setHandIco($('#arSizeArt'), window.handIcons && (handIcons.week(week) || {}).id, S.emoji); $('#arLine2').textContent = '像' + S.name + '了 · 约 ' + S.txt; $('#arPct').textContent = pct + '%'; /* 孕程从 84px 的圆环换成了通栏细条,所以这里不再是 setRing, 直接写宽度;CSS 上挂着 transition,从 0 长出来的动画照旧。 档案页也就这一个环,环动画的调用一并去掉了。 */ setArBar(pct); $('#genWeekTag').textContent = '第 ' + week + ' 周'; // 生成档案卡上的周次 /* ④ 称呼:小名一改,全站写着小名的地方(首页三个模块名、来信提醒卡文案)跟着换 */ syncBabyWords(); /* ⑤ 用户卡记录行:已完善 → 保留「已连续记录 N 天」(数据源确认前先维持原型值) */ const _rl2 = $('#meRecordLine'); if (_rl2) _rl2.innerHTML = '已连续记录 42 天'; /* ⑥ 页面里那些示例日期(时光轴、珍贵时刻、小卡、热词…)也按新孕周重刷。 改预产期会连带改孕周,这些跟着一起动才不会前后矛盾。 */ syncStaticDates(); stampAiLogs(); } /* ⚠️ 这里原来是 syncBio() —— 刷「我的」页头像旁那行一句话签名。 一句话签名本批按用户要求整块删除,四处一起走: index.html #pfBio 输入框、

展示行 app.js state.userBio 初值、这个 syncBio()、init() 里对它的调用、 #pfSave 里那两行 别只补回其中一处:留输入框没展示行 = 白填;留展示行没输入框 = 死文案。 */ /* 把散落在各个页面里的固定文案统一按小名刷一遍。 这些是写死在标记里的静态字,不归任何 render 函数管, 只能在这里集中改,漏一处就会出现两个不同的小名并存。 */ function syncBabyWords() { /* 首页三个模块名都跟着小名走: 「孕期必用工具」→「小汤圆的孕育工具」 「AI今日小结」 →「小汤圆今天想说」 「今日成长脚印」→「小汤圆的今日脚印」 这三处原来是写死的功能名。换成小名不只是亲切 —— 数胎动、称体重这些事, 妈妈是在替谁做,标题里就该有谁。 ⚠️ 气泡上原来那枚 .ab-badge「AI」角标,2026-10-02 用户明确要求去掉(首页便签不再标 AI)。 详情页 #detailAI 的说明仍在;以后要恢复机器生成提示,回 index.html 的 .fn-top 里加回来。 */ /* 2026-11-01 工具和数据合成一个模块,标题定为「我的孕期工具」(不再拼小名) */ $('#dockTitle').textContent = '我的孕期工具'; $('#abTag').textContent = babyWord('今天想说'); $('#tlTitle').textContent = babyWord('的今日脚印'); /* 2026-10-03 用户:婴儿房角标「往里看看 · 3D 婴儿房」→「往里看看 · 豆豆的婴儿房」;同日又改成「进去看看」,没填小名就是「宝宝的婴儿房」 */ const _nc = $('#nyChip'); if (_nc) _nc.textContent = '进去看看 · ' + babyWord('的婴儿房'); const _sg = $('#fpSign'); if (_sg) _sg.textContent = '—— 爱你的' + (state.babyName || '宝宝'); /* 整条时间轴自己是个 role="button",它的 aria-label 里也写着模块名。 只刷可见的 #tlTitle、把这句留在原地,读屏用户会一直听见上一个小名 —— 而这种不同步在屏幕上根本看不出来,只能靠这行和它的断言守着。 */ $('#tlSteps').setAttribute('aria-label', babyWord('的今日脚印') + ',查看完整档案'); /* 「我的」页那张福利卡的副标题,和它点开后的弹窗标题 —— 两处说的是同一个人, 必须一起刷。 ⚠️ 挂点从 #meWxTitle 搬到了 #meWxSub:那张卡的标题现在讲的是商品本身 (「可心柔婴儿纸巾」),顾问是谁退到了副标题里。别再往标题上挂 —— 320 屏上标题那格只有 159px,光产品名就占 132px, 再塞进一个 12 字的小名必被省略号吃掉产品名。 ⚠️ #wxTitle 藏在弹窗里,改完小名后**不点开就看不见**, 是那种肉眼巡查永远扫不到、只能靠断言守住的挂点。 ⚠️ #rlgSub 是资源库 banner 上的同一句话(原来是写死的静态文本,不带小名)。 用户要求两处一模一样,所以这里**先算出一个字符串再赋给两处**, 而不是各写各的 —— 分开写就是上一版「同一件商品三个叫法」的老路。 */ const wxSub = '加' + babyWord('的孕育顾问') + '领取'; $('#meWxSub').textContent = wxSub; $('#rlgSub').textContent = wxSub; $('#wxTitle').textContent = babyWord('的孕育顾问'); } /* ⚠️ 这里原来是 $('#pgSave') —— 「编辑孕期信息」那张弹窗自己的保存按钮。 那张弹窗已整个并进「我的资料」(理由见 index.html 里 #modalProfile 上方那段), 它的校验和写库逻辑原样搬进了下面的 #pfSave。 别再按 id 去找 pgSave:DOM 里已经没有这个元素了,$() 会返回 null, 而 null.addEventListener 是启动即崩 —— 整个 app.js 一行都跑不起来。 */ /* ---------- 我的资料 · 换头像 ---------- */ const avFile = $('#avFile'), avPreview = $('#avPreview'), avBig = $('#avPick'); const meAvatar = $('#meAvatar'), meAvatarImg = $('#meAvatarImg'); /* 点大圆 → 转交给隐藏的 file input。openModal 里会 focus 第一个输入框, 这里 avFile 排在 pfName 前面,所以 openModal 用的选择器只取 .modal-ta/input 可能命中 file input——用 :not([type=file]) 排除掉,见 openModal */ /* 2026-10-11 用户:点头像弹预设选项,也能从相册上传、用微信头像 → 走统一头像面板 avatarPicker (/_bridge/avatar-picker.js:sync:false 选完只回调;保存时 commit 写共享资料、派发 mmbox:avatar)。 选中的是正式 URL,记在 pickedAvatar,保存时一起 PUT 进首页档案;面板没加载上才退回系统选图。 */ function openAvatarPanel() { if (window.avatarPicker && window.avatarPicker.open) { window.avatarPicker.open({ current: avBig.classList.contains('has-img') ? avPreview.src : '', accent: '#F4593C', accentSoft: '#FDECE7', /* 2026-10-11 用户:点「保存资料」后才让所有应用同步头像 → 面板只回调,保存时 commit */ sync: false, onPick: function (url) { pickedAvatar = url; avFile.value = ''; avPreview.src = url; avBig.classList.add('has-img'); haptic(); } }); } else avFile.click(); } avBig.addEventListener('click', e => { e.stopPropagation(); openAvatarPanel(); }); /* 孕期档案里头像是一整行,点行上任何地方都换头像 */ const pfMeRow = $('#pfMe'); if (pfMeRow) { pfMeRow.addEventListener('click', e => { if (e.target !== avFile) openAvatarPanel(); }); pfMeRow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openAvatarPanel(); } }); } avFile.addEventListener('change', () => { const f = avFile.files && avFile.files[0]; if (!f) return; if (!/^image\//.test(f.type)) { toast('请选择一张图片'); return; } if (f.size > 8 * 1024 * 1024) { toast('图片太大了,换一张小于 8MB 的'); return; } const r = new FileReader(); /* 读成 data URI 就地预览:产物是单文件、没有后端,不做真正的上传 */ pickedAvatar = ''; r.onload = () => { avPreview.src = r.result; avBig.classList.add('has-img'); haptic(); }; r.onerror = () => toast('这张图读不出来,换一张试试'); r.readAsDataURL(f); }); /* 「我的资料」现在同时管**妈妈**(头像 / 昵称)和**宝宝**(小名 / 性别 / 预产期 / 孕周), 所以这一个按钮要把原来 #pfSave 和 #pgSave 两段逻辑都做完。 ⚠️ 顺序不能反:先校验预产期,校验没过就整个 return,**头像和昵称也一起不保存**。 听起来像是误伤,但另一种做法更坏 —— 保存一半、弹窗还开着, 用户看到「资料已更新」却发现孕周没变,会以为是自己没填对。 一个按钮就该是一次原子的「保存」。 */ $('#pfSave').addEventListener('click', () => { /* 预产期是一切的源头:孕周、剩余天数、信里的日期全从它算。没有它没什么可存的。 */ if (!parseDay(pgDue.value)) { toast('先填一下预产期,孕周会自动算出来'); openPicker('week'); return; } const name = $('#pfName').value.trim() || '妈妈'; const babyName = pgName.value.trim() || '宝宝'; const week = Math.min(42, Math.max(0, parseInt(pgWeek.value, 10) || 0)); const day = Math.min(6, Math.max(0, parseInt(pgDay.value, 10) || 0)); const sexBtn = $('#pgSex .pick.is-active'); const babySex = (sexBtn && sexBtn.dataset.sex) || 'unknown'; const due = parseDay(pgDue.value); /* 未授权时妈妈栏是隐藏的:不写昵称/头像,否则会把旧昵称重新存回去 */ const _unauth = state.authorized === false; /* 2026-10-22(测试会话 150e0991):原来本地先改、先关弹窗、先提示「资料已更新」,接口还没回来; 失败时用户已经看到成功提示、输入也没了。现在改成**接口成功后**才写本地 state / 刷孕期卡 / 关弹窗 / 提示, 失败(含头像上传失败)弹「保存失败,请重试」、弹窗不关、输入原样保留。 无 openid(浏览器直访)没有可落的库,照旧只改本地。 */ function applyLocal(avatarUrl) { /* ---- 妈妈那半 ---- ⚠️ 这里原来还有一行 state.userBio = bio 和一次 syncBio() 调用。 一句话签名整块已按用户要求删除(输入框 #pfBio、展示行 #meBio、 syncBio()、state.userBio 四处一起删),别只加回其中一处。 */ if (!_unauth) { $('#meName').textContent = name; state.userName = name; if (avatarUrl) { $('#meAvatar').classList.add('has-img'); meAvatarImg.src = avatarUrl; } } /* ---- 宝宝那半(原 #pgSave) ---- */ state.babyName = babyName; /* 2026-10-22:产后态孕周两格是空的(见上面回填),parseInt 出来是 0 → 原来会把 state.week 写成 0、 提示「孕 0 周」。产后不碰孕周,提示里也不带孕周 */ if (!state.postpartum) { state.week = week; state.day = day; } state.babySex = babySex; state.due = due; /* ⚠️ syncPregnancy() 就是用户说的「同步到孕期卡上边」那一步 —— 它把孕周环、剩余天数、预产期、宝宝多大重刷一遍,顺带 syncBabyWords() 把全站写着小名的地方(首页三个模块名、来信提醒卡文案…)一起改。 少调这一句,弹窗里改完了、外面还是旧数字,而且没有任何报错。 */ syncPregnancy(); closeModal(); setTimeout(() => toast('资料已更新 · ' + name + ' · ' + babyName + (state.postpartum ? '' : ' 孕 ' + week + ' 周' + (day ? '+' + day + ' 天' : ''))), 260); } /* ---- 落库:PUT /profile(孕育字段 + 昵称/头像)→ 刷新/重进后资料不丢。 若弹窗里新选了头像(avFile 有文件),先 POST /upload 换正式 URL 再一起提交。 ---- */ var _oid = hashParam('openid'); var _wasEmpty = state.pregEmpty; /* 新用户完善资料后 → 重载拉真实孕周 */ var saveBtn = $('#pfSave'); if (saveBtn.dataset.busy === '1') return; /* 接口没回来前连点只算一次 */ function setBusy(on) { saveBtn.dataset.busy = on ? '1' : ''; saveBtn.disabled = !!on; } function fail(msg) { setBusy(false); toast(msg || '保存失败,请重试'); } /* 保存时才把头像写进共享资料:avatarPicker.commit 已授权才写 auth/profile,并派发 mmbox:avatar */ function syncSharedAvatar(url) { if (url && window.avatarPicker && window.avatarPicker.commit) window.avatarPicker.commit(url); } function _putProfile(avatarUrl) { if (!_oid) { applyLocal(avatarUrl); setBusy(false); return; } var body = { babyName: babyName, babySex: babySex, }; /* 预产期没改就不传:表单可能是几分钟前回填的,别的应用这期间改过的话会被旧值盖回去 */ var _due = due ? toISO(due) : ''; /* 2026-10-31:登记过出生的人在这里保存孕周 = 回到孕期 → 预产期没改也要传,后台据此把账号翻回孕期 */ var _wasPost = !!state.postpartum; if (_due !== filledDue || _wasEmpty || (_wasPost && _due)) body.dueDate = _due; if (!_unauth) body.userName = name; if (avatarUrl && !_unauth) body.avatar = avatarUrl; fetch('/api/mp/public/mama-box/profile?openid=' + encodeURIComponent(_oid), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) { return r.json().catch(function () { return null; }); }).then(function (res) { /* ⚠️ 2026-09-22(档案以上游为准):预产期/昵称会**先推上游、推失败后端返回 400 且不落库**。 这里必须让用户看见失败 —— 静默的话下次加载会被上游值覆盖回去,用户以为"改了没生效"。 */ if (!res || res.code !== 200) { fail(res && res.message ? res.message + ',请重试' : ''); return; } setBusy(false); if (avatarUrl && !_unauth) { syncSharedAvatar(avatarUrl); pickedAvatar = ''; } applyLocal(_unauth ? '' : avatarUrl); /* birth-flow.js 听这个事件:回到孕期时把本机「已出生」阶段改回孕期,并重读首页卡片 */ try { window.dispatchEvent(new CustomEvent('mmbox:profile-saved', { detail: { dueDate: body.dueDate || '', wasPostpartum: _wasPost } })); } catch (e) {} /* 新用户首次完善资料 / 产后改回孕期:重载页面 → pregnancy 出真实孕周(首页 hero、「宝宝已出生」卡一起换掉) */ var _back = _wasPost && body.dueDate && body.dueDate >= toISO(new Date()); /* 预产期在未来才算回到孕期(和后台同一条规则) */ if (_wasEmpty || _back) setTimeout(bustReload, 700); }).catch(function () { fail(); }); } setBusy(true); var newAvatarFile = (avFile.files && avFile.files[0]) || null; if (pickedAvatar && !_unauth) { /* 头像面板选的已经是正式 URL(预设图 / 面板自己上传过),直接写 */ _putProfile(pickedAvatar); } else if (newAvatarFile && avBig.classList.contains('has-img') && !_unauth) { var fd = new FormData(); fd.append('file', newAvatarFile, newAvatarFile.name || 'avatar.png'); /* 2026-10-22:上传失败原来静默跳过头像、其余照存,用户以为头像也换好了 → 现在整次不存、提示重试 */ fetch('/api/upload', { method: 'POST', body: fd }) .then(function (r) { return r.json(); }) .then(function (res) { var url = res && res.result && res.result.url; if (!url) { fail('头像上传失败,请重试'); return; } _putProfile(url); }) .catch(function () { fail('头像上传失败,请检查网络后重试'); }); } else { _putProfile(''); } }); /* 「我的二维码」按需求已整块移除 */ /* ================= 设置与服务 二级页 ================= 四项内容形态其实一样:若干「分组 + 若干行」。所以只写一张表 + 一个渲染器。 行的三种类型: sw 开关行(可点,本地切换状态并 toast) row 普通跳转行(右侧箭头,点了给个 toast) txt 纯说明段落 */ /* 说明:「提醒设置」整项已按需求删除(连同「我的」里的入口行)。 隐私与权限去掉了「社区匿名发言」「设备权限」「数据」三处; 帮助与反馈只留「联系我们」;关于兜爸心选只留介绍。 */ const SETTINGS = { privacy: { title: '隐私与权限', more: '你的记录只属于你', lead: '孕期的记录是很私人的东西。默认情况下,只有你自己能看到它们。', groups: [ /* 2026-10-22 删「谁能看到」两个开关(档案仅自己可见 / 允许家人查看):只变色 + toast,什么都不存 */ /* 2026-10-03 首页彩蛋开关(js/bump.js):第 5 项是 localStorage 键,开关状态真的存下来,不是摆设 */ { t: '首页小彩蛋', rows: [ ['sw', '宝宝在肚皮下动', '孕 20 周起,首页偶尔被宝宝顶起来,摁一下会躲开', 1, 'mb_bump'] ] }, { t: '', rows: [ ['txt', '我们不会把你的孕期数据用于广告投放,也不会卖给第三方。数据加密存储。'] ] } ], foot: '《隐私政策》最近一次更新:2026 年 10 月 22 日' }, help: { title: '帮助与反馈', more: '一般 1 个工作日内回复', lead: '有任何用不明白的地方,都可以直接问我们,妈妈的时间很宝贵。', groups: [ { t: '联系我们', rows: [ ['row', '在线客服', '9:00 – 21:00'], ['row', '提交反馈或建议', '功能问题、建议都可以', 'fb'], // 2026-10-04 真能提交了 → 打开 #modalFeedback ['row', '加入内测群', '抢先体验新功能'] ] } ], foot: '你提的每一条建议我们都会看,兜爸心选有很多功能就是这么来的。' }, about: { title: '关于兜爸心选', more: '当前版本 v' + VER, /* 2026-10-22(测试会话 150e0991):v3.2.1 是写死的,与页脚 VER 对不上 → 全部改读 VER */ hero: 1, lead: '兜爸心选想做的事很简单:让你不用记录,也能留下记录。', groups: [ { t: '', rows: [ ['txt', '怀孕已经够辛苦了,我们不想再让你多填一张表。你每一次数胎动、称体重、看产检、读宝宝的来信,兜爸心选都会悄悄记下来,替你整理成一本档案。'], ['txt', '等宝宝出生、等他长大、等你某天忽然想起这段日子,这本档案都还在原地等你。'] ] } ], foot: '兜爸心选 v' + VER + ' · 已陪伴 268 万位妈妈' } }; /* 「我的」页底的版本号同样读 VER(index.html 里写死的只是占位) */ { const mv = document.querySelector('.me-ver'); if (mv) mv.textContent = '兜爸心选 · v' + VER; } const ABOUT_HERO = '

' + '' + '
兜爸心选
' + '
v' + VER + ' · 陪你从两条杠到见面
' + '
'; function renderSetting(key) { const cfg = SETTINGS[key] || SETTINGS.privacy; $('#stTitle').textContent = cfg.title; $('#stMore').dataset.toast = cfg.more; $('#stHero').innerHTML = cfg.hero ? ABOUT_HERO : ''; $('#stLead').textContent = cfg.lead; $('#stFoot').textContent = cfg.foot; const html = cfg.groups.map(g => { const rows = g.rows.map(r => { /* .st-para 而不是 .st-txt:后者是首页成长脚印在用的类名 */ if (r[0] === 'txt') return '

' + r[1] + '

'; if (r[0] === 'row' && r[3] === 'fb') return ''; if (r[0] === 'row') return ''; /* 开关行:aria-checked 让读屏也能拿到状态,样式只认 .on。 带第 5 项(localStorage 键)的是真开关:状态从本地读,'0' = 关 */ if (r[4]) r = [r[0], r[1], r[2], localStorage.getItem(r[4]) === '0' ? 0 : 1, r[4]]; return ''; }).join(''); return (g.t ? '

' + g.t + '

' : '') + '
' + rows + '
'; }).join(''); $('#stBody').innerHTML = html; /* 「关于兜爸心选」的版本号已经在顶部 / 页脚(都读 VER),原来底部单独追加的 #aboutVer 去掉,免得同一页出现三遍 */ scrollTop($('#detailSetting .detail-body')); } /* 开关走委托:内容是每次打开重新渲染的,逐个绑定会随渲染丢失 */ $('#stBody').addEventListener('click', e => { const b = e.target.closest('.st-row.sw'); if (!b) return; const on = !b.classList.contains('on'); b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); if (b.dataset.key) { localStorage.setItem(b.dataset.key, on ? '1' : '0'); window.dispatchEvent(new Event('mb-bump')); } haptic(); toast(b.dataset.sw + (on ? ' 已开启' : ' 已关闭')); }); /* ================= AI 孕育小结 内容页 ================= */ /* ---------- AI 小结数据 ---------- 写建议有一条铁律:不吓人。 孕妇本来就容易多想,一句「连续 3 天减少要就医」「久坐会水肿」, 看完只会开始数自己今天动了几次、腿是不是肿了。 所以这里全部改成「做了什么很好」「可以试试这样」的正向说法, 不提异常、不提风险、不提就医,把判断留给产检和医生 (页尾那句免责声明也已经改成「不代替医生的判断」)。 每条建议带 cat(分类)+ tone(配色)+ head(一句标题)+ txt(一句正文), 渲染成卡片而不是一行长句 —— 一眼能看清在讲哪方面。 stats 第三项是和昨天的对比,up / dn / eq 决定颜色。 ---------- 口吻:这些话是宝宝说的,不是平台说的 ---------- 原来 txt 和 tips 都是第三人称的平台播报:「今天你数到 3 次胎动,小家伙很有精神」 「午后和睡前是他最爱动的时候」—— 妈妈、宝宝、系统三方在场,宝宝被当成第三个人 指指点点,读起来就是一份日报。 现在全部改成宝宝用第一人称直接对妈妈说:称呼妈妈为「你」,自称「我」, 该出现宝宝的地方一律是「我」,不再有「他 / 小家伙 / 宝宝」。 建议也照这个来 —— 不是「你应该怎样」,而是「你这样做我会更舒服」。 两条边界,别越过去: · 口吻变软了,但不吓人那条铁律照旧 —— 不提异常、不提风险、不提就医。 · 只有宝宝在说的话改口吻。页尾免责声明(以及 2026-10-02 前气泡上的「AI」角标,已按用户要求去掉) 是系统在说话,保持原样,否则就成了宝宝自己声明自己不是医生。 (气泡落款那句「AI自动汇总你每日记录」第五批按用户要求删了, 机器生成的提示现在只剩那枚角标。) · mood 那枚小圆标(安心 / 安稳…)说的是这一天的状态,不是谁在说话,不动。 ⚠️ AI_LOGS[0].txt 在首页气泡里还有一份手写副本(index.html 的 .ab-say, 带 和
的排版所以没法共用变量)。改这一条务必两处一起改。 */ const AI_LOGS = [ { date: '9月3日 周二', week: '孕24周+3', mood: '安心', txt: '妈妈,今天你数到我动了 3 次。我今天精神特别好,这是踏实的一天。', stats: [['胎动', '3 次', '+1'], ['体重', '58.2 kg', '+0.2'], ['睡眠', '7.5 h', '-0.5']], /* 三条建议全是宝宝在跟妈妈说话,但称呼**恰好只放两条**(第 2、3 条)。 三条都以「妈妈,」起头会读成排比,像模板灌出来的,反而不像在说话; 所以第 1 条把称呼让出去,改放进正文里 —— 整张卡仍有「妈妈」,只是不在句首。 ⚠️ 这个「2 / 3」是被测试钉住的,不是随手写的。要改先想清楚再改断言。 ⚠️ 同样**不做运行时随机**:这一页自称「小结每天 21:00 自动生成」(⋯ 按钮的 toast), 重开一次文案就变,等于当场拆穿自己。真要按天变,得按日期做 seed 从更大的文案池里取。 */ tips: [ { cat: '胎动', tone: 'p', head: '午后和睡前是我最爱动的时候', txt: '妈妈,这两次你都记下来啦。以后就固定在这两个点,安安静静陪我待一会儿好不好,我会更有安全感哟。' }, /* ⚠️ 这里的 0.3kg 是**妈妈的**周增重(和上面 stats 里「体重 58.2 kg +0.2」同源), 所以只加称呼、不改人称。改成「我这周长了 0.3kg」会有两处坏掉: 下面正文「不用为了我硬撑着多吃」讲的是妈妈的饭量,标题一换就对不上; 而且 0.3kg 对孕 24 周的胎儿明显偏大(实际周增约 100g),成了条假数据。 */ { cat: '饮食', tone: 'g', head: '妈妈,你这周长了 0.3kg,刚刚好', txt: '不用为了我硬撑着多吃,你按平时的胃口来,我就吃得饱饱的啦。' }, { cat: '放松', tone: 'b', head: '妈妈,你睡前那 20 分钟散步,我很喜欢', txt: '你走路的时候我像被轻轻摇着。这几天你睡得比上周沉,我们每天都去走一走吧。' } ] }, { date: '9月2日 周一', week: '孕24周+2', mood: '安稳', txt: '妈妈,今天我安静了一点,其实是在补觉;你也多睡了半小时,我很开心。', stats: [['胎动', '2 次', '-1'], ['体重', '58.0 kg', '+0.1'], ['睡眠', '8.0 h', '+1.0']] }, { date: '9月1日 周日', week: '孕24周+1', mood: '愉快', txt: '周末你和爸爸放了胎教音乐,音乐一停我就动个不停 —— 我还想再听一遍。', stats: [['胎动', '4 次', '+1'], ['体重', '57.9 kg', '+0.1'], ['睡眠', '7.0 h', '-0.2']] }, { /* 这条原来写「进入孕 24 周啦,他大约 30cm、600g」—— 又一处写死的孕周。 往期第 4 条对应的是四天前,孕周随用户当前进度变,这句话必然对不上。 换成不点具体周数的说法。 */ date: '8月31日 周六', week: '孕24周', mood: '期待', txt: '我的耳朵最近越来越灵啦。今天你说话的时候,我动了两下回应你。', stats: [['胎动', '3 次', '0'], ['体重', '57.8 kg', '+0.2'], ['睡眠', '7.2 h', '+0.4']] }, { date: '8月30日 周五', week: '孕23周+6', mood: '用心', txt: '这一周你为我记了 21 条,是到现在最认真的一周。谢谢你呀妈妈。', stats: [['胎动', '3 次', '0'], ['体重', '57.6 kg', '+0.1'], ['睡眠', '6.8 h', '-0.3']] } /* 往期这四条只留 date / week / mood / txt / stats。 原本每条还挂着三条 tips,是给往期弹窗铺 1/2/3 建议卡用的,弹窗不显示了, 数据也就跟着删掉 —— 留着不用,下次翻到这儿的人只会以为哪里漏渲染了。 aiTipsHTML 现在只有 AI_LOGS[0](今日小建议)一个消费者。 */ ]; /* AI 小结的 date / week 是写死的('9月3日 周二' / '孕24周+3')。 这五条按定义顺序就是「今天、昨天、前天…」,所以直接按下标当天数往回推 (backAt 从那一刻倒推)。不这么做的话,档案打印出来会是 封面「孕 27 周 +2 天」、内页「今天 · 孕24周+3」,自己打自己的脸。 */ function stampAiLogs() { AI_LOGS.forEach((L, i) => { const d = new Date(backAt(i, [12, 0])); const g = clampGA(state.week * 7 + state.day - i); L.date = (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + WEEKDAY[d.getDay()]; L.week = '孕' + Math.floor(g / 7) + '周' + (g % 7 ? '+' + (g % 7) : ''); }); } stampAiLogs(); /* 这两段今日卡和往期弹窗都要用,抽出来共用一份 */ const aiStatsHTML = d => d.stats.map(s => { /* 和昨天持平就写「持平」,别显示一个孤零零的「0」 */ const n = parseFloat(s[2]); const cls = !n ? 'eq' : n > 0 ? 'up' : 'dn'; const txt = !n ? '持平' : (n > 0 ? '↑' : '↓') + Math.abs(n); return '
' + s[1] + '' + txt + '' + '' + s[0] + '
'; }).join(''); const aiTipsHTML = d => d.tips.map((t, i) => '
' + '' + '' + t.cat + '' + '

' + t.head + '

' + '

' + t.txt + '

' + '
').join(''); function renderAI(idx) { const d = AI_LOGS[idx] || AI_LOGS[0]; $('#aiWeek').textContent = d.week; $('#aiDate').textContent = d.date; $('#aiMood').textContent = d.mood; $('#aiTxt').textContent = d.txt; $('#aiStats').innerHTML = aiStatsHTML(d); $('#aiTips').innerHTML = aiTipsHTML(d); /* 往期列表跳过索引 0 —— 那就是顶上今日卡这一条,列出来是重复。 data-ai 仍写真实索引,好让 openAiLog 直接取。 */ $('#aiLogs').innerHTML = AI_LOGS.slice(1) .map((l, i) => '').join(''); /* 原来点一条是把顶上的今日卡整个换掉,看完想回到「今天」没有出口。 改成在本页弹一层浮窗,看完点底下的叉就走,今日卡始终留在原位。 */ $$('#aiLogs .ai-log').forEach(b => b.addEventListener('click', () => openAiLog(+b.dataset.ai))); } function openAiLog(idx) { const d = AI_LOGS[idx]; if (!d) return; $('#alogWeek').textContent = d.week; $('#alogMood').textContent = d.mood; $('#alogTitle').textContent = d.date; $('#alogTxt').textContent = d.txt; /* 往期只给当天的数,不再铺 1/2/3 建议卡。#alogTips 连同标记一起删了, 这里必须跟着删 —— 只删 HTML 的话,一点往期就是 null.innerHTML 崩溃。 */ $('#alogStats').innerHTML = aiStatsHTML(d); $('#modalAiLog').scrollTop = 0; /* .modal 自己带 overflow-y:auto,上次翻到哪儿要清掉 */ openModal('modalAiLog'); } /* ================= 孕育资源库:列表页 + 内容页 ================= */ const RES = { book: { img: 'assets/img/9f3fa8edf4aa.png', cat: '产检', tint: 'o', title: '《糖耐检查全攻略》', desc: '孕24-28周必看 · 附检查前饮食清单', meta: ['8 分钟读完', '12.8 万妈妈已领取', '孕24-28周'], body: [['什么是糖耐量检查', '糖耐量检查(OGTT)用于筛查妊娠期糖尿病,一般安排在孕 24–28 周。检查当天需空腹抽血一次,喝下 75g 葡萄糖水后 1 小时、2 小时各抽血一次,共三次。'], ['检查前 3 天怎么吃', '不需要刻意节食。保持平时饮食即可,但每天碳水化合物摄入不少于 150g,否则可能出现假阳性。避免油炸、含糖饮料与夜宵。'], ['检查当天注意事项', '前一晚 22:00 后禁食,可少量饮水。带上水杯、小零食(抽完最后一管血再吃)和陪同家属。喝糖水要在 5 分钟内喝完,之后静坐等待,不要来回走动。'], ['结果怎么看', '空腹 < 5.1、1 小时 < 10.0、2 小时 < 8.5(mmol/L),任意一项超标即诊断为妊娠期糖尿病。多数可通过饮食与运动控制,不必过度紧张。']] }, food: { img: 'assets/img/3657b1f76026.png', cat: '营养', tint: 'g', title: '孕期营养全书', desc: '按孕周编排的一日三餐参考', meta: ['12 分钟读完', '20.4 万妈妈已领取', '全孕期'], body: [['孕中期的能量需求', '孕中期每天比孕前多约 300 kcal,相当于一杯牛奶加一个鸡蛋加半碗米饭,不需要「吃两人份」。'], ['四类必须补的营养素', '钙 1000mg/天(奶制品、豆制品)、铁 24mg/天(红肉、动物肝脏)、DHA 200mg/天(深海鱼)、叶酸 400μg/天(绿叶菜、补充剂)。'], ['一日三餐参考', '早餐:牛奶 + 全麦面包 + 鸡蛋 + 一份水果;午餐:杂粮饭 + 瘦肉 + 深绿色蔬菜 + 豆制品;晚餐:清淡为主,鱼虾 + 蔬菜 + 少量主食;加餐:坚果一小把或酸奶一杯。'], ['要少碰的东西', '生食(刺身、溏心蛋)、未杀菌乳制品、高汞鱼(金枪鱼、鲨鱼)、酒精与浓咖啡。']] }, music: { img: 'assets/img/67405412c52c.png', cat: '胎教', tint: 'p', title: '胎教音乐合集', desc: '30 首适合孕中期的舒缓曲目', meta: ['30 首 · 共 96 分钟', '9.6 万妈妈已领取', '孕16周起'], body: [['宝宝什么时候能听见', '孕 16 周左右听觉器官初步形成,24 周后已能对外界声音作出反应,这是胎教音乐最合适的开始时间。'], ['正确的听法', '不要把设备贴在肚皮上。房间内正常音量播放即可,控制在 60 分贝以下,每次 15–20 分钟,每天 1–2 次。'], ['选什么曲子', '节奏平稳、旋律简单、无强烈鼓点的音乐最合适,如古典小品、轻钢琴、自然音。妈妈自己听着放松,才是最好的胎教。'], ['比音乐更重要的事', '妈妈的声音是宝宝最熟悉的声音。每天和宝宝说几句话,效果胜过任何一张专辑。']] }, sport: { img: 'assets/img/72910df66ee3.png', cat: '运动', tint: 'g', title: '孕中期安全运动指南', desc: '每天 30 分钟,缓解腰背酸痛', meta: ['6 分钟读完', '7.2 万妈妈已领取', '孕13-28周'], body: [['为什么孕中期最适合运动', '早孕反应消退、腹部尚未过分隆起,是整个孕期活动最舒适的窗口,规律运动能降低妊娠糖尿病与子痫前期风险。'], ['推荐的四种运动', '快走、游泳、孕妇瑜伽、固定式单车。每周 5 天、每次 30 分钟,强度以「能正常说话但唱不了歌」为准。'], ['必须避开的动作', '仰卧位动作(孕 16 周后)、跳跃与急转、需要平衡的运动、任何有腹部撞击风险的项目。'], ['出现这些情况立即停止', '阴道出血、规律宫缩、头晕胸闷、羊水样液体流出,应停止运动并及时就医。']] }, sleep: { img: 'assets/img/f0d5e062f11a.png', cat: '心理', tint: 'b', title: '睡不好怎么办', desc: '孕期失眠的 7 个改善方法', meta: ['5 分钟读完', '15.1 万妈妈已领取', '全孕期'], body: [['孕期失眠很常见', '约 70% 的孕妇有不同程度的睡眠问题,多由激素变化、尿频、腿抽筋和焦虑引起,绝大多数不需要药物治疗。'], ['睡姿怎么选', '孕中晚期优先左侧卧,可在两腿之间与腰后各垫一个枕头,减轻下腔静脉压迫。'], ['睡前一小时的仪式', '关掉主灯、放下手机、温水泡脚 10 分钟、轻音乐或呼吸练习,让身体建立入睡信号。'], ['白天该做的事', '固定起床时间、午睡不超过 30 分钟、下午之后不摄入咖啡因、傍晚适量活动。']] }, diet: { img: 'assets/img/dd5639fde61d.png', cat: '营养', tint: 'o', title: '孕期体重管理', desc: '孕前 BMI 决定你该长多少', meta: ['7 分钟读完', '11.3 万妈妈已领取', '全孕期'], body: [['先算孕前 BMI', 'BMI = 体重(kg) ÷ 身高(m)²。孕前偏瘦(<18.5)建议全孕期增重 12.5–18kg,正常(18.5–24.9)11.5–16kg,超重(25–29.9)7–11.5kg。'], ['孕中期的增重节奏', '孕中晚期每周增重 0.3–0.5kg 较理想。单周超过 1kg 多为水肿或饮食过量,需要复盘。'], ['称重的正确方法', '固定每周同一天、同一时间、同样着装,晨起排空后测量,只看趋势不纠结单次数字。'], ['长得太快或太慢', '连续两周超标先减少精制主食与含糖饮料;增长过慢则增加优质蛋白与健康脂肪,并排查甲功与贫血。']] }, report: { img: 'assets/img/b855b45df1f3.png', cat: '产检', tint: 'b', title: '产检时间表(全孕期)', desc: '13 次产检,一次都不漏', meta: ['4 分钟读完', '26.7 万妈妈已领取', '全孕期'], body: [['孕早期(≤13周)', '6–8 周建卡 + B超确认宫内孕;11–13⁺⁶ 周 NT 检查与早期唐筛。'], ['孕中期(14–27周)', '15–20 周唐筛或无创 DNA;20–24 周大排畸;24–28 周糖耐 + 血常规复查。'], ['孕晚期(28周后)', '28–36 周每 2 周一次;36 周后每周一次,含胎心监护、B超估重与骨盆评估。'], ['每次都要带的东西', '母子健康手册、既往报告、医保卡、水杯与小零食。建议提前一天在手机里存好问题清单。']] }, med: { img: 'assets/img/4657f50f42c8.png', cat: '心理', tint: 'p', title: '孕期情绪自查手册', desc: '分清正常波动与孕期抑郁', meta: ['6 分钟读完', '5.9 万妈妈已领取', '全孕期'], body: [['情绪起伏是正常的', '激素剧烈变化叠加身份转变,孕期爱哭、易怒、突然焦虑都很常见,本身不等于抑郁。'], ['需要警惕的信号', '持续两周以上的情绪低落、对什么都提不起兴趣、睡眠与食欲明显改变、反复自责或出现伤害自己的念头。'], ['可以自己做的三件事', '把感受说出来而不是憋着;每天留 20 分钟只属于自己;减少无休止的搜索式焦虑。'], ['寻求帮助不丢人', '产科、心理科都可以提供帮助,孕期心理干预安全且有效,越早越好。']] } }; const RES_ORDER = ['book', 'food', 'music', 'report', 'sport', 'diet', 'sleep', 'med']; /* ⚠️ 2026-09-19(9.19 需求 1.2):这里原来有 PAN_DEFAULT / PAN 两张**百度网盘 链接表**、panOf()、以及企业微信兑换码 WX_CODE / wxCodeOK()。 需求方确认「扫码加顾问 → 回复领资料 → 顾问直接发资料」之后,H5 不再发放网盘 链接,这套(连同 #modalPan 弹窗、givePan()、后台下发的 pan/panDefault/wxCode) 整块删除。要恢复「H5 自己发链接」,等于把「解锁」概念加回来,先回问需求方。 */ const isFav = k => state.resFav.indexOf(k) >= 0; function resCardHTML(k) { const r = RES[k]; /* ⚠️ 这里原来还有「领过的那一份」分支:整张卡加 .is-got、缩略图右上角打 ✓、 底行的「免费」标换成「已领 ✓」。9.19 需求取消「已领」概念后一并删除, 现在每张卡都是同一个样子(免费)。 */ return ''; } function bindResCards(root) { $$('.res-item', root).forEach(b => b.addEventListener('click', () => openPanel('detailRes', b))); } function renderResList(cat) { state.resCat = cat || 'all'; /* 收藏不是分类而是筛选,默认排在最末。但只要用户真收藏过东西, 它就成了最常用的一格 —— 排在末尾要横向滑到头才够得着,所以提到「全部」后面。 用 insertBefore/appendChild 挪节点(不是重建 innerHTML),init 时绑的点击事件才不会丢。 已经在第二位时 children[1] 就是 favChip 自己,insertBefore 原地不动,无副作用。 ⚠️ 原来还有个 .chip-got(✓ 已领)跟着一起挪,已随「已领」概念删除。 */ const chipsRow = $('#resChips'), favChip = $('.chip-fav', chipsRow); const favN = state.resFav.length; if (favN) chipsRow.insertBefore(favChip, chipsRow.children[1]); else chipsRow.appendChild(favChip); favChip.textContent = favN ? '★ 收藏 ' + favN : '★ 收藏'; /* fav 不是分类,是「只看收藏过的」,所以单走一支 */ const fav = state.resCat === 'fav'; const keys = fav ? RES_ORDER.filter(isFav) : RES_ORDER.filter(k => state.resCat === 'all' || RES[k].cat === state.resCat); $('#resList').innerHTML = keys.map(resCardHTML).join(''); bindResCards($('#resList')); $('#resEmptyHint').textContent = keys.length ? (fav ? '已收藏 ' + keys.length + ' 份资源' : '共 ' + keys.length + ' 份资源 · 更多内容按孕周陆续更新') : (fav ? '还没有收藏 · 打开任意资源,点右上角 ☆ 就能收进来' : '该分类下暂无资源,换一个试试~'); $$('#resChips .chip').forEach(c => c.classList.toggle('is-active', c.dataset.rescat === state.resCat)); } $$('#resChips .chip').forEach(c => c.addEventListener('click', () => { renderResList(c.dataset.rescat); haptic(); })); function fillRes(key) { const r = RES[key] || RES.book; state.resKey = RES[key] ? key : 'book'; $('#rdImg').src = r.img; $('#rdCover').className = 'rd-cover ' + r.tint; $('#rdCat').textContent = r.cat; $('#rdTitle').textContent = r.title; $('#rdDesc').textContent = r.desc; $('#rdMeta').innerHTML = r.meta.map(m => '' + m + '').join(''); $('#rdBody').innerHTML = r.body .map(p => '

' + p[0] + '

' + p[1] + '

').join(''); $('#rdMore').innerHTML = RES_ORDER .filter(k => k !== state.resKey).slice(0, 3).map(resCardHTML).join(''); bindResCards($('#rdMore')); /* 原来这里无条件重置成 ☆,收藏过的资源再打开会显示成没收藏。 改成按 state.resFav 回显 */ const fav = state.resFav.indexOf(state.resKey) >= 0; const star = $('#resStar'); star.classList.toggle('on', fav); star.textContent = fav ? '★' : '☆'; /* 底部左键已从「收藏」改成「资源库」,是个固定文案的导航按钮,不再需要回显。 ⚠️ 右键「立即领取」原来会按 state.resGot 回显成「再拿一次链接」, 9.19 需求取消「已领」概念后改回**固定文案**(点它只是弹顾问二维码)。 */ $('#rdGet').textContent = '立即领取'; $('#detailRes .detail-body').scrollTop = 0; } $('#resStar').addEventListener('click', function () { const i = state.resFav.indexOf(state.resKey); const on = i < 0; if (on) state.resFav.push(state.resKey); else state.resFav.splice(i, 1); saveResFav(); this.classList.toggle('on', on); this.textContent = on ? '★' : '☆'; /* 列表页就压在这一层下面,退回去时得是新的。 原来只在「正停在收藏筛选上」时才重渲染,但现在 chip 的位置也跟收藏数有关 —— 收藏第一份资源之后返回列表,「收藏」得已经挪到第二位了,所以无条件重来一遍 */ renderResList(state.resCat); toast(on ? '已收藏到「我的书架」' : '已取消收藏'); haptic(); }); /* 底部左键:去「孕育资源库」。 ⚠️ 不能简单写 openPanel('detailResList')。用户多半是**从资源库列表点进这个详情页**的, 此时 panelStack = [detailResList, detailRes],而 openPanel 开头有一句 `if (reopen) return`(见上面 openPanel 的定义)—— 列表已在栈内,它会静默 return, 页面纹丝不动,看着就像按钮坏了。而从首页书架直接进来的用户栈里没有列表, 同一个按钮又是好的:不处理的话这颗按钮行为不一致。 所以分两种情况,但保证结果一致(点完一定停在资源库这一层): 已在栈里 → 关掉压在它上面的层,退回去(返回键随之回主屏,符合「往回走」的心智) 不在栈里 → 正常压栈打开 */ $('#rdLib').addEventListener('click', () => { const list = $('#detailResList'); if (panelStack.indexOf(list) >= 0) { while (panelStack.length && panelStack[panelStack.length - 1] !== list) closePanel(); renderResList(state.resCat); // 收藏/领取状态可能刚变过,退回去得是新的 haptic(); } else { openPanel('detailResList'); // openPanel 内部自带 renderResList + haptic } }); /* 点「立即领取」= 打开企业微信顾问二维码。 ⚠️ 2026-09-19(9.19 需求 1.2):这里原来是「兑换码闸门」—— 未解锁的用户先被拦下, 填码解锁之后 H5 才把网盘链接交给他。需求方确认「扫码加顾问 → 回复「领资料」 → 顾问直接发资料」之后,闸门、兑换码、网盘弹窗整条链路都删了(连同 givePan()、 state.wxUnlocked、wxPending),这颗按钮现在只做一件事:弹二维码。 不存在「已解锁 / 未解锁」两种起点,所以这里也没有任何状态判断 —— 别再加回来。 */ $('#rdGet').addEventListener('click', () => { openWx(); }); /* ================= 企业微信顾问弹窗(单态) ================= 三个入口共用这一个壳:「我的」页服务卡 #meWxCard、资源库顶部试用装 banner #rlGift、 资料详情页「立即领取」#rdGet。三者说同一句话,openWx() 只有一个参数都没有。 ⚠️ 弹窗里**没有任何按钮**,唯一出口是右上角 .modal-x(外加遮罩 / Esc)—— 删按钮之前确认过它在;别做「头部无按钮时隐藏」这种优化。 ⚠️ 这里原来有 openWx(mode, key) + is-gate/is-gift/is-done 三模式类名 + wxPending + setLead() 高亮 + 兑换码校验 + #wxOk / #wxCode 监听,全部随闸门删除。 别再引入「按入口改写文案」的分支:三个入口同一件事,分叉只会让以后改文案的人漏一条。 */ const wxModal = $('#modalWx'); function openWx() { openModal('modalWx'); } $('#meWxCard').addEventListener('click', () => openWx()); /* 资源库顶部那条 banner 和「我的」页服务卡是同一件事、同一个入口。 ⚠️ 走 openWx() 而不是挂 data-open="modalWx":那条通用通道直接调 openModal, 不经过这里 —— 今天两者等价,但留一个入口,以后要统一加埋点/清理只改一处。 */ $('#rlGift').addEventListener('click', () => openWx()); /* ⚠️ 2026-09-19(9.19 需求):这里原来有一个 syncUnlockCopy(),按「已解锁 / 未解锁」 把首页书架与资源库的两句引导语改成两个版本,并给资源库顶部试用装 banner 挂 .hide。 兑换码闸门取消后不存在解锁态: · 两句文案改为 index.html 里的**静态文案**(#shelfLeadTxt / #rlLead 的 id 也删了, 顺带解掉「给

写 innerHTML 会静默换掉「查看全部 ›」的监听」那个坑); · banner 恒显示,`.hide` 那套不再需要。 别把这个函数加回来 —— 它唯一的存在理由就是「两种解锁状态」。 */ /* ================= 档案生成:周档案 / 完整档案 ================= 产物必须是一张图(要能存相册、发朋友圈、拿去打印),所以直接用 canvas 手画,不引第三方截图库 —— 单文件产物依然 0 外部依赖。 排版走「量一遍、再画一遍」两趟:第一趟 ctx 传 null 只累加 y 得到总高, 按这个高度把 canvas 撑开,第二趟才真正落笔。这样长图有多长就画多长, 完整档案条目再多也不会被裁掉。 换行没有用 ctx.measureText:中文按 1em、ASCII 按 0.52em 估宽就够准, 而且不依赖 ctx,量高那一趟即使拿不到 2d 上下文也能跑完。 */ const PST_W = 750, PST_PAD = 54; const PST_FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif'; const pf = (sz, wt) => (wt || 400) + ' ' + sz + 'px ' + PST_FONT; const fmtMD = d => (d.getMonth() + 1) + '月' + d.getDate() + '日'; const fmtYMD = d => d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日'; const CN_DAY = ['日', '一', '二', '三', '四', '五', '六']; /* 本周一 ~ 本周日(周一为一周之始) */ function weekRange() { const t = midnight(new Date()); const mon = new Date(t.getTime() - ((t.getDay() + 6) % 7) * DAY_MS); return [mon, new Date(mon.getTime() + 6 * DAY_MS)]; } /* 本周的记录:按「周几」定位,未到的日子不编造 */ const WEEK_LOG = [ [0, '他动了 12 次,你说那感觉像有人在里面轻轻敲门。', '数胎动'], [2, '产检一切都好。医生说「长得很稳」,你在走廊上站了一会儿才走。', '孕检日历'], [4, '睡前放了 20 分钟胎教音乐,他跟着动了好几下。', '胎教音乐'], [6, '给他写了一封信,讲了你小时候的事。', '我的回信'] ]; function wrapCJK(text, maxW, fs) { const cap = maxW / fs, out = []; let cur = '', w = 0; for (const ch of String(text)) { const cw = /[\x00-\xff]/.test(ch) ? .52 : 1; if (w + cw > cap && cur) { out.push(cur); cur = ''; w = 0; } cur += ch; w += cw; } if (cur) out.push(cur); return out; } /* 老 Safari 没有 ctx.roundRect,自己用 arcTo 拼 */ function rrect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } /* 时光轴 / 珍贵时刻直接从页面上读,页面改了图自然跟着改 */ const timelineLogs = () => $$('#screen-archive .tl-item').map(it => ({ date: $('.tl-date', it).textContent.trim(), text: $('.tl-text', it).textContent.trim(), from: ($('.tl-from', it) || { textContent: '' }).textContent.trim() })); const momentLogs = () => $$('#screen-archive .moment').map(m => ({ date: $('.mo-meta', m).textContent.trim(), text: $('.mo-title', m).textContent.trim(), from: ($('.mo-from', m) || { textContent: '' }).textContent.trim() })); /* 成长数据这一屏是写死在 index.html 里的,没有 JS 数据源。 照着上面两个的老规矩从 DOM 读 —— 页面上改了数字,打印出来的也跟着改。 体重曲线直接抄 SVG 那条 .line 的 points(viewBox 320×150), 等比缩进画布上的图表框里重画,不用另编一套坐标。 */ function growthData() { const P = $('#screen-archive [data-panel="data"]'); return { cards: $$('.stat-card', P).map(c => { const t = $('.sc-trend', c); return { label: $('.sc-label', c).textContent.trim(), value: $('.sc-value', c).textContent.trim(), trend: t.textContent.trim(), /* up / ok / purple —— 页面上三张卡的趋势色各不相同,图上也照着分 */ tone: (t.className.replace('sc-trend', '').trim() || 'up') }; }), chart: { title: $('.cc-title', P).textContent.trim(), pts: $('.chart .line', P).getAttribute('points').trim() .split(/\s+/).map(s => s.split(',').map(Number)), xs: $$('.cc-x span', P).map(s => s.textContent.trim()) }, /* .m-val 里是「6.1 + cm + P58」三截, textContent 会粘成「6.1cmP58」,所以分开取 */ meas: $$('.meas', P).map(m => { const v = $('.m-val', m); return { name: $('.m-name', m).textContent.trim(), val: (v.childNodes[0].textContent + ($('small', v) || { textContent: '' }).textContent).trim(), rank: ($('em', v) || { textContent: '' }).textContent.trim(), pct: parseFloat($('.m-bar', m).style.getPropertyValue('--p')) || 0 }; }) }; } function posterData(kind) { const week = state.week, day = state.day, days = week * 7 + day; const D = { kind: kind, name: state.babyName, week: week, days: days, left: Math.max(0, (40 - week) * 7 - day), pct: Math.max(0, Math.min(100, Math.round(days / TERM * 100))), weekTxt: '孕 ' + week + ' 周' + (day ? ' +' + day + ' 天' : ''), dueTxt: state.due ? '预产期 ' + fmtYMD(state.due) : '预产期待填' }; if (kind === 'week') { const mon = weekRange()[0], sun = weekRange()[1]; const todayOff = (midnight(new Date()).getDay() + 6) % 7; D.label = '兜爸心选 · 本周档案'; D.range = fmtMD(mon) + ' — ' + fmtMD(sun); D.logs = WEEK_LOG.filter(l => l[0] <= todayOff).map(l => { const d = new Date(mon.getTime() + l[0] * DAY_MS); return { date: '周' + CN_DAY[d.getDay()] + ' · ' + fmtMD(d), text: l[1], from: '来自「' + l[2] + '」' }; }); D.stats = [['本周归档', D.logs.length + ' 条'], ['日均胎动', '31 次'], ['体重变化', '+0.4kg']]; D.secs = [['这一周', D.logs]]; D.foot = '孕 ' + week + ' 周 · 本周共 ' + D.logs.length + ' 条记录'; } else { const tl = timelineLogs(), mo = momentLogs(); D.label = '兜爸心选 · 完整档案'; D.range = '从记录第一天到今天 · 共 ' + days + ' 天'; D.stats = [['已陪伴', days + ' 天'], ['累计归档', '386 条'], ['珍贵时刻', mo.length + ' 个']]; D.growth = growthData(); /* 成长数据那一节不是日志列表,用 null 占位,交给 growthBlocks() 专门画 */ D.secs = [['成长数据', null], ['时光轴 · 全部记录', tl], ['珍贵时刻', mo]]; D.foot = '完整档案 · ' + tl.length + ' 段时光 · ' + mo.length + ' 个珍贵时刻'; } return D; } /* ctx 为 null = 只量高不落笔;返回整张图的高度 */ function paintPoster(D, ctx) { /* HERO 比最后一行文字(基线 288)多留一截,白卡压上来才不会贴脸 */ const W = PST_W, P = PST_PAD, CW = W - P * 2, HERO = 356; if (ctx) { ctx.fillStyle = '#FBF6F2'; ctx.fillRect(0, 0, W, ctx.canvas.height); const g = ctx.createLinearGradient(0, 0, W, HERO); g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FA8A5E'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, HERO); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = 'rgba(255,255,255,.82)'; ctx.font = pf(24, 600); ctx.fillText(D.label, P, 84); ctx.fillStyle = '#fff'; ctx.font = pf(46, 800); ctx.fillText(D.name + '的成长档案', P, 148); ctx.fillStyle = 'rgba(255,255,255,.94)'; ctx.font = pf(26, 500); ctx.fillText(D.weekTxt + ' · 已陪伴 ' + D.days + ' 天', P, 202); ctx.fillText(D.dueTxt + ' · 还有 ' + D.left + ' 天', P, 244); ctx.fillStyle = 'rgba(255,255,255,.8)'; ctx.font = pf(22, 400); ctx.fillText(D.range, P, 288); /* 孕程环 */ const cx = W - 124, cy = 134, r = 54; ctx.lineWidth = 13; ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(255,255,255,.3)'; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = '#fff'; ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * D.pct / 100); ctx.stroke(); ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.font = pf(30, 800); ctx.fillText(D.pct + '%', cx, cy + 2); ctx.font = pf(18, 400); ctx.fillStyle = 'rgba(255,255,255,.85)'; ctx.fillText('孕程', cx, cy + 32); ctx.textAlign = 'left'; } /* 数据卡:压在色带下缘,视觉上把头图和正文缝在一起 */ let y = HERO - 44; if (ctx) { ctx.fillStyle = '#fff'; rrect(ctx, P, y, CW, 168, 30); ctx.fill(); D.stats.forEach((s, i) => { const x = P + CW * (i + .5) / D.stats.length; ctx.textAlign = 'center'; ctx.fillStyle = '#F4593C'; ctx.font = pf(38, 800); ctx.fillText(s[1], x, y + 78); ctx.fillStyle = '#9A8B80'; ctx.font = pf(22, 400); ctx.fillText(s[0], x, y + 122); }); ctx.textAlign = 'left'; } y += 168 + 44; D.secs.forEach(sec => { const title = sec[0], logs = sec[1]; if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(30, 800); ctx.fillText(title, P, y + 30); } y += 30 + 26; logs.forEach((L, i) => { if (ctx) { ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 600); ctx.fillText(L.date, P, y + 21); } y += 21 + 15; wrapCJK(L.text, CW, 27).forEach(ln => { if (ctx) { ctx.fillStyle = '#4A403A'; ctx.font = pf(27, 400); ctx.fillText(ln, P, y + 27); } y += 42; }); if (L.from) { if (ctx) { ctx.fillStyle = '#B39C8E'; ctx.font = pf(20, 400); ctx.fillText(L.from, P, y + 20); } y += 20 + 6; } y += 12; if (i < logs.length - 1) { if (ctx) { ctx.strokeStyle = '#EFE4DB'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke(); } y += 24; } }); y += 30; }); if (ctx) { ctx.strokeStyle = '#EAD9CE'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke(); ctx.textAlign = 'center'; ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 600); ctx.fillText(D.foot, W / 2, y + 48); ctx.font = pf(20, 400); ctx.fillText('生成于 ' + fmtYMD(new Date()) + ' · 兜爸心选', W / 2, y + 84); ctx.textAlign = 'left'; } return y + 84 + 46; } /* ================= 完整档案:A4 分页册子 ================= 本周档案还是上面那张长图 —— 它是发朋友圈用的,切成几页反而没法发。 完整档案不一样,它本来就是拿去打印装订的,所以按真 A4 排。 排版分两步走,不在绘制途中判断换页: 1) 摊平:把整本内容摊成一串 block,每项 {h, keep, draw}。 h 是这一小块的高度,keep 表示「不许和下一块之间断开」。 最小单位是「一行」,任何一块都比整页矮,结构上不可能溢出。 2) 装页:顺序取 block,放不下就翻页。 途中换页最容易出的事是把一条记录腰斩,这样排就不会。 */ const A4_W = 750, A4_H = Math.round(750 * 297 / 210); // 750×1061,正 A4 比例 /* bind = 左侧装订边,留给打孔/订书钉。单面打印、左侧装订, 所以左边距固定放宽,不做奇偶页镜像 —— 家用打印机双面对齐本来就不可靠 */ const MG = { top: 64, bottom: 84, out: 56, bind: 92 }; const BODY_X = MG.bind, BODY_W = A4_W - MG.bind - MG.out; // 92 → 694,宽 602 const BODY_TOP = MG.top + 54; // 让开页眉那行字和它底下的细线 const BODY_BTM = A4_H - MG.bottom; // 正文下边界,再往下是页码 const PAGE_H = BODY_BTM - BODY_TOP; const TONE = { up: '#C4553A', ok: '#5F8A4A', purple: '#7B5FD0' }; /* 一张白底圆角卡,成长数据那几块都用它打底 */ function card(c, x, y, w, h) { c.fillStyle = '#fff'; rrect(c, x, y, w, h, 22); c.fill(); c.strokeStyle = '#F0E6DE'; c.lineWidth = 2; c.stroke(); } /* 章节大标题:keep=true,绝不让它孤零零落在页脚 */ const titleBlock = t => ({ h: 56, keep: true, isTitle: true, draw: (c, x, y) => { c.textAlign = 'left'; c.fillStyle = '#2E241E'; c.font = pf(30, 800); c.fillText(t, x, y + 30); } }); /* 成长数据:三张小卡 + 体重曲线 + 三条测量条 */ function growthBlocks(G) { const out = []; out.push({ h: 150 + 26, draw: (c, x, y) => { const gap = 16, w = (BODY_W - gap * 2) / 3; G.cards.forEach((k, i) => { const cx = x + i * (w + gap); card(c, cx, y, w, 150); c.textAlign = 'left'; c.fillStyle = '#9A8B80'; c.font = pf(19, 500); c.fillText(k.label, cx + 18, y + 40); c.fillStyle = '#2E241E'; c.font = pf(31, 800); c.fillText(k.value, cx + 18, y + 86); c.fillStyle = TONE[k.tone] || TONE.up; c.font = pf(19, 600); c.fillText(k.trend, cx + 18, y + 124); }); } }); out.push({ h: 300 + 26, draw: (c, x, y) => { const H = 300; card(c, x, y, BODY_W, H); c.textAlign = 'left'; c.fillStyle = '#2E241E'; c.font = pf(21, 700); c.fillText(G.chart.title, x + 24, y + 40); /* 把 SVG 的 320×150 视口等比铺到这个框里 */ const gx = x + 26, gy = y + 66, gw = BODY_W - 52, gh = H - 66 - 44; const sx = v => gx + v / 320 * gw, sy = v => gy + v / 150 * gh; const pts = G.chart.pts; if (!pts.length) return; c.strokeStyle = '#F2EAE3'; c.lineWidth = 1.6; [20, 66, 112].forEach(v => { c.beginPath(); c.moveTo(sx(4), sy(v)); c.lineTo(sx(316), sy(v)); c.stroke(); }); const trace = () => { c.beginPath(); c.moveTo(sx(pts[0][0]), sy(pts[0][1])); for (let i = 1; i < pts.length; i++) c.lineTo(sx(pts[i][0]), sy(pts[i][1])); }; trace(); c.lineTo(sx(pts[pts.length - 1][0]), sy(146)); c.lineTo(sx(pts[0][0]), sy(146)); c.closePath(); c.fillStyle = 'rgba(244,89,60,.10)'; c.fill(); trace(); c.strokeStyle = '#F4593C'; c.lineWidth = 3.4; c.lineJoin = 'round'; c.lineCap = 'round'; c.stroke(); pts.forEach((p, i) => { const last = i === pts.length - 1; c.beginPath(); c.arc(sx(p[0]), sy(p[1]), last ? 7 : 5, 0, Math.PI * 2); c.fillStyle = last ? '#F4593C' : '#fff'; c.fill(); c.strokeStyle = '#F4593C'; c.lineWidth = 2.6; c.stroke(); }); c.textAlign = 'center'; c.fillStyle = '#B39C8E'; c.font = pf(18, 400); G.chart.xs.forEach((t, i) => c.fillText(t, gx + gw * (i + .5) / G.chart.xs.length, y + H - 16)); c.textAlign = 'left'; } }); const rowH = 62, measH = G.meas.length * rowH + 28; out.push({ h: measH + 26, draw: (c, x, y) => { card(c, x, y, BODY_W, measH); G.meas.forEach((m, i) => { const ry = y + 14 + i * rowH; c.textAlign = 'left'; c.fillStyle = '#4A403A'; c.font = pf(20, 600); c.fillText(m.name, x + 24, ry + 34); const bx = x + 128, bw = BODY_W - 128 - 180, by = ry + 25; c.fillStyle = '#F2EAE3'; rrect(c, bx, by, bw, 12, 6); c.fill(); c.fillStyle = '#F4593C'; rrect(c, bx, by, Math.max(12, bw * m.pct / 100), 12, 6); c.fill(); c.textAlign = 'right'; c.fillStyle = '#2E241E'; c.font = pf(21, 700); c.fillText(m.val, x + BODY_W - 88, ry + 34); c.fillStyle = '#9A8B80'; c.font = pf(18, 500); c.fillText(m.rank, x + BODY_W - 24, ry + 34); }); c.textAlign = 'left'; } }); return out; } /* 一条记录 = 日期行 + 若干正文行 + 来源行 + 尾巴(分隔线或留白)。 除末行外都挂 keep,整条捆在一起;真有超过一页的长记录, 装页时会把这条的 keep 松掉让它断开 —— 总比撑破一页强。 */ function logBlocks(L, last) { const out = [{ h: 36, keep: true, draw: (c, x, y) => { c.textAlign = 'left'; c.fillStyle = '#B39C8E'; c.font = pf(21, 600); c.fillText(L.date, x, y + 21); } }]; const lines = wrapCJK(L.text, BODY_W, 26); lines.forEach((ln, i) => out.push({ h: 40, keep: i < lines.length - 1 || !!L.from, draw: (c, x, y) => { c.textAlign = 'left'; c.fillStyle = '#4A403A'; c.font = pf(26, 400); c.fillText(ln, x, y + 26); } })); if (L.from) out.push({ h: 26, draw: (c, x, y) => { c.textAlign = 'left'; c.fillStyle = '#B39C8E'; c.font = pf(20, 400); c.fillText(L.from, x, y + 20); } }); /* filler:落在页首就直接扔掉,免得新一页顶上飘一条分隔线或一段空白 */ out.push(last ? { h: 30, filler: true, draw: function () {} } : { h: 36, filler: true, draw: (c, x, y) => { c.strokeStyle = '#EFE4DB'; c.lineWidth = 2; c.beginPath(); c.moveTo(x, y + 12); c.lineTo(x + BODY_W, y + 12); c.stroke(); } }); return out; } /* 摊平 → 装页。返回 {pages, toc},pages[i] 是 [{b, y}…] */ function bookPages(D) { const blocks = []; D.secs.forEach(sec => { const name = sec[0], logs = sec[1]; const push = b => { b.sec = name; blocks.push(b); }; push(titleBlock(name)); if (logs === null) growthBlocks(D.growth).forEach(push); else logs.forEach((L, i) => logBlocks(L, i === logs.length - 1).forEach(push)); push({ h: 26, filler: true, draw: function () {} }); // 节与节之间喘口气 }); const pages = []; let cur = [], y = BODY_TOP; for (let i = 0; i < blocks.length; i++) { const b = blocks[i]; if (!cur.length && b.filler) continue; /* 把这一块和它后面连着的 keep 块当成一个整体量高 */ let need = b.h, j = i; while (blocks[j].keep && j + 1 < blocks.length) { j++; need += blocks[j].h; } if (need > PAGE_H) need = b.h; // 整条比一页还高,只能让它断开 if (y + need > BODY_BTM && cur.length) { pages.push(cur); cur = []; y = BODY_TOP; } cur.push({ b: b, y: y }); y += b.h; } if (cur.length) pages.push(cur); /* 目录页码:封面占第 1 页,正文从第 2 页起 */ const toc = []; D.secs.forEach(sec => { const at = pages.findIndex(p => p.some(it => it.b.sec === sec[0])); toc.push([sec[0], at < 0 ? 1 : at + 2]); }); return { pages: pages, toc: toc }; } function drawCover(c, D, toc, total) { const HH = Math.round(A4_H * .46); c.fillStyle = '#fff'; c.fillRect(0, 0, A4_W, A4_H); /* 色块不做出血:家用打印机印不到纸边,留白反而干净,也省墨 */ const g = c.createLinearGradient(0, 0, A4_W, HH); g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FA8A5E'); c.fillStyle = g; c.fillRect(0, 0, A4_W, HH); c.textAlign = 'left'; c.textBaseline = 'alphabetic'; c.fillStyle = 'rgba(255,255,255,.82)'; c.font = pf(24, 600); c.fillText(D.label, BODY_X, 116); c.fillStyle = '#fff'; c.font = pf(46, 800); c.fillText(D.name + '的成长档案', BODY_X, 190); c.fillStyle = 'rgba(255,255,255,.94)'; c.font = pf(26, 500); c.fillText(D.weekTxt + ' · 已陪伴 ' + D.days + ' 天', BODY_X, 250); c.fillText(D.dueTxt + ' · 还有 ' + D.left + ' 天', BODY_X, 292); c.fillStyle = 'rgba(255,255,255,.8)'; c.font = pf(22, 400); c.fillText(D.range, BODY_X, 340); const cx = A4_W - 148, cy = 178, r = 58; c.lineWidth = 13; c.lineCap = 'round'; c.strokeStyle = 'rgba(255,255,255,.3)'; c.beginPath(); c.arc(cx, cy, r, 0, Math.PI * 2); c.stroke(); c.strokeStyle = '#fff'; c.beginPath(); c.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * D.pct / 100); c.stroke(); c.textAlign = 'center'; c.fillStyle = '#fff'; c.font = pf(30, 800); c.fillText(D.pct + '%', cx, cy + 2); c.font = pf(18, 400); c.fillStyle = 'rgba(255,255,255,.85)'; c.fillText('孕程', cx, cy + 32); /* 数据条压在色块下沿,把封面上下两半缝在一起 */ const sy = HH - 56; c.fillStyle = '#fff'; rrect(c, BODY_X, sy, BODY_W, 152, 26); c.fill(); c.strokeStyle = '#F0E6DE'; c.lineWidth = 2; c.stroke(); D.stats.forEach((s, i) => { const x = BODY_X + BODY_W * (i + .5) / D.stats.length; c.textAlign = 'center'; c.fillStyle = '#F4593C'; c.font = pf(34, 800); c.fillText(s[1], x, sy + 74); c.fillStyle = '#9A8B80'; c.font = pf(21, 400); c.fillText(s[0], x, sy + 114); }); c.textAlign = 'left'; let y = sy + 152 + 86; c.fillStyle = '#2E241E'; c.font = pf(28, 800); c.fillText('目录', BODY_X, y); c.strokeStyle = '#EFE4DB'; c.lineWidth = 2; c.beginPath(); c.moveTo(BODY_X, y + 22); c.lineTo(BODY_X + BODY_W, y + 22); c.stroke(); y += 68; toc.forEach(t => { c.textAlign = 'left'; c.fillStyle = '#4A403A'; c.font = pf(23, 600); c.fillText(t[0], BODY_X, y); c.textAlign = 'right'; c.fillStyle = '#B39C8E'; c.font = pf(22, 500); c.fillText('第 ' + t[1] + ' 页', BODY_X + BODY_W, y); y += 50; }); c.textAlign = 'center'; c.fillStyle = '#C9BBB1'; c.font = pf(19, 400); c.fillText('全书共 ' + total + ' 页 · 沿左侧装订线打孔即可成册', A4_W / 2, A4_H - MG.bottom + 46); c.fillText('生成于 ' + fmtYMD(new Date()) + ' · 兜爸心选', A4_W / 2, A4_H - MG.bottom + 78); c.textAlign = 'left'; } function drawBookPage(c, D, page, no, total) { c.fillStyle = '#fff'; c.fillRect(0, 0, A4_W, A4_H); /* 开章那页不写页眉 —— 大标题就在下面两行处,再写一遍是重复。 页眉是给翻到章节中段的人认路用的,标题在眼前时它多余。 */ const sec = (page[0] && !page[0].b.isTitle && page[0].b.sec) || ''; c.textBaseline = 'alphabetic'; c.fillStyle = '#B39C8E'; c.font = pf(19, 600); c.textAlign = 'left'; c.fillText(D.name + '的成长档案', BODY_X, MG.top); c.textAlign = 'right'; c.fillText(sec, A4_W - MG.out, MG.top); c.strokeStyle = '#EFE4DB'; c.lineWidth = 2; c.beginPath(); c.moveTo(BODY_X, MG.top + 18); c.lineTo(A4_W - MG.out, MG.top + 18); c.stroke(); /* 装订线:一道浅浅的竖虚线,提示这一条左边是留给打孔的 */ if (c.setLineDash) { c.save(); c.setLineDash([6, 8]); c.strokeStyle = '#F0E6DE'; c.lineWidth = 1.6; c.beginPath(); c.moveTo(MG.bind - 34, MG.top - 26); c.lineTo(MG.bind - 34, A4_H - MG.bottom + 26); c.stroke(); c.restore(); } page.forEach(it => it.b.draw(c, BODY_X, it.y)); c.textAlign = 'center'; c.fillStyle = '#B39C8E'; c.font = pf(20, 600); c.fillText('— ' + no + ' —', A4_W / 2, A4_H - MG.bottom + 48); c.textAlign = 'right'; c.fillStyle = '#C9BBB1'; c.font = pf(17, 400); c.fillText('兜爸心选 · 生成于 ' + fmtYMD(new Date()) + ' · 共 ' + total + ' 页', A4_W - MG.out, A4_H - MG.bottom + 48); c.textAlign = 'left'; } let posterURLs = [], posterKind = 'week'; /* 导出层的「这批图叫什么」由 beginSheets() 每次现设,不从 posterKind 推。 exportTag 进文件名,exportTitle 进系统分享面板的标题。 */ let exportTag = '档案', exportTitle = '兜爸心选'; /* 每次要铺新的一批图,必须先调这个。 ⚠️ posterURLs 是模块级的。不清空的后果是静默的:先看一遍「完整档案」, 退出来再点别的生成入口,若这一次没能生成图(老浏览器),posterURLs 里 还躺着上一批带预产期、体重、年份的档案页 —— 一点「分享」就原样发出去了。 把清空和打标签绑在同一个函数里,就没有「忘了清」这条路径。 */ function beginSheets(host, tag, title) { host.textContent = ''; posterURLs = []; exportTag = tag; exportTitle = title; } /* 铺一张纸:白卡纸外框(.pst-frame)+ 一块画布,画完顺手存成 data URI。 拿不到 2d 上下文(极老的机器)就把画布留白,不让整页崩掉。 */ function addSheet(host, w, h, label, drawFn) { const fr = el('div', 'pst-frame'); const cv = document.createElement('canvas'); cv.width = w; cv.height = h; cv.setAttribute('role', 'img'); cv.setAttribute('aria-label', label); fr.appendChild(cv); host.appendChild(fr); let ctx = null; try { ctx = cv.getContext && cv.getContext('2d'); } catch (e) { ctx = null; } if (!ctx) return; drawFn(ctx); try { posterURLs.push(cv.toDataURL('image/png') || ''); } catch (e) { /* 画得出但导不出,忽略 */ } } function renderPoster(kind) { posterKind = kind === 'all' ? 'all' : 'week'; /* ⚠️ 产后态 state.week 是 null:别再拼出「第 null 周档案」,退化成完整档案。 */ const weekOk = (posterKind === 'week' && state.week != null); const D = posterData(posterKind); const host = $('#pstPages'); beginSheets(host, weekOk ? '第' + state.week + '周档案' : '完整档案', state.babyName + (weekOk ? '的本周档案' : '的成长档案')); let sizeChip; if (posterKind === 'week') { /* 本周档案照旧:一张长图,发朋友圈用的 */ const h = Math.round(paintPoster(D, null)); // 先量高,再撑画布 addSheet(host, PST_W, h, '本周档案', c => paintPoster(D, c)); sizeChip = ['成图', PST_W + '×' + h]; } else { const bk = bookPages(D); const total = bk.pages.length + 1; // 加上封面 addSheet(host, A4_W, A4_H, '封面与目录', c => drawCover(c, D, bk.toc, total)); bk.pages.forEach((p, i) => addSheet(host, A4_W, A4_H, '第 ' + (i + 2) + ' 页', c => drawBookPage(c, D, p, i + 2, total))); sizeChip = ['共', total + ' 页 · A4']; D.pageCount = total; } const title = posterKind === 'week' ? '本周档案' : '完整档案'; $('#pstBar').textContent = title; $('#pstName').textContent = title; $('#pstLead').textContent = posterKind === 'week' ? '本周(' + D.range + ')的记录已经排成一张图,存到相册或发朋友圈都行。' : '从记录第一天到今天共 ' + D.days + ' 天,已排成 ' + D.pageCount + ' 页 A4,打印出来沿左边装订线打孔,就能装订成册。'; /* 胶囊直接复用画在图上的那几组数字,再补一条尺寸 —— 到底有多长/多少页,心里先有个数 */ const chips = $('#pstChips'); chips.textContent = ''; D.stats.concat([sizeChip]).forEach(s => { const c = el('span', 'pst-chip', s[0]); c.appendChild(el('b', null, s[1])); chips.appendChild(c); }); /* 两种档案的主按钮不一样:本周档案是拿去发的,完整档案是拿去印的 */ const isBook = posterKind === 'all'; $('#pstPrint').classList.toggle('main', isBook); $('#pstPrint').classList.toggle('ghost', !isBook); $('#pstPrint').textContent = isBook ? '打印装订' : '打印'; $('#pstShare').classList.toggle('main', !isBook); $('#pstShare').classList.toggle('ghost', isBook); $('#pstTip').textContent = !posterURLs.length ? '当前浏览器画不了图片,换个浏览器打开就能保存和打印了。' : isBook ? '上面就是打印出来的样子,一屏一页。点「打印装订」直接出 ' + D.pageCount + ' 张 A4。' : '图片已生成。在微信里也可以长按上面这张图,直接保存或转发。'; } $$('[data-poster]').forEach(b => b.addEventListener('click', () => { renderPoster(b.dataset.poster); openPanel('detailPoster', b); })); /* i 省略 = 单张;带 i 就在名字里标上页码。 /* i 省略 = 单张;带 i 就在名字里标上页码。 名字里那截「是什么」由 beginSheets() 时定下的 exportTag 决定。 */ function posterFile(i) { const d = new Date(), p = n => (n < 10 ? '0' : '') + n; const stamp = d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()); return '兜爸心选-' + exportTag + '-' + stamp + (i ? '-第' + i + '页' : '') + '.png'; } /* 保存 / 分享 / 打印这三件事按 data-export 委托: 按钮上写什么就调 exportAct 里的哪一个,不用逐个绑。 */ const exportAct = {}; $$('[data-export]').forEach(b => b.addEventListener('click', () => { const f = exportAct[b.dataset.export]; if (f) f(); })); /* posterURLs(dataURL)→ File[],给 navigator.share 用。 canvas 出来就是 dataURL,fetch 自己的 dataURL 是最省事的解码方式。 */ function sheetFiles() { const many = posterURLs.length > 1; return Promise.all(posterURLs.map(u => fetch(u).then(r => r.blob()))) .then(bs => bs.map((b, i) => new File([b], posterFile(many ? i + 1 : 0), { type: 'image/png' }))); } const canShareFiles = () => !!(navigator.share && navigator.canShare && typeof fetch === 'function' && typeof File === 'function'); /* ⚠️ 网页存不进相册,这是浏览器的硬限制,不是这里没写。 没有任何 Web API 能让网页把文件写进系统相册 —— 真能,那随便哪个网页 都可以往你手机相册里塞东西了。所以「直接保存到相册」这件事, 网页能做到的最短路径只有一条:调起系统分享面板, 用户点一下「存储图像 / 保存到相册」,由系统去写。iOS 和安卓都是这样。 所以这颗键的顺序改成: 1. 有系统面板(iOS Safari / 安卓 Chrome)→ 直接叫出来,一点就进相册; 2. 没有(微信内置浏览器就没有)→ 退回 , 安卓会落进「下载」相册,iOS 会进「文件」; 3. 再不行 → 长按图片,这是微信里唯一能进相册的路。 以前是先走 2,iOS 上点完只是进了「文件」,用户在相册里怎么翻都找不到。 */ exportAct.save = () => { if (!posterURLs.length) { toast('这台设备生成不了图片,长按图片试试'); return; } const many = posterURLs.length > 1; const download = () => { /* 一次点 N 个 有的浏览器会拦,隔开一点点再点下一张 */ posterURLs.forEach((u, i) => setTimeout(() => { const a = document.createElement('a'); a.href = u; a.download = posterFile(many ? i + 1 : 0); document.body.appendChild(a); a.click(); a.remove(); }, i * 260)); toast(many ? '正在保存 ' + posterURLs.length + ' 页……' : '已下载 · 微信里请长按图片存到相册'); }; if (canShareFiles()) { sheetFiles().then(files => { const pick = navigator.canShare({ files: files }) ? files : navigator.canShare({ files: [files[0]] }) ? [files[0]] : null; if (!pick) { download(); return; } /* 只给 files,不给 title/text:带上文字,系统面板会优先推「发送给某人」, 而这颗键要的是「存到相册」那一项排在前面。 */ return navigator.share({ files: pick }) .then(() => toast('在面板里选「存储图像」就进相册了')); }).catch(() => download()); // 用户取消也会走到这儿,兜底下载不影响什么 } else download(); haptic(); }; exportAct.share = () => { const title = exportTitle; const manual = () => toast('长按上面的图片,就能保存或转发到朋友圈'); /* 能调起系统分享面板就带图分享;微信内置浏览器没有 share(), 退回「长按图片」——那是微信里最通用的一条路。 完整档案是一整本,支持多图就整本发出去,不支持就只发第一页(封面)。 */ if (canShareFiles() && posterURLs.length) { const many = posterURLs.length > 1; /* 和「保存图片」的区别只在这里:分享要带上标题文案, 系统面板才会把「发送给朋友 / 朋友圈」这类去处排在前面。 */ sheetFiles() .then(files => { if (navigator.canShare({ files: files })) return navigator.share({ files: files, title: title, text: title }); if (navigator.canShare({ files: [files[0]] })) return navigator.share({ files: [files[0]], title: title, text: title }); return navigator.share({ title: title, text: title }); }) .then(() => toast(many ? '已唤起分享 · 整本 ' + posterURLs.length + ' 页' : '已唤起分享 · 选「朋友圈」就行'), manual); } else manual(); haptic(); }; exportAct.print = () => { if (!window.print) { toast('这个浏览器不支持直接打印,先保存图片再打印吧'); return; } toast('正在准备打印……'); /* 样式表里的 @media print 已经把这张图之外的东西都藏起来了 */ setTimeout(() => { try { window.print(); } catch (e) { toast('打印没能启动,先保存图片再打印吧'); } }, 140); haptic(); }; /* ================= 搜索层 ================= */ const sl = $('#searchLayer'); $$('[data-search="1"]').forEach(b => b.addEventListener('click', () => { sl.classList.add('show'); setStatusbar('dark'); setTimeout(() => $('#searchInput').focus(), 300); })); $$('[data-search="close"]').forEach(b => b.addEventListener('click', () => { sl.classList.remove('show'); $('#searchInput').value = ''; setStatusbar('auto'); })); $$('.sl-hot .hot').forEach(h => h.addEventListener('click', () => toast('搜索「' + h.textContent.replace(/^\d/, '') + '」'))); $('#searchInput').addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.value.trim()) toast('搜索「' + e.target.value.trim() + '」'); }); /* ================= 横向滑动切页 ================= */ (function swipe() { const order = ['home', 'archive', 'me']; let x0 = 0, y0 = 0, tracking = false; /* 手指落在一个「自己就能横向滚」的容器里时,这次滑动属于那个容器,不是翻页。 这里动态判断,而不是继续往下面那串 class 名单里加东西 —— 首页的 .shelf-rail(横向卡带)就是因为名单没人同步维护才漏掉的: 名单是在它诞生之前写的,于是在卡带上滑一张卡(位移 ~292px,远超 70 的阈值) 会被当成翻页,直接跳到「档案」。名单里的 .stat-row / .moment-grid 其实早就是 grid 布局、根本不横滚,也没人清理,可见这种硬编码名单靠不住。 ⚠️ 阈值给 4px 而不是 0:只设了 overflow-y:auto 的纵向滚动容器, 其 overflow-x 会被 CSS 计算成 auto,再叠上亚像素取整,很容易 出现 scrollWidth 比 clientWidth 大零点几的假阳性,那会把整页的翻页手势废掉。 */ function inHScroll(el) { for (let n = el; n && n.nodeType === 1; n = n.parentElement) { if (n.scrollWidth > n.clientWidth + 4) { const ox = getComputedStyle(n).overflowX; if (ox === 'auto' || ox === 'scroll') return true; } } return false; } document.addEventListener('touchstart', e => { if (openedModal || sheet.classList.contains('show') || panelStack.length || sl.classList.contains('show')) return; if (e.touches.length !== 1) return; if (e.target.closest('.chips-row, .stat-row, .moment-grid, textarea, input')) return; if (inHScroll(e.target)) return; x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; tracking = true; }, { passive: true }); document.addEventListener('touchend', e => { if (!tracking) return; tracking = false; const dx = e.changedTouches[0].clientX - x0; const dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) < 70 || Math.abs(dy) > 60) return; const i = order.indexOf(state.screen); if (i < 0) return; const next = dx < 0 ? i + 1 : i - 1; if (next >= 0 && next < order.length) go(order[next]); }, { passive: true }); })(); /* ================= ESC / 返回 ================= */ document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; /* 弹窗永远在最上层,优先关它 */ if (openedModal) { closeModal(); return; } if (sl.classList.contains('show')) { sl.classList.remove('show'); return; } if (sheet.classList.contains('show')) closeSheet(); }); /* ================= 初始化 ================= */ function init() { /* 返回首页 → 清 last_app(后退重载 / 从应用页 href 回来两种情形;bfcache 由 pageshow 处理)。 放在 resumeLastApp 之前:后退回来的会话里恢复闸门已占住,两者不会打架。 */ clearLastAppOnBackHome(false); /* 冷启动恢复:读一次 last_app,命中就跳到上次进入的 H5 应用(会话内只尝试一次, 见 resumeLastApp)。放在最前面,尽量少闪一下首页。 */ resumeLastApp(); /* 首屏向小程序宿主上报当前位置:宿主据此解析分享卡片(见 reportHostLocation 说明)。 */ if (window.wxAuth && window.wxAuth.reportLocation) window.wxAuth.reportLocation(); /* 默认预产期是「今天 + 3 个月」现算出来的,标记里那些 「孕 24 周 +3 天 / 预产期 2024年12月22日」只是设计稿留下的占位, 进门先按真实 state 刷一遍,免得首屏闪一下旧数字 */ syncPregnancy(); /* ⚠️ 这里原来还有一次 syncBio()。一句话签名整块已删(见 state 里那段说明)。 */ /* ⚠️ 这里原来还有一次 syncUnlockCopy()(按解锁态改写首页/资源库两句引导语)。 闸门取消后文案是静态的,函数已删。 */ animateRings(); /* ⚠️ 底 tab 红点与首页「宝宝来信」卡的 owner 都是 laixin 未读数(syncLaixinReminder), 不由任何本地计数驱动。别在这里再写一遍 —— 两处都写会互相覆盖。 两者在 syncLaixinReminder() 拉到数之前保持隐藏/收起(index.html 里就是默认态)。 */ // 让档案页的分段控件在首次布局后归位 setTimeout(() => { $('#archiveSeg')._reflow && $('#archiveSeg')._reflow(); }, 60); /* 远程数据按**依赖顺序**取,别并发(2026-09-22 收口): ① 身份 —— hashParam('openid'/'unionid')(URL → 同源缓存兜底,见 wx-auth.js) ② 资料 —— box_app /profile + /pregnancy:**会从上游同步预产期/小名/宝宝名**, 也是决定 state.pregEmpty 的唯一来源 ③ 应用数据 —— 今日脚印 / 首页工具卡 / 来信未读(都依赖 ①,足迹的显隐还依赖 ②) ⚠️ 原来这五个 fetch 一起发、谁先回来谁写 DOM —— 竞态都是这么来的: 今日脚印整块显隐被 profile 回来后又翻回去;来信提示的红点与卡各写一份判据。 所以这里显式**等 ② 落地**再取 ③。 ⚠️ 加兜底超时:② 万一挂住(上游不响应),③ 也必须照常发出,别让首页空等。 ⚠️ 必须放在 init() 渲染之后(若在脚本解析时就拉,fetch 的 then 常比 init 先跑, 随后 init 又把 state 重置回默认值 → 档案数据被覆盖)。 */ Promise.race([ applyRemoteProfile(), new Promise(resolve => setTimeout(resolve, REMOTE_PROFILE_WAIT_MS)), ]).then(() => { // 第三批:应用数据(资料库/记录/内容 + 今日脚印 + 首页工具卡 + 来信未读) applyRemoteAll(); applyRemoteFootprint(); applyRemoteCardsFetch(); loadVitals(); syncLaixinReminder(); loadBlindbox().then(() => { // 孕期盲盒:要等 ② 的预产期落地(服务端按同一份上游视图算孕周) /* 2026-10-07 用户要盲盒直达链接:地址带 open=box(# 或 ? 里都行)→ 数据到了直接弹盲盒面板。 入口横幅 / 卡册行仍隐藏,不带参数时页面和以前一样。只弹一次,别的参数不受影响。 */ if (hashParam('open') === 'box' && !window.__bbDeepOpened) { window.__bbDeepOpened = 1; openBoxEntry(); } }); }); } /* ---------- 远程配置(box_app 后台下发):已下线 ---------- ⚠️ 2026-09-19(9.19 需求 1.2):这里原来拉 /api/mp/public/mama-box/config, 用后台下发的 pan / panDefault / wxCode 覆盖页面里的网盘链接表与兑换码。 H5 不再发放网盘链接(资料由顾问在微信里直接发)之后,配置里已经没有一项是 H5 还读的,整个 fetchRemoteConfig() 删除。 ⚠️ 后端那个接口与 MamaBoxConfig 模型(pan / panDefault / wxCode)暂时**保留未删** —— 它们还有管理端读写、以及 /resources/{key}/claim 那条老接口在用; 等确认没有任何消费者之后再一起清理(见 docs/mama-box-change-list-20260919.md 的 T1b)。 */ /* ---------- 远程个人档案(box_app 下发,openid 单一身份键) ---------- 小程序首页把 openid 经 #hash 传给本页(微信 web-view 丢 query,hash 不丢), 这里按 openid 拉 box_app 档案,覆盖 demo 默认值: babyName / userName / babySex / dueDate(dueDate 是源,改了会重算孕周)。 让首页、孕周都跟真实身份走。 拉不到 —— 无 openid / 未建档 / 接口挂 —— 就用上方内置默认值,页面照常可用。 ⚠️ 只在「后台有值」时覆盖:后台该字段为空(未建档)就维持内置默认,不把页面 打回白板。同样静默失败,不打断用户。 ⚠️ 竞态:fetch 异步,回来时 init() 已跑完,这里只需回刷那些读 state 的展示。 */ function hashParam(name) { /* 同时读 hash 和 query:浏览器直访(?openid=x)只有 query; 小程序 web-view 的授权回传在 hash(微信 webview 丢 query 不丢 hash)。 ⚠️ 先在原始串上匹配、再对捕获值 decode —— 先 decode 整串会把 %26 之类的编码提前还原成 &,破坏参数切分。 ⚠️ split('?'):历史 bug 会把缓存戳粘进 openid(abc001?t=123), 真 openid 不含 ?,剥掉以兼容已被污染的旧链接。 */ try { const h = (location.hash.slice(1) || '') + '&' + (location.search.slice(1) || ''); const m = h.match(new RegExp('(?:^|[&#])' + name + '=([^&#]*)')); const v = m ? decodeURIComponent(m[1]).split('?')[0] : ''; if (v) return v; } catch (e) { /* 落到下面的兜底 */ } /* 身份两键(openid/unionid)URL 没带时回落**同源缓存**: H5 应用里「返回首页」是页面自己发起的同源跳转,宿主的 web-view src 没变、 _applyIdentity() 不会触发,URL 上永远不会有身份(第三方应用还会主动把 openid 从自己的 URL 上擦掉)。缓存由 wx-auth.js 的 identity() 写入 —— 凡是引了共享库的页面(含 mama-box 自己)拿到 URL 身份时都会顺手存一份。 纪律(URL 永远权威、只在本 origin 有效、只存 openid/unionid)见 js/wx-auth.js 顶部那段;命中缓存时 identity() 会打一条日志便于排查。 */ if (name === 'openid' || name === 'unionid') { try { const id = (window.wxAuth && window.wxAuth.identity) ? window.wxAuth.identity() : null; if (id && id[name]) return id[name]; } catch (e) {} } return ''; } /* ---------- 身份未就绪提示(2026-09-20) ---------- 宿主(小程序首页壳)拿不到 openid 时会**匿名加载**本页 —— 页面照常渲染, 但全是占位数据(孕周 `--`、档案空、跳应用不带身份)。以前这种情况完全静默, 排查时只能靠服务端日志反推;现在给一条可见提示。 ⚠️ 只在「确实没有 openid」时显示;一旦宿主把身份注入回来(onShow 重载), applyRemoteProfile 会再跑一次,这里要 hide。 ⚠️ 不做自动轮询/自动 reload:宿主只在它自己重设 web-view src 或 onShow 时注入身份, 页面自己 reload 同一 URL 并不会让身份出现(反而可能循环),所以只留手动重试。 */ /* 2026-09-30 产品要求:顶部这条黄色提示条先不给用户看。 只关展示,判定逻辑、标记、重试都原样留着;恢复时删掉下面这个开关即可。 没身份时仍在控制台打一条,排查不受影响。 */ const ID_NOTICE_ENABLED = false; function showIdNotice(reason) { if (!ID_NOTICE_ENABLED) { try { console.warn('[mama-box] 未获取到微信身份(提示条已关闭)', reason || ''); } catch (e) {} return; } const el = $('#idNotice'), txt = $('#idNoticeTxt'); if (!el) return; if (txt) { /* ⚠️ 重试过一次之后(sessionStorage 计数)文案要保留「请退出小程序重进」, 否则重载回来又变回温和版,用户以为重试没发生过(重试是 location.replace, 文案状态活不过那一次跳转,只能落到 sessionStorage)。 */ let tried = reason === 'retried'; if (!tried) { try { tried = (parseInt(sessionStorage.getItem('mmbox.idretry') || '0', 10) || 0) > 0; } catch (e) {} } txt.textContent = tried ? '仍未获取到微信身份,请退出小程序后重新进入' : '未获取到微信身份,页面数据可能不完整'; } el.classList.remove('hide'); } function hideIdNotice() { const el = $('#idNotice'); if (el) el.classList.add('hide'); } /* 重试:只做一次「换 URL 重载」——带上 ?t= 让宿主/缓存放行。 宿主 onShow 也会重新注入身份(切到别的 tab 再回来即可),提示文案里已说明。 */ function retryIdentity() { let n = 0; try { n = parseInt(sessionStorage.getItem('mmbox.idretry') || '0', 10) || 0; } catch (e) {} try { sessionStorage.setItem('mmbox.idretry', String(n + 1)); } catch (e) {} showIdNotice('retried'); setTimeout(() => { try { const u = location.href.split('#')[0].split('?')[0] + '?t=' + Date.now(); location.replace(u); } catch (e) { location.reload(); } }, 500); } $('#idNoticeRetry').addEventListener('click', retryIdentity); /* ② 资料最多等这么久(ms)就让 ③ 应用数据照常发出:上游慢/挂住时首页不能空等 */ const REMOTE_PROFILE_WAIT_MS = 10000; /* 孕期状态写进 state(/pregnancy 返回体)。首屏和「回到前台 / 打开孕期档案」重拉共用。 2026-10-11 用户:所有应用孕周必须统一 —— 别的应用改了预产期,回到首页要跟着变。 */ function applyPregnancy(pg) { if (pg && pg.code === 200 && pg.result) { if (pg.result.postpartum) { /* 产后态(2026-09-22):上游 journey_stage=postpartum / 有 baby_birth_date。 ⚠️ 必须**先**判它再判 empty/week==null —— 产后返回体的 week 也是 null, 落到下面那个分支会被当成「未完善资料」,给已生产的用户弹建档引导。 */ state.postpartum = true; state.pregEmpty = false; state.week = null; state.day = null; state.babyAgeTxt = pg.result.babyAgeTxt || '宝宝已出生'; state.babyAgeDays = (pg.result.babyAgeDays == null ? null : pg.result.babyAgeDays); state.babyBirthDate = pg.result.babyBirthDate || ''; if (pg.result.due) { const d = parseDay(pg.result.due); if (d) state.due = d; } } else if (pg.result.empty || pg.result.week == null) { state.postpartum = false; state.pregEmpty = true; state.due = null; state.week = null; state.day = null; } else { state.postpartum = false; state.pregEmpty = false; state.week = pg.result.week; state.day = pg.result.day; if (pg.result.due) { const d = parseDay(pg.result.due); if (d) state.due = d; } } } } let _pregAt = 0; function refreshPregnancy() { const openid = hashParam('openid'); if (!openid || DEMO) return Promise.resolve(false); return fetch('/api/mp/public/mama-box/pregnancy?openid=' + encodeURIComponent(openid)) .then(r => (r.ok ? r.json() : null)) .then(function (pg) { if (!pg || pg.code !== 200 || !pg.result) return false; const before = state.due ? toISO(state.due) : ''; applyPregnancy(pg); _pregAt = Date.now(); if ((state.due ? toISO(state.due) : '') === before) return false; syncPregnancy(); if (!state.pregEmpty && !state.postpartum) { syncBabyWords(); syncStaticDates(); } if (plHook) setTimeout(plHook, 0); // 「此刻」按新孕周重拉 return true; }).catch(function () { return false; }); } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && Date.now() - _pregAt > 5000) refreshPregnancy(); }); window.addEventListener('pageshow', function (e) { if (e.persisted) refreshPregnancy(); }); let _profileApplied = false; /* ⚠️ 返回 Promise:调用方要**等资料落地**再取应用数据(预产期/小名来自上游同步, 它决定 state.pregEmpty,而今日脚印的显隐依赖它)。别改成 fire-and-forget。 */ function applyRemoteProfile() { if (_profileApplied) return Promise.resolve(); // init 只跑一次,防重复 fetch _profileApplied = true; const openid = hashParam('openid'); /* 访客态:宿主未注入身份(冷启动 silenceAuth 失败走「匿名加载 H5」兜底)。 ⚠️ 绝不能停在内置演示默认值上(小汤圆 / 孕27周 都是前端现算的假数据)—— 全部切占位/引导;宿主拿到身份后会带 openid 重载本页,届时自动切真实数据。 */ if (!openid) { /* ⚠️ 可见提示(2026-09-20):宿主没注入身份时别静默装正常。 排查路径见 showIdNotice() 上方那段注释。 */ showIdNotice(); state.pregEmpty = true; state.due = null; state.week = null; state.day = null; state.babyName = ''; state.userName = ''; syncPregnancy(); /* 占位 + 隐藏脚印时间轴(内含 renderLetterReminder(),会收起来信提示) */ /* 兜底再算一次:访客态不该出现任何「你有新信」的暗示(卡 + 红点一起收)。 唯一渲染入口是 renderLetterReminder(),别再这里单独 hideLetterCard()。 */ renderLetterReminder(); return Promise.resolve(); } 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 以服务端权威派生为准(避免客户端重复算、口径统一) 拉不到(无 openid / 未返回 200 / 断网)就静默保持内置默认值,页面照常可用。 */ /* 走到这里说明这次加载是带身份的(openid 非空)→ 把上一次的提示收掉 */ hideIdNotice(); return Promise.all([ 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]; if (pj && pj.code === 200 && pj.result) { const p = pj.result; /* 服务端是唯一事实源:新用户 babyName 为空也要覆盖内置演示名(小汤圆), 让全站称呼走 babyWord 的「宝宝」兜底 */ state.babyName = p.babyName || ''; /* 2026-10-25 用户定:昵称和其他应用统一 —— 脱敏手机号(含 ****)当空值,不显示也不预填进编辑资料 */ var _un = (p.userName && p.userName.indexOf('****') < 0) ? p.userName : ''; state.userName = _un; if (p.babySex && p.babySex !== 'unknown') state.babySex = p.babySex; if (p.dueDate) { const d = parseDay(p.dueDate); if (d) state.due = d; } /* 状态化「我的」页:未授权→占位+显示授权入口;已授权→资料+隐藏授权入口;已建档(B→C)由孕期信息卡引导 */ var _authorized = !!p.authorized || !!p.isAuthorized; state.authorized = _authorized; /* 方案A:显示看 authorized 而不是 userName —— 后端退出只置 authorized=false,昵称/头像仍在库里 */ /* 已授权但没有可用昵称:别写「未登录」,和育儿首页一样按小名叫「兜兜妈妈」,没小名叫「准妈妈」 */ var _n = $('#meName'); if (_n) _n.textContent = _authorized ? (_un || (state.babyName ? state.babyName + '妈妈' : (state.postpartum ? '宝宝妈妈' : '准妈妈'))) : '未登录'; /* 2026-10-25:和其他应用同一套取值(见 /yunyu/ js/app.js 头像取值注释)—— 商城默认头像(shop.douba.top/uploads/system/…)当空值,退到头像库 avatarPicker.defaultFor(openid), 否则「我的」显示商城灰头像、别的应用显示头像库图,同一个人两处不一样 */ var _av = (p.avatar && p.avatar.indexOf('/uploads/system/') < 0) ? p.avatar : ''; if (!_av) { try { if (window.avatarPicker && window.avatarPicker.defaultFor) _av = window.avatarPicker.defaultFor(openid); } catch (e) { /* 落到线稿小人 */ } } if (_av && _authorized) { var _ai = $('#meAvatarImg'), _aw = $('#meAvatar'); if (_ai) _ai.src = _av; if (_aw) _aw.classList.add('has-img'); } /* 授权入口:未授权时显示(引导先授权);退出按钮:已授权时显示 */ var _box = $('#meAuthBox'); if (_box) _box.style.display = _authorized ? 'none' : ''; var _sv = $('#meSaved'); if (_sv) _sv.style.display = _authorized ? 'none' : ''; ['#meLogoutCard', '#pfLogout'].forEach(function (sel) { var _lx = $(sel); if (_lx) _lx.style.display = _authorized ? '' : 'none'; }); if (!_authorized) { /* 昵称为空不再连带清头像:头像已有头像库兜底 */ var _ai2 = $('#meAvatarImg'), _aw2 = $('#meAvatar'); if (_ai2) _ai2.removeAttribute('src'); if (_aw2) _aw2.classList.remove('has-img'); } } applyPregnancy(pg); if (DEMO) { // 示例模式:固定孕 29 周 0 天,预产期 = 今天 + (280 - 203) 天 state.postpartum = false; state.pregEmpty = false; state.week = DEMO_WEEK; state.day = 0; const t = new Date(); t.setHours(0, 0, 0, 0); state.due = new Date(t.getTime() + (TERM - DEMO_WEEK * 7) * DAY_MS); if (!state.babyName) state.babyName = '小汤圆'; } // 统一回刷依赖这些 state 的展示 syncPregnancy(); if (plHook) setTimeout(plHook, 0); // 「此刻」按孕周重拉(setTimeout:等下面 syncStaticDates 把 #calNext 算好) /* 新用户空态:syncPregnancy 已写完引导占位(含称呼/静态位归零)。 ⚠️ 下面 syncBabyWords/syncStaticDates 全按 state.week 重算, 而 pregEmpty 时 state.week 还是内置默认 27 —— 不拦住的话, 刚写好的占位会被改回「孕 27 周 / 9月15日」演示值。 ⚠️ 产后态同样要拦:syncPregnancy 的产后分支已写好「已出生」与 '--' 占位, 而 state.week 是 null → syncStaticDates 会按 ga=0 算出**看着正常但全错**的日期。 */ if (state.pregEmpty || state.postpartum) { return; } syncBabyWords(); syncStaticDates(); }).catch(function () { /* 静默回退内置值 */ }); } /* ---------- 远程数据(box_app,统一改H5 · 第一批:资料库/记录/内容) ---------- applyRemoteProfile 已拉 profile/pregnancy/config; 这里再拉 resources/records/content 并**原地覆盖**本地 demo,接口数据为准,拉不到则保持内置。 ⚠️ RES 图片:后端把封面图放到 box_app /assets,这里把返回的 imgUrl 赋给 RES[key].img, 渲染(用 r.img)无需改动;正文(body)仍用本地(接口未下发,保证详情不空)。 */ function __applyResources(items) { if (!items || !items.length) return; Object.keys(RES).forEach(function (k) { delete RES[k]; }); var order = []; items.forEach(function (it) { var old = RES[it.key] || { body: [] }; RES[it.key] = { img: it.imgUrl || old.img || '', cat: it.cat, tint: it.tint, title: it.title, desc: it.desc, meta: it.meta || old.meta || [], body: old.body || [], }; order.push(it.key); RES[it.key].fav = !!it.fav; /* ⚠️ 接口还返回 got(已领),但 H5 侧已无「已领」概念(9.19 需求), 这里**有意不读** —— 别顺手把它加回 RES/state。 */ }); // 同步收藏状态(对齐后端)。已领(resGot)已删除,见文件顶部 state 那段说明。 state.resFav = items.filter(function (i) { return i.fav; }).map(function (i) { return i.key; }); renderResList(state.resCat); } function __applyRecords(rec) { if (!rec) return; state.kickToday = rec.kickCount || 0; state.todayCount = rec.total || 0; var a = $('#kickTodayHome'), b = $('#todayCount'), c = $('#kickCount'); /* ⚠️ #kickTodayHome 归**卡片接口**(tool-cards,来自数胎动应用本身)优先: mama-box 早就不自己记胎动了(卡上那颗「记录」是跳去数胎动),本地 records 常年是 0。 两个接口并行返回、先后不定,所以用 cardsOwned.kick 这个旗子定优先级,而不是赌顺序。 */ if (a && !cardsOwned.kick) a.textContent = state.kickToday; if (b) b.textContent = state.todayCount; if (c) c.textContent = state.kickQuick || 0; } /* 禁止双指缩放(2026-10-01 用户要求首页固定)。iOS 微信会忽略 viewport 的 user-scalable=no, 所以再拦一道:iOS 的 gesture 事件 + 多指 touchmove;双击放大由 CSS touch-action:manipulation 处理。 */ (function noZoom() { ['gesturestart', 'gesturechange', 'gestureend'].forEach(t => document.addEventListener(t, e => e.preventDefault(), { passive: false })); document.addEventListener('touchmove', e => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false }); })(); /* ================= 首页「孕育工具」卡片(box_app 封装数胎动平台数据,2026-09-22) ================= 数据源:GET /api/mp/public/mama-box/tool-cards(box_app 归一化后的稳定契约; 上游是数胎动平台的 GET /taidong/api/user/app-cards,H5 不直连、也不持有上游 token)。 两条纪律: ① **接口优先、失败回退本地**:ok=false 或某槽缺失 → 保留卡上现有值(本地派生/占位), 页面绝不因为上游抖动而变空;`stale=true` 时接口回吐的是 6h 内的旧值,照样可用。 ② **名称与链接仍以 App 表为准**:卡片点击走 MINI_APP_MAP + APP_BY_ID(mama-box AGENTS §4.4 「不要在本仓库写死应用名单」);接口给的 appId/path 只作诊断 —— 上游的 app_id 是 URL slug(`taier-guzhong`),与 App 表 identifier(`taierguzhong`)刻意不同,不能拿来 join。 每槽落到哪:kick→#kickTodayHome(+#kickExtra) / fweight→.fweight .mc-sub / weight→#weightVal(+#weightExtra) / cal→#calNext / curve→.curve .mc-sub。 ⚠️ 上游字符串一律走 textContent,**绝不用 innerHTML**(同 footprint 的写法)。 ⚠️ fweight/cal/curve 三张是固定高度且列窄,没有第二行的位置 —— 它们的 sub 暂不展示, 要展示得先重排 .bento 的高度账(CSS 里 .mc-extra 那段有说明)。 */ const cardsOwned = { kick: false, cal: false }; // 卡片接口已接管哪些格(本地源据此让位) function applyRemoteCards(r) { if (!r || r.ok === false || !(r.cards || []).length) return; // 降级:保留现有值 lastCards = r; vtCards(r); const bySlot = {}; /* 换成替补的坑位(孕周没到)不吃原工具的数据,免得把「馋了先查一下」改成「今天 0 次」 */ r.cards.forEach(c => { const el = c && c.slot ? $('.tg[data-mini="' + c.slot + '"]') : null; if (c && c.slot && !(el && el.dataset.swap)) bySlot[c.slot] = c; }); const cardOf = slot => $('.tg[data-mini="' + slot + '"]'); const mark = (slot, c) => { const el = cardOf(slot); if (el) el.classList.toggle('is-warn', !!c.warn); }; const setText = (sel, v) => { const el = $(sel); if (el) el.textContent = v || ''; }; const k = bySlot.kick; if (k) { /* 卡上静态文案就是「今天 N 次」,上游的 label/unit 正是「今天 / 次」—— 只写数字、保留 的数字强调。上游若改措辞,这里要连带改静态文案。 */ cardsOwned.kick = true; const b = $('#kickTodayHome'); if (b) b.textContent = k.value || (k.empty ? '0' : '--'); setText('#kickExtra', k.sub); mark('kick', k); } const f = bySlot.fweight; if (f) { setText('.tg.fweight .tg-s', f.text); mark('fweight', f); } const w = bySlot.weight; if (w) { const big = $('#weightVal'); if (big) { big.textContent = w.value || '记一笔'; /* 2026-11-01:没记过体重时胶囊给动作,不显示「--」 */ /* 单位单独小字;只在有值时补,避免出现「-- kg」 */ if (w.value && w.unit) { const sm = document.createElement('small'); sm.textContent = w.unit; big.appendChild(sm); } } setText('#weightExtra', w.sub); mark('weight', w); } const cal = bySlot.cal; if (cal) { cardsOwned.cal = true; // 让 syncStaticDates 的本地推算让位(两者并行、先后不定) setText('#calNext', cal.text || '--'); if (cal.warn) { /* 产检已过期未记时上游给 label=待补记:卡上只有一行,把 label 拼在日期前一起标红 */ setText('#calNext', (cal.label && cal.label !== '下次产检') ? cal.label + ' · ' + (cal.text || '') : (cal.text || '--')); } mark('cal', cal); } const cv = bySlot.curve; if (cv) { const el = $('.tg.curve .tg-s'); if (el) { /* 有数据时上游 text 是「最近间隔 4分12秒」,那列只有 ≈73px 放不下(会省略掉最关键的数字)→ 改用紧凑的 value+unit(「4分12秒」);空态 text 本来就是「孕 7 周」这种短句,直接用。 */ el.textContent = (!cv.empty && cv.value) ? (cv.value + (cv.unit || '')) : (cv.text || '--'); /* 摘掉 data-wk:否则 syncStaticDates 的 [data-wk] 循环会把「最近间隔 4分12秒」覆盖成孕周 */ el.removeAttribute('data-wk'); } mark('curve', cv); } } /* ================= 宝宝消消乐 · 推广入口(2026-09-30) ================= GAME_URL 为空 → 入口不显示。小棋盘:每 2.6 秒随机一行/一列先亮一下,再「消掉」换一批新的掉下来。 */ /* 2026-10-09 用户:消消乐改调「首页 / 宝宝消消乐」会话(80d347e2)那版。正式版,2026-10-08 上线。 2026-10-09 mp nginx 已加 /muying-xiaoxiao/ 反代 → 改同源相对路径。 对方不读 URL 参数(进度、兜豆按设备号存在浏览器本地),带身份参数无害;没有完成回传接口。 */ const GAME_URL = '/muying-xiaoxiao/'; // 2026-10-09 同源相对路径(mp nginx 反代到 taidong) const GAME_ID = 'baobao-xiaoxiaole'; (function gamePromo() { const sec = $('#gamePromo'), board = $('#gpBoard'); if (!sec || !GAME_URL) return; sec.classList.remove('hide'); $('#gpCard').addEventListener('click', () => jumpApp(GAME_URL, GAME_ID)); const ITEMS = ['🍼', '🧸', '👶', '🧦', '🛁', '🧴', '👕', '🎀']; const rnd = () => ITEMS[Math.floor(Math.random() * ITEMS.length)]; const cells = []; for (let i = 0; i < 9; i++) { const c = document.createElement('i'); c.textContent = rnd(); board.appendChild(c); cells.push(c); } if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8]]; setInterval(() => { if (document.hidden) return; const line = LINES[Math.floor(Math.random() * LINES.length)], e = rnd(); line.forEach(k => { cells[k].textContent = e; cells[k].classList.add('is-hint'); }); setTimeout(() => line.forEach(k => { cells[k].classList.remove('is-hint'); cells[k].classList.add('is-pop'); }), 700); setTimeout(() => line.forEach(k => { const c = cells[k]; c.textContent = rnd(); c.classList.remove('is-pop', 'is-in'); void c.offsetWidth; c.classList.add('is-in'); }), 1100); }, 2600); })(); /* ================= 3D 婴儿房 · 页面上的一道空隙(2026-10-01 三版)================= 设计写在 index.html 的 #nursery 注释里。地址由「首页 / 3D婴儿房」会话(6a7224a5)维护; 带 return_url(首页净地址,不含 hash),对方返回按钮读到就跳回来(只放行 *.yizhezhe.com / *.douba.top)。syncPregnancy() 每次都会调 nurseryRender()。 2026-10-09 用户:房子链接改成 6a7224a5 会话那版;同日 mp nginx 加了 /yinger-fang/ 反代 → 改同源相对路径。 */ const NURSERY_URL = '/yinger-fang/'; // 2026-10-09 同源相对路径(mp nginx 反代到 taidong) const NURSERY_ID = 'yef-nursery'; function nurseryRender() { if (!$('#nursery')) return; /* 2026-10-01 封面换成整栋小别墅(婴儿房是二楼剖开那间),H5 进去先看整栋、标题「我们的家」,这里跟着改 */ $('#nyTitle').textContent = '我们的家'; let days = null; if (!state.postpartum && state.pregEmpty === false && state.due) { const t = new Date(); t.setHours(0, 0, 0, 0); days = Math.round((state.due - t) / 864e5); } $('#nySub').innerHTML = state.postpartum ? 'TA 已经住进来啦,再添点新东西吧' : days > 0 ? '还有 ' + days + ' 天入住 · 攒兜豆把它布置好' : days != null ? 'TA 随时会住进来,快去布置好' : '做孕期任务攒兜豆,一件件布置好'; } (function nurseryInit() { const sec = $('#nursery'); if (!sec) return; /* 2026-10-02 整块可点(按钮的点击冒泡上来,同一个入口) */ sec.addEventListener('click', () => jumpApp(NURSERY_URL + '?return_url=' + encodeURIComponent(location.origin + location.pathname), NURSERY_ID)); /* 视差:图层始终对齐 .screen 的可视区 → 页面滚、房间不动,像铺在整页背后 */ const layer = $('#nypLayer'), scr = sec.closest('.screen'); let raf = 0; const place = () => { raf = 0; if (!scr) return; const s = scr.getBoundingClientRect(), r = sec.getBoundingClientRect(); if (!s.height || !r.height) return; layer.style.height = s.height + 'px'; layer.style.transform = 'translate3d(0,' + Math.round(s.top - r.top) + 'px,0)'; }; const req = () => { if (!raf) raf = requestAnimationFrame(place); }; if (scr) scr.addEventListener('scroll', req, { passive: true }); window.addEventListener('resize', req); if ('IntersectionObserver' in window) new IntersectionObserver(req).observe(sec); place(); /* 封面版本:3D 婴儿房会话换图(文件名不变)时改这里,强制微信 webview 重新拉图 */ const NY_COVER_V = '20261006a'; const tick = () => { const h = new Date(Date.now() + 8 * 3600e3).getUTCHours(); // 北京时间 const night = h >= 19 || h < 6; sec.classList.toggle('is-night', night); const img = $('#nypImg'), src = 'assets/img/nursery-' + (night ? 'night' : 'day') + '-sky.jpg?v=' + NY_COVER_V; // 2026-10-06 换补了天空的版本 if (img.getAttribute('src') !== src) img.setAttribute('src', src); }; tick(); setInterval(tick, 600e3); nurseryRender(); })(); /* ================= 此刻,孕妈们在做什么(2026-10-01,需求文档 v1.0 第二节) ================= GET /api/mp/public/mama-box/pulse(免登录、所有人同一份)→ { show, total, today[], feed[] }。 · 数据是模拟还是真实由 box_app 后台 pulseMode 决定,前端不区分、不兜底造数:show=false / 接口失败 → 整卡隐藏。 ⚠️ 模拟阶段是 AGENTS.md §4.12 登记的例外,只限这张卡;别把这里的「失败兜底」改成前端自造数字。 · 动效:进入视口时大数字从 0 数上去;之后每 60 秒刷新一次,数字平滑涨到新值;动态每 3 秒往上翻一条。 · 今日三项可点:kick / weight 复用首页工具卡的跳转(MINI_APP_MAP),game 去消消乐。 · 2026-10-01 A+B 改版:已建档时带 ?week= 拉,接口多给 peer → 卡片换成「和你一样孕 N 周的妈妈们」: 同周人数 + 她们今天在做(百分比条,右侧对比「你今天还没数 ›」可直达)+ 今天别忘了(产检倒数 / 本周盲盒) + 同周动态。无 peer(访客 / real 模式)→ 一行总人数 + 动态。 · 同日再改:收进孕育工具卡底部做轻量条,删「她们在聊」和全站「今日三项」小卡(见 index.html #pulse 注释)。 */ const PULSE_ICO = { kick: '', weight: '', cal: '', game: '', box: '' }; /* 2026-10-01 曾按用户要求换成应用 logo;同日「此刻」A+B 改版用户又要求「去掉应用 logo 一类的工具化视觉、 改用表现人的元素」(和上面孕育工具区撞脸)→ 动态行改用妈妈头像(momAva),全站统计格回到单色小图标。 */ function pulseIco(k, cls) { return '' + (PULSE_ICO[k] || PULSE_ICO.box) + ''; } /* 「此刻」头像(2026-10-10 用户:「换成真实人物的,不要卡通」→ 同日二改:妈妈、宝宝、风景穿插 → 同日三改:「上面那 8 个和下面一直轮换的头像不要有重叠,多准备一些」)。 45 张 AI 生成的写实照片(妈妈 m* / 宝宝 k* / 风景 s*),拆成两个**不相交**的池: · AVA_TOP 20 张(含 4 张头像库)→ 上面叠着的 8 个(按孕周取连续 8 张,组内不重复); · AVA_FEED 35 张(含 6 张头像库)→ 下面滚动动态,按行序依次取(一轮动态最多 20 条 < 35,不会撞)。 全部是 AI 虚构画面,不对应任何真实用户;96×96 JPG,懒加载。原 SVG 卡通头像见 git 历史 v26101001。 2026-10-12 用户:「好多特写的妈妈近脸照,太单调,要有全身照、和老公的合照」→ 新图生成额度不足,先过渡: 妈妈近脸从 22 张砍到 14 张(去掉最像的几张),优先半身 / 户外 / 孕肚(m18~m22),每 3 个里最多 1 张妈妈脸; 两池仍不相交:AVA_TOP 18 张、AVA_FEED 29 张。同日额度充上后补 14 张 p01~p14(孕妇全身、夫妻合照、一家三口、生活场景), 两池各约 1/3 是 p*,妈妈近脸 m* 退到一半以下;AVA_TOP 23 张、AVA_FEED 37 张,仍不相交。 */ const AVA_TOP = ['p01', 'k01', 's02', 'm20', 'p02', 'mom-03', 'k04', 'p03', 's05', 'baby-02', 'm13', 'p04', 'k06', 's09', 'p05', 'mom-11', 'k10', 'p06', 'm21', 's10', 'p07', 'baby-06', 'k12']; const AVA_FEED = ['p08', 'k02', 's01', 'm01', 'p09', 'k03', 'mom-05', 'p10', 's03', 'm18', 'k05', 'p11', 's04', 'baby-04', 'm22', 'p12', 'k07', 's06', 'mom-08', 'p13', 'm03', 'k08', 's07', 'p14', 'm19', 'k09', 's08', 'mom-14', 'm09', 'k11', 'baby-09', 'm11', 'k13', 'mom-17', 'm14', 'm15', 'm17']; /* mom-* / baby-* 取自统一头像库 /_bridge/avatars/(2026-10-11 用户:「可以适当调几个我库里预存的头像,妈妈和宝宝的」); 其余仍是 assets/img/mom/ 下的写实照片。库头像是 256px 透明底 PNG,底色由 .pr-ava / .pp-avs i 的 #FFE3D3 托住。 */ const avaSrc = n => /^(mom|baby)-\d+$/.test(n) ? 'https://mp.yizhezhe.com/_bridge/avatars/' + n + '.png' : 'assets/img/mom/' + n + '.jpg'; const avaImg = n => ''; const strSeed = t => { let x = 7; for (let i = 0; i < t.length; i++) x = (x * 31 + t.charCodeAt(i)) | 0; return Math.abs(x); }; /* ================= 孕妈广场 · 首页入口(2026-11-01 第一版,占位内容) ================= 用户:「在此刻下边加一个广场,放一些话题……不要像『其他妈妈也在搜』那种列表形式」 → 一排横滑话题封面卡 + 家庭/经验小分段;话题表 window.GC_TOPICS(guangchang/topics.js,与广场页共用)。 进广场是**本站子页**(不是别的 H5 应用),所以不走 jumpApp / 不报 last_app: 相对路径 + 原样带上 location.hash(身份在 hash 里),换页前照例 reportHostLocation。 */ (function initPlaza() { const sec = $('#gcHome'), row = $('#gchRow'); const T = window.GC_TOPICS; if (!sec || !row || !Array.isArray(T) || !T.length) return; const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); const kfmt = n => n >= 10000 ? (n / 10000).toFixed(1).replace(/\.0$/, '') + '万' : n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(n); function go(id) { haptic(); const url = 'guangchang/' + (id ? '?t=' + encodeURIComponent(id) : '') + location.hash; reportHostLocation(new URL(url, location.href).href); location.href = url; } /* 「全部」:只放热门,四块(家庭/婚姻/职场/母婴)轮流排,别一排全是同一块;选了某块:该块热门在前、其余在后 */ const CATS = ['family', 'work', 'baby']; // 11-01 家庭+婚姻合成「家庭婚姻」、母婴改「经验」,marriage 已在 topics.js 并进 family function pick(cat) { if (cat) { const l = T.filter(t => t.cat === cat); return l.filter(t => t.hot).concat(l.filter(t => !t.hot)); } const by = CATS.map(c => T.filter(t => t.cat === c && t.hot)), out = []; for (let r = 0; r < 10; r++) by.forEach(l => { if (l[r]) out.push(l[r]); }); return out; } /* 11-01 第五版「广场地面」(用户:重新调一版,要有广场那种创意): 一块可横滑的地面,话题是一个个说话气泡、气泡下站着一位妈妈的头像,一高一低「之」字形排开、轻轻浮动; 背景一条虚线小路从每个头像脚下穿过,空地上零星散着几个在逛的小头像。隔一个气泡带照片(topics.js 的 ph);「全部」里都是热门,不再逐个挂「热」。 */ const CAT_CN = { family: '家庭婚姻', work: '职场', baby: '经验' }; const POOL = AVA_FEED.concat(AVA_TOP).filter(n => /^(p|m|mom-)\d/.test(n)); // 只用妈妈头像,k/s/baby 是孩子和风景 function bubble(t, i, allHot) { const ph = i % 2 === 0 && t.ph; return ''; } function paint(cat) { const list = pick(cat); row.innerHTML = '

'; row.scrollLeft = 0; requestAnimationFrame(walk); } /* 排好之后量一遍:小路穿过每个头像的脚下(平滑曲线),地面高度按最低的气泡收口,几个闲逛的小头像放在「低」气泡头顶的空地上 */ function walk() { const map = row.firstElementChild; if (!map || !map.offsetWidth) return; const o = map.getBoundingClientRect(), W = map.scrollWidth; const pts = $$('.gb-a', map).map(a => { const r = a.getBoundingClientRect(); return [r.left - o.left + r.width / 2, r.top - o.top + r.height / 2]; }); if (!pts.length) return; const H = Math.max.apply(null, $$('.gb-col', map).map(c => c.getBoundingClientRect().bottom - o.top)) + 14; map.style.height = row.style.height = H + 'px'; const P = [[0, pts[0][1]]].concat(pts, [[W, pts[pts.length - 1][1]]]); let d = 'M' + P[0][0] + ' ' + P[0][1]; for (let i = 1; i < P.length; i++) { const mx = (P[i - 1][0] + P[i][0]) / 2; d += ' C' + mx + ' ' + P[i - 1][1] + ' ' + mx + ' ' + P[i][1] + ' ' + P[i][0] + ' ' + P[i][1]; } const svg = $('.gb-path', map); svg.setAttribute('width', W); svg.setAttribute('height', H); svg.firstElementChild.setAttribute('d', d); $$('.gb-w', map).forEach(x => x.remove()); $$('.gb-col.lo', map).forEach((c, k) => { if (k % 2) return; const r = c.getBoundingClientRect(), img = document.createElement('img'); img.className = 'gb-w'; img.alt = ''; img.setAttribute('aria-hidden', 'true'); img.loading = 'lazy'; img.src = avaSrc(POOL[(k * 5 + 11) % POOL.length]); img.style.left = (r.left - o.left + 18 + (k % 4 ? 50 : 0)) + 'px'; img.style.top = (k % 4 ? 22 : 12) + 'px'; map.appendChild(img); }); } window.addEventListener('resize', () => requestAnimationFrame(walk)); paint(''); const talk = T.reduce((a, t) => a + (t.talk || 0), 0); $('#gchCnt').innerHTML = '' + fmtNum(talk) + ' 位妈妈正在聊'; $('#gchAvs').innerHTML = ['p05', 'm07', 's03', 'k09', 'm16'].map(n => '').join(''); sec.classList.remove('hide'); /* 投稿审核提醒(11-01):有结果且没看过 → 模块里挂一条提示,点进广场 ?mine=1 弹结果(广场页弹完才标已读) */ const oid = hashParam('openid'); if (oid) fetch('/api/mp/public/mama-box/plaza/mine?openid=' + encodeURIComponent(oid)).then(r => r.json()).then(j => { const un = (j && j.result && j.result.unread) || []; if (!un.length) return; const ok = un.some(p => p.status === 'approved'), b = document.createElement('button'); b.type = 'button'; b.className = 'gch-note' + (ok ? ' ok' : ''); b.innerHTML = '' + (ok ? '你的投稿通过啦!奖励 ¥30 现金 + 500 兜豆' : '你的投稿有审核结果了') + '去看看 ›'; b.addEventListener('click', () => { haptic(); const url = 'guangchang/?mine=1' + location.hash; reportHostLocation(new URL(url, location.href).href); location.href = url; }); $('.gch-who', sec).before(b); }).catch(() => {}); row.addEventListener('click', e => { const b = e.target.closest('.gb-b,.gb-all'); if (b) go(b.dataset.t); }); $('#gchMore').addEventListener('click', () => go('')); $('#gchCat').addEventListener('click', e => { const b = e.target.closest('button[data-cat]'); if (!b || b.classList.contains('on')) return; $$('#gchCat button').forEach(x => x.classList.toggle('on', x === b)); haptic(); paint(b.dataset.cat); }); function fmtNum(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } })(); /* 「此刻」重绘钩子:足迹 / 档案 / 盲盒状态回来时调用(它们决定「和你比」「今日待办」和孕周) */ let plHook = null; const fmtN = n => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); /* 数字补间:el.dataset.v 记当前值,从它平滑走到 to;首次(无 dataset.v)从 0 起 */ function tweenNum(el, to, dur) { if (!el) return; const from = el.dataset.v == null ? 0 : +el.dataset.v; el.dataset.v = to; if (from === to || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) { el.textContent = fmtN(to); return; } const t0 = performance.now(), d = dur || 1400; (function step(t) { const k = Math.min(1, (t - t0) / d), e = 1 - Math.pow(1 - k, 4); el.textContent = fmtN(from + (to - from) * e); if (k < 1) requestAnimationFrame(step); })(t0); } /* ================= 此刻 · 同周悄悄话(2026-10-02) ================= 用户原话:「升级成同周妈妈的悄悄话,就是匿名一句心愿墙……不做复杂社区,就是只做一点陪伴」;发言 = 预设句 + 自由输入。 GET /api/mp/public/mama-box/whisper?openid=&week= → { show, week, canPost, left, maxLen, presets[], list[] } POST /api/mp/public/mama-box/whisper?openid= {text} → 新的一条(自由输入由服务端过微信内容安全审核,不通就 code≠200) POST /api/mp/public/mama-box/whisper/hug?openid=&id= · 只能「抱抱」,没有评论、昵称、头像 —— 刻意不做成社区。自己说的那句排第一,显示「收到 N 个抱抱」。 · 拉不到(show=false / 失败 / 访客没孕周)→ 整块隐藏,此刻卡退回滚动动态。不在前端造任何内容。 · 由 pulseCard 的 load() 带着孕周触发(孕周回来 / 变了才重拉),另每 5 分钟静默刷新一次。 */ let whLoad = null; (function whisperWall() { const entry = $('#whWall'), page = $('#whPage'); if (!entry || !page) return; const API = '/api/mp/public/mama-box/whisper'; const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); const HEART = ''; let data = null, wk = null, sig = '', busy = false, ri = 0, rollT = null; const oid = () => hashParam('openid'); const list = () => (data && data.list) || []; /* ---------- 每周话题(2026-10-02 用户:「一周一个主题,要么大家也不知道说什么」)---------- 一条 = 从 from(北京时间那周的周一)起生效,直到下一条的 from;所以表里永远取「from ≤ 今天」的最后一条, 没配新话题时沿用上一周的,不会空着。换话题 = 往表尾追加一条。 后端将来若在 /whisper 返回 theme(同结构),以后端为准。 presets 只是「不知道说什么?点一句」的示范句,发出去时不在服务端预设库里 → 照常走微信内容安全审核。 */ const WH_THEMES = [ /* 2026-10-02 用户:本周话题由「对宝宝说的话」改成「对老公说的话」;2026-11-01 用户:改成「对未来宝宝说的话」 */ { from: '2026-09-28', name: '对未来宝宝说的话', tip: '想对还没见面的宝宝说的,悄悄放进星空', say: '对未来宝宝说一句悄悄话', ph: '宝宝,妈妈想跟你说……', presets: ['宝宝,妈妈每天都在等你', '不用多优秀,健康快乐就好', '今天你踢了我好多下,妈妈都记着呢', '等你来了,带你去看海', '谢谢你选中我做你的妈妈', '我们很快就见面啦'] }, ]; function theme() { if (data && data.theme && data.theme.name) return data.theme; const d = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10); let t = null; WH_THEMES.forEach(x => { if (x.from <= d) t = x; }); return t; } /* ---------- 首页入口:一句轮播 ---------- */ function roll() { const l = list().filter(x => !x.mine); const el = $('#weRoll'); if (!l.length) { el.textContent = ''; return; } ri = (ri + 1) % l.length; el.classList.add('out'); setTimeout(() => { el.textContent = '“' + l[ri].text + '”'; el.classList.remove('out'); }, 260); } function paintEntry() { const l = list().filter(x => !x.mine); /* 用户:「同孕周悄悄话改成 30 周孕妈悄悄话」—— 周数跟着她自己的孕周走 */ const nm = data && data.week ? data.week + '周孕妈悄悄话' : '孕妈悄悄话'; $('#weName').textContent = nm; const th = theme(); $('#weTopic').textContent = th ? th.name : ''; entry.classList.toggle('has-topic', !!th); entry.setAttribute('aria-label', '打开' + nm + (th ? ',本周话题:' + th.name : '')); $('#weRoll').textContent = l.length ? '“' + l[0].text + '”' : ''; ri = 0; $('#weGo').innerHTML = (th ? '去说一句' : '去看看') + ''; clearInterval(rollT); if (l.length > 1) rollT = setInterval(() => { if (!document.hidden && !page.classList.contains('show')) roll(); }, 4000); } /* ---------- 全屏页 ---------- */ function hugBtn(it) { if (it.mine) return '' + HEART + (it.hugs ? '收到 ' + it.hugs + ' 个抱抱' : '抱抱正在路上') + ''; return ''; } /* 五改:流星夜空。用户原话:「打开页面夜空里特别多星星;点开一颗,它像流星一样滑落……那个头就是白色的, 尾巴就是这些字;首先保证用户能看清,刚开始慢一点,说完了慢慢的唰的就快了;同时把对应内容呈现出来」 · 亮星 = 一句(data-go = 下标 + 1),其余一百多颗只是装饰; · 点亮星:星闪一下 → 变成白色流星头往左下滑,字从星里一个个拖出来当尾巴;慢速段按字数给够阅读时间, 之后加速、拉长、字被拖糊,唰地划出屏幕;星星回来时变暗(看过了); · 底部内容卡同时出现:整句 + 孕周 + 抱抱 + 「下一颗」。流星在卡片下面穿过,不挡字。 */ const RM = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches); const AURA = [['#6B4AA8', '#C2667A'], ['#3F4FA6', '#8E5BB0'], ['#9A4F86', '#E0907A'], ['#365C9E', '#7A58AE'], ['#7D3F8C', '#D27A9A']]; /* 五声音阶 C4 D4 E4 G4 A4 C5 …(18 个音,C4 到 E7,一屏亮星一颗一个) */ const PENTA = Array.from({ length: 18 }, (_, k) => 440 * Math.pow(2, (60 + [0, 2, 4, 7, 9][k % 5] + 12 * Math.floor(k / 5) - 69) / 12)); const hsh = s => { let h = 7; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h; }; const card = $('#wsc'); const seen = new Set(); let cur = 0; const mts = new Map(); // 十改:可以连点多颗,多颗流星同时往下落;key = 星的序号 function paintDeco() { const box = $('#wskStars'); if (box.childElementCount) return; let h = ''; /* 八改(用户:「星空里都要是星星,不要圆点;有大有小,有的一闪一闪」):全部用四角星形, 大小 4–15px,约一半会闪 */ for (let k = 0; k < 120; k++) { const r = Math.random(), sz = r < .68 ? 4 + Math.random() * 3 : r < .94 ? 8 + Math.random() * 3 : 12 + Math.random() * 3; h += ''; } box.innerHTML = h; } /* 亮星落在夜空中上部偏右(流星往左下走,右边起步才划得够长);抖动网格,不会叠在一起 */ function paintSky(l) { const n = l.length, cols = n > 16 ? 5 : 4, rows = Math.max(2, Math.ceil(n / cols)); const fresh = l.findIndex(x => !x.mine && !seen.has(x.id)); const xs = l.map((it, i) => 36 + ((i % cols + .5 + ((hsh(it.id) % 100) / 100 - .5) * .7) / cols) * 58); /* 十三改(用户:「每点一下都是一个音符,每一个星星代表不同的音符」): 按横向位置从左到右排成一架竖琴——越靠左越低、越靠右越高,五声音阶(C4 起),连着点就是一段旋律 */ const rank = xs.map((x, i) => i).sort((p, q) => xs[p] - xs[q]); const note = []; rank.forEach((i, k) => { note[i] = PENTA[k % PENTA.length]; }); $('#wskBright').innerHTML = l.map((it, i) => { const h = hsh(it.id), r = Math.floor(i / cols); const x = xs[i]; const y = 21 + ((r + .5 + (((h >> 8) % 100) / 100 - .5) * .6) / rows) * 38; const sz = it.mine ? 26 : Math.round(15 + Math.min(it.hugs || 0, 30) / 30 * 11); return ''; }).join(''); } function paintList(l) { $('#whpCount').textContent = l.length + ' 句 · 最新在上'; $('#whpList').innerHTML = l.map((it, i) => { const lv = it.mine ? 'mine' : it.hugs >= 25 ? 'l3' : it.hugs >= 10 ? 'l2' : 'l1'; return '
' + '
孕 ' + it.week + ' 周 · ' + esc(it.ago) + '' + hugBtn(it) + '
'; }).join(''); } function paintCard(it, i) { card.innerHTML = '' + (it.mine ? '我说的' : '刚划过的那颗星说') + '

' + esc(it.text) + '

' + '
孕 ' + it.week + ' 周的妈妈 · ' + esc(it.ago) + '' + hugBtn(it) + '
' + ''; card.classList.remove('show'); void card.offsetWidth; card.classList.add('show'); const pal = it.mine ? ['#A8723A', '#E0A86A'] : AURA[(i - 1) % AURA.length]; const au = $$('#whvAura i'); au[0].style.backgroundColor = pal[0]; au[1].style.backgroundColor = pal[1]; } /* 七改(用户:「点一个流星底下就出现文字,好像就不用看流星上面写什么了」): 内容卡改到流星划完才浮出来(小字回放 + 抱抱),先看流星、再看卡;中途换星就不出旧卡。 */ function stopMeteor(m, done) { if (!m || mts.get(m.i) !== m) return; cancelAnimationFrame(m.raf); m.el.remove(); m.btn.classList.remove('flare', 'gone'); m.btn.classList.add('used'); mts.delete(m.i); if (done) paintCard(m.it, m.i); checkDone(); } /* 十一改(用户:「被点掉的流星划过之后,就不要再出现在原来的位置;点完之后给一些动效、声音」): · 划走的星从夜空里消失(.used),全部落完 → 「星星都落下来啦」+「让星星回来」; · 点下去:一圈光波 + 一把碎星迸开;飞行中头后撒星尘; · 声音用 WebAudio 现合成,不加载音频文件:点星 = 一声随机五声音阶的「叮」(连点几颗像在弹琴), 唰出去 = 一道由低到高的风声;右上角喇叭可关,记在本地。 */ function checkDone() { const all = $$('.wsk-st', page); $('#wskDone').classList.toggle('show', all.length > 0 && all.every(b => b.classList.contains('used')) && !mts.size); } const SND = 'mmbox.whsnd.v1'; let actx = null, sndOn = true; try { sndOn = localStorage.getItem(SND) !== '0'; } catch (e) {} const sndBtn = $('#whvSnd'); const paintSnd = () => { sndBtn.classList.toggle('off', !sndOn); sndBtn.setAttribute('aria-label', sndOn ? '关闭声音' : '打开声音'); }; paintSnd(); sndBtn.addEventListener('click', () => { sndOn = !sndOn; try { localStorage.setItem(SND, sndOn ? '1' : '0'); } catch (e) {} paintSnd(); haptic(); if (sndOn) chime(); }); function ac() { if (!sndOn) return null; try { actx = actx || new (window.AudioContext || window.webkitAudioContext)(); if (actx.state === 'suspended') actx.resume(); } catch (e) { return null; } return actx; } function chime(f) { const c = ac(); if (!c) return; const t = c.currentTime; f = f || PENTA[5 + Math.floor(Math.random() * 6)]; const g = c.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(.16, t + .012); g.gain.exponentialRampToValueAtTime(.001, t + 1.8); g.connect(c.destination); [[f, 'sine', 1], [f * 2, 'sine', .28], [f * 3.01, 'triangle', .08]].forEach(([fr, ty, v]) => { const o = c.createOscillator(), og = c.createGain(); o.type = ty; o.frequency.value = fr; og.gain.value = v; o.connect(og); og.connect(g); o.start(t); o.stop(t + 1.9); }); } function whoosh() { const c = ac(); if (!c) return; const t = c.currentTime, d = .9; const buf = c.createBuffer(1, Math.floor(c.sampleRate * d), c.sampleRate), ch = buf.getChannelData(0); for (let k = 0; k < ch.length; k++) ch[k] = Math.random() * 2 - 1; const src = c.createBufferSource(), bp = c.createBiquadFilter(), g = c.createGain(); src.buffer = buf; bp.type = 'bandpass'; bp.Q.value = 1.4; bp.frequency.setValueAtTime(500, t); bp.frequency.exponentialRampToValueAtTime(3800, t + d * .75); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(.1, t + d * .5); g.gain.exponentialRampToValueAtTime(.001, t + d); src.connect(bp); bp.connect(g); g.connect(c.destination); src.start(t); src.stop(t + d); } function fx(cls, x, y, ms, set) { const e = document.createElement('i'); e.className = cls; e.style.left = x.toFixed(1) + 'px'; e.style.top = y.toFixed(1) + 'px'; if (set) set(e); page.insertBefore(e, card); setTimeout(() => e.remove(), ms); } function tapFx(x, y, gold) { if (RM) return; fx('wfx-ring' + (gold ? ' gold' : ''), x, y, 800); for (let k = 0; k < 10; k++) { const a = Math.PI * 2 * k / 10 + Math.random() * .5, r = 26 + Math.random() * 30; fx('wfx-sp' + (gold ? ' gold' : ''), x, y, 900, e => { e.style.setProperty('--x', (Math.cos(a) * r).toFixed(0) + 'px'); e.style.setProperty('--y', (Math.sin(a) * r).toFixed(0) + 'px'); e.style.setProperty('--z', (5 + Math.random() * 7).toFixed(0) + 'px'); e.style.animationDelay = (Math.random() * 80).toFixed(0) + 'ms'; }); } } $('#wskBack').addEventListener('click', () => { haptic(); seen.clear(); $('#wskDone').classList.remove('show'); paintSky(list()); /* 星星回来:从低到高刷一遍琴弦 */ PENTA.slice(0, 8).forEach((f, k) => setTimeout(() => chime(f), k * 70)); $('#wskBright').classList.remove('back'); void $('#wskBright').offsetWidth; $('#wskBright').classList.add('back'); }); const stopAll = () => [...mts.values()].forEach(m => stopMeteor(m)); /* 第一次进来:一闪一闪的提示,点过一次星星就永久收起 */ const TIP = 'mmbox.whtip.v1'; const tipEl = $('#wskTip'); function tip(on) { if (on) { try { on = !localStorage.getItem(TIP); } catch (e) {} } else { try { localStorage.setItem(TIP, '1'); } catch (e) {} } tipEl.classList.toggle('show', !!on); } function launch(i) { const it = list()[i - 1]; if (!it || mts.has(i)) return; cur = i; seen.add(it.id); $$('.wsk-st.hint', page).forEach(x => x.classList.remove('hint')); tip(false); card.classList.remove('show'); $$('.wsk-st.cur', page).forEach(x => x.classList.remove('cur')); const btn = $('.wsk-st[data-go="' + i + '"]', page); if (!btn) { paintCard(it, i); return; } btn.classList.add('cur'); if (RM) { btn.classList.add('used'); paintCard(it, i); checkDone(); return; } const pr = page.getBoundingClientRect(), br = btn.getBoundingClientRect(); const x0 = br.left + br.width / 2 - pr.left, y0 = br.top + br.height / 2 - pr.top; /* 瞄准左下(屏幕左边外、六成高处):越靠左起步角度越陡,保证在屏幕里拖得够长 */ const th = Math.max(24, Math.min(55, Math.atan2(pr.height * .8 - y0, x0 + 30) * 180 / Math.PI)) * Math.PI / 180, dx = -Math.cos(th), dy = Math.sin(th), rot = -th * 180 / Math.PI; /* 六改(用户:「头是窄的,往后越来越宽;前面的字小,后面慢慢变大,形成流星尾巴;头特别亮往前滑; 点完亮一下就开始滑,一点点变快,不要太慢也别太快」): 字号从头到尾 12px → 26px 渐大,尾巴是一条从头部尖起、往后张开的光楔; 运动 = 起步就有速度、缓缓提速,整句出来后再猛加速唰出去。 */ const chars = [...String(it.text)], n = chars.length; const f0 = 13, f1 = n > 20 ? 21 : n > 12 ? 24 : 27, gap = 50; // 十二改:字离白头再远一点,不压在头的白光里 const fs = chars.map((c, k) => f0 + (f1 - f0) * Math.pow(n > 1 ? k / (n - 1) : 1, .85)); const off = []; fs.reduce((d, f) => { off.push(d + f * .55); return d + f * 1.08; }, gap); const body = off.length ? off[n - 1] + fs[n - 1] * .6 : gap; const el = document.createElement('div'); el.className = 'wmt' + (it.mine ? ' mine' : ''); el.innerHTML = '' + chars.map((c, k) => '' + esc(c) + '').join(''); page.insertBefore(el, card); const line = el.children[0], head = el.children[1], sp = $$('span', el); line.style.height = (f1 * 1.9).toFixed(0) + 'px'; line.style.marginTop = (-f1 * .95).toFixed(0) + 'px'; /* 节奏:星闪 420ms → 以约 90px/s 起步并缓缓提速,整句拖出来后再留 0.7 秒给眼睛 → 加速度猛增,1.3 秒内唰出屏幕 */ const T0 = 420, v0 = .09, TR = (body + 20) / v0 + 700, A = .0018, TE = 1300; btn.classList.add('flare'); btn.classList.remove('used'); $('#wskDone').classList.remove('show'); chime(+btn.dataset.note); tapFx(x0, y0, it.mine); let swish = false, dustT = 0; const t0 = performance.now(); const frame = now => { const t = now - t0; if (t < T0) { m.raf = requestAnimationFrame(frame); return; } btn.classList.add('gone'); const tc = t - T0, dt = Math.max(0, tc - TR), u = Math.min(1, dt / TE); const s = v0 * tc + v0 * tc * tc / 16000 + .5 * A * dt * dt; const v = v0 * (1 + tc / 8000) + A * dt; if (dt > 0 && !swish) { swish = true; whoosh(); } const hx = x0 + dx * s, hy = y0 + dy * s, at = 'translate(' + hx.toFixed(1) + 'px,' + hy.toFixed(1) + 'px) rotate(' + rot.toFixed(1) + 'deg)'; const st = Math.min(1.6, Math.max(0, (v - .25) / .5)); head.style.transform = at + ' scaleX(' + (1 + st * 1.6).toFixed(2) + ')'; const tl = Math.min(s, body + 70 + 260 * st); line.style.width = tl.toFixed(0) + 'px'; line.style.transform = at; const fade = u < .6 ? 1 : Math.max(0, 1 - (u - .6) / .4); el.style.setProperty('--bl', (st * 1.6).toFixed(1) + 'px'); sp.forEach((c, k) => { const d = s - off[k]; if (d < 0) { c.style.opacity = 0; return; } c.style.opacity = (Math.min(1, d / 26) * fade).toFixed(2); c.style.transform = 'translate(' + (x0 + dx * d).toFixed(1) + 'px,' + (y0 + dy * d).toFixed(1) + 'px) translate(-50%,-50%) rotate(' + rot.toFixed(1) + 'deg)'; }); if (t - dustT > 70 && u < .7 && hx > -20 && hy < pr.height + 20) { dustT = t; fx('wfx-dust' + (it.mine ? ' gold' : ''), hx + (Math.random() - .5) * 10, hy + (Math.random() - .5) * 10, 1000, e => { e.style.setProperty('--z', (3 + Math.random() * 5).toFixed(0) + 'px'); e.style.setProperty('--y', (8 + Math.random() * 14).toFixed(0) + 'px'); }); } if (u >= 1) { stopMeteor(m, true); return; } m.raf = requestAnimationFrame(frame); }; const m = { el, btn, it, i, raf: 0 }; mts.set(i, m); m.raf = requestAnimationFrame(frame); } function nextStar() { const l = list(), n = l.length; for (let k = 1; k <= n; k++) { const j = (cur - 1 + k) % n; if (!seen.has(l[j].id) && !l[j].mine) return j + 1; } return cur % n + 1; } function paintPage() { const l = list(), n = l.length; const bh = (new Date().getUTCHours() + 8) % 24, night = bh >= 18 || bh < 5; $('#whpTag').textContent = data ? '孕 ' + data.week + ' 周 · 前后一周的妈妈' : ''; /* 2026-10-02 用户:「每个星星都是孕妈的一句话,类似这个感觉,替换掉今晚多少句悄悄话」→ 标题不再报条数 */ /* 2026-10-02 用户:上半部分文字太占地方、挡住星星 → 标题收成一行,话题做成一行小胶囊;副标题去掉,话题说明挪进写悄悄话弹窗 */ const th = theme(); $('#wskTitle').innerHTML = '
每一颗星星,都是一位孕妈的一句话
' + (th ? '
本周话题' + esc(th.name) + '
' : '

' + (night ? '今晚' : '今天') + ',她们和你一样在等宝宝来

'); $('#whSay').firstElementChild.textContent = th && th.say ? th.say : '说一句悄悄话'; paintDeco(); paintSky(l); paintList(l); if (cur && list()[cur - 1]) $$('.wsk-st[data-go="' + cur + '"]', page).forEach(x => x.classList.add('cur')); $('#whpLeft').textContent = !data ? '' : !oid() || !data.canPost ? '填好预产期,就能说一句' : data.left ? '今天还能说 ' + data.left + ' 句' : '今天的 3 句说完啦,明天再来'; } function drawer(on) { $('#whDrawer').classList.toggle('show', on); $('#whDrawer').setAttribute('aria-hidden', on ? 'false' : 'true'); } $('#whvAll').addEventListener('click', () => { haptic(); drawer(true); }); $('#whdX').addEventListener('click', () => drawer(false)); $('#whDrawer').addEventListener('click', e => { if (e.target.id === 'whDrawer') drawer(false); }); page.addEventListener('click', e => { const g = e.target.closest('[data-go],[data-next]'); if (!g) return; haptic(); drawer(false); launch(g.dataset.next ? nextStar() : +g.dataset.go); }); function openPage() { stopAll(); cur = 0; card.classList.remove('show'); paintPage(); tip(true); checkDone(); page.classList.add('show'); page.setAttribute('aria-hidden', 'false'); drawer(false); haptic(); } function closePage() { stopAll(); drawer(false); page.classList.remove('show'); page.setAttribute('aria-hidden', 'true'); } entry.addEventListener('click', openPage); $('#whpBack').addEventListener('click', closePage); function show(on) { entry.classList.toggle('hide', !on); const sec = $('#pulse'); if (sec) sec.classList.toggle('has-wh', on); /* 2026-10-22(测试会话 150e0991):原来这里 if (!on) closePage() —— 「此刻」每 60 秒刷新一次, 某次接口抖一下 / 返回 show=false,就把用户正在看的悄悄话页关掉。现在只收首页入口,开着的页面让用户自己关 */ } function load(w, silent) { wk = w; if (!w) { data = null; show(false); return; } fetch(API + '?openid=' + encodeURIComponent(oid() || '') + '&week=' + w) .then(r => (r.ok ? r.json() : null)) .then(j => { const r = j && j.code === 200 && j.result; if (!r || !r.show || !(r.list || []).length) { data = null; show(false); return; } data = r; const ns = r.list.map(x => x.id + ':' + x.hugs + (x.hugged ? 'h' : '')).join('|'); if (ns !== sig) { sig = ns; paintEntry(); /* 页面开着时静默刷新不重画,免得正在看的那句跳走 */ if (!page.classList.contains('show')) paintPage(); } show(true); }) .catch(() => { if (!silent) show(false); }); } whLoad = w => load(w, w === wk); /* 同一孕周的重拉(「此刻」每 60 秒一次)按静默刷新走:失败不收入口 */ setInterval(() => { if (wk && !document.hidden && !busy) load(wk, true); }, 300000); /* 抱抱:先变(心跳 + 一串小心飘上去),失败再退回 */ function burst(b) { if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; for (let k = 0; k < 7; k++) { const h = document.createElement('i'); h.className = 'whp-fly'; h.innerHTML = HEART; h.style.setProperty('--x', (Math.random() * 60 - 30).toFixed(0) + 'px'); h.style.setProperty('--r', (Math.random() * 50 - 25).toFixed(0) + 'deg'); h.style.animationDelay = (k * 60) + 'ms'; b.appendChild(h); setTimeout(() => h.remove(), 1300); } } page.addEventListener('click', e => { const b = e.target.closest('[data-hug]'); if (!b) return; const o = oid(); if (!o) { toast('登录后就能给她抱抱啦'); return; } if (b.classList.contains('on')) { toast('你已经抱过她啦'); return; } const id = b.dataset.hug; const it = list().find(x => x.id === id); haptic(); const n0 = it ? it.hugs : 0; if (it) { it.hugs = n0 + 1; it.hugged = true; } $$('[data-hug="' + CSS.escape(id) + '"]', page).forEach(x => { x.classList.add('on', 'pop'); $('b', x).textContent = n0 + 1; setTimeout(() => x.classList.remove('pop'), 600); }); burst(b); fetch(API + '/hug?openid=' + encodeURIComponent(o) + '&id=' + encodeURIComponent(id), { method: 'POST' }) .then(r => r.json()) .then(j => { if (!j || j.code !== 200) throw j; }) .catch(j => { if (it) { it.hugs = n0; it.hugged = false; } $$('[data-hug="' + CSS.escape(id) + '"]', page).forEach(x => { x.classList.remove('on'); $('b', x).textContent = n0 || ''; }); toast((j && j.message) || '网络不太好,再抱一次试试'); }); }); /* 说一句(弹窗盖在全屏页上面) */ const ta = $('#whTa'); const cnt = () => { $('#whCnt').textContent = ta.value.length + '/' + ((data && data.maxLen) || 40); }; ta.addEventListener('input', () => { cnt(); $$('.wh-pre', $('#whPresets')).forEach(c => c.classList.toggle('on', c.textContent === ta.value)); }); $('#whSay').addEventListener('click', () => { if (!data) return; if (!oid() || !data.canPost) { toast('填好预产期,就能和同孕周妈妈说悄悄话啦'); return; } if (!data.left) { toast('今天已经说了 3 句啦,明天再来'); return; } ta.value = ''; ta.maxLength = data.maxLen || 40; cnt(); const th = theme(); $('#whMTitle').textContent = th && th.say ? th.say : '说一句悄悄话'; $('#whMTopic').innerHTML = th ? '本周话题:' + esc(th.name) + (th.tip ? '' + esc(th.tip) + '' : '') : ''; $('#whMTopic').style.display = th ? '' : 'none'; ta.placeholder = th && th.ph ? th.ph : '今天想对谁说点什么?'; $('#whPresets').innerHTML = ((th && th.presets && th.presets.length) ? th.presets : (data.presets || [])).map(t => '').join(''); $('#whLeft').textContent = '今天还能说 ' + data.left + ' 句'; openModal('modalWhisper'); }); $('#whPresets').addEventListener('click', e => { const c = e.target.closest('.wh-pre'); if (!c) return; haptic(); ta.value = c.textContent; ta.dispatchEvent(new Event('input')); }); $('#whSend').addEventListener('click', () => { const t = ta.value.trim(); if (t.length < 2) { toast('写一两个字,或者点一句吧'); return; } if (busy) return; busy = true; const btn = $('#whSend'); btn.disabled = true; btn.textContent = '正在发出…'; fetch(API + '?openid=' + encodeURIComponent(oid()), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: t }) }) .then(r => r.json()) .then(j => { if (!j || j.code !== 200 || !j.result) { toast((j && j.message) || '没发出去,再试一次'); return; } const it = j.result; data.left = it.left; delete it.left; data.list = [it].concat(data.list || []); sig = ''; paintEntry(); paintPage(); closeModal(); setTimeout(() => launch(1), 350); toast('发出去啦,同孕周妈妈会给你抱抱的'); }) .catch(() => toast('网络不太好,再试一次')) .then(() => { busy = false; btn.disabled = false; btn.textContent = '悄悄发出'; }); }); })(); (function pulseCard() { const sec = $('#pulse'); if (!sec) return; let seen = false, last = null, feed = [], fi = 0, ticking = null, peerMode = false, loadedWk = null, shownN = 0; const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); const tapMini = k => { const b = $('.tg[data-mini="' + k + '"]'); if (b) b.click(); }; const TODAY_GO = { kick: () => tapMini('kick'), weight: () => tapMini('weight'), cal: () => tapMini('cal'), /* 2026-10-10 用户:此刻去掉开盲盒(盲盒功能已隐藏),加数宫缩、待产清单 → 后端 act 改成 kick/weight/cal/curve/list/game */ curve: () => tapMini('curve'), list: () => { const a = APP_BY_ID.daichanqingdan; if (a && a.url) jumpApp(a.url, a.identifier); }, game: () => { if (GAME_URL) jumpApp(GAME_URL, GAME_ID); } }; /* 用户自己今天做没做(足迹 used[].appId 是上游 URL slug)。足迹没回来 / 读不到 → null,不显示「你」那半句 */ const SELF_APP = { kick: 'taidong', weight: 'yunqi-tizhong' }; /* 2026-10-02 陪伴版:不说「你今天还没数」(像点名),改成宝宝在等你的邀请;做过了就一句肯定。 同周人数那句也不再给百分比(「她们有 18%」是在和你比),只说多少位妈妈今天也在做。 */ const nmB = () => state.babyName || '宝宝'; const SELF_TXT = { kick: [() => nmB() + '在等你,陪它数一数', () => '你今天也陪' + nmB() + '数过啦'], weight: [() => '也记一下今天的自己', () => '你今天也记过啦'] }; const CROWD_TXT = { kick: '位妈妈陪宝宝数了胎动', weight: '位妈妈记下了体重' }; function selfDone(k) { if (_fpMode !== 'rows' && _fpMode !== 'guide') return null; return ((_fpData && _fpData.used) || []).some(u => u.appId === SELF_APP[k]); } function rowHTML(f) { /* 一行:小头像 + 「同周妈妈 · 刚刚」+ 动作(数字标色)。原文「孕 29 周的妈妈 数完胎动……」按「的妈妈 」切。 动作能对上首页入口(act ∈ TODAY_GO)→ 整行可点,直接进那个工具 */ const t = String(f.text || ''), i = t.indexOf('的妈妈 '); const who = peerMode ? '同孕周妈妈' : (i > 0 ? t.slice(0, i + 3) : '一位妈妈'), what = i > 0 ? t.slice(i + 4) : t; const go = f.act && TODAY_GO[f.act] ? ' data-go="' + esc(f.act) + '"' : ''; return '
' + avaImg(AVA_FEED[(f._ava || 0) % AVA_FEED.length]) + '' + /* 同周版标题已经说了「和你同一周」,行里不再重复「同孕周妈妈」,省出地方给动作。 不显示「N 分钟前」:三行按工具错开排(见 paint),时间会前后颠倒,又不能改接口给的时间 */ (peerMode ? '' : '' + esc(who) + '') + '' + esc(what).replace(/(\d+)/g, '$1') + '' + (go ? '' : '') + '
'; } const FEED_N = 2; /* 常驻两行(2026-11-01 用户:三行轮播改两行;高度见 index.html .pl-feed3 覆盖) */ const ROW_H = 30; /* = .pl-feed3 .pl-row 高度 */ function tick() { const tr = $('#plTrack'); if (!tr || feed.length <= FEED_N || document.hidden) return; fi = (fi + 1) % feed.length; tr.insertAdjacentHTML('beforeend', rowHTML(feed[(fi + FEED_N - 1) % feed.length])); tr.firstElementChild.classList.add('is-out'); tr.classList.add('is-move'); tr.style.transform = 'translateY(-' + ROW_H + 'px)'; setTimeout(() => { tr.classList.remove('is-move'); /* 翻动途中若 paint() 重置过轨道,别把刚铺好的三行删掉 */ if (tr.childElementCount > FEED_N) tr.removeChild(tr.firstElementChild); tr.style.transform = ''; }, 600); } /* 头像叠:同周版按孕周换一组,全站版固定一组;只在换组时重画 */ function paintAvs(seed) { const avs = $('#ppAvs'); if (avs.dataset.seed === String(seed)) return; avs.dataset.seed = seed; /* 2026-10-02 用户:「头像现在展示 3 个,给我展示 6 到 8 个」→ 7 个 */ /* 2026-10-10 用户说「那 8 个」→ 8 个。按孕周轮换起点连续取 8 张:20 张的池子组内必不重复,也不和下面动态共用 */ const st = (Math.abs(seed | 0) * 8) % AVA_TOP.length; avs.innerHTML = Array.from({ length: 8 }, (_, k) => '' + avaImg(AVA_TOP[(st + k) % AVA_TOP.length]) + '').join(''); } function paintPeer(p) { paintAvs(p.week); $('#plTitle').innerHTML = '0 位孕 ' + p.week + ' 周妈妈和你同一周'; /* 只说一句:优先挑你今天还没做的那项;都做了(或读不到你的足迹)就取第一项 */ const ds = (p.doing || []).filter(d => SELF_TXT[d.key]); const pick = ds.find(d => selfDone(d.key) === false) || ds[0]; const n = pick ? Math.round((+p.count || 0) * (+pick.pct || 0) / 100) : 0; $('#ppCrowd').innerHTML = pick && n >= 3 ? '今天 ' + fmtN(n) + ' ' + CROWD_TXT[pick.key] : '她们和你一样,在等宝宝来'; const me = pick ? selfDone(pick.key) : null; if (me != null) { $('#ppDoing').innerHTML = me ? '' + esc(SELF_TXT[pick.key][1]()) + '' : ''; } $('#ppDoing').classList.toggle('hide', me == null); } function paint() { const r = last; if (!r || !r.show) { sec.classList.add('hide'); return; } sec.classList.remove('hide'); peerMode = !!r.peer; sec.classList.toggle('is-peer', peerMode); if (peerMode) paintPeer(r.peer); else { /* 全站版(访客 / real 模式):只留一行总人数 + 滚动动态 */ paintAvs(0); $('#ppDoing').classList.add('hide'); const td = (r.today || [])[0]; $('#ppCrowd').innerHTML = td ? '今天 ' + fmtN(td.value) + ' ' + esc(td.label) : '她们和你一样,在等宝宝来'; const t = r.total; /* 前缀「已有」省掉:一行放得下「10,037 位妈妈在这里迎接宝宝」,带上就会折出一个孤零零的「宝宝」 */ $('#plTitle').innerHTML = t ? '0 ' + esc(t.suffix) : '孕妈们在做什么'; } /* 标题每次重画会重建 :从上次显示到的数接着涨,不要每分钟从 0 重数 */ const nb = $('#plTitle b'); if (nb) { nb.dataset.v = shownN; nb.textContent = fmtN(shownN); if (seen) { tweenNum(nb, +nb.dataset.n || 0, 1600); shownN = +nb.dataset.n || 0; } } const nf = (peerMode ? r.peer.feed : r.feed) || []; const fe = $('#plFeed'); fe.classList.toggle('hide', !nf.length); /* 每分钟刷新时只在内容真变了才重置滚动,否则正在翻的那条会跳回第一条 */ const sig = nf.map(x => x.text).join('|'); if (nf.length && fe.dataset.sig !== sig) { fe.dataset.sig = sig; /* 三行尽量是三种不同的工具:按原顺序贪心挑,跟前两条动作不同的优先(挑不到就按原顺序) */ const pool = nf.slice(), mix = []; while (pool.length) { const recent = mix.slice(-(FEED_N - 1)).map(x => x.act); const k = pool.findIndex(x => recent.indexOf(x.act) < 0); mix.push(pool.splice(k < 0 ? 0 : k, 1)[0]); } /* 头像按行序依次分配(不再按文案哈希):相邻几行、同一轮里都不会出现同一张 */ const a0 = strSeed(sig) % AVA_FEED.length; mix.forEach((x, k) => { x._ava = a0 + k; }); feed = mix; fi = 0; const tr = $('#plTrack'); tr.classList.remove('is-move'); tr.style.transform = ''; tr.innerHTML = feed.slice(0, FEED_N).map(rowHTML).join(''); if (!ticking) ticking = setInterval(tick, 3000); } } sec.addEventListener('click', e => { const b = e.target.closest('[data-go]'); const k = b && b.dataset.go; if (k && TODAY_GO[k]) { haptic(); TODAY_GO[k](); } }); const myWeek = () => (state.pregEmpty === false && !state.postpartum && state.week >= 4) ? state.week : null; function load() { const w = myWeek(); loadedWk = w; if (whLoad) whLoad(w); return fetch('/api/mp/public/mama-box/pulse' + (w ? '?week=' + w : '')) .then(r => (r.ok ? r.json() : null)) .then(j => { if (j && j.code === 200 && j.result) { last = j.result; window.__mbPulse = last; paint(); if (window.__mbHeat) window.__mbHeat(); } }) .catch(() => { /* 静默:保持现状(首次失败即一直隐藏) */ }); } /* 档案回来孕周才可信 → 孕周变了重拉;否则只是足迹 / 盲盒状态变了,就地重画「和你比」「今日待办」 */ plHook = () => { if (myWeek() !== loadedWk) load(); else if (last) paint(); }; /* 进入视口才开始数数,否则用户滑到这里时数字早就数完了 */ function onSeen() { if (seen) return; seen = true; paint(); } if ('IntersectionObserver' in window) { const io = new IntersectionObserver(es => { if (es.some(e => e.isIntersecting)) { io.disconnect(); onSeen(); } }, { threshold: .25 }); io.observe(sec); } else seen = true; load(); setInterval(() => { if (!document.hidden) load(); }, 60000); })(); /* ================= 孕期盲盒(2026-10-01,需求文档 v1.0 第三节) ================= 接口(box_app,openid 身份): GET /api/mp/public/mama-box/blindbox?openid= → 横幅 + 本页的全部状态(含卡册、概率表) POST /api/mp/public/mama-box/blindbox/open?openid=&week= → 开盒(服务端抽卡/抽奖励,原子去重) · 抽什么完全由服务端决定,前端只播动画;概率说明用接口下发的 probs,和服务端永远一致(合规:公示概率)。 · 访客态 / 孕 4 周前 / 接口失败 → 横幅保持隐藏。未填预产期 → 横幅改成「填预产期领盲盒」引导。 · 周卡插画在 assets/img(文件名 = md5 前 12 位),CARD_IMG 是孕周 → 文件的表;没有图的孕周用「本周水果」占位画。 */ const CARD_IMG = { 4: 'b743ed8646d5', 5: 'bddd81b7315d', 6: 'affcfc61f21f', 7: 'fe00c2bddf9b', 8: '2a88a5a7c1c3', 9: '6a17a98c52b8', 10: 'c6e9c5612d8e', 11: '5ae2e7f617e2', 12: '0fa71babe9a2', 13: '6bdabd9eecc5', 15: 'd41305de1db9', 16: 'cceeb3b3f4ef', 24: '83f0ab30cf36', 32: '2314f334e4d0' }; const BX_GROUPS = [ { name: '孕早期', from: 4, to: 13, c: '#9ED8C0' }, { name: '孕中期', from: 14, to: 27, c: '#FFB08F' }, { name: '孕晚期', from: 28, to: 40, c: '#C9B6FF' } ]; let BB = null, bbSel = null, bbBusy = false, bbNew = null, giftSeq = 0; function giftSVG() { const g = 'gf' + (++giftSeq); return ''; } const bbEsc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); function cardImgHTML(w) { const h = CARD_IMG[w]; return h ? '孕 ' + w + ' 周宝宝卡' : ''; } /* 卡正面。card = { week, v, size:{name,txt}, say } */ function cardFrontHTML(c) { const sz = c.size || babySize(c.week); return '
' + '✦ 闪卡No.' + String(c.week).padStart(2, '0') + '' + '
' + cardImgHTML(c.week) + '
' + '
孕' + c.week + '周像' + bbEsc(sz.name) + ' · ' + bbEsc(sz.txt) + '
' + '

' + (c.say ? '“' + bbEsc(c.say) + '”' : '') + '

' + '
'; } function bbApi(path, method) { const oid = hashParam('openid'); return fetch('/api/mp/public/mama-box/' + path + (path.indexOf('?') >= 0 ? '&' : '?') + 'openid=' + encodeURIComponent(oid), { method: method || 'GET' }) .then(r => r.json().catch(() => null)); } function loadBlindbox() { if (!hashParam('openid')) { $('#bbBanner').classList.add('hide'); return Promise.resolve(); } return bbApi('blindbox').then(j => { if (!j || j.code !== 200 || !j.result) return; BB = j.result; (BB.newRewards || []).forEach(g => bxPrizeQ.push(g)); renderBanner(); if (BB.backfilled > 0) setTimeout(() => toast('错过的 ' + BB.backfilled + ' 张宝宝卡已补给你,去卡册看看吧'), 1800); if ($('#detailBox').classList.contains('show')) { renderBox(); if (bxPrizeQ.length && !bbBusy && !$('#bxReveal').classList.contains('show')) setTimeout(showPrize, 400); } }).catch(() => { /* 静默:横幅保持现状 */ }); } /* 最近的一个集齐目标:先看当前孕周所在的那段,集齐了再看全集。用于「离孕中期大奖还差 3 张」 */ function bxGoal() { const ss = (BB && BB.stages) || []; const cur = ss.find(x => x.key !== 'all' && BB.week >= x.from && BB.week <= x.to && !x.complete); const g = cur || ss.find(x => x.key === 'all' && !x.complete); return g && g.left > 0 ? g : null; } const BX_TASK_ICO = { kick3: '👶', weight: '⚖️', tool: '📅' }; let bxLockSel = null, bxPrizeQ = []; function renderBanner() { if (plHook) plHook(); // 「此刻」的今日待办看本周盲盒开没开 const ban = $('#bbBanner'); const set = (t, s, cta, ready, calm) => { $('#bbTitle').textContent = t; $('#bbSub').innerHTML = s; $('#bbCta').textContent = cta; ban.classList.toggle('is-ready', !!ready); ban.classList.toggle('is-calm', !!calm); ban.classList.remove('hide'); }; const me = $('#meBoxCard'); /* 2026-10-05 用户:「孕期卡册删除掉」→ 「我的」页卡册入口一律收起(首页横幅 10-03 起已由 CSS #bbBanner 隐藏)。 盲盒数据、卡册详情页代码都保留,恢复入口:把下面这行改回 me.classList.toggle('hide', !BB || !BB.enabled || !!BB.notYet || !!BB.needProfile); */ me.classList.add('hide'); if (BB && BB.totalWeeks) $('#meBoxV').textContent = (BB.collected || 0) + '/' + BB.totalWeeks; if (!BB || !BB.enabled || BB.notYet) { ban.classList.add('hide'); return; } if (!$('#bbGift').firstChild) $('#bbGift').innerHTML = giftSVG(); const badge = $('#bbBadge'); badge.classList.add('hide'); if (BB.needProfile) { set('每周一个宝宝盲盒', '填好预产期就能领 · 完全免费', '去填写', true); return; } const got = '已收集 ' + BB.collected + '/' + BB.totalWeeks; const op = BB.openable || []; if (op.length > 1) { badge.textContent = op.length; badge.classList.remove('hide'); } if (BB.ended) set('我的孕期卡册', got + ' 张宝宝卡,永久保留', '去看看', false, true); else if (!BB.currentOpened && op.indexOf(BB.week) >= 0) set('孕 ' + BB.week + ' 周的盲盒到了', got + ' · 免费开启', '去开启', true); else if (op.length) set('还有 ' + op.length + ' 个盲盒没开', '已解锁,随时补开 · ' + got, '去补开', true); else if ((BB.locked || []).length && BB.credits) set('可以解锁 ' + BB.credits + ' 个错过的盲盒', '你完成了孕期小任务 · ' + got, '去解锁', true); else { const g = bxGoal(); set(BB.nextIn ? '下一个盲盒 ' + BB.nextIn + ' 天后到' : '本周盲盒已开启', g ? '离' + g.name + '大奖还差 ' + g.left + ' 张' : got + (BB.flash ? ' · 闪卡 ' + BB.flash + ' 张' : ''), '看卡册', false, true); } } /* 盲盒入口:首页横幅 + 「我的」页卡册行(#meBoxCard)走同一条路 */ function openBoxEntry() { if (!BB) return; if (BB.needProfile) { openModal('modalProfile'); return; } bbSel = null; renderBox(); openPanel('detailBox'); if (bxPrizeQ.length) setTimeout(showPrize, 600); } $('#bbBanner').addEventListener('click', openBoxEntry); $('#meBoxCard').addEventListener('click', openBoxEntry); function renderBox() { if (!BB || BB.needProfile) return; const op = BB.openable || []; if (op.indexOf(bbSel) < 0) bbSel = op.indexOf(BB.week) >= 0 ? BB.week : (op[0] || null); const st = $('#bxStage'), box = $('#bxBox'), mini = $('#bxMini'), btn = $('#bxOpen'); st.classList.remove('is-shaking', 'is-popping'); btn.disabled = false; if (bbSel) { st.classList.remove('is-done'); $('#bxWeek').textContent = '孕 ' + bbSel + ' 周 · 宝宝盲盒'; $('#bxHint').textContent = bbSel === BB.week ? '宝宝给你准备了这周的小惊喜' : '这是你错过的一周,现在补开'; box.innerHTML = giftSVG(); box.classList.remove('hide'); mini.classList.add('hide'); btn.textContent = '开启盲盒'; btn.classList.remove('hide'); } else { st.classList.add('is-done'); box.classList.add('hide'); const cc = BB.currentCard; $('#bxWeek').textContent = BB.ended ? '孕期卡册' : '孕 ' + BB.week + ' 周 · 已开启'; $('#bxHint').textContent = BB.ended ? (BB.postpartum ? '宝宝出生啦,卡册会一直留着' : '孕期盲盒已经全部开完') : (BB.nextIn ? '下一个盲盒 ' + BB.nextIn + ' 天后到' : '本周的盲盒已经开过啦'); if (cc) { mini.innerHTML = cardFrontHTML(cc); mini.classList.remove('hide'); mini.onclick = () => showReveal(cc, null, false); } else { mini.classList.add('hide'); box.innerHTML = giftSVG(); box.classList.remove('hide'); } btn.classList.toggle('hide', !cc); btn.textContent = '看看这张卡'; } $('#bxMisc').textContent = (BB.weekOpeners >= 50) ? '本周已有 ' + fmtN(BB.weekOpeners) + ' 位妈妈开了盲盒' : '每周一个 · 完全免费'; /* 下周预告:剪影 + 倒计时(本周已开时才露出来,免得和「开启盲盒」抢视线) */ const nx = BB.next, nxEl = $('#bxNext'); nxEl.classList.toggle('hide', !(nx && !bbSel && !BB.ended)); if (nx && !bbSel && !BB.ended) { $('#bxNextImg').innerHTML = cardImgHTML(nx.week); $('#bxNextW').textContent = '孕 ' + nx.week + ' 周 · 下周预告'; $('#bxNextS').textContent = nx.size ? '宝宝会长到' + nx.size.name + '那么大' : ''; $('#bxNextD').textContent = nx.inDays; } /* 待解锁 + 任务 */ const lk = BB.locked || [], lkEl = $('#bxLock'); lkEl.classList.toggle('hide', !lk.length); if (lk.length) { if (lk.indexOf(bxLockSel) < 0) bxLockSel = lk[lk.length - 1]; $('#bxLockN').textContent = lk.length; $('#bxLockChips').innerHTML = lk.map(w => '').join(''); $('#bxTasks').innerHTML = (BB.tasks || []).map(t => { const btn = t.ready ? '' : t.used ? '' + (t.period === 'week' ? '本周已用' : '今天已用') + '' : ''; const prog = t.need > 1 ? '' + t.progress + '/' + t.need + '' : ''; return '
' + '
' + bbEsc(t.title) + '' + bbEsc(t.desc) + '' + (prog ? '
' + prog + '
' : '') + '
' + btn + '
'; }).join(''); } /* 集齐奖励 */ $('#bxGoal').innerHTML = (BB.stages || []).map(g => { const e = g.earned, pct = Math.round(g.got / g.total * 100); const prize = (g.doudou ? g.doudou + ' 兜豆' : '') + (g.gift ? (g.doudou ? ' + ' : '') + '实物纪念礼' : ''); let tail; if (e) tail = e.needAddr ? '' : '' + (e.status === 'sent' ? '已发放' : '待发放') + ''; else if (g.complete) tail = '补发集齐'; else tail = '差 ' + g.left + ' 张'; return '
' + g.name + '' + prize + '' + tail + '
' + '
'; }).join(''); /* 待补开 */ const pend = $('#bxPend'); pend.classList.toggle('hide', op.length < 2); if (op.length >= 2) { $('#bxPendN').textContent = op.length; $('#bxChips').innerHTML = op.map(w => '').join(''); } /* 卡册 */ $('#bxGot').textContent = BB.collected; $('#bxAll').textContent = BB.totalWeeks; requestAnimationFrame(() => { $('#bxBar').style.width = Math.round(BB.collected / BB.totalWeeks * 100) + '%'; }); const nBack = (BB.album || []).filter(a => a.src === 'backfill').length; $('#bxAnote').textContent = (BB.flash ? '其中闪卡 ' + BB.flash + ' 张。' : '') + (nBack ? '标「补」的是来之前的孕周,已自动补发普通款。' : '') + (lk.length ? '标 🔒 的是错过的周,完成小任务即可解锁。' : ''); const byW = {}; (BB.album || []).forEach(a => { byW[a.week] = a; }); $('#bxGroups').innerHTML = BX_GROUPS.map(g => { let n = 0, cells = ''; for (let w = g.from; w <= g.to; w++) { const a = byW[w] || {}; if (a.got) { n++; cells += ''; } else if (lk.indexOf(w) >= 0) { cells += ''; } else if (op.indexOf(w) >= 0) { cells += ''; } else { cells += '
?' + w + ' 周
'; } } const all = g.to - g.from + 1; return '
' + g.name + ' · ' + g.from + '–' + g.to + ' 周' + (n === all ? '已集齐 ✓' : n + '/' + all) + '
' + cells + '
'; }).join(''); bbNew = null; /* 规则与概率(接口下发) */ const pb = BB.probs || {}; const bar = x => '
' + bbEsc(x.name) + '' + x.p + '%
'; $('#bxRulesBody').innerHTML = '

宝宝卡(每盒必出 1 张)

' + (pb.card || []).map(bar).join('') + '

随机奖励(每盒 1 份)

' + (pb.reward || []).map(bar).join('') + '

规则

    ' + (pb.notes || []).map(t => '
  • ' + bbEsc(t) + '
  • ').join('') + '
' + '

孕期盲盒完全免费,不出售、不需要任何付费。

'; } $('#bxChips').addEventListener('click', e => { const b = e.target.closest('.bx-chip'); if (!b || bbBusy) return; bbSel = +b.dataset.w; haptic(); renderBox(); $('#detailBox .detail-body').scrollTo({ top: 0, behavior: 'smooth' }); }); $('#bxGroups').addEventListener('click', e => { const t = e.target.closest('.bx-t'); if (!t || bbBusy) return; if (t.dataset.op) { bbSel = +t.dataset.op; renderBox(); $('#detailBox .detail-body').scrollTo({ top: 0, behavior: 'smooth' }); return; } if (t.dataset.lk) { bxLockSel = +t.dataset.lk; renderBox(); $('#bxLock').scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } const w = +t.dataset.w; if (!w) return; /* 卡册里点开一张:大小来自本地 SIZE 表,发育说明取孕期小知识的「宝宝这周」;周记 / 宝宝的话由接口 album 带回 */ const a = (BB.album || []).find(x => x.week === w) || {}; const c = { week: w, v: t.dataset.v, size: babySize(w), say: a.say || '', diary: a.diary || [] }; showReveal(c, null, false); loadTips().then(() => { const x = TIPS && TIPS[w]; if (x && x.baby && $('#bxReveal').classList.contains('show')) $('#bxDev').textContent = x.baby; }); }); $('#bxLockChips').addEventListener('click', e => { const b = e.target.closest('.bx-chip'); if (!b || bbBusy) return; bxLockSel = +b.dataset.w; haptic(); renderBox(); }); $('#bxTasks').addEventListener('click', e => { const go = e.target.closest('[data-app]'); if (go) { const app = APP_BY_ID[go.dataset.app]; if (app && app.url) jumpApp(app.url, app.identifier); else toast('这个工具暂时打不开,换一个任务试试'); return; } const u = e.target.closest('[data-unlock]'); if (!u || bbBusy || !bxLockSel) return; bbBusy = true; u.disabled = true; u.textContent = '解锁中…'; const w = bxLockSel; bbApi('blindbox/unlock?week=' + w + '&task=' + u.dataset.unlock, 'POST').catch(() => null).then(j => { bbBusy = false; if (!j || j.code !== 200) { toast((j && j.message) || '网络开小差了,再试一次'); loadBlindbox(); return; } if (navigator.vibrate) navigator.vibrate([10, 30, 14]); toast('孕 ' + w + ' 周的盲盒解锁啦,快打开看看'); bbSel = w; loadBlindbox().then(() => { bbSel = w; renderBox(); $('#detailBox .detail-body').scrollTo({ top: 0, behavior: 'smooth' }); }); }); }); $('#bxGoal').addEventListener('click', e => { if (e.target.closest('[data-addr]')) openModal('modalBxAddr'); }); $('#bxAddrSave').addEventListener('click', () => { const v = id => $('#' + id).value.trim(); const body = { name: v('bxAddrName'), phone: v('bxAddrPhone'), addr: v('bxAddrAddr') }; if (!body.name || !body.phone || !body.addr) { toast('请把收货人、电话、地址填完整'); return; } if (!/^1\d{10}$/.test(body.phone)) { toast('手机号好像不对,再检查一下'); return; } fetch('/api/mp/public/mama-box/blindbox/claim?openid=' + encodeURIComponent(hashParam('openid')), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json()).catch(() => null).then(j => { if (!j || j.code !== 200) { toast((j && j.message) || '提交失败,再试一次'); return; } closeModal(); toast('收到啦,工作人员会尽快寄出'); loadBlindbox(); }); }); /* 集齐庆祝:开盒 / 刷新时接口带回 newRewards(服务端只返回一次),排队逐个弹 */ function showPrize() { const g = bxPrizeQ.shift(); if (!g) return; $('#bxPrizeT').textContent = g.key === 'all' ? '37 张宝宝卡全部集齐!' : '集齐' + g.name + '!'; $('#bxPrizeP').textContent = (g.doudou ? g.doudou + ' 兜豆' : '') + (g.gift ? (g.doudou ? '和' : '') + g.gift : '') + '已为你记下,工作人员会在 7 个工作日内发放' + (g.gift ? ',先填一下收货信息吧。' : '。'); $('#bxPrizeOk').textContent = g.gift ? '去填收货信息' : '太好了'; $('#bxPrizeOk').dataset.addr = g.gift ? '1' : ''; openModal('modalBxPrize'); } $('#bxPrizeOk').addEventListener('click', () => { const addr = $('#bxPrizeOk').dataset.addr; closeModal(); if (addr) openModal('modalBxAddr'); else setTimeout(showPrize, 250); }); $('#bxRuleBtn').addEventListener('click', () => { const d = $('#bxRules'); d.open = true; d.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); $('#bxBox').addEventListener('click', () => openBox()); $('#bxOpen').addEventListener('click', () => { if (bbSel) openBox(); else if (BB && BB.currentCard) showReveal(BB.currentCard, null, false); }); function openBox() { if (bbBusy || !bbSel) return; bbBusy = true; haptic(); const st = $('#bxStage'), btn = $('#bxOpen'), w = bbSel, t0 = Date.now(); st.classList.add('is-shaking'); btn.disabled = true; btn.textContent = '正在打开…'; bbApi('blindbox/open?week=' + w, 'POST').catch(() => null).then(j => { /* 至少晃 1.3 秒:接口太快时动画一闪而过,没有「拆」的感觉 */ setTimeout(() => { st.classList.remove('is-shaking'); if (!j || j.code !== 200 || !j.result) { bbBusy = false; btn.disabled = false; btn.textContent = '开启盲盒'; toast((j && j.message) || '网络开小差了,再试一次'); if (j && j.code === 400) loadBlindbox(); return; } st.classList.add('is-popping'); if (navigator.vibrate) navigator.vibrate([12, 40, 18]); const r = j.result; (r.newRewards || []).forEach(g => bxPrizeQ.push(g)); bbNew = r.card.week; setTimeout(() => { showReveal(r.card, r.reward, true); bbBusy = false; }, 420); loadBlindbox(); // 后台刷新横幅与卡册(翻卡层盖着,用户看不到重绘) }, Math.max(0, 1300 - (Date.now() - t0))); }); } function burst(flash) { const box = $('#bxConfetti'); const C = flash ? ['#FFD66E', '#FF9AD5', '#9ADFFF', '#fff', '#C9B6FF'] : ['#FFD66E', '#FF8A66', '#FFB3C1', '#fff', '#9ED8C0']; let h = ''; for (let i = 0; i < (flash ? 44 : 30); i++) { const a = Math.random() * Math.PI * 2, d = 90 + Math.random() * 170; h += ''; } box.innerHTML = h; setTimeout(() => { box.innerHTML = ''; }, 1800); } /* 翻卡层。fresh=true:刚开出来(彩纸 + 翻面 + 奖励);false:从卡册/舞台查看(直接正面) */ function showReveal(card, reward, fresh) { const rv = $('#bxReveal'), c3 = $('#bxCard3d'), flash = card.v === 'flash'; $('#bxFront').innerHTML = cardFrontHTML(card); if (!$('#bxBack').firstChild) $('#bxBack').innerHTML = giftSVG() + '兜爸心选 · 孕期盲盒'; $('#bxTag').textContent = fresh ? (flash ? '✦ 哇!开出了闪卡 ✦' : '新卡 · 孕 ' + card.week + ' 周') : (flash ? '✦ 闪卡 · 孕 ' + card.week + ' 周' : '孕 ' + card.week + ' 周'); $('#bxDev').textContent = card.dev || ''; const dl = card.diary || []; $('#bxDiary').innerHTML = dl.length ? '
📖 宝宝周记 · 孕 ' + card.week + ' 周
' + dl.map(x => '

' + bbEsc(x) + '

').join('') : ''; let rw = ''; if (reward) { const tj = reward.type === 'taijiao'; rw = '
' + (tj ? '🎵' : '💌') + '' + (tj ? '胎教时间 · ' + bbEsc(reward.kind || '') : '宝宝对你说') + '随机奖励
' + (reward.title ? '
' + bbEsc(reward.title) + '
' : '') + '

' + bbEsc(reward.text) + '

'; } $('#bxReward').innerHTML = rw; $('#bxKeep').textContent = fresh ? '收进卡册' : '好的'; rv.classList.remove('is-done', 'is-burst'); rv.classList.toggle('is-flash', flash); c3.classList.remove('is-flip', 'no-anim'); void c3.offsetWidth; rv.classList.add('show'); rv.setAttribute('aria-hidden', 'false'); rv.scrollTop = 0; if (fresh) { rv.classList.add('is-burst'); burst(flash); setTimeout(() => c3.classList.add('is-flip'), 260); setTimeout(() => rv.classList.add('is-done'), 1100); } else { c3.classList.add('no-anim', 'is-flip'); rv.classList.add('is-done'); } } function hideReveal() { const rv = $('#bxReveal'); rv.classList.remove('show', 'is-done', 'is-burst'); rv.setAttribute('aria-hidden', 'true'); } $('#bxKeep').addEventListener('click', () => { hideReveal(); renderBox(); if (bxPrizeQ.length) setTimeout(showPrize, 350); }); $('#detailBox .dt-back').addEventListener('click', hideReveal); /* ================= 孕期小知识(2026-09-30) ================= 内容 assets/data/preg-tips.json:[{week, baby, mom, eat, care, days:[{t,d}×7]}],孕 4–40 周。 第几天 = state.day(0–6),和首页「孕 N 周 D 天」同一口径。 ⚠️ 只在 state.pregEmpty === false(/pregnancy 已确认有孕周)且非产后时显示: pregEmpty 初值是 undefined —— 档案没回来之前 state.week 是 DEFAULT_DUE 推出来的假孕周, 拿它配知识就是给人看假数据。所以这里用「=== false」而不是「!pregEmpty」。 */ let TIPS = null, tipsLoading = null, tipSel = -1; function loadTips() { if (!tipsLoading) { tipsLoading = fetch('assets/data/preg-tips.json?v=' + VER) .then(r => (r.ok ? r.json() : null)) .then(arr => { const m = {}; (arr || []).forEach(x => { if (x && x.week) m[x.week] = x; }); TIPS = m; }) .catch(() => { TIPS = {}; }); } return tipsLoading; } /* 手绘图标:el 里放一张 (装饰,aria-hidden);没有库 / 没有这个 id / 图片加载失败 → 写回 emoji */ function setHandIco(el, id, emoji) { if (!el) return; if (!id || !window.handIcons || !handIcons.has(id)) { el.textContent = emoji; return; } const cur = el.querySelector('img'); if (cur && cur.dataset.hi === id) return; const im = handIcons.img(id); im.removeAttribute('width'); im.removeAttribute('height'); // 尺寸跟着各处的 font-size(css 里 1em 起算) im.dataset.hi = id; im.onerror = function () { el.textContent = emoji; }; el.textContent = ''; el.appendChild(im); } /* 小知识四个固定入口(首页那排 + 详情页四张卡):妈妈 / 吃 / 留心固定图标;「宝宝这周」跟孕周走,在 renderTips 里写 */ $$('[data-pt="mom"] > i, [data-pt="mom"] > .pt-ico').forEach(el => setHandIco(el, 'mom', '🤰')); $$('[data-pt="eat"] > i, [data-pt="eat"] > .pt-ico').forEach(el => setHandIco(el, 'eat', '🥗')); $$('[data-pt="care"] > i, [data-pt="care"] > .pt-ico').forEach(el => setHandIco(el, 'care', '📋')); function renderTips() { const sec = $('#pregTips'); if (!sec) return; if (state.pregEmpty !== false || state.postpartum) { sec.classList.add('hide'); return; } if (!TIPS) { loadTips().then(renderTips); return; } const w = Math.max(4, Math.min(40, state.week || 0)); const wk = TIPS[w]; if (!wk || !(wk.days || []).length) { sec.classList.add('hide'); return; } const today = Math.max(0, Math.min(6, state.day || 0)); const size = babySize(w); /* 2026-10-12 用户:小知识里的 emoji 换成兜爸手绘图标库(/_bridge/hand-icons.js,小程序后台 375797cf 维护); 图标按孕周从 handIcons.week() 取,SIZE 表只管文字;库没加载 / 图片挂了 → 退回原来的 emoji,位置不空 */ const hw = window.handIcons && handIcons.week(w); setHandIco($('#ptBabyIco'), hw && hw.id, size.emoji); /* 四张卡全文只在详情页 #detailTips;首页那排入口只有图标 + 标题 */ $('#ptBaby').textContent = '像' + size.name + '那么大。' + (wk.baby || ''); $('#ptMom').textContent = wk.mom || ''; $('#ptEat').textContent = wk.eat || ''; $('#ptCare').textContent = wk.care || ''; $('#ptDtTitle').textContent = '孕 ' + state.week + ' 周 · 这一周'; /* 详情页「本周每日小知识」:今天在上,往前倒着排到第 1 天;后面几天只露一行「明天见」 */ const list = $('#ptList'); list.textContent = ''; for (let i = today; i >= 0; i--) { const d = wk.days[i] || {}; const it = document.createElement('div'); it.className = 'pt-li' + (i === today ? ' is-today' : ''); const tag = document.createElement('span'); tag.className = 'pt-li-tag'; tag.textContent = i === today ? '今天 · 第 ' + (i + 1) + ' 天' : '第 ' + (i + 1) + ' 天'; const t = document.createElement('div'); t.className = 'pt-li-t'; t.textContent = d.t || ''; const p = document.createElement('div'); p.className = 'pt-li-d'; p.textContent = d.d || ''; it.appendChild(tag); it.appendChild(t); it.appendChild(p); list.appendChild(it); } if (today < 6) { const lk = document.createElement('div'); lk.className = 'pt-li is-lock'; lk.textContent = '第 ' + (today + 2) + (today < 5 ? '–7' : '') + ' 天 · 明天见,每天解锁一条'; list.appendChild(lk); } renderSearch(wk, today, size); sec.classList.remove('hide'); revealTipsGrid(); } /* 2026-10-31 首页「其他妈妈也在搜」(用户给了参考图,定了问题):问题库 assets/data/qa/hot.json,共 24 条, 前 6 条就是参考图上那 6 个问题,首页只露这 6 条;点任意一条 / 「查看全部」进 #detailQA 看答案和其余问题。 条目字段:q 问题 / a 答案 / tag 分类 / hot 热 / href 有专题页的直接跳 / quiz 答案区是「测一测」小测试。 问题库没加载到时退回 5 条本周小知识入口(落点 #detailTips),首页不空。 */ let QA = null, qaLoading = null; function loadQA() { if (QA) return Promise.resolve(QA); if (!qaLoading) qaLoading = fetch('assets/data/qa/hot.json?v=' + VER).then(r => (r.ok ? r.json() : [])).catch(() => []) .then(a => (QA = Array.isArray(a) ? a : [])); return qaLoading; } const qaHref = h => h + location.search + location.hash; /* 2026-11-02 用户定:「其他妈妈也在搜」按孕周分 6 段(按产检节点)—— 首页 6 条 = 第 1 条固定生娃计算器 + 当前孕周段 4 条(段内按热度) + 通用 1 条(每天按北京日期轮换)。问题库条目带 st:[段…] 的是分段题,不带的是通用题;测一测里已有的测试不进列表。 段:①4~8 周 刚怀上 ②9~13 建档+NT ③14~20 孕中前段 ④21~28 大排畸+糖耐 ⑤29~36 孕晚前段 ⑥37+ 足月待产。没孕周(未设/产后)→ 按库里原顺序。 */ const QA_FIXED = 'topic/huafei/', QA_SKIP = ['topic/ceshi/', 'topic/duiyou/']; const qaBand = w => (!w ? 0 : w <= 8 ? 1 : w <= 13 ? 2 : w <= 20 ? 3 : w <= 28 ? 4 : w <= 36 ? 5 : 6); function qaPick(bank, week, n) { n = n || 6; const b = qaBand(week), fixed = bank.filter(x => x.href === QA_FIXED); const ok = x => x.href !== QA_FIXED && QA_SKIP.indexOf(x.href) < 0; if (!b) return fixed.concat(bank.filter(ok)).slice(0, n); const byHeat = (a, c) => (+c.heat || 0) - (+a.heat || 0); const stage = bank.filter(x => ok(x) && x.st && x.st.indexOf(b) >= 0).sort(byHeat).slice(0, n - 2); const gen = bank.filter(x => ok(x) && !x.st); const day = Math.floor((Date.now() + 288e5) / 864e5); const g = gen.length ? [gen[day % gen.length]] : []; const out = fixed.concat(stage, g); bank.filter(x => ok(x) && out.indexOf(x) < 0).forEach(x => { if (out.length < n) out.push(x); }); return out.slice(0, n); } window.__mbQaPick = qaPick; window.__mbQaBand = qaBand; const MS_FIRE = ''; const MS_FIRE_W = ''; function msHeat() { const p = window.__mbPulse, tot = (p && p.total && +p.total.value) || 1510000; $$('#msList .ms-h').forEach(h => { const n = tot * (+h.dataset.heat || 0); h.querySelector('b').textContent = (n >= 1e4 ? (Math.round(n / 1e3) / 10) + '万' : Math.round(n)) + ' 人看过'; }); } window.__mbHeat = msHeat; /* 11-02 用户定:按名次固定发标签 —— 第 1 爆、第 2/3 热、第 4/5 新、第 6 不显示(样式 .ms-bd.*) */ const MS_BD = [{ t: '爆', c: 'bao' }, { t: '热', c: 'hot' }, { t: '热', c: 'hot' }, { t: '新', c: 'new' }, { t: '新', c: 'new' }]; function msBadge(i) { return MS_BD[i] || { t: '', c: '' }; } window.__mbMsBadge = msBadge; function renderSearch(wk, today, size) { const W = state.week, L = $('#msList'); if (!L) return; if (!QA) loadQA().then(q => { if (q.length) renderSearch(wk, today, size); }); $('#msWk').textContent = '孕 ' + W + ' 周'; $('#msCnt').textContent = '孕 ' + W + ' 周的妈妈都在看'; const pool = AVA_TOP.concat(AVA_FEED), s0 = (W * 7) % pool.length; $('#msAvs').innerHTML = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => avaImg(pool[(s0 + i * 3) % pool.length])).join(''); const bank = QA || []; let items; if (bank.length) { items = qaPick(bank, W).map(x => ({ q: x.q, href: x.href ? qaHref(x.href) : '', qa: bank.indexOf(x), badge: x.badge, heat: x.heat })); } else { const q = t => /[??吗呢]$/.test(t) ? t : t + (/怎么办|什么|多少|怎么|几/.test(t) ? '?' : ''); const d0 = wk.days[today]; items = []; if (d0 && d0.t) items.push({ q: q(d0.t), pt: '' }); items.push({ q: '孕' + W + '周,宝宝长多大了?', pt: 'baby' }); items.push({ q: '这周身体会有哪些变化?', pt: 'mom' }); items.push({ q: '孕' + W + '周吃什么对宝宝好?', pt: 'eat' }); items.push({ q: '这周产检和生活要留心什么?', pt: 'care' }); } L.textContent = ''; items.forEach((it, i) => { const el = document.createElement(it.href ? 'a' : 'button'); el.className = 'ms-it ripple'; if (it.href) el.href = it.href; else { el.type = 'button'; el.addEventListener('click', () => (it.qa != null ? openQA(it.qa) : openTips(it.pt))); } const no = document.createElement('i'); no.className = 'ms-no'; no.textContent = i + 1; const m = document.createElement('span'); m.className = 'ms-m'; const t = document.createElement('span'); t.className = 'ms-q'; t.textContent = it.q; m.appendChild(t); /* 热度行:火苗 + 「N 万人看过」。人数 = 「此刻」同一个总人数(后台 pulseBase)× 每条固定比例,和首页其它人数同一口径 */ if (it.heat) { const h = document.createElement('span'); h.className = 'ms-h'; h.dataset.heat = it.heat; h.innerHTML = MS_FIRE + ''; m.appendChild(h); } el.appendChild(no); el.appendChild(m); /* 2026-11-01 爆 = 红橙渐变 + 小火苗 + 光泽;热 = 橙色实底;新 = 绿(样式在 index.html .ms-bd) */ it.badge = msBadge(i, it.badge).t; if (it.badge) { const g = document.createElement('span'); g.className = 'ms-bd ' + msBadge(i).c; g.textContent = it.badge; if (it.badge === '爆') g.insertAdjacentHTML('afterbegin', MS_FIRE_W); el.appendChild(g); } const go = document.createElement('i'); go.className = 'ms-go'; go.textContent = '›'; el.appendChild(go); L.appendChild(el); }); msHeat(); const all = $('#msAll'); if (all) { all.classList.toggle('hide', bank.length <= 6); all.textContent = '查看全部问题 ›'; /* 11-02 不写条数(库里 100 条,用户印象是 50,条数进去再看) */ } } /* 全部问题页:focus = 从首页点的那条的下标(展开 + 滚到它);不传 = 从「查看全部」进来 */ function openQA(focus) { const bank = QA || []; if (!bank.length) { openTips(); return; } /* 2026-11-02 按孕周排:生娃计算器 → 当前孕周段(按热度)→ 其余按库里顺序;分类条第 2 个是「孕 N 周」只看本段 ⚠️ band 必须在用它的标题之前声明(11-02 曾写反 → const 暂时性死区报错,「查看全部」点了没反应) */ const band = (state.postpartum || state.pregEmpty) ? 0 : qaBand(state.week); $('#qaTitle').textContent = '其他妈妈也在搜'; $('#qaHeroW').textContent = band ? '孕 ' + state.week + ' 周' : '大家都在问'; $('#qaHeroT').textContent = '妈妈们最常问的问题'; $('#qaHeroD').textContent = bank.length + ' 个问题 · 点开看答案'; /* 11-02 用户二改:不要「正在看」和热度条,每条只显示「N 人看过」(= 「此刻」总人数 × heat,同首页;模拟数,AGENTS.md 12b); 前 10 名像微博热搜挂小标:第 1 爆、2~3 沸、4~7 热、8~10 新,其余不挂 */ const tot = (window.__mbPulse && window.__mbPulse.total && +window.__mbPulse.total.value) || 1510000; const QA_BD = i => i < 1 ? ['爆', 'bao'] : i < 3 ? ['沸', 'fei'] : i < 7 ? ['热', 're'] : i < 10 ? ['新', 'xin'] : null; const wan = n => n >= 1e4 ? (Math.round(n / 1e3) / 10) + '万' : String(Math.round(n)); $('#qaTopic').classList.add('hide'); const inBand = x => !!(band && x.st && x.st.indexOf(band) >= 0); const order = bank.filter(x => x.href === QA_FIXED) .concat(bank.filter(x => x.href !== QA_FIXED && inBand(x)).sort((a, c) => (+c.heat || 0) - (+a.heat || 0))) .concat(bank.filter(x => x.href !== QA_FIXED && !inBand(x))); const cats = []; bank.forEach(x => { if (x.tag && cats.indexOf(x.tag) < 0) cats.push(x.tag); }); if (band) cats.unshift('__st'); const cb = $('#qaCats'); cb.textContent = ''; ['全部'].concat(cats).forEach(c => { const b = document.createElement('button'); b.type = 'button'; b.className = 'qa-cat'; b.dataset.cat = c; b.textContent = c === '__st' ? '孕 ' + state.week + ' 周' : c; const sm = document.createElement('small'); sm.textContent = c === '全部' ? bank.length : c === '__st' ? bank.filter(inBand).length : bank.filter(x => x.tag === c).length; b.appendChild(sm); b.addEventListener('click', () => qaFilter(c)); cb.appendChild(b); }); const L = $('#qaList'); L.textContent = ''; order.forEach((x, i) => { const it = document.createElement('div'); it.className = 'qa-it' + (i < 6 ? ' top' : ''); it.dataset.cat = x.tag || ''; if (inBand(x)) it.dataset.st = '1'; const h = document.createElement('button'); h.type = 'button'; h.className = 'qa-q'; const no = document.createElement('i'); no.className = 'qa-no'; no.textContent = i + 1; const t = document.createElement('span'); t.className = 'qa-m'; const tq = document.createElement('span'); tq.className = 'qa-tx'; tq.textContent = x.q; t.appendChild(tq); if (+x.heat) { const st = document.createElement('span'); st.className = 'qa-st'; st.textContent = wan(tot * +x.heat) + ' 人看过'; t.appendChild(st); } const bd = QA_BD(i), g = document.createElement('span'); if (bd) { g.className = 'qa-bd ' + bd[1]; g.textContent = bd[0]; } const ch = document.createElement('i'); ch.className = 'qa-ch'; ch.textContent = '›'; h.appendChild(no); h.appendChild(t); if (g.textContent) h.appendChild(g); h.appendChild(ch); const a = document.createElement('div'); a.className = 'qa-a'; a.textContent = x.a || ''; /* 有专题页的条目(首页那 6 个)点了直接进专题页 */ h.addEventListener('click', () => { if (x.href) { haptic(); location.href = qaHref(x.href); return; } it.classList.toggle('open'); haptic(); }); it.appendChild(h); it.appendChild(a); L.appendChild(it); }); qaFilter('全部'); openPanel('detailQA'); const body = $('#detailQA .detail-body'); body.scrollTop = 0; const fi = focus != null ? order.indexOf(bank[focus]) : -1; if (fi >= 0 && L.children[fi] && !bank[focus].href) { const c = L.children[fi]; c.classList.add('open'); setTimeout(() => { body.scrollTo({ top: Math.max(0, c.getBoundingClientRect().top - body.getBoundingClientRect().top + body.scrollTop - 12), behavior: 'smooth' }); c.classList.remove('hit'); void c.offsetWidth; c.classList.add('hit'); }, 400); // 等详情页滑入再滚 } } function qaFilter(c) { $$('#qaCats .qa-cat').forEach(b => b.classList.toggle('on', b.dataset.cat === c)); $$('#qaList .qa-it').forEach(it => it.classList.toggle('hide', c === '__st' ? it.dataset.st !== '1' : c !== '全部' && it.dataset.cat !== c)); } (function () { const all = $('#msAll'); if (all) all.addEventListener('click', () => loadQA().then(() => openQA())); /* 11-02 合并版里原模块可能没渲染过,先确保问题库到手 */ const tp = $('#qaTips'); if (tp) tp.addEventListener('click', () => openTips()); })(); /* 首页四个入口 → 详情页,滚到对应那张卡并闪一下 */ function openTips(key) { openPanel('detailTips'); const body = $('#detailTips .detail-body'); body.scrollTop = 0; $('#ptGrid').classList.add('is-shown'); const c = key && $('#ptGrid .pt-card[data-pt="' + key + '"]'); if (c) setTimeout(() => { body.scrollTo({ top: c.offsetTop - 16, behavior: 'smooth' }); c.classList.remove('is-hit'); void c.offsetWidth; c.classList.add('is-hit'); }, 400); // 等详情页滑入再滚 } function revealTipsGrid() { const g = $('#ptGrid'); if (!g || g.classList.contains('is-shown')) return; if (!('IntersectionObserver' in window)) { g.classList.add('is-shown'); return; } const io = new IntersectionObserver(es => { if (es.some(e => e.isIntersecting)) { g.classList.add('is-shown'); io.disconnect(); } }, { threshold: 0.2 }); io.observe(g); } /* 拉卡片数据(与其它远程数据一起打;失败静默,卡上保留现有值) */ /* 预览环境专用的一组示例数据(2026-09-30 用户要求:五张工具卡填一组示例数据,看排版效果)。 ⚠️ 只在 /mama-box-preview/ 路径下生效,正式首页(/mama-box/)永远走不到这里 —— 2026-09-22 出过写死演示值被所有用户看到的事故(AGENTS §4.2 不造假数据), 所以示例数据绝不能靠「接口没回来就兜底」这种条件触发,只认预览路径。 结构照搬 tool-cards 接口,直接喂给 applyRemoteCards,渲染逻辑一行不多写。 */ /* 2026-10-08 用户:「把正式版对接后端接口都同步到预览版」→ 预览默认也调真实 tool-cards,只有显式 demo=1(DEMO 已限定预览路径)才用示例。 */ const DEMO_CARDS = DEMO ? { ok: true, day: '2026-09-30', cards: [ { slot: 'kick', value: '12', sub: '3 段 · 最近 14:20' }, { slot: 'weight', value: '58.2', unit: 'kg', sub: '本周 +0.3kg' }, { slot: 'cal', text: '10月8日' }, { slot: 'fweight', text: '约 1250g' }, { slot: 'curve', value: '5分20秒' }, ], } : null; /* ---------- 我的孕期数据(体重 / 血压 / 血糖,2026-10-31 用户定) ---------- 上游 /user/app-cards 只聚合了五张工具卡,没有血压、血糖 → 这里直接读三个工具自己的数据(只读): · 体重 yunqi-tizhong:GET /taidong/api/state → pw_data_v1.weights(跳过 demo:true 示例记录) · 血糖 yunqi-xuetang:GET /taidong/api/state → tmrj.v1.readings(跳过 demo:1 与老种子 id 如 r29fasting) · 血压 yunqi-xueya :GET /taidong/api/xueya/records(服务端只存真实记录) 每个工具单独 openid-login 换 token(state 按 app_id 分区),token 在 sessionStorage 缓存 20 分钟。 回到前台 / bfcache 恢复时重拉:妈妈在工具里刚填完,回首页就看到新数。 */ const VT_API = '/taidong/api'; const VT_SLOT = { fasting: '空腹', b1: '早餐后1h', b2: '早餐后2h', l1: '午餐后1h', l2: '午餐后2h', d1: '晚餐后1h', d2: '晚餐后2h', bed: '睡前', night: '夜间' }; const VT_TGT = { fasting: [3.3, 5.3], b1: [3.3, 7.8], l1: [3.3, 7.8], d1: [3.3, 7.8], b2: [3.3, 6.7], l2: [3.3, 6.7], d2: [3.3, 6.7], bed: [4.4, 6.7], night: [3.3, 6.7] }; let _vtAt = 0; function vtToken(app) { const openid = hashParam('openid'), unionid = hashParam('unionid'); const ck = 'mmbox.vt.tok.' + app; try { const c = JSON.parse(sessionStorage.getItem(ck) || 'null'); if (c && c.o === openid && Date.now() - c.t < 20 * 60e3) return Promise.resolve(c.k); } catch (e) {} return fetch(VT_API + '/auth/openid-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ openid: openid, unionid: unionid || null, app_id: app }) }).then(r => (r.ok ? r.json() : null)).then(j => { const k = j && (j.token || (j.data && j.data.token)); if (!k) return null; try { sessionStorage.setItem(ck, JSON.stringify({ o: openid, k: k, t: Date.now() })); } catch (e) {} return k; }); } function vtGet(app, path) { return vtToken(app).then(k => k ? fetch(VT_API + path, { headers: { Authorization: 'Bearer ' + k } }).then(r => (r.ok ? r.json() : null)) : null).catch(() => null); } function vtVal(items, key) { /* state 条目可能是 {value:"…json…"} 或直接值 */ let v = items && items[key]; if (v && typeof v === 'object' && 'value' in v) v = v.value; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (e) { v = null; } } return v && typeof v === 'object' ? v : null; } function vtDay(d) { /* 'YYYY-MM-DD…' 或时间戳 → 今天 / 昨天 / M月D日 */ const t = typeof d === 'number' ? new Date(d) : parseDay(String(d || '').slice(0, 10)); if (!t || isNaN(t)) return ''; const a = new Date(); a.setHours(0, 0, 0, 0); const b = new Date(t); b.setHours(0, 0, 0, 0); const n = Math.round((a - b) / DAY_MS); return n === 0 ? '今天' : n === 1 ? '昨天' : (b.getMonth() + 1) + '月' + b.getDate() + '日'; } const VT_ID = { kick: 'vtK', weight: 'vtW', bp: 'vtP', glu: 'vtG', curve: 'vtC', fweight: 'vtE' }; const VT_TG = { bp: '量一量', glu: '记一笔' }; /* 小图标格上的默认胶囊(同 index.html) */ /* 2026-10-31 用户:去掉「示例」两个字,没记过的格直接按正常数据的样子显示(值 / 胶囊 / 胶囊样式 / 时间) */ const VT_DEMO = { kick: ['12', '今天', '', '最近 14:20'], weight: ['58.2', '比孕前 +4.6', '', '今天记录'], bp: ['115/75', '正常', 'ok', '昨天记录'], glu: ['5.1', '达标', 'ok', '今天 空腹'] }; /* 这两格有记录才出现 */ function vtSet(key, val, pill, cls, when, ts) { const it = $('.vt-it[data-vt="' + key + '"]'); if (!it) return; const id = VT_ID[key]; it.classList.toggle('empty', !val); it.dataset.has = val ? '1' : ''; it.dataset.ts = val && ts ? String(ts) : ''; /* 最近一次记录时间(毫秒),数据卡按它排先后 */ /* 2026-10-31 用户定:没记过的格放示例数(像育儿首页「宝宝的记录」那样标「示例」),一记就换成自己的 */ const dm = !val && VT_DEMO[key]; it.classList.toggle('demo', !!dm); $('#' + id).textContent = val || (dm ? dm[0] : '--'); const s = $('#' + id + 's'); s.textContent = val ? (pill || '') : (dm ? dm[1] : (key === 'kick' ? '去数一数 ›' : key === 'fweight' ? '去测算 ›' : '去记一笔 ›')); s.className = 'vt-s' + (val && cls ? ' ' + cls : dm && dm[2] ? ' ' + dm[2] : ''); $('#' + id + 'd').textContent = val ? (when || '') : (dm ? dm[3] : ''); /* 2026-11-01 血压 / 血糖没排进数据卡、落在小图标宫格里时,胶囊也显示最近一次(体重等由卡片接口写) */ const tgS = VT_TG[key] && $('.tg[data-mini="' + key + '"] .tg-s'); if (tgS) tgS.textContent = val || VT_TG[key]; vtLayout(); } /* 2026-11-01 起数据卡出哪几张、什么顺序、折叠都在 renderVitals(只看有没有真实记录 + 记录时间,和编辑列表无关)。 */ function vtLayout() { renderVitals(); } if (location.pathname.indexOf('/mama-box-preview/') === 0) window.__mbVtSet = vtSet; /* 预览专用:截图 / 联调时手动喂数据卡,正式版不暴露 */ /* 胎动 / 数宫缩 / 胎儿估重 来自卡片接口(tool-cards),applyRemoteCards 收到就喂进来 */ function vtCards(r) { const by = {}; ((r && r.cards) || []).forEach(c => { if (c && c.slot) by[c.slot] = c; }); const k = by.kick; const at = c => (c && c.at && Date.parse(c.at)) || 0; /* 卡片接口的最近记录时间 */ if (k) vtSet('kick', !k.empty && +k.value > 0 ? String(k.value) : '', '今天', '', String(k.sub || '').replace(/^.*·\s*/, ''), at(k)); const c = by.curve; if (c) vtSet('curve', !c.empty && c.value ? c.value + (c.unit || '') : '', '最近一次', '', c.sub || '', at(c)); const f = by.fweight; if (f) vtSet('fweight', !f.empty && (f.value || f.text) ? (f.value ? f.value + (f.unit || '') : f.text) : '', '', '', f.sub || '', at(f)); } function loadVitals() { if (!$('#vitals')) return; renderVitals(); /* 数据卡显隐跟身份 / 产后走(htVtOn),这里按最新状态排一次 */ if (!htVtOn()) return; _vtAt = Date.now(); vtGet('yunqi-tizhong', '/state').then(j => { const pw = vtVal(j && j.items, 'pw_data_v1'); const ws = ((pw && pw.weights) || []).filter(w => w && !w.demo && +w.weight > 0).sort((a, b) => String(a.date).localeCompare(String(b.date)) || (a.updatedAt || 0) - (b.updatedAt || 0)); const last = ws[ws.length - 1]; if (!last) { vtSet('weight', ''); return; } const pre = pw && !pw.demo && pw.profile && +pw.profile.preWeight; const gain = pre ? Math.round((last.weight - pre) * 10) / 10 : null; vtSet('weight', (+last.weight).toFixed(1), gain != null ? '比孕前 ' + (gain >= 0 ? '+' : '') + gain : '', '', vtDay(last.date) + '记录', Math.max(+last.updatedAt || 0, Date.parse(String(last.date || '').slice(0, 10) + 'T12:00:00') || 0)); }); vtGet('yunqi-xueya', '/xueya/records?limit=5').then(j => { const rs = ((j && j.items) || []).filter(r => r && r.systolic && r.diastolic).sort((a, b) => String(b.measured_at).localeCompare(String(a.measured_at))); const r = rs[0]; if (!r) { vtSet('bp', ''); return; } const sy = +r.systolic, di = +r.diastolic; /* 孕期口径:≥140/90 偏高(≥160/110 很高),<90/60 偏低 */ const st = (sy >= 160 || di >= 110) ? ['很高,请就医', 'hi'] : (sy >= 140 || di >= 90) ? ['偏高', 'hi'] : (sy < 90 || di < 60) ? ['偏低', 'lo'] : ['正常', 'ok']; vtSet('bp', sy + '/' + di, st[0], st[1], vtDay(r.measured_at) + '记录', Date.parse(r.measured_at) || 0); }); vtGet('yunqi-xuetang', '/state').then(j => { const tm = vtVal(j && j.items, 'tmrj.v1'); const rs = ((tm && tm.readings) || []).filter(r => r && !r.demo && !/^r\d{1,3}[a-z]/.test(String(r.id || '')) && +r.value > 0).sort((a, b) => (a.ts || 0) - (b.ts || 0)); const r = rs[rs.length - 1]; if (!r) { vtSet('glu', ''); return; } const t = (tm.targets && tm.targets[r.slot]) || VT_TGT[r.slot] || [3.3, 7.8]; const st = r.value > t[1] ? ['偏高', 'hi'] : r.value < t[0] ? ['偏低', 'lo'] : ['达标', 'ok']; vtSet('glu', (+r.value).toFixed(1), st[0], st[1], vtDay(r.ts) + (VT_SLOT[r.slot] ? ' ' + VT_SLOT[r.slot] : ''), +r.ts || 0); }); } $$('.vt-it[data-vt]').forEach(b => b.addEventListener('click', () => { const k = b.dataset.vt; const app = APP_BY_ID[MINI_APP_MAP[k]]; if (app && app.url) { jumpApp(app.url, app.identifier); return; } const path = { kick: '/taidong/', weight: '/yunqi-tizhong/', bp: '/yunqi-xueya/', glu: '/yunqi-xuetang/', curve: '/gongsuo-pinlv/', fweight: '/taier-guzhong/' }[k]; haptic(); location.href = withIdentity(location.origin + path); })); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && _vtAt && Date.now() - _vtAt > 3000) loadVitals(); }); window.addEventListener('pageshow', function (e) { if (e.persisted && _vtAt) loadVitals(); }); function applyRemoteCardsFetch() { if (DEMO_CARDS) { applyRemoteCards(DEMO_CARDS); return; } const openid = hashParam('openid'); if (!openid) return; // 访客态不问(接口要 openid) const unionid = hashParam('unionid'); const qs = 'openid=' + encodeURIComponent(openid) + (unionid ? '&unionid=' + encodeURIComponent(unionid) : ''); fetch('/api/mp/public/mama-box/tool-cards?' + qs) .then(r => (r.ok ? r.json() : null)) .then(j => { const r = j && j.result; if (r) applyRemoteCards(r); }) .catch(() => { /* 静默:保留现有值 */ }); } function __applyAbout(ct) { if (!ct) return; SETTINGS.about.title = ct.title || SETTINGS.about.title; /* 2026-10-22:后台文案里写死的版本号(如 v3.2.1)一律换成当前 VER,关于页 / 页脚 / 我的页底只认这一个版本号 */ const _v = t => String(t).replace(/v\d+(?:\.\d+)+/gi, 'v' + VER); SETTINGS.about.more = ct.more ? _v(ct.more) : SETTINGS.about.more; SETTINGS.about.foot = ct.foot ? _v(ct.foot) : SETTINGS.about.foot; } function applyRemoteAll() { var openid = hashParam('openid'); if (!openid) return; var base = '/api/mp/public/mama-box/'; var j = function (u) { return fetch(u).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }; Promise.all([ j(base + 'resources?openid=' + encodeURIComponent(openid)), j(base + 'records?openid=' + encodeURIComponent(openid)), j(base + 'content?key=about'), ]).then(function (rs) { var rr = rs[0], rec = rs[1], ct = rs[2]; if (rr && rr.code === 200 && rr.result) __applyResources(rr.result.items); if (rec && rec.code === 200 && rec.result) __applyRecords(rec.result); if (ct && ct.code === 200 && ct.result) __applyAbout(ct.result); }).catch(function () { /* 静默回退内置值 */ }); } /* ---------- 今日脚印(box_app → 数胎动对外 API,动态获取) ---------- 首页 .today-line 时间轴原来是纯演示内容(无真实数据源,访客/未完善态整块隐藏), 现在启动后拉 /footprint/today 动态渲染。后端封装见 box_app 的 FootprintService: 上游数胎动按 unionid 建档(openid 跨应用互不相干),H5 身份注入惯例带 openid+unionid,unionid 缺失时后端用映射表兜底,这里只管把参数带上。 各分支(对齐全站「远程数据为准、拉不到回退内置」的惯例): - linked=false(未关联数胎动 / 上游查无此人)→ 整块隐藏。演示行是假数据, 这用户在数胎动体系里没有档案,不能留着冒充真足迹。 - linked=true + events → 重建 .steps 行。textContent 拼装(上游文案直接进 DOM,杜绝注入);最多 8 条(接口升序,取最近 8 条,更早的让给「查看全部」)。 - linked=true + 无事件 → 一条占位,别让卡空成一截竖线。 - 拉取失败 → 什么都不动,保持内置演示行(跟其它远程接口的回退一致); 访客态不请求(pregEmpty 分支已整块隐藏,不会露出)。 ⚠️ 竞态:本函数与 applyRemoteProfile 并行。profile 回来若判 pregEmpty 会 隐藏时间轴,所以这里落笔前再看一眼 state.pregEmpty,别把刚藏好的放出来。 */ var _footprintApplied = false; /* 今日脚印(2026-09-23,B 方案:数据全部来自 box_app 的 /footprint/today, 它的上游是数胎动平台的**用户面**接口 /user/daily/footprint): rows 拿到事件 → 时间轴;AI 气泡用接口给的 digest.text(**真文案**) guide 今天**一次都没用过**(used 为空)/ 上游查无此人 → 引导占位:文案用接口的 lead、 应用用 unused[](上游已按我们上架清单过滤,且带回 url), 对得上 App 表的还能直接点进该应用 hide 接口读不到(网络 / 非 200 / 上游故障且无旧值)→ **整块不显示** ⚠️ AI 气泡不再挂写死的那句「今天你数到我动了 3 次」:文案一律取 digest.text,取不到就收起气泡 —— **data-driven**,不靠"有没有事件"猜(那句演示文案已从 index.html 删掉)。 ⚠️ 未建档(pregEmpty)不再整块隐藏:照接口结论显示,新用户也能看到引导。 ⚠️ 模块显隐只有 fpRender 一个 owner(原来 syncPregnancy 也直接改 display,会撞竞态)。 */ let _fpMode = 'pending'; // pending(加载中) | rows | guide | hide let _fpData = null; // 接口 result(events / digest / lead / unused…) function fpApply(mode, r) { if (DEMO) { // 示例模式:固定一组脚印 + 宝宝气泡(不再追加下面 fpRender 里的预览示例行) mode = 'rows'; r = { events: [ { at: '08:10', label: '称体重 58.2 kg,本周 +0.3 kg' }, { at: '09:20', label: '数胎动,宝宝动了 12 次' }, { at: '12:40', label: '查了「孕期能不能吃」:杨梅' }, { at: '15:05', label: '记一次血糖 5.6 mmol/L(餐后 2 小时)' }, ], digest: { text: '妈妈今天好认真呀!我动了 12 下,你都数到啦~下午记得多喝点水,晚上我们一起听故事。' }, }; } _fpMode = mode; _fpData = r || null; fpRender(); if (plHook) plHook(); } function fpRender() { const tl = document.querySelector('.today-line'), steps = $('#tlSteps'), guide = $('#tlGuide'); const _cd = $('#fpCalD'); if (_cd) { const n = new Date(); _cd.textContent = (n.getDate() < 10 ? '0' : '') + n.getDate(); $('#fpCalM').textContent = (n.getMonth() + 1) + '月 · 周' + '日一二三四五六'[n.getDay()]; /* 2026-10-03 精简版:日历块隐藏,日期改成标题下一行小字 */ if ($('#fpDate')) $('#fpDate').textContent = (n.getMonth() + 1) + '月' + n.getDate() + '日 · 周' + '日一二三四五六'[n.getDay()]; } if (!tl || !steps) return; const mode = _fpMode, r = _fpData || {}; if (mode === 'hide') { tl.style.display = 'none'; return; } tl.style.display = ''; /* AI 气泡:文案来自接口 digest.text;没有就整体收起。上游文案是**纯文本**,用 textContent 写。 */ const say = document.querySelector('.ab-say'); const txt = (r.digest && r.digest.text) || ''; if (say) say.textContent = txt; tl.classList.toggle('no-ai', !txt); tl.classList.toggle('ai-warn', !!(r.digest && r.digest.warn)); if (guide) guide.style.display = (mode === 'guide') ? '' : 'none'; /* 2026-10-02 用户要求:没脚印时改成「今日待办」清单(见 renderGuide),不再放示例行。 */ if (mode === 'guide') { steps.style.display = 'none'; steps.innerHTML = ''; renderGuide(r); return; } steps.style.display = ''; steps.innerHTML = ''; if (mode === 'pending') { steps.append(pendingFootprintStep()); return; } /* ⚠️ 上游口径(2026-09-23 接口方确认):`used` = 当天**用过**(有页面访问或有业务结果), `results` 只是其中的**业务记录**。所以「用过但没留下记录」的日子 `used` 非空、`results` 为空 —— 这种日子要显示**应用名列表**,不能走「还没有脚印」引导(否则会和气泡里那句 「今天用了 N 个工具,还没有留下具体记录。」自相矛盾,用户截图里就是这个现象)。 两类行按 HH:MM 合并排序:有记录的行来自 events,没记录的应用按 first_at 出一行。 */ const rows = (r.events || []).map(e => ({ at: e.at || '', label: e.label || '', detail: e.detail || '', app: e.app || '' })); const withEvents = new Set((r.events || []).map(e => e.appId).filter(Boolean)); (r.used || []).filter(u => u.appId && !withEvents.has(u.appId)).forEach(u => { rows.push({ at: u.at || '', label: u.appName || '', detail: u.views > 1 ? '浏览 ' + u.views + ' 次' : '', app: '', }); }); /* ⚠️ 示例脚印(2026-10-03 用户:「多放两个脚印示例,数胎动、数宫缩、称体重」)——**只在预览环境** (路径含 mama-box-preview,或 hash 带 fpdemo=1)追加,用来看多条时的排版;正式版不会出现,不冒充真记录。 确认样式后整段删掉即可。 */ /* 2026-10-08 起预览默认只显示真实脚印;要看多条排版,预览地址加 fpdemo=1。 */ if (!DEMO && /mama-box-preview/.test(location.pathname) && hashParam('fpdemo') === '1') { rows.push( { at: '09:20', label: '数胎动,宝宝动了 12 次', detail: '', app: '' }, { at: '11:05', label: '数宫缩 3 次,间隔约 8 分钟', detail: '', app: '' }, { at: '13:30', label: '称体重 58.2 kg,本周 +0.3 kg', detail: '', app: '' } ); } rows.sort((a, b) => String(a.at).localeCompare(String(b.at))); /* 2026-10-03 精简版:首页只放最近 4 条,更多点「全部」 */ rows.slice(-4).forEach(row => steps.append(buildFootprintStep(row))); } /* 引导占位:文案与应用都来自上游 —— lead 是上游自己的引导语,unused[] 是今天没用过的应用。 能对上 App 表的(identifier 由 box_app 按 url 路径解析)渲染成可点芯片,其余只显示名字。 */ /* 今日待办:每个还没用的工具配一句「今天该做的事」。按应用名关键词匹配,匹配不上就用通用句。 */ const FP_TODO = [ [/胎动/, '数一次胎动', '安静侧躺 1 小时,看看小家伙动了几次'], [/宫缩/, '留意一下宫缩', '肚子发紧时记下间隔和持续时间'], [/体重/, '称一下今天的体重', '早上空腹称最准,看看本周涨了多少'], [/血糖/, '记一次血糖', '餐后 2 小时测一次,记下来给医生看'], [/血压/, '量一次血压', '坐着休息 5 分钟后再量'], [/补水|补剂|喝水/, '喝水和补剂打个卡', '今天的叶酸、钙片、水喝够了吗'], [/待产/, '看看待产包还差什么', '一次备一点,临产不手忙脚乱'], [/产检|孕检|日历/, '确认下次产检时间', '提前看看要空腹还是要憋尿'], [/能不能吃/, '查查今天想吃的能不能吃', '馋什么先查一下,吃得放心'], [/能不能做/, '查查今天想做的能不能做', '染发、泡澡、运动……先查再做'], [/估重/, '算一算宝宝有多重', '拿最近一次 B 超数据就能估'], [/起名/, '给宝宝想一个名字', '挑几个喜欢的字,和家人一起选'], [/档案/, '翻翻孕期档案', '把这周的变化记进档案里'], [/运动/, '做 10 分钟孕期运动', '散散步、拉伸一下,身体会轻松些'], ]; /* 今日待办前面的线性小图标(2026-10-02 用户:「把 logo 调到前面……跟背景的绿色协调,用线性的」)。 不用各应用的彩色 logo(五颜六色压在薄荷绿卡上很花),统一画成 24 格、1.8 线宽、currentColor 的线稿, 颜色由 CSS 给(墨绿),底是一块浅绿圆角方。按应用名关键词匹配,匹配不上用一支笔。 */ const TD_ICO = [ [/胎动/, ''], [/宫缩/, ''], [/体重/, ''], [/血糖/, ''], [/血压|胎心/, ''], [/运动/, ''], [/产检|孕检|日历/, ''], [/待产/, ''], [/能不能吃|饮食|营养/, ''], [/能不能做/, ''], [/估重/, ''], [/补水|补剂|喝水/, ''], [/起名/, ''], [/档案/, ''], ]; const TD_DEF = ''; function todoIco(name) { for (const [re, svg] of TD_ICO) if (re.test(name)) return svg; return TD_DEF; } function todoLine(name) { for (const [re, t, d] of FP_TODO) if (re.test(name)) return [t, d]; return ['用一下「' + name + '」', '花一分钟,给今天留个记录']; } function renderGuide(r) { const title = $('#tlGuideTitle'), sub = $('#tlGuideSub'), box = $('#tlGuideApps'), cnt = $('#tdCnt'); if (title) title.textContent = (r && r.lead) || ''; if (title) title.style.display = (r && r.lead) ? '' : 'none'; if (!box) return; box.innerHTML = ''; ((r && r.unused) || []).filter(u => !stageLocked(stageGateOf(u.url, u.identifier))).slice(0, 4).forEach(u => { const ident = u.identifier || ''; const app = ident ? APP_BY_ID[ident] : null; /* ⚠️ 优先用上游带回的 `url`(2026-09-23 起它只推**我们已上架**的应用并把正式 url 带回来, 接口方原话「有 url 就直接跳,不用再对 App 表」);App 表只作兜底。 identifier 仍用于 jumpApp 的 last_app 上报(服务端会校验 url 对得上在架应用)。 */ const href = u.url || (app && app.url) || ''; const name = u.appName || (app && app.name) || ident; if (!name) return; const [t, d] = todoLine(name); const row = document.createElement(href ? 'button' : 'div'); row.className = 'td-row' + (href ? ' ripple' : ''); if (href) row.type = 'button'; row.innerHTML = '' + (href ? '去完成' : ''); row.querySelector('b').textContent = t; row.querySelector('em').textContent = d; if (href) row.addEventListener('click', () => jumpApp(href, (app && app.identifier) || ident || '')); box.appendChild(row); }); const n = box.childElementCount; if (cnt) cnt.textContent = n ? '0 / ' + n : ''; if (sub) sub.textContent = n ? '完成任意一件,今天的第一只小脚丫就会印上来' : '去上面的工具记一笔,今天的第一只小脚丫就会印上来'; } /* 「记一下」弹窗上半部分(2026-10-02 用户:「有点单薄」)。数据只认 _fpData(今日脚印接口), used = 今天用过的工具,unused = 还没用的;拿不到就把两块藏起来,退回原来的「标题 + 宫格」。 */ sheetPaint = function () { const n = new Date(Date.now() + 8 * 3600e3); // 北京时间 const wd = '日一二三四五六'[n.getUTCDay()]; const wk = state.week ? '孕' + state.week + '周' + (state.day ? '+' + state.day + '天' : '') + ' · ' : ''; $('#skSub').textContent = wk + (n.getUTCMonth() + 1) + '月' + n.getUTCDate() + '日 周' + wd; const r = _fpMode === 'rows' || _fpMode === 'guide' ? (_fpData || {}) : null; const today = $('#skToday'), go = $('#skGo'); today.classList.toggle('hide', !r); go.classList.add('hide'); $$('#appsGrid .app').forEach(b => b.classList.remove('is-done')); if (!r) return; const used = (r.used || []).filter(u => u.appName), unused = r.unused || []; const total = used.length + unused.length; const nm = state.babyName || '宝宝'; $('#skNum').textContent = used.length; $('#skArc').style.strokeDasharray = (total ? used.length / total * 113.1 : 0).toFixed(1) + ' 113.1'; $('#skLine').textContent = used.length ? '今天已经记了 ' + used.length + ' 项' : '今天还没有记录'; /* 宫格里今天用过的工具打个小勾(代替原来那排「已记」小胶囊) */ const usedIds = new Set(used.map(u => u.identifier).filter(Boolean)); $$('#appsGrid .app').forEach(b => { if (usedIds.has(decodeURIComponent(b.dataset.id || ''))) b.classList.add('is-done'); }); /* 下一件:第一个还没用、且有链接的工具 */ const done = $('#skDone'); let nx = null; for (const u of unused) { if (stageLocked(stageGateOf(u.url, u.identifier))) continue; // 孕周没到的不当「下一件」推 const ident = u.identifier || '', app = ident ? APP_BY_ID[ident] : null; const href = u.url || (app && app.url) || '', name = u.appName || (app && app.name) || ''; if (href && name) { nx = { href, name, id: (app && app.identifier) || ident }; break; } } if (nx) { done.textContent = '下一件:' + todoLine(nx.name)[0]; go.classList.remove('hide'); go.onclick = () => jumpApp(nx.href, nx.id); } else done.textContent = used.length ? '今天要记的都记好啦' : '记下一笔,就是' + nm + '今天的第一只小脚丫'; }; /* 加载中的中性占位(不是假数据):与 index.html 里那条首屏占位同构 */ function pendingFootprintStep() { const d = document.createElement('div'); d.className = 'step'; const time = document.createElement('span'); time.className = 'st-time'; time.textContent = '今天'; const dot = document.createElement('span'); dot.className = 'st-dot'; const txt = document.createElement('span'); txt.className = 'st-txt'; txt.textContent = '正在获取今天的脚印…'; dot.innerHTML = FP_FOOT; const card = document.createElement('span'); card.className = 'st-card'; card.append(txt); d.append(dot, card); return d; } function applyRemoteFootprint() { if (_footprintApplied) return; // init 只跑一次,防重复 fetch _footprintApplied = true; fpRender(); // 首屏先出「加载中」中性占位(不是假数据) const openid = hashParam('openid'); if (!openid) return; // 访客态: pregEmpty 分支已隐藏,保持 const qs = ['openid=' + encodeURIComponent(openid)]; const unionid = hashParam('unionid'); if (unionid) qs.push('unionid=' + encodeURIComponent(unionid)); fetch('/api/mp/public/mama-box/footprint/today?' + qs.join('&')) .then(r => (r.ok ? r.json() : null)) .then(j => { const r = (j && j.code === 200 && j.result) ? j.result : null; if (!r) { fpApply('hide'); return; } // 读不到 → 不显示(不再编数据) if (r.degraded) { fpApply('hide'); return; } // 上游故障且无旧值 → 不显示 /* 判据是 `used`(当天用没用过),**不是** `results`(有没有留下记录)—— 接口方 2026-09-23 明确:lead 只在 used 为空时才有值;used 非空但没 results 时应显示应用名列表。 所以:used 空 → 引导(上游查无此人 linked=false 时也是这个分支); used 非空 → 时间轴(里面自带"用过但没记录"的行,见 fpRender)。 */ if ((r.used || []).length) { fpApply('rows', r); return; } fpApply('guide', r); }) .catch(function () { fpApply('hide'); }); // 读不到 → 不显示 } /* 一行脚印:与 index.html 演示行同构(时间/圆点/文案/来源)。 文案里的数字段加粗对齐演示排版(「数胎动 2 次」), 全程 createElement+textContent,上游字段再离谱也进不来标签。 */ function buildFootprintStep(e) { const d = document.createElement('div'); d.className = 'step'; const time = document.createElement('span'); time.className = 'st-time'; time.textContent = e.at || '--:--'; const dot = document.createElement('span'); dot.className = 'st-dot'; const txt = document.createElement('span'); txt.className = 'st-txt'; const m = String(e.label || '').match(/^(.*?)(\d[\d.]*)(.*)$/); if (m) { if (m[1]) txt.append(m[1]); const b = document.createElement('b'); b.textContent = m[2]; txt.append(b); if (m[3]) txt.append(m[3]); } else { txt.textContent = String(e.label || ''); } const from = document.createElement('span'); from.className = 'st-from'; from.textContent = e.app || ''; /* 2026-10-01 脚印改版:圆点里放一只小脚丫(左右交替靠 CSS :nth-child 翻转), 文案 + 时间 + 来源包进一张小卡 .st-card。SVG 是写死的常量,不含上游字段。 */ dot.innerHTML = FP_FOOT; const card = document.createElement('span'); card.className = 'st-card'; const meta = document.createElement('span'); meta.className = 'st-meta'; meta.append(time, from); if (e.detail) { const dt = document.createElement('span'); dt.className = 'st-det'; dt.textContent = e.detail; meta.append(dt); } card.append(txt, meta); d.append(dot, card); return d; } const FP_FOOT = ''; /* 无足迹占位行:同构的一行,读屏和眼睛都不会把空卡当成坏卡。 */ /* ---------- 进商城(web-view 内跳商城原生页) ---------- 兜爸心选 H5 由小程序 web-view 承载;点「商城」跳回商城原生页。 需微信 JS-SDK 的 wx.miniProgram.switchTab —— tabBar 页必须用 switchTab (详见 docs/mama-box-api-plan.md 2.4)。 落点 260918 起改为「商城」tab(/pages/index/mall)=原装 CRMEB 装修首页 恢复成的第 5 个 tabBar 页;此前一直落在分类页 /pages/goods_cate/goods_cate (商品列表),按用户要求改成进商城先看首页。 仅在 web-view 环境生效;浏览器独立打开时 wx.miniProgram 不可用,降级为 toast。 */ function goMall() { haptic(); /* 2026-10-26:统一委托共享库(小程序 wx.miniProgram / App uni.switchTab 都由它分流)。 下面的旧实现保留作兜底(极端情况下 wx-auth.js 未加载)。 */ if (window.wxAuth && window.wxAuth.goMall) { window.wxAuth.goMall('/pages/index/mall'); return; } if (window.wx && window.wx.miniProgram) { /* getEnv 在纯浏览器里回调不会触发(等不到微信桥), 所以加一个超时兜底:800ms 没回就按「非小程序环境」提示。 */ var responded = false; window.wx.miniProgram.getEnv(function (r) { responded = true; if (r && r.miniprogram) { /* fail 兜底:宿主小程序若还是旧版(没有「商城」tab /pages/index/mall), switchTab 会失败且无任何反应 —— 这里给出提示,别让用户点了没动静。 (发布顺序:先发小程序版再加这个 H5 落点;反过来也不会白屏/死寂) */ window.wx.miniProgram.switchTab({ url: '/pages/index/mall', fail: function (err) { var m = (err && err.errMsg) || '未知原因'; toast('打开商城失败:' + m); try { console.warn('[goMall] switchTab fail:', m); } catch (e) {} } }); } else { toast('请在微信小程序中打开商城'); } }); setTimeout(function () { if (!responded) toast('请在微信小程序中打开商城'); }, 800); } else { toast('请在微信小程序中打开商城'); } } /* ---------- 兜豆兑换商城入口(2026-10-01) ---------- 两个入口:「我的」页兜豆卡 #meExchCard、首页婴儿房下面那条 #homeExch。 地址/接口由「兜爸商城」侧提供(2026-10-01):正式与预览同一个 URL,后端切生产时 URL 不变。 · 跳转走 jumpApp()(query 带 openid/unionid),另带 return_url —— 商城左上角出返回按钮。 · 应用后台若配了同名应用(EXCH_IDS 里任一 identifier),以后台 URL 为准。 */ const EXCH_IDS = ['doudou-duihuan', 'doudouduihuan', 'duihuan']; /* 2026-10-09 用户:「首页的链接去掉域名,避免跨域」→ 同源相对路径。页面和 /api 都在目录下,mp nginx 反代到 taidong; 兜豆接口(openid-login / summary / drop)因此变成同源请求,不再跨域。 */ const EXCH_URL = '/doudou-duihuan/'; const DOU_API = EXCH_URL + 'api'; /* page:可选,'orders' = 直接弹商城的「兑换记录」(2026-10-05 商城会话 cc4ca879 提供 ?page=orders; 带 return_url 时点 × 直接回首页,不带只关面板留在商城)。 */ function openExchange(page) { let app = null; for (const id of EXCH_IDS) { if (APP_BY_ID[id]) { app = APP_BY_ID[id]; break; } } const base = (app && app.url) || EXCH_URL; const url = base + (base.indexOf('?') >= 0 ? '&' : '?') + (typeof page === 'string' && page ? 'page=' + encodeURIComponent(page) + '&' : '') + 'return_url=' + encodeURIComponent(location.href); jumpApp(url, (app && app.identifier) || 'doudou-duihuan'); } $$('#meExchCard, #homeExch').forEach(function (b) { b.addEventListener('click', function () { openExchange(); }); }); /* 「我的 → 兑换记录」(2026-10-05) */ const _mo = $('#meExchOrders'); if (_mo) _mo.addEventListener('click', function () { openExchange('orders'); }); /* 兜豆余额 + 可兑件数 + 本场热门好物。 · /mall/drop 无需登录:拿商品(热门前 3 显示在「我的」页卡片底部); · 有 openid 时 /auth/openid-login 换 token → /dou/summary 拿 balance; · 可兑 M 件 = stock>0 且 price<=balance 且(opens_at 为空或已到点),口径与商城侧约定一致。 任何一步失败都静默保留默认文案「攒兜豆,好物免费兑」,不影响页面其它部分。 从商城返回(页面重新可见)时再刷一次,兑换后余额能跟上。 */ let douToken = ''; function setExchSub(t) { /* 2026-10-02 首页标题改成「攒兜豆好物免费兑」(同日再改「兜豆兑换实物」,10-06 用户要求去掉「实物」→「兜豆兑换」),首页副标题默认文案用「做孕期任务就能攒兜豆」 */ $$('#meExchSub, #homeExchSub').forEach(function (el) { el.textContent = (el.id === 'homeExchSub' && t === '攒兜豆,好物免费兑') ? '做孕期任务就能攒兜豆' : t; }); } function douImg(g) { return /^https?:/.test(g.img || '') ? g.img : EXCH_URL + String(g.img || '').replace(/^\//, ''); } /* 一件商品的小卡(「我的」页 3 件 / 首页横滑 6 件共用),全用 DOM 拼,商品名不进 innerHTML */ function douItem(g, cls, more) { const it = document.createElement('span'); it.className = cls; const pic = document.createElement('span'); pic.className = 'gi-pic'; const im = document.createElement('img'); im.alt = ''; im.loading = 'lazy'; im.src = douImg(g); pic.appendChild(im); if (more && g.badge) { const bd = document.createElement('i'); bd.className = 'gi-badge'; bd.textContent = g.badge; pic.appendChild(bd); } if (more && g.stock <= 5) { const st = document.createElement('i'); st.className = 'gi-left'; st.textContent = '仅剩 ' + g.stock + ' 件'; pic.appendChild(st); } const nm = document.createElement('span'); nm.className = 'bd-nm'; nm.textContent = g.name || ''; /* 价格和兜豆商城「本场好物」同款:小金币在前 + 千分位数字 + 淡色「兜豆」(2026-10-02 用户要求) */ const pr = document.createElement('b'); pr.className = 'gi-pr'; const coin = document.createElement('i'); coin.className = 'gi-coin'; const num = document.createElement('span'); num.textContent = String(g.price).replace(/\B(?=(\d{3})+$)/g, ','); const unit = document.createElement('small'); unit.textContent = '兜豆'; pr.append(coin, num, unit); it.append(pic, nm, pr); return it; } function renderDrop(d) { const now = d && d.server_time ? new Date(d.server_time).getTime() : Date.now(); const goods = ((d && d.goods) || []).filter(function (g) { return g.stock > 0 && (!g.opens_at || new Date(g.opens_at).getTime() <= now); }).sort(function (x, y) { return (y.heat_30m || 0) - (x.heat_30m || 0); }); /* 「我的」页:本场标题 + 热门前 3 */ const box = $('#meExchDrop'); if (box) { if (!goods.length) box.classList.add('hide'); else { const dr = d.drop || {}; $('#meExchDropT').textContent = (dr.title || '本场好物') + (dr.open && dr.left != null ? ' · 剩 ' + dr.left + ' 件' : ''); const wrap = $('#meExchGoods'); wrap.textContent = ''; goods.slice(0, 3).forEach(function (g) { wrap.appendChild(douItem(g, 'bd-it')); }); box.classList.remove('hide'); } } /* 首页:横滑前 6 件,带「热门」角标和「仅剩 N 件」 */ const row = $('#homeExchGoods'); if (row) { row.textContent = ''; goods.slice(0, 6).forEach(function (g) { row.appendChild(douItem(g, 'ex-it', true)); }); row.classList.toggle('hide', !goods.length); } } const _hxg = $('#homeExchGoods'); if (_hxg) _hxg.addEventListener('click', function (e) { if (e.target.closest('.ex-it')) openExchange(); }); function loadDou() { const oid = hashParam('openid'), uid = hashParam('unionid'); const dropP = fetch(DOU_API + '/mall/drop').then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); dropP.then(renderDrop); if (!oid) return; const tokP = douToken ? Promise.resolve(douToken) : fetch(DOU_API + '/auth/openid-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ openid: oid, unionid: uid || null, app_id: 'doudou-duihuan' }) }).then(function (r) { return r.ok ? r.json() : null; }).then(function (j) { return (douToken = (j && j.token) || ''); }); tokP.then(function (tok) { if (!tok) return null; return fetch(DOU_API + '/dou/summary', { headers: { Authorization: 'Bearer ' + tok } }) .then(function (r) { if (r.status === 401) douToken = ''; return r.ok ? r.json() : null; }); }).then(function (sum) { if (DEMO) sum = Object.assign({}, sum || {}, { balance: 2000 }); // 示例模式:兜豆默认 2000 if (!sum || typeof sum.balance !== 'number') return; /* 「兑换记录」角标:待发货笔数(orders_pending,商城 2026-10-05 加的字段;0 / 没这个字段就不显示) */ const _ov = $('#meOrdersV'); if (_ov) _ov.textContent = sum.orders_pending > 0 ? sum.orders_pending + ' 待发货' : ''; return dropP.then(function (d) { const now = d && d.server_time ? new Date(d.server_time).getTime() : Date.now(); const n = ((d && d.goods) || []).filter(function (g) { return g.stock > 0 && g.price <= sum.balance && (!g.opens_at || new Date(g.opens_at).getTime() <= now); }).length; /* 0 兜豆时「兜豆 0 · 可兑 0 件」读着像在劝退 —— 保留引导文案;有豆但一件都不够时给句鼓励 */ if (!sum.balance) setExchSub('攒兜豆,好物免费兑'); else if (!d) setExchSub('兜豆 ' + sum.balance); else setExchSub('兜豆 ' + sum.balance + (n ? ' · 可兑 ' + n + ' 件' : ' · 再攒攒就能兑')); /* 「我的」页:余额放右侧大号数字(2026-10-11 用户),副标题只说能兑几件 */ const bal = $('#meDouBal'); if (bal) { $('#meDouN').textContent = String(sum.balance).replace(/\B(?=(\d{3})+$)/g, ','); bal.classList.remove('hide'); const go = $('#meExchGo'); if (go) go.classList.add('hide'); const sub = $('#meExchSub'); if (sub) sub.textContent = !sum.balance ? '做孕期任务就能攒兜豆' : !d ? '去看看能兑什么' : n ? '现在可兑 ' + n + ' 件好物' : '再攒攒就能兑好物'; } }); }).catch(function () {}); } loadDou(); /* ---------- 新人欢迎礼(2026-10-10 用户定,「首页 / 小程序后台」会话 375797cf 提供账本口径) ---------- 进首页就调统一兜豆账本:/doudou-ledger/api/auth/login {app:'box-app', openid, unionid} 换 token → /me/report {rule:'welcome'}。账本保证每人只发一次: · granted>0 且 duplicate=false → 弹「新人欢迎礼 +N 兜豆」(N 用返回的 granted,不写死),再按账本余额刷新兑换条; · duplicate=true → 什么都不显示;接口失败 → 静默,下次进来再调;没有 openid(匿名)→ 不调。 同源走 mp 反代(/doudou-ledger/ 已加进 nginx 统一反代),不跨域。 */ (function welcomeGift() { const oid = hashParam('openid'), uid = hashParam('unionid'); if (!oid || DEMO) return; const LEDGER = '/doudou-ledger/api'; fetch(LEDGER + '/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: 'box-app', openid: oid, unionid: uid || null }) }).then(function (r) { return r.ok ? r.json() : null; }).then(function (j) { if (!j || !j.token) return null; return fetch(LEDGER + '/me/report', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + j.token }, body: JSON.stringify({ rule: 'welcome' }) }).then(function (r) { return r.ok ? r.json() : null; }); }).then(function (res) { if (!res || res.duplicate || !(res.granted > 0)) return; loadDou(); // 余额以账本为准:领完立刻重拉,不在前端加减 const show = function () { $('#wgN').textContent = res.granted; /* 进页面时可能已经有别的弹窗(如完善资料引导)开着 → 不抢,改成一条 toast */ if (openedModal) toast('新人欢迎礼 +' + res.granted + ' 兜豆,已到账'); else openModal('modalWelcome'); }; setTimeout(show, 900); }).catch(function () {}); })(); $('#wgGo').addEventListener('click', function () { closeModal(); openExchange(); }); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') loadDou(); }); /* 商城入口:「我的」页服务卡与首页商城主卡。 ⚠️ 2026-09-19:底 tab #tabMall 已经改回「宝宝来信」(见下面的 #tabLetter 绑定), 不再走 goMall —— 所以这里只留两颗真正的商城入口。 */ $$('#btnMall, #meMallCard').forEach(function (b) { b.addEventListener('click', goMall); }); /* ---------- 宝宝来信(第三方 H5)入口 ---------- 底 tab「宝宝来信」(#tabLetter) → 跳 mp.yizhezhe.com/laixin/youju/(2026-10-02 起新版)。 ⚠️ 2026-09-19(9.19 需求第 2 条,D2=C):这枚 tab 原来跳的是小程序原生商城页 (goMall),现在改跳**第三方宝宝来信 H5**。落点不是硬编码,而是走 App 表: 用户在应用后台新增了「宝宝来信」应用(identifier=laixin,URL 由 /api/mp/apps 下发),所以这里复用 jumpApp() —— 与宫格 / 工具卡跳其它 H5 应用 完全一致(location.href 同页跳转 + query 带 openid/unionid + 上报分享地址)。 ⚠️ 拿不到 App 表记录时的兜底写死业务域名入口:mp.yizhezhe.com/laixin/(nginx 反代 到 taidong.douba.top/laixin/)。两边都没有就只能提示,别静默什么都不做。 ⚠️ 这枚 tab **不挂 data-tab**,所以它不在 tabs 集合里、不参与 go() 高亮切换 —— 跳过去就离开本页了,没有高亮态可言。 */ /* 2026-10-02 「宝宝来信(新)」会话(369ba3a9)要求换新版:/laixin/ → /laixin/youju/(登录仍是 app_id=laixin;别用 /laixin/youju/demo/,那是示例数据)。 当前 App 表里没有已上架的 laixin 记录,实际走的就是这条兜底。 */ const LAIXIN_FALLBACK_URL = 'https://mp.yizhezhe.com/laixin/youju/'; function openLetterApp() { const app = APP_BY_ID['laixin']; const url = (app && app.url) ? app.url : LAIXIN_FALLBACK_URL; if (!url) { toast('「宝宝来信」即将上线'); return; } jumpApp(url, (app && app.identifier) || 'laixin'); } $('#tabLetter').addEventListener('click', openLetterApp); /* 首页那张「宝宝来信」卡与底 tab 是**同一个入口**(2026-09-20): 卡的数据源已经是 laixin,点它当然也该进 laixin —— 以前它打开的是内置来信详情, 那会「卡上写着有 2 封新信、点进去是空的」(两套数据)。 */ $('#homeLetter').addEventListener('click', openLetterApp); /* ---------- 底 tab 红点 + 首页来信卡:宝宝来信未读数(唯一 owner) ---------- 数据源:box_app 封装的联动接口 GET /api/mp/public/laixin/unread (box_app 后端再去调第三方 https://taidong.douba.top/laixin/api/laixin/unread)。 ⚠️ 路径按应用命名空间走 /api/mp/public//<资源>(app 段 = App 表 identifier), 不要塞回 /mama-box/ 命名空间 —— 那里是妈妈盒子自己的接口。规则见 box_app/docs/api-naming-convention.md。 ⚠️ 为什么不直连 taidong:那台的 CORS 白名单只放了自己的域名,页面直连会被浏览器拦; 而且「mama-box ↔ laixin 的联动接口由 box_app 封装」是本项目的既定边界 (见 docs/mama-box-change-list-20260919.md §1.5)。 ⚠️ 口径:#tabBadge 与首页「宝宝来信」卡的 owner **只有这一个函数**(改名自 syncLaixinBadge)。 别在 applyRemoteProfile() 里再写它 —— 两处都写会互相覆盖(谁后跑谁说了算)。 ⚠️ 容错:接口返回 ok=false(上游抖动)时**保持现状** —— 既不把已经点亮的红点抹掉, 也不因为一次失败就冒出一个红点。角标不是关键路径,失败一律静默。 */ function syncLaixinReminder() { const openid = hashParam('openid'); /* 接口按 openid 查,没有身份就别问(浏览器直开调试时常见) —— 清掉上次结果后交给 renderLetterReminder() 统一收起(它也会因 pregEmpty 收)。 */ if (!openid) { _laixinUnread = null; renderLetterReminder(); return; } const unionid = hashParam('unionid'); const qs = 'openid=' + encodeURIComponent(openid) + (unionid ? '&unionid=' + encodeURIComponent(unionid) : ''); fetch('/api/mp/public/laixin/unread?' + qs) .then(r => (r.ok ? r.json() : null)) .then(j => { const r = j && j.result; if (!r || r.ok === false) return; // 降级:保持现状(红点与卡都不动) _laixinUnread = r; // 存下来:建档状态变化时要靠它重算 renderLetterReminder(); }) .catch(() => { /* 静默 */ }); } /* 从「宝宝来信」H5 返回、或小程序 onShow 把 web-view 唤回前台时刷一次 (上游未读数是「在来信页读完就归零」,所以回来这一下必须重新拉)。 将来第三方上 SSE 后,这里就是它的降级路径(见 docs/laixin-sse-requirement.md)。 */ document.addEventListener('visibilitychange', function () { if (!document.hidden) syncLaixinReminder(); }); /* 2026-10-05 用户:「来信里有信了就该提醒,不用等我点进来信页」→ 停在首页时也每 60 秒拉一次未读数, 信按时间慢慢送达时,tab 圈数字会自己冒出来(页面在后台 / 不在前台时不拉,省流量)。 ⚠️ 前提是上游 unread 到点就算数;如果上游要等打开来信页才投递,这里拉多少次也是 0(已找「宝宝来信(新)」会话确认)。 */ setInterval(function () { if (!document.hidden) syncLaixinReminder(); }, 60000); /* 从来信页「返回」时浏览器可能直接从往返缓存(bfcache)恢复本页,不一定触发 visibilitychange → 补一个 pageshow */ addEventListener('pageshow', function (e) { if (e.persisted) syncLaixinReminder(); }); /* 「授权完善资料」:H5 → navigateTo → 小程序端独立授权页(pages/annex/auth/index)。 说明:微信 web-view 的 postMessage 非实时送达(只在分享/后退/onHide 等时机 才派发 bindmessage),点按钮时端上收不到 → 改用 wx.miniProgram.navigateTo 实时跳转到授权页,避开该限制。 */ function goAuth() { haptic(); /* 2026-10-26:统一委托共享库 —— 小程序走授权页,App 走「消息 → 宿主直接回传资料」。 旧实现保留兜底。 */ if (window.wxAuth && window.wxAuth.requestProfile) { window.wxAuth.requestProfile(); return; } /* 与 goMall 同一套环境判定:wx.miniProgram 存在还要 getEnv 确认(纯浏览器里 jweixin 也可能已加载,getEnv 不回调 → 800ms 超时兜底按非小程序提示) */ if (window.wx && window.wx.miniProgram) { var responded = false; window.wx.miniProgram.getEnv(function (r) { responded = true; if (r && r.miniprogram) { /* 带上 backUrl(当前地址)→ 授权页写进 __AUTH_RESULT__ → 宿主优先按它 回传:从哪个 H5 触发的授权就回到哪个 H5(不赌 postMessage 派发时机)。 */ var u = '/pages/annex/auth/index'; try { u += '?backUrl=' + encodeURIComponent(location.href); } catch (e) {} window.wx.miniProgram.navigateTo({ url: u }); } else { toast('请在小程序中完成授权登录'); } }); setTimeout(function () { if (!responded) toast('请在小程序中完成授权登录'); }, 800); } else { toast('请在小程序中完成授权登录'); } } /* 11-02 「我的」→ 我的收藏:进孕妈广场的收藏层(guangchang/?fav=1),条数读本机 mmbox.gc.v1[openid].fav */ (function () { const card = $('#meFavCard'); if (!card) return; const cnt = () => { try { const a = JSON.parse(localStorage.getItem('mmbox.gc.v1') || '{}'), me = a[hashParam('openid') || '_guest'] || {}; const k = Object.keys(me.fav || {}).length; $('#meFavN').textContent = k ? '已收藏 ' + k + ' 篇' : '孕妈广场里收藏的帖子'; } catch (e) {} }; cnt(); window.addEventListener('pageshow', cnt); card.addEventListener('click', () => { haptic(); const url = 'guangchang/?fav=1' + location.hash; reportHostLocation(new URL(url, location.href).href); location.href = url; }); })(); var meAuth = $('#meAuthCard'); if (meAuth) meAuth.addEventListener('click', goAuth); /* 方案A:未授权时点「我的」页顶部资料卡 → 直接去授权,不打开资料弹窗(捕获阶段拦在 data-modal 之前) */ var meCard = $('.me-card'); if (meCard) meCard.addEventListener('click', function (e) { if (state.authorized !== false) return; if (e.target.closest && e.target.closest('#meLogoutCard')) return; e.preventDefault(); e.stopImmediatePropagation(); goAuth(); }, true); var pfUnauth = $('#pfUnauth'); if (pfUnauth) pfUnauth.addEventListener('click', function () { closeModal(); goAuth(); }); /* 「退出登录」(个人资料卡右上角图标按钮): 调后端 /api/mp/public/auth/logout 只清「授权登录信息」三字段(昵称/头像/手机号), openid/unionid(小程序匿名身份)保留,孕育资料(小名/性别/预产期)保留。 成功后重载页面 → applyRemoteProfile 重新拉到空显示字段 → 回到未登录态、授权入口重现。 ⚠️ stopPropagation:me-card 整卡是 data-modal="modalProfile",不阻止冒泡 会顺带打开资料弹窗。 */ /* 2026-10-10 用户:「我的」页和「我的资料」弹窗里都要有「退出登录」→ 两个按钮共用 doLogout */ /* 2026-10-10 用户:退出前弹确认框(#modalLogout),确认后才真正调接口 */ function doLogout(e) { e.preventDefault(); e.stopPropagation(); openModal('modalLogout'); } function confirmLogout() { closeModal(); var openid = hashParam('openid'); if (!openid) { toast('未获得身份,无法退出'); return; } fetch('/api/mp/public/auth/logout?openid=' + encodeURIComponent(openid), { method: 'POST' }) .then(function (r) { return r.json(); }) .then(function (res) { if (res.code === 200) { toast('已退出登录'); setTimeout(bustReload, 600); } else { toast(res.message || '退出失败,请重试'); } }) .catch(function () { toast('退出失败,请检查网络'); }); } ['#meLogoutCard', '#pfLogout'].forEach(function (sel) { var b = $(sel); if (b) b.addEventListener('click', doLogout); }); var lgOk = $('#lgOk'); if (lgOk) lgOk.addEventListener('click', confirmLogout); /* 从商城/后台切回小程序首页时,webview 会再次可见 —— 把 H5 重置回「首页」屏。 ⚠️ 若不处理:切到商城再点「首页」tab,仍是同一个 webview 实例, SPA 还停留在切走前的屏(如「我的」),与"回到首页"的预期不符。 只在"当前不是首页"时才切,避免打断用户在首页上的浏览/滚动。 */ function resetScreenOnShow() { var cur = document.querySelector('.screen.is-active'); if (cur && cur.dataset.screen && cur.dataset.screen !== 'home') go('home'); } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') resetScreenOnShow(); }); window.addEventListener('pageshow', function (e) { /* bfcache 恢复(persisted=true)= 用户「返回」回到了本页 → 清掉 last_app(见 clearLastAppOnBackHome) */ clearLastAppOnBackHome(!!(e && e.persisted)); if (document.visibilityState !== 'hidden') resetScreenOnShow(); }); /* 首页头图里会动的宝宝:2026-10-04 起按孕周换图,整段移到 js/hero-fx.js */ if (document.readyState === 'complete') init(); else window.addEventListener('load', init); })();