Files
game/assets/chat.js
T
李鹏宇 73d764938d feat: 前端 UI 精修 + 后端视图调整 + 资源预置
前端:
- world.html 游戏内聊天组件重构:表情选择器改固定方格+自适应列+去滚动条;
  输入框清空占位文字+隐藏滚动条;聊天面板整体高度缩短约 1/3;
  顶部用户区改为纯文字并点开个人信息弹窗(头像/头像框/性别/说明/称号/注册)
- chat.html 聊天输入框压扁(46→34px)
- codex.html 新增神话品质、天赋/技能书/书籍分类与预置数据
- rules.html 规则文档更新

后端(Odoo):
- game_base 附件改为看板视图(图片/名称/备注/大小)
- yt_world 玩家菜单直接指向 hr.employee
- game_base 员工表单隐藏原生联系人/组织架构页签
- chat 模型/接口/视图与规则数据扩充

资源:生成 4x4 资源精灵表(树木/石材/芦苇/宝箱)
2026-08-14 14:27:15 +08:00

169 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 世界聊天前端:微信风气泡 + 轮询增量拉取(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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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();
})();
})();