mama-box 9.19 需求: 资料弹窗去兑换码闸门、底tab改回宝宝来信、我的页卡片改兜爸心选商城 (v26092001)

资料详情页(需求 1.1/1.2):
- #modalWx 删掉兑换码输入框与「以后再说」「解锁全部资料」两颗按钮,is-gate/is-gift/is-done
  三模式合并成单态(唯一出口是右上角 .modal-x);二维码下方文案改需求指定句
- 需求方确认「扫码加顾问 → 回复领资料 → 顾问直接发资料」,故「解锁」概念整体取消

死代码清理(D1 的连带):
- 删 PAN/PAN_DEFAULT 网盘链接表、panOf、WX_CODE/wxCodeOK、givePan、#modalPan 弹窗、
  state.wxUnlocked/WXOK_KEY、syncUnlockCopy、fetchRemoteConfig
- 删「已领」整套:state.resGot/RESGOT_KEY/isGot/列表「已领 ✓」标与筛选 chip/详情页「再拿一次链接」

底 tab(需求 2,D2=C):
- 「兜爸心选」→「宝宝来信」,图标换回 ic-letter,点击改走 jumpApp 跳第三方 H5
  mp.yizhezhe.com/laixin/(App 表 identifier=laixin,取不到时兜底硬编码)
- 红点 #tabBadge 挪回该 tab;角标数据源改为 box_app 封装的 /laixin/unread
  (syncLaixinBadge,ok=false 时保持现状,不把红点抹掉)

其它:
- 我的页卡片「兜爸心选」→「兜爸心选商城」(需求 3)
- 内置来信屏 #screen-letter 与 box_app /letters 链路保留不动,但不再有底 tab 入口
This commit is contained in:
2026-09-20 01:36:06 +08:00
parent d39e52cf4f
commit aa53d5c6e6
3 changed files with 238 additions and 559 deletions

528
js/app.js
View File

