feat(首页): 此刻卡换应用 logo / 树洞星夜版 / 孕期小知识带孕周并压缩 (v26100102)

- 此刻:今日三项与动态的图标换成对应应用的 logo(取 App 表 logo,后台换即生效);
  消消乐、盲盒用本站同风格 logo(assets/img/429ffcba0d6e.svg、b0087c3c012a.svg)
- 妈妈的树洞重做为星夜版:深夜天幕 + 剪影树 + 旋转发光的漩涡树洞;每条心事化成天上一颗星(位置按时间戳固定);
  扔进去时字逐个被吸进漩涡、洞口爆亮、新星射上天空;整块比旧版更矮。心事仍只存本机,热线兜底不变
- 孕期小知识:标题改「孕 N 周 · 今日孕期小知识」,右侧「本周第 N 天」;今日卡与圆点收紧,四张小卡改一行横滑(点开看全文)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 02:10:13 +00:00
parent c414a7ff8f
commit 70c9786f15
5 changed files with 284 additions and 110 deletions

163
js/app.js
View File

@@ -3,7 +3,7 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26100101';
var VER = '26100102';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -3100,6 +3100,16 @@
game: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2.5" fill="currentColor"/><rect x="13" y="3" width="8" height="8" rx="2.5" fill="currentColor" opacity=".6"/><rect x="3" y="13" width="8" height="8" rx="2.5" fill="currentColor" opacity=".6"/><rect x="13" y="13" width="8" height="8" rx="2.5" fill="currentColor"/></svg>',
box: '<svg viewBox="0 0 24 24"><rect x="4" y="11" width="16" height="10" rx="2" fill="currentColor"/><rect x="2.5" y="7.5" width="19" height="4.5" rx="1.6" fill="currentColor"/><path d="M12 7.5V21" stroke="#fff" stroke-width="2"/><path d="M12 7.5C10 3.5 6 4 7.5 6.6 8.3 7.8 10.4 7.6 12 7.5zM12 7.5c2-4 6-3.5 4.5-.9-.8 1.2-2.9 1-4.5.9z" fill="currentColor"/></svg>'
};
/* 2026-10-01 用户要求:图标一律换成对应应用的 logo(数胎动就用数胎动的 logo)。
logo 取 App 表(APP_BY_ID[identifier].logo,后台换 logo 即生效);应用列表还没回来时先用上面的单色图标顶一下。
消消乐、盲盒不在 App 表里 → 用本站 assets/img 里的同风格 logo。 */
const PULSE_APP = { kick: 'taidong', weight: 'yunqitizhong', cal: 'chanjianrili' };
const PULSE_LOGO = { game: 'assets/img/429ffcba0d6e.svg', box: 'assets/img/b0087c3c012a.svg' };
function pulseIco(k, cls) {
const a = APP_BY_ID[PULSE_APP[k]], src = (a && a.logo) || PULSE_LOGO[k];
return '<span class="' + cls + (src ? ' is-logo' : '') + '">' +
(src ? '<img class="logo" src="' + String(src).replace(/"/g, '&quot;') + '" alt="">' : (PULSE_ICO[k] || PULSE_ICO.box)) + '</span>';
}
const fmtN = n => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
/* 数字补间:el.dataset.v 记当前值,从它平滑走到 to;首次(无 dataset.v)从 0 起 */
function tweenNum(el, to, dur) {
@@ -3128,8 +3138,8 @@
const t = String(f.text || ''), i = t.indexOf('的妈妈 ');
const who = i > 0 ? t.slice(0, i + 3) : '一位妈妈', what = i > 0 ? t.slice(i + 4) : t;
const esc = s => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
return '<div class="pl-row a-' + (PULSE_ICO[f.act] ? f.act : 'box') + '"><span class="pr-ico">' + (PULSE_ICO[f.act] || PULSE_ICO.box) +
'</span><span class="pr-t"><span class="pr-who">' + esc(who) + ' · ' + esc(f.ago || '刚刚') + '</span><span class="pr-do">' +
return '<div class="pl-row a-' + (PULSE_ICO[f.act] ? f.act : 'box') + '">' + pulseIco(PULSE_ICO[f.act] ? f.act : 'box', 'pr-ico') +
'<span class="pr-t"><span class="pr-who">' + esc(who) + ' · ' + esc(f.ago || '刚刚') + '</span><span class="pr-do">' +
esc(what).replace(/(\d+)/g, '<em>$1</em>') + '</span></span></div>';
}
function tick() {
@@ -3158,11 +3168,13 @@
}
const td = $('#plToday'), items = r.today || [];
td.classList.toggle('hide', !items.length);
const keys = items.map(x => x.key).join(',');
const keys = items.map(x => x.key).join(',') + (APP_BY_ID.taidong ? '|L' : ''); // 应用 logo 到了再重画一次
if (td.dataset.keys !== keys) {
td.dataset.keys = keys;
td.innerHTML = items.map(x => '<button class="pl-stat k-' + x.key + '" type="button" data-k="' + x.key + '"><span class="ps-ico">' +
(PULSE_ICO[x.key] || PULSE_ICO.box) + '</span><b>0</b><span>' + String(x.label || '').replace(/[<>&]/g, '') + '</span><i class="go">›</i></button>').join('');
const was = {}; // logo 到了重画时,数字接着上次的值走,不从 0 再数一遍
$$('.pl-stat', td).forEach(b => { const n = b.querySelector('b'); if (n && n.dataset.v != null) was[b.dataset.k] = n.dataset.v; });
td.innerHTML = items.map(x => '<button class="pl-stat k-' + x.key + '" type="button" data-k="' + x.key + '">' +
pulseIco(x.key, 'ps-ico') + '<b' + (was[x.key] != null ? ' data-v="' + was[x.key] + '">' + fmtN(+was[x.key]) : '>0') + '</b><span>' + String(x.label || '').replace(/[<>&]/g, '') + '</span><i class="go">›</i></button>').join('');
}
if (seen) items.forEach(x => tweenNum(td.querySelector('[data-k="' + x.key + '"] b'), x.value, 1600));
feed = r.feed || [];
@@ -3499,7 +3511,8 @@
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 + '天' : '');
$('#ptWk').textContent = '孕 ' + state.week + ' 周';
$('#ptWhen').textContent = '本周第 ' + (today + 1) + ' 天';
$('#ptFloat').textContent = size.emoji;
$('#ptBabyIco').textContent = size.emoji;
$('#ptBaby').textContent = '像' + size.name + '那么大。' + (wk.baby || '');
@@ -3543,6 +3556,11 @@
const body = $('#ptBody');
body.classList.remove('is-in'); void body.offsetWidth; body.classList.add('is-in'); // 重播进场动画
}
/* 四张小卡压成横滑后正文只露 3 行,点一下展开全文、再点收起 */
(function () {
const g = $('#ptGrid');
if (g) g.addEventListener('click', e => { const c = e.target.closest('.pt-card'); if (c) c.classList.toggle('is-open'); });
})();
function revealTipsGrid() {
const g = $('#ptGrid');
if (!g || g.classList.contains('is-shown')) return;
@@ -3553,24 +3571,53 @@
io.observe(g);
}
/* ================= 妈妈的树洞(2026-09-30) =================
/* ================= 妈妈的树洞 · 星夜版(2026-09-30 首版,2026-10-01 重做 UI) =================
用户选定:心事**只存在本机**(localStorage,不上传),扔进去后只有「已收好」动画、不回应。
唯一例外:出现明显轻生字眼时,在「已收好」下面多一行援助热线 —— 兜底,不是回应。 */
唯一例外:出现明显轻生字眼时,在「已收好」下面多一行援助热线 —— 兜底,不是回应。
星夜版:天幕上每颗亮星 = 一条心事(位置由时间戳哈希定,刷新不跳);扔进去时字逐个被吸进树洞漩涡,
洞口爆亮,一颗新星从洞口射上天空。 */
(function treeHole() {
const KEY = 'mmbox.treehole.v1', MAX = 200;
const KEY = 'mmbox.treehole.v1', MAX = 200, SHOW = 40;
const ta = $('#thText'), send = $('#thSend'), list = $('#thList'), done = $('#thDone');
const night = $('#thNight'), sky = $('#thSky');
if (!ta || !send) return;
const SOS = /想死|不想活|自杀|轻生|结束生命|活不下去|去死|割腕|跳楼/;
const RM = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
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()); };
/* 确定性伪随机:同一个种子永远落在同一个位置 */
const rnd = seed => { let x = (seed % 2147483647) || 1; return () => (x = x * 16807 % 2147483647) / 2147483647; };
/* 星星避开右下角的树、底部输入条和左上角的月亮:x 4~66%,y 6~58% */
const starPos = seed => { const r = rnd(seed); r(); const x = 4 + r() * 62, y = 6 + r() * 52; return [x < 20 && y < 30 ? x + 22 : x, y]; }; // 左上角让给月亮
function count() { $('#thCount').textContent = load().length; }
function paintSky() {
if (!sky) return;
sky.textContent = '';
const bg = rnd(20261001);
for (let i = 0; i < 26; i++) { // 背景小星,固定一套
const s = document.createElement('i');
s.style.left = (bg() * 100) + '%'; s.style.top = (bg() * 70) + '%';
s.style.setProperty('--o', (0.25 + bg() * 0.5).toFixed(2));
s.style.animationDelay = (bg() * 3.2).toFixed(2) + 's';
sky.appendChild(s);
}
load().slice(0, SHOW).forEach(e => addStar(e.t, false));
}
function addStar(t, fresh) {
const p = starPos(t), s = document.createElement('i');
s.className = 'me' + (fresh ? ' is-new' : '');
s.style.left = p[0] + '%'; s.style.top = p[1] + '%';
s.style.animationDelay = fresh ? '' : ((t % 3200) / 1000).toFixed(2) + 's';
sky.appendChild(s);
return s;
}
function renderList() {
const a = load();
list.textContent = '';
if (!a.length) {
const li = document.createElement('li'); li.className = 'th-empty'; li.textContent = '树洞还是空的';
const li = document.createElement('li'); li.className = 'th-empty'; li.textContent = '天上还没有你的星星';
list.appendChild(li); return;
}
a.forEach(e => {
@@ -3581,15 +3628,15 @@
del.setAttribute('aria-label', '删除这条'); del.textContent = '×';
del.addEventListener('click', () => {
if (!window.confirm('删掉这条心事?删了就找不回来了')) return;
save(load().filter(x => x.t !== e.t)); renderList(); count();
save(load().filter(x => x.t !== e.t)); renderList(); count(); paintSky();
});
li.appendChild(t); li.appendChild(p); li.appendChild(del);
list.appendChild(li);
});
}
function showDone(sos) {
function showDone(sos, n) {
done.classList.toggle('is-sos', !!sos);
done.textContent = '已收好 🍃';
done.textContent = '已收好,化成了第 ' + n + ' 颗星';
if (sos) {
const s = document.createElement('div'); s.className = 'th-sos';
s.textContent = '如果你现在很难受,请告诉身边的人,或拨打 12356 心理援助热线,有人愿意听你说。';
@@ -3597,50 +3644,82 @@
}
done.classList.add('is-on');
clearTimeout(showDone._t);
if (!sos) showDone._t = setTimeout(() => done.classList.remove('is-on'), 2600);
if (!sos) showDone._t = setTimeout(() => done.classList.remove('is-on'), 3200);
}
/* 纸团飞进树洞:从输入框中间出发,弧线落到树洞,边转边缩小 */
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); }
const center = el => { const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
/* ① 字一个个从输入条飞起,绕一道弧被吸进漩涡 */
function suck(text, cb) {
const hole = $('#thHole');
if (RM || !hole || !document.body.animate) { cb(); return; }
const chars = Array.from(text.replace(/\s+/g, '')).slice(0, 14);
const a = ta.getBoundingClientRect(), h = center(hole);
let left = chars.length;
if (!left) { cb(); return; }
chars.forEach((c, i) => {
const el = document.createElement('span');
el.className = 'th-ch'; el.textContent = c;
document.body.appendChild(el);
const x0 = a.left + 6 + Math.min(i * 14, a.width - 20), y0 = a.top + 4;
const mx = (x0 + h[0]) / 2 + (i % 2 ? 24 : -18), my = Math.min(y0, h[1]) - 40 - (i % 3) * 14;
const anim = el.animate([
{ transform: 'translate(' + x0 + 'px,' + y0 + 'px) scale(1) rotate(0)', opacity: 0 },
{ transform: 'translate(' + x0 + 'px,' + (y0 - 10) + 'px) scale(1.1) rotate(0)', opacity: 1, offset: .12 },
{ transform: 'translate(' + mx + 'px,' + my + 'px) scale(.8) rotate(' + (i % 2 ? 160 : -160) + 'deg)', opacity: 1, offset: .55 },
{ transform: 'translate(' + (h[0] - 6) + 'px,' + (h[1] - 8) + 'px) scale(.1) rotate(' + (i % 2 ? 540 : -540) + 'deg)', opacity: 0 }
], { duration: 900, delay: i * 55, easing: 'cubic-bezier(.5,0,.3,1)', fill: 'forwards' });
anim.onfinish = () => { el.remove(); if (--left === 0) cb(); };
});
}
/* ② 洞口爆亮 → 新星从洞口划一道弧射上天空 */
function shoot(t, cb) {
const tree = $('#thTree'), hole = $('#thHole');
if (tree) { tree.classList.remove('is-eat'); void tree.getBoundingClientRect(); tree.classList.add('is-eat'); setTimeout(() => tree.classList.remove('is-eat'), 1000); }
const star = addStar(t, true);
if (RM || !hole || !document.body.animate) { cb(); return; }
star.style.visibility = 'hidden';
const h = center(hole), d = center(star);
const el = document.createElement('i'); el.className = 'th-shoot';
document.body.appendChild(el);
const mx = (h[0] + d[0]) / 2 + 30, my = Math.min(h[1], d[1]) - 30;
el.animate([
{ transform: 'translate(' + h[0] + 'px,' + h[1] + 'px) scale(.4)', opacity: 0 },
{ transform: 'translate(' + h[0] + 'px,' + (h[1] - 6) + 'px) scale(1.6)', opacity: 1, offset: .15 },
{ transform: 'translate(' + mx + 'px,' + my + 'px) scale(1)', opacity: 1, offset: .6 },
{ transform: 'translate(' + d[0] + 'px,' + d[1] + 'px) scale(1.2)', opacity: 1 }
], { duration: 900, delay: 250, easing: 'cubic-bezier(.3,.1,.3,1)', fill: 'forwards' }).onfinish = () => {
el.remove();
star.style.visibility = '';
star.classList.remove('is-new'); void star.offsetWidth; star.classList.add('is-new');
cb();
};
}
const sync = () => { send.disabled = !ta.value.trim(); };
ta.addEventListener('input', sync);
ta.addEventListener('focus', () => night && night.classList.add('is-typing'));
ta.addEventListener('blur', () => { if (night && !ta.value.trim()) night.classList.remove('is-typing'); });
let busy = false;
send.addEventListener('mousedown', e => e.preventDefault()); // 点按钮时别先 blur 收起输入框,免得按钮跳位
send.addEventListener('click', () => {
const x = ta.value.trim();
if (!x) return;
const a = load(); a.unshift({ t: Date.now(), x: x });
if (!x || busy) return;
const t = Date.now();
const a = load(); a.unshift({ t: t, x: x });
if (!save(a)) { toast('手机存储空间不足,没能收好'); return; }
ta.value = ''; sync(); ta.blur();
fly(x, () => {
showDone(SOS.test(x));
busy = true; haptic();
suck(x, () => shoot(t, () => {
busy = false;
showDone(SOS.test(x), load().length);
count();
if (!list.classList.contains('hide')) renderList();
});
}));
ta.value = ''; sync(); ta.blur();
if (night) night.classList.remove('is-typing');
});
$('#thMine').addEventListener('click', () => {
const open = list.classList.toggle('hide') === false;
if (open) renderList();
});
sync(); count();
sync(); count(); paintSky();
})();
/* 拉卡片数据(与其它远程数据一起打;失败静默,卡上保留现有值) */