/* ================= 兜爸心选 H5 · 交互 ================= */ (function () { 'use strict'; /* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */ var VER = '26090814'; 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; } } /* 「档案」tab 一期跳独立 H5(yunqi-dangan)。 ⚠️ 内置档案屏(#screen-archive)暂时屏蔽:数据来源未确认,不放进一期给用户。 屏的代码/样式全部保留,后续确认数据源后把这里改回 go('archive') 原逻辑即可。 身份转发:把当前 openid/unionid 带过去(query + hash 双写,对齐身份注入惯例)。 */ function goArchiveH5() { const oid = hashParam('openid'); const uid = hashParam('unionid'); let u = 'https://mp.yizhezhe.com/yunqi-dangan/'; const q = []; if (oid) q.push('openid=' + encodeURIComponent(oid)); if (uid) q.push('unionid=' + encodeURIComponent(uid)); if (q.length) u += '?' + q.join('&'); if (oid) u += '#openid=' + encodeURIComponent(oid); location.href = u; } /* ================= 「记一下」弹层 · 全部孕期工具 = 已上架应用列表 ================= 数据:box_app /api/mp/apps(应用管理统一维护图标/名称/链接)。 范围:isPublished=1 且有 url;排除授权示例(wx-h5-demo,开发者用的)。 交互:全量铺进弹层宫格(沿用 .apps-grid/.app 样式),点击带身份跳转对应 H5。 */ function appTileColor(name) { const PALETTE = ['#F2917A', '#E8A2B8', '#8FB4E3', '#7FC8A9', '#E3B771', '#A99BD1', '#73BFB2', '#DE8F6E']; let h = 0; for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return PALETTE[h % PALETTE.length]; } function jumpApp(url) { haptic(); const oid = hashParam('openid'); const uid = hashParam('unionid'); const q = []; if (oid) q.push('openid=' + encodeURIComponent(oid)); if (uid) q.push('unionid=' + encodeURIComponent(uid)); if (q.length) url += (url.indexOf('?') >= 0 ? '&' : '?') + q.join('&'); if (oid) url += '#openid=' + encodeURIComponent(oid); location.href = url; } function renderAppGrid(apps) { const grid = $('#appsGrid'); if (!grid) return; grid.innerHTML = apps.map(a => { const ico = a.logo || a.icon || ''; const name = a.name || a.identifier || '应用'; /* 有 logo:白底圆角让图盖满;无 logo:colorTheme/调色板 + 首字彩色块。 ⚠️ colorTheme 目前全是 'blue' 颜色名 —— 有图时别拿它当底色,否则清一色蓝。 */ const bg = ico ? 'background:#fff' : 'background:' + (a.colorTheme || appTileColor(name)); const img = ico ? '' : '' + name.slice(0, 1) + ''; return '' + img + name + ''; }).join(''); /* 磁贴点击(事件委托,innerHTML 重建后不用重绑) */ grid.onclick = function (e) { const t = e.target.closest('.app'); if (t && t.dataset.url) jumpApp(decodeURIComponent(t.dataset.url)); }; /* 角标「共 N 个」同步真实数量 */ const cnt = $('.apps-head.for-all .sec-more'); if (cnt) cnt.textContent = '共 ' + apps.length + ' 个 ›'; } function loadPublishedApps() { fetch('/api/mp/apps?isPublished=1&pageSize=200') .then(r => (r.ok ? r.json() : null)) .then(d => { const items = (d && d.result && d.result.items) || []; const apps = items .filter(a => a.url && !/wx-h5-demo/i.test(a.url)) /* 排除授权示例 */ .sort((x, y) => (x.seq || 0) - (y.seq || 0) || (x.no || 0) - (y.no || 0)); apps.forEach(a => { APP_BY_ID[a.identifier] = a; }); /* 供首页工具卡按 identifier 取 URL */ renderAppGrid(apps); }) .catch(function () { /* 拉不到就保留弹层原有占位,不影响其余页面 */ }); } loadPublishedApps(); function go(name) { /* 「档案」先跳孕期档案 H5(一期屏蔽内置档案屏,原因见 goArchiveH5 注释) */ if (name === 'archive') { goArchiveH5(); return; } 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' && !state.pregEmpty); /* 档案页的孕程已经从圆环换成细条了,这里跟着换成「归零再长回去」。 归零和写目标值必须隔一帧,同一帧里改两次浏览器只会看到最后那个值, 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); /* 真 上敲回车/空格,浏览器会自动派一个 click; 不会。 首页那张脚印卡就是这样的 div,手动补上,键盘和读屏才用得了它 */ if (b.tagName !== 'BUTTON') b.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fire(); } }); }); /* ---------- 已移除模拟状态栏:保留空实现,避免调用点散落判空 ---------- */ function updateStatusbar() {} function setStatusbar() {} /* ================= 进度环动画 ================= */ function animateRings(root) { $$('.ring-fg', root || document).forEach(c => { const r = c.r.baseVal.value; const len = 2 * Math.PI * r; const p = parseFloat(c.dataset.ring || 0.6); c.style.strokeDasharray = len; c.style.strokeDashoffset = len; // 强制重绘后再动画 void c.getBoundingClientRect(); requestAnimationFrame(() => { c.style.strokeDashoffset = len * (1 - p); }); }); } /* 档案页孕程条:目标值记在 data-pct 上(syncPregnancy 写), 每次进页面归零再长回去,和上面那个环的观感一致。 判空是因为 replayArBar 挂在 go() 里 —— 万一这一块以后又改结构, 没有条也不该把整个切页函数带崩。 */ function setArBar(pct) { const b = $('#arBar'); if (!b) return; b.dataset.pct = pct; b.style.width = pct + '%'; } function replayArBar() { const b = $('#arBar'); if (!b) return; const p = b.dataset.pct || 0; b.style.width = '0%'; void b.getBoundingClientRect(); // 强制重绘,否则两次赋值会被合并 requestAnimationFrame(() => { b.style.width = p + '%'; }); } /* ================= 花瓣飘落 ================= */ (function petals() { const box = $('#petals'); if (!box) return; for (let i = 0; i < 10; i++) { const p = document.createElement('i'); p.style.left = Math.random() * 100 + '%'; p.style.animationDuration = (6 + Math.random() * 6) + 's'; p.style.animationDelay = (Math.random() * 8) + 's'; p.style.opacity = 0.3 + Math.random() * 0.5; const s = 5 + Math.random() * 7; p.style.width = s + 'px'; p.style.height = s + 'px'; box.appendChild(p); } })(); /* ================= 分段控件(档案) ================= */ function initSegmented(segId, thumbId, attr, onChange) { const seg = $(segId), thumb = $(thumbId); if (!seg) return; const btns = $$('.seg-btn', seg); function move(btn) { const pad = 5; thumb.style.width = btn.offsetWidth + 'px'; thumb.style.transform = `translateX(${btn.offsetLeft - pad}px)`; } btns.forEach(b => b.addEventListener('click', () => { btns.forEach(x => x.classList.toggle('is-active', x === b)); move(b); haptic(); onChange && onChange(b.dataset[attr], b); })); const init = () => move($('.seg-btn.is-active', seg)); requestAnimationFrame(init); window.addEventListener('resize', init); seg._reflow = init; } initSegmented('#archiveSeg', '#segThumb', 'seg', key => { $$('.seg-panel', $('#screen-archive')).forEach(p => p.classList.toggle('is-active', p.dataset.panel === key)); // 重新触发图表动画 if (key === 'data') { $$('#screen-archive .line, #screen-archive .spark polyline').forEach(el => { el.style.animation = 'none'; void el.offsetWidth; el.style.animation = ''; }); } }); initSegmented('#letterSeg', '#letterThumb', 'lseg', key => { state.letterFilter = key; filterLetters(key); }); /* filterLetters 见下方「宝宝来信 · 会话式」一节: 列表已经是一封信一条 thread,筛选是按 thread 整体显隐的 */ /* ================= 筛选 chips ================= */ /* 档案页时光轴和珍贵时刻那两排分类 chips 已经删了(上面刚有一排分段, 紧跟着再来一排长得差不多的标签,反而看不清谁管谁), 所以这里也不再有 timeline / moment 两个分支 —— 只剩搜索页那种「点一下弹个 toast」 的装饰性 chips。tl-item / moment 上的 data-cat 留着没删,将来真要做筛选还用得上。 */ $$('.chips-row').forEach(row => { /* 资源库那一排 chips 由 renderResList() 单独接管(按 data-rescat 筛选)。 它也带 .chips-row,会被这里重复绑定一次,结果每点一次分类 都会多弹一句「搜索「产检」」 */ if (row.id === 'resChips') return; const chips = $$('.chip', row); chips.forEach(c => c.addEventListener('click', () => { chips.forEach(x => x.classList.toggle('is-active', x === c)); haptic(); toast('搜索「' + c.textContent.trim() + '」'); })); }); /* ================= 珍贵时刻收藏 ================= */ $$('.star').forEach(st => st.addEventListener('click', e => { e.stopPropagation(); const on = st.classList.toggle('is-on'); state.favCount += on ? 1 : -1; const fc = $('#favCount'); if (fc) { fc.textContent = state.favCount; bump(fc); } toast(on ? '已收藏到珍贵时刻 ★' : '已取消收藏'); haptic(); })); function bump(el) { el.classList.remove('bump'); void el.offsetWidth; el.classList.add('bump'); } /* ================= 记一下 · 底部弹层 ================= */ const sheet = $('#sheet'), scrim = $('#scrim'), phoneEl = $('#phone'); /* ⚠️ .phone 上的 sheet-open 必须和 sheet 自己的 show **成对**切,不是冗余状态。 「记一下」圆钮展开时转 45°,原来靠 `.sheet.show ~ .tabbar .fab` 实现。 通用兄弟选择器只能向**后**选,而本批把 移到了 .phone 的最前面(让底部 tab 尽早渲染,理由见 index.html 里 nav 上方那段), nav 排到 .sheet 前面之后那条规则永远不再命中,圆钮会直接不转 —— 而且 这种「动画没了」的回归 jsdom 根本量不到,只能靠这段注释和下面那条断言钉住。 ⚠️ 别图省事删掉这两行改回兄弟选择器,除非你把 nav 也挪回文件末尾。 */ function openSheet() { scrim.classList.add('show'); sheet.classList.add('show'); phoneEl.classList.add('sheet-open'); setStatusbar('light'); haptic(); } function closeSheet() { scrim.classList.remove('show'); sheet.classList.remove('show'); phoneEl.classList.remove('sheet-open'); sheet.style.transform = ''; if (!detail.classList.contains('show')) setStatusbar('auto'); } $$('[data-sheet="open"]').forEach(b => b.addEventListener('click', openSheet)); $$('[data-sheet="close"]').forEach(b => b.addEventListener('click', closeSheet)); scrim.addEventListener('click', closeSheet); /* 下拉关闭 */ (function drag() { const grip = $('#sheetGrip'); let startY = 0, dy = 0, dragging = false; grip.addEventListener('pointerdown', e => { dragging = true; startY = e.clientY; dy = 0; sheet.classList.add('dragging'); grip.setPointerCapture(e.pointerId); }); grip.addEventListener('pointermove', e => { if (!dragging) return; dy = Math.max(0, e.clientY - startY); sheet.style.transform = `translateY(${dy}px)`; scrim.style.opacity = Math.max(0, 1 - dy / 400); }); const end = () => { if (!dragging) return; dragging = false; sheet.classList.remove('dragging'); scrim.style.opacity = ''; if (dy > 110) closeSheet(); else sheet.style.transform = ''; }; grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end); })(); /* 快捷:数胎动计数 */ $('#quickKick').addEventListener('click', () => { state.kickQuick++; state.kickToday++; state.todayCount++; const a = $('#kickCount'), b = $('#kickTodayHome'), c = $('#todayCount'); a.textContent = state.kickQuick; bump(a.parentElement); /* #kickTodayHome 原在首页数胎动演示卡上(已换成应用宫格),守卫判空 */ if (b) { b.textContent = state.kickToday; bump(b.parentElement); } c.textContent = state.todayCount; toast('胎动 +1,今天已记录 ' + state.kickQuick + ' 次'); haptic(); /* 妈妈数胎动 → 宝宝会寄一封「肚子里的声音」过来(几小时后才到,全孕期最多两封) */ maybeVoiceAfterKick(); }); /* 工具列表去重:顶部「今天已使用」的快捷入口若在下方列表里重复出现, 用户会觉得同一个工具出现了两次,这里按名称把它们从全部列表中摘掉 */ (function dedupeApps() { const used = new Set($$('.quick-row .q-t').map(e => e.textContent.trim())); $$('#appsGrid .app').forEach(b => { if (used.has(b.textContent.trim())) b.remove(); }); const rest = $$('#appsGrid .app').length; const head = $('.apps-head.for-all'); if (head) head.firstChild.nodeValue = '其他孕期工具 '; const more = $('.apps-head.for-all .sec-more'); if (more) { more.textContent = '还有 ' + rest + ' 个 ›'; more.dataset.toast = '共 ' + (rest + used.size) + ' 个孕期工具,已使用的 ' + used.size + ' 个已置顶'; } })(); /* 工具宫格 */ $$('#appsGrid .app').forEach(a => a.addEventListener('click', () => { const name = a.textContent.trim(); if (name === '更多工具') { toast('已展示全部孕期工具'); return; } toast('打开「' + name + '」'); haptic(); })); /* 首页宫格入口。 五个坑位里有三个已经有真站点了,点了直接跳过去;剩下两个(数胎动 / 生长曲线) 还没有后端,维持原来弹 toast 的占位行为,不假装能打开。 跳转用 location.href 走**当前标签页**:这个壳是照着原生 App 做的, 新开标签会在微信 / Safari 里堆出一串返回不去的页面;同页跳转的话, 浏览器返回键就是天然的「返回」。 */ /* 首页五张工具卡 → 已上架应用(identifier 在应用管理维护,后台改 URL 即生效)。 ⚠️ 不再裸跳旧 beian*.douba.top 域名:统一走 jumpApp 带身份(openid/unionid)。 */ const MINI_APP_MAP = { kick: 'taidong', // 数胎动 (1005) weight: 'yunqitizhong', // 孕期体重管理 (1012) cal: 'chanjianrili', // 孕检日历 (1009) curve: 'taiershengzhang', // 生长曲线 (1013) fweight:'taierguzhong' // 胎儿估重 (1037) }; const APP_BY_ID = {}; $$('[data-mini]').forEach(b => b.addEventListener('click', () => { const map = { kick: '数胎动', weight: '孕期体重', cal: '孕检日历', curve: '生长曲线', fweight: '胎儿估重' }; const app = APP_BY_ID[MINI_APP_MAP[b.dataset.mini]]; if (app && app.url) { jumpApp(app.url); return; } toast('「' + map[b.dataset.mini] + '」即将上线'); })); /* 记一下弹层只保留「全部孕期工具」,不再提供输入框 */ /* ================= 滑入详情页(栈式,可多层返回) ================= */ const panelStack = []; function openPanel(id, trigger) { const el = document.getElementById(id); if (!el) return; /* 这一页此刻在栈里的位置:-1 = 没开过,length-1 = 正停在最上层,其余 = 开着但被压住了 */ const at = panelStack.indexOf(el); /* detailLetter 的内容由 openLetter() 先填好再调 openPanel,这里不再插手 */ if (id === 'detailAI') renderAI(0); if (id === 'detailResList') renderResList(state.resCat); if (id === 'detailRes') fillRes((trigger && trigger.dataset.res) || 'book'); if (id === 'detailSetting') renderSetting((trigger && trigger.dataset.set) || 'privacy'); haptic(); /* 已经停在最上层(例:内容页里点「相关推荐」)→ 只换内容,不动层级。 ⚠️ `at >= 0` 这半句不能省:栈空时 at 和 length-1 都是 -1, 只写 `at === panelStack.length - 1` 会把**第一次打开任何一页**也判成 「已在最上层」而早退 —— 整个 App 的详情页全部打不开。写这一版时就栽在这儿, 是 smoke 里那条「列表和详情页显示的是同一句」先崩的(#dlThread 根本没渲染)。 */ if (at >= 0 && at === panelStack.length - 1) return; /* ⚠️ 这里原来是一句 `if (reopen) return;`,把「在最上层」和「被压住」当成了同一回事, 后者会**静默失败**: 首页书架 → detailRes(栈 = [detailRes])→ 点「其他资源」→ 栈 = [detailRes, detailResList], 此时点资源库里任何一份资源 → detailRes 已在栈内 → fillRes() 把**底下那页**的内容悄悄换掉 → haptic() 照常震一下 → return。资源库纹丝不动,整列资源看着像全坏了; 而且底下那页的内容已经被掉包,返回时看到的不是当初点进去的那一份。 下面 #rdLib 那段注释早就写过同一个陷阱(「它会静默 return,页面纹丝不动,看着就像按钮坏了」), 但当时只堵了「从详情页去资源库」这一个方向,反方向漏了。 所以「开着但被压住」不能早退,要把它挪到栈顶重新亮出来。 */ if (at >= 0) panelStack.splice(at, 1); panelStack.push(el); /* ⚠️ 必须整栈重铺,不能只给挪上来的这一个赋值: splice 之后原栈顶的下标变了,单独算一个必然和它撞成同一层(80+1*2 对 80+1*2), 撞层时谁在上面由 DOM 顺序决定 —— 而 detailResList 在 HTML 里正好排在 detailRes 前面, 结果就是「代码以为挪上来了,屏幕上还是被压着」,比现在这个 bug 更难查。 */ panelStack.forEach((p, i) => { p.style.zIndex = 80 + i * 2; }); el.classList.add('show'); } function closePanel() { const el = panelStack.pop(); if (el) el.classList.remove('show'); } function closeAllPanels() { while (panelStack.length) closePanel(); } $$('[data-open]').forEach(b => b.addEventListener('click', () => openPanel(b.dataset.open, b))); /* ================= 宝宝来信 · 会话式 ================= 一封来信就是一段对话:宝宝的信 + 妈妈的回信 + 宝宝的再回复, 全都挂在同一条 thread 上。列表里回信缩进在信下面,详情页里顺序展开, 所以「这条回信是回给哪封信的」一眼可见,不会再另开一页。 宝宝的回信从不实时:妈妈一互动就往队列里排一条「3~5 小时后送达」, 到点由 30 秒一跳的 ticker 投递。队列存在 localStorage 里, 关掉页面过几个小时再回来,信照样会在。 */ const detail = $('#detailLetter'); const HOUR = 3600000; const LETTER_ARCHIVED = 20; // 更早的信已折进档案,只在「共 N 封」里计数 const VOICE_CAP = 2; // 「肚子里的声音」整个孕期最多两封 /* ---------- 按性别切换口吻:选了男孩/女孩,宝宝回信的味道就不一样 ---------- 落款不再跟性别走 —— 改用宝宝小名「爱你的小汤圆」,见 renderLetterDetail */ const BABY_REPLY = { unknown: [ '妈妈,你的信我收到啦。你写字的时候肚子暖暖的,我就知道是你。', '妈妈,你说的话我都记下了。等我出去,我们再念一遍好不好?', '妈妈,谢谢你每天都想着我。我在这里很好,你也要好好吃饭。' ], boy: [ '妈妈,信收到啦!我刚伸了个大懒腰,等我出去背你上楼梯。', '妈妈,我又踢了两下,是跟你击掌呢,不是调皮。', '妈妈你放心,我好好长个子。以后你靠着我就行。' ], girl: [ '妈妈,你写的每个字我都听见啦。我在这儿悄悄转了个圈。', '妈妈,我把小手贴在你说话的地方了,暖暖的。', '妈妈,等我出去,我要挽着你的胳膊去买菜。' ] }; /* ---------- 等待文案池 ---------- 妈妈按下「发送」之后、宝宝真的回信之前,那段空窗期里显示的一行话。 原来只有写死的一句「{baby}收到啦,让我先睡一小觉,醒来就回你」—— 妈妈每回一封都看见同一行字,第二次起就不像宝宝在说话了,像个系统回执。 所以摊成 30 句,排队时随机挑一句。 写这些句子时守的三条: 1. **不报时间。** 一句「一会儿就回你」都不行 —— 说了妈妈就会盯着表, 到点没来像是坏了。宝宝写信本来就该是件慢事。 2. **说的是宝宝的状态,不是回执。** 是「我在打哈欠 / 在打草稿 / 在数你的字」,不是「您的消息已送达」。 3. **每句留一个小钩子。** 妈妈看完该有点想再回来看看的意思。 小名写成 {baby} 占位符,渲染时才由 fillBaby() 换掉 —— 排队记录是要落盘的,如果这里存的是已经拼好名字的整句, 妈妈之后在「编辑孕期信息」里改了小名,这行会一直挂着旧名字。 ⚠️ 别往这些句子里加 emoji。站内的实体 emoji 前几批已经清过一轮了。 ⚠️ 也别把句子写长:这行在列表里是 11.5px 的小字, 算上小名超过 30 个字就会折行,把卡片顶散。smoke 里有一条盯着长度。 */ const WAIT_TXT = [ '{baby}收到啦,让我先睡一小觉,醒来就回你。', '信收到啦,{baby}正把它读第二遍,读完就悄悄回你。', '{baby}把你的信贴在耳朵边上了,听着听着就想好说什么了。', '收到啦。{baby}现在有点困,等睡饱了,一笔一画地回你。', '妈妈的信到了,{baby}要先想一想怎么说才好听。', '{baby}正踮着脚够信封呢,够到了就给你回。', '信我收好啦。{baby}要先攒一点力气,才写得动字。', '{baby}读到一半就笑了,剩下半封留着慢慢读。', '妈妈别催呀,{baby}正在心里打草稿呢。', '收到啦,{baby}把信摊在肚皮上晒一晒,晒暖了再回。', '{baby}翻了个身,说这封要好好回,得想久一点。', '信到啦。{baby}正含着手指头想句子,想好就说给你听。', '{baby}把信折成小方块塞进被窝了,醒来第一件事就是回你。', '妈妈写的字暖暖的,{baby}要抱着睡一觉。', '收到收到。{baby}先眯一会儿,攒够了话再回你。', '{baby}正一个字一个字地数你的信,数完就回。', '信我看见啦,{baby}要留到最安静的时候再读一遍。', '{baby}现在有点害羞,等脸不红了就回你。', '妈妈的悄悄话收到啦,{baby}也想了一句悄悄话要给你。', '{baby}把信举得高高的,说这是今天最好的一件事。', '收到啦。{baby}要先伸个懒腰,伸完就动笔。', '{baby}听见了。下次踢你的时候,就是在说「我在回你啦」。', '信收下啦,{baby}正挑最软的那句话回你。', '{baby}读完打了个小哈欠,说回信的事交给梦里想。', '妈妈稍等一下下,{baby}的话还在路上呢。', '收到啦,{baby}要慢慢回,快了怕说错。', '{baby}把你的信藏在最靠近心跳的地方了,随后就回你。', '信到了。{baby}正学着你写字的样子,一横一竖地练呢。', '妈妈的信让{baby}安心了一整天,等我把这份安心还给你。', '{baby}小声说了句「知道啦」,就去准备回信啦。' ]; /* ---------- 「宝宝」这两个字的统一出口 ---------- 用户在「我的 → 编辑孕期信息」里填了小名,来信这条线上所有称呼 都得跟着换:「米可来信」「米可回信啦」「看看米可的回复」「给米可回信」。 没填就回落到「宝宝」,每句文案照样通顺。 写成 function 而不是箭头常量:它被上面的 renderLetterDetail 等函数引用, 声明会提升,谁先谁后都不会踩到暂时性死区。 */ function babyWord(suffix) { return (state.babyName || '宝宝') + (suffix || ''); } /* 示例往来里妈妈写的那几句也会喊小名。数据里写成占位符 {baby}, 渲染时才替换 —— 用户自己敲进去的回信原样保留,不做任何替换。 */ function fillBaby(str) { return str.replace(/\{baby\}/g, babyWord()); } /* ---------- 信件数据 ---------- 正文刻意写短:一封信三四行、几十个字就够了。 长了反而像科普稿,妈妈滑两下就走神;短句子留白多, 一句「我知道那是为了我」比一整段发育数据更能让人红眼眶。 ⚠️ 日期不写死。以前每封信硬编码着「2024年9月3日 · 孕 24 周 +3 天」, 可孕周是按打开当天现算的 —— 首页顶着「孕 27 周 +2 天」,来信列表里最新 一封却写「孕 24 周 +3 天 · 2024年 9月」,两处自相矛盾,打印出来的完整 档案里尤其刺眼。现在每封只记 back(几天前寄到)+ time(当天几点几分), week / weekTxt / month / date / dateFull 一律由 stampLetters() 现推。 back 是「相对今天」而不是「钉死在某个孕周」:钉死的话,用户要是已经 30 周了, 最新那封未读信会变成三周前寄到的,红点就说不通了。 */ const LETTERS = [ { id: 'l1', back: 0, time: [8, 0], /* ⚠️ 标题里的「妈妈,」是**编排出来的,不是运行时随机**,而且只挑了 l1 / l4 两封。 为什么不随机:列表每次 renderLetters() 都整个重渲染,Math.random() 会让同一封信的 标题在眼前跳来跳去。4 封静态信要的「随机」本意只是「别整齐划一」,写死即可,还能被测试钉住。 为什么是这两封:宽度。.lt-body 在 320 屏上约 202px,而标题这一行还要分给 「未读」「N 条新回复」这些标签 —— l1 带「未读」标(~34px) → 标题可用 ~160px,本句 10 字 ≈ 152px ✓ l4 什么标都没有 → 标题可用 ~194px,本句 8 字 ≈ 128px ✓ l3 带「1 条新回复」标(~65px) → 只剩 ~129px,加了字必被省略号切掉,所以没加 改标题前先照这个算一遍。 */ title: '妈妈,我今天翻了个身', preview: '妈妈,今天我翻了个身,你有没有感觉到?', paras: [ '妈妈,今天我翻了个身,你有没有感觉到?', '我听见你笑了,我这里就跟着晃一晃。', '你最近吃饭很按时,睡得也早。我知道那是为了我。' ], unread: true, fav: false, voice: false, thread: [] }, { id: 'l2', back: 2, time: [21, 10], title: '我能听见你的声音了', preview: '我的小耳朵长好啦,隔着一层水,你的声音闷闷的,很好听。', paras: [ '妈妈,我的小耳朵长好啦。隔着一层水,你的声音闷闷的,很好听。', '我把这儿听到的动静录了一小段给你。不是我在说话哦,我还不会呢。' ], unread: false, fav: true, /* 第一封「肚子里的声音」。全孕期最多两封,另一封由第一次数胎动触发 */ voice: true, thread: [] }, { id: 'l3', back: 8, time: [19, 40], title: '我的小手会握拳啦', preview: '今天我学会握拳了,还偷偷吸了一下大拇指。', paras: [ '妈妈,今天我学会握拳了,还偷偷吸了一下大拇指。', '你走路慢了好多,是不是我又重了?', '对不起呀妈妈。我会长得刚刚好。' ], unread: false, fav: false, voice: false, /* 一条已经完成的往来,示范「妈妈的回信 + 宝宝的再回复都挂在这封信下面」。 这两条的 at / when 同样是现推的:妈妈在这封信寄到后第 3 天回的(back 8→5), 宝宝又隔了一晚回过来(back 4,凌晨 01:20,所以显示上比妈妈那条晚一天)。 运行时用户自己写的回信不带 back,只有真实时间戳,stampLetters() 不碰它们。 */ thread: [ { who: 'mom', text: '{baby},今天买了你的第一件小衣服,粉色的,好小好软。等你出来我们一起穿给爸爸看。', back: 5, time: [21, 5], isNew: false }, { who: 'baby', text: '妈妈,粉色的呀!我把小手举起来比划了一下,刚刚好。你别再买啦,我长得快。', back: 4, time: [1, 20], isNew: true } ] }, { id: 'l4', back: 13, time: [10, 15], /* 第二封带称呼的(另一封是 l1)。l2 / l3 刻意不带 —— 见 l1 那段的宽度账 */ title: '妈妈,我打了个嗝', preview: '肚子一跳一跳的,不是胎动,是我在打嗝呀,别担心。', paras: [ '妈妈,肚子一跳一跳的,不是胎动,是我在打嗝呀,别担心。', '我在练习呼吸,想让出去的第一口气顺顺的。', '昨晚你睡不着,把手放在肚子上,我就贴过去了。' ], unread: false, fav: false, voice: false, thread: [] } ]; /* back(几天前)+ time(几点几分)→ 那一刻的时间戳。back 只影响「哪一天」, 时分是当天的绝对钟点,所以先归零到 0 点再往回数,避免夏令时/跨月自己算。 */ function backAt(back, time) { const d = midnight(new Date()); d.setDate(d.getDate() - back); d.setHours(time[0], time[1], 0, 0); return d.getTime(); } /* 把一个时间戳 + 一个孕龄(天)盖到信件对象上,凑齐渲染要用的五个字段。 语音信也走这里 —— 它的孕周是寄出那一刻冻住的,不跟着日子往前跑, 所以 ga 由调用方给,这个函数不自己算。 */ function stampOne(L, at, ga) { const d = new Date(at), g = clampGA(ga), w = Math.floor(g / 7), dd = g % 7; L.at = at; L.week = w; L.weekTxt = '孕 ' + w + ' 周' + (dd ? ' +' + dd + ' 天' : ''); L.month = d.getFullYear() + '年 ' + (d.getMonth() + 1) + '月'; L.date = (d.getMonth() + 1) + '月' + d.getDate() + '日'; L.dateFull = fmtFull(d); } /* 把所有带 back 的样例信(和它们线程里的样例消息)按「今天」重新盖一遍日期。 没有 back 的一律跳过 —— 那是运行时真实产生的东西(语音信、妈妈的回信、 宝宝的延迟回复),它们的 at 是真的,改了就成假的了。 调用点:开机渲染前一次,syncPregnancy() 里再一次(改预产期会变孕周文案)。 */ function stampLetters() { const todayGA = state.week * 7 + state.day; LETTERS.forEach(L => { if (L.back == null) return; stampOne(L, backAt(L.back, L.time), todayGA - L.back); L.thread.forEach(m => { if (m.back == null) return; m.at = backAt(m.back, m.time); m.when = fmtWhen(m.at); }); }); } stampLetters(); state.curLetter = LETTERS[0].id; state.homeLetter = LETTERS[0].id; /* ---------- 延迟投递队列(3~5 小时) ---------- */ /* v1 只存了排队记录,没存产生排队的那封回信 —— 一半存一半不存。 刷新后 LETTERS 复位成静态样例(妈妈的回信没了),排队记录却读了回来, 于是那行「宝宝收到啦,正在写回信」的等待文案,挂在一封没人回过的信下面。 v2 把运行时新增的往来一起存上。换版本号,v1 那批无主数据自然作废。 */ const PEND_KEY = 'mmbox.pending.v2'; let store = { pending: [], voiceSent: 1, // 已寄出的语音:l2 算第一封 msgs: {}, // { 信id: [消息…] } 只存运行时新增的,静态样例仍从 LETTERS 来 voices: [], // [ {at, week, day} ] 已送达的语音信,存参数就够重建 lastWi: -1 // 上一句等待文案的下标,只为了不连着抽中同一句,见 pickWaitIdx() }; try { const raw = localStorage.getItem(PEND_KEY); if (raw) { const o = JSON.parse(raw); if (o && Array.isArray(o.pending)) store = { pending: o.pending, voiceSent: o.voiceSent || 1, msgs: (o.msgs && typeof o.msgs === 'object') ? o.msgs : {}, voices: Array.isArray(o.voices) ? o.voices : [], /* ⚠️ 这是白名单,不是「读回整个 o」——漏写一个字段,它就在刷新的 那一刻被静默丢掉。lastWi 是后加的,老存档里没有,回落成 -1。 */ lastWi: typeof o.lastWi === 'number' ? o.lastWi : -1 }; } } catch (e) { /* 隐私模式下 localStorage 会抛错,忽略即可,只是不跨会话而已 */ } function saveStore() { try { localStorage.setItem(PEND_KEY, JSON.stringify(store)); } catch (e) {} } const pendingOf = lid => store.pending.filter(p => p.letterId === lid); /* 往来只从这一个口子进:挂到内存里的信上,同时落盘。 以前 addMomReply 和 deliverBabyReply 各 push 各的,漏一个就出上面那个 bug。 */ function pushMsg(L, m) { L.thread.push(m); (store.msgs[L.id] || (store.msgs[L.id] = [])).push(m); saveStore(); } /* 开机回放:先按存下的参数重建语音信(它得先在 LETTERS 里, 后面那些挂在它下面的往来才找得到落脚点),再把往来挂回各封信。 */ function restoreStore() { store.voices.forEach(v => buildVoiceLetter(v.at, v.week, v.day)); Object.keys(store.msgs).forEach(lid => { const L = LETTERS.find(x => x.id === lid); if (!L) return; // 信不在了(样例数据改过),那批往来只能丢 store.msgs[lid].forEach(m => L.thread.push(m)); }); } /* 抽一句等待文案的下标。30 句里随机,但**不连着抽中上一句** —— 只重摇一次就够:连撞两次的概率是 1/900,而循环重摇万一池子退化成 一句话就会变成死循环,不值当。 */ function pickWaitIdx() { let i = Math.floor(Math.random() * WAIT_TXT.length); if (i === store.lastWi) i = (i + 1) % WAIT_TXT.length; store.lastWi = i; return i; } /* 回信内容在「排队时」就选好,这样刷新页面也不会变来变去。 ⚠️ 等待文案(wi)也必须在这儿定死,理由和上面那句是同一个,而且更要紧: 它同时画在列表和详情页两处,如果改成每次渲染现抽, 同一封信在两个地方会显示两句不同的话,滑一下列表还会自己变。 */ function scheduleBabyReply(letterId) { const pool = BABY_REPLY[state.babySex] || BABY_REPLY.unknown; store.pending.push({ kind: 'reply', letterId: letterId, text: pool[Math.floor(Math.random() * pool.length)], wi: pickWaitIdx(), at: Date.now() + 3 * HOUR + Math.random() * 2 * HOUR // 3~5 小时随机 }); saveStore(); } function scheduleVoiceLetter() { store.pending.push({ kind: 'voice', letterId: null, at: Date.now() + 3 * HOUR + Math.random() * 2 * HOUR }); saveStore(); } /* 妈妈第一次数胎动 → 宝宝寄一封「肚子里的声音」,同样是几小时后才到 */ function maybeVoiceAfterKick() { if (store.voiceSent >= VOICE_CAP) return; if (store.pending.some(p => p.kind === 'voice')) return; scheduleVoiceLetter(); store.voiceSent++; saveStore(); } function deliverDue() { const now = Date.now(); const due = store.pending.filter(p => p.at <= now); if (!due.length) return; store.pending = store.pending.filter(p => p.at > now); due.forEach(p => p.kind === 'voice' ? deliverVoiceLetter(p) : deliverBabyReply(p)); saveStore(); renderLetters(); syncLetterCounts(); pushLatestToHome(); toast('宝宝给你回信啦 💌'); } function deliverBabyReply(p) { const L = LETTERS.find(x => x.id === p.letterId); if (!L) return; pushMsg(L, { who: 'baby', text: p.text, at: p.at, when: fmtWhen(p.at), isNew: true }); } function deliverVoiceLetter(p) { /* 正文是模板,只把 3 个参数记下来就能原样重建 —— 不必把整段文字塞进 localStorage。 孕周也一起存:这封信写于「寄出那一周」,不该随着日子往前跑而改口。 */ store.voices.push({ at: p.at, week: state.week, day: state.day }); buildVoiceLetter(p.at, state.week, state.day); } function buildVoiceLetter(at, week, day) { /* 不带 back —— at 是真实的寄出时刻,stampLetters() 会跳过它。 孕周也是寄出那一刻冻住的,所以这里显式把 week*7+day 交给 stampOne。 */ const L = { id: 'lv' + at, title: '我让你听听我在你肚子里的声音', preview: '妈妈,今天你数了我的胎动,我知道你在数。我把我这儿的声音录给你听听哦……', paras: [ '妈妈,今天你一下一下地数我,我都感觉到了。你手心是热的,隔着肚皮我也知道那是你。', '我想让你听听我这里是什么样子的。你别期待能听见我说话哦 —— 我还不会呢。', '那是羊水晃来晃去的声音、你的心跳,还有你和爸爸在外面说话的动静。我每天就是听着这些睡着的。' ], unread: true, fav: false, voice: true, thread: [] }; stampOne(L, at, week * 7 + day); LETTERS.unshift(L); } /* ---------- 小工具 ---------- */ function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt != null) n.textContent = txt; return n; } /* ⚠️ SVG 不能走上面那个 el()。document.createElement('circle') 造出来的是 HTML 命名空间里的 ,浏览器当成 HTMLUnknownElement:节点在 DOM 里、 属性也都在,就是**一个像素都不画**。查这种问题最费时间 —— 元素明明在, 控制台里也看得见,只是永远不显示。所以 SVG 一律走 createElementNS。 */ const SVGNS = 'http://www.w3.org/2000/svg'; function svgEl(tag, attrs) { const n = document.createElementNS(SVGNS, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); return n; } /* 来信列表左边那颗宝宝头像里装什么。 ⚠️ 这里原来是 babyFace():一张纯 CSS/SVG 画的小脸(呆毛 + 腮红 + 眯眼)。 本批按用户要求换成一个「宝」字。已经删掉了,别再画回去。 为什么一个字比一张脸好:那张脸是**每一条**来信左边都画一遍的同一张脸, 十条信十张一模一样的脸,扫过去像一堵墙;而且它长什么样是我们替用户 决定的,用户换不掉。换成一个字之后,这颗圆盘的身份变了 —— 它是「还没设置头像」的占位(跟微信、通讯录一个路数), 于是「点一下换成自己的照片」就成了顺理成章的下一步, 也就是用户接着要的那件事。 ⚠️ 为什么是固定的「宝」,不是小名的第一个字: 小名的第一个字通常没有信息量 ——「小汤圆」取到的是「小」, 「小豆包」也是「小」,两个不同的宝宝头像一模一样。 而「宝」在任何小名下都读得通。 ⚠️ 设了自定义头像就换成 。这里返回节点而不是直接写 innerHTML: 小名是用户输入,将来若真改成取小名首字,innerHTML 就是个注入口。 ⚠️ 每条来信都会调一次,renderLetters() 每次筛选还会整个重渲染, 所以这里只造节点,不做任何测量或读样式。 */ function babyAvatarInner() { if (state.babyAvatar) { const img = el('img', 'lt-ava-img'); img.src = state.babyAvatar; img.alt = ''; // 装饰性:这颗圆盘的可读信息在按钮的 aria-label 上 return img; } return el('span', 'lt-ava-txt', '宝'); } /* 列表里每封信标题底下那条手绘波浪线。 和详情页大标题下那条 .dl-wave 成对出现:列表里瞥见的那条线, 点进去在详情页会再看到一条更长的,两处接得上,像是同一支笔画的。 ⚠️ path 的 d 和 stroke 是从 index.html:1016-1017 的 .dl-wave **手抄**过来的, 两处没有共享的源。改一处就得改另一处,否则两条线一眼看出不是一支笔。 smoke 里有一条断言把两处的 d 和 stroke 逐字比对,抄漏了会红。 ⚠️ 必须走 svgEl(createElementNS)。document.createElement('svg') 造出来的是 HTML 名字空间的节点:querySelector 找得到、className 对得上、 **但一个像素都不画**。这种坏法只有 namespaceURI 检得出来, 肉眼和常规断言全都看不见 —— smoke 里专门钉了 namespaceURI。 ⚠️ preserveAspectRatio="none" 让线横向拉满容器;配套要求 CSS 那边把 height 写死(.lt-wave 的 6px),否则宽度一变高度跟着缩放,线会随之变粗变细。 ⚠️ aria-hidden:这是纯装饰,读屏念出来只会打断标题到摘要的连读。 ⚠️ 只长在宝宝的信上,靠的是**结构**不是判断 —— 它挂在 .lt-h 所在的 body 上, 而 .lt-h 只有顶层的宝宝来信才有;妈妈的回信是 .lt-sub 里的 .lt-msg.mom, 没有标题行。所以这里不需要 who === 'baby' 的分支,加了是死代码。 */ function letterWave() { const svg = svgEl('svg', { 'class': 'lt-wave', viewBox: '0 0 120 9', 'aria-hidden': 'true', preserveAspectRatio: 'none' }); svg.appendChild(svgEl('path', { d: 'M2 5.6C13 1.9 24 8.4 35 5.1S57 1.6 68 5.4 90 8.6 102 4.6c5-1.7 10-2.2 16-1.4', fill: 'none', stroke: '#F3A88C', 'stroke-width': '2.3', 'stroke-linecap': 'round' })); return svg; } /* pad2 / fmtWhen / fmtFull 原来在这里,已挪到文件开头的「孕期日期算术」—— 信件那一节要用,那边比这里早。 */ /* 原来这里报一个「大约 X 小时后送到」的预估。宝宝写信本来就该是件慢事, 报了时间反而让人盯着表等,到点没来像是出了故障。不给承诺,只给一句话。 注:这行只在妈妈回过信之后才会出现 —— store.pending 初值是空的, 唯一的写入口 scheduleBabyReply() 只被 addMomReply() 调用。 那一句话从哪儿来:**排队那一刻就从 WAIT_TXT 里抽好了** (scheduleBabyReply 里的 wi),这里只照着下标取出来、填上小名。 所以列表和详情页显示的是同一句,刷新、重开、隔天回来还是那一句 —— 它说的是宝宝当下的状态,不该妈妈每路过一次就换一副表情。 waits 就是 pendingOf(L.id),取第 0 条:一封信底下可能排着两条, 但等待文案只画一行(理由见 buildThread()),两处都取第 0 条才对得上。 老存档里的记录没有 wi,回落到第 0 句 —— 也就是原来那句。 */ function waitTxt(waits) { const p = waits && waits[0]; const i = (p && typeof p.wi === 'number' ? p.wi : 0) % WAIT_TXT.length; return fillBaby(WAIT_TXT[i] || WAIT_TXT[0]); } const hasNew = L => L.thread.some(m => m.isNew); const hasMom = L => L.thread.some(m => m.who === 'mom'); /* ---------- 列表 ---------- */ /* 来信页空数据 · 引导按钮:直接打开「我的资料」弹窗(保存后自动重载播种) */ const letterGoOnboard = $('#letterGoOnboard'); if (letterGoOnboard) letterGoOnboard.addEventListener('click', e => { e.stopPropagation(); openModal('modalProfile'); }); function renderLetters() { const list = $('#letterList'); list.textContent = ''; let curMonth = null, group = null; LETTERS.forEach(L => { if (L.month !== curMonth) { curMonth = L.month; group = el('div', 'lgroup'); group.appendChild(el('div', 'lg-title', curMonth)); list.appendChild(group); } group.appendChild(buildThread(L)); }); /* 收一行的活儿交给 filterLetters 末尾那一句,这里不用再调一次 —— 被筛掉的线程是 display:none,量不出宽度,必须等它重新显示出来才能截。 */ filterLetters(state.letterFilter); } /* 六点省略号。中文排版里省略号就是六个点(占两个字位), 写成一个「…」是英文的三点,在中文正文里偏细偏短。全站统一走这个常量。 */ const ELL = '……'; /* 摘要末尾统一收一个省略号。 摘要是信的头一两句,不是全文,可原来结尾是个完整的句号,卡片看上去 就像话已经说完了,没有「点进去还有」的暗示。 句号直接吃掉(「……。」不成话),问号叹号是语气,留着再跟省略号。 */ const ellip = s => new RegExp(ELL + '$').test(s) ? s : s.replace(/[。,、…]+$/, '') + ELL; /* ---------- 把一行文字裁到刚好一行,尾巴换成六点省略号 ---------- 为什么不能交给 CSS:-webkit-line-clamp 和 text-overflow:ellipsis 打的点 都是浏览器画的三点「…」,那个字符 CSS 改不了(text-overflow 接受自定义 字符串是 Firefox 独有的,webview 上一律不认)。要六点只能自己截。 量宽用的是 scrollWidth vs clientWidth —— 真实布局,不是估算, 所以字体、字号、内边距怎么变都跟得上。 二分找最长的「装得下」的位置,比一个字一个字砍快得多, 长回信也就十来次布局。 ⚠️ 元素没布局时(clientWidth = 0,比如 jsdom、或者 display:none 的祖先) 直接返回,一个字都不动 —— 这时候量出来的宽度是假的,照着裁会把整句 切成一个字。这种情况下 CSS 里的 text-overflow 兜底,最差是三点省略号。 */ function clampLine(node) { if (!node || !node.clientWidth) return; /* 原文存一份在 data-full 上。没有它,横竖屏转一次就是拿「已经截过的字」 再截一遍 —— 只会越截越短,转回去也长不回来。有了它这个函数可以反复调。 */ const full = node.dataset.full || node.textContent; node.dataset.full = full; node.textContent = full; if (node.scrollWidth <= node.clientWidth) return; // 装得下就不动它 let lo = 0, hi = full.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); node.textContent = full.slice(0, mid) + ELL; if (node.scrollWidth <= node.clientWidth) lo = mid; else hi = mid - 1; } /* 断在标点上很难看(「今天我……」比「今天我,……」顺眼),顺手削掉 */ node.textContent = full.slice(0, lo).replace(/[,。、!?;:\s]+$/, '') + ELL; } const LINE_SEL = '#letterList .lt-body p, #letterList .lm-txt'; /* 列表重画之后统一收一遍。必须放进 rAF:刚 append 进去的节点这一帧还没布局, clientWidth 是 0,当场量必然量不到,clampLine() 会原样返回。 */ function clampLetterList() { if (typeof requestAnimationFrame !== 'function') return; requestAnimationFrame(() => { $$(LINE_SEL).forEach(clampLine); }); } /* 转屏 / 窗口变宽都会改变可用宽度,得重算。防抖是因为 resize 一次能连发几十下, 而每次 clampLine 都要触发布局。 */ let clampTimer = null; window.addEventListener('resize', () => { clearTimeout(clampTimer); clampTimer = setTimeout(() => $$(LINE_SEL).forEach(clampLine), 180); }); function buildThread(L) { const th = el('div', 'lthread'); th.dataset.lid = L.id; /* 未读整条都跟着变(气泡底色更饱和 + 一枚「未读」标签),不只是右上角一颗点。 原来只有一颗 8px 的圆点挂在标题右端,在「全部」这一页里 满屏白卡从上往下扫,那颗点根本抓不住眼睛,未读的信就这么被划过去了。 ⚠️ .ripple 从这一行挪到了下面的 .lt-body 上。原因是这一版之后 .lt-item 本身是**透明的一行**(头像 + 气泡),不再是一张卡: 涟漪往透明行里插 .rp,那颗橘色圆片就直接画在页面底上、还盖住头像。 挪到气泡上之后,涟漪被气泡自己的 overflow:hidden 裁在气泡里。 ⚠️ .lt-item 从 改成了 ,这一步是被迫的,别改回去: 本批要求「头像可以点击换头像」,于是头像必须是个可交互元素; 而 HTML 规定 里不能再放交互内容(、 role=button + tabindex 都算)。硬套 button 套 button, 浏览器会在解析时**把 DOM 拆开**:内层按钮被提到外层后面, 头像会从行首跳到行尾 —— 而且只在真浏览器里发生,jsdom 看不出来。 现在的结构是:.lt-item(div) 里并排两个**兄弟** : .lt-ava —— 点它换宝宝头像 .lt-body —— 点它打开这封信(气泡本体,也是涟漪的宿主) 两个各有各的 aria-label / 可聚焦,读屏和键盘都比原来更清楚。 */ const item = el('div', 'lt-item' + (L.unread ? ' is-unread' : '')); /* 左边这颗是**宝宝的头像**。 ⚠️ 类名从 .lt-week 改成了 .lt-ava,是少数值得改名的一次: 这个格子原来装的是日期(「12月 / 5」),现在装的是头像, 留着 week 这个名字会让下一个人以为这儿还写着孕周。 日期没丢,挪进了下面 .lt-meta 那行(聊天列表里时间戳本来就该是小字)。 ⚠️ 它原来是 (纯装饰)。现在能点了,就不能再对 读屏隐身 —— 隐身的可聚焦元素是最糟的一种:键盘能 Tab 到它, 读屏却念不出这是什么。改成带 aria-label 的真按钮。 ⚠️ type="button":这一行以后若被塞进 ,默认的 submit 会把页面刷掉。 */ const ava = el('button', 'lt-ava'); ava.type = 'button'; ava.setAttribute('aria-label', '更换' + babyWord() + '的头像'); ava.appendChild(babyAvatarInner()); const body = el('button', 'lt-body ripple'); body.type = 'button'; const h = el('div', 'lt-h'); h.appendChild(el('h4', null, L.title)); /* ⚠️ 这一行原来还挂着一枚「N 条新回复」的汇总标签,本批**删掉了**,别加回来。 它的毛病不是占地方,是**同一件事说两遍**:下面每条宝宝的新回复自己 已经挂着「未读」,标题行再汇总一次,一屏里「未读」出现三四遍, 反而谁都不显眼 —— 而这枚标签存在的全部理由就是让人注意到。 计数没丢:底 tab 那颗红点的数字(syncUnread 里的 state.unread) 一直把这些新回复算在内,「一共几条没看」本来就该在那儿看。 ⚠️ 顺带,删掉它给 .lt-h 让出约 65px,标题的可用宽度变松了 —— 别再拿「标题行挤」当理由去砍这一行里别的东西。 */ /* 圆点换成写字的标签:一颗无字的点要人先学会「红点 = 没读」才看得懂, 写出来就不用猜。位置不变,仍靠 margin-left:auto 顶在标题行右端。 */ if (L.unread) h.appendChild(el('span', 'dot-unread', '未读')); body.appendChild(h); body.appendChild(letterWave()); body.appendChild(el('p', null, ellip(L.preview))); /* 未读的信这里不再重复写「· 未读」—— 右上角那枚标签已经说了, 同一行里说两遍反而稀释掉它。已读仍留「· 已读」,那是这行唯一的状态来源。 ⚠️ 日期回到了这一行打头。上一版它单独占着左边那格邮票,这一版左格换成了头像, 日期没地方放了 —— 而聊天列表里时间戳本来就是气泡底下一行小字,正好。 别再把它挪回左边:左边现在是脸,写上日期就成了「脸上刻着 12月5日」。 */ const meta = el('div', 'lt-meta', L.date + ' · 孕' + L.week + '周' + (L.unread ? '' : ' · 已读')); if (L.voice) meta.appendChild(document.createTextNode(' · 含一段肚子里的声音')); if (L.fav) { meta.appendChild(document.createTextNode(' · ')); meta.appendChild(el('span', 'star-txt', '★ 已收藏')); } body.appendChild(meta); item.append(ava, body); th.appendChild(item); /* 妈妈的回信 / 宝宝的再回复,缩进挂在这封信下面 */ const waits = pendingOf(L.id); if (L.thread.length || waits.length) { th.classList.add('has-sub'); const sub = el('div', 'lt-sub'); L.thread.forEach(m => { const msg = el('div', 'lt-msg ' + m.who + (m.isNew ? ' is-new' : '')); const who = el('div', 'lm-who'); who.appendChild(el('span', null, m.who === 'mom' ? '妈妈的回信' : babyWord('的回复'))); /* ⚠️ 写「未读」不写「新」。一个字的「新」有歧义 —— 到底是「这条是新的」 还是「这条你还没看」?读者得自己猜。而且旁边 .lm-when 就是时间戳, 「新不新」时间戳本来就说了,这枚标签唯一要说的是**你没看过**。 和标题行那枚 .dot-unread 用同一个词,是有意的:同一个状态在 这个页面里只能有一种叫法,两处不一致比说得不准还糟。 ⚠️ 这两枚不会同时出现:L.unread 是「整封信没打开过」, 而宝宝的再回复只可能发生在妈妈回过信之后 —— 回信必须先打开信, 打开时 openLetter() 已经把 L.unread 清成 false 了。 */ if (m.isNew) who.appendChild(el('span', 'lt-new', '未读')); who.appendChild(el('span', 'lm-when', m.when)); msg.appendChild(who); msg.appendChild(el('div', 'lm-txt', fillBaby(m.text))); sub.appendChild(msg); }); /* 妈妈连回两条,就排了两条 pending,之前这里 forEach 就原样吐出两行 一模一样的「收到啦,让我先睡一小觉」。这话是宝宝的状态,不是回执, 排多少条都只是「它还没醒」这一件事,所以最多只画一行。 pending 本身仍是一条一条留着的 —— 每条到点各自送达一条回复,那是对的。 */ if (waits.length) sub.appendChild(el('div', 'lt-wait', waitTxt(waits))); th.appendChild(sub); } return th; } function filterLetters(key) { const map = { all: () => true, unread: L => L.unread || hasNew(L), reply: hasMom, fav: L => L.fav }; const test = map[key] || map.all; let shown = 0; $$('.lthread').forEach(th => { const L = LETTERS.find(x => x.id === th.dataset.lid); const ok = !!L && test(L); th.classList.toggle('hide', !ok); if (ok) shown++; }); $$('.lgroup').forEach(g => g.classList.toggle('hide', !$$('.lthread:not(.hide)', g).length)); $('#letterEmpty').classList.toggle('show', shown === 0); /* 空数据双态:未完善资料(pregEmpty)→ 引导去完善;已完善但无信 → 普通空提示。 完善资料保存后会自动重载并播种样例信,引导按钮 = 打开「我的资料」弹窗。 */ const needOnboard = !!state.pregEmpty && shown === 0; const guide = $('#letterEmptyGuide'), plain = $('#letterEmptyPlain'); if (guide) guide.style.display = needOnboard ? 'block' : 'none'; if (plain) plain.style.display = needOnboard ? 'none' : ''; /* ⚠️ 必须放在这儿,不能只在 renderLetters 末尾调一次。 .hide 是 display:none —— 被筛掉的那些线程量出来的 clientWidth 是 0, clampLine() 会原样跳过它们。等用户切回「全部」,这些从没截过的整段话 就会顶出来。每次筛完重收一遍,才不会漏。 */ clampLetterList(); } /* 列表整条(信、妈妈的回信、宝宝的回复)都指向同一封信的详情。 ⚠️ 头像是这一行里**唯一**的例外:它自己是一个按钮,点它换头像、不开信。 这个 if 必须排在 openLetter() **之前**(现在顺带也排在取 .lthread 之前, 但要紧的是前者)—— 事件是冒泡上来的,头像本来就长在 .lthread 里面, 两个分支都会命中。谁先跑谁说了算:让 openLetter 先跑, 点头像就变成「打开这封信」,选图框永远不弹。 而那是个**看着完全正常的动作**(信确实开了),没人会当成 bug, 只会觉得「这个头像点不动」。 这里不写 stopPropagation:委托只有这一个监听器, 真正要表达的是「这两件事互斥」,用 return 说得比拦冒泡清楚。 */ $('#letterList').addEventListener('click', e => { if (e.target.closest('.lt-ava')) { pickBabyAvatar(); return; } const th = e.target.closest('.lthread'); if (th) openLetter(th.dataset.lid); }); /* ---------- 宝宝头像 · 换成自己的照片 ---------- ⚠️ 和「我的资料」里妈妈那颗头像走的**不是**同一条路,是有意的: 妈妈那颗在弹窗里,选完先进 #avPreview 预览、按「保存」才落地 (那张弹窗里还有别的字段,得有一次统一的确认)。 宝宝这颗在列表里,点一下就该换好 —— 中间再插一张确认弹窗, 为了一件只有一个选项的事开一张窗,纯属添堵。选错了再点一次就是。 ⚠️ file input 复用「我的资料」里那个 #avFile 会串味:那个 input 的 change 监听器写的是「填 #avPreview + 给 #avPick 打 has-img」。 两边共用一个 input,选完宝宝头像会顺带把妈妈的预览也改了。 所以这里自己建一个,不进 DOM —— 不进 DOM 是可以的, 点击触发选图不要求元素在文档树里(和 .av-file 那个不同: 那个要留在流里是因为它由**另一个元素**代点,见 index.html)。 */ const babyAvFile = document.createElement('input'); babyAvFile.type = 'file'; babyAvFile.accept = 'image/*'; babyAvFile.addEventListener('change', () => { const f = babyAvFile.files && babyAvFile.files[0]; if (!f) return; // 用户在系统相册里点了取消 /* 下面两条和妈妈那颗头像逐字同一套限制。故意写重复而不是抽公共函数: 两处的落地方式完全不同(一处预览待保存、一处即时生效), 抽出来的公共函数只能抱着两个分支,读起来比重复更绕。 ⚠️ 但**文案**必须一致 —— 同一个错误在两个地方说两种话, 用户会以为是两个不同的毛病。 */ if (!/^image\//.test(f.type)) { toast('请选择一张图片'); return; } if (f.size > 8 * 1024 * 1024) { toast('图片太大了,换一张小于 8MB 的'); return; } const r = new FileReader(); r.onload = () => { /* 产物是单文件、没有后端,读成 data URI 就地用 */ state.babyAvatar = r.result; /* ⚠️ 必须整列表重渲染,不能只改被点的那一颗:同一个宝宝的头像在 列表里出现 N 次(每封信一次),只改一颗会得到「一颗新的 + 一堆旧的」。 renderLetters() 本来就是全量重画,直接借它。 */ renderLetters(); haptic(); toast(babyWord() + '的头像换好啦'); }; r.onerror = () => toast('这张图读不出来,换一张试试'); r.readAsDataURL(f); }); function pickBabyAvatar() { /* ⚠️ 先清空 value 再弹:不清的话,用户选了同一张图, change 事件不会触发(值没变),看起来就像「点了没反应」。 */ babyAvFile.value = ''; babyAvFile.click(); } /* ---------- 计数 / 徽标 ---------- 原先这里还要再刷一遍页顶那张「最新」大卡(#flWeek/#flTitle/…)。 那张卡画的就是 LETTERS[0],而列表本来也是从 LETTERS[0] 开始渲染的, 同一封信在一屏里出现了两次,卡片连同这段同步逻辑一起删了。 未读数原来还有个 #unreadTxt 写在 hero 第二行,现在只留标签上的 #unreadNum。 */ function syncLetterCounts() { const unread = LETTERS.filter(L => L.unread).length; const news = LETTERS.reduce((n, L) => n + L.thread.filter(m => m.isNew).length, 0); state.unread = unread + news; /* 未完善资料:hero 计数给占位符(没有"共 24 封"这种演示数字),无未读角标 */ if (state.pregEmpty) { $('#letterTotal').textContent = '--'; $('#unreadNum').textContent = '0'; $('#unreadNum').classList.add('zero'); $('#tabBadge').classList.remove('show'); return; } /* 实际数量:LETTERS 即服务端返回的真实信件(LETTER_ARCHIVED 是原型虚构值,别加) */ $('#letterTotal').textContent = LETTERS.length; $('#unreadNum').textContent = state.unread; /* 全读完了就把红点徽标收起来,别在标签上挂一个孤零零的「0」 */ $('#unreadNum').classList.toggle('zero', state.unread === 0); $('#tabBadge').classList.toggle('show', state.unread > 0); } /* 宝宝的新回信同时推到首页头部那张「宝宝来信」卡上。 卡是「有信才在」:既没有未读的信、也没有没看过的新回信时,整张收起来 —— 全读完了还硬顶一封旧信在首页正中间,是在假装有新消息。 收起来会空出 64px 的流高(卡是 margin-top:-16 + height:80), 所以同时给 .home-body 挂 .no-letter 把下面的标题往下推一点, 免得「孕期必用工具」直接贴到橙色头图底边上。 */ function pushLatestToHome() { const card = $('#homeLetter'); const body = $('#homeBody'); const show = () => { card.classList.remove('hide'); body.classList.remove('no-letter'); }; let msg = null, owner = null; LETTERS.forEach(L => L.thread.forEach(m => { if (m.who === 'baby' && m.isNew && (!msg || m.at > msg.at)) { msg = m; owner = L; } })); if (msg) { $('#lcTitle').textContent = babyWord('回信啦'); $('#lcDesc').textContent = msg.text; /* 这行连着尖括号只够一行,别把信的标题拼进来 —— 原来的「看看我的小手会握…的回复」正好撑破,尖括号会被挤到第四行 */ $('#lcLink').textContent = '看看' + babyWord('的回复'); state.homeLetter = owner.id; card.classList.add('has-new'); show(); return; } /* 这里以前是 LETTERS.find(...) || LETTERS[0],那个 || 就是「哪怕全读完了 也硬挑第一封顶上」的来源。去掉之后 top 可能是 undefined —— 那正是要藏的情况。 */ const top = LETTERS.find(L => L.unread); if (!top) { card.classList.add('hide'); card.classList.remove('has-new'); body.classList.add('no-letter'); /* 卡都不显示了,这个 id 只是兜底:openDetail() 是 openLetter(state.homeLetter), 留 null 的话别处万一调到就炸。 */ state.homeLetter = LETTERS[0] ? LETTERS[0].id : null; return; } /* 「小汤圆来信」。上一批这句加长成过「小汤圆的新消息」(配合底 tab 改口叫 「宝宝消息」),本批用户要求整条线退回「来信」,这里跟着退。 ⚠️ 退的是「来信」不是「的来信」—— 加长的唯一理由是当时要改口, 理由没了就退回原样,别顺手留个中间态。 ⚠️ 上面那条分支是 babyWord('回信啦'),两次改口都**没跟着动**,这是有意的: 那条说的是「宝宝回了你的回信」,是个更具体的事件;这条说的是 「有一封你还没看过的信」。两句本来就不是一回事,抹平反而丢信息。 ⚠️ 宽度:.lc-body = 卡 249 − 内边 25.5 − 信封 64 − gap 9 = 150.5px, .lc-title 是 16px。「小汤圆来信」5 × 16 = 80 ✓。 后缀从「的新消息」4 字缩回「来信」2 字, **不被省略号啃掉的小名上限也从 5 字回到了 7 字**。 但小名输入框 maxlength=12,7 字仍然不够 —— 所以 .lc-title 那条 text-overflow:ellipsis **继续留着**,不能因为这次退回就顺手删掉。 再想加长这个后缀之前,先回来看这笔账。 */ $('#lcTitle').textContent = babyWord('来信'); $('#lcDesc').textContent = ellip(top.preview); /* 首页这张卡也是摘要,同样收省略号 */ $('#lcLink').textContent = '读读' + babyWord('的话'); state.homeLetter = top.id; card.classList.add('has-new'); show(); } /* ---------- 详情页 ---------- */ function openLetter(id) { const L = LETTERS.find(x => x.id === id) || LETTERS[0]; state.curLetter = L.id; L.unread = false; L.thread.forEach(m => { m.isNew = false; }); renderLetterDetail(L); renderLetters(); syncLetterCounts(); pushLatestToHome(); openPanel('detailLetter'); } function openDetail() { openLetter(state.homeLetter); } /* ---------- 把一句话拆成一个个歪歪扭扭的小方块 ---------- 光靠 font-family 只在装了翩翩体的 iOS/macOS 上才歪; 这里再给每个字加一点随机旋转和上下错位,安卓和 Windows 上 也能有「小朋友一笔一画写的」那种劲儿。 幅度压在 ±1.4° / ±0.9px:再大就从「手写」滑向「排版事故」了。 标点不单独成块,而是黏在前一个字后面 —— 否则中文断行时 「。」「,」很容易被挤到下一行行首。 */ const KID_PUNCT = /[,。!?、;:""'')〕】》」』…—·,.!?;:)\]}]/; function kidText(str) { const frag = document.createDocumentFragment(); let cell = null; for (const ch of str) { if (ch === ' ' || ch === '\n') { frag.appendChild(document.createTextNode(ch)); cell = null; continue; } if (cell && KID_PUNCT.test(ch)) { cell.textContent += ch; continue; } cell = document.createElement('span'); cell.className = 'kc'; cell.textContent = ch; cell.style.transform = 'rotate(' + (Math.random() * 2.8 - 1.4).toFixed(2) + 'deg)' + ' translateY(' + (Math.random() * 1.8 - 0.9).toFixed(2) + 'px)'; frag.appendChild(cell); } return frag; } /* 造一个内容已经「手写化」的元素;textContent 读出来仍是原文 */ function kidEl(tag, cls, str) { const n = el(tag, cls); n.appendChild(kidText(str)); return n; } function renderLetterDetail(L) { if (!L) return; /* 新用户空态:没有任何信件可渲染 */ $('#dlWeek').textContent = L.weekTxt; $('#dlTitle').textContent = L.title; $('#dlDate').textContent = L.dateFull; /* 顶栏标题跟着小名走:填了「米可」就是「米可来信」。 ⚠️ 上一批这里短暂叫过「的消息」(配合底 tab 改口),本批随整条线退回「来信」。 三处必须同口径:底 tab、首页卡、这条顶栏指的是同一样东西, 任意一处不一致,用户会以为自己点到了别的地方。改一个就得改三个。 ⚠️ 退回后那条老矛盾会跟着回来:tab 写「来信」、点进去是一屏气泡对话。 这是用户看过两版之后**选定**的说法,不是漏改 —— 想再改回「消息」之前先去问。 */ $('#dlBarTitle').textContent = babyWord('来信'); const txt = $('#dlText'); txt.textContent = ''; L.paras.forEach(p => txt.appendChild(kidEl('p', null, p))); /* 落款用宝宝小名:填了就是「爱你的小汤圆」,没填回落到「爱你的宝宝」。 名字上面那条横杠和下面那行孕周都去掉了 —— 孕周在页首眉批里已经有一次, 落款只留一句话,才像签名而不像资料栏。 */ const sign = el('div', 'dl-sign'); sign.appendChild(kidEl('span', 'ds-name', '爱你的' + (state.babyName || '宝宝'))); txt.appendChild(sign); const vbox = $('#dlVoice'); vbox.textContent = ''; if (L.voice) vbox.appendChild(buildVoiceCard()); const tb = $('#dlThread'); tb.textContent = ''; const waits = pendingOf(L.id); if (L.thread.length || waits.length) { tb.appendChild(el('h3', 'dl-sec', '这封信下面的往来')); L.thread.forEach(m => { const box = el('div', 'dl-msg ' + m.who); const who = el('div', 'dm-who'); who.appendChild(el('span', null, m.who === 'mom' ? '妈妈的回信' : babyWord('的回复'))); who.appendChild(el('span', 'dm-when', m.when)); box.appendChild(who); /* 宝宝那条也走手写化;妈妈写的保持原样,两边一眼分得清 */ box.appendChild(m.who === 'baby' ? kidEl('div', 'dm-txt', fillBaby(m.text)) : el('div', 'dm-txt', fillBaby(m.text))); tb.appendChild(box); }); /* 同列表:回几条都只画一行,理由见 buildThread() 里那段注释 */ if (waits.length) tb.appendChild(el('div', 'dl-wait', waitTxt(waits))); } /* 收藏开关全站只剩右上角这一颗(底部那颗已撤)。 它装的是文字符号 ★/☆、没有 svg,所以可以直接写 textContent。 */ const star = $('#dtStar'); star.classList.toggle('on', !!L.fav); star.textContent = L.fav ? '★' : '☆'; star.setAttribute('aria-label', L.fav ? '已收藏,点这里取消' : '收藏这封信'); } const curLetter = () => LETTERS.find(x => x.id === state.curLetter) || LETTERS[0]; /* 收藏状态同步后台(box_app 持久化)。本地**先切**保证即时反馈; 后台同步失败就静默 —— 下次 applyRemoteLetters 覆盖回来,以后台为准。 用了 openid + letters/{key}/favorite 的 POST / DELETE 这对接口。 */ function syncRemoteFav(L) { const openid = hashParam('openid'); if (!openid) return; const verb = L.fav ? 'POST' : 'DELETE'; fetch('/api/mp/public/mama-box/letters/' + encodeURIComponent(L.id) + '/favorite?openid=' + encodeURIComponent(openid), { method: verb }) .catch(function () { /* 静默:后台同步失败不打断本地切换 */ }); } function toggleFav() { const L = curLetter(); L.fav = !L.fav; renderLetterDetail(L); renderLetters(); toast(L.fav ? '已收藏这封信 ★' : '已取消收藏'); haptic(); syncRemoteFav(L); } $('#dtStar').addEventListener('click', toggleFav); $$('[data-detail="close"]').forEach(b => b.addEventListener('click', closePanel)); /* ---------- 肚子里的声音 ---------- 不是宝宝在说话,是宝宝隔着羊水听到的世界:水声、妈妈的心跳、外面的人声。 用 WebAudio 现场合成(布朗噪声过低通 + 正弦双击心跳), 这样单文件产物里一个音频资源都不用带。 */ function buildVoiceCard() { const card = el('div', 'voice-card'); card.appendChild(el('p', 'vc-lead', '妈妈,我今天让你听听我在你肚子里的声音哦 —— 那不是我在说话,我还不会呢。')); const row = el('div', 'vc-row'); const play = el('button', 'vc-play', '▶'); play.setAttribute('aria-label', '播放'); const wave = el('div', 'vc-wave'); for (let i = 0; i < 22; i++) wave.appendChild(document.createElement('i')); row.append(play, wave); card.appendChild(row); card.appendChild(el('p', 'vc-note', '羊水晃动的声音 · 你的心跳 · 你和爸爸在外面说话的动静')); play.addEventListener('click', () => playBelly(card)); return card; } let actx = null, stopBelly = null; function playBelly(card) { if (stopBelly) { stopBelly(); return; } const AC = window.AudioContext || window.webkitAudioContext; if (!AC) { toast('这个浏览器暂时放不了声音'); return; } try { actx = actx || new AC(); } catch (e) { toast('这个浏览器暂时放不了声音'); return; } if (actx.state === 'suspended' && actx.resume) actx.resume(); const t0 = actx.currentTime, DUR = 14; const out = actx.createGain(); out.connect(actx.destination); out.gain.setValueAtTime(0, t0); out.gain.linearRampToValueAtTime(.85, t0 + 1); out.gain.setValueAtTime(.85, t0 + DUR - 1.4); out.gain.linearRampToValueAtTime(0, t0 + DUR); /* ① 羊水:布朗噪声压到 420Hz 以下,像隔着一层水听外面 */ const len = Math.floor(actx.sampleRate * DUR); const buf = actx.createBuffer(1, len, actx.sampleRate); const d = buf.getChannelData(0); let last = 0; for (let i = 0; i < len; i++) { last = (last + (Math.random() * 2 - 1) * .09) * .985; d[i] = last * 3.4; } const noise = actx.createBufferSource(); noise.buffer = buf; const lp = actx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 420; lp.Q.value = .7; const swell = actx.createGain(); swell.gain.value = .55; /* 很慢的起伏,像妈妈的呼吸 */ const lfo = actx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = .22; const lfoG = actx.createGain(); lfoG.gain.value = .25; lfo.connect(lfoG); lfoG.connect(swell.gain); noise.connect(lp); lp.connect(swell); swell.connect(out); /* ② 心跳:两下一组,约每分钟 140 下 —— 胎心就是这个速度 */ const beat = 60 / 140; for (let t = .4; t < DUR - .4; t += beat) { thump(t0 + t, 64, .5); thump(t0 + t + beat * .33, 50, .3); } function thump(when, freq, amp) { const o = actx.createOscillator(), g = actx.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(freq, when); o.frequency.exponentialRampToValueAtTime(freq * .55, when + .17); g.gain.setValueAtTime(.0001, when); g.gain.linearRampToValueAtTime(amp, when + .02); g.gain.exponentialRampToValueAtTime(.0001, when + .24); o.connect(g); g.connect(out); o.start(when); o.stop(when + .28); } noise.start(t0); noise.stop(t0 + DUR); lfo.start(t0); lfo.stop(t0 + DUR); card.classList.add('playing'); const btn = $('.vc-play', card); if (btn) btn.textContent = '❚❚'; const timer = setTimeout(() => { if (stopBelly) stopBelly(); }, DUR * 1000); stopBelly = function () { clearTimeout(timer); try { noise.stop(); lfo.stop(); } catch (e) {} try { out.gain.cancelScheduledValues(actx.currentTime); out.gain.setTargetAtTime(0, actx.currentTime, .04); } catch (e) {} card.classList.remove('playing'); if (btn) btn.textContent = '▶'; stopBelly = null; }; } /* 先把上次会话留下的往来挂回 LETTERS,再补投一次到点的队列 —— 顺序不能反: 补投出来的回信要挂到语音信上,而语音信是 restoreStore 重建的。 必须放在这个位置:这两个函数都依赖上面那几个 const 小工具,早于它们会撞 TDZ */ restoreStore(); deliverDue(); renderLetters(); syncLetterCounts(); pushLatestToHome(); renderLetterDetail(LETTERS[0]); // 详情页先垫上最新那封,免得首次打开有一帧空白 /* 30 秒一跳。间隔取得宽松,是因为这里的等待本来就以小时计 */ setInterval(deliverDue, 30000); /* ================= 通用弹窗 ================= 回信弹窗要能盖在来信详情页之上,所以不复用 sheet 的那套 scrim, 单独用一层 z-index 更高的 #modalScrim */ const modalScrim = $('#modalScrim'); let openedModal = null; function openModal(id) { const m = document.getElementById(id); if (!m) return; closeModal(); openedModal = m; modalScrim.classList.add('show'); m.classList.add('show'); haptic(); /* 「我的资料」打开时按服务端档案回填表单(新用户全空,不残留演示默认值) */ if (id === 'modalProfile') fillProfileForm(); /* 有些弹窗的主角不是输入框。企业微信那个弹窗正中间是一张二维码, 用户要做的第一件事是**举起另一台手机去扫它**;自动聚焦输入框会把 手机键盘顶上来,恰好盖住二维码的下半截。所以给一个显式的退出口, 写在标记上而不是在这里 if id ——「这个弹窗不要自动聚焦」是弹窗自己的 属性,让它随弹窗一起被读到,别攒成一张要来这儿查的 id 名单。 */ if (m.dataset.autofocus === 'off') return; /* 自动聚焦第一个可输入项;排除 type=file(资料弹窗里那个是隐藏的选图入口, 聚焦它既没意义,还会让「点大圆换头像」的焦点态乱掉) */ const first = $('.modal-ta, input:not([type="file"])', m); if (first) setTimeout(() => first.focus({ preventScroll: true }), 300); } function closeModal() { if (!openedModal) return; openedModal.classList.remove('show'); modalScrim.classList.remove('show'); openedModal = null; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } $$('[data-modal]').forEach(b => b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); const t = b.dataset.modal; if (t === 'close') closeModal(); else openModal(t); })); modalScrim.addEventListener('click', closeModal); /* ---------- 给宝宝回信 ---------- */ const replyText = $('#replyText'), replyCount = $('#replyCount'); const syncCount = () => { replyCount.textContent = replyText.value.length; }; replyText.addEventListener('input', syncCount); $$('.mtag').forEach(t => t.addEventListener('click', () => { /* 快捷短语作为「起个头」,已有内容时接在后面而不是覆盖 */ const cur = replyText.value.trim(); replyText.value = cur ? cur + '\n' + t.dataset.fill : t.dataset.fill; syncCount(); replyText.focus(); haptic(); })); /* 妈妈的回信不再单独成条,而是挂到「正在回的那封信」下面(state.curLetter), 所以列表里一眼就能看出这条回信是回给哪封来信的。 寄出后同时给宝宝排一条 3~5 小时后送达的回复 —— 绝不实时。 */ function addMomReply(txt) { const L = curLetter(); pushMsg(L, { who: 'mom', text: txt, at: Date.now(), when: fmtWhen(Date.now()), isNew: false }); state.replyCount++; scheduleBabyReply(L.id); renderLetters(); syncLetterCounts(); return L; } /* 回信同步后台(box_app 持久化,含排队宝宝回信)。本地 addMomReply 先落 UI + localStorage 保证即时反馈;后台同步失败就静默 —— 下次 applyRemoteLetters 覆盖回来,以后台数据为准(后台排队的那条宝宝回信到点会自己投递)。 */ function syncRemoteReply(L, text) { const openid = hashParam('openid'); if (!openid) return; fetch('/api/mp/public/mama-box/letters/' + encodeURIComponent(L.id) + '/reply?openid=' + encodeURIComponent(openid) + '&text=' + encodeURIComponent(text), { method: 'POST' }) .catch(function () { /* 静默:后台同步失败不打断本地回信 */ }); } /* 打开回信弹窗前,先把「回给哪封信」写进抬头 */ function openReply() { $('#replyOn').textContent = curLetter().title; $('#replyTo').textContent = state.babyName; openModal('modalReply'); } $('#replySend').addEventListener('click', () => { const txt = replyText.value.trim(); if (!txt) { toast('还没写内容哦,写一句也好 ✎'); replyText.focus(); return; } closeModal(); replyText.value = ''; syncCount(); const L = addMomReply(txt); syncRemoteReply(L, txt); /* 详情页如果开着,就地刷新,妈妈立刻看到自己的回信落在这封信下面 */ if (panelStack.indexOf(detail) >= 0) renderLetterDetail(L); else { go('letter'); setTimeout(() => { const th = $('.lthread[data-lid="' + L.id + '"]'); if (!th) return; try { th.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {} th.classList.add('just-added'); setTimeout(() => th.classList.remove('just-added'), 2200); }, 380); } setTimeout(() => toast('回信已挂到《' + L.title + '》下面 💌 宝宝正在慢慢写回信呢'), 420); }); $('#dlReply').addEventListener('click', openReply); /* 悬浮笔:没打开具体某封信时,默认回最新那封 */ $('#fabWrite').addEventListener('click', () => { if (panelStack.indexOf(detail) < 0) state.curLetter = LETTERS[0].id; openReply(); }); /* ================= 孕期信息 ================= 预产期是唯一的「源」:填了它就按今天倒推出孕周 + 额外天数; 没填时那两格 disabled(置灰不可编辑)。算出来之后用户可以微调, 微调会反推回预产期,保证「孕 N 周」和「预产期 X · 还有 N 天」永远自洽。 「今日体重」已按需求删除。 注:DAY_MS / TERM / midnight / clampGA / gaFromDue / dueFromGA / addMonths / DEFAULT_DUE 都提到文件最前面去了 —— 信件日期要用,那边比这里早。 */ /* 孕周 → 这周宝宝大概多大,每项 [emoji, 名字, 克数]。 第 24 周写「玉米 / 600 克」,和 AI 小结里那句「差不多一根玉米那么长了…600g」 是同一套说法,两处不会打架。 只列到 40 周,不足 4 周或超过 40 周的都夹到两端。 emoji 一律挑 Unicode 12 以前就有的,新版 emoji 在旧安卓上会变豆腐块。 */ const SIZE = { 4: ['🌱', '一颗小芽', 1], 5: ['🌱', '一粒苹果籽', 1], 6: ['🍇', '一颗小豌豆', 1], 7: ['🍇', '一颗蓝莓', 2], 8: ['🍇', '一颗覆盆子', 3], 9: ['🍇', '一颗葡萄', 4], 10: ['🍓', '一颗草莓', 7], 11: ['🍋', '一个青柠', 14], 12: ['🍑', '一颗李子', 18], 13: ['🍋', '一个柠檬', 23], 14: ['🍑', '一个桃子', 43], 15: ['🍎', '一个苹果', 70], 16: ['🥑', '一个牛油果', 100], 17: ['🍐', '一只洋梨', 140], 18: ['🍅', '一个番茄', 190], 19: ['🥭', '一个杧果', 240], 20: ['🍌', '一根香蕉', 300], 21: ['🥕', '一根胡萝卜', 360], 22: ['🥭', '一个木瓜', 430], 23: ['🍆', '一根小茄子', 500], 24: ['🌽', '一根玉米', 600], 25: ['🥬', '一棵小白菜', 660], 26: ['🥒', '一根西葫芦', 760], 27: ['🥦', '一朵花菜', 875], 28: ['🍆', '一个大茄子', 1000], 29: ['🥥', '一个椰子', 1150], 30: ['🥬', '一棵卷心菜', 1320], 31: ['🍍', '一个菠萝', 1500], 32: ['🥬', '一棵大白菜', 1700], 33: ['🍈', '一个哈密瓜', 1920], 34: ['🍈', '一个蜜瓜', 2150], 35: ['🥥', '一个大椰子', 2380], 36: ['🥬', '一棵生菜', 2620], 37: ['🥒', '一根冬瓜', 2860], 38: ['🎃', '一个小南瓜', 3080], 39: ['🍉', '一个小西瓜', 3290], 40: ['🍉', '一个大西瓜', 3460] }; /* 克数上千就改说「公斤」——「约 3290 克」没人这么讲话 */ function babySize(w) { const s = SIZE[Math.max(4, Math.min(40, w))] || SIZE[24]; return { emoji: s[0], name: s[1], /* g = 原始克数。分享卡的「比大小」模板要按体积开三次方算半径, 从 txt 里反解「1.5 公斤」太别扭,直接把数字带出来 */ g: s[2], txt: s[2] >= 1000 ? (s[2] / 1000).toFixed(1) + ' 公斤' : s[2] + ' 克' }; } const pgName = $('#pgName'), pgDue = $('#pgDue'), pgWeek = $('#pgWeek'), pgDay = $('#pgDay'), pgHint = $('#pgHint'); /* 不用 new Date('2024-12-22') —— 那是 UTC 解析,东八区会掉到前一天 */ function parseDay(v) { if (!/^\d{4}-\d{2}-\d{2}$/.test(v || '')) return null; const p = v.split('-'), d = new Date(+p[0], +p[1] - 1, +p[2]); return isNaN(d.getTime()) ? null : d; } function toISO(d) { const p = n => (n < 10 ? '0' : '') + n; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); } function lockWeekFields(locked) { pgWeek.disabled = pgDay.disabled = locked; /* 锁着的时候连数字也不留 —— 没填预产期就没有「当前孕周」这回事, 留个旧值在灰格子里反而像是已经填好了 */ if (locked) { pgWeek.value = ''; pgDay.value = ''; } pgHint.textContent = locked ? '还没填预产期,孕周先锁着 —— 选好上面的预产期就会自动算出来。' : '已按预产期自动算出,觉得不准可以自己微调。'; } /* 缓存戳重载:URL 已有 query 时必须用 & 拼接 —— 直接拼 '?t=' 会把 第二个 ? 吃进已有参数值里(?openid=abc001?t=123 → openid 变脏)。 */ function bustReload() { const b = location.href.split('#')[0]; location.replace(b + (b.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now()); } /* state.due / week / day 在 fetch 返回前是内置默认 —— 不能在脚本加载时 直接把默认值写进表单(新用户会看到「小汤圆/孕27周」假数据)。 改为:打开「我的资料」弹窗时按**当前档案**回填(fillProfileForm)。 */ lockWeekFields(true); /* 打开资料弹窗时的表单回填:全部以服务端档案为准(最后写入者胜)。 新用户(未完善资料):昵称/小名/预产期/孕周全空 + 孕周锁定,只留 placeholder。 */ function fillProfileForm() { $('#pfName').value = state.userName || ''; pgName.value = state.babyName || ''; $$('#pgSex .pick').forEach(b => b.classList.toggle('is-active', (b.dataset.sex || 'unknown') === (state.babySex || 'unknown'))); if (state.due) { pgDue.value = toISO(state.due); const ga = gaFromDue(state.due); pgWeek.value = Math.floor(ga / 7); pgDay.value = ga % 7; lockWeekFields(false); } else { pgDue.value = ''; pgWeek.value = ''; pgDay.value = ''; lockWeekFields(true); } syncDuePh(); /* 头像预览跟随卡片(有正式头像才显示;新用户空圆 + +号) */ const _me = $('#meAvatar'); if (_me && _me.classList.contains('has-img') && meAvatarImg && meAvatarImg.src) { avPreview.src = meAvatarImg.src; avBig.classList.add('has-img'); } else { avPreview.removeAttribute('src'); avBig.classList.remove('has-img'); } } /* 预产期占位层:空值显示「- 年 - 月 - 日」,选了值隐藏 */ function syncDuePh() { const ph = $('#pgDuePh'); if (ph) ph.style.display = pgDue.value ? 'none' : 'block'; pgDue.classList.toggle('due-empty', !pgDue.value); } /* 预产期一变,孕周 / 额外天数自动算出来 */ pgDue.addEventListener('change', () => { syncDuePh(); const due = parseDay(pgDue.value); if (!due) { lockWeekFields(true); return; } const ga = gaFromDue(due); pgWeek.value = Math.floor(ga / 7); pgDay.value = ga % 7; lockWeekFields(false); }); /* 微调孕周 / 天数 → 反推预产期 */ [pgWeek, pgDay].forEach(el => el.addEventListener('change', () => { const w = Math.min(42, Math.max(0, parseInt(pgWeek.value, 10) || 0)); const d = Math.min(6, Math.max(0, parseInt(pgDay.value, 10) || 0)); pgWeek.value = w; pgDay.value = d; pgDue.value = toISO(dueFromGA(w * 7 + d)); })); /* 性别三选一 */ $$('#pgSex .pick').forEach(b => b.addEventListener('click', () => { $$('#pgSex .pick').forEach(x => x.classList.toggle('is-active', x === b)); haptic(); })); /* 环的目标值改了要顺手清掉上一轮动画的终值,否则 animateRings 不会重跑 */ function setRing(el, p) { if (!el) return; el.dataset.ring = Math.max(0, Math.min(1, p)).toFixed(3); el.style.strokeDashoffset = ''; } /* ---------- 页面里那些写死的示例日期 ---------- 时光轴、珍贵时刻、首页两张小卡、指南标题、「记一下」抬头、搜索热词, 原来全是设计稿留下的固定字符串(「8月15日 · 孕20周」「孕24周」「9月3日 周二」)。 孕周是按打开当天现算的,这些一天都对不上,档案打印出来最露馅。 现在 HTML 里只留一个标记,文字全由这里生成: data-ga="20" 这件事发生在孕 20 周 → 日期按预产期倒推 data-ga="now" 就是今天 data-wk="n" 只要一个孕周数字(如「孕 26 周胎动多少正常」) data-wk="cn" 要「孕 26 周」这种带字的写法 时光轴写「日期 · 孕周」,珍贵时刻写「孕周 · 日期」—— 两边顺序本来就是反的, 按 class 分流,别为了统一去改设计。 */ function gaToDate(ga) { const d = midnight(new Date()); d.setDate(d.getDate() - ((state.week * 7 + state.day) - ga)); return d; } const mdTxt = d => (d.getMonth() + 1) + '月' + d.getDate() + '日'; const gaTxt = g => '孕' + Math.floor(g / 7) + '周' + (g % 7 ? '+' + (g % 7) : ''); /* 常规产检节奏:28 周前四周一次,之后两周一次,36 周后每周一次。 取第一个还没到的孕周当「下次产检」;都过了就显示预产期当天。 */ const CHECKUPS = [12, 16, 20, 24, 28, 30, 32, 34, 36, 37, 38, 39, 40]; function syncStaticDates() { const gaNow = state.week * 7 + state.day; $$('[data-ga]').forEach(n => { const raw = n.dataset.ga; const g = raw === 'now' ? gaNow : +raw * 7; const day = raw === 'now' ? '今天' : mdTxt(gaToDate(g)); n.textContent = n.classList.contains('mo-meta') ? gaTxt(g) + ' · ' + day : day + ' · ' + gaTxt(g); }); $$('[data-wk]').forEach(n => { n.textContent = n.dataset.wk === 'cn' ? '孕 ' + state.week + ' 周' : state.week; }); const next = CHECKUPS.find(w => w * 7 > gaNow); $('#calNext').textContent = mdTxt(next ? gaToDate(next * 7) : state.due); const t = new Date(); $('#noteWhen').textContent = mdTxt(t) + ' ' + WEEKDAY[t.getDay()] + ' · ' + gaTxt(gaNow); } /* 把 state 里的孕期信息刷到「我的」孕期卡 + 首页头部 + 档案页顶部 */ function syncPregnancy() { /* ---- 新用户空态:未完善资料(无预产期)→ 全部显示引导占位,不读演示数据 ---- me 页已有两条引导(授权卡 + 孕期信息必填卡);这里把首页/档案页的 孕周数字换成引导文案,避免新用户看到"孕 27 周"这类假数据。 */ if (state.pregEmpty) { const nm = state.babyName || '宝宝'; /* 脚印时间轴是纯演示内容(无真实数据源),访客/未完善态整块隐藏 */ const _tl = document.querySelector('.today-line'); if (_tl) _tl.style.display = 'none'; /* ① 我的 · 孕期卡 → 引导 */ $('#pcTitle').textContent = '待完善孕期信息'; $('#pcSub').textContent = '填好预产期,开启孕期陪伴'; $('#pcBar').style.setProperty('--p', '0%'); const rg = $('#pcRing'); if (rg) { const C = 2 * Math.PI * parseFloat(rg.getAttribute('r')); rg.setAttribute('stroke-dasharray', '0 ' + C.toFixed(2)); } $$('.pc-stage span').forEach((s) => { s.textContent = s.textContent.split(' · ')[0]; s.classList.remove('cur'); }); /* ② 首页 hero → 占位 */ $('#heroWeek').textContent = '--'; $('#heroPlus').textContent = ''; $('#heroDays').textContent = '--'; $('#heroWho').textContent = nm; setRing($('#heroRing'), 0); /* ③ 档案页 → 占位 */ $('#arTitleName').textContent = nm; $('#arLine1').textContent = '完善孕期信息后开启'; $('#arDays').textContent = '--'; $('#arLine2').textContent = '先去「我的资料」填预产期'; $('#arPct').textContent = '0%'; setArBar(0); $('#genWeekTag').textContent = '--'; /* ④ 信件/称呼:新用户无信(后端不播种),列表走 letterEmpty 空提示 */ $('#replyTo').textContent = nm; /* 未完善 → 「写回信」FAB 隐藏(没有信可回;完善后由 go() 恢复) */ $('#fabWrite').classList.remove('show'); /* ⑤ 用户卡记录行:未完善 → 引导文案(数据源未定,先不给演示数字) */ const _rl = $('#meRecordLine'); if (_rl) _rl.textContent = '完善孕期信息,记录宝宝的成长记忆'; /* ⑥ 孕期衍生的静态演示位(生长曲线周数/孕检日历/时光轴日期)一并归中性占位, 不保留 HTML 里的演示默认值(孕27周/9月15日…) */ $$('[data-ga]').forEach(n => { n.textContent = '--'; }); $$('[data-wk]').forEach(n => { n.textContent = '--'; }); const _t = new Date(); $('#noteWhen').textContent = mdTxt(_t); syncBabyWords(); pushLatestToHome(); renderLetters(); renderLetterDetail(curLetter()); return; } const week = state.week, day = state.day, name = state.babyName; const _tl2 = document.querySelector('.today-line'); if (_tl2) _tl2.style.display = ''; const days = week * 7 + day; // 一起生活了多少天 const weekTxt = '孕 ' + week + ' 周' + (day ? ' +' + day + ' 天' : ''); /* 剩余天数按 40 周足月倒推,比直接用 date 差值更贴合「孕周」这个语境 */ const left = Math.max(0, (40 - week) * 7 - day); const pct = Math.max(0, Math.min(100, Math.round(days / TERM * 100))); /* ⚠️ 这里原来还算了一个短格式 dueShort(「12月22日」), 专供「我的」页头像旁那行用。那行改成签名后 dueShort 就没人读了, 已一并删除 —— 只写不读的变量留着,下一个人会以为还有别处在用它。 */ let dueTxt = ''; if (state.due) { const d = state.due; dueTxt = '预产期 ' + d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日'; } /* ① 我的 · 孕期卡 */ $('#pcTitle').textContent = weekTxt; $('#pcSub').textContent = (dueTxt ? dueTxt + ' · ' : '') + '还有 ' + left + ' 天'; $('#pcBar').style.setProperty('--p', pct + '%'); /* 卡片左上角那个图标是一圈进度环,和这条进度条同源(同一个 pct), 不会出现「环走了一半、条走了三分之二」这种各说各话。 ⚠️ 周长按元素上的 r 现算,不写死常数 —— 哪天调了环的半径这里自动跟上; 写死的话只会静默画错一个角度,谁也看不出来。 ⚠️ 用 getAttribute('r') 而不是 ring.r.baseVal.value:后者 jsdom 没实现, 会让整个 smoke 直接炸在这一行。 */ const ring = $('#pcRing'); if (ring) { const C = 2 * Math.PI * parseFloat(ring.getAttribute('r')); ring.setAttribute('stroke-dasharray', (C * pct / 100).toFixed(2) + ' ' + C.toFixed(2)); } /* 进度条下方三段固定是早/中/晚期,高亮那一段跟着孕周走, 所以每次都把三段全部重写一遍,避免出现两个「孕中期」 */ const stages = ['孕早期', '孕中期', '孕晚期']; const cur = week <= 13 ? 0 : week <= 27 ? 1 : 2; $$('.pc-stage span').forEach((s, i) => { s.textContent = stages[i] + (i === cur ? ' · 当前' : ''); s.classList.toggle('cur', i === cur); }); /* ⚠️ 这里原来还有一行 if (dueShort) $('.me-info p').textContent = weekTxt + ' · 预产期 ' + dueShort; 已删除,别加回来。孕周信息没丢:本函数上面的 ① 孕期卡一直在显示, 那行本来就是重复的(同一屏往下滚一点就是孕期卡)。 ⚠️ 而且 $('.me-info p') 选的是**第一个** 。曾经那是一句话签名(已删), 现在第一个 是「已连续记录 42 天」—— 加回这行会把连续记录天数 静默冲成孕周,页面不报错,只是那个数字再也不对。 */ /* ② 首页头部:孕周环、+N 天、「小名,我们已经一起生活了 N 天」 */ $('#heroWeek').textContent = week; $('#heroPlus').textContent = '+' + day + '天'; $('#heroDays').textContent = days; $('#heroWho').textContent = name; setRing($('#heroRing'), pct / 100); animateRings($('#screen-home')); /* ③ 档案页顶部:页面名、孕周、大小图标、孕程条 */ /* 只写小名那一格。#arTitle 本身现在是一枚牌子(图标 + 小名 + 「的成长档案」), 整句写回 #arTitle.textContent 会把图标和后半句一起冲掉。 */ $('#arTitleName').textContent = name; /* 「已陪伴 N 天」原来拿个「·」接在孕周后面,孕周和天数是两个单位, 连在一起读像一句话。头部重排后它挪到孕程条右下角,单独成条。 */ $('#arLine1').textContent = weekTxt; $('#arDays').textContent = days; /* 第二行原来写「预产期 X · 还有 N 天」,和孕程条说的是同一件事。 换成「这周宝宝多大了」——「还有 110 天」是抽象数字,「像一根玉米」摸得着。 预产期没丢:我的页 #pcSub 和海报上仍是全写法。 */ const S = babySize(week); $('#arSizeArt').textContent = S.emoji; $('#arLine2').textContent = '像' + S.name + '了 · 约 ' + S.txt; $('#arPct').textContent = pct + '%'; /* 孕程从 84px 的圆环换成了通栏细条,所以这里不再是 setRing, 直接写宽度;CSS 上挂着 transition,从 0 长出来的动画照旧。 档案页也就这一个环,环动画的调用一并去掉了。 */ setArBar(pct); $('#genWeekTag').textContent = '第 ' + week + ' 周'; // 生成档案卡上的周次 /* ④ 来信:小名一改,落款、称呼、按钮文案全部跟着换。 孕周一改,每封信头上那句「孕 N 周 +M 天」也得跟着重算 —— 所以先盖日期 再渲染,顺序反了就要等下一次 syncPregnancy 才生效。 */ $('#replyTo').textContent = name; syncBabyWords(); stampLetters(); pushLatestToHome(); renderLetters(); renderLetterDetail(curLetter()); /* ⑤ 用户卡记录行:已完善 → 保留「已连续记录 N 天」(数据源确认前先维持原型值) */ const _rl2 = $('#meRecordLine'); if (_rl2) _rl2.innerHTML = '已连续记录 42 天'; /* ⑥ 页面里那些示例日期(时光轴、珍贵时刻、小卡、热词…)也按新孕周重刷。 改预产期会连带改孕周,这些跟着一起动才不会前后矛盾。 */ syncStaticDates(); stampAiLogs(); } /* ⚠️ 这里原来是 syncBio() —— 刷「我的」页头像旁那行一句话签名。 一句话签名本批按用户要求整块删除,四处一起走: index.html #pfBio 输入框、 展示行 app.js state.userBio 初值、这个 syncBio()、init() 里对它的调用、 #pfSave 里那两行 别只补回其中一处:留输入框没展示行 = 白填;留展示行没输入框 = 死文案。 */ /* 把散落在各个页面里的固定文案统一按小名刷一遍。 这些是写死在标记里的静态字,不归任何 render 函数管, 只能在这里集中改,漏一处就会出现「米可回信啦」和「宝宝写给你的信」并存。 */ function syncBabyWords() { $('#ltHeroH1').textContent = babyWord('写给你的信'); $('#ltHeroSub').textContent = '每周' + babyWord() + '都会悄悄给你写一封'; /* 只改文字那一层。写 #dlReply.textContent 会连同里面的扁平笔 svg 一起清掉, 而且是静默的 —— 按钮照样能点,只是图标从此再也不出现 */ $('#dlReplyTxt').textContent = '给' + babyWord('回信'); $('#modalReplyTitle').textContent = '给' + babyWord('回信'); /* 首页三个模块名都跟着小名走: 「孕期必用工具」→「小汤圆的孕育工具」 「AI今日小结」 →「小汤圆今天想说」 「今日成长脚印」→「小汤圆的今日脚印」 这三处原来是写死的功能名。换成小名不只是亲切 —— 数胎动、称体重这些事, 妈妈是在替谁做,标题里就该有谁。 ⚠️ AI 气泡上那枚 .ab-badge「AI」必须留着。 标题可以换成宝宝的口吻,但「这段话是机器生成的」不能因此说不清楚。 气泡页脚原来还有一句「AI自动汇总你每日记录」,第五批按用户要求删了 —— 于是这枚角标现在是**唯一**的机器生成提示,别再动它。测试里钉了一条。 */ $('#dockTitle').textContent = babyWord('的孕育工具'); $('#abTag').textContent = babyWord('今天想说'); $('#tlTitle').textContent = babyWord('的今日脚印'); /* 整条时间轴自己是个 role="button",它的 aria-label 里也写着模块名。 只刷可见的 #tlTitle、把这句留在原地,读屏用户会一直听见上一个小名 —— 而这种不同步在屏幕上根本看不出来,只能靠这行和它的断言守着。 */ $('#tlSteps').setAttribute('aria-label', babyWord('的今日脚印') + ',查看完整档案'); /* 「我的」页那张福利卡的副标题,和它点开后的弹窗标题 —— 两处说的是同一个人, 必须一起刷。 ⚠️ 挂点从 #meWxTitle 搬到了 #meWxSub:那张卡的标题现在讲的是商品本身 (「可心柔婴儿纸巾」),顾问是谁退到了副标题里。别再往标题上挂 —— 320 屏上标题那格只有 159px,光产品名就占 132px, 再塞进一个 12 字的小名必被省略号吃掉产品名。 ⚠️ #wxTitle 藏在弹窗里,改完小名后**不点开就看不见**, 是那种肉眼巡查永远扫不到、只能靠断言守住的挂点。 ⚠️ #rlgSub 是资源库 banner 上的同一句话(原来是写死的静态文本,不带小名)。 用户要求两处一模一样,所以这里**先算出一个字符串再赋给两处**, 而不是各写各的 —— 分开写就是上一版「同一件商品三个叫法」的老路。 */ const wxSub = '加' + babyWord('的孕育顾问') + '领取'; $('#meWxSub').textContent = wxSub; $('#rlgSub').textContent = wxSub; $('#wxTitle').textContent = babyWord('的孕育顾问'); } /* ⚠️ 这里原来是 $('#pgSave') —— 「编辑孕期信息」那张弹窗自己的保存按钮。 那张弹窗已整个并进「我的资料」(理由见 index.html 里 #modalProfile 上方那段), 它的校验和写库逻辑原样搬进了下面的 #pfSave。 别再按 id 去找 pgSave:DOM 里已经没有这个元素了,$() 会返回 null, 而 null.addEventListener 是启动即崩 —— 整个 app.js 一行都跑不起来。 */ /* ---------- 我的资料 · 换头像 ---------- */ const avFile = $('#avFile'), avPreview = $('#avPreview'), avBig = $('#avPick'); const meAvatar = $('#meAvatar'), meAvatarImg = $('#meAvatarImg'); /* 点大圆 → 转交给隐藏的 file input。openModal 里会 focus 第一个输入框, 这里 avFile 排在 pfName 前面,所以 openModal 用的选择器只取 .modal-ta/input 可能命中 file input——用 :not([type=file]) 排除掉,见 openModal */ avBig.addEventListener('click', () => avFile.click()); avFile.addEventListener('change', () => { const f = avFile.files && avFile.files[0]; if (!f) return; if (!/^image\//.test(f.type)) { toast('请选择一张图片'); return; } if (f.size > 8 * 1024 * 1024) { toast('图片太大了,换一张小于 8MB 的'); return; } const r = new FileReader(); /* 读成 data URI 就地预览:产物是单文件、没有后端,不做真正的上传 */ r.onload = () => { avPreview.src = r.result; avBig.classList.add('has-img'); haptic(); }; r.onerror = () => toast('这张图读不出来,换一张试试'); r.readAsDataURL(f); }); /* 「我的资料」现在同时管**妈妈**(头像 / 昵称)和**宝宝**(小名 / 性别 / 预产期 / 孕周), 所以这一个按钮要把原来 #pfSave 和 #pgSave 两段逻辑都做完。 ⚠️ 顺序不能反:先校验预产期,校验没过就整个 return,**头像和昵称也一起不保存**。 听起来像是误伤,但另一种做法更坏 —— 保存一半、弹窗还开着, 用户看到「资料已更新」却发现孕周没变,会以为是自己没填对。 一个按钮就该是一次原子的「保存」。 */ $('#pfSave').addEventListener('click', () => { /* 预产期是一切的源头:孕周、剩余天数、信里的日期全从它算。没有它没什么可存的。 */ if (!parseDay(pgDue.value)) { toast('先选一下预产期,孕周会自动算出来'); pgDue.focus(); return; } const name = $('#pfName').value.trim() || '妈妈'; const babyName = pgName.value.trim() || '宝宝'; const week = Math.min(42, Math.max(0, parseInt(pgWeek.value, 10) || 0)); const day = Math.min(6, Math.max(0, parseInt(pgDay.value, 10) || 0)); const sexBtn = $('#pgSex .pick.is-active'); /* ---- 妈妈那半(本地即时生效)---- 身份模型:卡片统一读档案;资料编辑的昵称/头像也是档案的一部分(最后写者胜)。 落库在下方 fetch(头像需先上传换 URL)。 */ $('#meName').textContent = name; state.userName = name; /* ⚠️ 这里原来还有一行 state.userBio = bio 和一次 syncBio() 调用。 一句话签名整块已按用户要求删除(输入框 #pfBio、展示行 #meBio、 syncBio()、state.userBio 四处一起删),别只加回其中一处。 */ /* ---- 宝宝那半(原 #pgSave) ---- */ state.babyName = babyName; state.week = week; state.day = day; state.babySex = (sexBtn && sexBtn.dataset.sex) || 'unknown'; state.due = parseDay(pgDue.value); /* ---- 落库:PUT /profile(孕育字段 + 昵称/头像)→ 刷新/重进后资料不丢。 若弹窗里新选了头像(avFile 有文件),先 POST /upload 换正式 URL 再一起提交; 上传失败则跳过头像字段(昵称/孕育照存)。断网/接口失败静默,本地已生效。 ---- */ var _oid = hashParam('openid'); var _wasEmpty = state.pregEmpty; /* 新用户完善资料后 → 重载拉真实孕周+播种信 */ function _putProfile(avatarUrl) { if (!_oid) return; var body = { babyName: babyName, babySex: state.babySex, dueDate: state.due ? toISO(state.due) : '', userName: name }; if (avatarUrl) { body.avatar = avatarUrl; meAvatarImg.src = avatarUrl; /* 用正式 URL 替换 base64 预览 */ } fetch('/api/mp/public/mama-box/profile?openid=' + encodeURIComponent(_oid), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(function () { /* 新用户首次完善资料:重载页面 → pregnancy 出真实孕周、样例信播种 */ if (_wasEmpty) setTimeout(bustReload, 700); }).catch(function () { /* 保持本地值 */ }); } var newAvatarFile = (avFile.files && avFile.files[0]) || null; if (newAvatarFile && avBig.classList.contains('has-img')) { var fd = new FormData(); fd.append('file', newAvatarFile, newAvatarFile.name || 'avatar.png'); fetch('/api/upload', { method: 'POST', body: fd }) .then(function (r) { return r.json(); }) .then(function (res) { var url = res && res.result && res.result.url; _putProfile(url || ''); }) .catch(function () { _putProfile(''); }); } else { _putProfile(''); } /* ⚠️ syncPregnancy() 就是用户说的「同步到孕期卡上边」那一步 —— 它把孕周环、剩余天数、预产期、宝宝多大重刷一遍,顺带 syncBabyWords() 把全站写着小名的地方(首页三个模块名、来信页标题、回信按钮…)一起改。 少调这一句,弹窗里改完了、外面还是旧数字,而且没有任何报错。 */ syncPregnancy(); closeModal(); setTimeout(() => toast('资料已更新 · ' + name + ' · ' + babyName + ' 孕 ' + week + ' 周' + (day ? '+' + day + ' 天' : '')), 260); }); /* 「我的二维码」按需求已整块移除 */ /* ================= 设置与服务 二级页 ================= 四项内容形态其实一样:若干「分组 + 若干行」。所以只写一张表 + 一个渲染器。 行的三种类型: sw 开关行(可点,本地切换状态并 toast) row 普通跳转行(右侧箭头,点了给个 toast) txt 纯说明段落 */ /* 说明:「提醒设置」整项已按需求删除(连同「我的」里的入口行)。 隐私与权限去掉了「社区匿名发言」「设备权限」「数据」三处; 帮助与反馈只留「联系我们」;关于兜爸心选只留介绍。 */ const SETTINGS = { privacy: { title: '隐私与权限', more: '你的记录只属于你', lead: '孕期的记录是很私人的东西。默认情况下,只有你自己能看到它们。', groups: [ { t: '谁能看到', rows: [ ['sw', '档案仅自己可见', '关掉后可生成分享链接', 1], ['sw', '允许家人查看', '爸爸可看孕周与产检日程', 1] ] }, { t: '', rows: [ ['txt', '我们不会把你的孕期数据用于广告投放,也不会卖给第三方。数据加密存储,你可以随时导出或删除。'] ] } ], foot: '《隐私政策》最近一次更新:2024 年 8 月 12 日' }, help: { title: '帮助与反馈', more: '一般 1 个工作日内回复', lead: '有任何用不明白的地方,都可以直接问我们,妈妈的时间很宝贵。', groups: [ { t: '联系我们', rows: [ ['row', '在线客服', '9:00 – 21:00'], ['row', '提交反馈或建议', '截图一起发给我们'], ['row', '加入内测群', '抢先体验新功能'] ] } ], foot: '你提的每一条建议我们都会看,兜爸心选有很多功能就是这么来的。' }, about: { title: '关于兜爸心选', more: '当前版本 v3.2.1', hero: 1, lead: '兜爸心选想做的事很简单:让你不用记录,也能留下记录。', groups: [ { t: '', rows: [ ['txt', '怀孕已经够辛苦了,我们不想再让你多填一张表。你每一次数胎动、称体重、看产检、读宝宝的来信,兜爸心选都会悄悄记下来,替你整理成一本档案。'], ['txt', '等宝宝出生、等他长大、等你某天忽然想起这段日子,这本档案都还在原地等你。'] ] } ], foot: '兜爸心选 v3.2.1 · 已陪伴 268 万位妈妈' } }; const ABOUT_HERO = '' + '' + '' + '' + '' + '' + '' + '' + '兜爸心选' + 'v3.2.1 · 陪你从两条杠到见面' + ''; function renderSetting(key) { const cfg = SETTINGS[key] || SETTINGS.privacy; $('#stTitle').textContent = cfg.title; $('#stMore').dataset.toast = cfg.more; $('#stHero').innerHTML = cfg.hero ? ABOUT_HERO : ''; $('#stLead').textContent = cfg.lead; $('#stFoot').textContent = cfg.foot; const html = cfg.groups.map(g => { const rows = g.rows.map(r => { /* .st-para 而不是 .st-txt:后者是首页成长脚印在用的类名 */ if (r[0] === 'txt') return '' + r[1] + ''; if (r[0] === 'row') return '' + '' + r[1] + '' + (r[2] ? '' + r[2] + '' : '') + '›'; /* 开关行:aria-checked 让读屏也能拿到状态,样式只认 .on */ return '' + '' + r[1] + '' + r[2] + '' + ''; }).join(''); return (g.t ? '' + g.t + '' : '') + '' + rows + ''; }).join(''); $('#stBody').innerHTML = html; /* 「关于兜爸心选」页底部显示版本号(全站不再有固定底标) */ if (key === 'about') { let av = document.getElementById('aboutVer'); if (!av) { av = document.createElement('p'); av.id = 'aboutVer'; av.className = 'about-ver'; $('#detailSetting .detail-body').appendChild(av); } av.textContent = 'v' + VER; } 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) => '' + '' + (i + 1) + '' + '' + 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) => '' + '' + '' + l.date + '' + '' + l.week + '' + '' + l.txt + '').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 '' + '' + (got ? '✓' : '') + '' + '' + r.title + '' + '' + r.desc + '' + /* 底部这行的顺序换过一次: 原来是「免费 / 分类 …… 8 分钟读完」。两处毛病 —— 「几分钟读完」对着一个网盘资料包说不通(音乐那份写的是「30 首 · 共 96 分钟」, 压根不是一回事),换成 meta[2] 的适用孕周:在孕育资源库里, 「这份现在适不适合我」比「要读多久」有用得多; 状态标(免费 / 已领 ✓)顶到最右,整列对齐, 扫列表时哪几份领过一眼就出来,不用逐行读到第一格。 */ '' + '' + r.cat + '' + '' + r.meta[2] + '' + (got ? '已领 ✓' : '免费') + ''; } 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) { /* 同时读 hash 和 query:浏览器直访(?openid=x)只有 query; 小程序 web-view 的授权回传在 hash(微信 webview 丢 query 不丢 hash)。 ⚠️ 先在原始串上匹配、再对捕获值 decode —— 先 decode 整串会把 %26 之类的编码提前还原成 &,破坏参数切分。 ⚠️ split('?'):历史 bug 会把缓存戳粘进 openid(abc001?t=123), 真 openid 不含 ?,剥掉以兼容已被污染的旧链接。 */ try { const h = (location.hash.slice(1) || '') + '&' + (location.search.slice(1) || ''); const m = h.match(new RegExp('(?:^|[])' + name + '=([^]*)')); return m ? decodeURIComponent(m[1]).split('?')[0] : ''; } catch (e) { return ''; } } let _profileApplied = false; function applyRemoteProfile() { if (_profileApplied) return; // init 只跑一次,防重复 fetch _profileApplied = true; const openid = hashParam('openid'); /* 访客态:宿主未注入身份(冷启动 silenceAuth 失败走「匿名加载 H5」兜底)。 ⚠️ 绝不能停在内置演示默认值上(小汤圆/孕27周/回信啦 都是前端现算的假数据)—— 全部切占位/引导;宿主拿到身份后会带 openid 重载本页,届时自动切真实数据。 */ if (!openid) { state.pregEmpty = true; state.due = null; state.week = null; state.day = null; state.babyName = ''; state.userName = ''; LETTERS.splice(0, LETTERS.length); state.curLetter = ''; state.homeLetter = ''; syncPregnancy(); /* 占位 + 隐藏脚印时间轴 + 信件引导态 */ syncLetterCounts(); /* hero 计数 '--'、未读角标隐藏 */ 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; /* 服务端是唯一事实源:新用户 babyName 为空也要覆盖内置演示名(小汤圆), 让全站称呼走 babyWord 的「宝宝」兜底 */ state.babyName = p.babyName || ''; 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; } /* 状态化「我的」页:未授权→占位+显示授权入口;已授权→资料+隐藏授权入口;已建档(B→C)由孕期信息卡引导 */ var _authorized = !!p.authorized || !!p.isAuthorized; var _n = $('#meName'); if (_n) _n.textContent = p.userName || '未登录'; if (p.avatar) { var _ai = $('#meAvatarImg'), _aw = $('#meAvatar'); if (_ai) _ai.src = p.avatar; if (_aw) _aw.classList.add('has-img'); } /* 授权入口:未授权时显示(引导先授权);退出按钮:已授权时显示 */ var _box = $('#meAuthBox'); if (_box) _box.style.display = _authorized ? 'none' : ''; var _lx = $('#meLogoutCard'); if (_lx) _lx.style.display = _authorized ? '' : 'none'; if (!p.userName) { var _ai2 = $('#meAvatarImg'); if (_ai2) _ai2.removeAttribute('src'); } } if (pg && pg.code === 200 && pg.result) { /* 新用户空态(未完善资料):后端不再给默认孕周 → 显示引导占位, 不读任何演示数据(详见 syncPregnancy 的 pregEmpty 分支)。 ⚠️ 同时清掉 state 的内置演示默认值(DEFAULT_DUE=今天+3个月/孕27周), 否则「我的资料」弹窗回填时又会把它们带回来。 */ if (pg.result.empty || pg.result.week == null) { state.pregEmpty = true; state.due = null; state.week = null; state.day = null; } else { state.pregEmpty = false; 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(); /* 新用户空态:syncPregnancy 已写完引导占位(含信件/称呼/静态位归零)。 ⚠️ 下面 stampLetters/filterLetters/syncStaticDates 全按 state.week 重算, 而 pregEmpty 时 state.week 还是内置默认 27 —— 不拦住的话, 刚写好的占位会被改回「孕 27 周 / 9月15日」演示值。 */ if (state.pregEmpty) { $('#tabBadge').classList.remove('show'); return; } 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) return; /* 新用户空态:后端成功返回空列表 → 清掉内置样例信,展示「这里还没有内容」。 ⚠️ 不能直接 return —— 那会把 LETTERS 留在内置样例上,新用户看到演示信。 */ if (!lj.result.items.length) { LETTERS.splice(0, LETTERS.length); state.curLetter = ''; state.homeLetter = ''; renderLetters(); syncLetterCounts(); pushLatestToHome(); 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, #meMallCard').forEach(function (b) { b.addEventListener('click', goMall); }); /* 「授权完善资料」:H5 → navigateTo → 小程序端独立授权页(pages/annex/auth/index)。 说明:微信 web-view 的 postMessage 非实时送达(只在分享/后退/onHide 等时机 才派发 bindmessage),点按钮时端上收不到 → 改用 wx.miniProgram.navigateTo 实时跳转到授权页,避开该限制。 */ function goAuth() { haptic(); /* 与 goMall 同一套环境判定:wx.miniProgram 存在还要 getEnv 确认(纯浏览器里 jweixin 也可能已加载,getEnv 不回调 → 800ms 超时兜底按非小程序提示) */ if (window.wx && window.wx.miniProgram) { var responded = false; window.wx.miniProgram.getEnv(function (r) { responded = true; if (r && r.miniprogram) { window.wx.miniProgram.navigateTo({ url: '/pages/annex/auth/index' }); } else { toast('请在小程序中完成授权登录'); } }); setTimeout(function () { if (!responded) toast('请在小程序中完成授权登录'); }, 800); } else { toast('请在小程序中完成授权登录'); } } var meAuth = $('#meAuthCard'); if (meAuth) meAuth.addEventListener('click', goAuth); /* 「退出登录」(个人资料卡右上角图标按钮): 调后端 /api/mp/public/auth/logout 只清「授权登录信息」三字段(昵称/头像/手机号), openid/unionid(小程序匿名身份)保留,孕育资料(小名/性别/预产期)保留。 成功后重载页面 → applyRemoteProfile 重新拉到空显示字段 → 回到未登录态、授权入口重现。 ⚠️ stopPropagation:me-card 整卡是 data-modal="modalProfile",不阻止冒泡 会顺带打开资料弹窗。 */ var meLogout = $('#meLogoutCard'); if (meLogout) meLogout.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); haptic(); var openid = hashParam('openid'); if (!openid) { toast('未获得身份,无法退出'); return; } fetch('/api/mp/public/auth/logout?openid=' + encodeURIComponent(openid), { method: 'POST' }) .then(function (r) { return r.json(); }) .then(function (res) { if (res.code === 200) { toast('已退出登录'); setTimeout(bustReload, 600); } else { toast(res.message || '退出失败,请重试'); } }) .catch(function () { toast('退出失败,请检查网络'); }); }); /* 从商城/后台切回小程序首页时,webview 会再次可见 —— 把 H5 重置回「首页」屏。 ⚠️ 若不处理:切到商城再点「首页」tab,仍是同一个 webview 实例, SPA 还停留在切走前的屏(如「我的」),与"回到首页"的预期不符。 只在"当前不是首页"时才切,避免打断用户在首页上的浏览/滚动。 */ function resetScreenOnShow() { var cur = document.querySelector('.screen.is-active'); if (cur && cur.dataset.screen && cur.dataset.screen !== 'home') go('home'); } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') resetScreenOnShow(); }); window.addEventListener('pageshow', function () { if (document.visibilityState !== 'hidden') resetScreenOnShow(); }); if (document.readyState === 'complete') init(); else window.addEventListener('load', init); })();
。曾经那是一句话签名(已删), 现在第一个
是「已连续记录 42 天」—— 加回这行会把连续记录天数 静默冲成孕周,页面不报错,只是那个数字再也不对。 */ /* ② 首页头部:孕周环、+N 天、「小名,我们已经一起生活了 N 天」 */ $('#heroWeek').textContent = week; $('#heroPlus').textContent = '+' + day + '天'; $('#heroDays').textContent = days; $('#heroWho').textContent = name; setRing($('#heroRing'), pct / 100); animateRings($('#screen-home')); /* ③ 档案页顶部:页面名、孕周、大小图标、孕程条 */ /* 只写小名那一格。#arTitle 本身现在是一枚牌子(图标 + 小名 + 「的成长档案」), 整句写回 #arTitle.textContent 会把图标和后半句一起冲掉。 */ $('#arTitleName').textContent = name; /* 「已陪伴 N 天」原来拿个「·」接在孕周后面,孕周和天数是两个单位, 连在一起读像一句话。头部重排后它挪到孕程条右下角,单独成条。 */ $('#arLine1').textContent = weekTxt; $('#arDays').textContent = days; /* 第二行原来写「预产期 X · 还有 N 天」,和孕程条说的是同一件事。 换成「这周宝宝多大了」——「还有 110 天」是抽象数字,「像一根玉米」摸得着。 预产期没丢:我的页 #pcSub 和海报上仍是全写法。 */ const S = babySize(week); $('#arSizeArt').textContent = S.emoji; $('#arLine2').textContent = '像' + S.name + '了 · 约 ' + S.txt; $('#arPct').textContent = pct + '%'; /* 孕程从 84px 的圆环换成了通栏细条,所以这里不再是 setRing, 直接写宽度;CSS 上挂着 transition,从 0 长出来的动画照旧。 档案页也就这一个环,环动画的调用一并去掉了。 */ setArBar(pct); $('#genWeekTag').textContent = '第 ' + week + ' 周'; // 生成档案卡上的周次 /* ④ 来信:小名一改,落款、称呼、按钮文案全部跟着换。 孕周一改,每封信头上那句「孕 N 周 +M 天」也得跟着重算 —— 所以先盖日期 再渲染,顺序反了就要等下一次 syncPregnancy 才生效。 */ $('#replyTo').textContent = name; syncBabyWords(); stampLetters(); pushLatestToHome(); renderLetters(); renderLetterDetail(curLetter()); /* ⑤ 用户卡记录行:已完善 → 保留「已连续记录 N 天」(数据源确认前先维持原型值) */ const _rl2 = $('#meRecordLine'); if (_rl2) _rl2.innerHTML = '已连续记录 42 天'; /* ⑥ 页面里那些示例日期(时光轴、珍贵时刻、小卡、热词…)也按新孕周重刷。 改预产期会连带改孕周,这些跟着一起动才不会前后矛盾。 */ syncStaticDates(); stampAiLogs(); } /* ⚠️ 这里原来是 syncBio() —— 刷「我的」页头像旁那行一句话签名。 一句话签名本批按用户要求整块删除,四处一起走: index.html #pfBio 输入框、
展示行 app.js state.userBio 初值、这个 syncBio()、init() 里对它的调用、 #pfSave 里那两行 别只补回其中一处:留输入框没展示行 = 白填;留展示行没输入框 = 死文案。 */ /* 把散落在各个页面里的固定文案统一按小名刷一遍。 这些是写死在标记里的静态字,不归任何 render 函数管, 只能在这里集中改,漏一处就会出现「米可回信啦」和「宝宝写给你的信」并存。 */ function syncBabyWords() { $('#ltHeroH1').textContent = babyWord('写给你的信'); $('#ltHeroSub').textContent = '每周' + babyWord() + '都会悄悄给你写一封'; /* 只改文字那一层。写 #dlReply.textContent 会连同里面的扁平笔 svg 一起清掉, 而且是静默的 —— 按钮照样能点,只是图标从此再也不出现 */ $('#dlReplyTxt').textContent = '给' + babyWord('回信'); $('#modalReplyTitle').textContent = '给' + babyWord('回信'); /* 首页三个模块名都跟着小名走: 「孕期必用工具」→「小汤圆的孕育工具」 「AI今日小结」 →「小汤圆今天想说」 「今日成长脚印」→「小汤圆的今日脚印」 这三处原来是写死的功能名。换成小名不只是亲切 —— 数胎动、称体重这些事, 妈妈是在替谁做,标题里就该有谁。 ⚠️ AI 气泡上那枚 .ab-badge「AI」必须留着。 标题可以换成宝宝的口吻,但「这段话是机器生成的」不能因此说不清楚。 气泡页脚原来还有一句「AI自动汇总你每日记录」,第五批按用户要求删了 —— 于是这枚角标现在是**唯一**的机器生成提示,别再动它。测试里钉了一条。 */ $('#dockTitle').textContent = babyWord('的孕育工具'); $('#abTag').textContent = babyWord('今天想说'); $('#tlTitle').textContent = babyWord('的今日脚印'); /* 整条时间轴自己是个 role="button",它的 aria-label 里也写着模块名。 只刷可见的 #tlTitle、把这句留在原地,读屏用户会一直听见上一个小名 —— 而这种不同步在屏幕上根本看不出来,只能靠这行和它的断言守着。 */ $('#tlSteps').setAttribute('aria-label', babyWord('的今日脚印') + ',查看完整档案'); /* 「我的」页那张福利卡的副标题,和它点开后的弹窗标题 —— 两处说的是同一个人, 必须一起刷。 ⚠️ 挂点从 #meWxTitle 搬到了 #meWxSub:那张卡的标题现在讲的是商品本身 (「可心柔婴儿纸巾」),顾问是谁退到了副标题里。别再往标题上挂 —— 320 屏上标题那格只有 159px,光产品名就占 132px, 再塞进一个 12 字的小名必被省略号吃掉产品名。 ⚠️ #wxTitle 藏在弹窗里,改完小名后**不点开就看不见**, 是那种肉眼巡查永远扫不到、只能靠断言守住的挂点。 ⚠️ #rlgSub 是资源库 banner 上的同一句话(原来是写死的静态文本,不带小名)。 用户要求两处一模一样,所以这里**先算出一个字符串再赋给两处**, 而不是各写各的 —— 分开写就是上一版「同一件商品三个叫法」的老路。 */ const wxSub = '加' + babyWord('的孕育顾问') + '领取'; $('#meWxSub').textContent = wxSub; $('#rlgSub').textContent = wxSub; $('#wxTitle').textContent = babyWord('的孕育顾问'); } /* ⚠️ 这里原来是 $('#pgSave') —— 「编辑孕期信息」那张弹窗自己的保存按钮。 那张弹窗已整个并进「我的资料」(理由见 index.html 里 #modalProfile 上方那段), 它的校验和写库逻辑原样搬进了下面的 #pfSave。 别再按 id 去找 pgSave:DOM 里已经没有这个元素了,$() 会返回 null, 而 null.addEventListener 是启动即崩 —— 整个 app.js 一行都跑不起来。 */ /* ---------- 我的资料 · 换头像 ---------- */ const avFile = $('#avFile'), avPreview = $('#avPreview'), avBig = $('#avPick'); const meAvatar = $('#meAvatar'), meAvatarImg = $('#meAvatarImg'); /* 点大圆 → 转交给隐藏的 file input。openModal 里会 focus 第一个输入框, 这里 avFile 排在 pfName 前面,所以 openModal 用的选择器只取 .modal-ta/input 可能命中 file input——用 :not([type=file]) 排除掉,见 openModal */ avBig.addEventListener('click', () => avFile.click()); avFile.addEventListener('change', () => { const f = avFile.files && avFile.files[0]; if (!f) return; if (!/^image\//.test(f.type)) { toast('请选择一张图片'); return; } if (f.size > 8 * 1024 * 1024) { toast('图片太大了,换一张小于 8MB 的'); return; } const r = new FileReader(); /* 读成 data URI 就地预览:产物是单文件、没有后端,不做真正的上传 */ r.onload = () => { avPreview.src = r.result; avBig.classList.add('has-img'); haptic(); }; r.onerror = () => toast('这张图读不出来,换一张试试'); r.readAsDataURL(f); }); /* 「我的资料」现在同时管**妈妈**(头像 / 昵称)和**宝宝**(小名 / 性别 / 预产期 / 孕周), 所以这一个按钮要把原来 #pfSave 和 #pgSave 两段逻辑都做完。 ⚠️ 顺序不能反:先校验预产期,校验没过就整个 return,**头像和昵称也一起不保存**。 听起来像是误伤,但另一种做法更坏 —— 保存一半、弹窗还开着, 用户看到「资料已更新」却发现孕周没变,会以为是自己没填对。 一个按钮就该是一次原子的「保存」。 */ $('#pfSave').addEventListener('click', () => { /* 预产期是一切的源头:孕周、剩余天数、信里的日期全从它算。没有它没什么可存的。 */ if (!parseDay(pgDue.value)) { toast('先选一下预产期,孕周会自动算出来'); pgDue.focus(); return; } const name = $('#pfName').value.trim() || '妈妈'; const babyName = pgName.value.trim() || '宝宝'; const week = Math.min(42, Math.max(0, parseInt(pgWeek.value, 10) || 0)); const day = Math.min(6, Math.max(0, parseInt(pgDay.value, 10) || 0)); const sexBtn = $('#pgSex .pick.is-active'); /* ---- 妈妈那半(本地即时生效)---- 身份模型:卡片统一读档案;资料编辑的昵称/头像也是档案的一部分(最后写者胜)。 落库在下方 fetch(头像需先上传换 URL)。 */ $('#meName').textContent = name; state.userName = name; /* ⚠️ 这里原来还有一行 state.userBio = bio 和一次 syncBio() 调用。 一句话签名整块已按用户要求删除(输入框 #pfBio、展示行 #meBio、 syncBio()、state.userBio 四处一起删),别只加回其中一处。 */ /* ---- 宝宝那半(原 #pgSave) ---- */ state.babyName = babyName; state.week = week; state.day = day; state.babySex = (sexBtn && sexBtn.dataset.sex) || 'unknown'; state.due = parseDay(pgDue.value); /* ---- 落库:PUT /profile(孕育字段 + 昵称/头像)→ 刷新/重进后资料不丢。 若弹窗里新选了头像(avFile 有文件),先 POST /upload 换正式 URL 再一起提交; 上传失败则跳过头像字段(昵称/孕育照存)。断网/接口失败静默,本地已生效。 ---- */ var _oid = hashParam('openid'); var _wasEmpty = state.pregEmpty; /* 新用户完善资料后 → 重载拉真实孕周+播种信 */ function _putProfile(avatarUrl) { if (!_oid) return; var body = { babyName: babyName, babySex: state.babySex, dueDate: state.due ? toISO(state.due) : '', userName: name }; if (avatarUrl) { body.avatar = avatarUrl; meAvatarImg.src = avatarUrl; /* 用正式 URL 替换 base64 预览 */ } fetch('/api/mp/public/mama-box/profile?openid=' + encodeURIComponent(_oid), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(function () { /* 新用户首次完善资料:重载页面 → pregnancy 出真实孕周、样例信播种 */ if (_wasEmpty) setTimeout(bustReload, 700); }).catch(function () { /* 保持本地值 */ }); } var newAvatarFile = (avFile.files && avFile.files[0]) || null; if (newAvatarFile && avBig.classList.contains('has-img')) { var fd = new FormData(); fd.append('file', newAvatarFile, newAvatarFile.name || 'avatar.png'); fetch('/api/upload', { method: 'POST', body: fd }) .then(function (r) { return r.json(); }) .then(function (res) { var url = res && res.result && res.result.url; _putProfile(url || ''); }) .catch(function () { _putProfile(''); }); } else { _putProfile(''); } /* ⚠️ syncPregnancy() 就是用户说的「同步到孕期卡上边」那一步 —— 它把孕周环、剩余天数、预产期、宝宝多大重刷一遍,顺带 syncBabyWords() 把全站写着小名的地方(首页三个模块名、来信页标题、回信按钮…)一起改。 少调这一句,弹窗里改完了、外面还是旧数字,而且没有任何报错。 */ syncPregnancy(); closeModal(); setTimeout(() => toast('资料已更新 · ' + name + ' · ' + babyName + ' 孕 ' + week + ' 周' + (day ? '+' + day + ' 天' : '')), 260); }); /* 「我的二维码」按需求已整块移除 */ /* ================= 设置与服务 二级页 ================= 四项内容形态其实一样:若干「分组 + 若干行」。所以只写一张表 + 一个渲染器。 行的三种类型: sw 开关行(可点,本地切换状态并 toast) row 普通跳转行(右侧箭头,点了给个 toast) txt 纯说明段落 */ /* 说明:「提醒设置」整项已按需求删除(连同「我的」里的入口行)。 隐私与权限去掉了「社区匿名发言」「设备权限」「数据」三处; 帮助与反馈只留「联系我们」;关于兜爸心选只留介绍。 */ const SETTINGS = { privacy: { title: '隐私与权限', more: '你的记录只属于你', lead: '孕期的记录是很私人的东西。默认情况下,只有你自己能看到它们。', groups: [ { t: '谁能看到', rows: [ ['sw', '档案仅自己可见', '关掉后可生成分享链接', 1], ['sw', '允许家人查看', '爸爸可看孕周与产检日程', 1] ] }, { t: '', rows: [ ['txt', '我们不会把你的孕期数据用于广告投放,也不会卖给第三方。数据加密存储,你可以随时导出或删除。'] ] } ], foot: '《隐私政策》最近一次更新:2024 年 8 月 12 日' }, help: { title: '帮助与反馈', more: '一般 1 个工作日内回复', lead: '有任何用不明白的地方,都可以直接问我们,妈妈的时间很宝贵。', groups: [ { t: '联系我们', rows: [ ['row', '在线客服', '9:00 – 21:00'], ['row', '提交反馈或建议', '截图一起发给我们'], ['row', '加入内测群', '抢先体验新功能'] ] } ], foot: '你提的每一条建议我们都会看,兜爸心选有很多功能就是这么来的。' }, about: { title: '关于兜爸心选', more: '当前版本 v3.2.1', hero: 1, lead: '兜爸心选想做的事很简单:让你不用记录,也能留下记录。', groups: [ { t: '', rows: [ ['txt', '怀孕已经够辛苦了,我们不想再让你多填一张表。你每一次数胎动、称体重、看产检、读宝宝的来信,兜爸心选都会悄悄记下来,替你整理成一本档案。'], ['txt', '等宝宝出生、等他长大、等你某天忽然想起这段日子,这本档案都还在原地等你。'] ] } ], foot: '兜爸心选 v3.2.1 · 已陪伴 268 万位妈妈' } }; const ABOUT_HERO = '
' + r[1] + '
' + t.txt + '
' + p[1] + '
写 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) { /* 同时读 hash 和 query:浏览器直访(?openid=x)只有 query; 小程序 web-view 的授权回传在 hash(微信 webview 丢 query 不丢 hash)。 ⚠️ 先在原始串上匹配、再对捕获值 decode —— 先 decode 整串会把 %26 之类的编码提前还原成 &,破坏参数切分。 ⚠️ split('?'):历史 bug 会把缓存戳粘进 openid(abc001?t=123), 真 openid 不含 ?,剥掉以兼容已被污染的旧链接。 */ try { const h = (location.hash.slice(1) || '') + '&' + (location.search.slice(1) || ''); const m = h.match(new RegExp('(?:^|[])' + name + '=([^]*)')); return m ? decodeURIComponent(m[1]).split('?')[0] : ''; } catch (e) { return ''; } } let _profileApplied = false; function applyRemoteProfile() { if (_profileApplied) return; // init 只跑一次,防重复 fetch _profileApplied = true; const openid = hashParam('openid'); /* 访客态:宿主未注入身份(冷启动 silenceAuth 失败走「匿名加载 H5」兜底)。 ⚠️ 绝不能停在内置演示默认值上(小汤圆/孕27周/回信啦 都是前端现算的假数据)—— 全部切占位/引导;宿主拿到身份后会带 openid 重载本页,届时自动切真实数据。 */ if (!openid) { state.pregEmpty = true; state.due = null; state.week = null; state.day = null; state.babyName = ''; state.userName = ''; LETTERS.splice(0, LETTERS.length); state.curLetter = ''; state.homeLetter = ''; syncPregnancy(); /* 占位 + 隐藏脚印时间轴 + 信件引导态 */ syncLetterCounts(); /* hero 计数 '--'、未读角标隐藏 */ 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; /* 服务端是唯一事实源:新用户 babyName 为空也要覆盖内置演示名(小汤圆), 让全站称呼走 babyWord 的「宝宝」兜底 */ state.babyName = p.babyName || ''; 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; } /* 状态化「我的」页:未授权→占位+显示授权入口;已授权→资料+隐藏授权入口;已建档(B→C)由孕期信息卡引导 */ var _authorized = !!p.authorized || !!p.isAuthorized; var _n = $('#meName'); if (_n) _n.textContent = p.userName || '未登录'; if (p.avatar) { var _ai = $('#meAvatarImg'), _aw = $('#meAvatar'); if (_ai) _ai.src = p.avatar; if (_aw) _aw.classList.add('has-img'); } /* 授权入口:未授权时显示(引导先授权);退出按钮:已授权时显示 */ var _box = $('#meAuthBox'); if (_box) _box.style.display = _authorized ? 'none' : ''; var _lx = $('#meLogoutCard'); if (_lx) _lx.style.display = _authorized ? '' : 'none'; if (!p.userName) { var _ai2 = $('#meAvatarImg'); if (_ai2) _ai2.removeAttribute('src'); } } if (pg && pg.code === 200 && pg.result) { /* 新用户空态(未完善资料):后端不再给默认孕周 → 显示引导占位, 不读任何演示数据(详见 syncPregnancy 的 pregEmpty 分支)。 ⚠️ 同时清掉 state 的内置演示默认值(DEFAULT_DUE=今天+3个月/孕27周), 否则「我的资料」弹窗回填时又会把它们带回来。 */ if (pg.result.empty || pg.result.week == null) { state.pregEmpty = true; state.due = null; state.week = null; state.day = null; } else { state.pregEmpty = false; 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(); /* 新用户空态:syncPregnancy 已写完引导占位(含信件/称呼/静态位归零)。 ⚠️ 下面 stampLetters/filterLetters/syncStaticDates 全按 state.week 重算, 而 pregEmpty 时 state.week 还是内置默认 27 —— 不拦住的话, 刚写好的占位会被改回「孕 27 周 / 9月15日」演示值。 */ if (state.pregEmpty) { $('#tabBadge').classList.remove('show'); return; } 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) return; /* 新用户空态:后端成功返回空列表 → 清掉内置样例信,展示「这里还没有内容」。 ⚠️ 不能直接 return —— 那会把 LETTERS 留在内置样例上,新用户看到演示信。 */ if (!lj.result.items.length) { LETTERS.splice(0, LETTERS.length); state.curLetter = ''; state.homeLetter = ''; renderLetters(); syncLetterCounts(); pushLatestToHome(); 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, #meMallCard').forEach(function (b) { b.addEventListener('click', goMall); }); /* 「授权完善资料」:H5 → navigateTo → 小程序端独立授权页(pages/annex/auth/index)。 说明:微信 web-view 的 postMessage 非实时送达(只在分享/后退/onHide 等时机 才派发 bindmessage),点按钮时端上收不到 → 改用 wx.miniProgram.navigateTo 实时跳转到授权页,避开该限制。 */ function goAuth() { haptic(); /* 与 goMall 同一套环境判定:wx.miniProgram 存在还要 getEnv 确认(纯浏览器里 jweixin 也可能已加载,getEnv 不回调 → 800ms 超时兜底按非小程序提示) */ if (window.wx && window.wx.miniProgram) { var responded = false; window.wx.miniProgram.getEnv(function (r) { responded = true; if (r && r.miniprogram) { window.wx.miniProgram.navigateTo({ url: '/pages/annex/auth/index' }); } else { toast('请在小程序中完成授权登录'); } }); setTimeout(function () { if (!responded) toast('请在小程序中完成授权登录'); }, 800); } else { toast('请在小程序中完成授权登录'); } } var meAuth = $('#meAuthCard'); if (meAuth) meAuth.addEventListener('click', goAuth); /* 「退出登录」(个人资料卡右上角图标按钮): 调后端 /api/mp/public/auth/logout 只清「授权登录信息」三字段(昵称/头像/手机号), openid/unionid(小程序匿名身份)保留,孕育资料(小名/性别/预产期)保留。 成功后重载页面 → applyRemoteProfile 重新拉到空显示字段 → 回到未登录态、授权入口重现。 ⚠️ stopPropagation:me-card 整卡是 data-modal="modalProfile",不阻止冒泡 会顺带打开资料弹窗。 */ var meLogout = $('#meLogoutCard'); if (meLogout) meLogout.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); haptic(); var openid = hashParam('openid'); if (!openid) { toast('未获得身份,无法退出'); return; } fetch('/api/mp/public/auth/logout?openid=' + encodeURIComponent(openid), { method: 'POST' }) .then(function (r) { return r.json(); }) .then(function (res) { if (res.code === 200) { toast('已退出登录'); setTimeout(bustReload, 600); } else { toast(res.message || '退出失败,请重试'); } }) .catch(function () { toast('退出失败,请检查网络'); }); }); /* 从商城/后台切回小程序首页时,webview 会再次可见 —— 把 H5 重置回「首页」屏。 ⚠️ 若不处理:切到商城再点「首页」tab,仍是同一个 webview 实例, SPA 还停留在切走前的屏(如「我的」),与"回到首页"的预期不符。 只在"当前不是首页"时才切,避免打断用户在首页上的浏览/滚动。 */ function resetScreenOnShow() { var cur = document.querySelector('.screen.is-active'); if (cur && cur.dataset.screen && cur.dataset.screen !== 'home') go('home'); } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') resetScreenOnShow(); }); window.addEventListener('pageshow', function () { if (document.visibilityState !== 'hidden') resetScreenOnShow(); }); if (document.readyState === 'complete') init(); else window.addEventListener('load', init); })();