commit 0a199f352ca3255e538b622c8181dd1a465e64c6 Author: Karlcarlo Date: Mon Sep 7 15:47:42 2026 +0800 box_app: serve mama-box at /mama-box/ (align with mp.yizhezhe.com subdirectory apps); mount /mama-box, drop /apps+/assets; _img_url & init_mamabox URL → /mama-box/ diff --git a/assets/img/02cf420f0d07.png b/assets/img/02cf420f0d07.png new file mode 100644 index 0000000..f0a9520 Binary files /dev/null and b/assets/img/02cf420f0d07.png differ diff --git a/assets/img/056212807d86.png b/assets/img/056212807d86.png new file mode 100644 index 0000000..29e7b13 Binary files /dev/null and b/assets/img/056212807d86.png differ diff --git a/assets/img/0ebc7631fea4.png b/assets/img/0ebc7631fea4.png new file mode 100644 index 0000000..30771f6 Binary files /dev/null and b/assets/img/0ebc7631fea4.png differ diff --git a/assets/img/1afd85e578dd.png b/assets/img/1afd85e578dd.png new file mode 100644 index 0000000..b48077c Binary files /dev/null and b/assets/img/1afd85e578dd.png differ diff --git a/assets/img/2d23b079caca.png b/assets/img/2d23b079caca.png new file mode 100644 index 0000000..17eb7da Binary files /dev/null and b/assets/img/2d23b079caca.png differ diff --git a/assets/img/30de43bbc9aa.png b/assets/img/30de43bbc9aa.png new file mode 100644 index 0000000..985a928 Binary files /dev/null and b/assets/img/30de43bbc9aa.png differ diff --git a/assets/img/3657b1f76026.png b/assets/img/3657b1f76026.png new file mode 100644 index 0000000..04552d9 Binary files /dev/null and b/assets/img/3657b1f76026.png differ diff --git a/assets/img/4657f50f42c8.png b/assets/img/4657f50f42c8.png new file mode 100644 index 0000000..0a05510 Binary files /dev/null and b/assets/img/4657f50f42c8.png differ diff --git a/assets/img/505ace532176.png b/assets/img/505ace532176.png new file mode 100644 index 0000000..5d72745 Binary files /dev/null and b/assets/img/505ace532176.png differ diff --git a/assets/img/67405412c52c.png b/assets/img/67405412c52c.png new file mode 100644 index 0000000..b1c2b71 Binary files /dev/null and b/assets/img/67405412c52c.png differ diff --git a/assets/img/69818ea975da.png b/assets/img/69818ea975da.png new file mode 100644 index 0000000..98c3360 Binary files /dev/null and b/assets/img/69818ea975da.png differ diff --git a/assets/img/72910df66ee3.png b/assets/img/72910df66ee3.png new file mode 100644 index 0000000..295b8c7 Binary files /dev/null and b/assets/img/72910df66ee3.png differ diff --git a/assets/img/72f41378ef8e.png b/assets/img/72f41378ef8e.png new file mode 100644 index 0000000..2948916 Binary files /dev/null and b/assets/img/72f41378ef8e.png differ diff --git a/assets/img/7980160b80b0.png b/assets/img/7980160b80b0.png new file mode 100644 index 0000000..8b55a2c Binary files /dev/null and b/assets/img/7980160b80b0.png differ diff --git a/assets/img/7b7d81e02f42.png b/assets/img/7b7d81e02f42.png new file mode 100644 index 0000000..05e9f31 Binary files /dev/null and b/assets/img/7b7d81e02f42.png differ diff --git a/assets/img/8b0f0a53938b.png b/assets/img/8b0f0a53938b.png new file mode 100644 index 0000000..45e9c4e Binary files /dev/null and b/assets/img/8b0f0a53938b.png differ diff --git a/assets/img/949338312a1f.png b/assets/img/949338312a1f.png new file mode 100644 index 0000000..efedc33 Binary files /dev/null and b/assets/img/949338312a1f.png differ diff --git a/assets/img/97c33531806b.png b/assets/img/97c33531806b.png new file mode 100644 index 0000000..36a2b47 Binary files /dev/null and b/assets/img/97c33531806b.png differ diff --git a/assets/img/9a2e7fa472dc.png b/assets/img/9a2e7fa472dc.png new file mode 100644 index 0000000..c5f4017 Binary files /dev/null and b/assets/img/9a2e7fa472dc.png differ diff --git a/assets/img/9f3fa8edf4aa.png b/assets/img/9f3fa8edf4aa.png new file mode 100644 index 0000000..2216629 Binary files /dev/null and b/assets/img/9f3fa8edf4aa.png differ diff --git a/assets/img/a9e3b27a4a23.png b/assets/img/a9e3b27a4a23.png new file mode 100644 index 0000000..71276bc Binary files /dev/null and b/assets/img/a9e3b27a4a23.png differ diff --git a/assets/img/b00c707f7b1f.png b/assets/img/b00c707f7b1f.png new file mode 100644 index 0000000..fc83e3b Binary files /dev/null and b/assets/img/b00c707f7b1f.png differ diff --git a/assets/img/b2066fd752bf.png b/assets/img/b2066fd752bf.png new file mode 100644 index 0000000..e22b594 Binary files /dev/null and b/assets/img/b2066fd752bf.png differ diff --git a/assets/img/b855b45df1f3.png b/assets/img/b855b45df1f3.png new file mode 100644 index 0000000..d22e75b Binary files /dev/null and b/assets/img/b855b45df1f3.png differ diff --git a/assets/img/c2ae6e98283b.png b/assets/img/c2ae6e98283b.png new file mode 100644 index 0000000..5b4dfe3 Binary files /dev/null and b/assets/img/c2ae6e98283b.png differ diff --git a/assets/img/dd5639fde61d.png b/assets/img/dd5639fde61d.png new file mode 100644 index 0000000..6548de4 Binary files /dev/null and b/assets/img/dd5639fde61d.png differ diff --git a/assets/img/de0bc9e250f2.png b/assets/img/de0bc9e250f2.png new file mode 100644 index 0000000..915f27a Binary files /dev/null and b/assets/img/de0bc9e250f2.png differ diff --git a/assets/img/df905dcf3a1c.png b/assets/img/df905dcf3a1c.png new file mode 100644 index 0000000..8ef6ed5 Binary files /dev/null and b/assets/img/df905dcf3a1c.png differ diff --git a/assets/img/f0d5e062f11a.png b/assets/img/f0d5e062f11a.png new file mode 100644 index 0000000..25c4ff7 Binary files /dev/null and b/assets/img/f0d5e062f11a.png differ diff --git a/assets/img/f34fb7fdeea5.png b/assets/img/f34fb7fdeea5.png new file mode 100644 index 0000000..f9049b9 Binary files /dev/null and b/assets/img/f34fb7fdeea5.png differ diff --git a/assets/img/f989a64fe960.png b/assets/img/f989a64fe960.png new file mode 100644 index 0000000..37be383 Binary files /dev/null and b/assets/img/f989a64fe960.png differ diff --git a/assets/img/fa7b793b47e8.png b/assets/img/fa7b793b47e8.png new file mode 100644 index 0000000..fc350f8 Binary files /dev/null and b/assets/img/fa7b793b47e8.png differ diff --git a/assets/img/fd875eb78785.png b/assets/img/fd875eb78785.png new file mode 100644 index 0000000..df175d3 Binary files /dev/null and b/assets/img/fd875eb78785.png differ diff --git a/assets/resources/book.png b/assets/resources/book.png new file mode 100644 index 0000000..2216629 Binary files /dev/null and b/assets/resources/book.png differ diff --git a/assets/resources/diet.png b/assets/resources/diet.png new file mode 100644 index 0000000..6548de4 Binary files /dev/null and b/assets/resources/diet.png differ diff --git a/assets/resources/food.png b/assets/resources/food.png new file mode 100644 index 0000000..04552d9 Binary files /dev/null and b/assets/resources/food.png differ diff --git a/assets/resources/med.png b/assets/resources/med.png new file mode 100644 index 0000000..0a05510 Binary files /dev/null and b/assets/resources/med.png differ diff --git a/assets/resources/music.png b/assets/resources/music.png new file mode 100644 index 0000000..b1c2b71 Binary files /dev/null and b/assets/resources/music.png differ diff --git a/assets/resources/report.png b/assets/resources/report.png new file mode 100644 index 0000000..d22e75b Binary files /dev/null and b/assets/resources/report.png differ diff --git a/assets/resources/sleep.png b/assets/resources/sleep.png new file mode 100644 index 0000000..25c4ff7 Binary files /dev/null and b/assets/resources/sleep.png differ diff --git a/assets/resources/sport.png b/assets/resources/sport.png new file mode 100644 index 0000000..295b8c7 Binary files /dev/null and b/assets/resources/sport.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..564f65f --- /dev/null +++ b/index.html @@ -0,0 +1,1719 @@ + + + + + + + +妈妈盒子 · 孕育陪伴 + + + + + +
+ + +
+ +
+
+
+
+ + + + +
+
妈妈盒子
+

每次记录都是一生记忆

+
+ +
+
+ + + + +
24孕周
+
+
+3天
+
+ + +
+
+ + + 小汤圆在妈妈肚子里 + + + 已经生活了 171 天 +
+
+
+ + + + + +
+ +
+ + +
+

宝宝的孕育工具

+ +
+

妈妈每记录一次,宝宝就多一段成长记忆

+ +
+
+ + + +
+ +
+ + + + + +
+
+
+ + +
+
+ 今天 + +

宝宝的今日脚印

+ +
+ +

把每次记录变成孩子一生的记忆

+ + +
+
09:10胎动 12 次来源 · 数胎动
+
13:00体重 58.2kg来源 · 孕期体重
+
16:30产检提醒来源 · 孕检日历
+
20:15睡前散步 20 分钟来源 · 运动记录
+
+ + +
+ + +
+
+

本周孕育指南 · 孕—周

+ +
+

适配孕周的专属孕育参考资料

+ +
+ + + +
+ +

128 份孕育资料等你来领 · 查看全部 ›

+
+
+
+ +
+
+
+
+ + +
+
+ +
+ +

宝宝的成长档案

+ +
+ +
+

孕 24 周 +3 天

+

像一根玉米了 · 约 600 克

+
+
+ +
+
+ 孕程 60% + 已陪伴 171 天 +
+
+
+ +
+ +
+
+ 正在自动归档 + 第 24 周 +
+ +
+ + +
+
+ 今日已归档 4 条 + 累计 386 条 + 来自 9 个孕期工具 +
+
+ +
+ + + + + + +
+ + +
+
+
+
体重
+
58.2kg
+
↑ 7.7kg
+ +
+
+
日均胎动
+
31次
+
正常
+ +
+
+
估计体重
+
600g
+
P55
+ +
+
+ +
+
妈妈体重 · 孕12–24周
+ + + + + + + + + + +
12周16周20周24周
+
我的体重推荐区间
+
+ +
+
双顶径
6.1cmP58
+
股骨长
4.4cmP52
+
腹围
19.6cmP61
+
+
+ + + +
+
+
+
+

早上 9 点 10 分,他动了 12 次。你说那感觉像有人在里面轻轻敲门。

+ 来自「数胎动」 +
+
+
+

大排畸那天,你第一次看清了他的侧脸。医生说「一切都好」,你在诊室外站了很久。

+ 来自「孕检日历」+「B超影像」 +
+
+
+

傍晚散步时,肚子里忽然有一下轻轻的翻涌 —— 第一次胎动。你愣在原地,然后笑了。

+ 来自「胎动记录」 +
+
+
+

第一次听见心跳,很快,像小火车。你把那 12 秒录了下来。

+ 来自「胎心记录」 +
+
+
+

那两条线出现的早晨,你把它拍下来,发给了他。你们谁都没说话,笑了很久。

+ 来自「妈妈手记」 +
+
+
+ + + +
+ +
+ + + + + + + +
+
+ + +
+
+ +
+
+ + + + + +
+
+ +

宝宝写给你的信

+ +

共 24 封

+

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

+
+ + +
+ + +
+ + + + + +
+
+ +
+ +
+
这里还没有内容
+
+
+ +
+
+
+ + + + +
+ +
+
+ + + +
+
+
小满妈妈
+ +

已连续记录 42 天

+
+ + +
+
+ +
+ + +

孕期信息(必填)

+ + + +

专属福利

+ + +

设置与服务

+
+ + + + +
+
+
+
+ + + + +
+
+ + + 宝宝来信 + +
+
+ +
+
+

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

+

今天我翻了个身

+ + + +
+
+
+
+
+
+ +
+ +
+ + + + +
+
+
+ + +
+
+ + AI 孕育小结 + +
+ +
+ +
+ + +
+ 孕24周+3 + 9月3日 周二 +
+ + 安心 + +

妈妈,今天你数到我动了 3 次。我今天精神特别好,这是踏实的一天。

+
+
+ +
+ 01 + +

今日小建议

+ +
+
+ +
+ 02 +

往期小结

+ + +
+
+ +

小结由 AI 依据你的记录生成,是陪你一起看记录的伙伴,不代替医生的判断。

+
+
+ + +
+
+ + 孕育资源库 + + +
+
+ + + +

128 份资源按孕周持续更新 · 首次领取需添加顾问微信

+
+ + + + + + + + + + +
+
+

+
+
+ + +
+
+ + 资源详情 + +
+
+
+ 产检 +

《糖耐检查全攻略》

+

孕24-28周必看 · 附检查前饮食清单

+
+
+

相关推荐

+
+
+ +
+ + +
+
+ + +
+
+ + 本周档案 +
+
+ +
+ +
+

本周档案

+

这一周的记录已经排好版了。

+
+
+ +
+ +
+

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

+
+
+ + + + +
+
+ + +
+
+ + 分享卡 +
+
+
+ +
+

这封信的分享卡

+

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

+
+
+ +
+
+

+
+
+ + + +
+
+ + +
+
+ + 隐私与权限 + +
+
+
+

+
+

+
+
+ + +
+
+
+
+
+

记一下

+

— · 今天已记 4 条

