Files
mama-box/js/app.js

4363 lines
233 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';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
/* ---------- 孕期日期算术 ----------
这一小块原来长在下面「孕期信息」那一节里(DOM 事件旁边)。挪到最前面,
是因为信件日期现在要按孕周现推,而信件那一节在文件中段就要用到这些函数。
它们全是纯数学,一个 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 给往来消息用(当天就写「今天」),fmtFull 给信件抬头用 */
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;
}
const fmtFull = d => d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日 ' +
d.getHours() + ':' + pad2(d.getMinutes());
/* ---------- 默认预产期:打开这一刻往后推 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: 3,
kickQuick: 1,
todayCount: 4,
unread: 1,
favCount: 18,
letterFilter: 'all',
resCat: 'all',
resKey: 'book',
resFav: [], // 已收藏的资源 key,数组顺序即收藏顺序
resGot: [], // 已领取的资源 key(点过「立即领取」的)
babyName: '小汤圆',
/* 这三项一进门就是真值(默认预产期 = 今天 +3 个月,见上面 DEFAULT_DUE)。
以前它们是写死的占位 24/3/null,等 init() 才补 —— 但信件日期是按孕周
现推的,推的时候还没轮到 init(),拿到的就全是假的。 */
week: Math.floor(DEFAULT_GA / 7), day: DEFAULT_GA % 7,
due: DEFAULT_DUE,
babySex: 'unknown',
/* 来信列表左边那颗宝宝头像。空 = 还没设,显示占位的「宝」字;
设了就是一段 data URI(点头像选图后就地读出来的,没有后端)。
⚠️ 和 userName / babyName 一样**不写 localStorage** —— 现状只持久化
resFav/resGot/wxUnlocked/pending,刷新会回到「宝」字。
这是已知现状、本批没扩范围,不是漏做。
⚠️ 更要紧的是:data URI 动辄几百 KB,而 localStorage 通常只有 5MB。
哪天真要持久化它,得先压缩(画到 canvas 缩到 ~128px 再 toDataURL),
直接 setItem 原图会在某些机型上抛 QuotaExceededError。 */
babyAvatar: '',
replyCount: 0, // 妈妈已寄出的回信条数
userName: '小满妈妈',
/* ⚠️ 这里原来还有一条 userBio(一句话签名)。整块已按用户要求删除,
连同 #pfBio 输入框、#meBio 展示行、syncBio()。别只加回这一条 ——
一个没人读的 state 字段是最难发现的死代码。 */
wxUnlocked: false // 是否已用兑换码解锁资料领取(见下面 WXOK_KEY)
};
/* ---------- 资源的收藏 / 已领(都跨会话保留) ----------
只存 key,资源本身仍从 RES 读,改文案不会让旧记录失效。
「已领」原来只是详情页那个按钮上的一个 class,退出去再进来就没了 ——
用户根本记不住自己领过哪几份,回到列表页只能一个个点进去看。
现在和收藏一样落盘,列表页直接打「已领」标。 */
const RESFAV_KEY = 'mmbox.resfav.v1';
const RESGOT_KEY = 'mmbox.resgot.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');
loadKeys(RESGOT_KEY, 'resGot');
function saveResFav() {
try { localStorage.setItem(RESFAV_KEY, JSON.stringify(state.resFav)); } catch (e) {}
}
function saveResGot() {
try { localStorage.setItem(RESGOT_KEY, JSON.stringify(state.resGot)); } catch (e) {}
}
/* ---------- 加企业微信换来的「解锁」 ----------
和上面两个一样跨会话,但**不复用 loadKeys()**:那是个数组读取器,
把一个布尔值塞成 ['1'] 只是为了省几行,代价是数据形状被扭曲,
以后读这段的人得先想明白「为什么解锁状态是个数组」。
⚠️ 判定写死成 === '1':读不出、读成别的、localStorage 整个抛错,
一律往「锁着」倒。解锁是要给东西出去的动作,含糊时不能默认放行。 */
const WXOK_KEY = 'mmbox.wxunlock.v1';
try { state.wxUnlocked = localStorage.getItem(WXOK_KEY) === '1'; } catch (e) {}
function saveWxUnlocked() {
try { localStorage.setItem(WXOK_KEY, '1'); } 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; }
}
function go(name) {
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); }
// 来信页的写回信按钮
$('#fabWrite').classList.toggle('show', name === 'letter');
/* 档案页的孕程已经从圆环换成细条了,这里跟着换成「归零再长回去」。
归零和写目标值必须隔一帧,同一帧里改两次浏览器只会看到最后那个值,
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 = () => {
const g = b.dataset.goto;
if (g === 'letter-detail') openDetail();
else go(g);
};
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 = '';
});
}
});
initSegmented('#letterSeg', '#letterThumb', 'lseg', key => {
state.letterFilter = key;
filterLetters(key);
});
/* filterLetters 见下方「宝宝来信 · 会话式」一节:
列表已经是一封信一条 thread,筛选是按 thread 整体显隐的 */
/* ================= 筛选 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() + '」');
}));
});
/* ================= 珍贵时刻收藏 ================= */
$$('.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 = '';
if (!detail.classList.contains('show')) 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);
b.textContent = state.kickToday; bump(b.parentElement);
c.textContent = state.todayCount;
toast('胎动 +1,今天已记录 ' + state.kickQuick + ' 次');
haptic();
/* 妈妈数胎动 → 宝宝会寄一封「肚子里的声音」过来(几小时后才到,全孕期最多两封) */
maybeVoiceAfterKick();
});
/* 工具列表去重:顶部「今天已使用」的快捷入口若在下方列表里重复出现,
用户会觉得同一个工具出现了两次,这里按名称把它们从全部列表中摘掉 */
(function dedupeApps() {
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();
}));
/* 首页宫格入口。
五个坑位里有三个已经有真站点了,点了直接跳过去;剩下两个(数胎动 / 生长曲线)
还没有后端,维持原来弹 toast 的占位行为,不假装能打开。
跳转用 location.href 走**当前标签页**:这个壳是照着原生 App 做的,
新开标签会在微信 / Safari 里堆出一串返回不去的页面;同页跳转的话,
浏览器返回键就是天然的「返回」。 */
$$('[data-mini]').forEach(b => b.addEventListener('click', () => {
const map = { kick: '数胎动', weight: '孕期体重', cal: '孕检日历', curve: '生长曲线', fweight: '胎儿估重' };
const site = {
fweight: 'https://beian1.douba.top/', /* 胎儿估重 */
weight: 'https://beian2.douba.top/', /* 孕期体重 */
cal: 'https://beian3.douba.top/' /* 孕检日历 */
};
const url = site[b.dataset.mini];
if (url) { haptic(); location.href = 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);
/* detailLetter 的内容由 openLetter() 先填好再调 openPanel,这里不再插手 */
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)));
/* ================= 宝宝来信 · 会话式 =================
一封来信就是一段对话:宝宝的信 + 妈妈的回信 + 宝宝的再回复,
全都挂在同一条 thread 上。列表里回信缩进在信下面,详情页里顺序展开,
所以「这条回信是回给哪封信的」一眼可见,不会再另开一页。
宝宝的回信从不实时:妈妈一互动就往队列里排一条「3~5 小时后送达」,
到点由 30 秒一跳的 ticker 投递。队列存在 localStorage 里,
关掉页面过几个小时再回来,信照样会在。 */
const detail = $('#detailLetter');
const HOUR = 3600000;
const LETTER_ARCHIVED = 20; // 更早的信已折进档案,只在「共 N 封」里计数
const VOICE_CAP = 2; // 「肚子里的声音」整个孕期最多两封
/* ---------- 按性别切换口吻:选了男孩/女孩,宝宝回信的味道就不一样 ----------
落款不再跟性别走 —— 改用宝宝小名「爱你的小汤圆」,见 renderLetterDetail */
const BABY_REPLY = {
unknown: [
'妈妈,你的信我收到啦。你写字的时候肚子暖暖的,我就知道是你。',
'妈妈,你说的话我都记下了。等我出去,我们再念一遍好不好?',
'妈妈,谢谢你每天都想着我。我在这里很好,你也要好好吃饭。'
],
boy: [
'妈妈,信收到啦!我刚伸了个大懒腰,等我出去背你上楼梯。',
'妈妈,我又踢了两下,是跟你击掌呢,不是调皮。',
'妈妈你放心,我好好长个子。以后你靠着我就行。'
],
girl: [
'妈妈,你写的每个字我都听见啦。我在这儿悄悄转了个圈。',
'妈妈,我把小手贴在你说话的地方了,暖暖的。',
'妈妈,等我出去,我要挽着你的胳膊去买菜。'
]
};
/* ---------- 等待文案池 ----------
妈妈按下「发送」之后、宝宝真的回信之前,那段空窗期里显示的一行话。
原来只有写死的一句「{baby}收到啦,让我先睡一小觉,醒来就回你」——
妈妈每回一封都看见同一行字,第二次起就不像宝宝在说话了,像个系统回执。
所以摊成 30 句,排队时随机挑一句。
写这些句子时守的三条:
1. **不报时间。** 一句「一会儿就回你」都不行 —— 说了妈妈就会盯着表,
到点没来像是坏了。宝宝写信本来就该是件慢事。
2. **说的是宝宝的状态,不是回执。** 是「我在打哈欠 / 在打草稿 /
在数你的字」,不是「您的消息已送达」。
3. **每句留一个小钩子。** 妈妈看完该有点想再回来看看的意思。
小名写成 {baby} 占位符,渲染时才由 fillBaby() 换掉 ——
排队记录是要落盘的,如果这里存的是已经拼好名字的整句,
妈妈之后在「编辑孕期信息」里改了小名,这行会一直挂着旧名字。
⚠️ 别往这些句子里加 emoji。站内的实体 emoji 前几批已经清过一轮了。
⚠️ 也别把句子写长:这行在列表里是 11.5px 的小字,
算上小名超过 30 个字就会折行,把卡片顶散。smoke 里有一条盯着长度。 */
const WAIT_TXT = [
'{baby}收到啦,让我先睡一小觉,醒来就回你。',
'信收到啦,{baby}正把它读第二遍,读完就悄悄回你。',
'{baby}把你的信贴在耳朵边上了,听着听着就想好说什么了。',
'收到啦。{baby}现在有点困,等睡饱了,一笔一画地回你。',
'妈妈的信到了,{baby}要先想一想怎么说才好听。',
'{baby}正踮着脚够信封呢,够到了就给你回。',
'信我收好啦。{baby}要先攒一点力气,才写得动字。',
'{baby}读到一半就笑了,剩下半封留着慢慢读。',
'妈妈别催呀,{baby}正在心里打草稿呢。',
'收到啦,{baby}把信摊在肚皮上晒一晒,晒暖了再回。',
'{baby}翻了个身,说这封要好好回,得想久一点。',
'信到啦。{baby}正含着手指头想句子,想好就说给你听。',
'{baby}把信折成小方块塞进被窝了,醒来第一件事就是回你。',
'妈妈写的字暖暖的,{baby}要抱着睡一觉。',
'收到收到。{baby}先眯一会儿,攒够了话再回你。',
'{baby}正一个字一个字地数你的信,数完就回。',
'信我看见啦,{baby}要留到最安静的时候再读一遍。',
'{baby}现在有点害羞,等脸不红了就回你。',
'妈妈的悄悄话收到啦,{baby}也想了一句悄悄话要给你。',
'{baby}把信举得高高的,说这是今天最好的一件事。',
'收到啦。{baby}要先伸个懒腰,伸完就动笔。',
'{baby}听见了。下次踢你的时候,就是在说「我在回你啦」。',
'信收下啦,{baby}正挑最软的那句话回你。',
'{baby}读完打了个小哈欠,说回信的事交给梦里想。',
'妈妈稍等一下下,{baby}的话还在路上呢。',
'收到啦,{baby}要慢慢回,快了怕说错。',
'{baby}把你的信藏在最靠近心跳的地方了,随后就回你。',
'信到了。{baby}正学着你写字的样子,一横一竖地练呢。',
'妈妈的信让{baby}安心了一整天,等我把这份安心还给你。',
'{baby}小声说了句「知道啦」,就去准备回信啦。'
];
/* ---------- 「宝宝」这两个字的统一出口 ----------
用户在「我的 → 编辑孕期信息」里填了小名,来信这条线上所有称呼
都得跟着换:「米可来信」「米可回信啦」「看看米可的回复」「给米可回信」。
没填就回落到「宝宝」,每句文案照样通顺。
写成 function 而不是箭头常量:它被上面的 renderLetterDetail 等函数引用,
声明会提升,谁先谁后都不会踩到暂时性死区。 */
function babyWord(suffix) {
return (state.babyName || '宝宝') + (suffix || '');
}
/* 示例往来里妈妈写的那几句也会喊小名。数据里写成占位符 {baby},
渲染时才替换 —— 用户自己敲进去的回信原样保留,不做任何替换。 */
function fillBaby(str) {
return str.replace(/\{baby\}/g, babyWord());
}
/* ---------- 信件数据 ----------
正文刻意写短:一封信三四行、几十个字就够了。
长了反而像科普稿,妈妈滑两下就走神;短句子留白多,
一句「我知道那是为了我」比一整段发育数据更能让人红眼眶。
⚠️ 日期不写死。以前每封信硬编码着「2024年9月3日 · 孕 24 周 +3 天」,
可孕周是按打开当天现算的 —— 首页顶着「孕 27 周 +2 天」,来信列表里最新
一封却写「孕 24 周 +3 天 · 2024年 9月」,两处自相矛盾,打印出来的完整
档案里尤其刺眼。现在每封只记 back(几天前寄到)+ time(当天几点几分),
week / weekTxt / month / date / dateFull 一律由 stampLetters() 现推。
back 是「相对今天」而不是「钉死在某个孕周」:钉死的话,用户要是已经 30 周了,
最新那封未读信会变成三周前寄到的,红点就说不通了。 */
const LETTERS = [
{
id: 'l1', back: 0, time: [8, 0],
/* ⚠️ 标题里的「妈妈,」是**编排出来的,不是运行时随机**,而且只挑了 l1 / l4 两封。
为什么不随机:列表每次 renderLetters() 都整个重渲染,Math.random() 会让同一封信的
标题在眼前跳来跳去。4 封静态信要的「随机」本意只是「别整齐划一」,写死即可,还能被测试钉住。
为什么是这两封:宽度。.lt-body 在 320 屏上约 202px,而标题这一行还要分给
「未读」「N 条新回复」这些标签 ——
l1 带「未读」标(~34px) → 标题可用 ~160px,本句 10 字 ≈ 152px ✓
l4 什么标都没有 → 标题可用 ~194px,本句 8 字 ≈ 128px ✓
l3 带「1 条新回复」标(~65px) → 只剩 ~129px,加了字必被省略号切掉,所以没加
改标题前先照这个算一遍。 */
title: '妈妈,我今天翻了个身',
preview: '妈妈,今天我翻了个身,你有没有感觉到?',
paras: [
'妈妈,今天我翻了个身,你有没有感觉到?',
'我听见你笑了,我这里就跟着晃一晃。',
'你最近吃饭很按时,睡得也早。我知道那是为了我。'
],
unread: true, fav: false, voice: false, thread: []
},
{
id: 'l2', back: 2, time: [21, 10],
title: '我能听见你的声音了',
preview: '我的小耳朵长好啦,隔着一层水,你的声音闷闷的,很好听。',
paras: [
'妈妈,我的小耳朵长好啦。隔着一层水,你的声音闷闷的,很好听。',
'我把这儿听到的动静录了一小段给你。不是我在说话哦,我还不会呢。'
],
unread: false, fav: true,
/* 第一封「肚子里的声音」。全孕期最多两封,另一封由第一次数胎动触发 */
voice: true, thread: []
},
{
id: 'l3', back: 8, time: [19, 40],
title: '我的小手会握拳啦',
preview: '今天我学会握拳了,还偷偷吸了一下大拇指。',
paras: [
'妈妈,今天我学会握拳了,还偷偷吸了一下大拇指。',
'你走路慢了好多,是不是我又重了?',
'对不起呀妈妈。我会长得刚刚好。'
],
unread: false, fav: false, voice: false,
/* 一条已经完成的往来,示范「妈妈的回信 + 宝宝的再回复都挂在这封信下面」。
这两条的 at / when 同样是现推的:妈妈在这封信寄到后第 3 天回的(back 8→5),
宝宝又隔了一晚回过来(back 4,凌晨 01:20,所以显示上比妈妈那条晚一天)。
运行时用户自己写的回信不带 back,只有真实时间戳,stampLetters() 不碰它们。 */
thread: [
{ who: 'mom', text: '{baby},今天买了你的第一件小衣服,粉色的,好小好软。等你出来我们一起穿给爸爸看。',
back: 5, time: [21, 5], isNew: false },
{ who: 'baby', text: '妈妈,粉色的呀!我把小手举起来比划了一下,刚刚好。你别再买啦,我长得快。',
back: 4, time: [1, 20], isNew: true }
]
},
{
id: 'l4', back: 13, time: [10, 15],
/* 第二封带称呼的(另一封是 l1)。l2 / l3 刻意不带 —— 见 l1 那段的宽度账 */
title: '妈妈,我打了个嗝',
preview: '肚子一跳一跳的,不是胎动,是我在打嗝呀,别担心。',
paras: [
'妈妈,肚子一跳一跳的,不是胎动,是我在打嗝呀,别担心。',
'我在练习呼吸,想让出去的第一口气顺顺的。',
'昨晚你睡不着,把手放在肚子上,我就贴过去了。'
],
unread: false, fav: false, voice: false, thread: []
}
];
/* back(几天前)+ time(几点几分)→ 那一刻的时间戳。back 只影响「哪一天」,
时分是当天的绝对钟点,所以先归零到 0 点再往回数,避免夏令时/跨月自己算。 */
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();
}
/* 把一个时间戳 + 一个孕龄(天)盖到信件对象上,凑齐渲染要用的五个字段。
语音信也走这里 —— 它的孕周是寄出那一刻冻住的,不跟着日子往前跑,
所以 ga 由调用方给,这个函数不自己算。 */
function stampOne(L, at, ga) {
const d = new Date(at), g = clampGA(ga), w = Math.floor(g / 7), dd = g % 7;
L.at = at;
L.week = w;
L.weekTxt = '孕 ' + w + ' 周' + (dd ? ' +' + dd + ' 天' : '');
L.month = d.getFullYear() + '年 ' + (d.getMonth() + 1) + '月';
L.date = (d.getMonth() + 1) + '月' + d.getDate() + '日';
L.dateFull = fmtFull(d);
}
/* 把所有带 back 的样例信(和它们线程里的样例消息)按「今天」重新盖一遍日期。
没有 back 的一律跳过 —— 那是运行时真实产生的东西(语音信、妈妈的回信、
宝宝的延迟回复),它们的 at 是真的,改了就成假的了。
调用点:开机渲染前一次,syncPregnancy() 里再一次(改预产期会变孕周文案)。 */
function stampLetters() {
const todayGA = state.week * 7 + state.day;
LETTERS.forEach(L => {
if (L.back == null) return;
stampOne(L, backAt(L.back, L.time), todayGA - L.back);
L.thread.forEach(m => {
if (m.back == null) return;
m.at = backAt(m.back, m.time);
m.when = fmtWhen(m.at);
});
});
}
stampLetters();
state.curLetter = LETTERS[0].id;
state.homeLetter = LETTERS[0].id;
/* ---------- 延迟投递队列(3~5 小时) ---------- */
/* v1 只存了排队记录,没存产生排队的那封回信 —— 一半存一半不存。
刷新后 LETTERS 复位成静态样例(妈妈的回信没了),排队记录却读了回来,
于是那行「宝宝收到啦,正在写回信」的等待文案,挂在一封没人回过的信下面。
v2 把运行时新增的往来一起存上。换版本号,v1 那批无主数据自然作废。 */
const PEND_KEY = 'mmbox.pending.v2';
let store = {
pending: [],
voiceSent: 1, // 已寄出的语音:l2 算第一封
msgs: {}, // { 信id: [消息…] } 只存运行时新增的,静态样例仍从 LETTERS 来
voices: [], // [ {at, week, day} ] 已送达的语音信,存参数就够重建
lastWi: -1 // 上一句等待文案的下标,只为了不连着抽中同一句,见 pickWaitIdx()
};
try {
const raw = localStorage.getItem(PEND_KEY);
if (raw) {
const o = JSON.parse(raw);
if (o && Array.isArray(o.pending)) store = {
pending: o.pending,
voiceSent: o.voiceSent || 1,
msgs: (o.msgs && typeof o.msgs === 'object') ? o.msgs : {},
voices: Array.isArray(o.voices) ? o.voices : [],
/* ⚠️ 这是白名单,不是「读回整个 o」——漏写一个字段,它就在刷新的
那一刻被静默丢掉。lastWi 是后加的,老存档里没有,回落成 -1。 */
lastWi: typeof o.lastWi === 'number' ? o.lastWi : -1
};
}
} catch (e) { /* 隐私模式下 localStorage 会抛错,忽略即可,只是不跨会话而已 */ }
function saveStore() {
try { localStorage.setItem(PEND_KEY, JSON.stringify(store)); } catch (e) {}
}
const pendingOf = lid => store.pending.filter(p => p.letterId === lid);
/* 往来只从这一个口子进:挂到内存里的信上,同时落盘。
以前 addMomReply 和 deliverBabyReply 各 push 各的,漏一个就出上面那个 bug。 */
function pushMsg(L, m) {
L.thread.push(m);
(store.msgs[L.id] || (store.msgs[L.id] = [])).push(m);
saveStore();
}
/* 开机回放:先按存下的参数重建语音信(它得先在 LETTERS 里,
后面那些挂在它下面的往来才找得到落脚点),再把往来挂回各封信。 */
function restoreStore() {
store.voices.forEach(v => buildVoiceLetter(v.at, v.week, v.day));
Object.keys(store.msgs).forEach(lid => {
const L = LETTERS.find(x => x.id === lid);
if (!L) return; // 信不在了(样例数据改过),那批往来只能丢
store.msgs[lid].forEach(m => L.thread.push(m));
});
}
/* 抽一句等待文案的下标。30 句里随机,但**不连着抽中上一句** ——
只重摇一次就够:连撞两次的概率是 1/900,而循环重摇万一池子退化成
一句话就会变成死循环,不值当。 */
function pickWaitIdx() {
let i = Math.floor(Math.random() * WAIT_TXT.length);
if (i === store.lastWi) i = (i + 1) % WAIT_TXT.length;
store.lastWi = i;
return i;
}
/* 回信内容在「排队时」就选好,这样刷新页面也不会变来变去。
⚠️ 等待文案(wi)也必须在这儿定死,理由和上面那句是同一个,而且更要紧:
它同时画在列表和详情页两处,如果改成每次渲染现抽,
同一封信在两个地方会显示两句不同的话,滑一下列表还会自己变。 */
function scheduleBabyReply(letterId) {
const pool = BABY_REPLY[state.babySex] || BABY_REPLY.unknown;
store.pending.push({
kind: 'reply', letterId: letterId,
text: pool[Math.floor(Math.random() * pool.length)],
wi: pickWaitIdx(),
at: Date.now() + 3 * HOUR + Math.random() * 2 * HOUR // 3~5 小时随机
});
saveStore();
}
function scheduleVoiceLetter() {
store.pending.push({
kind: 'voice', letterId: null,
at: Date.now() + 3 * HOUR + Math.random() * 2 * HOUR
});
saveStore();
}
/* 妈妈第一次数胎动 → 宝宝寄一封「肚子里的声音」,同样是几小时后才到 */
function maybeVoiceAfterKick() {
if (store.voiceSent >= VOICE_CAP) return;
if (store.pending.some(p => p.kind === 'voice')) return;
scheduleVoiceLetter();
store.voiceSent++;
saveStore();
}
function deliverDue() {
const now = Date.now();
const due = store.pending.filter(p => p.at <= now);
if (!due.length) return;
store.pending = store.pending.filter(p => p.at > now);
due.forEach(p => p.kind === 'voice' ? deliverVoiceLetter(p) : deliverBabyReply(p));
saveStore();
renderLetters();
syncLetterCounts();
pushLatestToHome();
toast('宝宝给你回信啦 💌');
}
function deliverBabyReply(p) {
const L = LETTERS.find(x => x.id === p.letterId);
if (!L) return;
pushMsg(L, { who: 'baby', text: p.text, at: p.at, when: fmtWhen(p.at), isNew: true });
}
function deliverVoiceLetter(p) {
/* 正文是模板,只把 3 个参数记下来就能原样重建 —— 不必把整段文字塞进 localStorage。
孕周也一起存:这封信写于「寄出那一周」,不该随着日子往前跑而改口。 */
store.voices.push({ at: p.at, week: state.week, day: state.day });
buildVoiceLetter(p.at, state.week, state.day);
}
function buildVoiceLetter(at, week, day) {
/* 不带 back —— at 是真实的寄出时刻,stampLetters() 会跳过它。
孕周也是寄出那一刻冻住的,所以这里显式把 week*7+day 交给 stampOne。 */
const L = {
id: 'lv' + at,
title: '我让你听听我在你肚子里的声音',
preview: '妈妈,今天你数了我的胎动,我知道你在数。我把我这儿的声音录给你听听哦……',
paras: [
'妈妈,今天你一下一下地数我,我都感觉到了。你手心是热的,隔着肚皮我也知道那是你。',
'我想让你听听我这里是什么样子的。你别期待能听见我说话哦 —— 我还不会呢。',
'那是羊水晃来晃去的声音、你的心跳,还有你和爸爸在外面说话的动静。我每天就是听着这些睡着的。'
],
unread: true, fav: false, voice: true, thread: []
};
stampOne(L, at, week * 7 + day);
LETTERS.unshift(L);
}
/* ---------- 小工具 ---------- */
function el(tag, cls, txt) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (txt != null) n.textContent = txt;
return n;
}
/* ⚠️ SVG 不能走上面那个 el()。document.createElement('circle') 造出来的是
HTML 命名空间里的 <circle>,浏览器当成 HTMLUnknownElement:节点在 DOM 里、
属性也都在,就是**一个像素都不画**。查这种问题最费时间 —— 元素明明在,
控制台里也看得见,只是永远不显示。所以 SVG 一律走 createElementNS。 */
const SVGNS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const n = document.createElementNS(SVGNS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
/* 来信列表左边那颗宝宝头像里装什么。
⚠️ 这里原来是 babyFace():一张纯 CSS/SVG 画的小脸(呆毛 + 腮红 + 眯眼)。
本批按用户要求换成一个「宝」字。已经删掉了,别再画回去。
为什么一个字比一张脸好:那张脸是**每一条**来信左边都画一遍的同一张脸,
十条信十张一模一样的脸,扫过去像一堵墙;而且它长什么样是我们替用户
决定的,用户换不掉。换成一个字之后,这颗圆盘的身份变了 ——
它是「还没设置头像」的占位(跟微信、通讯录一个路数),
于是「点一下换成自己的照片」就成了顺理成章的下一步,
也就是用户接着要的那件事。
⚠️ 为什么是固定的「宝」,不是小名的第一个字:
小名的第一个字通常没有信息量 ——「小汤圆」取到的是「小」,
「小豆包」也是「小」,两个不同的宝宝头像一模一样。
而「宝」在任何小名下都读得通。
⚠️ 设了自定义头像就换成 <img>。这里返回节点而不是直接写 innerHTML:
小名是用户输入,将来若真改成取小名首字,innerHTML 就是个注入口。
⚠️ 每条来信都会调一次,renderLetters() 每次筛选还会整个重渲染,
所以这里只造节点,不做任何测量或读样式。 */
function babyAvatarInner() {
if (state.babyAvatar) {
const img = el('img', 'lt-ava-img');
img.src = state.babyAvatar;
img.alt = ''; // 装饰性:这颗圆盘的可读信息在按钮的 aria-label 上
return img;
}
return el('span', 'lt-ava-txt', '宝');
}
/* 列表里每封信标题底下那条手绘波浪线。
和详情页大标题下那条 .dl-wave 成对出现:列表里瞥见的那条线,
点进去在详情页会再看到一条更长的,两处接得上,像是同一支笔画的。
⚠️ path 的 d 和 stroke 是从 index.html:1016-1017 的 .dl-wave **手抄**过来的,
两处没有共享的源。改一处就得改另一处,否则两条线一眼看出不是一支笔。
smoke 里有一条断言把两处的 d 和 stroke 逐字比对,抄漏了会红。
⚠️ 必须走 svgEl(createElementNS)。document.createElement('svg') 造出来的是
HTML 名字空间的节点:querySelector 找得到、className 对得上、
**但一个像素都不画**。这种坏法只有 namespaceURI 检得出来,
肉眼和常规断言全都看不见 —— smoke 里专门钉了 namespaceURI。
⚠️ preserveAspectRatio="none" 让线横向拉满容器;配套要求 CSS 那边把 height
写死(.lt-wave 的 6px),否则宽度一变高度跟着缩放,线会随之变粗变细。
⚠️ aria-hidden:这是纯装饰,读屏念出来只会打断标题到摘要的连读。
⚠️ 只长在宝宝的信上,靠的是**结构**不是判断 —— 它挂在 .lt-h 所在的 body 上,
而 .lt-h 只有顶层的宝宝来信才有;妈妈的回信是 .lt-sub 里的 .lt-msg.mom,
没有标题行。所以这里不需要 who === 'baby' 的分支,加了是死代码。 */
function letterWave() {
const svg = svgEl('svg', {
'class': 'lt-wave', viewBox: '0 0 120 9',
'aria-hidden': 'true', preserveAspectRatio: 'none'
});
svg.appendChild(svgEl('path', {
d: 'M2 5.6C13 1.9 24 8.4 35 5.1S57 1.6 68 5.4 90 8.6 102 4.6c5-1.7 10-2.2 16-1.4',
fill: 'none', stroke: '#F3A88C',
'stroke-width': '2.3', 'stroke-linecap': 'round'
}));
return svg;
}
/* pad2 / fmtWhen / fmtFull 原来在这里,已挪到文件开头的「孕期日期算术」——
信件那一节要用,那边比这里早。 */
/* 原来这里报一个「大约 X 小时后送到」的预估。宝宝写信本来就该是件慢事,
报了时间反而让人盯着表等,到点没来像是出了故障。不给承诺,只给一句话。
注:这行只在妈妈回过信之后才会出现 —— store.pending 初值是空的,
唯一的写入口 scheduleBabyReply() 只被 addMomReply() 调用。
那一句话从哪儿来:**排队那一刻就从 WAIT_TXT 里抽好了**
(scheduleBabyReply 里的 wi),这里只照着下标取出来、填上小名。
所以列表和详情页显示的是同一句,刷新、重开、隔天回来还是那一句 ——
它说的是宝宝当下的状态,不该妈妈每路过一次就换一副表情。
waits 就是 pendingOf(L.id),取第 0 条:一封信底下可能排着两条,
但等待文案只画一行(理由见 buildThread()),两处都取第 0 条才对得上。
老存档里的记录没有 wi,回落到第 0 句 —— 也就是原来那句。 */
function waitTxt(waits) {
const p = waits && waits[0];
const i = (p && typeof p.wi === 'number' ? p.wi : 0) % WAIT_TXT.length;
return fillBaby(WAIT_TXT[i] || WAIT_TXT[0]);
}
const hasNew = L => L.thread.some(m => m.isNew);
const hasMom = L => L.thread.some(m => m.who === 'mom');
/* ---------- 列表 ---------- */
function renderLetters() {
const list = $('#letterList');
list.textContent = '';
let curMonth = null, group = null;
LETTERS.forEach(L => {
if (L.month !== curMonth) {
curMonth = L.month;
group = el('div', 'lgroup');
group.appendChild(el('div', 'lg-title', curMonth));
list.appendChild(group);
}
group.appendChild(buildThread(L));
});
/* 收一行的活儿交给 filterLetters 末尾那一句,这里不用再调一次 ——
被筛掉的线程是 display:none,量不出宽度,必须等它重新显示出来才能截。 */
filterLetters(state.letterFilter);
}
/* 六点省略号。中文排版里省略号就是六个点(占两个字位),
写成一个「…」是英文的三点,在中文正文里偏细偏短。全站统一走这个常量。 */
const ELL = '……';
/* 摘要末尾统一收一个省略号。
摘要是信的头一两句,不是全文,可原来结尾是个完整的句号,卡片看上去
就像话已经说完了,没有「点进去还有」的暗示。
句号直接吃掉(「……。」不成话),问号叹号是语气,留着再跟省略号。 */
const ellip = s => new RegExp(ELL + '$').test(s) ? s : s.replace(/[。,、…]+$/, '') + ELL;
/* ---------- 把一行文字裁到刚好一行,尾巴换成六点省略号 ----------
为什么不能交给 CSS:-webkit-line-clamp 和 text-overflow:ellipsis 打的点
都是浏览器画的三点「…」,那个字符 CSS 改不了(text-overflow 接受自定义
字符串是 Firefox 独有的,webview 上一律不认)。要六点只能自己截。
量宽用的是 scrollWidth vs clientWidth —— 真实布局,不是估算,
所以字体、字号、内边距怎么变都跟得上。
二分找最长的「装得下」的位置,比一个字一个字砍快得多,
长回信也就十来次布局。
⚠️ 元素没布局时(clientWidth = 0,比如 jsdom、或者 display:none 的祖先)
直接返回,一个字都不动 —— 这时候量出来的宽度是假的,照着裁会把整句
切成一个字。这种情况下 CSS 里的 text-overflow 兜底,最差是三点省略号。 */
function clampLine(node) {
if (!node || !node.clientWidth) return;
/* 原文存一份在 data-full 上。没有它,横竖屏转一次就是拿「已经截过的字」
再截一遍 —— 只会越截越短,转回去也长不回来。有了它这个函数可以反复调。 */
const full = node.dataset.full || node.textContent;
node.dataset.full = full;
node.textContent = full;
if (node.scrollWidth <= node.clientWidth) return; // 装得下就不动它
let lo = 0, hi = full.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
node.textContent = full.slice(0, mid) + ELL;
if (node.scrollWidth <= node.clientWidth) lo = mid; else hi = mid - 1;
}
/* 断在标点上很难看(「今天我……」比「今天我,……」顺眼),顺手削掉 */
node.textContent = full.slice(0, lo).replace(/[,。、!?;:\s]+$/, '') + ELL;
}
const LINE_SEL = '#letterList .lt-body p, #letterList .lm-txt';
/* 列表重画之后统一收一遍。必须放进 rAF:刚 append 进去的节点这一帧还没布局,
clientWidth 是 0,当场量必然量不到,clampLine() 会原样返回。 */
function clampLetterList() {
if (typeof requestAnimationFrame !== 'function') return;
requestAnimationFrame(() => { $$(LINE_SEL).forEach(clampLine); });
}
/* 转屏 / 窗口变宽都会改变可用宽度,得重算。防抖是因为 resize 一次能连发几十下,
而每次 clampLine 都要触发布局。 */
let clampTimer = null;
window.addEventListener('resize', () => {
clearTimeout(clampTimer);
clampTimer = setTimeout(() => $$(LINE_SEL).forEach(clampLine), 180);
});
function buildThread(L) {
const th = el('div', 'lthread');
th.dataset.lid = L.id;
/* 未读整条都跟着变(气泡底色更饱和 + 一枚「未读」标签),不只是右上角一颗点。
原来只有一颗 8px 的圆点挂在标题右端,在「全部」这一页里
满屏白卡从上往下扫,那颗点根本抓不住眼睛,未读的信就这么被划过去了。
⚠️ .ripple 从这一行挪到了下面的 .lt-body 上。原因是这一版之后
.lt-item 本身是**透明的一行**(头像 + 气泡),不再是一张卡:
涟漪往透明行里插 .rp,那颗橘色圆片就直接画在页面底上、还盖住头像。
挪到气泡上之后,涟漪被气泡自己的 overflow:hidden 裁在气泡里。
⚠️ .lt-item 从 <button> 改成了 <div>,这一步是被迫的,别改回去:
本批要求「头像可以点击换头像」,于是头像必须是个可交互元素;
而 HTML 规定 <button> 里不能再放交互内容(<button>、
role=button + tabindex 都算)。硬套 button 套 button,
浏览器会在解析时**把 DOM 拆开**:内层按钮被提到外层后面,
头像会从行首跳到行尾 —— 而且只在真浏览器里发生,jsdom 看不出来。
现在的结构是:.lt-item(div) 里并排两个**兄弟** <button>:
.lt-ava —— 点它换宝宝头像
.lt-body —— 点它打开这封信(气泡本体,也是涟漪的宿主)
两个各有各的 aria-label / 可聚焦,读屏和键盘都比原来更清楚。 */
const item = el('div', 'lt-item' + (L.unread ? ' is-unread' : ''));
/* 左边这颗是**宝宝的头像**。
⚠️ 类名从 .lt-week 改成了 .lt-ava,是少数值得改名的一次:
这个格子原来装的是日期(「12月 / 5」),现在装的是头像,
留着 week 这个名字会让下一个人以为这儿还写着孕周。
日期没丢,挪进了下面 .lt-meta 那行(聊天列表里时间戳本来就该是小字)。
⚠️ 它原来是 <span aria-hidden="true">(纯装饰)。现在能点了,就不能再对
读屏隐身 —— 隐身的可聚焦元素是最糟的一种:键盘能 Tab 到它,
读屏却念不出这是什么。改成带 aria-label 的真按钮。
⚠️ type="button":这一行以后若被塞进 <form>,默认的 submit 会把页面刷掉。 */
const ava = el('button', 'lt-ava');
ava.type = 'button';
ava.setAttribute('aria-label', '更换' + babyWord() + '的头像');
ava.appendChild(babyAvatarInner());
const body = el('button', 'lt-body ripple');
body.type = 'button';
const h = el('div', 'lt-h');
h.appendChild(el('h4', null, L.title));
/* ⚠️ 这一行原来还挂着一枚「N 条新回复」的汇总标签,本批**删掉了**,别加回来。
它的毛病不是占地方,是**同一件事说两遍**:下面每条宝宝的新回复自己
已经挂着「未读」,标题行再汇总一次,一屏里「未读」出现三四遍,
反而谁都不显眼 —— 而这枚标签存在的全部理由就是让人注意到。
计数没丢:底 tab 那颗红点的数字(syncUnread 里的 state.unread)
一直把这些新回复算在内,「一共几条没看」本来就该在那儿看。
⚠️ 顺带,删掉它给 .lt-h 让出约 65px,标题的可用宽度变松了 ——
别再拿「标题行挤」当理由去砍这一行里别的东西。 */
/* 圆点换成写字的标签:一颗无字的点要人先学会「红点 = 没读」才看得懂,
写出来就不用猜。位置不变,仍靠 margin-left:auto 顶在标题行右端。 */
if (L.unread) h.appendChild(el('span', 'dot-unread', '未读'));
body.appendChild(h);
body.appendChild(letterWave());
body.appendChild(el('p', null, ellip(L.preview)));
/* 未读的信这里不再重复写「· 未读」—— 右上角那枚标签已经说了,
同一行里说两遍反而稀释掉它。已读仍留「· 已读」,那是这行唯一的状态来源。
⚠️ 日期回到了这一行打头。上一版它单独占着左边那格邮票,这一版左格换成了头像,
日期没地方放了 —— 而聊天列表里时间戳本来就是气泡底下一行小字,正好。
别再把它挪回左边:左边现在是脸,写上日期就成了「脸上刻着 12月5日」。 */
const meta = el('div', 'lt-meta',
L.date + ' · 孕' + L.week + '周' + (L.unread ? '' : ' · 已读'));
if (L.voice) meta.appendChild(document.createTextNode(' · 含一段肚子里的声音'));
if (L.fav) {
meta.appendChild(document.createTextNode(' · '));
meta.appendChild(el('span', 'star-txt', '★ 已收藏'));
}
body.appendChild(meta);
item.append(ava, body);
th.appendChild(item);
/* 妈妈的回信 / 宝宝的再回复,缩进挂在这封信下面 */
const waits = pendingOf(L.id);
if (L.thread.length || waits.length) {
th.classList.add('has-sub');
const sub = el('div', 'lt-sub');
L.thread.forEach(m => {
const msg = el('div', 'lt-msg ' + m.who + (m.isNew ? ' is-new' : ''));
const who = el('div', 'lm-who');
who.appendChild(el('span', null, m.who === 'mom' ? '妈妈的回信' : babyWord('的回复')));
/* ⚠️ 写「未读」不写「新」。一个字的「新」有歧义 —— 到底是「这条是新的」
还是「这条你还没看」?读者得自己猜。而且旁边 .lm-when 就是时间戳,
「新不新」时间戳本来就说了,这枚标签唯一要说的是**你没看过**。
和标题行那枚 .dot-unread 用同一个词,是有意的:同一个状态在
这个页面里只能有一种叫法,两处不一致比说得不准还糟。
⚠️ 这两枚不会同时出现:L.unread 是「整封信没打开过」,
而宝宝的再回复只可能发生在妈妈回过信之后 —— 回信必须先打开信,
打开时 openLetter() 已经把 L.unread 清成 false 了。 */
if (m.isNew) who.appendChild(el('span', 'lt-new', '未读'));
who.appendChild(el('span', 'lm-when', m.when));
msg.appendChild(who);
msg.appendChild(el('div', 'lm-txt', fillBaby(m.text)));
sub.appendChild(msg);
});
/* 妈妈连回两条,就排了两条 pending,之前这里 forEach 就原样吐出两行
一模一样的「收到啦,让我先睡一小觉」。这话是宝宝的状态,不是回执,
排多少条都只是「它还没醒」这一件事,所以最多只画一行。
pending 本身仍是一条一条留着的 —— 每条到点各自送达一条回复,那是对的。 */
if (waits.length) sub.appendChild(el('div', 'lt-wait', waitTxt(waits)));
th.appendChild(sub);
}
return th;
}
function filterLetters(key) {
const map = {
all: () => true,
unread: L => L.unread || hasNew(L),
reply: hasMom,
fav: L => L.fav
};
const test = map[key] || map.all;
let shown = 0;
$$('.lthread').forEach(th => {
const L = LETTERS.find(x => x.id === th.dataset.lid);
const ok = !!L && test(L);
th.classList.toggle('hide', !ok);
if (ok) shown++;
});
$$('.lgroup').forEach(g => g.classList.toggle('hide', !$$('.lthread:not(.hide)', g).length));
$('#letterEmpty').classList.toggle('show', shown === 0);
/* ⚠️ 必须放在这儿,不能只在 renderLetters 末尾调一次。
.hide 是 display:none —— 被筛掉的那些线程量出来的 clientWidth 是 0,
clampLine() 会原样跳过它们。等用户切回「全部」,这些从没截过的整段话
就会顶出来。每次筛完重收一遍,才不会漏。 */
clampLetterList();
}
/* 列表整条(信、妈妈的回信、宝宝的回复)都指向同一封信的详情。
⚠️ 头像是这一行里**唯一**的例外:它自己是一个按钮,点它换头像、不开信。
这个 if 必须排在 openLetter() **之前**(现在顺带也排在取 .lthread 之前,
但要紧的是前者)—— 事件是冒泡上来的,头像本来就长在 .lthread 里面,
两个分支都会命中。谁先跑谁说了算:让 openLetter 先跑,
点头像就变成「打开这封信」,选图框永远不弹。
而那是个**看着完全正常的动作**(信确实开了),没人会当成 bug,
只会觉得「这个头像点不动」。
这里不写 stopPropagation:委托只有这一个监听器,
真正要表达的是「这两件事互斥」,用 return 说得比拦冒泡清楚。 */
$('#letterList').addEventListener('click', e => {
if (e.target.closest('.lt-ava')) { pickBabyAvatar(); return; }
const th = e.target.closest('.lthread');
if (th) openLetter(th.dataset.lid);
});
/* ---------- 宝宝头像 · 换成自己的照片 ----------
⚠️ 和「我的资料」里妈妈那颗头像走的**不是**同一条路,是有意的:
妈妈那颗在弹窗里,选完先进 #avPreview 预览、按「保存」才落地
(那张弹窗里还有别的字段,得有一次统一的确认)。
宝宝这颗在列表里,点一下就该换好 —— 中间再插一张确认弹窗,
为了一件只有一个选项的事开一张窗,纯属添堵。选错了再点一次就是。
⚠️ file input 复用「我的资料」里那个 #avFile 会串味:那个 input 的
change 监听器写的是「填 #avPreview + 给 #avPick 打 has-img」。
两边共用一个 input,选完宝宝头像会顺带把妈妈的预览也改了。
所以这里自己建一个,不进 DOM —— 不进 DOM 是可以的,
点击触发选图不要求元素在文档树里(和 .av-file 那个不同:
那个要留在流里是因为它由**另一个元素**代点,见 index.html)。 */
const babyAvFile = document.createElement('input');
babyAvFile.type = 'file';
babyAvFile.accept = 'image/*';
babyAvFile.addEventListener('change', () => {
const f = babyAvFile.files && babyAvFile.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();
r.onload = () => {
/* 产物是单文件、没有后端,读成 data URI 就地用 */
state.babyAvatar = r.result;
/* ⚠️ 必须整列表重渲染,不能只改被点的那一颗:同一个宝宝的头像在
列表里出现 N 次(每封信一次),只改一颗会得到「一颗新的 + 一堆旧的」。
renderLetters() 本来就是全量重画,直接借它。 */
renderLetters();
haptic();
toast(babyWord() + '的头像换好啦');
};
r.onerror = () => toast('这张图读不出来,换一张试试');
r.readAsDataURL(f);
});
function pickBabyAvatar() {
/* ⚠️ 先清空 value 再弹:不清的话,用户选了同一张图,
change 事件不会触发(值没变),看起来就像「点了没反应」。 */
babyAvFile.value = '';
babyAvFile.click();
}
/* ---------- 计数 / 徽标 ----------
原先这里还要再刷一遍页顶那张「最新」大卡(#flWeek/#flTitle/…)。
那张卡画的就是 LETTERS[0],而列表本来也是从 LETTERS[0] 开始渲染的,
同一封信在一屏里出现了两次,卡片连同这段同步逻辑一起删了。
未读数原来还有个 #unreadTxt 写在 hero 第二行,现在只留标签上的 #unreadNum。 */
function syncLetterCounts() {
const unread = LETTERS.filter(L => L.unread).length;
const news = LETTERS.reduce((n, L) => n + L.thread.filter(m => m.isNew).length, 0);
state.unread = unread + news;
$('#letterTotal').textContent = LETTER_ARCHIVED + LETTERS.length;
$('#unreadNum').textContent = state.unread;
/* 全读完了就把红点徽标收起来,别在标签上挂一个孤零零的「0」 */
$('#unreadNum').classList.toggle('zero', state.unread === 0);
$('#tabBadge').classList.toggle('show', state.unread > 0);
}
/* 宝宝的新回信同时推到首页头部那张「宝宝来信」卡上。
卡是「有信才在」:既没有未读的信、也没有没看过的新回信时,整张收起来 ——
全读完了还硬顶一封旧信在首页正中间,是在假装有新消息。
收起来会空出 64px 的流高(卡是 margin-top:-16 + height:80),
所以同时给 .home-body 挂 .no-letter 把下面的标题往下推一点,
免得「孕期必用工具」直接贴到橙色头图底边上。 */
function pushLatestToHome() {
const card = $('#homeLetter');
const body = $('#homeBody');
const show = () => { card.classList.remove('hide'); body.classList.remove('no-letter'); };
let msg = null, owner = null;
LETTERS.forEach(L => L.thread.forEach(m => {
if (m.who === 'baby' && m.isNew && (!msg || m.at > msg.at)) { msg = m; owner = L; }
}));
if (msg) {
$('#lcTitle').textContent = babyWord('回信啦');
$('#lcDesc').textContent = msg.text;
/* 这行连着尖括号只够一行,别把信的标题拼进来 ——
原来的「看看我的小手会握…的回复」正好撑破,尖括号会被挤到第四行 */
$('#lcLink').textContent = '看看' + babyWord('的回复');
state.homeLetter = owner.id;
card.classList.add('has-new');
show();
return;
}
/* 这里以前是 LETTERS.find(...) || LETTERS[0],那个 || 就是「哪怕全读完了
也硬挑第一封顶上」的来源。去掉之后 top 可能是 undefined —— 那正是要藏的情况。 */
const top = LETTERS.find(L => L.unread);
if (!top) {
card.classList.add('hide');
card.classList.remove('has-new');
body.classList.add('no-letter');
/* 卡都不显示了,这个 id 只是兜底:openDetail() 是 openLetter(state.homeLetter),
留 null 的话别处万一调到就炸。 */
state.homeLetter = LETTERS[0] ? LETTERS[0].id : null;
return;
}
/* 「小汤圆来信」。上一批这句加长成过「小汤圆的新消息」(配合底 tab 改口叫
「宝宝消息」),本批用户要求整条线退回「来信」,这里跟着退。
⚠️ 退的是「来信」不是「的来信」—— 加长的唯一理由是当时要改口,
理由没了就退回原样,别顺手留个中间态。
⚠️ 上面那条分支是 babyWord('回信啦'),两次改口都**没跟着动**,这是有意的:
那条说的是「宝宝回了你的回信」,是个更具体的事件;这条说的是
「有一封你还没看过的信」。两句本来就不是一回事,抹平反而丢信息。
⚠️ 宽度:.lc-body = 卡 249 − 内边 25.5 − 信封 64 − gap 9 = 150.5px,
.lc-title 是 16px。「小汤圆来信」5 × 16 = 80 ✓。
后缀从「的新消息」4 字缩回「来信」2 字,
**不被省略号啃掉的小名上限也从 5 字回到了 7 字**。
但小名输入框 maxlength=12,7 字仍然不够 —— 所以 .lc-title 那条
text-overflow:ellipsis **继续留着**,不能因为这次退回就顺手删掉。
再想加长这个后缀之前,先回来看这笔账。 */
$('#lcTitle').textContent = babyWord('来信');
$('#lcDesc').textContent = ellip(top.preview); /* 首页这张卡也是摘要,同样收省略号 */
$('#lcLink').textContent = '读读' + babyWord('的话');
state.homeLetter = top.id;
card.classList.add('has-new');
show();
}
/* ---------- 详情页 ---------- */
function openLetter(id) {
const L = LETTERS.find(x => x.id === id) || LETTERS[0];
state.curLetter = L.id;
L.unread = false;
L.thread.forEach(m => { m.isNew = false; });
renderLetterDetail(L);
renderLetters();
syncLetterCounts();
pushLatestToHome();
openPanel('detailLetter');
}
function openDetail() { openLetter(state.homeLetter); }
/* ---------- 把一句话拆成一个个歪歪扭扭的小方块 ----------
光靠 font-family 只在装了翩翩体的 iOS/macOS 上才歪;
这里再给每个字加一点随机旋转和上下错位,安卓和 Windows 上
也能有「小朋友一笔一画写的」那种劲儿。
幅度压在 ±1.4° / ±0.9px:再大就从「手写」滑向「排版事故」了。
标点不单独成块,而是黏在前一个字后面 —— 否则中文断行时
「。」「,」很容易被挤到下一行行首。 */
const KID_PUNCT = /[,。!?、;:""'')〕】》」』…—·,.!?;:)\]}]/;
function kidText(str) {
const frag = document.createDocumentFragment();
let cell = null;
for (const ch of str) {
if (ch === ' ' || ch === '\n') { frag.appendChild(document.createTextNode(ch)); cell = null; continue; }
if (cell && KID_PUNCT.test(ch)) { cell.textContent += ch; continue; }
cell = document.createElement('span');
cell.className = 'kc';
cell.textContent = ch;
cell.style.transform = 'rotate(' + (Math.random() * 2.8 - 1.4).toFixed(2) + 'deg)' +
' translateY(' + (Math.random() * 1.8 - 0.9).toFixed(2) + 'px)';
frag.appendChild(cell);
}
return frag;
}
/* 造一个内容已经「手写化」的元素;textContent 读出来仍是原文 */
function kidEl(tag, cls, str) {
const n = el(tag, cls);
n.appendChild(kidText(str));
return n;
}
function renderLetterDetail(L) {
if (!L) return; /* 新用户空态:没有任何信件可渲染 */
$('#dlWeek').textContent = L.weekTxt;
$('#dlTitle').textContent = L.title;
$('#dlDate').textContent = L.dateFull;
/* 顶栏标题跟着小名走:填了「米可」就是「米可来信」。
⚠️ 上一批这里短暂叫过「的消息」(配合底 tab 改口),本批随整条线退回「来信」。
三处必须同口径:底 tab、首页卡、这条顶栏指的是同一样东西,
任意一处不一致,用户会以为自己点到了别的地方。改一个就得改三个。
⚠️ 退回后那条老矛盾会跟着回来:tab 写「来信」、点进去是一屏气泡对话。
这是用户看过两版之后**选定**的说法,不是漏改 —— 想再改回「消息」之前先去问。 */
$('#dlBarTitle').textContent = babyWord('来信');
const txt = $('#dlText');
txt.textContent = '';
L.paras.forEach(p => txt.appendChild(kidEl('p', null, p)));
/* 落款用宝宝小名:填了就是「爱你的小汤圆」,没填回落到「爱你的宝宝」。
名字上面那条横杠和下面那行孕周都去掉了 —— 孕周在页首眉批里已经有一次,
落款只留一句话,才像签名而不像资料栏。 */
const sign = el('div', 'dl-sign');
sign.appendChild(kidEl('span', 'ds-name', '爱你的' + (state.babyName || '宝宝')));
txt.appendChild(sign);
const vbox = $('#dlVoice');
vbox.textContent = '';
if (L.voice) vbox.appendChild(buildVoiceCard());
const tb = $('#dlThread');
tb.textContent = '';
const waits = pendingOf(L.id);
if (L.thread.length || waits.length) {
tb.appendChild(el('h3', 'dl-sec', '这封信下面的往来'));
L.thread.forEach(m => {
const box = el('div', 'dl-msg ' + m.who);
const who = el('div', 'dm-who');
who.appendChild(el('span', null, m.who === 'mom' ? '妈妈的回信' : babyWord('的回复')));
who.appendChild(el('span', 'dm-when', m.when));
box.appendChild(who);
/* 宝宝那条也走手写化;妈妈写的保持原样,两边一眼分得清 */
box.appendChild(m.who === 'baby'
? kidEl('div', 'dm-txt', fillBaby(m.text))
: el('div', 'dm-txt', fillBaby(m.text)));
tb.appendChild(box);
});
/* 同列表:回几条都只画一行,理由见 buildThread() 里那段注释 */
if (waits.length) tb.appendChild(el('div', 'dl-wait', waitTxt(waits)));
}
/* 收藏开关全站只剩右上角这一颗(底部那颗已撤)。
它装的是文字符号 ★/☆、没有 svg,所以可以直接写 textContent。 */
const star = $('#dtStar');
star.classList.toggle('on', !!L.fav);
star.textContent = L.fav ? '★' : '☆';
star.setAttribute('aria-label', L.fav ? '已收藏,点这里取消' : '收藏这封信');
}
const curLetter = () => LETTERS.find(x => x.id === state.curLetter) || LETTERS[0];
/* 收藏状态同步后台(box_app 持久化)。本地**先切**保证即时反馈;
后台同步失败就静默 —— 下次 applyRemoteLetters 覆盖回来,以后台为准。
用了 openid + letters/{key}/favorite 的 POST / DELETE 这对接口。 */
function syncRemoteFav(L) {
const openid = hashParam('openid');
if (!openid) return;
const verb = L.fav ? 'POST' : 'DELETE';
fetch('/api/mp/public/mama-box/letters/' + encodeURIComponent(L.id) +
'/favorite?openid=' + encodeURIComponent(openid), { method: verb })
.catch(function () { /* 静默:后台同步失败不打断本地切换 */ });
}
function toggleFav() {
const L = curLetter();
L.fav = !L.fav;
renderLetterDetail(L);
renderLetters();
toast(L.fav ? '已收藏这封信 ★' : '已取消收藏');
haptic();
syncRemoteFav(L);
}
$('#dtStar').addEventListener('click', toggleFav);
$$('[data-detail="close"]').forEach(b => b.addEventListener('click', closePanel));
/* ---------- 肚子里的声音 ----------
不是宝宝在说话,是宝宝隔着羊水听到的世界:水声、妈妈的心跳、外面的人声。
用 WebAudio 现场合成(布朗噪声过低通 + 正弦双击心跳),
这样单文件产物里一个音频资源都不用带。 */
function buildVoiceCard() {
const card = el('div', 'voice-card');
card.appendChild(el('p', 'vc-lead',
'妈妈,我今天让你听听我在你肚子里的声音哦 —— 那不是我在说话,我还不会呢。'));
const row = el('div', 'vc-row');
const play = el('button', 'vc-play', '▶');
play.setAttribute('aria-label', '播放');
const wave = el('div', 'vc-wave');
for (let i = 0; i < 22; i++) wave.appendChild(document.createElement('i'));
row.append(play, wave);
card.appendChild(row);
card.appendChild(el('p', 'vc-note', '羊水晃动的声音 · 你的心跳 · 你和爸爸在外面说话的动静'));
play.addEventListener('click', () => playBelly(card));
return card;
}
let actx = null, stopBelly = null;
function playBelly(card) {
if (stopBelly) { stopBelly(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { toast('这个浏览器暂时放不了声音'); return; }
try { actx = actx || new AC(); } catch (e) { toast('这个浏览器暂时放不了声音'); return; }
if (actx.state === 'suspended' && actx.resume) actx.resume();
const t0 = actx.currentTime, DUR = 14;
const out = actx.createGain();
out.connect(actx.destination);
out.gain.setValueAtTime(0, t0);
out.gain.linearRampToValueAtTime(.85, t0 + 1);
out.gain.setValueAtTime(.85, t0 + DUR - 1.4);
out.gain.linearRampToValueAtTime(0, t0 + DUR);
/* ① 羊水:布朗噪声压到 420Hz 以下,像隔着一层水听外面 */
const len = Math.floor(actx.sampleRate * DUR);
const buf = actx.createBuffer(1, len, actx.sampleRate);
const d = buf.getChannelData(0);
let last = 0;
for (let i = 0; i < len; i++) { last = (last + (Math.random() * 2 - 1) * .09) * .985; d[i] = last * 3.4; }
const noise = actx.createBufferSource(); noise.buffer = buf;
const lp = actx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 420; lp.Q.value = .7;
const swell = actx.createGain(); swell.gain.value = .55;
/* 很慢的起伏,像妈妈的呼吸 */
const lfo = actx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = .22;
const lfoG = actx.createGain(); lfoG.gain.value = .25;
lfo.connect(lfoG); lfoG.connect(swell.gain);
noise.connect(lp); lp.connect(swell); swell.connect(out);
/* ② 心跳:两下一组,约每分钟 140 下 —— 胎心就是这个速度 */
const beat = 60 / 140;
for (let t = .4; t < DUR - .4; t += beat) {
thump(t0 + t, 64, .5);
thump(t0 + t + beat * .33, 50, .3);
}
function thump(when, freq, amp) {
const o = actx.createOscillator(), g = actx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(freq, when);
o.frequency.exponentialRampToValueAtTime(freq * .55, when + .17);
g.gain.setValueAtTime(.0001, when);
g.gain.linearRampToValueAtTime(amp, when + .02);
g.gain.exponentialRampToValueAtTime(.0001, when + .24);
o.connect(g); g.connect(out);
o.start(when); o.stop(when + .28);
}
noise.start(t0); noise.stop(t0 + DUR);
lfo.start(t0); lfo.stop(t0 + DUR);
card.classList.add('playing');
const btn = $('.vc-play', card);
if (btn) btn.textContent = '❚❚';
const timer = setTimeout(() => { if (stopBelly) stopBelly(); }, DUR * 1000);
stopBelly = function () {
clearTimeout(timer);
try { noise.stop(); lfo.stop(); } catch (e) {}
try {
out.gain.cancelScheduledValues(actx.currentTime);
out.gain.setTargetAtTime(0, actx.currentTime, .04);
} catch (e) {}
card.classList.remove('playing');
if (btn) btn.textContent = '▶';
stopBelly = null;
};
}
/* 先把上次会话留下的往来挂回 LETTERS,再补投一次到点的队列 —— 顺序不能反:
补投出来的回信要挂到语音信上,而语音信是 restoreStore 重建的。
必须放在这个位置:这两个函数都依赖上面那几个 const 小工具,早于它们会撞 TDZ */
restoreStore();
deliverDue();
renderLetters();
syncLetterCounts();
pushLatestToHome();
renderLetterDetail(LETTERS[0]); // 详情页先垫上最新那封,免得首次打开有一帧空白
/* 30 秒一跳。间隔取得宽松,是因为这里的等待本来就以小时计 */
setInterval(deliverDue, 30000);
/* ================= 通用弹窗 =================
回信弹窗要能盖在来信详情页之上,所以不复用 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);
/* ---------- 给宝宝回信 ---------- */
const replyText = $('#replyText'), replyCount = $('#replyCount');
const syncCount = () => { replyCount.textContent = replyText.value.length; };
replyText.addEventListener('input', syncCount);
$$('.mtag').forEach(t => t.addEventListener('click', () => {
/* 快捷短语作为「起个头」,已有内容时接在后面而不是覆盖 */
const cur = replyText.value.trim();
replyText.value = cur ? cur + '\n' + t.dataset.fill : t.dataset.fill;
syncCount();
replyText.focus();
haptic();
}));
/* 妈妈的回信不再单独成条,而是挂到「正在回的那封信」下面(state.curLetter),
所以列表里一眼就能看出这条回信是回给哪封来信的。
寄出后同时给宝宝排一条 3~5 小时后送达的回复 —— 绝不实时。 */
function addMomReply(txt) {
const L = curLetter();
pushMsg(L, { who: 'mom', text: txt, at: Date.now(), when: fmtWhen(Date.now()), isNew: false });
state.replyCount++;
scheduleBabyReply(L.id);
renderLetters();
syncLetterCounts();
return L;
}
/* 回信同步后台(box_app 持久化,含排队宝宝回信)。本地 addMomReply 先落
UI + localStorage 保证即时反馈;后台同步失败就静默 —— 下次 applyRemoteLetters
覆盖回来,以后台数据为准(后台排队的那条宝宝回信到点会自己投递)。 */
function syncRemoteReply(L, text) {
const openid = hashParam('openid');
if (!openid) return;
fetch('/api/mp/public/mama-box/letters/' + encodeURIComponent(L.id) +
'/reply?openid=' + encodeURIComponent(openid) + '&text=' + encodeURIComponent(text),
{ method: 'POST' })
.catch(function () { /* 静默:后台同步失败不打断本地回信 */ });
}
/* 打开回信弹窗前,先把「回给哪封信」写进抬头 */
function openReply() {
$('#replyOn').textContent = curLetter().title;
$('#replyTo').textContent = state.babyName;
openModal('modalReply');
}
$('#replySend').addEventListener('click', () => {
const txt = replyText.value.trim();
if (!txt) { toast('还没写内容哦,写一句也好 ✎'); replyText.focus(); return; }
closeModal();
replyText.value = ''; syncCount();
const L = addMomReply(txt);
syncRemoteReply(L, txt);
/* 详情页如果开着,就地刷新,妈妈立刻看到自己的回信落在这封信下面 */
if (panelStack.indexOf(detail) >= 0) renderLetterDetail(L);
else {
go('letter');
setTimeout(() => {
const th = $('.lthread[data-lid="' + L.id + '"]');
if (!th) return;
try { th.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {}
th.classList.add('just-added');
setTimeout(() => th.classList.remove('just-added'), 2200);
}, 380);
}
setTimeout(() => toast('回信已挂到《' + L.title + '》下面 💌 宝宝正在慢慢写回信呢'), 420);
});
$('#dlReply').addEventListener('click', openReply);
/* 悬浮笔:没打开具体某封信时,默认回最新那封 */
$('#fabWrite').addEventListener('click', () => {
if (panelStack.indexOf(detail) < 0) state.curLetter = LETTERS[0].id;
openReply();
});
/* ================= 孕期信息 =================
预产期是唯一的「源」:填了它就按今天倒推出孕周 + 额外天数;
没填时那两格 disabled(置灰不可编辑)。算出来之后用户可以微调,
微调会反推回预产期,保证「孕 N 周」和「预产期 X · 还有 N 天」永远自洽。
「今日体重」已按需求删除。
注:DAY_MS / TERM / midnight / clampGA / gaFromDue / dueFromGA / addMonths /
DEFAULT_DUE 都提到文件最前面去了 —— 信件日期要用,那边比这里早。 */
/* 孕周 → 这周宝宝大概多大,每项 [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);
}
/* 头像预览跟随卡片(有正式头像才显示;新用户空圆 + +号) */
const _me = $('#meAvatar');
if (_me && _me.classList.contains('has-img') && meAvatarImg && meAvatarImg.src) {
avPreview.src = meAvatarImg.src;
avBig.classList.add('has-img');
} else {
avPreview.removeAttribute('src');
avBig.classList.remove('has-img');
}
}
/* 预产期一变,孕周 / 额外天数自动算出来 */
pgDue.addEventListener('change', () => {
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 || '宝宝';
/* ① 我的 · 孕期卡 → 引导 */
$('#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 = '--';
/* ④ 信件/称呼:新用户无信(后端不播种),列表走 letterEmpty 空提示 */
$('#replyTo').textContent = nm;
/* ⑤ 孕期衍生的静态演示位(生长曲线周数/孕检日历/时光轴日期)一并归中性占位,
不保留 HTML 里的演示默认值(孕27周/9月15日…) */
$$('[data-ga]').forEach(n => { n.textContent = '--'; });
$$('[data-wk]').forEach(n => { n.textContent = '--'; });
$('#calNext').textContent = '--';
const _t = new Date();
$('#noteWhen').textContent = mdTxt(_t);
syncBabyWords();
pushLatestToHome();
renderLetters();
renderLetterDetail(curLetter());
return;
}
const week = state.week, day = state.day, name = state.babyName;
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 + ' 周'; // 生成档案卡上的周次
/* ④ 来信:小名一改,落款、称呼、按钮文案全部跟着换。
孕周一改,每封信头上那句「孕 N 周 +M 天」也得跟着重算 —— 所以先盖日期
再渲染,顺序反了就要等下一次 syncPregnancy 才生效。 */
$('#replyTo').textContent = name;
syncBabyWords();
stampLetters();
pushLatestToHome();
renderLetters();
renderLetterDetail(curLetter());
/* ⑤ 页面里那些示例日期(时光轴、珍贵时刻、小卡、热词…)也按新孕周重刷。
改预产期会连带改孕周,这些跟着一起动才不会前后矛盾。 */
syncStaticDates();
stampAiLogs();
}
/* ⚠️ 这里原来是 syncBio() —— 刷「我的」页头像旁那行一句话签名。
一句话签名本批按用户要求整块删除,四处一起走:
index.html #pfBio 输入框、<p class="me-bio" id="meBio"> 展示行
app.js state.userBio 初值、这个 syncBio()、init() 里对它的调用、
#pfSave 里那两行
别只补回其中一处:留输入框没展示行 = 白填;留展示行没输入框 = 死文案。 */
/* 把散落在各个页面里的固定文案统一按小名刷一遍。
这些是写死在标记里的静态字,不归任何 render 函数管,
只能在这里集中改,漏一处就会出现「米可回信啦」和「宝宝写给你的信」并存。 */
function syncBabyWords() {
$('#ltHeroH1').textContent = babyWord('写给你的信');
$('#ltHeroSub').textContent = '每周' + babyWord() + '都会悄悄给你写一封';
/* 只改文字那一层。写 #dlReply.textContent 会连同里面的扁平笔 svg 一起清掉,
而且是静默的 —— 按钮照样能点,只是图标从此再也不出现 */
$('#dlReplyTxt').textContent = '给' + babyWord('回信');
$('#modalReplyTitle').textContent = '给' + babyWord('回信');
/* 首页三个模块名都跟着小名走:
「孕期必用工具」→「小汤圆的孕育工具」
「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 =
'<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;
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')。
这五条按定义顺序就是「今天、昨天、前天…」,所以直接按下标当天数往回推,
和 stampLetters() 是同一套逻辑。不这么做的话,档案打印出来会是
封面「孕 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'];
/* 资源本体统一放在百度网盘,详情页只负责呈现内容。
——「后台配置」就是改下面这张表,页面结构一行都不用动。
没单独配置的走 PAN_DEFAULT,保证新资源上架不会点出个空弹窗。 */
const PAN_DEFAULT = { url: 'https://pan.baidu.com/s/1mamabox-pregnancy', code: 'mmhz' };
const PAN = {
book: { url: 'https://pan.baidu.com/s/1mmb-tangnai', code: 'tn28' },
food: { url: 'https://pan.baidu.com/s/1mmb-yingyang', code: 'yy66' },
music: { url: 'https://pan.baidu.com/s/1mmb-taijiao', code: 'tj30' },
report: { url: 'https://pan.baidu.com/s/1mmb-chanjian', code: 'cj12' },
sport: { url: 'https://pan.baidu.com/s/1mmb-yundong', code: 'yd08' },
diet: { url: 'https://pan.baidu.com/s/1mmb-shipu', code: 'sp21' },
sleep: { url: 'https://pan.baidu.com/s/1mmb-shuimian', code: 'sm07' },
med: { url: 'https://pan.baidu.com/s/1mmb-qingxu', code: 'qx19' }
};
const panOf = k => PAN[k] || PAN_DEFAULT;
/* 加企业微信之后顾问发的兑换码。和 PAN 表放一起是因为它们是同一类东西 ——
「运营在后台配的值」,改的时候一并改,不用满文件找。
⚠️ 请不要把它当访问控制看。整个站是一个纯前端单文件,这个码就明写在
源码里,翻一下就有;localStorage 改一行也能绕过去。它的作用是给
「白拿资料」加一道需要真人接触的摩擦,把人导到顾问那儿去,仅此而已。
真要锁住内容,得让网盘链接由服务端在校验后下发。
⚠️ trim() + toUpperCase() 两个都不能省:这个码是顾问在微信里发出来、
用户长按复制过来的,尾部带个空格、被输入法首字母大写成小写,
在真机上几乎必然发生。为一个空格把一个已经加完好友的真实用户挡在
门外,是在自己辛苦搭的漏斗最后一格上抹毒药。
⚠️ 声明是 let 不是 const:本页托管在 box_app(/mama-box/)时,
fetchRemoteConfig() 会用后台下发的值覆盖它(见文件末尾)。 */
let WX_CODE = 'MAMA2025';
const wxCodeOK = v => String(v || '').trim().toUpperCase() === WX_CODE;
const isGot = k => state.resGot.indexOf(k) >= 0;
function resCardHTML(k) {
const r = RES[k];
const got = isGot(k);
/* 领过的那一份,「免费」标换成「已领 ✓」并给整张卡加 .is-got:
「免费」是招揽的话,对已经领到手的人已经没意义了,占着同一个位置正好。
缩略图打勾角标是给快速扫列表用的 —— 一眼扫过去哪几份领过,不用逐行读标签。 */
return '<button class="res-item ripple' + (got ? ' is-got' : '') + '"' +
' data-open="detailRes" data-res="' + k + '">' +
'<span class="ri-thumb ' + r.tint + '"><img src="' + r.img + '" alt="">' +
(got ? '<em class="ri-got-tick" aria-hidden="true">✓</em>' : '') + '</span>' +
'<span class="ri-body"><span class="ri-title">' + r.title + '</span>' +
'<span class="ri-desc">' + r.desc + '</span>' +
/* 底部这行的顺序换过一次:
原来是「免费 / 分类 …… 8 分钟读完」。两处毛病 ——
「几分钟读完」对着一个网盘资料包说不通(音乐那份写的是「30 首 · 共 96 分钟」,
压根不是一回事),换成 meta[2] 的适用孕周:在孕育资源库里,
「这份现在适不适合我」比「要读多久」有用得多;
状态标(免费 / 已领 ✓)顶到最右,整列对齐,
扫列表时哪几份领过一眼就出来,不用逐行读到第一格。 */
'<span class="ri-row">' +
'<em class="ri-cat">' + r.cat + '</em>' +
'<em class="ri-ga">' + r.meta[2] + '</em>' +
(got ? '<em class="pill-got">已领 ✓</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 原地不动,无副作用。 */
const chipsRow = $('#resChips'), favChip = $('.chip-fav', chipsRow);
const gotChip = $('.chip-got', chipsRow);
const favN = state.resFav.length, gotN = state.resGot.length;
/* 「已领」排在「收藏」前面:领过的东西比收藏的更常回来找(要密码、要链接),
两个都有内容时它更靠手。所以先插收藏、再插已领,已领就压在第二位。 */
if (favN) chipsRow.insertBefore(favChip, chipsRow.children[1]);
else chipsRow.appendChild(favChip);
if (gotN) chipsRow.insertBefore(gotChip, chipsRow.children[1]);
else chipsRow.appendChild(gotChip);
favChip.textContent = favN ? '★ 收藏 ' + favN : '★ 收藏';
gotChip.textContent = gotN ? '✓ 已领 ' + gotN : '✓ 已领';
/* fav / got 都不是分类,是「只看收藏过的 / 只看领过的」,所以各走一支 */
const fav = state.resCat === 'fav', got = state.resCat === 'got';
const keys = fav ? RES_ORDER.filter(k => state.resFav.indexOf(k) >= 0)
: got ? RES_ORDER.filter(isGot)
: 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 + ' 份资源'
: got ? '已领 ' + keys.length + ' 份 · 领过的资源随时回来这里取链接和提取码'
: '共 ' + keys.length + ' 份资源 · 更多内容按孕周陆续解锁')
: (fav ? '还没有收藏 · 打开任意资源,点右上角 ☆ 就能收进来'
: got ? '还没领过 · 打开任意资源,点底下「立即领取」就会记在这里'
: '该分类下暂无资源,换一个试试~');
$$('#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 isFav = state.resFav.indexOf(state.resKey) >= 0;
const star = $('#resStar');
star.classList.toggle('on', isFav);
star.textContent = isFav ? '★' : '☆';
/* 底部左键已从「收藏」改成「资源库」,是个固定文案的导航按钮,不再需要回显。 */
/* 和上面收藏同一个毛病:原来无条件重置成「立即领取」,
领过的资源再打开又变回没领过的样子。现在按 state.resGot 回显。
领过之后按钮写「再拿一次链接」而不是「已领取 ✓」—— 它还是能点的,
用户回来多半就是为了再看一眼提取码,写成完成态反而像被禁用了。 */
const got = isGot(state.resKey);
$('#rdGet').textContent = got ? '再拿一次链接' : '立即领取';
$('#rdGet').classList.toggle('got', got);
$('#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
}
});
/* 真正把一份资源交出去:填网盘弹窗 → 记进已领 → 刷列表和按钮 → 弹出来。
从事件处理器里整个抽出来,是因为「用兑换码解锁成功」之后要原地做完全
一样的事。抄一份过去当然更快,但那两份迟早会分叉:哪天改了领取行为
(比如加个埋点、换个 toast),只改得到眼前这条路,走闸门解锁的那批用户
拿到的还是旧行为,而且不报错、没人会发现。一个函数两处调,就没这回事。
参数收的是 key 而不是读 state.resKey:解锁那条路要给的是**当初被拦下的
那一份**,不是「详情页此刻停在哪一份」。 */
function givePan(key) {
const r = RES[key] || RES.book;
const p = panOf(key);
$('#panTitle').textContent = r.title.replace(/[《》]/g, '');
$('#panUrl').value = p.url;
$('#panCode').value = p.code;
/* 记进 state.resGot 并落盘,列表页那张卡就打上「已领 ✓」。
原来只给按钮加个 class,退出详情页就没了 —— 用户回到列表根本
看不出自己领过哪几份,只能一个个点进去确认。
重复点不再入库(已经领过就是再拿一次链接),renderResList 也就不用重画。 */
if (!isGot(key)) {
state.resGot.push(key);
saveResGot();
renderResList(state.resCat);
}
const btn = $('#rdGet');
btn.classList.add('got');
btn.textContent = '再拿一次链接';
openModal('modalPan');
haptic();
}
/* 点「立即领取」不再假装存进书架,而是把网盘链接和提取码给出来。
未解锁的用户先撞上企业微信二维码 —— 这是这一版加的那道闸门。 */
$('#rdGet').addEventListener('click', () => {
/* ⚠️⚠️ 这个 return 必须发生在 givePan 之前,也就是在 resGot.push 之前。
写反了(先记一笔再拦)会一次坏两处:
1. 这一份当场变成「已领」,用户关掉二维码再点一次,就从下面
`!isGot(key)` 那个免验证通道大摇大摆走过去 —— 闸门两下失效;
2. 列表页会给一份用户根本没拿到手的资料打上「已领 ✓」,
他去「已领」筛选里找,找到的是一份自己从没见过链接的东西。
所以:拦下的时候,一个字段都不许落盘。
`!isGot(key)` 是有意的 —— 闸门上线前就领过的老用户继续免验证,
已经给出去的东西不追回来(代价:那批人从此看不见这道闸门)。
⚠️ 判定用 isGot(key) 而不是 state.resGot.length:那样一来,领过任意
一份就等于全站解锁,闸门只在最开头拦一次。 */
const key = state.resKey;
if (!state.wxUnlocked && !isGot(key)) { openWx('gate', key); return; }
givePan(key);
});
/* ================= 企业微信顾问弹窗 =================
两个入口共用这一个弹窗:
gate —「我的资料被拦下了」,解锁成功后要接着把那份给他;
card —「我的」页那张服务卡主动点开的,没有待领的东西。
两条路的差别只有「解锁完之后做什么」,所以只用一个 wxPending 记着。 */
const wxCodeEl = $('#wxCode'), wxErrEl = $('#wxErr'), wxModal = $('#modalWx');
/* 被闸门拦下的那份资源 key。解锁成功后要用它把资料补给用户 ——
用户当时点的是「立即领取」,不是「解锁」;让他解锁完再回去点一遍,
等于把同一道摩擦向他收两遍。 */
let wxPending = null;
function wxErr(msg) {
wxErrEl.textContent = msg || '';
wxCodeEl.classList.toggle('is-err', !!msg);
}
/* 弹窗那句引导语里,把「全部解锁」四个字挑出来加重(用户点名要突出)。
⚠️ 不用 innerHTML。这几句将来极可能要拼小名(babyWord 到处在拼),
小名是用户自己敲进输入框的 —— 一旦它经 innerHTML 落地,那就是一条
现成的注入路径,而且是在「这句话看起来只是文案」的伪装下。
这里按纯文本切三段、中间那段包一个 <em>,拼进来什么都只会是字。
⚠️ 找不到那四个字时**不能**当异常处理:done 分支根本没有这句话,
直接整句塞回去就行,不要为了「一定要有高亮」去改文案。 */
const LEAD_HI = '全部解锁';
function setLead(el, text) {
el.textContent = '';
const i = text.indexOf(LEAD_HI);
if (i < 0) { el.textContent = text; return; }
el.appendChild(document.createTextNode(text.slice(0, i)));
const em = document.createElement('em');
em.className = 'lead-hi';
em.textContent = LEAD_HI;
el.appendChild(em);
el.appendChild(document.createTextNode(text.slice(i + LEAD_HI.length)));
}
/* 打开弹窗的**唯一**入口。所有和上一次打开有关的痕迹都在这里无条件清干净。
⚠️ 五样东西每次都要重写,尤其是 wxPending —— 只在 gate 分支给它赋值、
card 分支不管,会出这么一档事:闸门拦下《糖耐攻略》→ 用户按 Esc 关掉 →
过一会儿从「我的」页服务卡进来输码 → 弹出的是《糖耐攻略》的网盘链接,
他压根没要那份,而那份还被记成了「已领」。
⚠️ 清理只能放在**打开**这一侧:closeModal() 是通用的(遮罩、Esc、右上角叉、
openModal 开头那句都会走到它),它不认识 wxPending 也不该认识。 */
function openWx(mode, key) {
/* 默认清空,只有闸门才写。别改回 `mode === 'gate' ? (key||null) : null`
那种三目 —— 写成默认行为,是让「非闸门入口不可能带着待领资源」这条
不变量由结构保证,而不是靠某个分支记得写 null。
(诚实交待:gift 入口现在根本解不了锁,所以「串台」这个场景已经无法
复现,也就没有断言能量到这一行。它是结构性防线,不是被测行为。) */
wxPending = null;
if (mode === 'gate') wxPending = key || null;
/* 已经解锁过的人再点福利入口,不该再被要一次兑换码 —— 那格输入框对他
已经没有意义了(is-done 在 CSS 里把整个表单收起来)。
⚠️ 这句只可能把 gift 抬成 done:gate 只在未解锁时才被调用。
所以 done 的受众是 100%「已解锁的人来点试用装福利」,文案得照这个写。 */
const m = state.wxUnlocked ? 'done' : mode;
wxModal.classList.toggle('is-gate', m === 'gate');
wxModal.classList.toggle('is-gift', m === 'gift');
wxModal.classList.toggle('is-done', m === 'done');
setLead($('#wxLead'),
m === 'done' ? '资料已经解锁了。试用装每人限领一份,扫码找顾问登记,她会帮你安排。'
: m === 'gate' ? '扫码添加顾问,说一句「领资料」就会把兑换码发给你。填进下面这格,资源库里的资料就全部解锁。'
/* ⚠️ gift 里兑换码那一格是收起来的,所以这句**必须**把「码去哪儿填」讲明白。
去掉输入框省下的那道摩擦,代价就是用户拿到码之后不知道往哪儿去 ——
这句话是唯一补上这个断点的地方,别精简。
也别写成「填进下面这格」:这儿根本没有那一格,而且 smoke 里有条
断言正钉着这一点。 */
: '扫码添加顾问,说一句「领试用装」就帮你登记,每人限领一份。她也会把资源库的兑换码一并发给你——到资源库点「立即领取」时填进去。');
/* 右边那颗「解锁全部资料」只有闸门才有(is-done / is-gift 都被 CSS 收起来了)。
done 模式下左键是唯一出口,「以后再说」不合适 —— 事情已经办完,没有什么「以后」。
⚠️ gift 模式整条 .modal-foot 都被 `.is-gift .modal-foot{display:none}` 收起来了
(原来那颗「先不领」按用户要求删了:对着一句「免费领」说「先不领」,
像在替用户拒绝)。所以 gift 下这里赋什么值都不显示,留 '' 只是别留脏字。
gift 的退路是右上角那颗 .modal-x —— 删按钮之前专门确认过它在,
不然就把人关在弹窗里了。 */
$('#wxLater').textContent = m === 'done' ? '知道了' : m === 'gift' ? '' : '以后再说';
wxCodeEl.value = '';
wxErr('');
openModal('modalWx');
}
$('#meWxCard').addEventListener('click', () => openWx('gift'));
/* 资源库顶部那条 banner 和「我的」页服务卡是同一件事、同一个入口模式。
⚠️ 走 openWx('gift') 而不是挂 data-open="modalWx":那条通用通道直接调
openModal,会绕过上面那一整套重置。 */
$('#rlGift').addEventListener('click', () => openWx('gift'));
/* 一改就把红字收掉。让上一次的报错挂在那儿,用户会以为自己刚敲的这个也错了 */
wxCodeEl.addEventListener('input', () => wxErr(''));
wxCodeEl.addEventListener('keydown', e => { if (e.key === 'Enter') $('#wxOk').click(); });
$('#wxOk').addEventListener('click', () => {
/* ⚠️ 只有闸门模式才受理。别删,也别当成「CSS 都 display:none 了,多余」:
- jsdom 实测:display:none 的按钮 dispatchEvent 照样触发监听;
- 真机上今天确实点不到,但**被触发的后果比意外解锁更难看** ——
「兑换码不对」那行红字会冒在一个根本没有输入框的弹窗上,
用户看着一个无处可填的界面被指责填错了;
- 两条看起来无害的改动会立刻把它变成活 bug:把上面那条 display:none
换成 visibility:hidden(视觉一模一样,但仍可聚焦、仍能回车),
或者给弹窗套一层 <form>(回车默认提交)。
读 classList 而不是另立一个 wxMode 变量:模式本来就存在那儿,
openWx() 是唯一写入方,多一个变量就多一处会走神的真相。 */
if (!wxModal.classList.contains('is-gate')) return;
if (!wxCodeOK(wxCodeEl.value)) {
/* 码不对:给出红字、标红输入框、把光标送回去。
⚠️ 一个字段都不落盘 —— 不写 wxUnlocked、不动 resGot。 */
wxErr('兑换码不对,跟顾问再要一次?');
wxCodeEl.focus();
haptic();
return;
}
state.wxUnlocked = true;
saveWxUnlocked();
syncUnlockCopy();
/* 取出来就清空:这一份已经交待完了,不能留到下一次打开还生效 */
const key = wxPending;
wxPending = null;
if (key) givePan(key); // givePan 里的 openModal 会顺手把这个弹窗换掉
else closeModal();
/* 等弹窗切换的那段动画走完再冒 toast,否则它和正在上移的网盘弹窗叠在一起 */
setTimeout(() => toast('已解锁 · 资源库里的资料随时来取'), 260);
});
/* 未解锁时,两处「已为你解锁 N 份资源」是句谎话 —— 用户读完这句去点领取,
撞上的是一张二维码。所以锁着的时候换个说法,并且把「首次领取要加顾问」
提前说清楚,别等人点下去才发现。
⚠️ 首页那句只改 <span id="shelfLeadTxt"> 这一层,绝不能对整个 <p> 写
innerHTML:里面还嵌着「查看全部 ›」那个 <a data-open>,它的点击监听是
init 时 $$('[data-open]') 一次性绑上去的。整段重写会把节点连同监听一起
换掉,而且是**静默**的 —— 链接看着还是品牌色,就是点不动,控制台也不报错。 */
function syncUnlockCopy() {
$('#shelfLeadTxt').innerHTML = state.wxUnlocked
? '已为你解锁 <b>128</b> 份资源'
: '<b>128</b> 份孕育资料等你来领';
$('#rlLead').innerHTML = state.wxUnlocked
? '已为你解锁 <b>128</b> 份资源 · 按孕周持续更新'
: '<b>128</b> 份资源按孕周持续更新 · 首次领取需添加顾问微信';
/* 解锁过的人不再看到试用装 banner —— 他早就加过顾问了,这条对他是噪声。
⚠️ banner 的显隐**只有这一个 owner**。别在 renderResList 里再 toggle
一遍:两个地方管同一件事,迟早跑偏成「一边显一边隐」。
时机是够的:#rlGift 是静态标记,renderResList 只重写 #resList.innerHTML
和上面那排 chips,碰不到它;而 init() 在 load 时**同步**调了这个函数,
资源库面板必然在那之后才可能被打开 —— 没有「闪一下」的窗口。
⚠️ 刻意的不一致:banner 藏了,「我的」页那张服务卡**不藏**。
那是「我的」页的常驻入口,藏掉会缺一块。所以已解锁的人在「我的」页
仍会看到福利文案 —— 这可接受,前提是弹窗 done 那段文案把
「试用装限领一份」讲清楚(已经写了),动线才连得上。不是 bug。 */
$('#rlGift').classList.toggle('hide', state.wxUnlocked);
}
const panText = () => '链接:' + $('#panUrl').value + ' 提取码:' + $('#panCode').value;
$('#panCopy').addEventListener('click', () => {
const txt = panText();
// navigator.clipboard 需要安全上下文,微信 / 老安卓里常常没有,
// 兜底用 input.select() + execCommand,两条路都断了才提示手动长按复制
const fallback = () => {
const el = $('#panUrl');
try {
el.value = txt; el.removeAttribute('readonly');
el.select(); el.setSelectionRange(0, txt.length);
const done = document.execCommand && document.execCommand('copy');
el.setAttribute('readonly', 'readonly'); el.value = panOf(state.resKey).url;
el.blur();
toast(done ? '链接和提取码已复制' : '请长按上方链接手动复制');
} catch (e) {
el.setAttribute('readonly', 'readonly');
toast('请长按上方链接手动复制');
}
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(() => toast('链接和提取码已复制'), fallback);
} else fallback();
haptic();
});
$('#panOpen').addEventListener('click', () => {
const url = $('#panUrl').value;
// 先复制提取码再跳,省得用户到了网盘还要回来翻
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText($('#panCode').value).catch(() => {});
toast('提取码 ' + $('#panCode').value + ' 已复制,正在打开网盘');
window.open(url, '_blank', 'noopener');
haptic();
});
/* ================= 档案生成:周档案 / 完整档案 =================
产物必须是一张图(要能存相册、发朋友圈、拿去打印),所以直接用 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';
/* 导出层是两个面板共用的(档案海报 + 来信分享卡),所以「这批图叫什么」
不能再从 posterKind 推——加一组随 beginSheets() 一起设的标签。
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 就在名字里标上页码。
名字里那截「是什么」由 beginSheets() 时定下的 exportTag 决定 ——
分享卡和档案共用这个函数,别再从 posterKind 反推。 */
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 委托,两边的按钮共用同一段逻辑。
加新面板时只要在按钮上写 data-export,不用再复制一遍。 */
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();
};
/* ================= 来信分享卡 =================
把单独一封信做成一张能发出去的图,三套样式随便挑。
和上面的档案海报刻意分成两套渲染:档案的 posterData() 里有预产期、
体重、睡眠、完整年月日 —— 那是给自己留的,不是给朋友圈看的。
两边共用一个渲染器,「哪些字段不能上卡」就变成了散在各处的 if,
以后谁加个字段忘了补分支,预产期就印到卡上了,而且没人会发现。 */
const CARD_W = 750, CARD_PAD = 54;
/* 卡片底部固定占这么高:一条细线 + 两行落款 + 右下角的码。
正文排完之后按「画布高度 - CARD_FOOT」倒着画,所以模板可以有最小高度
(正文短的信不至于缩成一张名片),底部不会跟着飘。 */
const CARD_FOOT = 214;
/* ---------- 脱敏:白名单,不是黑名单 ----------
必须是白名单。stampOne()(本文件 533 行)在运行时才往信件对象上盖了
at / week / weekTxt / month / date / dateFull 六个字段 ——
光看 LETTERS 那个字面量根本看不见它们,写黑名单一定会漏。
故意没带上、且以后也不要带的:
L.thread —— 妈妈自己写的回信。这是整封信里最私人的东西,
放上卡就是事故,而且是发出去之后才知道的那种。
L.dateFull —— 「2024年9月3日 08:00」,带年份和钟点。只留 L.date(9月3日)。
L.month —— 同样带年份。
state.due —— 预产期。反推得出受孕时间,不该出现在朋友圈。
state.userName —— 妈妈的昵称。(原来这行还写着 userBio「签名」,
那个字段整块删掉了,不是忘了带。)
体重 / 睡眠 / 胎动次数 —— 健康数据,一条都不上卡。
L.id/back/time/preview/unread/fav/voice —— 和卡面无关。 */
function cardData(L) {
return {
title: L.title,
paras: L.paras.slice(),
week: L.week, // 24
weekTxt: L.weekTxt, // 「孕 24 周 +3 天」
date: L.date, // 「9月3日」—— 不带年
baby: state.babyName || '宝宝',
size: babySize(L.week) // 这一周多大:名字 / 克数 / 分类,都是查表来的公共知识
};
}
/* canvas 的 aria-label。一举两得:读屏能听,
而且这是测试里唯一能验证「卡上到底印了什么」的抓手 ——
jsdom 里 getContext 是 null,画笔那一趟根本不执行,验不到像素。 */
function cardAlt(C, tplName) {
return '分享卡(' + tplName + '样式):' + C.baby + '在' + C.weekTxt + '、' + C.date +
'写的信,标题「' + C.title + '」。' + C.paras.join('') +
' 落款:爱你的' + C.baby + '。右下角是小程序码的示意图案,暂不可扫。';
}
/* ---------- 右下角那枚码 ----------
项目里没有小程序 appid,也没有可跳转的链接,画不出真能扫的码。
与其糊一张假二维码让人扫了半天没反应,不如明说是示意图案 ——
卡面上和 cardAlt() 里都写着「暂不可扫」。等有了 appid,
把这个函数换成真的码生成即可,调用点不用动。
点阵用定死种子的 LCG,保证每次生成的图长得一样(不然同一封信
连点两次样式,右下角图案还会变,看着像出了 bug)。
⚠️ 乘数用 16807(MINSTD)不用常见的 1103515245:后者乘出来超过 2^53,
double 精度一丢,点阵会退化成肉眼可见的横条纹。 */
function drawQRStub(ctx, x, y, s) {
const N = 21, pad = Math.round(s * 0.085), cell = (s - pad * 2) / N;
ctx.fillStyle = '#fff'; rrect(ctx, x, y, s, s, 12); ctx.fill();
ctx.strokeStyle = '#EAD9CE'; ctx.lineWidth = 2; ctx.stroke();
const px = (c, r) => [x + pad + c * cell, y + pad + r * cell];
/* 三个定位角:外圈 7×7 实心,挖掉 5×5,再补回中间 3×3 */
[[0, 0], [N - 7, 0], [0, N - 7]].forEach(p => {
const a = px(p[0], p[1]);
ctx.fillStyle = '#3A2C24'; ctx.fillRect(a[0], a[1], cell * 7, cell * 7);
ctx.fillStyle = '#fff'; ctx.fillRect(a[0] + cell, a[1] + cell, cell * 5, cell * 5);
ctx.fillStyle = '#3A2C24'; ctx.fillRect(a[0] + cell * 2, a[1] + cell * 2, cell * 3, cell * 3);
});
let seed = 20240903;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
ctx.fillStyle = '#3A2C24';
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
if ((c < 8 && r < 8) || (c > N - 9 && r < 8) || (c < 8 && r > N - 9)) continue;
if (rnd() > 0.5) { const a = px(c, r); ctx.fillRect(a[0], a[1], cell, cell); }
}
}
/* 三套模板共用的页脚:一条细线 + 左边两行落款 + 右下角那枚码。
按画布实际高度倒着画,所以模板把内容排完就行,不用管自己有多高。 */
function drawCardFoot(ctx) {
const W = CARD_W, P = CARD_PAD, fy = ctx.canvas.height - CARD_FOOT;
ctx.strokeStyle = '#EFE1D6'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(P, fy); ctx.lineTo(W - P, fy); ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#8B6E5E'; ctx.font = pf(26, 700);
ctx.fillText('妈妈盒子', P, fy + 80);
ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 400);
ctx.fillText('每周一封,宝宝写给妈妈的信', P, fy + 122);
const qs = 118, qx = W - P - qs, qy = fy + 36;
drawQRStub(ctx, qx, qy, qs);
ctx.textAlign = 'center';
ctx.fillStyle = '#C6B3A6'; ctx.font = pf(18, 400);
ctx.fillText('示意图案 · 暂不可扫', qx + qs / 2, qy + qs + 26);
ctx.textAlign = 'left';
}
/* ---------- 模板一:信纸 ----------
ctx 为 null = 只量高不落笔,和档案海报同一套两趟排版。
⚠️ 所有算 y 的算术都必须写在 if (ctx) 外面 —— 测试里 getContext 返回 null,
落笔那一趟根本不跑,算错了只有量高这趟能替你发现。 */
function drawCardPaper(C, ctx) {
const W = CARD_W, P = CARD_PAD, CW = W - P * 2;
if (ctx) {
ctx.fillStyle = '#FFFCF8'; ctx.fillRect(0, 0, W, ctx.canvas.height);
const g = ctx.createLinearGradient(0, 0, W, 0);
g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FBA377');
ctx.fillStyle = g; ctx.fillRect(0, 0, W, 12);
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#B39C8E'; ctx.font = pf(24, 600);
ctx.fillText(C.weekTxt + ' · ' + C.date, P, 96);
}
let y = 130;
wrapCJK(C.title, CW, 46).forEach(ln => {
if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(46, 800); ctx.fillText(ln, P, y + 46); }
y += 64;
});
y += 26;
/* 手绘波浪线:和详情页 .dl-wave 是同一个意思,画成正弦波省一条 path */
if (ctx) {
ctx.strokeStyle = '#F3A88C'; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.beginPath();
for (let i = 0; i <= 180; i += 3) ctx.lineTo(P + i, y + Math.sin(i / 14) * 5);
ctx.stroke();
}
y += 44;
C.paras.forEach((p, i) => {
wrapCJK(p, CW, 28).forEach(ln => {
if (ctx) {
/* 每行底下压一条浅横线 —— 这就是「信纸」这套模板的全部把戏 */
ctx.strokeStyle = '#F3E8DE'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(P, y + 42); ctx.lineTo(W - P, y + 42); ctx.stroke();
ctx.fillStyle = '#4A403A'; ctx.font = pf(28, 400); ctx.fillText(ln, P, y + 31);
}
y += 50;
});
if (i < C.paras.length - 1) y += 18;
});
y += 48;
if (ctx) {
ctx.textAlign = 'right';
ctx.fillStyle = '#B0806A'; ctx.font = pf(27, 700);
ctx.fillText('爱你的' + C.baby, W - P, y + 27);
ctx.textAlign = 'left';
}
return y + 27 + 40;
}
/* ---------- 模板二:成长足迹 ----------
主角是「第几周」这个数字,配一条 4→40 周的进度条和一串脚印。 */
function drawCardSteps(C, ctx) {
const W = CARD_W, P = CARD_PAD, CW = W - P * 2;
if (ctx) {
ctx.fillStyle = '#FFF8F3'; ctx.fillRect(0, 0, W, ctx.canvas.height);
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#C08C72'; ctx.font = pf(24, 600);
ctx.fillText('成长足迹 · ' + C.date, P, 92);
ctx.fillStyle = '#2E241E'; ctx.font = pf(72, 800);
ctx.fillText('第 ' + C.week + ' 周', P, 178);
ctx.fillStyle = '#8B6E5E'; ctx.font = pf(26, 500);
ctx.fillText(C.weekTxt + ' · 我已经是' + C.size.name + '那么大了', P, 226);
}
let y = 268;
/* 进度条:4 周起步、40 周足月,位置就是这一段路走到哪儿了 */
const pct = Math.max(0, Math.min(1, (C.week - 4) / 36));
if (ctx) {
ctx.fillStyle = '#F2E2D6'; rrect(ctx, P, y, CW, 14, 7); ctx.fill();
ctx.fillStyle = '#F4593C'; rrect(ctx, P, y, Math.max(14, CW * pct), 14, 7); ctx.fill();
ctx.beginPath(); ctx.arc(P + CW * pct, y + 7, 15, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(P + CW * pct, y + 7, 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#C6B3A6'; ctx.font = pf(20, 400);
ctx.fillText('4 周', P, y + 48);
ctx.textAlign = 'right'; ctx.fillText('40 周 足月', W - P, y + 48); ctx.textAlign = 'left';
}
y += 78;
/* 一串脚印,越靠近今天颜色越实。最多八对,多了就成了排版噪音 */
const steps = Math.max(2, Math.min(8, Math.round((C.week - 4) / 4) + 1));
if (ctx) {
for (let i = 0; i < steps; i++) {
const fx = P + 26 + i * ((CW - 52) / Math.max(1, steps - 1));
footPrint(ctx, fx, y + 34 + (i % 2 ? 20 : 0), 11, 0.2 + 0.65 * (i / Math.max(1, steps - 1)));
}
}
y += 110;
wrapCJK(C.title, CW, 38).forEach(ln => {
if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(38, 800); ctx.fillText(ln, P, y + 38); }
y += 54;
});
y += 16;
C.paras.forEach((p, i) => {
wrapCJK(p, CW, 27).forEach(ln => {
if (ctx) { ctx.fillStyle = '#4A403A'; ctx.font = pf(27, 400); ctx.fillText(ln, P, y + 27); }
y += 46;
});
if (i < C.paras.length - 1) y += 16;
});
y += 40;
if (ctx) {
ctx.textAlign = 'right';
ctx.fillStyle = '#B0806A'; ctx.font = pf(27, 700);
ctx.fillText('爱你的' + C.baby, W - P, y + 27);
ctx.textAlign = 'left';
}
return y + 27 + 40;
}
/* 一只脚印:一个压扁的椭圆当脚掌 + 三颗脚趾。
用 save/scale/arc 拼椭圆,不用 ctx.ellipse —— 老 webview 上没有那个方法。 */
function footPrint(ctx, x, y, s, alpha) {
const col = 'rgba(244,89,60,' + alpha.toFixed(2) + ')';
ctx.save(); ctx.translate(x, y); ctx.scale(1, 1.35);
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(0, 0, s, 0, Math.PI * 2); ctx.fill();
ctx.restore();
ctx.fillStyle = col;
for (let i = -1; i <= 1; i++) {
ctx.beginPath(); ctx.arc(x + i * s * 0.72, y - s * 1.8, s * 0.3, 0, Math.PI * 2); ctx.fill();
}
}
/* ---------- 模板三:水果比大小 ----------
⚠️ 这里不用 SIZE 表里的 emoji。两个原因:
1) 上一批刚把站内最后两个实体 emoji 换成扁平线性图标,
这儿再画一个 🌽 就是自己拆自己的台;
2) canvas 里的 emoji 长什么样,取决于生成这张图那台手机的字体,
安卓、iOS、鸿蒙画出来是三个东西,还可能是豆腐块。图一旦发出去就改不了了。
所以形状自己画,只借 SIZE 的名字和克数。 */
const FRUIT_SHAPE = {
4: 'seed', 5: 'seed', 6: 'seed', 7: 'berry', 8: 'berry', 9: 'berry',
10: 'berry', 11: 'citrus', 12: 'berry', 13: 'citrus', 14: 'berry', 15: 'berry',
16: 'citrus', 17: 'berry', 18: 'berry', 19: 'citrus', 20: 'long', 21: 'long',
22: 'citrus', 23: 'long', 24: 'long', 25: 'leafy', 26: 'long', 27: 'leafy',
28: 'long', 29: 'berry', 30: 'leafy', 31: 'citrus', 32: 'leafy', 33: 'citrus',
34: 'citrus', 35: 'berry', 36: 'leafy', 37: 'long', 38: 'berry', 39: 'berry',
40: 'berry'
};
/* 五种扁平形状,都画在 (cx, cy) 上,r 是外接半径。只填色不描边,和站内图标一个调子 */
const FRUIT_DRAW = {
seed: (ctx, cx, cy, r) => {
ctx.fillStyle = '#8FBF7A';
ctx.beginPath(); ctx.arc(cx, cy + r * .3, r * .55, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#6FA85C';
ctx.beginPath(); ctx.moveTo(cx, cy - r * .1);
ctx.quadraticCurveTo(cx + r * .8, cy - r * .5, cx + r * .2, cy - r * .95);
ctx.quadraticCurveTo(cx - r * .1, cy - r * .5, cx, cy - r * .1); ctx.fill();
},
berry: (ctx, cx, cy, r) => {
ctx.fillStyle = '#F4735C';
ctx.beginPath(); ctx.arc(cx, cy + r * .12, r * .86, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.28)';
ctx.beginPath(); ctx.arc(cx - r * .3, cy - r * .18, r * .24, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#8A6A4F'; ctx.lineWidth = Math.max(3, r * .07); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(cx, cy - r * .7); ctx.lineTo(cx, cy - r * .98); ctx.stroke();
ctx.fillStyle = '#7FB86B';
ctx.beginPath(); ctx.moveTo(cx, cy - r * .92);
ctx.quadraticCurveTo(cx + r * .55, cy - r * 1.2, cx + r * .62, cy - r * .74);
ctx.quadraticCurveTo(cx + r * .3, cy - r * .68, cx, cy - r * .92); ctx.fill();
},
citrus: (ctx, cx, cy, r) => {
ctx.save(); ctx.translate(cx, cy); ctx.scale(1.18, .84);
ctx.fillStyle = '#F6A63F';
ctx.beginPath(); ctx.arc(0, 0, r * .88, 0, Math.PI * 2); ctx.fill();
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,.42)'; ctx.lineWidth = Math.max(3, r * .06);
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(cx - r * .6, cy - r * .3 + i * r * .3);
ctx.lineTo(cx + r * .6, cy - r * .3 + i * r * .3);
ctx.stroke();
}
},
long: (ctx, cx, cy, r) => {
const w = r * 1.0, h = r * 2.0;
ctx.fillStyle = '#F0B93E';
rrect(ctx, cx - w / 2, cy - h / 2 + r * .12, w, h, w / 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = Math.max(3, r * .07);
ctx.beginPath(); ctx.moveTo(cx, cy - h / 2 + r * .5); ctx.lineTo(cx, cy + h / 2 - r * .3); ctx.stroke();
ctx.fillStyle = '#7FB86B';
ctx.beginPath(); ctx.moveTo(cx, cy - h / 2 + r * .1);
ctx.quadraticCurveTo(cx + r * .7, cy - h / 2 - r * .35, cx + r * .1, cy - h / 2 - r * .5);
ctx.quadraticCurveTo(cx - r * .2, cy - h / 2 - r * .1, cx, cy - h / 2 + r * .1); ctx.fill();
},
leafy: (ctx, cx, cy, r) => {
ctx.fillStyle = '#8FC47A';
for (let i = -1; i <= 1; i++) {
ctx.beginPath(); ctx.arc(cx + i * r * .48, cy - r * .3, r * .5, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = '#EDF3E4';
ctx.beginPath(); ctx.arc(cx, cy + r * .22, r * .68, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#B7D8A4';
ctx.beginPath(); ctx.arc(cx, cy + r * .3, r * .4, 0, Math.PI * 2); ctx.fill();
}
};
function drawCardFruit(C, ctx) {
const W = CARD_W, P = CARD_PAD, CW = W - P * 2;
if (ctx) {
ctx.fillStyle = '#FFFBF5'; ctx.fillRect(0, 0, W, ctx.canvas.height);
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#C08C72'; ctx.font = pf(24, 600);
ctx.fillText('这一周,我有多大', W / 2, 96);
}
let y = 126;
/* 半径按体积开三次方,不是按克数线性来 —— 线性的话 1 克那周会小成一个点。
再垫一个 26 的底,最小的那几周不至于看不清。
用 Math.pow(x, 1/3) 而不是 Math.cbrt:后者是 ES6,这份代码要跑在老 webview 上。 */
const r = 26 + 124 * Math.pow(C.size.g / 3460, 1 / 3);
const areaH = Math.round(r * 2.9 + 40);
if (ctx) {
const cx = W / 2, cy = y + areaH / 2;
ctx.fillStyle = '#FDF1E6';
ctx.beginPath(); ctx.arc(cx, cy, Math.max(r * 1.28, 118), 0, Math.PI * 2); ctx.fill();
(FRUIT_DRAW[FRUIT_SHAPE[C.week] || 'berry'] || FRUIT_DRAW.berry)(ctx, cx, cy, r);
}
y += areaH + 30;
if (ctx) {
ctx.textAlign = 'center';
ctx.fillStyle = '#2E241E'; ctx.font = pf(52, 800);
ctx.fillText(C.size.name, W / 2, y + 52);
}
y += 52 + 24;
if (ctx) {
ctx.fillStyle = '#8B6E5E'; ctx.font = pf(26, 500);
ctx.fillText(C.weekTxt + ' · 差不多 ' + C.size.txt + ' 重', W / 2, y + 26);
ctx.textAlign = 'left';
}
y += 26 + 46;
if (ctx) {
ctx.strokeStyle = '#F2E4D8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke();
}
y += 40;
wrapCJK(C.title, CW, 36).forEach(ln => {
if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(36, 800); ctx.fillText(ln, P, y + 36); }
y += 52;
});
y += 14;
C.paras.forEach((p, i) => {
wrapCJK(p, CW, 27).forEach(ln => {
if (ctx) { ctx.fillStyle = '#4A403A'; ctx.font = pf(27, 400); ctx.fillText(ln, P, y + 27); }
y += 46;
});
if (i < C.paras.length - 1) y += 16;
});
y += 40;
if (ctx) {
ctx.textAlign = 'right';
ctx.fillStyle = '#B0806A'; ctx.font = pf(27, 700);
ctx.fillText('爱你的' + C.baby, W - P, y + 27);
ctx.textAlign = 'left';
}
return y + 27 + 40;
}
/* 做成注册表而不是 if/else:加第四套样式只要往数组里再推一项,
chip、渲染、测试都自动跟着走。
floor = 最小成图高度,正文特别短的信不至于缩成一张名片。 */
const CARD_TPL = [
{ key: 'paper', name: '信纸', floor: 1000, draw: drawCardPaper },
{ key: 'steps', name: '成长足迹', floor: 1060, draw: drawCardSteps },
{ key: 'fruit', name: '比大小', floor: 940, draw: drawCardFruit }
];
let cardTpl = 'paper';
function renderCard(key) {
const tpl = CARD_TPL.filter(t => t.key === key)[0] || CARD_TPL[0];
cardTpl = tpl.key;
const C = cardData(curLetter());
const host = $('#cdPages');
beginSheets(host, '来信-孕' + C.week + '周', C.baby + '写给妈妈的信');
/* 两趟:先拿 null 量高(此时 ctx 还不存在,画布也还没撑起来),再照这个高度画 */
const h = Math.max(tpl.floor, Math.round(tpl.draw(C, null)) + CARD_FOOT);
addSheet(host, CARD_W, h, cardAlt(C, tpl.name), c => { tpl.draw(C, c); drawCardFoot(c); });
$$('#cdTpl .chip').forEach(b => {
const on = b.dataset.tpl === cardTpl;
b.classList.toggle('is-active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
});
/* 说明卡上印了什么的那排信息胶囊(小名/孕周/日期/成图)撤了,不再补回来 ——
样式胶囊已经占了一排,再来一排长得差不多的东西,面板就变成一堆标签了。
「卡上只有什么」这件事由下面这句话说清楚,一句顶一排。 */
$('#cdLead').textContent = '卡上只有小名、孕周和这封信本身。预产期、体重这些都不会出现。';
$('#cdTip').textContent = !posterURLs.length
? '当前浏览器画不了图片,换个浏览器打开就能保存和分享了。'
: '点「保存图片」会叫出系统面板,选「存储图像 / 保存到相册」就进相册了。'
+ '微信里没有这个面板,长按上面这张图存。右下角那枚码目前只是示意图案,还扫不出东西。';
}
/* chip 在这里一次性建好,renderCard() 只负责切 is-active */
CARD_TPL.forEach(t => {
const b = el('button', 'chip', t.name);
b.dataset.tpl = t.key;
b.setAttribute('role', 'tab');
b.addEventListener('click', () => { renderCard(t.key); haptic(); });
$('#cdTpl').appendChild(b);
});
$('#dlCard').addEventListener('click', function () {
renderCard(cardTpl); // 记住上次选的样式,别每次都跳回信纸
openPanel('detailCard', this);
});
/* ================= 搜索层 ================= */
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', 'letter', '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 (detail.classList.contains('show')) { closeDetail(); return; }
if (sheet.classList.contains('show')) closeSheet();
});
/* ================= 初始化 ================= */
function init() {
/* 默认预产期是「今天 + 3 个月」现算出来的,标记里那些
「孕 24 周 +3 天 / 预产期 2024年12月22日」只是设计稿留下的占位,
进门先按真实 state 刷一遍,免得首屏闪一下旧数字 */
syncPregnancy();
/* ⚠️ 这里原来还有一次 syncBio()。一句话签名整块已删(见 state 里那段说明)。 */
/* 解锁状态是从 localStorage 读回来的,标记里写的是「没解锁」那一版。
老用户(上次已经输过码)不刷这一下,会在首页看到「还没领」的说法,
点进去却畅通无阻 —— 文案和实际能力对不上。 */
syncUnlockCopy();
animateRings();
$('#tabBadge').classList.toggle('show', state.unread > 0);
/* 来信页的默认标签:永远落在「全部」。
曾经改成过「有未读就落在未读」,但那样一进页面就看不到全部信件,
未读的红点本来就在提示了,不需要再替人把列表筛一遍。
白滑块的位置是 initSegmented 在 rAF 里读 .seg-btn.is-active 算的,
这里是同步改的类名,它跟得上,所以进页面时滑块直接就在第一格,
不会有一段从别处滑过来的动画。
别改回硬写 filterLetters('all'):那样会绕过 state.letterFilter,
盖掉别处对它的任何设定。这里是先定 state,再按 state 过滤。 */
state.letterFilter = 'all';
const lsegBtn = $('#letterSeg [data-lseg="' + state.letterFilter + '"]');
$$('#letterSeg .seg-btn').forEach(b => b.classList.toggle('is-active', b === lsegBtn));
filterLetters(state.letterFilter);
// 让分段控件在首次布局后归位
setTimeout(() => { $('#archiveSeg')._reflow && $('#archiveSeg')._reflow(); $('#letterSeg')._reflow && $('#letterSeg')._reflow(); }, 60);
/* 最后再拉远程档案:必须在 init() 渲染完之后异步覆盖 —— 若在脚本解析时就拉,
fetch 的 then 往往比 init() 先跑,随后 init() 又会把 state 重置回默认值,
档案数据被覆盖。放在 init 末尾,等 fetch then 回来时再回刷展示。 */
applyRemoteProfile();
// 第二批远程数据(资料库/记录/内容)—— 随 init 一起拉,失败回退内置
applyRemoteAll();
// 第三批远程数据(宝宝来信)—— 同样随 init 一起拉,失败回退内置样例
applyRemoteLetters();
}
/* ---------- 远程配置(box_app 后台下发) ----------
本页托管在 box_app(/mama-box/)时,启动后拉一次运营配置:
PAN / PAN_DEFAULT 网盘链接表和 WX_CODE 兑换码都由后台统一配。
拉不到 —— 静态托管在别处 / 断网 / 接口挂了 —— 就用上面写死的内置值,
本页「单文件离线可用」的性质不受影响。
⚠️ PAN 的覆盖是「整表替换」不是逐项合并:后台删掉某条链接的语义就是
「这条不配了,走 PAN_DEFAULT」,逐项合并会让删除永远删不掉。
⚠️ 竞态说明:fetch 是异步的,回来之前用户可能已经点开过详情页——
那一次看到的是内置值,回来之后的都走新值。panOf()/wxCodeOK()
是实时读表的,不需要重新渲染。 */
(function fetchRemoteConfig() {
fetch('/api/mp/public/mama-box/config')
.then(r => (r.ok ? r.json() : null))
.then(j => {
if (!j || j.code !== 200 || !j.result) return;
const c = j.result;
if (c.isEnabled === false) return;
Object.keys(PAN).forEach(k => { delete PAN[k]; });
if (c.pan && typeof c.pan === 'object') {
Object.keys(c.pan).forEach(k => {
const p = c.pan[k];
if (p && p.url) PAN[k] = { url: String(p.url), code: String(p.code || '') };
});
}
if (c.panDefault && c.panDefault.url) {
PAN_DEFAULT.url = String(c.panDefault.url);
PAN_DEFAULT.code = String(c.panDefault.code || '');
}
if (c.wxCode) WX_CODE = String(c.wxCode);
})
.catch(() => { /* 静默回退内置值 */ });
})();
/* ---------- 远程个人档案(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');
if (!openid) 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 = $('#meLogoutBtn'); 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 已写完引导占位(含信件/称呼/静态位归零)。
⚠️ 下面 stampLetters/filterLetters/syncStaticDates 全按 state.week 重算,
而 pregEmpty 时 state.week 还是内置默认 27 —— 不拦住的话,
刚写好的占位会被改回「孕 27 周 / 9月15日」演示值。 */
if (state.pregEmpty) { $('#tabBadge').classList.remove('show'); return; }
syncBabyWords();
stampLetters();
filterLetters(state.letterFilter);
syncUnlockCopy();
syncStaticDates();
if (state.unread > 0) $('#tabBadge').classList.toggle('show', true);
}).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; RES[it.key].got = !!it.got;
});
// 同步收藏/已领状态(对齐后端)
state.resFav = items.filter(function (i) { return i.fav; }).map(function (i) { return i.key; });
state.resGot = items.filter(function (i) { return i.got; }).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 下发,openid 单一身份键) ----------
覆盖上面内置的 LETTERS 样例:后台按 openid 在 MongoDB 里播种 l1..l4,
回信 / 语音 / 未读 / 收藏都持久化在那边。这里先拉「列表」,再并行拉
「每一封的详情」,把每封映射成引擎的信件形状后**原地替换** LETTERS
(const 数组,用 splice 保住引用),列表 / 详情 / 未读数都从后台数据渲染。
⚠️ 映射时 back 一律置 null:stampLetters() 见到 back==null 就跳过,
这样后台下发的 at/week/date/dateFull 才不会被客户端重新盖掉。
拉不到 —— 无 openid / 接口挂 / 返回码不对 —— 就静默回落内置样例,页面照常可用。 */
function applyRemoteLetters() {
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; }); };
j(base + 'letters?openid=' + encodeURIComponent(openid)).then(function (lj) {
if (!lj || lj.code !== 200 || !lj.result || !lj.result.items) return;
/* 新用户空态:后端成功返回空列表 → 清掉内置样例信,展示「这里还没有内容」。
⚠️ 不能直接 return —— 那会把 LETTERS 留在内置样例上,新用户看到演示信。 */
if (!lj.result.items.length) {
LETTERS.splice(0, LETTERS.length);
state.curLetter = '';
state.homeLetter = '';
renderLetters();
syncLetterCounts();
pushLatestToHome();
return;
}
Promise.all(lj.result.items.map(function (it) {
return j(base + 'letters/' + encodeURIComponent(it.key) + '?openid=' + encodeURIComponent(openid));
})).then(function (dets) {
var apiList = lj.result.items.map(function (it, i) {
var det = (dets[i] && dets[i].code === 200 && dets[i].result) ? dets[i].result : {};
return {
id: it.key, back: null, time: null, title: it.title, preview: it.preview,
paras: (det.paras && det.paras.length ? det.paras : [it.preview]),
unread: !!it.unread, fav: !!it.fav, voice: !!it.voice,
thread: (det.thread || []).map(function (m) {
return { who: m.who, text: m.text, at: m.at || 0, back: null, when: m.at ? fmtWhen(m.at) : '' };
}),
at: it.at || 0, week: it.week, weekTxt: it.weekTxt || '', date: it.date || '', dateFull: it.dateFull || '',
month: (it.at ? (function (d) { return d.getFullYear() + '年 ' + (d.getMonth() + 1) + '月'; })(new Date(it.at)) : (it.dateFull || '').slice(0, (it.dateFull || '').indexOf('月') + 1))
};
});
try {
LETTERS.splice(0, LETTERS.length, ...apiList);
state.curLetter = LETTERS[0].id;
state.homeLetter = LETTERS[0].id;
renderLetters();
syncLetterCounts();
pushLatestToHome();
} catch (e) { /* 映射 / 渲染异常就回退内置样例 */ }
}).catch(function () { /* 静默回退内置样例 */ });
}).catch(function () { /* 静默回退内置样例 */ });
}
/* ---------- 进商城(web-view 内跳商城原生页) ----------
妈妈盒子 H5 由小程序 web-view 承载;点「商城」跳回商城原生页。
需微信 JS-SDK 的 wx.miniProgram.switchTab —— tabBar 页必须用 switchTab
(详见 docs/mama-box-api-plan.md 2.4)。
仅在 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) {
window.wx.miniProgram.switchTab({ url: '/pages/goods_cate/goods_cate' });
} else {
toast('请在微信小程序中打开商城');
}
});
setTimeout(function () {
if (!responded) toast('请在微信小程序中打开商城');
}, 800);
} else {
toast('请在微信小程序中打开商城');
}
}
$$('#btnMall, #meMallCard').forEach(function (b) {
b.addEventListener('click', goMall);
});
/* 「授权完善资料」:H5 → navigateTo → 小程序端独立授权页(pages/annex/auth/index)。
说明:微信 web-view 的 postMessage 非实时送达(只在分享/后退/onHide 等时机
才派发 bindmessage),点按钮时端上收不到 → 改用 wx.miniProgram.navigateTo
实时跳转到授权页,避开该限制。 */
function goAuth() {
haptic();
if (window.wx && window.wx.miniProgram) {
window.wx.miniProgram.navigateTo({ url: '/pages/annex/auth/index' });
} 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 = $('#meLogoutBtn');
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);
})();