feat(首页): 新增「孕期小知识」和「妈妈的树洞」两个模块 (v26093013)

孕期小知识(工具区下方):
- 按真实孕周 + 天数显示「今日小知识」大卡,右上角本周水果 emoji 缓慢浮动;
  底部 7 个圆点 = 本周第 1–7 天,今天高亮呼吸、以前可回看、以后锁着(明天见)。
- 四张马卡龙小卡:宝宝这周(带大小比喻)/ 妈妈身体 / 吃点什么 / 这周留心,进入视口依次浮上来。
- 内容 assets/data/preg-tips.json:孕 4–40 周,每周 4 句 + 每天 1 条,共 259 条,按需 fetch。
- 只在 /pregnancy 确认真实孕周后显示(pregEmpty === false 且非产后),不拿默认孕周配内容。

妈妈的树洞(今日脚印下方):
- 心事只存本机 localStorage(mmbox.treehole.v1,最多 200 条),不上传;按用户选定不做回应,
  只有纸团飞进树洞 + 树冠晃动 + 「已收好」动画;可展开「我的心事」回看、删除。
- 出现明显轻生字眼时,在「已收好」下加一行 12356 心理援助热线(兜底);卡底常驻一行求助提示。

动效在 prefers-reduced-motion 下关闭。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 13:23:00 +00:00
parent 9ea9f3c50f
commit 9a1ad1fb93
4 changed files with 364 additions and 4 deletions

178
js/app.js
View File