@@ -3,7 +3,7 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26091901';
var VER = '26092001';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -55,7 +55,6 @@
resCat: 'all',
resKey: 'book',
resFav: [], // 已收藏的资源 key,数组顺序即收藏顺序
resGot: [], // 已领取的资源 key(点过「立即领取」的)
babyName: '小汤圆',
/* 这三项一进门就是真值(默认预产期 = 今天 +3 个月,见上面 DEFAULT_DUE)。
以前它们是写死的占位 24/3/null,等 init() 才补 —— 但信件日期是按孕周
@@ -66,7 +65,7 @@
/* 来信列表左边那颗宝宝头像。空 = 还没设,显示占位的「宝」字;
设了就是一段 data URI(点头像选图后就地读出来的,没有后端)。
⚠️ 和 userName / babyName 一样**不写 localStorage** —— 现状只持久化
resFav/resGot/wxUnlocked/pending,刷新会回到「宝」字。
resFav,刷新会回到「宝」字。
这是已知现状、本批没扩范围,不是漏做。
⚠️ 更要紧的是:data URI 动辄几百 KB,而 localStorage 通常只有 5MB。
哪天真要持久化它,得先压缩(画到 canvas 缩到 ~128px 再 toDataURL),
@@ -77,16 +76,19 @@
/* ⚠️ 这里原来还有一条 userBio(一句话签名)。整块已按用户要求删除,
连同 #pfBio 输入框、#meBio 展示行、syncBio()。别只加回这一条 ——
一个没人读的 state 字段是最难发现的死代码。 */
wxUnlocked: false // 是否已用兑换码解锁资料领取(见下面 WXOK_KEY)
/* ⚠️ 这里原来还有一条 wxUnlocked(兑换码解锁态)。2026-09-19(9.19 需求)
「资料改由顾问直接发」之后解锁概念整体取消,它连同
WXOK_KEY / saveWxUnlocked / syncUnlockCopy 一起删了,别再加回来。 */
};
/* ---------- 资源的收藏 / 已领(都跨会话保留) ----------
/* ---------- 资源的收藏(跨会话保留) ----------
只存 key,资源本身仍从 RES 读,改文案不会让旧记录失效。
「已领」原来只是详情页那个按钮上的一个 class,退出去再进来就没了 ——
用户根本记不住自己领过哪几份,回到列表页只能一个个点进去看。
现在和收藏一样落盘,列表页直接打「已领」标。 */
⚠️ 原来还有一整套「已领」:state.resGot / RESGOT_KEY / isGot() / 列表页的
「已领 ✓」标与筛选 chip / 详情页的「再拿一次链接」。9.19 需求取消了
「点立即领取就到手」这个动作(资料由顾问在微信里直接发),H5 侧不再记录
「领过哪几份」,整块连同 localStorage 'mmbox.resgot.v1' 一起删除。
要恢复得先回问需求方。 */
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');
@@ -94,25 +96,9 @@
} 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');
@@ -1310,20 +1296,22 @@
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 封"这种演示数字),无未读角标 */
/* 未完善资料: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」 */
/* 全读完了就把数字标灰,别挂一个孤零零的「0」 */
$('#unreadNum').classList.toggle('zero', state.unread === 0);
$('#tabBadge').classList.toggle('show', state.unread > 0);
/* ⚠️ 这里原来还会开关底 tab 的 #tabBadge。2026-09-19 起那枚 tab 是「宝宝来信」
(跳第三方 H5),角标口径必须跟它一致 —— owner 是 laixin 的 /laixin/unread
(见 syncLaixinBadge)。本地内置来信屏的未读数与它不是同一套,别在这里写 #tabBadge,
两处都写会互相覆盖。 */
}
/* 宝宝的新回信同时推到首页头部那张「宝宝来信」卡上。
@@ -2567,61 +2555,29 @@
};
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;
/* ⚠️ 2026-09-19(9.19 需求 1.2):这里原来有 PAN_DEFAULT / PAN 两张**百度网盘
链接表**、panOf()、以及企业微信兑换码 WX_CODE / wxCodeOK()。
需求方确认「扫码加顾问 → 回复领资料 → 顾问直接发资料」之后,H5 不再发放网盘
链接,这套(连同 #modalPan 弹窗、givePan()、后台下发的 pan/panDefault/wxCode)
整块删除。要恢复「H5 自己发链接」,等于把「解锁」概念加回来,先回问需求方。 */
/* 加企业微信之后顾问发的兑换码。和 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;
const isFav = k => state.resFav.indexOf(k) >= 0;
function resCardHTML(k) {
const r = RES[k];
const got = isGot(k);
/* 领过的那一份,「免费」标换成「已领 ✓」并给整张卡加 .is-got:
「免费」是招揽的话,对已经领到手的人已经没意义了,占着同一个位置正好。
缩略图打勾角标是给快速扫列表用的 —— 一眼扫过去哪几份领过,不用逐行读标签。 */
return '<button class="res-item ripple' + (got ? ' is-got' : '') + '"' +
/* ⚠️ 这里原来还有「领过的那一份」分支:整张卡加 .is-got、缩略图右上角打 ✓、
底行的「免费」标换成「已领 ✓」。9.19 需求取消「已领」概念后一并删除,
现在每张卡都是同一个样子(免费)。 */
return '<button class="res-item ripple"' +
' data-open="detailRes" data-res="' + k + '">' +
'<span class="ri-thumb ' + r.tint + '"><img src="' + r.img + '" alt="">' +
(got ? '<em class="ri-got-tick" aria-hidden="true">✓</em>' : '') + '</span>' +
'<span class="ri-thumb ' + r.tint + '"><img src="' + r.img + '" alt=""></span>' +
'<span class="ri-body"><span class="ri-title">' + r.title + '</span>' +
'<span class="ri-desc">' + r.desc + '</span>' +
/* 底部这行的顺序换过一次:
原来是「免费 / 分类 …… 8 分钟读完」。两处毛病 ——
「几分钟读完」对着一个网盘资料包说不通(音乐那份写的是「30 首 · 共 96 分钟」,
压根不是一回事),换成 meta[2] 的适用孕周:在孕育资源库里,
「这份现在适不适合我」比「要读多久」有用得多;
状态标(免费 / 已领 ✓)顶到最右,整列对齐,
扫列表时哪几份领过一眼就出来,不用逐行读到第一格。 */
/* 底部这行:分类 + 适用孕周 + 右侧状态标。
⚠️ 状态标现在恒为「免费」(原来领过的会换成「已领 ✓」,已随「已领」概念删除)。 */
'<span class="ri-row">' +
'<em class="ri-cat">' + r.cat + '</em>' +
'<em class="ri-ga">' + r.meta[2] + '</em>' +
(got ? '<em class="pill-got">已领 ✓</em>' : '<em class="pill-free">免费</em>') +
'<em class="pill-free">免费</em>' +
'</span></span></button>';
}
function bindResCards(root) {
@@ -2633,32 +2589,24 @@
/* 收藏不是分类而是筛选,默认排在最末。但只要用户真收藏过东西,
它就成了最常用的一格 —— 排在末尾要横向滑到头才够得着,所以提到「全部」后面。
用 insertBefore/appendChild 挪节点(不是重建 innerHTML),init 时绑的点击事件才不会丢。
已经在第二位时 children[1] 就是 favChip 自己,insertBefore 原地不动,无副作用。 */
已经在第二位时 children[1] 就是 favChip 自己,insertBefore 原地不动,无副作用。
⚠️ 原来还有个 .chip-got(✓ 已领)跟着一起挪,已随「已领」概念删除。 */
const chipsRow = $('#resChips'), favChip = $('.chip-fav', chipsRow);
const gotChip = $('.chip-got', chipsRow);
const favN = state.resFav.length, gotN = state.resGot.length;
/* 「已领」排在「收藏」前面:领过的东西比收藏的更常回来找(要密码、要链接),
两个都有内容时它更靠手。所以先插收藏、再插已领,已领就压在第二位。 */
const favN = state.resFav.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)
/* fav 不是分类,是「只看收藏过的」,所以单走一支 */
const fav = state.resCat === 'fav';
const keys = fav ? RES_ORDER.filter(isFav)
: 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 + ' 份资源 · 更多内容按孕周陆续解锁')
: '共 ' + keys.length + ' 份资源 · 更多内容按孕周陆续更新')
: (fav ? '还没有收藏 · 打开任意资源,点右上角 ☆ 就能收进来'
: got ? '还没领过 · 打开任意资源,点底下「立即领取」就会记在这里'
: '该分类下暂无资源,换一个试试~');
$$('#resChips .chip').forEach(c =>
c.classList.toggle('is-active', c.dataset.rescat === state.resCat));
@@ -2684,18 +2632,14 @@
bindResCards($('#rdMore'));
/* 原来这里无条件重置成 ☆,收藏过的资源再打开会显示成没收藏。
改成按 state.resFav 回显 */
const isFav = state.resFav.indexOf(state.resKey) >= 0;
const fav = 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);
star.classList.toggle('on', fav);
star.textContent = fav ? '★' : '☆';
/* 底部左键已从「收藏」改成「资源库」,是个固定文案的导航按钮,不再需要回显。
⚠️ 右键「立即领取」原来会按 state.resGot 回显成「再拿一次链接」,
9.19 需求取消「已领」概念后改回**固定文案**(点它只是弹顾问二维码)。 */
$('#rdGet').textContent = '立即领取';
$('#detailRes .detail-body').scrollTop = 0;
}
$('#resStar').addEventListener('click', function () {
@@ -2732,236 +2676,40 @@
}
});
/* 真正把一份资源交出去:填网盘弹窗 → 记进已领 → 刷列表和按钮 → 弹出来。
从事件处理器里整个抽出来,是因为「用兑换码解锁成功」之后要原地做完全
一样的事。抄一份过去当然更快,但那两份迟早会分叉:哪天改了领取行为
(比如加个埋点、换个 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();
}
/* 点「立即领取」= 打开企业微信顾问二维码。
⚠️ 2026-09-19(9.19 需求 1.2):这里原来是「兑换码闸门」—— 未解锁的用户先被拦下,
填码解锁之后 H5 才把网盘链接交给他。需求方确认「扫码加顾问 → 回复「领资料」 →
顾问直接发资料」之后,闸门、兑换码、网盘弹窗整条链路都删了(连同 givePan()、
state.wxUnlocked、wxPending),这颗按钮现在只做一件事:弹二维码。
不存在「已解锁 / 未解锁」两种起点,所以这里也没有任何状态判断 —— 别再加回来。 */
$('#rdGet').addEventListener('click', () => { openWx(); });
/* 点「立即领取」不再假装存进书架,而是把网盘链接和提取码给出来。
未解锁的用户先撞上企业微信二维码 —— 这是这一版加的那道闸门。 */
$('#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 落地,那就是一条
现成的注入路径,而且是在「这句话看起来只是文案」的伪装下。
这里按纯文本切三段、中间那段包一个 <em>,拼进来什么都只会是字。
⚠️ 找不到那四个字时**不能**当异常处理: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('');
/* ================= 企业微信顾问弹窗(单态) =================
三个入口共用这一个壳:「我的」页服务卡 #meWxCard、资源库顶部试用装 banner #rlGift、
资料详情页「立即领取」#rdGet。三者说同一句话,openWx() 只有一个参数都没有。
⚠️ 弹窗里**没有任何按钮**,唯一出口是右上角 .modal-x(外加遮罩 / Esc)——
删按钮之前确认过它在;别做「头部无按钮时隐藏」这种优化。
⚠️ 这里原来有 openWx(mode, key) + is-gate/is-gift/is-done 三模式类名 + wxPending +
setLead() 高亮 + 兑换码校验 + #wxOk / #wxCode 监听,全部随闸门删除。
别再引入「按入口改写文案」的分支:三个入口同一件事,分叉只会让以后改文案的人漏一条。 */
const wxModal = $('#modalWx');
function openWx() {
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(); });
$('#meWxCard').addEventListener('click', () => openWx());
/* 资源库顶部那条 banner 和「我的」页服务卡是同一件事、同一个入口。
⚠️ 走 openWx() 而不是挂 data-open="modalWx":那条通用通道直接调 openModal,
不经过这里 —— 今天两者等价,但留一个入口,以后要统一加埋点/清理只改一处。 */
$('#rlGift').addEventListener('click', () => openWx());
$('#wxOk').addEventListener('click', () => {
/* ⚠️ 只有闸门模式才受理。别删,也别当成「CSS 都 display:none 了,多余」:
- jsdom 实测:display:none 的按钮 dispatchEvent 照样触发监听;
- 真机上今天确实点不到,但**被触发的后果比意外解锁更难看** ——
「兑换码不对」那行红字会冒在一个根本没有输入框的弹窗上,
用户看着一个无处可填的界面被指责填错了;
- 两条看起来无害的改动会立刻把它变成活 bug:把上面那条 display:none
换成 visibility:hidden(视觉一模一样,但仍可聚焦、仍能回车),
或者给弹窗套一层 <form>(回车默认提交)。
读 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 份资源」是句谎话 —— 用户读完这句去点领取,
撞上的是一张二维码。所以锁着的时候换个说法,并且把「首次领取要加顾问」
提前说清楚,别等人点下去才发现。
⚠️ 首页那句只改 <span id="shelfLeadTxt"> 这一层,绝不能对整个 <p> 写
innerHTML:里面还嵌着「查看全部 ›」那个 <a data-open>,它的点击监听是
init 时 $$('[data-open]') 一次性绑上去的。整段重写会把节点连同监听一起
换掉,而且是**静默**的 —— 链接看着还是品牌色,就是点不动,控制台也不报错。 */
function syncUnlockCopy() {
$('#shelfLeadTxt').innerHTML = state.wxUnlocked
? '已为你解锁 <b>128</b> 份资源'
: '<b>128</b> 份孕育资料等你来领';
$('#rlLead').innerHTML = state.wxUnlocked
? '已为你解锁 <b>128</b> 份资源 · 按孕周持续更新'
: '<b>128</b> 份资源按孕周持续更新 · 首次领取需添加顾问微信';
/* 解锁过的人不再看到试用装 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();
});
/* ⚠️ 2026-09-19(9.19 需求):这里原来有一个 syncUnlockCopy(),按「已解锁 / 未解锁」
把首页书架与资源库的两句引导语改成两个版本,并给资源库顶部试用装 banner 挂 .hide。
兑换码闸门取消后不存在解锁态:
· 两句文案改为 index.html 里的**静态文案**(#shelfLeadTxt / #rlLead 的 id 也删了,
顺带解掉「给 <p> 写 innerHTML 会静默换掉「查看全部 ›」的监听」那个坑);
· banner 恒显示,`.hide` 那套不再需要。
别把这个函数加回来 —— 它唯一的存在理由就是「两种解锁状态」。 */
/* ================= 档案生成:周档案 / 完整档案 =================
产物必须是一张图(要能存相册、发朋友圈、拿去打印),所以直接用 canvas
@@ -4159,12 +3907,12 @@
进门先按真实 state 刷一遍,免得首屏闪一下旧数字 */
syncPregnancy();
/* ⚠️ 这里原来还有一次 syncBio()。一句话签名整块已删(见 state 里那段说明)。 */
/* 解锁状态是从 localStorage 读回来的,标记里写的是「没解锁」那一版。
老用户(上次已经输过码)不刷这一下,会在首页看到「还没领」的说法,
点进去却畅通无阻 —— 文案和实际能力对不上。 */
syncUnlockCopy();
/* ⚠️ 这里原来还有一次 syncUnlockCopy()(按解锁态改写首页/资源库两句引导语)。
闸门取消后文案是静态的,函数已删。 */
animateRings();
$('#tabBadge').classList.toggle('show', state.unread > 0);
/* ⚠️ 角标 #tabBadge 的 owner 是 laixin 未读数(syncLaixinBadge),不是本地信件数。
这里曾经用 state.unread 初始它,现已删除 —— 两处都写会互相覆盖。
角标在 syncLaixinBadge() 拉到数之前保持隐藏。 */
/* 来信页的默认标签:永远落在「全部」。
曾经改成过「有未读就落在未读」,但那样一进页面就看不到全部信件,
未读的红点本来就在提示了,不需要再替人把列表筛一遍。
@@ -4190,39 +3938,17 @@
// 第四批远程数据(今日脚印,来自数胎动)—— 渲染首页 .today-line 时间轴,
// 拉不到/未关联都各有回退,见 applyRemoteFootprint 内的分支说明
applyRemoteFootprint();
// 底 tab「宝宝来信」未读角标(box_app 代理第三方 laixin 未读数)
syncLaixinBadge();
}
/* ---------- 远程配置(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 后台下发):已下线 ----------
⚠️ 2026-09-19(9.19 需求 1.2):这里原来拉 /api/mp/public/mama-box/config,
用后台下发的 pan / panDefault / wxCode 覆盖页面里的网盘链接表与兑换码。
H5 不再发放网盘链接(资料由顾问在微信里直接发)之后,配置里已经没有一项是
H5 还读的,整个 fetchRemoteConfig() 删除。
⚠️ 后端那个接口与 MamaBoxConfig 模型(pan / panDefault / wxCode)暂时**保留未删** ——
它们还有管理端读写、以及 /resources/{key}/claim 那条老接口在用;
等确认没有任何消费者之后再一起清理(见 docs/mama-box-change-list-20260919.md 的 T1b)。 */
/* ---------- 远程个人档案(box_app 下发,openid 单一身份键) ----------
小程序首页把 openid 经 #hash 传给本页(微信 web-view 丢 query,hash 不丢),
@@ -4261,7 +3987,7 @@
LETTERS.splice(0, LETTERS.length);
state.curLetter = ''; state.homeLetter = '';
syncPregnancy(); /* 占位 + 隐藏脚印时间轴 + 信件引导态 */
syncLetterCounts(); /* hero 计数 '--'、未读角标隐藏 */
syncLetterCounts(); /* hero 计数切 '--'(角标 #tabBadge 已不由本地信件数驱动) */
return;
}
const base = '/api/mp/public/mama-box/';
@@ -4318,13 +4044,11 @@
⚠️ 下面 stampLetters/filterLetters/syncStaticDates 全按 state.week 重算,
而 pregEmpty 时 state.week 还是内置默认 27 —— 不拦住的话,
刚写好的占位会被改回「孕 27 周 / 9月15日」演示值。 */
if (state.pregEmpty) { $('#tabBadge').classList.remove('show'); return; }
if (state.pregEmpty) { return; }
syncBabyWords();
stampLetters();
filterLetters(state.letterFilter);
syncUnlockCopy();
syncStaticDates();
if (state.unread > 0) $('#tabBadge').classList.toggle('show', true);
}).catch(function () { /* 静默回退内置值 */ });
}
@@ -4345,11 +4069,12 @@
body: old.body || [],
};
order.push(it.key);
RES[it.key].fav = !!it.fav; RES[it.key].got = !!it.got;
RES[it.key].fav = !!it.fav;
/* ⚠️ 接口还返回 got(已领),但 H5 侧已无「已领」概念(9.19 需求),
这里**有意不读** —— 别顺手把它加回 RES/state。 */
});
// 同步收藏/已领状态(对齐后端)
// 同步收藏状态(对齐后端)。已领(resGot)已删除,见文件顶部 state 那段说明。
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) {
@@ -4557,13 +4282,68 @@
toast('请在微信小程序中打开商城');
}
}
/* 第九批:底部 tab「兜爸心选」(#tabMall)也走同一个 goMall()。
它**不挂 data-tab**,因此不在上面的 tabs 集合里、不参与 go() 高亮切换;
点击只做「跳商城」这一件事。落点与「我的」页「进入商城」完全一致。 */
$$('#btnMall, #meMallCard, #tabMall').forEach(function (b) {
/* 商城入口:「我的」页服务卡与首页商城主卡。
⚠️ 2026-09-19:底 tab #tabMall 已经改回「宝宝来信」(见下面的 #tabLetter 绑定),
不再走 goMall —— 所以这里只留两颗真正的商城入口。 */
$$('#btnMall, #meMallCard').forEach(function (b) {
b.addEventListener('click', goMall);
});
/* ---------- 宝宝来信(第三方 H5)入口 ----------
底 tab「宝宝来信」(#tabLetter) → 跳 mp.yizhezhe.com/laixin/。
⚠️ 2026-09-19(9.19 需求第 2 条,D2=C):这枚 tab 原来跳的是小程序原生商城页
(goMall),现在改跳**第三方宝宝来信 H5**。落点不是硬编码,而是走 App 表:
用户在应用后台新增了「宝宝来信」应用(identifier=laixin,URL 由
/api/mp/apps 下发),所以这里复用 jumpApp() —— 与宫格 / 工具卡跳其它 H5 应用
完全一致(location.href 同页跳转 + query 带 openid/unionid + 上报分享地址)。
⚠️ 拿不到 App 表记录时的兜底写死业务域名入口:mp.yizhezhe.com/laixin/(nginx 反代
到 taidong.douba.top/laixin/)。两边都没有就只能提示,别静默什么都不做。
⚠️ 这枚 tab **不挂 data-tab**,所以它不在 tabs 集合里、不参与 go() 高亮切换 ——
跳过去就离开本页了,没有高亮态可言。 */
const LAIXIN_FALLBACK_URL = 'https://mp.yizhezhe.com/laixin/';
function openLetterApp() {
const app = APP_BY_ID['laixin'];
const url = (app && app.url) ? app.url : LAIXIN_FALLBACK_URL;
if (!url) { toast('「宝宝来信」即将上线'); return; }
jumpApp(url);
}
$('#tabLetter').addEventListener('click', openLetterApp);
/* ---------- 底 tab 角标:宝宝来信未读数 ----------
数据源:box_app 封装的联动接口 GET /api/mp/public/mama-box/laixin/unread
(box_app 后端再去调第三方 https://taidong.douba.top/laixin/api/laixin/unread)。
⚠️ 为什么不直连 taidong:那台的 CORS 白名单只放了自己的域名,页面直连会被浏览器拦;
而且「mama-box ↔ laixin 的联动接口由 box_app 封装」是本项目的既定边界
(见 docs/mama-box-change-list-20260919.md §1.5)。
⚠️ 口径:#tabBadge 的 owner **只有这一个函数**。别在 syncLetterCounts() 或
applyRemoteProfile() 里再写它 —— 内置来信屏的未读数(box_app /letters)
与 laixin 的未读数是两套数,两处都写会互相覆盖(谁后跑谁说了算)。
⚠️ 容错:接口返回 ok=false(上游抖动)时**保持现状** —— 既不把已经点亮的红点抹掉,
也不因为一次失败就冒出一个红点。角标不是关键路径,失败一律静默。 */
function syncLaixinBadge() {
const badge = $('#tabBadge');
if (!badge) return;
const openid = hashParam('openid');
/* 接口按 openid 查,没有身份就别问(浏览器直开调试时常见)——此时不显示角标 */
if (!openid) { badge.classList.remove('show'); return; }
const unionid = hashParam('unionid');
const qs = 'openid=' + encodeURIComponent(openid) +
(unionid ? '&unionid=' + encodeURIComponent(unionid) : '');
fetch('/api/mp/public/mama-box/laixin/unread?' + qs)
.then(r => (r.ok ? r.json() : null))
.then(j => {
const r = j && j.result;
if (!r || r.ok === false) return; // 降级:保持现状
badge.classList.toggle('show', Number(r.unread) > 0);
})
.catch(() => { /* 静默 */ });
}
/* 从「宝宝来信」H5 返回、或小程序 onShow 把 web-view 唤回前台时刷一次
(上游未读数是「在来信页读完就归零」,所以回来这一下必须重新拉)。 */
document.addEventListener('visibilitychange', function () {
if (!document.hidden) syncLaixinBadge();
});
/* 「授权完善资料」:H5 → navigateTo → 小程序端独立授权页(pages/annex/auth/index)。
说明:微信 web-view 的 postMessage 非实时送达(只在分享/后退/onHide 等时机
才派发 bindmessage),点按钮时端上收不到 → 改用 wx.miniProgram.navigateTo