diff --git a/server/db.js b/server/db.js index 1fa596a..7a43a1b 100644 --- a/server/db.js +++ b/server/db.js @@ -2,7 +2,6 @@ const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); -// 确保数据目录存在 const dataDir = '/app/data'; if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); @@ -10,12 +9,9 @@ if (!fs.existsSync(dataDir)) { const dbPath = path.join(dataDir, 'xiaocai.db'); const db = new Database(dbPath); - - -// 启用 WAL 模式提升并发 db.pragma('journal_mode = WAL'); -// 创建表 +// 创建基础表(如果不存在) db.exec(` CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, @@ -27,7 +23,7 @@ db.exec(` id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, - white_list TEXT DEFAULT '', -- 逗号分隔用户名,空表示所有人 + white_list TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now','localtime')) ); @@ -36,7 +32,7 @@ db.exec(` room_id TEXT NOT NULL, user TEXT NOT NULL, text TEXT, - attachments TEXT, -- JSON array of file paths + attachments TEXT, timestamp TEXT DEFAULT (datetime('now','localtime')), FOREIGN KEY(room_id) REFERENCES rooms(id) ); @@ -45,13 +41,17 @@ db.exec(` id TEXT PRIMARY KEY, room_id TEXT NOT NULL, item TEXT, - amount REAL, - method TEXT, + quantity INTEGER DEFAULT 1, + unit_price REAL DEFAULT 0, + amount REAL DEFAULT 0, + freight REAL DEFAULT 0, + payment_method TEXT DEFAULT '未指定', + invoice_type TEXT DEFAULT '无票', status TEXT DEFAULT '待处理', applicant TEXT, + remarks TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now','localtime')), updated_at TEXT DEFAULT (datetime('now','localtime')), - ai_notes TEXT, FOREIGN KEY(room_id) REFERENCES rooms(id) ); @@ -75,4 +75,21 @@ db.exec(` ); `); +// 为已有数据库添加可能缺失的列(安全迁移,忽略错误) +const migrations = [ + `ALTER TABLE purchases ADD COLUMN quantity INTEGER DEFAULT 1`, + `ALTER TABLE purchases ADD COLUMN unit_price REAL DEFAULT 0`, + `ALTER TABLE purchases ADD COLUMN freight REAL DEFAULT 0`, + `ALTER TABLE purchases ADD COLUMN invoice_type TEXT DEFAULT '无票'`, + `ALTER TABLE purchases ADD COLUMN payment_method TEXT DEFAULT '未指定'`, + `ALTER TABLE purchases ADD COLUMN remarks TEXT DEFAULT ''`, +]; +for (const sql of migrations) { + try { + db.exec(sql); + } catch (e) { + // 列已存在或其他错误,忽略 + } +} + module.exports = db; diff --git a/server/public/index.html b/server/public/index.html index 848bd0f..437c29f 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -77,7 +77,7 @@ .purchase-item .item-main { display: flex; justify-content: space-between; align-items: baseline; } .purchase-item .item-name { font-size: 16px; font-weight: 500; } .purchase-item .item-amount { font-size: 16px; font-weight: 600; } - .purchase-item .item-meta { display: flex; justify-content: space-between; margin-top: 4px; font-size: 13px; color: #888; } + .purchase-item .item-meta { display: flex; justify-content: space-between; margin-top: 4px; font-size: 13px; color: #888; flex-wrap: wrap; gap: 4px; } .status-badge { background: #fef3c7; padding: 1px 6px; border-radius: 8px; font-size: 12px; } .status-badge.done { background: #d1fae5; } @@ -99,7 +99,6 @@ .version { text-align: center; font-size: 12px; color: #aaa; margin-top: 16px; } - /* 全屏预览弹窗 */ .fullscreen-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center; @@ -111,6 +110,8 @@ overflow: auto; font-size: 16px; line-height: 1.5; color: #333; } .fullscreen-overlay .fullscreen-text img { max-width: 200px; border-radius: 8px; margin: 4px 0; } + + .detail-row { margin-bottom: 8px; } @@ -123,7 +124,7 @@

