Files
mama-box/js/app.js
deploy dc54a29748 v26110903 广场/专题/计算器/测一测在小程序里也开容器页,复制链接直达;容器页返回走 navigateBack
新增 js/host-nav.js(MBHost.open/back/home + 页内同站 <a> 拦截):
- 首页进广场、专题问题、百宝箱测一测/计算器 → 容器页;广场点笔记 → 容器页 ?n=
- 容器页(_hv=1)里笔记关闭、广场/专题/计算器返回 → navigateBack;广场「返回」tab → switchTab 首页
- 失败或不在小程序照旧本页跳转

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:10:54 +00:00

6993 lines
435 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ================= 兜爸心选 H5 · 交互 ================= */
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26110902';
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,数组顺序即收藏顺序
/* 2026-11-08(测试会话 150e0991):原为演示名「小汤圆」。资料还没回来时打开「我的资料」点保存,
会把它当真名 PUT 进库(实测 body.babyName='小汤圆')。初值留空,称呼走 babyWord() 的「宝宝」兜底。 */
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 });
}
/* 小程序里进应用:开一个小程序容器页,不在首页 web-view 里换地址(2026-11-09 用户)。
「在任何一个子页面点小程序『复制链接』,打开应该进到那个子页面,现在都进首页」——
「复制链接」复制的是**小程序当前页面**的路径和参数;首页壳里 location.href 换应用,小程序页面一直是首页壳,
复制出来永远是首页。改为 navigateTo 通用容器 pages/annex/web_view/index?url=<净地址>
(分享卡片落地、冷启动恢复用的同一个页,容器按打开者自己的身份注入),小程序当前页就是那个应用。
⚠️ url 只放净地址:去掉 openid/unionid/__no_identity,复制出去的链接不能带分享人身份。
不在小程序里、navigateTo 失败(页面栈满 10 层等)→ 返回 false / 走 fail,照旧 location.href。 */
const HOST_VIEW = '/pages/annex/web_view/index';
let hostNavAt = 0;
function inMiniProgram() {
return window.__wxjs_environment === 'miniprogram' || /miniProgram/i.test(navigator.userAgent);
}
function hostCleanUrl(url) {
const u = new URL(url, location.href);
/* return_url 也去掉:它是首页地址、hash 里带身份;没有它时应用的返回按规范走 goHome() */
['openid', 'unionid', '__no_identity', 'return_url'].forEach(k => u.searchParams.delete(k));
if (/(^|[#&])(openid|unionid|phone|nickname|avatar)=/.test(u.hash)) u.hash = '';
return u.href;
}
function openInHost(target) {
if (window.MBHost) return window.MBHost.open(target); // js/host-nav.js(加 _hv=1,子页面返回走 navigateBack)
const mp = window.wx && window.wx.miniProgram;
if (!inMiniProgram() || !mp || typeof mp.navigateTo !== 'function') return false;
if (Date.now() - hostNavAt < 1000) return true; // 连点:上一次 navigateTo 还在路上
let clean;
try { clean = hostCleanUrl(target); } catch (e) { return false; }
hostNavAt = Date.now();
mp.navigateTo({
url: HOST_VIEW + '?url=' + encodeURIComponent(clean),
fail: function () { hostNavAt = 0; location.href = target; }
});
return true;
}
/* ================= 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) {} }
}
/* ================= 子页面恢复(2026-11-07 用户)=================
「在孕妈广场、笔记页或任何一个子页面,在小程序上刷新,刷新结束之后它会回到首页」——
小程序刷新 / 重新进入只加载首页;首页仓自己的子页面不在 App 表里,last_app 记不住。
子页面由 js/last-sub.js 把当前地址记在本机 mmbox.lastsub.v1;星空页开着时 whQs 也记一条 ?wh=1。
首页被**宿主**加载(没有同源 referrer、不是后退)且只在小程序里时,回到那一页;
同时有 last_app 时比时间,谁新回谁。用户自己点回首页(同源 referrer / 后退)= 想待在首页 → 清掉记录。
返回 true = 已经在跳了,后面的 resumeLastApp 不用再跑。 */
const SUB_KEY = 'mmbox.lastsub.v1';
function homeDir() { return location.pathname.replace(/[^/]*$/, ''); }
function resumeSub() {
try {
if (/[?&](wh|wc|go|demo)=/.test(location.search) || hashParam('norestore')) return false;
/* 11-07 二修:不再看 referrer —— 宿主回前台 / 刷新是在原页面里直接换地址,referrer 也是子页面。
改为:子页面里用户刚点过东西才离开(last-sub.js 记 sessionStorage mmbox.usernav)或后退 = 用户自己回首页 → 清记录;
5 秒内刚把人送回同一页、它又回到首页且不是用户点的(那页自己跳回来)→ 不再恢复,防来回跳。 */
let un = 0, rs = null;
try { un = +sessionStorage.getItem('mmbox.usernav') || 0; rs = JSON.parse(sessionStorage.getItem('mmbox.subback') || 'null'); sessionStorage.removeItem('mmbox.usernav'); } catch (e) {}
let nt = '';
try { const nv = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; nt = nv ? nv.type : ''; } catch (e) {}
const ref = document.referrer || '', fromApp = ref.indexOf(location.origin + '/') === 0 && ref.indexOf(location.origin + homeDir()) !== 0; /* 从别的应用(不在首页仓里)回来:那边没有 usernav 标记,照旧按 referrer 算用户回首页 */
if (Date.now() - un < 8000 || nt === 'back_forward' || fromApp) { localStorage.removeItem(SUB_KEY); return false; }
const inMp = window.__wxjs_environment === 'miniprogram' || /miniProgram/i.test(navigator.userAgent);
if (!inMp) return false;
const sub = JSON.parse(localStorage.getItem(SUB_KEY) || 'null');
if (!sub || !sub.u || sub.h !== homeDir() || sub.u.indexOf(homeDir()) !== 0 || Date.now() - sub.at > 7 * 864e5) return false;
if (rs && rs.u === sub.u && Date.now() - rs.at < 5000) { localStorage.removeItem(SUB_KEY); return false; } /* 5 秒前刚送回这一页、它自己又回了首页:别来回跳 */
const go = function () {
console.log('[resume] 回到上次停的子页面:', sub.u);
try { sessionStorage.setItem('mmbox.subback', JSON.stringify({ u: sub.u, at: Date.now() })); } catch (e) {}
location.replace(withIdentity(sub.u) + location.hash); /* 身份 query + 原 hash 都带上(子页面 hash/query 双读) */
};
const oid = hashParam('openid');
try { sessionStorage.setItem(RESUME_DONE_KEY, '1'); } catch (e) {}
if (!oid) { go(); return true; }
fetch(LAST_APP_API + '?openid=' + encodeURIComponent(oid))
.then(r => (r.ok ? r.json() : null))
.then(d => {
const rec = d && d.result, t = rec && Date.parse(rec.at);
if (rec && rec.url && t > sub.at && urlBase(rec.url) !== urlBase(location.href)) { /* 后来又进了别的应用:回那个应用 */
const target = withIdentity(rec.url);
reportHostLocation(target);
location.replace(target);
} else go();
})
.catch(go);
return true;
} catch (e) { return false; }
}
/* 脚本一加载就判断,不等 init(init 挂在 load 上,要等图片全下完,太晚:用户已经刷新走了、记录还没清;跳转也会先闪首页) */
const SUB_GONE = resumeSub();
/* 首屏恢复:读一次 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;
/* 11-07:转发来的悄悄话卡片(?wh=1 / ?wc=<留言id>)是要直接看星空页的,不能被「恢复上次应用」跳走 */
if (/[?&](wh|wc)=/.test(location.search)) { sessionStorage.setItem(RESUME_DONE_KEY, '1'); 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/<app>/
宿主恢复/返回落点那两次加载都是 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/(孕检日历)。 */
/* 11-03 档案会话(19875b1b)做了预览版 /chengzhang-dangan/preview-v10/(折叠区块 + 底部 tab 栏):只有从 /mama-box-preview/ 进时才去预览版,正式版仍是 v10,合并上正式不用改这行 */
const ARCHIVE_H5 = /\/mama-box-preview\//.test(location.pathname) ? '/chengzhang-dangan/preview-v10/' : '/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);
if (openInHost(u)) return;
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 = rawParam('openid');
const uid = rawParam('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 !== false || 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 ? '<img src="' + ico + '" alt="" width="40" height="40">' : '<b>' + g.name.slice(0, 1) + '</b>';
$('#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: '<path d="M4 12h16a8 8 0 01-16 0z"/><path d="M9 8.5c0-1.5 1-1.8 1-3.2M13 8.5c0-1.5 1-1.8 1-3.2"/>',
bushui: '<path d="M7 4h10l-1.3 15a2 2 0 01-2 1.8h-3.4a2 2 0 01-2-1.8z"/><path d="M7.6 10h8.8"/>',
yun: '<path d="M12 19.5s-7.5-4.6-7.5-10A4.2 4.2 0 0112 7a4.2 4.2 0 017.5 2.5c0 5.4-7.5 10-7.5 10z"/><path d="M6 12h3l1.5-2.5 2 4 1.5-1.5H18"/>',
yunqixuetang: '<path d="M12 3.5s-5.5 6.2-5.5 10.2a5.5 5.5 0 0011 0C17.5 9.7 12 3.5 12 3.5z"/><path d="M9.8 14.2a2.3 2.3 0 002.2 2.1"/>',
daichanqingdan: '<path d="M5.5 9h13l-1 11h-11z"/><path d="M9 9V7a3 3 0 016 0v2"/>',
};
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 '<svg class="sw-art" viewBox="2 2 20 20" aria-hidden="true" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + (SWAP_ART[id] || '') + '</svg>';
}
/* ================= 首页「我的孕期工具」:数据卡 + 图标宫格,用户自选(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', 'yunqixuetang']; /* 11-07 用户:默认 7 个 + 末尾「添加」格,正好两行 */
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 业务域名的绝对地址 → /<slug>/… */
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 = '<i class="tg-ic">' + swapArt(id) + '</i><span class="tg-n"></span><span class="tg-s"></span>';
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 = '<img alt="" aria-hidden="true">'; 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 = '<i class="tg-ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg></i><span class="tg-n">添加</span><span class="tg-s">编辑</span>';
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;
const VT_EMPTY = ['weight', 'glu', 'bp'];
function renderVitals() {
const box = $('#vitals'), more = $('#vtMore');
if (!box) return;
/* 11-03 用户:「没用过孕期工具,记录数据那块先不显示;用了才显示」→ 没真实记录不再出 4 张示例卡,连同上面那行说明一起藏 */
const note = $('.dock-note'), noteShow = v => { if (note) note.style.display = v ? '' : 'none'; };
if (!htVtOn()) { box.classList.add('hide'); noteShow(false); 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 {
/* 11-03 前:没记录时出胎动 / 体重 / 血压 / 血糖 4 张示例数卡;11-03~11-06 没记录整块不出。
11-07 用户:「记录卡默认显示 3 个,只是不显示数据」→ 体重 / 血糖 / 血压 3 张空卡(「--」+「去记一笔」),不分孕周都能记 */
keys = VT_EMPTY.filter(k => its[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 + ' 项数据') + '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 4.5L6 8l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
box.classList.toggle('hide', !vis.length);
noteShow(vis.length > 0);
}
(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);
if (openInHost(target)) return;
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 ? '<img class="a-ico" src="' + ico + '" alt="" loading="lazy">' : '<span class="a-ico a-ico-letter" style="background:' + bg + '">' + name.slice(0, 1) + '</span>';
return '<button class="app ripple" data-ord="' + i + '" data-url="' + encodeURIComponent(a.url) + '" data-id="' + encodeURIComponent(a.identifier || '') + '">' + img + '<span class="a-n">' + name + '</span></button>';
}).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);
/* 真 <button> 上敲回车/空格,浏览器会自动派一个 click;<div role="button"> 不会。
首页那张脚印卡就是这样的 div,手动补上,键盘和读屏才用得了它 */
if (b.tagName !== 'BUTTON') b.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fire(); }
});
});
/* ---------- 已移除模拟状态栏:保留空实现,避免调用点散落判空 ---------- */
function updateStatusbar() {}
function setStatusbar() {}
/* ================= 进度环动画 ================= */
function animateRings(root) {
$$('.ring-fg', root || document).forEach(c => {
const r = c.r.baseVal.value;
const len = 2 * Math.PI * r;
const p = parseFloat(c.dataset.ring || 0.6);
c.style.strokeDasharray = len;
c.style.strokeDashoffset = len;
// 强制重绘后再动画
void c.getBoundingClientRect();
requestAnimationFrame(() => { c.style.strokeDashoffset = len * (1 - p); });
});
}
/* 档案页孕程条:目标值记在 data-pct 上(syncPregnancy 写),
每次进页面归零再长回去,和上面那个环的观感一致。
判空是因为 replayArBar 挂在 go() 里 —— 万一这一块以后又改结构,
没有条也不该把整个切页函数带崩。 */
function setArBar(pct) {
const b = $('#arBar');
if (!b) return;
b.dataset.pct = pct;
b.style.width = pct + '%';
}
function replayArBar() {
const b = $('#arBar');
if (!b) return;
const p = b.dataset.pct || 0;
b.style.width = '0%';
void b.getBoundingClientRect(); // 强制重绘,否则两次赋值会被合并
requestAnimationFrame(() => { b.style.width = p + '%'; });
}
/* ================= 分段控件(档案) ================= */
function initSegmented(segId, thumbId, attr, onChange) {
const seg = $(segId), thumb = $(thumbId);
if (!seg) return;
const btns = $$('.seg-btn', seg);
function move(btn) {
const pad = 5;
thumb.style.width = btn.offsetWidth + 'px';
thumb.style.transform = `translateX(${btn.offsetLeft - pad}px)`;
}
btns.forEach(b => b.addEventListener('click', () => {
btns.forEach(x => x.classList.toggle('is-active', x === b));
move(b);
haptic();
onChange && onChange(b.dataset[attr], b);
}));
const init = () => move($('.seg-btn.is-active', seg));
requestAnimationFrame(init);
window.addEventListener('resize', init);
seg._reflow = init;
}
initSegmented('#archiveSeg', '#segThumb', 'seg', key => {
$$('.seg-panel', $('#screen-archive')).forEach(p =>
p.classList.toggle('is-active', p.dataset.panel === key));
// 重新触发图表动画
if (key === 'data') {
$$('#screen-archive .line, #screen-archive .spark polyline').forEach(el => {
el.style.animation = 'none'; void el.offsetWidth; el.style.animation = '';
});
}
});
/* ================= 筛选 chips ================= */
/* 档案页时光轴和珍贵时刻那两排分类 chips 已经删了(上面刚有一排分段,
紧跟着再来一排长得差不多的标签,反而看不清谁管谁),
所以这里也不再有 timeline / moment 两个分支 —— 只剩搜索页那种「点一下弹个 toast」
的装饰性 chips。tl-item / moment 上的 data-cat 留着没删,将来真要做筛选还用得上。 */
$$('.chips-row').forEach(row => {
/* 资源库那一排 chips 由 renderResList() 单独接管(按 data-rescat 筛选)。
它也带 .chips-row,会被这里重复绑定一次,结果每点一次分类
都会多弹一句「搜索「产检」」 */
if (row.id === 'resChips') return;
const chips = $$('.chip', row);
chips.forEach(c => c.addEventListener('click', () => {
chips.forEach(x => x.classList.toggle('is-active', x === c));
haptic();
toast('搜索「' + c.textContent.trim() + '」');
}));
});
/* ================= 珍贵时刻收藏 ================= */
/* ⚠️ 计数不能写死(原来 #favCount=18、state.favCount=18,而屏幕上点亮的星标只有 6 个,
数字与内容对不上)。启动时按 DOM 里 `.star.is-on` 的**实际个数**回填,
点星标时再 ±1 —— 这样标签上的数永远等于卡上看得见的收藏。
(档案页不是首屏,用户看不到"先 0 再回填"的中间态。) */
state.favCount = $$('.star.is-on').length;
(function () { const fc = $('#favCount'); if (fc) fc.textContent = state.favCount; })();
$$('.star').forEach(st => st.addEventListener('click', e => {
e.stopPropagation();
const on = st.classList.toggle('is-on');
state.favCount += on ? 1 : -1;
const fc = $('#favCount');
if (fc) { fc.textContent = state.favCount; bump(fc); }
toast(on ? '已收藏到珍贵时刻 ★' : '已取消收藏');
haptic();
}));
function bump(el) { el.classList.remove('bump'); void el.offsetWidth; el.classList.add('bump'); }
/* ================= 记一下 · 底部弹层 ================= */
const sheet = $('#sheet'), scrim = $('#scrim'), phoneEl = $('#phone');
/* ⚠️ .phone 上的 sheet-open 必须和 sheet 自己的 show **成对**切,不是冗余状态。
「记一下」圆钮展开时转 45°,原来靠 `.sheet.show ~ .tabbar .fab` 实现。
通用兄弟选择器只能向**后**选,而本批把 <nav class="tabbar"> 移到了 .phone
的最前面(让底部 tab 尽早渲染,理由见 index.html 里 nav 上方那段),
nav 排到 .sheet 前面之后那条规则永远不再命中,圆钮会直接不转 —— 而且
这种「动画没了」的回归 jsdom 根本量不到,只能靠这段注释和下面那条断言钉住。
⚠️ 别图省事删掉这两行改回兄弟选择器,除非你把 nav 也挪回文件末尾。 */
let sheetPaint = null; // 「记一下」今天卡 + 先记这件(定义在今日脚印那段,依赖 _fpData / todoLine)
function openSheet() {
if (sheetPaint) try { sheetPaint(); } catch (e) { /* 只是装饰层,出错不挡弹窗 */ }
scrim.classList.add('show');
sheet.classList.add('show');
phoneEl.classList.add('sheet-open');
setStatusbar('light');
haptic();
}
function closeSheet() {
scrim.classList.remove('show');
sheet.classList.remove('show');
phoneEl.classList.remove('sheet-open');
sheet.style.transform = '';
setStatusbar('auto');
}
$$('[data-sheet="open"]').forEach(b => b.addEventListener('click', openSheet));
/* 「今日脚印」右上角掀角里的祈福树 → 宝宝祈福树(2026-10-05)。stopPropagation:卡片本身点了会跳档案。走业务域名反代 /qifu-shu/,jumpApp 负责带身份和上报。 */
/* 夜晚模式开关(2026-10-05):<html class="night"> + localStorage 'mmbox.night.v1',样式全在 night.css。
首屏的 class 由 index.html <head> 的内联脚本先加好,这里只管点击切换和按钮状态。 */
const NIGHT_KEY = 'mmbox.night.v1';
const nb = $('#nightBtn');
const syncNightBtn = () => {
const on = document.documentElement.classList.contains('night');
if (nb) { nb.setAttribute('aria-pressed', on ? 'true' : 'false'); nb.setAttribute('aria-label', on ? '切换到白天模式' : '切换到夜晚模式'); }
const tc = document.querySelector('meta[name=theme-color]'); if (tc) tc.content = on ? '#2A2340' : '#F4593C';
};
syncNightBtn();
if (nb) nb.addEventListener('click', e => {
e.stopPropagation();
const root = document.documentElement;
root.classList.add('nt-anim');
const on = root.classList.toggle('night');
try { localStorage.setItem(NIGHT_KEY, on ? '1' : '0'); } catch (err) {}
syncNightBtn();
setTimeout(() => root.classList.remove('nt-anim'), 450);
});
const QIFU_URL = location.origin + '/qifu-shu/';
const fq = $('#fpQifu');
if (fq) fq.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); jumpApp(QIFU_URL); });
$$('[data-sheet="close"]').forEach(b => b.addEventListener('click', closeSheet));
/* 11-03 档案页(/chengzhang-dangan/)底部也放首页这排 tab:那边点「我的」「记一下」回到首页并直达 —— 带 ?go=me / ?go=rec 进来。
用完就从地址里去掉,刷新不会重复弹。 */
{
const sp = new URLSearchParams(location.search), gv = sp.get('go');
if (gv === 'me' || gv === 'rec') {
sp.delete('go');
const qs = sp.toString();
try { history.replaceState(history.state, '', location.pathname + (qs ? '?' + qs : '') + location.hash); } catch (e) {}
window.__mbDeepAt = Date.now(); /* 首次 pageshow 会把屏重置回首页(resetScreenOnShow),刚直达的这一下要放过 */
requestAnimationFrame(() => { if (gv === 'me') go('me'); else openSheet(); });
}
}
scrim.addEventListener('click', closeSheet);
/* 下拉关闭 */
(function drag() {
const grip = $('#sheetGrip');
let startY = 0, dy = 0, dragging = false;
grip.addEventListener('pointerdown', e => {
dragging = true; startY = e.clientY; dy = 0;
sheet.classList.add('dragging');
grip.setPointerCapture(e.pointerId);
});
grip.addEventListener('pointermove', e => {
if (!dragging) return;
dy = Math.max(0, e.clientY - startY);
sheet.style.transform = `translateY(${dy}px)`;
scrim.style.opacity = Math.max(0, 1 - dy / 400);
});
const end = () => {
if (!dragging) return;
dragging = false;
sheet.classList.remove('dragging');
scrim.style.opacity = '';
if (dy > 110) closeSheet(); else sheet.style.transform = '';
};
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
})();
/* 快捷:数胎动计数 */
$('#quickKick').addEventListener('click', () => {
state.kickQuick++; state.kickToday++; state.todayCount++;
const a = $('#kickCount'), b = $('#kickTodayHome'), c = $('#todayCount');
a.textContent = state.kickQuick; bump(a.parentElement);
/* #kickTodayHome 原在首页数胎动演示卡上(已换成应用宫格),守卫判空 */
if (b) { b.textContent = state.kickToday; bump(b.parentElement); }
c.textContent = state.todayCount;
toast('胎动 +1,今天已记录 ' + state.kickQuick + ' 次');
haptic();
});
/* 工具列表去重:顶部「今天已使用」的快捷入口若在下方列表里重复出现,
用户会觉得同一个工具出现了两次,这里按名称把它们从全部列表中摘掉 */
(function dedupeApps() {
/* ⚠️ 快捷区「今天已使用」功能未实现、已临时隐藏(index.html 里挂 .tbd-hide)。
没有置顶区就不存在「同一个工具出现两次」,所以两件事都要跳过:
① 按 .q-t 摘除同名磁贴;② 把小标题改成「其他孕期工具」——藏了上面那块之后,
第一眼看到的就是它,「其他」俩字会变成没有指代对象的错文案,
保持 index.html 里的默认「全部孕期工具」才对。
判据直接读 DOM(.tbd-hide 在不在),单一事实来源在 index.html:
把那里三处 .tbd-hide 去掉,本段会自动重新生效,不用改这里。 */
if ($('.quick-row.tbd-hide')) return;
const used = new Set($$('.quick-row .q-t').map(e => e.textContent.trim()));
$$('#appsGrid .app').forEach(b => { if (used.has(b.textContent.trim())) b.remove(); });
const rest = $$('#appsGrid .app').length;
const head = $('.apps-head.for-all');
if (head) head.firstChild.nodeValue = '其他孕期工具 ';
const more = $('.apps-head.for-all .sec-more');
if (more) {
more.textContent = '还有 ' + rest + ' 个 ›';
more.dataset.toast = '共 ' + (rest + used.size) + ' 个孕期工具,已使用的 ' + used.size + ' 个已置顶';
}
})();
/* 工具宫格 */
$$('#appsGrid .app').forEach(a => a.addEventListener('click', () => {
const name = a.textContent.trim();
if (name === '更多工具') { toast('已展示全部孕期工具'); return; }
toast('打开「' + name + '」');
haptic();
}));
/* 首页宫格入口。
五个坑位现在**全部**指向已上架应用(都由 App 表下发 URL),没有占位 toast 了;
某张卡的 identifier 在 App 表里查不到 / 没配 URL 时,才退回「即将上线」提示。
跳转用 location.href 走**当前标签页**:这个壳是照着原生 App 做的,
新开标签会在微信 / Safari 里堆出一串返回不去的页面;同页跳转的话,
浏览器返回键就是天然的「返回」。 */
/* 首页五张工具卡 → 已上架应用(identifier 在应用管理维护,后台改 URL 即生效)。
⚠️ 不再裸跳旧 beian*.douba.top 域名:统一走 jumpApp 带身份(openid/unionid)。
⚠️ App 表里的 URL 一律用业务域名 mp.yizhezhe.com/<slug>/(nginx 反代到源站)——
小程序 web-view 只放行业务域名,写 taidong.douba.top 直连会被拦。
第九批(2026-09-16):curve 从「生长曲线 taiershengzhang」改成「数宫缩 gongsuopinlv」。 */
const MINI_APP_MAP = {
kick: 'taidong', // 数胎动 (1005)
weight: 'yunqitizhong', // 孕期体重管理 (1012)
cal: 'chanjianrili', // 孕检日历 (1009)
curve: 'gongsuopinlv', // 数宫缩(原「生长曲线」,第九批换坑位)
fweight:'taierguzhong', // 胎儿估重 (1037)
bp: 'yun', // 孕期血压(2026-10-31 宫格改版加入)
glu: 'yunqixuetang' // 孕期血糖(同上)
};
const APP_BY_ID = {};
$$('[data-mini]').forEach(b => b.addEventListener('click', () => {
const map = { kick: '数胎动', weight: '孕期体重', cal: '孕检日历', curve: '数宫缩', fweight: '胎儿估重', bp: '孕期血压', glu: '孕期血糖' };
const sw = b.dataset.swap; /* 孕周没到、坑位换成了替补(见 swapHomeCards) */
const app = APP_BY_ID[sw || MINI_APP_MAP[b.dataset.mini]];
if (app && app.url) { jumpApp(app.url, app.identifier); return; }
toast('「' + (sw ? (SWAP_POOL.find(x => x.id === sw) || {}).name : map[b.dataset.mini]) + '」即将上线');
}));
renderHomeTools(); /* 首屏就按用户自选 / 默认顺序排好(应用列表、孕周、卡片数据回来后会再排) */
/* ================= 编辑首页工具(#htSheet,2026-11-01) =================
· 上半「首页显示的工具」:长按整行 300ms 或按住右侧把手拖动排序;↑↓ 兜底;「−」移除(至少留 HT_MIN 个)。
· 下半「更多孕期工具」:APP_BY_ID 里没放上首页的孕期工具,点「+」加到末尾(最多 HT_MAX 个)。
· 关弹层(完成 / 叉 / 遮罩)即保存,首页立即重排;「恢复默认」= 删 HT_KEY,回到默认顺序 + 孕周替补。
· 拖动用 pointer 事件 + 列表上非被动 touchmove 拦滚动(长按触发后第一下 touchmove 就拦,浏览器来不及接管成滚动)。 */
(function htEditor() {
const sh = $('#htSheet'), scr = $('#htScrim'), list = $('#htsList'), more = $('#htsMore'), box = $('#htsScroll');
const openBtn = $('#htEdit');
if (!sh || !list || !openBtn) return;
let draft = [], dirty = false, resetAsked = false, hd = null;
const ICO = {
rm: '<svg viewBox="0 0 12 12"><path d="M2.5 6h7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
up: '<svg viewBox="0 0 16 16"><path d="M4 10l4-4 4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
dn: '<svg viewBox="0 0 16 16"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
hd: '<svg viewBox="0 0 20 20"><path d="M4 6.5h12M4 10h12M4 13.5h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
};
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const icoHtml = f => f.logo ? '<img class="hts-ic" src="' + esc(f.logo) + '" alt="" aria-hidden="true">' : '<span class="hts-ic ph" aria-hidden="true">' + esc((f.short || f.name || '工').slice(0, 1)) + '</span>';
function pool() { /* 能加的全部孕期工具:App 表顺序;列表没回来时用缓存 + 7 个老格子 */
const A = htApps();
const ids = (A && Object.keys(A).length) ? Object.keys(A).filter(id => A[id].url) : Object.keys(htMeta);
HT_DEFAULT.forEach(id => { if (ids.indexOf(id) < 0 && !htAppsLoaded) ids.push(id); });
return ids;
}
function render() {
list.innerHTML = draft.map((id, i) => {
const f = htInfo(id), g = STAGE_GATE.find(x => x.id === id), lk = stageLocked(g);
const nm = f.name || f.short || id;
return '<li class="hts-row" data-id="' + esc(id) + '">' +
'<button type="button" class="hts-rm" data-act="rm" aria-label="从首页移除' + esc(nm) + '">' + ICO.rm + '</button>' +
icoHtml(f) +
'<span class="hts-n"><i>' + esc(nm) + '</i>' + (lk ? '<em class="hts-tag lk">孕' + g.wk + '周开放</em>' : '') + '</span>' +
'<button type="button" class="hts-mv" data-act="up" aria-label="上移"' + (i === 0 ? ' disabled' : '') + '>' + ICO.up + '</button>' +
'<button type="button" class="hts-mv" data-act="dn" aria-label="下移"' + (i === draft.length - 1 ? ' disabled' : '') + '>' + ICO.dn + '</button>' +
'<span class="hts-hd" aria-hidden="true">' + ICO.hd + '</span></li>';
}).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 '<button type="button" class="hts-add' + (full ? ' is-full' : '') + '" data-id="' + esc(id) + '" aria-label="加到首页:' + esc(f.name || f.short) + '">' + icoHtml(f) + '<span>' + esc(f.short || f.name) + '</span><b aria-hidden="true">+</b></button>';
}).join('') : '<div class="hts-empty">孕期工具都已经放在首页啦</div>';
$('#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');
if (el && el.dataset.t0 != null) { document.title = el.dataset.t0; delete el.dataset.t0; } /* 百宝箱进来的百问:关掉还原顶栏标题 */
}
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]) => '<line x1="' + st[0][a][0] * 100 + '" y1="' + st[0][a][1] * 100 + '" x2="' + st[0][b][0] * 100 + '" y2="' + st[0][b][1] * 100 + '"/>').join('') +
st[0].map((p, i) => '<circle cx="' + p[0] * 100 + '" cy="' + p[1] * 100 + '" r="' + (1 + p[2] * 1.6).toFixed(2) + '" style="animation-delay:' + (i * .45 % 3).toFixed(2) + 's"/>').join('') : '';
const mo = (window.ZODIAC_MOTIF || {})[el_];
$('#zdMotif').innerHTML = mo ? mo.map(d => '<path d="' + 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) : '') + '<span>' + n + '宝宝</span>';
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 && state.pregEmpty === false) {
/* ⚠️ 只认 /pregnancy 已确认的预产期(pregEmpty === false):接口没回来/失败时 state.due 还是
内置默认(今天 +3 个月),回填进表单再一保存,假预产期就进库了(2026-11-08) */
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 '<div class="pk-col" data-k="' + key + '">' + items.map((t, i) => '<i data-i="' + i + '">' + t + '</i>').join('') + '</div>';
}
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);
/* 11-03 用户:「首页头图小孩左边做成倒计时的感觉」→ 孕期且有天数时换成翻页数字牌 + 时分秒走字(js/hero-cd.js),其余状态照旧 */
window.__mbHeroLeft = o.left > 0 ? o.left : 0;
if (window.__mbHeroCd) window.__mbHeroCd(window.__mbHeroLeft);
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;
});
setTimeout(renderGiftShelf); // 福利区按孕周出;异步调,避开它那几个 const 还没初始化的时段
/* ⚠️ #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() {
/* pregEmpty 只有 === false 才算「孕周已确认」:undefined(接口没回来/失败)时 state.week 是 DEFAULT_DUE 推的假值,
原来照发给头图,星座牌就挂出「水瓶宝宝」(2026-11-08) */
const _ok = state.pregEmpty === false && !state.postpartum;
const d = { week: _ok ? state.week : null, day: state.day,
postpartum: !!state.postpartum, empty: !state.postpartum && state.pregEmpty !== false,
due: (state.due && _ok) ? [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 产后} */
if (state.pregEmpty !== undefined || state.postpartum) 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 周"这类假数据。
2026-11-08(测试会话 150e0991):pregEmpty 还是 undefined(/pregnancy 没回来 / 失败)也走这里。
原来这时落到下面的正常分支,按 DEFAULT_DUE(今天 +3 个月)画出「26 周+6 天 · 距离见面 92 天 ·
孕检日历 11月16日」一整套假数据;接口挂了它就一直留在首页,用户会当成自己的。
现在三种文案:未建档 → 引导填预产期;加载中 → 「正在加载」;失败(state.pregFail)→ 点击重试。 */
if (state.pregEmpty !== false) {
const nm = state.babyName || '宝宝';
const _unk = state.pregEmpty === undefined, _fail = _unk && state.pregFail;
/* 脚印时间轴:未建档整块隐藏 —— 交给 fpRender() 统一判(它同时看 footprint 的结论),
别再直接改 display:与本模块的另一个 owner 撞竞态(2026-09-22 实测漏过 degraded 场景)。 */
fpRender();
/* ① 我的 · 孕期卡 → 引导 */
$('#pcTitle').textContent = OUT() ? '已退出登录' : _fail ? '孕期信息没加载出来' : _unk ? '正在加载孕期信息…' : '待完善孕期信息';
setMeTag('');
$('#pcSub').textContent = OUT() ? '登录后查看你的孕期信息和记录' : _fail ? '网络不太好,点首页顶部重试' : _unk ? '' : '填好预产期,开启孕期陪伴';
$('#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: OUT() ? '登录后查看宝宝这周的变化' : _fail ? '<u class="hero-retry">没加载出来,点这里重试</u>' : _unk ? '正在加载…' : '填好预产期,开启倒计时', emoji: '🌱', hi: 'sprout', size: '', pct: 0 });
/* ③ 档案页 → 占位 */
$('#arTitleName').textContent = nm;
$('#arLine1').textContent = _fail ? '孕期信息没加载出来' : _unk ? '正在加载…' : '完善孕期信息后开启';
$('#arDays').textContent = '--';
$('#arLine2').textContent = _fail ? '请稍后重新打开' : _unk ? '' : '先去「孕期档案」填预产期';
$('#arPct').textContent = '0%';
setArBar(0);
$('#genWeekTag').textContent = '--';
/* ⑤ 用户卡记录行:未完善 → 引导文案(数据源未定,先不给演示数字) */
const _rl = $('#meRecordLine');
if (_rl) _rl.textContent = _unk ? '' : '完善孕期信息,记录宝宝的成长记忆';
/* ⑥ 孕期衍生的静态演示位(生长曲线周数/孕检日历/时光轴日期)一并归中性占位,
不保留 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') 选的是**第一个** <p>。曾经那是一句话签名(已删),
现在第一个 <p> 是「已连续记录 42 天」—— 加回这行会把连续记录天数
静默冲成孕周,页面不报错,只是那个数字再也不对。 */
/* ② 首页头部:孕周环、+N 天、「小名,我们已经一起生活了 N 天」 */
$('#heroWeek').textContent = week;
$('#heroPlus').textContent = '+' + day + '天';
$('#heroDays').textContent = days;
$('#heroWho').textContent = name;
{
const sz = babySize(week);
setHero({
due: left > 0 ? '距离见面还有 <b>' + left + '</b> 天' : '宝宝随时会来见你',
left: 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();
/* ⑤ 用户卡记录行:原为写死的「已连续记录 42 天」(原型值,所有人都一样、和记没记无关 —— 2026-11-08 测试会话 150e0991)。
连续记录天数没有数据源,改成按孕周算的真实天数;以后有了连续打卡接口再换回来。 */
const _rl2 = $('#meRecordLine');
if (_rl2) _rl2.innerHTML = '和宝宝一起走过 <b>' + days + '</b> 天';
/* ⑥ 页面里那些示例日期(时光轴、珍贵时刻、小卡、热词…)也按新孕周重刷。
改预产期会连带改孕周,这些跟着一起动才不会前后矛盾。 */
syncStaticDates();
stampAiLogs();
}
/* ⚠️ 这里原来是 syncBio() —— 刷「我的」页头像旁那行一句话签名。
一句话签名本批按用户要求整块删除,四处一起走:
index.html #pfBio 输入框、<p class="me-bio" id="meBio"> 展示行
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', () => {
/* 预产期是一切的源头:孕周、剩余天数、信里的日期全从它算。没有它没什么可存的。 */
/* 2026-11-08:孕期信息还没拉到(接口慢/失败)时,表单里的预产期、小名都不可信,不让保存 */
if (hashParam('openid') && state.pregEmpty === undefined && !DEMO) {
toast('资料还没加载出来,请稍等几秒再保存');
return;
}
if (!parseDay(pgDue.value)) {
toast('先填一下预产期,孕周会自动算出来');
openPicker('week');
return;
}
/* 预产期范围(2026-11-08 测试:选 2025-01-01 / 2027-12-31 都能存进库):
只校验**改过的**预产期 —— 没动过的旧值(例如已经超期几周的老用户)不拦,免得改个昵称都存不了。
下限 = 今天 -14 天(42 周封顶);上限 = 今天 +280 天(再往后就是「还没怀上」)。 */
if (pgDue.value !== filledDue || state.pregEmpty) {
const _d = parseDay(pgDue.value), _t = midnight(new Date());
const _left = Math.round((_d - _t) / DAY_MS);
if (_left < -14) { toast('预产期已经过去太久了,请检查一下日期'); openPicker('week'); return; }
if (_left > TERM) { toast('预产期离现在超过 40 周,请检查一下日期'); openPicker('week'); return; }
}
/* 空着不填就不改:原来兜底成「妈妈 / 宝宝」,会把这两个字当真名推给上游(2026-11-08) */
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 && name) {
$('#meName').textContent = name;
state.userName = name;
if (avatarUrl) { $('#meAvatar').classList.add('has-img'); meAvatarImg.src = avatarUrl; }
}
/* ---- 宝宝那半(原 #pgSave) ---- */
if (babyName) 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('资料已更新 · ' + (state.userName || '妈妈') + ' · ' + (state.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 = { babySex: babySex };
if (babyName) body.babyName = babyName;
/* 预产期没改就不传:表单可能是几分钟前回填的,别的应用这期间改过的话会被旧值盖回去 */
var _due = due ? toISO(due) : '';
/* 2026-10-31:登记过出生的人在这里保存孕周 = 回到孕期 → 预产期没改也要传,后台据此把账号翻回孕期 */
var _wasPost = !!state.postpartum;
if (_due !== filledDue || _wasEmpty || (_wasPost && _due)) body.dueDate = _due;
if (!_unauth && name) 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'],
/* 11-05 用户:踢腿音效加开关,默认开(js/baby-limbs.js 每次响之前读 mb_kick_snd,'0' = 关) */
['sw', '宝宝踢腿音效', '孕 20 周起,宝宝蹬腿时轻轻「噗」一声', 1, 'mb_kick_snd']
] },
{ 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 =
'<div class="st-about">' +
'<div class="st-logo">' +
'<svg viewBox="0 0 48 48" aria-hidden="true">' +
'<path d="M7 18h34v20a4 4 0 0 1-4 4H11a4 4 0 0 1-4-4z" fill="#fff" opacity=".9"/>' +
'<path d="M5 12.5h38a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3.5a2 2 0 0 1 2-2z" fill="#fff"/>' +
'<path d="M24 20v22M24 12.5c-2-5-5.5-6.5-8-5s-1.5 5 8 5zM24 12.5c2-5 5.5-6.5 8-5s1.5 5-8 5z" ' +
'fill="none" stroke="#F4593C" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>' +
'</div>' +
'<div class="st-appname">兜爸心选</div>' +
'<div class="st-ver">v' + VER + ' · 陪你从两条杠到见面</div>' +
'</div>';
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 '<p class="st-para">' + r[1] + '</p>';
if (r[0] === 'row' && r[3] === 'fb')
return '<button class="st-row ripple" data-fb="1"><span class="st-rt">' + r[1] + '</span>' +
'<span class="st-rv">' + r[2] + '</span><i class="chev">›</i></button>';
if (r[0] === 'row')
return '<button class="st-row ripple" data-toast="' + r[1] + (r[2] ? ' · ' + r[2] : '') + '">' +
'<span class="st-rt">' + r[1] + '</span>' +
(r[2] ? '<span class="st-rv">' + r[2] + '</span>' : '') +
'<i class="chev">›</i></button>';
/* 开关行: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 '<button class="st-row sw' + (r[3] ? ' on' : '') + ' ripple" role="switch" ' +
'aria-checked="' + (r[3] ? 'true' : 'false') + '" data-sw="' + r[1] + '"' + (r[4] ? ' data-key="' + r[4] + '"' : '') + '>' +
'<span class="st-rt">' + r[1] + '<em>' + r[2] + '</em></span>' +
'<i class="sw-track"><b></b></i></button>';
}).join('');
return (g.t ? '<h3 class="d-sec">' + g.t + '</h3>' : '') + '<div class="st-card">' + rows + '</div>';
}).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,
带 <b> 和 <br> 的排版所以没法共用变量)。改这一条务必两处一起改。 */
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 '<div class="ai-st"><b>' + s[1] + '<em class="' + cls + '">' + txt + '</em></b>' +
'<span>' + s[0] + '</span></div>';
}).join('');
const aiTipsHTML = d => d.tips.map((t, i) =>
'<article class="ai-tip c-' + t.tone + '">' +
'<span class="at-n" aria-hidden="true">' + (i + 1) + '</span>' +
'<span class="at-cat">' + t.cat + '</span>' +
'<h4 class="at-head">' + t.head + '</h4>' +
'<p class="at-txt">' + t.txt + '</p>' +
'</article>').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) => '<button class="ai-log ripple" data-ai="' + (i + 1) + '">' +
'<span class="al-dot" aria-hidden="true"></span>' +
'<span class="al-date">' + l.date + '</span>' +
'<span class="al-week">' + l.week + '</span>' +
'<span class="al-txt">' + l.txt + '</span></button>').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');
}
/* ================= 孕育资源库:列表页 + 内容页 ================= */
/* 2026-11-05 起「孕N周专属福利」资料由数胎动后台维护(taidong.douba.top/admin 左侧「孕周专属福利」,会话 375797cf)。
公开列表 GET /taidong/api/public/week-perks → {items:[{key,title,cat,tint,desc,read_text,claim_text,cover,images,week_from,week_to,
pack_fmt,pack_items,body:[{h,p}],price,sort,detail}]}(week_from=week_to=0 = 全孕期),
只含已上架、不含网盘地址;兑换走 box_app /resources/{id}/claim(服务端去对方取链接 + 扣豆,价格以对方为准)。
原来写死的 8 份资料已按原 key 导入对方后台,这里不再内置;列表为空 / 本周没有 / 接口出错 → 整个福利区块隐藏。
RES[key] = {img,title,cat,desc,read,claim,detail,images,fmt,items,body,price,wf,wt,tint},RES_ORDER 按 sort。 */
const RES = {};
const RES_ORDER = [];
const TINTS = ['o', 'g', 'p', 'b'];
const escR = t => String(t == null ? '' : t).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const wkTxt = r => (!r.wf || !r.wt || (r.wf <= 1 && r.wt >= 42)) ? '全孕期' : r.wf === r.wt ? '孕' + r.wf + '周' : '孕' + r.wf + '-' + r.wt + '周';
const priceTxt = r => r.price > 0 ? r.price + ' 兜豆' : '免费';
let perksLoaded = false;
function loadPerks() {
return fetch('/taidong/api/public/week-perks').then(r => r.ok ? r.json() : null).then(j => {
const items = ((j && j.items) || []).slice().sort((a, b) => (a.sort || 0) - (b.sort || 0));
Object.keys(RES).forEach(k => { delete RES[k]; });
RES_ORDER.length = 0;
items.forEach((it, n) => {
const id = String(it.key || it.id);
const arr = v => Array.isArray(v) ? v : [];
RES[id] = {
img: it.cover || '', title: it.title || '', cat: it.cat || '', desc: it.desc || it.subtitle || '',
read: it.read_text || '', claim: it.claim_text || '', detail: it.detail || '',
images: arr(it.images), fmt: arr(it.pack_fmt), items: arr(it.pack_items),
body: arr(it.body).map(b => [b && b.h || '', b && b.p || '']), price: +it.price || 0,
wf: +it.week_from || 0, wt: +it.week_to || 0, tint: TINTS.indexOf(it.tint) >= 0 ? it.tint : TINTS[n % 4]
};
RES_ORDER.push(id);
});
perksLoaded = true;
}).catch(() => { perksLoaded = true; }).then(renderGiftShelf);
}
/* 首页福利区:只出适用当前孕周的(含两端),按 sort;一份都没有就整块藏 */
let giftShelfSig = '';
function renderGiftShelf() {
const shelf = $('.shelf.gift2');
if (!shelf || !perksLoaded) return;
const wk = state.week || 0;
const keys = RES_ORDER.filter(k => { const r = RES[k]; return !r.wf || !r.wt || (wk >= r.wf && wk <= r.wt); });
const sig = wk + '|' + keys.join(',') + '|' + resGot.join(',');
if (sig === giftShelfSig) return;
giftShelfSig = sig;
const n = $('#giftTotal'); if (n) n.textContent = RES_ORDER.length;
const m = $('#rlTotal'); if (m) m.textContent = RES_ORDER.length;
shelf.style.display = keys.length ? '' : 'none';
if (!keys.length) return;
$('.shelf-rail', shelf).innerHTML = keys.map(k => {
const r = RES[k], got = resGot.indexOf(k) >= 0;
return '<button class="shelf-book ' + ({ o: 'sb-book', g: 'sb-food', p: 'sb-music', b: 'sb-blue' })[r.tint] + ' ripple" data-open="detailRes" data-res="' + escR(k) + '">' +
'<span class="sb-tag">孕<span>' + wk + '</span>周专属</span>' +
'<img class="sb-cover" src="' + escR(r.img) + '" alt="" aria-hidden="true">' +
'<span class="sb-title">' + escR(r.title) + '</span>' +
'<span class="sb-desc">' + escR(r.desc) + '</span>' +
'<span class="sb-row"><span class="sb-faces" aria-hidden="true"></span><i class="sb-got">' + escR(r.claim || '宝妈都在领') + '</i>' +
'<span class="pill-free">' + (got ? '已兑换' : r.price > 0 ? r.price + ' 豆兑' : '免费领') + '</span></span></button>';
}).join('');
$$('.shelf-book', shelf).forEach(b => b.addEventListener('click', () => openPanel('detailRes', b)));
if (window.mbGiftFaces) window.mbGiftFaces();
}
/* 已兑换的 id(来自 claim-status.resGot);兑换成功后本地追加 */
const resGot = [];
function loadResGot() {
const oid = hashParam('openid');
if (!oid) return Promise.resolve();
return fetch('/api/mp/public/mama-box/resources/claim-status?openid=' + encodeURIComponent(oid))
.then(r => r.ok ? r.json() : null).then(j => {
const x = j && j.result;
if (!x) return;
resGot.length = 0;
(x.resGot || []).forEach(k => resGot.push(String(k)));
(x.resFav || []).forEach(k => { if (state.resFav.indexOf(String(k)) < 0) state.resFav.push(String(k)); });
renderGiftShelf();
setRdGet();
}).catch(() => {});
}
/* ⚠️ 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];
return '<button class="res-item ripple"' +
' data-open="detailRes" data-res="' + escR(k) + '">' +
'<span class="ri-thumb ' + r.tint + '"><img src="' + escR(r.img) + '" alt=""></span>' +
'<span class="ri-body"><span class="ri-title">' + escR(r.title) + '</span>' +
'<span class="ri-desc">' + escR(r.desc) + '</span>' +
'<span class="ri-row">' +
(r.cat ? '<em class="ri-cat">' + escR(r.cat) + '</em>' : '') +
'<em class="ri-ga">' + wkTxt(r) + '</em>' +
'<em class="pill-free">' + (resGot.indexOf(k) >= 0 ? '已兑换' : priceTxt(r)) + '</em>' +
'</span></span></button>';
}
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[RES_ORDER[0]];
if (!r) return;
state.resKey = RES[key] ? key : RES_ORDER[0];
$('#rdImg').src = r.img;
$('#rdCover').className = 'rd-cover ' + r.tint;
$('#rdCat').textContent = r.cat || wkTxt(r);
$('#rdTitle').textContent = r.title;
$('#rdDesc').textContent = r.desc;
$('#rdMeta').innerHTML = [r.read, r.claim, wkTxt(r)].filter(Boolean).map(m => '<span>' + escR(m) + '</span>').join('');
/* 「这份资料里有什么」= 文件形式标签 + 内容清单 + 补充说明(多行,一行一段)+ 详情图;正文 body 放「内容抢先看」 */
const paras = r.detail.split(/\n+/).map(t => t.trim()).filter(Boolean);
$('#rdPack').innerHTML = (r.fmt.length || r.items.length || paras.length || r.images.length) ? '<h3 class="d-sec">这份资料里有什么</h3>' +
'<div class="rp-box">' +
(r.fmt.length ? '<div class="rp-fmt">' + r.fmt.map(f => '<span>' + escR(f) + '</span>').join('') + '</div>' : '') +
(r.items.length ? '<ul class="rp-list">' + r.items.map(t => '<li>' + escR(t) + '</li>').join('') + '</ul>' : '') +
paras.map(t => '<p class="rp-p">' + escR(t) + '</p>').join('') +
r.images.map(u => '<img class="rp-img" src="' + escR(u) + '" alt="" loading="lazy">').join('') +
'<p class="rp-how">' + (r.price > 0 ? '<b>' + r.price + '</b> 兜豆兑换,' : '免费领取,') +
'马上给你网盘地址和提取码,复制去下载;领过的再点也能看' + (r.price > 0 ? ',不重复扣豆' : '') + '。</p></div>' +
(r.body.length ? '<h3 class="d-sec">内容抢先看</h3>' : '') : '';
$('#rdBody').innerHTML = r.body.map(b => '<h4>' + escR(b[0]) + '</h4><p>' + escR(b[1]) + '</p>').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 需求取消「已领」概念后改回**固定文案**(点它只是弹顾问二维码)。 */
setRdGet();
$('#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),这颗按钮现在只做一件事:弹二维码。
不存在「已解锁 / 未解锁」两种起点,所以这里也没有任何状态判断 —— 别再加回来。 */
/* 2026-11-05 用户改:「立即领取」→ 用兜豆兑换,兑换完立刻弹网盘地址(顾问二维码不要了)。
后端 POST /resources/{key}/claim:同份同人只扣一次,兑换过的再点直接回链接(spent=0)。 */
function setRdGet() {
const b = $('#rdGet');
if (!b) return;
const got = resGot.indexOf(state.resKey) >= 0, r = RES[state.resKey];
b.textContent = got ? '查看网盘地址' : r && r.price > 0 ? r.price + ' 兜豆兑换' : '免费领取';
b.classList.toggle('is-got', got);
}
let resBusy = false;
function redeemRes(key) {
const oid = hashParam('openid'), uid = hashParam('unionid');
if (!oid) { toast('请从小程序里打开后再兑换'); return; }
if (resBusy) return;
resBusy = true;
const btn = $('#rbOk'); if (btn) btn.textContent = '兑换中…';
fetch('/api/mp/public/mama-box/resources/' + key + '/claim?openid=' + encodeURIComponent(oid) +
(uid ? '&unionid=' + encodeURIComponent(uid) : ''), { method: 'POST' })
.then(r => r.json().catch(() => null))
.then(j => {
const x = (j && j.result) || {};
if (j && j.code === 200 && x.pan && x.pan.url) {
if (resGot.indexOf(key) < 0) resGot.push(key);
setRdGet();
renderResList(state.resCat);
renderGiftShelf();
showPan(key, x.pan, x.spent > 0 ? x.spent : 0);
if (x.spent > 0) loadDou(); // 刷新「我的」页兜豆余额
} else if (j && j.code === 409 && x.insufficient) {
closeModal();
$('#rsShort').textContent = x.short || (RES[key] && RES[key].price) || '';
$('#rsBal').textContent = typeof x.balance === 'number' ? x.balance : 0;
openModal('modalResShort');
} else {
closeModal();
toast((j && j.message) || '网络不太好,稍后再试');
}
})
.catch(() => { closeModal(); toast('网络不太好,稍后再试'); })
.then(() => { resBusy = false; if (btn) btn.textContent = '确认兑换'; });
}
function showPan(key, pan, spent) {
const r = RES[key] || {};
$('#rpTitle').textContent = spent ? '兑换成功' : '你的网盘地址';
$('#rpLead').textContent = (spent ? '已扣 ' + spent + ' 兜豆,' : '') + '复制下面的地址,打开浏览器或百度网盘 App 就能下载《' + String(r.title || '').replace(/[《》]/g, '') + '》';
$('#rpUrl').textContent = pan.url;
$('#rpCode').textContent = pan.code || '无';
$('#rpCodeRow').classList.toggle('hide', !pan.code);
openModal('modalResPan');
}
function copyText(t) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(t).catch(() => legacyCopy(t));
return Promise.resolve(legacyCopy(t));
}
function legacyCopy(t) {
const ta = document.createElement('textarea');
ta.value = t; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:-99px;opacity:0';
document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, t.length);
try { document.execCommand('copy'); } catch (e) { /* 复制不了也给提示,用户可长按地址手动复制 */ }
ta.remove();
}
$('#rpCopy').addEventListener('click', () => {
const code = $('#rpCodeRow').classList.contains('hide') ? '' : $('#rpCode').textContent;
copyText($('#rpUrl').textContent + (code ? ' 提取码: ' + code : '')).then(() => toast('已复制,去浏览器或网盘 App 粘贴打开'));
haptic();
});
$('#rdGet').addEventListener('click', () => {
const key = state.resKey;
if (!hashParam('openid')) { toast('请从小程序里打开后再兑换'); return; }
const r = RES[key] || {};
if (resGot.indexOf(key) >= 0 || !(r.price > 0)) { redeemRes(key); return; } // 兑换过 / 免费:直接拿链接,不扣豆
$('#rbName').textContent = '《' + String(r.title || '').replace(/[《》]/g, '') + '》';
$('#rbPrice').textContent = r.price;
const n = $('#meDouN') && !$('#meDouBal').classList.contains('hide') ? $('#meDouN').textContent : '';
$('#rbBal').textContent = n ? '你现在有 ' + n + ' 兜豆' : '';
openModal('modalResBuy');
});
$('#rbOk').addEventListener('click', () => redeemRes(state.resKey));
$('#rsGo').addEventListener('click', () => { closeModal(); openExchange(); });
loadPerks();
loadResGot();
/* ================= 企业微信顾问弹窗(单态) =================
三个入口共用这一个壳:「我的」页服务卡 #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());
$('#rlGift').classList.add('hide'); // 2026-11-05 用户:不保留顾问二维码,资源库顶部这条 banner 一起藏(DOM 留着)
/* ⚠️ 2026-09-19(9.19 需求):这里原来有一个 syncUnlockCopy(),按「已解锁 / 未解锁」
把首页书架与资源库的两句引导语改成两个版本,并给资源库顶部试用装 banner 挂 .hide。
兑换码闸门取消后不存在解锁态:
· 两句文案改为 index.html 里的**静态文案**(#shelfLeadTxt / #rlLead 的 id 也删了,
顺带解掉「给 <p> 写 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 + <small>cm</small> + <em>P58</em>」三截,
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. 没有(微信内置浏览器就没有)→ 退回 <a download>,
安卓会落进「下载」相册,iOS 会进「文件」;
3. 再不行 → 长按图片,这是微信里唯一能进相册的路。
以前是先走 2,iOS 上点完只是进了「文件」,用户在相册里怎么翻都找不到。 */
exportAct.save = () => {
if (!posterURLs.length) { toast('这台设备生成不了图片,长按图片试试'); return; }
const many = posterURLs.length > 1;
const download = () => {
/* 一次点 N 个 <a download> 有的浏览器会拦,隔开一点点再点下一张 */
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)。放在最前面,尽量少闪一下首页。 */
if (!SUB_GONE) 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 的展示。 */
/* 2026-11-07 用户:退出登录后按访客显示(js/login-gate.js 的 mbGate.out)——身份两键对页面「不存在」,
页面各处现成的访客分支自动生效(不出孕周、记录、来信……)。
跳到别的应用(withIdentity)和授权仍用真身份(rawParam):授权页要认人,其他应用由各自处理退出状态。 */
function OUT() { return !!(window.mbGate && window.mbGate.out); }
function hashParam(name) {
if ((name === 'openid' || name === 'unionid') && window.mbGate && window.mbGate.out) return '';
return rawParam(name);
}
function rawParam(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;
/* 11-08 资料预取(测试会话 150e0991):init() 挂在 window load 上,要等首页 4MB 图片全下完才开始拉
/profile + /pregnancy —— 实测资料 11~12 秒才落地,这之间首页一直是占位。
现在脚本解析时就把请求发出去,init 里 applyRemoteProfile 直接用这份结果(仍在 init 渲染之后才**写** state,
不违反下面那条「必须放在 init() 渲染之后」)。js/birth-flow.js 首次加载也复用它(window.__mbPfPre),不再各拉一遍。 */
function pfFetchPair(openid) {
const base = '/api/mp/public/mama-box/', u = hashParam('unionid');
const qs = 'openid=' + encodeURIComponent(openid) + (u ? '&unionid=' + encodeURIComponent(u) : '');
return Promise.all([
fetch(base + 'profile?' + qs).then(r => (r.ok ? r.json() : null)),
fetch(base + 'pregnancy?openid=' + encodeURIComponent(openid)).then(r => (r.ok ? r.json() : null))
]);
}
const _pfPre = (function () {
const o = hashParam('openid');
if (!o) return null;
const p = pfFetchPair(o);
p.catch(function () {}); /* 失败交给 applyRemoteProfile 的 catch,这里只防「未处理的 rejection」告警 */
window.__mbPfPre = { openid: o, p: p };
return window.__mbPfPre;
})();
/* ⚠️ 返回 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() 上方那段注释。
11-07:退出登录(mbGate.out)也走这里,但身份其实在 → 不出「未获取到微信身份」,「我的」按未登录显示 */
if (window.mbGate && window.mbGate.out) { state.authorized = false; hideIdNotice(); }
else 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 里有就带上,服务端成功后会回写映射。 */
/* (拼 openid + unionid 的查询串已挪进 pfFetchPair) */
/* 并行拉 个人档案 + 孕期状态:
- profile:babyName/userName/babySex/dueDate(身份 + 建档字段)
- pregnancy:week/day/due 以服务端权威派生为准(避免客户端重复算、口径统一)
拉不到(无 openid / 未返回 200 / 断网)就静默保持内置默认值,页面照常可用。 */
/* 走到这里说明这次加载是带身份的(openid 非空)→ 把上一次的提示收掉 */
hideIdNotice();
return (_pfPre && _pfPre.openid === openid ? _pfPre.p : pfFetchPair(openid)).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);
/* /pregnancy 没拿到(非 200 / 断网)→ 标失败,syncPregnancy 显示「点这里重试」,别留默认孕周(2026-11-08) */
if (state.pregEmpty === undefined && !DEMO) state.pregFail = true;
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 !== false || state.postpartum) { return; } /* 含 undefined:没拿到孕周,别按默认值重算日期 */
syncBabyWords();
syncStaticDates();
}).catch(function () {
/* 2026-11-08:原为「静默回退内置值」—— 内置值就是今天 +3 个月推出来的假孕周,不能静默留着 */
if (state.pregEmpty === undefined && !DEMO) { state.pregFail = true; syncPregnancy(); }
});
}
/* 首页头图「没加载出来,点这里重试」 */
document.addEventListener('click', function (e) {
if (state.pregFail && e.target.closest && e.target.closest('.hero-retry')) { e.stopPropagation(); bustReload(); }
}, true);
/* ---------- 远程数据(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;
/* 2026-11-05 修:原来先清空 RES 再取 old,内置正文 body 全丢了,详情页「内容」一直是空的 —— 先留一份旧表 */
var prev = Object.assign({}, RES);
Object.keys(RES).forEach(function (k) { delete RES[k]; });
var order = [];
items.forEach(function (it) {
var old = prev[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) {
/* 卡上静态文案就是「今天 <b>N</b> 次」,上游的 label/unit 正是「今天 / 次」——
只写数字、保留 <b> 的数字强调。上游若改措辞,这里要连带改静态文案。 */
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 ? '还有 <em>' + days + '</em> 天入住 · 攒兜豆把它布置好'
: 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: '<svg viewBox="0 0 24 24"><ellipse cx="10" cy="15" rx="5" ry="6.5" fill="currentColor"/><circle cx="16.5" cy="6" r="2" fill="currentColor"/><circle cx="12.5" cy="4.6" r="1.6" fill="currentColor"/><circle cx="19.4" cy="9.6" r="1.6" fill="currentColor"/></svg>',
weight: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M8 10a4 4 0 018 0z" fill="currentColor"/><path d="M12 10l1.6-2.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>',
cal: '<svg viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="15" rx="3.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M3.5 10h17M8 3v4M16 3v4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
game: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2.5" fill="currentColor"/><rect x="13" y="3" width="8" height="8" rx="2.5" fill="currentColor" opacity=".6"/><rect x="3" y="13" width="8" height="8" rx="2.5" fill="currentColor" opacity=".6"/><rect x="13" y="13" width="8" height="8" rx="2.5" fill="currentColor"/></svg>',
box: '<svg viewBox="0 0 24 24"><rect x="4" y="11" width="16" height="10" rx="2" fill="currentColor"/><rect x="2.5" y="7.5" width="19" height="4.5" rx="1.6" fill="currentColor"/><path d="M12 7.5V21" stroke="#fff" stroke-width="2"/><path d="M12 7.5C10 3.5 6 4 7.5 6.6 8.3 7.8 10.4 7.6 12 7.5zM12 7.5c2-4 6-3.5 4.5-.9-.8 1.2-2.9 1-4.5.9z" fill="currentColor"/></svg>'
};
/* 2026-10-01 曾按用户要求换成应用 logo;同日「此刻」A+B 改版用户又要求「去掉应用 logo 一类的工具化视觉、
改用表现人的元素」(和上面孕育工具区撞脸)→ 动态行改用妈妈头像(momAva),全站统计格回到单色小图标。 */
function pulseIco(k, cls) {
return '<span class="' + cls + '">' + (PULSE_ICO[k] || PULSE_ICO.box) + '</span>';
}
/* 「此刻」头像(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 张,仍不相交。 */
/* 11-05 用户:各模块头像不要互相重复 → 改读 js/ava-pool.js 的分配表(pulseTop / pulseFeed),下面两行原表只作加载失败兜底 */
const AVA_TOP = (window.MB_AVA && MB_AVA.S.pulseTop) || ['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 = (window.MB_AVA && MB_AVA.S.pulseFeed) || ['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 => /:|^\//.test(n) && window.MB_AVA ? MB_AVA.src(n, '') : /^(mom|baby)-\d+$/.test(n) ? 'https://mp.yizhezhe.com/_bridge/avatars/' + n + '.png' : 'assets/img/mom/' + n + '.jpg';
const avaImg = n => '<img src="' + avaSrc(n) + '" alt="" loading="lazy" decoding="async" width="48" height="48">';
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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);
if (openInHost(url)) return;
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 = (window.MB_AVA && MB_AVA.S.gbWalk) || AVA_FEED.concat(AVA_TOP).filter(n => /^(p|m|mom-)\d/.test(n)); // 11-05 改用分配表 gbWalk(不和「此刻」撞)
/* 11-08:这块默认会被 js/gch/v6 整块换掉,但换之前 innerHTML 已经把 7 张 720×960 原图(约 800KB)拉下来了。
改用 guangchang/img/s/ 宽 200 的同名缩略图(见 guangchang/img/SOURCES.txt) */
const gbThumb = src => String(src || '').replace(/^guangchang\/img\/([0-9a-f]{12}\.jpg)$/, 'guangchang/img/s/$1');
function bubble(t, i, allHot) {
const ph = i % 2 === 0 && t.ph;
return '<button type="button" class="gb-b' + (ph ? ' ph' : '') + (t.hot ? ' hot' : '') + '" data-t="' + esc(t.id) + '" style="--c1:' + t.c[0] + ';--c2:' + t.c[1] + ';--ink:' + t.ink + ';--d:' + (i % 5) * -.8 + 's">' +
'<span class="gb-in">' +
(ph ? '<img class="gb-ph" src="' + esc(gbThumb(t.ph)) + '" alt="" loading="lazy">' : '') +
'<span class="gb-k">' + (CAT_CN[t.cat] || '') + (t.hot && !allHot ? '<em>热</em>' : '') + '</span>' +
'<span class="gb-n">' + esc(t.name) + '</span>' +
'<span class="gb-m"><i></i>' + kfmt(t.talk) + ' 人在聊</span></span>' +
'<img class="gb-a" src="' + avaSrc(POOL[(i * 7 + 3) % POOL.length]) + '" alt="" loading="lazy"></button>';
}
function paint(cat) {
const list = pick(cat);
row.innerHTML = '<div class="gb-map"><svg class="gb-path" aria-hidden="true"><path/></svg>' +
list.map((t, i) => '<div class="gb-col' + (i % 2 ? ' lo' : '') + '">' + bubble(t, i, !cat) + '</div>').join('') +
'<div class="gb-col gb-end"><button type="button" class="gb-all" data-t=""><b>' + T.length + '</b><span>个话题<br>都在广场</span><i>去逛逛 ›</i></button></div></div>';
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 = '<b>' + fmtNum(talk) + '</b> 位妈妈正在聊';
$('#gchAvs').innerHTML = ['p05', 'm07', 's03', 'k09', 'm16'].map(n => '<img src="' + avaSrc(n) + '" alt="" loading="lazy">').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 = '<span>' + (ok ? '你的投稿通过啦!奖励 ¥30 现金 + 500 兜豆' : '你的投稿有审核结果了') + '</span><i>去看看 ›</i>';
b.addEventListener('click', () => {
haptic();
const url = 'guangchang/?mine=1' + location.hash;
reportHostLocation(new URL(url, location.href).href);
if (openInHost(url)) return;
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const HEART = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0112 7.6a4.3 4.3 0 017.5 2.7c0 5.6-7.5 10.2-7.5 10.2z"/></svg>';
let data = null, wk = null, sig = '', busy = false, ri = 0, rollT = null;
const oid = () => hashParam('openid');
const list = () => (data && data.list) || [];
/* ---------- 每周话题:表在 js/wh-themes.js(2026-11-07 抽出,孕妈广场也要用)---------- */
function theme() {
if (data && data.theme && data.theme.name) return data.theme;
return window.MB_WH_THEME ? window.MB_WH_THEME() : null;
}
/* ---------- 首页入口:一句轮播 ---------- */
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);
/* 11-07 用户:名字去掉孕周,只叫「孕妈悄悄话」(之前是「30 周孕妈悄悄话」) */
const nm = '孕妈悄悄话';
$('#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 ? '去说一句' : '去看看') + '<i aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></i>';
clearInterval(rollT);
if (l.length > 1) rollT = setInterval(() => { if (!document.hidden && !page.classList.contains('show')) roll(); }, 4000);
}
/* ---------- 全屏页 ---------- */
function hugBtn(it) {
if (it.mine) return '<span class="whp-got">' + HEART + (it.hugs ? '收到 <b>' + it.hugs + '</b> 个抱抱' : '抱抱正在路上') + '</span>';
return '<button class="whp-hug' + (it.hugged ? ' on' : '') + '" type="button" data-hug="' + esc(it.id) + '" aria-label="给她一个抱抱">' +
HEART + '<b>' + (it.hugs || '') + '</b></button>';
}
/* 五改:流星夜空。用户原话:「打开页面夜空里特别多星星;点开一颗,它像流星一样滑落……那个头就是白色的,
尾巴就是这些字;首先保证用户能看清,刚开始慢一点,说完了慢慢的唰的就快了;同时把对应内容呈现出来」
· 亮星 = 一句(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 += '<i class="' + (Math.random() < .5 ? 'tw' : '') + '" style="left:' + (Math.random() * 100).toFixed(1) + '%;top:' + (Math.random() * 92).toFixed(1) + '%;width:' + sz.toFixed(1) + 'px;height:' + sz.toFixed(1) +
'px;--o:' + (.35 + Math.random() * .6).toFixed(2) + ';animation-delay:-' + (Math.random() * 6).toFixed(1) + 's;animation-duration:' + (1.6 + Math.random() * 3).toFixed(1) + 's"></i>';
}
box.innerHTML = h;
}
/* 亮星落在夜空中上部偏右(流星往左下走,右边起步才划得够长);抖动网格,不会叠在一起 */
function paintSky(l) {
/* 11-07 用户:「星星要均匀地挂在星空中,不要密集地放在一个地方」→ 话题牌下面到「说一句」上面整片夜空
(横 8–92%、竖 19–79%)切成差不多正方的格子,n 颗星按等间隔挑格子,每格里小幅抖动 */
const n = l.length, X0 = 8, XW = 84, Y0 = 19, YH = 60;
const pw = page.clientWidth || 390, phh = page.clientHeight || 844;
const cols = Math.max(3, Math.round(Math.sqrt(n * (XW * pw) / (YH * phh)))), rows = Math.max(2, Math.ceil(n / cols));
const cell = l.map((it, i) => Math.floor(i * cols * rows / n));
const fresh = l.findIndex(x => !x.mine && !seen.has(x.id));
const xs = l.map((it, i) => X0 + ((cell[i] % cols + .5 + ((hsh(it.id) % 100) / 100 - .5) * .6) / cols) * XW);
/* 十三改(用户:「每点一下都是一个音符,每一个星星代表不同的音符」):
按横向位置从左到右排成一架竖琴——越靠左越低、越靠右越高,五声音阶(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(cell[i] / cols);
const x = xs[i];
const y = Y0 + ((r + .5 + (((h >> 8) % 100) / 100 - .5) * .5) / rows) * YH;
const sz = it.mine ? 26 : Math.round(15 + Math.min(it.hugs || 0, 30) / 30 * 11);
return '<button type="button" class="wsk-st' + (it.mine ? ' mine' : '') + (seen.has(it.id) ? ' used' : '') + (i === fresh && !seen.size ? ' hint' : '') +
'" data-go="' + (i + 1) + '" data-hx="' + x.toFixed(1) + '" data-hy="' + y.toFixed(1) + '" data-note="' + note[i].toFixed(1) + '" style="left:' + x.toFixed(1) + '%;top:' + y.toFixed(1) + '%;--s:' + sz + 'px;--tw:' + (2 + (h % 30) / 10).toFixed(1) + 's" aria-label="' + esc(it.text) + '"><i></i></button>';
}).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 '<div class="whl ' + lv + '"><button type="button" class="whl-t" data-go="' + (i + 1) + '">' +
(it.mine ? '<em>我说的</em>' : '') + esc(it.text) + '</button>' +
'<div class="whp-meta"><span>孕 ' + it.week + ' 周 · ' + esc(it.ago) + '</span>' + hugBtn(it) + '</div></div>';
}).join('');
}
function paintCard(it, i) {
card.innerHTML = '<span class="wsc-k">' + (it.mine ? '我说的' : '刚划过的那颗星说') + '</span><p class="wsc-q">' + esc(it.text) + '</p>' +
'<div class="wsc-m"><span>孕 ' + it.week + ' 周的妈妈 · ' + esc(it.ago) + '</span>' + hugBtn(it) + '</div>' +
'<button type="button" class="wsc-next" data-next="1">下一颗 <svg viewBox="0 0 24 24" width="13" height="13"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>';
card.classList.remove('show');
void card.offsetWidth;
card.classList.add('show');
/* 11-07 用户:这句话呈现出来之后,过几秒钟就让它消失 */
clearTimeout(paintCard.t);
paintCard.t = setTimeout(() => card.classList.remove('show'), 6500);
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');
reborn(m.btn);
mts.delete(m.i);
if (done) paintCard(m.it, m.i);
checkDone();
}
/* 十一改(用户:「被点掉的流星划过之后,就不要再出现在原来的位置;点完之后给一些动效、声音」):
· 划走的星从夜空里消失(.used),全部落完 → 「星星都落下来啦」+「让星星回来」;
· 点下去:一圈光波 + 一把碎星迸开;飞行中头后撒星尘;
· 声音用 WebAudio 现合成,不加载音频文件:点星 = 一声随机五声音阶的「叮」(连点几颗像在弹琴),
唰出去 = 一道由低到高的风声;右上角喇叭可关,记在本地。 */
function checkDone() {
/* 11-07 用户:点完不要整片星空都空掉 —— 星星会再冒出来(reborn),「星星都落下来啦」不再出现 */
$('#wskDone').classList.remove('show');
}
/* 11-07 用户:「星星跑掉了,过一会儿还有其他星星出来,相当于其他妈妈说的话能出来,而不是都点完之后整个星空就不能点了」
划走的星 8~16 秒后换个位置重新亮起来(同一句话;整片夜空一直有星可点) */
function reborn(btn) {
setTimeout(() => {
if (!btn.isConnected || !btn.classList.contains('used')) return;
/* 回到自己那一格(小幅挪一点),整片星空保持均匀 */
btn.style.left = (+btn.dataset.hx + (Math.random() - .5) * 4).toFixed(1) + '%';
btn.style.top = (+btn.dataset.hy + (Math.random() - .5) * 3).toFixed(1) + '%';
btn.classList.remove('used', 'cur', 'flare', 'gone');
btn.classList.add('born');
setTimeout(() => btn.classList.remove('born'), 1500);
}, 8000 + Math.random() * 8000);
}
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'); reborn(btn); 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 = '<i class="wmt-l"></i><i class="wmt-h"></i>' + chars.map((c, k) => '<span style="font-size:' + fs[k].toFixed(1) + 'px">' + esc(c) + '</span>').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);
}
/* 11-07 用户:「背后的一些小星星也可以点」—— 点到装饰小星附近,借一颗还没在飞的亮星挪到那儿划出去
(话按顺序轮着来,所以背景星点不完;小星本身闪一下) */
$('#wsk').addEventListener('click', e => {
if (e.target.closest('.wsk-st')) return;
const l = list(); if (!l.length) return;
let best = null, bd = 24 * 24;
$$('#wskStars i').forEach(d => {
const r = d.getBoundingClientRect(), dx = r.left + r.width / 2 - e.clientX, dy = r.top + r.height / 2 - e.clientY, dd = dx * dx + dy * dy;
if (dd < bd) { bd = dd; best = d; }
});
if (!best) return;
let i = 0;
for (let k = 1; k <= l.length; k++) { const j = (deco.n + k) % l.length; if (!mts.has(j + 1)) { i = j + 1; break; } }
if (!i) return;
deco.n = i - 1 + 1;
const btn = $('.wsk-st[data-go="' + i + '"]', page); if (!btn) return;
btn.style.left = best.style.left; btn.style.top = best.style.top;
btn.classList.remove('used', 'born');
best.classList.add('pk'); setTimeout(() => best.classList.remove('pk'), 900);
haptic(); drawer(false);
launch(i);
});
const deco = { n: 0 };
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 = '<div class="wsk-t">每颗星星,都是<b>一个妈妈</b>说的话</div>' +
(th ? '<div class="wsk-topic"><em>本周话题</em><b>' + esc(th.name) + '</b></div>' : '<p>' + (night ? '今晚' : '今天') + ',她们和你一样在等宝宝来</p>');
$('#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 ? '填好预产期,就能说一句' :
window.MBWhCm ? '先审核再公开,被赞 ' + MBWhCm.starLikes() + ' 次就变成星星' : /* 11-07 悄悄话第 2 步:说一句 = 发进下面的留言区 */
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);
});
/* 11-07 用户:小程序里转发「本周话题」,对方点进来要直接到星空页 —— 宿主转发好友用的是 web-view 当前真实地址
(onShareAppMessage 的 webViewUrl),不看上报的 href,所以星空页开着时地址里要一直带 ?wh=1,关掉再去掉。 */
function whQs(on) {
try {
const q = new URLSearchParams(location.search);
if (on) q.set('wh', '1'); else { q.delete('wh'); q.delete('wc'); }
const u = location.pathname + (q.toString() ? '?' + q : '') + location.hash;
if (u !== location.pathname + location.search + location.hash) history.replaceState(history.state, '', u);
try { /* 星空页开着也算一个子页面:刷新回来还在星空页(见 resumeSub) */
const cur = JSON.parse(localStorage.getItem('mmbox.lastsub.v1') || 'null');
if (on) localStorage.setItem('mmbox.lastsub.v1', JSON.stringify({ u: location.pathname + '?wh=1', h: location.pathname.replace(/[^/]*$/, ''), at: Date.now() }));
else if (cur && /[?&]wh=1/.test(cur.u)) localStorage.removeItem('mmbox.lastsub.v1');
} catch (e) {}
const th = window.MB_WH_THEME ? window.MB_WH_THEME() : null;
if (window.wxAuth && wxAuth.reportLocation) wxAuth.reportLocation(on ? { title: th ? '本周话题:' + th.name : '孕妈悄悄话', imageUrl: new URL('assets/share/wh-star.jpg', document.baseURI).href } : {}); /* 转发卡 = 星空图 */
} catch (e) {}
}
function openPage() {
whQs(true);
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);
if (fromPlaza) { fromPlaza = false; history.back(); return; } /* 从广场置顶笔记来的:回广场 */
whQs(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 + '&more=1') /* 11-07 用户:星星再多一些 */
.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); autoFail(); return; }
data = r;
window.MB_WH_WEEK = r.week; /* 留言区「同孕周」用(js/wh-cm.js) */
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);
if (autoOpen) { autoOpen = false; openPage(); }
})
.catch(() => { if (!silent) show(false); autoFail(); });
}
/* 2026-11-07 孕妈广场信息流的置顶笔记「孕妈悄悄话」点了跳到首页带 ?wh=1 → 直接打开星空页:
· 星空页先空着盖上来(不露首页),孕周取广场也在用的 mmbox.gcwk.v1,马上拉数据,不等「此刻」;
· 参数用完就去掉,刷新不再弹;从广场来的,关掉星空页 = 浏览器返回,回到广场;
· 拉不到 → 提示一句再回广场。 */
let autoOpen = /[?&]wh=1(?:&|$)/.test(location.search), fromPlaza = false;
if (autoOpen) {
fromPlaza = /\/guangchang\//.test(document.referrer || '') && history.length > 1;
page.classList.add('show');
page.setAttribute('aria-hidden', 'false');
let gw = 0;
try { const g = JSON.parse(localStorage.getItem('mmbox.gcwk.v1') || 'null'); gw = g && !g.pp && g.w ? +g.w : 0; } catch (e) {}
if (gw < 4 && /[?&]wc=/.test(location.search)) gw = 20; /* 别人转发来请你点赞的:没建档也打开(孕周以服务端档案为准,没有就随便取一周的星空) */
if (gw >= 4) setTimeout(() => load(gw), 0); else setTimeout(autoFail, 0);
}
function autoFail() {
if (!autoOpen) return;
autoOpen = false;
toast('悄悄话暂时打不开,稍后再试');
setTimeout(closePage, 1500);
}
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 && !window.MBWhCm) { 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 ? '<span>' + esc(th.tip) + '</span>' : '') : '';
$('#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 => '<button class="wh-pre" type="button">' + esc(t) + '</button>').join('');
$('#whLeft').textContent = window.MBWhCm ? '' : '今天还能说 ' + data.left + ' 句';
if (window.MBWhCm && MBWhCm.fillModal) MBWhCm.fillModal(); /* 11-07 弹窗重新设计(规则三步走 + 星空样式),见 js/wh-cm.js */
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 = '正在发出…';
/* 11-07 悄悄话第 2 步(用户定):星空页「说一句」并入留言区 —— 发成置顶笔记下的留言(先审后发),赞够门槛自动挂星 */
if (window.MBWhCm) {
MBWhCm.post(t, data.week)
.then(m => { if (m) { toast(m); return; } closeModal(); toast('已发出,审核通过后大家就能看到'); })
.then(() => { busy = false; btn.disabled = false; btn.textContent = '悄悄发出'; });
return;
}
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 '<div class="pl-row' + (go ? ' is-go' : '') + '"' + go + '><span class="pr-ava">' + avaImg(AVA_FEED[(f._ava || 0) % AVA_FEED.length]) + '</span>' +
/* 同周版标题已经说了「和你同一周」,行里不再重复「同孕周妈妈」,省出地方给动作。
不显示「N 分钟前」:三行按工具错开排(见 paint),时间会前后颠倒,又不能改接口给的时间 */
(peerMode ? '' : '<span class="pr-who">' + esc(who) + '</span>') + '<span class="pr-do">' +
esc(what).replace(/(\d+)/g, '<em>$1</em>') + '</span>' +
(go ? '<i class="pr-go" aria-hidden="true">›</i>' : '') + '</div>';
}
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) => '<i>' + avaImg(AVA_TOP[(st + k) % AVA_TOP.length]) + '</i>').join('');
}
function paintPeer(p) {
paintAvs(p.week);
$('#plTitle').innerHTML = '<b data-n="' + p.count + '">0</b> 位孕 ' + 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 ? '今天 <b>' + fmtN(n) + '</b> ' + CROWD_TXT[pick.key] : '她们和你一样,在等宝宝来';
const me = pick ? selfDone(pick.key) : null;
if (me != null) {
$('#ppDoing').innerHTML = me
? '<span class="pp-me ok"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.4l3 3 6-6.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' + esc(SELF_TXT[pick.key][1]()) + '</span>'
: '<button class="pp-me todo" type="button" data-go="' + pick.key + '">' + esc(SELF_TXT[pick.key][0]()) +
'<i aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></i></button>';
}
$('#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 ? '今天 <b>' + fmtN(td.value) + '</b> ' + esc(td.label) : '她们和你一样,在等宝宝来';
const t = r.total;
/* 前缀「已有」省掉:一行放得下「10,037 位妈妈在这里迎接宝宝」,带上就会折出一个孤零零的「宝宝」 */
$('#plTitle').innerHTML = t ? '<b data-n="' + t.value + '">0</b> ' + esc(t.suffix) : '孕妈们在做什么';
}
/* 标题每次重画会重建 <b>:从上次显示到的数接着涨,不要每分钟从 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 '<svg class="gift" viewBox="0 0 120 120" aria-hidden="true"><defs>' +
'<linearGradient id="' + g + 'b" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#FF9677"/><stop offset="1" stop-color="#EE5A43"/></linearGradient>' +
'<linearGradient id="' + g + 'l" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFB49A"/><stop offset="1" stop-color="#FF8064"/></linearGradient>' +
'<linearGradient id="' + g + 'r" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFF6D6"/><stop offset="1" stop-color="#FFD36E"/></linearGradient></defs>' +
'<g class="g-body"><rect x="20" y="54" width="80" height="56" rx="11" fill="url(#' + g + 'b)"/>' +
'<rect x="20" y="54" width="80" height="9" fill="#9B2B1E" opacity=".12"/>' +
'<rect x="53" y="54" width="14" height="56" fill="url(#' + g + 'r)"/>' +
'<circle cx="34" cy="80" r="3.2" fill="#fff" opacity=".55"/><circle cx="86" cy="94" r="2.4" fill="#fff" opacity=".45"/>' +
'<path d="M84 68l1.6 3.8 3.8 1.6-3.8 1.6L84 78.8l-1.6-3.8-3.8-1.6 3.8-1.6z" fill="#fff" opacity=".8"/></g>' +
'<g class="g-lid"><path d="M60 38C51 20 32 17 33 29c1 9 15 10 27 9z" fill="url(#' + g + 'r)" stroke="#EFAE3C" stroke-width="2"/>' +
'<path d="M60 38c9-18 28-21 27-9-1 9-15 10-27 9z" fill="url(#' + g + 'r)" stroke="#EFAE3C" stroke-width="2"/>' +
'<rect x="14" y="36" width="92" height="22" rx="8" fill="url(#' + g + 'l)"/>' +
'<rect x="14" y="36" width="92" height="7" rx="4" fill="#fff" opacity=".2"/>' +
'<rect x="52" y="36" width="16" height="22" fill="url(#' + g + 'r)"/>' +
'<circle cx="60" cy="37.5" r="6.5" fill="#FFD36E" stroke="#EFAE3C" stroke-width="2"/></g></svg>';
}
const bbEsc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
function cardImgHTML(w) {
const h = CARD_IMG[w];
return h ? '<img src="assets/img/' + h + '.jpg" alt="孕 ' + w + ' 周宝宝卡" decoding="async">'
: '<span class="cf-fb" aria-hidden="true">' + babySize(w).emoji + '</span>';
}
/* 卡正面。card = { week, v, size:{name,txt}, say } */
function cardFrontHTML(c) {
const sz = c.size || babySize(c.week);
return '<div class="cf v-' + (c.v === 'flash' ? 'flash' : 'normal') + '">' +
'<span class="cf-tag">✦ 闪卡</span><span class="cf-no">No.' + String(c.week).padStart(2, '0') + '</span>' +
'<div class="cf-img">' + cardImgHTML(c.week) + '</div>' +
'<div class="cf-row"><span class="cf-wk">孕<b>' + c.week + '</b>周</span><span class="cf-size">像<b>' + bbEsc(sz.name) + '</b> · ' + bbEsc(sz.txt) + '</span></div>' +
'<p class="cf-say">' + (c.say ? '“' + bbEsc(c.say) + '”' : '') + '</p>' +
'<i class="cf-holo"></i></div>';
}
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 = '已收集 <b>' + BB.collected + '</b>/' + 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 + '大奖还差 <b>' + g.left + '</b> 张' : got + (BB.flash ? ' · 闪卡 <b>' + BB.flash + '</b> 张' : ''), '看卡册', 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 => '<button class="bx-chip lk' + (w === bxLockSel ? ' on' : '') + '" data-w="' + w + '" type="button">🔒 孕 ' + w + ' 周</button>').join('');
$('#bxTasks').innerHTML = (BB.tasks || []).map(t => {
const btn = t.ready ? '<button class="bx-tk-b go" data-unlock="' + t.id + '" type="button">解锁孕 ' + bxLockSel + ' 周</button>'
: t.used ? '<span class="bx-tk-b done">' + (t.period === 'week' ? '本周已用' : '今天已用') + '</span>'
: '<button class="bx-tk-b" data-app="' + bbEsc(t.identifier) + '" type="button">去完成</button>';
const prog = t.need > 1 ? '<i class="bx-tk-p"><s style="width:' + Math.round(t.progress / t.need * 100) + '%"></s></i><em>' + t.progress + '/' + t.need + '</em>' : '';
return '<div class="bx-tk' + (t.ready ? ' is-ready' : '') + '"><span class="bx-tk-i" aria-hidden="true">' + (BX_TASK_ICO[t.id] || '✨') + '</span>' +
'<div class="bx-tk-m"><b>' + bbEsc(t.title) + '</b><span>' + bbEsc(t.desc) + '</span>' + (prog ? '<div class="bx-tk-pr">' + prog + '</div>' : '') + '</div>' + btn + '</div>';
}).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 ? '<button class="bx-gl-b" data-addr="1" type="button">填收货信息</button>'
: '<span class="bx-gl-s' + (e.status === 'sent' ? ' sent' : '') + '">' + (e.status === 'sent' ? '已发放' : '待发放') + '</span>';
else if (g.complete) tail = '<span class="bx-gl-s off">补发集齐</span>';
else tail = '<span class="bx-gl-l">差 <b>' + g.left + '</b> 张</span>';
return '<div class="bx-gl' + (g.key === 'all' ? ' all' : '') + (e ? ' got' : '') + '"><div class="bx-gl-h"><b>' + g.name + '</b><em>' + prize + '</em>' + tail + '</div>' +
'<i class="bx-gl-bar"><s style="width:' + pct + '%"></s></i></div>';
}).join('');
/* 待补开 */
const pend = $('#bxPend');
pend.classList.toggle('hide', op.length < 2);
if (op.length >= 2) {
$('#bxPendN').textContent = op.length;
$('#bxChips').innerHTML = op.map(w => '<button class="bx-chip' + (w === bbSel ? ' on' : '') + '" data-w="' + w + '" type="button">孕 ' + w + ' 周</button>').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 += '<button class="bx-t got' + (a.v === 'flash' ? ' flash' : '') + (bbNew === w ? ' is-new' : '') + '" data-w="' + w + '" data-v="' + (a.v || 'normal') + '" type="button">' +
(CARD_IMG[w] ? '<img src="assets/img/' + CARD_IMG[w] + '.jpg" alt="" loading="lazy">' : '<span class="fb">' + babySize(w).emoji + '</span>') +
(a.src === 'backfill' ? '<span class="bf">补</span>' : '') + '<span class="wk">' + w + ' 周</span></button>';
} else if (lk.indexOf(w) >= 0) {
cells += '<button class="bx-t lockd" data-lk="' + w + '" type="button"><span class="li" aria-hidden="true">🔒</span><span class="wk">' + w + ' 周</span></button>';
} else if (op.indexOf(w) >= 0) {
cells += '<button class="bx-t can" data-op="' + w + '" type="button">' + giftSVG() + '<span class="wk">' + w + ' 周</span></button>';
} else {
cells += '<div class="bx-t lock">?<span class="wk">' + w + ' 周</span></div>';
}
}
const all = g.to - g.from + 1;
return '<div class="bx-grp"><div class="bx-gh' + (n === all ? ' full' : '') + '" style="--c:' + g.c + '"><i></i>' + g.name + ' · ' + g.from + '–' + g.to + ' 周<span>' +
(n === all ? '已集齐 ✓' : n + '/' + all) + '</span></div><div class="bx-grid">' + cells + '</div></div>';
}).join('');
bbNew = null;
/* 规则与概率(接口下发) */
const pb = BB.probs || {};
const bar = x => '<div class="bx-pr"><span>' + bbEsc(x.name) + '</span><i style="--w:' + x.p + '%"></i><b>' + x.p + '%</b></div>';
$('#bxRulesBody').innerHTML = '<h4>宝宝卡(每盒必出 1 张)</h4>' + (pb.card || []).map(bar).join('') +
'<h4>随机奖励(每盒 1 份)</h4>' + (pb.reward || []).map(bar).join('') +
'<h4>规则</h4><ul>' + (pb.notes || []).map(t => '<li>' + bbEsc(t) + '</li>').join('') + '</ul>' +
'<p class="free">孕期盲盒完全免费,不出售、不需要任何付费。</p>';
}
$('#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 += '<i class="' + (i % 3 ? '' : 'r') + '" style="--c:' + C[i % C.length] + ';--x:' + Math.round(Math.cos(a) * d) + 'px;--y:' + Math.round(Math.sin(a) * d + 60) +
'px;--r:' + Math.round(Math.random() * 720 - 360) + 'deg;--d:' + (Math.random() * .15).toFixed(2) + 's"></i>';
}
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() + '<b>兜爸心选 · 孕期盲盒</b>';
$('#bxTag').textContent = fresh ? (flash ? '✦ 哇!开出了闪卡 ✦' : '新卡 · 孕 ' + card.week + ' 周') : (flash ? '✦ 闪卡 · 孕 ' + card.week + ' 周' : '孕 ' + card.week + ' 周');
$('#bxDev').textContent = card.dev || '';
const dl = card.diary || [];
$('#bxDiary').innerHTML = dl.length ? '<div class="bx-dy-h">📖 宝宝周记 · 孕 ' + card.week + ' 周</div>' + dl.map(x => '<p>' + bbEsc(x) + '</p>').join('') : '';
let rw = '';
if (reward) {
const tj = reward.type === 'taijiao';
rw = '<div class="bx-rw"><div class="bx-rw-h"><i>' + (tj ? '🎵' : '💌') + '</i>' + (tj ? '胎教时间 · ' + bbEsc(reward.kind || '') : '宝宝对你说') +
'<em>随机奖励</em></div>' + (reward.title ? '<div class="bx-rw-t">' + bbEsc(reward.title) + '</div>' : '') +
'<p class="bx-rw-p">' + bbEsc(reward.text) + '</p></div>';
}
$('#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 里放一张 <img>(装饰,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;
/* 11-03 用户:「孕期百问观看人数有点少」→ 单条「N 人看过」= 总人数 × heat × 3.6(约 12 万~64 万,仍低于百宝箱入口「孕期百问 93.6 万人看过」)。模拟数,AGENTS.md 12b,12-31 复核 */
const QA_K = 3.6;
const qaViews = h => ((window.__mbPulse && window.__mbPulse.total && +window.__mbPulse.total.value) || 1510000) * (+h || 0) * QA_K;
window.__mbQaViews = qaViews;
const MS_FIRE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12.6 2.5c.4 3-1.6 4.6-3 6.2C8 10.4 7 12 7 14.3A5 5 0 0012 19.5a5 5 0 005-5.2c0-2-1-3.6-1.9-4.6-.2 1.3-.9 2.2-1.9 2.6.6-3.4-.2-7-.6-9.8z" fill="currentColor"/><path d="M12 19.5a2.6 2.6 0 01-2.6-2.6c0-1.6 1.3-2.5 2-3.6.3 1 .9 1.6 1.6 1.9.9.4 1.6 1.2 1.6 2.1A2.6 2.6 0 0112 19.5z" fill="#FFD36B"/></svg>';
const MS_FIRE_W = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12.6 2.5c.4 3-1.6 4.6-3 6.2C8 10.4 7 12 7 14.3A5 5 0 0012 19.5a5 5 0 005-5.2c0-2-1-3.6-1.9-4.6-.2 1.3-.9 2.2-1.9 2.6.6-3.4-.2-7-.6-9.8z" fill="#FFE07A"/></svg>';
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 = (window.MB_AVA && MB_AVA.S.search) || AVA_TOP.concat(AVA_FEED), s0 = (W * 7) % pool.length; // 11-05 分配表 search
$('#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 + '<b></b>'; 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 !== false) ? 0 : qaBand(state.week);
$('#qaTitle').textContent = '其他孕妈也在搜';
/* 11-03 用户:「孕期百问列表太素、看的人少,做得有设计感一点」→
Banner 加头像叠 + 「N 万孕妈在这找答案」;前 3 条做成「本周热问」大卡(渐变名次 + 答案摘要);其余为卡片行:问题 + 分类彩签 + 火苗人数 + 一行答案摘要 */
const tot = (window.__mbPulse && window.__mbPulse.total && +window.__mbPulse.total.value) || 1510000;
const wan = n => n >= 1e4 ? (Math.round(n / 1e3) / 10) + '万' : String(Math.round(n));
$('#qaHeroW').textContent = band ? '孕 ' + state.week + ' 周' : '大家都在问';
$('#qaHeroT').textContent = '孕妈最常问的问题';
const QH = (window.MB_AVA && MB_AVA.S.qaHero) || AVA_TOP, s0 = ((state.week || 1) * 5) % QH.length; // 11-05 分配表 qaHero
$('#qaHeroD').innerHTML = '<span class="qa-avs">' + [0, 1, 2, 3, 4].map(i => avaImg(QH[(s0 + i * 2) % QH.length])).join('') + '</span>' + wan(tot * 0.62) + ' 孕妈在这找答案';
const QA_BD = i => i < 1 ? ['爆', 'bao'] : i < 7 ? ['热', 're'] : i < 10 ? ['新', 'xin'] : null;
const QA_EM = { 身体: '🤰', 准备: '📋', 产检: '🩺', 宝宝: '👶', 生活: '🏠', 营养: '🥗', 职场: '💼', 花钱: '💰', 心情: '💗', 趣味: '🎈', 补剂: '💊', 专题: '📌' };
const QA_KW = {
'生娃计算器,到底要花多少钱?': '生娃花费', '大排畸什么时候做?能看出什么?': '大排畸', '糖耐三个值只超了一个,算妊娠糖尿病吗?': '糖耐',
'四维彩超有必要做吗?': '四维彩超', '胎动多少算正常?怎么数?': '数胎动', '确诊妊糖后,一天测几次血糖?控制在多少?': '孕期控糖',
'肚子发紧是宫缩吗?': '宫缩', 'B 超说宝宝偏大或偏小,怎么办?': '胎儿大小', '补了钙还是半夜腿抽筋,还可能是什么原因?': '抽筋', '糖耐前一天怎么吃?': '糖耐饮食',
/* 11-04 用户:「效果可以,随机再放到 40 个,序号也要留着」→ 另随机 30 条(seed 1104),其余 60 条只显示序号 */
'孕期营养影响孩子智商吗?': '营养智商', '怀孕后什么时候去医院建档?': '建档', '孕前偏瘦、正常、偏胖,整个孕期各该长几斤?': '孕期体重',
'家里养了猫,怀孕要送走吗?弓形虫怎么查?': '养宠物', '孕多少周后航空公司不让登机?要开什么证明?': '出远门', '孕吐什么时候能结束?': '孕吐',
'生育津贴怎么领?能领多少?': '生育津贴', 'NT 值 2.5mm 算高吗?多少要进一步检查?': 'NT', '腿一按一个坑,水肿到什么程度要警惕?': '水肿',
'腰酸背痛,托腹带有用吗?从几周开始用?': '腰酸背痛', '孕期吃三文鱼刺身、生蚝安全吗?': '海鲜', '产假有多少天?怎么休更合适?': '产假',
'孕期能开空调、用电热毯吗?': '空调', '孕晚期还能自己开车吗?安全带该怎么系?': '开车', '孕期能泡澡、泡温泉吗?': '泡澡',
'32 周还是臀位,还有机会转过来吗?': '胎位', '到了预产期还没动静怎么办?': '过预产期', '宫缩几分钟一次,就该去医院了?': '临产信号',
'孕早期见红是要流产吗?': '见红', '不知道怀孕时吃了药、喝了酒怎么办?': '误吃药', '宫外孕有什么信号?': '宫外孕',
'早孕反应突然消失要紧吗?': '反应消失', '双胞胎是单绒还是双绒,差别有多大?': '双胞胎', '孕期一定要左侧睡吗?': '睡姿',
'B 超提示脐带绕颈一周,还能顺产吗?': '脐带绕颈', '什么时候开始休产假?': '休产假', '面试月嫂,一定要问哪几个问题?': '月嫂',
'新生儿衣服买 59 码还是 66 码?买几件够?': '宝宝衣物', '侧切、撕裂能避免吗?': '侧切', '催产素、水囊、人工破膜有什么区别?哪个更疼?': '催产',
/* 11-04 用户:「加到 60%,60 条有核心词」→ 再随机 20 条(seed 110402),其余 40 条只显示序号 */
'孕前一直在健身、跑步,怀孕后还能接着练吗?': '孕期运动', '叶酸吃到几个月能停?钙、铁、DHA 从几周开始补?': '补剂', '剖宫产前要准备什么?': '剖宫产',
'孕期要避开哪些成分?水杨酸、视黄醇能用吗?': '护肤', '孕期水果吃多少?哪些要少吃?': '吃水果', '验孕两道杠,接下来做什么?': '两道杠',
'唐筛高风险怎么办?': '唐筛', '孕期牙疼、牙龈出血能看牙吗?': '看牙', '孕 7 周还没看到胎心,是胎停了吗?': '胎心胎芽',
'第一次产检抽血查什么?': '抽血', '什么时候告诉领导和同事?': '告诉领导', '孕期要穿防辐射服吗?手机电脑有影响吗?': '防辐射',
'孕期能打流感疫苗吗?': '流感疫苗', '胎心监护 20 分钟没过,是宝宝有问题吗?': '胎心监护', '吃了补铁剂便秘更严重,怎么办?': '便秘',
'尿频、乳房胀痛正常吗?': '尿频', '孕晚期喘不上气、睡不好怎么办?': '喘不上气', '孕期动不动就想哭,是产前抑郁吗?怎么自测?': '孕期焦虑',
'孕期烧到 38.5℃,能吃对乙酰氨基酚吗?': '感冒发烧', '建档要带什么、查什么?': '建档材料'
};
/* 11-04 用户:「核心词方块的背景每个配一张小图(风景什么的都行)」→ assets/kw/kNN.webp(120×120,取自广场 Burst 免费商用图库,尽量贴题),文字白色压暗色蒙层 */
const QA_KP = {"生娃花费":"k00.webp","大排畸":"k01.webp","糖耐":"k02.webp","四维彩超":"k03.webp","数胎动":"k04.webp","孕期控糖":"k05.webp","宫缩":"k06.webp","胎儿大小":"k07.webp","抽筋":"k08.webp","糖耐饮食":"k09.webp","营养智商":"k10.webp","建档":"k11.webp","孕期体重":"k12.webp","养宠物":"k13.webp","出远门":"k14.webp","孕吐":"k15.webp","生育津贴":"k16.webp","NT":"k17.webp","水肿":"k18.webp","腰酸背痛":"k19.webp","海鲜":"k20.webp","产假":"k21.webp","空调":"k22.webp","开车":"k23.webp","泡澡":"k24.webp","胎位":"k25.webp","过预产期":"k26.webp","临产信号":"k27.webp","见红":"k28.webp","误吃药":"k29.webp","宫外孕":"k30.webp","反应消失":"k31.webp","双胞胎":"k32.webp","睡姿":"k33.webp","脐带绕颈":"k34.webp","休产假":"k35.webp","月嫂":"k36.webp","宝宝衣物":"k37.webp","侧切":"k38.webp","催产":"k39.webp","孕期运动":"k40.webp","补剂":"k41.webp","剖宫产":"k42.webp","护肤":"k43.webp","吃水果":"k44.webp","两道杠":"k45.webp","唐筛":"k46.webp","看牙":"k47.webp","胎心胎芽":"k48.webp","抽血":"k49.webp","告诉领导":"k50.webp","防辐射":"k51.webp","流感疫苗":"k52.webp","胎心监护":"k53.webp","便秘":"k54.webp","尿频":"k55.webp","喘不上气":"k56.webp","孕期焦虑":"k57.webp","感冒发烧":"k58.webp","建档材料":"k59.webp"};
const QA_IC = {
全部: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
孕周: '<path d="M12 3a9 9 0 110 18 9 9 0 010-18zm-1 4v6l5 3 1-1.6-4-2.4V7z"/>',
身体: '<circle cx="12" cy="5" r="2.6"/><path d="M8.6 9h4.2c3.4 0 5.7 2.4 5.7 5.3 0 2.5-1.9 4.2-4.3 4.2H13V22h-3v-5.5L8 15V10z"/>',
准备: '<path d="M9 3h6v2h3a1 1 0 011 1v15a1 1 0 01-1 1H6a1 1 0 01-1-1V6a1 1 0 011-1h3zM8 11h8v-2H8zm0 4h8v-2H8zm0 4h5v-2H8z"/>',
产检: '<path d="M10 3h4v7h7v4h-7v7h-4v-7H3v-4h7z"/>',
宝宝: '<path d="M12 3a9 9 0 110 18 9 9 0 010-18zM9 10.2a1.2 1.2 0 100 2.4 1.2 1.2 0 000-2.4zm6 0a1.2 1.2 0 100 2.4 1.2 1.2 0 000-2.4zM9 15.2c1.7 1.6 4.3 1.6 6 0l-1-1c-1.1 1-2.9 1-4 0z" fill-rule="evenodd"/>',
生活: '<path d="M12 3l9 8h-2.5v10h-5v-6h-3v6h-5V11H3z"/>',
营养: '<path d="M15 2.5c.3 1.9-.6 3.4-2.2 4.2C12 5.4 12.3 3.4 15 2.5zM12 7.8c1.3-1 2.8-1.4 4.2-1.1 2.6.5 3.8 3.1 3.6 6-.3 4.2-3 8.3-5.4 8.3-.9 0-1.6-.5-2.4-.5s-1.5.5-2.4.5c-2.4 0-5.1-4.1-5.4-8.3-.2-2.9 1-5.5 3.6-6 1.4-.3 2.9.1 4.2 1.1z"/>',
职场: '<path d="M9 4h6a1 1 0 011 1v2h4a1 1 0 011 1v11a1 1 0 01-1 1H4a1 1 0 01-1-1V8a1 1 0 011-1h4V5a1 1 0 011-1zm1 3h4V6h-4z"/>',
花钱: '<path d="M12 3a9 9 0 110 18 9 9 0 010-18zm-2.6 4.5L8.2 8.6 10.6 11H8.5v1.6H11v1.2H8.5v1.6H11V18h2v-2.6h2.5v-1.6H13v-1.2h2.5V11h-2.1l2.4-2.4-1.2-1.1L12 10.1z" fill-rule="evenodd"/>',
心情: '<path d="M12 20.5s-8-4.8-9.5-9.8C1.5 7.2 3.8 4 7.2 4c2 0 3.7 1.1 4.8 2.7C13.1 5.1 14.8 4 16.8 4c3.4 0 5.7 3.2 4.7 6.7-1.5 5-9.5 9.8-9.5 9.8z"/>',
趣味: '<path d="M12 2.8l2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 16.8l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z"/>',
补剂: '<path d="M3.8 13.6l6.4-6.4a4.5 4.5 0 016.4 6.4l-6.4 6.4a4.5 4.5 0 01-6.4-6.4zm7.8-5l-3.5 3.5 4.3 4.3 3.5-3.5a3 3 0 00-4.3-4.3z" fill-rule="evenodd"/>',
专题: '<path d="M6 3h12a1 1 0 011 1v17l-7-4-7 4V4a1 1 0 011-1z"/>'
};
const QA_TC = { 身体: 'c1', 准备: 'c2', 产检: 'c3', 宝宝: 'c4', 生活: 'c5', 营养: 'c6', 职场: 'c2', 花钱: 'c6', 心情: 'c1', 趣味: 'c4', 补剂: 'c3', 专题: 'c5' };
$('#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;
/* 11-04 用户:「标签前面的小图标全部扁平化」→ emoji 换成实心单色 SVG(QA_IC),颜色跟分类彩签同色,选中时变白 */
const ik = c === '__st' ? '孕周' : c;
b.innerHTML = (QA_IC[ik] ? '<svg class="qa-ci ' + (QA_TC[c] || 'c0') + '" viewBox="0 0 24 24" aria-hidden="true">' + QA_IC[ik] + '</svg>' : '') + (c === '__st' ? '孕 ' + state.week + ' 周' : c);
b.addEventListener('click', () => qaFilter(c));
cb.appendChild(b);
});
const L = $('#qaList');
L.textContent = '';
const sec = (t, d) => { const h = document.createElement('div'); h.className = 'qa-sec'; h.innerHTML = '<b>' + t + '</b><small>' + d + '</small>'; L.appendChild(h); };
const snip = a => (a || '').replace(/\s+/g, ' ').trim();
order.forEach((x, i) => {
if (i === 0) sec('<svg class="qa-fi" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21c-4 0-6.5-2.7-6.5-6.2 0-3.6 2.9-5.6 3.6-9.3 2.6 1.4 3.6 3.6 3.6 5.4 1-.6 1.6-1.8 1.7-3 2.4 1.9 4.1 4.3 4.1 7 0 3.5-2.5 6.1-6.5 6.1z"/></svg>本周热问', '看的人最多的 3 个问题');
if (i === 3) sec('全部问题', '点开就有答案');
const it = document.createElement('div');
/* 11-04 用户:「1~100 的卡片都是白色块,看着疲劳」→ 非前 3 条按分类铺一层很淡的同色系底色 */
it.className = 'qa-it' + (i < 3 ? ' t3 r' + (i + 1) : ' t' + (QA_TC[x.tag] || 'c5'));
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 < 9 ? '0' + (i + 1) : i + 1;
/* 11-04 用户:「全是字,从标题里提炼 2~4 个核心字做成前面的小图标,扫一眼就知道讲啥」→ 先做孕 26 周前 10 条看效果(QA_KW),
有关键词的条目在序号后面加关键词方块(底色 / 字色跟分类彩签同色系);没配关键词的只有序号 */
const kw = QA_KW[x.q];
if (kw) {
it.classList.add('kw');
const kt = document.createElement('i');
kt.className = 'qa-kw ' + (QA_TC[x.tag] || 'c5') + ' n' + kw.length;
kt.innerHTML = '<span>' + (kw.length === 4 ? kw.slice(0, 2) + '<br>' + kw.slice(2) : kw) + '</span>';
if (QA_KP[kw]) { kt.classList.add('ph'); kt.style.backgroundImage = 'url(assets/kw/' + QA_KP[kw] + ')'; kt.innerHTML = '<span>' + kw + '</span>'; } /* 带图的字放底条,一行不换行 */
it._kw = kt; /* 序号照留(11-04 用户要求),关键词方块排在序号后面 */
}
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);
const mt = document.createElement('span'); mt.className = 'qa-mt';
if (x.tag) mt.insertAdjacentHTML('beforeend', '<em class="qa-tg ' + (QA_TC[x.tag] || 'c5') + '">' + x.tag + '</em>');
if (+x.heat) mt.insertAdjacentHTML('beforeend', '<span class="qa-st">' + MS_FIRE + '<b>' + wan(qaViews(x.heat)) + '</b>人看过</span>');
t.appendChild(mt);
const sn = snip(x.a);
if (sn) { const p = document.createElement('span'); p.className = 'qa-sn'; p.textContent = sn; t.appendChild(p); }
else if (x.href) { const p = document.createElement('span'); p.className = 'qa-sn go'; p.textContent = '点开看专题,一次讲清楚 ›'; t.appendChild(p); }
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 = '›';
/* 11-04 用户:「序号在上、关键词块在下,右边放标题」→ 有关键词的条目左边是一竖列(序号 + 关键词块) */
/* 11-04 用户:「带图和不带图的标题要上下对齐」→ 没关键词的也包一层同宽的左列,标题都从同一条线起 */
{ const col = document.createElement('span'); col.className = 'qa-lc'; col.appendChild(no); if (it._kw) col.appendChild(it._kw); h.appendChild(col); }
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(); const u = qaHref(x.href); if (!openInHost(u)) location.href = u; 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;
const its = L.querySelectorAll('.qa-it');
if (fi >= 0 && its[fi] && !bank[focus].href) {
const c = its[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));
const L = $('#qaList'); if (L) L.classList.toggle('flt', c !== '全部'); /* 筛选时不分「本周热问 / 全部问题」两段 */
}
(function () {
const all = $('#msAll'); if (all) all.addEventListener('click', () => loadQA().then(() => openQA())); /* 11-02 合并版里原模块可能没渲染过,先确保问题库到手 */
/* 11-03 首页「孕期百问」入口(js/kit.js)打开同一个全部问题页,标题换成「孕期百问」 */
/* 11-04 用户:从孕期百宝箱进百问,小程序最上边(取 document.title)也要是「孕期百宝箱」,关掉还原 */
window.__mbOpenQA = t => loadQA().then(() => {
openQA(); if (t) $('#qaTitle').textContent = t;
const d = $('#detailQA'); if (t === '孕期百问' && d && d.dataset.t0 == null) { d.dataset.t0 = document.title; document.title = '孕期百宝箱'; }
});
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 = {}; /* 11-07 用户:没记过的卡不显示数据(原示例数 胎动12 / 体重58.2 / 血压115/75 / 血糖5.1 停用) */ /* 这两格有记录才出现 */
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(); const t = withIdentity(location.origin + path); if (!openInHost(t)) location.href = t;
}));
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];
/* 11-05 资料改由数胎动后台提供(loadPerks),box_app 那份内置 8 条不再覆盖 RES */
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 = [
[/胎动/, '<path d="M10.5 21c-2.6 0-4-2-3.6-4.6l.6-3.6c.4-2.5 1.9-4.1 3.9-4.1 2.2 0 3.4 1.8 3 4.3l-.7 4.1c-.4 2.4-1.5 3.9-3.2 3.9z"/><circle cx="8.3" cy="4.8" r="1.4"/><circle cx="11.6" cy="3.9" r="1.2"/><circle cx="14.6" cy="4.7" r="1.1"/><circle cx="16.8" cy="6.6" r="1"/>'],
[/宫缩/, '<path d="M3 13h3l2-5 3 10 3-12 2.5 7H21"/>'],
[/体重/, '<rect x="4" y="4" width="16" height="16" rx="4"/><path d="M8.5 10a3.5 3.5 0 017 0z"/><path d="M12 10l1.4-2"/>'],
[/血糖/, '<path d="M12 3.5s-5.5 6.2-5.5 10.2a5.5 5.5 0 0011 0C17.5 9.7 12 3.5 12 3.5z"/><path d="M9.8 14.2a2.3 2.3 0 002.2 2.1"/>'],
[/血压|胎心/, '<path d="M12 19.5s-7.5-4.6-7.5-10A4.2 4.2 0 0112 7a4.2 4.2 0 017.5 2.5c0 5.4-7.5 10-7.5 10z"/><path d="M6 12h3l1.5-2.5 2 4 1.5-1.5H18"/>'],
[/运动/, '<circle cx="13" cy="4.6" r="1.7"/><path d="M10 21l2-5.5-2.5-2.5 1.5-5 3.5 3 3 .5"/><path d="M8 11l2.5-3.5"/><path d="M14.5 21l-2.5-5.5"/>'],
[/产检|孕检|日历/, '<rect x="4" y="5.5" width="16" height="14.5" rx="3"/><path d="M4 10h16M8.5 3.5v4M15.5 3.5v4"/><path d="M9 14.6l2 2 4-4"/>'],
[/待产/, '<path d="M5.5 9h13l-1 11h-11z"/><path d="M9 9V7a3 3 0 016 0v2"/><path d="M12 16.6s-2.2-1.3-2.2-2.7a1.1 1.1 0 012.2-.3 1.1 1.1 0 012.2.3c0 1.4-2.2 2.7-2.2 2.7z"/>'],
[/能不能吃|饮食|营养/, '<path d="M4 12h16a8 8 0 01-16 0z"/><path d="M9 8.5c0-1.5 1-1.8 1-3.2M13 8.5c0-1.5 1-1.8 1-3.2"/>'],
[/能不能做/, '<circle cx="12" cy="12" r="8"/><path d="M9.8 9.6a2.3 2.3 0 114 1.6c-.9.7-1.8 1.1-1.8 2.3"/><path d="M12 16.4v.1"/>'],
[/估重/, '<circle cx="12" cy="13" r="7"/><path d="M12 13l3-3"/><path d="M9 3.5h6"/>'],
[/补水|补剂|喝水/, '<path d="M7 4h10l-1.3 15a2 2 0 01-2 1.8h-3.4a2 2 0 01-2-1.8z"/><path d="M7.6 10h8.8"/>'],
[/起名/, '<path d="M5 19l3.5-1 9.8-9.8a1.8 1.8 0 00-2.5-2.5L6 15.5z"/><path d="M14.5 7l2.5 2.5"/>'],
[/档案/, '<path d="M6 3.5h8l4 4V20H6z"/><path d="M14 3.5v4h4M9 12h6M9 15.5h6"/>'],
];
const TD_DEF = '<path d="M5 19l3.5-1 9.8-9.8a1.8 1.8 0 00-2.5-2.5L6 15.5z"/><path d="M14.5 7l2.5 2.5"/>';
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 = '<i class="td-li" aria-hidden="true"><svg viewBox="0 0 24 24">' + todoIco(name) + '</svg></i><span class="td-txt"><b></b><em></em></span>' +
(href ? '<span class="td-go">去完成</span>' : '');
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()];
/* 只认已确认的孕周:未建档/没加载出来时 state.week 是默认值(实测「孕26周+6天」,2026-11-08) */
const wk = (state.week && state.pregEmpty === false && !state.postpartum) ? '孕' + 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 演示行同构(时间/圆点/文案/来源)。
文案里的数字段加粗对齐演示排版(「数胎动 <b>2</b> 次」),
全程 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 = '<svg viewBox="0 0 16 22" width="12" height="16" aria-hidden="true"><ellipse cx="8.4" cy="13.6" rx="4.6" ry="6.6" fill="currentColor"/><circle cx="3.6" cy="4.6" r="1.7" fill="currentColor"/><circle cx="6.6" cy="2.6" r="1.6" fill="currentColor"/><circle cx="9.8" cy="2.4" r="1.5" fill="currentColor"/><circle cx="12.6" cy="3.8" r="1.3" fill="currentColor"/></svg>';
/* 无足迹占位行:同构的一行,读屏和眼睛都不会把空卡当成坏卡。 */
/* ---------- 进商城(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 段 = 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);
if (openInHost(url)) return;
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 = rawParam('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('已退出登录');
if (window.mbGate) window.mbGate.set(true); /* 11-07:本机立刻记下,重载后首屏就是访客样子 */
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() {
if (window.__mbDeepAt && Date.now() - window.__mbDeepAt < 3000) return; /* ?go=me 直达后的首次 pageshow 不重置 */
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);
})();