前端: - world.html 游戏内聊天组件重构:表情选择器改固定方格+自适应列+去滚动条; 输入框清空占位文字+隐藏滚动条;聊天面板整体高度缩短约 1/3; 顶部用户区改为纯文字并点开个人信息弹窗(头像/头像框/性别/说明/称号/注册) - chat.html 聊天输入框压扁(46→34px) - codex.html 新增神话品质、天赋/技能书/书籍分类与预置数据 - rules.html 规则文档更新 后端(Odoo): - game_base 附件改为看板视图(图片/名称/备注/大小) - yt_world 玩家菜单直接指向 hr.employee - game_base 员工表单隐藏原生联系人/组织架构页签 - chat 模型/接口/视图与规则数据扩充 资源:生成 4x4 资源精灵表(树木/石材/芦苇/宝箱)
169 lines
5.8 KiB
JavaScript
169 lines
5.8 KiB
JavaScript
// 世界聊天前端:微信风气泡 + 轮询增量拉取(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 = `<span class="ico">${TYPE_ICON[ch.type] || '💬'}</span>
|
||
<span class="name">${escapeHtml(ch.name)}</span>`;
|
||
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 = `
|
||
<div class="ava">${escapeHtml(initial)}</div>
|
||
<div class="bubble-wrap">
|
||
<div class="meta"><span>${escapeHtml(m.author_name)}</span><span>${fmtTime(m.create_date)}</span></div>
|
||
<div class="bubble">${escapeHtml(m.body)}</div>
|
||
</div>`;
|
||
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();
|
||
})();
|
||
})();
|