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 `
`;
+ return `
`;
+ }).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 `
`;
- return ``;
- }).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];