diff --git a/js/app.js b/js/app.js
index 5ca0624..e26ecdd 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3,13 +3,14 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
- var VER = '26092005';
+ var VER = '26092006';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
/* ---------- 孕期日期算术 ----------
这一小块原来长在下面「孕期信息」那一节里(DOM 事件旁边)。挪到最前面,
- 是因为信件日期现在要按孕周现推,而信件那一节在文件中段就要用到这些函数。
+ 是因为默认预产期和孕周要在脚本一加载就算出来(下面「孕期信息」和 AI 小结
+ 的日期推算都在用)。
它们全是纯数学,一个 DOM 都不碰,放最前面不会有副作用;
留在原地则 state.week 只能先塞个占位数,等 init() 再补 —— 中间那段时间
任何按孕周算日期的代码拿到的都是假数据。 */
@@ -24,7 +25,7 @@
if (x.getDate() !== d.getDate()) x.setDate(0);
return x;
}
- /* 日期显示:fmtWhen 给往来消息用(当天就写「今天」),fmtFull 给信件抬头用 */
+ /* 日期显示:fmtWhen 给首页「宝宝来信」提醒卡用(未读接口只给时间戳,当天就写「今天」) */
const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const pad2 = n => (n < 10 ? '0' : '') + n;
function fmtWhen(ts) {
@@ -33,8 +34,6 @@
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 周封顶,等于一进门就「超预产期」了。
@@ -49,29 +48,22 @@
kickToday: 3,
kickQuick: 1,
todayCount: 4,
- unread: 1,
favCount: 18,
- letterFilter: 'all',
resCat: 'all',
resKey: 'book',
resFav: [], // 已收藏的资源 key,数组顺序即收藏顺序
babyName: '小汤圆',
/* 这三项一进门就是真值(默认预产期 = 今天 +3 个月,见上面 DEFAULT_DUE)。
- 以前它们是写死的占位 24/3/null,等 init() 才补 —— 但信件日期是按孕周
- 现推的,推的时候还没轮到 init(),拿到的就全是假的。 */
+ 以前它们是写死的占位 24/3/null,等 init() 才补 —— 但那之前就有按孕周
+ 现算的展示(首页孕周环、档案页),拿到的就全是假的。 */
week: Math.floor(DEFAULT_GA / 7), day: DEFAULT_GA % 7,
due: DEFAULT_DUE,
babySex: 'unknown',
- /* 来信列表左边那颗宝宝头像。空 = 还没设,显示占位的「宝」字;
- 设了就是一段 data URI(点头像选图后就地读出来的,没有后端)。
- ⚠️ 和 userName / babyName 一样**不写 localStorage** —— 现状只持久化
- resFav,刷新会回到「宝」字。
- 这是已知现状、本批没扩范围,不是漏做。
- ⚠️ 更要紧的是:data URI 动辄几百 KB,而 localStorage 通常只有 5MB。
- 哪天真要持久化它,得先压缩(画到 canvas 缩到 ~128px 再 toDataURL),
- 直接 setItem 原图会在某些机型上抛 QuotaExceededError。 */
- babyAvatar: '',
- replyCount: 0, // 妈妈已寄出的回信条数
+ /* ⚠️ 这里原来还有一条 babyAvatar(来信列表那颗宝宝头像的 data URI)。
+ 它的读写方全在内置来信屏里,随该屏一起删除 —— 别再只加字段不加读写方,
+ 那正是下面 userBio 那条注释警告过的「最难发现的死代码」。
+ 将来要恢复它,除了 state 字段,还得补上「选图 → 压缩(canvas 缩到 ~128px
+ 再 toDataURL,原图可能几 MB)→ localStorage 落盘」这几步。 */
userName: '小满妈妈',
/* ⚠️ 这里原来还有一条 userBio(一句话签名)。整块已按用户要求删除,
连同 #pfBio 输入框、#meBio 展示行、syncBio()。别只加回这一条 ——
@@ -243,8 +235,6 @@
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' && !state.pregEmpty);
/* 档案页的孕程已经从圆环换成细条了,这里跟着换成「归零再长回去」。
归零和写目标值必须隔一帧,同一帧里改两次浏览器只会看到最后那个值,
transition 无从触发(和 animateRings 里那次强制重绘是一个道理)。 */
@@ -353,13 +343,6 @@
}
});
- initSegmented('#letterSeg', '#letterThumb', 'lseg', key => {
- state.letterFilter = key;
- filterLetters(key);
- });
-
- /* filterLetters 见下方「宝宝来信 · 会话式」一节:
- 列表已经是一封信一条 thread,筛选是按 thread 整体显隐的 */
/* ================= 筛选 chips ================= */
/* 档案页时光轴和珍贵时刻那两排分类 chips 已经删了(上面刚有一排分段,
@@ -413,7 +396,7 @@
sheet.classList.remove('show');
phoneEl.classList.remove('sheet-open');
sheet.style.transform = '';
- if (!detail.classList.contains('show')) setStatusbar('auto');
+ setStatusbar('auto');
}
$$('[data-sheet="open"]').forEach(b => b.addEventListener('click', openSheet));
$$('[data-sheet="close"]').forEach(b => b.addEventListener('click', closeSheet));
@@ -455,8 +438,6 @@
c.textContent = state.todayCount;
toast('胎动 +1,今天已记录 ' + state.kickQuick + ' 次');
haptic();
- /* 妈妈数胎动 → 宝宝会寄一封「肚子里的声音」过来(几小时后才到,全孕期最多两封) */
- maybeVoiceAfterKick();
});
/* 工具列表去重:顶部「今天已使用」的快捷入口若在下方列表里重复出现,
@@ -517,7 +498,6 @@
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');
@@ -554,358 +534,28 @@
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 而不是箭头常量:声明会提升,谁先谁后都不会踩到暂时性死区。 */
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: []
- }
- ];
+ /* ⚠️ 这里原来还有一个 fillBaby(str)(把 {baby} 占位符换成小名),和 babyWord() 一对。
+ 它的调用点全在内置来信屏的渲染里,随该屏一起删除。
+ 将来若要用,直接调 babyWord() 拼,别只留一个空函数在这儿。 */
/* back(几天前)+ time(几点几分)→ 那一刻的时间戳。back 只影响「哪一天」,
- 时分是当天的绝对钟点,所以先归零到 0 点再往回数,避免夏令时/跨月自己算。 */
+ 时分是当天的绝对钟点,所以先归零到 0 点再往回数,避免夏令时/跨月自己算。
+ ⚠️ 现在唯一调用方是 stampAiLogs()(AI 小结的往期日期)。内置来信屏删除后
+ 信件那一层语义没了,但 AI 小结还在靠它推日期 —— 别顺手删。 */
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;
-
- /* ---------- 延迟投递队列(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();
- 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);
@@ -913,419 +563,21 @@
if (txt != null) n.textContent = txt;
return n;
}
- /* ⚠️ SVG 不能走上面那个 el()。document.createElement('circle') 造出来的是
- HTML 命名空间里的
,浏览器当成 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 画的小脸(呆毛 + 腮红 + 眯眼)。
- 本批按用户要求换成一个「宝」字。已经删掉了,别再画回去。
- 为什么一个字比一张脸好:那张脸是**每一条**来信左边都画一遍的同一张脸,
- 十条信十张一模一样的脸,扫过去像一堵墙;而且它长什么样是我们替用户
- 决定的,用户换不掉。换成一个字之后,这颗圆盘的身份变了 ——
- 它是「还没设置头像」的占位(跟微信、通讯录一个路数),
- 于是「点一下换成自己的照片」就成了顺理成章的下一步,
- 也就是用户接着要的那件事。
-
- ⚠️ 为什么是固定的「宝」,不是小名的第一个字:
- 小名的第一个字通常没有信息量 ——「小汤圆」取到的是「小」,
- 「小豆包」也是「小」,两个不同的宝宝头像一模一样。
- 而「宝」在任何小名下都读得通。
-
- ⚠️ 设了自定义头像就换成
。这里返回节点而不是直接写 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');
-
- /* ---------- 列表 ---------- */
- /* 来信页空数据 · 引导按钮:直接打开「我的资料」弹窗(保存后自动重载播种) */
- const letterGoOnboard = $('#letterGoOnboard');
- if (letterGoOnboard) letterGoOnboard.addEventListener('click', e => {
- e.stopPropagation();
- openModal('modalProfile');
- });
-
- 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 从