fix(首页): 来信卡数据源改为 laixin,与底 tab 红点统一(修两套数据不一致)(v26092004)

问题:底 tab 早已改跳 laixin、红点读 /api/mp/public/laixin/unread,但首页那张
「宝宝来信」卡仍由 pushLatestToHome() 按 LETTERS(box_app /mama-box/letters)驱动,
而那条线对多数用户是 0 封 —— 同一屏上会出现「红点说有新信、卡里什么都没有」。

改法(两者共用一个 laixin 未读结果):
- 新增 hideLetterCard() + applyLaixinReminder(r):unread>0 才显示,文案按
  「N 封新信 / M 条新回信」组装并附「最近一封 <时间>」;归零收起;上游降级(ok:false)、
  访客态、未建档一律收起(与角标同一套判据)
- syncLaixinBadge() → syncLaixinReminder():一次 fetch 同时落红点与首页卡(owner 唯一)
- 删除 pushLatestToHome() / lettersResolved / state.homeLetter / openDetail():
  内置来信屏(#screen-letter)与其 /letters 数据仍在,但不再往首页推卡
- 首页卡点击改走 openLetterApp()(跳 laixin),与底 tab 同落点;卡不再打开内置详情
- 卡仍默认 .hide + #homeBody 默认 .no-letter(保住 §9 的不闪规则)

验证(本地四态 mock + 浏览器):
- laixin 未读=2 → 接口回来前 hidden,回来后显示「小汤圆来信 / 有 1 封新信、1 条新回信 ·
  最近一封 今天 9:00」,红点同亮(同一次请求)
- 未读=0 → 全程收起;上游 ok:false → 冷启动收起、已显示则保持显示
- 访客(无 openid)→ 收起
- **box_app 来信有 1 封未读但 laixin 未读=0 → 首页卡仍收起**(内置屏照常渲染,互不影响)
- 点卡 → https://mp.yizhezhe.com/laixin/?openid=…&unionid=…;内置列表点开详情仍正常;errors 空
This commit is contained in:
2026-09-20 02:31:19 +08:00
parent e29052f107
commit ee04a26ba3
2 changed files with 94 additions and 131 deletions

194
js/app.js
View File

@@ -3,7 +3,7 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26092003';
var VER = '26092004';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -255,9 +255,9 @@
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);
/* ⚠️ 原来这里有个 letter-detail 分支(首页来信卡 → 内置来信详情)。
2026-09-20 卡改跳 laixin 之后没人再用它,连同 openDetail() 一起删除。 */
go(b.dataset.goto);
};
b.addEventListener('click', fire);
/* 真 <button> 上敲回车/空格,浏览器会自动派一个 click;<div role="button"> 不会。
@@ -774,7 +774,6 @@
stampLetters();
state.curLetter = LETTERS[0].id;
state.homeLetter = LETTERS[0].id;
/* ---------- 延迟投递队列(3~5 小时) ---------- */
/* v1 只存了排队记录,没存产生排队的那封回信 —— 一半存一半不存。
@@ -877,7 +876,6 @@
saveStore();
renderLetters();
syncLetterCounts();
pushLatestToHome();
toast('宝宝给你回信啦 💌');
}
function deliverBabyReply(p) {
@@ -1310,87 +1308,52 @@
$('#unreadNum').classList.toggle('zero', state.unread === 0);
/* ⚠️ 这里原来还会开关底 tab 的 #tabBadge。2026-09-19 起那枚 tab 是「宝宝来信」
(跳第三方 H5),角标口径必须跟它一致 —— owner 是 laixin 的 /laixin/unread
(见 syncLaixinBadge)。本地内置来信屏的未读数与它不是同一套,别在这里写 #tabBadge,
(见 syncLaixinReminder)。本地内置来信屏的未读数与它不是同一套,别在这里写 #tabBadge,
两处都写会互相覆盖。 */
}
/* 宝宝的新回信同时推到首页头部那张「宝宝来信」卡上。
卡是「有信才在」:既没有未读的信、也没有没看过的新回信时,整张收起来 ——
全读完了还硬顶一封旧信在首页正中间,是在假装有新消息。
收起来会空出 64px 的流高(卡是 margin-top:-16 + height:80),
所以同时给 .home-body 挂 .no-letter 把下面的标题往下推一点,
免得「孕期必用工具」直接贴到橙色头图底边上。
⚠️⚠️ 2026-09-20:卡的显隐**要等 /letters 回来才算数**(lettersResolved)。
之前的写法是「先把内置样例/本地队列拼出来的卡显示出来,数据回来再决定」——
结果商城↔首页来回切时会闪一下:先冒出一张(甚至写着「宝宝回信啦」)的卡,
几百毫秒后数据说没有未读,又缩回去。现在反过来:
· index.html 里 #homeLetter 默认带 .hide、#homeBody 默认带 .no-letter;
· 没确定(!lettersResolved)→ 一律保持收起;
· 确定之后才可能显示,且只有在真有未读/新回信时才显示。
代价是「有信」的用户看到卡出现在数据回来那一刻(晚一点点,但不闪)。 */
let lettersResolved = false; // /letters 已经问过结果(含失败/访客态)—— 见 applyRemoteLetters
/* 首页那张「宝宝来信」卡:**数据源是 laixin(第三方宝宝来信应用)**,不是本页的 LETTERS。
⚠️⚠️ 2026-09-20 —— 理顺一处数据源不一致:
底 tab 早已改跳 laixin、角标读 /api/mp/public/laixin/unread,
但这张卡原来由 pushLatestToHome() 按 LETTERS(box_app 的 /mama-box/letters)驱动,
而那条线对绝大多数用户是 0 封 —— 于是同一屏上「红点说有新信、卡里什么都没有」。
现在两者**共用同一个 laixin 未读结果**:
· 唯一 owner:syncLaixinReminder()(拉数)→ applyLaixinReminder()(落卡 + 落红点)
· 判据:unread > 0 才显示;归零则收起;上游降级(ok:false)保持现状
· 点击:跳 laixin(与底 tab 同落点),**不再**打开内置来信详情
⚠️ 卡里只有数字与时间(接口就给这些),所以文案是**通用提醒**,不做单封预览。
将来第三方上 SSE 的 event: letter 之后,才有标题/摘要可显示
(见 docs/laixin-sse-requirement.md)。
⚠️ 内置来信屏 #screen-letter 与 LETTERS 仍在(左右滑动还能进),
只是不再往首页推卡 —— 别在这里把 LETTERS 那套再接回来。 */
function hideLetterCard() {
const card = $('#homeLetter'), body = $('#homeBody');
if (card) { card.classList.add('hide'); card.classList.remove('has-new'); }
if (body) body.classList.add('no-letter');
}
function pushLatestToHome() {
const card = $('#homeLetter');
const body = $('#homeBody');
/* 还没问出结果、访客态(没身份)、或后端明确说该用户没建档 —— 都别显示。
⚠️ hashParam 是函数声明(在文件靠后定义),这里调用靠提升,没问题。 */
if (!lettersResolved || !hashParam('openid') || state.pregEmpty) { hideLetterCard(); return; }
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 **继续留着**,不能因为这次退回就顺手删掉。
再想加长这个后缀之前,先回来看这笔账。 */
/* laixin 未读结果 → 首页卡。badge 由 syncLaixinReminder() 一并处理。 */
function applyLaixinReminder(r) {
const card = $('#homeLetter'), body = $('#homeBody');
if (!card) return;
/* 访客态(没身份)、后端说他没建档、上游降级、未读为 0 —— 一律收起。
⚠️ 与底 tab 角标同一套判据,两处别各写一份。 */
const has = r && r.ok !== false && Number(r.unread) > 0;
if (!has || !hashParam('openid') || state.pregEmpty) { hideLetterCard(); return; }
const letters = Number(r.unreadLetters) || 0;
const replies = Number(r.unreadReplies) || 0;
/* 两种都有时分开讲,别合成一个含糊的总数(用户不知道「3」是什么东西) */
let desc = (letters && replies) ? '有 ' + letters + ' 封新信、' + replies + ' 条新回信'
: replies ? '宝宝回了你 ' + replies + ' 条话'
: '宝宝给你写了 ' + letters + ' 封信';
const at = r.latestLetterAt ? Date.parse(r.latestLetterAt) : NaN;
if (!isNaN(at)) desc += ' · 最近一封 ' + fmtWhen(at);
$('#lcTitle').textContent = babyWord('来信');
$('#lcDesc').textContent = ellip(top.preview); /* 首页这张卡也是摘要,同样收省略号 */
$('#lcDesc').textContent = desc;
$('#lcLink').textContent = '读读' + babyWord('的话');
state.homeLetter = top.id;
card.classList.add('has-new');
show();
card.classList.remove('hide');
if (body) body.classList.remove('no-letter');
}
/* ---------- 详情页 ---------- */
@@ -1402,10 +1365,8 @@
renderLetterDetail(L);
renderLetters();
syncLetterCounts();
pushLatestToHome();
openPanel('detailLetter');
}
function openDetail() { openLetter(state.homeLetter); }
/* ---------- 把一句话拆成一个个歪歪扭扭的小方块 ----------
光靠 font-family 只在装了翩翩体的 iOS/macOS 上才歪;
@@ -1612,7 +1573,8 @@
deliverDue();
renderLetters();
syncLetterCounts();
pushLatestToHome();
/* ⚠️ 这里原来有一次 pushLatestToHome():把内置样例信推上首页卡。
卡现在归 laixin(默认收起、有未读才显示),这一推会先冒出一张假卡再被收起 —— 删掉。 */
renderLetterDetail(LETTERS[0]); // 详情页先垫上最新那封,免得首次打开有一帧空白
/* 30 秒一跳。间隔取得宽松,是因为这里的等待本来就以小时计 */
setInterval(deliverDue, 30000);
@@ -1967,7 +1929,7 @@
const _t = new Date();
$('#noteWhen').textContent = mdTxt(_t);
syncBabyWords();
pushLatestToHome();
hideLetterCard();
renderLetters();
renderLetterDetail(curLetter());
return;
@@ -2055,7 +2017,8 @@
$('#replyTo').textContent = name;
syncBabyWords();
stampLetters();
pushLatestToHome();
/* ⚠️ 原来这里有一次 pushLatestToHome():小名/孕周改动要跟着刷新首页那张卡。
卡现在归 laixin(内容与 LETTERS 无关),这一推已无意义,删掉。 */
renderLetters();
renderLetterDetail(curLetter());
@@ -3930,9 +3893,9 @@
/* ⚠️ 这里原来还有一次 syncUnlockCopy()(按解锁态改写首页/资源库两句引导语)。
闸门取消后文案是静态的,函数已删。 */
animateRings();
/* ⚠️ 角标 #tabBadge 的 owner 是 laixin 未读数(syncLaixinBadge),不是本地信件数。
这里曾经用 state.unread 初始它,现已删除 —— 两处都写会互相覆盖。
角标在 syncLaixinBadge() 拉到数之前保持隐藏。 */
/* ⚠️ 底 tab 红点与首页「宝宝来信」卡的 owner 都是 laixin 未读数(syncLaixinReminder),
不是本地信件数。这里曾经用 state.unread 初始角标,现已删除 —— 两处都写会互相覆盖。
两者在 syncLaixinReminder() 拉到数之前保持隐藏/收起(index.html 里就是默认态)。 */
/* 来信页的默认标签:永远落在「全部」。
曾经改成过「有未读就落在未读」,但那样一进页面就看不到全部信件,
未读的红点本来就在提示了,不需要再替人把列表筛一遍。
@@ -3959,7 +3922,7 @@
// 拉不到/未关联都各有回退,见 applyRemoteFootprint 内的分支说明
applyRemoteFootprint();
// 底 tab「宝宝来信」未读角标(box_app 代理第三方 laixin 未读数)
syncLaixinBadge();
syncLaixinReminder();
}
/* ---------- 远程配置(box_app 后台下发):已下线 ----------
⚠️ 2026-09-19(9.19 需求 1.2):这里原来拉 /api/mp/public/mama-box/config,
@@ -4005,13 +3968,11 @@
state.due = null; state.week = null; state.day = null;
state.babyName = ''; state.userName = '';
LETTERS.splice(0, LETTERS.length);
state.curLetter = ''; state.homeLetter = '';
state.curLetter = '';
syncPregnancy(); /* 占位 + 隐藏脚印时间轴 + 信件引导态 */
syncLetterCounts(); /* hero 计数切 '--'(角标 #tabBadge 已不由本地信件数驱动) */
/* ⚠️ 必须重算首页来信卡:上面刚把 LETTERS 清空、pregEmpty 置真,
不重算的话那张卡会停在「显示」状态,顶着一封已经不存在的信。 */
lettersResolved = true;
pushLatestToHome();
/* 首页来信卡归 laixin(applyLaixinReminder 里也判 pregEmpty/无身份),这里直接收起 */
hideLetterCard();
return;
}
const base = '/api/mp/public/mama-box/';
@@ -4143,28 +4104,22 @@
拉不到 —— 无 openid / 接口挂 / 返回码不对 —— 就静默回落内置样例,页面照常可用。 */
function applyRemoteLetters() {
var openid = hashParam('openid');
/* ⚠️ 下面每条**终态**都必须把 lettersResolved 置 true(含失败/无身份),
否则首页那张来信卡会永远停在收起态(宁可收起也不能闪,见 pushLatestToHome)。
无身份这条:卡本来就不该出现,置 true 只是让状态机收敛。 */
if (!openid) { lettersResolved = true; pushLatestToHome(); return; }
/* ⚠️ 2026-09-20:本函数只管**内置来信屏**(#screen-letter)的数据。
它原来还负责驱动首页那张「宝宝来信」卡(lettersResolved + pushLatestToHome),
那张卡现在完全归 laixin(syncLaixinReminder → applyLaixinReminder),
两套数据别再互相写同一个 DOM。 */
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) {
lettersResolved = true; pushLatestToHome(); return;
}
if (!lj || lj.code !== 200 || !lj.result || !lj.result.items) return; /* 静默回落内置样例 */
/* 新用户空态:后端成功返回空列表 → 清掉内置样例信,展示「这里还没有内容」。
⚠️ 不能直接 return —— 那会把 LETTERS 留在内置样例上,新用户看到演示信。 */
if (!lj.result.items.length) {
lettersResolved = true;
LETTERS.splice(0, LETTERS.length);
state.curLetter = '';
state.homeLetter = '';
renderLetters();
syncLetterCounts();
pushLatestToHome();
return;
}
Promise.all(lj.result.items.map(function (it) {
@@ -4186,14 +4141,11 @@
try {
LETTERS.splice(0, LETTERS.length, ...apiList);
state.curLetter = LETTERS[0].id;
state.homeLetter = LETTERS[0].id;
lettersResolved = true;
renderLetters();
syncLetterCounts();
pushLatestToHome();
} catch (e) { /* 映射 / 渲染异常就回退内置样例 */ }
}).catch(function () { lettersResolved = true; pushLatestToHome(); /* 静默回退内置样例 */ });
}).catch(function () { lettersResolved = true; pushLatestToHome(); /* 静默回退内置样例 */ });
}).catch(function () { /* 静默回退内置样例 */ });
}).catch(function () { /* 静默回退内置样例 */ });
}
/* ---------- 今日脚印(box_app → 数胎动对外 API,动态获取) ----------
@@ -4341,8 +4293,12 @@
jumpApp(url);
}
$('#tabLetter').addEventListener('click', openLetterApp);
/* 首页那张「宝宝来信」卡与底 tab 是**同一个入口**(2026-09-20):
卡的数据源已经是 laixin,点它当然也该进 laixin —— 以前它打开的是内置来信详情,
那会「卡上写着有 2 封新信、点进去是空的」(两套数据)。 */
$('#homeLetter').addEventListener('click', openLetterApp);
/* ---------- 底 tab 角标:宝宝来信未读数 ----------
/* ---------- 底 tab 红点 + 首页来信卡:宝宝来信未读数(唯一 owner) ----------
数据源:box_app 封装的联动接口 GET /api/mp/public/laixin/unread
(box_app 后端再去调第三方 https://taidong.douba.top/laixin/api/laixin/unread)。
⚠️ 路径按应用命名空间走 /api/mp/public/<app>/<资源>(app 段 = App 表 identifier),
@@ -4351,17 +4307,21 @@
⚠️ 为什么不直连 taidong:那台的 CORS 白名单只放了自己的域名,页面直连会被浏览器拦;
而且「mama-box ↔ laixin 的联动接口由 box_app 封装」是本项目的既定边界
(见 docs/mama-box-change-list-20260919.md §1.5)。
⚠️ 口径:#tabBadge 的 owner **只有这一个函数**。别在 syncLetterCounts() 或
⚠️ 口径:#tabBadge 与首页「宝宝来信」卡的 owner **只有这一个函数**(改名自 syncLaixinBadge)。别在 syncLetterCounts() 或
applyRemoteProfile() 里再写它 —— 内置来信屏的未读数(box_app /letters)
与 laixin 的未读数是两套数,两处都写会互相覆盖(谁后跑谁说了算)。
⚠️ 容错:接口返回 ok=false(上游抖动)时**保持现状** —— 既不把已经点亮的红点抹掉,
也不因为一次失败就冒出一个红点。角标不是关键路径,失败一律静默。 */
function syncLaixinBadge() {
function syncLaixinReminder() {
const badge = $('#tabBadge');
if (!badge) return;
const openid = hashParam('openid');
/* 接口按 openid 查,没有身份就别问(浏览器直开调试时常见)——此时不显示角标 */
if (!openid) { badge.classList.remove('show'); return; }
/* 接口按 openid 查,没有身份就别问(浏览器直开调试时常见)
—— 此时红点与首页卡都收起(访客态不该出现任何「你有新信」的暗示)。 */
if (!openid) {
if (badge) badge.classList.remove('show');
hideLetterCard();
return;
}
const unionid = hashParam('unionid');
const qs = 'openid=' + encodeURIComponent(openid) +
(unionid ? '&unionid=' + encodeURIComponent(unionid) : '');
@@ -4369,15 +4329,17 @@
.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);
if (!r || r.ok === false) return; // 降级:保持现状(红点与卡都不动)
if (badge) badge.classList.toggle('show', Number(r.unread) > 0);
applyLaixinReminder(r);
})
.catch(() => { /* 静默 */ });
}
/* 从「宝宝来信」H5 返回、或小程序 onShow 把 web-view 唤回前台时刷一次
(上游未读数是「在来信页读完就归零」,所以回来这一下必须重新拉)。 */
(上游未读数是「在来信页读完就归零」,所以回来这一下必须重新拉)。
将来第三方上 SSE 后,这里就是它的降级路径(见 docs/laixin-sse-requirement.md)。 */
document.addEventListener('visibilitychange', function () {
if (!document.hidden) syncLaixinBadge();
if (!document.hidden) syncLaixinReminder();
});
/* 「授权完善资料」:H5 → navigateTo → 小程序端独立授权页(pages/annex/auth/index)。