5095 lines
292 KiB
JavaScript
5095 lines
292 KiB
JavaScript
|
||
/* ================= 兜爸心选 H5 · 交互 ================= */
|
||
(function () {
|
||
'use strict';
|
||
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
|
||
var VER = '26100179';
|
||
const $ = (s, c) => (c || document).querySelector(s);
|
||
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
|
||
|
||
/* ---------- 孕期日期算术 ----------
|
||
这一小块原来长在下面「孕期信息」那一节里(DOM 事件旁边)。挪到最前面,
|
||
是因为默认预产期和孕周要在脚本一加载就算出来(下面「孕期信息」和 AI 小结
|
||
的日期推算都在用)。
|
||
它们全是纯数学,一个 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 给首页「宝宝来信」提醒卡用(未读接口只给时间戳,当天就写「今天」) */
|
||
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;
|
||
}
|
||
/* ---------- 默认预产期:打开这一刻往后推 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: 0, /* ⚠️ 原为演示值 3:访客态/接口失败时它会直接落到首页数胎动卡上,
|
||
现已改 0 且 HTML 默认渲染 '--'(真实值由 __applyRecords 写) */
|
||
kickQuick: 1,
|
||
todayCount: 4,
|
||
favCount: 0, /* ⚠️ 原为演示值 18(与屏幕上的星标数不符);
|
||
启动时按 DOM 里 .star.is-on 的实际个数回填,见「珍贵时刻收藏」那段 */
|
||
resCat: 'all',
|
||
resKey: 'book',
|
||
resFav: [], // 已收藏的资源 key,数组顺序即收藏顺序
|
||
babyName: '小汤圆',
|
||
/* 这三项一进门就是真值(默认预产期 = 今天 +3 个月,见上面 DEFAULT_DUE)。
|
||
以前它们是写死的占位 24/3/null,等 init() 才补 —— 但那之前就有按孕周
|
||
现算的展示(首页孕周环、档案页),拿到的就全是假的。 */
|
||
week: Math.floor(DEFAULT_GA / 7), day: DEFAULT_GA % 7,
|
||
due: DEFAULT_DUE,
|
||
babySex: 'unknown',
|
||
/* 产后态(2026-09-22,来自上游 journey_stage=postpartum / baby_birth_date):
|
||
已生产的用户不能再按预产期算孕周(会得到「孕 42 周」这种荒谬值),
|
||
改显示「宝宝已出生 N 天」。读写方:applyRemoteProfile 的 /pregnancy 分支 + syncPregnancy。 */
|
||
postpartum: false,
|
||
babyAgeTxt: '', // 「宝宝已出生 25 天」/「宝宝已出生 3 个月」(服务端算好)
|
||
babyAgeDays: null, // 出生天数(没有出生日期时为 null)
|
||
babyBirthDate: '',
|
||
/* ⚠️ 这里原来还有一条 babyAvatar(来信列表那颗宝宝头像的 data URI)。
|
||
它的读写方全在内置来信屏里,随该屏一起删除 —— 别再只加字段不加读写方,
|
||
那正是下面 userBio 那条注释警告过的「最难发现的死代码」。
|
||
将来要恢复它,除了 state 字段,还得补上「选图 → 压缩(canvas 缩到 ~128px
|
||
再 toDataURL,原图可能几 MB)→ localStorage 落盘」这几步。 */
|
||
userName: '小满妈妈',
|
||
/* ⚠️ 这里原来还有一条 userBio(一句话签名)。整块已按用户要求删除,
|
||
连同 #pfBio 输入框、#meBio 展示行、syncBio()。别只加回这一条 ——
|
||
一个没人读的 state 字段是最难发现的死代码。 */
|
||
/* ⚠️ 这里原来还有一条 wxUnlocked(兑换码解锁态)。2026-09-19(9.19 需求)
|
||
「资料改由顾问直接发」之后解锁概念整体取消,它连同
|
||
WXOK_KEY / saveWxUnlocked / syncUnlockCopy 一起删了,别再加回来。 */
|
||
};
|
||
|
||
/* ---------- 资源的收藏(跨会话保留) ----------
|
||
只存 key,资源本身仍从 RES 读,改文案不会让旧记录失效。
|
||
⚠️ 原来还有一整套「已领」:state.resGot / RESGOT_KEY / isGot() / 列表页的
|
||
「已领 ✓」标与筛选 chip / 详情页的「再拿一次链接」。9.19 需求取消了
|
||
「点立即领取就到手」这个动作(资料由顾问在微信里直接发),H5 侧不再记录
|
||
「领过哪几份」,整块连同 localStorage 'mmbox.resgot.v1' 一起删除。
|
||
要恢复得先回问需求方。 */
|
||
const RESFAV_KEY = 'mmbox.resfav.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');
|
||
function saveResFav() {
|
||
try { localStorage.setItem(RESFAV_KEY, JSON.stringify(state.resFav)); } 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。第九批(2026-09-16)按用户要求把落点从
|
||
「孕期档案管理 yunqi-dangan」改成「孕检日历 chanjian-rili」。
|
||
⚠️ 地址必须走业务域名 mp.yizhezhe.com(小程序 web-view 只放行业务域名,
|
||
taidong.douba.top 直连会被拦),所以这里用的是 nginx 反代入口,不是源站直连。
|
||
⚠️ 内置档案屏(#screen-archive)依旧屏蔽:数据来源未确认,不放进一期给用户。
|
||
屏的代码/样式全部保留,后续确认数据源后把这里改回 go('archive') 原逻辑即可。
|
||
身份转发:把当前 openid/unionid 带过去(query + hash 双写,对齐身份注入惯例)。 */
|
||
/* 换页/换应用前向小程序宿主上报「即将进入的地址」(分享卡片状态同步)。
|
||
背景:web-view 里的 H5 不能自己拉起分享面板,宿主只能靠 onShareAppMessage(options).webViewUrl
|
||
拿到当前地址,而 onShareTimeline 没有回调参数;首页壳也不会随 H5 换页重新 onLoad。
|
||
postMessage 非实时(微信在「分享 / 后退」等时机才派发),届时 web-view 已停在该地址。
|
||
协议见 box_app/docs/h5-auth-standard.md 第四节。 */
|
||
function reportHostLocation(url) {
|
||
if (window.wxAuth && window.wxAuth.reportLocation) window.wxAuth.reportLocation({ href: url });
|
||
}
|
||
|
||
/* ================= H5 宿主恢复:冷启动回到上次进入的 H5 应用(2026-09-22) =================
|
||
需求:在小程序里进入某个 H5 应用后退出小程序,再次进入要恢复那个应用。
|
||
数据源:box_app GET /api/mp/public/last-app?openid=(Redis,per-openid,7 天 TTL)
|
||
· 写入 last_app:jumpApp / goArchiveH5 换应用前 reportLastApp()(sendBeacon,实时可靠)
|
||
· 读取恢复:resumeLastApp() 在 init() 首屏跑一次,命中就 location.replace 过去
|
||
闸门:sessionStorage 一次 —— 冷启动(web-view 上下文新建)才恢复;
|
||
同一会话内宿主 onShow 的 ?t= 重载 / 用户切回首页 tab 都不会再跳。
|
||
⚠️ 用 replace 不用 href:不留 history 条目,back 不会来回弹。
|
||
⚠️ 只上报/恢复「净地址」(不带 openid/unionid):身份必须按**当前**用户重新注入,
|
||
绝不能把记录那一刻的身份带过去(服务端也会再 sanitize 一遍兜底)。
|
||
⚠️ 服务端有 App 表在架校验,所以恢复不到已下架的应用;Redis 挂掉时读到空 → 留在首页。 */
|
||
const LAST_APP_API = '/api/mp/public/last-app';
|
||
const RESUME_DONE_KEY = 'dsh_resume_done';
|
||
|
||
function urlBase(u) {
|
||
return String(u || '').split('#')[0].split('?')[0].replace(/\/+$/, '');
|
||
}
|
||
|
||
/* 换应用前上报「即将进入的 H5 应用」。
|
||
⚠️ sendBeacon:这一步后面紧跟 location.href 整页跳转,普通 fetch 会被取消;
|
||
sendBeacon 不阻塞导航且由浏览器保证投递(同源 POST,参数走 query,
|
||
因为 sendBeacon 发的是 text/plain,带 JSON body 会被 FastAPI 拒)。 */
|
||
function reportLastApp(url, identifier) {
|
||
try {
|
||
if (!url) return;
|
||
const oid = hashParam('openid');
|
||
if (!oid) return; // 没有身份键,服务端无法落到 per-openid 记录:跳过(不影响跳转)
|
||
const qs = ['openid=' + encodeURIComponent(oid), 'url=' + encodeURIComponent(url)];
|
||
if (identifier) qs.push('identifier=' + encodeURIComponent(identifier));
|
||
const target = LAST_APP_API + '?' + qs.join('&');
|
||
if (navigator.sendBeacon && navigator.sendBeacon(target)) return;
|
||
if (window.fetch) { fetch(target, { method: 'POST', keepalive: true }).catch(function () {}); return; }
|
||
} catch (e) { try { console.warn('[resume] reportLastApp fail:', e); } catch (e2) {} }
|
||
}
|
||
|
||
/* 首屏恢复:读一次 last_app,命中就带着当前身份 replace 过去。 */
|
||
function resumeLastApp() {
|
||
try {
|
||
if (sessionStorage.getItem(RESUME_DONE_KEY)) return; // 同一 web-view 会话只尝试一次
|
||
/* 预览页(/mama-box-preview/)是直接发链接给人看的,不在小程序里冷启动 —— 不恢复上次应用,
|
||
否则一打开就被送去上次进过的应用(2026-10-01 用户反馈:打开预览链接直接跳到孕检日历)。 */
|
||
if (location.pathname.indexOf('/mama-box-preview/') === 0) return;
|
||
const oid = hashParam('openid');
|
||
if (!oid) return; // 无身份不跳(宿主拿不到 openid 时也不会加载本页)
|
||
/* 先置闸门再请求/再判 norestore:
|
||
· #norestore=1 = 小程序首页壳**已经自己决策过**了(它直接把用户送进了目标应用,
|
||
本页只是它留在身后的「返回落点」)—— 这里既不跳,也要用闸门占住,
|
||
免得后续宿主 ?t= 重载(不带该标记)时又跳一次,把返回落点也换成目标应用。
|
||
· 浏览器直访 / 首页壳取数失败后回落加载本页时,没有该标记 → 照旧自行恢复(兜底)。 */
|
||
sessionStorage.setItem(RESUME_DONE_KEY, '1');
|
||
if (hashParam('norestore')) return;
|
||
fetch(LAST_APP_API + '?openid=' + encodeURIComponent(oid))
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(d => {
|
||
const rec = d && d.result;
|
||
if (!rec || !rec.url) return;
|
||
if (urlBase(rec.url) === urlBase(location.href)) return; // 防呆:记录指向本页自身就不跳
|
||
const target = withIdentity(rec.url); // 用当前 openid/unionid 重新注入
|
||
console.log('[resume] 恢复上次 H5 应用:', rec.identifier || rec.url);
|
||
reportHostLocation(target); // 让宿主分享卡片同步到目标地址
|
||
location.replace(target);
|
||
})
|
||
.catch(function () {});
|
||
} catch (e) {}
|
||
}
|
||
|
||
/* ================= 返回首页 → 清除 last_app(2026-09-22) =================
|
||
问题:用户进入 H5 应用后又退回 mama-box 首页,但记录还停在那个应用上 ——
|
||
下次冷启动(普通入口)会把他又送回刚退出的应用(用户实测)。
|
||
记录的是「最后进入的 H5 应用」,用户已经回到首页,那首页才是他最后待的地方 → 必须清掉。
|
||
⚠️ 不能用「本页每次加载都清」:宿主恢复时会把 mama-box 当返回落点加载(#norestore=1),
|
||
那时清掉会把刚用上的记录毁掉。
|
||
所以只认「这次是返回回到本页」的三种情形:
|
||
① bfcache 恢复:pageshow 事件 persisted === true(脚本不重跑,只有这个事件)
|
||
② 后退导致整页重载:navigation.type === 'back_forward'
|
||
③ 从别的 H5 应用页以 location.href 回本页:document.referrer 是 https://mp.yizhezhe.com/<app>/
|
||
宿主恢复/返回落点那两次加载都是 navigate + referrer 空 → 不会误清。 */
|
||
let _lastAppCleared = false;
|
||
function isBackToThisPage() {
|
||
try {
|
||
const nav = window.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
|
||
if (nav && nav.type === 'back_forward') return true;
|
||
} catch (e) {}
|
||
try {
|
||
const ref = document.referrer || '';
|
||
if (ref.indexOf('https://mp.yizhezhe.com/') === 0 && ref.indexOf('/mama-box') === -1) return true;
|
||
} catch (e) {}
|
||
return false;
|
||
}
|
||
function clearLastAppOnBackHome(persisted) {
|
||
try {
|
||
if (_lastAppCleared) return;
|
||
if (!persisted && !isBackToThisPage()) return;
|
||
const oid = hashParam('openid');
|
||
if (!oid) return;
|
||
_lastAppCleared = true;
|
||
console.log('[enter-app] 返回 mama-box 首页 → 清除 last_app(persisted=' + !!persisted +
|
||
',下次冷启动不再回上次那个应用)');
|
||
if (window.fetch) {
|
||
fetch(LAST_APP_API + '?openid=' + encodeURIComponent(oid), { method: 'DELETE', keepalive: true })
|
||
.catch(function () {});
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
/* ② 后退重载:脚本会重跑,init() 里调;① bfcache:整页不重载,挂在文件末尾已有的 pageshow 上 */
|
||
|
||
/* 2026-10-01 用户要求「档案」Tab 改跳孕期档案 H5(会话 19875b1b 提供)。
|
||
⚠️ 对方说明:目前是设计演示版(写死演示数据),还不读 URL 参数;接真实数据时按 ?openid=&unionid= 读,
|
||
所以这里照旧拼上。无 return_url,返回靠宿主自带返回。原来跳的是 /chanjian-rili/(孕检日历)。 */
|
||
const ARCHIVE_H5 = 'https://taidong.douba.top/chengzhang-dangan/v5/';
|
||
function goArchiveH5() {
|
||
const oid = hashParam('openid');
|
||
const uid = hashParam('unionid');
|
||
let u = ARCHIVE_H5;
|
||
const q = [];
|
||
if (oid) q.push('openid=' + encodeURIComponent(oid));
|
||
if (uid) q.push('unionid=' + encodeURIComponent(uid));
|
||
if (q.length) u += '?' + q.join('&');
|
||
reportLastApp(ARCHIVE_H5, 'chengzhangdangan');
|
||
reportHostLocation(u);
|
||
location.href = u;
|
||
}
|
||
|
||
/* ================= 「记一下」弹层 · 全部孕期工具 = 已上架应用列表 =================
|
||
数据:box_app /api/mp/apps(应用管理统一维护图标/名称/链接)。
|
||
范围:isPublished=1 且有 url。**前端不做任何额外过滤** ——
|
||
谁能出现在这里,完全由后台「应用管理」的上架/下架(isPublished)人工决定。
|
||
⚠️ 2026-09-20 按需求去掉了原来那条 `!/wx-h5-demo/i` 过滤(它单独屏蔽「H5应用授权示例」)。
|
||
去掉的两个理由,别再加回来:
|
||
1)同一个应用在别的端(商城小程序「我的应用」直接铺全量)是可见的,
|
||
只在 mama-box 屏蔽会造成「同一份数据、两个端不一样」;
|
||
2)mama-box 端要**用这个应用排查「openid 为空」**的场景(授权示例页本身就是干这个的),
|
||
前端把它藏掉就没法从宫格进去。
|
||
真要让某个应用不出现,去后台下架它,别在前端写名单 —— 名单会漂。
|
||
⚠️ 那个过滤是按 URL 正则匹配的,本身就是脆的: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];
|
||
}
|
||
/* 给目标 H5 拼上当前身份(openid/unionid,query 形态)。
|
||
⚠️ 不再追加 #openid —— 部分应用自带 hash 路由(如 /baobao-yingji),追加 hash 会被当
|
||
非法路由 → 页面未找到。宿主每次加载/onShow 会重新注入身份兜底;
|
||
hash 仅保留作为「授权回传结果」(#phone/nickname/avatar)的通道。 */
|
||
function withIdentity(url) {
|
||
const oid = hashParam('openid');
|
||
const uid = hashParam('unionid');
|
||
const q = [];
|
||
if (oid) q.push('openid=' + encodeURIComponent(oid));
|
||
if (uid) q.push('unionid=' + encodeURIComponent(uid));
|
||
/* ⚠️ 2026-09-20:一个身份都没有时**显式标记**,别让目标应用/日志去猜
|
||
「是宿主没注入身份」还是「参数在路上被平台丢了」。
|
||
· 被跳的 H5 可以据此显示自己的身份提示(或走自己的兜底);
|
||
· nginx/后端日志里 __no_identity=1 一眼可查(排查 openid 为空时很快)。
|
||
排查背景见 docs/mama-box-change-list-20260919.md 与 showIdNotice() 的注释。 */
|
||
if (!oid && !uid) q.push('__no_identity=1');
|
||
return q.length ? url + (url.indexOf('?') >= 0 ? '&' : '?') + q.join('&') : url;
|
||
}
|
||
/* 跳转到其它 H5 应用。identifier 可选(来自 App 表),用于 last_app 上报的服务端在架校验;
|
||
缺省时服务端按 URL 前缀反查,效果一致。 */
|
||
function jumpApp(url, identifier) {
|
||
haptic();
|
||
const target = withIdentity(url);
|
||
reportLastApp(url, identifier); // 净地址:恢复时身份按当前用户重新注入
|
||
reportHostLocation(target);
|
||
location.href = target;
|
||
}
|
||
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 ? '<img class="a-ico" src="' + ico + '" alt="" loading="lazy">' : '<span class="a-ico a-ico-letter" style="background:' + bg + '">' + name.slice(0, 1) + '</span>';
|
||
return '<button class="app ripple" data-url="' + encodeURIComponent(a.url) + '" data-id="' + encodeURIComponent(a.identifier || '') + '">' + img + name + '</button>';
|
||
}).join('');
|
||
/* 磁贴点击(事件委托,innerHTML 重建后不用重绑) */
|
||
grid.onclick = function (e) {
|
||
const t = e.target.closest('.app');
|
||
if (t && t.dataset.url) jumpApp(decodeURIComponent(t.dataset.url), decodeURIComponent(t.dataset.id || ''));
|
||
};
|
||
/* 角标「共 N 个」同步真实数量。
|
||
⚠️ 顺带写 data-toast:点它弹的那句原来由 dedupeApps 拼「已使用的 N 个已置顶」,
|
||
而快捷区现在被临时隐藏(dedupeApps 直接 return,见那里的注释),
|
||
若这里不写,就会退化成 index.html 里写死的「共 16 个」—— 数字比实际多。 */
|
||
const cnt = $('.apps-head.for-all .sec-more');
|
||
if (cnt) {
|
||
cnt.textContent = '共 ' + apps.length + ' 个 ›';
|
||
cnt.dataset.toast = '共 ' + 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) || [];
|
||
/* ⚠️ 只要求有 url(没配链接的点了没反应);**不再按应用名/URL 过滤** ——
|
||
「H5应用授权示例(wx-h5-demo)」曾经在这里被单独屏蔽,2026-09-20 按需求去掉:
|
||
mama-box 要用它排查 openid 为空的场景,且它的可见性该由后台上下架决定。
|
||
详见上面那段注释;别在前端重新引入名单。 */
|
||
const apps = items
|
||
.filter(a => a.url)
|
||
.sort((x, y) => (y.seq || 0) - (x.seq || 0) || (y.no || 0) - (x.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); }
|
||
/* 档案页的孕程已经从圆环换成细条了,这里跟着换成「归零再长回去」。
|
||
归零和写目标值必须隔一帧,同一帧里改两次浏览器只会看到最后那个值,
|
||
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 = () => {
|
||
/* ⚠️ 原来这里有个 letter-detail 分支(首页来信卡 → 内置来信详情)。
|
||
2026-09-20 卡改跳 laixin 之后没人再用它,连同 openDetail() 一起删除。 */
|
||
go(b.dataset.goto);
|
||
};
|
||
b.addEventListener('click', fire);
|
||
/* 真 <button> 上敲回车/空格,浏览器会自动派一个 click;<div role="button"> 不会。
|
||
首页那张脚印卡就是这样的 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 = '';
|
||
});
|
||
}
|
||
});
|
||
|
||
|
||
/* ================= 筛选 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() + '」');
|
||
}));
|
||
});
|
||
|
||
/* ================= 珍贵时刻收藏 ================= */
|
||
/* ⚠️ 计数不能写死(原来 #favCount=18、state.favCount=18,而屏幕上点亮的星标只有 6 个,
|
||
数字与内容对不上)。启动时按 DOM 里 `.star.is-on` 的**实际个数**回填,
|
||
点星标时再 ±1 —— 这样标签上的数永远等于卡上看得见的收藏。
|
||
(档案页不是首屏,用户看不到"先 0 再回填"的中间态。) */
|
||
state.favCount = $$('.star.is-on').length;
|
||
(function () { const fc = $('#favCount'); if (fc) fc.textContent = state.favCount; })();
|
||
$$('.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` 实现。
|
||
通用兄弟选择器只能向**后**选,而本批把 <nav class="tabbar"> 移到了 .phone
|
||
的最前面(让底部 tab 尽早渲染,理由见 index.html 里 nav 上方那段),
|
||
nav 排到 .sheet 前面之后那条规则永远不再命中,圆钮会直接不转 —— 而且
|
||
这种「动画没了」的回归 jsdom 根本量不到,只能靠这段注释和下面那条断言钉住。
|
||
⚠️ 别图省事删掉这两行改回兄弟选择器,除非你把 nav 也挪回文件末尾。 */
|
||
let sheetPaint = null; // 「记一下」今天卡 + 先记这件(定义在今日脚印那段,依赖 _fpData / todoLine)
|
||
function openSheet() {
|
||
if (sheetPaint) try { sheetPaint(); } catch (e) { /* 只是装饰层,出错不挡弹窗 */ }
|
||
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 = '';
|
||
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();
|
||
});
|
||
|
||
/* 工具列表去重:顶部「今天已使用」的快捷入口若在下方列表里重复出现,
|
||
用户会觉得同一个工具出现了两次,这里按名称把它们从全部列表中摘掉 */
|
||
(function dedupeApps() {
|
||
/* ⚠️ 快捷区「今天已使用」功能未实现、已临时隐藏(index.html 里挂 .tbd-hide)。
|
||
没有置顶区就不存在「同一个工具出现两次」,所以两件事都要跳过:
|
||
① 按 .q-t 摘除同名磁贴;② 把小标题改成「其他孕期工具」——藏了上面那块之后,
|
||
第一眼看到的就是它,「其他」俩字会变成没有指代对象的错文案,
|
||
保持 index.html 里的默认「全部孕期工具」才对。
|
||
判据直接读 DOM(.tbd-hide 在不在),单一事实来源在 index.html:
|
||
把那里三处 .tbd-hide 去掉,本段会自动重新生效,不用改这里。 */
|
||
if ($('.quick-row.tbd-hide')) return;
|
||
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();
|
||
}));
|
||
|
||
/* 首页宫格入口。
|
||
五个坑位现在**全部**指向已上架应用(都由 App 表下发 URL),没有占位 toast 了;
|
||
某张卡的 identifier 在 App 表里查不到 / 没配 URL 时,才退回「即将上线」提示。
|
||
跳转用 location.href 走**当前标签页**:这个壳是照着原生 App 做的,
|
||
新开标签会在微信 / Safari 里堆出一串返回不去的页面;同页跳转的话,
|
||
浏览器返回键就是天然的「返回」。 */
|
||
/* 首页五张工具卡 → 已上架应用(identifier 在应用管理维护,后台改 URL 即生效)。
|
||
⚠️ 不再裸跳旧 beian*.douba.top 域名:统一走 jumpApp 带身份(openid/unionid)。
|
||
⚠️ App 表里的 URL 一律用业务域名 mp.yizhezhe.com/<slug>/(nginx 反代到源站)——
|
||
小程序 web-view 只放行业务域名,写 taidong.douba.top 直连会被拦。
|
||
第九批(2026-09-16):curve 从「生长曲线 taiershengzhang」改成「数宫缩 gongsuopinlv」。 */
|
||
const MINI_APP_MAP = {
|
||
kick: 'taidong', // 数胎动 (1005)
|
||
weight: 'yunqitizhong', // 孕期体重管理 (1012)
|
||
cal: 'chanjianrili', // 孕检日历 (1009)
|
||
curve: 'gongsuopinlv', // 数宫缩(原「生长曲线」,第九批换坑位)
|
||
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, app.identifier); 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);
|
||
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)));
|
||
|
||
/* ---------- 「宝宝」这两个字的统一出口 ----------
|
||
用户在「我的 → 编辑孕期信息」里填了小名,首页那张「宝宝来信」提醒卡的
|
||
标题 / 摘要 / 链接文案都得跟着换。没填就回落到「宝宝」,每句文案照样通顺。
|
||
写成 function 而不是箭头常量:声明会提升,谁先谁后都不会踩到暂时性死区。 */
|
||
function babyWord(suffix) {
|
||
return (state.babyName || '宝宝') + (suffix || '');
|
||
}
|
||
/* ⚠️ 这里原来还有一个 fillBaby(str)(把 {baby} 占位符换成小名),和 babyWord() 一对。
|
||
它的调用点全在内置来信屏的渲染里,随该屏一起删除。
|
||
将来若要用,直接调 babyWord() 拼,别只留一个空函数在这儿。 */
|
||
|
||
/* back(几天前)+ time(几点几分)→ 那一刻的时间戳。back 只影响「哪一天」,
|
||
时分是当天的绝对钟点,所以先归零到 0 点再往回数,避免夏令时/跨月自己算。
|
||
⚠️ 现在唯一调用方是 stampAiLogs()(AI 小结的往期日期)。内置来信屏删除后
|
||
信件那一层语义没了,但 AI 小结还在靠它推日期 —— 别顺手删。 */
|
||
function backAt(back, time) {
|
||
const d = midnight(new Date());
|
||
d.setDate(d.getDate() - back);
|
||
d.setHours(time[0], time[1], 0, 0);
|
||
return d.getTime();
|
||
}
|
||
|
||
/* ---------- 小工具 ---------- */
|
||
function el(tag, cls, txt) {
|
||
const n = document.createElement(tag);
|
||
if (cls) n.className = cls;
|
||
if (txt != null) n.textContent = txt;
|
||
return n;
|
||
}
|
||
|
||
/* 首页那张「宝宝来信」提醒卡:**数据源是 laixin(第三方宝宝来信应用)**。
|
||
⚠️⚠️ 2026-09-20 —— 理顺一处数据源不一致:
|
||
底 tab 早已改跳 laixin、角标读 /api/mp/public/laixin/unread,
|
||
但这张卡原来由 pushLatestToHome() 按本地 LETTERS(box_app 的 /mama-box/letters)驱动,
|
||
而那条线对绝大多数用户是 0 封 —— 于是同一屏上「红点说有新信、卡里什么都没有」。
|
||
现在两者**共用同一个 laixin 未读结果**,而且**只有一个渲染入口**:
|
||
· 拉数:syncLaixinReminder()(首屏 + 每次 visibilitychange)
|
||
· 渲染:renderLetterReminder() —— 卡与底 tab 红点由它**一次判定**同时落,
|
||
不会再出现「一边有一边没有」的屏
|
||
· 判据(2026-09-22 产品口径):**未建档(pregEmpty)= 一律不提示**;
|
||
建档后看 unread > 0;归零则一起收;上游降级(ok:false)保持现状
|
||
· 点击:跳 laixin(与底 tab 同落点)
|
||
⚠️ 判据翻转过一次(旧版「故意不看 pregEmpty」已被产品否掉),细节与理由写在
|
||
renderLetterReminder() 上方,改之前先读那段。
|
||
⚠️ 卡里只有数字与时间(接口就给这些),所以文案是**通用提醒**,不做单封预览。
|
||
将来第三方上 SSE 的 event: letter 之后,才有标题/摘要可显示
|
||
(见 docs/laixin-sse-requirement.md)。
|
||
⚠️ 2026-09-20:内置来信屏(#screen-letter)及其全部功能代码已整体删除,
|
||
这里没有「本地信件数据」可以接回去了 —— 这张卡只认 laixin。 */
|
||
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');
|
||
}
|
||
|
||
/* 来信提示(首页卡 + 底 tab 红点)的**唯一渲染入口**。
|
||
两个触发方:syncLaixinReminder()(拿到新未读结果)与 syncPregnancy()(建档状态变了)。
|
||
卡与红点永远由这**一次判定**同时决定 —— 之前它们各写一份判据,结果就是
|
||
「红点有、卡没有」那种自相矛盾的屏(2026-09-22 定时复现过)。
|
||
|
||
判定(2026-09-22 产品口径):
|
||
① **未建档(pregEmpty,即没填孕产期)→ 一律不提示**:卡与红点一起收。
|
||
口径翻转过一次,说明白免得被改回去:
|
||
· 旧注释写的是「来信与建档是两件事,故意不看 pregEmpty」;
|
||
· 产品实测后确认**不合理** —— 没填孕产期的用户本来就不该被「宝宝来信」打扰
|
||
(那时连宝宝档案都没有),所以现在 pregEmpty 是**硬门槛**;
|
||
· 别再按「unread>0 就该提示」把它改回去。
|
||
② 建档了 → 只看未读:unread>0 才出(卡 + 红点),归零则一起收。
|
||
③ 上游降级(ok:false)→ 保持现状不动(不误导)。 */
|
||
let _laixinUnread = null; // 最近一次未读结果;建档状态变化时靠它重算
|
||
function renderLetterReminder() {
|
||
const badge = $('#tabBadge');
|
||
const r = _laixinUnread;
|
||
if (state.pregEmpty || !r || r.ok === false || !(Number(r.unread) > 0)) {
|
||
if (badge) badge.classList.remove('show');
|
||
hideLetterCard();
|
||
return;
|
||
}
|
||
const card = $('#homeLetter'), body = $('#homeBody');
|
||
if (!card) 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 = desc;
|
||
$('#lcLink').textContent = '读读' + babyWord('的话');
|
||
const num = $('#lcNum'); if (num) { const n = letters + replies; num.textContent = n > 99 ? '99+' : (n || ''); }
|
||
card.classList.add('has-new');
|
||
card.classList.remove('hide');
|
||
if (body) body.classList.remove('no-letter');
|
||
if (badge) badge.classList.add('show');
|
||
}
|
||
|
||
$$('[data-detail="close"]').forEach(b => b.addEventListener('click', closePanel));
|
||
|
||
/* ================= 通用弹窗 =================
|
||
不复用「记一下」那套 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);
|
||
|
||
/* ================= 孕期信息 =================
|
||
预产期是唯一的「源」:填了它就按今天倒推出孕周 + 额外天数;
|
||
没填时那两格 disabled(置灰不可编辑)。算出来之后用户可以微调,
|
||
微调会反推回预产期,保证「孕 N 周」和「预产期 X · 还有 N 天」永远自洽。
|
||
「今日体重」已按需求删除。
|
||
注:DAY_MS / TERM / midnight / clampGA / gaFromDue / dueFromGA / addMonths /
|
||
DEFAULT_DUE 都提到文件最前面去了 —— 孕期计算和 AI 小结的日期推算要用,那边比这里早。 */
|
||
|
||
/* 孕周 → 这周宝宝大概多大,每项 [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());
|
||
}
|
||
|
||
/* 2026-10-01 资料弹窗改版后:孕周两格不再锁(「按当前孕周」那页本来就是让人从零填的),
|
||
这里只负责没预产期时把两格清空。函数名留着,调用点不用动。 */
|
||
function lockWeekFields(locked) {
|
||
if (locked) { pgWeek.value = ''; pgDay.value = ''; }
|
||
}
|
||
/* 缓存戳重载:只换查询串(?t=…),两处都不能丢:
|
||
① URL 已有 query 时必须用 & 拼接 —— 直接拼 '?t=' 会把第二个 ? 吃进已有参数值里
|
||
(?openid=abc001?t=123 → openid 变脏);
|
||
② **hash 必须原样拼回去** —— openid/unionid 只活在 hash 里(宿主注入的形态是
|
||
…/mama-box/#openid=…&unionid=…),丢掉 hash 再重载 = 把身份一起丢了 →
|
||
applyRemoteProfile 走访客分支:误报「未获取到微信身份」、孕周清空、
|
||
昵称/头像也拉不到(后端其实都保留着)。
|
||
调用它的两个场景都要求身份还在:① 退出授权登录(只翻授权状态,资料保留)
|
||
② 新用户首次完善资料(重载后要按服务端孕周显示)。 */
|
||
function bustReload() {
|
||
const b = location.href.split('#')[0];
|
||
location.replace(b + (b.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now() + location.hash);
|
||
}
|
||
|
||
/* 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.postpartum) {
|
||
/* 产后态:预产期已过,**不要**再按它算出「孕 42 周」回填进表单(那是荒谬值)。
|
||
预产期本身照常显示/可改;孕周两格留空并锁住。 */
|
||
pgDue.value = state.due ? toISO(state.due) : '';
|
||
pgWeek.value = '';
|
||
pgDay.value = '';
|
||
lockWeekFields(true);
|
||
} else 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();
|
||
setPgMode('due');
|
||
if (pgLmp) { pgLmp.value = ''; pgLmp.dispatchEvent(new Event('change')); }
|
||
/* 头像预览跟随卡片(有正式头像才显示;新用户空圆 + +号) */
|
||
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');
|
||
}
|
||
}
|
||
|
||
/* 预产期按钮上的文字 + 顶部结果条,都从 #pgDue 现算(唯一数据源)。
|
||
函数名沿用 syncDuePh(原来只管「- 年 - 月 - 日」占位层),调用点不用动。 */
|
||
const WK = '日一二三四五六';
|
||
function fmtDay(d) { return d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日 周' + WK[d.getDay()]; }
|
||
function syncDuePh() {
|
||
const due = parseDay(pgDue.value);
|
||
const t = $('#pgDueTxt');
|
||
if (t) { t.textContent = due ? fmtDay(due) : '点这里选预产期'; t.parentNode.classList.toggle('is-set', !!due); }
|
||
const sum = $('#pgSum');
|
||
if (!sum) return;
|
||
sum.classList.toggle('is-set', !!due);
|
||
if (!due) {
|
||
$('#pgSumT').textContent = '还没填预产期';
|
||
$('#pgSumS').textContent = '选一种方式填一下,马上就好';
|
||
return;
|
||
}
|
||
const left = Math.round((midnight(due) - midnight(new Date())) / DAY_MS);
|
||
if (left < 0) {
|
||
$('#pgSumT').textContent = '宝宝已经出生啦';
|
||
$('#pgSumS').textContent = '预产期 ' + fmtDay(due);
|
||
return;
|
||
}
|
||
const ga = gaFromDue(due);
|
||
$('#pgSumT').textContent = '孕 ' + Math.floor(ga / 7) + ' 周' + (ga % 7 ? ' +' + (ga % 7) + ' 天' : '');
|
||
$('#pgSumS').textContent = (left ? '还有 ' + left + ' 天见面' : '就是今天啦') + ' · 预产期 ' + (due.getMonth() + 1) + '月' + due.getDate() + '日';
|
||
}
|
||
|
||
/* 三种填法切换(只切面板;数据都落在 #pgDue) */
|
||
function setPgMode(m) {
|
||
$$('#pgMode .seg-b').forEach(b => b.classList.toggle('is-active', b.dataset.mode === m));
|
||
$$('#modalProfile .pg-pane').forEach(p => p.classList.toggle('is-on', p.dataset.pane === m));
|
||
}
|
||
$$('#pgMode .seg-b').forEach(b => b.addEventListener('click', () => { setPgMode(b.dataset.mode); haptic(); }));
|
||
|
||
/* 孕周步进:空着时从 12 周起步(一般建档时间),天数 6 再 + 进位到下一周 */
|
||
$$('#modalProfile .stp-b').forEach(b => b.addEventListener('click', () => {
|
||
let w = parseInt(pgWeek.value, 10), d = parseInt(pgDay.value, 10);
|
||
if (isNaN(w)) w = 12;
|
||
if (isNaN(d)) d = 0;
|
||
const k = +b.dataset.d;
|
||
if (b.dataset.stp === 'w') w += k;
|
||
else {
|
||
d += k;
|
||
if (d > 6) { d = 0; w += 1; }
|
||
if (d < 0) { d = w > 0 ? 6 : 0; if (w > 0) w -= 1; }
|
||
}
|
||
pgWeek.value = Math.min(42, Math.max(0, w));
|
||
pgDay.value = d;
|
||
pgWeek.dispatchEvent(new Event('change'));
|
||
haptic();
|
||
}));
|
||
|
||
/* 末次月经第一天 + 280 天 = 预产期 */
|
||
const pgLmp = $('#pgLmp');
|
||
if (pgLmp) pgLmp.addEventListener('change', () => {
|
||
const l = parseDay(pgLmp.value);
|
||
const t = $('#pgLmpTxt');
|
||
if (t) { t.textContent = l ? fmtDay(l) : '点这里选末次月经第一天'; t.parentNode.classList.toggle('is-set', !!l); }
|
||
if (!l) return;
|
||
pgDue.value = toISO(new Date(l.getFullYear(), l.getMonth(), l.getDate() + 280));
|
||
pgDue.dispatchEvent(new Event('change'));
|
||
});
|
||
|
||
/* 小名建议:点一下填进去 */
|
||
$$('#pgNameChips .nm-c').forEach(b => b.addEventListener('click', () => { pgName.value = b.textContent; haptic(); }));
|
||
|
||
/* 预产期一变,孕周 / 额外天数自动算出来 */
|
||
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));
|
||
syncDuePh();
|
||
}));
|
||
|
||
/* 性别三选一 */
|
||
$$('#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 = '';
|
||
}
|
||
/* 首页头部 2026-10-01 改版:见面倒计时 / 孕程条(骑着本周水果)/ 大小一句话。三种状态都走这里,别各写各的。
|
||
进度条先归 0 再隔一帧写目标值,才有「长出来」的动画(同 replayArBar 的道理)。 */
|
||
function setHero(o) {
|
||
const bar = $('#heroBar');
|
||
if (!bar) return;
|
||
$('#heroDueT').innerHTML = o.due;
|
||
$('#heroFruit').textContent = o.emoji || '';
|
||
$('#heroSize').textContent = o.size || '';
|
||
$('#heroWeekU').textContent = o.unit == null ? '周' : o.unit;
|
||
$('.hero-pane').classList.toggle('is-born', !!o.born);
|
||
bar.style.width = '0%';
|
||
requestAnimationFrame(() => requestAnimationFrame(() => { bar.style.width = Math.max(0, Math.min(100, o.pct || 0)) + '%'; }));
|
||
}
|
||
|
||
/* ---------- 页面里那些写死的示例日期 ----------
|
||
时光轴、珍贵时刻、首页两张小卡、指南标题、「记一下」抬头、搜索热词,
|
||
原来全是设计稿留下的固定字符串(「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;
|
||
});
|
||
/* ⚠️ #calNext 归**卡片接口**优先(与孕检日历应用同一套排期、还支持"改过预约日以预约日为准");
|
||
本地按预产期推算只在接口没数据时兜底。两者并行返回、先后不定,所以看 cardsOwned.cal。
|
||
(curve 卡的 [data-wk] 由 applyRemoteCards 摘掉,这里自然跳过,无需额外判断。) */
|
||
if (!cardsOwned.cal) {
|
||
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 里的孕期信息刷到「我的」孕期卡 + 首页头部 + 档案页顶部 */
|
||
/* 成长数据面板的空态开关(CSS 见 mama-box.css 的 .data-empty 段):
|
||
未建档 / 产后 = 面板里那些数字(58.2kg、日均胎动 31 次、双顶径 6.1cm、体重曲线)
|
||
**一个都没有接口数据源**,对这类用户是纯假数据 → 整块隐藏改显示引导。
|
||
⚠️ 有真实孕周的用户仍看演示值:这是与「AI 小结演示文案」同一批的遗留,
|
||
要一起清得先有数据源,别只在这里删。 */
|
||
function setGrowthDataEmpty(on) {
|
||
const p = $('.seg-panel[data-panel="data"]');
|
||
if (p) p.classList.toggle('is-empty', !!on);
|
||
}
|
||
/* 「我的」页头部名字下面那枚小标签:孕周 / 宝宝月龄;未完善时隐藏 */
|
||
function setMeTag(t) {
|
||
const el = $('#meTag'); if (!el) return;
|
||
el.textContent = t || '';
|
||
el.classList.toggle('hide', !t);
|
||
}
|
||
function syncPregnancy() {
|
||
/* 孕期小知识:只认真实孕周(自己判显隐,见 renderTips) */
|
||
renderTips();
|
||
nurseryRender();
|
||
/* ---- 产后态:宝宝已出生 → 一律不显示孕周数字(2026-09-22) ----
|
||
⚠️ 必须放在 pregEmpty 之前:产后返回体的 week 也是 null,
|
||
落到空态分支会给**已经生产**的用户弹「完善孕期信息」引导。
|
||
文案集中在这里,要改只改这一段:
|
||
· 首页 hero:已出生 / N 天 / 「已经生活了 N 天」
|
||
· 我的孕期卡:宝宝已出生 N 天 · 记录宝宝的成长时光
|
||
· 档案页:宝宝已出生 N 天 · 孕期陪伴已结束,继续记录宝宝的成长
|
||
⚠️ hero 的「孕周」两个字是 #heroWeek **紧邻的兄弟 span**,JS 只写 #heroWeek,
|
||
所以这里要顺手把它清空,否则会读成「已出生 孕周」。 */
|
||
if (state.postpartum) {
|
||
const nm = state.babyName || '宝宝';
|
||
const ageTxt = state.babyAgeTxt || '宝宝已出生';
|
||
const ageDays = state.babyAgeDays;
|
||
/* ① 我的 · 孕期卡 */
|
||
const pcT = $('#pcTitle'), pcS = $('#pcSub'), pcB = $('#pcBar'), pcR = $('#pcRing');
|
||
if (pcT) pcT.textContent = ageTxt;
|
||
setMeTag(ageTxt);
|
||
if (pcS) pcS.textContent = '记录宝宝的成长时光';
|
||
if (pcB) pcB.style.setProperty('--p', '100%');
|
||
if (pcR) {
|
||
const C = 2 * Math.PI * parseFloat(pcR.getAttribute('r'));
|
||
pcR.setAttribute('stroke-dasharray', C.toFixed(2) + ' ' + C.toFixed(2));
|
||
}
|
||
$$('.pc-stage span').forEach((s) => {
|
||
s.textContent = s.textContent.split(' · ')[0];
|
||
s.classList.remove('cur');
|
||
});
|
||
/* ② 首页 hero */
|
||
const hw = $('#heroWeek');
|
||
if (hw) hw.textContent = '已出生';
|
||
setHero({ due: nm + '已经来到身边', emoji: '👶', size: '', unit: '', pct: 100, born: true });
|
||
const hp = $('#heroPlus');
|
||
if (hp) hp.textContent = (ageDays == null ? '' : ageDays + '天');
|
||
const hd = $('#heroDays');
|
||
if (hd) hd.textContent = (ageDays == null ? '--' : ageDays);
|
||
const hwho = $('#heroWho');
|
||
if (hwho) hwho.textContent = nm;
|
||
const hwhere = document.querySelector('.hd-where');
|
||
if (hwhere) hwhere.textContent = '来到这个世界';
|
||
/* ③ 档案页 */
|
||
const arN = $('#arTitleName'); if (arN) arN.textContent = nm;
|
||
const arL1 = $('#arLine1'); if (arL1) arL1.textContent = ageTxt;
|
||
const arD = $('#arDays'); if (arD) arD.textContent = (ageDays == null ? '--' : ageDays);
|
||
const arArt = $('#arSizeArt'); if (arArt) arArt.textContent = '👶';
|
||
const arL2 = $('#arLine2'); if (arL2) arL2.textContent = '孕期陪伴已结束,继续记录宝宝的成长';
|
||
const arP = $('#arPct'); if (arP) arP.textContent = '100%';
|
||
setArBar(100);
|
||
const gwt = $('#genWeekTag'); if (gwt) gwt.textContent = '产后';
|
||
$$('[data-ga]').forEach(n => { n.textContent = '--'; });
|
||
$$('[data-wk]').forEach(n => { n.textContent = '--'; });
|
||
/* ⚠️ #calNext(首页「孕检日历」卡)**没有 data-wk / data-ga 钩子**,上面两条循环抓不到它。
|
||
而 init 早已按内置默认(孕 27 周)把它写成「9月29日」这类演示日期,
|
||
产后分支之后没人再改 → 会以假日期留在首页。这里显式归占位。
|
||
(2026-09-22 新用户实测:孕检日历 9月29日 / 孕期体重 58.2kg 两处漏网。) */
|
||
const _cnPost = $('#calNext'); if (_cnPost) _cnPost.textContent = '--';
|
||
setGrowthDataEmpty(true); /* 产后同样没有这些数据的来源,别留演示数字 */
|
||
syncBabyWords();
|
||
return;
|
||
}
|
||
/* ---- 新用户空态:未完善资料(无预产期)→ 全部显示引导占位,不读演示数据 ----
|
||
me 页已有两条引导(授权卡 + 孕期信息必填卡);这里把首页/档案页的
|
||
孕周数字换成引导文案,避免新用户看到"孕 27 周"这类假数据。 */
|
||
if (state.pregEmpty) {
|
||
const nm = state.babyName || '宝宝';
|
||
/* 脚印时间轴:未建档整块隐藏 —— 交给 fpRender() 统一判(它同时看 footprint 的结论),
|
||
别再直接改 display:与本模块的另一个 owner 撞竞态(2026-09-22 实测漏过 degraded 场景)。 */
|
||
fpRender();
|
||
/* ① 我的 · 孕期卡 → 引导 */
|
||
$('#pcTitle').textContent = '待完善孕期信息';
|
||
setMeTag('');
|
||
$('#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;
|
||
setHero({ due: '填好预产期,开启倒计时', emoji: '🌱', size: '', pct: 0 });
|
||
/* ③ 档案页 → 占位 */
|
||
$('#arTitleName').textContent = nm;
|
||
$('#arLine1').textContent = '完善孕期信息后开启';
|
||
$('#arDays').textContent = '--';
|
||
$('#arLine2').textContent = '先去「我的资料」填预产期';
|
||
$('#arPct').textContent = '0%';
|
||
setArBar(0);
|
||
$('#genWeekTag').textContent = '--';
|
||
/* ⑤ 用户卡记录行:未完善 → 引导文案(数据源未定,先不给演示数字) */
|
||
const _rl = $('#meRecordLine');
|
||
if (_rl) _rl.textContent = '完善孕期信息,记录宝宝的成长记忆';
|
||
/* ⑥ 孕期衍生的静态演示位(生长曲线周数/孕检日历/时光轴日期)一并归中性占位,
|
||
不保留 HTML 里的演示默认值(孕27周/9月15日…) */
|
||
$$('[data-ga]').forEach(n => { n.textContent = '--'; });
|
||
$$('[data-wk]').forEach(n => { n.textContent = '--'; });
|
||
/* ⚠️ 上面注释里的「孕检日历」**并不被那两条循环覆盖**:#calNext 没有 data-wk / data-ga
|
||
属性,选择器抓不到。而 init 早已按内置默认(孕 27 周)把它写成「9月29日」,
|
||
且 applyRemoteProfile 在 pregEmpty 时提前 return —— 于是新用户首页上
|
||
只剩这一个孕周派生的假日期(2026-09-22 实测)。这里显式归占位。 */
|
||
const _cn = $('#calNext'); if (_cn) _cn.textContent = '--';
|
||
setGrowthDataEmpty(true);
|
||
const _t = new Date();
|
||
$('#noteWhen').textContent = mdTxt(_t);
|
||
syncBabyWords();
|
||
/* 未建档 → 不给「宝宝来信」提示(卡 + 红点一起收)。
|
||
⚠️ 这里**必须**重算一次,不能只写 hideLetterCard():来信提示的唯一渲染入口是
|
||
renderLetterReminder(),它同时管卡和底 tab 红点。只藏卡会留下「红点有、卡没有」
|
||
(2026-09-22 定时复现:laixin 先回来亮红点、/profile 后回来藏卡,就是那个不一致)。
|
||
现在两处判据合成一条(pregEmpty 是硬门槛),谁先回来都收敛到同一结果。 */
|
||
renderLetterReminder();
|
||
return;
|
||
}
|
||
const week = state.week, day = state.day, name = state.babyName;
|
||
setGrowthDataEmpty(false); /* 有真实孕周了:恢复面板(数据源接入前仍是演示值,见函数注释) */
|
||
/* 刚建档/填了孕产期 → 按新状态重算来信提示:此前因未建档被压住的未读,这时该出现 */
|
||
renderLetterReminder();
|
||
/* 脚印时间轴:建档状态变了要重算 —— 同样只经 fpRender(footprint 还没回来时它保持「加载中」) */
|
||
fpRender();
|
||
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;
|
||
setMeTag(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') 选的是**第一个** <p>。曾经那是一句话签名(已删),
|
||
现在第一个 <p> 是「已连续记录 42 天」—— 加回这行会把连续记录天数
|
||
静默冲成孕周,页面不报错,只是那个数字再也不对。 */
|
||
|
||
/* ② 首页头部:孕周环、+N 天、「小名,我们已经一起生活了 N 天」 */
|
||
$('#heroWeek').textContent = week;
|
||
$('#heroPlus').textContent = '+' + day + '天';
|
||
$('#heroDays').textContent = days;
|
||
$('#heroWho').textContent = name;
|
||
{
|
||
const sz = babySize(week);
|
||
setHero({
|
||
due: left > 0 ? '距离见面还有 <b>' + left + '</b> 天' : '宝宝随时会来见你',
|
||
emoji: sz.emoji, size: '像' + sz.name.replace(/^一[个颗根只串把粒条棵颗]/, ''), pct: pct
|
||
});
|
||
}
|
||
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 + ' 周'; // 生成档案卡上的周次
|
||
|
||
/* ④ 称呼:小名一改,全站写着小名的地方(首页三个模块名、来信提醒卡文案)跟着换 */
|
||
syncBabyWords();
|
||
|
||
/* ⑤ 用户卡记录行:已完善 → 保留「已连续记录 N 天」(数据源确认前先维持原型值) */
|
||
const _rl2 = $('#meRecordLine');
|
||
if (_rl2) _rl2.innerHTML = '已连续记录 <b>42</b> 天';
|
||
/* ⑥ 页面里那些示例日期(时光轴、珍贵时刻、小卡、热词…)也按新孕周重刷。
|
||
改预产期会连带改孕周,这些跟着一起动才不会前后矛盾。 */
|
||
syncStaticDates();
|
||
stampAiLogs();
|
||
}
|
||
|
||
/* ⚠️ 这里原来是 syncBio() —— 刷「我的」页头像旁那行一句话签名。
|
||
一句话签名本批按用户要求整块删除,四处一起走:
|
||
index.html #pfBio 输入框、<p class="me-bio" id="meBio"> 展示行
|
||
app.js state.userBio 初值、这个 syncBio()、init() 里对它的调用、
|
||
#pfSave 里那两行
|
||
别只补回其中一处:留输入框没展示行 = 白填;留展示行没输入框 = 死文案。 */
|
||
|
||
/* 把散落在各个页面里的固定文案统一按小名刷一遍。
|
||
这些是写死在标记里的静态字,不归任何 render 函数管,
|
||
只能在这里集中改,漏一处就会出现两个不同的小名并存。 */
|
||
function syncBabyWords() {
|
||
/* 首页三个模块名都跟着小名走:
|
||
「孕期必用工具」→「小汤圆的孕育工具」
|
||
「AI今日小结」 →「小汤圆今天想说」
|
||
「今日成长脚印」→「小汤圆的今日脚印」
|
||
这三处原来是写死的功能名。换成小名不只是亲切 —— 数胎动、称体重这些事,
|
||
妈妈是在替谁做,标题里就该有谁。
|
||
⚠️ 气泡上原来那枚 .ab-badge「AI」角标,2026-10-02 用户明确要求去掉(首页便签不再标 AI)。
|
||
详情页 #detailAI 的说明仍在;以后要恢复机器生成提示,回 index.html 的 .fn-top 里加回来。 */
|
||
$('#dockTitle').textContent = babyWord('的孕育工具');
|
||
$('#abTag').textContent = babyWord('今天想说');
|
||
$('#tlTitle').textContent = babyWord('的今日脚印');
|
||
const _sg = $('#fpSign'); if (_sg) _sg.textContent = '—— 爱你的' + (state.babyName || '宝宝');
|
||
/* 整条时间轴自己是个 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('先填一下预产期,孕周会自动算出来');
|
||
setPgMode('due');
|
||
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 (r) {
|
||
return r.json().catch(function () { return null; });
|
||
}).then(function (res) {
|
||
/* ⚠️ 2026-09-22(档案以上游为准):预产期/昵称会**先推上游、推失败后端返回 400 且不落库**。
|
||
这里必须让用户看见失败 —— 静默的话下次加载会被上游值覆盖回去,用户以为"改了没生效"。 */
|
||
if (!res || res.code !== 200) {
|
||
toast((res && res.message) || '保存失败,请检查网络后重试');
|
||
return;
|
||
}
|
||
/* 新用户首次完善资料:重载页面 → pregnancy 出真实孕周 */
|
||
if (_wasEmpty) setTimeout(bustReload, 700);
|
||
}).catch(function () {
|
||
toast('保存失败,请检查网络后重试');
|
||
});
|
||
}
|
||
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 =
|
||
'<div class="st-about">' +
|
||
'<div class="st-logo">' +
|
||
'<svg viewBox="0 0 48 48" aria-hidden="true">' +
|
||
'<path d="M7 18h34v20a4 4 0 0 1-4 4H11a4 4 0 0 1-4-4z" fill="#fff" opacity=".9"/>' +
|
||
'<path d="M5 12.5h38a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3.5a2 2 0 0 1 2-2z" fill="#fff"/>' +
|
||
'<path d="M24 20v22M24 12.5c-2-5-5.5-6.5-8-5s-1.5 5 8 5zM24 12.5c2-5 5.5-6.5 8-5s1.5 5-8 5z" ' +
|
||
'fill="none" stroke="#F4593C" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>' +
|
||
'</svg>' +
|
||
'</div>' +
|
||
'<div class="st-appname">兜爸心选</div>' +
|
||
'<div class="st-ver">v3.2.1 · 陪你从两条杠到见面</div>' +
|
||
'</div>';
|
||
|
||
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 '<p class="st-para">' + r[1] + '</p>';
|
||
if (r[0] === 'row')
|
||
return '<button class="st-row ripple" data-toast="' + r[1] + (r[2] ? ' · ' + r[2] : '') + '">' +
|
||
'<span class="st-rt">' + r[1] + '</span>' +
|
||
(r[2] ? '<span class="st-rv">' + r[2] + '</span>' : '') +
|
||
'<i class="chev">›</i></button>';
|
||
/* 开关行:aria-checked 让读屏也能拿到状态,样式只认 .on */
|
||
return '<button class="st-row sw' + (r[3] ? ' on' : '') + ' ripple" role="switch" ' +
|
||
'aria-checked="' + (r[3] ? 'true' : 'false') + '" data-sw="' + r[1] + '">' +
|
||
'<span class="st-rt">' + r[1] + '<em>' + r[2] + '</em></span>' +
|
||
'<i class="sw-track"><b></b></i></button>';
|
||
}).join('');
|
||
return (g.t ? '<h3 class="d-sec">' + g.t + '</h3>' : '') + '<div class="st-card">' + rows + '</div>';
|
||
}).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 次胎动,小家伙很有精神」
|
||
「午后和睡前是他最爱动的时候」—— 妈妈、宝宝、系统三方在场,宝宝被当成第三个人
|
||
指指点点,读起来就是一份日报。
|
||
现在全部改成宝宝用第一人称直接对妈妈说:称呼妈妈为「你」,自称「我」,
|
||
该出现宝宝的地方一律是「我」,不再有「他 / 小家伙 / 宝宝」。
|
||
建议也照这个来 —— 不是「你应该怎样」,而是「你这样做我会更舒服」。
|
||
|
||
两条边界,别越过去:
|
||
· 口吻变软了,但不吓人那条铁律照旧 —— 不提异常、不提风险、不提就医。
|
||
· 只有宝宝在说的话改口吻。页尾免责声明(以及 2026-10-02 前气泡上的「AI」角标,已按用户要求去掉)
|
||
是系统在说话,保持原样,否则就成了宝宝自己声明自己不是医生。
|
||
(气泡落款那句「AI自动汇总你每日记录」第五批按用户要求删了,
|
||
机器生成的提示现在只剩那枚角标。)
|
||
· mood 那枚小圆标(安心 / 安稳…)说的是这一天的状态,不是谁在说话,不动。
|
||
|
||
⚠️ AI_LOGS[0].txt 在首页气泡里还有一份手写副本(index.html 的 .ab-say,
|
||
带 <b> 和 <br> 的排版所以没法共用变量)。改这一条务必两处一起改。 */
|
||
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')。
|
||
这五条按定义顺序就是「今天、昨天、前天…」,所以直接按下标当天数往回推
|
||
(backAt 从那一刻倒推)。不这么做的话,档案打印出来会是
|
||
封面「孕 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 '<div class="ai-st"><b>' + s[1] + '<em class="' + cls + '">' + txt + '</em></b>' +
|
||
'<span>' + s[0] + '</span></div>';
|
||
}).join('');
|
||
const aiTipsHTML = d => d.tips.map((t, i) =>
|
||
'<article class="ai-tip c-' + t.tone + '">' +
|
||
'<span class="at-n" aria-hidden="true">' + (i + 1) + '</span>' +
|
||
'<span class="at-cat">' + t.cat + '</span>' +
|
||
'<h4 class="at-head">' + t.head + '</h4>' +
|
||
'<p class="at-txt">' + t.txt + '</p>' +
|
||
'</article>').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) => '<button class="ai-log ripple" data-ai="' + (i + 1) + '">' +
|
||
'<span class="al-dot" aria-hidden="true"></span>' +
|
||
'<span class="al-date">' + l.date + '</span>' +
|
||
'<span class="al-week">' + l.week + '</span>' +
|
||
'<span class="al-txt">' + l.txt + '</span></button>').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'];
|
||
|
||
/* ⚠️ 2026-09-19(9.19 需求 1.2):这里原来有 PAN_DEFAULT / PAN 两张**百度网盘
|
||
链接表**、panOf()、以及企业微信兑换码 WX_CODE / wxCodeOK()。
|
||
需求方确认「扫码加顾问 → 回复领资料 → 顾问直接发资料」之后,H5 不再发放网盘
|
||
链接,这套(连同 #modalPan 弹窗、givePan()、后台下发的 pan/panDefault/wxCode)
|
||
整块删除。要恢复「H5 自己发链接」,等于把「解锁」概念加回来,先回问需求方。 */
|
||
|
||
const isFav = k => state.resFav.indexOf(k) >= 0;
|
||
function resCardHTML(k) {
|
||
const r = RES[k];
|
||
/* ⚠️ 这里原来还有「领过的那一份」分支:整张卡加 .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=""></span>' +
|
||
'<span class="ri-body"><span class="ri-title">' + r.title + '</span>' +
|
||
'<span class="ri-desc">' + r.desc + '</span>' +
|
||
/* 底部这行:分类 + 适用孕周 + 右侧状态标。
|
||
⚠️ 状态标现在恒为「免费」(原来领过的会换成「已领 ✓」,已随「已领」概念删除)。 */
|
||
'<span class="ri-row">' +
|
||
'<em class="ri-cat">' + r.cat + '</em>' +
|
||
'<em class="ri-ga">' + r.meta[2] + '</em>' +
|
||
'<em class="pill-free">免费</em>' +
|
||
'</span></span></button>';
|
||
}
|
||
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 原地不动,无副作用。
|
||
⚠️ 原来还有个 .chip-got(✓ 已领)跟着一起挪,已随「已领」概念删除。 */
|
||
const chipsRow = $('#resChips'), favChip = $('.chip-fav', chipsRow);
|
||
const favN = state.resFav.length;
|
||
if (favN) chipsRow.insertBefore(favChip, chipsRow.children[1]);
|
||
else chipsRow.appendChild(favChip);
|
||
favChip.textContent = favN ? '★ 收藏 ' + favN : '★ 收藏';
|
||
|
||
/* 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 + ' 份资源'
|
||
: '共 ' + keys.length + ' 份资源 · 更多内容按孕周陆续更新')
|
||
: (fav ? '还没有收藏 · 打开任意资源,点右上角 ☆ 就能收进来'
|
||
: '该分类下暂无资源,换一个试试~');
|
||
$$('#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 => '<span>' + m + '</span>').join('');
|
||
$('#rdBody').innerHTML = r.body
|
||
.map(p => '<h4>' + p[0] + '</h4><p>' + p[1] + '</p>').join('');
|
||
$('#rdMore').innerHTML = RES_ORDER
|
||
.filter(k => k !== state.resKey).slice(0, 3).map(resCardHTML).join('');
|
||
bindResCards($('#rdMore'));
|
||
/* 原来这里无条件重置成 ☆,收藏过的资源再打开会显示成没收藏。
|
||
改成按 state.resFav 回显 */
|
||
const fav = state.resFav.indexOf(state.resKey) >= 0;
|
||
const star = $('#resStar');
|
||
star.classList.toggle('on', fav);
|
||
star.textContent = fav ? '★' : '☆';
|
||
/* 底部左键已从「收藏」改成「资源库」,是个固定文案的导航按钮,不再需要回显。
|
||
⚠️ 右键「立即领取」原来会按 state.resGot 回显成「再拿一次链接」,
|
||
9.19 需求取消「已领」概念后改回**固定文案**(点它只是弹顾问二维码)。 */
|
||
$('#rdGet').textContent = '立即领取';
|
||
$('#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
|
||
}
|
||
});
|
||
|
||
/* 点「立即领取」= 打开企业微信顾问二维码。
|
||
⚠️ 2026-09-19(9.19 需求 1.2):这里原来是「兑换码闸门」—— 未解锁的用户先被拦下,
|
||
填码解锁之后 H5 才把网盘链接交给他。需求方确认「扫码加顾问 → 回复「领资料」 →
|
||
顾问直接发资料」之后,闸门、兑换码、网盘弹窗整条链路都删了(连同 givePan()、
|
||
state.wxUnlocked、wxPending),这颗按钮现在只做一件事:弹二维码。
|
||
不存在「已解锁 / 未解锁」两种起点,所以这里也没有任何状态判断 —— 别再加回来。 */
|
||
$('#rdGet').addEventListener('click', () => { openWx(); });
|
||
|
||
/* ================= 企业微信顾问弹窗(单态) =================
|
||
三个入口共用这一个壳:「我的」页服务卡 #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());
|
||
/* 资源库顶部那条 banner 和「我的」页服务卡是同一件事、同一个入口。
|
||
⚠️ 走 openWx() 而不是挂 data-open="modalWx":那条通用通道直接调 openModal,
|
||
不经过这里 —— 今天两者等价,但留一个入口,以后要统一加埋点/清理只改一处。 */
|
||
$('#rlGift').addEventListener('click', () => openWx());
|
||
|
||
/* ⚠️ 2026-09-19(9.19 需求):这里原来有一个 syncUnlockCopy(),按「已解锁 / 未解锁」
|
||
把首页书架与资源库的两句引导语改成两个版本,并给资源库顶部试用装 banner 挂 .hide。
|
||
兑换码闸门取消后不存在解锁态:
|
||
· 两句文案改为 index.html 里的**静态文案**(#shelfLeadTxt / #rlLead 的 id 也删了,
|
||
顺带解掉「给 <p> 写 innerHTML 会静默换掉「查看全部 ›」的监听」那个坑);
|
||
· banner 恒显示,`.hide` 那套不再需要。
|
||
别把这个函数加回来 —— 它唯一的存在理由就是「两种解锁状态」。 */
|
||
|
||
/* ================= 档案生成:周档案 / 完整档案 =================
|
||
产物必须是一张图(要能存相册、发朋友圈、拿去打印),所以直接用 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 + <small>cm</small> + <em>P58</em>」三截,
|
||
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';
|
||
/* 导出层的「这批图叫什么」由 beginSheets() 每次现设,不从 posterKind 推。
|
||
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';
|
||
/* ⚠️ 产后态 state.week 是 null:别再拼出「第 null 周档案」,退化成完整档案。 */
|
||
const weekOk = (posterKind === 'week' && state.week != null);
|
||
const D = posterData(posterKind);
|
||
const host = $('#pstPages');
|
||
beginSheets(host,
|
||
weekOk ? '第' + state.week + '周档案' : '完整档案',
|
||
state.babyName + (weekOk ? '的本周档案' : '的成长档案'));
|
||
|
||
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 就在名字里标上页码。
|
||
/* i 省略 = 单张;带 i 就在名字里标上页码。
|
||
名字里那截「是什么」由 beginSheets() 时定下的 exportTag 决定。 */
|
||
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 委托:
|
||
按钮上写什么就调 exportAct 里的哪一个,不用逐个绑。 */
|
||
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. 没有(微信内置浏览器就没有)→ 退回 <a download>,
|
||
安卓会落进「下载」相册,iOS 会进「文件」;
|
||
3. 再不行 → 长按图片,这是微信里唯一能进相册的路。
|
||
以前是先走 2,iOS 上点完只是进了「文件」,用户在相册里怎么翻都找不到。 */
|
||
exportAct.save = () => {
|
||
if (!posterURLs.length) { toast('这台设备生成不了图片,长按图片试试'); return; }
|
||
const many = posterURLs.length > 1;
|
||
|
||
const download = () => {
|
||
/* 一次点 N 个 <a download> 有的浏览器会拦,隔开一点点再点下一张 */
|
||
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();
|
||
};
|
||
|
||
/* ================= 搜索层 ================= */
|
||
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', '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 (sheet.classList.contains('show')) closeSheet();
|
||
});
|
||
|
||
/* ================= 初始化 ================= */
|
||
function init() {
|
||
/* 返回首页 → 清 last_app(后退重载 / 从应用页 href 回来两种情形;bfcache 由 pageshow 处理)。
|
||
放在 resumeLastApp 之前:后退回来的会话里恢复闸门已占住,两者不会打架。 */
|
||
clearLastAppOnBackHome(false);
|
||
/* 冷启动恢复:读一次 last_app,命中就跳到上次进入的 H5 应用(会话内只尝试一次,
|
||
见 resumeLastApp)。放在最前面,尽量少闪一下首页。 */
|
||
resumeLastApp();
|
||
/* 首屏向小程序宿主上报当前位置:宿主据此解析分享卡片(见 reportHostLocation 说明)。 */
|
||
if (window.wxAuth && window.wxAuth.reportLocation) window.wxAuth.reportLocation();
|
||
/* 默认预产期是「今天 + 3 个月」现算出来的,标记里那些
|
||
「孕 24 周 +3 天 / 预产期 2024年12月22日」只是设计稿留下的占位,
|
||
进门先按真实 state 刷一遍,免得首屏闪一下旧数字 */
|
||
syncPregnancy();
|
||
/* ⚠️ 这里原来还有一次 syncBio()。一句话签名整块已删(见 state 里那段说明)。 */
|
||
/* ⚠️ 这里原来还有一次 syncUnlockCopy()(按解锁态改写首页/资源库两句引导语)。
|
||
闸门取消后文案是静态的,函数已删。 */
|
||
animateRings();
|
||
/* ⚠️ 底 tab 红点与首页「宝宝来信」卡的 owner 都是 laixin 未读数(syncLaixinReminder),
|
||
不由任何本地计数驱动。别在这里再写一遍 —— 两处都写会互相覆盖。
|
||
两者在 syncLaixinReminder() 拉到数之前保持隐藏/收起(index.html 里就是默认态)。 */
|
||
// 让档案页的分段控件在首次布局后归位
|
||
setTimeout(() => { $('#archiveSeg')._reflow && $('#archiveSeg')._reflow(); }, 60);
|
||
/* 远程数据按**依赖顺序**取,别并发(2026-09-22 收口):
|
||
① 身份 —— hashParam('openid'/'unionid')(URL → 同源缓存兜底,见 wx-auth.js)
|
||
② 资料 —— box_app /profile + /pregnancy:**会从上游同步预产期/小名/宝宝名**,
|
||
也是决定 state.pregEmpty 的唯一来源
|
||
③ 应用数据 —— 今日脚印 / 首页工具卡 / 来信未读(都依赖 ①,足迹的显隐还依赖 ②)
|
||
⚠️ 原来这五个 fetch 一起发、谁先回来谁写 DOM —— 竞态都是这么来的:
|
||
今日脚印整块显隐被 profile 回来后又翻回去;来信提示的红点与卡各写一份判据。
|
||
所以这里显式**等 ② 落地**再取 ③。
|
||
⚠️ 加兜底超时:② 万一挂住(上游不响应),③ 也必须照常发出,别让首页空等。
|
||
⚠️ 必须放在 init() 渲染之后(若在脚本解析时就拉,fetch 的 then 常比 init 先跑,
|
||
随后 init 又把 state 重置回默认值 → 档案数据被覆盖)。 */
|
||
Promise.race([
|
||
applyRemoteProfile(),
|
||
new Promise(resolve => setTimeout(resolve, REMOTE_PROFILE_WAIT_MS)),
|
||
]).then(() => {
|
||
// 第三批:应用数据(资料库/记录/内容 + 今日脚印 + 首页工具卡 + 来信未读)
|
||
applyRemoteAll();
|
||
applyRemoteFootprint();
|
||
applyRemoteCardsFetch();
|
||
syncLaixinReminder();
|
||
loadBlindbox(); // 孕期盲盒:要等 ② 的预产期落地(服务端按同一份上游视图算孕周)
|
||
});
|
||
}
|
||
/* ---------- 远程配置(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 不丢),
|
||
这里按 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 + '=([^&#]*)'));
|
||
const v = m ? decodeURIComponent(m[1]).split('?')[0] : '';
|
||
if (v) return v;
|
||
} catch (e) { /* 落到下面的兜底 */ }
|
||
/* 身份两键(openid/unionid)URL 没带时回落**同源缓存**:
|
||
H5 应用里「返回首页」是页面自己发起的同源跳转,宿主的 web-view src 没变、
|
||
_applyIdentity() 不会触发,URL 上永远不会有身份(第三方应用还会主动把
|
||
openid 从自己的 URL 上擦掉)。缓存由 wx-auth.js 的 identity() 写入 ——
|
||
凡是引了共享库的页面(含 mama-box 自己)拿到 URL 身份时都会顺手存一份。
|
||
纪律(URL 永远权威、只在本 origin 有效、只存 openid/unionid)见
|
||
js/wx-auth.js 顶部那段;命中缓存时 identity() 会打一条日志便于排查。 */
|
||
if (name === 'openid' || name === 'unionid') {
|
||
try {
|
||
const id = (window.wxAuth && window.wxAuth.identity) ? window.wxAuth.identity() : null;
|
||
if (id && id[name]) return id[name];
|
||
} catch (e) {}
|
||
}
|
||
return '';
|
||
}
|
||
/* ---------- 身份未就绪提示(2026-09-20) ----------
|
||
宿主(小程序首页壳)拿不到 openid 时会**匿名加载**本页 —— 页面照常渲染,
|
||
但全是占位数据(孕周 `--`、档案空、跳应用不带身份)。以前这种情况完全静默,
|
||
排查时只能靠服务端日志反推;现在给一条可见提示。
|
||
⚠️ 只在「确实没有 openid」时显示;一旦宿主把身份注入回来(onShow 重载),
|
||
applyRemoteProfile 会再跑一次,这里要 hide。
|
||
⚠️ 不做自动轮询/自动 reload:宿主只在它自己重设 web-view src 或 onShow 时注入身份,
|
||
页面自己 reload 同一 URL 并不会让身份出现(反而可能循环),所以只留手动重试。 */
|
||
/* 2026-09-30 产品要求:顶部这条黄色提示条先不给用户看。
|
||
只关展示,判定逻辑、标记、重试都原样留着;恢复时删掉下面这个开关即可。
|
||
没身份时仍在控制台打一条,排查不受影响。 */
|
||
const ID_NOTICE_ENABLED = false;
|
||
function showIdNotice(reason) {
|
||
if (!ID_NOTICE_ENABLED) { try { console.warn('[mama-box] 未获取到微信身份(提示条已关闭)', reason || ''); } catch (e) {} return; }
|
||
const el = $('#idNotice'), txt = $('#idNoticeTxt');
|
||
if (!el) return;
|
||
if (txt) {
|
||
/* ⚠️ 重试过一次之后(sessionStorage 计数)文案要保留「请退出小程序重进」,
|
||
否则重载回来又变回温和版,用户以为重试没发生过(重试是 location.replace,
|
||
文案状态活不过那一次跳转,只能落到 sessionStorage)。 */
|
||
let tried = reason === 'retried';
|
||
if (!tried) {
|
||
try { tried = (parseInt(sessionStorage.getItem('mmbox.idretry') || '0', 10) || 0) > 0; } catch (e) {}
|
||
}
|
||
txt.textContent = tried
|
||
? '仍未获取到微信身份,请退出小程序后重新进入'
|
||
: '未获取到微信身份,页面数据可能不完整';
|
||
}
|
||
el.classList.remove('hide');
|
||
}
|
||
function hideIdNotice() {
|
||
const el = $('#idNotice');
|
||
if (el) el.classList.add('hide');
|
||
}
|
||
/* 重试:只做一次「换 URL 重载」——带上 ?t= 让宿主/缓存放行。
|
||
宿主 onShow 也会重新注入身份(切到别的 tab 再回来即可),提示文案里已说明。 */
|
||
function retryIdentity() {
|
||
let n = 0;
|
||
try { n = parseInt(sessionStorage.getItem('mmbox.idretry') || '0', 10) || 0; } catch (e) {}
|
||
try { sessionStorage.setItem('mmbox.idretry', String(n + 1)); } catch (e) {}
|
||
showIdNotice('retried');
|
||
setTimeout(() => {
|
||
try {
|
||
const u = location.href.split('#')[0].split('?')[0] + '?t=' + Date.now();
|
||
location.replace(u);
|
||
} catch (e) { location.reload(); }
|
||
}, 500);
|
||
}
|
||
$('#idNoticeRetry').addEventListener('click', retryIdentity);
|
||
|
||
/* ② 资料最多等这么久(ms)就让 ③ 应用数据照常发出:上游慢/挂住时首页不能空等 */
|
||
const REMOTE_PROFILE_WAIT_MS = 10000;
|
||
|
||
let _profileApplied = false;
|
||
/* ⚠️ 返回 Promise:调用方要**等资料落地**再取应用数据(预产期/小名来自上游同步,
|
||
它决定 state.pregEmpty,而今日脚印的显隐依赖它)。别改成 fire-and-forget。 */
|
||
function applyRemoteProfile() {
|
||
if (_profileApplied) return Promise.resolve(); // init 只跑一次,防重复 fetch
|
||
_profileApplied = true;
|
||
const openid = hashParam('openid');
|
||
/* 访客态:宿主未注入身份(冷启动 silenceAuth 失败走「匿名加载 H5」兜底)。
|
||
⚠️ 绝不能停在内置演示默认值上(小汤圆 / 孕27周 都是前端现算的假数据)——
|
||
全部切占位/引导;宿主拿到身份后会带 openid 重载本页,届时自动切真实数据。 */
|
||
if (!openid) {
|
||
/* ⚠️ 可见提示(2026-09-20):宿主没注入身份时别静默装正常。
|
||
排查路径见 showIdNotice() 上方那段注释。 */
|
||
showIdNotice();
|
||
state.pregEmpty = true;
|
||
state.due = null; state.week = null; state.day = null;
|
||
state.babyName = ''; state.userName = '';
|
||
syncPregnancy(); /* 占位 + 隐藏脚印时间轴(内含 renderLetterReminder(),会收起来信提示) */
|
||
/* 兜底再算一次:访客态不该出现任何「你有新信」的暗示(卡 + 红点一起收)。
|
||
唯一渲染入口是 renderLetterReminder(),别再这里单独 hideLetterCard()。 */
|
||
renderLetterReminder();
|
||
return Promise.resolve();
|
||
}
|
||
const base = '/api/mp/public/mama-box/';
|
||
/* ⚠️ profile 拉取要带上 unionid(2026-09-21 排查「首页个人信息为空」):
|
||
服务端「先取用户中心」需要 unionid —— 只有 openid 时走本地 openid→unionid
|
||
映射(Member),映射缺失的老用户(资料全靠 CRMEB 同步来的)会先取失败,
|
||
预产期/孕周永远预填不上,而第三方应用因为 URL 自带 unionid 一直有数据。
|
||
与 footprint/today 同款:hash 里有就带上,服务端成功后会回写映射。 */
|
||
const _pfUnionid = hashParam('unionid');
|
||
const _pfQs = 'openid=' + encodeURIComponent(openid) +
|
||
(_pfUnionid ? '&unionid=' + encodeURIComponent(_pfUnionid) : '');
|
||
/* 并行拉 个人档案 + 孕期状态:
|
||
- profile:babyName/userName/babySex/dueDate(身份 + 建档字段)
|
||
- pregnancy:week/day/due 以服务端权威派生为准(避免客户端重复算、口径统一)
|
||
拉不到(无 openid / 未返回 200 / 断网)就静默保持内置默认值,页面照常可用。 */
|
||
/* 走到这里说明这次加载是带身份的(openid 非空)→ 把上一次的提示收掉 */
|
||
hideIdNotice();
|
||
return Promise.all([
|
||
fetch(base + 'profile?' + _pfQs).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) {
|
||
if (pg.result.postpartum) {
|
||
/* 产后态(2026-09-22):上游 journey_stage=postpartum / 有 baby_birth_date。
|
||
⚠️ 必须**先**判它再判 empty/week==null —— 产后返回体的 week 也是 null,
|
||
落到下面那个分支会被当成「未完善资料」,给已生产的用户弹建档引导。 */
|
||
state.postpartum = true;
|
||
state.pregEmpty = false;
|
||
state.week = null;
|
||
state.day = null;
|
||
state.babyAgeTxt = pg.result.babyAgeTxt || '宝宝已出生';
|
||
state.babyAgeDays = (pg.result.babyAgeDays == null ? null : pg.result.babyAgeDays);
|
||
state.babyBirthDate = pg.result.babyBirthDate || '';
|
||
if (pg.result.due) { const d = parseDay(pg.result.due); if (d) state.due = d; }
|
||
} else if (pg.result.empty || pg.result.week == null) {
|
||
state.postpartum = false;
|
||
state.pregEmpty = true;
|
||
state.due = null;
|
||
state.week = null;
|
||
state.day = null;
|
||
} else {
|
||
state.postpartum = false;
|
||
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();
|
||
if (plHook) setTimeout(plHook, 0); // 「此刻」按孕周重拉(setTimeout:等下面 syncStaticDates 把 #calNext 算好)
|
||
/* 新用户空态:syncPregnancy 已写完引导占位(含称呼/静态位归零)。
|
||
⚠️ 下面 syncBabyWords/syncStaticDates 全按 state.week 重算,
|
||
而 pregEmpty 时 state.week 还是内置默认 27 —— 不拦住的话,
|
||
刚写好的占位会被改回「孕 27 周 / 9月15日」演示值。
|
||
⚠️ 产后态同样要拦:syncPregnancy 的产后分支已写好「已出生」与 '--' 占位,
|
||
而 state.week 是 null → syncStaticDates 会按 ga=0 算出**看着正常但全错**的日期。 */
|
||
if (state.pregEmpty || state.postpartum) { return; }
|
||
syncBabyWords();
|
||
syncStaticDates();
|
||
}).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;
|
||
/* ⚠️ 接口还返回 got(已领),但 H5 侧已无「已领」概念(9.19 需求),
|
||
这里**有意不读** —— 别顺手把它加回 RES/state。 */
|
||
});
|
||
// 同步收藏状态(对齐后端)。已领(resGot)已删除,见文件顶部 state 那段说明。
|
||
state.resFav = items.filter(function (i) { return i.fav; }).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');
|
||
/* ⚠️ #kickTodayHome 归**卡片接口**(tool-cards,来自数胎动应用本身)优先:
|
||
mama-box 早就不自己记胎动了(卡上那颗「记录」是跳去数胎动),本地 records 常年是 0。
|
||
两个接口并行返回、先后不定,所以用 cardsOwned.kick 这个旗子定优先级,而不是赌顺序。 */
|
||
if (a && !cardsOwned.kick) a.textContent = state.kickToday;
|
||
if (b) b.textContent = state.todayCount;
|
||
if (c) c.textContent = state.kickQuick || 0;
|
||
}
|
||
|
||
/* 禁止双指缩放(2026-10-01 用户要求首页固定)。iOS 微信会忽略 viewport 的 user-scalable=no,
|
||
所以再拦一道:iOS 的 gesture 事件 + 多指 touchmove;双击放大由 CSS touch-action:manipulation 处理。 */
|
||
(function noZoom() {
|
||
['gesturestart', 'gesturechange', 'gestureend'].forEach(t => document.addEventListener(t, e => e.preventDefault(), { passive: false }));
|
||
document.addEventListener('touchmove', e => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false });
|
||
})();
|
||
/* ================= 首页「孕育工具」卡片(box_app 封装数胎动平台数据,2026-09-22) =================
|
||
数据源:GET /api/mp/public/mama-box/tool-cards(box_app 归一化后的稳定契约;
|
||
上游是数胎动平台的 GET /taidong/api/user/app-cards,H5 不直连、也不持有上游 token)。
|
||
|
||
两条纪律:
|
||
① **接口优先、失败回退本地**:ok=false 或某槽缺失 → 保留卡上现有值(本地派生/占位),
|
||
页面绝不因为上游抖动而变空;`stale=true` 时接口回吐的是 6h 内的旧值,照样可用。
|
||
② **名称与链接仍以 App 表为准**:卡片点击走 MINI_APP_MAP + APP_BY_ID(mama-box AGENTS §4.4
|
||
「不要在本仓库写死应用名单」);接口给的 appId/path 只作诊断 —— 上游的 app_id 是
|
||
URL slug(`taier-guzhong`),与 App 表 identifier(`taierguzhong`)刻意不同,不能拿来 join。
|
||
|
||
每槽落到哪:kick→#kickTodayHome(+#kickExtra) / fweight→.fweight .mc-sub /
|
||
weight→#weightVal(+#weightExtra) / cal→#calNext / curve→.curve .mc-sub。
|
||
⚠️ 上游字符串一律走 textContent,**绝不用 innerHTML**(同 footprint 的写法)。
|
||
⚠️ fweight/cal/curve 三张是固定高度且列窄,没有第二行的位置 —— 它们的 sub 暂不展示,
|
||
要展示得先重排 .bento 的高度账(CSS 里 .mc-extra 那段有说明)。 */
|
||
const cardsOwned = { kick: false, cal: false }; // 卡片接口已接管哪些格(本地源据此让位)
|
||
function applyRemoteCards(r) {
|
||
if (!r || r.ok === false || !(r.cards || []).length) return; // 降级:保留现有值
|
||
const bySlot = {};
|
||
r.cards.forEach(c => { if (c && c.slot) bySlot[c.slot] = c; });
|
||
const cardOf = slot => $('.mini-card[data-mini="' + slot + '"]');
|
||
const mark = (slot, c) => { const el = cardOf(slot); if (el) el.classList.toggle('is-warn', !!c.warn); };
|
||
const setText = (sel, v) => { const el = $(sel); if (el) el.textContent = v || ''; };
|
||
|
||
const k = bySlot.kick;
|
||
if (k) {
|
||
/* 卡上静态文案就是「今天 <b>N</b> 次」,上游的 label/unit 正是「今天 / 次」——
|
||
只写数字、保留 <b> 的数字强调。上游若改措辞,这里要连带改静态文案。 */
|
||
cardsOwned.kick = true;
|
||
const b = $('#kickTodayHome');
|
||
if (b) b.textContent = k.value || (k.empty ? '0' : '--');
|
||
setText('#kickExtra', k.sub);
|
||
mark('kick', k);
|
||
}
|
||
const f = bySlot.fweight;
|
||
if (f) { setText('.mini-card.fweight .mc-sub', f.text); mark('fweight', f); }
|
||
|
||
const w = bySlot.weight;
|
||
if (w) {
|
||
const big = $('#weightVal');
|
||
if (big) {
|
||
big.textContent = w.value || '--';
|
||
/* 单位单独小字;只在有值时补,避免出现「-- kg」 */
|
||
if (w.value && w.unit) {
|
||
const sm = document.createElement('small');
|
||
sm.textContent = w.unit;
|
||
big.appendChild(sm);
|
||
}
|
||
}
|
||
setText('#weightExtra', w.sub);
|
||
mark('weight', w);
|
||
}
|
||
|
||
const cal = bySlot.cal;
|
||
if (cal) {
|
||
cardsOwned.cal = true; // 让 syncStaticDates 的本地推算让位(两者并行、先后不定)
|
||
setText('#calNext', cal.text || '--');
|
||
if (cal.warn) {
|
||
/* 产检已过期未记时上游给 label=待补记:卡上只有一行,把 label 拼在日期前一起标红 */
|
||
setText('#calNext', (cal.label && cal.label !== '下次产检') ? cal.label + ' · ' + (cal.text || '') : (cal.text || '--'));
|
||
}
|
||
mark('cal', cal);
|
||
}
|
||
|
||
const cv = bySlot.curve;
|
||
if (cv) {
|
||
const el = $('.mini-card.curve .mc-sub');
|
||
if (el) {
|
||
/* 有数据时上游 text 是「最近间隔 4分12秒」,那列只有 ≈73px 放不下(会省略掉最关键的数字)→
|
||
改用紧凑的 value+unit(「4分12秒」);空态 text 本来就是「孕 7 周」这种短句,直接用。 */
|
||
el.textContent = (!cv.empty && cv.value)
|
||
? (cv.value + (cv.unit || ''))
|
||
: (cv.text || '--');
|
||
/* 摘掉 data-wk:否则 syncStaticDates 的 [data-wk] 循环会把「最近间隔 4分12秒」覆盖成孕周 */
|
||
el.removeAttribute('data-wk');
|
||
}
|
||
mark('curve', cv);
|
||
}
|
||
}
|
||
|
||
/* ================= 宝宝消消乐 · 推广入口(2026-09-30) =================
|
||
GAME_URL 为空 → 入口不显示。小棋盘:每 2.6 秒随机一行/一列先亮一下,再「消掉」换一批新的掉下来。 */
|
||
const GAME_URL = 'https://beian3.douba.top/myxx/index.html'; // 由「母婴消消」会话维护,部署在 47.120.70.60,不需要身份参数(多带无害)
|
||
const GAME_ID = 'baobao-xiaoxiaole';
|
||
(function gamePromo() {
|
||
const sec = $('#gamePromo'), board = $('#gpBoard');
|
||
if (!sec || !GAME_URL) return;
|
||
sec.classList.remove('hide');
|
||
$('#gpCard').addEventListener('click', () => jumpApp(GAME_URL, GAME_ID));
|
||
const ITEMS = ['🍼', '🧸', '👶', '🧦', '🛁', '🧴', '👕', '🎀'];
|
||
const rnd = () => ITEMS[Math.floor(Math.random() * ITEMS.length)];
|
||
const cells = [];
|
||
for (let i = 0; i < 9; i++) { const c = document.createElement('i'); c.textContent = rnd(); board.appendChild(c); cells.push(c); }
|
||
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8]];
|
||
setInterval(() => {
|
||
if (document.hidden) return;
|
||
const line = LINES[Math.floor(Math.random() * LINES.length)], e = rnd();
|
||
line.forEach(k => { cells[k].textContent = e; cells[k].classList.add('is-hint'); });
|
||
setTimeout(() => line.forEach(k => { cells[k].classList.remove('is-hint'); cells[k].classList.add('is-pop'); }), 700);
|
||
setTimeout(() => line.forEach(k => {
|
||
const c = cells[k]; c.textContent = rnd(); c.classList.remove('is-pop', 'is-in'); void c.offsetWidth; c.classList.add('is-in');
|
||
}), 1100);
|
||
}, 2600);
|
||
})();
|
||
|
||
/* ================= 3D 婴儿房 · 页面上的一道空隙(2026-10-01 三版)=================
|
||
设计写在 index.html 的 #nursery 注释里。地址由「3D 婴儿房」会话(33d6c837)维护;
|
||
带 return_url(首页净地址,不含 hash),对方返回按钮读到就跳回来。syncPregnancy() 每次都会调 nurseryRender()。 */
|
||
const NURSERY_URL = 'https://beian3.douba.top/yef/';
|
||
const NURSERY_ID = 'yef-nursery';
|
||
function nurseryRender() {
|
||
if (!$('#nursery')) return;
|
||
/* 2026-10-01 封面换成整栋小别墅(婴儿房是二楼剖开那间),H5 进去先看整栋、标题「我们的家」,这里跟着改 */
|
||
$('#nyTitle').textContent = '我们的家';
|
||
let days = null;
|
||
if (!state.postpartum && state.pregEmpty === false && state.due) {
|
||
const t = new Date(); t.setHours(0, 0, 0, 0);
|
||
days = Math.round((state.due - t) / 864e5);
|
||
}
|
||
$('#nySub').innerHTML = state.postpartum ? 'TA 已经住进来啦,再添点新东西吧'
|
||
: days > 0 ? '还有 <em>' + days + '</em> 天入住 · 攒兜豆把它布置好'
|
||
: days != null ? 'TA 随时会住进来,快去布置好'
|
||
: '做孕期任务攒兜豆,一件件布置好';
|
||
}
|
||
(function nurseryInit() {
|
||
const sec = $('#nursery');
|
||
if (!sec) return;
|
||
$('#nyBtn').addEventListener('click', () =>
|
||
jumpApp(NURSERY_URL + '?return_url=' + encodeURIComponent(location.origin + location.pathname), NURSERY_ID));
|
||
/* 视差:图层始终对齐 .screen 的可视区 → 页面滚、房间不动,像铺在整页背后 */
|
||
const layer = $('#nypLayer'), scr = sec.closest('.screen');
|
||
let raf = 0;
|
||
const place = () => {
|
||
raf = 0;
|
||
if (!scr) return;
|
||
const s = scr.getBoundingClientRect(), r = sec.getBoundingClientRect();
|
||
if (!s.height || !r.height) return;
|
||
layer.style.height = s.height + 'px';
|
||
layer.style.transform = 'translate3d(0,' + Math.round(s.top - r.top) + 'px,0)';
|
||
};
|
||
const req = () => { if (!raf) raf = requestAnimationFrame(place); };
|
||
if (scr) scr.addEventListener('scroll', req, { passive: true });
|
||
window.addEventListener('resize', req);
|
||
if ('IntersectionObserver' in window) new IntersectionObserver(req).observe(sec);
|
||
place();
|
||
/* 封面版本:3D 婴儿房会话换图(文件名不变)时改这里,强制微信 webview 重新拉图 */
|
||
const NY_COVER_V = '20261001p';
|
||
const tick = () => {
|
||
const h = new Date(Date.now() + 8 * 3600e3).getUTCHours(); // 北京时间
|
||
const night = h >= 19 || h < 6;
|
||
sec.classList.toggle('is-night', night);
|
||
const img = $('#nypImg'), src = 'assets/img/nursery-' + (night ? 'night' : 'day') + '.jpg?v=' + NY_COVER_V;
|
||
if (img.getAttribute('src') !== src) img.setAttribute('src', src);
|
||
};
|
||
tick(); setInterval(tick, 600e3);
|
||
nurseryRender();
|
||
})();
|
||
|
||
/* ================= 此刻,孕妈们在做什么(2026-10-01,需求文档 v1.0 第二节) =================
|
||
GET /api/mp/public/mama-box/pulse(免登录、所有人同一份)→ { show, total, today[], feed[] }。
|
||
· 数据是模拟还是真实由 box_app 后台 pulseMode 决定,前端不区分、不兜底造数:show=false / 接口失败 → 整卡隐藏。
|
||
⚠️ 模拟阶段是 AGENTS.md §4.12 登记的例外,只限这张卡;别把这里的「失败兜底」改成前端自造数字。
|
||
· 动效:进入视口时大数字从 0 数上去;之后每 60 秒刷新一次,数字平滑涨到新值;动态每 3 秒往上翻一条。
|
||
· 今日三项可点:kick / weight 复用首页工具卡的跳转(MINI_APP_MAP),game 去消消乐。
|
||
· 2026-10-01 A+B 改版:已建档时带 ?week= 拉,接口多给 peer → 卡片换成「和你一样孕 N 周的妈妈们」:
|
||
同周人数 + 她们今天在做(百分比条,右侧对比「你今天还没数 ›」可直达)+ 今天别忘了(产检倒数 / 本周盲盒)
|
||
+ 同周动态。无 peer(访客 / real 模式)→ 一行总人数 + 动态。
|
||
· 同日再改:收进孕育工具卡底部做轻量条,删「她们在聊」和全站「今日三项」小卡(见 index.html #pulse 注释)。 */
|
||
const PULSE_ICO = {
|
||
kick: '<svg viewBox="0 0 24 24"><ellipse cx="10" cy="15" rx="5" ry="6.5" fill="currentColor"/><circle cx="16.5" cy="6" r="2" fill="currentColor"/><circle cx="12.5" cy="4.6" r="1.6" fill="currentColor"/><circle cx="19.4" cy="9.6" r="1.6" fill="currentColor"/></svg>',
|
||
weight: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M8 10a4 4 0 018 0z" fill="currentColor"/><path d="M12 10l1.6-2.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>',
|
||
cal: '<svg viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="15" rx="3.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M3.5 10h17M8 3v4M16 3v4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
|
||
game: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2.5" fill="currentColor"/><rect x="13" y="3" width="8" height="8" rx="2.5" fill="currentColor" opacity=".6"/><rect x="3" y="13" width="8" height="8" rx="2.5" fill="currentColor" opacity=".6"/><rect x="13" y="13" width="8" height="8" rx="2.5" fill="currentColor"/></svg>',
|
||
box: '<svg viewBox="0 0 24 24"><rect x="4" y="11" width="16" height="10" rx="2" fill="currentColor"/><rect x="2.5" y="7.5" width="19" height="4.5" rx="1.6" fill="currentColor"/><path d="M12 7.5V21" stroke="#fff" stroke-width="2"/><path d="M12 7.5C10 3.5 6 4 7.5 6.6 8.3 7.8 10.4 7.6 12 7.5zM12 7.5c2-4 6-3.5 4.5-.9-.8 1.2-2.9 1-4.5.9z" fill="currentColor"/></svg>'
|
||
};
|
||
/* 2026-10-01 曾按用户要求换成应用 logo;同日「此刻」A+B 改版用户又要求「去掉应用 logo 一类的工具化视觉、
|
||
改用表现人的元素」(和上面孕育工具区撞脸)→ 动态行改用妈妈头像(momAva),全站统计格回到单色小图标。 */
|
||
function pulseIco(k, cls) {
|
||
return '<span class="' + cls + '">' + (PULSE_ICO[k] || PULSE_ICO.box) + '</span>';
|
||
}
|
||
/* 妈妈头像插画(纯 SVG,5 种发色 × 3 种发型 × 5 种底色,按 seed 取)。只是装饰,不代表任何真实用户。 */
|
||
const MOM_HAIR = ['#4A3328', '#2F2420', '#6B4532', '#3B2A2A', '#7A5038'];
|
||
const MOM_BG = ['#FFD9CB', '#E6DCFF', '#FFE9B8', '#CDEBDD', '#FFD3E1'];
|
||
const MOM_TOP = ['#FF9E85', '#9F8CF0', '#F2B84B', '#6CBF9A', '#F07AA0'];
|
||
function momAva(seed) {
|
||
const i = Math.abs(seed | 0), h = MOM_HAIR[i % 5], st = Math.floor(i / 5) % 3;
|
||
const bg = MOM_BG[Math.floor(i / 3) % 5], top = MOM_TOP[(i + 2) % 5];
|
||
const back = st === 0 ? '<circle cx="20" cy="9" r="4.6" fill="' + h + '"/><path d="M11 21c0-7 4-11.5 9-11.5s9 4.5 9 11.5z" fill="' + h + '"/>'
|
||
: st === 1 ? '<path d="M9.5 23c0-9 4.6-14 10.5-14s10.5 5 10.5 14v11h-21z" fill="' + h + '"/>'
|
||
: '<path d="M10.3 25c-1-9 3.4-15.4 9.7-15.4S30.7 16 29.7 25l-2 2.4H12.3z" fill="' + h + '"/>';
|
||
return '<svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" fill="' + bg + '"/>' + back +
|
||
'<path d="M7 41c1.2-6.4 6.4-9.6 13-9.6S31.8 34.6 33 41z" fill="' + top + '"/>' +
|
||
'<rect x="17.6" y="28" width="4.8" height="4.6" rx="2" fill="#F6CDB8"/>' +
|
||
'<ellipse cx="20" cy="22.4" rx="8" ry="8.8" fill="#FFE3D3"/>' +
|
||
'<path d="M12 21.4c0-5.6 3.6-9.4 8-9.4s8 3.8 8 9.4c-2.6-2.6-5.8-3.9-8.6-4.1-2.4 1.6-4.6 3-7.4 4.1z" fill="' + h + '"/>' +
|
||
'<path d="M15.8 23.4q1.3 1.1 2.6 0M21.6 23.4q1.3 1.1 2.6 0" stroke="#5A3A2E" stroke-width="1.2" fill="none" stroke-linecap="round"/>' +
|
||
'<circle cx="15" cy="26" r="1.5" fill="#FF8F7A" opacity=".45"/><circle cx="25" cy="26" r="1.5" fill="#FF8F7A" opacity=".45"/>' +
|
||
'<path d="M18.6 27.2q1.4 1 2.8 0" stroke="#D9735E" stroke-width="1" fill="none" stroke-linecap="round"/></svg>';
|
||
}
|
||
const strSeed = t => { let x = 7; for (let i = 0; i < t.length; i++) x = (x * 31 + t.charCodeAt(i)) | 0; return Math.abs(x); };
|
||
/* 「此刻」重绘钩子:足迹 / 档案 / 盲盒状态回来时调用(它们决定「和你比」「今日待办」和孕周) */
|
||
let plHook = null;
|
||
const fmtN = n => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||
/* 数字补间:el.dataset.v 记当前值,从它平滑走到 to;首次(无 dataset.v)从 0 起 */
|
||
function tweenNum(el, to, dur) {
|
||
if (!el) return;
|
||
const from = el.dataset.v == null ? 0 : +el.dataset.v;
|
||
el.dataset.v = to;
|
||
if (from === to || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) { el.textContent = fmtN(to); return; }
|
||
const t0 = performance.now(), d = dur || 1400;
|
||
(function step(t) {
|
||
const k = Math.min(1, (t - t0) / d), e = 1 - Math.pow(1 - k, 4);
|
||
el.textContent = fmtN(from + (to - from) * e);
|
||
if (k < 1) requestAnimationFrame(step);
|
||
})(t0);
|
||
}
|
||
/* ================= 此刻 · 同周悄悄话(2026-10-02) =================
|
||
用户原话:「升级成同周妈妈的悄悄话,就是匿名一句心愿墙……不做复杂社区,就是只做一点陪伴」;发言 = 预设句 + 自由输入。
|
||
GET /api/mp/public/mama-box/whisper?openid=&week= → { show, week, canPost, left, maxLen, presets[], list[] }
|
||
POST /api/mp/public/mama-box/whisper?openid= {text} → 新的一条(自由输入由服务端过微信内容安全审核,不通就 code≠200)
|
||
POST /api/mp/public/mama-box/whisper/hug?openid=&id=
|
||
· 只能「抱抱」,没有评论、昵称、头像 —— 刻意不做成社区。自己说的那句排第一,显示「收到 N 个抱抱」。
|
||
· 拉不到(show=false / 失败 / 访客没孕周)→ 整块隐藏,此刻卡退回滚动动态。不在前端造任何内容。
|
||
· 由 pulseCard 的 load() 带着孕周触发(孕周回来 / 变了才重拉),另每 5 分钟静默刷新一次。 */
|
||
let whLoad = null;
|
||
(function whisperWall() {
|
||
const entry = $('#whWall'), page = $('#whPage');
|
||
if (!entry || !page) return;
|
||
const API = '/api/mp/public/mama-box/whisper';
|
||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||
const HEART = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0112 7.6a4.3 4.3 0 017.5 2.7c0 5.6-7.5 10.2-7.5 10.2z"/></svg>';
|
||
let data = null, wk = null, sig = '', busy = false, ri = 0, rollT = null;
|
||
const oid = () => hashParam('openid');
|
||
const list = () => (data && data.list) || [];
|
||
|
||
/* ---------- 首页入口:一句轮播 ---------- */
|
||
function roll() {
|
||
const l = list().filter(x => !x.mine);
|
||
const el = $('#weRoll');
|
||
if (!l.length) { el.textContent = ''; return; }
|
||
ri = (ri + 1) % l.length;
|
||
el.classList.add('out');
|
||
setTimeout(() => { el.textContent = '“' + l[ri].text + '”'; el.classList.remove('out'); }, 260);
|
||
}
|
||
function paintEntry() {
|
||
const l = list().filter(x => !x.mine);
|
||
/* 用户:「同孕周悄悄话改成 30 周孕妈悄悄话」—— 周数跟着她自己的孕周走 */
|
||
const nm = data && data.week ? data.week + '周孕妈悄悄话' : '孕妈悄悄话';
|
||
$('#weName').textContent = nm;
|
||
entry.setAttribute('aria-label', '打开' + nm);
|
||
$('#weRoll').textContent = l.length ? '“' + l[0].text + '”' : '';
|
||
ri = 0;
|
||
$('#weGo').innerHTML = list().length + ' 句<i aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></i>';
|
||
clearInterval(rollT);
|
||
if (l.length > 1) rollT = setInterval(() => { if (!document.hidden && !page.classList.contains('show')) roll(); }, 4000);
|
||
}
|
||
|
||
/* ---------- 全屏页 ---------- */
|
||
function hugBtn(it) {
|
||
if (it.mine) return '<span class="whp-got">' + HEART + (it.hugs ? '收到 <b>' + it.hugs + '</b> 个抱抱' : '抱抱正在路上') + '</span>';
|
||
return '<button class="whp-hug' + (it.hugged ? ' on' : '') + '" type="button" data-hug="' + esc(it.id) + '" aria-label="给她一个抱抱">' +
|
||
HEART + '<b>' + (it.hugs || '') + '</b></button>';
|
||
}
|
||
/* 五改:流星夜空。用户原话:「打开页面夜空里特别多星星;点开一颗,它像流星一样滑落……那个头就是白色的,
|
||
尾巴就是这些字;首先保证用户能看清,刚开始慢一点,说完了慢慢的唰的就快了;同时把对应内容呈现出来」
|
||
· 亮星 = 一句(data-go = 下标 + 1),其余一百多颗只是装饰;
|
||
· 点亮星:星闪一下 → 变成白色流星头往左下滑,字从星里一个个拖出来当尾巴;慢速段按字数给够阅读时间,
|
||
之后加速、拉长、字被拖糊,唰地划出屏幕;星星回来时变暗(看过了);
|
||
· 底部内容卡同时出现:整句 + 孕周 + 抱抱 + 「下一颗」。流星在卡片下面穿过,不挡字。 */
|
||
const RM = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||
const AURA = [['#6B4AA8', '#C2667A'], ['#3F4FA6', '#8E5BB0'], ['#9A4F86', '#E0907A'], ['#365C9E', '#7A58AE'], ['#7D3F8C', '#D27A9A']];
|
||
/* 五声音阶 C4 D4 E4 G4 A4 C5 …(18 个音,C4 到 E7,一屏亮星一颗一个) */
|
||
const PENTA = Array.from({ length: 18 }, (_, k) => 440 * Math.pow(2, (60 + [0, 2, 4, 7, 9][k % 5] + 12 * Math.floor(k / 5) - 69) / 12));
|
||
const hsh = s => { let h = 7; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h; };
|
||
const card = $('#wsc');
|
||
const seen = new Set();
|
||
let cur = 0;
|
||
const mts = new Map(); // 十改:可以连点多颗,多颗流星同时往下落;key = 星的序号
|
||
function paintDeco() {
|
||
const box = $('#wskStars');
|
||
if (box.childElementCount) return;
|
||
let h = '';
|
||
/* 八改(用户:「星空里都要是星星,不要圆点;有大有小,有的一闪一闪」):全部用四角星形,
|
||
大小 4–15px,约一半会闪 */
|
||
for (let k = 0; k < 120; k++) {
|
||
const r = Math.random(), sz = r < .68 ? 4 + Math.random() * 3 : r < .94 ? 8 + Math.random() * 3 : 12 + Math.random() * 3;
|
||
h += '<i class="' + (Math.random() < .5 ? 'tw' : '') + '" style="left:' + (Math.random() * 100).toFixed(1) + '%;top:' + (Math.random() * 92).toFixed(1) + '%;width:' + sz.toFixed(1) + 'px;height:' + sz.toFixed(1) +
|
||
'px;--o:' + (.35 + Math.random() * .6).toFixed(2) + ';animation-delay:-' + (Math.random() * 6).toFixed(1) + 's;animation-duration:' + (1.6 + Math.random() * 3).toFixed(1) + 's"></i>';
|
||
}
|
||
box.innerHTML = h;
|
||
}
|
||
/* 亮星落在夜空中上部偏右(流星往左下走,右边起步才划得够长);抖动网格,不会叠在一起 */
|
||
function paintSky(l) {
|
||
const n = l.length, cols = n > 16 ? 5 : 4, rows = Math.max(2, Math.ceil(n / cols));
|
||
const fresh = l.findIndex(x => !x.mine && !seen.has(x.id));
|
||
const xs = l.map((it, i) => 36 + ((i % cols + .5 + ((hsh(it.id) % 100) / 100 - .5) * .7) / cols) * 58);
|
||
/* 十三改(用户:「每点一下都是一个音符,每一个星星代表不同的音符」):
|
||
按横向位置从左到右排成一架竖琴——越靠左越低、越靠右越高,五声音阶(C4 起),连着点就是一段旋律 */
|
||
const rank = xs.map((x, i) => i).sort((p, q) => xs[p] - xs[q]);
|
||
const note = [];
|
||
rank.forEach((i, k) => { note[i] = PENTA[k % PENTA.length]; });
|
||
$('#wskBright').innerHTML = l.map((it, i) => {
|
||
const h = hsh(it.id), r = Math.floor(i / cols);
|
||
const x = xs[i];
|
||
const y = 21 + ((r + .5 + (((h >> 8) % 100) / 100 - .5) * .6) / rows) * 38;
|
||
const sz = it.mine ? 26 : Math.round(15 + Math.min(it.hugs || 0, 30) / 30 * 11);
|
||
return '<button type="button" class="wsk-st' + (it.mine ? ' mine' : '') + (seen.has(it.id) ? ' used' : '') + (i === fresh && !seen.size ? ' hint' : '') +
|
||
'" data-go="' + (i + 1) + '" data-note="' + note[i].toFixed(1) + '" style="left:' + x.toFixed(1) + '%;top:' + y.toFixed(1) + '%;--s:' + sz + 'px;--tw:' + (2 + (h % 30) / 10).toFixed(1) + 's" aria-label="' + esc(it.text) + '"><i></i></button>';
|
||
}).join('');
|
||
}
|
||
function paintList(l) {
|
||
$('#whpCount').textContent = l.length + ' 句 · 最新在上';
|
||
$('#whpList').innerHTML = l.map((it, i) => {
|
||
const lv = it.mine ? 'mine' : it.hugs >= 25 ? 'l3' : it.hugs >= 10 ? 'l2' : 'l1';
|
||
return '<div class="whl ' + lv + '"><button type="button" class="whl-t" data-go="' + (i + 1) + '">' +
|
||
(it.mine ? '<em>我说的</em>' : '') + esc(it.text) + '</button>' +
|
||
'<div class="whp-meta"><span>孕 ' + it.week + ' 周 · ' + esc(it.ago) + '</span>' + hugBtn(it) + '</div></div>';
|
||
}).join('');
|
||
}
|
||
function paintCard(it, i) {
|
||
card.innerHTML = '<span class="wsc-k">' + (it.mine ? '我说的' : '刚划过的那颗星说') + '</span><p class="wsc-q">' + esc(it.text) + '</p>' +
|
||
'<div class="wsc-m"><span>孕 ' + it.week + ' 周的妈妈 · ' + esc(it.ago) + '</span>' + hugBtn(it) + '</div>' +
|
||
'<button type="button" class="wsc-next" data-next="1">下一颗 <svg viewBox="0 0 24 24" width="13" height="13"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>';
|
||
card.classList.remove('show');
|
||
void card.offsetWidth;
|
||
card.classList.add('show');
|
||
const pal = it.mine ? ['#A8723A', '#E0A86A'] : AURA[(i - 1) % AURA.length];
|
||
const au = $$('#whvAura i');
|
||
au[0].style.backgroundColor = pal[0]; au[1].style.backgroundColor = pal[1];
|
||
}
|
||
/* 七改(用户:「点一个流星底下就出现文字,好像就不用看流星上面写什么了」):
|
||
内容卡改到流星划完才浮出来(小字回放 + 抱抱),先看流星、再看卡;中途换星就不出旧卡。 */
|
||
function stopMeteor(m, done) {
|
||
if (!m || mts.get(m.i) !== m) return;
|
||
cancelAnimationFrame(m.raf);
|
||
m.el.remove();
|
||
m.btn.classList.remove('flare', 'gone');
|
||
m.btn.classList.add('used');
|
||
mts.delete(m.i);
|
||
if (done) paintCard(m.it, m.i);
|
||
checkDone();
|
||
}
|
||
/* 十一改(用户:「被点掉的流星划过之后,就不要再出现在原来的位置;点完之后给一些动效、声音」):
|
||
· 划走的星从夜空里消失(.used),全部落完 → 「星星都落下来啦」+「让星星回来」;
|
||
· 点下去:一圈光波 + 一把碎星迸开;飞行中头后撒星尘;
|
||
· 声音用 WebAudio 现合成,不加载音频文件:点星 = 一声随机五声音阶的「叮」(连点几颗像在弹琴),
|
||
唰出去 = 一道由低到高的风声;右上角喇叭可关,记在本地。 */
|
||
function checkDone() {
|
||
const all = $$('.wsk-st', page);
|
||
$('#wskDone').classList.toggle('show', all.length > 0 && all.every(b => b.classList.contains('used')) && !mts.size);
|
||
}
|
||
const SND = 'mmbox.whsnd.v1';
|
||
let actx = null, sndOn = true;
|
||
try { sndOn = localStorage.getItem(SND) !== '0'; } catch (e) {}
|
||
const sndBtn = $('#whvSnd');
|
||
const paintSnd = () => { sndBtn.classList.toggle('off', !sndOn); sndBtn.setAttribute('aria-label', sndOn ? '关闭声音' : '打开声音'); };
|
||
paintSnd();
|
||
sndBtn.addEventListener('click', () => {
|
||
sndOn = !sndOn;
|
||
try { localStorage.setItem(SND, sndOn ? '1' : '0'); } catch (e) {}
|
||
paintSnd();
|
||
haptic();
|
||
if (sndOn) chime();
|
||
});
|
||
function ac() {
|
||
if (!sndOn) return null;
|
||
try {
|
||
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
|
||
if (actx.state === 'suspended') actx.resume();
|
||
} catch (e) { return null; }
|
||
return actx;
|
||
}
|
||
function chime(f) {
|
||
const c = ac();
|
||
if (!c) return;
|
||
const t = c.currentTime;
|
||
f = f || PENTA[5 + Math.floor(Math.random() * 6)];
|
||
const g = c.createGain();
|
||
g.gain.setValueAtTime(0, t);
|
||
g.gain.linearRampToValueAtTime(.16, t + .012);
|
||
g.gain.exponentialRampToValueAtTime(.001, t + 1.8);
|
||
g.connect(c.destination);
|
||
[[f, 'sine', 1], [f * 2, 'sine', .28], [f * 3.01, 'triangle', .08]].forEach(([fr, ty, v]) => {
|
||
const o = c.createOscillator(), og = c.createGain();
|
||
o.type = ty; o.frequency.value = fr; og.gain.value = v;
|
||
o.connect(og); og.connect(g); o.start(t); o.stop(t + 1.9);
|
||
});
|
||
}
|
||
function whoosh() {
|
||
const c = ac();
|
||
if (!c) return;
|
||
const t = c.currentTime, d = .9;
|
||
const buf = c.createBuffer(1, Math.floor(c.sampleRate * d), c.sampleRate), ch = buf.getChannelData(0);
|
||
for (let k = 0; k < ch.length; k++) ch[k] = Math.random() * 2 - 1;
|
||
const src = c.createBufferSource(), bp = c.createBiquadFilter(), g = c.createGain();
|
||
src.buffer = buf;
|
||
bp.type = 'bandpass'; bp.Q.value = 1.4;
|
||
bp.frequency.setValueAtTime(500, t);
|
||
bp.frequency.exponentialRampToValueAtTime(3800, t + d * .75);
|
||
g.gain.setValueAtTime(0, t);
|
||
g.gain.linearRampToValueAtTime(.1, t + d * .5);
|
||
g.gain.exponentialRampToValueAtTime(.001, t + d);
|
||
src.connect(bp); bp.connect(g); g.connect(c.destination);
|
||
src.start(t); src.stop(t + d);
|
||
}
|
||
function fx(cls, x, y, ms, set) {
|
||
const e = document.createElement('i');
|
||
e.className = cls;
|
||
e.style.left = x.toFixed(1) + 'px';
|
||
e.style.top = y.toFixed(1) + 'px';
|
||
if (set) set(e);
|
||
page.insertBefore(e, card);
|
||
setTimeout(() => e.remove(), ms);
|
||
}
|
||
function tapFx(x, y, gold) {
|
||
if (RM) return;
|
||
fx('wfx-ring' + (gold ? ' gold' : ''), x, y, 800);
|
||
for (let k = 0; k < 10; k++) {
|
||
const a = Math.PI * 2 * k / 10 + Math.random() * .5, r = 26 + Math.random() * 30;
|
||
fx('wfx-sp' + (gold ? ' gold' : ''), x, y, 900, e => {
|
||
e.style.setProperty('--x', (Math.cos(a) * r).toFixed(0) + 'px');
|
||
e.style.setProperty('--y', (Math.sin(a) * r).toFixed(0) + 'px');
|
||
e.style.setProperty('--z', (5 + Math.random() * 7).toFixed(0) + 'px');
|
||
e.style.animationDelay = (Math.random() * 80).toFixed(0) + 'ms';
|
||
});
|
||
}
|
||
}
|
||
$('#wskBack').addEventListener('click', () => {
|
||
haptic();
|
||
seen.clear();
|
||
$('#wskDone').classList.remove('show');
|
||
paintSky(list());
|
||
/* 星星回来:从低到高刷一遍琴弦 */
|
||
PENTA.slice(0, 8).forEach((f, k) => setTimeout(() => chime(f), k * 70));
|
||
$('#wskBright').classList.remove('back');
|
||
void $('#wskBright').offsetWidth;
|
||
$('#wskBright').classList.add('back');
|
||
});
|
||
const stopAll = () => [...mts.values()].forEach(m => stopMeteor(m));
|
||
/* 第一次进来:一闪一闪的提示,点过一次星星就永久收起 */
|
||
const TIP = 'mmbox.whtip.v1';
|
||
const tipEl = $('#wskTip');
|
||
function tip(on) {
|
||
if (on) { try { on = !localStorage.getItem(TIP); } catch (e) {} }
|
||
else { try { localStorage.setItem(TIP, '1'); } catch (e) {} }
|
||
tipEl.classList.toggle('show', !!on);
|
||
}
|
||
function launch(i) {
|
||
const it = list()[i - 1];
|
||
if (!it || mts.has(i)) return;
|
||
cur = i;
|
||
seen.add(it.id);
|
||
$$('.wsk-st.hint', page).forEach(x => x.classList.remove('hint'));
|
||
tip(false);
|
||
card.classList.remove('show');
|
||
$$('.wsk-st.cur', page).forEach(x => x.classList.remove('cur'));
|
||
const btn = $('.wsk-st[data-go="' + i + '"]', page);
|
||
if (!btn) { paintCard(it, i); return; }
|
||
btn.classList.add('cur');
|
||
if (RM) { btn.classList.add('used'); paintCard(it, i); checkDone(); return; }
|
||
const pr = page.getBoundingClientRect(), br = btn.getBoundingClientRect();
|
||
const x0 = br.left + br.width / 2 - pr.left, y0 = br.top + br.height / 2 - pr.top;
|
||
/* 瞄准左下(屏幕左边外、六成高处):越靠左起步角度越陡,保证在屏幕里拖得够长 */
|
||
const th = Math.max(24, Math.min(55, Math.atan2(pr.height * .8 - y0, x0 + 30) * 180 / Math.PI)) * Math.PI / 180, dx = -Math.cos(th), dy = Math.sin(th), rot = -th * 180 / Math.PI;
|
||
/* 六改(用户:「头是窄的,往后越来越宽;前面的字小,后面慢慢变大,形成流星尾巴;头特别亮往前滑;
|
||
点完亮一下就开始滑,一点点变快,不要太慢也别太快」):
|
||
字号从头到尾 12px → 26px 渐大,尾巴是一条从头部尖起、往后张开的光楔;
|
||
运动 = 起步就有速度、缓缓提速,整句出来后再猛加速唰出去。 */
|
||
const chars = [...String(it.text)], n = chars.length;
|
||
const f0 = 13, f1 = n > 20 ? 21 : n > 12 ? 24 : 27, gap = 50; // 十二改:字离白头再远一点,不压在头的白光里
|
||
const fs = chars.map((c, k) => f0 + (f1 - f0) * Math.pow(n > 1 ? k / (n - 1) : 1, .85));
|
||
const off = [];
|
||
fs.reduce((d, f) => { off.push(d + f * .55); return d + f * 1.08; }, gap);
|
||
const body = off.length ? off[n - 1] + fs[n - 1] * .6 : gap;
|
||
const el = document.createElement('div');
|
||
el.className = 'wmt' + (it.mine ? ' mine' : '');
|
||
el.innerHTML = '<i class="wmt-l"></i><i class="wmt-h"></i>' + chars.map((c, k) => '<span style="font-size:' + fs[k].toFixed(1) + 'px">' + esc(c) + '</span>').join('');
|
||
page.insertBefore(el, card);
|
||
const line = el.children[0], head = el.children[1], sp = $$('span', el);
|
||
line.style.height = (f1 * 1.9).toFixed(0) + 'px';
|
||
line.style.marginTop = (-f1 * .95).toFixed(0) + 'px';
|
||
/* 节奏:星闪 420ms → 以约 90px/s 起步并缓缓提速,整句拖出来后再留 0.7 秒给眼睛 → 加速度猛增,1.3 秒内唰出屏幕 */
|
||
const T0 = 420, v0 = .09, TR = (body + 20) / v0 + 700, A = .0018, TE = 1300;
|
||
btn.classList.add('flare');
|
||
btn.classList.remove('used');
|
||
$('#wskDone').classList.remove('show');
|
||
chime(+btn.dataset.note);
|
||
tapFx(x0, y0, it.mine);
|
||
let swish = false, dustT = 0;
|
||
const t0 = performance.now();
|
||
const frame = now => {
|
||
const t = now - t0;
|
||
if (t < T0) { m.raf = requestAnimationFrame(frame); return; }
|
||
btn.classList.add('gone');
|
||
const tc = t - T0, dt = Math.max(0, tc - TR), u = Math.min(1, dt / TE);
|
||
const s = v0 * tc + v0 * tc * tc / 16000 + .5 * A * dt * dt;
|
||
const v = v0 * (1 + tc / 8000) + A * dt;
|
||
if (dt > 0 && !swish) { swish = true; whoosh(); }
|
||
const hx = x0 + dx * s, hy = y0 + dy * s, at = 'translate(' + hx.toFixed(1) + 'px,' + hy.toFixed(1) + 'px) rotate(' + rot.toFixed(1) + 'deg)';
|
||
const st = Math.min(1.6, Math.max(0, (v - .25) / .5));
|
||
head.style.transform = at + ' scaleX(' + (1 + st * 1.6).toFixed(2) + ')';
|
||
const tl = Math.min(s, body + 70 + 260 * st);
|
||
line.style.width = tl.toFixed(0) + 'px';
|
||
line.style.transform = at;
|
||
const fade = u < .6 ? 1 : Math.max(0, 1 - (u - .6) / .4);
|
||
el.style.setProperty('--bl', (st * 1.6).toFixed(1) + 'px');
|
||
sp.forEach((c, k) => {
|
||
const d = s - off[k];
|
||
if (d < 0) { c.style.opacity = 0; return; }
|
||
c.style.opacity = (Math.min(1, d / 26) * fade).toFixed(2);
|
||
c.style.transform = 'translate(' + (x0 + dx * d).toFixed(1) + 'px,' + (y0 + dy * d).toFixed(1) + 'px) translate(-50%,-50%) rotate(' + rot.toFixed(1) + 'deg)';
|
||
});
|
||
if (t - dustT > 70 && u < .7 && hx > -20 && hy < pr.height + 20) {
|
||
dustT = t;
|
||
fx('wfx-dust' + (it.mine ? ' gold' : ''), hx + (Math.random() - .5) * 10, hy + (Math.random() - .5) * 10, 1000, e => {
|
||
e.style.setProperty('--z', (3 + Math.random() * 5).toFixed(0) + 'px');
|
||
e.style.setProperty('--y', (8 + Math.random() * 14).toFixed(0) + 'px');
|
||
});
|
||
}
|
||
if (u >= 1) { stopMeteor(m, true); return; }
|
||
m.raf = requestAnimationFrame(frame);
|
||
};
|
||
const m = { el, btn, it, i, raf: 0 };
|
||
mts.set(i, m);
|
||
m.raf = requestAnimationFrame(frame);
|
||
}
|
||
function nextStar() {
|
||
const l = list(), n = l.length;
|
||
for (let k = 1; k <= n; k++) {
|
||
const j = (cur - 1 + k) % n;
|
||
if (!seen.has(l[j].id) && !l[j].mine) return j + 1;
|
||
}
|
||
return cur % n + 1;
|
||
}
|
||
function paintPage() {
|
||
const l = list(), n = l.length;
|
||
const bh = (new Date().getUTCHours() + 8) % 24, night = bh >= 18 || bh < 5;
|
||
$('#whpTag').textContent = data ? '孕 ' + data.week + ' 周 · 前后一周的妈妈' : '';
|
||
$('#wskTitle').innerHTML = '<div class="wsk-t">' + (night ? '今晚' : '今天') + ',<b>' + n + '</b> 句悄悄话</div><p>每颗亮星是一句。点一颗,听她说。</p>';
|
||
paintDeco();
|
||
paintSky(l);
|
||
paintList(l);
|
||
if (cur && list()[cur - 1]) $$('.wsk-st[data-go="' + cur + '"]', page).forEach(x => x.classList.add('cur'));
|
||
$('#whpLeft').textContent = !data ? '' : !oid() || !data.canPost ? '填好预产期,就能说一句' : data.left ? '今天还能说 ' + data.left + ' 句' : '今天的 3 句说完啦,明天再来';
|
||
}
|
||
function drawer(on) {
|
||
$('#whDrawer').classList.toggle('show', on);
|
||
$('#whDrawer').setAttribute('aria-hidden', on ? 'false' : 'true');
|
||
}
|
||
$('#whvAll').addEventListener('click', () => { haptic(); drawer(true); });
|
||
$('#whdX').addEventListener('click', () => drawer(false));
|
||
$('#whDrawer').addEventListener('click', e => { if (e.target.id === 'whDrawer') drawer(false); });
|
||
page.addEventListener('click', e => {
|
||
const g = e.target.closest('[data-go],[data-next]');
|
||
if (!g) return;
|
||
haptic();
|
||
drawer(false);
|
||
launch(g.dataset.next ? nextStar() : +g.dataset.go);
|
||
});
|
||
function openPage() {
|
||
stopAll();
|
||
cur = 0;
|
||
card.classList.remove('show');
|
||
paintPage();
|
||
tip(true);
|
||
checkDone();
|
||
page.classList.add('show');
|
||
page.setAttribute('aria-hidden', 'false');
|
||
drawer(false);
|
||
haptic();
|
||
}
|
||
function closePage() {
|
||
stopAll();
|
||
drawer(false);
|
||
page.classList.remove('show');
|
||
page.setAttribute('aria-hidden', 'true');
|
||
}
|
||
entry.addEventListener('click', openPage);
|
||
$('#whpBack').addEventListener('click', closePage);
|
||
|
||
function show(on) {
|
||
entry.classList.toggle('hide', !on);
|
||
const sec = $('#pulse');
|
||
if (sec) sec.classList.toggle('has-wh', on);
|
||
if (!on) closePage();
|
||
}
|
||
function load(w, silent) {
|
||
wk = w;
|
||
if (!w) { data = null; show(false); return; }
|
||
fetch(API + '?openid=' + encodeURIComponent(oid() || '') + '&week=' + w)
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(j => {
|
||
const r = j && j.code === 200 && j.result;
|
||
if (!r || !r.show || !(r.list || []).length) { data = null; show(false); return; }
|
||
data = r;
|
||
const ns = r.list.map(x => x.id + ':' + x.hugs + (x.hugged ? 'h' : '')).join('|');
|
||
if (ns !== sig) {
|
||
sig = ns;
|
||
paintEntry();
|
||
/* 页面开着时静默刷新不重画,免得正在看的那句跳走 */
|
||
if (!page.classList.contains('show')) paintPage();
|
||
}
|
||
show(true);
|
||
})
|
||
.catch(() => { if (!silent) show(false); });
|
||
}
|
||
whLoad = w => load(w, false);
|
||
setInterval(() => { if (wk && !document.hidden && !busy) load(wk, true); }, 300000);
|
||
|
||
/* 抱抱:先变(心跳 + 一串小心飘上去),失败再退回 */
|
||
function burst(b) {
|
||
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
for (let k = 0; k < 7; k++) {
|
||
const h = document.createElement('i');
|
||
h.className = 'whp-fly';
|
||
h.innerHTML = HEART;
|
||
h.style.setProperty('--x', (Math.random() * 60 - 30).toFixed(0) + 'px');
|
||
h.style.setProperty('--r', (Math.random() * 50 - 25).toFixed(0) + 'deg');
|
||
h.style.animationDelay = (k * 60) + 'ms';
|
||
b.appendChild(h);
|
||
setTimeout(() => h.remove(), 1300);
|
||
}
|
||
}
|
||
page.addEventListener('click', e => {
|
||
const b = e.target.closest('[data-hug]');
|
||
if (!b) return;
|
||
const o = oid();
|
||
if (!o) { toast('登录后就能给她抱抱啦'); return; }
|
||
if (b.classList.contains('on')) { toast('你已经抱过她啦'); return; }
|
||
const id = b.dataset.hug;
|
||
const it = list().find(x => x.id === id);
|
||
haptic();
|
||
const n0 = it ? it.hugs : 0;
|
||
if (it) { it.hugs = n0 + 1; it.hugged = true; }
|
||
$$('[data-hug="' + CSS.escape(id) + '"]', page).forEach(x => { x.classList.add('on', 'pop'); $('b', x).textContent = n0 + 1; setTimeout(() => x.classList.remove('pop'), 600); });
|
||
burst(b);
|
||
fetch(API + '/hug?openid=' + encodeURIComponent(o) + '&id=' + encodeURIComponent(id), { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(j => { if (!j || j.code !== 200) throw j; })
|
||
.catch(j => {
|
||
if (it) { it.hugs = n0; it.hugged = false; }
|
||
$$('[data-hug="' + CSS.escape(id) + '"]', page).forEach(x => { x.classList.remove('on'); $('b', x).textContent = n0 || ''; });
|
||
toast((j && j.message) || '网络不太好,再抱一次试试');
|
||
});
|
||
});
|
||
|
||
/* 说一句(弹窗盖在全屏页上面) */
|
||
const ta = $('#whTa');
|
||
const cnt = () => { $('#whCnt').textContent = ta.value.length + '/' + ((data && data.maxLen) || 40); };
|
||
ta.addEventListener('input', () => {
|
||
cnt();
|
||
$$('.wh-pre', $('#whPresets')).forEach(c => c.classList.toggle('on', c.textContent === ta.value));
|
||
});
|
||
$('#whSay').addEventListener('click', () => {
|
||
if (!data) return;
|
||
if (!oid() || !data.canPost) { toast('填好预产期,就能和同孕周妈妈说悄悄话啦'); return; }
|
||
if (!data.left) { toast('今天已经说了 3 句啦,明天再来'); return; }
|
||
ta.value = '';
|
||
ta.maxLength = data.maxLen || 40;
|
||
cnt();
|
||
$('#whPresets').innerHTML = (data.presets || []).map(t => '<button class="wh-pre" type="button">' + esc(t) + '</button>').join('');
|
||
$('#whLeft').textContent = '今天还能说 ' + data.left + ' 句';
|
||
openModal('modalWhisper');
|
||
});
|
||
$('#whPresets').addEventListener('click', e => {
|
||
const c = e.target.closest('.wh-pre');
|
||
if (!c) return;
|
||
haptic();
|
||
ta.value = c.textContent;
|
||
ta.dispatchEvent(new Event('input'));
|
||
});
|
||
$('#whSend').addEventListener('click', () => {
|
||
const t = ta.value.trim();
|
||
if (t.length < 2) { toast('写一两个字,或者点一句吧'); return; }
|
||
if (busy) return;
|
||
busy = true;
|
||
const btn = $('#whSend');
|
||
btn.disabled = true;
|
||
btn.textContent = '正在发出…';
|
||
fetch(API + '?openid=' + encodeURIComponent(oid()), {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: t })
|
||
})
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
if (!j || j.code !== 200 || !j.result) { toast((j && j.message) || '没发出去,再试一次'); return; }
|
||
const it = j.result;
|
||
data.left = it.left;
|
||
delete it.left;
|
||
data.list = [it].concat(data.list || []);
|
||
sig = '';
|
||
paintEntry();
|
||
paintPage();
|
||
closeModal();
|
||
setTimeout(() => launch(1), 350);
|
||
toast('发出去啦,同孕周妈妈会给你抱抱的');
|
||
})
|
||
.catch(() => toast('网络不太好,再试一次'))
|
||
.then(() => { busy = false; btn.disabled = false; btn.textContent = '悄悄发出'; });
|
||
});
|
||
})();
|
||
|
||
(function pulseCard() {
|
||
const sec = $('#pulse');
|
||
if (!sec) return;
|
||
let seen = false, last = null, feed = [], fi = 0, ticking = null, peerMode = false, loadedWk = null, shownN = 0;
|
||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||
const tapMini = k => { const b = $('.mini-card[data-mini="' + k + '"]'); if (b) b.click(); };
|
||
const TODAY_GO = {
|
||
kick: () => tapMini('kick'),
|
||
weight: () => tapMini('weight'),
|
||
cal: () => tapMini('cal'),
|
||
box: () => openBoxEntry(),
|
||
game: () => { if (GAME_URL) jumpApp(GAME_URL, GAME_ID); }
|
||
};
|
||
/* 用户自己今天做没做(足迹 used[].appId 是上游 URL slug)。足迹没回来 / 读不到 → null,不显示「你」那半句 */
|
||
const SELF_APP = { kick: 'taidong', weight: 'yunqi-tizhong' };
|
||
/* 2026-10-02 陪伴版:不说「你今天还没数」(像点名),改成宝宝在等你的邀请;做过了就一句肯定。
|
||
同周人数那句也不再给百分比(「她们有 18%」是在和你比),只说多少位妈妈今天也在做。 */
|
||
const nmB = () => state.babyName || '宝宝';
|
||
const SELF_TXT = {
|
||
kick: [() => nmB() + '在等你,陪它数一数', () => '你今天也陪' + nmB() + '数过啦'],
|
||
weight: [() => '也记一下今天的自己', () => '你今天也记过啦']
|
||
};
|
||
const CROWD_TXT = { kick: '位妈妈陪宝宝数了胎动', weight: '位妈妈记下了体重' };
|
||
function selfDone(k) {
|
||
if (_fpMode !== 'rows' && _fpMode !== 'guide') return null;
|
||
return ((_fpData && _fpData.used) || []).some(u => u.appId === SELF_APP[k]);
|
||
}
|
||
function rowHTML(f) {
|
||
/* 一行:小头像 + 「同周妈妈 · 刚刚」+ 动作(数字标色)。原文「孕 29 周的妈妈 数完胎动……」按「的妈妈 」切 */
|
||
const t = String(f.text || ''), i = t.indexOf('的妈妈 ');
|
||
const who = peerMode ? '同孕周妈妈' : (i > 0 ? t.slice(0, i + 3) : '一位妈妈'), what = i > 0 ? t.slice(i + 4) : t;
|
||
return '<div class="pl-row"><span class="pr-ava">' + momAva(strSeed(t + (f.ago || ''))) + '</span>' +
|
||
'<span class="pr-who">' + esc(who) + ' · ' + esc(f.ago || '刚刚') + '</span><span class="pr-do">' +
|
||
esc(what).replace(/(\d+)/g, '<em>$1</em>') + '</span></div>';
|
||
}
|
||
function tick() {
|
||
const tr = $('#plTrack');
|
||
if (!tr || feed.length < 2 || document.hidden) return;
|
||
fi = (fi + 1) % feed.length;
|
||
tr.insertAdjacentHTML('beforeend', rowHTML(feed[fi]));
|
||
tr.classList.add('is-move');
|
||
tr.style.transform = 'translateY(-26px)'; /* = .pl-row 高度 */
|
||
setTimeout(() => {
|
||
tr.classList.remove('is-move');
|
||
/* 翻动途中若 paint() 重置过轨道(只剩 1 行),别把仅剩的那行也删了 */
|
||
if (tr.childElementCount > 1) tr.removeChild(tr.firstElementChild);
|
||
tr.style.transform = '';
|
||
}, 600);
|
||
}
|
||
/* 头像叠:同周版按孕周换一组,全站版固定一组;只在换组时重画 */
|
||
function paintAvs(seed) {
|
||
const avs = $('#ppAvs');
|
||
if (avs.dataset.seed === String(seed)) return;
|
||
avs.dataset.seed = seed;
|
||
/* 2026-10-02 用户:「头像现在展示 3 个,给我展示 6 到 8 个」→ 7 个 */
|
||
avs.innerHTML = [3, 11, 7, 19, 2, 14, 23].map(x => '<i>' + momAva(x + seed) + '</i>').join('');
|
||
}
|
||
function paintPeer(p) {
|
||
paintAvs(p.week);
|
||
$('#plTitle').innerHTML = '<b data-n="' + p.count + '">0</b> 位孕 ' + p.week + ' 周妈妈和你同一周';
|
||
/* 只说一句:优先挑你今天还没做的那项;都做了(或读不到你的足迹)就取第一项 */
|
||
const ds = (p.doing || []).filter(d => SELF_TXT[d.key]);
|
||
const pick = ds.find(d => selfDone(d.key) === false) || ds[0];
|
||
const n = pick ? Math.round((+p.count || 0) * (+pick.pct || 0) / 100) : 0;
|
||
$('#ppCrowd').innerHTML = pick && n >= 3 ? '今天 <b>' + fmtN(n) + '</b> ' + CROWD_TXT[pick.key] : '她们和你一样,在等宝宝来';
|
||
const me = pick ? selfDone(pick.key) : null;
|
||
if (me != null) {
|
||
$('#ppDoing').innerHTML = me
|
||
? '<span class="pp-me ok"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.4l3 3 6-6.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' + esc(SELF_TXT[pick.key][1]()) + '</span>'
|
||
: '<button class="pp-me todo" type="button" data-go="' + pick.key + '">' + esc(SELF_TXT[pick.key][0]()) +
|
||
'<i aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></i></button>';
|
||
}
|
||
$('#ppDoing').classList.toggle('hide', me == null);
|
||
}
|
||
function paint() {
|
||
const r = last;
|
||
if (!r || !r.show) { sec.classList.add('hide'); return; }
|
||
sec.classList.remove('hide');
|
||
peerMode = !!r.peer;
|
||
sec.classList.toggle('is-peer', peerMode);
|
||
if (peerMode) paintPeer(r.peer);
|
||
else {
|
||
/* 全站版(访客 / real 模式):只留一行总人数 + 滚动动态 */
|
||
paintAvs(0);
|
||
$('#ppDoing').classList.add('hide');
|
||
const td = (r.today || [])[0];
|
||
$('#ppCrowd').innerHTML = td ? '今天 <b>' + fmtN(td.value) + '</b> ' + esc(td.label) : '她们和你一样,在等宝宝来';
|
||
const t = r.total;
|
||
/* 前缀「已有」省掉:一行放得下「10,037 位妈妈在这里迎接宝宝」,带上就会折出一个孤零零的「宝宝」 */
|
||
$('#plTitle').innerHTML = t ? '<b data-n="' + t.value + '">0</b> ' + esc(t.suffix) : '孕妈们在做什么';
|
||
}
|
||
/* 标题每次重画会重建 <b>:从上次显示到的数接着涨,不要每分钟从 0 重数 */
|
||
const nb = $('#plTitle b');
|
||
if (nb) {
|
||
nb.dataset.v = shownN;
|
||
nb.textContent = fmtN(shownN);
|
||
if (seen) { tweenNum(nb, +nb.dataset.n || 0, 1600); shownN = +nb.dataset.n || 0; }
|
||
}
|
||
const nf = (peerMode ? r.peer.feed : r.feed) || [];
|
||
const fe = $('#plFeed');
|
||
fe.classList.toggle('hide', !nf.length);
|
||
/* 每分钟刷新时只在内容真变了才重置滚动,否则正在翻的那条会跳回第一条 */
|
||
const sig = nf.map(x => x.text).join('|');
|
||
if (nf.length && fe.dataset.sig !== sig) {
|
||
fe.dataset.sig = sig;
|
||
feed = nf;
|
||
fi = 0;
|
||
const tr = $('#plTrack');
|
||
tr.classList.remove('is-move');
|
||
tr.style.transform = '';
|
||
tr.innerHTML = rowHTML(feed[0]);
|
||
if (!ticking) ticking = setInterval(tick, 3000);
|
||
}
|
||
}
|
||
sec.addEventListener('click', e => {
|
||
const b = e.target.closest('[data-go]');
|
||
const k = b && b.dataset.go;
|
||
if (k && TODAY_GO[k]) { haptic(); TODAY_GO[k](); }
|
||
});
|
||
const myWeek = () => (state.pregEmpty === false && !state.postpartum && state.week >= 4) ? state.week : null;
|
||
function load() {
|
||
const w = myWeek();
|
||
loadedWk = w;
|
||
if (whLoad) whLoad(w);
|
||
return fetch('/api/mp/public/mama-box/pulse' + (w ? '?week=' + w : ''))
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(j => { if (j && j.code === 200 && j.result) { last = j.result; paint(); } })
|
||
.catch(() => { /* 静默:保持现状(首次失败即一直隐藏) */ });
|
||
}
|
||
/* 档案回来孕周才可信 → 孕周变了重拉;否则只是足迹 / 盲盒状态变了,就地重画「和你比」「今日待办」 */
|
||
plHook = () => { if (myWeek() !== loadedWk) load(); else if (last) paint(); };
|
||
/* 进入视口才开始数数,否则用户滑到这里时数字早就数完了 */
|
||
function onSeen() {
|
||
if (seen) return;
|
||
seen = true;
|
||
paint();
|
||
}
|
||
if ('IntersectionObserver' in window) {
|
||
const io = new IntersectionObserver(es => { if (es.some(e => e.isIntersecting)) { io.disconnect(); onSeen(); } }, { threshold: .25 });
|
||
io.observe(sec);
|
||
} else seen = true;
|
||
load();
|
||
setInterval(() => { if (!document.hidden) load(); }, 60000);
|
||
})();
|
||
|
||
/* ================= 孕期盲盒(2026-10-01,需求文档 v1.0 第三节) =================
|
||
接口(box_app,openid 身份):
|
||
GET /api/mp/public/mama-box/blindbox?openid= → 横幅 + 本页的全部状态(含卡册、概率表)
|
||
POST /api/mp/public/mama-box/blindbox/open?openid=&week= → 开盒(服务端抽卡/抽奖励,原子去重)
|
||
· 抽什么完全由服务端决定,前端只播动画;概率说明用接口下发的 probs,和服务端永远一致(合规:公示概率)。
|
||
· 访客态 / 孕 4 周前 / 接口失败 → 横幅保持隐藏。未填预产期 → 横幅改成「填预产期领盲盒」引导。
|
||
· 周卡插画在 assets/img(文件名 = md5 前 12 位),CARD_IMG 是孕周 → 文件的表;没有图的孕周用「本周水果」占位画。 */
|
||
const CARD_IMG = {
|
||
4: 'b743ed8646d5', 5: 'bddd81b7315d', 6: 'affcfc61f21f', 7: 'fe00c2bddf9b', 8: '2a88a5a7c1c3', 9: '6a17a98c52b8',
|
||
10: 'c6e9c5612d8e', 11: '5ae2e7f617e2', 12: '0fa71babe9a2', 13: '6bdabd9eecc5', 15: 'd41305de1db9', 16: 'cceeb3b3f4ef',
|
||
24: '83f0ab30cf36', 32: '2314f334e4d0'
|
||
};
|
||
const BX_GROUPS = [
|
||
{ name: '孕早期', from: 4, to: 13, c: '#9ED8C0' },
|
||
{ name: '孕中期', from: 14, to: 27, c: '#FFB08F' },
|
||
{ name: '孕晚期', from: 28, to: 40, c: '#C9B6FF' }
|
||
];
|
||
let BB = null, bbSel = null, bbBusy = false, bbNew = null, giftSeq = 0;
|
||
function giftSVG() {
|
||
const g = 'gf' + (++giftSeq);
|
||
return '<svg class="gift" viewBox="0 0 120 120" aria-hidden="true"><defs>' +
|
||
'<linearGradient id="' + g + 'b" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#FF9677"/><stop offset="1" stop-color="#EE5A43"/></linearGradient>' +
|
||
'<linearGradient id="' + g + 'l" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFB49A"/><stop offset="1" stop-color="#FF8064"/></linearGradient>' +
|
||
'<linearGradient id="' + g + 'r" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFF6D6"/><stop offset="1" stop-color="#FFD36E"/></linearGradient></defs>' +
|
||
'<g class="g-body"><rect x="20" y="54" width="80" height="56" rx="11" fill="url(#' + g + 'b)"/>' +
|
||
'<rect x="20" y="54" width="80" height="9" fill="#9B2B1E" opacity=".12"/>' +
|
||
'<rect x="53" y="54" width="14" height="56" fill="url(#' + g + 'r)"/>' +
|
||
'<circle cx="34" cy="80" r="3.2" fill="#fff" opacity=".55"/><circle cx="86" cy="94" r="2.4" fill="#fff" opacity=".45"/>' +
|
||
'<path d="M84 68l1.6 3.8 3.8 1.6-3.8 1.6L84 78.8l-1.6-3.8-3.8-1.6 3.8-1.6z" fill="#fff" opacity=".8"/></g>' +
|
||
'<g class="g-lid"><path d="M60 38C51 20 32 17 33 29c1 9 15 10 27 9z" fill="url(#' + g + 'r)" stroke="#EFAE3C" stroke-width="2"/>' +
|
||
'<path d="M60 38c9-18 28-21 27-9-1 9-15 10-27 9z" fill="url(#' + g + 'r)" stroke="#EFAE3C" stroke-width="2"/>' +
|
||
'<rect x="14" y="36" width="92" height="22" rx="8" fill="url(#' + g + 'l)"/>' +
|
||
'<rect x="14" y="36" width="92" height="7" rx="4" fill="#fff" opacity=".2"/>' +
|
||
'<rect x="52" y="36" width="16" height="22" fill="url(#' + g + 'r)"/>' +
|
||
'<circle cx="60" cy="37.5" r="6.5" fill="#FFD36E" stroke="#EFAE3C" stroke-width="2"/></g></svg>';
|
||
}
|
||
const bbEsc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||
function cardImgHTML(w) {
|
||
const h = CARD_IMG[w];
|
||
return h ? '<img src="assets/img/' + h + '.jpg" alt="孕 ' + w + ' 周宝宝卡" decoding="async">'
|
||
: '<span class="cf-fb" aria-hidden="true">' + babySize(w).emoji + '</span>';
|
||
}
|
||
/* 卡正面。card = { week, v, size:{name,txt}, say } */
|
||
function cardFrontHTML(c) {
|
||
const sz = c.size || babySize(c.week);
|
||
return '<div class="cf v-' + (c.v === 'flash' ? 'flash' : 'normal') + '">' +
|
||
'<span class="cf-tag">✦ 闪卡</span><span class="cf-no">No.' + String(c.week).padStart(2, '0') + '</span>' +
|
||
'<div class="cf-img">' + cardImgHTML(c.week) + '</div>' +
|
||
'<div class="cf-row"><span class="cf-wk">孕<b>' + c.week + '</b>周</span><span class="cf-size">像<b>' + bbEsc(sz.name) + '</b> · ' + bbEsc(sz.txt) + '</span></div>' +
|
||
'<p class="cf-say">' + (c.say ? '“' + bbEsc(c.say) + '”' : '') + '</p>' +
|
||
'<i class="cf-holo"></i></div>';
|
||
}
|
||
function bbApi(path, method) {
|
||
const oid = hashParam('openid');
|
||
return fetch('/api/mp/public/mama-box/' + path + (path.indexOf('?') >= 0 ? '&' : '?') + 'openid=' + encodeURIComponent(oid), { method: method || 'GET' })
|
||
.then(r => r.json().catch(() => null));
|
||
}
|
||
function loadBlindbox() {
|
||
if (!hashParam('openid')) { $('#bbBanner').classList.add('hide'); return Promise.resolve(); }
|
||
return bbApi('blindbox').then(j => {
|
||
if (!j || j.code !== 200 || !j.result) return;
|
||
BB = j.result;
|
||
(BB.newRewards || []).forEach(g => bxPrizeQ.push(g));
|
||
renderBanner();
|
||
if (BB.backfilled > 0) setTimeout(() => toast('错过的 ' + BB.backfilled + ' 张宝宝卡已补给你,去卡册看看吧'), 1800);
|
||
if ($('#detailBox').classList.contains('show')) {
|
||
renderBox();
|
||
if (bxPrizeQ.length && !bbBusy && !$('#bxReveal').classList.contains('show')) setTimeout(showPrize, 400);
|
||
}
|
||
}).catch(() => { /* 静默:横幅保持现状 */ });
|
||
}
|
||
/* 最近的一个集齐目标:先看当前孕周所在的那段,集齐了再看全集。用于「离孕中期大奖还差 3 张」 */
|
||
function bxGoal() {
|
||
const ss = (BB && BB.stages) || [];
|
||
const cur = ss.find(x => x.key !== 'all' && BB.week >= x.from && BB.week <= x.to && !x.complete);
|
||
const g = cur || ss.find(x => x.key === 'all' && !x.complete);
|
||
return g && g.left > 0 ? g : null;
|
||
}
|
||
const BX_TASK_ICO = { kick3: '👶', weight: '⚖️', tool: '📅' };
|
||
let bxLockSel = null, bxPrizeQ = [];
|
||
function renderBanner() {
|
||
if (plHook) plHook(); // 「此刻」的今日待办看本周盲盒开没开
|
||
const ban = $('#bbBanner');
|
||
const set = (t, s, cta, ready, calm) => {
|
||
$('#bbTitle').textContent = t;
|
||
$('#bbSub').innerHTML = s;
|
||
$('#bbCta').textContent = cta;
|
||
ban.classList.toggle('is-ready', !!ready);
|
||
ban.classList.toggle('is-calm', !!calm);
|
||
ban.classList.remove('hide');
|
||
};
|
||
const me = $('#meBoxCard');
|
||
me.classList.toggle('hide', !BB || !BB.enabled || !!BB.notYet || !!BB.needProfile);
|
||
if (BB && BB.totalWeeks) $('#meBoxV').textContent = (BB.collected || 0) + '/' + BB.totalWeeks;
|
||
if (!BB || !BB.enabled || BB.notYet) { ban.classList.add('hide'); return; }
|
||
if (!$('#bbGift').firstChild) $('#bbGift').innerHTML = giftSVG();
|
||
const badge = $('#bbBadge');
|
||
badge.classList.add('hide');
|
||
if (BB.needProfile) { set('每周一个宝宝盲盒', '填好预产期就能领 · 完全免费', '去填写', true); return; }
|
||
const got = '已收集 <b>' + BB.collected + '</b>/' + BB.totalWeeks;
|
||
const op = BB.openable || [];
|
||
if (op.length > 1) { badge.textContent = op.length; badge.classList.remove('hide'); }
|
||
if (BB.ended) set('我的孕期卡册', got + ' 张宝宝卡,永久保留', '去看看', false, true);
|
||
else if (!BB.currentOpened && op.indexOf(BB.week) >= 0) set('孕 ' + BB.week + ' 周的盲盒到了', got + ' · 免费开启', '去开启', true);
|
||
else if (op.length) set('还有 ' + op.length + ' 个盲盒没开', '已解锁,随时补开 · ' + got, '去补开', true);
|
||
else if ((BB.locked || []).length && BB.credits) set('可以解锁 ' + BB.credits + ' 个错过的盲盒', '你完成了孕期小任务 · ' + got, '去解锁', true);
|
||
else {
|
||
const g = bxGoal();
|
||
set(BB.nextIn ? '下一个盲盒 ' + BB.nextIn + ' 天后到' : '本周盲盒已开启',
|
||
g ? '离' + g.name + '大奖还差 <b>' + g.left + '</b> 张' : got + (BB.flash ? ' · 闪卡 <b>' + BB.flash + '</b> 张' : ''), '看卡册', false, true);
|
||
}
|
||
}
|
||
/* 盲盒入口:首页横幅 + 「我的」页卡册行(#meBoxCard)走同一条路 */
|
||
function openBoxEntry() {
|
||
if (!BB) return;
|
||
if (BB.needProfile) { openModal('modalProfile'); return; }
|
||
bbSel = null;
|
||
renderBox();
|
||
openPanel('detailBox');
|
||
if (bxPrizeQ.length) setTimeout(showPrize, 600);
|
||
}
|
||
$('#bbBanner').addEventListener('click', openBoxEntry);
|
||
$('#meBoxCard').addEventListener('click', openBoxEntry);
|
||
function renderBox() {
|
||
if (!BB || BB.needProfile) return;
|
||
const op = BB.openable || [];
|
||
if (op.indexOf(bbSel) < 0) bbSel = op.indexOf(BB.week) >= 0 ? BB.week : (op[0] || null);
|
||
const st = $('#bxStage'), box = $('#bxBox'), mini = $('#bxMini'), btn = $('#bxOpen');
|
||
st.classList.remove('is-shaking', 'is-popping');
|
||
btn.disabled = false;
|
||
if (bbSel) {
|
||
st.classList.remove('is-done');
|
||
$('#bxWeek').textContent = '孕 ' + bbSel + ' 周 · 宝宝盲盒';
|
||
$('#bxHint').textContent = bbSel === BB.week ? '宝宝给你准备了这周的小惊喜' : '这是你错过的一周,现在补开';
|
||
box.innerHTML = giftSVG();
|
||
box.classList.remove('hide'); mini.classList.add('hide');
|
||
btn.textContent = '开启盲盒'; btn.classList.remove('hide');
|
||
} else {
|
||
st.classList.add('is-done');
|
||
box.classList.add('hide');
|
||
const cc = BB.currentCard;
|
||
$('#bxWeek').textContent = BB.ended ? '孕期卡册' : '孕 ' + BB.week + ' 周 · 已开启';
|
||
$('#bxHint').textContent = BB.ended ? (BB.postpartum ? '宝宝出生啦,卡册会一直留着' : '孕期盲盒已经全部开完') :
|
||
(BB.nextIn ? '下一个盲盒 ' + BB.nextIn + ' 天后到' : '本周的盲盒已经开过啦');
|
||
if (cc) { mini.innerHTML = cardFrontHTML(cc); mini.classList.remove('hide'); mini.onclick = () => showReveal(cc, null, false); }
|
||
else { mini.classList.add('hide'); box.innerHTML = giftSVG(); box.classList.remove('hide'); }
|
||
btn.classList.toggle('hide', !cc);
|
||
btn.textContent = '看看这张卡';
|
||
}
|
||
$('#bxMisc').textContent = (BB.weekOpeners >= 50) ? '本周已有 ' + fmtN(BB.weekOpeners) + ' 位妈妈开了盲盒' : '每周一个 · 完全免费';
|
||
/* 下周预告:剪影 + 倒计时(本周已开时才露出来,免得和「开启盲盒」抢视线) */
|
||
const nx = BB.next, nxEl = $('#bxNext');
|
||
nxEl.classList.toggle('hide', !(nx && !bbSel && !BB.ended));
|
||
if (nx && !bbSel && !BB.ended) {
|
||
$('#bxNextImg').innerHTML = cardImgHTML(nx.week);
|
||
$('#bxNextW').textContent = '孕 ' + nx.week + ' 周 · 下周预告';
|
||
$('#bxNextS').textContent = nx.size ? '宝宝会长到' + nx.size.name + '那么大' : '';
|
||
$('#bxNextD').textContent = nx.inDays;
|
||
}
|
||
/* 待解锁 + 任务 */
|
||
const lk = BB.locked || [], lkEl = $('#bxLock');
|
||
lkEl.classList.toggle('hide', !lk.length);
|
||
if (lk.length) {
|
||
if (lk.indexOf(bxLockSel) < 0) bxLockSel = lk[lk.length - 1];
|
||
$('#bxLockN').textContent = lk.length;
|
||
$('#bxLockChips').innerHTML = lk.map(w => '<button class="bx-chip lk' + (w === bxLockSel ? ' on' : '') + '" data-w="' + w + '" type="button">🔒 孕 ' + w + ' 周</button>').join('');
|
||
$('#bxTasks').innerHTML = (BB.tasks || []).map(t => {
|
||
const btn = t.ready ? '<button class="bx-tk-b go" data-unlock="' + t.id + '" type="button">解锁孕 ' + bxLockSel + ' 周</button>'
|
||
: t.used ? '<span class="bx-tk-b done">' + (t.period === 'week' ? '本周已用' : '今天已用') + '</span>'
|
||
: '<button class="bx-tk-b" data-app="' + bbEsc(t.identifier) + '" type="button">去完成</button>';
|
||
const prog = t.need > 1 ? '<i class="bx-tk-p"><s style="width:' + Math.round(t.progress / t.need * 100) + '%"></s></i><em>' + t.progress + '/' + t.need + '</em>' : '';
|
||
return '<div class="bx-tk' + (t.ready ? ' is-ready' : '') + '"><span class="bx-tk-i" aria-hidden="true">' + (BX_TASK_ICO[t.id] || '✨') + '</span>' +
|
||
'<div class="bx-tk-m"><b>' + bbEsc(t.title) + '</b><span>' + bbEsc(t.desc) + '</span>' + (prog ? '<div class="bx-tk-pr">' + prog + '</div>' : '') + '</div>' + btn + '</div>';
|
||
}).join('');
|
||
}
|
||
/* 集齐奖励 */
|
||
$('#bxGoal').innerHTML = (BB.stages || []).map(g => {
|
||
const e = g.earned, pct = Math.round(g.got / g.total * 100);
|
||
const prize = (g.doudou ? g.doudou + ' 兜豆' : '') + (g.gift ? (g.doudou ? ' + ' : '') + '实物纪念礼' : '');
|
||
let tail;
|
||
if (e) tail = e.needAddr ? '<button class="bx-gl-b" data-addr="1" type="button">填收货信息</button>'
|
||
: '<span class="bx-gl-s' + (e.status === 'sent' ? ' sent' : '') + '">' + (e.status === 'sent' ? '已发放' : '待发放') + '</span>';
|
||
else if (g.complete) tail = '<span class="bx-gl-s off">补发集齐</span>';
|
||
else tail = '<span class="bx-gl-l">差 <b>' + g.left + '</b> 张</span>';
|
||
return '<div class="bx-gl' + (g.key === 'all' ? ' all' : '') + (e ? ' got' : '') + '"><div class="bx-gl-h"><b>' + g.name + '</b><em>' + prize + '</em>' + tail + '</div>' +
|
||
'<i class="bx-gl-bar"><s style="width:' + pct + '%"></s></i></div>';
|
||
}).join('');
|
||
/* 待补开 */
|
||
const pend = $('#bxPend');
|
||
pend.classList.toggle('hide', op.length < 2);
|
||
if (op.length >= 2) {
|
||
$('#bxPendN').textContent = op.length;
|
||
$('#bxChips').innerHTML = op.map(w => '<button class="bx-chip' + (w === bbSel ? ' on' : '') + '" data-w="' + w + '" type="button">孕 ' + w + ' 周</button>').join('');
|
||
}
|
||
/* 卡册 */
|
||
$('#bxGot').textContent = BB.collected;
|
||
$('#bxAll').textContent = BB.totalWeeks;
|
||
requestAnimationFrame(() => { $('#bxBar').style.width = Math.round(BB.collected / BB.totalWeeks * 100) + '%'; });
|
||
const nBack = (BB.album || []).filter(a => a.src === 'backfill').length;
|
||
$('#bxAnote').textContent = (BB.flash ? '其中闪卡 ' + BB.flash + ' 张。' : '') + (nBack ? '标「补」的是来之前的孕周,已自动补发普通款。' : '') +
|
||
(lk.length ? '标 🔒 的是错过的周,完成小任务即可解锁。' : '');
|
||
const byW = {};
|
||
(BB.album || []).forEach(a => { byW[a.week] = a; });
|
||
$('#bxGroups').innerHTML = BX_GROUPS.map(g => {
|
||
let n = 0, cells = '';
|
||
for (let w = g.from; w <= g.to; w++) {
|
||
const a = byW[w] || {};
|
||
if (a.got) {
|
||
n++;
|
||
cells += '<button class="bx-t got' + (a.v === 'flash' ? ' flash' : '') + (bbNew === w ? ' is-new' : '') + '" data-w="' + w + '" data-v="' + (a.v || 'normal') + '" type="button">' +
|
||
(CARD_IMG[w] ? '<img src="assets/img/' + CARD_IMG[w] + '.jpg" alt="" loading="lazy">' : '<span class="fb">' + babySize(w).emoji + '</span>') +
|
||
(a.src === 'backfill' ? '<span class="bf">补</span>' : '') + '<span class="wk">' + w + ' 周</span></button>';
|
||
} else if (lk.indexOf(w) >= 0) {
|
||
cells += '<button class="bx-t lockd" data-lk="' + w + '" type="button"><span class="li" aria-hidden="true">🔒</span><span class="wk">' + w + ' 周</span></button>';
|
||
} else if (op.indexOf(w) >= 0) {
|
||
cells += '<button class="bx-t can" data-op="' + w + '" type="button">' + giftSVG() + '<span class="wk">' + w + ' 周</span></button>';
|
||
} else {
|
||
cells += '<div class="bx-t lock">?<span class="wk">' + w + ' 周</span></div>';
|
||
}
|
||
}
|
||
const all = g.to - g.from + 1;
|
||
return '<div class="bx-grp"><div class="bx-gh' + (n === all ? ' full' : '') + '" style="--c:' + g.c + '"><i></i>' + g.name + ' · ' + g.from + '–' + g.to + ' 周<span>' +
|
||
(n === all ? '已集齐 ✓' : n + '/' + all) + '</span></div><div class="bx-grid">' + cells + '</div></div>';
|
||
}).join('');
|
||
bbNew = null;
|
||
/* 规则与概率(接口下发) */
|
||
const pb = BB.probs || {};
|
||
const bar = x => '<div class="bx-pr"><span>' + bbEsc(x.name) + '</span><i style="--w:' + x.p + '%"></i><b>' + x.p + '%</b></div>';
|
||
$('#bxRulesBody').innerHTML = '<h4>宝宝卡(每盒必出 1 张)</h4>' + (pb.card || []).map(bar).join('') +
|
||
'<h4>随机奖励(每盒 1 份)</h4>' + (pb.reward || []).map(bar).join('') +
|
||
'<h4>规则</h4><ul>' + (pb.notes || []).map(t => '<li>' + bbEsc(t) + '</li>').join('') + '</ul>' +
|
||
'<p class="free">孕期盲盒完全免费,不出售、不需要任何付费。</p>';
|
||
}
|
||
$('#bxChips').addEventListener('click', e => {
|
||
const b = e.target.closest('.bx-chip');
|
||
if (!b || bbBusy) return;
|
||
bbSel = +b.dataset.w; haptic(); renderBox();
|
||
$('#detailBox .detail-body').scrollTo({ top: 0, behavior: 'smooth' });
|
||
});
|
||
$('#bxGroups').addEventListener('click', e => {
|
||
const t = e.target.closest('.bx-t');
|
||
if (!t || bbBusy) return;
|
||
if (t.dataset.op) { bbSel = +t.dataset.op; renderBox(); $('#detailBox .detail-body').scrollTo({ top: 0, behavior: 'smooth' }); return; }
|
||
if (t.dataset.lk) { bxLockSel = +t.dataset.lk; renderBox(); $('#bxLock').scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
|
||
const w = +t.dataset.w;
|
||
if (!w) return;
|
||
/* 卡册里点开一张:大小来自本地 SIZE 表,发育说明取孕期小知识的「宝宝这周」;周记 / 宝宝的话由接口 album 带回 */
|
||
const a = (BB.album || []).find(x => x.week === w) || {};
|
||
const c = { week: w, v: t.dataset.v, size: babySize(w), say: a.say || '', diary: a.diary || [] };
|
||
showReveal(c, null, false);
|
||
loadTips().then(() => { const x = TIPS && TIPS[w]; if (x && x.baby && $('#bxReveal').classList.contains('show')) $('#bxDev').textContent = x.baby; });
|
||
});
|
||
$('#bxLockChips').addEventListener('click', e => {
|
||
const b = e.target.closest('.bx-chip');
|
||
if (!b || bbBusy) return;
|
||
bxLockSel = +b.dataset.w; haptic(); renderBox();
|
||
});
|
||
$('#bxTasks').addEventListener('click', e => {
|
||
const go = e.target.closest('[data-app]');
|
||
if (go) {
|
||
const app = APP_BY_ID[go.dataset.app];
|
||
if (app && app.url) jumpApp(app.url, app.identifier); else toast('这个工具暂时打不开,换一个任务试试');
|
||
return;
|
||
}
|
||
const u = e.target.closest('[data-unlock]');
|
||
if (!u || bbBusy || !bxLockSel) return;
|
||
bbBusy = true; u.disabled = true; u.textContent = '解锁中…';
|
||
const w = bxLockSel;
|
||
bbApi('blindbox/unlock?week=' + w + '&task=' + u.dataset.unlock, 'POST').catch(() => null).then(j => {
|
||
bbBusy = false;
|
||
if (!j || j.code !== 200) { toast((j && j.message) || '网络开小差了,再试一次'); loadBlindbox(); return; }
|
||
if (navigator.vibrate) navigator.vibrate([10, 30, 14]);
|
||
toast('孕 ' + w + ' 周的盲盒解锁啦,快打开看看');
|
||
bbSel = w;
|
||
loadBlindbox().then(() => { bbSel = w; renderBox(); $('#detailBox .detail-body').scrollTo({ top: 0, behavior: 'smooth' }); });
|
||
});
|
||
});
|
||
$('#bxGoal').addEventListener('click', e => { if (e.target.closest('[data-addr]')) openModal('modalBxAddr'); });
|
||
$('#bxAddrSave').addEventListener('click', () => {
|
||
const v = id => $('#' + id).value.trim();
|
||
const body = { name: v('bxAddrName'), phone: v('bxAddrPhone'), addr: v('bxAddrAddr') };
|
||
if (!body.name || !body.phone || !body.addr) { toast('请把收货人、电话、地址填完整'); return; }
|
||
if (!/^1\d{10}$/.test(body.phone)) { toast('手机号好像不对,再检查一下'); return; }
|
||
fetch('/api/mp/public/mama-box/blindbox/claim?openid=' + encodeURIComponent(hashParam('openid')), {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||
}).then(r => r.json()).catch(() => null).then(j => {
|
||
if (!j || j.code !== 200) { toast((j && j.message) || '提交失败,再试一次'); return; }
|
||
closeModal(); toast('收到啦,工作人员会尽快寄出'); loadBlindbox();
|
||
});
|
||
});
|
||
/* 集齐庆祝:开盒 / 刷新时接口带回 newRewards(服务端只返回一次),排队逐个弹 */
|
||
function showPrize() {
|
||
const g = bxPrizeQ.shift();
|
||
if (!g) return;
|
||
$('#bxPrizeT').textContent = g.key === 'all' ? '37 张宝宝卡全部集齐!' : '集齐' + g.name + '!';
|
||
$('#bxPrizeP').textContent = (g.doudou ? g.doudou + ' 兜豆' : '') + (g.gift ? (g.doudou ? '和' : '') + g.gift : '') + '已为你记下,工作人员会在 7 个工作日内发放' +
|
||
(g.gift ? ',先填一下收货信息吧。' : '。');
|
||
$('#bxPrizeOk').textContent = g.gift ? '去填收货信息' : '太好了';
|
||
$('#bxPrizeOk').dataset.addr = g.gift ? '1' : '';
|
||
openModal('modalBxPrize');
|
||
}
|
||
$('#bxPrizeOk').addEventListener('click', () => {
|
||
const addr = $('#bxPrizeOk').dataset.addr;
|
||
closeModal();
|
||
if (addr) openModal('modalBxAddr'); else setTimeout(showPrize, 250);
|
||
});
|
||
$('#bxRuleBtn').addEventListener('click', () => {
|
||
const d = $('#bxRules'); d.open = true;
|
||
d.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
$('#bxBox').addEventListener('click', () => openBox());
|
||
$('#bxOpen').addEventListener('click', () => {
|
||
if (bbSel) openBox();
|
||
else if (BB && BB.currentCard) showReveal(BB.currentCard, null, false);
|
||
});
|
||
function openBox() {
|
||
if (bbBusy || !bbSel) return;
|
||
bbBusy = true; haptic();
|
||
const st = $('#bxStage'), btn = $('#bxOpen'), w = bbSel, t0 = Date.now();
|
||
st.classList.add('is-shaking');
|
||
btn.disabled = true; btn.textContent = '正在打开…';
|
||
bbApi('blindbox/open?week=' + w, 'POST').catch(() => null).then(j => {
|
||
/* 至少晃 1.3 秒:接口太快时动画一闪而过,没有「拆」的感觉 */
|
||
setTimeout(() => {
|
||
st.classList.remove('is-shaking');
|
||
if (!j || j.code !== 200 || !j.result) {
|
||
bbBusy = false; btn.disabled = false; btn.textContent = '开启盲盒';
|
||
toast((j && j.message) || '网络开小差了,再试一次');
|
||
if (j && j.code === 400) loadBlindbox();
|
||
return;
|
||
}
|
||
st.classList.add('is-popping');
|
||
if (navigator.vibrate) navigator.vibrate([12, 40, 18]);
|
||
const r = j.result;
|
||
(r.newRewards || []).forEach(g => bxPrizeQ.push(g));
|
||
bbNew = r.card.week;
|
||
setTimeout(() => { showReveal(r.card, r.reward, true); bbBusy = false; }, 420);
|
||
loadBlindbox(); // 后台刷新横幅与卡册(翻卡层盖着,用户看不到重绘)
|
||
}, Math.max(0, 1300 - (Date.now() - t0)));
|
||
});
|
||
}
|
||
function burst(flash) {
|
||
const box = $('#bxConfetti');
|
||
const C = flash ? ['#FFD66E', '#FF9AD5', '#9ADFFF', '#fff', '#C9B6FF'] : ['#FFD66E', '#FF8A66', '#FFB3C1', '#fff', '#9ED8C0'];
|
||
let h = '';
|
||
for (let i = 0; i < (flash ? 44 : 30); i++) {
|
||
const a = Math.random() * Math.PI * 2, d = 90 + Math.random() * 170;
|
||
h += '<i class="' + (i % 3 ? '' : 'r') + '" style="--c:' + C[i % C.length] + ';--x:' + Math.round(Math.cos(a) * d) + 'px;--y:' + Math.round(Math.sin(a) * d + 60) +
|
||
'px;--r:' + Math.round(Math.random() * 720 - 360) + 'deg;--d:' + (Math.random() * .15).toFixed(2) + 's"></i>';
|
||
}
|
||
box.innerHTML = h;
|
||
setTimeout(() => { box.innerHTML = ''; }, 1800);
|
||
}
|
||
/* 翻卡层。fresh=true:刚开出来(彩纸 + 翻面 + 奖励);false:从卡册/舞台查看(直接正面) */
|
||
function showReveal(card, reward, fresh) {
|
||
const rv = $('#bxReveal'), c3 = $('#bxCard3d'), flash = card.v === 'flash';
|
||
$('#bxFront').innerHTML = cardFrontHTML(card);
|
||
if (!$('#bxBack').firstChild) $('#bxBack').innerHTML = giftSVG() + '<b>兜爸心选 · 孕期盲盒</b>';
|
||
$('#bxTag').textContent = fresh ? (flash ? '✦ 哇!开出了闪卡 ✦' : '新卡 · 孕 ' + card.week + ' 周') : (flash ? '✦ 闪卡 · 孕 ' + card.week + ' 周' : '孕 ' + card.week + ' 周');
|
||
$('#bxDev').textContent = card.dev || '';
|
||
const dl = card.diary || [];
|
||
$('#bxDiary').innerHTML = dl.length ? '<div class="bx-dy-h">📖 宝宝周记 · 孕 ' + card.week + ' 周</div>' + dl.map(x => '<p>' + bbEsc(x) + '</p>').join('') : '';
|
||
let rw = '';
|
||
if (reward) {
|
||
const tj = reward.type === 'taijiao';
|
||
rw = '<div class="bx-rw"><div class="bx-rw-h"><i>' + (tj ? '🎵' : '💌') + '</i>' + (tj ? '胎教时间 · ' + bbEsc(reward.kind || '') : '宝宝对你说') +
|
||
'<em>随机奖励</em></div>' + (reward.title ? '<div class="bx-rw-t">' + bbEsc(reward.title) + '</div>' : '') +
|
||
'<p class="bx-rw-p">' + bbEsc(reward.text) + '</p></div>';
|
||
}
|
||
$('#bxReward').innerHTML = rw;
|
||
$('#bxKeep').textContent = fresh ? '收进卡册' : '好的';
|
||
rv.classList.remove('is-done', 'is-burst');
|
||
rv.classList.toggle('is-flash', flash);
|
||
c3.classList.remove('is-flip', 'no-anim');
|
||
void c3.offsetWidth;
|
||
rv.classList.add('show');
|
||
rv.setAttribute('aria-hidden', 'false');
|
||
rv.scrollTop = 0;
|
||
if (fresh) {
|
||
rv.classList.add('is-burst');
|
||
burst(flash);
|
||
setTimeout(() => c3.classList.add('is-flip'), 260);
|
||
setTimeout(() => rv.classList.add('is-done'), 1100);
|
||
} else {
|
||
c3.classList.add('no-anim', 'is-flip');
|
||
rv.classList.add('is-done');
|
||
}
|
||
}
|
||
function hideReveal() {
|
||
const rv = $('#bxReveal');
|
||
rv.classList.remove('show', 'is-done', 'is-burst');
|
||
rv.setAttribute('aria-hidden', 'true');
|
||
}
|
||
$('#bxKeep').addEventListener('click', () => { hideReveal(); renderBox(); if (bxPrizeQ.length) setTimeout(showPrize, 350); });
|
||
$('#detailBox .dt-back').addEventListener('click', hideReveal);
|
||
|
||
/* ================= 孕期小知识(2026-09-30) =================
|
||
内容 assets/data/preg-tips.json:[{week, baby, mom, eat, care, days:[{t,d}×7]}],孕 4–40 周。
|
||
第几天 = state.day(0–6),和首页「孕 N 周 D 天」同一口径。
|
||
⚠️ 只在 state.pregEmpty === false(/pregnancy 已确认有孕周)且非产后时显示:
|
||
pregEmpty 初值是 undefined —— 档案没回来之前 state.week 是 DEFAULT_DUE 推出来的假孕周,
|
||
拿它配知识就是给人看假数据。所以这里用「=== false」而不是「!pregEmpty」。 */
|
||
let TIPS = null, tipsLoading = null, tipSel = -1;
|
||
function loadTips() {
|
||
if (!tipsLoading) {
|
||
tipsLoading = fetch('assets/data/preg-tips.json?v=' + VER)
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(arr => {
|
||
const m = {};
|
||
(arr || []).forEach(x => { if (x && x.week) m[x.week] = x; });
|
||
TIPS = m;
|
||
})
|
||
.catch(() => { TIPS = {}; });
|
||
}
|
||
return tipsLoading;
|
||
}
|
||
function renderTips() {
|
||
const sec = $('#pregTips');
|
||
if (!sec) return;
|
||
if (state.pregEmpty !== false || state.postpartum) { sec.classList.add('hide'); return; }
|
||
if (!TIPS) { loadTips().then(renderTips); return; }
|
||
const w = Math.max(4, Math.min(40, state.week || 0));
|
||
const wk = TIPS[w];
|
||
if (!wk || !(wk.days || []).length) { sec.classList.add('hide'); return; }
|
||
const today = Math.max(0, Math.min(6, state.day || 0));
|
||
const size = babySize(w);
|
||
$('#ptWk').textContent = '孕 ' + state.week + ' 周';
|
||
$('#ptWhen').textContent = '本周第 ' + (today + 1) + ' 天';
|
||
$('#ptFloat').textContent = size.emoji;
|
||
$('#ptBabyIco').textContent = size.emoji;
|
||
/* 四张卡全文只在详情页 #detailTips;首页那排入口只有图标 + 标题 */
|
||
$('#ptBaby').textContent = '像' + size.name + '那么大。' + (wk.baby || '');
|
||
$('#ptMom').textContent = wk.mom || '';
|
||
$('#ptEat').textContent = wk.eat || '';
|
||
$('#ptCare').textContent = wk.care || '';
|
||
$('#ptChipBaby').textContent = size.emoji;
|
||
$('#ptDtTitle').textContent = '孕 ' + state.week + ' 周 · 这一周';
|
||
/* 一周 7 个圆点:以前可回看,今天实心,以后锁着 */
|
||
const box = $('#ptDays');
|
||
box.textContent = '';
|
||
for (let i = 0; i < 7; i++) {
|
||
const b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = 'pt-day ' + (i < today ? 'is-past' : i === today ? 'is-today' : 'is-lock');
|
||
b.setAttribute('role', 'tab');
|
||
const n = document.createElement('i'); n.textContent = i + 1;
|
||
const t = document.createElement('span');
|
||
t.textContent = i === today ? '今天' : (i === today + 1 ? '明天' : '\u00a0'); // 圈里已是「第几天」,只给今天/明天配字
|
||
b.appendChild(n); b.appendChild(t);
|
||
b.addEventListener('click', () => {
|
||
if (i > today) { toast(i === today + 1 ? '明天再来看新的小知识~' : '到那天再来看哦~'); return; }
|
||
showTip(wk, i, today);
|
||
});
|
||
box.appendChild(b);
|
||
}
|
||
/* 详情页「本周每日小知识」:今天在上,往前倒着排到第 1 天;后面几天只露一行「明天见」 */
|
||
const list = $('#ptList');
|
||
list.textContent = '';
|
||
for (let i = today; i >= 0; i--) {
|
||
const d = wk.days[i] || {};
|
||
const it = document.createElement('div');
|
||
it.className = 'pt-li' + (i === today ? ' is-today' : '');
|
||
const tag = document.createElement('span');
|
||
tag.className = 'pt-li-tag';
|
||
tag.textContent = i === today ? '今天 · 第 ' + (i + 1) + ' 天' : '第 ' + (i + 1) + ' 天';
|
||
const t = document.createElement('div'); t.className = 'pt-li-t'; t.textContent = d.t || '';
|
||
const p = document.createElement('div'); p.className = 'pt-li-d'; p.textContent = d.d || '';
|
||
it.appendChild(tag); it.appendChild(t); it.appendChild(p);
|
||
list.appendChild(it);
|
||
}
|
||
if (today < 6) {
|
||
const lk = document.createElement('div');
|
||
lk.className = 'pt-li is-lock';
|
||
lk.textContent = '第 ' + (today + 2) + (today < 5 ? '–7' : '') + ' 天 · 明天见,每天解锁一条';
|
||
list.appendChild(lk);
|
||
}
|
||
tipSel = -1;
|
||
showTip(wk, today, today);
|
||
sec.classList.remove('hide');
|
||
revealTipsGrid();
|
||
}
|
||
function showTip(wk, i, today) {
|
||
if (i === tipSel) return;
|
||
tipSel = i;
|
||
const d = wk.days[i] || wk.days[0];
|
||
$('#ptBadge').textContent = i === today ? '今日小知识' : '第 ' + (i + 1) + ' 天 · 回看';
|
||
$('#ptT').textContent = d.t || '';
|
||
$('#ptD').textContent = d.d || '';
|
||
$$('#ptDays .pt-day').forEach((b, k) => {
|
||
b.classList.toggle('is-sel', k === i);
|
||
b.setAttribute('aria-selected', k === i ? 'true' : 'false');
|
||
});
|
||
const body = $('#ptBody');
|
||
body.classList.remove('is-in'); void body.offsetWidth; body.classList.add('is-in'); // 重播进场动画
|
||
}
|
||
/* 首页四个入口 → 详情页,滚到对应那张卡并闪一下 */
|
||
function openTips(key) {
|
||
openPanel('detailTips');
|
||
const body = $('#detailTips .detail-body');
|
||
body.scrollTop = 0;
|
||
$('#ptGrid').classList.add('is-shown');
|
||
const c = key && $('#ptGrid .pt-card[data-pt="' + key + '"]');
|
||
if (c) setTimeout(() => {
|
||
body.scrollTo({ top: c.offsetTop - 16, behavior: 'smooth' });
|
||
c.classList.remove('is-hit'); void c.offsetWidth; c.classList.add('is-hit');
|
||
}, 400); // 等详情页滑入再滚
|
||
}
|
||
(function () {
|
||
const ch = $('#ptChips');
|
||
if (ch) ch.addEventListener('click', e => { const b = e.target.closest('.pt-chip'); if (b) openTips(b.dataset.pt); });
|
||
/* 首页今日小知识大卡也能点进详情页;底下 7 个圆点仍是切换回看,不跳页 */
|
||
const td = $('#ptToday');
|
||
if (td) td.addEventListener('click', e => { if (!e.target.closest('#ptDays')) openTips(); });
|
||
})();
|
||
function revealTipsGrid() {
|
||
const g = $('#ptGrid');
|
||
if (!g || g.classList.contains('is-shown')) return;
|
||
if (!('IntersectionObserver' in window)) { g.classList.add('is-shown'); return; }
|
||
const io = new IntersectionObserver(es => {
|
||
if (es.some(e => e.isIntersecting)) { g.classList.add('is-shown'); io.disconnect(); }
|
||
}, { threshold: 0.2 });
|
||
io.observe(g);
|
||
}
|
||
|
||
/* 拉卡片数据(与其它远程数据一起打;失败静默,卡上保留现有值) */
|
||
/* 预览环境专用的一组示例数据(2026-09-30 用户要求:五张工具卡填一组示例数据,看排版效果)。
|
||
⚠️ 只在 /mama-box-preview/ 路径下生效,正式首页(/mama-box/)永远走不到这里 ——
|
||
2026-09-22 出过写死演示值被所有用户看到的事故(AGENTS §4.2 不造假数据),
|
||
所以示例数据绝不能靠「接口没回来就兜底」这种条件触发,只认预览路径。
|
||
结构照搬 tool-cards 接口,直接喂给 applyRemoteCards,渲染逻辑一行不多写。 */
|
||
const DEMO_CARDS = location.pathname.indexOf('/mama-box-preview/') === 0 ? {
|
||
ok: true, day: '2026-09-30',
|
||
cards: [
|
||
{ slot: 'kick', value: '12', sub: '3 段 · 最近 14:20' },
|
||
{ slot: 'weight', value: '58.2', unit: 'kg', sub: '本周 +0.3kg' },
|
||
{ slot: 'cal', text: '10月8日' },
|
||
{ slot: 'fweight', text: '约 1250g' },
|
||
{ slot: 'curve', value: '5分20秒' },
|
||
],
|
||
} : null;
|
||
|
||
function applyRemoteCardsFetch() {
|
||
if (DEMO_CARDS) { applyRemoteCards(DEMO_CARDS); return; }
|
||
const openid = hashParam('openid');
|
||
if (!openid) return; // 访客态不问(接口要 openid)
|
||
const unionid = hashParam('unionid');
|
||
const qs = 'openid=' + encodeURIComponent(openid) +
|
||
(unionid ? '&unionid=' + encodeURIComponent(unionid) : '');
|
||
fetch('/api/mp/public/mama-box/tool-cards?' + qs)
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(j => { const r = j && j.result; if (r) applyRemoteCards(r); })
|
||
.catch(() => { /* 静默:保留现有值 */ });
|
||
}
|
||
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 → 数胎动对外 API,动态获取) ----------
|
||
首页 .today-line 时间轴原来是纯演示内容(无真实数据源,访客/未完善态整块隐藏),
|
||
现在启动后拉 /footprint/today 动态渲染。后端封装见 box_app 的 FootprintService:
|
||
上游数胎动按 unionid 建档(openid 跨应用互不相干),H5 身份注入惯例带
|
||
openid+unionid,unionid 缺失时后端用映射表兜底,这里只管把参数带上。
|
||
|
||
各分支(对齐全站「远程数据为准、拉不到回退内置」的惯例):
|
||
- linked=false(未关联数胎动 / 上游查无此人)→ 整块隐藏。演示行是假数据,
|
||
这用户在数胎动体系里没有档案,不能留着冒充真足迹。
|
||
- linked=true + events → 重建 .steps 行。textContent 拼装(上游文案直接进
|
||
DOM,杜绝注入);最多 8 条(接口升序,取最近 8 条,更早的让给「查看全部」)。
|
||
- linked=true + 无事件 → 一条占位,别让卡空成一截竖线。
|
||
- 拉取失败 → 什么都不动,保持内置演示行(跟其它远程接口的回退一致);
|
||
访客态不请求(pregEmpty 分支已整块隐藏,不会露出)。
|
||
⚠️ 竞态:本函数与 applyRemoteProfile 并行。profile 回来若判 pregEmpty 会
|
||
隐藏时间轴,所以这里落笔前再看一眼 state.pregEmpty,别把刚藏好的放出来。 */
|
||
var _footprintApplied = false;
|
||
/* 今日脚印(2026-09-23,B 方案:数据全部来自 box_app 的 /footprint/today,
|
||
它的上游是数胎动平台的**用户面**接口 /user/daily/footprint):
|
||
rows 拿到事件 → 时间轴;AI 气泡用接口给的 digest.text(**真文案**)
|
||
guide 今天**一次都没用过**(used 为空)/ 上游查无此人 → 引导占位:文案用接口的 lead、
|
||
应用用 unused[](上游已按我们上架清单过滤,且带回 url),
|
||
对得上 App 表的还能直接点进该应用
|
||
hide 接口读不到(网络 / 非 200 / 上游故障且无旧值)→ **整块不显示**
|
||
⚠️ AI 气泡不再挂写死的那句「今天你数到我动了 3 次」:文案一律取 digest.text,取不到就收起气泡
|
||
—— **data-driven**,不靠"有没有事件"猜(那句演示文案已从 index.html 删掉)。
|
||
⚠️ 未建档(pregEmpty)不再整块隐藏:照接口结论显示,新用户也能看到引导。
|
||
⚠️ 模块显隐只有 fpRender 一个 owner(原来 syncPregnancy 也直接改 display,会撞竞态)。 */
|
||
let _fpMode = 'pending'; // pending(加载中) | rows | guide | hide
|
||
let _fpData = null; // 接口 result(events / digest / lead / unused…)
|
||
function fpApply(mode, r) { _fpMode = mode; _fpData = r || null; fpRender(); if (plHook) plHook(); }
|
||
function fpRender() {
|
||
const tl = document.querySelector('.today-line'), steps = $('#tlSteps'), guide = $('#tlGuide');
|
||
const _cd = $('#fpCalD');
|
||
if (_cd) {
|
||
const n = new Date();
|
||
_cd.textContent = (n.getDate() < 10 ? '0' : '') + n.getDate();
|
||
$('#fpCalM').textContent = (n.getMonth() + 1) + '月 · 周' + '日一二三四五六'[n.getDay()];
|
||
}
|
||
if (!tl || !steps) return;
|
||
const mode = _fpMode, r = _fpData || {};
|
||
if (mode === 'hide') { tl.style.display = 'none'; return; }
|
||
tl.style.display = '';
|
||
/* AI 气泡:文案来自接口 digest.text;没有就整体收起。上游文案是**纯文本**,用 textContent 写。 */
|
||
const say = document.querySelector('.ab-say');
|
||
const txt = (r.digest && r.digest.text) || '';
|
||
if (say) say.textContent = txt;
|
||
tl.classList.toggle('no-ai', !txt);
|
||
tl.classList.toggle('ai-warn', !!(r.digest && r.digest.warn));
|
||
if (guide) guide.style.display = (mode === 'guide') ? '' : 'none';
|
||
/* 2026-10-02 用户要求:没脚印时改成「今日待办」清单(见 renderGuide),不再放示例行。 */
|
||
if (mode === 'guide') { steps.style.display = 'none'; steps.innerHTML = ''; renderGuide(r); return; }
|
||
steps.style.display = '';
|
||
steps.innerHTML = '';
|
||
if (mode === 'pending') { steps.append(pendingFootprintStep()); return; }
|
||
/* ⚠️ 上游口径(2026-09-23 接口方确认):`used` = 当天**用过**(有页面访问或有业务结果),
|
||
`results` 只是其中的**业务记录**。所以「用过但没留下记录」的日子 `used` 非空、`results` 为空 ——
|
||
这种日子要显示**应用名列表**,不能走「还没有脚印」引导(否则会和气泡里那句
|
||
「今天用了 N 个工具,还没有留下具体记录。」自相矛盾,用户截图里就是这个现象)。
|
||
两类行按 HH:MM 合并排序:有记录的行来自 events,没记录的应用按 first_at 出一行。 */
|
||
const rows = (r.events || []).map(e => ({ at: e.at || '', label: e.label || '', detail: e.detail || '', app: e.app || '' }));
|
||
const withEvents = new Set((r.events || []).map(e => e.appId).filter(Boolean));
|
||
(r.used || []).filter(u => u.appId && !withEvents.has(u.appId)).forEach(u => {
|
||
rows.push({
|
||
at: u.at || '',
|
||
label: u.appName || '',
|
||
detail: u.views > 1 ? '浏览 ' + u.views + ' 次' : '',
|
||
app: '',
|
||
});
|
||
});
|
||
rows.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
||
rows.slice(-8).forEach(row => steps.append(buildFootprintStep(row)));
|
||
}
|
||
|
||
/* 引导占位:文案与应用都来自上游 —— lead 是上游自己的引导语,unused[] 是今天没用过的应用。
|
||
能对上 App 表的(identifier 由 box_app 按 url 路径解析)渲染成可点芯片,其余只显示名字。 */
|
||
/* 今日待办:每个还没用的工具配一句「今天该做的事」。按应用名关键词匹配,匹配不上就用通用句。 */
|
||
const FP_TODO = [
|
||
[/胎动/, '数一次胎动', '安静侧躺 1 小时,看看小家伙动了几次'],
|
||
[/宫缩/, '留意一下宫缩', '肚子发紧时记下间隔和持续时间'],
|
||
[/体重/, '称一下今天的体重', '早上空腹称最准,看看本周涨了多少'],
|
||
[/血糖/, '记一次血糖', '餐后 2 小时测一次,记下来给医生看'],
|
||
[/血压/, '量一次血压', '坐着休息 5 分钟后再量'],
|
||
[/补水|补剂|喝水/, '喝水和补剂打个卡', '今天的叶酸、钙片、水喝够了吗'],
|
||
[/待产/, '看看待产包还差什么', '一次备一点,临产不手忙脚乱'],
|
||
[/产检|孕检|日历/, '确认下次产检时间', '提前看看要空腹还是要憋尿'],
|
||
[/能不能吃/, '查查今天想吃的能不能吃', '馋什么先查一下,吃得放心'],
|
||
[/能不能做/, '查查今天想做的能不能做', '染发、泡澡、运动……先查再做'],
|
||
[/估重/, '算一算宝宝有多重', '拿最近一次 B 超数据就能估'],
|
||
[/起名/, '给宝宝想一个名字', '挑几个喜欢的字,和家人一起选'],
|
||
[/档案/, '翻翻孕期档案', '把这周的变化记进档案里'],
|
||
[/运动/, '做 10 分钟孕期运动', '散散步、拉伸一下,身体会轻松些'],
|
||
];
|
||
/* 今日待办前面的线性小图标(2026-10-02 用户:「把 logo 调到前面……跟背景的绿色协调,用线性的」)。
|
||
不用各应用的彩色 logo(五颜六色压在薄荷绿卡上很花),统一画成 24 格、1.8 线宽、currentColor 的线稿,
|
||
颜色由 CSS 给(墨绿),底是一块浅绿圆角方。按应用名关键词匹配,匹配不上用一支笔。 */
|
||
const TD_ICO = [
|
||
[/胎动/, '<path d="M10.5 21c-2.6 0-4-2-3.6-4.6l.6-3.6c.4-2.5 1.9-4.1 3.9-4.1 2.2 0 3.4 1.8 3 4.3l-.7 4.1c-.4 2.4-1.5 3.9-3.2 3.9z"/><circle cx="8.3" cy="4.8" r="1.4"/><circle cx="11.6" cy="3.9" r="1.2"/><circle cx="14.6" cy="4.7" r="1.1"/><circle cx="16.8" cy="6.6" r="1"/>'],
|
||
[/宫缩/, '<path d="M3 13h3l2-5 3 10 3-12 2.5 7H21"/>'],
|
||
[/体重/, '<rect x="4" y="4" width="16" height="16" rx="4"/><path d="M8.5 10a3.5 3.5 0 017 0z"/><path d="M12 10l1.4-2"/>'],
|
||
[/血糖/, '<path d="M12 3.5s-5.5 6.2-5.5 10.2a5.5 5.5 0 0011 0C17.5 9.7 12 3.5 12 3.5z"/><path d="M9.8 14.2a2.3 2.3 0 002.2 2.1"/>'],
|
||
[/血压|胎心/, '<path d="M12 19.5s-7.5-4.6-7.5-10A4.2 4.2 0 0112 7a4.2 4.2 0 017.5 2.5c0 5.4-7.5 10-7.5 10z"/><path d="M6 12h3l1.5-2.5 2 4 1.5-1.5H18"/>'],
|
||
[/运动/, '<circle cx="13" cy="4.6" r="1.7"/><path d="M10 21l2-5.5-2.5-2.5 1.5-5 3.5 3 3 .5"/><path d="M8 11l2.5-3.5"/><path d="M14.5 21l-2.5-5.5"/>'],
|
||
[/产检|孕检|日历/, '<rect x="4" y="5.5" width="16" height="14.5" rx="3"/><path d="M4 10h16M8.5 3.5v4M15.5 3.5v4"/><path d="M9 14.6l2 2 4-4"/>'],
|
||
[/待产/, '<path d="M5.5 9h13l-1 11h-11z"/><path d="M9 9V7a3 3 0 016 0v2"/><path d="M12 16.6s-2.2-1.3-2.2-2.7a1.1 1.1 0 012.2-.3 1.1 1.1 0 012.2.3c0 1.4-2.2 2.7-2.2 2.7z"/>'],
|
||
[/能不能吃|饮食|营养/, '<path d="M4 12h16a8 8 0 01-16 0z"/><path d="M9 8.5c0-1.5 1-1.8 1-3.2M13 8.5c0-1.5 1-1.8 1-3.2"/>'],
|
||
[/能不能做/, '<circle cx="12" cy="12" r="8"/><path d="M9.8 9.6a2.3 2.3 0 114 1.6c-.9.7-1.8 1.1-1.8 2.3"/><path d="M12 16.4v.1"/>'],
|
||
[/估重/, '<circle cx="12" cy="13" r="7"/><path d="M12 13l3-3"/><path d="M9 3.5h6"/>'],
|
||
[/补水|补剂|喝水/, '<path d="M7 4h10l-1.3 15a2 2 0 01-2 1.8h-3.4a2 2 0 01-2-1.8z"/><path d="M7.6 10h8.8"/>'],
|
||
[/起名/, '<path d="M5 19l3.5-1 9.8-9.8a1.8 1.8 0 00-2.5-2.5L6 15.5z"/><path d="M14.5 7l2.5 2.5"/>'],
|
||
[/档案/, '<path d="M6 3.5h8l4 4V20H6z"/><path d="M14 3.5v4h4M9 12h6M9 15.5h6"/>'],
|
||
];
|
||
const TD_DEF = '<path d="M5 19l3.5-1 9.8-9.8a1.8 1.8 0 00-2.5-2.5L6 15.5z"/><path d="M14.5 7l2.5 2.5"/>';
|
||
function todoIco(name) {
|
||
for (const [re, svg] of TD_ICO) if (re.test(name)) return svg;
|
||
return TD_DEF;
|
||
}
|
||
function todoLine(name) {
|
||
for (const [re, t, d] of FP_TODO) if (re.test(name)) return [t, d];
|
||
return ['用一下「' + name + '」', '花一分钟,给今天留个记录'];
|
||
}
|
||
function renderGuide(r) {
|
||
const title = $('#tlGuideTitle'), sub = $('#tlGuideSub'), box = $('#tlGuideApps'), cnt = $('#tdCnt');
|
||
if (title) title.textContent = (r && r.lead) || '';
|
||
if (title) title.style.display = (r && r.lead) ? '' : 'none';
|
||
if (!box) return;
|
||
box.innerHTML = '';
|
||
((r && r.unused) || []).slice(0, 4).forEach(u => {
|
||
const ident = u.identifier || '';
|
||
const app = ident ? APP_BY_ID[ident] : null;
|
||
/* ⚠️ 优先用上游带回的 `url`(2026-09-23 起它只推**我们已上架**的应用并把正式 url 带回来,
|
||
接口方原话「有 url 就直接跳,不用再对 App 表」);App 表只作兜底。
|
||
identifier 仍用于 jumpApp 的 last_app 上报(服务端会校验 url 对得上在架应用)。 */
|
||
const href = u.url || (app && app.url) || '';
|
||
const name = u.appName || (app && app.name) || ident;
|
||
if (!name) return;
|
||
const [t, d] = todoLine(name);
|
||
const row = document.createElement(href ? 'button' : 'div');
|
||
row.className = 'td-row' + (href ? ' ripple' : '');
|
||
if (href) row.type = 'button';
|
||
row.innerHTML = '<i class="td-li" aria-hidden="true"><svg viewBox="0 0 24 24">' + todoIco(name) + '</svg></i><span class="td-txt"><b></b><em></em></span>' +
|
||
(href ? '<span class="td-go">去完成</span>' : '');
|
||
row.querySelector('b').textContent = t;
|
||
row.querySelector('em').textContent = d;
|
||
if (href) row.addEventListener('click', () => jumpApp(href, (app && app.identifier) || ident || ''));
|
||
box.appendChild(row);
|
||
});
|
||
const n = box.childElementCount;
|
||
if (cnt) cnt.textContent = n ? '0 / ' + n : '';
|
||
if (sub) sub.textContent = n ? '完成任意一件,今天的第一只小脚丫就会印上来' : '去上面的工具记一笔,今天的第一只小脚丫就会印上来';
|
||
}
|
||
|
||
/* 「记一下」弹窗上半部分(2026-10-02 用户:「有点单薄」)。数据只认 _fpData(今日脚印接口),
|
||
used = 今天用过的工具,unused = 还没用的;拿不到就把两块藏起来,退回原来的「标题 + 宫格」。 */
|
||
sheetPaint = function () {
|
||
const n = new Date(Date.now() + 8 * 3600e3); // 北京时间
|
||
const wd = '日一二三四五六'[n.getUTCDay()];
|
||
const wk = state.week ? '孕 ' + state.week + ' 周' + (state.day ? ' +' + state.day + ' 天' : '') + ' · ' : '';
|
||
$('#skSub').textContent = wk + (n.getUTCMonth() + 1) + '月' + n.getUTCDate() + '日 周' + wd;
|
||
const r = _fpMode === 'rows' || _fpMode === 'guide' ? (_fpData || {}) : null;
|
||
const today = $('#skToday'), next = $('#skNext');
|
||
today.classList.toggle('hide', !r);
|
||
next.classList.add('hide');
|
||
$$('#appsGrid .app').forEach(b => b.classList.remove('is-done'));
|
||
if (!r) return;
|
||
const used = (r.used || []).filter(u => u.appName), unused = r.unused || [];
|
||
const total = used.length + unused.length;
|
||
const nm = state.babyName || '宝宝';
|
||
$('#skNum').textContent = used.length;
|
||
$('#skArc').style.strokeDasharray = (total ? used.length / total * 113.1 : 0).toFixed(1) + ' 113.1';
|
||
$('#skLine').textContent = used.length ? '今天已经记了 ' + used.length + ' 项' : '今天还没有记录';
|
||
const done = $('#skDone');
|
||
done.textContent = '';
|
||
if (used.length) {
|
||
used.slice(0, 4).forEach(u => { const c = document.createElement('i'); c.textContent = u.appName; done.appendChild(c); });
|
||
if (used.length > 4) done.append('等 ' + used.length + ' 项');
|
||
} else done.textContent = '记下一笔,就是' + nm + '今天的第一只小脚丫';
|
||
/* 宫格里今天用过的工具打个小勾 */
|
||
const usedIds = new Set(used.map(u => u.identifier).filter(Boolean));
|
||
$$('#appsGrid .app').forEach(b => { if (usedIds.has(decodeURIComponent(b.dataset.id || ''))) b.classList.add('is-done'); });
|
||
/* 先记这件:前 2 个还没用的 */
|
||
const box = $('#skCards');
|
||
box.innerHTML = '';
|
||
unused.slice(0, 2).forEach((u, i) => {
|
||
const ident = u.identifier || '', app = ident ? APP_BY_ID[ident] : null;
|
||
const href = u.url || (app && app.url) || '';
|
||
const name = u.appName || (app && app.name) || '';
|
||
if (!href || !name) return;
|
||
const [t, d] = todoLine(name);
|
||
const logo = (app && (app.logo || app.icon)) || '';
|
||
const c = document.createElement('button');
|
||
c.type = 'button';
|
||
c.className = 'sk-card ripple c' + i;
|
||
c.innerHTML = (logo ? '<img alt="" loading="lazy">' : '<span class="sk-l"></span>') + '<b></b><em></em><span class="sk-go">去记 ›</span>';
|
||
if (logo) c.querySelector('img').src = logo; else c.querySelector('.sk-l').textContent = name.slice(0, 1);
|
||
c.querySelector('b').textContent = t;
|
||
c.querySelector('em').textContent = d;
|
||
c.addEventListener('click', () => jumpApp(href, (app && app.identifier) || ident));
|
||
box.appendChild(c);
|
||
});
|
||
next.classList.toggle('hide', !box.childElementCount);
|
||
};
|
||
|
||
/* 加载中的中性占位(不是假数据):与 index.html 里那条首屏占位同构 */
|
||
function pendingFootprintStep() {
|
||
const d = document.createElement('div'); d.className = 'step';
|
||
const time = document.createElement('span'); time.className = 'st-time'; time.textContent = '今天';
|
||
const dot = document.createElement('span'); dot.className = 'st-dot';
|
||
const txt = document.createElement('span'); txt.className = 'st-txt'; txt.textContent = '正在获取今天的脚印…';
|
||
dot.innerHTML = FP_FOOT;
|
||
const card = document.createElement('span'); card.className = 'st-card';
|
||
card.append(txt);
|
||
d.append(dot, card);
|
||
return d;
|
||
}
|
||
|
||
function applyRemoteFootprint() {
|
||
if (_footprintApplied) return; // init 只跑一次,防重复 fetch
|
||
_footprintApplied = true;
|
||
fpRender(); // 首屏先出「加载中」中性占位(不是假数据)
|
||
const openid = hashParam('openid');
|
||
if (!openid) return; // 访客态: pregEmpty 分支已隐藏,保持
|
||
const qs = ['openid=' + encodeURIComponent(openid)];
|
||
const unionid = hashParam('unionid');
|
||
if (unionid) qs.push('unionid=' + encodeURIComponent(unionid));
|
||
fetch('/api/mp/public/mama-box/footprint/today?' + qs.join('&'))
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(j => {
|
||
const r = (j && j.code === 200 && j.result) ? j.result : null;
|
||
if (!r) { fpApply('hide'); return; } // 读不到 → 不显示(不再编数据)
|
||
if (r.degraded) { fpApply('hide'); return; } // 上游故障且无旧值 → 不显示
|
||
/* 判据是 `used`(当天用没用过),**不是** `results`(有没有留下记录)——
|
||
接口方 2026-09-23 明确:lead 只在 used 为空时才有值;used 非空但没 results 时应显示应用名列表。
|
||
所以:used 空 → 引导(上游查无此人 linked=false 时也是这个分支);
|
||
used 非空 → 时间轴(里面自带"用过但没记录"的行,见 fpRender)。 */
|
||
if ((r.used || []).length) { fpApply('rows', r); return; }
|
||
fpApply('guide', r);
|
||
})
|
||
.catch(function () { fpApply('hide'); }); // 读不到 → 不显示
|
||
}
|
||
|
||
/* 一行脚印:与 index.html 演示行同构(时间/圆点/文案/来源)。
|
||
文案里的数字段加粗对齐演示排版(「数胎动 <b>2</b> 次」),
|
||
全程 createElement+textContent,上游字段再离谱也进不来标签。 */
|
||
function buildFootprintStep(e) {
|
||
const d = document.createElement('div'); d.className = 'step';
|
||
const time = document.createElement('span'); time.className = 'st-time';
|
||
time.textContent = e.at || '--:--';
|
||
const dot = document.createElement('span'); dot.className = 'st-dot';
|
||
const txt = document.createElement('span'); txt.className = 'st-txt';
|
||
const m = String(e.label || '').match(/^(.*?)(\d[\d.]*)(.*)$/);
|
||
if (m) {
|
||
if (m[1]) txt.append(m[1]);
|
||
const b = document.createElement('b'); b.textContent = m[2]; txt.append(b);
|
||
if (m[3]) txt.append(m[3]);
|
||
} else {
|
||
txt.textContent = String(e.label || '');
|
||
}
|
||
const from = document.createElement('span'); from.className = 'st-from';
|
||
from.textContent = e.app || '';
|
||
/* 2026-10-01 脚印改版:圆点里放一只小脚丫(左右交替靠 CSS :nth-child 翻转),
|
||
文案 + 时间 + 来源包进一张小卡 .st-card。SVG 是写死的常量,不含上游字段。 */
|
||
dot.innerHTML = FP_FOOT;
|
||
const card = document.createElement('span'); card.className = 'st-card';
|
||
const meta = document.createElement('span'); meta.className = 'st-meta';
|
||
meta.append(time, from);
|
||
if (e.detail) { const dt = document.createElement('span'); dt.className = 'st-det'; dt.textContent = e.detail; meta.append(dt); }
|
||
card.append(txt, meta);
|
||
d.append(dot, card);
|
||
return d;
|
||
}
|
||
const FP_FOOT = '<svg viewBox="0 0 16 22" width="12" height="16" aria-hidden="true"><ellipse cx="8.4" cy="13.6" rx="4.6" ry="6.6" fill="currentColor"/><circle cx="3.6" cy="4.6" r="1.7" fill="currentColor"/><circle cx="6.6" cy="2.6" r="1.6" fill="currentColor"/><circle cx="9.8" cy="2.4" r="1.5" fill="currentColor"/><circle cx="12.6" cy="3.8" r="1.3" fill="currentColor"/></svg>';
|
||
|
||
/* 无足迹占位行:同构的一行,读屏和眼睛都不会把空卡当成坏卡。 */
|
||
|
||
/* ---------- 进商城(web-view 内跳商城原生页) ----------
|
||
兜爸心选 H5 由小程序 web-view 承载;点「商城」跳回商城原生页。
|
||
需微信 JS-SDK 的 wx.miniProgram.switchTab —— tabBar 页必须用 switchTab
|
||
(详见 docs/mama-box-api-plan.md 2.4)。
|
||
落点 260918 起改为「商城」tab(/pages/index/mall)=原装 CRMEB 装修首页
|
||
恢复成的第 5 个 tabBar 页;此前一直落在分类页 /pages/goods_cate/goods_cate
|
||
(商品列表),按用户要求改成进商城先看首页。
|
||
仅在 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) {
|
||
/* fail 兜底:宿主小程序若还是旧版(没有「商城」tab /pages/index/mall),
|
||
switchTab 会失败且无任何反应 —— 这里给出提示,别让用户点了没动静。
|
||
(发布顺序:先发小程序版再加这个 H5 落点;反过来也不会白屏/死寂) */
|
||
window.wx.miniProgram.switchTab({
|
||
url: '/pages/index/mall',
|
||
fail: function (err) {
|
||
var m = (err && err.errMsg) || '未知原因';
|
||
toast('打开商城失败:' + m);
|
||
try { console.warn('[goMall] switchTab fail:', m); } catch (e) {}
|
||
}
|
||
});
|
||
} else {
|
||
toast('请在微信小程序中打开商城');
|
||
}
|
||
});
|
||
setTimeout(function () {
|
||
if (!responded) toast('请在微信小程序中打开商城');
|
||
}, 800);
|
||
} else {
|
||
toast('请在微信小程序中打开商城');
|
||
}
|
||
}
|
||
/* ---------- 兜豆兑换商城入口(2026-10-01) ----------
|
||
两个入口:「我的」页兜豆卡 #meExchCard、首页婴儿房下面那条 #homeExch。
|
||
地址/接口由「兜爸商城」侧提供(2026-10-01):正式与预览同一个 URL,后端切生产时 URL 不变。
|
||
· 跳转走 jumpApp()(query 带 openid/unionid),另带 return_url —— 商城左上角出返回按钮。
|
||
· 应用后台若配了同名应用(EXCH_IDS 里任一 identifier),以后台 URL 为准。 */
|
||
const EXCH_IDS = ['doudou-duihuan', 'doudouduihuan', 'duihuan'];
|
||
const EXCH_URL = 'https://taidong.douba.top/doudou-duihuan/';
|
||
const DOU_API = EXCH_URL + 'api';
|
||
function openExchange() {
|
||
let app = null;
|
||
for (const id of EXCH_IDS) { if (APP_BY_ID[id]) { app = APP_BY_ID[id]; break; } }
|
||
const base = (app && app.url) || EXCH_URL;
|
||
const url = base + (base.indexOf('?') >= 0 ? '&' : '?') + 'return_url=' + encodeURIComponent(location.href);
|
||
jumpApp(url, (app && app.identifier) || 'doudou-duihuan');
|
||
}
|
||
$$('#meExchCard, #homeExch').forEach(function (b) {
|
||
b.addEventListener('click', openExchange);
|
||
});
|
||
|
||
/* 兜豆余额 + 可兑件数 + 本场热门好物。
|
||
· /mall/drop 无需登录:拿商品(热门前 3 显示在「我的」页卡片底部);
|
||
· 有 openid 时 /auth/openid-login 换 token → /dou/summary 拿 balance;
|
||
· 可兑 M 件 = stock>0 且 price<=balance 且(opens_at 为空或已到点),口径与商城侧约定一致。
|
||
任何一步失败都静默保留默认文案「攒兜豆,好物免费兑」,不影响页面其它部分。
|
||
从商城返回(页面重新可见)时再刷一次,兑换后余额能跟上。 */
|
||
let douToken = '';
|
||
function setExchSub(t) {
|
||
$$('#meExchSub, #homeExchSub').forEach(function (el) { el.textContent = t; });
|
||
}
|
||
function douImg(g) {
|
||
return /^https?:/.test(g.img || '') ? g.img : EXCH_URL + String(g.img || '').replace(/^\//, '');
|
||
}
|
||
/* 一件商品的小卡(「我的」页 3 件 / 首页横滑 6 件共用),全用 DOM 拼,商品名不进 innerHTML */
|
||
function douItem(g, cls, more) {
|
||
const it = document.createElement('span'); it.className = cls;
|
||
const pic = document.createElement('span'); pic.className = 'gi-pic';
|
||
const im = document.createElement('img'); im.alt = ''; im.loading = 'lazy'; im.src = douImg(g);
|
||
pic.appendChild(im);
|
||
if (more && g.badge) { const bd = document.createElement('i'); bd.className = 'gi-badge'; bd.textContent = g.badge; pic.appendChild(bd); }
|
||
if (more && g.stock <= 5) { const st = document.createElement('i'); st.className = 'gi-left'; st.textContent = '仅剩 ' + g.stock + ' 件'; pic.appendChild(st); }
|
||
const nm = document.createElement('span'); nm.className = 'bd-nm'; nm.textContent = g.name || '';
|
||
/* 价格和兜豆商城「本场好物」同款:小金币在前 + 千分位数字 + 淡色「兜豆」(2026-10-02 用户要求) */
|
||
const pr = document.createElement('b'); pr.className = 'gi-pr';
|
||
const coin = document.createElement('i'); coin.className = 'gi-coin';
|
||
const num = document.createElement('span'); num.textContent = String(g.price).replace(/\B(?=(\d{3})+$)/g, ',');
|
||
const unit = document.createElement('small'); unit.textContent = '兜豆';
|
||
pr.append(coin, num, unit);
|
||
it.append(pic, nm, pr);
|
||
return it;
|
||
}
|
||
function renderDrop(d) {
|
||
const now = d && d.server_time ? new Date(d.server_time).getTime() : Date.now();
|
||
const goods = ((d && d.goods) || []).filter(function (g) {
|
||
return g.stock > 0 && (!g.opens_at || new Date(g.opens_at).getTime() <= now);
|
||
}).sort(function (x, y) { return (y.heat_30m || 0) - (x.heat_30m || 0); });
|
||
/* 「我的」页:本场标题 + 热门前 3 */
|
||
const box = $('#meExchDrop');
|
||
if (box) {
|
||
if (!goods.length) box.classList.add('hide');
|
||
else {
|
||
const dr = d.drop || {};
|
||
$('#meExchDropT').textContent = (dr.title || '本场好物') + (dr.open && dr.left != null ? ' · 剩 ' + dr.left + ' 件' : '');
|
||
const wrap = $('#meExchGoods'); wrap.textContent = '';
|
||
goods.slice(0, 3).forEach(function (g) { wrap.appendChild(douItem(g, 'bd-it')); });
|
||
box.classList.remove('hide');
|
||
}
|
||
}
|
||
/* 首页:横滑前 6 件,带「热门」角标和「仅剩 N 件」 */
|
||
const row = $('#homeExchGoods');
|
||
if (row) {
|
||
row.textContent = '';
|
||
goods.slice(0, 6).forEach(function (g) { row.appendChild(douItem(g, 'ex-it', true)); });
|
||
row.classList.toggle('hide', !goods.length);
|
||
}
|
||
}
|
||
const _hxg = $('#homeExchGoods');
|
||
if (_hxg) _hxg.addEventListener('click', function (e) { if (e.target.closest('.ex-it')) openExchange(); });
|
||
function loadDou() {
|
||
const oid = hashParam('openid'), uid = hashParam('unionid');
|
||
const dropP = fetch(DOU_API + '/mall/drop').then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||
dropP.then(renderDrop);
|
||
if (!oid) return;
|
||
const tokP = douToken ? Promise.resolve(douToken) :
|
||
fetch(DOU_API + '/auth/openid-login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ openid: oid, unionid: uid || null, app_id: 'doudou-duihuan' })
|
||
}).then(function (r) { return r.ok ? r.json() : null; }).then(function (j) { return (douToken = (j && j.token) || ''); });
|
||
tokP.then(function (tok) {
|
||
if (!tok) return null;
|
||
return fetch(DOU_API + '/dou/summary', { headers: { Authorization: 'Bearer ' + tok } })
|
||
.then(function (r) { if (r.status === 401) douToken = ''; return r.ok ? r.json() : null; });
|
||
}).then(function (sum) {
|
||
if (!sum || typeof sum.balance !== 'number') return;
|
||
return dropP.then(function (d) {
|
||
const now = d && d.server_time ? new Date(d.server_time).getTime() : Date.now();
|
||
const n = ((d && d.goods) || []).filter(function (g) {
|
||
return g.stock > 0 && g.price <= sum.balance && (!g.opens_at || new Date(g.opens_at).getTime() <= now);
|
||
}).length;
|
||
/* 0 兜豆时「兜豆 0 · 可兑 0 件」读着像在劝退 —— 保留引导文案;有豆但一件都不够时给句鼓励 */
|
||
if (!sum.balance) setExchSub('攒兜豆,好物免费兑');
|
||
else if (!d) setExchSub('兜豆 ' + sum.balance);
|
||
else setExchSub('兜豆 ' + sum.balance + (n ? ' · 可兑 ' + n + ' 件' : ' · 再攒攒就能兑'));
|
||
});
|
||
}).catch(function () {});
|
||
}
|
||
loadDou();
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (document.visibilityState === 'visible') loadDou();
|
||
});
|
||
|
||
/* 商城入口:「我的」页服务卡与首页商城主卡。
|
||
⚠️ 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, (app && app.identifier) || 'laixin');
|
||
}
|
||
$('#tabLetter').addEventListener('click', openLetterApp);
|
||
/* 首页那张「宝宝来信」卡与底 tab 是**同一个入口**(2026-09-20):
|
||
卡的数据源已经是 laixin,点它当然也该进 laixin —— 以前它打开的是内置来信详情,
|
||
那会「卡上写着有 2 封新信、点进去是空的」(两套数据)。 */
|
||
$('#homeLetter').addEventListener('click', openLetterApp);
|
||
|
||
/* ---------- 底 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),
|
||
不要塞回 /mama-box/ 命名空间 —— 那里是妈妈盒子自己的接口。规则见
|
||
box_app/docs/api-naming-convention.md。
|
||
⚠️ 为什么不直连 taidong:那台的 CORS 白名单只放了自己的域名,页面直连会被浏览器拦;
|
||
而且「mama-box ↔ laixin 的联动接口由 box_app 封装」是本项目的既定边界
|
||
(见 docs/mama-box-change-list-20260919.md §1.5)。
|
||
⚠️ 口径:#tabBadge 与首页「宝宝来信」卡的 owner **只有这一个函数**(改名自 syncLaixinBadge)。
|
||
别在 applyRemoteProfile() 里再写它 —— 两处都写会互相覆盖(谁后跑谁说了算)。
|
||
⚠️ 容错:接口返回 ok=false(上游抖动)时**保持现状** —— 既不把已经点亮的红点抹掉,
|
||
也不因为一次失败就冒出一个红点。角标不是关键路径,失败一律静默。 */
|
||
function syncLaixinReminder() {
|
||
const openid = hashParam('openid');
|
||
/* 接口按 openid 查,没有身份就别问(浏览器直开调试时常见)
|
||
—— 清掉上次结果后交给 renderLetterReminder() 统一收起(它也会因 pregEmpty 收)。 */
|
||
if (!openid) {
|
||
_laixinUnread = null;
|
||
renderLetterReminder();
|
||
return;
|
||
}
|
||
const unionid = hashParam('unionid');
|
||
const qs = 'openid=' + encodeURIComponent(openid) +
|
||
(unionid ? '&unionid=' + encodeURIComponent(unionid) : '');
|
||
fetch('/api/mp/public/laixin/unread?' + qs)
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(j => {
|
||
const r = j && j.result;
|
||
if (!r || r.ok === false) return; // 降级:保持现状(红点与卡都不动)
|
||
_laixinUnread = r; // 存下来:建档状态变化时要靠它重算
|
||
renderLetterReminder();
|
||
})
|
||
.catch(() => { /* 静默 */ });
|
||
}
|
||
/* 从「宝宝来信」H5 返回、或小程序 onShow 把 web-view 唤回前台时刷一次
|
||
(上游未读数是「在来信页读完就归零」,所以回来这一下必须重新拉)。
|
||
将来第三方上 SSE 后,这里就是它的降级路径(见 docs/laixin-sse-requirement.md)。 */
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (!document.hidden) syncLaixinReminder();
|
||
});
|
||
|
||
/* 「授权完善资料」: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) {
|
||
/* 带上 backUrl(当前地址)→ 授权页写进 __AUTH_RESULT__ → 宿主优先按它
|
||
回传:从哪个 H5 触发的授权就回到哪个 H5(不赌 postMessage 派发时机)。 */
|
||
var u = '/pages/annex/auth/index';
|
||
try {
|
||
u += '?backUrl=' + encodeURIComponent(location.href);
|
||
} catch (e) {}
|
||
window.wx.miniProgram.navigateTo({ url: u });
|
||
} 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 (e) {
|
||
/* bfcache 恢复(persisted=true)= 用户「返回」回到了本页 → 清掉 last_app(见 clearLastAppOnBackHome) */
|
||
clearLastAppOnBackHome(!!(e && e.persisted));
|
||
if (document.visibilityState !== 'hidden') resetScreenOnShow();
|
||
});
|
||
|
||
/* 首页头图里会动的宝宝(2026-09-30,v6 起是 GIF)。装饰用:加载完再淡入,没加载出来就停在底下的静态图上。
|
||
「减少动态效果」下 CSS 直接隐藏。 */
|
||
(function heroKid() {
|
||
var g = document.getElementById('heroKid');
|
||
if (!g) return;
|
||
function on() { g.classList.add('is-on'); }
|
||
if (g.complete && g.naturalWidth) on();
|
||
else g.addEventListener('load', on);
|
||
})();
|
||
|
||
if (document.readyState === 'complete') init();
|
||
else window.addEventListener('load', init);
|
||
})();
|
||
|