// 世界聊天前端:微信风气泡 + 轮询增量拉取(1.5s) // 与后端 /game/api/chat/* 双向打通;登录态沿用 Odoo session cookie(dev-server 同源代理)。 (() => { const $ = (id) => document.getElementById(id); const me = { uid: null, name: '我' }; let channels = []; let activeChannel = null; // {id, name} let lastId = 0; // 已渲染的最大消息 id(增量轮询基准) let renderedIds = new Set(); // 已渲染消息 id,避免即时渲染与轮询重复 let pollTimer = null; const TYPE_ICON = { world: '🌐', guild: '🛡️', pm: '💬', guide: '🤖' }; // ---------- 工具 ---------- function fmtTime(s) { // 中国习惯:今天 HH:mm / 昨天 昨天 HH:mm / 更早 MM-DD HH:mm const d = new Date(s.replace(' ', 'T')); if (isNaN(d)) return ''; const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const hm = pad(d.getHours()) + ':' + pad(d.getMinutes()); const sameDay = d.toDateString() === now.toDateString(); const yest = new Date(now); yest.setDate(now.getDate() - 1); if (sameDay) return hm; if (d.toDateString() === yest.toDateString()) return '昨天 ' + hm; return pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + hm; } async function api(path, opts) { const res = await fetch(path, Object.assign({ credentials: 'same-origin' }, opts)); return res.json(); } function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } function scrollBottom() { const m = $('messages'); m.scrollTop = m.scrollHeight; } // ---------- 渲染 ---------- function renderChannels() { const box = $('channels'); box.innerHTML = ''; channels.forEach((ch) => { const el = document.createElement('div'); el.className = 'channel' + (activeChannel && ch.id === activeChannel.id ? ' active' : ''); el.innerHTML = `${TYPE_ICON[ch.type] || '💬'} ${escapeHtml(ch.name)}`; el.onclick = () => selectChannel(ch); box.appendChild(el); }); } function renderMessage(m, prepend) { if (renderedIds.has(m.id)) return; // 去重:即时渲染与轮询可能重复拉到同一消息 renderedIds.add(m.id); const isGuide = !!m.is_guide; const row = document.createElement('div'); row.className = 'row' + (m.is_self ? ' self' : '') + (isGuide ? ' guide' : ''); const initial = isGuide ? '🤖' : (m.author_name || '?').slice(0, 1); row.innerHTML = `
${escapeHtml(initial)}
${escapeHtml(m.author_name)}${fmtTime(m.create_date)}
${escapeHtml(m.body)}
`; const box = $('messages'); if (prepend) box.insertBefore(row, box.firstChild); else box.appendChild(row); if (m.id > lastId) lastId = m.id; } function setStatus(t) { $('status').textContent = t || ''; } // ---------- 流程 ---------- async function loadMe() { const data = await api('/game/api/me'); if (data.uid) { me.uid = data.uid; me.name = data.name || '我'; $('meText').textContent = me.name; $('meAvatar').textContent = (me.name || '?').slice(0, 1); $('mask').classList.add('hidden'); $('input').disabled = false; $('sendBtn').disabled = false; } else { $('mask').classList.remove('hidden'); } } async function loadChannels() { const data = await api('/game/api/chat/channels'); channels = data.items || []; if (!channels.length) { setStatus('暂无频道'); return; } renderChannels(); const first = channels[0]; selectChannel(first); } async function selectChannel(ch) { activeChannel = ch; lastId = 0; renderedIds.clear(); $('messages').innerHTML = ''; $('chTitle').textContent = ch.name; $('chSub').textContent = ch.type === 'guide' ? '向宇森向导提问,了解属性 / 等级 / 货币 / 制度' : '和大家聊聊宇森世界吧'; renderChannels(); setStatus('加载中…'); const data = await api(`/game/api/chat/messages?channel_id=${ch.id}&limit=50`); const items = (data.items || []).slice().sort((a, b) => a.id - b.id); items.forEach((m) => renderMessage(m)); scrollBottom(); setStatus(items.length ? '' : '还没有人说话,来抢沙发~'); startPoll(); } function startPoll() { if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(async () => { if (!activeChannel) return; const data = await api(`/game/api/chat/messages?channel_id=${activeChannel.id}&after_id=${lastId}`); const items = data.items || []; if (items.length) { items.forEach((m) => renderMessage(m)); scrollBottom(); } }, 1500); } async function send() { const ta = $('input'); const body = ta.value.trim(); if (!body || !activeChannel) return; const btn = $('sendBtn'); btn.disabled = true; const data = await api('/game/api/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel_id: activeChannel.id, body }), }); btn.disabled = false; if (data.ok) { ta.value = ''; renderMessage(data.message, false); if (data.guide_reply) renderMessage(data.guide_reply, false); scrollBottom(); } else { setStatus(data.error || '发送失败'); } } // ---------- 事件 ---------- $('sendBtn').onclick = send; $('input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); // ---------- 启动 ---------- (async () => { await loadMe(); await loadChannels(); })(); })();