更改消息时间

This commit is contained in:
2026-07-22 11:10:37 +08:00
parent d2d8b9325a
commit 65bf725eab
2 changed files with 145 additions and 157 deletions

View File

@@ -18,7 +18,7 @@
.icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } .icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.chat-list { flex: 1; overflow-y: auto; padding-bottom: 8px; } .chat-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
.chat-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; cursor: pointer; } .chat-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; cursor: pointer; position: relative; }
.chat-item:active { background: #f9f9f9; } .chat-item:active { background: #f9f9f9; }
.avatar { width: 44px; height: 44px; border-radius: 50%; background: #e0e0e0; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #555; flex-shrink: 0; } .avatar { width: 44px; height: 44px; border-radius: 50%; background: #e0e0e0; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #555; flex-shrink: 0; }
.chat-info { flex: 1; min-width: 0; } .chat-info { flex: 1; min-width: 0; }
@@ -26,6 +26,12 @@
.last-msg { font-size: 14px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .last-msg { font-size: 14px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-time { font-size: 12px; color: #aaa; margin-left: 8px; white-space: nowrap; } .chat-time { font-size: 12px; color: #aaa; margin-left: 8px; white-space: nowrap; }
.edit-room-btn {
position: absolute; right: 40px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer; display: flex; align-items: center; padding: 8px;
}
.edit-room-btn svg { width: 18px; height: 18px; stroke: #888; fill: none; stroke-width: 2; }
.summary-card { background: #f0f7ff; margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } .summary-card { background: #f0f7ff; margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.summary-card h4 { font-size: 16px; font-weight: 500; } .summary-card h4 { font-size: 16px; font-weight: 500; }
.summary-card .summary-preview { font-size: 14px; color: #555; } .summary-card .summary-preview { font-size: 14px; color: #555; }
@@ -174,6 +180,20 @@
</div> </div>
</div> </div>
<!-- 管理群聊弹窗 -->
<div id="manage-modal" class="modal-overlay hidden">
<div class="modal">
<button class="modal-close-btn" onclick="closeManageModal()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<h3 id="manage-title">编辑群聊</h3>
<input type="text" id="manage-room-name" placeholder="群聊名称">
<input type="text" id="manage-white-list" placeholder="白名单成员(逗号分隔)">
<button class="primary-btn" onclick="saveRoom()">保存</button>
<button class="close-modal" style="background:#e53e3e;color:#fff;margin-top:8px;" onclick="deleteRoom()">删除群聊</button>
</div>
</div>
<!-- 采购详情弹窗 --> <!-- 采购详情弹窗 -->
<div id="detail-modal" class="modal-overlay hidden"> <div id="detail-modal" class="modal-overlay hidden">
<div class="modal"> <div class="modal">
@@ -211,7 +231,6 @@
marked.setOptions({ breaks: true, gfm: true, sanitize: false }); marked.setOptions({ breaks: true, gfm: true, sanitize: false });
} }
// 图片压缩
function compressImage(file) { function compressImage(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -219,14 +238,9 @@
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
let width = img.width; let width = img.width, height = img.height;
let height = img.height; if (width > 1200) { height = height * (1200 / width); width = 1200; }
if (width > 1200) { canvas.width = width; canvas.height = height;
height = height * (1200 / width);
width = 1200;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height); ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8); canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
@@ -237,14 +251,12 @@
}); });
} }
// HTML 转义
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
// 渲染单条消息 HTML
function renderMessageHTML(msg) { function renderMessageHTML(msg) {
const currentUser = localStorage.getItem('currentUser'); const currentUser = localStorage.getItem('currentUser');
const isMe = msg.user === currentUser; const isMe = msg.user === currentUser;
@@ -272,21 +284,18 @@
</div>`; </div>`;
} }
// 全量渲染(切换房间时)
function renderAllMessages(msgs) { function renderAllMessages(msgs) {
messageCache = msgs || []; messageCache = msgs || [];
messagesContainer.innerHTML = messageCache.map(renderMessageHTML).join(''); messagesContainer.innerHTML = messageCache.map(renderMessageHTML).join('');
scrollToBottom(); scrollToBottom();
} }
// 追加一条消息
function appendMessage(msg) { function appendMessage(msg) {
messageCache.push(msg); messageCache.push(msg);
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg)); messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg));
scrollToBottom(); scrollToBottom();
} }
// 替换指定消息(占位更新)
function replaceMessage(msgId, newMsg) { function replaceMessage(msgId, newMsg) {
const idx = messageCache.findIndex(m => m.id == msgId); const idx = messageCache.findIndex(m => m.id == msgId);
if (idx !== -1) { if (idx !== -1) {
@@ -298,11 +307,9 @@
} }
} }
function scrollToBottom() { function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// WebSocket 及重连 // WebSocket
let wsReconnectTimer = null; let wsReconnectTimer = null;
let reconnectAttempts = 0; let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000; const MAX_RECONNECT_DELAY = 30000;
@@ -321,22 +328,23 @@
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (data.type === 'new_message') { if (data.type === 'new_message') {
const msg = data.message; const msg = data.message;
if (currentRoom === msg.room_id) { if (currentRoom === msg.room_id) appendMessage(msg);
appendMessage(msg);
}
if (data.room_preview) { if (data.room_preview) {
const room = rooms.find(r => r.id === data.room_preview.room_id); const room = rooms.find(r => r.id === data.room_preview.room_id);
if (room) { if (room) { room.last_message = data.room_preview.last_message; room.last_time = data.room_preview.last_time; renderChatList(); }
room.last_message = data.room_preview.last_message;
room.last_time = data.room_preview.last_time;
renderChatList();
}
} }
} else if (data.type === 'purchase_updated') { } else if (data.type === 'purchase_updated') {
if (currentRoom) loadPurchases(); if (currentRoom) loadPurchases();
updateSummaryPreview(); updateSummaryPreview();
} else if (data.type === 'room_created') { } else if (data.type === 'room_created') {
loadRooms(); loadRooms();
} else if (data.type === 'room_updated') {
const room = 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') {
rooms = rooms.filter(r => r.id !== data.roomId);
renderChatList();
if (currentRoom === data.roomId) showList();
} }
}; };
ws.onerror = (e) => console.error('WebSocket 错误', e); ws.onerror = (e) => console.error('WebSocket 错误', e);
@@ -351,10 +359,7 @@
if (wsReconnectTimer) clearTimeout(wsReconnectTimer); if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY);
reconnectAttempts++; reconnectAttempts++;
wsReconnectTimer = setTimeout(() => { wsReconnectTimer = setTimeout(() => { console.log('尝试重连...'); initWebSocket(); }, delay);
console.log('尝试重连...');
initWebSocket();
}, delay);
} }
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
@@ -366,7 +371,6 @@
} }
}); });
// 初始化
async function init() { async function init() {
const token = getToken(); const token = getToken();
if (token) { if (token) {
@@ -413,7 +417,6 @@
} }
} }
// 群聊列表
async function loadRooms() { async function loadRooms() {
const token = getToken(); const token = getToken();
const res = await fetch(API + '/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } }); const res = await fetch(API + '/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } });
@@ -424,17 +427,22 @@
function renderChatList() { function renderChatList() {
const container = document.getElementById('chat-list-container'); const container = document.getElementById('chat-list-container');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
container.innerHTML = rooms.map(room => { container.innerHTML = rooms.map(room => {
const lastMsg = room.last_message || '暂无消息'; const lastMsg = room.last_message || '暂无消息';
const lastTime = room.last_time || ''; const lastTime = room.last_time || '';
return ` return `
<div class="chat-item" onclick="openChat('${room.id}')"> <div class="chat-item" data-room-id="${room.id}">
<div class="avatar">${room.name.charAt(0)}</div> <div class="avatar" onclick="openChat('${room.id}')">${room.name.charAt(0)}</div>
<div class="chat-info"> <div class="chat-info" onclick="openChat('${room.id}')">
<div class="chat-name">${room.name}</div> <div class="chat-name">${room.name}</div>
<div class="last-msg">${escapeHtml(lastMsg)}</div> <div class="last-msg">${escapeHtml(lastMsg)}</div>
</div> </div>
<div class="chat-time">${lastTime}</div> <div class="chat-time" onclick="openChat('${room.id}')">${lastTime}</div>
${isAdmin ? `
<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>`;
}).join(''); }).join('');
} }
@@ -451,7 +459,6 @@
} catch(e) {} } catch(e) {}
} }
// 进入聊天
async function openChat(roomId, autoOpenPurchase = false) { async function openChat(roomId, autoOpenPurchase = false) {
currentRoom = roomId; currentRoom = roomId;
document.getElementById('list-page').classList.add('hidden'); document.getElementById('list-page').classList.add('hidden');
@@ -459,9 +466,7 @@
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(() => openPurchasePanel(), 200);
setTimeout(() => openPurchasePanel(), 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();
@@ -477,7 +482,6 @@
updateSummaryPreview(); updateSummaryPreview();
} }
// 发送文本消息
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();
@@ -494,45 +498,31 @@
appendMessage(newMsg); appendMessage(newMsg);
input.value = ''; input.value = '';
pendingUploads = []; pendingUploads = [];
} catch(e) { } catch(e) { alert('发送失败,请重试'); }
alert('发送失败,请重试');
}
} }
// 图片选择上传
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 = {
id: placeholderId, id: placeholderId, room_id: currentRoom, user: localStorage.getItem('currentUser'),
room_id: currentRoom, text: '图片发送中...', attachments: [], timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }), isPlaceholder: true
user: localStorage.getItem('currentUser'),
text: '图片发送中...',
attachments: [],
timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }),
isPlaceholder: true
}; };
appendMessage(placeholderMsg); appendMessage(placeholderMsg);
try { try {
for (let file of files) { for (let file of files) {
let blob = file; 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(); 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', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData });
const res = await fetch(API + '/api/upload', {
method: 'POST', 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);
} }
// 移除占位
messageCache = messageCache.filter(m => m.id !== placeholderId); messageCache = messageCache.filter(m => m.id !== placeholderId);
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`); const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
if (placeholderEl) placeholderEl.remove(); if (placeholderEl) placeholderEl.remove();
// 发送正式图片消息
sendMessage(); sendMessage();
} catch(e) { } catch(e) {
messageCache = messageCache.filter(m => m.id !== placeholderId); messageCache = messageCache.filter(m => m.id !== placeholderId);
@@ -542,7 +532,6 @@
} }
} }
// 采购清单
async function loadPurchases() { async function loadPurchases() {
if (!currentRoom) return; if (!currentRoom) return;
const token = getToken(); const token = getToken();
@@ -553,10 +542,7 @@
function renderPurchasePanel(items) { function renderPurchasePanel(items) {
const container = document.getElementById('purchase-panel-body'); const container = document.getElementById('purchase-panel-body');
if (items.length === 0) { if (items.length === 0) { container.innerHTML = '<p style="text-align:center;color:#888;padding:40px;">暂无采购记录</p>'; return; }
container.innerHTML = '<p style="text-align:center;color:#888;padding:40px;">暂无采购记录</p>';
return;
}
const groups = {}; const groups = {};
items.forEach(item => { items.forEach(item => {
const month = formatMonth(item.created_at); const month = formatMonth(item.created_at);
@@ -567,19 +553,14 @@
Object.keys(groups).sort().reverse().forEach(month => { Object.keys(groups).sort().reverse().forEach(month => {
const monthItems = groups[month]; const monthItems = groups[month];
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0); const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
html += `<div class="purchase-month-group"> html += `<div class="purchase-month-group"><div class="purchase-month-header"><span>${month}</span><span>¥${monthTotal}</span></div>`;
<div class="purchase-month-header"><span>${month}</span><span>¥${monthTotal}</span></div>`;
monthItems.forEach(item => { monthItems.forEach(item => {
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || ''; const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
const dateShort = item.created_at.split(' ')[0]?.substring(5) || ''; const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
html += ` html += `
<div class="purchase-item" onclick="openDetail('${item.id}')"> <div class="purchase-item" onclick="openDetail('${item.id}')">
<div class="item-main"><span class="item-name">${item.item}</span><span class="item-amount">¥${item.amount}</span></div> <div class="item-main"><span class="item-name">${item.item}</span><span class="item-amount">¥${item.amount}</span></div>
<div class="item-meta"> <div class="item-meta"><span>${dateShort} ${timeShort}</span><span>${item.applicant || ''} / ${item.method || ''}</span><span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span></div>
<span>${dateShort} ${timeShort}</span>
<span>${item.applicant || ''} / ${item.method || ''}</span>
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
</div>
</div>`; </div>`;
}); });
html += '</div>'; html += '</div>';
@@ -589,11 +570,7 @@
function formatMonth(dateStr) { function formatMonth(dateStr) {
const parts = dateStr.split('/'); const parts = dateStr.split('/');
if (parts.length >= 2) { if (parts.length >= 2) return `${parts[0]}${parseInt(parts[1], 10)}`;
const year = parts[0];
const month = parseInt(parts[1], 10);
return `${year}${month}`;
}
return dateStr.substring(0,7); return dateStr.substring(0,7);
} }
@@ -602,10 +579,7 @@
document.getElementById('purchase-panel-overlay').classList.remove('hidden'); document.getElementById('purchase-panel-overlay').classList.remove('hidden');
loadPurchases(); loadPurchases();
} }
function closePurchasePanel() { document.getElementById('purchase-panel-overlay').classList.add('hidden'); }
function closePurchasePanel() {
document.getElementById('purchase-panel-overlay').classList.add('hidden');
}
async function openDetail(pid) { async function openDetail(pid) {
const token = getToken(); const token = getToken();
@@ -613,19 +587,11 @@
const p = await res.json(); const p = await res.json();
document.getElementById('detail-title').textContent = p.item; document.getElementById('detail-title').textContent = p.item;
let attachHtml = ''; let attachHtml = '';
if (p.attachments?.length) { if (p.attachments?.length) attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}">`).join('') + '</div>';
attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}">`).join('') + '</div>';
}
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || ''; 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 = ` document.getElementById('detail-content').innerHTML = `<p><strong>金额:</strong>¥${p.amount} | <strong>方式:</strong>${p.method}</p><p><strong>申请人:</strong>${p.applicant}</p>${attachHtml}<h4 style="margin-top:12px;">操作历史</h4><ul class="history-list">${historyHtml}</ul>`;
<p><strong>金额:</strong>¥${p.amount} | <strong>方式:</strong>${p.method}</p>
<p><strong>申请人:</strong>${p.applicant}</p>
${attachHtml}
<h4 style="margin-top:12px;">操作历史</h4>
<ul class="history-list">${historyHtml}</ul>`;
document.getElementById('detail-modal').classList.remove('hidden'); document.getElementById('detail-modal').classList.remove('hidden');
} }
function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); } function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); }
async function openSummary() { async function openSummary() {
@@ -636,10 +602,7 @@
if (summary.length === 0) html = '<p>暂无采购记录</p>'; if (summary.length === 0) html = '<p>暂无采购记录</p>';
else { else {
summary.forEach(s => { summary.forEach(s => {
html += `<div class="summary-card" onclick="event.stopPropagation(); closeDetailModal(); openChat('${s.room_id}', true)" style="margin:8px 0;"> html += `<div class="summary-card" onclick="event.stopPropagation(); closeDetailModal(); openChat('${s.room_id}', true)" style="margin:8px 0;"><h4>${s.room_name}</h4><p>${s.purchase_count} 条,合计 ¥${s.total_amount}</p></div>`;
<h4>${s.room_name}</h4>
<p>${s.purchase_count} 条,合计 ¥${s.total_amount}</p>
</div>`;
}); });
} }
document.getElementById('detail-title').textContent = '采购汇总'; document.getElementById('detail-title').textContent = '采购汇总';
@@ -654,20 +617,49 @@
if (!name) return alert('请输入名称'); if (!name) return alert('请输入名称');
const whiteList = document.getElementById('white-list').value.trim(); const whiteList = document.getElementById('white-list').value.trim();
const token = getToken(); const token = getToken();
await fetch(API + '/api/rooms', { await fetch(API + '/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) });
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, whiteList })
});
closeCreateModal(); closeCreateModal();
loadRooms(); loadRooms();
} }
// 群聊管理
function openManageRoom(roomId) {
const room = 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 = getToken();
const res = await fetch(API + '/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 = getToken();
const res = await fetch(API + '/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
if (res.ok) {
closeManageModal();
loadRooms();
if (currentRoom === roomId) showList();
} else { const err = await res.json(); alert(err.error || '删除失败'); }
}
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) { if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) { e.preventDefault(); sendMessage(); }
e.preventDefault();
sendMessage();
}
}); });
init(); init();

