refactor: v2 重构 — 模块化拆分 + AI Prompt模板化 + 前端状态管理
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
This commit is contained in:
54
server/public/js/auth.js
Normal file
54
server/public/js/auth.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// 认证与初始化
|
||||
async function init() {
|
||||
const token = Store.getToken();
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch('/api/user', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
document.getElementById('login-page').classList.add('hidden');
|
||||
document.getElementById('main-page').classList.remove('hidden');
|
||||
if (localStorage.getItem('isAdmin') === 'true') {
|
||||
document.getElementById('create-room-btn').classList.add('visible');
|
||||
} else {
|
||||
document.getElementById('create-room-btn').classList.remove('visible');
|
||||
}
|
||||
initWebSocket();
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
initSwipeGestures();
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
Store.clearAuth();
|
||||
}
|
||||
document.getElementById('login-page').classList.remove('hidden');
|
||||
document.getElementById('main-page').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const u = document.getElementById('login-user').value;
|
||||
const p = document.getElementById('login-pass').value;
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u, password: p })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
Store.saveAuth(data);
|
||||
document.getElementById('login-page').classList.add('hidden');
|
||||
document.getElementById('main-page').classList.remove('hidden');
|
||||
if (data.isAdmin) {
|
||||
document.getElementById('create-room-btn').classList.add('visible');
|
||||
} else {
|
||||
document.getElementById('create-room-btn').classList.remove('visible');
|
||||
}
|
||||
initWebSocket();
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
initSwipeGestures();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
document.getElementById('login-error').textContent = err.error || '登录失败';
|
||||
}
|
||||
}
|
||||
167
server/public/js/chat.js
Normal file
167
server/public/js/chat.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// 工具函数
|
||||
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());
|
||||
}
|
||||
182
server/public/js/purchases.js
Normal file
182
server/public/js/purchases.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// 采购相关功能
|
||||
async function loadPurchases() {
|
||||
if (!Store.currentRoom) return;
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + Store.currentRoom + '/purchases', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const items = await res.json();
|
||||
renderPurchasePanel(items);
|
||||
}
|
||||
|
||||
function renderPurchasePanel(items) {
|
||||
const container = document.getElementById('purchase-panel-body');
|
||||
if (items.length === 0) { container.innerHTML = '<p style="text-align:center;color:#888;padding:40px;">暂无采购记录</p>'; return; }
|
||||
const groups = {};
|
||||
items.forEach(item => {
|
||||
const month = formatMonth(item.created_at);
|
||||
if (!groups[month]) groups[month] = [];
|
||||
groups[month].push(item);
|
||||
});
|
||||
let html = '';
|
||||
Object.keys(groups).sort().reverse().forEach(month => {
|
||||
const monthItems = groups[month];
|
||||
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
|
||||
html += `<div class="purchase-month-group"><div class="purchase-month-header"><span>${month}</span><span>¥${monthTotal}</span></div>`;
|
||||
monthItems.forEach(item => {
|
||||
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
|
||||
const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
|
||||
html += `
|
||||
<div class="purchase-item" onclick="openDetail('${item.id}')">
|
||||
<div class="item-main"><span class="item-name">${escapeHtml(item.item)}</span><span class="item-amount">¥${item.amount}</span></div>
|
||||
<div class="item-meta">
|
||||
<span>${dateShort} ${timeShort}</span>
|
||||
<span>×${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''}</span>
|
||||
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatMonth(dateStr) {
|
||||
const clean = dateStr.replace(/-/g, '/');
|
||||
const parts = clean.split('/');
|
||||
if (parts.length >= 2) return `${parts[0]}年${parseInt(parts[1], 10)}月`;
|
||||
return dateStr.substring(0,7);
|
||||
}
|
||||
|
||||
function openPurchasePanel() {
|
||||
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
|
||||
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
|
||||
loadPurchases();
|
||||
}
|
||||
|
||||
function closePurchasePanel() { document.getElementById('purchase-panel-overlay').classList.add('hidden'); }
|
||||
|
||||
async function openDetail(pid) {
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/purchases/' + pid, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const p = await res.json();
|
||||
document.getElementById('detail-title').textContent = p.item;
|
||||
let attachHtml = '';
|
||||
if (p.attachments?.length) attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}" ondblclick="onImgThumbDblClick(this)">`).join('') + '</div>';
|
||||
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || '';
|
||||
document.getElementById('detail-content').innerHTML = `
|
||||
<div class="detail-row"><strong>数量:</strong>${p.quantity}</div>
|
||||
<div class="detail-row"><strong>单价:</strong>¥${p.unit_price}</div>
|
||||
<div class="detail-row"><strong>邮费:</strong>¥${p.freight}</div>
|
||||
<div class="detail-row"><strong>总金额:</strong>¥${p.amount}</div>
|
||||
<div class="detail-row"><strong>付款方式:</strong>${p.payment_method || '未指定'}</div>
|
||||
<div class="detail-row"><strong>发票:</strong>${p.invoice_type || '无票'}</div>
|
||||
<div class="detail-row"><strong>状态:</strong>${p.status}</div>
|
||||
<div class="detail-row"><strong>申请人:</strong>${p.applicant}</div>
|
||||
<div class="detail-row"><strong>采购时间:</strong>${p.created_at}</div>
|
||||
<div class="detail-row"><strong>备注:</strong>${p.remarks || '无'}</div>
|
||||
${attachHtml}
|
||||
<h4 style="margin-top:12px;">操作历史</h4>
|
||||
<ul class="history-list">${historyHtml}</ul>`;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); }
|
||||
|
||||
async function openSummary() {
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const summary = await res.json();
|
||||
let html = '';
|
||||
if (summary.length === 0) html = '<p>暂无采购记录</p>';
|
||||
else {
|
||||
summary.forEach(s => {
|
||||
html += `<div class="summary-card" onclick="event.stopPropagation(); closeDetailModal(); openChat('${s.room_id}', true)" style="margin:8px 0;"><h4>${escapeHtml(s.room_name)}</h4><p>${s.purchase_count} 条,合计 ¥${s.total_amount}</p></div>`;
|
||||
});
|
||||
}
|
||||
document.getElementById('detail-title').textContent = '采购汇总';
|
||||
document.getElementById('detail-content').innerHTML = html;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 群聊管理
|
||||
function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); }
|
||||
function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); }
|
||||
async function confirmCreateRoom() {
|
||||
const name = document.getElementById('new-room-name').value.trim();
|
||||
if (!name) return alert('请输入名称');
|
||||
const whiteList = document.getElementById('white-list').value.trim();
|
||||
const token = Store.getToken();
|
||||
await fetch('/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) });
|
||||
closeCreateModal();
|
||||
loadRooms();
|
||||
}
|
||||
|
||||
function openManageRoom(roomId) {
|
||||
const room = Store.rooms.find(r => r.id === roomId);
|
||||
if (!room) return;
|
||||
document.getElementById('manage-title').textContent = '编辑群聊';
|
||||
document.getElementById('manage-room-name').value = room.name;
|
||||
document.getElementById('manage-white-list').value = room.white_list || '';
|
||||
document.getElementById('manage-modal').dataset.roomId = roomId;
|
||||
document.getElementById('manage-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeManageModal() { document.getElementById('manage-modal').classList.add('hidden'); }
|
||||
|
||||
async function saveRoom() {
|
||||
const roomId = document.getElementById('manage-modal').dataset.roomId;
|
||||
const name = document.getElementById('manage-room-name').value.trim();
|
||||
const whiteList = document.getElementById('manage-white-list').value.trim();
|
||||
if (!name) return alert('请输入群聊名称');
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + roomId, {
|
||||
method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, whiteList })
|
||||
});
|
||||
if (res.ok) { closeManageModal(); loadRooms(); }
|
||||
else { const err = await res.json(); alert(err.error || '更新失败'); }
|
||||
}
|
||||
|
||||
async function deleteRoom() {
|
||||
if (!confirm('确定要删除该群聊吗?所有消息和采购数据将被永久删除。')) return;
|
||||
const roomId = document.getElementById('manage-modal').dataset.roomId;
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
closeManageModal();
|
||||
loadRooms();
|
||||
if (Store.currentRoom === roomId) showList();
|
||||
} else { const err = await res.json(); alert(err.error || '删除失败'); }
|
||||
}
|
||||
|
||||
// 滑动手势
|
||||
function initSwipeGestures() {
|
||||
const chatPage = document.getElementById('chat-page');
|
||||
const purchaseOverlay = document.getElementById('purchase-panel-overlay');
|
||||
let startX = 0, startY = 0;
|
||||
|
||||
function handleTouchStart(e) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }
|
||||
|
||||
function handleTouchEnd(e, target) {
|
||||
if (!startX) return;
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const diffX = endX - startX;
|
||||
const diffY = endY - startY;
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||
if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) {
|
||||
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||
} else if (target === chatPage) {
|
||||
if (diffX > 30) showList();
|
||||
else if (diffX < -30) openPurchasePanel();
|
||||
} else if (target === purchaseOverlay) {
|
||||
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||
}
|
||||
}
|
||||
startX = 0; startY = 0;
|
||||
}
|
||||
|
||||
chatPage.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
chatPage.addEventListener('touchend', (e) => handleTouchEnd(e, chatPage), { passive: true });
|
||||
purchaseOverlay.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
purchaseOverlay.addEventListener('touchend', (e) => handleTouchEnd(e, purchaseOverlay), { passive: true });
|
||||
}
|
||||
36
server/public/js/store.js
Normal file
36
server/public/js/store.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// 全局状态管理
|
||||
const Store = {
|
||||
ws: null,
|
||||
currentRoom: null,
|
||||
rooms: [],
|
||||
pendingUploads: [],
|
||||
messageCache: [],
|
||||
tempBubbleTimer: null,
|
||||
wsReconnectTimer: null,
|
||||
reconnectAttempts: 0,
|
||||
pollTimer: null,
|
||||
|
||||
MAX_RECONNECT_DELAY: 30000,
|
||||
|
||||
getToken() { return localStorage.getItem('token'); },
|
||||
saveAuth(data) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('currentUser', data.username);
|
||||
localStorage.setItem('isAdmin', data.isAdmin);
|
||||
},
|
||||
clearAuth() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('currentUser');
|
||||
localStorage.removeItem('isAdmin');
|
||||
},
|
||||
logout() {
|
||||
if (Store.ws) { Store.ws.close(); Store.ws = null; }
|
||||
Store.clearAuth();
|
||||
document.getElementById('login-page').classList.remove('hidden');
|
||||
document.getElementById('main-page').classList.add('hidden');
|
||||
Store.currentRoom = null;
|
||||
Store.rooms = [];
|
||||
document.getElementById('chat-list-container').innerHTML = '';
|
||||
document.getElementById('messages-container').innerHTML = '';
|
||||
},
|
||||
};
|
||||
180
server/public/js/ws.js
Normal file
180
server/public/js/ws.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// WebSocket 连接管理
|
||||
function initWebSocket() {
|
||||
const token = Store.getToken();
|
||||
if (!token) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
Store.ws = new WebSocket(`${protocol}//${location.host}?token=${token}`);
|
||||
Store.ws.onopen = () => {
|
||||
console.log('WebSocket 已连接');
|
||||
Store.reconnectAttempts = 0;
|
||||
if (Store.currentRoom) Store.ws.send(JSON.stringify({ type: 'join', roomId: Store.currentRoom }));
|
||||
};
|
||||
Store.ws.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log('📩 WS 收到:', data.type);
|
||||
if (data.type === 'new_message') {
|
||||
const msg = data.message;
|
||||
if (Store.currentRoom === msg.room_id) appendMessage(msg);
|
||||
if (data.room_preview) {
|
||||
const room = Store.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 (Store.currentRoom) loadPurchases();
|
||||
updateSummaryPreview();
|
||||
} else if (data.type === 'room_created') {
|
||||
loadRooms();
|
||||
} else if (data.type === 'room_updated') {
|
||||
const room = Store.rooms.find(r => r.id === data.room.id);
|
||||
if (room) { room.name = data.room.name; room.white_list = data.room.white_list; renderChatList(); }
|
||||
} else if (data.type === 'room_deleted') {
|
||||
Store.rooms = Store.rooms.filter(r => r.id !== data.roomId);
|
||||
renderChatList();
|
||||
if (Store.currentRoom === data.roomId) showList();
|
||||
}
|
||||
};
|
||||
Store.ws.onerror = (e) => console.error('WebSocket 错误', e);
|
||||
Store.ws.onclose = (event) => {
|
||||
console.log('WebSocket 关闭,代码:', event.code);
|
||||
if (event.code === 1000) return;
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (Store.wsReconnectTimer) clearTimeout(Store.wsReconnectTimer);
|
||||
const delay = Math.min(1000 * Math.pow(2, Store.reconnectAttempts), Store.MAX_RECONNECT_DELAY);
|
||||
Store.reconnectAttempts++;
|
||||
Store.wsReconnectTimer = setTimeout(() => { console.log('尝试重连...'); initWebSocket(); }, delay);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (!Store.ws || Store.ws.readyState !== WebSocket.OPEN) {
|
||||
if (Store.wsReconnectTimer) clearTimeout(Store.wsReconnectTimer);
|
||||
initWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 聊天列表
|
||||
async function loadRooms() {
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.status === 401) { Store.clearAuth(); location.reload(); return; }
|
||||
Store.rooms = await res.json();
|
||||
renderChatList();
|
||||
}
|
||||
|
||||
function renderChatList() {
|
||||
const container = document.getElementById('chat-list-container');
|
||||
const isAdmin = localStorage.getItem('isAdmin') === 'true';
|
||||
container.innerHTML = Store.rooms.map(room => {
|
||||
const lastMsg = room.last_message || '暂无消息';
|
||||
const lastTime = room.last_time || '';
|
||||
return `
|
||||
<div class="chat-item" data-room-id="${room.id}">
|
||||
<div class="avatar" onclick="openChat('${room.id}')">${escapeHtml(room.name.charAt(0))}</div>
|
||||
<div class="chat-info" onclick="openChat('${room.id}')">
|
||||
<div class="chat-name">${escapeHtml(room.name)}</div>
|
||||
<div class="last-msg">${escapeHtml(lastMsg)}</div>
|
||||
</div>
|
||||
<div class="chat-right">
|
||||
<div class="chat-time" onclick="openChat('${room.id}')">${lastTime}</div>
|
||||
${isAdmin ? `<div class="room-actions">
|
||||
<button class="edit-room-btn" onclick="event.stopPropagation(); openManageRoom('${room.id}')" title="编辑群聊">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function openChat(roomId, autoOpenPurchase = false) {
|
||||
Store.currentRoom = roomId;
|
||||
clearTempBubble();
|
||||
document.getElementById('list-page').classList.add('hidden');
|
||||
document.getElementById('chat-page').classList.remove('hidden');
|
||||
const room = Store.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(() => openPurchasePanel(), 200);
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + roomId + '/messages', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const msgs = await res.json();
|
||||
renderAllMessages(msgs);
|
||||
if (Store.ws && Store.ws.readyState === WebSocket.OPEN) Store.ws.send(JSON.stringify({ type: 'join', roomId }));
|
||||
}
|
||||
|
||||
function showList() {
|
||||
clearTempBubble();
|
||||
document.getElementById('chat-page').classList.add('hidden');
|
||||
document.getElementById('list-page').classList.remove('hidden');
|
||||
Store.currentRoom = null;
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('msg-input');
|
||||
const text = input.value.trim();
|
||||
if (!text && Store.pendingUploads.length === 0) return;
|
||||
if (!Store.currentRoom) return;
|
||||
const token = Store.getToken();
|
||||
try {
|
||||
const res = await fetch('/api/rooms/' + Store.currentRoom + '/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text || '', attachments: Store.pendingUploads })
|
||||
});
|
||||
const newMsg = await res.json();
|
||||
appendMessage(newMsg);
|
||||
input.value = '';
|
||||
Store.pendingUploads = [];
|
||||
insertTempBubble();
|
||||
} catch(e) { alert('发送失败,请重试'); }
|
||||
}
|
||||
|
||||
async function handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
const token = Store.getToken();
|
||||
const placeholderId = 'placeholder_' + Date.now();
|
||||
const placeholderMsg = {
|
||||
id: placeholderId, room_id: Store.currentRoom, user: localStorage.getItem('currentUser'),
|
||||
text: '图片发送中...', attachments: [], timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }), isPlaceholder: true
|
||||
};
|
||||
appendMessage(placeholderMsg);
|
||||
try {
|
||||
for (let file of files) {
|
||||
let blob = 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/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData });
|
||||
const data = await res.json();
|
||||
if (data.path) Store.pendingUploads.push(data.path);
|
||||
}
|
||||
Store.messageCache = Store.messageCache.filter(m => m.id !== placeholderId);
|
||||
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
|
||||
if (placeholderEl) placeholderEl.remove();
|
||||
sendMessage();
|
||||
} catch(e) {
|
||||
Store.messageCache = Store.messageCache.filter(m => m.id !== placeholderId);
|
||||
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
|
||||
if (placeholderEl) placeholderEl.remove();
|
||||
alert('图片发送失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSummaryPreview() {
|
||||
const token = Store.getToken();
|
||||
try {
|
||||
const res = await fetch('/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const summary = await res.json();
|
||||
const totalItems = summary.reduce((s, r) => s + r.purchase_count, 0);
|
||||
const totalAmount = summary.reduce((s, r) => s + r.total_amount, 0);
|
||||
const preview = document.getElementById('summary-preview');
|
||||
if (preview) preview.innerText = `共 ${totalItems} 条,合计 ¥${totalAmount}`;
|
||||
} catch(e) {}
|
||||
}
|
||||
Reference in New Issue
Block a user