Files
mama-box/js/wh-cm.js

297 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 星空页 · 妈妈们的留言(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:<id>。
· 星空页「说一句」发的话也进这里(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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
var m = ((location.hash || '') + '&').match(/[#&]openid=([^&]*)/), OID = m ? decodeURIComponent(m[1]) : '';
var HEART = '<svg viewBox="0 0 24 24"><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>';
/* 入口:「说一句」下面一条,带几个头像 + 条数,看得出能点 */
var go = document.createElement('button');
go.type = 'button'; go.className = 'wcm-go'; go.id = 'wcmGo';
go.innerHTML = '<span class="wcm-go-av" id="wcmGoAv"></span><span class="wcm-go-t">看看大家说了啥<em id="wcmGoN"></em></span><i class="wcm-go-up" aria-hidden="true"></i>';
bar.appendChild(go);
/* 半屏面板 */
var sh = document.createElement('div');
sh.className = 'wcs'; sh.id = 'wcs'; sh.setAttribute('aria-hidden', 'true');
sh.innerHTML = '<div class="wcs-p" role="dialog" aria-label="妈妈们的留言">' +
'<div class="wcs-hd"><i class="wcs-grab" aria-hidden="true"></i><div class="wcs-tt"><b>妈妈们的留言</b><span id="wcmN"></span>' +
'<button type="button" class="wcs-x" aria-label="收起"><svg viewBox="0 0 24 24" width="14" height="14"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg></button></div>' +
'<div class="wcm-tb" role="tablist"><button type="button" class="on" data-wso="hot">最热</button><button type="button" data-wso="new">最新</button><button type="button" data-wso="wk">同孕周</button>' +
'<span class="wcm-rule" id="wcmRule"></span></div></div>' +
'<div class="wcs-ls" id="wcsLs"><div id="wcmLs"><p class="wcm-e">留言加载中…</p></div><button type="button" class="wcm-more" id="wcmMore" hidden>再看看</button></div>' +
'<div class="wcs-in"><span class="wcs-to" id="wcsTo" hidden></span><input id="wcsIn" type="text" maxlength="200" enterkeyhint="send" placeholder="说点什么…"><button type="button" id="wcsSend">发送</button></div>' +
'</div>';
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 '<img src="' + esc(c.av) + '" alt="">'; }).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 = '<p class="wcm-e">' + (SORT === 'wk' ? (window.MB_WH_WEEK ? '同孕周的妈妈还没留言,你先说一句吧' : '填好预产期,就能看同孕周妈妈说了啥') : '还没有人留言') + '</p>';
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 '<div class="wcm-it" data-k="' + esc(c.key) + '"><img class="wcm-av" src="' + esc(c.av) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">' +
'<div class="wcm-bd"><p class="wcm-nm"><b>' + esc(c.n) + '</b>' + (c.s ? '<span>' + esc(c.s) + '</span>' : '') + (c.ts ? '<span>' + ago(c.ts) + '</span>' : '') + '</p>' +
'<p class="wcm-tx">' + esc(c.t) + '</p>' + (c.st === 'pending' ? '<p class="wcm-st">审核中,通过了大家才看得到</p>' : '') +
(c.star ? '<p class="wcm-star"><svg viewBox="0 0 20 20"><path d="M10 .5C10.9 7.4 12.6 9.1 19.5 10 12.6 10.9 10.9 12.6 10 19.5 9.1 12.6 7.4 10.9.5 10 7.4 9.1 9.1 7.4 10 .5Z"/></svg>已经变成星星了</p>' : '') +
(rp.length ? '<div class="wcm-rp">' + rp.map(function (x) {
return '<p' + (x.me ? ' class="me"' : '') + '><b>' + esc(x.n) + '</b>' + (x.to && x.to !== c.n ? ' 回复 <b>' + esc(x.to) + '</b>' : '') + ':' + esc(x.t) +
(x.st === 'pending' ? '<span class="wcm-st">(审核中)</span>' : '') + '</p>';
}).join('') + '</div>' : '') +
(c.me ? '' : '<button type="button" class="wcm-re" data-wrp="' + esc(c.key) + '">回复</button>') + '</div>' +
(c.me ? '<span class="wcm-lk me">' + HEART + '<span>' + fmt(likesOf(c)) + '</span></span></div>' :
'<button type="button" class="wcm-lk' + (on ? ' on' : '') + '" data-wcl="' + esc(c.key) + '" aria-label="赞">' + HEART + '<span>' + fmt(likesOf(c)) + '</span></button></div>');
}
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');
});
/* app.js 先画了「今天还能说 N 句」(那时本脚本还没加载)—— 说一句现在发进留言区,不再按每天 3 句算,换成留言规则 */
function fixLeft() {
var el = document.getElementById('whpLeft');
if (el && /^今天/.test(el.textContent)) el.textContent = '先审核再公开,被赞 ' + STAR_N + ' 次就变成星星';
}
new MutationObserver(fixLeft).observe(document.getElementById('whpLeft'), { childList: true, characterData: true, subtree: true });
/* 关掉星空页时面板一起收 */
new MutationObserver(function () {
fixLeft();
if (page.classList.contains('show')) load(); else snap('closed');
}).observe(page, { attributes: true, attributeFilter: ['class'] });
if (page.classList.contains('show')) load();
fixLeft();
/* 星空页「说一句」→ 发成留言(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
};
})();