-
v2.2
+
v2.4
@@ -332,14 +333,11 @@ 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); @@ -348,10 +346,9 @@ function onImageDblClick(e) { e.stopPropagation(); - const imgSrc = e.target.src; const overlay = document.createElement('div'); overlay.className = 'fullscreen-overlay'; - overlay.innerHTML = ``; + overlay.innerHTML = ``; overlay.addEventListener('click', () => overlay.remove()); document.body.appendChild(overlay); } @@ -359,9 +356,7 @@ 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'); @@ -488,12 +483,10 @@ } } - // 修正滑动手势方向 function initSwipeGestures() { const chatPage = document.getElementById('chat-page'); const purchaseOverlay = document.getElementById('purchase-panel-overlay'); - let startX = 0; - let startY = 0; + let startX = 0, startY = 0; function handleTouchStart(e) { startX = e.touches[0].clientX; @@ -508,22 +501,12 @@ 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(); - } + if (Math.abs(diffX) > 30) closePurchasePanel(); } else if (target === chatPage) { - // 右滑返回列表,左滑打开采购清单 - if (diffX > 30) { - showList(); - } else if (diffX < -30) { - openPurchasePanel(); - } + if (diffX > 30) showList(); + else if (diffX < -30) openPurchasePanel(); } else if (target === purchaseOverlay) { - // 采购清单上任意滑动都关闭 - if (Math.abs(diffX) > 30) { - closePurchasePanel(); - } + if (Math.abs(diffX) > 30) closePurchasePanel(); } } startX = 0; startY = 0; @@ -681,7 +664,11 @@ html += `
${item.item}¥${item.amount}
-
${dateShort} ${timeShort}${item.applicant || ''} / ${item.method || ''}${item.status}
+
+ ${dateShort} ${timeShort} + ×${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''} + ${item.status} +
`; }); html += ''; @@ -710,7 +697,20 @@ let attachHtml = ''; if (p.attachments?.length) attachHtml = '
' + p.attachments.map(a => `${a.file_path}`).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.quantity}
    +
    单价:¥${p.unit_price}
    +
    邮费:¥${p.freight}
    +
    总金额:¥${p.amount}
    +
    付款方式:${p.payment_method || '未指定'}
    +
    发票:${p.invoice_type || '无票'}
    +
    状态:${p.status}
    +
    申请人:${p.applicant}
    +
    采购时间:${p.created_at}
    +
    备注:${p.remarks || '无'}
    + ${attachHtml} +

    操作历史

    + `; document.getElementById('detail-modal').classList.remove('hidden'); } function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); } diff --git a/server/server.js b/server/server.js index ea149ff..abe7e74 100644 --- a/server/server.js +++ b/server/server.js @@ -22,11 +22,14 @@ 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 }); } +function currentTimeStr() { + return timestamp(); +} // 初始化用户 const insertUser = db.prepare('INSERT OR IGNORE INTO users (username, password, is_admin) VALUES (?, ?, ?)'); @@ -113,7 +116,6 @@ app.post('/api/rooms', auth, adminOnly, (req, res) => { res.json({ id, name }); }); -// 更新群聊信息(管理员) app.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => { const { name, whiteList } = req.body; const roomId = req.params.roomId; @@ -124,7 +126,6 @@ app.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => { 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); @@ -141,7 +142,6 @@ app.get('/api/rooms/:roomId/messages', auth, (req, res) => { res.json(msgs); }); -// 发送消息(统一入口,异步触发 AI) app.post('/api/rooms/:roomId/messages', auth, async (req, res) => { const { text, attachments } = req.body; const roomId = req.params.roomId; @@ -161,22 +161,15 @@ app.post('/api/rooms/:roomId/messages', auth, async (req, res) => { }; const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId); - const preview = { - room_id: roomId, - last_message: lastMsg ? lastMsg.text : '', - last_time: lastMsg ? lastMsg.timestamp : '' - }; - broadcastToRoomExcludeSelf(roomId, username, { type: 'new_message', message: msg, - room_preview: preview + room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' } }); addToHistory(roomId, 'user', `${username}: ${text || '图片'}`); res.json(msg); - // 检查是否有待确认操作且用户回复了“确认” if (text && text.trim() === '确认' && hasPendingAction(roomId, username)) { executePendingAction(roomId, username); return; @@ -228,9 +221,9 @@ app.get('/api/summary', auth, (req, res) => { app.get('/api/rooms/:roomId/purchases/export', auth, (req, res) => { const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ?').all(req.params.roomId); - let csv = '时间,事项,金额,付款方式,状态,申请人\n'; + let csv = '时间,事项,数量,单价,金额,邮费,付款方式,发票类型,状态,申请人,备注\n'; purchases.forEach(p => { - csv += `${p.created_at},"${p.item||''}",${p.amount},"${p.method||''}","${p.status}","${p.applicant}"\n`; + csv += `${p.created_at},"${p.item||''}",${p.quantity},${p.unit_price},${p.amount},${p.freight},"${p.payment_method||''}","${p.invoice_type||''}","${p.status}","${p.applicant}","${p.remarks||''}"\n`; }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="purchases-${req.params.roomId}.csv"`); @@ -310,7 +303,7 @@ function storeAndBroadcastText(roomId, user, text) { return msg; } -// ---------- 待处理操作管理 ---------- +// 待处理操作 const pendingActions = new Map(); function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); } function setPendingAction(roomId, username, action) { pendingActions.set(`${roomId}:${username}`, action); } @@ -343,40 +336,30 @@ function executePendingAction(roomId, username) { } catch (e) { console.error('执行待处理操作失败:', e); storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; } } -// ---------- 对话历史 ---------- +// 对话历史 const conversationHistory = new Map(); -function getHistory(roomId) { - if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); - return conversationHistory.get(roomId); -} -function addToHistory(roomId, role, content) { - const history = getHistory(roomId); - history.push({ role, content }); - if (history.length > 60) conversationHistory.set(roomId, history.slice(-40)); -} +function getHistory(roomId) { if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); return conversationHistory.get(roomId); } +function addToHistory(roomId, role, content) { const history = getHistory(roomId); history.push({ role, content }); if (history.length > 60) conversationHistory.set(roomId, history.slice(-40)); } -// ---------- AI 函数 ---------- +// AI 分析 async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) { - const systemPrompt = `你是智能财务助手“小财”。结合对话历史理解用户意图。只返回JSON。 + const systemPrompt = `你是智能财务助手“小财”。当前服务器时间:${currentTimeStr()}。结合对话历史理解用户意图,只返回JSON。 意图分类: -- 采购/付款/发票相关:action="purchase",提取purchase_item, amount, method, status, applicant, created_time(可选,根据聊天中的时间推断,格式yyyy/MM/dd HH:mm:ss) +- 采购/付款/发票相关:action="purchase",提取以下字段(所有字段可选,缺失则不返回): + purchase_item (物品名称), quantity (数量,默认1), unit_price (单价,默认0), amount (总金额,默认0), + freight (邮费,默认0), payment_method (付款方式:支付宝/微信/对公转账/现金等,淘宝购买默认支付宝), + invoice_type (发票类型:增票/普票/无票,默认无票), status (待采购/已采购/发票已收), applicant (申请人), + remarks (备注,重要信息如“下次换商家”“缺少配件”等), created_time (如果用户提到具体时间则按yyyy/MM/dd HH:mm:ss格式输出) - 查询汇总:action="query" - 聊天:action="chat",reply简短回复 - 删除指定物品:action="delete",delete_item为物品名 - 清空所有采购数据:action="clear_all" - 忽略:action="ignore" -重要权限规则: -- 删除和清空操作只有管理员可以执行。当前用户${username}的管理员状态:${isAdmin}。 -- 如果用户要求删除或清空,但当前用户不是管理员,你必须直接回复拒绝,返回action="chat",reply说明需要管理员权限。 -- 如果用户是管理员,返回delete或clear_all action。 - -采购规则: -- 金额缺失=0,方式缺失="未指定",申请人缺失="${username}"。 -- 状态:已付→已采购,发票→发票已收,否则待采购。 -- 用户补充信息时更新已有记录。 -- 时间提取:如果用户提到具体时间,设置created_time字段,格式yyyy/MM/dd HH:mm:ss。`; +权限规则:删除和清空仅限管理员。当前用户${username},管理员状态:${isAdmin}。 +采购规则:用户补充信息时更新已有记录。更新时请返回完整字段值,包括未变化的字段。 +如果用户要求修改时间,请生成正确的created_time字段。`; const messages = [ { role: 'system', content: systemPrompt }, @@ -405,10 +388,9 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) { return; } - // 删除指定物品(管理员权限) if (aiResult.action === 'delete') { const itemName = aiResult.delete_item; - const purchases = db.prepare('SELECT id, item, amount, method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?').all(roomId, `%${itemName}%`); + const purchases = db.prepare('SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?').all(roomId, `%${itemName}%`); if (purchases.length === 0) { storeAndBroadcastText(roomId, '小财', `没有找到与“${itemName}”相关的采购记录。`); addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`); @@ -421,14 +403,10 @@ 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) } - }); + setPendingAction(roomId, username, { type: 'delete', data: { itemName, purchaseIds: purchases.map(p => p.id) } }); return; } - // 清空所有采购数据(管理员权限) if (aiResult.action === 'clear_all') { const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count; if (count === 0) { @@ -447,25 +425,79 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) { const createdTime = aiResult.created_time || now; let purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成'); let replyText = ''; + + // 计算总金额:优先使用 AI 提供的 amount,否则根据单价*数量+邮费计算 + const quantity = aiResult.quantity || 1; + const unitPrice = aiResult.unit_price || 0; + const freight = aiResult.freight || 0; + let amount; + if (aiResult.amount !== undefined && aiResult.amount !== null) { + amount = aiResult.amount; + } else { + amount = unitPrice * quantity + freight; + } + if (!purchase) { const id = uuidv4(); - db.prepare('INSERT INTO purchases (id, room_id, item, amount, method, status, applicant, created_at) VALUES (?,?,?,?,?,?,?,?)').run( - id, roomId, aiResult.purchase_item, aiResult.amount, aiResult.method, aiResult.status || '待处理', aiResult.applicant || username, createdTime + const item = aiResult.purchase_item; + const paymentMethod = aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || '未指定')); + const invoiceType = aiResult.invoice_type || '无票'; + const status = aiResult.status || '待处理'; + const applicant = aiResult.applicant || username; + const remarks = aiResult.remarks || ''; + + db.prepare(`INSERT INTO purchases (id, room_id, item, quantity, unit_price, amount, freight, payment_method, invoice_type, status, applicant, remarks, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + id, roomId, item, quantity, unitPrice, amount, freight, paymentMethod, invoiceType, status, applicant, remarks, createdTime ); - db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, 'AI 创建采购条目', '小财', now); - replyText = `✅ 已记录采购:${aiResult.purchase_item},金额 ¥${aiResult.amount},状态 ${aiResult.status || '待处理'}`; + db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, `创建采购:${item},数量${quantity},金额¥${amount},状态${status}`, '小财', now); + replyText = `✅ 已记录采购:${item},数量 ${quantity},金额 ¥${amount},状态 ${status}`; purchase = { id }; } else { - db.prepare('UPDATE purchases SET amount = ?, method = ?, status = ?, updated_at = ? WHERE id = ?').run( - aiResult.amount, aiResult.method, aiResult.status, now, purchase.id - ); - db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新采购信息(金额:${aiResult.amount},状态:${aiResult.status})`, username, now); - replyText = `🔄 已更新采购「${aiResult.purchase_item}」:金额 ¥${aiResult.amount},状态 ${aiResult.status}`; + const old = purchase; + // 合并新值 + const newVals = { + quantity: aiResult.quantity ?? old.quantity, + unit_price: aiResult.unit_price ?? old.unit_price, + freight: aiResult.freight ?? old.freight, + payment_method: aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || old.payment_method)), + invoice_type: aiResult.invoice_type || old.invoice_type, + status: aiResult.status || old.status, + applicant: aiResult.applicant || old.applicant, + remarks: aiResult.remarks !== undefined ? aiResult.remarks : old.remarks, + created_at: aiResult.created_time || old.created_at + }; + // 重新计算金额,除非 AI 明确给了 amount + if (aiResult.amount !== undefined && aiResult.amount !== null) { + newVals.amount = aiResult.amount; + } else { + newVals.amount = newVals.unit_price * newVals.quantity + newVals.freight; + } + + const historyChanges = []; + let changed = false; + for (const [field, newVal] of Object.entries(newVals)) { + if (String(newVal) !== String(old[field])) { + changed = true; + historyChanges.push(`${field}: ${old[field]} → ${newVal}`); + db.prepare(`UPDATE purchases SET ${field} = ? WHERE id = ?`).run(newVal, purchase.id); + } + } + + if (changed) { + db.prepare('UPDATE purchases SET updated_at = ? WHERE id = ?').run(now, purchase.id); + db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新:${historyChanges.join(';')}`, username, now); + replyText = `🔄 已更新「${aiResult.purchase_item}」:${historyChanges.join(',')}`; + } else { + replyText = `ℹ️ 采购「${aiResult.purchase_item}」的信息没有发生变化。`; + } } + if (attachments?.length) { const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)'); attachments.forEach(fp => insertAttach.run(purchase.id, fp, username, now)); } + storeAndBroadcastText(roomId, '小财', replyText); addToHistory(roomId, 'assistant', replyText); broadcastToRoom(roomId, { type: 'purchase_updated' }); @@ -473,8 +505,8 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) { } if (aiResult.action === 'query') { - const purchaseData = db.prepare('SELECT item, amount, method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId) - .map(p => `${p.item} ¥${p.amount} ${p.status}`).join('\n'); + const purchaseData = db.prepare('SELECT item, amount, payment_method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId) + .map(p => `${p.item} ¥${p.amount} ${p.payment_method||''} ${p.status}`).join('\n'); const summaryPrompt = `根据以下采购记录,用自然语言回答用户查询“${originalText}”。采购记录:\n${purchaseData || '暂无记录'}`; callDeepSeekForSummary(summaryPrompt).then(reply => { storeAndBroadcastText(roomId, '小财', reply);