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:
178
js/app.js
178
js/app.js
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user