完善字段信息
This commit is contained in:
37
server/db.js
37
server/db.js
@@ -2,7 +2,6 @@ const Database = require('better-sqlite3');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// 确保数据目录存在
|
|
||||||
const dataDir = '/app/data';
|
const dataDir = '/app/data';
|
||||||
if (!fs.existsSync(dataDir)) {
|
if (!fs.existsSync(dataDir)) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
@@ -10,12 +9,9 @@ if (!fs.existsSync(dataDir)) {
|
|||||||
|
|
||||||
const dbPath = path.join(dataDir, 'xiaocai.db');
|
const dbPath = path.join(dataDir, 'xiaocai.db');
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
|
||||||
// 启用 WAL 模式提升并发
|
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// 创建表
|
// 创建基础表(如果不存在)
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
username TEXT PRIMARY KEY,
|
username TEXT PRIMARY KEY,
|
||||||
@@ -27,7 +23,7 @@ db.exec(`
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
created_by TEXT NOT NULL,
|
created_by TEXT NOT NULL,
|
||||||
white_list TEXT DEFAULT '', -- 逗号分隔用户名,空表示所有人
|
white_list TEXT DEFAULT '',
|
||||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -36,7 +32,7 @@ db.exec(`
|
|||||||
room_id TEXT NOT NULL,
|
room_id TEXT NOT NULL,
|
||||||
user TEXT NOT NULL,
|
user TEXT NOT NULL,
|
||||||
text TEXT,
|
text TEXT,
|
||||||
attachments TEXT, -- JSON array of file paths
|
attachments TEXT,
|
||||||
timestamp TEXT DEFAULT (datetime('now','localtime')),
|
timestamp TEXT DEFAULT (datetime('now','localtime')),
|
||||||
FOREIGN KEY(room_id) REFERENCES rooms(id)
|
FOREIGN KEY(room_id) REFERENCES rooms(id)
|
||||||
);
|
);
|
||||||
@@ -45,13 +41,17 @@ db.exec(`
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
room_id TEXT NOT NULL,
|
room_id TEXT NOT NULL,
|
||||||
item TEXT,
|
item TEXT,
|
||||||
amount REAL,
|
quantity INTEGER DEFAULT 1,
|
||||||
method TEXT,
|
unit_price REAL DEFAULT 0,
|
||||||
|
amount REAL DEFAULT 0,
|
||||||
|
freight REAL DEFAULT 0,
|
||||||
|
payment_method TEXT DEFAULT '未指定',
|
||||||
|
invoice_type TEXT DEFAULT '无票',
|
||||||
status TEXT DEFAULT '待处理',
|
status TEXT DEFAULT '待处理',
|
||||||
applicant TEXT,
|
applicant TEXT,
|
||||||
|
remarks TEXT DEFAULT '',
|
||||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
updated_at TEXT DEFAULT (datetime('now','localtime')),
|
updated_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
ai_notes TEXT,
|
|
||||||
FOREIGN KEY(room_id) REFERENCES rooms(id)
|
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;
|
module.exports = db;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
.purchase-item .item-main { display: flex; justify-content: space-between; align-items: baseline; }
|
.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-name { font-size: 16px; font-weight: 500; }
|
||||||
.purchase-item .item-amount { font-size: 16px; font-weight: 600; }
|
.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 { background: #fef3c7; padding: 1px 6px; border-radius: 8px; font-size: 12px; }
|
||||||
.status-badge.done { background: #d1fae5; }
|
.status-badge.done { background: #d1fae5; }
|
||||||
|
|
||||||
@@ -99,7 +99,6 @@
|
|||||||
|
|
||||||
.version { text-align: center; font-size: 12px; color: #aaa; margin-top: 16px; }
|
.version { text-align: center; font-size: 12px; color: #aaa; margin-top: 16px; }
|
||||||
|
|
||||||
/* 全屏预览弹窗 */
|
|
||||||
.fullscreen-overlay {
|
.fullscreen-overlay {
|
||||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
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;
|
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;
|
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; }
|
.fullscreen-overlay .fullscreen-text img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
|
||||||
|
|
||||||
|
.detail-row { margin-bottom: 8px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
<input type="password" id="login-pass" placeholder="密码">
|
<input type="password" id="login-pass" placeholder="密码">
|
||||||
<button class="primary-btn" onclick="login()" style="width:100%;">登录</button>
|
<button class="primary-btn" onclick="login()" style="width:100%;">登录</button>
|
||||||
<p id="login-error" style="color:red; margin-top:8px;"></p>
|
<p id="login-error" style="color:red; margin-top:8px;"></p>
|
||||||
<div class="version">v2.2</div>
|
<div class="version">v2.4</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -332,14 +333,11 @@
|
|||||||
|
|
||||||
function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
|
function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
|
||||||
|
|
||||||
// 双击全屏相关
|
|
||||||
function bindMessageEvents() {
|
function bindMessageEvents() {
|
||||||
// 图片双击全屏
|
|
||||||
document.querySelectorAll('.msg-img').forEach(img => {
|
document.querySelectorAll('.msg-img').forEach(img => {
|
||||||
img.removeEventListener('dblclick', onImageDblClick);
|
img.removeEventListener('dblclick', onImageDblClick);
|
||||||
img.addEventListener('dblclick', onImageDblClick);
|
img.addEventListener('dblclick', onImageDblClick);
|
||||||
});
|
});
|
||||||
// 气泡双击全屏
|
|
||||||
document.querySelectorAll('.msg-bubble').forEach(bubble => {
|
document.querySelectorAll('.msg-bubble').forEach(bubble => {
|
||||||
bubble.removeEventListener('dblclick', onBubbleDblClick);
|
bubble.removeEventListener('dblclick', onBubbleDblClick);
|
||||||
bubble.addEventListener('dblclick', onBubbleDblClick);
|
bubble.addEventListener('dblclick', onBubbleDblClick);
|
||||||
@@ -348,10 +346,9 @@
|
|||||||
|
|
||||||
function onImageDblClick(e) {
|
function onImageDblClick(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const imgSrc = e.target.src;
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'fullscreen-overlay';
|
overlay.className = 'fullscreen-overlay';
|
||||||
overlay.innerHTML = `<img src="${imgSrc}" alt="">`;
|
overlay.innerHTML = `<img src="${e.target.src}" alt="">`;
|
||||||
overlay.addEventListener('click', () => overlay.remove());
|
overlay.addEventListener('click', () => overlay.remove());
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
@@ -359,9 +356,7 @@
|
|||||||
function onBubbleDblClick(e) {
|
function onBubbleDblClick(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const bubble = e.currentTarget;
|
const bubble = e.currentTarget;
|
||||||
// 克隆气泡内容,去掉事件
|
|
||||||
const clone = bubble.cloneNode(true);
|
const clone = bubble.cloneNode(true);
|
||||||
// 移除可能有的链接行为
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'fullscreen-overlay';
|
overlay.className = 'fullscreen-overlay';
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
@@ -488,12 +483,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修正滑动手势方向
|
|
||||||
function initSwipeGestures() {
|
function initSwipeGestures() {
|
||||||
const chatPage = document.getElementById('chat-page');
|
const chatPage = document.getElementById('chat-page');
|
||||||
const purchaseOverlay = document.getElementById('purchase-panel-overlay');
|
const purchaseOverlay = document.getElementById('purchase-panel-overlay');
|
||||||
let startX = 0;
|
let startX = 0, startY = 0;
|
||||||
let startY = 0;
|
|
||||||
|
|
||||||
function handleTouchStart(e) {
|
function handleTouchStart(e) {
|
||||||
startX = e.touches[0].clientX;
|
startX = e.touches[0].clientX;
|
||||||
@@ -508,22 +501,12 @@
|
|||||||
const diffY = endY - startY;
|
const diffY = endY - startY;
|
||||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||||
if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) {
|
if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) {
|
||||||
// 采购清单打开时,任意水平滑动都关闭
|
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||||
if (Math.abs(diffX) > 30) {
|
|
||||||
closePurchasePanel();
|
|
||||||
}
|
|
||||||
} else if (target === chatPage) {
|
} else if (target === chatPage) {
|
||||||
// 右滑返回列表,左滑打开采购清单
|
if (diffX > 30) showList();
|
||||||
if (diffX > 30) {
|
else if (diffX < -30) openPurchasePanel();
|
||||||
showList();
|
|
||||||
} else if (diffX < -30) {
|
|
||||||
openPurchasePanel();
|
|
||||||
}
|
|
||||||
} else if (target === purchaseOverlay) {
|
} else if (target === purchaseOverlay) {
|
||||||
// 采购清单上任意滑动都关闭
|
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||||
if (Math.abs(diffX) > 30) {
|
|
||||||
closePurchasePanel();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
startX = 0; startY = 0;
|
startX = 0; startY = 0;
|
||||||
@@ -681,7 +664,11 @@
|
|||||||
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"><span>${dateShort} ${timeShort}</span><span>${item.applicant || ''} / ${item.method || ''}</span><span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</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>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
@@ -710,7 +697,20 @@
|
|||||||
let attachHtml = '';
|
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}">`).join('') + '</div>';
|
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>';
|
||||||
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 = `<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-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');
|
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'); }
|
||||||
|
|||||||
144
server/server.js
144
server/server.js
@@ -22,11 +22,14 @@ 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';
|
const TIMEZONE = 'Asia/Shanghai';
|
||||||
function timestamp() {
|
function timestamp() {
|
||||||
return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false });
|
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 (?, ?, ?)');
|
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 });
|
res.json({ id, name });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新群聊信息(管理员)
|
|
||||||
app.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
|
app.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
|
||||||
const { name, whiteList } = req.body;
|
const { name, whiteList } = req.body;
|
||||||
const roomId = req.params.roomId;
|
const roomId = req.params.roomId;
|
||||||
@@ -124,7 +126,6 @@ app.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 删除群聊(管理员)
|
|
||||||
app.delete('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
|
app.delete('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
|
||||||
const roomId = req.params.roomId;
|
const roomId = req.params.roomId;
|
||||||
db.prepare('DELETE FROM messages WHERE room_id = ?').run(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);
|
res.json(msgs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 发送消息(统一入口,异步触发 AI)
|
|
||||||
app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
|
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;
|
||||||
@@ -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 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, {
|
broadcastToRoomExcludeSelf(roomId, username, {
|
||||||
type: 'new_message',
|
type: 'new_message',
|
||||||
message: msg,
|
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 || '图片'}`);
|
addToHistory(roomId, 'user', `${username}: ${text || '图片'}`);
|
||||||
res.json(msg);
|
res.json(msg);
|
||||||
|
|
||||||
// 检查是否有待确认操作且用户回复了“确认”
|
|
||||||
if (text && text.trim() === '确认' && hasPendingAction(roomId, username)) {
|
if (text && text.trim() === '确认' && hasPendingAction(roomId, username)) {
|
||||||
executePendingAction(roomId, username);
|
executePendingAction(roomId, username);
|
||||||
return;
|
return;
|
||||||
@@ -228,9 +221,9 @@ app.get('/api/summary', auth, (req, res) => {
|
|||||||
|
|
||||||
app.get('/api/rooms/:roomId/purchases/export', 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);
|
const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ?').all(req.params.roomId);
|
||||||
let csv = '时间,事项,金额,付款方式,状态,申请人\n';
|
let csv = '时间,事项,数量,单价,金额,邮费,付款方式,发票类型,状态,申请人,备注\n';
|
||||||
purchases.forEach(p => {
|
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-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="purchases-${req.params.roomId}.csv"`);
|
res.setHeader('Content-Disposition', `attachment; filename="purchases-${req.params.roomId}.csv"`);
|
||||||
@@ -310,7 +303,7 @@ function storeAndBroadcastText(roomId, user, text) {
|
|||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 待处理操作管理 ----------
|
// 待处理操作
|
||||||
const pendingActions = new Map();
|
const pendingActions = new Map();
|
||||||
function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); }
|
function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); }
|
||||||
function setPendingAction(roomId, username, action) { pendingActions.set(`${roomId}:${username}`, action); }
|
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; }
|
} catch (e) { 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, []); return conversationHistory.get(roomId); }
|
||||||
if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []);
|
function addToHistory(roomId, role, content) { const history = getHistory(roomId); history.push({ role, content }); if (history.length > 60) conversationHistory.set(roomId, history.slice(-40)); }
|
||||||
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) {
|
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="query"
|
||||||
- 聊天:action="chat",reply简短回复
|
- 聊天:action="chat",reply简短回复
|
||||||
- 删除指定物品:action="delete",delete_item为物品名
|
- 删除指定物品:action="delete",delete_item为物品名
|
||||||
- 清空所有采购数据:action="clear_all"
|
- 清空所有采购数据:action="clear_all"
|
||||||
- 忽略:action="ignore"
|
- 忽略:action="ignore"
|
||||||
|
|
||||||
重要权限规则:
|
权限规则:删除和清空仅限管理员。当前用户${username},管理员状态:${isAdmin}。
|
||||||
- 删除和清空操作只有管理员可以执行。当前用户${username}的管理员状态:${isAdmin}。
|
采购规则:用户补充信息时更新已有记录。更新时请返回完整字段值,包括未变化的字段。
|
||||||
- 如果用户要求删除或清空,但当前用户不是管理员,你必须直接回复拒绝,返回action="chat",reply说明需要管理员权限。
|
如果用户要求修改时间,请生成正确的created_time字段。`;
|
||||||
- 如果用户是管理员,返回delete或clear_all action。
|
|
||||||
|
|
||||||
采购规则:
|
|
||||||
- 金额缺失=0,方式缺失="未指定",申请人缺失="${username}"。
|
|
||||||
- 状态:已付→已采购,发票→发票已收,否则待采购。
|
|
||||||
- 用户补充信息时更新已有记录。
|
|
||||||
- 时间提取:如果用户提到具体时间,设置created_time字段,格式yyyy/MM/dd HH:mm:ss。`;
|
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -405,10 +388,9 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除指定物品(管理员权限)
|
|
||||||
if (aiResult.action === 'delete') {
|
if (aiResult.action === 'delete') {
|
||||||
const itemName = aiResult.delete_item;
|
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) {
|
if (purchases.length === 0) {
|
||||||
storeAndBroadcastText(roomId, '小财', `没有找到与“${itemName}”相关的采购记录。`);
|
storeAndBroadcastText(roomId, '小财', `没有找到与“${itemName}”相关的采购记录。`);
|
||||||
addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`);
|
addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`);
|
||||||
@@ -421,14 +403,10 @@ 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', data: { itemName, purchaseIds: purchases.map(p => p.id) } });
|
||||||
type: 'delete',
|
|
||||||
data: { itemName, purchaseIds: purchases.map(p => p.id) }
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空所有采购数据(管理员权限)
|
|
||||||
if (aiResult.action === 'clear_all') {
|
if (aiResult.action === 'clear_all') {
|
||||||
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
|
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
@@ -447,25 +425,79 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
|
|||||||
const createdTime = aiResult.created_time || now;
|
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 purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成');
|
||||||
let replyText = '';
|
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) {
|
if (!purchase) {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
db.prepare('INSERT INTO purchases (id, room_id, item, amount, method, status, applicant, created_at) VALUES (?,?,?,?,?,?,?,?)').run(
|
const item = aiResult.purchase_item;
|
||||||
id, roomId, aiResult.purchase_item, aiResult.amount, aiResult.method, aiResult.status || '待处理', aiResult.applicant || username, createdTime
|
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);
|
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, `创建采购:${item},数量${quantity},金额¥${amount},状态${status}`, '小财', now);
|
||||||
replyText = `✅ 已记录采购:${aiResult.purchase_item},金额 ¥${aiResult.amount},状态 ${aiResult.status || '待处理'}`;
|
replyText = `✅ 已记录采购:${item},数量 ${quantity},金额 ¥${amount},状态 ${status}`;
|
||||||
purchase = { id };
|
purchase = { id };
|
||||||
} else {
|
} else {
|
||||||
db.prepare('UPDATE purchases SET amount = ?, method = ?, status = ?, updated_at = ? WHERE id = ?').run(
|
const old = purchase;
|
||||||
aiResult.amount, aiResult.method, aiResult.status, now, purchase.id
|
// 合并新值
|
||||||
);
|
const newVals = {
|
||||||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新采购信息(金额:${aiResult.amount},状态:${aiResult.status})`, username, now);
|
quantity: aiResult.quantity ?? old.quantity,
|
||||||
replyText = `🔄 已更新采购「${aiResult.purchase_item}」:金额 ¥${aiResult.amount},状态 ${aiResult.status}`;
|
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) {
|
if (attachments?.length) {
|
||||||
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
|
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));
|
attachments.forEach(fp => insertAttach.run(purchase.id, fp, username, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
storeAndBroadcastText(roomId, '小财', replyText);
|
storeAndBroadcastText(roomId, '小财', replyText);
|
||||||
addToHistory(roomId, 'assistant', replyText);
|
addToHistory(roomId, 'assistant', replyText);
|
||||||
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
||||||
@@ -473,8 +505,8 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (aiResult.action === 'query') {
|
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)
|
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.status}`).join('\n');
|
.map(p => `${p.item} ¥${p.amount} ${p.payment_method||''} ${p.status}`).join('\n');
|
||||||
const summaryPrompt = `根据以下采购记录,用自然语言回答用户查询“${originalText}”。采购记录:\n${purchaseData || '暂无记录'}`;
|
const summaryPrompt = `根据以下采购记录,用自然语言回答用户查询“${originalText}”。采购记录:\n${purchaseData || '暂无记录'}`;
|
||||||
callDeepSeekForSummary(summaryPrompt).then(reply => {
|
callDeepSeekForSummary(summaryPrompt).then(reply => {
|
||||||
storeAndBroadcastText(roomId, '小财', reply);
|
storeAndBroadcastText(roomId, '小财', reply);
|
||||||
|
|||||||
Reference in New Issue
Block a user