From 31d153b5130e11850377a2cf3eef2861c62330dd Mon Sep 17 00:00:00 2001 From: Karlcarlo Date: Sun, 20 Sep 2026 02:45:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E6=9D=A5=E4=BF=A1):=20=E5=BD=BB?= =?UTF-8?q?=E5=BA=95=E5=88=A0=E9=99=A4=E5=86=85=E7=BD=AE=E6=9D=A5=E4=BF=A1?= =?UTF-8?q?=E5=B1=8F=E5=8F=8A=E5=85=A8=E9=83=A8=E5=8A=9F=E8=83=BD=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=EF=BC=8C=E6=9D=A5=E4=BF=A1=E6=95=B4=E4=BD=93=E7=94=B1?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=96=B9=20laixin=20=E6=89=BF=E6=8E=A5?= =?UTF-8?q?=EF=BC=88v26092006=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「宝宝来信」功能已全部由第三方应用 laixin(mp.yizhezhe.com/laixin/)承接, mama-box 只保留两个入口/提醒,二者逻辑一行未改: · 底 tab「宝宝来信」#tabLetter → openLetterApp() 跳 laixin · 首页提醒卡 #homeLetter + 底 tab 红点 #tabBadge → syncLaixinReminder()/applyLaixinReminder() (读 /api/mp/public/laixin/unread) 删除的界面:#screen-letter(来信列表屏)、#detailLetter(来信详情)、 #detailCard(来信分享卡)、#fabWrite(写回信钮)、#modalReply(回信弹窗)。 删除的代码:LETTERS 数据与 stampLetters/renderLetters/filterLetters/buildThread/ renderLetterDetail/openLetter/curLetter/kidText/kidEl、本地延迟投递队列全套 (store/pending、deliverDue、deliverBabyReply、deliverVoiceLetter、语音信)、 syncLetterCounts、信箱头像换图 pickBabyAvatar、回信提交、applyRemoteLetters (H5 从此不再请求 box_app 的 /mama-box/letters)、滑动 order 里的 letter、 go() 里 #fabWrite 的开关、state.{unread,letterFilter,curLetter,replyCount,babyAvatar} 以及随之无调用方的 fillBaby()。 保留(删前逐个 grep 确认仍有调用方):babyWord()、backAt()(stampAiLogs 在用)、 el()(档案海报在用)、.segmented(档案页在用)、.letter-card(首页提醒卡在用)、 档案页「珍贵时刻」里仅以文案提及来信的装饰卡。 CSS:删掉来信专用规则块与随之失效的变量(--font-round 等);净变化 -2396/+93 行。 --- index.html | 269 +------- js/app.js | 1682 ++------------------------------------------------ mama-box.css | 544 +--------------- 3 files changed, 94 insertions(+), 2401 deletions(-) diff --git a/index.html b/index.html index ff904e2..6b9a49d 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ 兜爸心选 · 孕育陪伴 - + @@ -28,15 +28,15 @@ - + 数字搬到标签上(和分段控件里其它计数同一种写法),#favCount 仍由 JS 更新 --> @@ -662,88 +662,6 @@ -
-
- - - - - -
-
- -

宝宝写给你的信

- -

共 24 封

-

每周宝宝都会悄悄给你写一封

-
- - -
- - -
- - - - - -
-
- -
- -
- -
- - 这里还没有内容 -
-
-
-
@@ -1068,75 +986,6 @@ - -
-
- - - 宝宝来信 - -
-
- -
-
-

- 孕 24 周 +3 天 - - 2024年9月3日 08:00 -

-

今天我翻了个身

- - - -
-
-
-
-
-
- -
- -
- - - - -
-
-
-
@@ -1370,8 +1219,8 @@

图片已生成。在微信里也可以长按上面这张图,直接保存或转发。

- @@ -1379,49 +1228,6 @@
- -
-
- - 分享卡 -
-
-
- -
-

这封信的分享卡

-

生成一张可以直接发出去的图。

-
-
- -
-
-

-
-
- - - -
-
- @@ -1489,18 +1295,17 @@
- -
🔍
+ 抓不到它,改口时别忘了它。 --> +
🔍
最近搜索
- +
大家都在搜
@@ -1512,17 +1317,9 @@
- - - + - - - - -
- +
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 从