Files
mama-box/js/app.js
Karlcarlo 77fb75796d feat(今日脚印): AI 气泡改说真话 —— 文案取接口 digest,引导取 lead+unused (v26092217)
配合 box_app 的 B 方案(今日脚印换源到数胎动用户面 /user/daily/footprint)。
接口现在一次给全:事件 + digest(气泡文案)+ lead/unused(引导),于是首页不用再编数据。

- **AI 气泡**:文案由 fpRender() 从 `digest.text` 写入(真实文案,宝宝口气的 AI 句子),
  `digest.warn` 为真时整句标红(.ai-warn)。取不到 digest 就整颗气泡收起。
  ⚠️ index.html 里那句写死的「妈妈,今天你数到我动了 3 次…」已删除 —— 它对任何用户都显示,
     而且把 today 的假次数和真实时间轴摆在同一屏。现在这条规则是 **data-driven**(有文案才显示),
     不再靠"有没有事件"猜。
- **引导占位**:文案用接口的 `lead`(如「宝妈,先从这几件事记起吧。」),应用用 `unused[]` ——
  能对上 App 表的渲染成**可点芯片**(点击 jumpApp 进该应用),对不上(如不在 App 表的宝宝来信)只显示名字。
- **三种显隐模式**(fpRender 是模块显隐的唯一 owner):
    rows  拿到事件 → 时间轴;guide 一条都没有(上游查无此人 / kind=fallback 今天没活动 /
    只浏览没产出记录)→ 引导;hide 接口读不到 / degraded → 整块不显示。
  原来「有档案但今天没记」的「还没有足迹」空态并入 guide(上游的 lead + 推荐应用比一句空态有用)。
- 删掉不再使用的 emptyFootprintStep()。

本地验证(桩后端,五种返回 × 资料延迟):rows → 时间轴 + 气泡显示**真实 digest 文案**;
guide → 上游 lead + 芯片(数胎动/胎儿估重可点、宝宝来信纯文本);linked=false → 引导兜底标题;
degraded / 接口 500 → 整块不显示。全程无 JS 异常。
2026-09-23 00:19:00 +08:00

