/(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); 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 未读结果**:
· 唯一 owner:syncLaixinReminder()(拉数)→ applyLaixinReminder()(落卡 + 落红点)
· 判据:unread > 0 才显示;归零则收起;上游降级(ok:false)保持现状
· 点击:跳 laixin(与底 tab 同落点)
⚠️ 卡里只有数字与时间(接口就给这些),所以文案是**通用提醒**,不做单封预览。
将来第三方上 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');
}
/* laixin 未读结果 → 首页卡。badge 由 syncLaixinReminder() 一并处理。 */
function applyLaixinReminder(r) {
const card = $('#homeLetter'), body = $('#homeBody');
if (!card) return;
/* 上游降级(ok:false)或未读为 0 —— 收起。
⚠️ 与底 tab 红点**同一套判据**,两处别各写一份(写歪一处就会出现
「红点亮了、卡没出来」这种自相矛盾的屏)。
⚠️⚠️ 这里**故意不看 state.pregEmpty**:那是「兜爸心选档案未建档」,
与「宝宝来信里有没有信」是两件事。加过一版,结果生产上实测出
「红点有 1 封未读、卡不显示」—— 用户能从 laixin 收到信,
却未必在 mama-box 填过预产期。别再加回来。 */
if (!r || r.ok === false || !(Number(r.unread) > 0)) { hideLetterCard(); 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');
}
$$('[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
? '还没填预产期,孕周先锁着 —— 选好上面的预产期就会自动算出来。'
: '已按预产期自动算出,觉得不准可以自己微调。';
}
/* 缓存戳重载:URL 已有 query 时必须用 & 拼接 —— 直接拼 '?t=' 会把
第二个 ? 吃进已有参数值里(?openid=abc001?t=123 → openid 变脏)。 */
function bustReload() {
const b = location.href.split('#')[0];
location.replace(b + (b.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now());
}
/* 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.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;
});
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 里的孕期信息刷到「我的」孕期卡 + 首页头部 + 档案页顶部 */
function syncPregnancy() {
/* ---- 新用户空态:未完善资料(无预产期)→ 全部显示引导占位,不读演示数据 ----
me 页已有两条引导(授权卡 + 孕期信息必填卡);这里把首页/档案页的
孕周数字换成引导文案,避免新用户看到"孕 27 周"这类假数据。 */
if (state.pregEmpty) {
const nm = state.babyName || '宝宝';
/* 脚印时间轴是纯演示内容(无真实数据源),访客/未完善态整块隐藏 */
const _tl = document.querySelector('.today-line');
if (_tl) _tl.style.display = 'none';
/* ① 我的 · 孕期卡 → 引导 */
$('#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 = '--'; });
const _t = new Date();
$('#noteWhen').textContent = mdTxt(_t);
syncBabyWords();
hideLetterCard();
return;
}
const week = state.week, day = state.day, name = state.babyName;
const _tl2 = document.querySelector('.today-line');
if (_tl2) _tl2.style.display = '';
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') 选的是**第一个** 。曾经那是一句话签名(已删),
现在第一个
是「已连续记录 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 = '已连续记录 42 天';
/* ⑥ 页面里那些示例日期(时光轴、珍贵时刻、小卡、热词…)也按新孕周重刷。
改预产期会连带改孕周,这些跟着一起动才不会前后矛盾。 */
syncStaticDates();
stampAiLogs();
}
/* ⚠️ 这里原来是 syncBio() —— 刷「我的」页头像旁那行一句话签名。
一句话签名本批按用户要求整块删除,四处一起走:
index.html #pfBio 输入框、
展示行
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 () {
/* 新用户首次完善资料:重载页面 → pregnancy 出真实孕周 */
if (_wasEmpty) setTimeout(bustReload, 700);
}).catch(function () { /* 保持本地值 */ });
}
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 =
'
' +
'
' +
'
' +
'
' +
'
兜爸心选
' +
'
v3.2.1 · 陪你从两条杠到见面
' +
'
';
function renderSetting(key) {
const cfg = SETTINGS[key] || SETTINGS.privacy;
$('#stTitle').textContent = cfg.title;
$('#stMore').dataset.toast = cfg.more;
$('#stHero').innerHTML = cfg.hero ? ABOUT_HERO : '';
$('#stLead').textContent = cfg.lead;
$('#stFoot').textContent = cfg.foot;
const html = cfg.groups.map(g => {
const rows = g.rows.map(r => {
/* .st-para 而不是 .st-txt:后者是首页成长脚印在用的类名 */
if (r[0] === 'txt') return '' + r[1] + '
';
if (r[0] === 'row')
return '';
/* 开关行:aria-checked 让读屏也能拿到状态,样式只认 .on */
return '';
}).join('');
return (g.t ? '' + g.t + '
' : '') + '' + rows + '
';
}).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,
带 和
的排版所以没法共用变量)。改这一条务必两处一起改。 */
const AI_LOGS = [
{
date: '9月3日 周二', week: '孕24周+3', mood: '安心',
txt: '妈妈,今天你数到我动了 3 次。我今天精神特别好,这是踏实的一天。',
stats: [['胎动', '3 次', '+1'], ['体重', '58.2 kg', '+0.2'], ['睡眠', '7.5 h', '-0.5']],
/* 三条建议全是宝宝在跟妈妈说话,但称呼**恰好只放两条**(第 2、3 条)。
三条都以「妈妈,」起头会读成排比,像模板灌出来的,反而不像在说话;
所以第 1 条把称呼让出去,改放进正文里 —— 整张卡仍有「妈妈」,只是不在句首。
⚠️ 这个「2 / 3」是被测试钉住的,不是随手写的。要改先想清楚再改断言。
⚠️ 同样**不做运行时随机**:这一页自称「小结每天 21:00 自动生成」(⋯ 按钮的 toast),
重开一次文案就变,等于当场拆穿自己。真要按天变,得按日期做 seed 从更大的文案池里取。 */
tips: [
{ cat: '胎动', tone: 'p', head: '午后和睡前是我最爱动的时候',
txt: '妈妈,这两次你都记下来啦。以后就固定在这两个点,安安静静陪我待一会儿好不好,我会更有安全感哟。' },
/* ⚠️ 这里的 0.3kg 是**妈妈的**周增重(和上面 stats 里「体重 58.2 kg +0.2」同源),
所以只加称呼、不改人称。改成「我这周长了 0.3kg」会有两处坏掉:
下面正文「不用为了我硬撑着多吃」讲的是妈妈的饭量,标题一换就对不上;
而且 0.3kg 对孕 24 周的胎儿明显偏大(实际周增约 100g),成了条假数据。 */
{ cat: '饮食', tone: 'g', head: '妈妈,你这周长了 0.3kg,刚刚好',
txt: '不用为了我硬撑着多吃,你按平时的胃口来,我就吃得饱饱的啦。' },
{ cat: '放松', tone: 'b', head: '妈妈,你睡前那 20 分钟散步,我很喜欢',
txt: '你走路的时候我像被轻轻摇着。这几天你睡得比上周沉,我们每天都去走一走吧。' }
]
},
{
date: '9月2日 周一', week: '孕24周+2', mood: '安稳',
txt: '妈妈,今天我安静了一点,其实是在补觉;你也多睡了半小时,我很开心。',
stats: [['胎动', '2 次', '-1'], ['体重', '58.0 kg', '+0.1'], ['睡眠', '8.0 h', '+1.0']]
},
{
date: '9月1日 周日', week: '孕24周+1', mood: '愉快',
txt: '周末你和爸爸放了胎教音乐,音乐一停我就动个不停 —— 我还想再听一遍。',
stats: [['胎动', '4 次', '+1'], ['体重', '57.9 kg', '+0.1'], ['睡眠', '7.0 h', '-0.2']]
},
{
/* 这条原来写「进入孕 24 周啦,他大约 30cm、600g」—— 又一处写死的孕周。
往期第 4 条对应的是四天前,孕周随用户当前进度变,这句话必然对不上。
换成不点具体周数的说法。 */
date: '8月31日 周六', week: '孕24周', mood: '期待',
txt: '我的耳朵最近越来越灵啦。今天你说话的时候,我动了两下回应你。',
stats: [['胎动', '3 次', '0'], ['体重', '57.8 kg', '+0.2'], ['睡眠', '7.2 h', '+0.4']]
},
{
date: '8月30日 周五', week: '孕23周+6', mood: '用心',
txt: '这一周你为我记了 21 条,是到现在最认真的一周。谢谢你呀妈妈。',
stats: [['胎动', '3 次', '0'], ['体重', '57.6 kg', '+0.1'], ['睡眠', '6.8 h', '-0.3']]
}
/* 往期这四条只留 date / week / mood / txt / stats。
原本每条还挂着三条 tips,是给往期弹窗铺 1/2/3 建议卡用的,弹窗不显示了,
数据也就跟着删掉 —— 留着不用,下次翻到这儿的人只会以为哪里漏渲染了。
aiTipsHTML 现在只有 AI_LOGS[0](今日小建议)一个消费者。 */
];
/* AI 小结的 date / week 是写死的('9月3日 周二' / '孕24周+3')。
这五条按定义顺序就是「今天、昨天、前天…」,所以直接按下标当天数往回推
(backAt 从那一刻倒推)。不这么做的话,档案打印出来会是
封面「孕 27 周 +2 天」、内页「今天 · 孕24周+3」,自己打自己的脸。 */
function stampAiLogs() {
AI_LOGS.forEach((L, i) => {
const d = new Date(backAt(i, [12, 0]));
const g = clampGA(state.week * 7 + state.day - i);
L.date = (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + WEEKDAY[d.getDay()];
L.week = '孕' + Math.floor(g / 7) + '周' + (g % 7 ? '+' + (g % 7) : '');
});
}
stampAiLogs();
/* 这两段今日卡和往期弹窗都要用,抽出来共用一份 */
const aiStatsHTML = d => d.stats.map(s => {
/* 和昨天持平就写「持平」,别显示一个孤零零的「0」 */
const n = parseFloat(s[2]);
const cls = !n ? 'eq' : n > 0 ? 'up' : 'dn';
const txt = !n ? '持平' : (n > 0 ? '↑' : '↓') + Math.abs(n);
return '' + s[1] + '' + txt + '' +
'' + s[0] + '
';
}).join('');
const aiTipsHTML = d => d.tips.map((t, i) =>
'' +
'' + (i + 1) + '' +
'' + t.cat + '' +
'' + t.head + '
' +
'' + t.txt + '
' +
'').join('');
function renderAI(idx) {
const d = AI_LOGS[idx] || AI_LOGS[0];
$('#aiWeek').textContent = d.week;
$('#aiDate').textContent = d.date;
$('#aiMood').textContent = d.mood;
$('#aiTxt').textContent = d.txt;
$('#aiStats').innerHTML = aiStatsHTML(d);
$('#aiTips').innerHTML = aiTipsHTML(d);
/* 往期列表跳过索引 0 —— 那就是顶上今日卡这一条,列出来是重复。
data-ai 仍写真实索引,好让 openAiLog 直接取。 */
$('#aiLogs').innerHTML = AI_LOGS.slice(1)
.map((l, i) => '').join('');
/* 原来点一条是把顶上的今日卡整个换掉,看完想回到「今天」没有出口。
改成在本页弹一层浮窗,看完点底下的叉就走,今日卡始终留在原位。 */
$$('#aiLogs .ai-log').forEach(b =>
b.addEventListener('click', () => openAiLog(+b.dataset.ai)));
}
function openAiLog(idx) {
const d = AI_LOGS[idx];
if (!d) return;
$('#alogWeek').textContent = d.week;
$('#alogMood').textContent = d.mood;
$('#alogTitle').textContent = d.date;
$('#alogTxt').textContent = d.txt;
/* 往期只给当天的数,不再铺 1/2/3 建议卡。#alogTips 连同标记一起删了,
这里必须跟着删 —— 只删 HTML 的话,一点往期就是 null.innerHTML 崩溃。 */
$('#alogStats').innerHTML = aiStatsHTML(d);
$('#modalAiLog').scrollTop = 0; /* .modal 自己带 overflow-y:auto,上次翻到哪儿要清掉 */
openModal('modalAiLog');
}
/* ================= 孕育资源库:列表页 + 内容页 ================= */
const RES = {
book: {
img: 'assets/img/9f3fa8edf4aa.png', cat: '产检', tint: 'o',
title: '《糖耐检查全攻略》', desc: '孕24-28周必看 · 附检查前饮食清单',
meta: ['8 分钟读完', '12.8 万妈妈已领取', '孕24-28周'],
body: [['什么是糖耐量检查', '糖耐量检查(OGTT)用于筛查妊娠期糖尿病,一般安排在孕 24–28 周。检查当天需空腹抽血一次,喝下 75g 葡萄糖水后 1 小时、2 小时各抽血一次,共三次。'],
['检查前 3 天怎么吃', '不需要刻意节食。保持平时饮食即可,但每天碳水化合物摄入不少于 150g,否则可能出现假阳性。避免油炸、含糖饮料与夜宵。'],
['检查当天注意事项', '前一晚 22:00 后禁食,可少量饮水。带上水杯、小零食(抽完最后一管血再吃)和陪同家属。喝糖水要在 5 分钟内喝完,之后静坐等待,不要来回走动。'],
['结果怎么看', '空腹 < 5.1、1 小时 < 10.0、2 小时 < 8.5(mmol/L),任意一项超标即诊断为妊娠期糖尿病。多数可通过饮食与运动控制,不必过度紧张。']]
},
food: {
img: 'assets/img/3657b1f76026.png', cat: '营养', tint: 'g',
title: '孕期营养全书', desc: '按孕周编排的一日三餐参考',
meta: ['12 分钟读完', '20.4 万妈妈已领取', '全孕期'],
body: [['孕中期的能量需求', '孕中期每天比孕前多约 300 kcal,相当于一杯牛奶加一个鸡蛋加半碗米饭,不需要「吃两人份」。'],
['四类必须补的营养素', '钙 1000mg/天(奶制品、豆制品)、铁 24mg/天(红肉、动物肝脏)、DHA 200mg/天(深海鱼)、叶酸 400μg/天(绿叶菜、补充剂)。'],
['一日三餐参考', '早餐:牛奶 + 全麦面包 + 鸡蛋 + 一份水果;午餐:杂粮饭 + 瘦肉 + 深绿色蔬菜 + 豆制品;晚餐:清淡为主,鱼虾 + 蔬菜 + 少量主食;加餐:坚果一小把或酸奶一杯。'],
['要少碰的东西', '生食(刺身、溏心蛋)、未杀菌乳制品、高汞鱼(金枪鱼、鲨鱼)、酒精与浓咖啡。']]
},
music: {
img: 'assets/img/67405412c52c.png', cat: '胎教', tint: 'p',
title: '胎教音乐合集', desc: '30 首适合孕中期的舒缓曲目',
meta: ['30 首 · 共 96 分钟', '9.6 万妈妈已领取', '孕16周起'],
body: [['宝宝什么时候能听见', '孕 16 周左右听觉器官初步形成,24 周后已能对外界声音作出反应,这是胎教音乐最合适的开始时间。'],
['正确的听法', '不要把设备贴在肚皮上。房间内正常音量播放即可,控制在 60 分贝以下,每次 15–20 分钟,每天 1–2 次。'],
['选什么曲子', '节奏平稳、旋律简单、无强烈鼓点的音乐最合适,如古典小品、轻钢琴、自然音。妈妈自己听着放松,才是最好的胎教。'],
['比音乐更重要的事', '妈妈的声音是宝宝最熟悉的声音。每天和宝宝说几句话,效果胜过任何一张专辑。']]
},
sport: {
img: 'assets/img/72910df66ee3.png', cat: '运动', tint: 'g',
title: '孕中期安全运动指南', desc: '每天 30 分钟,缓解腰背酸痛',
meta: ['6 分钟读完', '7.2 万妈妈已领取', '孕13-28周'],
body: [['为什么孕中期最适合运动', '早孕反应消退、腹部尚未过分隆起,是整个孕期活动最舒适的窗口,规律运动能降低妊娠糖尿病与子痫前期风险。'],
['推荐的四种运动', '快走、游泳、孕妇瑜伽、固定式单车。每周 5 天、每次 30 分钟,强度以「能正常说话但唱不了歌」为准。'],
['必须避开的动作', '仰卧位动作(孕 16 周后)、跳跃与急转、需要平衡的运动、任何有腹部撞击风险的项目。'],
['出现这些情况立即停止', '阴道出血、规律宫缩、头晕胸闷、羊水样液体流出,应停止运动并及时就医。']]
},
sleep: {
img: 'assets/img/f0d5e062f11a.png', cat: '心理', tint: 'b',
title: '睡不好怎么办', desc: '孕期失眠的 7 个改善方法',
meta: ['5 分钟读完', '15.1 万妈妈已领取', '全孕期'],
body: [['孕期失眠很常见', '约 70% 的孕妇有不同程度的睡眠问题,多由激素变化、尿频、腿抽筋和焦虑引起,绝大多数不需要药物治疗。'],
['睡姿怎么选', '孕中晚期优先左侧卧,可在两腿之间与腰后各垫一个枕头,减轻下腔静脉压迫。'],
['睡前一小时的仪式', '关掉主灯、放下手机、温水泡脚 10 分钟、轻音乐或呼吸练习,让身体建立入睡信号。'],
['白天该做的事', '固定起床时间、午睡不超过 30 分钟、下午之后不摄入咖啡因、傍晚适量活动。']]
},
diet: {
img: 'assets/img/dd5639fde61d.png', cat: '营养', tint: 'o',
title: '孕期体重管理', desc: '孕前 BMI 决定你该长多少',
meta: ['7 分钟读完', '11.3 万妈妈已领取', '全孕期'],
body: [['先算孕前 BMI', 'BMI = 体重(kg) ÷ 身高(m)²。孕前偏瘦(<18.5)建议全孕期增重 12.5–18kg,正常(18.5–24.9)11.5–16kg,超重(25–29.9)7–11.5kg。'],
['孕中期的增重节奏', '孕中晚期每周增重 0.3–0.5kg 较理想。单周超过 1kg 多为水肿或饮食过量,需要复盘。'],
['称重的正确方法', '固定每周同一天、同一时间、同样着装,晨起排空后测量,只看趋势不纠结单次数字。'],
['长得太快或太慢', '连续两周超标先减少精制主食与含糖饮料;增长过慢则增加优质蛋白与健康脂肪,并排查甲功与贫血。']]
},
report: {
img: 'assets/img/b855b45df1f3.png', cat: '产检', tint: 'b',
title: '产检时间表(全孕期)', desc: '13 次产检,一次都不漏',
meta: ['4 分钟读完', '26.7 万妈妈已领取', '全孕期'],
body: [['孕早期(≤13周)', '6–8 周建卡 + B超确认宫内孕;11–13⁺⁶ 周 NT 检查与早期唐筛。'],
['孕中期(14–27周)', '15–20 周唐筛或无创 DNA;20–24 周大排畸;24–28 周糖耐 + 血常规复查。'],
['孕晚期(28周后)', '28–36 周每 2 周一次;36 周后每周一次,含胎心监护、B超估重与骨盆评估。'],
['每次都要带的东西', '母子健康手册、既往报告、医保卡、水杯与小零食。建议提前一天在手机里存好问题清单。']]
},
med: {
img: 'assets/img/4657f50f42c8.png', cat: '心理', tint: 'p',
title: '孕期情绪自查手册', desc: '分清正常波动与孕期抑郁',
meta: ['6 分钟读完', '5.9 万妈妈已领取', '全孕期'],
body: [['情绪起伏是正常的', '激素剧烈变化叠加身份转变,孕期爱哭、易怒、突然焦虑都很常见,本身不等于抑郁。'],
['需要警惕的信号', '持续两周以上的情绪低落、对什么都提不起兴趣、睡眠与食欲明显改变、反复自责或出现伤害自己的念头。'],
['可以自己做的三件事', '把感受说出来而不是憋着;每天留 20 分钟只属于自己;减少无休止的搜索式焦虑。'],
['寻求帮助不丢人', '产科、心理科都可以提供帮助,孕期心理干预安全且有效,越早越好。']]
}
};
const RES_ORDER = ['book', 'food', 'music', 'report', 'sport', 'diet', 'sleep', 'med'];
/* ⚠️ 2026-09-19(9.19 需求 1.2):这里原来有 PAN_DEFAULT / PAN 两张**百度网盘
链接表**、panOf()、以及企业微信兑换码 WX_CODE / wxCodeOK()。
需求方确认「扫码加顾问 → 回复领资料 → 顾问直接发资料」之后,H5 不再发放网盘
链接,这套(连同 #modalPan 弹窗、givePan()、后台下发的 pan/panDefault/wxCode)
整块删除。要恢复「H5 自己发链接」,等于把「解锁」概念加回来,先回问需求方。 */
const isFav = k => state.resFav.indexOf(k) >= 0;
function resCardHTML(k) {
const r = RES[k];
/* ⚠️ 这里原来还有「领过的那一份」分支:整张卡加 .is-got、缩略图右上角打 ✓、
底行的「免费」标换成「已领 ✓」。9.19 需求取消「已领」概念后一并删除,
现在每张卡都是同一个样子(免费)。 */
return '';
}
function bindResCards(root) {
$$('.res-item', root).forEach(b =>
b.addEventListener('click', () => openPanel('detailRes', b)));
}
function renderResList(cat) {
state.resCat = cat || 'all';
/* 收藏不是分类而是筛选,默认排在最末。但只要用户真收藏过东西,
它就成了最常用的一格 —— 排在末尾要横向滑到头才够得着,所以提到「全部」后面。
用 insertBefore/appendChild 挪节点(不是重建 innerHTML),init 时绑的点击事件才不会丢。
已经在第二位时 children[1] 就是 favChip 自己,insertBefore 原地不动,无副作用。
⚠️ 原来还有个 .chip-got(✓ 已领)跟着一起挪,已随「已领」概念删除。 */
const chipsRow = $('#resChips'), favChip = $('.chip-fav', chipsRow);
const favN = state.resFav.length;
if (favN) chipsRow.insertBefore(favChip, chipsRow.children[1]);
else chipsRow.appendChild(favChip);
favChip.textContent = favN ? '★ 收藏 ' + favN : '★ 收藏';
/* fav 不是分类,是「只看收藏过的」,所以单走一支 */
const fav = state.resCat === 'fav';
const keys = fav ? RES_ORDER.filter(isFav)
: RES_ORDER.filter(k => state.resCat === 'all' || RES[k].cat === state.resCat);
$('#resList').innerHTML = keys.map(resCardHTML).join('');
bindResCards($('#resList'));
$('#resEmptyHint').textContent = keys.length
? (fav ? '已收藏 ' + keys.length + ' 份资源'
: '共 ' + keys.length + ' 份资源 · 更多内容按孕周陆续更新')
: (fav ? '还没有收藏 · 打开任意资源,点右上角 ☆ 就能收进来'
: '该分类下暂无资源,换一个试试~');
$$('#resChips .chip').forEach(c =>
c.classList.toggle('is-active', c.dataset.rescat === state.resCat));
}
$$('#resChips .chip').forEach(c => c.addEventListener('click', () => {
renderResList(c.dataset.rescat);
haptic();
}));
function fillRes(key) {
const r = RES[key] || RES.book;
state.resKey = RES[key] ? key : 'book';
$('#rdImg').src = r.img;
$('#rdCover').className = 'rd-cover ' + r.tint;
$('#rdCat').textContent = r.cat;
$('#rdTitle').textContent = r.title;
$('#rdDesc').textContent = r.desc;
$('#rdMeta').innerHTML = r.meta.map(m => '' + m + '').join('');
$('#rdBody').innerHTML = r.body
.map(p => '' + p[0] + '
' + p[1] + '
').join('');
$('#rdMore').innerHTML = RES_ORDER
.filter(k => k !== state.resKey).slice(0, 3).map(resCardHTML).join('');
bindResCards($('#rdMore'));
/* 原来这里无条件重置成 ☆,收藏过的资源再打开会显示成没收藏。
改成按 state.resFav 回显 */
const fav = state.resFav.indexOf(state.resKey) >= 0;
const star = $('#resStar');
star.classList.toggle('on', fav);
star.textContent = fav ? '★' : '☆';
/* 底部左键已从「收藏」改成「资源库」,是个固定文案的导航按钮,不再需要回显。
⚠️ 右键「立即领取」原来会按 state.resGot 回显成「再拿一次链接」,
9.19 需求取消「已领」概念后改回**固定文案**(点它只是弹顾问二维码)。 */
$('#rdGet').textContent = '立即领取';
$('#detailRes .detail-body').scrollTop = 0;
}
$('#resStar').addEventListener('click', function () {
const i = state.resFav.indexOf(state.resKey);
const on = i < 0;
if (on) state.resFav.push(state.resKey); else state.resFav.splice(i, 1);
saveResFav();
this.classList.toggle('on', on);
this.textContent = on ? '★' : '☆';
/* 列表页就压在这一层下面,退回去时得是新的。
原来只在「正停在收藏筛选上」时才重渲染,但现在 chip 的位置也跟收藏数有关
—— 收藏第一份资源之后返回列表,「收藏」得已经挪到第二位了,所以无条件重来一遍 */
renderResList(state.resCat);
toast(on ? '已收藏到「我的书架」' : '已取消收藏');
haptic();
});
/* 底部左键:去「孕育资源库」。
⚠️ 不能简单写 openPanel('detailResList')。用户多半是**从资源库列表点进这个详情页**的,
此时 panelStack = [detailResList, detailRes],而 openPanel 开头有一句
`if (reopen) return`(见上面 openPanel 的定义)—— 列表已在栈内,它会静默 return,
页面纹丝不动,看着就像按钮坏了。而从首页书架直接进来的用户栈里没有列表,
同一个按钮又是好的:不处理的话这颗按钮行为不一致。
所以分两种情况,但保证结果一致(点完一定停在资源库这一层):
已在栈里 → 关掉压在它上面的层,退回去(返回键随之回主屏,符合「往回走」的心智)
不在栈里 → 正常压栈打开 */
$('#rdLib').addEventListener('click', () => {
const list = $('#detailResList');
if (panelStack.indexOf(list) >= 0) {
while (panelStack.length && panelStack[panelStack.length - 1] !== list) closePanel();
renderResList(state.resCat); // 收藏/领取状态可能刚变过,退回去得是新的
haptic();
} else {
openPanel('detailResList'); // openPanel 内部自带 renderResList + haptic
}
});
/* 点「立即领取」= 打开企业微信顾问二维码。
⚠️ 2026-09-19(9.19 需求 1.2):这里原来是「兑换码闸门」—— 未解锁的用户先被拦下,
填码解锁之后 H5 才把网盘链接交给他。需求方确认「扫码加顾问 → 回复「领资料」 →
顾问直接发资料」之后,闸门、兑换码、网盘弹窗整条链路都删了(连同 givePan()、
state.wxUnlocked、wxPending),这颗按钮现在只做一件事:弹二维码。
不存在「已解锁 / 未解锁」两种起点,所以这里也没有任何状态判断 —— 别再加回来。 */
$('#rdGet').addEventListener('click', () => { openWx(); });
/* ================= 企业微信顾问弹窗(单态) =================
三个入口共用这一个壳:「我的」页服务卡 #meWxCard、资源库顶部试用装 banner #rlGift、
资料详情页「立即领取」#rdGet。三者说同一句话,openWx() 只有一个参数都没有。
⚠️ 弹窗里**没有任何按钮**,唯一出口是右上角 .modal-x(外加遮罩 / Esc)——
删按钮之前确认过它在;别做「头部无按钮时隐藏」这种优化。
⚠️ 这里原来有 openWx(mode, key) + is-gate/is-gift/is-done 三模式类名 + wxPending +
setLead() 高亮 + 兑换码校验 + #wxOk / #wxCode 监听,全部随闸门删除。
别再引入「按入口改写文案」的分支:三个入口同一件事,分叉只会让以后改文案的人漏一条。 */
const wxModal = $('#modalWx');
function openWx() {
openModal('modalWx');
}
$('#meWxCard').addEventListener('click', () => openWx());
/* 资源库顶部那条 banner 和「我的」页服务卡是同一件事、同一个入口。
⚠️ 走 openWx() 而不是挂 data-open="modalWx":那条通用通道直接调 openModal,
不经过这里 —— 今天两者等价,但留一个入口,以后要统一加埋点/清理只改一处。 */
$('#rlGift').addEventListener('click', () => openWx());
/* ⚠️ 2026-09-19(9.19 需求):这里原来有一个 syncUnlockCopy(),按「已解锁 / 未解锁」
把首页书架与资源库的两句引导语改成两个版本,并给资源库顶部试用装 banner 挂 .hide。
兑换码闸门取消后不存在解锁态:
· 两句文案改为 index.html 里的**静态文案**(#shelfLeadTxt / #rlLead 的 id 也删了,
顺带解掉「给 写 innerHTML 会静默换掉「查看全部 ›」的监听」那个坑);
· banner 恒显示,`.hide` 那套不再需要。
别把这个函数加回来 —— 它唯一的存在理由就是「两种解锁状态」。 */
/* ================= 档案生成:周档案 / 完整档案 =================
产物必须是一张图(要能存相册、发朋友圈、拿去打印),所以直接用 canvas
手画,不引第三方截图库 —— 单文件产物依然 0 外部依赖。
排版走「量一遍、再画一遍」两趟:第一趟 ctx 传 null 只累加 y 得到总高,
按这个高度把 canvas 撑开,第二趟才真正落笔。这样长图有多长就画多长,
完整档案条目再多也不会被裁掉。
换行没有用 ctx.measureText:中文按 1em、ASCII 按 0.52em 估宽就够准,
而且不依赖 ctx,量高那一趟即使拿不到 2d 上下文也能跑完。 */
const PST_W = 750, PST_PAD = 54;
const PST_FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif';
const pf = (sz, wt) => (wt || 400) + ' ' + sz + 'px ' + PST_FONT;
const fmtMD = d => (d.getMonth() + 1) + '月' + d.getDate() + '日';
const fmtYMD = d => d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日';
const CN_DAY = ['日', '一', '二', '三', '四', '五', '六'];
/* 本周一 ~ 本周日(周一为一周之始) */
function weekRange() {
const t = midnight(new Date());
const mon = new Date(t.getTime() - ((t.getDay() + 6) % 7) * DAY_MS);
return [mon, new Date(mon.getTime() + 6 * DAY_MS)];
}
/* 本周的记录:按「周几」定位,未到的日子不编造 */
const WEEK_LOG = [
[0, '他动了 12 次,你说那感觉像有人在里面轻轻敲门。', '数胎动'],
[2, '产检一切都好。医生说「长得很稳」,你在走廊上站了一会儿才走。', '孕检日历'],
[4, '睡前放了 20 分钟胎教音乐,他跟着动了好几下。', '胎教音乐'],
[6, '给他写了一封信,讲了你小时候的事。', '我的回信']
];
function wrapCJK(text, maxW, fs) {
const cap = maxW / fs, out = [];
let cur = '', w = 0;
for (const ch of String(text)) {
const cw = /[\x00-\xff]/.test(ch) ? .52 : 1;
if (w + cw > cap && cur) { out.push(cur); cur = ''; w = 0; }
cur += ch; w += cw;
}
if (cur) out.push(cur);
return out;
}
/* 老 Safari 没有 ctx.roundRect,自己用 arcTo 拼 */
function rrect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/* 时光轴 / 珍贵时刻直接从页面上读,页面改了图自然跟着改 */
const timelineLogs = () => $$('#screen-archive .tl-item').map(it => ({
date: $('.tl-date', it).textContent.trim(),
text: $('.tl-text', it).textContent.trim(),
from: ($('.tl-from', it) || { textContent: '' }).textContent.trim()
}));
const momentLogs = () => $$('#screen-archive .moment').map(m => ({
date: $('.mo-meta', m).textContent.trim(),
text: $('.mo-title', m).textContent.trim(),
from: ($('.mo-from', m) || { textContent: '' }).textContent.trim()
}));
/* 成长数据这一屏是写死在 index.html 里的,没有 JS 数据源。
照着上面两个的老规矩从 DOM 读 —— 页面上改了数字,打印出来的也跟着改。
体重曲线直接抄 SVG 那条 .line 的 points(viewBox 320×150),
等比缩进画布上的图表框里重画,不用另编一套坐标。 */
function growthData() {
const P = $('#screen-archive [data-panel="data"]');
return {
cards: $$('.stat-card', P).map(c => {
const t = $('.sc-trend', c);
return {
label: $('.sc-label', c).textContent.trim(),
value: $('.sc-value', c).textContent.trim(),
trend: t.textContent.trim(),
/* up / ok / purple —— 页面上三张卡的趋势色各不相同,图上也照着分 */
tone: (t.className.replace('sc-trend', '').trim() || 'up')
};
}),
chart: {
title: $('.cc-title', P).textContent.trim(),
pts: $('.chart .line', P).getAttribute('points').trim()
.split(/\s+/).map(s => s.split(',').map(Number)),
xs: $$('.cc-x span', P).map(s => s.textContent.trim())
},
/* .m-val 里是「6.1 + cm + P58」三截,
textContent 会粘成「6.1cmP58」,所以分开取 */
meas: $$('.meas', P).map(m => {
const v = $('.m-val', m);
return {
name: $('.m-name', m).textContent.trim(),
val: (v.childNodes[0].textContent + ($('small', v) || { textContent: '' }).textContent).trim(),
rank: ($('em', v) || { textContent: '' }).textContent.trim(),
pct: parseFloat($('.m-bar', m).style.getPropertyValue('--p')) || 0
};
})
};
}
function posterData(kind) {
const week = state.week, day = state.day, days = week * 7 + day;
const D = {
kind: kind,
name: state.babyName,
week: week,
days: days,
left: Math.max(0, (40 - week) * 7 - day),
pct: Math.max(0, Math.min(100, Math.round(days / TERM * 100))),
weekTxt: '孕 ' + week + ' 周' + (day ? ' +' + day + ' 天' : ''),
dueTxt: state.due ? '预产期 ' + fmtYMD(state.due) : '预产期待填'
};
if (kind === 'week') {
const mon = weekRange()[0], sun = weekRange()[1];
const todayOff = (midnight(new Date()).getDay() + 6) % 7;
D.label = '兜爸心选 · 本周档案';
D.range = fmtMD(mon) + ' — ' + fmtMD(sun);
D.logs = WEEK_LOG.filter(l => l[0] <= todayOff).map(l => {
const d = new Date(mon.getTime() + l[0] * DAY_MS);
return { date: '周' + CN_DAY[d.getDay()] + ' · ' + fmtMD(d), text: l[1], from: '来自「' + l[2] + '」' };
});
D.stats = [['本周归档', D.logs.length + ' 条'], ['日均胎动', '31 次'], ['体重变化', '+0.4kg']];
D.secs = [['这一周', D.logs]];
D.foot = '孕 ' + week + ' 周 · 本周共 ' + D.logs.length + ' 条记录';
} else {
const tl = timelineLogs(), mo = momentLogs();
D.label = '兜爸心选 · 完整档案';
D.range = '从记录第一天到今天 · 共 ' + days + ' 天';
D.stats = [['已陪伴', days + ' 天'], ['累计归档', '386 条'], ['珍贵时刻', mo.length + ' 个']];
D.growth = growthData();
/* 成长数据那一节不是日志列表,用 null 占位,交给 growthBlocks() 专门画 */
D.secs = [['成长数据', null], ['时光轴 · 全部记录', tl], ['珍贵时刻', mo]];
D.foot = '完整档案 · ' + tl.length + ' 段时光 · ' + mo.length + ' 个珍贵时刻';
}
return D;
}
/* ctx 为 null = 只量高不落笔;返回整张图的高度 */
function paintPoster(D, ctx) {
/* HERO 比最后一行文字(基线 288)多留一截,白卡压上来才不会贴脸 */
const W = PST_W, P = PST_PAD, CW = W - P * 2, HERO = 356;
if (ctx) {
ctx.fillStyle = '#FBF6F2';
ctx.fillRect(0, 0, W, ctx.canvas.height);
const g = ctx.createLinearGradient(0, 0, W, HERO);
g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FA8A5E');
ctx.fillStyle = g; ctx.fillRect(0, 0, W, HERO);
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = 'rgba(255,255,255,.82)'; ctx.font = pf(24, 600);
ctx.fillText(D.label, P, 84);
ctx.fillStyle = '#fff'; ctx.font = pf(46, 800);
ctx.fillText(D.name + '的成长档案', P, 148);
ctx.fillStyle = 'rgba(255,255,255,.94)'; ctx.font = pf(26, 500);
ctx.fillText(D.weekTxt + ' · 已陪伴 ' + D.days + ' 天', P, 202);
ctx.fillText(D.dueTxt + ' · 还有 ' + D.left + ' 天', P, 244);
ctx.fillStyle = 'rgba(255,255,255,.8)'; ctx.font = pf(22, 400);
ctx.fillText(D.range, P, 288);
/* 孕程环 */
const cx = W - 124, cy = 134, r = 54;
ctx.lineWidth = 13; ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,.3)';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = '#fff';
ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * D.pct / 100); ctx.stroke();
ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.font = pf(30, 800);
ctx.fillText(D.pct + '%', cx, cy + 2);
ctx.font = pf(18, 400); ctx.fillStyle = 'rgba(255,255,255,.85)';
ctx.fillText('孕程', cx, cy + 32);
ctx.textAlign = 'left';
}
/* 数据卡:压在色带下缘,视觉上把头图和正文缝在一起 */
let y = HERO - 44;
if (ctx) {
ctx.fillStyle = '#fff'; rrect(ctx, P, y, CW, 168, 30); ctx.fill();
D.stats.forEach((s, i) => {
const x = P + CW * (i + .5) / D.stats.length;
ctx.textAlign = 'center';
ctx.fillStyle = '#F4593C'; ctx.font = pf(38, 800);
ctx.fillText(s[1], x, y + 78);
ctx.fillStyle = '#9A8B80'; ctx.font = pf(22, 400);
ctx.fillText(s[0], x, y + 122);
});
ctx.textAlign = 'left';
}
y += 168 + 44;
D.secs.forEach(sec => {
const title = sec[0], logs = sec[1];
if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(30, 800); ctx.fillText(title, P, y + 30); }
y += 30 + 26;
logs.forEach((L, i) => {
if (ctx) { ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 600); ctx.fillText(L.date, P, y + 21); }
y += 21 + 15;
wrapCJK(L.text, CW, 27).forEach(ln => {
if (ctx) { ctx.fillStyle = '#4A403A'; ctx.font = pf(27, 400); ctx.fillText(ln, P, y + 27); }
y += 42;
});
if (L.from) {
if (ctx) { ctx.fillStyle = '#B39C8E'; ctx.font = pf(20, 400); ctx.fillText(L.from, P, y + 20); }
y += 20 + 6;
}
y += 12;
if (i < logs.length - 1) {
if (ctx) {
ctx.strokeStyle = '#EFE4DB'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke();
}
y += 24;
}
});
y += 30;
});
if (ctx) {
ctx.strokeStyle = '#EAD9CE'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke();
ctx.textAlign = 'center'; ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 600);
ctx.fillText(D.foot, W / 2, y + 48);
ctx.font = pf(20, 400);
ctx.fillText('生成于 ' + fmtYMD(new Date()) + ' · 兜爸心选', W / 2, y + 84);
ctx.textAlign = 'left';
}
return y + 84 + 46;
}
/* ================= 完整档案:A4 分页册子 =================
本周档案还是上面那张长图 —— 它是发朋友圈用的,切成几页反而没法发。
完整档案不一样,它本来就是拿去打印装订的,所以按真 A4 排。
排版分两步走,不在绘制途中判断换页:
1) 摊平:把整本内容摊成一串 block,每项 {h, keep, draw}。
h 是这一小块的高度,keep 表示「不许和下一块之间断开」。
最小单位是「一行」,任何一块都比整页矮,结构上不可能溢出。
2) 装页:顺序取 block,放不下就翻页。
途中换页最容易出的事是把一条记录腰斩,这样排就不会。 */
const A4_W = 750, A4_H = Math.round(750 * 297 / 210); // 750×1061,正 A4 比例
/* bind = 左侧装订边,留给打孔/订书钉。单面打印、左侧装订,
所以左边距固定放宽,不做奇偶页镜像 —— 家用打印机双面对齐本来就不可靠 */
const MG = { top: 64, bottom: 84, out: 56, bind: 92 };
const BODY_X = MG.bind, BODY_W = A4_W - MG.bind - MG.out; // 92 → 694,宽 602
const BODY_TOP = MG.top + 54; // 让开页眉那行字和它底下的细线
const BODY_BTM = A4_H - MG.bottom; // 正文下边界,再往下是页码
const PAGE_H = BODY_BTM - BODY_TOP;
const TONE = { up: '#C4553A', ok: '#5F8A4A', purple: '#7B5FD0' };
/* 一张白底圆角卡,成长数据那几块都用它打底 */
function card(c, x, y, w, h) {
c.fillStyle = '#fff'; rrect(c, x, y, w, h, 22); c.fill();
c.strokeStyle = '#F0E6DE'; c.lineWidth = 2; c.stroke();
}
/* 章节大标题:keep=true,绝不让它孤零零落在页脚 */
const titleBlock = t => ({
h: 56, keep: true, isTitle: true,
draw: (c, x, y) => {
c.textAlign = 'left';
c.fillStyle = '#2E241E'; c.font = pf(30, 800);
c.fillText(t, x, y + 30);
}
});
/* 成长数据:三张小卡 + 体重曲线 + 三条测量条 */
function growthBlocks(G) {
const out = [];
out.push({ h: 150 + 26, draw: (c, x, y) => {
const gap = 16, w = (BODY_W - gap * 2) / 3;
G.cards.forEach((k, i) => {
const cx = x + i * (w + gap);
card(c, cx, y, w, 150);
c.textAlign = 'left';
c.fillStyle = '#9A8B80'; c.font = pf(19, 500); c.fillText(k.label, cx + 18, y + 40);
c.fillStyle = '#2E241E'; c.font = pf(31, 800); c.fillText(k.value, cx + 18, y + 86);
c.fillStyle = TONE[k.tone] || TONE.up; c.font = pf(19, 600);
c.fillText(k.trend, cx + 18, y + 124);
});
} });
out.push({ h: 300 + 26, draw: (c, x, y) => {
const H = 300;
card(c, x, y, BODY_W, H);
c.textAlign = 'left';
c.fillStyle = '#2E241E'; c.font = pf(21, 700);
c.fillText(G.chart.title, x + 24, y + 40);
/* 把 SVG 的 320×150 视口等比铺到这个框里 */
const gx = x + 26, gy = y + 66, gw = BODY_W - 52, gh = H - 66 - 44;
const sx = v => gx + v / 320 * gw, sy = v => gy + v / 150 * gh;
const pts = G.chart.pts;
if (!pts.length) return;
c.strokeStyle = '#F2EAE3'; c.lineWidth = 1.6;
[20, 66, 112].forEach(v => {
c.beginPath(); c.moveTo(sx(4), sy(v)); c.lineTo(sx(316), sy(v)); c.stroke();
});
const trace = () => {
c.beginPath(); c.moveTo(sx(pts[0][0]), sy(pts[0][1]));
for (let i = 1; i < pts.length; i++) c.lineTo(sx(pts[i][0]), sy(pts[i][1]));
};
trace();
c.lineTo(sx(pts[pts.length - 1][0]), sy(146)); c.lineTo(sx(pts[0][0]), sy(146));
c.closePath(); c.fillStyle = 'rgba(244,89,60,.10)'; c.fill();
trace();
c.strokeStyle = '#F4593C'; c.lineWidth = 3.4;
c.lineJoin = 'round'; c.lineCap = 'round'; c.stroke();
pts.forEach((p, i) => {
const last = i === pts.length - 1;
c.beginPath(); c.arc(sx(p[0]), sy(p[1]), last ? 7 : 5, 0, Math.PI * 2);
c.fillStyle = last ? '#F4593C' : '#fff'; c.fill();
c.strokeStyle = '#F4593C'; c.lineWidth = 2.6; c.stroke();
});
c.textAlign = 'center'; c.fillStyle = '#B39C8E'; c.font = pf(18, 400);
G.chart.xs.forEach((t, i) =>
c.fillText(t, gx + gw * (i + .5) / G.chart.xs.length, y + H - 16));
c.textAlign = 'left';
} });
const rowH = 62, measH = G.meas.length * rowH + 28;
out.push({ h: measH + 26, draw: (c, x, y) => {
card(c, x, y, BODY_W, measH);
G.meas.forEach((m, i) => {
const ry = y + 14 + i * rowH;
c.textAlign = 'left';
c.fillStyle = '#4A403A'; c.font = pf(20, 600); c.fillText(m.name, x + 24, ry + 34);
const bx = x + 128, bw = BODY_W - 128 - 180, by = ry + 25;
c.fillStyle = '#F2EAE3'; rrect(c, bx, by, bw, 12, 6); c.fill();
c.fillStyle = '#F4593C';
rrect(c, bx, by, Math.max(12, bw * m.pct / 100), 12, 6); c.fill();
c.textAlign = 'right';
c.fillStyle = '#2E241E'; c.font = pf(21, 700); c.fillText(m.val, x + BODY_W - 88, ry + 34);
c.fillStyle = '#9A8B80'; c.font = pf(18, 500); c.fillText(m.rank, x + BODY_W - 24, ry + 34);
});
c.textAlign = 'left';
} });
return out;
}
/* 一条记录 = 日期行 + 若干正文行 + 来源行 + 尾巴(分隔线或留白)。
除末行外都挂 keep,整条捆在一起;真有超过一页的长记录,
装页时会把这条的 keep 松掉让它断开 —— 总比撑破一页强。 */
function logBlocks(L, last) {
const out = [{ h: 36, keep: true, draw: (c, x, y) => {
c.textAlign = 'left';
c.fillStyle = '#B39C8E'; c.font = pf(21, 600); c.fillText(L.date, x, y + 21);
} }];
const lines = wrapCJK(L.text, BODY_W, 26);
lines.forEach((ln, i) => out.push({
h: 40, keep: i < lines.length - 1 || !!L.from,
draw: (c, x, y) => {
c.textAlign = 'left';
c.fillStyle = '#4A403A'; c.font = pf(26, 400); c.fillText(ln, x, y + 26);
}
}));
if (L.from) out.push({ h: 26, draw: (c, x, y) => {
c.textAlign = 'left';
c.fillStyle = '#B39C8E'; c.font = pf(20, 400); c.fillText(L.from, x, y + 20);
} });
/* filler:落在页首就直接扔掉,免得新一页顶上飘一条分隔线或一段空白 */
out.push(last
? { h: 30, filler: true, draw: function () {} }
: { h: 36, filler: true, draw: (c, x, y) => {
c.strokeStyle = '#EFE4DB'; c.lineWidth = 2;
c.beginPath(); c.moveTo(x, y + 12); c.lineTo(x + BODY_W, y + 12); c.stroke();
} });
return out;
}
/* 摊平 → 装页。返回 {pages, toc},pages[i] 是 [{b, y}…] */
function bookPages(D) {
const blocks = [];
D.secs.forEach(sec => {
const name = sec[0], logs = sec[1];
const push = b => { b.sec = name; blocks.push(b); };
push(titleBlock(name));
if (logs === null) growthBlocks(D.growth).forEach(push);
else logs.forEach((L, i) => logBlocks(L, i === logs.length - 1).forEach(push));
push({ h: 26, filler: true, draw: function () {} }); // 节与节之间喘口气
});
const pages = [];
let cur = [], y = BODY_TOP;
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
if (!cur.length && b.filler) continue;
/* 把这一块和它后面连着的 keep 块当成一个整体量高 */
let need = b.h, j = i;
while (blocks[j].keep && j + 1 < blocks.length) { j++; need += blocks[j].h; }
if (need > PAGE_H) need = b.h; // 整条比一页还高,只能让它断开
if (y + need > BODY_BTM && cur.length) { pages.push(cur); cur = []; y = BODY_TOP; }
cur.push({ b: b, y: y });
y += b.h;
}
if (cur.length) pages.push(cur);
/* 目录页码:封面占第 1 页,正文从第 2 页起 */
const toc = [];
D.secs.forEach(sec => {
const at = pages.findIndex(p => p.some(it => it.b.sec === sec[0]));
toc.push([sec[0], at < 0 ? 1 : at + 2]);
});
return { pages: pages, toc: toc };
}
function drawCover(c, D, toc, total) {
const HH = Math.round(A4_H * .46);
c.fillStyle = '#fff'; c.fillRect(0, 0, A4_W, A4_H);
/* 色块不做出血:家用打印机印不到纸边,留白反而干净,也省墨 */
const g = c.createLinearGradient(0, 0, A4_W, HH);
g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FA8A5E');
c.fillStyle = g; c.fillRect(0, 0, A4_W, HH);
c.textAlign = 'left'; c.textBaseline = 'alphabetic';
c.fillStyle = 'rgba(255,255,255,.82)'; c.font = pf(24, 600);
c.fillText(D.label, BODY_X, 116);
c.fillStyle = '#fff'; c.font = pf(46, 800);
c.fillText(D.name + '的成长档案', BODY_X, 190);
c.fillStyle = 'rgba(255,255,255,.94)'; c.font = pf(26, 500);
c.fillText(D.weekTxt + ' · 已陪伴 ' + D.days + ' 天', BODY_X, 250);
c.fillText(D.dueTxt + ' · 还有 ' + D.left + ' 天', BODY_X, 292);
c.fillStyle = 'rgba(255,255,255,.8)'; c.font = pf(22, 400);
c.fillText(D.range, BODY_X, 340);
const cx = A4_W - 148, cy = 178, r = 58;
c.lineWidth = 13; c.lineCap = 'round';
c.strokeStyle = 'rgba(255,255,255,.3)';
c.beginPath(); c.arc(cx, cy, r, 0, Math.PI * 2); c.stroke();
c.strokeStyle = '#fff';
c.beginPath(); c.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * D.pct / 100); c.stroke();
c.textAlign = 'center'; c.fillStyle = '#fff'; c.font = pf(30, 800);
c.fillText(D.pct + '%', cx, cy + 2);
c.font = pf(18, 400); c.fillStyle = 'rgba(255,255,255,.85)';
c.fillText('孕程', cx, cy + 32);
/* 数据条压在色块下沿,把封面上下两半缝在一起 */
const sy = HH - 56;
c.fillStyle = '#fff'; rrect(c, BODY_X, sy, BODY_W, 152, 26); c.fill();
c.strokeStyle = '#F0E6DE'; c.lineWidth = 2; c.stroke();
D.stats.forEach((s, i) => {
const x = BODY_X + BODY_W * (i + .5) / D.stats.length;
c.textAlign = 'center';
c.fillStyle = '#F4593C'; c.font = pf(34, 800); c.fillText(s[1], x, sy + 74);
c.fillStyle = '#9A8B80'; c.font = pf(21, 400); c.fillText(s[0], x, sy + 114);
});
c.textAlign = 'left';
let y = sy + 152 + 86;
c.fillStyle = '#2E241E'; c.font = pf(28, 800);
c.fillText('目录', BODY_X, y);
c.strokeStyle = '#EFE4DB'; c.lineWidth = 2;
c.beginPath(); c.moveTo(BODY_X, y + 22); c.lineTo(BODY_X + BODY_W, y + 22); c.stroke();
y += 68;
toc.forEach(t => {
c.textAlign = 'left'; c.fillStyle = '#4A403A'; c.font = pf(23, 600);
c.fillText(t[0], BODY_X, y);
c.textAlign = 'right'; c.fillStyle = '#B39C8E'; c.font = pf(22, 500);
c.fillText('第 ' + t[1] + ' 页', BODY_X + BODY_W, y);
y += 50;
});
c.textAlign = 'center'; c.fillStyle = '#C9BBB1'; c.font = pf(19, 400);
c.fillText('全书共 ' + total + ' 页 · 沿左侧装订线打孔即可成册',
A4_W / 2, A4_H - MG.bottom + 46);
c.fillText('生成于 ' + fmtYMD(new Date()) + ' · 兜爸心选',
A4_W / 2, A4_H - MG.bottom + 78);
c.textAlign = 'left';
}
function drawBookPage(c, D, page, no, total) {
c.fillStyle = '#fff'; c.fillRect(0, 0, A4_W, A4_H);
/* 开章那页不写页眉 —— 大标题就在下面两行处,再写一遍是重复。
页眉是给翻到章节中段的人认路用的,标题在眼前时它多余。 */
const sec = (page[0] && !page[0].b.isTitle && page[0].b.sec) || '';
c.textBaseline = 'alphabetic';
c.fillStyle = '#B39C8E'; c.font = pf(19, 600);
c.textAlign = 'left'; c.fillText(D.name + '的成长档案', BODY_X, MG.top);
c.textAlign = 'right'; c.fillText(sec, A4_W - MG.out, MG.top);
c.strokeStyle = '#EFE4DB'; c.lineWidth = 2;
c.beginPath(); c.moveTo(BODY_X, MG.top + 18); c.lineTo(A4_W - MG.out, MG.top + 18); c.stroke();
/* 装订线:一道浅浅的竖虚线,提示这一条左边是留给打孔的 */
if (c.setLineDash) {
c.save(); c.setLineDash([6, 8]);
c.strokeStyle = '#F0E6DE'; c.lineWidth = 1.6;
c.beginPath(); c.moveTo(MG.bind - 34, MG.top - 26);
c.lineTo(MG.bind - 34, A4_H - MG.bottom + 26); c.stroke();
c.restore();
}
page.forEach(it => it.b.draw(c, BODY_X, it.y));
c.textAlign = 'center'; c.fillStyle = '#B39C8E'; c.font = pf(20, 600);
c.fillText('— ' + no + ' —', A4_W / 2, A4_H - MG.bottom + 48);
c.textAlign = 'right'; c.fillStyle = '#C9BBB1'; c.font = pf(17, 400);
c.fillText('兜爸心选 · 生成于 ' + fmtYMD(new Date()) + ' · 共 ' + total + ' 页',
A4_W - MG.out, A4_H - MG.bottom + 48);
c.textAlign = 'left';
}
let posterURLs = [], posterKind = 'week';
/* 导出层的「这批图叫什么」由 beginSheets() 每次现设,不从 posterKind 推。
exportTag 进文件名,exportTitle 进系统分享面板的标题。 */
let exportTag = '档案', exportTitle = '兜爸心选';
/* 每次要铺新的一批图,必须先调这个。
⚠️ posterURLs 是模块级的。不清空的后果是静默的:先看一遍「完整档案」,
退出来再点别的生成入口,若这一次没能生成图(老浏览器),posterURLs 里
还躺着上一批带预产期、体重、年份的档案页 —— 一点「分享」就原样发出去了。
把清空和打标签绑在同一个函数里,就没有「忘了清」这条路径。 */
function beginSheets(host, tag, title) {
host.textContent = '';
posterURLs = [];
exportTag = tag;
exportTitle = title;
}
/* 铺一张纸:白卡纸外框(.pst-frame)+ 一块画布,画完顺手存成 data URI。
拿不到 2d 上下文(极老的机器)就把画布留白,不让整页崩掉。 */
function addSheet(host, w, h, label, drawFn) {
const fr = el('div', 'pst-frame');
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
cv.setAttribute('role', 'img');
cv.setAttribute('aria-label', label);
fr.appendChild(cv);
host.appendChild(fr);
let ctx = null;
try { ctx = cv.getContext && cv.getContext('2d'); } catch (e) { ctx = null; }
if (!ctx) return;
drawFn(ctx);
try { posterURLs.push(cv.toDataURL('image/png') || ''); } catch (e) { /* 画得出但导不出,忽略 */ }
}
function renderPoster(kind) {
posterKind = kind === 'all' ? 'all' : 'week';
const D = posterData(posterKind);
const host = $('#pstPages');
beginSheets(host,
posterKind === 'week' ? '第' + state.week + '周档案' : '完整档案',
state.babyName + (posterKind === 'week' ? '的本周档案' : '的成长档案'));
let sizeChip;
if (posterKind === 'week') {
/* 本周档案照旧:一张长图,发朋友圈用的 */
const h = Math.round(paintPoster(D, null)); // 先量高,再撑画布
addSheet(host, PST_W, h, '本周档案', c => paintPoster(D, c));
sizeChip = ['成图', PST_W + '×' + h];
} else {
const bk = bookPages(D);
const total = bk.pages.length + 1; // 加上封面
addSheet(host, A4_W, A4_H, '封面与目录', c => drawCover(c, D, bk.toc, total));
bk.pages.forEach((p, i) =>
addSheet(host, A4_W, A4_H, '第 ' + (i + 2) + ' 页',
c => drawBookPage(c, D, p, i + 2, total)));
sizeChip = ['共', total + ' 页 · A4'];
D.pageCount = total;
}
const title = posterKind === 'week' ? '本周档案' : '完整档案';
$('#pstBar').textContent = title;
$('#pstName').textContent = title;
$('#pstLead').textContent = posterKind === 'week'
? '本周(' + D.range + ')的记录已经排成一张图,存到相册或发朋友圈都行。'
: '从记录第一天到今天共 ' + D.days + ' 天,已排成 ' + D.pageCount +
' 页 A4,打印出来沿左边装订线打孔,就能装订成册。';
/* 胶囊直接复用画在图上的那几组数字,再补一条尺寸 ——
到底有多长/多少页,心里先有个数 */
const chips = $('#pstChips');
chips.textContent = '';
D.stats.concat([sizeChip]).forEach(s => {
const c = el('span', 'pst-chip', s[0]);
c.appendChild(el('b', null, s[1]));
chips.appendChild(c);
});
/* 两种档案的主按钮不一样:本周档案是拿去发的,完整档案是拿去印的 */
const isBook = posterKind === 'all';
$('#pstPrint').classList.toggle('main', isBook);
$('#pstPrint').classList.toggle('ghost', !isBook);
$('#pstPrint').textContent = isBook ? '打印装订' : '打印';
$('#pstShare').classList.toggle('main', !isBook);
$('#pstShare').classList.toggle('ghost', isBook);
$('#pstTip').textContent = !posterURLs.length
? '当前浏览器画不了图片,换个浏览器打开就能保存和打印了。'
: isBook
? '上面就是打印出来的样子,一屏一页。点「打印装订」直接出 ' + D.pageCount + ' 张 A4。'
: '图片已生成。在微信里也可以长按上面这张图,直接保存或转发。';
}
$$('[data-poster]').forEach(b => b.addEventListener('click', () => {
renderPoster(b.dataset.poster);
openPanel('detailPoster', b);
}));
/* i 省略 = 单张;带 i 就在名字里标上页码。
/* i 省略 = 单张;带 i 就在名字里标上页码。
名字里那截「是什么」由 beginSheets() 时定下的 exportTag 决定。 */
function posterFile(i) {
const d = new Date(), p = n => (n < 10 ? '0' : '') + n;
const stamp = d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate());
return '兜爸心选-' + exportTag + '-' + stamp + (i ? '-第' + i + '页' : '') + '.png';
}
/* 保存 / 分享 / 打印这三件事按 data-export 委托:
按钮上写什么就调 exportAct 里的哪一个,不用逐个绑。 */
const exportAct = {};
$$('[data-export]').forEach(b =>
b.addEventListener('click', () => { const f = exportAct[b.dataset.export]; if (f) f(); }));
/* posterURLs(dataURL)→ File[],给 navigator.share 用。
canvas 出来就是 dataURL,fetch 自己的 dataURL 是最省事的解码方式。 */
function sheetFiles() {
const many = posterURLs.length > 1;
return Promise.all(posterURLs.map(u => fetch(u).then(r => r.blob())))
.then(bs => bs.map((b, i) => new File([b], posterFile(many ? i + 1 : 0), { type: 'image/png' })));
}
const canShareFiles = () =>
!!(navigator.share && navigator.canShare && typeof fetch === 'function' && typeof File === 'function');
/* ⚠️ 网页存不进相册,这是浏览器的硬限制,不是这里没写。
没有任何 Web API 能让网页把文件写进系统相册 —— 真能,那随便哪个网页
都可以往你手机相册里塞东西了。所以「直接保存到相册」这件事,
网页能做到的最短路径只有一条:调起系统分享面板,
用户点一下「存储图像 / 保存到相册」,由系统去写。iOS 和安卓都是这样。
所以这颗键的顺序改成:
1. 有系统面板(iOS Safari / 安卓 Chrome)→ 直接叫出来,一点就进相册;
2. 没有(微信内置浏览器就没有)→ 退回 ,
安卓会落进「下载」相册,iOS 会进「文件」;
3. 再不行 → 长按图片,这是微信里唯一能进相册的路。
以前是先走 2,iOS 上点完只是进了「文件」,用户在相册里怎么翻都找不到。 */
exportAct.save = () => {
if (!posterURLs.length) { toast('这台设备生成不了图片,长按图片试试'); return; }
const many = posterURLs.length > 1;
const download = () => {
/* 一次点 N 个 有的浏览器会拦,隔开一点点再点下一张 */
posterURLs.forEach((u, i) => setTimeout(() => {
const a = document.createElement('a');
a.href = u; a.download = posterFile(many ? i + 1 : 0);
document.body.appendChild(a); a.click(); a.remove();
}, i * 260));
toast(many ? '正在保存 ' + posterURLs.length + ' 页……' : '已下载 · 微信里请长按图片存到相册');
};
if (canShareFiles()) {
sheetFiles().then(files => {
const pick = navigator.canShare({ files: files }) ? files
: navigator.canShare({ files: [files[0]] }) ? [files[0]] : null;
if (!pick) { download(); return; }
/* 只给 files,不给 title/text:带上文字,系统面板会优先推「发送给某人」,
而这颗键要的是「存到相册」那一项排在前面。 */
return navigator.share({ files: pick })
.then(() => toast('在面板里选「存储图像」就进相册了'));
}).catch(() => download()); // 用户取消也会走到这儿,兜底下载不影响什么
} else download();
haptic();
};
exportAct.share = () => {
const title = exportTitle;
const manual = () => toast('长按上面的图片,就能保存或转发到朋友圈');
/* 能调起系统分享面板就带图分享;微信内置浏览器没有 share(),
退回「长按图片」——那是微信里最通用的一条路。
完整档案是一整本,支持多图就整本发出去,不支持就只发第一页(封面)。 */
if (canShareFiles() && posterURLs.length) {
const many = posterURLs.length > 1;
/* 和「保存图片」的区别只在这里:分享要带上标题文案,
系统面板才会把「发送给朋友 / 朋友圈」这类去处排在前面。 */
sheetFiles()
.then(files => {
if (navigator.canShare({ files: files }))
return navigator.share({ files: files, title: title, text: title });
if (navigator.canShare({ files: [files[0]] }))
return navigator.share({ files: [files[0]], title: title, text: title });
return navigator.share({ title: title, text: title });
})
.then(() => toast(many ? '已唤起分享 · 整本 ' + posterURLs.length + ' 页' : '已唤起分享 · 选「朋友圈」就行'), manual);
} else manual();
haptic();
};
exportAct.print = () => {
if (!window.print) { toast('这个浏览器不支持直接打印,先保存图片再打印吧'); return; }
toast('正在准备打印……');
/* 样式表里的 @media print 已经把这张图之外的东西都藏起来了 */
setTimeout(() => { try { window.print(); } catch (e) { toast('打印没能启动,先保存图片再打印吧'); } }, 140);
haptic();
};
/* ================= 搜索层 ================= */
const sl = $('#searchLayer');
$$('[data-search="1"]').forEach(b => b.addEventListener('click', () => {
sl.classList.add('show');
setStatusbar('dark');
setTimeout(() => $('#searchInput').focus(), 300);
}));
$$('[data-search="close"]').forEach(b => b.addEventListener('click', () => {
sl.classList.remove('show');
$('#searchInput').value = '';
setStatusbar('auto');
}));
$$('.sl-hot .hot').forEach(h => h.addEventListener('click', () => toast('搜索「' + h.textContent.replace(/^\d/, '') + '」')));
$('#searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && e.target.value.trim()) toast('搜索「' + e.target.value.trim() + '」');
});
/* ================= 横向滑动切页 ================= */
(function swipe() {
const order = ['home', 'archive', 'me'];
let x0 = 0, y0 = 0, tracking = false;
/* 手指落在一个「自己就能横向滚」的容器里时,这次滑动属于那个容器,不是翻页。
这里动态判断,而不是继续往下面那串 class 名单里加东西 ——
首页的 .shelf-rail(横向卡带)就是因为名单没人同步维护才漏掉的:
名单是在它诞生之前写的,于是在卡带上滑一张卡(位移 ~292px,远超 70 的阈值)
会被当成翻页,直接跳到「档案」。名单里的 .stat-row / .moment-grid 其实早就是
grid 布局、根本不横滚,也没人清理,可见这种硬编码名单靠不住。
⚠️ 阈值给 4px 而不是 0:只设了 overflow-y:auto 的纵向滚动容器,
其 overflow-x 会被 CSS 计算成 auto,再叠上亚像素取整,很容易
出现 scrollWidth 比 clientWidth 大零点几的假阳性,那会把整页的翻页手势废掉。 */
function inHScroll(el) {
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
if (n.scrollWidth > n.clientWidth + 4) {
const ox = getComputedStyle(n).overflowX;
if (ox === 'auto' || ox === 'scroll') return true;
}
}
return false;
}
document.addEventListener('touchstart', e => {
if (openedModal || sheet.classList.contains('show') || panelStack.length || sl.classList.contains('show')) return;
if (e.touches.length !== 1) return;
if (e.target.closest('.chips-row, .stat-row, .moment-grid, textarea, input')) return;
if (inHScroll(e.target)) return;
x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; tracking = true;
}, { passive: true });
document.addEventListener('touchend', e => {
if (!tracking) return; tracking = false;
const dx = e.changedTouches[0].clientX - x0;
const dy = e.changedTouches[0].clientY - y0;
if (Math.abs(dx) < 70 || Math.abs(dy) > 60) return;
const i = order.indexOf(state.screen);
if (i < 0) return;
const next = dx < 0 ? i + 1 : i - 1;
if (next >= 0 && next < order.length) go(order[next]);
}, { passive: true });
})();
/* ================= ESC / 返回 ================= */
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
/* 弹窗永远在最上层,优先关它 */
if (openedModal) { closeModal(); return; }
if (sl.classList.contains('show')) { sl.classList.remove('show'); return; }
if (sheet.classList.contains('show')) closeSheet();
});
/* ================= 初始化 ================= */
function init() {
/* 首屏向小程序宿主上报当前位置:宿主据此解析分享卡片(见 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);
/* 最后再拉远程档案:必须在 init() 渲染完之后异步覆盖 —— 若在脚本解析时就拉,
fetch 的 then 往往比 init() 先跑,随后 init() 又会把 state 重置回默认值,
档案数据被覆盖。放在 init 末尾,等 fetch then 回来时再回刷展示。 */
applyRemoteProfile();
// 第二批远程数据(资料库/记录/内容)—— 随 init 一起拉,失败回退内置
applyRemoteAll();
// 第三批远程数据(今日脚印,来自数胎动)—— 渲染首页 .today-line 时间轴,
// 拉不到/未关联都各有回退,见 applyRemoteFootprint 内的分支说明
applyRemoteFootprint();
// 底 tab「宝宝来信」未读角标(box_app 代理第三方 laixin 未读数)
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 + '=([^]*)'));
return m ? decodeURIComponent(m[1]).split('?')[0] : '';
} catch (e) { return ''; }
}
let _profileApplied = false;
function applyRemoteProfile() {
if (_profileApplied) return; // init 只跑一次,防重复 fetch
_profileApplied = true;
const openid = hashParam('openid');
/* 访客态:宿主未注入身份(冷启动 silenceAuth 失败走「匿名加载 H5」兜底)。
⚠️ 绝不能停在内置演示默认值上(小汤圆 / 孕27周 都是前端现算的假数据)——
全部切占位/引导;宿主拿到身份后会带 openid 重载本页,届时自动切真实数据。 */
if (!openid) {
state.pregEmpty = true;
state.due = null; state.week = null; state.day = null;
state.babyName = ''; state.userName = '';
syncPregnancy(); /* 占位 + 隐藏脚印时间轴 */
/* 首页来信卡归 laixin(applyLaixinReminder 里也判 pregEmpty/无身份),这里直接收起 */
hideLetterCard();
return;
}
const base = '/api/mp/public/mama-box/';
/* 并行拉 个人档案 + 孕期状态:
- profile:babyName/userName/babySex/dueDate(身份 + 建档字段)
- pregnancy:week/day/due 以服务端权威派生为准(避免客户端重复算、口径统一)
拉不到(无 openid / 未返回 200 / 断网)就静默保持内置默认值,页面照常可用。 */
Promise.all([
fetch(base + 'profile?openid=' + encodeURIComponent(openid)).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) {
/* 新用户空态(未完善资料):后端不再给默认孕周 → 显示引导占位,
不读任何演示数据(详见 syncPregnancy 的 pregEmpty 分支)。
⚠️ 同时清掉 state 的内置演示默认值(DEFAULT_DUE=今天+3个月/孕27周),
否则「我的资料」弹窗回填时又会把它们带回来。 */
if (pg.result.empty || pg.result.week == null) {
state.pregEmpty = true;
state.due = null;
state.week = null;
state.day = null;
} else {
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日」演示值。 */
if (state.pregEmpty) { 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');
if (a) a.textContent = state.kickToday;
if (b) b.textContent = state.todayCount;
if (c) c.textContent = state.kickQuick || 0;
}
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;
function applyRemoteFootprint() {
if (_footprintApplied) return; // init 只跑一次,防重复 fetch
_footprintApplied = true;
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 => {
if (!j || j.code !== 200 || !j.result) return; // 拉不到 → 回退演示行
const r = j.result;
const tl = document.querySelector('.today-line');
const steps = $('#tlSteps');
if (!tl || !steps) return;
if (!r.linked) { tl.style.display = 'none'; return; }
if (state.pregEmpty) return; // 别把 profile 刚藏好的放出来
const events = (r.events || []).slice(-8); // 升序取最近 8 条
steps.innerHTML = '';
if (!events.length) { steps.append(emptyFootprintStep()); return; }
events.forEach(e => steps.append(buildFootprintStep(e)));
})
.catch(function () { /* 静默回退演示行 */ });
}
/* 一行脚印:与 index.html 演示行同构(时间/圆点/文案/来源)。
文案里的数字段加粗对齐演示排版(「数胎动 2 次」),
全程 createElement+textContent,上游字段再离谱也进不来标签。 */
function buildFootprintStep(e) {
const d = document.createElement('div'); d.className = 'step';
const time = document.createElement('span'); time.className = 'st-time';
time.textContent = e.at || '--:--';
const dot = document.createElement('span'); dot.className = 'st-dot';
const txt = document.createElement('span'); txt.className = 'st-txt';
const m = String(e.label || '').match(/^(.*?)(\d[\d.]*)(.*)$/);
if (m) {
if (m[1]) txt.append(m[1]);
const b = document.createElement('b'); b.textContent = m[2]; txt.append(b);
if (m[3]) txt.append(m[3]);
} else {
txt.textContent = String(e.label || '');
}
const from = document.createElement('span'); from.className = 'st-from';
from.textContent = e.app ? '来源 · ' + e.app : '';
d.append(time, dot, txt, from);
return d;
}
/* 无足迹占位行:同构的一行,读屏和眼睛都不会把空卡当成坏卡。 */
function emptyFootprintStep() {
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';
from.textContent = '去逛逛上面的工具吧';
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);
}
$('#tabLetter').addEventListener('click', openLetterApp);
/* 首页那张「宝宝来信」卡与底 tab 是**同一个入口**(2026-09-20):
卡的数据源已经是 laixin,点它当然也该进 laixin —— 以前它打开的是内置来信详情,
那会「卡上写着有 2 封新信、点进去是空的」(两套数据)。 */
$('#homeLetter').addEventListener('click', openLetterApp);
/* ---------- 底 tab 红点 + 首页来信卡:宝宝来信未读数(唯一 owner) ----------
数据源:box_app 封装的联动接口 GET /api/mp/public/laixin/unread
(box_app 后端再去调第三方 https://taidong.douba.top/laixin/api/laixin/unread)。
⚠️ 路径按应用命名空间走 /api/mp/public//<资源>(app 段 = App 表 identifier),
不要塞回 /mama-box/ 命名空间 —— 那里是妈妈盒子自己的接口。规则见
box_app/docs/api-naming-convention.md。
⚠️ 为什么不直连 taidong:那台的 CORS 白名单只放了自己的域名,页面直连会被浏览器拦;
而且「mama-box ↔ laixin 的联动接口由 box_app 封装」是本项目的既定边界
(见 docs/mama-box-change-list-20260919.md §1.5)。
⚠️ 口径:#tabBadge 与首页「宝宝来信」卡的 owner **只有这一个函数**(改名自 syncLaixinBadge)。
别在 applyRemoteProfile() 里再写它 —— 两处都写会互相覆盖(谁后跑谁说了算)。
⚠️ 容错:接口返回 ok=false(上游抖动)时**保持现状** —— 既不把已经点亮的红点抹掉,
也不因为一次失败就冒出一个红点。角标不是关键路径,失败一律静默。 */
function syncLaixinReminder() {
const badge = $('#tabBadge');
const openid = hashParam('openid');
/* 接口按 openid 查,没有身份就别问(浏览器直开调试时常见)
—— 此时红点与首页卡都收起(访客态不该出现任何「你有新信」的暗示)。 */
if (!openid) {
if (badge) badge.classList.remove('show');
hideLetterCard();
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; // 降级:保持现状(红点与卡都不动)
if (badge) badge.classList.toggle('show', Number(r.unread) > 0);
applyLaixinReminder(r);
})
.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 () {
if (document.visibilityState !== 'hidden') resetScreenOnShow();
});
if (document.readyState === 'complete') init();
else window.addEventListener('load', init);
})();