`;
}).join('');
}
@@ -451,7 +459,6 @@
} catch(e) {}
}
- // 进入聊天
async function openChat(roomId, autoOpenPurchase = false) {
currentRoom = roomId;
document.getElementById('list-page').classList.add('hidden');
@@ -459,9 +466,7 @@
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(() => openPurchasePanel(), 200);
- }
+ if (autoOpenPurchase) setTimeout(() => openPurchasePanel(), 200);
const token = getToken();
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
const msgs = await res.json();
@@ -477,7 +482,6 @@
updateSummaryPreview();
}
- // 发送文本消息
async function sendMessage() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
@@ -494,45 +498,31 @@
appendMessage(newMsg);
input.value = '';
pendingUploads = [];
- } catch(e) {
- alert('发送失败,请重试');
- }
+ } catch(e) { alert('发送失败,请重试'); }
}
- // 图片选择上传
async function handleFileSelect(event) {
const files = event.target.files;
if (!files.length) return;
const token = getToken();
const placeholderId = 'placeholder_' + Date.now();
const placeholderMsg = {
- id: placeholderId,
- room_id: currentRoom,
- user: localStorage.getItem('currentUser'),
- text: '图片发送中...',
- attachments: [],
- timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }),
- isPlaceholder: true
+ id: placeholderId, room_id: 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 + '/api/upload', {
- method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData
- });
+ 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 });
const data = await res.json();
if (data.path) pendingUploads.push(data.path);
}
- // 移除占位
messageCache = messageCache.filter(m => m.id !== placeholderId);
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
if (placeholderEl) placeholderEl.remove();
- // 发送正式图片消息
sendMessage();
} catch(e) {
messageCache = messageCache.filter(m => m.id !== placeholderId);
@@ -542,7 +532,6 @@
}
}
- // 采购清单
async function loadPurchases() {
if (!currentRoom) return;
const token = getToken();
@@ -553,10 +542,7 @@
function renderPurchasePanel(items) {
const container = document.getElementById('purchase-panel-body');
- if (items.length === 0) {
- container.innerHTML = '
';
- return;
- }
+ if (items.length === 0) { container.innerHTML = '
'; return; }
const groups = {};
items.forEach(item => {
const month = formatMonth(item.created_at);
@@ -567,19 +553,14 @@
Object.keys(groups).sort().reverse().forEach(month => {
const monthItems = groups[month];
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
- html += `
- `;
+ html += `
`;
monthItems.forEach(item => {
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
html += `
${item.item}¥${item.amount}
-
- ${dateShort} ${timeShort}
- ${item.applicant || ''} / ${item.method || ''}
- ${item.status}
-
+
${dateShort} ${timeShort}${item.applicant || ''} / ${item.method || ''}${item.status}
`;
});
html += '
';
@@ -589,11 +570,7 @@
function formatMonth(dateStr) {
const parts = dateStr.split('/');
- if (parts.length >= 2) {
- const year = parts[0];
- const month = parseInt(parts[1], 10);
- return `${year}年${month}月`;
- }
+ if (parts.length >= 2) return `${parts[0]}年${parseInt(parts[1], 10)}月`;
return dateStr.substring(0,7);
}
@@ -602,10 +579,7 @@
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
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) {
const token = getToken();
@@ -613,19 +587,11 @@
const p = await res.json();
document.getElementById('detail-title').textContent = p.item;
let attachHtml = '';
- if (p.attachments?.length) {
- attachHtml = '
' + p.attachments.map(a => `

`).join('') + '
';
- }
+ if (p.attachments?.length) attachHtml = '
' + p.attachments.map(a => `