View File

@@ -22,6 +22,12 @@ const ADMINS = (process.env.ADMINS || '').split(',');
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY; const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY;
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-' + Date.now(); const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-' + Date.now();
// 时区:亚洲/上海
const TIMEZONE = 'Asia/Shanghai';
function timestamp() {
return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false });
}
// 初始化用户 // 初始化用户
const insertUser = db.prepare('INSERT OR IGNORE INTO users (username, password, is_admin) VALUES (?, ?, ?)'); const insertUser = db.prepare('INSERT OR IGNORE INTO users (username, password, is_admin) VALUES (?, ?, ?)');
for (const [username, password] of Object.entries(USERS)) { for (const [username, password] of Object.entries(USERS)) {
@@ -101,12 +107,35 @@ app.get('/api/rooms', auth, (req, res) => {
app.post('/api/rooms', auth, adminOnly, (req, res) => { app.post('/api/rooms', auth, adminOnly, (req, res) => {
const { name, whiteList } = req.body; const { name, whiteList } = req.body;
const id = uuidv4(); const id = uuidv4();
const now = new Date().toLocaleString('zh-CN', { hour12: false }); const now = timestamp();
db.prepare('INSERT INTO rooms (id, name, created_by, white_list, created_at) VALUES (?,?,?,?,?)').run(id, name, req.user.username, whiteList || '', now); db.prepare('INSERT INTO rooms (id, name, created_by, white_list, created_at) VALUES (?,?,?,?,?)').run(id, name, req.user.username, whiteList || '', now);
broadcast({ type: 'room_created', room: { id, name, white_list: whiteList || '' } }); broadcast({ type: 'room_created', room: { id, name, white_list: whiteList || '' } });
res.json({ id, name }); res.json({ id, name });
}); });
// 更新群聊信息(管理员)
app.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
const { name, whiteList } = req.body;
const roomId = req.params.roomId;
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId);
if (!room) return res.status(404).json({ error: '群聊不存在' });
db.prepare('UPDATE rooms SET name = ?, white_list = ? WHERE id = ?').run(name, whiteList || '', roomId);
broadcast({ type: 'room_updated', room: { id: roomId, name, white_list: whiteList || '' } });
res.json({ success: true });
});
// 删除群聊(管理员)
app.delete('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
const roomId = req.params.roomId;
db.prepare('DELETE FROM messages WHERE room_id = ?').run(roomId);
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
db.prepare('DELETE FROM rooms WHERE id = ?').run(roomId);
broadcast({ type: 'room_deleted', roomId });
res.json({ success: true });
});
app.get('/api/rooms/:roomId/messages', auth, (req, res) => { app.get('/api/rooms/:roomId/messages', auth, (req, res) => {
const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY timestamp ASC').all(req.params.roomId); const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY timestamp ASC').all(req.params.roomId);
res.json(msgs); res.json(msgs);
@@ -117,10 +146,10 @@ app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
const { text, attachments } = req.body; const { text, attachments } = req.body;
const roomId = req.params.roomId; const roomId = req.params.roomId;
const username = req.user.username; const username = req.user.username;
const timestamp = new Date().toLocaleString('zh-CN', { hour12: false }); const now = timestamp();
const result = db.prepare('INSERT INTO messages (room_id, user, text, attachments, timestamp) VALUES (?,?,?,?,?)').run( const result = db.prepare('INSERT INTO messages (room_id, user, text, attachments, timestamp) VALUES (?,?,?,?,?)').run(
roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, timestamp roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, now
); );
const msg = { const msg = {
id: result.lastInsertRowid, id: result.lastInsertRowid,
@@ -128,7 +157,7 @@ app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
user: username, user: username,
text: text || '', text: text || '',
attachments: attachments || [], attachments: attachments || [],
timestamp timestamp: now
}; };
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId); const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
@@ -208,15 +237,6 @@ app.get('/api/rooms/:roomId/purchases/export', auth, (req, res) => {
res.send('\uFEFF' + csv); res.send('\uFEFF' + csv);
}); });
app.post('/api/rooms/:roomId/purchases/clear', auth, adminOnly, (req, res) => {
const roomId = req.params.roomId;
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
broadcastToRoom(roomId, { type: 'purchase_updated' });
res.json({ success: true, message: '所有采购数据已清空' });
});
// WebSocket 管理 // WebSocket 管理
const clients = new Map(); const clients = new Map();
wss.on('connection', (ws, req) => { wss.on('connection', (ws, req) => {
@@ -278,54 +298,35 @@ function broadcast(data) {
} }
function storeAndBroadcastText(roomId, user, text) { function storeAndBroadcastText(roomId, user, text) {
const timestamp = new Date().toLocaleString('zh-CN', { hour12: false }); const now = timestamp();
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, timestamp); const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, now);
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp }; const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp: now };
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId); const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
broadcastToRoom(roomId, { broadcastToRoom(roomId, {
type: 'new_message', type: 'new_message',
message: msg, message: msg,
room_preview: { room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' }
room_id: roomId,
last_message: lastMsg ? lastMsg.text : '',
last_time: lastMsg ? lastMsg.timestamp : ''
}
}); });
return msg; return msg;
} }
// ---------- 待处理操作管理 ---------- // ---------- 待处理操作管理 ----------
const pendingActions = new Map(); // key: `${roomId}:${username}`, value: { type, data } const pendingActions = new Map();
function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); }
function hasPendingAction(roomId, username) { function setPendingAction(roomId, username, action) { pendingActions.set(`${roomId}:${username}`, action); }
return pendingActions.has(`${roomId}:${username}`); function clearPendingAction(roomId, username) { pendingActions.delete(`${roomId}:${username}`); }
}
function setPendingAction(roomId, username, action) {
pendingActions.set(`${roomId}:${username}`, action);
}
function clearPendingAction(roomId, username) {
pendingActions.delete(`${roomId}:${username}`);
}
function executePendingAction(roomId, username) { function executePendingAction(roomId, username) {
const action = pendingActions.get(`${roomId}:${username}`); const action = pendingActions.get(`${roomId}:${username}`);
if (!action) return false; if (!action) return false;
clearPendingAction(roomId, username); clearPendingAction(roomId, username);
const now = timestamp();
const now = new Date().toLocaleString('zh-CN', { hour12: false });
try { try {
if (action.type === 'delete') { if (action.type === 'delete') {
const { itemName, purchaseIds } = action.data; const { itemName, purchaseIds } = action.data;
const deleteAttachments = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?'); const deleteAttachments = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?');
const deleteHistory = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?'); const deleteHistory = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
const deletePurchase = db.prepare('DELETE FROM purchases WHERE id = ?'); const deletePurchase = db.prepare('DELETE FROM purchases WHERE id = ?');
for (const id of purchaseIds) { for (const id of purchaseIds) { deleteAttachments.run(id); deleteHistory.run(id); deletePurchase.run(id); }
deleteAttachments.run(id);
deleteHistory.run(id);
deletePurchase.run(id);
}
const reply = `✅ 已删除采购记录:${itemName}(共 ${purchaseIds.length} 条)`; const reply = `✅ 已删除采购记录:${itemName}(共 ${purchaseIds.length} 条)`;
storeAndBroadcastText(roomId, '小财', reply); storeAndBroadcastText(roomId, '小财', reply);
addToHistory(roomId, 'assistant', reply); addToHistory(roomId, 'assistant', reply);
@@ -339,14 +340,10 @@ function executePendingAction(roomId, username) {
} }
broadcastToRoom(roomId, { type: 'purchase_updated' }); broadcastToRoom(roomId, { type: 'purchase_updated' });
return true; return true;
} catch (e) { } catch (e) { console.error('执行待处理操作失败:', e); storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
console.error('执行待处理操作失败:', e);
storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。');
return false;
}
} }
// 对话历史 // ---------- 对话历史 ----------
const conversationHistory = new Map(); const conversationHistory = new Map();
function getHistory(roomId) { function getHistory(roomId) {
if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []);
@@ -358,11 +355,12 @@ function addToHistory(roomId, role, content) {
if (history.length > 60) conversationHistory.set(roomId, history.slice(-40)); if (history.length > 60) conversationHistory.set(roomId, history.slice(-40));
} }
// ---------- AI 函数 ----------
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) { async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
const systemPrompt = `你是智能财务助手“小财”。结合对话历史理解用户意图。只返回JSON。 const systemPrompt = `你是智能财务助手“小财”。结合对话历史理解用户意图。只返回JSON。
意图分类: 意图分类:
- 采购/付款/发票相关action="purchase"提取purchase_item, amount, method, status, applicant, created_time - 采购/付款/发票相关action="purchase"提取purchase_item, amount, method, status, applicant, created_time(可选根据聊天中的时间推断格式yyyy/MM/dd HH:mm:ss)
- 查询汇总action="query" - 查询汇总action="query"
- 聊天action="chat"reply简短回复 - 聊天action="chat"reply简短回复
- 删除指定物品action="delete"delete_item为物品名 - 删除指定物品action="delete"delete_item为物品名
@@ -399,7 +397,7 @@ async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
} }
function handleAIResult(aiResult, roomId, username, originalText, attachments) { function handleAIResult(aiResult, roomId, username, originalText, attachments) {
const now = new Date().toLocaleString('zh-CN', { hour12: false }); const now = timestamp();
if (aiResult.action === 'ask') { if (aiResult.action === 'ask') {
storeAndBroadcastText(roomId, '小财', aiResult.question); storeAndBroadcastText(roomId, '小财', aiResult.question);
@@ -416,7 +414,6 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`); addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`);
return; return;
} }
// 构建确认消息
let confirmText = `⚠️ 即将删除以下 ${purchases.length} 条采购记录,请回复“确认”继续:\n\n`; let confirmText = `⚠️ 即将删除以下 ${purchases.length} 条采购记录,请回复“确认”继续:\n\n`;
purchases.forEach(p => { purchases.forEach(p => {
confirmText += `${p.item} | ¥${p.amount} | ${p.status} | ${p.applicant} | ${p.created_at}\n`; confirmText += `${p.item} | ¥${p.amount} | ${p.status} | ${p.applicant} | ${p.created_at}\n`;
@@ -424,7 +421,6 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
confirmText += `\n如果不删除,请忽略此消息。`; confirmText += `\n如果不删除,请忽略此消息。`;
storeAndBroadcastText(roomId, '小财', confirmText); storeAndBroadcastText(roomId, '小财', confirmText);
addToHistory(roomId, 'assistant', confirmText); addToHistory(roomId, 'assistant', confirmText);
// 存储待处理操作
setPendingAction(roomId, username, { setPendingAction(roomId, username, {
type: 'delete', type: 'delete',
data: { itemName, purchaseIds: purchases.map(p => p.id) } data: { itemName, purchaseIds: purchases.map(p => p.id) }