P0 - AI交互层:
- ai/prompt.js: 模块化 Prompt 模板
- ai/validator.js: JSON Schema 校验 + normalize
- ai/client.js: DeepSeek API 封装
- 金额计算唯一化,时间格式归一化
P1 - 后端架构:
- routes/: auth/rooms/messages/purchases 独立路由
- middleware/auth.js: 认证授权中间件
- ws/index.js: WebSocket 连接管理
- db.js: 启用 foreign_keys
- server.js: 从588行精简到40行入口
P2 - 前端架构:
- public/css/style.css: CSS 独立
- public/js/store.js: 全局状态 Store
- public/js/{chat,ws,purchases,auth}.js: 功能模块拆分
- index.html: 纯 HTML 结构, v2.6
168 lines
6.2 KiB
JavaScript
168 lines
6.2 KiB
JavaScript
// 工具函数
|
|
if (typeof marked !== 'undefined') marked.setOptions({ breaks: true, gfm: true, sanitize: false });
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function compressImage(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
let width = img.width, height = img.height;
|
|
if (width > 1200) { height = height * (1200 / width); width = 1200; }
|
|
canvas.width = width; canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
|
|
};
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
// 消息渲染
|
|
const messagesContainer = document.getElementById('messages-container');
|
|
|
|
function renderMessageHTML(msg) {
|
|
const currentUser = localStorage.getItem('currentUser');
|
|
const isMe = msg.user === currentUser;
|
|
const name = msg.user === '小财' ? '小财' : msg.user;
|
|
let contentHtml = msg.text ? escapeHtml(msg.text) : '';
|
|
if (msg.user === '小财' && typeof marked !== 'undefined' && msg.text) {
|
|
try { contentHtml = marked.parse(msg.text); } catch(e) {}
|
|
}
|
|
let attachHtml = '';
|
|
if (msg.attachments && msg.attachments.length) {
|
|
try {
|
|
const files = typeof msg.attachments === 'string' ? JSON.parse(msg.attachments) : msg.attachments;
|
|
attachHtml = files.map(f => {
|
|
const isImage = /\.(jpg|jpeg|png|gif)$/i.test(f);
|
|
if (isImage) return `<img class="msg-img" src="${f}" alt="${f}" loading="lazy">`;
|
|
return `<div><a href="${f}" target="_blank">📎 ${f.split('/').pop()}</a></div>`;
|
|
}).join('');
|
|
} catch(e) {}
|
|
}
|
|
return `
|
|
<div class="msg ${isMe ? 'me' : 'other'}" data-msg-id="${msg.id}">
|
|
<div class="msg-user">${name}</div>
|
|
<div class="msg-bubble">${contentHtml}${attachHtml}</div>
|
|
<div class="msg-time">${msg.timestamp}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderAllMessages(msgs) {
|
|
Store.messageCache = msgs || [];
|
|
messagesContainer.innerHTML = Store.messageCache.map(renderMessageHTML).join('');
|
|
scrollToBottom();
|
|
bindMessageEvents();
|
|
}
|
|
|
|
function appendMessage(msg) {
|
|
Store.messageCache.push(msg);
|
|
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg));
|
|
scrollToBottom();
|
|
bindMessageEvents();
|
|
clearTempBubble();
|
|
}
|
|
|
|
function replaceMessage(msgId, newMsg) {
|
|
const idx = Store.messageCache.findIndex(m => m.id == msgId);
|
|
if (idx !== -1) {
|
|
Store.messageCache[idx] = newMsg;
|
|
const oldEl = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`);
|
|
if (oldEl) oldEl.outerHTML = renderMessageHTML(newMsg);
|
|
} else {
|
|
appendMessage(newMsg);
|
|
}
|
|
bindMessageEvents();
|
|
}
|
|
|
|
function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
|
|
|
|
// 图片/气泡双击
|
|
function bindMessageEvents() {
|
|
document.querySelectorAll('.msg-img').forEach(img => {
|
|
img.removeEventListener('dblclick', onImageDblClick);
|
|
img.addEventListener('dblclick', onImageDblClick);
|
|
});
|
|
document.querySelectorAll('.msg-bubble').forEach(bubble => {
|
|
bubble.removeEventListener('dblclick', onBubbleDblClick);
|
|
bubble.addEventListener('dblclick', onBubbleDblClick);
|
|
});
|
|
}
|
|
|
|
function onImageDblClick(e) {
|
|
e.stopPropagation();
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'fullscreen-overlay';
|
|
overlay.innerHTML = `<img src="${e.target.src}" alt="">`;
|
|
overlay.addEventListener('click', () => overlay.remove());
|
|
document.body.appendChild(overlay);
|
|
}
|
|
|
|
function onImgThumbDblClick(img) {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'fullscreen-overlay';
|
|
overlay.innerHTML = `<img src="${img.src}" style="max-width:100%;max-height:100%;object-fit:contain" alt="">`;
|
|
overlay.addEventListener('click', () => overlay.remove());
|
|
document.body.appendChild(overlay);
|
|
}
|
|
|
|
function onBubbleDblClick(e) {
|
|
e.stopPropagation();
|
|
const bubble = e.currentTarget;
|
|
const clone = bubble.cloneNode(true);
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'fullscreen-overlay';
|
|
const container = document.createElement('div');
|
|
container.className = 'fullscreen-text';
|
|
container.innerHTML = clone.innerHTML;
|
|
overlay.appendChild(container);
|
|
overlay.addEventListener('click', () => overlay.remove());
|
|
document.body.appendChild(overlay);
|
|
}
|
|
|
|
// 临时气泡 + 降级轮询
|
|
function insertTempBubble() {
|
|
clearTempBubble();
|
|
const tempId = 'temp_' + Date.now();
|
|
const tempMsg = { id: tempId, user: '小财', text: '输入中...', attachments: [], timestamp: '', isTemp: true };
|
|
Store.messageCache.push(tempMsg);
|
|
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg));
|
|
scrollToBottom();
|
|
Store.tempBubbleTimer = setTimeout(() => { clearTempBubble(); }, 8000);
|
|
if (Store.pollTimer) clearInterval(Store.pollTimer);
|
|
let pollCount = 0;
|
|
Store.pollTimer = setInterval(async () => {
|
|
pollCount++;
|
|
if (!Store.currentRoom || pollCount > 5) { clearInterval(Store.pollTimer); Store.pollTimer = null; return; }
|
|
try {
|
|
const token = Store.getToken();
|
|
const res = await fetch('/api/rooms/' + Store.currentRoom + '/messages', { headers: { 'Authorization': `Bearer ${token}` } });
|
|
const msgs = await res.json();
|
|
const lastCacheId = Store.messageCache.filter(m => !m.isTemp && !m.isPlaceholder).slice(-1)[0]?.id;
|
|
const newMsgs = msgs.filter(m => !lastCacheId || m.id > lastCacheId);
|
|
if (newMsgs.length) {
|
|
clearInterval(Store.pollTimer); Store.pollTimer = null;
|
|
clearTempBubble();
|
|
newMsgs.forEach(m => appendMessage(m));
|
|
}
|
|
} catch(e) {}
|
|
}, 2000);
|
|
}
|
|
|
|
function clearTempBubble() {
|
|
if (Store.tempBubbleTimer) { clearTimeout(Store.tempBubbleTimer); Store.tempBubbleTimer = null; }
|
|
if (Store.pollTimer) { clearInterval(Store.pollTimer); Store.pollTimer = null; }
|
|
Store.messageCache = Store.messageCache.filter(m => !m.isTemp);
|
|
const tempEls = messagesContainer.querySelectorAll('[data-msg-id^="temp_"]');
|
|
tempEls.forEach(el => el.remove());
|
|
}
|