// 世界聊天前端:微信风气泡 + 轮询增量拉取(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();
})();
})();