`).join('') + '
';
const historyHtml = p.history?.map(h => `
${h.timestamp} ${h.user}: ${h.action}`).join('') || '';
- document.getElementById('detail-content').innerHTML = `
-
金额:¥${p.amount} | 方式:${p.method}
-
申请人:${p.applicant}
- ${attachHtml}
-
操作历史
-
`;
+ document.getElementById('detail-content').innerHTML = `
金额:¥${p.amount} | 方式:${p.method}
申请人:${p.applicant}
${attachHtml}
操作历史
`;
document.getElementById('detail-modal').classList.remove('hidden');
}
-
function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); }
async function openSummary() {
@@ -636,10 +602,7 @@
if (summary.length === 0) html = '
暂无采购记录
';
else {
summary.forEach(s => {
- html += `
-
${s.room_name}
-
${s.purchase_count} 条,合计 ¥${s.total_amount}
-
`;
+ html += `
${s.room_name}
${s.purchase_count} 条,合计 ¥${s.total_amount}
`;
});
}
document.getElementById('detail-title').textContent = '采购汇总';
@@ -654,20 +617,49 @@
if (!name) return alert('请输入名称');
const whiteList = document.getElementById('white-list').value.trim();
const token = getToken();
- await fetch(API + '/api/rooms', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, whiteList })
- });
+ await fetch(API + '/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) });
closeCreateModal();
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) {
- if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) {
- e.preventDefault();
- sendMessage();
- }
+ if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) { e.preventDefault(); sendMessage(); }
});
init();
diff --git a/server/server.js b/server/server.js
index 52b57c7..ea149ff 100644
--- a/server/server.js
+++ b/server/server.js
@@ -22,6 +22,12 @@ const ADMINS = (process.env.ADMINS || '').split(',');
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY;
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 (?, ?, ?)');
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) => {
const { name, whiteList } = req.body;
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);
broadcast({ type: 'room_created', room: { id, name, white_list: whiteList || '' } });
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) => {
const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY timestamp ASC').all(req.params.roomId);
res.json(msgs);
@@ -117,10 +146,10 @@ app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
const { text, attachments } = req.body;
const roomId = req.params.roomId;
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(
- roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, timestamp
+ roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, now
);
const msg = {
id: result.lastInsertRowid,
@@ -128,7 +157,7 @@ app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
user: username,
text: text || '',
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);
@@ -208,15 +237,6 @@ app.get('/api/rooms/:roomId/purchases/export', auth, (req, res) => {
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 管理
const clients = new Map();
wss.on('connection', (ws, req) => {
@@ -278,54 +298,35 @@ function broadcast(data) {
}
function storeAndBroadcastText(roomId, user, text) {
- const timestamp = new Date().toLocaleString('zh-CN', { hour12: false });
- const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, timestamp);
- const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp };
+ const now = 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: now };
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
broadcastToRoom(roomId, {
type: 'new_message',
message: msg,
- room_preview: {
- room_id: roomId,
- last_message: lastMsg ? lastMsg.text : '',
- last_time: lastMsg ? lastMsg.timestamp : ''
- }
+ room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' }
});
return msg;
}
// ---------- 待处理操作管理 ----------
-const pendingActions = new Map(); // key: `${roomId}:${username}`, value: { type, data }
-
-function hasPendingAction(roomId, username) {
- return pendingActions.has(`${roomId}:${username}`);
-}
-
-function setPendingAction(roomId, username, action) {
- pendingActions.set(`${roomId}:${username}`, action);
-}
-
-function clearPendingAction(roomId, username) {
- pendingActions.delete(`${roomId}:${username}`);
-}
-
+const pendingActions = new Map();
+function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); }
+function setPendingAction(roomId, username, action) { pendingActions.set(`${roomId}:${username}`, action); }
+function clearPendingAction(roomId, username) { pendingActions.delete(`${roomId}:${username}`); }
function executePendingAction(roomId, username) {
const action = pendingActions.get(`${roomId}:${username}`);
if (!action) return false;
clearPendingAction(roomId, username);
-
- const now = new Date().toLocaleString('zh-CN', { hour12: false });
+ const now = timestamp();
try {
if (action.type === 'delete') {
const { itemName, purchaseIds } = action.data;
const deleteAttachments = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?');
const deleteHistory = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
const deletePurchase = db.prepare('DELETE FROM purchases WHERE id = ?');
- for (const id of purchaseIds) {
- deleteAttachments.run(id);
- deleteHistory.run(id);
- deletePurchase.run(id);
- }
+ for (const id of purchaseIds) { deleteAttachments.run(id); deleteHistory.run(id); deletePurchase.run(id); }
const reply = `✅ 已删除采购记录:${itemName}(共 ${purchaseIds.length} 条)`;
storeAndBroadcastText(roomId, '小财', reply);
addToHistory(roomId, 'assistant', reply);
@@ -339,14 +340,10 @@ function executePendingAction(roomId, username) {
}
broadcastToRoom(roomId, { type: 'purchase_updated' });
return true;
- } catch (e) {
- console.error('执行待处理操作失败:', e);
- storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。');
- return false;
- }
+ } catch (e) { console.error('执行待处理操作失败:', e); storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
}
-// 对话历史
+// ---------- 对话历史 ----------
const conversationHistory = new Map();
function getHistory(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));
}
+// ---------- AI 函数 ----------
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
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="chat",reply简短回复
- 删除指定物品:action="delete",delete_item为物品名
@@ -399,7 +397,7 @@ async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
}
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
- const now = new Date().toLocaleString('zh-CN', { hour12: false });
+ const now = timestamp();
if (aiResult.action === 'ask') {
storeAndBroadcastText(roomId, '小财', aiResult.question);
@@ -416,7 +414,6 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`);
return;
}
- // 构建确认消息
let confirmText = `⚠️ 即将删除以下 ${purchases.length} 条采购记录,请回复“确认”继续:\n\n`;
purchases.forEach(p => {
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如果不删除,请忽略此消息。`;
storeAndBroadcastText(roomId, '小财', confirmText);
addToHistory(roomId, 'assistant', confirmText);
- // 存储待处理操作
setPendingAction(roomId, username, {
type: 'delete',
data: { itemName, purchaseIds: purchases.map(p => p.id) }