realtime-test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { RealtimeProbe, redact } from './realtime-probe.js';
  2. const $ = (id) => document.getElementById(id);
  3. const LABELS = {
  4. 'admin.orders.sport': '体彩订单', 'admin.orders.lhc': '六合彩 / 彩票',
  5. 'admin.orders.sport_game': '足球游戏订单', 'admin.orders.jisu_game': '极速游戏订单',
  6. 'admin.orders.third_game': '三方游戏订单', 'admin.orders.bet': '投注记录',
  7. 'admin.orders.recharge': 'USDT 充值', 'admin.orders.payment': '代收订单',
  8. 'admin.orders.third_party_deposit': '三方充值', 'admin.orders.payment_withdraw': '人民币提现 / 代付',
  9. 'admin.orders.withdraw': 'USDT 提现', 'admin.orders.manual_audit': '人工上下分',
  10. 'admin.users': '用户列表', 'admin.users.online': '在线用户',
  11. 'admin.stats.platform_count': '平台在线人数', 'admin.stats.unprocessed': '待处理角标',
  12. };
  13. const STATE_LABELS = { initialized: '已初始化', connecting: '正在连接', connected: '已连接',
  14. disconnected: '未连接', unavailable: '暂不可用', failed: '连接失败' };
  15. const CHANNEL_STATES = { idle: '待订阅', subscribing: '订阅中', authorizing: '授权中',
  16. subscribed: '已订阅', denied: '无权限', error: '失败' };
  17. const time = (timestamp) => new Date(timestamp).toLocaleTimeString('zh-CN', { hour12: false });
  18. const make = (tag, text, className) => {
  19. const element = document.createElement(tag);
  20. if (text !== undefined) element.textContent = text;
  21. if (className) element.className = className;
  22. return element;
  23. };
  24. const pretty = (value, maximum = 32768) => {
  25. const text = JSON.stringify(value, null, 2) ?? '';
  26. return text.length > maximum ? `${text.slice(0, maximum)}\n… 内容较长,已截断显示。` : text;
  27. };
  28. let records = [];
  29. let eventFilter = 'all';
  30. let recordId = 0;
  31. let shownConfig = null;
  32. let latestState;
  33. const selected = new Set();
  34. const probe = new RealtimeProbe({ Echo: window.Echo?.default || window.Echo, Pusher: window.Pusher,
  35. onChange: renderState, onLog: addLog, onEvent: addEvent });
  36. function showError(error) {
  37. $('global-error').textContent = redact(error.message || String(error), probe.token);
  38. $('global-error').hidden = false;
  39. }
  40. function check(id, status, note, label) {
  41. const element = $(`check-${id}`);
  42. element.dataset.status = status;
  43. $(`check-${id}-note`).textContent = note;
  44. element.querySelector('.check-state').textContent = label;
  45. element.querySelector('.check-icon').textContent = status === 'ok' ? '✓' : status === 'error' ? '!' : { api: '1', ws: '2', auth: '3', live: '4' }[id];
  46. }
  47. function renderState(state) {
  48. latestState = state;
  49. if (state.config !== shownConfig) {
  50. shownConfig = state.config;
  51. selected.clear();
  52. $('channel-list').replaceChildren();
  53. if (shownConfig) {
  54. for (const name of shownConfig.channels) {
  55. selected.add(name);
  56. const label = make('label', undefined, 'channel');
  57. label.title = name;
  58. const input = make('input'); input.type = 'checkbox'; input.checked = true; input.dataset.channel = name;
  59. input.setAttribute('aria-label', LABELS[name] || name);
  60. input.addEventListener('change', () => { input.checked ? selected.add(name) : selected.delete(name); renderControls(); });
  61. const title = make('span', LABELS[name] || name, 'channel-name');
  62. const status = make('span', '待订阅', 'channel-status'); status.dataset.channelStatus = name;
  63. label.append(input, title, status); $('channel-list').append(label);
  64. }
  65. if (!shownConfig.channels.length) $('channel-list').append(make('div', '当前管理员没有可订阅的频道,请检查账号的列表权限。', 'empty compact-empty'));
  66. const scheme = shownConfig.force_tls ? 'wss' : 'ws';
  67. $('ws-address').textContent = `${scheme}://${shownConfig.ws_host}:${shownConfig.ws_port}/app/…`;
  68. $('config-seq').textContent = String(shownConfig.last_seq);
  69. $('session-expiry').textContent = shownConfig.session_expires_at
  70. ? new Date(shownConfig.session_expires_at * 1000).toLocaleString('zh-CN', { hour12: false }) : '后端未提供';
  71. $('config-json').textContent = pretty(shownConfig);
  72. } else $('channel-list').append(make('div', '读取配置后,在这里选择有权限的频道。', 'empty compact-empty'));
  73. $('config-summary').hidden = !shownConfig;
  74. }
  75. $('connection-pill').dataset.status = state.expired ? 'failed' : state.connection;
  76. $('connection-label').textContent = state.expired ? '登录已过期' : STATE_LABELS[state.connection] || state.connection;
  77. $('live-count').textContent = String(state.liveCount);
  78. $('replay-count').textContent = String(state.replayCount);
  79. $('duplicate-count').textContent = String(state.duplicateCount);
  80. if (shownConfig) $('config-seq').textContent = String(state.lastSeq);
  81. for (const element of document.querySelectorAll('[data-channel-status]')) {
  82. const channel = state.channels.find((c) => c.name === element.dataset.channelStatus);
  83. element.dataset.status = channel?.status || 'idle';
  84. element.textContent = channel?.replaying ? '补发中' : CHANNEL_STATES[channel?.status || 'idle'];
  85. element.title = channel ? `续传点 ${channel.checkpoint} · 最近实时 SEQ ${channel.liveSeq}` : '';
  86. }
  87. check('api', state.api, state.api === 'ok' ? '配置接口已返回有效的连接参数'
  88. : state.api === 'loading' ? '正在请求后台配置…' : state.api === 'error' ? '请查看上方提示或连接日志' : '等待获取后台配置',
  89. { ok: '通过', loading: '检查中', error: '失败', idle: '待检查' }[state.api]);
  90. const connected = state.connection === 'connected';
  91. const failed = ['failed', 'unavailable'].includes(state.connection);
  92. check('ws', connected ? 'ok' : failed ? 'error' : state.connection === 'connecting' ? 'loading' : 'idle',
  93. connected ? `握手成功 · socket ${state.socketId}` : failed ? '检查公网域名、Nginx 反代、证书及 Reverb 进程' : '等待连接 Reverb',
  94. connected ? '通过' : failed ? '失败' : state.connection === 'connecting' ? '连接中' : '待检查');
  95. check('live', state.liveCount ? 'ok' : 'idle', state.liveCount ? `本次检测已收到 ${state.liveCount} 条实时消息`
  96. : state.replayCount ? '已收到补发消息,仍需等待新的实时事件' : '连接后,在另一后台页面触发一条真实业务变更', state.liveCount ? '通过' : '待验证');
  97. renderControls();
  98. }
  99. function renderControls() {
  100. const state = latestState;
  101. if (!state) return;
  102. const ready = state.api === 'ok';
  103. const connected = state.connection === 'connected';
  104. const subscribed = state.channels.filter((c) => selected.has(c.name) && c.status === 'subscribed').length;
  105. const denied = state.channels.some((c) => selected.has(c.name) && ['denied', 'error'].includes(c.status));
  106. const authorizing = state.channels.some((c) => selected.has(c.name) && ['authorizing', 'subscribing'].includes(c.status));
  107. check('auth', state.expired ? 'error' : subscribed && subscribed === selected.size ? 'ok' : denied ? 'error' : authorizing ? 'loading' : 'idle',
  108. state.expired ? '登录已过期,请更新 token 后重新获取配置' : subscribed ? `${subscribed} / ${selected.size} 个已选频道订阅成功`
  109. : denied ? '频道授权未通过,请查看连接日志' : '使用当前管理员的频道权限',
  110. state.expired ? '登录失效' : subscribed && subscribed === selected.size ? '通过' : denied ? '失败' : authorizing ? '授权中' : '待检查');
  111. $('load-config').disabled = state.api === 'loading';
  112. $('load-config').firstChild.textContent = state.api === 'loading' ? '正在读取配置 ' : '获取连接配置 ';
  113. $('connect').disabled = !ready || !selected.size || state.expired;
  114. $('connect').textContent = probe.echo ? '重新连接' : '连接 WebSocket';
  115. $('disconnect').disabled = !probe.echo;
  116. $('apply-channels').disabled = !connected;
  117. $('select-all').disabled = $('select-none').disabled = !ready;
  118. $('replay').disabled = !ready || !selected.size || !state.channels.some((c) => selected.has(c.name)) || state.expired
  119. || state.channels.some((c) => selected.has(c.name) && c.replaying);
  120. $('channel-count').textContent = ready ? `已选 ${selected.size} / ${state.config.channels.length}` : '尚未读取';
  121. const passed = Number(ready) + Number(connected) + Number(subscribed > 0 && subscribed === selected.size) + Number(state.liveCount > 0);
  122. $('progress-count').textContent = `${passed} / 4 通过`;
  123. const complete = passed === 4;
  124. $('verdict').dataset.status = complete ? 'ok' : '';
  125. $('verdict').textContent = complete ? '已收到实时消息,当前 API → 频道授权 → relay / Reverb → 浏览器链路验证通过。'
  126. : state.expired ? '登录已过期。重新登录后粘贴新 token,再获取配置。'
  127. : connected && subscribed ? '连接和订阅已通过。在另一后台页面触发一条业务变更,等待实时消息。'
  128. : '连接成功仅代表通道可用;收到实时事件后,才能确认业务 → relay → Reverb 的链路。';
  129. }
  130. function addLog(entry) {
  131. const logs = $('logs');
  132. if (!logs.querySelector('[data-level]')) logs.replaceChildren();
  133. const follow = logs.scrollHeight - logs.scrollTop - logs.clientHeight < 50;
  134. const row = make('li', undefined, 'log-entry'); row.dataset.level = entry.level;
  135. const message = entry.details ? `${entry.message} ${pretty(entry.details, 1200)}` : entry.message;
  136. row.append(make('time', time(entry.time)), make('span', message)); logs.append(row);
  137. while (logs.children.length > 120) logs.firstChild.remove();
  138. if (follow) logs.scrollTop = logs.scrollHeight;
  139. }
  140. function addEvent(message) {
  141. const event = message.event;
  142. records.unshift({ ...message, event: { channel: event.channel, event: event.event, action: event.action, seq: event.seq,
  143. key: event.key }, json: pretty(event), recordId: ++recordId });
  144. records = records.slice(0, 200);
  145. renderEvents();
  146. }
  147. function renderEvents() {
  148. $('event-rows').replaceChildren();
  149. const visible = records.filter((record) => eventFilter === 'all' || record.source === eventFilter);
  150. $('events-empty').hidden = visible.length > 0;
  151. for (const record of visible) {
  152. const e = record.event;
  153. const row = make('tr'); row.dataset.recordId = String(record.recordId);
  154. row.append(make('td', time(record.time)));
  155. const source = make('td'); source.append(make('span', record.source === 'live' ? '实时' : '补发', `tag ${record.source}`));
  156. if (record.duplicate) source.append(make('span', '重复', 'tag duplicate'));
  157. row.append(source);
  158. const channel = make('td', LABELS[e.channel] || e.channel);
  159. channel.append(make('span', e.event, 'subline')); channel.title = e.channel; row.append(channel);
  160. row.append(make('td', String(e.seq), 'seq'), make('td', JSON.stringify(e.key)));
  161. const action = make('td'); const button = make('button', '查看详情', 'text-button'); button.type = 'button';
  162. button.addEventListener('click', () => {
  163. for (const other of $('event-rows').children) other.classList.toggle('selected', other === row);
  164. $('event-detail').hidden = false; $('event-detail').open = true;
  165. $('detail-meta').textContent = `SEQ ${e.seq} · ${e.channel}`;
  166. $('event-json').textContent = record.json;
  167. });
  168. action.append(button); row.append(action); $('event-rows').append(row);
  169. }
  170. }
  171. function clearEvents() {
  172. records = []; $('event-detail').hidden = true; $('event-json').textContent = ''; renderEvents();
  173. }
  174. $('config-form').addEventListener('submit', async (event) => {
  175. event.preventDefault(); $('global-error').hidden = true; clearEvents();
  176. try { await probe.configure($('api-base').value, $('admin-token').value); } catch (error) { showError(error); }
  177. });
  178. for (const id of ['api-base', 'admin-token']) $(id).addEventListener('input', () => {
  179. $('global-error').hidden = true;
  180. probe.invalidate(); clearEvents();
  181. });
  182. $('toggle-token').addEventListener('click', () => {
  183. const show = $('admin-token').type === 'password'; $('admin-token').type = show ? 'text' : 'password';
  184. $('toggle-token').textContent = show ? '隐藏' : '显示';
  185. $('toggle-token').setAttribute('aria-label', show ? '隐藏 token' : '显示 token');
  186. $('toggle-token').setAttribute('aria-pressed', String(show));
  187. });
  188. $('connect').addEventListener('click', () => { $('global-error').hidden = true; try { probe.connect([...selected]); } catch (error) { showError(error); } });
  189. $('disconnect').addEventListener('click', () => probe.disconnect());
  190. $('apply-channels').addEventListener('click', () => { try { probe.subscribe([...selected]); } catch (error) { showError(error); } });
  191. $('replay').addEventListener('click', async () => {
  192. $('global-error').hidden = true;
  193. try { await probe.replay([...selected], $('after-seq').value === '' ? null : Number($('after-seq').value)); } catch (error) { showError(error); }
  194. });
  195. for (const [id, value] of [['select-all', true], ['select-none', false]]) $(id).addEventListener('click', () => {
  196. selected.clear();
  197. for (const checkbox of document.querySelectorAll('[data-channel]')) {
  198. checkbox.checked = value; if (value) selected.add(checkbox.dataset.channel);
  199. }
  200. renderControls();
  201. });
  202. for (const button of document.querySelectorAll('[data-filter]')) button.addEventListener('click', () => {
  203. eventFilter = button.dataset.filter;
  204. for (const other of document.querySelectorAll('[data-filter]')) other.setAttribute('aria-pressed', String(other === button));
  205. renderEvents();
  206. });
  207. $('clear-events').addEventListener('click', () => { clearEvents(); probe.clearCounts(); });
  208. $('clear-logs').addEventListener('click', () => { $('logs').replaceChildren(); addLog({ level: 'info', message: '日志已清空。', time: Date.now() }); });
  209. window.addEventListener('beforeunload', () => probe.disconnect({ quiet: true }));
  210. if (['http:', 'https:'].includes(location.protocol)) $('api-base').value = location.origin;
  211. probe.emit();
  212. if (location.protocol === 'file:') addLog({ level: 'warning', message: '建议将页面部署到 API 域名下打开。本地 file 页面可能被 Reverb 的来源限制拦截。', time: Date.now() });