/* 星空页 · 妈妈们的留言(2026-11-07) · 二改(用户:「往下滑评论就挡住整个背景,不好;改成点『全部』那种半弹窗,最多拉到 2/3,一定留出一块星空; 首页要有个明显入口,别只放右上角『全部』」): 「说一句」按钮下面一条入口 → 半屏面板(先开到半屏,拖把手 / 往上划能拉到 2/3,再往上就不动了;往下拖关掉)。 面板里自己滚动看全部留言。右上角「全部」也打开这个面板(原来那个只列悄悄话的抽屉不用了)。 · 数据:广场置顶笔记 wh-<话题 from> 的评论 = cm.json 模拟(AGENTS.md 12c,不得挂星)+ /plaza/comments 已审核通过的真实评论 + 我自己发的(含审核中)。打开星空页才拉 cm.json(版本号跟广场 index.html 引的一致,共用缓存)。 · 真实留言的赞走服务端(POST /plaza/comment/like,每人每条一次、不能赞自己),赞够 starLikes(后台可改,先 3)自动挂星; 模拟留言的赞只记本机 mmbox.whcm.v1。最热 / 最新 / 同孕周 三种看法。 · 三改(用户:「我不能给别人的留言回复」):点留言上的「回复」→ 底部输入框回复那一楼(先审后发); 底部输入框不点「回复」时就是发一条新留言。楼的 key 同广场:模拟 = wh-xxx#序号,真实 = r:。 · 星空页「说一句」发的话也进这里(window.MBWhCm.post,app.js 调)。 */ (function () { var page = document.getElementById('whPage'), bar = page && page.querySelector('.whp-bar'); if (!page || !bar || !window.MB_WH_THEME) return; var TH = MB_WH_THEME(), PID = 'wh-' + TH.from.replace(/-/g, ''); var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }; var m = ((location.hash || '') + '&').match(/[#&]openid=([^&]*)/), OID = m ? decodeURIComponent(m[1]) : ''; var HEART = ''; /* 入口:「说一句」下面一条,带几个头像 + 条数,看得出能点 */ var go = document.createElement('button'); go.type = 'button'; go.className = 'wcm-go'; go.id = 'wcmGo'; go.innerHTML = '看看大家说了啥'; bar.appendChild(go); /* 半屏面板 */ var sh = document.createElement('div'); sh.className = 'wcs'; sh.id = 'wcs'; sh.setAttribute('aria-hidden', 'true'); sh.innerHTML = ''; page.appendChild(sh); var P = sh.querySelector('.wcs-p'), lsBox = sh.querySelector('#wcsLs'), ls = sh.querySelector('#wcmLs'), more = sh.querySelector('#wcmMore'); var inp = sh.querySelector('#wcsIn'), toEl = sh.querySelector('#wcsTo'), sendB = sh.querySelector('#wcsSend'); var LK = 'mmbox.whcm.v1', LIKE = {}; try { LIKE = JSON.parse(localStorage.getItem(LK) || '{}') || {}; } catch (e) {} var avP = function (a) { return typeof a === 'number' ? 'guangchang/av/' + ('000' + a).slice(-4) + '.webp' : a.indexOf('av:') === 0 ? 'guangchang/av/' + a.slice(3) + '.webp' : a.charAt(0) === '/' ? a : 'assets/img/mom/' + a + '.jpg'; }; var fmt = function (n) { return n >= 10000 ? (n / 10000).toFixed(1) + 'w' : String(n); }; var ago = function (ts) { var d = (Date.now() - ts) / 1000; if (d < 3600) return Math.max(1, Math.round(d / 60)) + ' 分钟前'; if (d < 86400) return Math.round(d / 3600) + ' 小时前'; var t = new Date(ts + 8 * 3600e3); return (t.getUTCMonth() + 1) + '-' + t.getUTCDate(); }; var tT; function toast(t) { var x = document.getElementById('toast'); if (!x) return; x.textContent = t; x.classList.add('show'); clearTimeout(tT); tT = setTimeout(function () { x.classList.remove('show'); }, 1800); } /* ---------- 数据 ---------- */ var SIM = null, REAL = null, RREP = [], ALL = [], shown = 0, loading = false, SORT = 'hot', STAR_N = 3; var top = function (c) { return !c.parent; }; function realOf(c) { return { key: 'r:' + c.id, id: c.id, real: 1, n: c.nickname || '孕妈', av: c.avatar || 'assets/img/mom/k01.jpg', s: c.stage, t: c.text, likes: c.likes || 0, liked: !!c.liked, star: !!c.star, ts: c.at, r: [] }; } function repOf(c, me) { return { p: c.parent, n: me ? '我' : (c.nickname || '孕妈'), me: me, to: c.parentName, t: c.text, ts: c.at, st: me ? c.status : '' }; } function load() { if (loading || SIM) return; loading = true; fetch('guangchang/cm.json?v=26110618').then(function (r) { return r.json(); }).then(function (j) { var PP = j.p; SIM = (j.c[PID] || []).map(function (c, k) { var p = PP[c[0]]; return { key: PID + '#' + k, n: p[0], av: avP(p[1]), s: p[2], t: c[1], likes: c[2], r: c[3].map(function (x) { var q = x[0] < 0 ? null : PP[x[0]]; return { n: q ? q[0] : '孕妈悄悄话', t: x[1] }; }) }; }); build(); paintGo(); }).catch(function () { SIM = []; build(); }); fetchReal(); } function fetchReal() { return fetch('/api/mp/public/mama-box/plaza/comments?kind=post&target=' + encodeURIComponent(PID) + (OID ? '&openid=' + encodeURIComponent(OID) : '')) .then(function (r) { return r.json(); }).then(function (j) { var R = j && j.code === 200 && j.result || {}; if (R.starLikes) STAR_N = R.starLikes; var mine = (R.mine || []).filter(function (c) { return c.status !== 'rejected'; }); REAL = (R.list || []).filter(top).map(realOf).concat(mine.filter(top).map(function (c) { var o = realOf(c); o.n = '我'; o.me = 1; o.st = c.status; return o; })); RREP = (R.list || []).filter(function (c) { return !top(c); }).map(function (c) { return repOf(c, 0); }) .concat(mine.filter(function (c) { return !top(c); }).map(function (c) { return repOf(c, 1); })); build(); paintGo(); }).catch(function () { if (!REAL) { REAL = []; build(); } }); } var wkOf = function (c) { var x = /孕\s*(\d+)\s*周/.exec(c.s || ''); return x ? +x[1] : 0; }; var likesOf = function (c) { return c.real ? c.likes : c.likes + (LIKE[c.key] ? 1 : 0); }; var likedOf = function (c) { return c.real ? c.liked : !!LIKE[c.key]; }; /* 最热:挂了星的在前,再按赞;最新:真实留言按时间,模拟的接在后面;同孕周:和我差 ±1 周的,按最热。我自己发的永远在最上面 */ function sorted() { var mine = REAL.filter(function (c) { return c.me; }), rest = REAL.filter(function (c) { return !c.me; }).concat(SIM); var hot = function (a, b) { return (!!b.star - !!a.star) || (likesOf(b) - likesOf(a)); }; if (SORT === 'new') rest = rest.filter(function (c) { return c.real; }).sort(function (a, b) { return b.ts - a.ts; }).concat(SIM); else if (SORT === 'wk') { var w = +window.MB_WH_WEEK || 0; rest = w ? rest.filter(function (c) { var k = wkOf(c); return k && Math.abs(k - w) <= 1; }) : []; rest.sort(hot); } else rest.sort(hot); return mine.sort(function (a, b) { return b.ts - a.ts; }).concat(rest); } function total() { return (REAL ? REAL.length : 0) + (SIM ? SIM.length : 0); } function paintGo() { var n = total(); document.getElementById('wcmGoN').textContent = n ? n + ' 条' : ''; var av = (SIM || []).slice(0, 3).map(function (c) { return ''; }).join(''); document.getElementById('wcmGoAv').innerHTML = av; } function build() { if (!SIM || !REAL) return; ALL = sorted(); shown = 0; ls.innerHTML = ''; document.getElementById('wcmN').textContent = total() ? total() + ' 条' : ''; document.getElementById('wcmRule').textContent = '被赞 ' + STAR_N + ' 次就变成星星'; if (!ALL.length) { ls.innerHTML = '

' + (SORT === 'wk' ? (window.MB_WH_WEEK ? '同孕周的妈妈还没留言,你先说一句吧' : '填好预产期,就能看同孕周妈妈说了啥') : '还没有人留言') + '

'; more.hidden = true; return; } add(20); } function repsOf(c) { var x = c.r.slice(); RREP.forEach(function (r) { if (r.p === c.key) x.push(r); }); return x; } function item(c) { var on = likedOf(c), rp = repsOf(c); return '
' + '

' + esc(c.n) + '' + (c.s ? '' + esc(c.s) + '' : '') + (c.ts ? '' + ago(c.ts) + '' : '') + '

' + '

' + esc(c.t) + '

' + (c.st === 'pending' ? '

审核中,通过了大家才看得到

' : '') + (c.star ? '

已经变成星星了

' : '') + (rp.length ? '
' + rp.map(function (x) { return '' + esc(x.n) + '' + (x.to && x.to !== c.n ? ' 回复 ' + esc(x.to) + '' : '') + ':' + esc(x.t) + (x.st === 'pending' ? '(审核中)' : '') + '

'; }).join('') + '
' : '') + (c.me ? '' : '') + '
' + (c.me ? '' + HEART + '' + fmt(likesOf(c)) + '
' : ''); } function add(k) { ls.insertAdjacentHTML('beforeend', ALL.slice(shown, shown + k).map(item).join('')); shown = Math.min(ALL.length, shown + k); more.hidden = shown >= ALL.length; } function rerender(c) { var el = ls.querySelector('.wcm-it[data-k="' + CSS.escape(c.key) + '"]'); if (el) el.outerHTML = item(c); } more.addEventListener('click', function () { add(30); }); /* ---------- 点赞 / 回复 ---------- */ function paintLk(b, c) { var on = likedOf(c); b.classList.toggle('on', on); b.querySelector('span').textContent = fmt(likesOf(c)); } var REPLY = null; function setReply(c) { REPLY = c; toEl.hidden = !c; toEl.textContent = c ? '回复 ' + c.n : ''; inp.placeholder = c ? '回复 ' + c.n + '…' : '说点什么…'; } ls.addEventListener('click', function (e) { var r = e.target.closest('[data-wrp]'); if (r) { var rc = ALL.filter(function (x) { return x.key === r.dataset.wrp; })[0]; if (!rc) return; setReply(rc); snap('full'); inp.focus(); return; } var b = e.target.closest('[data-wcl]'); if (!b) return; var k = b.dataset.wcl, c = ALL.filter(function (x) { return x.key === k; })[0]; if (!c) return; if (!c.real) { if (LIKE[k]) delete LIKE[k]; else LIKE[k] = 1; try { localStorage.setItem(LK, JSON.stringify(LIKE)); } catch (e2) {} paintLk(b, c); if (LIKE[k] && navigator.vibrate) navigator.vibrate(8); return; } /* 真实留言:服务端计数,赞了就不能取消(同星星的抱抱) */ if (!OID) { toast('登录后就能点赞啦'); return; } if (c.liked) { toast('你已经赞过啦'); return; } c.liked = true; c.likes++; paintLk(b, c); b.classList.add('pop'); setTimeout(function () { b.classList.remove('pop'); }, 500); if (navigator.vibrate) navigator.vibrate(8); fetch('/api/mp/public/mama-box/plaza/comment/like?openid=' + encodeURIComponent(OID) + '&id=' + encodeURIComponent(c.id), { method: 'POST' }) .then(function (x) { return x.json(); }).then(function (j) { if (!j || j.code !== 200) throw j; c.likes = j.result.likes; var was = c.star; c.star = !!j.result.star; paintLk(b, c); if (c.star && !was) { toast('被赞够 ' + STAR_N + ' 次,这句变成星星啦'); rerender(c); } }).catch(function (j) { c.liked = false; c.likes--; paintLk(b, c); toast((j && j.message) || '网络不太好,再点一次试试'); }); }); sh.querySelector('.wcm-tb').addEventListener('click', function (e) { var b = e.target.closest('[data-wso]'); if (!b || b.dataset.wso === SORT) return; SORT = b.dataset.wso; [].forEach.call(this.querySelectorAll('[data-wso]'), function (x) { x.classList.toggle('on', x === b); }); build(); lsBox.scrollTop = 0; }); function post(text, week, reply) { if (!OID) return Promise.resolve('登录后就能留言啦'); var body = { kind: 'post', target: PID, title: '孕妈悄悄话 · ' + TH.name, text: text, stage: week ? '孕 ' + week + ' 周' : '' }; if (reply) { body.parent = reply.key; body.parentName = reply.n; } return fetch('/api/mp/public/mama-box/plaza/comment?openid=' + encodeURIComponent(OID), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) { return r.json(); }).then(function (j) { if (!j || j.code !== 200 || !j.result) return (j && j.message) || '没发出去,再试一次'; if (reply) { RREP.push(repOf(j.result, 1)); rerender(reply); } else { var o = realOf(j.result); o.n = '我'; o.me = 1; o.st = j.result.status; REAL = [o].concat(REAL || []); if (SIM) { build(); lsBox.scrollTop = 0; } paintGo(); } return null; }).catch(function () { return '网络不太好,再试一次'; }); } var busy = false; function send() { var t = inp.value.trim(); if (t.length < 2) { toast('多写一两个字吧'); return; } if (busy) return; busy = true; sendB.disabled = true; post(t, +window.MB_WH_WEEK || 0, REPLY).then(function (msg) { busy = false; sendB.disabled = false; if (msg) { toast(msg); return; } inp.value = ''; inp.blur(); setReply(null); toast('发出去了,审核通过后大家就能看到'); }); } sendB.addEventListener('click', send); inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); send(); } }); /* ---------- 半屏面板:开 / 关 / 拖 ---------- 按高度伸缩(底部输入框一直露着):half = 星空页 50%,full = 68%(上面至少留 1/3 星空),再往上拉不动;往下拖到 25% 以下关掉 */ var state = 'closed'; function ph() { return page.clientHeight; } function hFor(s) { return ph() * (s === 'full' ? .68 : .5); } function setH(h, anim) { P.style.transition = anim ? '' : 'none'; P.style.height = Math.round(h) + 'px'; } function snap(s) { state = s; sh.classList.toggle('show', s !== 'closed'); sh.setAttribute('aria-hidden', s === 'closed' ? 'true' : 'false'); if (s !== 'closed') setH(hFor(s), true); if (s === 'closed') { inp.blur(); setReply(null); } } function open() { load(); snap('half'); } go.addEventListener('click', open); sh.querySelector('.wcs-x').addEventListener('click', function () { snap('closed'); }); sh.addEventListener('click', function (e) { if (e.target === sh) snap('closed'); }); /* 右上角「全部」也开这个面板(原来那个只列悄悄话的抽屉不再用) */ var all = document.getElementById('whvAll'); if (all) all.addEventListener('click', function (e) { e.stopImmediatePropagation(); open(); }, true); /* 入口上往上划也能拉开 */ var gy = null; go.addEventListener('touchstart', function (e) { gy = e.touches[0].clientY; }, { passive: true }); go.addEventListener('touchmove', function (e) { if (gy != null && gy - e.touches[0].clientY > 24) { gy = null; open(); } }, { passive: true }); /* 拖:抓把手 / 标题拖;列表在顶上时往下拖 = 拖面板;半屏时在列表上往上划 = 先把面板拉满 */ var d0 = null; P.addEventListener('touchstart', function (e) { if (e.touches.length !== 1 || e.target.closest('.wcs-in')) return; d0 = { y: e.touches[0].clientY, base: P.offsetHeight, hd: !!e.target.closest('.wcs-hd'), drag: false, t: Date.now() }; }, { passive: true }); P.addEventListener('touchmove', function (e) { if (!d0) return; var dy = e.touches[0].clientY - d0.y; if (!d0.drag) { if (Math.abs(dy) < 6) return; var canDown = dy > 0 && (d0.hd || lsBox.scrollTop <= 0); var canUp = dy < 0 && (d0.hd || state === 'half'); if (!canDown && !canUp) { d0 = null; return; } d0.drag = true; } if (e.cancelable) e.preventDefault(); setH(Math.max(ph() * .12, Math.min(hFor('full'), d0.base - dy)), false); }, { passive: false }); P.addEventListener('touchend', function (e) { if (!d0) return; var dd = d0; d0 = null; if (!dd.drag) return; var dy = e.changedTouches[0].clientY - dd.y, h = dd.base - dy, v = dy / Math.max(1, Date.now() - dd.t); if (v > .6 || h < ph() * .3) snap('closed'); else if (v < -.4 || h > ph() * .59) snap('full'); else snap('half'); }); /* 关掉星空页时面板一起收 */ new MutationObserver(function () { if (page.classList.contains('show')) load(); else snap('closed'); }).observe(page, { attributes: true, attributeFilter: ['class'] }); if (page.classList.contains('show')) load(); /* 星空页「说一句」→ 发成留言(app.js 的 #whSend 调)。返回 Promise:resolve(null) = 成功,resolve('提示') = 没发出去 */ window.MBWhCm = { starLikes: function () { return STAR_N; }, post: function (text, week) { return post(text, week, null).then(function (msg) { if (!msg) setTimeout(function () { snap('half'); }, 400); return msg; }); }, open: open }; })();