Files
notiMessage/debug-server/mmp_page.html
mars c8cbfdff82 feat(mmp): 独立红包领取台(手机+PC)、同步、筛选与功能说明
隔离通用消息台;支持设置/手气摘要/电脑同步;忽略临时逆向脚本与运行时 JSON。
2026-08-04 17:39:29 +08:00

463 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台</title>
<style>
:root {
--bg: #f3f4f6;
--panel: #ffffff;
--ink: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--money: #c2410c;
--best: #047857;
--best-bg: #ecfdf5;
--worst: #b45309;
--worst-bg: #fffbeb;
--done: #047857;
--open: #b45309;
--rank: #ea580c;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--ink);
display: flex; flex-direction: column;
}
button, input { font: inherit; }
header {
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
flex-shrink: 0;
}
h1 { margin: 0; font-size: 18px; font-weight: 700; margin-right: auto; }
.stat { color: var(--muted); font-size: 13px; }
a.nav { color: #2563eb; text-decoration: none; font-size: 13px; }
.btn {
border: 1px solid var(--line); background: #fff; color: var(--ink);
padding: 7px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.btn.primary { background: #ea580c; border-color: #ea580c; color: #fff; font-weight: 600; }
.toolbar {
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 10px 16px; display: grid; gap: 8px; flex-shrink: 0;
}
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.search {
flex: 1; min-width: 180px; border: 1px solid var(--line); border-radius: 6px;
padding: 8px 10px; background: #fff;
}
.chip {
border: 1px solid var(--line); background: #fff; color: var(--muted);
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.chip.active { background: #111827; color: #fff; border-color: #111827; }
.chip.tone.active { background: #ea580c; border-color: #ea580c; }
.range { display: none; gap: 8px; align-items: center; flex-wrap: wrap; }
.range.open { display: flex; }
.range input { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }
.settings-panel {
display: none; background: #fff; border-bottom: 1px solid var(--line);
padding: 12px 16px; flex-shrink: 0;
}
.settings-panel.open { display: block; }
.settings-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;
}
.settings-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.settings-grid input[type=number] { border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
.check { flex-direction: row !important; align-items: center; gap: 8px !important; }
.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; align-items: center; }
.settings-hint { color: var(--muted); font-size: 12px; margin-top: 8px; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar {
width: 340px; background: var(--panel); border-right: 1px solid var(--line);
overflow-y: auto; flex-shrink: 0;
}
.sidebar h2 {
margin: 0; padding: 10px 14px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--line); background: #fafafa; font-weight: 600;
}
.pkt {
padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer;
}
.pkt:hover { background: #f9fafb; }
.pkt.active { background: #fff7ed; box-shadow: inset 3px 0 0 #ea580c; }
.pkt-row { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
.pkt-name { font-size: 13px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pkt-money {
font-size: 15px; font-weight: 800; color: var(--money);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pkt-meta { margin-top: 3px; font-size: 11px; color: var(--muted); display: flex; gap: 5px; flex-wrap: nowrap; align-items: center; overflow: hidden; }
.pkt-meta span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: 700; flex-shrink: 0;
}
.badge.done { background: var(--best-bg); color: var(--done); }
.badge.open { background: var(--worst-bg); color: var(--open); }
.pkt-luck {
margin-top: 4px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
}
.mini {
border-radius: 4px; padding: 3px 6px; font-size: 11px; line-height: 1.25;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700;
}
.mini.best { background: var(--best-bg); color: var(--best); }
.mini.worst { background: var(--worst-bg); color: var(--worst); }
.pkt-id { margin-left: auto; flex-shrink: 0; font-size: 10px; color: #9ca3af; font-family: ui-monospace, Consolas, monospace; }
.main { flex: 1; overflow-y: auto; padding: 16px 18px; }
.empty { padding: 60px 20px; text-align: center; color: var(--muted); }
.title-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 6px; }
.title { margin: 0; font-size: 22px; font-weight: 800; }
.title-money {
font-size: 28px; font-weight: 900; color: var(--money);
font-variant-numeric: tabular-nums; line-height: 1;
}
.sub { color: var(--muted); font-size: 12px; margin-bottom: 14px; word-break: break-all; }
.kpi {
display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; margin-bottom: 12px;
}
.kpi .box {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px;
}
.kpi .k { font-size: 12px; color: var(--muted); font-weight: 600; }
.kpi .v {
margin-top: 4px; font-size: 22px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
.kpi .v.money { color: var(--money); }
.luck-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px;
}
.luck-card {
border-radius: 8px; padding: 14px 16px; border: 1px solid transparent;
}
.luck-card.best { background: var(--best-bg); border-color: #a7f3d0; }
.luck-card.worst { background: var(--worst-bg); border-color: #fde68a; }
.luck-card .lab { font-size: 12px; font-weight: 700; opacity: .9; }
.luck-card .name { margin-top: 4px; font-size: 18px; font-weight: 800; }
.luck-card .amt {
margin-top: 2px; font-size: 24px; font-weight: 900;
font-variant-numeric: tabular-nums;
}
.luck-card.best .lab, .luck-card.best .name, .luck-card.best .amt { color: var(--best); }
.luck-card.worst .lab, .luck-card.worst .name, .luck-card.worst .amt { color: var(--worst); }
.section { font-size: 13px; font-weight: 700; color: var(--muted); margin: 0 0 8px; }
table {
width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
th, td { padding: 11px 12px; text-align: left; border-bottom: 1px solid var(--line); font-size: 13px; }
th { background: #f9fafb; color: var(--muted); font-weight: 700; font-size: 12px; }
tr:last-child td { border-bottom: 0; }
tr.row-best { background: #ecfdf5; }
tr.row-worst { background: #fffbeb; }
.rank { width: 48px; color: var(--rank); font-weight: 800; font-variant-numeric: tabular-nums; }
.nick { font-weight: 700; }
.amt { color: var(--money); font-weight: 800; font-variant-numeric: tabular-nums; font-size: 15px; }
.tag-inline {
margin-left: 6px; font-size: 11px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
}
.tag-inline.best { background: #d1fae5; color: var(--best); }
.tag-inline.worst { background: #fef3c7; color: var(--worst); }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; max-height: 36vh; border-right: 0; border-bottom: 1px solid var(--line); }
.kpi, .luck-grid { grid-template-columns: 1fr 1fr; }
.title-money { font-size: 22px; }
}
</style>
</head>
<body>
<header>
<h1>红包领取台</h1>
<span class="stat" id="pktCount">0 个</span>
<span class="stat" id="updated">-</span>
<span class="stat" id="cfgHint">刷新: -</span>
<a class="nav" href="/mmp/help">功能说明</a>
<a class="nav" href="/">通用消息台</a>
<button class="btn" onclick="toggleSettings()">设置</button>
<button class="btn" onclick="loadData()">刷新</button>
<button class="btn primary" onclick="clearAll()">清空</button>
</header>
<div class="toolbar">
<div class="row">
<input class="search" type="search" id="filterQ" placeholder="搜索昵称 / 发送人 / ID" oninput="onFilterChange()" />
<button type="button" class="chip tone active" id="fAll" onclick="setStatus('all')">全部</button>
<button type="button" class="chip" id="fOpen" onclick="setStatus('open')">领取中</button>
<button type="button" class="chip" id="fDone" onclick="setStatus('done')">已领完</button>
</div>
<div class="row">
<button type="button" class="chip active" id="tAll" onclick="setTime('all')">全部时间</button>
<button type="button" class="chip" id="t1" onclick="setTime('1')">近1天</button>
<button type="button" class="chip" id="t3" onclick="setTime('3')">近3天</button>
<button type="button" class="chip" id="t7" onclick="setTime('7')">近7天</button>
<button type="button" class="chip" id="tCustom" onclick="setTime('custom')">自定义</button>
<div class="range" id="customRange">
<label><input type="date" id="dateFrom" onchange="onFilterChange()" /></label>
<label><input type="date" id="dateTo" onchange="onFilterChange()" /></label>
</div>
</div>
</div>
<div class="settings-panel" id="settingsPanel">
<div class="settings-grid">
<label>① 历史列表冷却(秒)— 多久扫一次红包列表
<input type="number" id="cfgHistory" min="1" max="300" step="1" />
</label>
<label>② 详情刷新冷却(秒)— 领取中的包多久再拉名单
<input type="number" id="cfgDetail" min="1" max="300" step="1" />
</label>
<label>③ 相同名单去重(秒)— 名单没变时防重复推送
<input type="number" id="cfgDedup" min="0" max="120" step="1" />
</label>
<label>④ 详情请求间隔(毫秒)— 连拉多个包之间的停顿
<input type="number" id="cfgGap" min="0" max="5000" step="10" />
</label>
<label>页面轮询(毫秒)
<input type="number" id="cfgPagePoll" min="500" max="30000" step="100" />
</label>
<label class="check"><input type="checkbox" id="cfgOpenHistory" /> 无模板时自动打开历史页</label>
</div>
<div class="settings-actions">
<button type="button" class="chip" onclick="applyPreset('fast')">极速</button>
<button type="button" class="chip" onclick="applyPreset('normal')">标准</button>
<button type="button" class="chip" onclick="applyPreset('slow')">省流</button>
<button type="button" class="btn primary" onclick="saveSettings()">保存</button>
<span class="stat" id="cfgStatus"></span>
</div>
<div class="settings-hint">不懂就选「标准」再保存。约 10 秒后对手机 Hook 生效。</div>
</div>
<div class="layout">
<aside class="sidebar">
<h2>红包列表</h2>
<div id="list"></div>
</aside>
<main class="main">
<div id="empty" class="empty">暂无数据。打开手机红包领取台会自动同步到这里。</div>
<div id="panel" style="display:none">
<div class="title-row">
<h2 class="title" id="title">-</h2>
<div class="title-money" id="titleMoney">RM 0.00</div>
</div>
<div class="sub" id="sub">-</div>
<div class="kpi">
<div class="box"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
<div class="box"><div class="k">领取合计</div><div class="v money" id="cSum">0.00</div></div>
<div class="box"><div class="k">红包总额</div><div class="v money" id="cTotal">-</div></div>
<div class="box"><div class="k">状态</div><div class="v" id="cStatus">-</div></div>
</div>
<div class="luck-grid">
<div class="luck-card best">
<div class="lab" id="luckBestLab">手气最佳</div>
<div class="name" id="luckBestName">-</div>
<div class="amt" id="luckBestAmt">-</div>
</div>
<div class="luck-card worst">
<div class="lab" id="luckWorstLab">手气最差</div>
<div class="name" id="luckWorstName">-</div>
<div class="amt" id="luckWorstAmt">-</div>
</div>
</div>
<div class="section">领取排行</div>
<table>
<thead><tr><th class="rank">#</th><th>昵称</th><th>金额</th><th>时间</th></tr></thead>
<tbody id="board"></tbody>
</table>
</div>
</main>
</div>
<script>
let packets = [], activeId = null, pagePollTimer = null;
let statusFilter = 'all', timeFilter = 'all', keyword = '';
let settings = { historyCooldownSec: 8, detailCooldownSec: 8, dedupSec: 3, detailGapMs: 80, pagePollMs: 1000, openHistoryIfNoTemplate: true };
function toggleSettings(){ document.getElementById('settingsPanel').classList.toggle('open'); }
function fillSettingsForm(){
cfgHistory.value = settings.historyCooldownSec; cfgDetail.value = settings.detailCooldownSec;
cfgDedup.value = settings.dedupSec; cfgGap.value = settings.detailGapMs;
cfgPagePoll.value = settings.pagePollMs; cfgOpenHistory.checked = !!settings.openHistoryIfNoTemplate;
cfgHint.textContent = '刷新: 历史' + settings.historyCooldownSec + 's / 详情' + settings.detailCooldownSec + 's';
}
function readSettingsForm(){
return {
historyCooldownSec: Number(cfgHistory.value), detailCooldownSec: Number(cfgDetail.value),
dedupSec: Number(cfgDedup.value), detailGapMs: Number(cfgGap.value),
pagePollMs: Number(cfgPagePoll.value), openHistoryIfNoTemplate: cfgOpenHistory.checked
};
}
function applyPreset(name){
if (name==='fast') settings={historyCooldownSec:5,detailCooldownSec:5,dedupSec:2,detailGapMs:50,pagePollMs:800,openHistoryIfNoTemplate:true};
else if (name==='slow') settings={historyCooldownSec:30,detailCooldownSec:45,dedupSec:15,detailGapMs:200,pagePollMs:3000,openHistoryIfNoTemplate:true};
else settings={historyCooldownSec:8,detailCooldownSec:8,dedupSec:3,detailGapMs:80,pagePollMs:1000,openHistoryIfNoTemplate:true};
fillSettingsForm();
}
async function loadSettings(){ settings = await (await fetch('/api/mmp/settings')).json(); fillSettingsForm(); restartPagePoll(); }
async function saveSettings(){
settings = await (await fetch('/api/mmp/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(readSettingsForm())})).json();
fillSettingsForm(); restartPagePoll(); cfgStatus.textContent = '已保存 ' + new Date().toLocaleTimeString();
}
function restartPagePoll(){ if(pagePollTimer) clearInterval(pagePollTimer); pagePollTimer=setInterval(loadData, Math.max(500, Number(settings.pagePollMs)||1000)); }
function setStatus(s){
statusFilter=s;
['fAll','fOpen','fDone'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById(s==='all'?'fAll':s==='open'?'fOpen':'fDone').classList.add('active');
renderList();
}
function setTime(t){
timeFilter=t;
['tAll','t1','t3','t7','tCustom'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById({all:'tAll','1':'t1','3':'t3','7':'t7',custom:'tCustom'}[t]).classList.add('active');
customRange.classList.toggle('open', t==='custom');
renderList();
}
function onFilterChange(){ keyword=(filterQ.value||'').trim().toLowerCase(); renderList(); }
function parseIssueMs(text){
const s=(text||'').trim(); if(!s) return null;
let m=s.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?/);
if(m) return new Date(+m[3],+m[2]-1,+m[1],+(m[4]||0),+(m[5]||0),+(m[6]||0)).getTime();
m=s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if(m) return new Date(+m[1],+m[2]-1,+m[3]).getTime();
const t=Date.parse(s); return Number.isNaN(t)?null:t;
}
function inTimeRange(p){
if(timeFilter==='all') return true;
const ms=parseIssueMs(p.issuedAt||p.latestAt||p.updatedAt||p.fetchedAt||'');
if(ms==null) return false;
const now=Date.now();
if(timeFilter==='1'||timeFilter==='3'||timeFilter==='7') return ms>=now-Number(timeFilter)*86400000;
if(timeFilter==='custom'){
const from=dateFrom.value, to=dateTo.value;
if(from){ const fromMs=new Date(from+'T00:00:00').getTime(); if(ms<fromMs) return false; }
if(to){ const toMs=new Date(to+'T00:00:00').getTime()+86400000-1; if(ms>toMs) return false; }
}
return true;
}
function filteredPackets(){
return packets.filter(p=>{
if(statusFilter==='open'&&p.finished) return false;
if(statusFilter==='done'&&!p.finished) return false;
if(!inTimeRange(p)) return false;
if(!keyword) return true;
const blob=[p.packetId,p.sender,p.title,p.group,p.total,p.bestNick,p.worstNick,...(p.leaderboard||[]).map(r=>r.nickname)].join(' ').toLowerCase();
return blob.indexOf(keyword)>=0;
});
}
function money(v){ const n=Number(v); return Number.isFinite(n)?n.toFixed(2):String(v??'-'); }
function shortId(id){ const s=String(id||''); return (s.length>=8&&s.indexOf('-')>0)?s.slice(0,8):(s.slice(0,16)||'-'); }
function hiLo(p){
return {
hi: p&&p.finished?'手气最佳':'目前最高',
lo: p&&p.finished?'手气最差':'目前最低'
};
}
async function loadData(){
packets = await (await fetch('/api/mmp')).json();
updated.textContent = '更新 ' + new Date().toLocaleTimeString();
renderList();
}
function renderList(){
const view=filteredPackets();
pktCount.textContent = view.length + '/' + packets.length + ' 个';
list.innerHTML='';
if(!view.length){ empty.style.display='block'; panel.style.display='none'; activeId=null; return; }
empty.style.display='none'; panel.style.display='block';
if(!activeId || !view.find(p=>p.packetId===activeId)) activeId=view[0].packetId;
for(const p of view){
const div=document.createElement('div');
div.className='pkt'+(p.packetId===activeId?' active':'');
div.onclick=()=>{ activeId=p.packetId; renderList(); };
const badge=p.finished?'<span class="badge done">已领完</span>':'<span class="badge open">领取中</span>';
const hiShort = p.finished ? '最佳' : '最高';
const loShort = p.finished ? '最差' : '最低';
const bestLine = `${hiShort} ${esc(p.bestNick||'-')} ${esc(p.bestAmount||'')}`.trim();
const worstLine = (p.claimantCount||0)<=1
? `${loShort} -`
: `${loShort} ${esc(p.worstNick||'-')} ${esc(p.worstAmount||'')}`.trim();
div.innerHTML=`
<div class="pkt-row">
<div class="pkt-name">${esc(p.sender||p.title||'红包')}</div>
<div class="pkt-money">${money(p.sumClaimed??p.total??0)}</div>
</div>
<div class="pkt-meta">${badge}<span>${p.claimantCount||0}人 · ${esc(p.issuedAt||'时间未知')}</span><span class="pkt-id">${esc(shortId(p.packetId))}</span></div>
<div class="pkt-luck">
<div class="mini best">${bestLine}</div>
<div class="mini worst">${worstLine}</div>
</div>`;
list.appendChild(div);
}
renderActive();
}
function renderActive(){
const p=packets.find(x=>x.packetId===activeId); if(!p) return;
const labels=hiLo(p);
title.textContent = p.sender ? p.sender + ' 的红包' : (p.title || '红包详情');
titleMoney.textContent = 'RM ' + money(p.sumClaimed ?? p.total ?? 0);
const parts=[];
if(p.packetId) parts.push('ID '+p.packetId);
if(p.issuedAt) parts.push('发放 '+p.issuedAt);
if(p.via) parts.push(p.via);
sub.textContent = parts.join(' · ');
cPeople.textContent = p.claimantCount || 0;
cSum.textContent = money(p.sumClaimed ?? 0);
cTotal.textContent = p.total || '-';
cStatus.innerHTML = p.finished ? '<span class="badge done">已领完</span>' : '<span class="badge open">领取中</span>';
luckBestLab.textContent = labels.hi;
luckWorstLab.textContent = labels.lo;
luckBestName.textContent = p.bestNick || '-';
luckBestAmt.textContent = p.bestAmount ? ('RM ' + p.bestAmount) : '-';
if((p.claimantCount||0)<=1){
luckWorstName.textContent='-'; luckWorstAmt.textContent='-';
} else {
luckWorstName.textContent = p.worstNick || '-';
luckWorstAmt.textContent = p.worstAmount ? ('RM ' + p.worstAmount) : '-';
}
const rows=p.leaderboard||[];
board.innerHTML='';
if(!rows.length){
board.innerHTML='<tr><td colspan="4" style="text-align:center;color:#6b7280">暂无领取记录</td></tr>';
return;
}
const maxAmt = Math.max(...rows.map(r=>Number(r.amount)||0));
const minAmt = Math.min(...rows.map(r=>Number(r.amount)||0));
for(const row of rows){
const amt=Number(row.amount)||0;
const isBest=rows.length>1 && amt===maxAmt;
const isWorst=rows.length>1 && amt===minAmt && maxAmt!==minAmt;
const tr=document.createElement('tr');
if(isBest) tr.className='row-best';
if(isWorst) tr.className='row-worst';
let nick=esc(row.nickname);
if(isBest) nick += '<span class="tag-inline best">最高</span>';
if(isWorst) nick += '<span class="tag-inline worst">最低</span>';
tr.innerHTML=`
<td class="rank">${row.rank}</td>
<td class="nick">${nick}</td>
<td class="amt">${esc(row.amountText || money(row.amount))}</td>
<td>${esc(row.claimTime || '-')}</td>`;
board.appendChild(tr);
}
}
async function clearAll(){
if(!confirm('仅清空红包领取台数据?')) return;
await fetch('/api/mmp',{method:'DELETE'}); activeId=null; loadData();
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
loadSettings().then(loadData);
</script>
</body>
</html>