悄悄话全屏页五改:流星夜空,点星变流星、字做尾巴先慢后唰、底部内容卡 (v26100169)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 07:08:57 +00:00
parent e70e4cb91b
commit e45306f0dd
3 changed files with 194 additions and 98 deletions

215
js/app.js
View File

@@ -3,7 +3,7 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26100168';
var VER = '26100169';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -3341,43 +3341,44 @@
return '<button class="whp-hug' + (it.hugged ? ' on' : '') + '" type="button" data-hug="' + esc(it.id) + '" aria-label="给她一个抱抱">' +
HEART + '<b>' + (it.hugs || '') + '</b></button>';
}
/* 四改:一屏一句的竖滑沉浸页。slide 0 = 封面(标题 + 星座),slide k = 第 k 句。 */
/* 五改:流星夜空。用户原话:「打开页面夜空里特别多星星;点开一颗,它像流星一样滑落……那个头就是白色的,
尾巴就是这些字;首先保证用户能看清,刚开始慢一点,说完了慢慢的唰的就快了;同时把对应内容呈现出来」
· 亮星 = 一句(data-go = 下标 + 1),其余一百多颗只是装饰;
· 点亮星:星闪一下 → 变成白色流星头往左下滑,字从星里一个个拖出来当尾巴;慢速段按字数给够阅读时间,
之后加速、拉长、字被拖糊,唰地划出屏幕;星星回来时变暗(看过了);
· 底部内容卡同时出现:整句 + 孕周 + 抱抱 + 「下一颗」。流星在卡片下面穿过,不挡字。 */
const RM = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
const AURA = [['#6B4AA8', '#C2667A'], ['#3F4FA6', '#8E5BB0'], ['#9A4F86', '#E0907A'], ['#365C9E', '#7A58AE'], ['#7D3F8C', '#D27A9A']];
const hsh = s => { let h = 7; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h; };
let cur = 0, io = null, vl = [];
/* 沉浸页先看别人的,自己说的放最后(金色收尾) */
const vlist = () => list().filter(x => !x.mine).concat(list().filter(x => x.mine));
const whv = $('#whv');
function skySvg(l) {
const n = l.length, cols = n > 12 ? 5 : 4, rows = Math.max(2, Math.ceil(n / cols));
const pts = l.map((it, i) => {
const h = hsh(it.id), c = i % cols, r = Math.floor(i / cols), cc = r % 2 ? cols - 1 - c : c;
return { i, it, x: ((cc + .5 + ((h % 100) / 100 - .5) * .62) / cols) * 100, y: ((r + .5 + (((h >> 8) % 100) / 100 - .5) * .55) / rows) * 100,
sz: it.mine ? 12 : Math.round(5 + Math.min(it.hugs || 0, 40) / 40 * 10) };
});
return '<svg class="whs-lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"><polyline points="' +
pts.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' ') + '"/></svg>' +
pts.map(p => '<button type="button" class="whs-star' + (p.it.mine ? ' mine' : '') + '" data-go="' + (p.i + 1) + '" style="left:' + p.x.toFixed(1) + '%;top:' + p.y.toFixed(1) +
'%;--s:' + p.sz + 'px;--tw:' + ((hsh(p.it.id) % 30) / 10).toFixed(1) + 's" aria-label="' + esc(p.it.text) + '"><i></i></button>').join('');
const card = $('#wsc');
const seen = new Set();
let cur = 0, mt = null;
function paintDeco() {
const box = $('#wskStars');
if (box.childElementCount) return;
let h = '';
for (let k = 0; k < 130; k++) {
const r = Math.random(), sz = r < .75 ? 1 + Math.random() : r < .95 ? 2 + Math.random() : 3;
h += '<i style="left:' + (Math.random() * 100).toFixed(1) + '%;top:' + (Math.random() * 92).toFixed(1) + '%;width:' + sz.toFixed(1) + 'px;height:' + sz.toFixed(1) +
'px;--o:' + (.25 + Math.random() * .6).toFixed(2) + ';animation-delay:-' + (Math.random() * 6).toFixed(1) + 's;animation-duration:' + (3 + Math.random() * 4).toFixed(1) + 's"></i>';
}
box.innerHTML = h;
}
function slide(it, i, n) {
const t = String(it.text || ''), len = [...t].length;
const fs = len <= 9 ? 'xl' : len <= 18 ? 'l' : 'm';
const chars = [...t].map((c, k) => '<span style="--c:' + k + '">' + esc(c) + '</span>').join('');
const rail = it.mine
? '<div class="whv-got">' + HEART + '<b>' + (it.hugs || 0) + '</b><small>收到的抱抱</small></div>'
: '<button class="whv-hug whp-hug' + (it.hugged ? ' on' : '') + '" type="button" data-hug="' + esc(it.id) + '" aria-label="给她一个抱抱">' + HEART + '<b>' + (it.hugs || '') + '</b></button><small class="whv-hl">抱抱</small>';
return '<article class="whv-s' + (it.mine ? ' mine' : '') + '" data-i="' + (i + 1) + '">' +
'<div class="whv-in">' +
'<div class="whv-no"><b>' + String(i + 1).padStart(2, '0') + '</b><span>/ ' + String(n).padStart(2, '0') + '</span>' + (it.mine ? '<em>我说的</em>' : '') + '</div>' +
'<p class="whv-q ' + fs + '">' + chars + '</p>' +
'<div class="whv-meta">孕 ' + it.week + ' 周的妈妈 · ' + esc(it.ago) + '</div>' +
(i === 0 && !it.mine ? '<div class="whv-tip">双击屏幕,也能给她一个抱抱</div>' : '') +
'</div><div class="whv-rail">' + rail + '</div></article>';
/* 亮星落在夜空中上部偏右(流星往左下走,右边起步才划得够长);抖动网格,不会叠在一起 */
function paintSky(l) {
const n = l.length, cols = n > 16 ? 5 : 4, rows = Math.max(2, Math.ceil(n / cols));
const fresh = l.findIndex(x => !x.mine && !seen.has(x.id));
$('#wskBright').innerHTML = l.map((it, i) => {
const h = hsh(it.id), c = i % cols, r = Math.floor(i / cols);
const x = 24 + ((c + .5 + ((h % 100) / 100 - .5) * .7) / cols) * 70;
const y = 21 + ((r + .5 + (((h >> 8) % 100) / 100 - .5) * .6) / rows) * 38;
const sz = it.mine ? 9 : Math.round(4 + Math.min(it.hugs || 0, 30) / 30 * 6);
return '<button type="button" class="wsk-st' + (it.mine ? ' mine' : '') + (seen.has(it.id) ? ' seen' : '') + (i === fresh && !seen.size ? ' hint' : '') +
'" data-go="' + (i + 1) + '" style="left:' + x.toFixed(1) + '%;top:' + y.toFixed(1) + '%;--s:' + sz + 'px;--tw:' + (2 + (h % 30) / 10).toFixed(1) + 's" aria-label="' + esc(it.text) + '"><i></i></button>';
}).join('');
}
function paintList(l) {
$('#whpCount').textContent = l.length + ' 句 · 我说的在最后';
$('#whpCount').textContent = l.length + ' 句 · 最新在上';
$('#whpList').innerHTML = l.map((it, i) => {
const lv = it.mine ? 'mine' : it.hugs >= 25 ? 'l3' : it.hugs >= 10 ? 'l2' : 'l1';
return '<div class="whl ' + lv + '"><button type="button" class="whl-t" data-go="' + (i + 1) + '">' +
@@ -3385,46 +3386,97 @@
'<div class="whp-meta"><span>孕 ' + it.week + ' 周 · ' + esc(it.ago) + '</span>' + hugBtn(it) + '</div></div>';
}).join('');
}
function setCur(i) {
cur = i;
const n = list().length;
$$('.whv-bars i', page).forEach((b, k) => { b.classList.toggle('done', k < i - 1); b.classList.toggle('on', k === i - 1); });
$('#whvBars').classList.toggle('hide', i === 0);
const it = vl[i - 1];
const pal = it && it.mine ? ['#A8723A', '#E0A86A'] : i === 0 ? ['#5A3B72', '#4A2C55'] : AURA[(i - 1) % AURA.length];
function paintCard(it, i) {
card.innerHTML = '<p class="wsc-q">' + (it.mine ? '<em>我说的</em>' : '') + esc(it.text) + '</p>' +
'<div class="wsc-m"><span>孕 ' + it.week + ' 周的妈妈 · ' + esc(it.ago) + '</span>' + hugBtn(it) + '</div>' +
'<button type="button" class="wsc-next" data-next="1">下一颗 <svg viewBox="0 0 24 24" width="13" height="13"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>';
card.classList.remove('show');
void card.offsetWidth;
card.classList.add('show');
const pal = it.mine ? ['#A8723A', '#E0A86A'] : AURA[(i - 1) % AURA.length];
const au = $$('#whvAura i');
au[0].style.backgroundColor = pal[0]; au[1].style.backgroundColor = pal[1];
$$('.whs-star', page).forEach(s => s.classList.toggle('on', +s.dataset.go === i));
}
function stopMeteor() {
if (!mt) return;
cancelAnimationFrame(mt.raf);
mt.el.remove();
mt.btn.classList.remove('flare', 'gone');
mt.btn.classList.add('seen');
mt = null;
}
function launch(i) {
const it = list()[i - 1];
if (!it) return;
stopMeteor();
cur = i;
seen.add(it.id);
$$('.wsk-st.hint', page).forEach(x => x.classList.remove('hint'));
$$('.wsk-st.cur', page).forEach(x => x.classList.remove('cur'));
paintCard(it, i);
const btn = $('.wsk-st[data-go="' + i + '"]', page);
if (!btn) return;
btn.classList.add('cur');
if (RM) { btn.classList.add('seen'); return; }
const pr = page.getBoundingClientRect(), br = btn.getBoundingClientRect();
const x0 = br.left + br.width / 2 - pr.left, y0 = br.top + br.height / 2 - pr.top;
/* 瞄准左下(屏幕左边外、六成高处):越靠左起步角度越陡,保证在屏幕里拖得够长 */
const th = Math.max(22, Math.min(50, Math.atan2(pr.height * .62 - y0, x0 + 30) * 180 / Math.PI)) * Math.PI / 180, dx = -Math.cos(th), dy = Math.sin(th), rot = -th * 180 / Math.PI;
const chars = [...String(it.text)], n = chars.length;
const cw = n > 16 ? 21 : 25, gap = 20, body = gap + n * cw;
const el = document.createElement('div');
el.className = 'wmt' + (it.mine ? ' mine' : '');
el.style.setProperty('--fs', (cw - 4) + 'px');
el.innerHTML = '<i class="wmt-l"></i><i class="wmt-h"></i>' + chars.map(c => '<span>' + esc(c) + '</span>').join('');
page.insertBefore(el, card);
const line = el.children[0], head = el.children[1], sp = $$('span', el);
/* 节奏:星闪 380ms → 慢飘(按字数给阅读时间,起步再缓一点)→ 750ms 加速唰出去 */
const T0 = 380, T1 = Math.min(11000, 2400 + n * 300), T2 = 800, L1 = body + 60, v1 = L1 / (.94 * T1);
btn.classList.add('flare');
const t0 = performance.now();
const frame = now => {
const t = now - t0;
if (t < T0) { mt.raf = requestAnimationFrame(frame); return; }
btn.classList.add('gone');
const t1 = t - T0;
let s, u = 0;
if (t1 < T1) { const p = t1 / T1; s = L1 * (p < .12 ? p * p / .24 : p - .06) / .94; }
else { u = Math.min(1, (t1 - T1) / T2); s = L1 + v1 * T2 * u + 1600 * Math.pow(u, 2.6); }
const hx = x0 + dx * s, hy = y0 + dy * s;
head.style.transform = 'translate(' + hx.toFixed(1) + 'px,' + hy.toFixed(1) + 'px) rotate(' + rot.toFixed(1) + 'deg) scaleX(' + (1 + u * 5).toFixed(2) + ')';
line.style.width = Math.min(s, body + 50 + 700 * u * u).toFixed(0) + 'px';
line.style.transform = 'translate(' + hx.toFixed(1) + 'px,' + hy.toFixed(1) + 'px) rotate(' + rot.toFixed(1) + 'deg)';
const fade = Math.max(0, 1 - u * 1.25);
el.style.setProperty('--bl', (u * 5).toFixed(1) + 'px');
sp.forEach((c, k) => {
const d = s - gap - k * cw;
if (d < 0) { c.style.opacity = 0; return; }
c.style.opacity = (Math.min(1, d / 22) * fade).toFixed(2);
c.style.transform = 'translate(' + (x0 + dx * d).toFixed(1) + 'px,' + (y0 + dy * d).toFixed(1) + 'px) translate(-50%,-50%) rotate(' + rot.toFixed(1) + 'deg)';
});
if (u >= 1) { stopMeteor(); return; }
mt.raf = requestAnimationFrame(frame);
};
mt = { el, btn, raf: requestAnimationFrame(frame) };
}
function nextStar() {
const l = list(), n = l.length;
for (let k = 1; k <= n; k++) {
const j = (cur - 1 + k) % n;
if (!seen.has(l[j].id) && !l[j].mine) return j + 1;
}
return cur % n + 1;
}
function paintPage() {
const l = vl = vlist(), n = l.length;
const l = list(), n = l.length;
const bh = (new Date().getUTCHours() + 8) % 24, night = bh >= 18 || bh < 5;
whv.innerHTML =
'<article class="whv-s whv-cover" data-i="0"><div class="whv-in">' +
'<span class="whp-tag">' + (data ? '孕 ' + data.week + ' 周 · 前后一周的妈妈' : '') + '</span>' +
'<div class="whv-ct">' + (night ? '今晚' : '今天') + ',<br><b>' + n + '</b> 句悄悄话</div>' +
'<p class="whp-sub">不留名字,只留一句话。听到了,就给她一个抱抱。</p>' +
'<div class="whp-sky2" id="whpSky" role="group" aria-label="今晚的星星,每颗是一句悄悄话">' + skySvg(l) + '</div>' +
'<div class="whv-down" aria-hidden="true"><i></i>上滑,听她们说</div></div></article>' +
l.map((it, i) => slide(it, i, n)).join('') +
'<article class="whv-s whv-end" data-i="' + (n + 1) + '"><div class="whv-in"><div class="whv-ct sm">' +
(night ? '都在这里了,<br>晚安。' : '都在这里了。') + '</div><p class="whp-sub">你也说一句吧,她们会听到的。</p></div></article>';
$('#whvBars').innerHTML = l.map(() => '<i></i>').join('');
$('#whpTag').textContent = data ? '孕 ' + data.week + ' 周 · 前后一周的妈妈' : '';
$('#wskTitle').innerHTML = '<div class="wsk-t">' + (night ? '今晚' : '今天') + ',<b>' + n + '</b> 句悄悄话</div><p>每颗亮星是一句。点一颗,听她说。</p>';
paintDeco();
paintSky(l);
paintList(l);
if (cur && list()[cur - 1]) $$('.wsk-st[data-go="' + cur + '"]', page).forEach(x => x.classList.add('cur'));
$('#whpLeft').textContent = !data ? '' : !oid() || !data.canPost ? '填好预产期,就能说一句' : data.left ? '今天还能说 ' + data.left + ' 句' : '今天的 3 句说完啦,明天再来';
if (io) io.disconnect();
if ('IntersectionObserver' in window) {
io = new IntersectionObserver(es => es.forEach(e => {
e.target.classList.toggle('on', e.isIntersecting);
if (e.isIntersecting) setCur(+e.target.dataset.i);
}), { root: whv, threshold: .6 });
$$('.whv-s', whv).forEach(s => io.observe(s));
} else $$('.whv-s', whv).forEach(s => s.classList.add('on'));
setCur(0);
}
function goTo(i, smooth) {
const s = $('.whv-s[data-i="' + i + '"]', whv);
if (s) whv.scrollTo({ top: s.offsetTop, behavior: smooth && !RM ? 'smooth' : 'auto' });
}
function drawer(on) {
$('#whDrawer').classList.toggle('show', on);
@@ -3434,43 +3486,24 @@
$('#whdX').addEventListener('click', () => drawer(false));
$('#whDrawer').addEventListener('click', e => { if (e.target.id === 'whDrawer') drawer(false); });
page.addEventListener('click', e => {
const g = e.target.closest('[data-go]');
const g = e.target.closest('[data-go],[data-next]');
if (!g) return;
haptic();
drawer(false);
goTo(+g.dataset.go, true);
});
/* 双击屏幕 = 抱抱:大爱心在手指处炸开,再走下面同一条抱抱逻辑 */
let lastTap = 0;
whv.addEventListener('click', e => {
if (e.target.closest('button')) return;
const now = Date.now();
if (now - lastTap > 320) { lastTap = now; return; }
lastTap = 0;
const s = e.target.closest('.whv-s');
const hb = s && $('.whv-hug', s);
if (!hb) return;
if (!RM) {
const big = document.createElement('i');
big.className = 'whv-big';
big.innerHTML = HEART;
const r = page.getBoundingClientRect();
big.style.left = (e.clientX - r.left) + 'px';
big.style.top = (e.clientY - r.top) + 'px';
page.appendChild(big);
setTimeout(() => big.remove(), 900);
}
if (!hb.classList.contains('on')) hb.click();
launch(g.dataset.next ? nextStar() : +g.dataset.go);
});
function openPage() {
stopMeteor();
cur = 0;
card.classList.remove('show');
paintPage();
page.classList.add('show');
page.setAttribute('aria-hidden', 'false');
whv.scrollTop = 0;
drawer(false);
haptic();
}
function closePage() {
stopMeteor();
drawer(false);
page.classList.remove('show');
page.setAttribute('aria-hidden', 'true');
@@ -3591,7 +3624,7 @@
paintEntry();
paintPage();
closeModal();
setTimeout(() => goTo(vl.findIndex(x => x.id === it.id) + 1, true), 80);
setTimeout(() => launch(1), 350);
toast('发出去啦,同孕周妈妈会给你抱抱的');
})
.catch(() => toast('网络不太好,再试一次'))