diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7200860 --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +docker compose down && docker compose build --no-cache && docker compose up -d diff --git a/server/public/index.html b/server/public/index.html index 96d8dc8..1b60f97 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -192,7 +192,8 @@ let currentRoom = null; let rooms = []; let pendingUploads = []; - const sentMsgIds = new Set(); // 本地已处理的消息 ID,防止广播重复 + const messagesContainer = document.getElementById('messages-container'); + let messageCache = []; function getToken() { return localStorage.getItem('token'); } function saveAuth(data) { @@ -236,6 +237,136 @@ }); } + // HTML 转义 + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // 渲染单条消息 HTML + 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 `${f}`; + return `
📎 ${f.split('/').pop()}
`; + }).join(''); + } catch(e) {} + } + return ` +
+
${name}
+
${contentHtml}${attachHtml}
+
${msg.timestamp}
+
`; + } + + // 全量渲染(切换房间时) + function renderAllMessages(msgs) { + messageCache = msgs || []; + messagesContainer.innerHTML = messageCache.map(renderMessageHTML).join(''); + scrollToBottom(); + } + + // 追加一条消息 + function appendMessage(msg) { + messageCache.push(msg); + messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg)); + scrollToBottom(); + } + + // 替换指定消息(占位更新) + function replaceMessage(msgId, newMsg) { + const idx = messageCache.findIndex(m => m.id == msgId); + if (idx !== -1) { + messageCache[idx] = newMsg; + const oldEl = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`); + if (oldEl) oldEl.outerHTML = renderMessageHTML(newMsg); + } else { + appendMessage(newMsg); + } + } + + function scrollToBottom() { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // WebSocket 及重连 + let wsReconnectTimer = null; + let reconnectAttempts = 0; + const MAX_RECONNECT_DELAY = 30000; + + function initWebSocket() { + const token = getToken(); + if (!token) return; + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${protocol}//${location.host}?token=${token}`); + ws.onopen = () => { + console.log('WebSocket 已连接'); + reconnectAttempts = 0; + if (currentRoom) ws.send(JSON.stringify({ type: 'join', roomId: currentRoom })); + }; + ws.onmessage = (e) => { + const data = JSON.parse(e.data); + if (data.type === 'new_message') { + const msg = data.message; + if (currentRoom === msg.room_id) { + appendMessage(msg); + } + if (data.room_preview) { + const room = rooms.find(r => r.id === data.room_preview.room_id); + if (room) { + room.last_message = data.room_preview.last_message; + room.last_time = data.room_preview.last_time; + renderChatList(); + } + } + } else if (data.type === 'purchase_updated') { + if (currentRoom) loadPurchases(); + updateSummaryPreview(); + } else if (data.type === 'room_created') { + loadRooms(); + } + }; + ws.onerror = (e) => console.error('WebSocket 错误', e); + ws.onclose = (event) => { + console.log('WebSocket 关闭,代码:', event.code); + if (event.code === 1000) return; + scheduleReconnect(); + }; + } + + function scheduleReconnect() { + if (wsReconnectTimer) clearTimeout(wsReconnectTimer); + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY); + reconnectAttempts++; + wsReconnectTimer = setTimeout(() => { + console.log('尝试重连...'); + initWebSocket(); + }, delay); + } + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + if (!ws || ws.readyState !== WebSocket.OPEN) { + if (wsReconnectTimer) clearTimeout(wsReconnectTimer); + initWebSocket(); + } + } + }); + + // 初始化 async function init() { const token = getToken(); if (token) { @@ -282,73 +413,6 @@ } } - // WebSocket 重连管理 - let wsReconnectTimer = null; - let reconnectAttempts = 0; - const MAX_RECONNECT_DELAY = 30000; - - function initWebSocket() { - const token = getToken(); - if (!token) return; - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${protocol}//${location.host}?token=${token}`); - ws.onopen = () => { - console.log('WebSocket 已连接'); - reconnectAttempts = 0; - if (currentRoom) { - ws.send(JSON.stringify({ type: 'join', roomId: currentRoom })); - } - }; - ws.onmessage = (e) => { - const data = JSON.parse(e.data); - if (data.type === 'new_message') { - const msg = data.message; - // 发送者本人不会收到自己的广播(已由后端过滤),因此直接添加 - if (currentRoom === msg.room_id) { - appendMessage(msg); - } - if (data.room_preview) { - const room = rooms.find(r => r.id === data.room_preview.room_id); - if (room) { - room.last_message = data.room_preview.last_message; - room.last_time = data.room_preview.last_time; - renderChatList(); - } - } - } else if (data.type === 'purchase_updated') { - if (currentRoom) loadPurchases(); - updateSummaryPreview(); - } else if (data.type === 'room_created') { - loadRooms(); - } - }; - ws.onerror = (e) => console.error('WebSocket 错误', e); - ws.onclose = (event) => { - console.log('WebSocket 关闭,代码:', event.code); - if (event.code === 1000) return; - scheduleReconnect(); - }; - } - - function scheduleReconnect() { - if (wsReconnectTimer) clearTimeout(wsReconnectTimer); - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY); - reconnectAttempts++; - wsReconnectTimer = setTimeout(() => { - console.log('尝试重连...'); - initWebSocket(); - }, delay); - } - - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - if (!ws || ws.readyState !== WebSocket.OPEN) { - if (wsReconnectTimer) clearTimeout(wsReconnectTimer); - initWebSocket(); - } - } - }); - // 群聊列表 async function loadRooms() { const token = getToken(); @@ -375,12 +439,6 @@ }).join(''); } - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - async function updateSummaryPreview() { const token = getToken(); try { @@ -393,27 +451,21 @@ } catch(e) {} } - // 打开聊天室(可自动展开采购面板) + // 进入聊天 async function openChat(roomId, autoOpenPurchase = false) { currentRoom = roomId; - document.getElementById('messages-container').innerHTML = ''; document.getElementById('list-page').classList.add('hidden'); document.getElementById('chat-page').classList.remove('hidden'); const room = rooms.find(r => r.id === roomId); document.getElementById('current-chat-name').textContent = room?.name || ''; document.getElementById('purchase-panel-overlay').classList.add('hidden'); if (autoOpenPurchase) { - setTimeout(() => { - document.getElementById('purchase-panel-body').innerHTML = '

加载中...

'; - document.getElementById('purchase-panel-overlay').classList.remove('hidden'); - loadPurchases(); - }, 200); + setTimeout(() => openPurchasePanel(), 200); } const token = getToken(); const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } }); const msgs = await res.json(); - window._messagesCache = msgs; - renderMessages(msgs); + renderAllMessages(msgs); if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'join', roomId })); } @@ -425,55 +477,12 @@ updateSummaryPreview(); } - function renderMessages(msgs) { - const container = document.getElementById('messages-container'); - const currentUser = localStorage.getItem('currentUser'); - container.innerHTML = msgs.map(msg => { - 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 `${f}`; - return `
📎 ${f.split('/').pop()}
`; - }).join(''); - } catch(e) {} - } - return ` -
-
${name}
-
${contentHtml}${attachHtml}
-
${msg.timestamp}
-
`; - }).join(''); - container.scrollTop = container.scrollHeight; - } - - function scrollToBottom() { - const container = document.getElementById('messages-container'); - if (container) container.scrollTop = container.scrollHeight; - } - - function appendMessage(msg) { - if (!window._messagesCache) window._messagesCache = []; - window._messagesCache.push(msg); - renderMessages(window._messagesCache); - } - - // 发送消息(文本/图片统一入口) + // 发送文本消息 async function sendMessage() { const input = document.getElementById('msg-input'); const text = input.value.trim(); if (!text && pendingUploads.length === 0) return; if (!currentRoom) return; - const token = getToken(); try { const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, { @@ -482,7 +491,6 @@ body: JSON.stringify({ text: text || '', attachments: pendingUploads }) }); const newMsg = await res.json(); - // 不记录 sentMsgIds,因为后端已经过滤,我们直接 append appendMessage(newMsg); input.value = ''; pendingUploads = []; @@ -491,11 +499,10 @@ } } - // 选图后自动压缩上传,显示占位,然后发送 + // 图片选择上传 async function handleFileSelect(event) { const files = event.target.files; if (!files.length) return; - const token = getToken(); const placeholderId = 'placeholder_' + Date.now(); const placeholderMsg = { @@ -507,38 +514,35 @@ timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }), isPlaceholder: true }; - if (!window._messagesCache) window._messagesCache = []; - window._messagesCache.push(placeholderMsg); - renderMessages(window._messagesCache); + appendMessage(placeholderMsg); try { for (let file of files) { let blob = file; - if (file.size > 1 * 1024 * 1024) { - blob = await compressImage(file); - } + if (file.size > 1 * 1024 * 1024) blob = await compressImage(file); const formData = new FormData(); formData.append('file', blob, file.name || 'image.jpg'); const res = await fetch(API + '/api/upload', { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: formData + method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); const data = await res.json(); if (data.path) pendingUploads.push(data.path); } // 移除占位 - window._messagesCache = window._messagesCache.filter(m => m.id !== placeholderId); - // 发送正式消息 + messageCache = messageCache.filter(m => m.id !== placeholderId); + const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`); + if (placeholderEl) placeholderEl.remove(); + // 发送正式图片消息 sendMessage(); } catch(e) { - window._messagesCache = window._messagesCache.filter(m => m.id !== placeholderId); - renderMessages(window._messagesCache); + messageCache = messageCache.filter(m => m.id !== placeholderId); + const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`); + if (placeholderEl) placeholderEl.remove(); alert('图片发送失败,请重试'); } } - // 采购清单相关 + // 采购清单 async function loadPurchases() { if (!currentRoom) return; const token = getToken(); @@ -559,7 +563,6 @@ if (!groups[month]) groups[month] = []; groups[month].push(item); }); - let html = ''; Object.keys(groups).sort().reverse().forEach(month => { const monthItems = groups[month];