// 工具函数 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 `${f}`; return `
📎 ${f.split('/').pop()}
`; }).join(''); } catch(e) {} } return `
${name}
${contentHtml}${attachHtml}
${msg.timestamp}
`; } function renderAllMessages(msgs) { Store.messageCache = msgs || []; messagesContainer.innerHTML = Store.messageCache.map(renderMessageHTML).join(''); scrollToBottom(); bindMessageEvents(); } function appendMessage(msg) { // 去重:轮询和 WebSocket 可能投递同一条消息 if (Store.messageCache.some(m => m.id === msg.id)) return; 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('click', onImageClick); img.addEventListener('click', onImageClick); }); document.querySelectorAll('.msg-bubble').forEach(bubble => { bubble.removeEventListener('click', onBubbleClick); bubble.addEventListener('click', onBubbleClick); }); } function onImageClick(e) { e.stopPropagation(); const overlay = document.createElement('div'); overlay.className = 'fullscreen-overlay'; overlay.innerHTML = ''; overlay.addEventListener('click', function(ev) { if (ev.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); initPinchZoom(overlay.querySelector('img')); } function onImgThumbClick(img) { const overlay = document.createElement('div'); overlay.className = 'fullscreen-overlay'; overlay.innerHTML = ''; overlay.addEventListener('click', function(ev) { if (ev.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); initPinchZoom(overlay.querySelector('img')); } function onBubbleClick(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(function(m) { return !m.isTemp; }); var tempEls = messagesContainer.querySelectorAll('[data-msg-id^="temp_"]'); tempEls.forEach(function(el) { el.remove(); }); } function showTempBubble(text) { clearTempBubble(); var tempId = 'temp_' + Date.now(); var tempMsg = { id: tempId, user: '小财', text: text, attachments: [], timestamp: '', isTemp: true }; Store.messageCache.push(tempMsg); messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg)); scrollToBottom(); } // 双指缩放 + 拖拽 function initPinchZoom(img) { var scale = 1, startDist = 0, startScale = 1; var translateX = 0, translateY = 0; var startX = 0, startY = 0, startTX = 0, startTY = 0; var dragging = false; function update() { img.style.transform = 'translate(' + translateX + 'px,' + translateY + 'px) scale(' + scale + ')'; } img.addEventListener('touchstart', function(e) { if (e.touches.length === 2) { dragging = false; startDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); startScale = scale; startTX = translateX; startTY = translateY; var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2; var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2; startX = cx; startY = cy; } else if (e.touches.length === 1 && scale > 1) { dragging = true; startX = e.touches[0].clientX; startY = e.touches[0].clientY; startTX = translateX; startTY = translateY; } }); img.addEventListener('touchmove', function(e) { if (e.touches.length === 2) { e.preventDefault(); var dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); scale = Math.max(0.5, Math.min(5, startScale * (dist / startDist))); var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2; var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2; translateX = startTX + (cx - startX); translateY = startTY + (cy - startY); update(); } else if (e.touches.length === 1 && dragging) { translateX = startTX + (e.touches[0].clientX - startX); translateY = startTY + (e.touches[0].clientY - startY); update(); } }, { passive: false }); img.addEventListener('touchend', function() { dragging = false; if (scale < 0.6) { scale = 0.5; translateX = 0; translateY = 0; update(); } if (scale <= 1) { translateX = 0; translateY = 0; update(); } }); }