+
+ +
+
+
今天已使用
+
+ + + + +
+ +
全部孕期工具
+
+ + + + + + + + + + + + + + + + +
+
+ +
+ + +
+
+ +
🔍
+ +
+
+
最近搜索
+
+ +
+
大家都在搜
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..249cc25 --- /dev/null +++ b/js/app.js @@ -0,0 +1,4137 @@ + +/* ================= 妈妈盒子 H5 · 交互 ================= */ +(function () { + 'use strict'; + const $ = (s, c) => (c || document).querySelector(s); + const $$ = (s, c) => Array.from((c || document).querySelectorAll(s)); + + /* ---------- 孕期日期算术 ---------- + 这一小块原来长在下面「孕期信息」那一节里(DOM 事件旁边)。挪到最前面, + 是因为信件日期现在要按孕周现推,而信件那一节在文件中段就要用到这些函数。 + 它们全是纯数学,一个 DOM 都不碰,放最前面不会有副作用; + 留在原地则 state.week 只能先塞个占位数,等 init() 再补 —— 中间那段时间 + 任何按孕周算日期的代码拿到的都是假数据。 */ + const DAY_MS = 86400000, TERM = 280; // 足月 40 周 = 280 天 + const midnight = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }; + const clampGA = g => Math.max(0, Math.min(42 * 7, g)); + const gaFromDue = due => clampGA(TERM - Math.round((midnight(due) - midnight(new Date())) / DAY_MS)); + const dueFromGA = ga => new Date(midnight(new Date()).getTime() + (TERM - clampGA(ga)) * DAY_MS); + function addMonths(d, n) { + const x = new Date(d.getFullYear(), d.getMonth() + n, d.getDate()); + /* 8月31日 +3 个月 = 11月31日,JS 会溢出到 12月1日,退回当月最后一天 */ + if (x.getDate() !== d.getDate()) x.setDate(0); + return x; + } + /* 日期显示:fmtWhen 给往来消息用(当天就写「今天」),fmtFull 给信件抬头用 */ + const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + const pad2 = n => (n < 10 ? '0' : '') + n; + function fmtWhen(ts) { + const d = new Date(ts), now = new Date(); + const hm = d.getHours() + ':' + pad2(d.getMinutes()); + 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 周封顶,等于一进门就「超预产期」了。 + 改成相对今天推 3 个月:无论哪天第一次打开,默认都落在孕 27 周上下, + 是个说得通的起点。用户自己选过预产期之后,这里就不再参与。 */ + const DEFAULT_DUE = addMonths(midnight(new Date()), 3); + const DEFAULT_GA = gaFromDue(DEFAULT_DUE); + + /* ---------- 全局状态 ---------- */ + const state = { + screen: 'home', + kickToday: 3, + kickQuick: 1, + todayCount: 4, + unread: 1, + favCount: 18, + letterFilter: 'all', + resCat: 'all', + resKey: 'book', + resFav: [], // 已收藏的资源 key,数组顺序即收藏顺序 + resGot: [], // 已领取的资源 key(点过「立即领取」的) + babyName: '小汤圆', + /* 这三项一进门就是真值(默认预产期 = 今天 +3 个月,见上面 DEFAULT_DUE)。 + 以前它们是写死的占位 24/3/null,等 init() 才补 —— 但信件日期是按孕周 + 现推的,推的时候还没轮到 init(),拿到的就全是假的。 */ + week: Math.floor(DEFAULT_GA / 7), day: DEFAULT_GA % 7, + due: DEFAULT_DUE, + babySex: 'unknown', + /* 来信列表左边那颗宝宝头像。空 = 还没设,显示占位的「宝」字; + 设了就是一段 data URI(点头像选图后就地读出来的,没有后端)。 + ⚠️ 和 userName / babyName 一样**不写 localStorage** —— 现状只持久化 + resFav/resGot/wxUnlocked/pending,刷新会回到「宝」字。 + 这是已知现状、本批没扩范围,不是漏做。 + ⚠️ 更要紧的是:data URI 动辄几百 KB,而 localStorage 通常只有 5MB。 + 哪天真要持久化它,得先压缩(画到 canvas 缩到 ~128px 再 toDataURL), + 直接 setItem 原图会在某些机型上抛 QuotaExceededError。 */ + babyAvatar: '', + replyCount: 0, // 妈妈已寄出的回信条数 + userName: '小满妈妈', + /* ⚠️ 这里原来还有一条 userBio(一句话签名)。整块已按用户要求删除, + 连同 #pfBio 输入框、#meBio 展示行、syncBio()。别只加回这一条 —— + 一个没人读的 state 字段是最难发现的死代码。 */ + wxUnlocked: false // 是否已用兑换码解锁资料领取(见下面 WXOK_KEY) + }; + + /* ---------- 资源的收藏 / 已领(都跨会话保留) ---------- + 只存 key,资源本身仍从 RES 读,改文案不会让旧记录失效。 + 「已领」原来只是详情页那个按钮上的一个 class,退出去再进来就没了 —— + 用户根本记不住自己领过哪几份,回到列表页只能一个个点进去看。 + 现在和收藏一样落盘,列表页直接打「已领」标。 */ + const RESFAV_KEY = 'mmbox.resfav.v1'; + const RESGOT_KEY = 'mmbox.resgot.v1'; + const loadKeys = (k, into) => { + try { + const arr = JSON.parse(localStorage.getItem(k) || 'null'); + if (Array.isArray(arr)) state[into] = arr.filter(x => typeof x === 'string'); + } catch (e) { /* 隐私模式下会抛错,忽略即可,只是不跨会话 */ } + }; + loadKeys(RESFAV_KEY, 'resFav'); + loadKeys(RESGOT_KEY, 'resGot'); + function saveResFav() { + try { localStorage.setItem(RESFAV_KEY, JSON.stringify(state.resFav)); } catch (e) {} + } + function saveResGot() { + try { localStorage.setItem(RESGOT_KEY, JSON.stringify(state.resGot)); } catch (e) {} + } + + /* ---------- 加企业微信换来的「解锁」 ---------- + 和上面两个一样跨会话,但**不复用 loadKeys()**:那是个数组读取器, + 把一个布尔值塞成 ['1'] 只是为了省几行,代价是数据形状被扭曲, + 以后读这段的人得先想明白「为什么解锁状态是个数组」。 + ⚠️ 判定写死成 === '1':读不出、读成别的、localStorage 整个抛错, + 一律往「锁着」倒。解锁是要给东西出去的动作,含糊时不能默认放行。 */ + const WXOK_KEY = 'mmbox.wxunlock.v1'; + try { state.wxUnlocked = localStorage.getItem(WXOK_KEY) === '1'; } catch (e) {} + function saveWxUnlocked() { + try { localStorage.setItem(WXOK_KEY, '1'); } catch (e) {} + } + + /* ---------- Toast ---------- */ + const toastEl = $('#toast'); + let toastTimer; + function toast(msg) { + if (!msg) return; + toastEl.textContent = msg; + toastEl.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800); + } + function haptic() { if (navigator.vibrate) navigator.vibrate(8); } + + /* ---------- 涟漪 ---------- */ + document.addEventListener('pointerdown', e => { + const t = e.target.closest('.ripple'); + if (!t) return; + const r = t.getBoundingClientRect(); + const size = Math.max(r.width, r.height) * 2.1; + const sp = document.createElement('span'); + sp.className = 'rp'; + sp.style.cssText = `width:${size}px;height:${size}px;left:${e.clientX - r.left}px;top:${e.clientY - r.top}px`; + t.appendChild(sp); + setTimeout(() => sp.remove(), 600); + }); + + /* ---------- 通用 data-toast ---------- */ + document.addEventListener('click', e => { + const t = e.target.closest('[data-toast]'); + if (t) { toast(t.dataset.toast); haptic(); } + }); + + /* ================= 底部导航 / 页面切换 ================= */ + const screens = $$('.screen'); + const tabs = $$('.tab[data-tab]'); + + /* 平滑滚顶:老旧 WebView 不支持 Element.scrollTo(options),降级为直接赋值 */ + function scrollTop(el) { + if (!el) return; + try { el.scrollTo({ top: 0, behavior: 'smooth' }); } + catch (e) { el.scrollTop = 0; } + } + + function go(name) { + if (state.screen === name) { + scrollTop($(`#screen-${name}`)); // 再次点击当前 tab 回到顶部 + return; + } + state.screen = name; + screens.forEach(s => s.classList.toggle('is-active', s.dataset.screen === name)); + 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'); + /* 档案页的孕程已经从圆环换成细条了,这里跟着换成「归零再长回去」。 + 归零和写目标值必须隔一帧,同一帧里改两次浏览器只会看到最后那个值, + transition 无从触发(和 animateRings 里那次强制重绘是一个道理)。 */ + if (name === 'archive') { requestAnimationFrame(() => { replayArBar(); }); } + if (name === 'home') { requestAnimationFrame(() => { animateRings(cur); }); } + haptic(); + } + tabs.forEach(t => t.addEventListener('click', () => go(t.dataset.tab))); + $$('[data-goto]').forEach(b => { + const fire = () => { + const g = b.dataset.goto; + if (g === 'letter-detail') openDetail(); + else go(g); + }; + b.addEventListener('click', fire); + /* 真 '; + /* 开关行:aria-checked 让读屏也能拿到状态,样式只认 .on */ + return ''; + }).join(''); + return (g.t ? '

' + g.t + '

' : '') + '
' + rows + '
'; + }).join(''); + + $('#stBody').innerHTML = html; + scrollTop($('#detailSetting .detail-body')); + } + + /* 开关走委托:内容是每次打开重新渲染的,逐个绑定会随渲染丢失 */ + $('#stBody').addEventListener('click', e => { + const b = e.target.closest('.st-row.sw'); + if (!b) return; + const on = !b.classList.contains('on'); + b.classList.toggle('on', on); + b.setAttribute('aria-checked', on ? 'true' : 'false'); + haptic(); + toast(b.dataset.sw + (on ? ' 已开启' : ' 已关闭')); + }); + + /* ================= AI 孕育小结 内容页 ================= */ + /* ---------- AI 小结数据 ---------- + 写建议有一条铁律:不吓人。 + 孕妇本来就容易多想,一句「连续 3 天减少要就医」「久坐会水肿」, + 看完只会开始数自己今天动了几次、腿是不是肿了。 + 所以这里全部改成「做了什么很好」「可以试试这样」的正向说法, + 不提异常、不提风险、不提就医,把判断留给产检和医生 + (页尾那句免责声明也已经改成「不代替医生的判断」)。 + + 每条建议带 cat(分类)+ tone(配色)+ head(一句标题)+ txt(一句正文), + 渲染成卡片而不是一行长句 —— 一眼能看清在讲哪方面。 + stats 第三项是和昨天的对比,up / dn / eq 决定颜色。 + + ---------- 口吻:这些话是宝宝说的,不是平台说的 ---------- + 原来 txt 和 tips 都是第三人称的平台播报:「今天你数到 3 次胎动,小家伙很有精神」 + 「午后和睡前是他最爱动的时候」—— 妈妈、宝宝、系统三方在场,宝宝被当成第三个人 + 指指点点,读起来就是一份日报。 + 现在全部改成宝宝用第一人称直接对妈妈说:称呼妈妈为「你」,自称「我」, + 该出现宝宝的地方一律是「我」,不再有「他 / 小家伙 / 宝宝」。 + 建议也照这个来 —— 不是「你应该怎样」,而是「你这样做我会更舒服」。 + + 两条边界,别越过去: + · 口吻变软了,但不吓人那条铁律照旧 —— 不提异常、不提风险、不提就医。 + · 只有宝宝在说的话改口吻。页尾免责声明、气泡上那枚 .ab-badge「AI」 + 是系统在说话,保持原样,否则就成了宝宝自己声明自己不是医生。 + (气泡落款那句「AI自动汇总你每日记录」第五批按用户要求删了, + 机器生成的提示现在只剩那枚角标。) + · mood 那枚小圆标(安心 / 安稳…)说的是这一天的状态,不是谁在说话,不动。 + + ⚠️ AI_LOGS[0].txt 在首页气泡里还有一份手写副本(index.html 的 .ab-say, + 带 和
的排版所以没法共用变量)。改这一条务必两处一起改。 */ + const AI_LOGS = [ + { + date: '9月3日 周二', week: '孕24周+3', mood: '安心', + txt: '妈妈,今天你数到我动了 3 次。我今天精神特别好,这是踏实的一天。', + stats: [['胎动', '3 次', '+1'], ['体重', '58.2 kg', '+0.2'], ['睡眠', '7.5 h', '-0.5']], + /* 三条建议全是宝宝在跟妈妈说话,但称呼**恰好只放两条**(第 2、3 条)。 + 三条都以「妈妈,」起头会读成排比,像模板灌出来的,反而不像在说话; + 所以第 1 条把称呼让出去,改放进正文里 —— 整张卡仍有「妈妈」,只是不在句首。 + ⚠️ 这个「2 / 3」是被测试钉住的,不是随手写的。要改先想清楚再改断言。 + ⚠️ 同样**不做运行时随机**:这一页自称「小结每天 21:00 自动生成」(⋯ 按钮的 toast), + 重开一次文案就变,等于当场拆穿自己。真要按天变,得按日期做 seed 从更大的文案池里取。 */ + tips: [ + { cat: '胎动', tone: 'p', head: '午后和睡前是我最爱动的时候', + txt: '妈妈,这两次你都记下来啦。以后就固定在这两个点,安安静静陪我待一会儿好不好,我会更有安全感哟。' }, + /* ⚠️ 这里的 0.3kg 是**妈妈的**周增重(和上面 stats 里「体重 58.2 kg +0.2」同源), + 所以只加称呼、不改人称。改成「我这周长了 0.3kg」会有两处坏掉: + 下面正文「不用为了我硬撑着多吃」讲的是妈妈的饭量,标题一换就对不上; + 而且 0.3kg 对孕 24 周的胎儿明显偏大(实际周增约 100g),成了条假数据。 */ + { cat: '饮食', tone: 'g', head: '妈妈,你这周长了 0.3kg,刚刚好', + txt: '不用为了我硬撑着多吃,你按平时的胃口来,我就吃得饱饱的啦。' }, + { cat: '放松', tone: 'b', head: '妈妈,你睡前那 20 分钟散步,我很喜欢', + txt: '你走路的时候我像被轻轻摇着。这几天你睡得比上周沉,我们每天都去走一走吧。' } + ] + }, + { + date: '9月2日 周一', week: '孕24周+2', mood: '安稳', + txt: '妈妈,今天我安静了一点,其实是在补觉;你也多睡了半小时,我很开心。', + stats: [['胎动', '2 次', '-1'], ['体重', '58.0 kg', '+0.1'], ['睡眠', '8.0 h', '+1.0']] + }, + { + date: '9月1日 周日', week: '孕24周+1', mood: '愉快', + txt: '周末你和爸爸放了胎教音乐,音乐一停我就动个不停 —— 我还想再听一遍。', + stats: [['胎动', '4 次', '+1'], ['体重', '57.9 kg', '+0.1'], ['睡眠', '7.0 h', '-0.2']] + }, + { + /* 这条原来写「进入孕 24 周啦,他大约 30cm、600g」—— 又一处写死的孕周。 + 往期第 4 条对应的是四天前,孕周随用户当前进度变,这句话必然对不上。 + 换成不点具体周数的说法。 */ + date: '8月31日 周六', week: '孕24周', mood: '期待', + txt: '我的耳朵最近越来越灵啦。今天你说话的时候,我动了两下回应你。', + stats: [['胎动', '3 次', '0'], ['体重', '57.8 kg', '+0.2'], ['睡眠', '7.2 h', '+0.4']] + }, + { + date: '8月30日 周五', week: '孕23周+6', mood: '用心', + txt: '这一周你为我记了 21 条,是到现在最认真的一周。谢谢你呀妈妈。', + stats: [['胎动', '3 次', '0'], ['体重', '57.6 kg', '+0.1'], ['睡眠', '6.8 h', '-0.3']] + } + /* 往期这四条只留 date / week / mood / txt / stats。 + 原本每条还挂着三条 tips,是给往期弹窗铺 1/2/3 建议卡用的,弹窗不显示了, + 数据也就跟着删掉 —— 留着不用,下次翻到这儿的人只会以为哪里漏渲染了。 + aiTipsHTML 现在只有 AI_LOGS[0](今日小建议)一个消费者。 */ + ]; + /* AI 小结的 date / week 也和信件一样是写死的('9月3日 周二' / '孕24周+3')。 + 这五条按定义顺序就是「今天、昨天、前天…」,所以直接按下标当天数往回推, + 和 stampLetters() 是同一套逻辑。不这么做的话,档案打印出来会是 + 封面「孕 27 周 +2 天」、内页「今天 · 孕24周+3」,自己打自己的脸。 */ + function stampAiLogs() { + AI_LOGS.forEach((L, i) => { + const d = new Date(backAt(i, [12, 0])); + const g = clampGA(state.week * 7 + state.day - i); + L.date = (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + WEEKDAY[d.getDay()]; + L.week = '孕' + Math.floor(g / 7) + '周' + (g % 7 ? '+' + (g % 7) : ''); + }); + } + stampAiLogs(); + + /* 这两段今日卡和往期弹窗都要用,抽出来共用一份 */ + const aiStatsHTML = d => d.stats.map(s => { + /* 和昨天持平就写「持平」,别显示一个孤零零的「0」 */ + const n = parseFloat(s[2]); + const cls = !n ? 'eq' : n > 0 ? 'up' : 'dn'; + const txt = !n ? '持平' : (n > 0 ? '↑' : '↓') + Math.abs(n); + return '
' + s[1] + '' + txt + '' + + '' + s[0] + '
'; + }).join(''); + const aiTipsHTML = d => d.tips.map((t, i) => + '
' + + '' + + '' + t.cat + '' + + '

' + t.head + '

' + + '

' + t.txt + '

' + + '
').join(''); + + function renderAI(idx) { + const d = AI_LOGS[idx] || AI_LOGS[0]; + $('#aiWeek').textContent = d.week; + $('#aiDate').textContent = d.date; + $('#aiMood').textContent = d.mood; + $('#aiTxt').textContent = d.txt; + $('#aiStats').innerHTML = aiStatsHTML(d); + $('#aiTips').innerHTML = aiTipsHTML(d); + /* 往期列表跳过索引 0 —— 那就是顶上今日卡这一条,列出来是重复。 + data-ai 仍写真实索引,好让 openAiLog 直接取。 */ + $('#aiLogs').innerHTML = AI_LOGS.slice(1) + .map((l, i) => '').join(''); + /* 原来点一条是把顶上的今日卡整个换掉,看完想回到「今天」没有出口。 + 改成在本页弹一层浮窗,看完点底下的叉就走,今日卡始终留在原位。 */ + $$('#aiLogs .ai-log').forEach(b => + b.addEventListener('click', () => openAiLog(+b.dataset.ai))); + } + + function openAiLog(idx) { + const d = AI_LOGS[idx]; + if (!d) return; + $('#alogWeek').textContent = d.week; + $('#alogMood').textContent = d.mood; + $('#alogTitle').textContent = d.date; + $('#alogTxt').textContent = d.txt; + /* 往期只给当天的数,不再铺 1/2/3 建议卡。#alogTips 连同标记一起删了, + 这里必须跟着删 —— 只删 HTML 的话,一点往期就是 null.innerHTML 崩溃。 */ + $('#alogStats').innerHTML = aiStatsHTML(d); + $('#modalAiLog').scrollTop = 0; /* .modal 自己带 overflow-y:auto,上次翻到哪儿要清掉 */ + openModal('modalAiLog'); + } + + /* ================= 孕育资源库:列表页 + 内容页 ================= */ + const RES = { + book: { + img: 'assets/img/9f3fa8edf4aa.png', cat: '产检', tint: 'o', + title: '《糖耐检查全攻略》', desc: '孕24-28周必看 · 附检查前饮食清单', + meta: ['8 分钟读完', '12.8 万妈妈已领取', '孕24-28周'], + body: [['什么是糖耐量检查', '糖耐量检查(OGTT)用于筛查妊娠期糖尿病,一般安排在孕 24–28 周。检查当天需空腹抽血一次,喝下 75g 葡萄糖水后 1 小时、2 小时各抽血一次,共三次。'], + ['检查前 3 天怎么吃', '不需要刻意节食。保持平时饮食即可,但每天碳水化合物摄入不少于 150g,否则可能出现假阳性。避免油炸、含糖饮料与夜宵。'], + ['检查当天注意事项', '前一晚 22:00 后禁食,可少量饮水。带上水杯、小零食(抽完最后一管血再吃)和陪同家属。喝糖水要在 5 分钟内喝完,之后静坐等待,不要来回走动。'], + ['结果怎么看', '空腹 < 5.1、1 小时 < 10.0、2 小时 < 8.5(mmol/L),任意一项超标即诊断为妊娠期糖尿病。多数可通过饮食与运动控制,不必过度紧张。']] + }, + food: { + img: 'assets/img/3657b1f76026.png', cat: '营养', tint: 'g', + title: '孕期营养全书', desc: '按孕周编排的一日三餐参考', + meta: ['12 分钟读完', '20.4 万妈妈已领取', '全孕期'], + body: [['孕中期的能量需求', '孕中期每天比孕前多约 300 kcal,相当于一杯牛奶加一个鸡蛋加半碗米饭,不需要「吃两人份」。'], + ['四类必须补的营养素', '钙 1000mg/天(奶制品、豆制品)、铁 24mg/天(红肉、动物肝脏)、DHA 200mg/天(深海鱼)、叶酸 400μg/天(绿叶菜、补充剂)。'], + ['一日三餐参考', '早餐:牛奶 + 全麦面包 + 鸡蛋 + 一份水果;午餐:杂粮饭 + 瘦肉 + 深绿色蔬菜 + 豆制品;晚餐:清淡为主,鱼虾 + 蔬菜 + 少量主食;加餐:坚果一小把或酸奶一杯。'], + ['要少碰的东西', '生食(刺身、溏心蛋)、未杀菌乳制品、高汞鱼(金枪鱼、鲨鱼)、酒精与浓咖啡。']] + }, + music: { + img: 'assets/img/67405412c52c.png', cat: '胎教', tint: 'p', + title: '胎教音乐合集', desc: '30 首适合孕中期的舒缓曲目', + meta: ['30 首 · 共 96 分钟', '9.6 万妈妈已领取', '孕16周起'], + body: [['宝宝什么时候能听见', '孕 16 周左右听觉器官初步形成,24 周后已能对外界声音作出反应,这是胎教音乐最合适的开始时间。'], + ['正确的听法', '不要把设备贴在肚皮上。房间内正常音量播放即可,控制在 60 分贝以下,每次 15–20 分钟,每天 1–2 次。'], + ['选什么曲子', '节奏平稳、旋律简单、无强烈鼓点的音乐最合适,如古典小品、轻钢琴、自然音。妈妈自己听着放松,才是最好的胎教。'], + ['比音乐更重要的事', '妈妈的声音是宝宝最熟悉的声音。每天和宝宝说几句话,效果胜过任何一张专辑。']] + }, + sport: { + img: 'assets/img/72910df66ee3.png', cat: '运动', tint: 'g', + title: '孕中期安全运动指南', desc: '每天 30 分钟,缓解腰背酸痛', + meta: ['6 分钟读完', '7.2 万妈妈已领取', '孕13-28周'], + body: [['为什么孕中期最适合运动', '早孕反应消退、腹部尚未过分隆起,是整个孕期活动最舒适的窗口,规律运动能降低妊娠糖尿病与子痫前期风险。'], + ['推荐的四种运动', '快走、游泳、孕妇瑜伽、固定式单车。每周 5 天、每次 30 分钟,强度以「能正常说话但唱不了歌」为准。'], + ['必须避开的动作', '仰卧位动作(孕 16 周后)、跳跃与急转、需要平衡的运动、任何有腹部撞击风险的项目。'], + ['出现这些情况立即停止', '阴道出血、规律宫缩、头晕胸闷、羊水样液体流出,应停止运动并及时就医。']] + }, + sleep: { + img: 'assets/img/f0d5e062f11a.png', cat: '心理', tint: 'b', + title: '睡不好怎么办', desc: '孕期失眠的 7 个改善方法', + meta: ['5 分钟读完', '15.1 万妈妈已领取', '全孕期'], + body: [['孕期失眠很常见', '约 70% 的孕妇有不同程度的睡眠问题,多由激素变化、尿频、腿抽筋和焦虑引起,绝大多数不需要药物治疗。'], + ['睡姿怎么选', '孕中晚期优先左侧卧,可在两腿之间与腰后各垫一个枕头,减轻下腔静脉压迫。'], + ['睡前一小时的仪式', '关掉主灯、放下手机、温水泡脚 10 分钟、轻音乐或呼吸练习,让身体建立入睡信号。'], + ['白天该做的事', '固定起床时间、午睡不超过 30 分钟、下午之后不摄入咖啡因、傍晚适量活动。']] + }, + diet: { + img: 'assets/img/dd5639fde61d.png', cat: '营养', tint: 'o', + title: '孕期体重管理', desc: '孕前 BMI 决定你该长多少', + meta: ['7 分钟读完', '11.3 万妈妈已领取', '全孕期'], + body: [['先算孕前 BMI', 'BMI = 体重(kg) ÷ 身高(m)²。孕前偏瘦(<18.5)建议全孕期增重 12.5–18kg,正常(18.5–24.9)11.5–16kg,超重(25–29.9)7–11.5kg。'], + ['孕中期的增重节奏', '孕中晚期每周增重 0.3–0.5kg 较理想。单周超过 1kg 多为水肿或饮食过量,需要复盘。'], + ['称重的正确方法', '固定每周同一天、同一时间、同样着装,晨起排空后测量,只看趋势不纠结单次数字。'], + ['长得太快或太慢', '连续两周超标先减少精制主食与含糖饮料;增长过慢则增加优质蛋白与健康脂肪,并排查甲功与贫血。']] + }, + report: { + img: 'assets/img/b855b45df1f3.png', cat: '产检', tint: 'b', + title: '产检时间表(全孕期)', desc: '13 次产检,一次都不漏', + meta: ['4 分钟读完', '26.7 万妈妈已领取', '全孕期'], + body: [['孕早期(≤13周)', '6–8 周建卡 + B超确认宫内孕;11–13⁺⁶ 周 NT 检查与早期唐筛。'], + ['孕中期(14–27周)', '15–20 周唐筛或无创 DNA;20–24 周大排畸;24–28 周糖耐 + 血常规复查。'], + ['孕晚期(28周后)', '28–36 周每 2 周一次;36 周后每周一次,含胎心监护、B超估重与骨盆评估。'], + ['每次都要带的东西', '母子健康手册、既往报告、医保卡、水杯与小零食。建议提前一天在手机里存好问题清单。']] + }, + med: { + img: 'assets/img/4657f50f42c8.png', cat: '心理', tint: 'p', + title: '孕期情绪自查手册', desc: '分清正常波动与孕期抑郁', + meta: ['6 分钟读完', '5.9 万妈妈已领取', '全孕期'], + body: [['情绪起伏是正常的', '激素剧烈变化叠加身份转变,孕期爱哭、易怒、突然焦虑都很常见,本身不等于抑郁。'], + ['需要警惕的信号', '持续两周以上的情绪低落、对什么都提不起兴趣、睡眠与食欲明显改变、反复自责或出现伤害自己的念头。'], + ['可以自己做的三件事', '把感受说出来而不是憋着;每天留 20 分钟只属于自己;减少无休止的搜索式焦虑。'], + ['寻求帮助不丢人', '产科、心理科都可以提供帮助,孕期心理干预安全且有效,越早越好。']] + } + }; + const RES_ORDER = ['book', 'food', 'music', 'report', 'sport', 'diet', 'sleep', 'med']; + + /* 资源本体统一放在百度网盘,详情页只负责呈现内容。 + ——「后台配置」就是改下面这张表,页面结构一行都不用动。 + 没单独配置的走 PAN_DEFAULT,保证新资源上架不会点出个空弹窗。 */ + const PAN_DEFAULT = { url: 'https://pan.baidu.com/s/1mamabox-pregnancy', code: 'mmhz' }; + const PAN = { + book: { url: 'https://pan.baidu.com/s/1mmb-tangnai', code: 'tn28' }, + food: { url: 'https://pan.baidu.com/s/1mmb-yingyang', code: 'yy66' }, + music: { url: 'https://pan.baidu.com/s/1mmb-taijiao', code: 'tj30' }, + report: { url: 'https://pan.baidu.com/s/1mmb-chanjian', code: 'cj12' }, + sport: { url: 'https://pan.baidu.com/s/1mmb-yundong', code: 'yd08' }, + diet: { url: 'https://pan.baidu.com/s/1mmb-shipu', code: 'sp21' }, + sleep: { url: 'https://pan.baidu.com/s/1mmb-shuimian', code: 'sm07' }, + med: { url: 'https://pan.baidu.com/s/1mmb-qingxu', code: 'qx19' } + }; + const panOf = k => PAN[k] || PAN_DEFAULT; + + /* 加企业微信之后顾问发的兑换码。和 PAN 表放一起是因为它们是同一类东西 —— + 「运营在后台配的值」,改的时候一并改,不用满文件找。 + ⚠️ 请不要把它当访问控制看。整个站是一个纯前端单文件,这个码就明写在 + 源码里,翻一下就有;localStorage 改一行也能绕过去。它的作用是给 + 「白拿资料」加一道需要真人接触的摩擦,把人导到顾问那儿去,仅此而已。 + 真要锁住内容,得让网盘链接由服务端在校验后下发。 + ⚠️ trim() + toUpperCase() 两个都不能省:这个码是顾问在微信里发出来、 + 用户长按复制过来的,尾部带个空格、被输入法首字母大写成小写, + 在真机上几乎必然发生。为一个空格把一个已经加完好友的真实用户挡在 + 门外,是在自己辛苦搭的漏斗最后一格上抹毒药。 + ⚠️ 声明是 let 不是 const:本页托管在 box_app(/mama-box/)时, + fetchRemoteConfig() 会用后台下发的值覆盖它(见文件末尾)。 */ + let WX_CODE = 'MAMA2025'; + const wxCodeOK = v => String(v || '').trim().toUpperCase() === WX_CODE; + + const isGot = k => state.resGot.indexOf(k) >= 0; + function resCardHTML(k) { + const r = RES[k]; + const got = isGot(k); + /* 领过的那一份,「免费」标换成「已领 ✓」并给整张卡加 .is-got: + 「免费」是招揽的话,对已经领到手的人已经没意义了,占着同一个位置正好。 + 缩略图打勾角标是给快速扫列表用的 —— 一眼扫过去哪几份领过,不用逐行读标签。 */ + return ''; + } + function bindResCards(root) { + $$('.res-item', root).forEach(b => + b.addEventListener('click', () => openPanel('detailRes', b))); + } + function renderResList(cat) { + state.resCat = cat || 'all'; + /* 收藏不是分类而是筛选,默认排在最末。但只要用户真收藏过东西, + 它就成了最常用的一格 —— 排在末尾要横向滑到头才够得着,所以提到「全部」后面。 + 用 insertBefore/appendChild 挪节点(不是重建 innerHTML),init 时绑的点击事件才不会丢。 + 已经在第二位时 children[1] 就是 favChip 自己,insertBefore 原地不动,无副作用。 */ + const chipsRow = $('#resChips'), favChip = $('.chip-fav', chipsRow); + const gotChip = $('.chip-got', chipsRow); + const favN = state.resFav.length, gotN = state.resGot.length; + /* 「已领」排在「收藏」前面:领过的东西比收藏的更常回来找(要密码、要链接), + 两个都有内容时它更靠手。所以先插收藏、再插已领,已领就压在第二位。 */ + if (favN) chipsRow.insertBefore(favChip, chipsRow.children[1]); + else chipsRow.appendChild(favChip); + if (gotN) chipsRow.insertBefore(gotChip, chipsRow.children[1]); + else chipsRow.appendChild(gotChip); + favChip.textContent = favN ? '★ 收藏 ' + favN : '★ 收藏'; + gotChip.textContent = gotN ? '✓ 已领 ' + gotN : '✓ 已领'; + + /* fav / got 都不是分类,是「只看收藏过的 / 只看领过的」,所以各走一支 */ + const fav = state.resCat === 'fav', got = state.resCat === 'got'; + const keys = fav ? RES_ORDER.filter(k => state.resFav.indexOf(k) >= 0) + : got ? RES_ORDER.filter(isGot) + : RES_ORDER.filter(k => state.resCat === 'all' || RES[k].cat === state.resCat); + $('#resList').innerHTML = keys.map(resCardHTML).join(''); + bindResCards($('#resList')); + $('#resEmptyHint').textContent = keys.length + ? (fav ? '已收藏 ' + keys.length + ' 份资源' + : got ? '已领 ' + keys.length + ' 份 · 领过的资源随时回来这里取链接和提取码' + : '共 ' + keys.length + ' 份资源 · 更多内容按孕周陆续解锁') + : (fav ? '还没有收藏 · 打开任意资源,点右上角 ☆ 就能收进来' + : got ? '还没领过 · 打开任意资源,点底下「立即领取」就会记在这里' + : '该分类下暂无资源,换一个试试~'); + $$('#resChips .chip').forEach(c => + c.classList.toggle('is-active', c.dataset.rescat === state.resCat)); + } + $$('#resChips .chip').forEach(c => c.addEventListener('click', () => { + renderResList(c.dataset.rescat); + haptic(); + })); + + function fillRes(key) { + const r = RES[key] || RES.book; + state.resKey = RES[key] ? key : 'book'; + $('#rdImg').src = r.img; + $('#rdCover').className = 'rd-cover ' + r.tint; + $('#rdCat').textContent = r.cat; + $('#rdTitle').textContent = r.title; + $('#rdDesc').textContent = r.desc; + $('#rdMeta').innerHTML = r.meta.map(m => '' + m + '').join(''); + $('#rdBody').innerHTML = r.body + .map(p => '

' + p[0] + '

' + p[1] + '

').join(''); + $('#rdMore').innerHTML = RES_ORDER + .filter(k => k !== state.resKey).slice(0, 3).map(resCardHTML).join(''); + bindResCards($('#rdMore')); + /* 原来这里无条件重置成 ☆,收藏过的资源再打开会显示成没收藏。 + 改成按 state.resFav 回显 */ + const isFav = state.resFav.indexOf(state.resKey) >= 0; + const star = $('#resStar'); + star.classList.toggle('on', isFav); + star.textContent = isFav ? '★' : '☆'; + /* 底部左键已从「收藏」改成「资源库」,是个固定文案的导航按钮,不再需要回显。 */ + /* 和上面收藏同一个毛病:原来无条件重置成「立即领取」, + 领过的资源再打开又变回没领过的样子。现在按 state.resGot 回显。 + 领过之后按钮写「再拿一次链接」而不是「已领取 ✓」—— 它还是能点的, + 用户回来多半就是为了再看一眼提取码,写成完成态反而像被禁用了。 */ + const got = isGot(state.resKey); + $('#rdGet').textContent = got ? '再拿一次链接' : '立即领取'; + $('#rdGet').classList.toggle('got', got); + $('#detailRes .detail-body').scrollTop = 0; + } + $('#resStar').addEventListener('click', function () { + const i = state.resFav.indexOf(state.resKey); + const on = i < 0; + if (on) state.resFav.push(state.resKey); else state.resFav.splice(i, 1); + saveResFav(); + this.classList.toggle('on', on); + this.textContent = on ? '★' : '☆'; + /* 列表页就压在这一层下面,退回去时得是新的。 + 原来只在「正停在收藏筛选上」时才重渲染,但现在 chip 的位置也跟收藏数有关 + —— 收藏第一份资源之后返回列表,「收藏」得已经挪到第二位了,所以无条件重来一遍 */ + renderResList(state.resCat); + toast(on ? '已收藏到「我的书架」' : '已取消收藏'); + haptic(); + }); + /* 底部左键:去「孕育资源库」。 + ⚠️ 不能简单写 openPanel('detailResList')。用户多半是**从资源库列表点进这个详情页**的, + 此时 panelStack = [detailResList, detailRes],而 openPanel 开头有一句 + `if (reopen) return`(见上面 openPanel 的定义)—— 列表已在栈内,它会静默 return, + 页面纹丝不动,看着就像按钮坏了。而从首页书架直接进来的用户栈里没有列表, + 同一个按钮又是好的:不处理的话这颗按钮行为不一致。 + 所以分两种情况,但保证结果一致(点完一定停在资源库这一层): + 已在栈里 → 关掉压在它上面的层,退回去(返回键随之回主屏,符合「往回走」的心智) + 不在栈里 → 正常压栈打开 */ + $('#rdLib').addEventListener('click', () => { + const list = $('#detailResList'); + if (panelStack.indexOf(list) >= 0) { + while (panelStack.length && panelStack[panelStack.length - 1] !== list) closePanel(); + renderResList(state.resCat); // 收藏/领取状态可能刚变过,退回去得是新的 + haptic(); + } else { + openPanel('detailResList'); // openPanel 内部自带 renderResList + haptic + } + }); + + /* 真正把一份资源交出去:填网盘弹窗 → 记进已领 → 刷列表和按钮 → 弹出来。 + 从事件处理器里整个抽出来,是因为「用兑换码解锁成功」之后要原地做完全 + 一样的事。抄一份过去当然更快,但那两份迟早会分叉:哪天改了领取行为 + (比如加个埋点、换个 toast),只改得到眼前这条路,走闸门解锁的那批用户 + 拿到的还是旧行为,而且不报错、没人会发现。一个函数两处调,就没这回事。 + 参数收的是 key 而不是读 state.resKey:解锁那条路要给的是**当初被拦下的 + 那一份**,不是「详情页此刻停在哪一份」。 */ + function givePan(key) { + const r = RES[key] || RES.book; + const p = panOf(key); + $('#panTitle').textContent = r.title.replace(/[《》]/g, ''); + $('#panUrl').value = p.url; + $('#panCode').value = p.code; + /* 记进 state.resGot 并落盘,列表页那张卡就打上「已领 ✓」。 + 原来只给按钮加个 class,退出详情页就没了 —— 用户回到列表根本 + 看不出自己领过哪几份,只能一个个点进去确认。 + 重复点不再入库(已经领过就是再拿一次链接),renderResList 也就不用重画。 */ + if (!isGot(key)) { + state.resGot.push(key); + saveResGot(); + renderResList(state.resCat); + } + const btn = $('#rdGet'); + btn.classList.add('got'); + btn.textContent = '再拿一次链接'; + openModal('modalPan'); + haptic(); + } + + /* 点「立即领取」不再假装存进书架,而是把网盘链接和提取码给出来。 + 未解锁的用户先撞上企业微信二维码 —— 这是这一版加的那道闸门。 */ + $('#rdGet').addEventListener('click', () => { + /* ⚠️⚠️ 这个 return 必须发生在 givePan 之前,也就是在 resGot.push 之前。 + 写反了(先记一笔再拦)会一次坏两处: + 1. 这一份当场变成「已领」,用户关掉二维码再点一次,就从下面 + `!isGot(key)` 那个免验证通道大摇大摆走过去 —— 闸门两下失效; + 2. 列表页会给一份用户根本没拿到手的资料打上「已领 ✓」, + 他去「已领」筛选里找,找到的是一份自己从没见过链接的东西。 + 所以:拦下的时候,一个字段都不许落盘。 + `!isGot(key)` 是有意的 —— 闸门上线前就领过的老用户继续免验证, + 已经给出去的东西不追回来(代价:那批人从此看不见这道闸门)。 + ⚠️ 判定用 isGot(key) 而不是 state.resGot.length:那样一来,领过任意 + 一份就等于全站解锁,闸门只在最开头拦一次。 */ + const key = state.resKey; + if (!state.wxUnlocked && !isGot(key)) { openWx('gate', key); return; } + givePan(key); + }); + + /* ================= 企业微信顾问弹窗 ================= + 两个入口共用这一个弹窗: + gate —「我的资料被拦下了」,解锁成功后要接着把那份给他; + card —「我的」页那张服务卡主动点开的,没有待领的东西。 + 两条路的差别只有「解锁完之后做什么」,所以只用一个 wxPending 记着。 */ + const wxCodeEl = $('#wxCode'), wxErrEl = $('#wxErr'), wxModal = $('#modalWx'); + /* 被闸门拦下的那份资源 key。解锁成功后要用它把资料补给用户 —— + 用户当时点的是「立即领取」,不是「解锁」;让他解锁完再回去点一遍, + 等于把同一道摩擦向他收两遍。 */ + let wxPending = null; + + function wxErr(msg) { + wxErrEl.textContent = msg || ''; + wxCodeEl.classList.toggle('is-err', !!msg); + } + + /* 弹窗那句引导语里,把「全部解锁」四个字挑出来加重(用户点名要突出)。 + ⚠️ 不用 innerHTML。这几句将来极可能要拼小名(babyWord 到处在拼), + 小名是用户自己敲进输入框的 —— 一旦它经 innerHTML 落地,那就是一条 + 现成的注入路径,而且是在「这句话看起来只是文案」的伪装下。 + 这里按纯文本切三段、中间那段包一个 ,拼进来什么都只会是字。 + ⚠️ 找不到那四个字时**不能**当异常处理:done 分支根本没有这句话, + 直接整句塞回去就行,不要为了「一定要有高亮」去改文案。 */ + const LEAD_HI = '全部解锁'; + function setLead(el, text) { + el.textContent = ''; + const i = text.indexOf(LEAD_HI); + if (i < 0) { el.textContent = text; return; } + el.appendChild(document.createTextNode(text.slice(0, i))); + const em = document.createElement('em'); + em.className = 'lead-hi'; + em.textContent = LEAD_HI; + el.appendChild(em); + el.appendChild(document.createTextNode(text.slice(i + LEAD_HI.length))); + } + + /* 打开弹窗的**唯一**入口。所有和上一次打开有关的痕迹都在这里无条件清干净。 + ⚠️ 五样东西每次都要重写,尤其是 wxPending —— 只在 gate 分支给它赋值、 + card 分支不管,会出这么一档事:闸门拦下《糖耐攻略》→ 用户按 Esc 关掉 → + 过一会儿从「我的」页服务卡进来输码 → 弹出的是《糖耐攻略》的网盘链接, + 他压根没要那份,而那份还被记成了「已领」。 + ⚠️ 清理只能放在**打开**这一侧:closeModal() 是通用的(遮罩、Esc、右上角叉、 + openModal 开头那句都会走到它),它不认识 wxPending 也不该认识。 */ + function openWx(mode, key) { + /* 默认清空,只有闸门才写。别改回 `mode === 'gate' ? (key||null) : null` + 那种三目 —— 写成默认行为,是让「非闸门入口不可能带着待领资源」这条 + 不变量由结构保证,而不是靠某个分支记得写 null。 + (诚实交待:gift 入口现在根本解不了锁,所以「串台」这个场景已经无法 + 复现,也就没有断言能量到这一行。它是结构性防线,不是被测行为。) */ + wxPending = null; + if (mode === 'gate') wxPending = key || null; + /* 已经解锁过的人再点福利入口,不该再被要一次兑换码 —— 那格输入框对他 + 已经没有意义了(is-done 在 CSS 里把整个表单收起来)。 + ⚠️ 这句只可能把 gift 抬成 done:gate 只在未解锁时才被调用。 + 所以 done 的受众是 100%「已解锁的人来点试用装福利」,文案得照这个写。 */ + const m = state.wxUnlocked ? 'done' : mode; + wxModal.classList.toggle('is-gate', m === 'gate'); + wxModal.classList.toggle('is-gift', m === 'gift'); + wxModal.classList.toggle('is-done', m === 'done'); + setLead($('#wxLead'), + m === 'done' ? '资料已经解锁了。试用装每人限领一份,扫码找顾问登记,她会帮你安排。' + : m === 'gate' ? '扫码添加顾问,说一句「领资料」就会把兑换码发给你。填进下面这格,资源库里的资料就全部解锁。' + /* ⚠️ gift 里兑换码那一格是收起来的,所以这句**必须**把「码去哪儿填」讲明白。 + 去掉输入框省下的那道摩擦,代价就是用户拿到码之后不知道往哪儿去 —— + 这句话是唯一补上这个断点的地方,别精简。 + 也别写成「填进下面这格」:这儿根本没有那一格,而且 smoke 里有条 + 断言正钉着这一点。 */ + : '扫码添加顾问,说一句「领试用装」就帮你登记,每人限领一份。她也会把资源库的兑换码一并发给你——到资源库点「立即领取」时填进去。'); + /* 右边那颗「解锁全部资料」只有闸门才有(is-done / is-gift 都被 CSS 收起来了)。 + done 模式下左键是唯一出口,「以后再说」不合适 —— 事情已经办完,没有什么「以后」。 + ⚠️ gift 模式整条 .modal-foot 都被 `.is-gift .modal-foot{display:none}` 收起来了 + (原来那颗「先不领」按用户要求删了:对着一句「免费领」说「先不领」, + 像在替用户拒绝)。所以 gift 下这里赋什么值都不显示,留 '' 只是别留脏字。 + gift 的退路是右上角那颗 .modal-x —— 删按钮之前专门确认过它在, + 不然就把人关在弹窗里了。 */ + $('#wxLater').textContent = m === 'done' ? '知道了' : m === 'gift' ? '' : '以后再说'; + wxCodeEl.value = ''; + wxErr(''); + openModal('modalWx'); + } + + $('#meWxCard').addEventListener('click', () => openWx('gift')); + /* 资源库顶部那条 banner 和「我的」页服务卡是同一件事、同一个入口模式。 + ⚠️ 走 openWx('gift') 而不是挂 data-open="modalWx":那条通用通道直接调 + openModal,会绕过上面那一整套重置。 */ + $('#rlGift').addEventListener('click', () => openWx('gift')); + /* 一改就把红字收掉。让上一次的报错挂在那儿,用户会以为自己刚敲的这个也错了 */ + wxCodeEl.addEventListener('input', () => wxErr('')); + wxCodeEl.addEventListener('keydown', e => { if (e.key === 'Enter') $('#wxOk').click(); }); + + $('#wxOk').addEventListener('click', () => { + /* ⚠️ 只有闸门模式才受理。别删,也别当成「CSS 都 display:none 了,多余」: + - jsdom 实测:display:none 的按钮 dispatchEvent 照样触发监听; + - 真机上今天确实点不到,但**被触发的后果比意外解锁更难看** —— + 「兑换码不对」那行红字会冒在一个根本没有输入框的弹窗上, + 用户看着一个无处可填的界面被指责填错了; + - 两条看起来无害的改动会立刻把它变成活 bug:把上面那条 display:none + 换成 visibility:hidden(视觉一模一样,但仍可聚焦、仍能回车), + 或者给弹窗套一层 (回车默认提交)。 + 读 classList 而不是另立一个 wxMode 变量:模式本来就存在那儿, + openWx() 是唯一写入方,多一个变量就多一处会走神的真相。 */ + if (!wxModal.classList.contains('is-gate')) return; + if (!wxCodeOK(wxCodeEl.value)) { + /* 码不对:给出红字、标红输入框、把光标送回去。 + ⚠️ 一个字段都不落盘 —— 不写 wxUnlocked、不动 resGot。 */ + wxErr('兑换码不对,跟顾问再要一次?'); + wxCodeEl.focus(); + haptic(); + return; + } + state.wxUnlocked = true; + saveWxUnlocked(); + syncUnlockCopy(); + /* 取出来就清空:这一份已经交待完了,不能留到下一次打开还生效 */ + const key = wxPending; + wxPending = null; + if (key) givePan(key); // givePan 里的 openModal 会顺手把这个弹窗换掉 + else closeModal(); + /* 等弹窗切换的那段动画走完再冒 toast,否则它和正在上移的网盘弹窗叠在一起 */ + setTimeout(() => toast('已解锁 · 资源库里的资料随时来取'), 260); + }); + + /* 未解锁时,两处「已为你解锁 N 份资源」是句谎话 —— 用户读完这句去点领取, + 撞上的是一张二维码。所以锁着的时候换个说法,并且把「首次领取要加顾问」 + 提前说清楚,别等人点下去才发现。 + ⚠️ 首页那句只改 这一层,绝不能对整个

写 + innerHTML:里面还嵌着「查看全部 ›」那个 ,它的点击监听是 + init 时 $$('[data-open]') 一次性绑上去的。整段重写会把节点连同监听一起 + 换掉,而且是**静默**的 —— 链接看着还是品牌色,就是点不动,控制台也不报错。 */ + function syncUnlockCopy() { + $('#shelfLeadTxt').innerHTML = state.wxUnlocked + ? '已为你解锁 128 份资源' + : '128 份孕育资料等你来领'; + $('#rlLead').innerHTML = state.wxUnlocked + ? '已为你解锁 128 份资源 · 按孕周持续更新' + : '128 份资源按孕周持续更新 · 首次领取需添加顾问微信'; + /* 解锁过的人不再看到试用装 banner —— 他早就加过顾问了,这条对他是噪声。 + ⚠️ banner 的显隐**只有这一个 owner**。别在 renderResList 里再 toggle + 一遍:两个地方管同一件事,迟早跑偏成「一边显一边隐」。 + 时机是够的:#rlGift 是静态标记,renderResList 只重写 #resList.innerHTML + 和上面那排 chips,碰不到它;而 init() 在 load 时**同步**调了这个函数, + 资源库面板必然在那之后才可能被打开 —— 没有「闪一下」的窗口。 + ⚠️ 刻意的不一致:banner 藏了,「我的」页那张服务卡**不藏**。 + 那是「我的」页的常驻入口,藏掉会缺一块。所以已解锁的人在「我的」页 + 仍会看到福利文案 —— 这可接受,前提是弹窗 done 那段文案把 + 「试用装限领一份」讲清楚(已经写了),动线才连得上。不是 bug。 */ + $('#rlGift').classList.toggle('hide', state.wxUnlocked); + } + + const panText = () => '链接:' + $('#panUrl').value + ' 提取码:' + $('#panCode').value; + $('#panCopy').addEventListener('click', () => { + const txt = panText(); + // navigator.clipboard 需要安全上下文,微信 / 老安卓里常常没有, + // 兜底用 input.select() + execCommand,两条路都断了才提示手动长按复制 + const fallback = () => { + const el = $('#panUrl'); + try { + el.value = txt; el.removeAttribute('readonly'); + el.select(); el.setSelectionRange(0, txt.length); + const done = document.execCommand && document.execCommand('copy'); + el.setAttribute('readonly', 'readonly'); el.value = panOf(state.resKey).url; + el.blur(); + toast(done ? '链接和提取码已复制' : '请长按上方链接手动复制'); + } catch (e) { + el.setAttribute('readonly', 'readonly'); + toast('请长按上方链接手动复制'); + } + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(txt).then(() => toast('链接和提取码已复制'), fallback); + } else fallback(); + haptic(); + }); + $('#panOpen').addEventListener('click', () => { + const url = $('#panUrl').value; + // 先复制提取码再跳,省得用户到了网盘还要回来翻 + if (navigator.clipboard && navigator.clipboard.writeText) + navigator.clipboard.writeText($('#panCode').value).catch(() => {}); + toast('提取码 ' + $('#panCode').value + ' 已复制,正在打开网盘'); + window.open(url, '_blank', 'noopener'); + haptic(); + }); + + /* ================= 档案生成:周档案 / 完整档案 ================= + 产物必须是一张图(要能存相册、发朋友圈、拿去打印),所以直接用 canvas + 手画,不引第三方截图库 —— 单文件产物依然 0 外部依赖。 + + 排版走「量一遍、再画一遍」两趟:第一趟 ctx 传 null 只累加 y 得到总高, + 按这个高度把 canvas 撑开,第二趟才真正落笔。这样长图有多长就画多长, + 完整档案条目再多也不会被裁掉。 + + 换行没有用 ctx.measureText:中文按 1em、ASCII 按 0.52em 估宽就够准, + 而且不依赖 ctx,量高那一趟即使拿不到 2d 上下文也能跑完。 */ + const PST_W = 750, PST_PAD = 54; + const PST_FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif'; + const pf = (sz, wt) => (wt || 400) + ' ' + sz + 'px ' + PST_FONT; + + const fmtMD = d => (d.getMonth() + 1) + '月' + d.getDate() + '日'; + const fmtYMD = d => d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日'; + const CN_DAY = ['日', '一', '二', '三', '四', '五', '六']; + + /* 本周一 ~ 本周日(周一为一周之始) */ + function weekRange() { + const t = midnight(new Date()); + const mon = new Date(t.getTime() - ((t.getDay() + 6) % 7) * DAY_MS); + return [mon, new Date(mon.getTime() + 6 * DAY_MS)]; + } + + /* 本周的记录:按「周几」定位,未到的日子不编造 */ + const WEEK_LOG = [ + [0, '他动了 12 次,你说那感觉像有人在里面轻轻敲门。', '数胎动'], + [2, '产检一切都好。医生说「长得很稳」,你在走廊上站了一会儿才走。', '孕检日历'], + [4, '睡前放了 20 分钟胎教音乐,他跟着动了好几下。', '胎教音乐'], + [6, '给他写了一封信,讲了你小时候的事。', '我的回信'] + ]; + + function wrapCJK(text, maxW, fs) { + const cap = maxW / fs, out = []; + let cur = '', w = 0; + for (const ch of String(text)) { + const cw = /[\x00-\xff]/.test(ch) ? .52 : 1; + if (w + cw > cap && cur) { out.push(cur); cur = ''; w = 0; } + cur += ch; w += cw; + } + if (cur) out.push(cur); + return out; + } + /* 老 Safari 没有 ctx.roundRect,自己用 arcTo 拼 */ + function rrect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + /* 时光轴 / 珍贵时刻直接从页面上读,页面改了图自然跟着改 */ + const timelineLogs = () => $$('#screen-archive .tl-item').map(it => ({ + date: $('.tl-date', it).textContent.trim(), + text: $('.tl-text', it).textContent.trim(), + from: ($('.tl-from', it) || { textContent: '' }).textContent.trim() + })); + const momentLogs = () => $$('#screen-archive .moment').map(m => ({ + date: $('.mo-meta', m).textContent.trim(), + text: $('.mo-title', m).textContent.trim(), + from: ($('.mo-from', m) || { textContent: '' }).textContent.trim() + })); + + /* 成长数据这一屏是写死在 index.html 里的,没有 JS 数据源。 + 照着上面两个的老规矩从 DOM 读 —— 页面上改了数字,打印出来的也跟着改。 + 体重曲线直接抄 SVG 那条 .line 的 points(viewBox 320×150), + 等比缩进画布上的图表框里重画,不用另编一套坐标。 */ + function growthData() { + const P = $('#screen-archive [data-panel="data"]'); + return { + cards: $$('.stat-card', P).map(c => { + const t = $('.sc-trend', c); + return { + label: $('.sc-label', c).textContent.trim(), + value: $('.sc-value', c).textContent.trim(), + trend: t.textContent.trim(), + /* up / ok / purple —— 页面上三张卡的趋势色各不相同,图上也照着分 */ + tone: (t.className.replace('sc-trend', '').trim() || 'up') + }; + }), + chart: { + title: $('.cc-title', P).textContent.trim(), + pts: $('.chart .line', P).getAttribute('points').trim() + .split(/\s+/).map(s => s.split(',').map(Number)), + xs: $$('.cc-x span', P).map(s => s.textContent.trim()) + }, + /* .m-val 里是「6.1 + cm + P58」三截, + textContent 会粘成「6.1cmP58」,所以分开取 */ + meas: $$('.meas', P).map(m => { + const v = $('.m-val', m); + return { + name: $('.m-name', m).textContent.trim(), + val: (v.childNodes[0].textContent + ($('small', v) || { textContent: '' }).textContent).trim(), + rank: ($('em', v) || { textContent: '' }).textContent.trim(), + pct: parseFloat($('.m-bar', m).style.getPropertyValue('--p')) || 0 + }; + }) + }; + } + + function posterData(kind) { + const week = state.week, day = state.day, days = week * 7 + day; + const D = { + kind: kind, + name: state.babyName, + week: week, + days: days, + left: Math.max(0, (40 - week) * 7 - day), + pct: Math.max(0, Math.min(100, Math.round(days / TERM * 100))), + weekTxt: '孕 ' + week + ' 周' + (day ? ' +' + day + ' 天' : ''), + dueTxt: state.due ? '预产期 ' + fmtYMD(state.due) : '预产期待填' + }; + if (kind === 'week') { + const mon = weekRange()[0], sun = weekRange()[1]; + const todayOff = (midnight(new Date()).getDay() + 6) % 7; + D.label = '妈妈盒子 · 本周档案'; + D.range = fmtMD(mon) + ' — ' + fmtMD(sun); + D.logs = WEEK_LOG.filter(l => l[0] <= todayOff).map(l => { + const d = new Date(mon.getTime() + l[0] * DAY_MS); + return { date: '周' + CN_DAY[d.getDay()] + ' · ' + fmtMD(d), text: l[1], from: '来自「' + l[2] + '」' }; + }); + D.stats = [['本周归档', D.logs.length + ' 条'], ['日均胎动', '31 次'], ['体重变化', '+0.4kg']]; + D.secs = [['这一周', D.logs]]; + D.foot = '孕 ' + week + ' 周 · 本周共 ' + D.logs.length + ' 条记录'; + } else { + const tl = timelineLogs(), mo = momentLogs(); + D.label = '妈妈盒子 · 完整档案'; + D.range = '从记录第一天到今天 · 共 ' + days + ' 天'; + D.stats = [['已陪伴', days + ' 天'], ['累计归档', '386 条'], ['珍贵时刻', mo.length + ' 个']]; + D.growth = growthData(); + /* 成长数据那一节不是日志列表,用 null 占位,交给 growthBlocks() 专门画 */ + D.secs = [['成长数据', null], ['时光轴 · 全部记录', tl], ['珍贵时刻', mo]]; + D.foot = '完整档案 · ' + tl.length + ' 段时光 · ' + mo.length + ' 个珍贵时刻'; + } + return D; + } + + /* ctx 为 null = 只量高不落笔;返回整张图的高度 */ + function paintPoster(D, ctx) { + /* HERO 比最后一行文字(基线 288)多留一截,白卡压上来才不会贴脸 */ + const W = PST_W, P = PST_PAD, CW = W - P * 2, HERO = 356; + if (ctx) { + ctx.fillStyle = '#FBF6F2'; + ctx.fillRect(0, 0, W, ctx.canvas.height); + const g = ctx.createLinearGradient(0, 0, W, HERO); + g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FA8A5E'); + ctx.fillStyle = g; ctx.fillRect(0, 0, W, HERO); + ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = 'rgba(255,255,255,.82)'; ctx.font = pf(24, 600); + ctx.fillText(D.label, P, 84); + ctx.fillStyle = '#fff'; ctx.font = pf(46, 800); + ctx.fillText(D.name + '的成长档案', P, 148); + ctx.fillStyle = 'rgba(255,255,255,.94)'; ctx.font = pf(26, 500); + ctx.fillText(D.weekTxt + ' · 已陪伴 ' + D.days + ' 天', P, 202); + ctx.fillText(D.dueTxt + ' · 还有 ' + D.left + ' 天', P, 244); + ctx.fillStyle = 'rgba(255,255,255,.8)'; ctx.font = pf(22, 400); + ctx.fillText(D.range, P, 288); + /* 孕程环 */ + const cx = W - 124, cy = 134, r = 54; + ctx.lineWidth = 13; ctx.lineCap = 'round'; + ctx.strokeStyle = 'rgba(255,255,255,.3)'; + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = '#fff'; + ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * D.pct / 100); ctx.stroke(); + ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.font = pf(30, 800); + ctx.fillText(D.pct + '%', cx, cy + 2); + ctx.font = pf(18, 400); ctx.fillStyle = 'rgba(255,255,255,.85)'; + ctx.fillText('孕程', cx, cy + 32); + ctx.textAlign = 'left'; + } + + /* 数据卡:压在色带下缘,视觉上把头图和正文缝在一起 */ + let y = HERO - 44; + if (ctx) { + ctx.fillStyle = '#fff'; rrect(ctx, P, y, CW, 168, 30); ctx.fill(); + D.stats.forEach((s, i) => { + const x = P + CW * (i + .5) / D.stats.length; + ctx.textAlign = 'center'; + ctx.fillStyle = '#F4593C'; ctx.font = pf(38, 800); + ctx.fillText(s[1], x, y + 78); + ctx.fillStyle = '#9A8B80'; ctx.font = pf(22, 400); + ctx.fillText(s[0], x, y + 122); + }); + ctx.textAlign = 'left'; + } + y += 168 + 44; + + D.secs.forEach(sec => { + const title = sec[0], logs = sec[1]; + if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(30, 800); ctx.fillText(title, P, y + 30); } + y += 30 + 26; + logs.forEach((L, i) => { + if (ctx) { ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 600); ctx.fillText(L.date, P, y + 21); } + y += 21 + 15; + wrapCJK(L.text, CW, 27).forEach(ln => { + if (ctx) { ctx.fillStyle = '#4A403A'; ctx.font = pf(27, 400); ctx.fillText(ln, P, y + 27); } + y += 42; + }); + if (L.from) { + if (ctx) { ctx.fillStyle = '#B39C8E'; ctx.font = pf(20, 400); ctx.fillText(L.from, P, y + 20); } + y += 20 + 6; + } + y += 12; + if (i < logs.length - 1) { + if (ctx) { + ctx.strokeStyle = '#EFE4DB'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke(); + } + y += 24; + } + }); + y += 30; + }); + + if (ctx) { + ctx.strokeStyle = '#EAD9CE'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke(); + ctx.textAlign = 'center'; ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 600); + ctx.fillText(D.foot, W / 2, y + 48); + ctx.font = pf(20, 400); + ctx.fillText('生成于 ' + fmtYMD(new Date()) + ' · 妈妈盒子', W / 2, y + 84); + ctx.textAlign = 'left'; + } + return y + 84 + 46; + } + + /* ================= 完整档案:A4 分页册子 ================= + 本周档案还是上面那张长图 —— 它是发朋友圈用的,切成几页反而没法发。 + 完整档案不一样,它本来就是拿去打印装订的,所以按真 A4 排。 + + 排版分两步走,不在绘制途中判断换页: + 1) 摊平:把整本内容摊成一串 block,每项 {h, keep, draw}。 + h 是这一小块的高度,keep 表示「不许和下一块之间断开」。 + 最小单位是「一行」,任何一块都比整页矮,结构上不可能溢出。 + 2) 装页:顺序取 block,放不下就翻页。 + 途中换页最容易出的事是把一条记录腰斩,这样排就不会。 */ + const A4_W = 750, A4_H = Math.round(750 * 297 / 210); // 750×1061,正 A4 比例 + /* bind = 左侧装订边,留给打孔/订书钉。单面打印、左侧装订, + 所以左边距固定放宽,不做奇偶页镜像 —— 家用打印机双面对齐本来就不可靠 */ + const MG = { top: 64, bottom: 84, out: 56, bind: 92 }; + const BODY_X = MG.bind, BODY_W = A4_W - MG.bind - MG.out; // 92 → 694,宽 602 + const BODY_TOP = MG.top + 54; // 让开页眉那行字和它底下的细线 + const BODY_BTM = A4_H - MG.bottom; // 正文下边界,再往下是页码 + const PAGE_H = BODY_BTM - BODY_TOP; + + const TONE = { up: '#C4553A', ok: '#5F8A4A', purple: '#7B5FD0' }; + + /* 一张白底圆角卡,成长数据那几块都用它打底 */ + function card(c, x, y, w, h) { + c.fillStyle = '#fff'; rrect(c, x, y, w, h, 22); c.fill(); + c.strokeStyle = '#F0E6DE'; c.lineWidth = 2; c.stroke(); + } + + /* 章节大标题:keep=true,绝不让它孤零零落在页脚 */ + const titleBlock = t => ({ + h: 56, keep: true, isTitle: true, + draw: (c, x, y) => { + c.textAlign = 'left'; + c.fillStyle = '#2E241E'; c.font = pf(30, 800); + c.fillText(t, x, y + 30); + } + }); + + /* 成长数据:三张小卡 + 体重曲线 + 三条测量条 */ + function growthBlocks(G) { + const out = []; + + out.push({ h: 150 + 26, draw: (c, x, y) => { + const gap = 16, w = (BODY_W - gap * 2) / 3; + G.cards.forEach((k, i) => { + const cx = x + i * (w + gap); + card(c, cx, y, w, 150); + c.textAlign = 'left'; + c.fillStyle = '#9A8B80'; c.font = pf(19, 500); c.fillText(k.label, cx + 18, y + 40); + c.fillStyle = '#2E241E'; c.font = pf(31, 800); c.fillText(k.value, cx + 18, y + 86); + c.fillStyle = TONE[k.tone] || TONE.up; c.font = pf(19, 600); + c.fillText(k.trend, cx + 18, y + 124); + }); + } }); + + out.push({ h: 300 + 26, draw: (c, x, y) => { + const H = 300; + card(c, x, y, BODY_W, H); + c.textAlign = 'left'; + c.fillStyle = '#2E241E'; c.font = pf(21, 700); + c.fillText(G.chart.title, x + 24, y + 40); + /* 把 SVG 的 320×150 视口等比铺到这个框里 */ + const gx = x + 26, gy = y + 66, gw = BODY_W - 52, gh = H - 66 - 44; + const sx = v => gx + v / 320 * gw, sy = v => gy + v / 150 * gh; + const pts = G.chart.pts; + if (!pts.length) return; + c.strokeStyle = '#F2EAE3'; c.lineWidth = 1.6; + [20, 66, 112].forEach(v => { + c.beginPath(); c.moveTo(sx(4), sy(v)); c.lineTo(sx(316), sy(v)); c.stroke(); + }); + const trace = () => { + c.beginPath(); c.moveTo(sx(pts[0][0]), sy(pts[0][1])); + for (let i = 1; i < pts.length; i++) c.lineTo(sx(pts[i][0]), sy(pts[i][1])); + }; + trace(); + c.lineTo(sx(pts[pts.length - 1][0]), sy(146)); c.lineTo(sx(pts[0][0]), sy(146)); + c.closePath(); c.fillStyle = 'rgba(244,89,60,.10)'; c.fill(); + trace(); + c.strokeStyle = '#F4593C'; c.lineWidth = 3.4; + c.lineJoin = 'round'; c.lineCap = 'round'; c.stroke(); + pts.forEach((p, i) => { + const last = i === pts.length - 1; + c.beginPath(); c.arc(sx(p[0]), sy(p[1]), last ? 7 : 5, 0, Math.PI * 2); + c.fillStyle = last ? '#F4593C' : '#fff'; c.fill(); + c.strokeStyle = '#F4593C'; c.lineWidth = 2.6; c.stroke(); + }); + c.textAlign = 'center'; c.fillStyle = '#B39C8E'; c.font = pf(18, 400); + G.chart.xs.forEach((t, i) => + c.fillText(t, gx + gw * (i + .5) / G.chart.xs.length, y + H - 16)); + c.textAlign = 'left'; + } }); + + const rowH = 62, measH = G.meas.length * rowH + 28; + out.push({ h: measH + 26, draw: (c, x, y) => { + card(c, x, y, BODY_W, measH); + G.meas.forEach((m, i) => { + const ry = y + 14 + i * rowH; + c.textAlign = 'left'; + c.fillStyle = '#4A403A'; c.font = pf(20, 600); c.fillText(m.name, x + 24, ry + 34); + const bx = x + 128, bw = BODY_W - 128 - 180, by = ry + 25; + c.fillStyle = '#F2EAE3'; rrect(c, bx, by, bw, 12, 6); c.fill(); + c.fillStyle = '#F4593C'; + rrect(c, bx, by, Math.max(12, bw * m.pct / 100), 12, 6); c.fill(); + c.textAlign = 'right'; + c.fillStyle = '#2E241E'; c.font = pf(21, 700); c.fillText(m.val, x + BODY_W - 88, ry + 34); + c.fillStyle = '#9A8B80'; c.font = pf(18, 500); c.fillText(m.rank, x + BODY_W - 24, ry + 34); + }); + c.textAlign = 'left'; + } }); + + return out; + } + + /* 一条记录 = 日期行 + 若干正文行 + 来源行 + 尾巴(分隔线或留白)。 + 除末行外都挂 keep,整条捆在一起;真有超过一页的长记录, + 装页时会把这条的 keep 松掉让它断开 —— 总比撑破一页强。 */ + function logBlocks(L, last) { + const out = [{ h: 36, keep: true, draw: (c, x, y) => { + c.textAlign = 'left'; + c.fillStyle = '#B39C8E'; c.font = pf(21, 600); c.fillText(L.date, x, y + 21); + } }]; + const lines = wrapCJK(L.text, BODY_W, 26); + lines.forEach((ln, i) => out.push({ + h: 40, keep: i < lines.length - 1 || !!L.from, + draw: (c, x, y) => { + c.textAlign = 'left'; + c.fillStyle = '#4A403A'; c.font = pf(26, 400); c.fillText(ln, x, y + 26); + } + })); + if (L.from) out.push({ h: 26, draw: (c, x, y) => { + c.textAlign = 'left'; + c.fillStyle = '#B39C8E'; c.font = pf(20, 400); c.fillText(L.from, x, y + 20); + } }); + /* filler:落在页首就直接扔掉,免得新一页顶上飘一条分隔线或一段空白 */ + out.push(last + ? { h: 30, filler: true, draw: function () {} } + : { h: 36, filler: true, draw: (c, x, y) => { + c.strokeStyle = '#EFE4DB'; c.lineWidth = 2; + c.beginPath(); c.moveTo(x, y + 12); c.lineTo(x + BODY_W, y + 12); c.stroke(); + } }); + return out; + } + + /* 摊平 → 装页。返回 {pages, toc},pages[i] 是 [{b, y}…] */ + function bookPages(D) { + const blocks = []; + D.secs.forEach(sec => { + const name = sec[0], logs = sec[1]; + const push = b => { b.sec = name; blocks.push(b); }; + push(titleBlock(name)); + if (logs === null) growthBlocks(D.growth).forEach(push); + else logs.forEach((L, i) => logBlocks(L, i === logs.length - 1).forEach(push)); + push({ h: 26, filler: true, draw: function () {} }); // 节与节之间喘口气 + }); + + const pages = []; + let cur = [], y = BODY_TOP; + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + if (!cur.length && b.filler) continue; + /* 把这一块和它后面连着的 keep 块当成一个整体量高 */ + let need = b.h, j = i; + while (blocks[j].keep && j + 1 < blocks.length) { j++; need += blocks[j].h; } + if (need > PAGE_H) need = b.h; // 整条比一页还高,只能让它断开 + if (y + need > BODY_BTM && cur.length) { pages.push(cur); cur = []; y = BODY_TOP; } + cur.push({ b: b, y: y }); + y += b.h; + } + if (cur.length) pages.push(cur); + + /* 目录页码:封面占第 1 页,正文从第 2 页起 */ + const toc = []; + D.secs.forEach(sec => { + const at = pages.findIndex(p => p.some(it => it.b.sec === sec[0])); + toc.push([sec[0], at < 0 ? 1 : at + 2]); + }); + return { pages: pages, toc: toc }; + } + + function drawCover(c, D, toc, total) { + const HH = Math.round(A4_H * .46); + c.fillStyle = '#fff'; c.fillRect(0, 0, A4_W, A4_H); + /* 色块不做出血:家用打印机印不到纸边,留白反而干净,也省墨 */ + const g = c.createLinearGradient(0, 0, A4_W, HH); + g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FA8A5E'); + c.fillStyle = g; c.fillRect(0, 0, A4_W, HH); + + c.textAlign = 'left'; c.textBaseline = 'alphabetic'; + c.fillStyle = 'rgba(255,255,255,.82)'; c.font = pf(24, 600); + c.fillText(D.label, BODY_X, 116); + c.fillStyle = '#fff'; c.font = pf(46, 800); + c.fillText(D.name + '的成长档案', BODY_X, 190); + c.fillStyle = 'rgba(255,255,255,.94)'; c.font = pf(26, 500); + c.fillText(D.weekTxt + ' · 已陪伴 ' + D.days + ' 天', BODY_X, 250); + c.fillText(D.dueTxt + ' · 还有 ' + D.left + ' 天', BODY_X, 292); + c.fillStyle = 'rgba(255,255,255,.8)'; c.font = pf(22, 400); + c.fillText(D.range, BODY_X, 340); + + const cx = A4_W - 148, cy = 178, r = 58; + c.lineWidth = 13; c.lineCap = 'round'; + c.strokeStyle = 'rgba(255,255,255,.3)'; + c.beginPath(); c.arc(cx, cy, r, 0, Math.PI * 2); c.stroke(); + c.strokeStyle = '#fff'; + c.beginPath(); c.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * D.pct / 100); c.stroke(); + c.textAlign = 'center'; c.fillStyle = '#fff'; c.font = pf(30, 800); + c.fillText(D.pct + '%', cx, cy + 2); + c.font = pf(18, 400); c.fillStyle = 'rgba(255,255,255,.85)'; + c.fillText('孕程', cx, cy + 32); + + /* 数据条压在色块下沿,把封面上下两半缝在一起 */ + const sy = HH - 56; + c.fillStyle = '#fff'; rrect(c, BODY_X, sy, BODY_W, 152, 26); c.fill(); + c.strokeStyle = '#F0E6DE'; c.lineWidth = 2; c.stroke(); + D.stats.forEach((s, i) => { + const x = BODY_X + BODY_W * (i + .5) / D.stats.length; + c.textAlign = 'center'; + c.fillStyle = '#F4593C'; c.font = pf(34, 800); c.fillText(s[1], x, sy + 74); + c.fillStyle = '#9A8B80'; c.font = pf(21, 400); c.fillText(s[0], x, sy + 114); + }); + + c.textAlign = 'left'; + let y = sy + 152 + 86; + c.fillStyle = '#2E241E'; c.font = pf(28, 800); + c.fillText('目录', BODY_X, y); + c.strokeStyle = '#EFE4DB'; c.lineWidth = 2; + c.beginPath(); c.moveTo(BODY_X, y + 22); c.lineTo(BODY_X + BODY_W, y + 22); c.stroke(); + y += 68; + toc.forEach(t => { + c.textAlign = 'left'; c.fillStyle = '#4A403A'; c.font = pf(23, 600); + c.fillText(t[0], BODY_X, y); + c.textAlign = 'right'; c.fillStyle = '#B39C8E'; c.font = pf(22, 500); + c.fillText('第 ' + t[1] + ' 页', BODY_X + BODY_W, y); + y += 50; + }); + + c.textAlign = 'center'; c.fillStyle = '#C9BBB1'; c.font = pf(19, 400); + c.fillText('全书共 ' + total + ' 页 · 沿左侧装订线打孔即可成册', + A4_W / 2, A4_H - MG.bottom + 46); + c.fillText('生成于 ' + fmtYMD(new Date()) + ' · 妈妈盒子', + A4_W / 2, A4_H - MG.bottom + 78); + c.textAlign = 'left'; + } + + function drawBookPage(c, D, page, no, total) { + c.fillStyle = '#fff'; c.fillRect(0, 0, A4_W, A4_H); + /* 开章那页不写页眉 —— 大标题就在下面两行处,再写一遍是重复。 + 页眉是给翻到章节中段的人认路用的,标题在眼前时它多余。 */ + const sec = (page[0] && !page[0].b.isTitle && page[0].b.sec) || ''; + c.textBaseline = 'alphabetic'; + c.fillStyle = '#B39C8E'; c.font = pf(19, 600); + c.textAlign = 'left'; c.fillText(D.name + '的成长档案', BODY_X, MG.top); + c.textAlign = 'right'; c.fillText(sec, A4_W - MG.out, MG.top); + c.strokeStyle = '#EFE4DB'; c.lineWidth = 2; + c.beginPath(); c.moveTo(BODY_X, MG.top + 18); c.lineTo(A4_W - MG.out, MG.top + 18); c.stroke(); + /* 装订线:一道浅浅的竖虚线,提示这一条左边是留给打孔的 */ + if (c.setLineDash) { + c.save(); c.setLineDash([6, 8]); + c.strokeStyle = '#F0E6DE'; c.lineWidth = 1.6; + c.beginPath(); c.moveTo(MG.bind - 34, MG.top - 26); + c.lineTo(MG.bind - 34, A4_H - MG.bottom + 26); c.stroke(); + c.restore(); + } + + page.forEach(it => it.b.draw(c, BODY_X, it.y)); + + c.textAlign = 'center'; c.fillStyle = '#B39C8E'; c.font = pf(20, 600); + c.fillText('— ' + no + ' —', A4_W / 2, A4_H - MG.bottom + 48); + c.textAlign = 'right'; c.fillStyle = '#C9BBB1'; c.font = pf(17, 400); + c.fillText('妈妈盒子 · 生成于 ' + fmtYMD(new Date()) + ' · 共 ' + total + ' 页', + A4_W - MG.out, A4_H - MG.bottom + 48); + c.textAlign = 'left'; + } + + let posterURLs = [], posterKind = 'week'; + /* 导出层是两个面板共用的(档案海报 + 来信分享卡),所以「这批图叫什么」 + 不能再从 posterKind 推——加一组随 beginSheets() 一起设的标签。 + exportTag 进文件名,exportTitle 进系统分享面板的标题。 */ + let exportTag = '档案', exportTitle = '妈妈盒子'; + + /* 每次要铺新的一批图,必须先调这个。 + ⚠️ posterURLs 是模块级的,两个面板都往里写。不清空的后果是静默的: + 先看一遍「完整档案」,退出来再点来信的「分享」,如果分享卡这边 + 没能生成图(老浏览器),posterURLs 里还躺着上一批带预产期、体重、 + 年份的档案页 —— 一点「分享」就原样发出去了。 + 把清空和打标签绑在同一个函数里,就没有「忘了清」这条路径。 */ + function beginSheets(host, tag, title) { + host.textContent = ''; + posterURLs = []; + exportTag = tag; + exportTitle = title; + } + + /* 铺一张纸:白卡纸外框(.pst-frame)+ 一块画布,画完顺手存成 data URI。 + 拿不到 2d 上下文(极老的机器)就把画布留白,不让整页崩掉。 */ + function addSheet(host, w, h, label, drawFn) { + const fr = el('div', 'pst-frame'); + const cv = document.createElement('canvas'); + cv.width = w; cv.height = h; + cv.setAttribute('role', 'img'); + cv.setAttribute('aria-label', label); + fr.appendChild(cv); + host.appendChild(fr); + let ctx = null; + try { ctx = cv.getContext && cv.getContext('2d'); } catch (e) { ctx = null; } + if (!ctx) return; + drawFn(ctx); + try { posterURLs.push(cv.toDataURL('image/png') || ''); } catch (e) { /* 画得出但导不出,忽略 */ } + } + + function renderPoster(kind) { + posterKind = kind === 'all' ? 'all' : 'week'; + const D = posterData(posterKind); + const host = $('#pstPages'); + beginSheets(host, + posterKind === 'week' ? '第' + state.week + '周档案' : '完整档案', + state.babyName + (posterKind === 'week' ? '的本周档案' : '的成长档案')); + + let sizeChip; + if (posterKind === 'week') { + /* 本周档案照旧:一张长图,发朋友圈用的 */ + const h = Math.round(paintPoster(D, null)); // 先量高,再撑画布 + addSheet(host, PST_W, h, '本周档案', c => paintPoster(D, c)); + sizeChip = ['成图', PST_W + '×' + h]; + } else { + const bk = bookPages(D); + const total = bk.pages.length + 1; // 加上封面 + addSheet(host, A4_W, A4_H, '封面与目录', c => drawCover(c, D, bk.toc, total)); + bk.pages.forEach((p, i) => + addSheet(host, A4_W, A4_H, '第 ' + (i + 2) + ' 页', + c => drawBookPage(c, D, p, i + 2, total))); + sizeChip = ['共', total + ' 页 · A4']; + D.pageCount = total; + } + + const title = posterKind === 'week' ? '本周档案' : '完整档案'; + $('#pstBar').textContent = title; + $('#pstName').textContent = title; + $('#pstLead').textContent = posterKind === 'week' + ? '本周(' + D.range + ')的记录已经排成一张图,存到相册或发朋友圈都行。' + : '从记录第一天到今天共 ' + D.days + ' 天,已排成 ' + D.pageCount + + ' 页 A4,打印出来沿左边装订线打孔,就能装订成册。'; + + /* 胶囊直接复用画在图上的那几组数字,再补一条尺寸 —— + 到底有多长/多少页,心里先有个数 */ + const chips = $('#pstChips'); + chips.textContent = ''; + D.stats.concat([sizeChip]).forEach(s => { + const c = el('span', 'pst-chip', s[0]); + c.appendChild(el('b', null, s[1])); + chips.appendChild(c); + }); + + /* 两种档案的主按钮不一样:本周档案是拿去发的,完整档案是拿去印的 */ + const isBook = posterKind === 'all'; + $('#pstPrint').classList.toggle('main', isBook); + $('#pstPrint').classList.toggle('ghost', !isBook); + $('#pstPrint').textContent = isBook ? '打印装订' : '打印'; + $('#pstShare').classList.toggle('main', !isBook); + $('#pstShare').classList.toggle('ghost', isBook); + + $('#pstTip').textContent = !posterURLs.length + ? '当前浏览器画不了图片,换个浏览器打开就能保存和打印了。' + : isBook + ? '上面就是打印出来的样子,一屏一页。点「打印装订」直接出 ' + D.pageCount + ' 张 A4。' + : '图片已生成。在微信里也可以长按上面这张图,直接保存或转发。'; + } + + $$('[data-poster]').forEach(b => b.addEventListener('click', () => { + renderPoster(b.dataset.poster); + openPanel('detailPoster', b); + })); + + /* i 省略 = 单张;带 i 就在名字里标上页码。 + 名字里那截「是什么」由 beginSheets() 时定下的 exportTag 决定 —— + 分享卡和档案共用这个函数,别再从 posterKind 反推。 */ + function posterFile(i) { + const d = new Date(), p = n => (n < 10 ? '0' : '') + n; + const stamp = d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()); + return '妈妈盒子-' + exportTag + '-' + stamp + (i ? '-第' + i + '页' : '') + '.png'; + } + + /* 保存 / 分享 / 打印这三件事,档案面板和分享卡面板一模一样, + 所以按 data-export 委托,两边的按钮共用同一段逻辑。 + 加新面板时只要在按钮上写 data-export,不用再复制一遍。 */ + const exportAct = {}; + $$('[data-export]').forEach(b => + b.addEventListener('click', () => { const f = exportAct[b.dataset.export]; if (f) f(); })); + + /* posterURLs(dataURL)→ File[],给 navigator.share 用。 + canvas 出来就是 dataURL,fetch 自己的 dataURL 是最省事的解码方式。 */ + function sheetFiles() { + const many = posterURLs.length > 1; + return Promise.all(posterURLs.map(u => fetch(u).then(r => r.blob()))) + .then(bs => bs.map((b, i) => new File([b], posterFile(many ? i + 1 : 0), { type: 'image/png' }))); + } + const canShareFiles = () => + !!(navigator.share && navigator.canShare && typeof fetch === 'function' && typeof File === 'function'); + + /* ⚠️ 网页存不进相册,这是浏览器的硬限制,不是这里没写。 + 没有任何 Web API 能让网页把文件写进系统相册 —— 真能,那随便哪个网页 + 都可以往你手机相册里塞东西了。所以「直接保存到相册」这件事, + 网页能做到的最短路径只有一条:调起系统分享面板, + 用户点一下「存储图像 / 保存到相册」,由系统去写。iOS 和安卓都是这样。 + + 所以这颗键的顺序改成: + 1. 有系统面板(iOS Safari / 安卓 Chrome)→ 直接叫出来,一点就进相册; + 2. 没有(微信内置浏览器就没有)→ 退回 , + 安卓会落进「下载」相册,iOS 会进「文件」; + 3. 再不行 → 长按图片,这是微信里唯一能进相册的路。 + 以前是先走 2,iOS 上点完只是进了「文件」,用户在相册里怎么翻都找不到。 */ + exportAct.save = () => { + if (!posterURLs.length) { toast('这台设备生成不了图片,长按图片试试'); return; } + const many = posterURLs.length > 1; + + const download = () => { + /* 一次点 N 个 有的浏览器会拦,隔开一点点再点下一张 */ + posterURLs.forEach((u, i) => setTimeout(() => { + const a = document.createElement('a'); + a.href = u; a.download = posterFile(many ? i + 1 : 0); + document.body.appendChild(a); a.click(); a.remove(); + }, i * 260)); + toast(many ? '正在保存 ' + posterURLs.length + ' 页……' : '已下载 · 微信里请长按图片存到相册'); + }; + + if (canShareFiles()) { + sheetFiles().then(files => { + const pick = navigator.canShare({ files: files }) ? files + : navigator.canShare({ files: [files[0]] }) ? [files[0]] : null; + if (!pick) { download(); return; } + /* 只给 files,不给 title/text:带上文字,系统面板会优先推「发送给某人」, + 而这颗键要的是「存到相册」那一项排在前面。 */ + return navigator.share({ files: pick }) + .then(() => toast('在面板里选「存储图像」就进相册了')); + }).catch(() => download()); // 用户取消也会走到这儿,兜底下载不影响什么 + } else download(); + haptic(); + }; + + exportAct.share = () => { + const title = exportTitle; + const manual = () => toast('长按上面的图片,就能保存或转发到朋友圈'); + /* 能调起系统分享面板就带图分享;微信内置浏览器没有 share(), + 退回「长按图片」——那是微信里最通用的一条路。 + 完整档案是一整本,支持多图就整本发出去,不支持就只发第一页(封面)。 */ + if (canShareFiles() && posterURLs.length) { + const many = posterURLs.length > 1; + /* 和「保存图片」的区别只在这里:分享要带上标题文案, + 系统面板才会把「发送给朋友 / 朋友圈」这类去处排在前面。 */ + sheetFiles() + .then(files => { + if (navigator.canShare({ files: files })) + return navigator.share({ files: files, title: title, text: title }); + if (navigator.canShare({ files: [files[0]] })) + return navigator.share({ files: [files[0]], title: title, text: title }); + return navigator.share({ title: title, text: title }); + }) + .then(() => toast(many ? '已唤起分享 · 整本 ' + posterURLs.length + ' 页' : '已唤起分享 · 选「朋友圈」就行'), manual); + } else manual(); + haptic(); + }; + + exportAct.print = () => { + if (!window.print) { toast('这个浏览器不支持直接打印,先保存图片再打印吧'); return; } + toast('正在准备打印……'); + /* 样式表里的 @media print 已经把这张图之外的东西都藏起来了 */ + setTimeout(() => { try { window.print(); } catch (e) { toast('打印没能启动,先保存图片再打印吧'); } }, 140); + haptic(); + }; + + /* ================= 来信分享卡 ================= + 把单独一封信做成一张能发出去的图,三套样式随便挑。 + + 和上面的档案海报刻意分成两套渲染:档案的 posterData() 里有预产期、 + 体重、睡眠、完整年月日 —— 那是给自己留的,不是给朋友圈看的。 + 两边共用一个渲染器,「哪些字段不能上卡」就变成了散在各处的 if, + 以后谁加个字段忘了补分支,预产期就印到卡上了,而且没人会发现。 */ + + const CARD_W = 750, CARD_PAD = 54; + /* 卡片底部固定占这么高:一条细线 + 两行落款 + 右下角的码。 + 正文排完之后按「画布高度 - CARD_FOOT」倒着画,所以模板可以有最小高度 + (正文短的信不至于缩成一张名片),底部不会跟着飘。 */ + const CARD_FOOT = 214; + + /* ---------- 脱敏:白名单,不是黑名单 ---------- + 必须是白名单。stampOne()(本文件 533 行)在运行时才往信件对象上盖了 + at / week / weekTxt / month / date / dateFull 六个字段 —— + 光看 LETTERS 那个字面量根本看不见它们,写黑名单一定会漏。 + + 故意没带上、且以后也不要带的: + L.thread —— 妈妈自己写的回信。这是整封信里最私人的东西, + 放上卡就是事故,而且是发出去之后才知道的那种。 + L.dateFull —— 「2024年9月3日 08:00」,带年份和钟点。只留 L.date(9月3日)。 + L.month —— 同样带年份。 + state.due —— 预产期。反推得出受孕时间,不该出现在朋友圈。 + state.userName —— 妈妈的昵称。(原来这行还写着 userBio「签名」, + 那个字段整块删掉了,不是忘了带。) + 体重 / 睡眠 / 胎动次数 —— 健康数据,一条都不上卡。 + L.id/back/time/preview/unread/fav/voice —— 和卡面无关。 */ + function cardData(L) { + return { + title: L.title, + paras: L.paras.slice(), + week: L.week, // 24 + weekTxt: L.weekTxt, // 「孕 24 周 +3 天」 + date: L.date, // 「9月3日」—— 不带年 + baby: state.babyName || '宝宝', + size: babySize(L.week) // 这一周多大:名字 / 克数 / 分类,都是查表来的公共知识 + }; + } + + /* canvas 的 aria-label。一举两得:读屏能听, + 而且这是测试里唯一能验证「卡上到底印了什么」的抓手 —— + jsdom 里 getContext 是 null,画笔那一趟根本不执行,验不到像素。 */ + function cardAlt(C, tplName) { + return '分享卡(' + tplName + '样式):' + C.baby + '在' + C.weekTxt + '、' + C.date + + '写的信,标题「' + C.title + '」。' + C.paras.join('') + + ' 落款:爱你的' + C.baby + '。右下角是小程序码的示意图案,暂不可扫。'; + } + + /* ---------- 右下角那枚码 ---------- + 项目里没有小程序 appid,也没有可跳转的链接,画不出真能扫的码。 + 与其糊一张假二维码让人扫了半天没反应,不如明说是示意图案 —— + 卡面上和 cardAlt() 里都写着「暂不可扫」。等有了 appid, + 把这个函数换成真的码生成即可,调用点不用动。 + + 点阵用定死种子的 LCG,保证每次生成的图长得一样(不然同一封信 + 连点两次样式,右下角图案还会变,看着像出了 bug)。 + ⚠️ 乘数用 16807(MINSTD)不用常见的 1103515245:后者乘出来超过 2^53, + double 精度一丢,点阵会退化成肉眼可见的横条纹。 */ + function drawQRStub(ctx, x, y, s) { + const N = 21, pad = Math.round(s * 0.085), cell = (s - pad * 2) / N; + ctx.fillStyle = '#fff'; rrect(ctx, x, y, s, s, 12); ctx.fill(); + ctx.strokeStyle = '#EAD9CE'; ctx.lineWidth = 2; ctx.stroke(); + const px = (c, r) => [x + pad + c * cell, y + pad + r * cell]; + /* 三个定位角:外圈 7×7 实心,挖掉 5×5,再补回中间 3×3 */ + [[0, 0], [N - 7, 0], [0, N - 7]].forEach(p => { + const a = px(p[0], p[1]); + ctx.fillStyle = '#3A2C24'; ctx.fillRect(a[0], a[1], cell * 7, cell * 7); + ctx.fillStyle = '#fff'; ctx.fillRect(a[0] + cell, a[1] + cell, cell * 5, cell * 5); + ctx.fillStyle = '#3A2C24'; ctx.fillRect(a[0] + cell * 2, a[1] + cell * 2, cell * 3, cell * 3); + }); + let seed = 20240903; + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + ctx.fillStyle = '#3A2C24'; + for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { + if ((c < 8 && r < 8) || (c > N - 9 && r < 8) || (c < 8 && r > N - 9)) continue; + if (rnd() > 0.5) { const a = px(c, r); ctx.fillRect(a[0], a[1], cell, cell); } + } + } + + /* 三套模板共用的页脚:一条细线 + 左边两行落款 + 右下角那枚码。 + 按画布实际高度倒着画,所以模板把内容排完就行,不用管自己有多高。 */ + function drawCardFoot(ctx) { + const W = CARD_W, P = CARD_PAD, fy = ctx.canvas.height - CARD_FOOT; + ctx.strokeStyle = '#EFE1D6'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(P, fy); ctx.lineTo(W - P, fy); ctx.stroke(); + ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#8B6E5E'; ctx.font = pf(26, 700); + ctx.fillText('妈妈盒子', P, fy + 80); + ctx.fillStyle = '#B39C8E'; ctx.font = pf(21, 400); + ctx.fillText('每周一封,宝宝写给妈妈的信', P, fy + 122); + const qs = 118, qx = W - P - qs, qy = fy + 36; + drawQRStub(ctx, qx, qy, qs); + ctx.textAlign = 'center'; + ctx.fillStyle = '#C6B3A6'; ctx.font = pf(18, 400); + ctx.fillText('示意图案 · 暂不可扫', qx + qs / 2, qy + qs + 26); + ctx.textAlign = 'left'; + } + + /* ---------- 模板一:信纸 ---------- + ctx 为 null = 只量高不落笔,和档案海报同一套两趟排版。 + ⚠️ 所有算 y 的算术都必须写在 if (ctx) 外面 —— 测试里 getContext 返回 null, + 落笔那一趟根本不跑,算错了只有量高这趟能替你发现。 */ + function drawCardPaper(C, ctx) { + const W = CARD_W, P = CARD_PAD, CW = W - P * 2; + if (ctx) { + ctx.fillStyle = '#FFFCF8'; ctx.fillRect(0, 0, W, ctx.canvas.height); + const g = ctx.createLinearGradient(0, 0, W, 0); + g.addColorStop(0, '#F4593C'); g.addColorStop(1, '#FBA377'); + ctx.fillStyle = g; ctx.fillRect(0, 0, W, 12); + ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#B39C8E'; ctx.font = pf(24, 600); + ctx.fillText(C.weekTxt + ' · ' + C.date, P, 96); + } + let y = 130; + + wrapCJK(C.title, CW, 46).forEach(ln => { + if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(46, 800); ctx.fillText(ln, P, y + 46); } + y += 64; + }); + y += 26; + /* 手绘波浪线:和详情页 .dl-wave 是同一个意思,画成正弦波省一条 path */ + if (ctx) { + ctx.strokeStyle = '#F3A88C'; ctx.lineWidth = 4; ctx.lineCap = 'round'; + ctx.beginPath(); + for (let i = 0; i <= 180; i += 3) ctx.lineTo(P + i, y + Math.sin(i / 14) * 5); + ctx.stroke(); + } + y += 44; + + C.paras.forEach((p, i) => { + wrapCJK(p, CW, 28).forEach(ln => { + if (ctx) { + /* 每行底下压一条浅横线 —— 这就是「信纸」这套模板的全部把戏 */ + ctx.strokeStyle = '#F3E8DE'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(P, y + 42); ctx.lineTo(W - P, y + 42); ctx.stroke(); + ctx.fillStyle = '#4A403A'; ctx.font = pf(28, 400); ctx.fillText(ln, P, y + 31); + } + y += 50; + }); + if (i < C.paras.length - 1) y += 18; + }); + + y += 48; + if (ctx) { + ctx.textAlign = 'right'; + ctx.fillStyle = '#B0806A'; ctx.font = pf(27, 700); + ctx.fillText('爱你的' + C.baby, W - P, y + 27); + ctx.textAlign = 'left'; + } + return y + 27 + 40; + } + + /* ---------- 模板二:成长足迹 ---------- + 主角是「第几周」这个数字,配一条 4→40 周的进度条和一串脚印。 */ + function drawCardSteps(C, ctx) { + const W = CARD_W, P = CARD_PAD, CW = W - P * 2; + if (ctx) { + ctx.fillStyle = '#FFF8F3'; ctx.fillRect(0, 0, W, ctx.canvas.height); + ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#C08C72'; ctx.font = pf(24, 600); + ctx.fillText('成长足迹 · ' + C.date, P, 92); + ctx.fillStyle = '#2E241E'; ctx.font = pf(72, 800); + ctx.fillText('第 ' + C.week + ' 周', P, 178); + ctx.fillStyle = '#8B6E5E'; ctx.font = pf(26, 500); + ctx.fillText(C.weekTxt + ' · 我已经是' + C.size.name + '那么大了', P, 226); + } + let y = 268; + + /* 进度条:4 周起步、40 周足月,位置就是这一段路走到哪儿了 */ + const pct = Math.max(0, Math.min(1, (C.week - 4) / 36)); + if (ctx) { + ctx.fillStyle = '#F2E2D6'; rrect(ctx, P, y, CW, 14, 7); ctx.fill(); + ctx.fillStyle = '#F4593C'; rrect(ctx, P, y, Math.max(14, CW * pct), 14, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(P + CW * pct, y + 7, 15, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(P + CW * pct, y + 7, 6, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#C6B3A6'; ctx.font = pf(20, 400); + ctx.fillText('4 周', P, y + 48); + ctx.textAlign = 'right'; ctx.fillText('40 周 足月', W - P, y + 48); ctx.textAlign = 'left'; + } + y += 78; + + /* 一串脚印,越靠近今天颜色越实。最多八对,多了就成了排版噪音 */ + const steps = Math.max(2, Math.min(8, Math.round((C.week - 4) / 4) + 1)); + if (ctx) { + for (let i = 0; i < steps; i++) { + const fx = P + 26 + i * ((CW - 52) / Math.max(1, steps - 1)); + footPrint(ctx, fx, y + 34 + (i % 2 ? 20 : 0), 11, 0.2 + 0.65 * (i / Math.max(1, steps - 1))); + } + } + y += 110; + + wrapCJK(C.title, CW, 38).forEach(ln => { + if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(38, 800); ctx.fillText(ln, P, y + 38); } + y += 54; + }); + y += 16; + + C.paras.forEach((p, i) => { + wrapCJK(p, CW, 27).forEach(ln => { + if (ctx) { ctx.fillStyle = '#4A403A'; ctx.font = pf(27, 400); ctx.fillText(ln, P, y + 27); } + y += 46; + }); + if (i < C.paras.length - 1) y += 16; + }); + + y += 40; + if (ctx) { + ctx.textAlign = 'right'; + ctx.fillStyle = '#B0806A'; ctx.font = pf(27, 700); + ctx.fillText('爱你的' + C.baby, W - P, y + 27); + ctx.textAlign = 'left'; + } + return y + 27 + 40; + } + + /* 一只脚印:一个压扁的椭圆当脚掌 + 三颗脚趾。 + 用 save/scale/arc 拼椭圆,不用 ctx.ellipse —— 老 webview 上没有那个方法。 */ + function footPrint(ctx, x, y, s, alpha) { + const col = 'rgba(244,89,60,' + alpha.toFixed(2) + ')'; + ctx.save(); ctx.translate(x, y); ctx.scale(1, 1.35); + ctx.fillStyle = col; ctx.beginPath(); ctx.arc(0, 0, s, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.fillStyle = col; + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); ctx.arc(x + i * s * 0.72, y - s * 1.8, s * 0.3, 0, Math.PI * 2); ctx.fill(); + } + } + + /* ---------- 模板三:水果比大小 ---------- + ⚠️ 这里不用 SIZE 表里的 emoji。两个原因: + 1) 上一批刚把站内最后两个实体 emoji 换成扁平线性图标, + 这儿再画一个 🌽 就是自己拆自己的台; + 2) canvas 里的 emoji 长什么样,取决于生成这张图那台手机的字体, + 安卓、iOS、鸿蒙画出来是三个东西,还可能是豆腐块。图一旦发出去就改不了了。 + 所以形状自己画,只借 SIZE 的名字和克数。 */ + const FRUIT_SHAPE = { + 4: 'seed', 5: 'seed', 6: 'seed', 7: 'berry', 8: 'berry', 9: 'berry', + 10: 'berry', 11: 'citrus', 12: 'berry', 13: 'citrus', 14: 'berry', 15: 'berry', + 16: 'citrus', 17: 'berry', 18: 'berry', 19: 'citrus', 20: 'long', 21: 'long', + 22: 'citrus', 23: 'long', 24: 'long', 25: 'leafy', 26: 'long', 27: 'leafy', + 28: 'long', 29: 'berry', 30: 'leafy', 31: 'citrus', 32: 'leafy', 33: 'citrus', + 34: 'citrus', 35: 'berry', 36: 'leafy', 37: 'long', 38: 'berry', 39: 'berry', + 40: 'berry' + }; + + /* 五种扁平形状,都画在 (cx, cy) 上,r 是外接半径。只填色不描边,和站内图标一个调子 */ + const FRUIT_DRAW = { + seed: (ctx, cx, cy, r) => { + ctx.fillStyle = '#8FBF7A'; + ctx.beginPath(); ctx.arc(cx, cy + r * .3, r * .55, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#6FA85C'; + ctx.beginPath(); ctx.moveTo(cx, cy - r * .1); + ctx.quadraticCurveTo(cx + r * .8, cy - r * .5, cx + r * .2, cy - r * .95); + ctx.quadraticCurveTo(cx - r * .1, cy - r * .5, cx, cy - r * .1); ctx.fill(); + }, + berry: (ctx, cx, cy, r) => { + ctx.fillStyle = '#F4735C'; + ctx.beginPath(); ctx.arc(cx, cy + r * .12, r * .86, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,.28)'; + ctx.beginPath(); ctx.arc(cx - r * .3, cy - r * .18, r * .24, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#8A6A4F'; ctx.lineWidth = Math.max(3, r * .07); ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(cx, cy - r * .7); ctx.lineTo(cx, cy - r * .98); ctx.stroke(); + ctx.fillStyle = '#7FB86B'; + ctx.beginPath(); ctx.moveTo(cx, cy - r * .92); + ctx.quadraticCurveTo(cx + r * .55, cy - r * 1.2, cx + r * .62, cy - r * .74); + ctx.quadraticCurveTo(cx + r * .3, cy - r * .68, cx, cy - r * .92); ctx.fill(); + }, + citrus: (ctx, cx, cy, r) => { + ctx.save(); ctx.translate(cx, cy); ctx.scale(1.18, .84); + ctx.fillStyle = '#F6A63F'; + ctx.beginPath(); ctx.arc(0, 0, r * .88, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.strokeStyle = 'rgba(255,255,255,.42)'; ctx.lineWidth = Math.max(3, r * .06); + for (let i = 0; i < 3; i++) { + ctx.beginPath(); + ctx.moveTo(cx - r * .6, cy - r * .3 + i * r * .3); + ctx.lineTo(cx + r * .6, cy - r * .3 + i * r * .3); + ctx.stroke(); + } + }, + long: (ctx, cx, cy, r) => { + const w = r * 1.0, h = r * 2.0; + ctx.fillStyle = '#F0B93E'; + rrect(ctx, cx - w / 2, cy - h / 2 + r * .12, w, h, w / 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = Math.max(3, r * .07); + ctx.beginPath(); ctx.moveTo(cx, cy - h / 2 + r * .5); ctx.lineTo(cx, cy + h / 2 - r * .3); ctx.stroke(); + ctx.fillStyle = '#7FB86B'; + ctx.beginPath(); ctx.moveTo(cx, cy - h / 2 + r * .1); + ctx.quadraticCurveTo(cx + r * .7, cy - h / 2 - r * .35, cx + r * .1, cy - h / 2 - r * .5); + ctx.quadraticCurveTo(cx - r * .2, cy - h / 2 - r * .1, cx, cy - h / 2 + r * .1); ctx.fill(); + }, + leafy: (ctx, cx, cy, r) => { + ctx.fillStyle = '#8FC47A'; + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); ctx.arc(cx + i * r * .48, cy - r * .3, r * .5, 0, Math.PI * 2); ctx.fill(); + } + ctx.fillStyle = '#EDF3E4'; + ctx.beginPath(); ctx.arc(cx, cy + r * .22, r * .68, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#B7D8A4'; + ctx.beginPath(); ctx.arc(cx, cy + r * .3, r * .4, 0, Math.PI * 2); ctx.fill(); + } + }; + + function drawCardFruit(C, ctx) { + const W = CARD_W, P = CARD_PAD, CW = W - P * 2; + if (ctx) { + ctx.fillStyle = '#FFFBF5'; ctx.fillRect(0, 0, W, ctx.canvas.height); + ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#C08C72'; ctx.font = pf(24, 600); + ctx.fillText('这一周,我有多大', W / 2, 96); + } + let y = 126; + + /* 半径按体积开三次方,不是按克数线性来 —— 线性的话 1 克那周会小成一个点。 + 再垫一个 26 的底,最小的那几周不至于看不清。 + 用 Math.pow(x, 1/3) 而不是 Math.cbrt:后者是 ES6,这份代码要跑在老 webview 上。 */ + const r = 26 + 124 * Math.pow(C.size.g / 3460, 1 / 3); + const areaH = Math.round(r * 2.9 + 40); + if (ctx) { + const cx = W / 2, cy = y + areaH / 2; + ctx.fillStyle = '#FDF1E6'; + ctx.beginPath(); ctx.arc(cx, cy, Math.max(r * 1.28, 118), 0, Math.PI * 2); ctx.fill(); + (FRUIT_DRAW[FRUIT_SHAPE[C.week] || 'berry'] || FRUIT_DRAW.berry)(ctx, cx, cy, r); + } + y += areaH + 30; + + if (ctx) { + ctx.textAlign = 'center'; + ctx.fillStyle = '#2E241E'; ctx.font = pf(52, 800); + ctx.fillText(C.size.name, W / 2, y + 52); + } + y += 52 + 24; + if (ctx) { + ctx.fillStyle = '#8B6E5E'; ctx.font = pf(26, 500); + ctx.fillText(C.weekTxt + ' · 差不多 ' + C.size.txt + ' 重', W / 2, y + 26); + ctx.textAlign = 'left'; + } + y += 26 + 46; + + if (ctx) { + ctx.strokeStyle = '#F2E4D8'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(P, y); ctx.lineTo(W - P, y); ctx.stroke(); + } + y += 40; + + wrapCJK(C.title, CW, 36).forEach(ln => { + if (ctx) { ctx.fillStyle = '#2E241E'; ctx.font = pf(36, 800); ctx.fillText(ln, P, y + 36); } + y += 52; + }); + y += 14; + + C.paras.forEach((p, i) => { + wrapCJK(p, CW, 27).forEach(ln => { + if (ctx) { ctx.fillStyle = '#4A403A'; ctx.font = pf(27, 400); ctx.fillText(ln, P, y + 27); } + y += 46; + }); + if (i < C.paras.length - 1) y += 16; + }); + + y += 40; + if (ctx) { + ctx.textAlign = 'right'; + ctx.fillStyle = '#B0806A'; ctx.font = pf(27, 700); + ctx.fillText('爱你的' + C.baby, W - P, y + 27); + ctx.textAlign = 'left'; + } + return y + 27 + 40; + } + + + /* 做成注册表而不是 if/else:加第四套样式只要往数组里再推一项, + chip、渲染、测试都自动跟着走。 + floor = 最小成图高度,正文特别短的信不至于缩成一张名片。 */ + const CARD_TPL = [ + { key: 'paper', name: '信纸', floor: 1000, draw: drawCardPaper }, + { key: 'steps', name: '成长足迹', floor: 1060, draw: drawCardSteps }, + { key: 'fruit', name: '比大小', floor: 940, draw: drawCardFruit } + ]; + let cardTpl = 'paper'; + + function renderCard(key) { + const tpl = CARD_TPL.filter(t => t.key === key)[0] || CARD_TPL[0]; + cardTpl = tpl.key; + const C = cardData(curLetter()); + const host = $('#cdPages'); + beginSheets(host, '来信-孕' + C.week + '周', C.baby + '写给妈妈的信'); + + /* 两趟:先拿 null 量高(此时 ctx 还不存在,画布也还没撑起来),再照这个高度画 */ + const h = Math.max(tpl.floor, Math.round(tpl.draw(C, null)) + CARD_FOOT); + addSheet(host, CARD_W, h, cardAlt(C, tpl.name), c => { tpl.draw(C, c); drawCardFoot(c); }); + + $$('#cdTpl .chip').forEach(b => { + const on = b.dataset.tpl === cardTpl; + b.classList.toggle('is-active', on); + b.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + + /* 说明卡上印了什么的那排信息胶囊(小名/孕周/日期/成图)撤了,不再补回来 —— + 样式胶囊已经占了一排,再来一排长得差不多的东西,面板就变成一堆标签了。 + 「卡上只有什么」这件事由下面这句话说清楚,一句顶一排。 */ + $('#cdLead').textContent = '卡上只有小名、孕周和这封信本身。预产期、体重这些都不会出现。'; + $('#cdTip').textContent = !posterURLs.length + ? '当前浏览器画不了图片,换个浏览器打开就能保存和分享了。' + : '点「保存图片」会叫出系统面板,选「存储图像 / 保存到相册」就进相册了。' + + '微信里没有这个面板,长按上面这张图存。右下角那枚码目前只是示意图案,还扫不出东西。'; + } + + /* chip 在这里一次性建好,renderCard() 只负责切 is-active */ + CARD_TPL.forEach(t => { + const b = el('button', 'chip', t.name); + b.dataset.tpl = t.key; + b.setAttribute('role', 'tab'); + b.addEventListener('click', () => { renderCard(t.key); haptic(); }); + $('#cdTpl').appendChild(b); + }); + + $('#dlCard').addEventListener('click', function () { + renderCard(cardTpl); // 记住上次选的样式,别每次都跳回信纸 + openPanel('detailCard', this); + }); + + /* ================= 搜索层 ================= */ + const sl = $('#searchLayer'); + $$('[data-search="1"]').forEach(b => b.addEventListener('click', () => { + sl.classList.add('show'); + setStatusbar('dark'); + setTimeout(() => $('#searchInput').focus(), 300); + })); + $$('[data-search="close"]').forEach(b => b.addEventListener('click', () => { + sl.classList.remove('show'); + $('#searchInput').value = ''; + setStatusbar('auto'); + })); + $$('.sl-hot .hot').forEach(h => h.addEventListener('click', () => toast('搜索「' + h.textContent.replace(/^\d/, '') + '」'))); + $('#searchInput').addEventListener('keydown', e => { + if (e.key === 'Enter' && e.target.value.trim()) toast('搜索「' + e.target.value.trim() + '」'); + }); + + /* ================= 横向滑动切页 ================= */ + (function swipe() { + const order = ['home', 'archive', 'letter', 'me']; + let x0 = 0, y0 = 0, tracking = false; + /* 手指落在一个「自己就能横向滚」的容器里时,这次滑动属于那个容器,不是翻页。 + 这里动态判断,而不是继续往下面那串 class 名单里加东西 —— + 首页的 .shelf-rail(横向卡带)就是因为名单没人同步维护才漏掉的: + 名单是在它诞生之前写的,于是在卡带上滑一张卡(位移 ~292px,远超 70 的阈值) + 会被当成翻页,直接跳到「档案」。名单里的 .stat-row / .moment-grid 其实早就是 + grid 布局、根本不横滚,也没人清理,可见这种硬编码名单靠不住。 + ⚠️ 阈值给 4px 而不是 0:只设了 overflow-y:auto 的纵向滚动容器, + 其 overflow-x 会被 CSS 计算成 auto,再叠上亚像素取整,很容易 + 出现 scrollWidth 比 clientWidth 大零点几的假阳性,那会把整页的翻页手势废掉。 */ + function inHScroll(el) { + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + if (n.scrollWidth > n.clientWidth + 4) { + const ox = getComputedStyle(n).overflowX; + if (ox === 'auto' || ox === 'scroll') return true; + } + } + return false; + } + document.addEventListener('touchstart', e => { + if (openedModal || sheet.classList.contains('show') || panelStack.length || sl.classList.contains('show')) return; + if (e.touches.length !== 1) return; + if (e.target.closest('.chips-row, .stat-row, .moment-grid, textarea, input')) return; + if (inHScroll(e.target)) return; + x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; tracking = true; + }, { passive: true }); + document.addEventListener('touchend', e => { + if (!tracking) return; tracking = false; + const dx = e.changedTouches[0].clientX - x0; + const dy = e.changedTouches[0].clientY - y0; + if (Math.abs(dx) < 70 || Math.abs(dy) > 60) return; + const i = order.indexOf(state.screen); + if (i < 0) return; + const next = dx < 0 ? i + 1 : i - 1; + if (next >= 0 && next < order.length) go(order[next]); + }, { passive: true }); + })(); + + /* ================= ESC / 返回 ================= */ + document.addEventListener('keydown', e => { + if (e.key !== 'Escape') return; + /* 弹窗永远在最上层,优先关它 */ + if (openedModal) { closeModal(); return; } + if (sl.classList.contains('show')) { sl.classList.remove('show'); return; } + if (detail.classList.contains('show')) { closeDetail(); return; } + if (sheet.classList.contains('show')) closeSheet(); + }); + + /* ================= 初始化 ================= */ + function init() { + /* 默认预产期是「今天 + 3 个月」现算出来的,标记里那些 + 「孕 24 周 +3 天 / 预产期 2024年12月22日」只是设计稿留下的占位, + 进门先按真实 state 刷一遍,免得首屏闪一下旧数字 */ + syncPregnancy(); + /* ⚠️ 这里原来还有一次 syncBio()。一句话签名整块已删(见 state 里那段说明)。 */ + /* 解锁状态是从 localStorage 读回来的,标记里写的是「没解锁」那一版。 + 老用户(上次已经输过码)不刷这一下,会在首页看到「还没领」的说法, + 点进去却畅通无阻 —— 文案和实际能力对不上。 */ + syncUnlockCopy(); + animateRings(); + $('#tabBadge').classList.toggle('show', state.unread > 0); + /* 来信页的默认标签:永远落在「全部」。 + 曾经改成过「有未读就落在未读」,但那样一进页面就看不到全部信件, + 未读的红点本来就在提示了,不需要再替人把列表筛一遍。 + 白滑块的位置是 initSegmented 在 rAF 里读 .seg-btn.is-active 算的, + 这里是同步改的类名,它跟得上,所以进页面时滑块直接就在第一格, + 不会有一段从别处滑过来的动画。 + 别改回硬写 filterLetters('all'):那样会绕过 state.letterFilter, + 盖掉别处对它的任何设定。这里是先定 state,再按 state 过滤。 */ + state.letterFilter = 'all'; + const lsegBtn = $('#letterSeg [data-lseg="' + state.letterFilter + '"]'); + $$('#letterSeg .seg-btn').forEach(b => b.classList.toggle('is-active', b === lsegBtn)); + filterLetters(state.letterFilter); + // 让分段控件在首次布局后归位 + setTimeout(() => { $('#archiveSeg')._reflow && $('#archiveSeg')._reflow(); $('#letterSeg')._reflow && $('#letterSeg')._reflow(); }, 60); + /* 最后再拉远程档案:必须在 init() 渲染完之后异步覆盖 —— 若在脚本解析时就拉, + fetch 的 then 往往比 init() 先跑,随后 init() 又会把 state 重置回默认值, + 档案数据被覆盖。放在 init 末尾,等 fetch then 回来时再回刷展示。 */ + applyRemoteProfile(); + // 第二批远程数据(资料库/记录/内容)—— 随 init 一起拉,失败回退内置 + applyRemoteAll(); + // 第三批远程数据(宝宝来信)—— 同样随 init 一起拉,失败回退内置样例 + applyRemoteLetters(); + } + /* ---------- 远程配置(box_app 后台下发) ---------- + 本页托管在 box_app(/mama-box/)时,启动后拉一次运营配置: + PAN / PAN_DEFAULT 网盘链接表和 WX_CODE 兑换码都由后台统一配。 + 拉不到 —— 静态托管在别处 / 断网 / 接口挂了 —— 就用上面写死的内置值, + 本页「单文件离线可用」的性质不受影响。 + ⚠️ PAN 的覆盖是「整表替换」不是逐项合并:后台删掉某条链接的语义就是 + 「这条不配了,走 PAN_DEFAULT」,逐项合并会让删除永远删不掉。 + ⚠️ 竞态说明:fetch 是异步的,回来之前用户可能已经点开过详情页—— + 那一次看到的是内置值,回来之后的都走新值。panOf()/wxCodeOK() + 是实时读表的,不需要重新渲染。 */ + (function fetchRemoteConfig() { + fetch('/api/mp/public/mama-box/config') + .then(r => (r.ok ? r.json() : null)) + .then(j => { + if (!j || j.code !== 200 || !j.result) return; + const c = j.result; + if (c.isEnabled === false) return; + Object.keys(PAN).forEach(k => { delete PAN[k]; }); + if (c.pan && typeof c.pan === 'object') { + Object.keys(c.pan).forEach(k => { + const p = c.pan[k]; + if (p && p.url) PAN[k] = { url: String(p.url), code: String(p.code || '') }; + }); + } + if (c.panDefault && c.panDefault.url) { + PAN_DEFAULT.url = String(c.panDefault.url); + PAN_DEFAULT.code = String(c.panDefault.code || ''); + } + if (c.wxCode) WX_CODE = String(c.wxCode); + }) + .catch(() => { /* 静默回退内置值 */ }); + })(); + + /* ---------- 远程个人档案(box_app 下发,openid 单一身份键) ---------- + 小程序首页把 openid 经 #hash 传给本页(微信 web-view 丢 query,hash 不丢), + 这里按 openid 拉 box_app 档案,覆盖 demo 默认值: + babyName / userName / babySex / dueDate(dueDate 是源,改了会重算孕周)。 + 让首页、来信称呼、孕周都跟真实身份走。 + 拉不到 —— 无 openid / 未建档 / 接口挂 —— 就用上方内置默认值,页面照常可用。 + ⚠️ 只在「后台有值」时覆盖:后台该字段为空(未建档)就维持内置默认,不把页面 + 打回白板。同样静默失败,不打断用户。 + ⚠️ 竞态:fetch 异步,回来时 init() 已跑完,这里只需回刷那些读 state 的展示。 */ + function hashParam(name) { + try { + const h = decodeURIComponent(location.hash.slice(1)); + /* openid 是首个参数时前面没有 &/#(如 "#openid=x"),[&#] 是"或",作可省处理 */ + const m = h.match(new RegExp('(?:^|[&#])' + name + '=([^&#]*)')); + return m ? m[1] : ''; + } catch (e) { return ''; } + } + let _profileApplied = false; + function applyRemoteProfile() { + if (_profileApplied) return; // init 只跑一次,防重复 fetch + _profileApplied = true; + const openid = hashParam('openid'); + if (!openid) return; + const base = '/api/mp/public/mama-box/'; + /* 并行拉 个人档案 + 孕期状态: + - profile:babyName/userName/babySex/dueDate(身份 + 建档字段) + - pregnancy:week/day/due 以服务端权威派生为准(避免客户端重复算、口径统一) + 拉不到(无 openid / 未返回 200 / 断网)就静默保持内置默认值,页面照常可用。 */ + Promise.all([ + fetch(base + 'profile?openid=' + encodeURIComponent(openid)).then(r => (r.ok ? r.json() : null)), + fetch(base + 'pregnancy?openid=' + encodeURIComponent(openid)).then(r => (r.ok ? r.json() : null)) + ]).then(function (rs) { + const pj = rs[0], pg = rs[1]; + if (pj && pj.code === 200 && pj.result) { + const p = pj.result; + if (p.babyName) state.babyName = p.babyName; + if (p.userName) state.userName = p.userName; + if (p.babySex && p.babySex !== 'unknown') state.babySex = p.babySex; + if (p.dueDate) { const d = parseDay(p.dueDate); if (d) state.due = d; } + } + if (pg && pg.code === 200 && pg.result) { + state.week = pg.result.week; + state.day = pg.result.day; + if (pg.result.due) { const d = parseDay(pg.result.due); if (d) state.due = d; } + } + // 统一回刷依赖这些 state 的展示 + syncPregnancy(); + syncBabyWords(); + stampLetters(); + filterLetters(state.letterFilter); + syncUnlockCopy(); + syncStaticDates(); + if (state.unread > 0) $('#tabBadge').classList.toggle('show', true); + }).catch(function () { /* 静默回退内置值 */ }); + } + + /* ---------- 远程数据(box_app,统一改H5 · 第一批:资料库/记录/内容) ---------- + applyRemoteProfile 已拉 profile/pregnancy/config; + 这里再拉 resources/records/content 并**原地覆盖**本地 demo,接口数据为准,拉不到则保持内置。 + ⚠️ RES 图片:后端把封面图放到 box_app /assets,这里把返回的 imgUrl 赋给 RES[key].img, + 渲染(用 r.img)无需改动;正文(body)仍用本地(接口未下发,保证详情不空)。 */ + function __applyResources(items) { + if (!items || !items.length) return; + Object.keys(RES).forEach(function (k) { delete RES[k]; }); + var order = []; + items.forEach(function (it) { + var old = RES[it.key] || { body: [] }; + RES[it.key] = { + img: it.imgUrl || old.img || '', cat: it.cat, tint: it.tint, + title: it.title, desc: it.desc, meta: it.meta || old.meta || [], + body: old.body || [], + }; + order.push(it.key); + RES[it.key].fav = !!it.fav; RES[it.key].got = !!it.got; + }); + // 同步收藏/已领状态(对齐后端) + state.resFav = items.filter(function (i) { return i.fav; }).map(function (i) { return i.key; }); + state.resGot = items.filter(function (i) { return i.got; }).map(function (i) { return i.key; }); + renderResList(state.resCat); + } + function __applyRecords(rec) { + if (!rec) return; + state.kickToday = rec.kickCount || 0; + state.todayCount = rec.total || 0; + var a = $('#kickTodayHome'), b = $('#todayCount'), c = $('#kickCount'); + if (a) a.textContent = state.kickToday; + if (b) b.textContent = state.todayCount; + if (c) c.textContent = state.kickQuick || 0; + } + function __applyAbout(ct) { + if (!ct) return; + SETTINGS.about.title = ct.title || SETTINGS.about.title; + SETTINGS.about.more = ct.more || SETTINGS.about.more; + SETTINGS.about.foot = ct.foot || SETTINGS.about.foot; + } + function applyRemoteAll() { + var openid = hashParam('openid'); + if (!openid) return; + var base = '/api/mp/public/mama-box/'; + var j = function (u) { return fetch(u).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }; + Promise.all([ + j(base + 'resources?openid=' + encodeURIComponent(openid)), + j(base + 'records?openid=' + encodeURIComponent(openid)), + j(base + 'content?key=about'), + ]).then(function (rs) { + var rr = rs[0], rec = rs[1], ct = rs[2]; + if (rr && rr.code === 200 && rr.result) __applyResources(rr.result.items); + if (rec && rec.code === 200 && rec.result) __applyRecords(rec.result); + if (ct && ct.code === 200 && ct.result) __applyAbout(ct.result); + }).catch(function () { /* 静默回退内置值 */ }); + } + + /* ---------- 远程宝宝来信(box_app 下发,openid 单一身份键) ---------- + 覆盖上面内置的 LETTERS 样例:后台按 openid 在 MongoDB 里播种 l1..l4, + 回信 / 语音 / 未读 / 收藏都持久化在那边。这里先拉「列表」,再并行拉 + 「每一封的详情」,把每封映射成引擎的信件形状后**原地替换** LETTERS + (const 数组,用 splice 保住引用),列表 / 详情 / 未读数都从后台数据渲染。 + ⚠️ 映射时 back 一律置 null:stampLetters() 见到 back==null 就跳过, + 这样后台下发的 at/week/date/dateFull 才不会被客户端重新盖掉。 + 拉不到 —— 无 openid / 接口挂 / 返回码不对 —— 就静默回落内置样例,页面照常可用。 */ + function applyRemoteLetters() { + var openid = hashParam('openid'); + if (!openid) return; + var base = '/api/mp/public/mama-box/'; + var j = function (u) { return fetch(u).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }; + j(base + 'letters?openid=' + encodeURIComponent(openid)).then(function (lj) { + if (!lj || lj.code !== 200 || !lj.result || !lj.result.items || !lj.result.items.length) return; + Promise.all(lj.result.items.map(function (it) { + return j(base + 'letters/' + encodeURIComponent(it.key) + '?openid=' + encodeURIComponent(openid)); + })).then(function (dets) { + var apiList = lj.result.items.map(function (it, i) { + var det = (dets[i] && dets[i].code === 200 && dets[i].result) ? dets[i].result : {}; + return { + id: it.key, back: null, time: null, title: it.title, preview: it.preview, + paras: (det.paras && det.paras.length ? det.paras : [it.preview]), + unread: !!it.unread, fav: !!it.fav, voice: !!it.voice, + thread: (det.thread || []).map(function (m) { + return { who: m.who, text: m.text, at: m.at || 0, back: null, when: m.at ? fmtWhen(m.at) : '' }; + }), + at: it.at || 0, week: it.week, weekTxt: it.weekTxt || '', date: it.date || '', dateFull: it.dateFull || '', + month: (it.at ? (function (d) { return d.getFullYear() + '年 ' + (d.getMonth() + 1) + '月'; })(new Date(it.at)) : (it.dateFull || '').slice(0, (it.dateFull || '').indexOf('月') + 1)) + }; + }); + try { + LETTERS.splice(0, LETTERS.length, ...apiList); + state.curLetter = LETTERS[0].id; + state.homeLetter = LETTERS[0].id; + renderLetters(); + syncLetterCounts(); + pushLatestToHome(); + } catch (e) { /* 映射 / 渲染异常就回退内置样例 */ } + }).catch(function () { /* 静默回退内置样例 */ }); + }).catch(function () { /* 静默回退内置样例 */ }); + } + + /* ---------- 进商城(web-view 内跳商城原生页) ---------- + 妈妈盒子 H5 由小程序 web-view 承载;点「商城」跳回商城原生页。 + 需微信 JS-SDK 的 wx.miniProgram.switchTab —— tabBar 页必须用 switchTab + (详见 docs/mama-box-api-plan.md 2.4)。 + 仅在 web-view 环境生效;浏览器独立打开时 wx.miniProgram 不可用,降级为 toast。 */ + function goMall() { + haptic(); + if (window.wx && window.wx.miniProgram) { + /* getEnv 在纯浏览器里回调不会触发(等不到微信桥), + 所以加一个超时兜底:800ms 没回就按「非小程序环境」提示。 */ + var responded = false; + window.wx.miniProgram.getEnv(function (r) { + responded = true; + if (r && r.miniprogram) { + window.wx.miniProgram.switchTab({ url: '/pages/goods_cate/goods_cate' }); + } else { + toast('请在微信小程序中打开商城'); + } + }); + setTimeout(function () { + if (!responded) toast('请在微信小程序中打开商城'); + }, 800); + } else { + toast('请在微信小程序中打开商城'); + } + } + $$('#btnMall').forEach(function (b) { + b.addEventListener('click', goMall); + }); + + if (document.readyState === 'complete') init(); + else window.addEventListener('load', init); +})(); + diff --git a/js/core.js b/js/core.js new file mode 100644 index 0000000..50c6f1d --- /dev/null +++ b/js/core.js @@ -0,0 +1,88 @@ +/* 妈妈盒子 · MPA 共享层(无框架,普通