v1.0, 基本功能完成
This commit is contained in:
1
build.sh
Executable file
1
build.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
docker compose down && docker compose build --no-cache && docker compose up -d
|
||||||
@@ -192,7 +192,8 @@
|
|||||||
let currentRoom = null;
|
let currentRoom = null;
|
||||||
let rooms = [];
|
let rooms = [];
|
||||||
let pendingUploads = [];
|
let pendingUploads = [];
|
||||||
const sentMsgIds = new Set(); // 本地已处理的消息 ID,防止广播重复
|
const messagesContainer = document.getElementById('messages-container');
|
||||||
|
let messageCache = [];
|
||||||
|
|
||||||
function getToken() { return localStorage.getItem('token'); }
|
function getToken() { return localStorage.getItem('token'); }
|
||||||
function saveAuth(data) {
|
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 `<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) {
|
||||||
|
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() {
|
async function init() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
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() {
|
async function loadRooms() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -375,12 +439,6 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateSummaryPreview() {
|
async function updateSummaryPreview() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
try {
|
try {
|
||||||
@@ -393,27 +451,21 @@
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开聊天室(可自动展开采购面板)
|
// 进入聊天
|
||||||
async function openChat(roomId, autoOpenPurchase = false) {
|
async function openChat(roomId, autoOpenPurchase = false) {
|
||||||
currentRoom = roomId;
|
currentRoom = roomId;
|
||||||
document.getElementById('messages-container').innerHTML = '';
|
|
||||||
document.getElementById('list-page').classList.add('hidden');
|
document.getElementById('list-page').classList.add('hidden');
|
||||||
document.getElementById('chat-page').classList.remove('hidden');
|
document.getElementById('chat-page').classList.remove('hidden');
|
||||||
const room = rooms.find(r => r.id === roomId);
|
const room = rooms.find(r => r.id === roomId);
|
||||||
document.getElementById('current-chat-name').textContent = room?.name || '';
|
document.getElementById('current-chat-name').textContent = room?.name || '';
|
||||||
document.getElementById('purchase-panel-overlay').classList.add('hidden');
|
document.getElementById('purchase-panel-overlay').classList.add('hidden');
|
||||||
if (autoOpenPurchase) {
|
if (autoOpenPurchase) {
|
||||||
setTimeout(() => {
|
setTimeout(() => openPurchasePanel(), 200);
|
||||||
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
|
|
||||||
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
|
|
||||||
loadPurchases();
|
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
|
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||||
const msgs = await res.json();
|
const msgs = await res.json();
|
||||||
window._messagesCache = msgs;
|
renderAllMessages(msgs);
|
||||||
renderMessages(msgs);
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'join', roomId }));
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'join', roomId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,55 +477,12 @@
|
|||||||
updateSummaryPreview();
|
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 `<img class="msg-img" src="${f}" alt="${f}" loading="lazy" onload="scrollToBottom()">`;
|
|
||||||
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>`;
|
|
||||||
}).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() {
|
async function sendMessage() {
|
||||||
const input = document.getElementById('msg-input');
|
const input = document.getElementById('msg-input');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text && pendingUploads.length === 0) return;
|
if (!text && pendingUploads.length === 0) return;
|
||||||
if (!currentRoom) return;
|
if (!currentRoom) return;
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, {
|
const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, {
|
||||||
@@ -482,7 +491,6 @@
|
|||||||
body: JSON.stringify({ text: text || '', attachments: pendingUploads })
|
body: JSON.stringify({ text: text || '', attachments: pendingUploads })
|
||||||
});
|
});
|
||||||
const newMsg = await res.json();
|
const newMsg = await res.json();
|
||||||
// 不记录 sentMsgIds,因为后端已经过滤,我们直接 append
|
|
||||||
appendMessage(newMsg);
|
appendMessage(newMsg);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
pendingUploads = [];
|
pendingUploads = [];
|
||||||
@@ -491,11 +499,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选图后自动压缩上传,显示占位,然后发送
|
// 图片选择上传
|
||||||
async function handleFileSelect(event) {
|
async function handleFileSelect(event) {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const placeholderId = 'placeholder_' + Date.now();
|
const placeholderId = 'placeholder_' + Date.now();
|
||||||
const placeholderMsg = {
|
const placeholderMsg = {
|
||||||
@@ -507,38 +514,35 @@
|
|||||||
timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }),
|
timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }),
|
||||||
isPlaceholder: true
|
isPlaceholder: true
|
||||||
};
|
};
|
||||||
if (!window._messagesCache) window._messagesCache = [];
|
appendMessage(placeholderMsg);
|
||||||
window._messagesCache.push(placeholderMsg);
|
|
||||||
renderMessages(window._messagesCache);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let file of files) {
|
for (let file of files) {
|
||||||
let blob = file;
|
let blob = file;
|
||||||
if (file.size > 1 * 1024 * 1024) {
|
if (file.size > 1 * 1024 * 1024) blob = await compressImage(file);
|
||||||
blob = await compressImage(file);
|
|
||||||
}
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', blob, file.name || 'image.jpg');
|
formData.append('file', blob, file.name || 'image.jpg');
|
||||||
const res = await fetch(API + '/api/upload', {
|
const res = await fetch(API + '/api/upload', {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
|
||||||
body: formData
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.path) pendingUploads.push(data.path);
|
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();
|
sendMessage();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
window._messagesCache = window._messagesCache.filter(m => m.id !== placeholderId);
|
messageCache = messageCache.filter(m => m.id !== placeholderId);
|
||||||
renderMessages(window._messagesCache);
|
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
|
||||||
|
if (placeholderEl) placeholderEl.remove();
|
||||||
alert('图片发送失败,请重试');
|
alert('图片发送失败,请重试');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 采购清单相关
|
// 采购清单
|
||||||
async function loadPurchases() {
|
async function loadPurchases() {
|
||||||
if (!currentRoom) return;
|
if (!currentRoom) return;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -559,7 +563,6 @@
|
|||||||
if (!groups[month]) groups[month] = [];
|
if (!groups[month]) groups[month] = [];
|
||||||
groups[month].push(item);
|
groups[month].push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
Object.keys(groups).sort().reverse().forEach(month => {
|
Object.keys(groups).sort().reverse().forEach(month => {
|
||||||
const monthItems = groups[month];
|
const monthItems = groups[month];
|
||||||
|
|||||||
Reference in New Issue
Block a user