@@ -3,7 +3,7 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26093012';
var VER = '26093013';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -1050,6 +1050,8 @@
function syncPregnancy() {
/* 孕期每日小打卡:产后整块收起;其它状态都显示(标签默认是中性提示,不依赖孕周) */
const _dc = $('#dailyCheck'); if (_dc) _dc.classList.toggle('hide', !!state.postpartum);
/* 孕期小知识:只认真实孕周(自己判显隐,见 renderTips) */
renderTips();
/* ---- 产后态:宝宝已出生 → 一律不显示孕周数字(2026-09-22) ----
⚠️ 必须放在 pregEmpty 之前:产后返回体的 week 也是 null,
落到空态分支会给**已经生产**的用户弹「完善孕期信息」引导。
@@ -3107,6 +3109,180 @@
}
}
/* ================= 孕期小知识(2026-09-30) =================
内容 assets/data/preg-tips.json:[{week, baby, mom, eat, care, days:[{t,d}×7]}],孕 4–40 周。
第几天 = state.day(0–6),和首页「孕 N 周 D 天」同一口径。
⚠️ 只在 state.pregEmpty === false(/pregnancy 已确认有孕周)且非产后时显示:
pregEmpty 初值是 undefined —— 档案没回来之前 state.week 是 DEFAULT_DUE 推出来的假孕周,
拿它配知识就是给人看假数据。所以这里用「=== false」而不是「!pregEmpty」。 */
let TIPS = null, tipsLoading = null, tipSel = -1;
function loadTips() {
if (!tipsLoading) {
tipsLoading = fetch('assets/data/preg-tips.json?v=' + VER)
.then(r => (r.ok ? r.json() : null))
.then(arr => {
const m = {};
(arr || []).forEach(x => { if (x && x.week) m[x.week] = x; });
TIPS = m;
})
.catch(() => { TIPS = {}; });
}
return tipsLoading;
}
function renderTips() {
const sec = $('#pregTips');
if (!sec) return;
if (state.pregEmpty !== false || state.postpartum) { sec.classList.add('hide'); return; }
if (!TIPS) { loadTips().then(renderTips); return; }
const w = Math.max(4, Math.min(40, state.week || 0));
const wk = TIPS[w];
if (!wk || !(wk.days || []).length) { sec.classList.add('hide'); return; }
const today = Math.max(0, Math.min(6, state.day || 0));
const size = babySize(w);
$('#ptWhen').textContent = '孕' + state.week + '周' + (state.day ? state.day + '天' : '');
$('#ptFloat').textContent = size.emoji;
$('#ptBabyIco').textContent = size.emoji;
$('#ptBaby').textContent = '像' + size.name + '那么大。' + (wk.baby || '');
$('#ptMom').textContent = wk.mom || '';
$('#ptEat').textContent = wk.eat || '';
$('#ptCare').textContent = wk.care || '';
/* 一周 7 个圆点:以前可回看,今天实心,以后锁着 */
const box = $('#ptDays');
box.textContent = '';
for (let i = 0; i < 7; i++) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'pt-day ' + (i < today ? 'is-past' : i === today ? 'is-today' : 'is-lock');
b.setAttribute('role', 'tab');
const n = document.createElement('i'); n.textContent = i + 1;
const t = document.createElement('span');
t.textContent = i === today ? '今天' : (i === today + 1 ? '明天' : '\u00a0'); // 圈里已是「第几天」,只给今天/明天配字
b.appendChild(n); b.appendChild(t);
b.addEventListener('click', () => {
if (i > today) { toast(i === today + 1 ? '明天再来看新的小知识~' : '到那天再来看哦~'); return; }
showTip(wk, i, today);
});
box.appendChild(b);
}
tipSel = -1;
showTip(wk, today, today);
sec.classList.remove('hide');
revealTipsGrid();
}
function showTip(wk, i, today) {
if (i === tipSel) return;
tipSel = i;
const d = wk.days[i] || wk.days[0];
$('#ptBadge').textContent = i === today ? '今日小知识' : '第 ' + (i + 1) + ' 天 · 回看';
$('#ptT').textContent = d.t || '';
$('#ptD').textContent = d.d || '';
$$('#ptDays .pt-day').forEach((b, k) => {
b.classList.toggle('is-sel', k === i);
b.setAttribute('aria-selected', k === i ? 'true' : 'false');
});
const body = $('#ptBody');
body.classList.remove('is-in'); void body.offsetWidth; body.classList.add('is-in'); // 重播进场动画
}
function revealTipsGrid() {
const g = $('#ptGrid');
if (!g || g.classList.contains('is-shown')) return;
if (!('IntersectionObserver' in window)) { g.classList.add('is-shown'); return; }
const io = new IntersectionObserver(es => {
if (es.some(e => e.isIntersecting)) { g.classList.add('is-shown'); io.disconnect(); }
}, { threshold: 0.2 });
io.observe(g);
}
/* ================= 妈妈的树洞(2026-09-30) =================
用户选定:心事**只存在本机**(localStorage,不上传),扔进去后只有「已收好」动画、不回应。
唯一例外:出现明显轻生字眼时,在「已收好」下面多一行援助热线 —— 兜底,不是回应。 */
(function treeHole() {
const KEY = 'mmbox.treehole.v1', MAX = 200;
const ta = $('#thText'), send = $('#thSend'), list = $('#thList'), done = $('#thDone');
if (!ta || !send) return;
const SOS = /想死|不想活|自杀|轻生|结束生命|活不下去|去死|割腕|跳楼/;
const load = () => { try { const a = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(a) ? a : []; } catch (e) { return []; } };
const save = a => { try { localStorage.setItem(KEY, JSON.stringify(a.slice(0, MAX))); return true; } catch (e) { return false; } };
const pad = n => (n < 10 ? '0' : '') + n;
const when = ts => { const d = new Date(ts); return (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); };
function count() { $('#thCount').textContent = load().length; }
function renderList() {
const a = load();
list.textContent = '';
if (!a.length) {
const li = document.createElement('li'); li.className = 'th-empty'; li.textContent = '树洞还是空的';
list.appendChild(li); return;
}
a.forEach(e => {
const li = document.createElement('li');
const t = document.createElement('time'); t.textContent = when(e.t);
const p = document.createElement('p'); p.textContent = e.x;
const del = document.createElement('button'); del.className = 'th-del'; del.type = 'button';
del.setAttribute('aria-label', '删除这条'); del.textContent = '×';
del.addEventListener('click', () => {
if (!window.confirm('删掉这条心事?删了就找不回来了')) return;
save(load().filter(x => x.t !== e.t)); renderList(); count();
});
li.appendChild(t); li.appendChild(p); li.appendChild(del);
list.appendChild(li);
});
}
function showDone(sos) {
done.classList.toggle('is-sos', !!sos);
done.textContent = '已收好 🍃';
if (sos) {
const s = document.createElement('div'); s.className = 'th-sos';
s.textContent = '如果你现在很难受,请告诉身边的人,或拨打 12356 心理援助热线,有人愿意听你说。';
done.appendChild(s);
}
done.classList.add('is-on');
clearTimeout(showDone._t);
if (!sos) showDone._t = setTimeout(() => done.classList.remove('is-on'), 2600);
}
/* 纸团飞进树洞:从输入框中间出发,弧线落到树洞,边转边缩小 */
function fly(text, cb) {
const hole = $('#thHole'), tree = $('#thTree');
const note = document.createElement('div');
if (!hole || !note.animate) { cb(); return; }
note.className = 'th-note'; note.textContent = text;
document.body.appendChild(note);
const a = ta.getBoundingClientRect(), h = hole.getBoundingClientRect();
const nw = note.offsetWidth, nh = note.offsetHeight;
const x0 = a.left + a.width / 2 - nw / 2, y0 = a.top + a.height / 2 - nh / 2;
const x1 = h.left + h.width / 2 - nw / 2, y1 = h.top + h.height / 2 - nh / 2;
const xm = (x0 + x1) / 2, ym = Math.min(y0, y1) - 70;
const anim = note.animate([
{ transform: 'translate(' + x0 + 'px,' + y0 + 'px) scale(1) rotate(0)', opacity: 1, borderRadius: '10px' },
{ transform: 'translate(' + xm + 'px,' + ym + 'px) scale(.45) rotate(200deg)', opacity: 1, borderRadius: '50%', offset: .55 },
{ transform: 'translate(' + x1 + 'px,' + y1 + 'px) scale(.08) rotate(420deg)', opacity: .2, borderRadius: '50%' }
], { duration: 950, easing: 'cubic-bezier(.45,.05,.4,1)', fill: 'forwards' });
anim.onfinish = () => {
note.remove();
if (tree) { tree.classList.remove('is-eat'); void tree.getBoundingClientRect(); tree.classList.add('is-eat'); setTimeout(() => tree.classList.remove('is-eat'), 1000); }
cb();
};
}
const sync = () => { send.disabled = !ta.value.trim(); };
ta.addEventListener('input', sync);
send.addEventListener('click', () => {
const x = ta.value.trim();
if (!x) return;
const a = load(); a.unshift({ t: Date.now(), x: x });
if (!save(a)) { toast('手机存储空间不足,没能收好'); return; }
ta.value = ''; sync(); ta.blur();
fly(x, () => {
showDone(SOS.test(x));
count();
if (!list.classList.contains('hide')) renderList();
});
});
$('#thMine').addEventListener('click', () => {
const open = list.classList.toggle('hide') === false;
if (open) renderList();
});
sync(); count();
})();
/* 拉卡片数据(与其它远程数据一起打;失败静默,卡上保留现有值) */
function applyRemoteCardsFetch() {
const openid = hashParam('openid');