3425 lines
191 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 = '26092217';
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 会话只尝试一次
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 上 */
function goArchiveH5() {
const oid = hashParam('openid');
const uid = hashParam('unionid');
let u = 'https://mp.yizhezhe.com/chanjian-rili/';
const q = [];
if (oid) q.push('openid=' + encodeURIComponent(oid));
if (uid) q.push('unionid=' + encodeURIComponent(uid));
if (q.length) u += '?' + q.join('&');
reportLastApp('https://mp.yizhezhe.com/chanjian-rili/', 'chanjianrili');
reportHostLocation(u);
location.href = u;
}
/* ================= 「记一下」弹层 · 全部孕期工具 = 已上架应用列表 =================
数据: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 也挪回文件末尾。 */
function openSheet() {
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('的话');
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());
}
function lockWeekFields(locked) {
pgWeek.disabled = pgDay.disabled = locked;
/* 锁着的时候连数字也不留 —— 没填预产期就没有「当前孕周」这回事,
留个旧值在灰格子里反而像是已经填好了 */
if (locked) { pgWeek.value = ''; pgDay.value = ''; }
pgHint.textContent = locked
? '还没填预产期,孕周先锁着 —— 选好上面的预产期就会自动算出来。'
: '已按预产期自动算出,觉得不准可以自己微调。';
}
/* 缓存戳重载:只换查询串(?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();
/* 头像预览跟随卡片(有正式头像才显示;新用户空圆 + +号) */
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');
}
}
/* 预产期占位层:空值显示「- 年 - 月 - 日」,选了值隐藏 */
function syncDuePh() {
const ph = $('#pgDuePh');
if (ph) ph.style.display = pgDue.value ? 'none' : 'block';
pgDue.classList.toggle('due-empty', !pgDue.value);
}
/* 预产期一变,孕周 / 额外天数自动算出来 */
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));
}));
/* 性别三选一 */
$$('#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 = '';
}
/* ---------- 页面里那些写死的示例日期 ----------
时光轴、珍贵时刻、首页两张小卡、指南标题、「记一下」抬头、搜索热词,
原来全是设计稿留下的固定字符串(「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 syncPregnancy() {
/* ---- 产后态:宝宝已出生 → 一律不显示孕周数字(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;
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 = '已出生';
const lb = hw.nextElementSibling;
if (lb) lb.textContent = '';
}
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 = '来到这个世界';
setRing($('#heroRing'), 1);
/* ③ 档案页 */
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 = '待完善孕期信息';
$('#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;
setRing($('#heroRing'), 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;
$('#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;
setRing($('#heroRing'), pct / 100);
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今日小结」 →「小汤圆今天想说」
「今日成长脚印」→「小汤圆的今日脚印」
这三处原来是写死的功能名。换成小名不只是亲切 —— 数胎动、称体重这些事,
妈妈是在替谁做,标题里就该有谁。
⚠️ AI 气泡上那枚 .ab-badge「AI」必须留着。
标题可以换成宝宝的口吻,但「这段话是机器生成的」不能因此说不清楚。
气泡页脚原来还有一句「AI自动汇总你每日记录」,第五批按用户要求删了 ——
于是这枚角标现在是**唯一**的机器生成提示,别再动它。测试里钉了一条。 */
$('#dockTitle').textContent = babyWord('的孕育工具');
$('#abTag').textContent = babyWord('今天想说');
$('#tlTitle').textContent = babyWord('的今日脚印');
/* 整条时间轴自己是个 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('先选一下预产期,孕周会自动算出来');
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 次胎动,小家伙很有精神」
「午后和睡前是他最爱动的时候」—— 妈妈、宝宝、系统三方在场,宝宝被当成第三个人
指指点点,读起来就是一份日报。
现在全部改成宝宝用第一人称直接对妈妈说:称呼妈妈为「你」,自称「我」,
该出现宝宝的地方一律是「我」,不再有「他 / 小家伙 / 宝宝」。
建议也照这个来 —— 不是「你应该怎样」,而是「你这样做我会更舒服」。
两条边界,别越过去:
· 口吻变软了,但不吓人那条铁律照旧 —— 不提异常、不提风险、不提就医。
· 只有宝宝在说的话改口吻。页尾免责声明、气泡上那枚 .ab-badge「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();
});
}
/* ---------- 远程配置(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 并不会让身份出现(反而可能循环),所以只留手动重试。 */
function showIdNotice(reason) {
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();
/* 新用户空态: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;
}
/* ================= 首页「孕育工具」卡片(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);
}
}
/* 拉卡片数据(与其它远程数据一起打;失败静默,卡上保留现有值) */
function applyRemoteCardsFetch() {
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 今天没有任何活动 / 上游查无此人 → 引导占位:文案用接口的 lead、应用用 unused[],
对得上 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(); }
function fpRender() {
const tl = document.querySelector('.today-line'), steps = $('#tlSteps'), guide = $('#tlGuide');
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';
if (mode === 'guide') { steps.style.display = 'none'; steps.innerHTML = ''; renderGuide(r); return; }
steps.style.display = '';
steps.innerHTML = '';
if (mode === 'pending') { steps.append(pendingFootprintStep()); return; }
(r.events || []).forEach(e => steps.append(buildFootprintStep(e)));
}
/* 引导占位:文案与应用都来自上游 —— lead 是上游自己的引导语,unused[] 是今天没用过的应用。
能对上 App 表的(identifier 由 box_app 按 url 路径解析)渲染成可点芯片,其余只显示名字。 */
function renderGuide(r) {
const title = $('#tlGuideTitle'), sub = $('#tlGuideSub'), box = $('#tlGuideApps');
if (title) title.textContent = (r && r.lead) || '还没有你的脚印';
if (!box) return;
box.innerHTML = '';
((r && r.unused) || []).slice(0, 5).forEach(u => {
const ident = u.identifier || '';
const app = ident ? APP_BY_ID[ident] : null;
if (app && app.url) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tl-chip ripple';
b.textContent = u.appName || app.name || ident;
b.addEventListener('click', () => jumpApp(app.url, app.identifier));
box.appendChild(b);
} else if (u.appName) {
const sp = document.createElement('span');
sp.className = 'tl-chip is-plain';
sp.textContent = u.appName;
box.appendChild(sp);
}
});
if (sub) {
sub.textContent = 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 = '正在获取今天的脚印…';
const from = document.createElement('span'); from.className = 'st-from';
d.append(time, dot, txt, from);
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; } // 上游故障且无旧值 → 不显示
const events = (r.events || []).slice(-8); // 升序取最近 8 条
/* 有事件 → 时间轴;一条都没有(上游查无此人 linked=false、今天 kind=fallback 没活动、
或只浏览没产出记录)→ 统一走引导:文案与应用名都由接口给,不再自己编。 */
if (events.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 ? '来源 · ' + e.app : '';
d.append(time, dot, txt, from);
return d;
}
/* 无足迹占位行:同构的一行,读屏和眼睛都不会把空卡当成坏卡。 */
/* ---------- 进商城(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-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();
});
if (document.readyState === 'complete') init();
else window.addEventListener('load', init);
})();