Files
mama-box/js/app.js

4970 lines
285 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 = '26100173';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
/* ---------- 孕期日期算术 ----------
这一小块原来长在下面「孕期信息」那一节里(DOM 事件旁边)。挪到最前面,
是因为默认预产期和孕周要在脚本一加载就算出来(下面「孕期信息」和 AI 小结
的日期推算都在用)。
它们全是纯数学,一个 DOM 都不碰,放最前面不会有副作用;
留在原地则 state.week 只能先塞个占位数,等 init() 再补 —— 中间那段时间
任何按孕周算日期的代码拿到的都是假数据。 */
const DAY_MS = 86400000, TERM = 280; // 足月 40 周 = 280 天
const midnight = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; };
const clampGA = g => Math.max(0, Math.min(42 * 7, g));
const gaFromDue = due => clampGA(TERM - Math.round((midnight(due) - midnight(new Date())) / DAY_MS));
const dueFromGA = ga => new Date(midnight(new Date()).getTime() + (TERM - clampGA(ga)) * DAY_MS);
function addMonths(d, n) {
const x = new Date(d.getFullYear(), d.getMonth() + n, d.getDate());
/* 8月31日 +3 个月 = 11月31日,JS 会溢出到 12月1日,退回当月最后一天 */
if (x.getDate() !== d.getDate()) x.setDate(0);
return x;
}
/* 日期显示:fmtWhen 给首页「宝宝来信」提醒卡用(未读接口只给时间戳,当天就写「今天」) */
const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const pad2 = n => (n < 10 ? '0' : '') + n;
function fmtWhen(ts) {
const d = new Date(ts), now = new Date();
const hm = d.getHours() + ':' + pad2(d.getMinutes());
return d.toDateString() === now.toDateString()
? '今天 ' + hm : (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + hm;
}
/* ---------- 默认预产期:打开这一刻往后推 3 个月 ----------
原来写死 2024-12-22,换个年份进来就是一个早已过去的日期,
孕周会被 clampGA 顶到 42 周封顶,等于一进门就「超预产期」了。
改成相对今天推 3 个月:无论哪天第一次打开,默认都落在孕 27 周上下,
是个说得通的起点。用户自己选过预产期之后,这里就不再参与。 */
const DEFAULT_DUE = addMonths(midnight(new Date()), 3);
const DEFAULT_GA = gaFromDue(DEFAULT_DUE);
/* ---------- 全局状态 ---------- */
const state = {
screen: 'home',
kickToday: 0, /* ⚠️ 原为演示值 3:访客态/接口失败时它会直接落到首页数胎动卡上,
现已改 0 且 HTML 默认渲染 '--'(真实值由 __applyRecords 写) */
kickQuick: 1,
todayCount: 4,
favCount: 0, /* ⚠️ 原为演示值 18(与屏幕上的星标数不符);
启动时按 DOM 里 .star.is-on 的实际个数回填,见「珍贵时刻收藏」那段 */
resCat: 'all',
resKey: 'book',
resFav: [], // 已收藏的资源 key,数组顺序即收藏顺序
babyName: '小汤圆',
/* 这三项一进门就是真值(默认预产期 = 今天 +3 个月,见上面 DEFAULT_DUE)。
以前它们是写死的占位 24/3/null,等 init() 才补 —— 但那之前就有按孕周
现算的展示(首页孕周环、档案页),拿到的就全是假的。 */
week: Math.floor(DEFAULT_GA / 7), day: DEFAULT_GA % 7,
due: DEFAULT_DUE,
babySex: 'unknown',
/* 产后态(2026-09-22,来自上游 journey_stage=postpartum / baby_birth_date):
已生产的用户不能再按预产期算孕周(会得到「孕 42 周」这种荒谬值),
改显示「宝宝已出生 N 天」。读写方:applyRemoteProfile 的 /pregnancy 分支 + syncPregnancy。 */
postpartum: false,
babyAgeTxt: '', // 「宝宝已出生 25 天」/「宝宝已出生 3 个月」(服务端算好)
babyAgeDays: null, // 出生天数(没有出生日期时为 null)
babyBirthDate: '',
/* ⚠️ 这里原来还有一条 babyAvatar(来信列表那颗宝宝头像的 data URI)。
它的读写方全在内置来信屏里,随该屏一起删除 —— 别再只加字段不加读写方,
那正是下面 userBio 那条注释警告过的「最难发现的死代码」。
将来要恢复它,除了 state 字段,还得补上「选图 → 压缩(canvas 缩到 ~128px
再 toDataURL,原图可能几 MB)→ localStorage 落盘」这几步。 */
userName: '小满妈妈',
/* ⚠️ 这里原来还有一条 userBio(一句话签名)。整块已按用户要求删除,
连同 #pfBio 输入框、#meBio 展示行、syncBio()。别只加回这一条 ——
一个没人读的 state 字段是最难发现的死代码。 */
/* ⚠️ 这里原来还有一条 wxUnlocked(兑换码解锁态)。2026-09-19(9.19 需求)
「资料改由顾问直接发」之后解锁概念整体取消,它连同
WXOK_KEY / saveWxUnlocked / syncUnlockCopy 一起删了,别再加回来。 */
};
/* ---------- 资源的收藏(跨会话保留) ----------
只存 key,资源本身仍从 RES 读,改文案不会让旧记录失效。
⚠️ 原来还有一整套「已领」:state.resGot / RESGOT_KEY / isGot() / 列表页的
「已领 ✓」标与筛选 chip / 详情页的「再拿一次链接」。9.19 需求取消了
「点立即领取就到手」这个动作(资料由顾问在微信里直接发),H5 侧不再记录
「领过哪几份」,整块连同 localStorage 'mmbox.resgot.v1' 一起删除。
要恢复得先回问需求方。 */
const RESFAV_KEY = 'mmbox.resfav.v1';
const loadKeys = (k, into) => {
try {
const arr = JSON.parse(localStorage.getItem(k) || 'null');
if (Array.isArray(arr)) state[into] = arr.filter(x => typeof x === 'string');
} catch (e) { /* 隐私模式下会抛错,忽略即可,只是不跨会话 */ }
};
loadKeys(RESFAV_KEY, 'resFav');
function saveResFav() {
try { localStorage.setItem(RESFAV_KEY, JSON.stringify(state.resFav)); } catch (e) {}
}
/* ---------- Toast ---------- */
const toastEl = $('#toast');
let toastTimer;
function toast(msg) {
if (!msg) return;
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800);
}
function haptic() { if (navigator.vibrate) navigator.vibrate(8); }
/* ---------- 涟漪 ---------- */
document.addEventListener('pointerdown', e => {
const t = e.target.closest('.ripple');
if (!t) return;
const r = t.getBoundingClientRect();
const size = Math.max(r.width, r.height) * 2.1;
const sp = document.createElement('span');
sp.className = 'rp';
sp.style.cssText = `width:${size}px;height:${size}px;left:${e.clientX - r.left}px;top:${e.clientY - r.top}px`;
t.appendChild(sp);
setTimeout(() => sp.remove(), 600);
});
/* ---------- 通用 data-toast ---------- */
document.addEventListener('click', e => {
const t = e.target.closest('[data-toast]');
if (t) { toast(t.dataset.toast); haptic(); }
});
/* ================= 底部导航 / 页面切换 ================= */
const screens = $$('.screen');
const tabs = $$('.tab[data-tab]');
/* 平滑滚顶:老旧 WebView 不支持 Element.scrollTo(options),降级为直接赋值 */
function scrollTop(el) {
if (!el) return;
try { el.scrollTo({ top: 0, behavior: 'smooth' }); }
catch (e) { el.scrollTop = 0; }
}
/* 「档案」tab 一期跳独立 H5。第九批(2026-09-16)按用户要求把落点从
「孕期档案管理 yunqi-dangan」改成「孕检日历 chanjian-rili」。
⚠️ 地址必须走业务域名 mp.yizhezhe.com(小程序 web-view 只放行业务域名,
taidong.douba.top 直连会被拦),所以这里用的是 nginx 反代入口,不是源站直连。
⚠️ 内置档案屏(#screen-archive)依旧屏蔽:数据来源未确认,不放进一期给用户。
屏的代码/样式全部保留,后续确认数据源后把这里改回 go('archive') 原逻辑即可。
身份转发:把当前 openid/unionid 带过去(query + hash 双写,对齐身份注入惯例)。 */
/* 换页/换应用前向小程序宿主上报「即将进入的地址」(分享卡片状态同步)。
背景:web-view 里的 H5 不能自己拉起分享面板,宿主只能靠 onShareAppMessage(options).webViewUrl
拿到当前地址,而 onShareTimeline 没有回调参数;首页壳也不会随 H5 换页重新 onLoad。
postMessage 非实时(微信在「分享 / 后退」等时机才派发),届时 web-view 已停在该地址。
协议见 box_app/docs/h5-auth-standard.md 第四节。 */
function reportHostLocation(url) {
if (window.wxAuth && window.wxAuth.reportLocation) window.wxAuth.reportLocation({ href: url });
}
/* ================= H5 宿主恢复:冷启动回到上次进入的 H5 应用(2026-09-22) =================
需求:在小程序里进入某个 H5 应用后退出小程序,再次进入要恢复那个应用。
数据源:box_app GET /api/mp/public/last-app?openid=(Redis,per-openid,7 天 TTL)
· 写入 last_app:jumpApp / goArchiveH5 换应用前 reportLastApp()(sendBeacon,实时可靠)
· 读取恢复:resumeLastApp() 在 init() 首屏跑一次,命中就 location.replace 过去
闸门:sessionStorage 一次 —— 冷启动(web-view 上下文新建)才恢复;
同一会话内宿主 onShow 的 ?t= 重载 / 用户切回首页 tab 都不会再跳。
⚠️ 用 replace 不用 href:不留 history 条目,back 不会来回弹。
⚠️ 只上报/恢复「净地址」(不带 openid/unionid):身份必须按**当前**用户重新注入,
绝不能把记录那一刻的身份带过去(服务端也会再 sanitize 一遍兜底)。
⚠️ 服务端有 App 表在架校验,所以恢复不到已下架的应用;Redis 挂掉时读到空 → 留在首页。 */
const LAST_APP_API = '/api/mp/public/last-app';
const RESUME_DONE_KEY = 'dsh_resume_done';
function urlBase(u) {
return String(u || '').split('#')[0].split('?')[0].replace(/\/+$/, '');
}
/* 换应用前上报「即将进入的 H5 应用」。
⚠️ sendBeacon:这一步后面紧跟 location.href 整页跳转,普通 fetch 会被取消;
sendBeacon 不阻塞导航且由浏览器保证投递(同源 POST,参数走 query,
因为 sendBeacon 发的是 text/plain,带 JSON body 会被 FastAPI 拒)。 */
function reportLastApp(url, identifier) {
try {
if (!url) return;
const oid = hashParam('openid');
if (!oid) return; // 没有身份键,服务端无法落到 per-openid 记录:跳过(不影响跳转)
const qs = ['openid=' + encodeURIComponent(oid), 'url=' + encodeURIComponent(url)];
if (identifier) qs.push('identifier=' + encodeURIComponent(identifier));
const target = LAST_APP_API + '?' + qs.join('&');
if (navigator.sendBeacon && navigator.sendBeacon(target)) return;
if (window.fetch) { fetch(target, { method: 'POST', keepalive: true }).catch(function () {}); return; }
} catch (e) { try { console.warn('[resume] reportLastApp fail:', e); } catch (e2) {} }
}
/* 首屏恢复:读一次 last_app,命中就带着当前身份 replace 过去。 */
function resumeLastApp() {
try {
if (sessionStorage.getItem(RESUME_DONE_KEY)) return; // 同一 web-view 会话只尝试一次
/* 预览页(/mama-box-preview/)是直接发链接给人看的,不在小程序里冷启动 —— 不恢复上次应用,
否则一打开就被送去上次进过的应用(2026-10-01 用户反馈:打开预览链接直接跳到孕检日历)。 */
if (location.pathname.indexOf('/mama-box-preview/') === 0) return;
const oid = hashParam('openid');
if (!oid) return; // 无身份不跳(宿主拿不到 openid 时也不会加载本页)
/* 先置闸门再请求/再判 norestore:
· #norestore=1 = 小程序首页壳**已经自己决策过**了(它直接把用户送进了目标应用,
本页只是它留在身后的「返回落点」)—— 这里既不跳,也要用闸门占住,
免得后续宿主 ?t= 重载(不带该标记)时又跳一次,把返回落点也换成目标应用。
· 浏览器直访 / 首页壳取数失败后回落加载本页时,没有该标记 → 照旧自行恢复(兜底)。 */
sessionStorage.setItem(RESUME_DONE_KEY, '1');
if (hashParam('norestore')) return;
fetch(LAST_APP_API + '?openid=' + encodeURIComponent(oid))
.then(r => (r.ok ? r.json() : null))
.then(d => {
const rec = d && d.result;
if (!rec || !rec.url) return;
if (urlBase(rec.url) === urlBase(location.href)) return; // 防呆:记录指向本页自身就不跳
const target = withIdentity(rec.url); // 用当前 openid/unionid 重新注入
console.log('[resume] 恢复上次 H5 应用:', rec.identifier || rec.url);
reportHostLocation(target); // 让宿主分享卡片同步到目标地址
location.replace(target);
})
.catch(function () {});
} catch (e) {}
}
/* ================= 返回首页 → 清除 last_app(2026-09-22) =================
问题:用户进入 H5 应用后又退回 mama-box 首页,但记录还停在那个应用上 ——
下次冷启动(普通入口)会把他又送回刚退出的应用(用户实测)。
记录的是「最后进入的 H5 应用」,用户已经回到首页,那首页才是他最后待的地方 → 必须清掉。
⚠️ 不能用「本页每次加载都清」:宿主恢复时会把 mama-box 当返回落点加载(#norestore=1),
那时清掉会把刚用上的记录毁掉。
所以只认「这次是返回回到本页」的三种情形:
① bfcache 恢复:pageshow 事件 persisted === true(脚本不重跑,只有这个事件)
② 后退导致整页重载:navigation.type === 'back_forward'
③ 从别的 H5 应用页以 location.href 回本页:document.referrer 是 https://mp.yizhezhe.com/<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-01 用户要求「档案」Tab 改跳孕期档案 H5(会话 19875b1b 提供)。
⚠️ 对方说明:目前是设计演示版(写死演示数据),还不读 URL 参数;接真实数据时按 ?openid=&unionid= 读,
所以这里照旧拼上。无 return_url,返回靠宿主自带返回。原来跳的是 /chanjian-rili/(孕检日历)。 */
const ARCHIVE_H5 = 'https://taidong.douba.top/chengzhang-dangan/v5/';
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));
if (q.length) u += '?' + q.join('&');
reportLastApp(ARCHIVE_H5, 'chengzhangdangan');
reportHostLocation(u);
location.href = u;
}
/* ================= 「记一下」弹层 · 全部孕期工具 = 已上架应用列表 =================
数据:box_app /api/mp/apps(应用管理统一维护图标/名称/链接)。
范围:isPublished=1 且有 url。**前端不做任何额外过滤** ——
谁能出现在这里,完全由后台「应用管理」的上架/下架(isPublished)人工决定。
⚠️ 2026-09-20 按需求去掉了原来那条 `!/wx-h5-demo/i` 过滤(它单独屏蔽「H5应用授权示例」)。
去掉的两个理由,别再加回来:
1)同一个应用在别的端(商城小程序「我的应用」直接铺全量)是可见的,
只在 mama-box 屏蔽会造成「同一份数据、两个端不一样」;
2)mama-box 端要**用这个应用排查「openid 为空」**的场景(授权示例页本身就是干这个的),
前端把它藏掉就没法从宫格进去。
真要让某个应用不出现,去后台下架它,别在前端写名单 —— 名单会漂。
⚠️ 那个过滤是按 URL 正则匹配的,本身就是脆的:demo 换个域名就自动失效。
交互:全量铺进弹层宫格(沿用 .apps-grid/.app 样式),点击带身份跳转对应 H5。 */
function appTileColor(name) {
const PALETTE = ['#F2917A', '#E8A2B8', '#8FB4E3', '#7FC8A9', '#E3B771', '#A99BD1', '#73BFB2', '#DE8F6E'];
let h = 0;
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
/* 给目标 H5 拼上当前身份(openid/unionid,query 形态)。
⚠️ 不再追加 #openid —— 部分应用自带 hash 路由(如 /baobao-yingji),追加 hash 会被当
非法路由 → 页面未找到。宿主每次加载/onShow 会重新注入身份兜底;
hash 仅保留作为「授权回传结果」(#phone/nickname/avatar)的通道。 */
function withIdentity(url) {
const oid = hashParam('openid');
const uid = hashParam('unionid');
const q = [];
if (oid) q.push('openid=' + encodeURIComponent(oid));
if (uid) q.push('unionid=' + encodeURIComponent(uid));
/* ⚠️ 2026-09-20:一个身份都没有时**显式标记**,别让目标应用/日志去猜
「是宿主没注入身份」还是「参数在路上被平台丢了」。
· 被跳的 H5 可以据此显示自己的身份提示(或走自己的兜底);
· nginx/后端日志里 __no_identity=1 一眼可查(排查 openid 为空时很快)。
排查背景见 docs/mama-box-change-list-20260919.md 与 showIdNotice() 的注释。 */
if (!oid && !uid) q.push('__no_identity=1');
return q.length ? url + (url.indexOf('?') >= 0 ? '&' : '?') + q.join('&') : url;
}
/* 跳转到其它 H5 应用。identifier 可选(来自 App 表),用于 last_app 上报的服务端在架校验;
缺省时服务端按 URL 前缀反查,效果一致。 */
function jumpApp(url, identifier) {
haptic();
const target = withIdentity(url);
reportLastApp(url, identifier); // 净地址:恢复时身份按当前用户重新注入
reportHostLocation(target);
location.href = target;
}
function renderAppGrid(apps) {
const grid = $('#appsGrid');
if (!grid) return;
grid.innerHTML = apps.map(a => {
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-url="' + encodeURIComponent(a.url) + '" data-id="' + encodeURIComponent(a.identifier || '') + '">' + img + name + '</button>';
}).join('');
/* 磁贴点击(事件委托,innerHTML 重建后不用重绑) */
grid.onclick = function (e) {
const t = e.target.closest('.app');
if (t && t.dataset.url) jumpApp(decodeURIComponent(t.dataset.url), 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 + ' 个孕期工具';
}
}
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 为空的场景,且它的可见性该由后台上下架决定。
详见上面那段注释;别在前端重新引入名单。 */
const apps = items
.filter(a => a.url)
.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 */
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 petals() {
const box = $('#petals');
if (!box) return;
for (let i = 0; i < 10; i++) {
const p = document.createElement('i');
p.style.left = Math.random() * 100 + '%';
p.style.animationDuration = (6 + Math.random() * 6) + 's';
p.style.animationDelay = (Math.random() * 8) + 's';
p.style.opacity = 0.3 + Math.random() * 0.5;
const s = 5 + Math.random() * 7;
p.style.width = s + 'px'; p.style.height = s + 'px';
box.appendChild(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));
$$('[data-sheet="close"]').forEach(b => b.addEventListener('click', closeSheet));
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)
};
const APP_BY_ID = {};
$$('[data-mini]').forEach(b => b.addEventListener('click', () => {
const map = { kick: '数胎动', weight: '孕期体重', cal: '孕检日历', curve: '数宫缩', fweight: '胎儿估重' };
const app = APP_BY_ID[MINI_APP_MAP[b.dataset.mini]];
if (app && app.url) { jumpApp(app.url, app.identifier); return; }
toast('「' + map[b.dataset.mini] + '」即将上线');
}));
/* 记一下弹层只保留「全部孕期工具」,不再提供输入框 */
/* ================= 滑入详情页(栈式,可多层返回) ================= */
const panelStack = [];
function openPanel(id, trigger) {
const el = document.getElementById(id);
if (!el) return;
/* 这一页此刻在栈里的位置:-1 = 没开过,length-1 = 正停在最上层,其余 = 开着但被压住了 */
const at = panelStack.indexOf(el);
if (id === 'detailAI') renderAI(0);
if (id === 'detailResList') renderResList(state.resCat);
if (id === 'detailRes') fillRes((trigger && trigger.dataset.res) || 'book');
if (id === 'detailSetting') renderSetting((trigger && trigger.dataset.set) || 'privacy');
haptic();
/* 已经停在最上层(例:内容页里点「相关推荐」)→ 只换内容,不动层级。
⚠️ `at >= 0` 这半句不能省:栈空时 at 和 length-1 都是 -1,
只写 `at === panelStack.length - 1` 会把**第一次打开任何一页**也判成
「已在最上层」而早退 —— 整个 App 的详情页全部打不开。写这一版时就栽在这儿,
是 smoke 里那条「列表和详情页显示的是同一句」先崩的(#dlThread 根本没渲染)。 */
if (at >= 0 && at === panelStack.length - 1) return;
/* ⚠️ 这里原来是一句 `if (reopen) return;`,把「在最上层」和「被压住」当成了同一回事,
后者会**静默失败**:
首页书架 → detailRes(栈 = [detailRes])→ 点「其他资源」→ 栈 = [detailRes, detailResList],
此时点资源库里任何一份资源 → detailRes 已在栈内 → fillRes() 把**底下那页**的内容悄悄换掉
→ haptic() 照常震一下 → return。资源库纹丝不动,整列资源看着像全坏了;
而且底下那页的内容已经被掉包,返回时看到的不是当初点进去的那一份。
下面 #rdLib 那段注释早就写过同一个陷阱(「它会静默 return,页面纹丝不动,看着就像按钮坏了」),
但当时只堵了「从详情页去资源库」这一个方向,反方向漏了。
所以「开着但被压住」不能早退,要把它挪到栈顶重新亮出来。 */
if (at >= 0) panelStack.splice(at, 1);
panelStack.push(el);
/* ⚠️ 必须整栈重铺,不能只给挪上来的这一个赋值:
splice 之后原栈顶的下标变了,单独算一个必然和它撞成同一层(80+1*2 对 80+1*2),
撞层时谁在上面由 DOM 顺序决定 —— 而 detailResList 在 HTML 里正好排在 detailRes 前面,
结果就是「代码以为挪上来了,屏幕上还是被压着」,比现在这个 bug 更难查。 */
panelStack.forEach((p, i) => { p.style.zIndex = 80 + i * 2; });
el.classList.add('show');
}
function closePanel() {
const el = panelStack.pop();
if (el) el.classList.remove('show');
}
function closeAllPanels() { while (panelStack.length) closePanel(); }
$$('[data-open]').forEach(b => b.addEventListener('click', () => openPanel(b.dataset.open, b)));
/* ---------- 「宝宝」这两个字的统一出口 ----------
用户在「我的 → 编辑孕期信息」里填了小名,首页那张「宝宝来信」提醒卡的
标题 / 摘要 / 链接文案都得跟着换。没填就回落到「宝宝」,每句文案照样通顺。
写成 function 而不是箭头常量:声明会提升,谁先谁后都不会踩到暂时性死区。 */
function babyWord(suffix) {
return (state.babyName || '宝宝') + (suffix || '');
}
/* ⚠️ 这里原来还有一个 fillBaby(str)(把 {baby} 占位符换成小名),和 babyWord() 一对。
它的调用点全在内置来信屏的渲染里,随该屏一起删除。
将来若要用,直接调 babyWord() 拼,别只留一个空函数在这儿。 */
/* back(几天前)+ time(几点几分)→ 那一刻的时间戳。back 只影响「哪一天」,
时分是当天的绝对钟点,所以先归零到 0 点再往回数,避免夏令时/跨月自己算。
⚠️ 现在唯一调用方是 stampAiLogs()(AI 小结的往期日期)。内置来信屏删除后
信件那一层语义没了,但 AI 小结还在靠它推日期 —— 别顺手删。 */
function backAt(back, time) {
const d = midnight(new Date());
d.setDate(d.getDate() - back);
d.setHours(time[0], time[1], 0, 0);
return d.getTime();
}
/* ---------- 小工具 ---------- */
function el(tag, cls, txt) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (txt != null) n.textContent = txt;
return n;
}
/* 首页那张「宝宝来信」提醒卡:**数据源是 laixin(第三方宝宝来信应用)**。
⚠️⚠️ 2026-09-20 —— 理顺一处数据源不一致:
底 tab 早已改跳 laixin、角标读 /api/mp/public/laixin/unread,
但这张卡原来由 pushLatestToHome() 按本地 LETTERS(box_app 的 /mama-box/letters)驱动,
而那条线对绝大多数用户是 0 封 —— 于是同一屏上「红点说有新信、卡里什么都没有」。
现在两者**共用同一个 laixin 未读结果**,而且**只有一个渲染入口**:
· 拉数:syncLaixinReminder()(首屏 + 每次 visibilitychange)
· 渲染:renderLetterReminder() —— 卡与底 tab 红点由它**一次判定**同时落,
不会再出现「一边有一边没有」的屏
· 判据(2026-09-22 产品口径):**未建档(pregEmpty)= 一律不提示**;
建档后看 unread > 0;归零则一起收;上游降级(ok:false)保持现状
· 点击:跳 laixin(与底 tab 同落点)
⚠️ 判据翻转过一次(旧版「故意不看 pregEmpty」已被产品否掉),细节与理由写在
renderLetterReminder() 上方,改之前先读那段。
⚠️ 卡里只有数字与时间(接口就给这些),所以文案是**通用提醒**,不做单封预览。
将来第三方上 SSE 的 event: letter 之后,才有标题/摘要可显示
(见 docs/laixin-sse-requirement.md)。
⚠️ 2026-09-20:内置来信屏(#screen-letter)及其全部功能代码已整体删除,
这里没有「本地信件数据」可以接回去了 —— 这张卡只认 laixin。 */
function hideLetterCard() {
const card = $('#homeLetter'), body = $('#homeBody');
if (card) { card.classList.add('hide'); card.classList.remove('has-new'); }
if (body) body.classList.add('no-letter');
}
/* 来信提示(首页卡 + 底 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 = _laixinUnread;
if (state.pregEmpty || !r || r.ok === false || !(Number(r.unread) > 0)) {
if (badge) badge.classList.remove('show');
hideLetterCard();
return;
}
const card = $('#homeLetter'), body = $('#homeBody');
if (!card) return;
const letters = Number(r.unreadLetters) || 0;
const replies = Number(r.unreadReplies) || 0;
/* 两种都有时分开讲,别合成一个含糊的总数(用户不知道「3」是什么东西) */
let desc = (letters && replies) ? '有 ' + letters + ' 封新信、' + replies + ' 条新回信'
: replies ? '宝宝回了你 ' + replies + ' 条话'
: '宝宝给你写了 ' + letters + ' 封信';
const at = r.latestLetterAt ? Date.parse(r.latestLetterAt) : NaN;
if (!isNaN(at)) desc += ' · 最近一封 ' + fmtWhen(at);
$('#lcTitle').textContent = babyWord('来信');
$('#lcDesc').textContent = desc;
$('#lcLink').textContent = '读读' + babyWord('的话');
const num = $('#lcNum'); if (num) { const n = letters + replies; num.textContent = n > 99 ? '99+' : (n || ''); }
card.classList.add('has-new');
card.classList.remove('hide');
if (body) body.classList.remove('no-letter');
if (badge) badge.classList.add('show');
}
$$('[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();
/* 有些弹窗的主角不是输入框。企业微信那个弹窗正中间是一张二维码,
用户要做的第一件事是**举起另一台手机去扫它**;自动聚焦输入框会把
手机键盘顶上来,恰好盖住二维码的下半截。所以给一个显式的退出口,
写在标记上而不是在这里 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);
/* ================= 孕期信息 =================
预产期是唯一的「源」:填了它就按今天倒推出孕周 + 额外天数;
没填时那两格 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] + ' 克'
};
}
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。 */
function fillProfileForm() {
$('#pfName').value = state.userName || '';
pgName.value = state.babyName || '';
$$('#pgSex .pick').forEach(b => b.classList.toggle('is-active', (b.dataset.sex || 'unknown') === (state.babySex || 'unknown')));
if (state.postpartum) {
/* 产后态:预产期已过,**不要**再按它算出「孕 42 周」回填进表单(那是荒谬值)。
预产期本身照常显示/可改;孕周两格留空并锁住。 */
pgDue.value = state.due ? toISO(state.due) : '';
pgWeek.value = '';
pgDay.value = '';
lockWeekFields(true);
} else if (state.due) {
pgDue.value = toISO(state.due);
const ga = gaFromDue(state.due);
pgWeek.value = Math.floor(ga / 7);
pgDay.value = ga % 7;
lockWeekFields(false);
} else {
pgDue.value = '';
pgWeek.value = '';
pgDay.value = '';
lockWeekFields(true);
}
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');
if (t) { t.textContent = due ? fmtDay(due) : '点这里选预产期'; t.parentNode.classList.toggle('is-set', !!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();
}));
/* 性别三选一 */
$$('#pgSex .pick').forEach(b => b.addEventListener('click', () => {
$$('#pgSex .pick').forEach(x => x.classList.toggle('is-active', x === b));
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;
$('#heroFruit').textContent = o.emoji || '';
$('#heroSize').textContent = o.size || '';
$('#heroWeekU').textContent = o.unit == null ? '周' : o.unit;
$('.hero-pane').classList.toggle('is-born', !!o.born);
bar.style.width = '0%';
requestAnimationFrame(() => requestAnimationFrame(() => { bar.style.width = Math.max(0, Math.min(100, o.pct || 0)) + '%'; }));
}
/* ---------- 页面里那些写死的示例日期 ----------
时光轴、珍贵时刻、首页两张小卡、指南标题、「记一下」抬头、搜索热词,
原来全是设计稿留下的固定字符串(「8月15日 · 孕20周」「孕24周」「9月3日 周二」)。
孕周是按打开当天现算的,这些一天都对不上,档案打印出来最露馅。
现在 HTML 里只留一个标记,文字全由这里生成:
data-ga="20" 这件事发生在孕 20 周 → 日期按预产期倒推
data-ga="now" 就是今天
data-wk="n" 只要一个孕周数字(如「孕 26 周胎动多少正常」)
data-wk="cn" 要「孕 26 周」这种带字的写法
时光轴写「日期 · 孕周」,珍贵时刻写「孕周 · 日期」—— 两边顺序本来就是反的,
按 class 分流,别为了统一去改设计。 */
function gaToDate(ga) {
const d = midnight(new Date());
d.setDate(d.getDate() - ((state.week * 7 + state.day) - ga));
return d;
}
const mdTxt = d => (d.getMonth() + 1) + '月' + d.getDate() + '日';
const gaTxt = g => '孕' + Math.floor(g / 7) + '周' + (g % 7 ? '+' + (g % 7) : '');
/* 常规产检节奏:28 周前四周一次,之后两周一次,36 周后每周一次。
取第一个还没到的孕周当「下次产检」;都过了就显示预产期当天。 */
const CHECKUPS = [12, 16, 20, 24, 28, 30, 32, 34, 36, 37, 38, 39, 40];
function syncStaticDates() {
const gaNow = state.week * 7 + state.day;
$$('[data-ga]').forEach(n => {
const raw = n.dataset.ga;
const g = raw === 'now' ? gaNow : +raw * 7;
const day = raw === 'now' ? '今天' : mdTxt(gaToDate(g));
n.textContent = n.classList.contains('mo-meta')
? gaTxt(g) + ' · ' + day
: day + ' · ' + gaTxt(g);
});
$$('[data-wk]').forEach(n => {
n.textContent = n.dataset.wk === 'cn' ? '孕 ' + state.week + ' 周' : state.week;
});
/* ⚠️ #calNext 归**卡片接口**优先(与孕检日历应用同一套排期、还支持"改过预约日以预约日为准");
本地按预产期推算只在接口没数据时兜底。两者并行返回、先后不定,所以看 cardsOwned.cal。
(curve 卡的 [data-wk] 由 applyRemoteCards 摘掉,这里自然跳过,无需额外判断。) */
if (!cardsOwned.cal) {
const next = CHECKUPS.find(w => w * 7 > gaNow);
$('#calNext').textContent = mdTxt(next ? gaToDate(next * 7) : state.due);
}
const t = new Date();
$('#noteWhen').textContent = mdTxt(t) + ' ' + WEEKDAY[t.getDay()] + ' · ' + gaTxt(gaNow);
}
/* 把 state 里的孕期信息刷到「我的」孕期卡 + 首页头部 + 档案页顶部 */
/* 成长数据面板的空态开关(CSS 见 mama-box.css 的 .data-empty 段):
未建档 / 产后 = 面板里那些数字(58.2kg、日均胎动 31 次、双顶径 6.1cm、体重曲线)
**一个都没有接口数据源**,对这类用户是纯假数据 → 整块隐藏改显示引导。
⚠️ 有真实孕周的用户仍看演示值:这是与「AI 小结演示文案」同一批的遗留,
要一起清得先有数据源,别只在这里删。 */
function setGrowthDataEmpty(on) {
const p = $('.seg-panel[data-panel="data"]');
if (p) p.classList.toggle('is-empty', !!on);
}
/* 「我的」页头部名字下面那枚小标签:孕周 / 宝宝月龄;未完善时隐藏 */
function setMeTag(t) {
const el = $('#meTag'); if (!el) return;
el.textContent = t || '';
el.classList.toggle('hide', !t);
}
function syncPregnancy() {
/* 孕期小知识:只认真实孕周(自己判显隐,见 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: '👶', 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);
const arArt = $('#arSizeArt'); if (arArt) arArt.textContent = '👶';
const arL2 = $('#arLine2'); if (arL2) arL2.textContent = '孕期陪伴已结束,继续记录宝宝的成长';
const arP = $('#arPct'); if (arP) arP.textContent = '100%';
setArBar(100);
const gwt = $('#genWeekTag'); if (gwt) gwt.textContent = '产后';
$$('[data-ga]').forEach(n => { n.textContent = '--'; });
$$('[data-wk]').forEach(n => { n.textContent = '--'; });
/* ⚠️ #calNext(首页「孕检日历」卡)**没有 data-wk / data-ga 钩子**,上面两条循环抓不到它。
而 init 早已按内置默认(孕 27 周)把它写成「9月29日」这类演示日期,
产后分支之后没人再改 → 会以假日期留在首页。这里显式归占位。
(2026-09-22 新用户实测:孕检日历 9月29日 / 孕期体重 58.2kg 两处漏网。) */
const _cnPost = $('#calNext'); if (_cnPost) _cnPost.textContent = '--';
setGrowthDataEmpty(true); /* 产后同样没有这些数据的来源,别留演示数字 */
syncBabyWords();
return;
}
/* ---- 新用户空态:未完善资料(无预产期)→ 全部显示引导占位,不读演示数据 ----
me 页已有两条引导(授权卡 + 孕期信息必填卡);这里把首页/档案页的
孕周数字换成引导文案,避免新用户看到"孕 27 周"这类假数据。 */
if (state.pregEmpty) {
const nm = state.babyName || '宝宝';
/* 脚印时间轴:未建档整块隐藏 —— 交给 fpRender() 统一判(它同时看 footprint 的结论),
别再直接改 display:与本模块的另一个 owner 撞竞态(2026-09-22 实测漏过 degraded 场景)。 */
fpRender();
/* ① 我的 · 孕期卡 → 引导 */
$('#pcTitle').textContent = '待完善孕期信息';
setMeTag('');
$('#pcSub').textContent = '填好预产期,开启孕期陪伴';
$('#pcBar').style.setProperty('--p', '0%');
const rg = $('#pcRing');
if (rg) {
const C = 2 * Math.PI * parseFloat(rg.getAttribute('r'));
rg.setAttribute('stroke-dasharray', '0 ' + C.toFixed(2));
}
$$('.pc-stage span').forEach((s) => {
s.textContent = s.textContent.split(' · ')[0];
s.classList.remove('cur');
});
/* ② 首页 hero → 占位 */
$('#heroWeek').textContent = '--';
$('#heroPlus').textContent = '';
$('#heroDays').textContent = '--';
$('#heroWho').textContent = nm;
setHero({ due: '填好预产期,开启倒计时', emoji: '🌱', size: '', pct: 0 });
/* ③ 档案页 → 占位 */
$('#arTitleName').textContent = nm;
$('#arLine1').textContent = '完善孕期信息后开启';
$('#arDays').textContent = '--';
$('#arLine2').textContent = '先去「我的资料」填预产期';
$('#arPct').textContent = '0%';
setArBar(0);
$('#genWeekTag').textContent = '--';
/* ⑤ 用户卡记录行:未完善 → 引导文案(数据源未定,先不给演示数字) */
const _rl = $('#meRecordLine');
if (_rl) _rl.textContent = '完善孕期信息,记录宝宝的成长记忆';
/* ⑥ 孕期衍生的静态演示位(生长曲线周数/孕检日历/时光轴日期)一并归中性占位,
不保留 HTML 里的演示默认值(孕27周/9月15日…) */
$$('[data-ga]').forEach(n => { n.textContent = '--'; });
$$('[data-wk]').forEach(n => { n.textContent = '--'; });
/* ⚠️ 上面注释里的「孕检日历」**并不被那两条循环覆盖**:#calNext 没有 data-wk / data-ga
属性,选择器抓不到。而 init 早已按内置默认(孕 27 周)把它写成「9月29日」,
且 applyRemoteProfile 在 pregEmpty 时提前 return —— 于是新用户首页上
只剩这一个孕周派生的假日期(2026-09-22 实测)。这里显式归占位。 */
const _cn = $('#calNext'); if (_cn) _cn.textContent = '--';
setGrowthDataEmpty(true);
const _t = new Date();
$('#noteWhen').textContent = mdTxt(_t);
syncBabyWords();
/* 未建档 → 不给「宝宝来信」提示(卡 + 红点一起收)。
⚠️ 这里**必须**重算一次,不能只写 hideLetterCard():来信提示的唯一渲染入口是
renderLetterReminder(),它同时管卡和底 tab 红点。只藏卡会留下「红点有、卡没有」
(2026-09-22 定时复现:laixin 先回来亮红点、/profile 后回来藏卡,就是那个不一致)。
现在两处判据合成一条(pregEmpty 是硬门槛),谁先回来都收敛到同一结果。 */
renderLetterReminder();
return;
}
const week = state.week, day = state.day, name = state.babyName;
setGrowthDataEmpty(false); /* 有真实孕周了:恢复面板(数据源接入前仍是演示值,见函数注释) */
/* 刚建档/填了孕产期 → 按新状态重算来信提示:此前因未建档被压住的未读,这时该出现 */
renderLetterReminder();
/* 脚印时间轴:建档状态变了要重算 —— 同样只经 fpRender(footprint 还没回来时它保持「加载中」) */
fpRender();
const days = week * 7 + day; // 一起生活了多少天
const weekTxt = '孕 ' + week + ' 周' + (day ? ' +' + day + ' 天' : '');
/* 剩余天数按 40 周足月倒推,比直接用 date 差值更贴合「孕周」这个语境 */
const left = Math.max(0, (40 - week) * 7 - day);
const pct = Math.max(0, Math.min(100, Math.round(days / TERM * 100)));
/* ⚠️ 这里原来还算了一个短格式 dueShort(「12月22日」),
专供「我的」页头像旁那行用。那行改成签名后 dueShort 就没人读了,
已一并删除 —— 只写不读的变量留着,下一个人会以为还有别处在用它。 */
let dueTxt = '';
if (state.due) {
const d = state.due;
dueTxt = '预产期 ' + d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日';
}
/* ① 我的 · 孕期卡 */
$('#pcTitle').textContent = weekTxt;
setMeTag(weekTxt);
$('#pcSub').textContent = (dueTxt ? dueTxt + ' · ' : '') + '还有 ' + left + ' 天';
$('#pcBar').style.setProperty('--p', pct + '%');
/* 卡片左上角那个图标是一圈进度环,和这条进度条同源(同一个 pct),
不会出现「环走了一半、条走了三分之二」这种各说各话。
⚠️ 周长按元素上的 r 现算,不写死常数 —— 哪天调了环的半径这里自动跟上;
写死的话只会静默画错一个角度,谁也看不出来。
⚠️ 用 getAttribute('r') 而不是 ring.r.baseVal.value:后者 jsdom 没实现,
会让整个 smoke 直接炸在这一行。 */
const ring = $('#pcRing');
if (ring) {
const C = 2 * Math.PI * parseFloat(ring.getAttribute('r'));
ring.setAttribute('stroke-dasharray', (C * pct / 100).toFixed(2) + ' ' + C.toFixed(2));
}
/* 进度条下方三段固定是早/中/晚期,高亮那一段跟着孕周走,
所以每次都把三段全部重写一遍,避免出现两个「孕中期」 */
const stages = ['孕早期', '孕中期', '孕晚期'];
const cur = week <= 13 ? 0 : week <= 27 ? 1 : 2;
$$('.pc-stage span').forEach((s, i) => {
s.textContent = stages[i] + (i === cur ? ' · 当前' : '');
s.classList.toggle('cur', i === cur);
});
/* ⚠️ 这里原来还有一行
if (dueShort) $('.me-info p').textContent = weekTxt + ' · 预产期 ' + dueShort;
已删除,别加回来。孕周信息没丢:本函数上面的 ① 孕期卡一直在显示,
那行本来就是重复的(同一屏往下滚一点就是孕期卡)。
⚠️ 而且 $('.me-info p') 选的是**第一个** <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> 天' : '宝宝随时会来见你',
emoji: sz.emoji, 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);
$('#arSizeArt').textContent = S.emoji;
$('#arLine2').textContent = '像' + S.name + '了 · 约 ' + S.txt;
$('#arPct').textContent = pct + '%';
/* 孕程从 84px 的圆环换成了通栏细条,所以这里不再是 setRing,
直接写宽度;CSS 上挂着 transition,从 0 长出来的动画照旧。
档案页也就这一个环,环动画的调用一并去掉了。 */
setArBar(pct);
$('#genWeekTag').textContent = '第 ' + week + ' 周'; // 生成档案卡上的周次
/* ④ 称呼:小名一改,全站写着小名的地方(首页三个模块名、来信提醒卡文案)跟着换 */
syncBabyWords();
/* ⑤ 用户卡记录行:已完善 → 保留「已连续记录 N 天」(数据源确认前先维持原型值) */
const _rl2 = $('#meRecordLine');
if (_rl2) _rl2.innerHTML = '已连续记录 <b>42</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 里加回来。 */
$('#dockTitle').textContent = babyWord('的孕育工具');
$('#abTag').textContent = babyWord('今天想说');
$('#tlTitle').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 */
avBig.addEventListener('click', () => avFile.click());
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 就地预览:产物是单文件、没有后端,不做真正的上传 */
r.onload = () => { avPreview.src = r.result; avBig.classList.add('has-img'); haptic(); };
r.onerror = () => toast('这张图读不出来,换一张试试');
r.readAsDataURL(f);
});
/* 「我的资料」现在同时管**妈妈**(头像 / 昵称)和**宝宝**(小名 / 性别 / 预产期 / 孕周),
所以这一个按钮要把原来 #pfSave 和 #pgSave 两段逻辑都做完。
⚠️ 顺序不能反:先校验预产期,校验没过就整个 return,**头像和昵称也一起不保存**。
听起来像是误伤,但另一种做法更坏 —— 保存一半、弹窗还开着,
用户看到「资料已更新」却发现孕周没变,会以为是自己没填对。
一个按钮就该是一次原子的「保存」。 */
$('#pfSave').addEventListener('click', () => {
/* 预产期是一切的源头:孕周、剩余天数、信里的日期全从它算。没有它没什么可存的。 */
if (!parseDay(pgDue.value)) {
toast('先填一下预产期,孕周会自动算出来');
setPgMode('due');
pgDue.focus();
return;
}
const name = $('#pfName').value.trim() || '妈妈';
const babyName = pgName.value.trim() || '宝宝';
const week = Math.min(42, Math.max(0, parseInt(pgWeek.value, 10) || 0));
const day = Math.min(6, Math.max(0, parseInt(pgDay.value, 10) || 0));
const sexBtn = $('#pgSex .pick.is-active');
/* ---- 妈妈那半(本地即时生效)----
身份模型:卡片统一读档案;资料编辑的昵称/头像也是档案的一部分(最后写者胜)。
落库在下方 fetch(头像需先上传换 URL)。 */
$('#meName').textContent = name;
state.userName = name;
/* ⚠️ 这里原来还有一行 state.userBio = bio 和一次 syncBio() 调用。
一句话签名整块已按用户要求删除(输入框 #pfBio、展示行 #meBio、
syncBio()、state.userBio 四处一起删),别只加回其中一处。 */
/* ---- 宝宝那半(原 #pgSave) ---- */
state.babyName = babyName;
state.week = week;
state.day = day;
state.babySex = (sexBtn && sexBtn.dataset.sex) || 'unknown';
state.due = parseDay(pgDue.value);
/* ---- 落库:PUT /profile(孕育字段 + 昵称/头像)→ 刷新/重进后资料不丢。
若弹窗里新选了头像(avFile 有文件),先 POST /upload 换正式 URL 再一起提交;
上传失败则跳过头像字段(昵称/孕育照存)。断网/接口失败静默,本地已生效。 ---- */
var _oid = hashParam('openid');
var _wasEmpty = state.pregEmpty; /* 新用户完善资料后 → 重载拉真实孕周 */
function _putProfile(avatarUrl) {
if (!_oid) return;
var body = {
babyName: babyName,
babySex: state.babySex,
dueDate: state.due ? toISO(state.due) : '',
userName: name
};
if (avatarUrl) {
body.avatar = avatarUrl;
meAvatarImg.src = avatarUrl; /* 用正式 URL 替换 base64 预览 */
}
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) {
toast((res && res.message) || '保存失败,请检查网络后重试');
return;
}
/* 新用户首次完善资料:重载页面 → pregnancy 出真实孕周 */
if (_wasEmpty) setTimeout(bustReload, 700);
}).catch(function () {
toast('保存失败,请检查网络后重试');
});
}
var newAvatarFile = (avFile.files && avFile.files[0]) || null;
if (newAvatarFile && avBig.classList.contains('has-img')) {
var fd = new FormData();
fd.append('file', newAvatarFile, newAvatarFile.name || 'avatar.png');
fetch('/api/upload', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (res) {
var url = res && res.result && res.result.url;
_putProfile(url || '');
})
.catch(function () { _putProfile(''); });
} else {
_putProfile('');
}
/* ⚠️ syncPregnancy() 就是用户说的「同步到孕期卡上边」那一步 ——
它把孕周环、剩余天数、预产期、宝宝多大重刷一遍,顺带 syncBabyWords()
把全站写着小名的地方(首页三个模块名、来信提醒卡文案…)一起改。
少调这一句,弹窗里改完了、外面还是旧数字,而且没有任何报错。 */
syncPregnancy();
closeModal();
setTimeout(() => toast('资料已更新 · ' + name + ' · ' + babyName +
' 孕 ' + week + ' 周' + (day ? '+' + day + ' 天' : '')), 260);
});
/* 「我的二维码」按需求已整块移除 */
/* ================= 设置与服务 二级页 =================
四项内容形态其实一样:若干「分组 + 若干行」。所以只写一张表 + 一个渲染器。
行的三种类型:
sw 开关行(可点,本地切换状态并 toast)
row 普通跳转行(右侧箭头,点了给个 toast)
txt 纯说明段落 */
/* 说明:「提醒设置」整项已按需求删除(连同「我的」里的入口行)。
隐私与权限去掉了「社区匿名发言」「设备权限」「数据」三处;
帮助与反馈只留「联系我们」;关于兜爸心选只留介绍。 */
const SETTINGS = {
privacy: {
title: '隐私与权限', more: '你的记录只属于你',
lead: '孕期的记录是很私人的东西。默认情况下,只有你自己能看到它们。',
groups: [
{ t: '谁能看到', rows: [
['sw', '档案仅自己可见', '关掉后可生成分享链接', 1],
['sw', '允许家人查看', '爸爸可看孕周与产检日程', 1]
] },
{ t: '', rows: [
['txt', '我们不会把你的孕期数据用于广告投放,也不会卖给第三方。数据加密存储,你可以随时导出或删除。']
] }
],
foot: '《隐私政策》最近一次更新:2024 年 8 月 12 日'
},
help: {
title: '帮助与反馈', more: '一般 1 个工作日内回复',
lead: '有任何用不明白的地方,都可以直接问我们,妈妈的时间很宝贵。',
groups: [
{ t: '联系我们', rows: [
['row', '在线客服', '9:00 – 21:00'],
['row', '提交反馈或建议', '截图一起发给我们'],
['row', '加入内测群', '抢先体验新功能']
] }
],
foot: '你提的每一条建议我们都会看,兜爸心选有很多功能就是这么来的。'
},
about: {
title: '关于兜爸心选', more: '当前版本 v3.2.1',
hero: 1,
lead: '兜爸心选想做的事很简单:让你不用记录,也能留下记录。',
groups: [
{ t: '', rows: [
['txt', '怀孕已经够辛苦了,我们不想再让你多填一张表。你每一次数胎动、称体重、看产检、读宝宝的来信,兜爸心选都会悄悄记下来,替你整理成一本档案。'],
['txt', '等宝宝出生、等他长大、等你某天忽然想起这段日子,这本档案都还在原地等你。']
] }
],
foot: '兜爸心选 v3.2.1 · 已陪伴 268 万位妈妈'
}
};
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">v3.2.1 · 陪你从两条杠到见面</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')
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 */
return '<button class="st-row sw' + (r[3] ? ' on' : '') + ' ripple" role="switch" ' +
'aria-checked="' + (r[3] ? 'true' : 'false') + '" data-sw="' + r[1] + '">' +
'<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;
/* 「关于兜爸心选」页底部显示版本号(全站不再有固定底标) */
if (key === 'about') {
let av = document.getElementById('aboutVer');
if (!av) { av = document.createElement('p'); av.id = 'aboutVer'; av.className = 'about-ver'; $('#detailSetting .detail-body').appendChild(av); }
av.textContent = 'v' + VER;
}
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');
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');
}
/* ================= 孕育资源库:列表页 + 内容页 ================= */
const RES = {
book: {
img: 'assets/img/9f3fa8edf4aa.png', cat: '产检', tint: 'o',
title: '《糖耐检查全攻略》', desc: '孕24-28周必看 · 附检查前饮食清单',
meta: ['8 分钟读完', '12.8 万妈妈已领取', '孕24-28周'],
body: [['什么是糖耐量检查', '糖耐量检查(OGTT)用于筛查妊娠期糖尿病,一般安排在孕 24–28 周。检查当天需空腹抽血一次,喝下 75g 葡萄糖水后 1 小时、2 小时各抽血一次,共三次。'],
['检查前 3 天怎么吃', '不需要刻意节食。保持平时饮食即可,但每天碳水化合物摄入不少于 150g,否则可能出现假阳性。避免油炸、含糖饮料与夜宵。'],
['检查当天注意事项', '前一晚 22:00 后禁食,可少量饮水。带上水杯、小零食(抽完最后一管血再吃)和陪同家属。喝糖水要在 5 分钟内喝完,之后静坐等待,不要来回走动。'],
['结果怎么看', '空腹 < 5.1、1 小时 < 10.0、2 小时 < 8.5(mmol/L),任意一项超标即诊断为妊娠期糖尿病。多数可通过饮食与运动控制,不必过度紧张。']]
},
food: {
img: 'assets/img/3657b1f76026.png', cat: '营养', tint: 'g',
title: '孕期营养全书', desc: '按孕周编排的一日三餐参考',
meta: ['12 分钟读完', '20.4 万妈妈已领取', '全孕期'],
body: [['孕中期的能量需求', '孕中期每天比孕前多约 300 kcal,相当于一杯牛奶加一个鸡蛋加半碗米饭,不需要「吃两人份」。'],
['四类必须补的营养素', '钙 1000mg/天(奶制品、豆制品)、铁 24mg/天(红肉、动物肝脏)、DHA 200mg/天(深海鱼)、叶酸 400μg/天(绿叶菜、补充剂)。'],
['一日三餐参考', '早餐:牛奶 + 全麦面包 + 鸡蛋 + 一份水果;午餐:杂粮饭 + 瘦肉 + 深绿色蔬菜 + 豆制品;晚餐:清淡为主,鱼虾 + 蔬菜 + 少量主食;加餐:坚果一小把或酸奶一杯。'],
['要少碰的东西', '生食(刺身、溏心蛋)、未杀菌乳制品、高汞鱼(金枪鱼、鲨鱼)、酒精与浓咖啡。']]
},
music: {
img: 'assets/img/67405412c52c.png', cat: '胎教', tint: 'p',
title: '胎教音乐合集', desc: '30 首适合孕中期的舒缓曲目',
meta: ['30 首 · 共 96 分钟', '9.6 万妈妈已领取', '孕16周起'],
body: [['宝宝什么时候能听见', '孕 16 周左右听觉器官初步形成,24 周后已能对外界声音作出反应,这是胎教音乐最合适的开始时间。'],
['正确的听法', '不要把设备贴在肚皮上。房间内正常音量播放即可,控制在 60 分贝以下,每次 15–20 分钟,每天 1–2 次。'],
['选什么曲子', '节奏平稳、旋律简单、无强烈鼓点的音乐最合适,如古典小品、轻钢琴、自然音。妈妈自己听着放松,才是最好的胎教。'],
['比音乐更重要的事', '妈妈的声音是宝宝最熟悉的声音。每天和宝宝说几句话,效果胜过任何一张专辑。']]
},
sport: {
img: 'assets/img/72910df66ee3.png', cat: '运动', tint: 'g',
title: '孕中期安全运动指南', desc: '每天 30 分钟,缓解腰背酸痛',
meta: ['6 分钟读完', '7.2 万妈妈已领取', '孕13-28周'],
body: [['为什么孕中期最适合运动', '早孕反应消退、腹部尚未过分隆起,是整个孕期活动最舒适的窗口,规律运动能降低妊娠糖尿病与子痫前期风险。'],
['推荐的四种运动', '快走、游泳、孕妇瑜伽、固定式单车。每周 5 天、每次 30 分钟,强度以「能正常说话但唱不了歌」为准。'],
['必须避开的动作', '仰卧位动作(孕 16 周后)、跳跃与急转、需要平衡的运动、任何有腹部撞击风险的项目。'],
['出现这些情况立即停止', '阴道出血、规律宫缩、头晕胸闷、羊水样液体流出,应停止运动并及时就医。']]
},
sleep: {
img: 'assets/img/f0d5e062f11a.png', cat: '心理', tint: 'b',
title: '睡不好怎么办', desc: '孕期失眠的 7 个改善方法',
meta: ['5 分钟读完', '15.1 万妈妈已领取', '全孕期'],
body: [['孕期失眠很常见', '约 70% 的孕妇有不同程度的睡眠问题,多由激素变化、尿频、腿抽筋和焦虑引起,绝大多数不需要药物治疗。'],
['睡姿怎么选', '孕中晚期优先左侧卧,可在两腿之间与腰后各垫一个枕头,减轻下腔静脉压迫。'],
['睡前一小时的仪式', '关掉主灯、放下手机、温水泡脚 10 分钟、轻音乐或呼吸练习,让身体建立入睡信号。'],
['白天该做的事', '固定起床时间、午睡不超过 30 分钟、下午之后不摄入咖啡因、傍晚适量活动。']]
},
diet: {
img: 'assets/img/dd5639fde61d.png', cat: '营养', tint: 'o',
title: '孕期体重管理', desc: '孕前 BMI 决定你该长多少',
meta: ['7 分钟读完', '11.3 万妈妈已领取', '全孕期'],
body: [['先算孕前 BMI', 'BMI = 体重(kg) ÷ 身高(m)²。孕前偏瘦(<18.5)建议全孕期增重 12.5–18kg,正常(18.5–24.9)11.5–16kg,超重(25–29.9)7–11.5kg。'],
['孕中期的增重节奏', '孕中晚期每周增重 0.3–0.5kg 较理想。单周超过 1kg 多为水肿或饮食过量,需要复盘。'],
['称重的正确方法', '固定每周同一天、同一时间、同样着装,晨起排空后测量,只看趋势不纠结单次数字。'],
['长得太快或太慢', '连续两周超标先减少精制主食与含糖饮料;增长过慢则增加优质蛋白与健康脂肪,并排查甲功与贫血。']]
},
report: {
img: 'assets/img/b855b45df1f3.png', cat: '产检', tint: 'b',
title: '产检时间表(全孕期)', desc: '13 次产检,一次都不漏',
meta: ['4 分钟读完', '26.7 万妈妈已领取', '全孕期'],
body: [['孕早期(≤13周)', '6–8 周建卡 + B超确认宫内孕;11–13⁺⁶ 周 NT 检查与早期唐筛。'],
['孕中期(14–27周)', '15–20 周唐筛或无创 DNA;20–24 周大排畸;24–28 周糖耐 + 血常规复查。'],
['孕晚期(28周后)', '28–36 周每 2 周一次;36 周后每周一次,含胎心监护、B超估重与骨盆评估。'],
['每次都要带的东西', '母子健康手册、既往报告、医保卡、水杯与小零食。建议提前一天在手机里存好问题清单。']]
},
med: {
img: 'assets/img/4657f50f42c8.png', cat: '心理', tint: 'p',
title: '孕期情绪自查手册', desc: '分清正常波动与孕期抑郁',
meta: ['6 分钟读完', '5.9 万妈妈已领取', '全孕期'],
body: [['情绪起伏是正常的', '激素剧烈变化叠加身份转变,孕期爱哭、易怒、突然焦虑都很常见,本身不等于抑郁。'],
['需要警惕的信号', '持续两周以上的情绪低落、对什么都提不起兴趣、睡眠与食欲明显改变、反复自责或出现伤害自己的念头。'],
['可以自己做的三件事', '把感受说出来而不是憋着;每天留 20 分钟只属于自己;减少无休止的搜索式焦虑。'],
['寻求帮助不丢人', '产科、心理科都可以提供帮助,孕期心理干预安全且有效,越早越好。']]
}
};
const RES_ORDER = ['book', 'food', 'music', 'report', 'sport', 'diet', 'sleep', 'med'];
/* ⚠️ 2026-09-19(9.19 需求 1.2):这里原来有 PAN_DEFAULT / PAN 两张**百度网盘
链接表**、panOf()、以及企业微信兑换码 WX_CODE / wxCodeOK()。
需求方确认「扫码加顾问 → 回复领资料 → 顾问直接发资料」之后,H5 不再发放网盘
链接,这套(连同 #modalPan 弹窗、givePan()、后台下发的 pan/panDefault/wxCode)
整块删除。要恢复「H5 自己发链接」,等于把「解锁」概念加回来,先回问需求方。 */
const isFav = k => state.resFav.indexOf(k) >= 0;
function resCardHTML(k) {
const r = RES[k];
/* ⚠️ 这里原来还有「领过的那一份」分支:整张卡加 .is-got、缩略图右上角打 ✓、
底行的「免费」标换成「已领 ✓」。9.19 需求取消「已领」概念后一并删除,
现在每张卡都是同一个样子(免费)。 */
return '<button class="res-item ripple"' +
' data-open="detailRes" data-res="' + k + '">' +
'<span class="ri-thumb ' + r.tint + '"><img src="' + r.img + '" alt=""></span>' +
'<span class="ri-body"><span class="ri-title">' + r.title + '</span>' +
'<span class="ri-desc">' + r.desc + '</span>' +
/* 底部这行:分类 + 适用孕周 + 右侧状态标。
⚠️ 状态标现在恒为「免费」(原来领过的会换成「已领 ✓」,已随「已领」概念删除)。 */
'<span class="ri-row">' +
'<em class="ri-cat">' + r.cat + '</em>' +
'<em class="ri-ga">' + r.meta[2] + '</em>' +
'<em class="pill-free">免费</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.book;
state.resKey = RES[key] ? key : 'book';
$('#rdImg').src = r.img;
$('#rdCover').className = 'rd-cover ' + r.tint;
$('#rdCat').textContent = r.cat;
$('#rdTitle').textContent = r.title;
$('#rdDesc').textContent = r.desc;
$('#rdMeta').innerHTML = r.meta.map(m => '<span>' + m + '</span>').join('');
$('#rdBody').innerHTML = r.body
.map(p => '<h4>' + p[0] + '</h4><p>' + p[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 需求取消「已领」概念后改回**固定文案**(点它只是弹顾问二维码)。 */
$('#rdGet').textContent = '立即领取';
$('#detailRes .detail-body').scrollTop = 0;
}
$('#resStar').addEventListener('click', function () {
const i = state.resFav.indexOf(state.resKey);
const on = i < 0;
if (on) state.resFav.push(state.resKey); else state.resFav.splice(i, 1);
saveResFav();
this.classList.toggle('on', on);
this.textContent = on ? '★' : '☆';
/* 列表页就压在这一层下面,退回去时得是新的。
原来只在「正停在收藏筛选上」时才重渲染,但现在 chip 的位置也跟收藏数有关
—— 收藏第一份资源之后返回列表,「收藏」得已经挪到第二位了,所以无条件重来一遍 */
renderResList(state.resCat);
toast(on ? '已收藏到「我的书架」' : '已取消收藏');
haptic();
});
/* 底部左键:去「孕育资源库」。
⚠️ 不能简单写 openPanel('detailResList')。用户多半是**从资源库列表点进这个详情页**的,
此时 panelStack = [detailResList, detailRes],而 openPanel 开头有一句
`if (reopen) return`(见上面 openPanel 的定义)—— 列表已在栈内,它会静默 return,
页面纹丝不动,看着就像按钮坏了。而从首页书架直接进来的用户栈里没有列表,
同一个按钮又是好的:不处理的话这颗按钮行为不一致。
所以分两种情况,但保证结果一致(点完一定停在资源库这一层):
已在栈里 → 关掉压在它上面的层,退回去(返回键随之回主屏,符合「往回走」的心智)
不在栈里 → 正常压栈打开 */
$('#rdLib').addEventListener('click', () => {
const list = $('#detailResList');
if (panelStack.indexOf(list) >= 0) {
while (panelStack.length && panelStack[panelStack.length - 1] !== list) closePanel();
renderResList(state.resCat); // 收藏/领取状态可能刚变过,退回去得是新的
haptic();
} else {
openPanel('detailResList'); // openPanel 内部自带 renderResList + haptic
}
});
/* 点「立即领取」= 打开企业微信顾问二维码。
⚠️ 2026-09-19(9.19 需求 1.2):这里原来是「兑换码闸门」—— 未解锁的用户先被拦下,
填码解锁之后 H5 才把网盘链接交给他。需求方确认「扫码加顾问 → 回复「领资料」 →
顾问直接发资料」之后,闸门、兑换码、网盘弹窗整条链路都删了(连同 givePan()、
state.wxUnlocked、wxPending),这颗按钮现在只做一件事:弹二维码。
不存在「已解锁 / 未解锁」两种起点,所以这里也没有任何状态判断 —— 别再加回来。 */
$('#rdGet').addEventListener('click', () => { openWx(); });
/* ================= 企业微信顾问弹窗(单态) =================
三个入口共用这一个壳:「我的」页服务卡 #meWxCard、资源库顶部试用装 banner #rlGift、
资料详情页「立即领取」#rdGet。三者说同一句话,openWx() 只有一个参数都没有。
⚠️ 弹窗里**没有任何按钮**,唯一出口是右上角 .modal-x(外加遮罩 / Esc)——
删按钮之前确认过它在;别做「头部无按钮时隐藏」这种优化。
⚠️ 这里原来有 openWx(mode, key) + is-gate/is-gift/is-done 三模式类名 + wxPending +
setLead() 高亮 + 兑换码校验 + #wxOk / #wxCode 监听,全部随闸门删除。
别再引入「按入口改写文案」的分支:三个入口同一件事,分叉只会让以后改文案的人漏一条。 */
const wxModal = $('#modalWx');
function openWx() {
openModal('modalWx');
}
$('#meWxCard').addEventListener('click', () => openWx());
/* 资源库顶部那条 banner 和「我的」页服务卡是同一件事、同一个入口。
⚠️ 走 openWx() 而不是挂 data-open="modalWx":那条通用通道直接调 openModal,
不经过这里 —— 今天两者等价,但留一个入口,以后要统一加埋点/清理只改一处。 */
$('#rlGift').addEventListener('click', () => openWx());
/* ⚠️ 2026-09-19(9.19 需求):这里原来有一个 syncUnlockCopy(),按「已解锁 / 未解锁」
把首页书架与资源库的两句引导语改成两个版本,并给资源库顶部试用装 banner 挂 .hide。
兑换码闸门取消后不存在解锁态:
· 两句文案改为 index.html 里的**静态文案**(#shelfLeadTxt / #rlLead 的 id 也删了,
顺带解掉「给 <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)。放在最前面,尽量少闪一下首页。 */
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();
syncLaixinReminder();
loadBlindbox(); // 孕期盲盒:要等 ② 的预产期落地(服务端按同一份上游视图算孕周)
});
}
/* ---------- 远程配置(box_app 后台下发):已下线 ----------
⚠️ 2026-09-19(9.19 需求 1.2):这里原来拉 /api/mp/public/mama-box/config,
用后台下发的 pan / panDefault / wxCode 覆盖页面里的网盘链接表与兑换码。
H5 不再发放网盘链接(资料由顾问在微信里直接发)之后,配置里已经没有一项是
H5 还读的,整个 fetchRemoteConfig() 删除。
⚠️ 后端那个接口与 MamaBoxConfig 模型(pan / panDefault / wxCode)暂时**保留未删** ——
它们还有管理端读写、以及 /resources/{key}/claim 那条老接口在用;
等确认没有任何消费者之后再一起清理(见 docs/mama-box-change-list-20260919.md 的 T1b)。 */
/* ---------- 远程个人档案(box_app 下发,openid 单一身份键) ----------
小程序首页把 openid 经 #hash 传给本页(微信 web-view 丢 query,hash 不丢),
这里按 openid 拉 box_app 档案,覆盖 demo 默认值:
babyName / userName / babySex / dueDate(dueDate 是源,改了会重算孕周)。
让首页、孕周都跟真实身份走。
拉不到 —— 无 openid / 未建档 / 接口挂 —— 就用上方内置默认值,页面照常可用。
⚠️ 只在「后台有值」时覆盖:后台该字段为空(未建档)就维持内置默认,不把页面
打回白板。同样静默失败,不打断用户。
⚠️ 竞态:fetch 异步,回来时 init() 已跑完,这里只需回刷那些读 state 的展示。 */
function hashParam(name) {
/* 同时读 hash 和 query:浏览器直访(?openid=x)只有 query;
小程序 web-view 的授权回传在 hash(微信 webview 丢 query 不丢 hash)。
⚠️ 先在原始串上匹配、再对捕获值 decode —— 先 decode 整串会把
%26 之类的编码提前还原成 &,破坏参数切分。
⚠️ split('?'):历史 bug 会把缓存戳粘进 openid(abc001?t=123),
真 openid 不含 ?,剥掉以兼容已被污染的旧链接。 */
try {
const h = (location.hash.slice(1) || '') + '&' + (location.search.slice(1) || '');
const m = h.match(new RegExp('(?:^|[&#])' + name + '=([^&#]*)'));
const v = m ? decodeURIComponent(m[1]).split('?')[0] : '';
if (v) return v;
} catch (e) { /* 落到下面的兜底 */ }
/* 身份两键(openid/unionid)URL 没带时回落**同源缓存**:
H5 应用里「返回首页」是页面自己发起的同源跳转,宿主的 web-view src 没变、
_applyIdentity() 不会触发,URL 上永远不会有身份(第三方应用还会主动把
openid 从自己的 URL 上擦掉)。缓存由 wx-auth.js 的 identity() 写入 ——
凡是引了共享库的页面(含 mama-box 自己)拿到 URL 身份时都会顺手存一份。
纪律(URL 永远权威、只在本 origin 有效、只存 openid/unionid)见
js/wx-auth.js 顶部那段;命中缓存时 identity() 会打一条日志便于排查。 */
if (name === 'openid' || name === 'unionid') {
try {
const id = (window.wxAuth && window.wxAuth.identity) ? window.wxAuth.identity() : null;
if (id && id[name]) return id[name];
} catch (e) {}
}
return '';
}
/* ---------- 身份未就绪提示(2026-09-20) ----------
宿主(小程序首页壳)拿不到 openid 时会**匿名加载**本页 —— 页面照常渲染,
但全是占位数据(孕周 `--`、档案空、跳应用不带身份)。以前这种情况完全静默,
排查时只能靠服务端日志反推;现在给一条可见提示。
⚠️ 只在「确实没有 openid」时显示;一旦宿主把身份注入回来(onShow 重载),
applyRemoteProfile 会再跑一次,这里要 hide。
⚠️ 不做自动轮询/自动 reload:宿主只在它自己重设 web-view src 或 onShow 时注入身份,
页面自己 reload 同一 URL 并不会让身份出现(反而可能循环),所以只留手动重试。 */
/* 2026-09-30 产品要求:顶部这条黄色提示条先不给用户看。
只关展示,判定逻辑、标记、重试都原样留着;恢复时删掉下面这个开关即可。
没身份时仍在控制台打一条,排查不受影响。 */
const ID_NOTICE_ENABLED = false;
function showIdNotice(reason) {
if (!ID_NOTICE_ENABLED) { try { console.warn('[mama-box] 未获取到微信身份(提示条已关闭)', reason || ''); } catch (e) {} return; }
const el = $('#idNotice'), txt = $('#idNoticeTxt');
if (!el) return;
if (txt) {
/* ⚠️ 重试过一次之后(sessionStorage 计数)文案要保留「请退出小程序重进」,
否则重载回来又变回温和版,用户以为重试没发生过(重试是 location.replace,
文案状态活不过那一次跳转,只能落到 sessionStorage)。 */
let tried = reason === 'retried';
if (!tried) {
try { tried = (parseInt(sessionStorage.getItem('mmbox.idretry') || '0', 10) || 0) > 0; } catch (e) {}
}
txt.textContent = tried
? '仍未获取到微信身份,请退出小程序后重新进入'
: '未获取到微信身份,页面数据可能不完整';
}
el.classList.remove('hide');
}
function hideIdNotice() {
const el = $('#idNotice');
if (el) el.classList.add('hide');
}
/* 重试:只做一次「换 URL 重载」——带上 ?t= 让宿主/缓存放行。
宿主 onShow 也会重新注入身份(切到别的 tab 再回来即可),提示文案里已说明。 */
function retryIdentity() {
let n = 0;
try { n = parseInt(sessionStorage.getItem('mmbox.idretry') || '0', 10) || 0; } catch (e) {}
try { sessionStorage.setItem('mmbox.idretry', String(n + 1)); } catch (e) {}
showIdNotice('retried');
setTimeout(() => {
try {
const u = location.href.split('#')[0].split('?')[0] + '?t=' + Date.now();
location.replace(u);
} catch (e) { location.reload(); }
}, 500);
}
$('#idNoticeRetry').addEventListener('click', retryIdentity);
/* ② 资料最多等这么久(ms)就让 ③ 应用数据照常发出:上游慢/挂住时首页不能空等 */
const REMOTE_PROFILE_WAIT_MS = 10000;
let _profileApplied = false;
/* ⚠️ 返回 Promise:调用方要**等资料落地**再取应用数据(预产期/小名来自上游同步,
它决定 state.pregEmpty,而今日脚印的显隐依赖它)。别改成 fire-and-forget。 */
function applyRemoteProfile() {
if (_profileApplied) return Promise.resolve(); // init 只跑一次,防重复 fetch
_profileApplied = true;
const openid = hashParam('openid');
/* 访客态:宿主未注入身份(冷启动 silenceAuth 失败走「匿名加载 H5」兜底)。
⚠️ 绝不能停在内置演示默认值上(小汤圆 / 孕27周 都是前端现算的假数据)——
全部切占位/引导;宿主拿到身份后会带 openid 重载本页,届时自动切真实数据。 */
if (!openid) {
/* ⚠️ 可见提示(2026-09-20):宿主没注入身份时别静默装正常。
排查路径见 showIdNotice() 上方那段注释。 */
showIdNotice();
state.pregEmpty = true;
state.due = null; state.week = null; state.day = null;
state.babyName = ''; state.userName = '';
syncPregnancy(); /* 占位 + 隐藏脚印时间轴(内含 renderLetterReminder(),会收起来信提示) */
/* 兜底再算一次:访客态不该出现任何「你有新信」的暗示(卡 + 红点一起收)。
唯一渲染入口是 renderLetterReminder(),别再这里单独 hideLetterCard()。 */
renderLetterReminder();
return Promise.resolve();
}
const base = '/api/mp/public/mama-box/';
/* ⚠️ profile 拉取要带上 unionid(2026-09-21 排查「首页个人信息为空」):
服务端「先取用户中心」需要 unionid —— 只有 openid 时走本地 openid→unionid
映射(Member),映射缺失的老用户(资料全靠 CRMEB 同步来的)会先取失败,
预产期/孕周永远预填不上,而第三方应用因为 URL 自带 unionid 一直有数据。
与 footprint/today 同款:hash 里有就带上,服务端成功后会回写映射。 */
const _pfUnionid = hashParam('unionid');
const _pfQs = 'openid=' + encodeURIComponent(openid) +
(_pfUnionid ? '&unionid=' + encodeURIComponent(_pfUnionid) : '');
/* 并行拉 个人档案 + 孕期状态:
- profile:babyName/userName/babySex/dueDate(身份 + 建档字段)
- pregnancy:week/day/due 以服务端权威派生为准(避免客户端重复算、口径统一)
拉不到(无 openid / 未返回 200 / 断网)就静默保持内置默认值,页面照常可用。 */
/* 走到这里说明这次加载是带身份的(openid 非空)→ 把上一次的提示收掉 */
hideIdNotice();
return Promise.all([
fetch(base + 'profile?' + _pfQs).then(r => (r.ok ? r.json() : null)),
fetch(base + 'pregnancy?openid=' + encodeURIComponent(openid)).then(r => (r.ok ? r.json() : null))
]).then(function (rs) {
const pj = rs[0], pg = rs[1];
if (pj && pj.code === 200 && pj.result) {
const p = pj.result;
/* 服务端是唯一事实源:新用户 babyName 为空也要覆盖内置演示名(小汤圆),
让全站称呼走 babyWord 的「宝宝」兜底 */
state.babyName = p.babyName || '';
state.userName = p.userName || '';
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;
var _n = $('#meName'); if (_n) _n.textContent = p.userName || '未登录';
if (p.avatar) {
var _ai = $('#meAvatarImg'), _aw = $('#meAvatar');
if (_ai) _ai.src = p.avatar;
if (_aw) _aw.classList.add('has-img');
}
/* 授权入口:未授权时显示(引导先授权);退出按钮:已授权时显示 */
var _box = $('#meAuthBox'); if (_box) _box.style.display = _authorized ? 'none' : '';
var _lx = $('#meLogoutCard'); if (_lx) _lx.style.display = _authorized ? '' : 'none';
if (!p.userName) { var _ai2 = $('#meAvatarImg'); if (_ai2) _ai2.removeAttribute('src'); }
}
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; }
}
}
// 统一回刷依赖这些 state 的展示
syncPregnancy();
if (plHook) setTimeout(plHook, 0); // 「此刻」按孕周重拉(setTimeout:等下面 syncStaticDates 把 #calNext 算好)
/* 新用户空态:syncPregnancy 已写完引导占位(含称呼/静态位归零)。
⚠️ 下面 syncBabyWords/syncStaticDates 全按 state.week 重算,
而 pregEmpty 时 state.week 还是内置默认 27 —— 不拦住的话,
刚写好的占位会被改回「孕 27 周 / 9月15日」演示值。
⚠️ 产后态同样要拦:syncPregnancy 的产后分支已写好「已出生」与 '--' 占位,
而 state.week 是 null → syncStaticDates 会按 ga=0 算出**看着正常但全错**的日期。 */
if (state.pregEmpty || state.postpartum) { return; }
syncBabyWords();
syncStaticDates();
}).catch(function () { /* 静默回退内置值 */ });
}
/* ---------- 远程数据(box_app,统一改H5 · 第一批:资料库/记录/内容) ----------
applyRemoteProfile 已拉 profile/pregnancy/config;
这里再拉 resources/records/content 并**原地覆盖**本地 demo,接口数据为准,拉不到则保持内置。
⚠️ RES 图片:后端把封面图放到 box_app /assets,这里把返回的 imgUrl 赋给 RES[key].img,
渲染(用 r.img)无需改动;正文(body)仍用本地(接口未下发,保证详情不空)。 */
function __applyResources(items) {
if (!items || !items.length) return;
Object.keys(RES).forEach(function (k) { delete RES[k]; });
var order = [];
items.forEach(function (it) {
var old = RES[it.key] || { body: [] };
RES[it.key] = {
img: it.imgUrl || old.img || '', cat: it.cat, tint: it.tint,
title: it.title, desc: it.desc, meta: it.meta || old.meta || [],
body: old.body || [],
};
order.push(it.key);
RES[it.key].fav = !!it.fav;
/* ⚠️ 接口还返回 got(已领),但 H5 侧已无「已领」概念(9.19 需求),
这里**有意不读** —— 别顺手把它加回 RES/state。 */
});
// 同步收藏状态(对齐后端)。已领(resGot)已删除,见文件顶部 state 那段说明。
state.resFav = items.filter(function (i) { return i.fav; }).map(function (i) { return i.key; });
renderResList(state.resCat);
}
function __applyRecords(rec) {
if (!rec) return;
state.kickToday = rec.kickCount || 0;
state.todayCount = rec.total || 0;
var a = $('#kickTodayHome'), b = $('#todayCount'), c = $('#kickCount');
/* ⚠️ #kickTodayHome 归**卡片接口**(tool-cards,来自数胎动应用本身)优先:
mama-box 早就不自己记胎动了(卡上那颗「记录」是跳去数胎动),本地 records 常年是 0。
两个接口并行返回、先后不定,所以用 cardsOwned.kick 这个旗子定优先级,而不是赌顺序。 */
if (a && !cardsOwned.kick) a.textContent = state.kickToday;
if (b) b.textContent = state.todayCount;
if (c) c.textContent = state.kickQuick || 0;
}
/* 禁止双指缩放(2026-10-01 用户要求首页固定)。iOS 微信会忽略 viewport 的 user-scalable=no,
所以再拦一道:iOS 的 gesture 事件 + 多指 touchmove;双击放大由 CSS touch-action:manipulation 处理。 */
(function noZoom() {
['gesturestart', 'gesturechange', 'gestureend'].forEach(t => document.addEventListener(t, e => e.preventDefault(), { passive: false }));
document.addEventListener('touchmove', e => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false });
})();
/* ================= 首页「孕育工具」卡片(box_app 封装数胎动平台数据,2026-09-22) =================
数据源:GET /api/mp/public/mama-box/tool-cards(box_app 归一化后的稳定契约;
上游是数胎动平台的 GET /taidong/api/user/app-cards,H5 不直连、也不持有上游 token)。
两条纪律:
① **接口优先、失败回退本地**:ok=false 或某槽缺失 → 保留卡上现有值(本地派生/占位),
页面绝不因为上游抖动而变空;`stale=true` 时接口回吐的是 6h 内的旧值,照样可用。
② **名称与链接仍以 App 表为准**:卡片点击走 MINI_APP_MAP + APP_BY_ID(mama-box AGENTS §4.4
「不要在本仓库写死应用名单」);接口给的 appId/path 只作诊断 —— 上游的 app_id 是
URL slug(`taier-guzhong`),与 App 表 identifier(`taierguzhong`)刻意不同,不能拿来 join。
每槽落到哪:kick→#kickTodayHome(+#kickExtra) / fweight→.fweight .mc-sub /
weight→#weightVal(+#weightExtra) / cal→#calNext / curve→.curve .mc-sub。
⚠️ 上游字符串一律走 textContent,**绝不用 innerHTML**(同 footprint 的写法)。
⚠️ fweight/cal/curve 三张是固定高度且列窄,没有第二行的位置 —— 它们的 sub 暂不展示,
要展示得先重排 .bento 的高度账(CSS 里 .mc-extra 那段有说明)。 */
const cardsOwned = { kick: false, cal: false }; // 卡片接口已接管哪些格(本地源据此让位)
function applyRemoteCards(r) {
if (!r || r.ok === false || !(r.cards || []).length) return; // 降级:保留现有值
const bySlot = {};
r.cards.forEach(c => { if (c && c.slot) bySlot[c.slot] = c; });
const cardOf = slot => $('.mini-card[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('.mini-card.fweight .mc-sub', f.text); mark('fweight', f); }
const w = bySlot.weight;
if (w) {
const big = $('#weightVal');
if (big) {
big.textContent = w.value || '--';
/* 单位单独小字;只在有值时补,避免出现「-- 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 = $('.mini-card.curve .mc-sub');
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 秒随机一行/一列先亮一下,再「消掉」换一批新的掉下来。 */
const GAME_URL = 'https://beian3.douba.top/myxx/index.html'; // 由「母婴消消」会话维护,部署在 47.120.70.60,不需要身份参数(多带无害)
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 婴儿房」会话(33d6c837)维护;
带 return_url(首页净地址,不含 hash),对方返回按钮读到就跳回来。syncPregnancy() 每次都会调 nurseryRender()。 */
const NURSERY_URL = 'https://beian3.douba.top/yef/';
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;
$('#nyBtn').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 = '20261001p';
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') + '.jpg?v=' + NY_COVER_V;
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>';
}
/* 妈妈头像插画(纯 SVG,5 种发色 × 3 种发型 × 5 种底色,按 seed 取)。只是装饰,不代表任何真实用户。 */
const MOM_HAIR = ['#4A3328', '#2F2420', '#6B4532', '#3B2A2A', '#7A5038'];
const MOM_BG = ['#FFD9CB', '#E6DCFF', '#FFE9B8', '#CDEBDD', '#FFD3E1'];
const MOM_TOP = ['#FF9E85', '#9F8CF0', '#F2B84B', '#6CBF9A', '#F07AA0'];
function momAva(seed) {
const i = Math.abs(seed | 0), h = MOM_HAIR[i % 5], st = Math.floor(i / 5) % 3;
const bg = MOM_BG[Math.floor(i / 3) % 5], top = MOM_TOP[(i + 2) % 5];
const back = st === 0 ? '<circle cx="20" cy="9" r="4.6" fill="' + h + '"/><path d="M11 21c0-7 4-11.5 9-11.5s9 4.5 9 11.5z" fill="' + h + '"/>'
: st === 1 ? '<path d="M9.5 23c0-9 4.6-14 10.5-14s10.5 5 10.5 14v11h-21z" fill="' + h + '"/>'
: '<path d="M10.3 25c-1-9 3.4-15.4 9.7-15.4S30.7 16 29.7 25l-2 2.4H12.3z" fill="' + h + '"/>';
return '<svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" fill="' + bg + '"/>' + back +
'<path d="M7 41c1.2-6.4 6.4-9.6 13-9.6S31.8 34.6 33 41z" fill="' + top + '"/>' +
'<rect x="17.6" y="28" width="4.8" height="4.6" rx="2" fill="#F6CDB8"/>' +
'<ellipse cx="20" cy="22.4" rx="8" ry="8.8" fill="#FFE3D3"/>' +
'<path d="M12 21.4c0-5.6 3.6-9.4 8-9.4s8 3.8 8 9.4c-2.6-2.6-5.8-3.9-8.6-4.1-2.4 1.6-4.6 3-7.4 4.1z" fill="' + h + '"/>' +
'<path d="M15.8 23.4q1.3 1.1 2.6 0M21.6 23.4q1.3 1.1 2.6 0" stroke="#5A3A2E" stroke-width="1.2" fill="none" stroke-linecap="round"/>' +
'<circle cx="15" cy="26" r="1.5" fill="#FF8F7A" opacity=".45"/><circle cx="25" cy="26" r="1.5" fill="#FF8F7A" opacity=".45"/>' +
'<path d="M18.6 27.2q1.4 1 2.8 0" stroke="#D9735E" stroke-width="1" fill="none" stroke-linecap="round"/></svg>';
}
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); };
/* 「此刻」重绘钩子:足迹 / 档案 / 盲盒状态回来时调用(它们决定「和你比」「今日待办」和孕周) */
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) || [];
/* ---------- 首页入口:一句轮播 ---------- */
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);
$('#weRoll').textContent = l.length ? '“' + l[0].text + '”' : '';
ri = 0;
$('#weGo').innerHTML = list().length + ' 句<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']];
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, mt = null;
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) {
const n = l.length, cols = n > 16 ? 5 : 4, rows = Math.max(2, Math.ceil(n / cols));
const fresh = l.findIndex(x => !x.mine && !seen.has(x.id));
$('#wskBright').innerHTML = l.map((it, i) => {
const h = hsh(it.id), c = i % cols, r = Math.floor(i / cols);
const x = 36 + ((c + .5 + ((h % 100) / 100 - .5) * .7) / cols) * 58;
const y = 21 + ((r + .5 + (((h >> 8) % 100) / 100 - .5) * .6) / rows) * 38;
const sz = it.mine ? 26 : Math.round(15 + Math.min(it.hugs || 0, 30) / 30 * 11);
return '<button type="button" class="wsk-st' + (it.mine ? ' mine' : '') + (seen.has(it.id) ? ' seen' : '') + (i === fresh && !seen.size ? ' hint' : '') +
'" data-go="' + (i + 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');
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(done) {
if (!mt) return;
cancelAnimationFrame(mt.raf);
mt.el.remove();
mt.btn.classList.remove('flare', 'gone');
mt.btn.classList.add('seen');
const m = mt;
mt = null;
if (done) paintCard(m.it, m.i);
}
/* 第一次进来:一闪一闪的提示,点过一次星星就永久收起 */
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) return;
stopMeteor();
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('seen'); paintCard(it, i); 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 = 12, f1 = n > 20 ? 21 : n > 12 ? 24 : 27, gap = 22;
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');
const t0 = performance.now();
const frame = now => {
const t = now - t0;
if (t < T0) { mt.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;
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 (u >= 1) { stopMeteor(true); return; }
mt.raf = requestAnimationFrame(frame);
};
mt = { el, btn, it, i, raf: requestAnimationFrame(frame) };
}
function nextStar() {
const l = list(), n = l.length;
for (let k = 1; k <= n; k++) {
const j = (cur - 1 + k) % n;
if (!seen.has(l[j].id) && !l[j].mine) return j + 1;
}
return cur % n + 1;
}
function paintPage() {
const l = list(), n = l.length;
const bh = (new Date().getUTCHours() + 8) % 24, night = bh >= 18 || bh < 5;
$('#whpTag').textContent = data ? '孕 ' + data.week + ' 周 · 前后一周的妈妈' : '';
$('#wskTitle').innerHTML = '<div class="wsk-t">' + (night ? '今晚' : '今天') + ',<b>' + n + '</b> 句悄悄话</div><p>每颗亮星是一句。点一颗,听她说。</p>';
paintDeco();
paintSky(l);
paintList(l);
if (cur && list()[cur - 1]) $$('.wsk-st[data-go="' + cur + '"]', page).forEach(x => x.classList.add('cur'));
$('#whpLeft').textContent = !data ? '' : !oid() || !data.canPost ? '填好预产期,就能说一句' : data.left ? '今天还能说 ' + data.left + ' 句' : '今天的 3 句说完啦,明天再来';
}
function drawer(on) {
$('#whDrawer').classList.toggle('show', on);
$('#whDrawer').setAttribute('aria-hidden', on ? 'false' : 'true');
}
$('#whvAll').addEventListener('click', () => { haptic(); drawer(true); });
$('#whdX').addEventListener('click', () => drawer(false));
$('#whDrawer').addEventListener('click', e => { if (e.target.id === 'whDrawer') drawer(false); });
page.addEventListener('click', e => {
const g = e.target.closest('[data-go],[data-next]');
if (!g) return;
haptic();
drawer(false);
launch(g.dataset.next ? nextStar() : +g.dataset.go);
});
function openPage() {
stopMeteor();
cur = 0;
card.classList.remove('show');
paintPage();
tip(true);
page.classList.add('show');
page.setAttribute('aria-hidden', 'false');
drawer(false);
haptic();
}
function closePage() {
stopMeteor();
drawer(false);
page.classList.remove('show');
page.setAttribute('aria-hidden', 'true');
}
entry.addEventListener('click', openPage);
$('#whpBack').addEventListener('click', closePage);
function show(on) {
entry.classList.toggle('hide', !on);
const sec = $('#pulse');
if (sec) sec.classList.toggle('has-wh', on);
if (!on) closePage();
}
function load(w, silent) {
wk = w;
if (!w) { data = null; show(false); return; }
fetch(API + '?openid=' + encodeURIComponent(oid() || '') + '&week=' + w)
.then(r => (r.ok ? r.json() : null))
.then(j => {
const r = j && j.code === 200 && j.result;
if (!r || !r.show || !(r.list || []).length) { data = null; show(false); return; }
data = r;
const ns = r.list.map(x => x.id + ':' + x.hugs + (x.hugged ? 'h' : '')).join('|');
if (ns !== sig) {
sig = ns;
paintEntry();
/* 页面开着时静默刷新不重画,免得正在看的那句跳走 */
if (!page.classList.contains('show')) paintPage();
}
show(true);
})
.catch(() => { if (!silent) show(false); });
}
whLoad = w => load(w, false);
setInterval(() => { if (wk && !document.hidden && !busy) load(wk, true); }, 300000);
/* 抱抱:先变(心跳 + 一串小心飘上去),失败再退回 */
function burst(b) {
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
for (let k = 0; k < 7; k++) {
const h = document.createElement('i');
h.className = 'whp-fly';
h.innerHTML = HEART;
h.style.setProperty('--x', (Math.random() * 60 - 30).toFixed(0) + 'px');
h.style.setProperty('--r', (Math.random() * 50 - 25).toFixed(0) + 'deg');
h.style.animationDelay = (k * 60) + 'ms';
b.appendChild(h);
setTimeout(() => h.remove(), 1300);
}
}
page.addEventListener('click', e => {
const b = e.target.closest('[data-hug]');
if (!b) return;
const o = oid();
if (!o) { toast('登录后就能给她抱抱啦'); return; }
if (b.classList.contains('on')) { toast('你已经抱过她啦'); return; }
const id = b.dataset.hug;
const it = list().find(x => x.id === id);
haptic();
const n0 = it ? it.hugs : 0;
if (it) { it.hugs = n0 + 1; it.hugged = true; }
$$('[data-hug="' + CSS.escape(id) + '"]', page).forEach(x => { x.classList.add('on', 'pop'); $('b', x).textContent = n0 + 1; setTimeout(() => x.classList.remove('pop'), 600); });
burst(b);
fetch(API + '/hug?openid=' + encodeURIComponent(o) + '&id=' + encodeURIComponent(id), { method: 'POST' })
.then(r => r.json())
.then(j => { if (!j || j.code !== 200) throw j; })
.catch(j => {
if (it) { it.hugs = n0; it.hugged = false; }
$$('[data-hug="' + CSS.escape(id) + '"]', page).forEach(x => { x.classList.remove('on'); $('b', x).textContent = n0 || ''; });
toast((j && j.message) || '网络不太好,再抱一次试试');
});
});
/* 说一句(弹窗盖在全屏页上面) */
const ta = $('#whTa');
const cnt = () => { $('#whCnt').textContent = ta.value.length + '/' + ((data && data.maxLen) || 40); };
ta.addEventListener('input', () => {
cnt();
$$('.wh-pre', $('#whPresets')).forEach(c => c.classList.toggle('on', c.textContent === ta.value));
});
$('#whSay').addEventListener('click', () => {
if (!data) return;
if (!oid() || !data.canPost) { toast('填好预产期,就能和同孕周妈妈说悄悄话啦'); return; }
if (!data.left) { toast('今天已经说了 3 句啦,明天再来'); return; }
ta.value = '';
ta.maxLength = data.maxLen || 40;
cnt();
$('#whPresets').innerHTML = (data.presets || []).map(t => '<button class="wh-pre" type="button">' + esc(t) + '</button>').join('');
$('#whLeft').textContent = '今天还能说 ' + data.left + ' 句';
openModal('modalWhisper');
});
$('#whPresets').addEventListener('click', e => {
const c = e.target.closest('.wh-pre');
if (!c) return;
haptic();
ta.value = c.textContent;
ta.dispatchEvent(new Event('input'));
});
$('#whSend').addEventListener('click', () => {
const t = ta.value.trim();
if (t.length < 2) { toast('写一两个字,或者点一句吧'); return; }
if (busy) return;
busy = true;
const btn = $('#whSend');
btn.disabled = true;
btn.textContent = '正在发出…';
fetch(API + '?openid=' + encodeURIComponent(oid()), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: t })
})
.then(r => r.json())
.then(j => {
if (!j || j.code !== 200 || !j.result) { toast((j && j.message) || '没发出去,再试一次'); return; }
const it = j.result;
data.left = it.left;
delete it.left;
data.list = [it].concat(data.list || []);
sig = '';
paintEntry();
paintPage();
closeModal();
setTimeout(() => launch(1), 350);
toast('发出去啦,同孕周妈妈会给你抱抱的');
})
.catch(() => toast('网络不太好,再试一次'))
.then(() => { busy = false; btn.disabled = false; btn.textContent = '悄悄发出'; });
});
})();
(function pulseCard() {
const sec = $('#pulse');
if (!sec) return;
let seen = false, last = null, feed = [], fi = 0, ticking = null, peerMode = false, loadedWk = null, shownN = 0;
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const tapMini = k => { const b = $('.mini-card[data-mini="' + k + '"]'); if (b) b.click(); };
const TODAY_GO = {
kick: () => tapMini('kick'),
weight: () => tapMini('weight'),
cal: () => tapMini('cal'),
box: () => openBoxEntry(),
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 周的妈妈 数完胎动……」按「的妈妈 」切 */
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;
return '<div class="pl-row"><span class="pr-ava">' + momAva(strSeed(t + (f.ago || ''))) + '</span>' +
'<span class="pr-who">' + esc(who) + ' · ' + esc(f.ago || '刚刚') + '</span><span class="pr-do">' +
esc(what).replace(/(\d+)/g, '<em>$1</em>') + '</span></div>';
}
function tick() {
const tr = $('#plTrack');
if (!tr || feed.length < 2 || document.hidden) return;
fi = (fi + 1) % feed.length;
tr.insertAdjacentHTML('beforeend', rowHTML(feed[fi]));
tr.classList.add('is-move');
tr.style.transform = 'translateY(-26px)'; /* = .pl-row 高度 */
setTimeout(() => {
tr.classList.remove('is-move');
/* 翻动途中若 paint() 重置过轨道(只剩 1 行),别把仅剩的那行也删了 */
if (tr.childElementCount > 1) 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 个 */
avs.innerHTML = [3, 11, 7, 19, 2, 14, 23].map(x => '<i>' + momAva(x + seed) + '</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;
feed = nf;
fi = 0;
const tr = $('#plTrack');
tr.classList.remove('is-move');
tr.style.transform = '';
tr.innerHTML = rowHTML(feed[0]);
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; paint(); } })
.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');
me.classList.toggle('hide', !BB || !BB.enabled || !!BB.notYet || !!BB.needProfile);
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;
}
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);
$('#ptWk').textContent = '孕 ' + state.week + ' 周';
$('#ptWhen').textContent = '本周第 ' + (today + 1) + ' 天';
$('#ptFloat').textContent = size.emoji;
$('#ptBabyIco').textContent = size.emoji;
/* 四张卡全文只在详情页 #detailTips;首页那排入口只有图标 + 标题 */
$('#ptBaby').textContent = '像' + size.name + '那么大。' + (wk.baby || '');
$('#ptMom').textContent = wk.mom || '';
$('#ptEat').textContent = wk.eat || '';
$('#ptCare').textContent = wk.care || '';
$('#ptChipBaby').textContent = size.emoji;
$('#ptDtTitle').textContent = '孕 ' + state.week + ' 周 · 这一周';
/* 一周 7 个圆点:以前可回看,今天实心,以后锁着 */
const box = $('#ptDays');
box.textContent = '';
for (let i = 0; i < 7; i++) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'pt-day ' + (i < today ? 'is-past' : i === today ? 'is-today' : 'is-lock');
b.setAttribute('role', 'tab');
const n = document.createElement('i'); n.textContent = i + 1;
const t = document.createElement('span');
t.textContent = i === today ? '今天' : (i === today + 1 ? '明天' : '\u00a0'); // 圈里已是「第几天」,只给今天/明天配字
b.appendChild(n); b.appendChild(t);
b.addEventListener('click', () => {
if (i > today) { toast(i === today + 1 ? '明天再来看新的小知识~' : '到那天再来看哦~'); return; }
showTip(wk, i, today);
});
box.appendChild(b);
}
/* 详情页「本周每日小知识」:今天在上,往前倒着排到第 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);
}
tipSel = -1;
showTip(wk, today, today);
sec.classList.remove('hide');
revealTipsGrid();
}
function showTip(wk, i, today) {
if (i === tipSel) return;
tipSel = i;
const d = wk.days[i] || wk.days[0];
$('#ptBadge').textContent = i === today ? '今日小知识' : '第 ' + (i + 1) + ' 天 · 回看';
$('#ptT').textContent = d.t || '';
$('#ptD').textContent = d.d || '';
$$('#ptDays .pt-day').forEach((b, k) => {
b.classList.toggle('is-sel', k === i);
b.setAttribute('aria-selected', k === i ? 'true' : 'false');
});
const body = $('#ptBody');
body.classList.remove('is-in'); void body.offsetWidth; body.classList.add('is-in'); // 重播进场动画
}
/* 首页四个入口 → 详情页,滚到对应那张卡并闪一下 */
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 () {
const ch = $('#ptChips');
if (ch) ch.addEventListener('click', e => { const b = e.target.closest('.pt-chip'); if (b) openTips(b.dataset.pt); });
/* 首页今日小知识大卡也能点进详情页;底下 7 个圆点仍是切换回看,不跳页 */
const td = $('#ptToday');
if (td) td.addEventListener('click', e => { if (!e.target.closest('#ptDays')) openTips(); });
})();
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,渲染逻辑一行不多写。 */
const DEMO_CARDS = location.pathname.indexOf('/mama-box-preview/') === 0 ? {
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;
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;
SETTINGS.about.more = ct.more || SETTINGS.about.more;
SETTINGS.about.foot = ct.foot || SETTINGS.about.foot;
}
function applyRemoteAll() {
var openid = hashParam('openid');
if (!openid) return;
var base = '/api/mp/public/mama-box/';
var j = function (u) { return fetch(u).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); };
Promise.all([
j(base + 'resources?openid=' + encodeURIComponent(openid)),
j(base + 'records?openid=' + encodeURIComponent(openid)),
j(base + 'content?key=about'),
]).then(function (rs) {
var rr = rs[0], rec = rs[1], ct = rs[2];
if (rr && rr.code === 200 && rr.result) __applyResources(rr.result.items);
if (rec && rec.code === 200 && rec.result) __applyRecords(rec.result);
if (ct && ct.code === 200 && ct.result) __applyAbout(ct.result);
}).catch(function () { /* 静默回退内置值 */ });
}
/* ---------- 今日脚印(box_app → 数胎动对外 API,动态获取) ----------
首页 .today-line 时间轴原来是纯演示内容(无真实数据源,访客/未完善态整块隐藏),
现在启动后拉 /footprint/today 动态渲染。后端封装见 box_app 的 FootprintService:
上游数胎动按 unionid 建档(openid 跨应用互不相干),H5 身份注入惯例带
openid+unionid,unionid 缺失时后端用映射表兜底,这里只管把参数带上。
各分支(对齐全站「远程数据为准、拉不到回退内置」的惯例):
- linked=false(未关联数胎动 / 上游查无此人)→ 整块隐藏。演示行是假数据,
这用户在数胎动体系里没有档案,不能留着冒充真足迹。
- linked=true + events → 重建 .steps 行。textContent 拼装(上游文案直接进
DOM,杜绝注入);最多 8 条(接口升序,取最近 8 条,更早的让给「查看全部」)。
- linked=true + 无事件 → 一条占位,别让卡空成一截竖线。
- 拉取失败 → 什么都不动,保持内置演示行(跟其它远程接口的回退一致);
访客态不请求(pregEmpty 分支已整块隐藏,不会露出)。
⚠️ 竞态:本函数与 applyRemoteProfile 并行。profile 回来若判 pregEmpty 会
隐藏时间轴,所以这里落笔前再看一眼 state.pregEmpty,别把刚藏好的放出来。 */
var _footprintApplied = false;
/* 今日脚印(2026-09-23,B 方案:数据全部来自 box_app 的 /footprint/today,
它的上游是数胎动平台的**用户面**接口 /user/daily/footprint):
rows 拿到事件 → 时间轴;AI 气泡用接口给的 digest.text(**真文案**)
guide 今天**一次都没用过**(used 为空)/ 上游查无此人 → 引导占位:文案用接口的 lead、
应用用 unused[](上游已按我们上架清单过滤,且带回 url),
对得上 App 表的还能直接点进该应用
hide 接口读不到(网络 / 非 200 / 上游故障且无旧值)→ **整块不显示**
⚠️ AI 气泡不再挂写死的那句「今天你数到我动了 3 次」:文案一律取 digest.text,取不到就收起气泡
—— **data-driven**,不靠"有没有事件"猜(那句演示文案已从 index.html 删掉)。
⚠️ 未建档(pregEmpty)不再整块隐藏:照接口结论显示,新用户也能看到引导。
⚠️ 模块显隐只有 fpRender 一个 owner(原来 syncPregnancy 也直接改 display,会撞竞态)。 */
let _fpMode = 'pending'; // pending(加载中) | rows | guide | hide
let _fpData = null; // 接口 result(events / digest / lead / unused…)
function fpApply(mode, r) { _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()];
}
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: '',
});
});
rows.sort((a, b) => String(a.at).localeCompare(String(b.at)));
rows.slice(-8).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) || []).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()];
const wk = state.week ? '孕 ' + state.week + ' 周' + (state.day ? ' +' + state.day + ' 天' : '') + ' · ' : '';
$('#skSub').textContent = wk + (n.getUTCMonth() + 1) + '月' + n.getUTCDate() + '日 周' + wd;
const r = _fpMode === 'rows' || _fpMode === 'guide' ? (_fpData || {}) : null;
const today = $('#skToday'), next = $('#skNext');
today.classList.toggle('hide', !r);
next.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 done = $('#skDone');
done.textContent = '';
if (used.length) {
used.slice(0, 4).forEach(u => { const c = document.createElement('i'); c.textContent = u.appName; done.appendChild(c); });
if (used.length > 4) done.append('等 ' + used.length + ' 项');
} else done.textContent = '记下一笔,就是' + nm + '今天的第一只小脚丫';
/* 宫格里今天用过的工具打个小勾 */
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'); });
/* 先记这件:前 2 个还没用的 */
const box = $('#skCards');
box.innerHTML = '';
unused.slice(0, 2).forEach((u, i) => {
const ident = u.identifier || '', app = ident ? APP_BY_ID[ident] : null;
const href = u.url || (app && app.url) || '';
const name = u.appName || (app && app.name) || '';
if (!href || !name) return;
const [t, d] = todoLine(name);
const logo = (app && (app.logo || app.icon)) || '';
const c = document.createElement('button');
c.type = 'button';
c.className = 'sk-card ripple c' + i;
c.innerHTML = (logo ? '<img alt="" loading="lazy">' : '<span class="sk-l"></span>') + '<b></b><em></em><span class="sk-go">去记 ›</span>';
if (logo) c.querySelector('img').src = logo; else c.querySelector('.sk-l').textContent = name.slice(0, 1);
c.querySelector('b').textContent = t;
c.querySelector('em').textContent = d;
c.addEventListener('click', () => jumpApp(href, (app && app.identifier) || ident));
box.appendChild(c);
});
next.classList.toggle('hide', !box.childElementCount);
};
/* 加载中的中性占位(不是假数据):与 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();
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'];
const EXCH_URL = 'https://taidong.douba.top/doudou-duihuan/';
const DOU_API = EXCH_URL + 'api';
function openExchange() {
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 ? '&' : '?') + 'return_url=' + encodeURIComponent(location.href);
jumpApp(url, (app && app.identifier) || 'doudou-duihuan');
}
$$('#meExchCard, #homeExch').forEach(function (b) {
b.addEventListener('click', openExchange);
});
/* 兜豆余额 + 可兑件数 + 本场热门好物。
· /mall/drop 无需登录:拿商品(热门前 3 显示在「我的」页卡片底部);
· 有 openid 时 /auth/openid-login 换 token → /dou/summary 拿 balance;
· 可兑 M 件 = stock>0 且 price<=balance 且(opens_at 为空或已到点),口径与商城侧约定一致。
任何一步失败都静默保留默认文案「攒兜豆,好物免费兑」,不影响页面其它部分。
从商城返回(页面重新可见)时再刷一次,兑换后余额能跟上。 */
let douToken = '';
function setExchSub(t) {
$$('#meExchSub, #homeExchSub').forEach(function (el) { el.textContent = 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 (!sum || typeof sum.balance !== 'number') return;
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 + ' 件' : ' · 再攒攒就能兑'));
});
}).catch(function () {});
}
loadDou();
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/。
⚠️ 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() 高亮切换 ——
跳过去就离开本页了,没有高亮态可言。 */
const LAIXIN_FALLBACK_URL = 'https://mp.yizhezhe.com/laixin/';
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();
});
/* 「授权完善资料」:H5 → navigateTo → 小程序端独立授权页(pages/annex/auth/index)。
说明:微信 web-view 的 postMessage 非实时送达(只在分享/后退/onHide 等时机
才派发 bindmessage),点按钮时端上收不到 → 改用 wx.miniProgram.navigateTo
实时跳转到授权页,避开该限制。 */
function goAuth() {
haptic();
/* 与 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('请在小程序中完成授权登录');
}
}
var meAuth = $('#meAuthCard');
if (meAuth) meAuth.addEventListener('click', goAuth);
/* 「退出登录」(个人资料卡右上角图标按钮):
调后端 /api/mp/public/auth/logout 只清「授权登录信息」三字段(昵称/头像/手机号),
openid/unionid(小程序匿名身份)保留,孕育资料(小名/性别/预产期)保留。
成功后重载页面 → applyRemoteProfile 重新拉到空显示字段 → 回到未登录态、授权入口重现。
⚠️ stopPropagation:me-card 整卡是 data-modal="modalProfile",不阻止冒泡
会顺带打开资料弹窗。 */
var meLogout = $('#meLogoutCard');
if (meLogout) meLogout.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
haptic();
var openid = hashParam('openid');
if (!openid) { toast('未获得身份,无法退出'); return; }
fetch('/api/mp/public/auth/logout?openid=' + encodeURIComponent(openid), { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.code === 200) {
toast('已退出登录');
setTimeout(bustReload, 600);
} else {
toast(res.message || '退出失败,请重试');
}
})
.catch(function () { toast('退出失败,请检查网络'); });
});
/* 从商城/后台切回小程序首页时,webview 会再次可见 —— 把 H5 重置回「首页」屏。
⚠️ 若不处理:切到商城再点「首页」tab,仍是同一个 webview 实例,
SPA 还停留在切走前的屏(如「我的」),与"回到首页"的预期不符。
只在"当前不是首页"时才切,避免打断用户在首页上的浏览/滚动。 */
function resetScreenOnShow() {
var cur = document.querySelector('.screen.is-active');
if (cur && cur.dataset.screen && cur.dataset.screen !== 'home') go('home');
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') resetScreenOnShow();
});
window.addEventListener('pageshow', function (e) {
/* bfcache 恢复(persisted=true)= 用户「返回」回到了本页 → 清掉 last_app(见 clearLastAppOnBackHome) */
clearLastAppOnBackHome(!!(e && e.persisted));
if (document.visibilityState !== 'hidden') resetScreenOnShow();
});
/* 首页头图里会动的宝宝(2026-09-30,v6 起是 GIF)。装饰用:加载完再淡入,没加载出来就停在底下的静态图上。
「减少动态效果」下 CSS 直接隐藏。 */
(function heroKid() {
var g = document.getElementById('heroKid');
if (!g) return;
function on() { g.classList.add('is-on'); }
if (g.complete && g.naturalWidth) on();
else g.addEventListener('load', on);
})();
if (document.readyState === 'complete') init();
else window.addEventListener('load', init);
})();