普通用户也支持新建群聊

This commit is contained in:
2026-07-28 19:02:23 +08:00
parent d4a512bc6e
commit 2e9da6c1ac
6 changed files with 103 additions and 174 deletions

View File

@@ -11,9 +11,9 @@ function addToHistory(roomId, role, content) { const h = getHistory(roomId); h.p
// 待处理操作
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 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 storeAndBroadcastText(roomId, user, text) {
const now = timestamp();
@@ -25,7 +25,7 @@ function storeAndBroadcastText(roomId, user, text) {
}
function executePendingAction(roomId, username) {
const action = pendingActions.get(`${roomId}:${username}`);
const action = pendingActions.get(roomId + ':' + username);
if (!action) return false;
clearPendingAction(roomId, username);
const now = timestamp();
@@ -36,26 +36,26 @@ function executePendingAction(roomId, username) {
const delH = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
const delP = db.prepare('DELETE FROM purchases WHERE id = ?');
for (const id of purchaseIds) { delA.run(id); delH.run(id); delP.run(id); }
const reply = `✅ 已删除采购记录:${itemName}(共 ${purchaseIds.length} 条)`;
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
const reply = '\u2705 \u5df2\u5220\u9664\u91c7\u8d2d\u8bb0\u5f55\uff1a' + itemName + '\uff08\u5171 ' + purchaseIds.length + ' \u6761\uff09';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply);
} else if (action.type === 'clear') {
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);
const reply = '✅ 已清空当前房间的所有采购数据。';
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
const reply = '\u2705 \u5df2\u6e05\u7a7a\u5f53\u524d\u623f\u95f4\u7684\u6240\u6709\u91c7\u8d2d\u6570\u636e\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply);
}
broadcastToRoom(roomId, { type: 'purchase_updated' });
return true;
} catch (e) { storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
} catch (e) { storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u274c \u64cd\u4f5c\u6267\u884c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002'); return false; }
}
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
const now = timestamp();
if (aiResult.action === 'ask') {
const q = aiResult.question || aiResult.reply || '请提供更多信息。';
storeAndBroadcastText(roomId, '小财', q); addToHistory(roomId, 'assistant', q); return;
const q = aiResult.question || aiResult.reply || '\u8bf7\u63d0\u4f9b\u66f4\u591a\u4fe1\u606f\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', q); addToHistory(roomId, 'assistant', q); return;
}
if (aiResult.action === 'delete') {
@@ -67,21 +67,20 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
purchases = db.prepare("SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND (item IS NULL OR item = '' OR item = 'null' OR item = 'undefined')").all(roomId);
} else {
let query = 'SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?';
const params = [roomId, `%${itemName}%`];
const params = [roomId, '%' + itemName + '%'];
if (aiResult.quantity !== undefined) { query += ' AND quantity = ?'; params.push(aiResult.quantity); }
if (aiResult.amount !== undefined) { query += ' AND amount = ?'; params.push(aiResult.amount); }
console.log('🔍 删除查询:', query, params);
purchases = db.prepare(query).all(...params);
}
if (purchases.length === 0) {
const label = itemName === '__AMOUNT_ZERO__' ? '金额为0' : (itemName === '__NULL_NAME__' ? '名称为空' : `"${itemName}"`);
storeAndBroadcastText(roomId, '小财', `没有找到与"${label}"相关的采购记录。`);
const label = itemName === '__AMOUNT_ZERO__' ? '\u91d1\u989d\u4e3a0' : (itemName === '__NULL_NAME__' ? '\u540d\u79f0\u4e3a\u7a7a' : '"' + itemName + '"');
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u6ca1\u6709\u627e\u5230\u4e0e"' + label + '"\u76f8\u5173\u7684\u91c7\u8d2d\u8bb0\u5f55\u3002');
return;
}
let confirmText = `⚠️ 即将删除以下 ${purchases.length} 条采购记录,请回复"确认"继续:\n\n`;
purchases.forEach(p => confirmText += `${p.item} | ¥${p.amount} | ${p.status} | ${p.applicant} | ${p.created_at}\n`);
confirmText += `\n如果不删除,请忽略此消息。`;
storeAndBroadcastText(roomId, '小财', confirmText);
let confirmText = '\u26a0\ufe0f \u5373\u5c06\u5220\u9664\u4ee5\u4e0b ' + purchases.length + ' \u6761\u91c7\u8d2d\u8bb0\u5f55\uff0c\u8bf7\u56de\u590d"\u786e\u8ba4"\u7ee7\u7eed\uff1a\n\n';
purchases.forEach(p => confirmText += '\u2022 ' + p.item + ' | ' + p.amount + ' | ' + p.status + ' | ' + p.applicant + ' | ' + p.created_at + '\n');
confirmText += '\n\u5982\u679c\u4e0d\u5220\u9664\uff0c\u8bf7\u5ffd\u7565\u6b64\u6d88\u606f\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', confirmText);
addToHistory(roomId, 'assistant', confirmText);
setPendingAction(roomId, username, { type: 'delete', data: { itemName, purchaseIds: purchases.map(p => p.id) } });
return;
@@ -89,84 +88,43 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
if (aiResult.action === 'clear_all') {
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
if (count === 0) { storeAndBroadcastText(roomId, '小财', '当前房间没有采购记录,无需清空。'); return; }
storeAndBroadcastText(roomId, '小财', `⚠️ 即将清空当前房间的 ${count} 条采购数据,请回复"确认"继续,否则忽略。`);
addToHistory(roomId, 'assistant', `请求清空${count}条记录`);
if (count === 0) { storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u5f53\u524d\u623f\u95f4\u6ca1\u6709\u91c7\u8d2d\u8bb0\u5f55\uff0c\u65e0\u9700\u6e05\u7a7a\u3002'); return; }
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u26a0\ufe0f \u5373\u5c06\u6e05\u7a7a\u5f53\u524d\u623f\u95f4\u7684 ' + count + ' \u6761\u91c7\u8d2d\u6570\u636e\uff0c\u8bf7\u56de\u590d"\u786e\u8ba4"\u7ee7\u7eed\uff0c\u5426\u5219\u5ffd\u7565\u3002');
addToHistory(roomId, 'assistant', '\u8bf7\u6c42\u6e05\u7a7a' + count + '\u6761\u8bb0\u5f55');
setPendingAction(roomId, username, { type: 'clear', data: {} });
return;
}
if (aiResult.action === 'purchase') {
// use_recent_image 但缺 purchase_item → 从房间最近一条采购推断
// use_recent_image 但缺 purchase_item
if (aiResult.use_recent_image && (!aiResult.purchase_item || aiResult.purchase_item === 'null')) {
const lastPurchase = db.prepare("SELECT item FROM purchases WHERE room_id = ? AND item IS NOT NULL AND item != '' ORDER BY created_at DESC LIMIT 1").get(roomId);
if (lastPurchase) {
console.log('🛡️ use_recent_image 缺 purchase_item推断为:', lastPurchase.item);
aiResult.purchase_item = lastPurchase.item;
}
if (lastPurchase) { aiResult.purchase_item = lastPurchase.item; }
}
const item = aiResult.purchase_item;
if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) {
console.log('🚫 拒绝无效 purchase_item:', JSON.stringify(item));
storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。');
addToHistory(roomId, 'assistant', '请提供具体的物品名称。');
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u8bf7\u63d0\u4f9b\u5177\u4f53\u7684\u7269\u54c1\u540d\u79f0\u3002');
addToHistory(roomId, 'assistant', '\u8bf7\u63d0\u4f9b\u5177\u4f53\u7684\u7269\u54c1\u540d\u79f0\u3002');
return;
}
console.log('🛒 purchase 分支, item:', item, 'attachments:', JSON.stringify(attachments));
let usedRecentImage = false;
if (aiResult.use_recent_image) {
console.log('🔍 use_recent_image: 查找最近图片消息, roomId=', roomId);
const recentMsg = db.prepare("SELECT attachments FROM messages WHERE room_id = ? AND attachments IS NOT NULL AND attachments != '' AND attachments != '[]' ORDER BY id DESC LIMIT 1").get(roomId);
if (recentMsg) {
try {
const files = JSON.parse(recentMsg.attachments);
if (files.length) { attachments = files; usedRecentImage = true; console.log('✅ 引用图片:', files.length, '个'); }
} catch(e) { console.error('❌ 解析附件失败:', e); }
try { const files = JSON.parse(recentMsg.attachments); if (files.length) { attachments = files; usedRecentImage = true; } } catch(e) {}
}
}
// 安全网:消息提到图片/附件但没带附件 → AI 忘设 use_recent_image自动补
if (!usedRecentImage && !attachments?.length && /(图片|附件|加到|存入|关联|作为附件)/.test(originalText)) {
console.log('🔍 安全网: 文本提及附件但无附件,自动尝试 use_recent_image');
if (!usedRecentImage && !attachments?.length && /(\u56fe\u7247|\u9644\u4ef6|\u52a0\u5230|\u5b58\u5165|\u5173\u8054|\u4f5c\u4e3a\u9644\u4ef6)/.test(originalText)) {
const recentMsg = db.prepare("SELECT attachments FROM messages WHERE room_id = ? AND attachments IS NOT NULL AND attachments != '' AND attachments != '[]' ORDER BY id DESC LIMIT 1").get(roomId);
if (recentMsg) {
try {
const files = JSON.parse(recentMsg.attachments);
if (files.length) { attachments = files; usedRecentImage = true; console.log('✅ 安全网引用图片:', files.length, '个'); }
} catch(e) {}
try { const files = JSON.parse(recentMsg.attachments); if (files.length) { attachments = files; usedRecentImage = true; } } catch(e) {}
}
}
const createdTime = normalizeTime(aiResult.created_time);
let isNew = aiResult.is_new === true;
let purchase;
if (isNew && attachments?.length > 0) {
const existing = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${aiResult.purchase_item}%`);
if (existing) { console.log('🛡️ 兜底纠正: 转为更新模式'); isNew = false; purchase = existing; }
}
if (!isNew && !purchase) {
purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${aiResult.purchase_item}%`);
// 全名匹配失败 → 去掉括号内容后用剩余关键词回退匹配
if (!purchase) {
const keyword = aiResult.purchase_item.replace(/[(].*[)]/g, '').trim();
if (keyword.length >= 2) {
console.log('🔍 全名不匹配,尝试关键词:', keyword);
purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${keyword}%`);
}
}
// 所有 LIKE 失败 → 回退到房间内最近一条采购
if (!purchase) {
console.log('🔍 LIKE 全失败,回退到最近记录');
purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? ORDER BY created_at DESC LIMIT 1').get(roomId);
}
}
console.log('📦 purchase_item:', aiResult.purchase_item, isNew ? '(强制新建)' : purchase ? '(匹配已有)' : '(未匹配-新建)');
// is_new 未明确设为 false、且所有回退匹配均失败 → 视为新建
if (!isNew && !purchase) {
console.log('📦 未匹配到已有记录,转为新建模式');
isNew = true;
}
// AI 不负责更新 — 聊天永远新建,修改由用户从采购清单手动完成
let purchase = null;
let replyText = '';
const quantity = aiResult.quantity || 1;
@@ -174,88 +132,50 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
const freight = aiResult.freight || 0;
const amount = quantity * unitPrice + freight;
// 新建但金额为 0 且 AI 未提供数量/单价 → 拒绝(避免垃圾记录)
if (!purchase && amount === 0 && aiResult.quantity === undefined && aiResult.unit_price === undefined) {
console.log('🚫 拒绝新建 amount=0 记录, item:', aiResult.purchase_item);
storeAndBroadcastText(roomId, '小财', `无法创建「${aiResult.purchase_item}」:缺少数量和价格信息,请补充。`);
addToHistory(roomId, 'assistant', '缺少数量和价格');
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u65e0\u6cd5\u521b\u5efa\u300c' + aiResult.purchase_item + '\u300d\uff1a\u7f3a\u5c11\u6570\u91cf\u548c\u4ef7\u683c\u4fe1\u606f\uff0c\u8bf7\u8865\u5145\u3002');
addToHistory(roomId, 'assistant', '\u7f3a\u5c11\u6570\u91cf\u548c\u4ef7\u683c');
return;
}
if (!purchase) {
const id = uuidv4();
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, `创建采购:${item},数量${quantity},金额¥${amount},状态${status}`, '小财', now);
replyText = `✅ 已记录采购:${item},数量 ${quantity},金额 ¥${amount},状态 ${status}`;
purchase = { id };
} else {
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
};
newVals.amount = newVals.quantity * newVals.unit_price + 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}」的信息没有发生变化。`;
}
}
// 永远新建
const id = uuidv4();
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, '创建采购:' + item + ',数量' + quantity + ',金额¥' + amount + ',状态' + status, '小财', now);
replyText = '✅ 已记录采购:' + item + ',数量 ' + quantity + ',金额 ¥' + amount + ',状态 ' + status;
purchase = { id };
if (attachments?.length) {
console.log('📎 插入附件:', attachments.length, '个, purchase_id=', purchase.id);
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
attachments.forEach(fp => { console.log(' 📎', fp); insertAttach.run(purchase.id, fp, username, now); });
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length}`, username, now);
if (replyText.includes('没有发生') || usedRecentImage) {
replyText = `📎 已为「${aiResult.purchase_item}」添加 ${attachments.length} 个附件`;
}
console.log('✅ 附件完成, replyText:', replyText);
attachments.forEach(fp => insertAttach.run(purchase.id, fp, username, now));
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, '\u6dfb\u52a0\u9644\u4ef6\uff1a' + attachments.length + ' \u4e2a', username, now);
if (replyText.includes('\u6ca1\u6709\u53d1\u751f') || usedRecentImage) replyText = '\ud83d\udcce \u5df2\u4e3a\u300c' + aiResult.purchase_item + '\u300d\u6dfb\u52a0 ' + attachments.length + ' \u4e2a\u9644\u4ef6';
} else if (usedRecentImage) {
replyText = '❌ 未找到最近的图片消息,请先发送图片再试。';
replyText = '\u274c \u672a\u627e\u5230\u6700\u8fd1\u7684\u56fe\u7247\u6d88\u606f\uff0c\u8bf7\u5148\u53d1\u9001\u56fe\u7247\u518d\u8bd5\u3002';
}
storeAndBroadcastText(roomId, '小财', replyText);
storeAndBroadcastText(roomId, '\u5c0f\u8d22', replyText);
addToHistory(roomId, 'assistant', replyText);
broadcastToRoom(roomId, { type: 'purchase_updated' });
return;
}
if (aiResult.action === 'query') {
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); addToHistory(roomId, 'assistant', reply); });
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 = '\u6839\u636e\u4ee5\u4e0b\u91c7\u8d2d\u8bb0\u5f55\uff0c\u7528\u81ea\u7136\u8bed\u8a00\u56de\u7b54\u7528\u6237\u67e5\u8be2"' + originalText + '"' + '\u3002\u91c7\u8d2d\u8bb0\u5f55\uff1a\n' + (purchaseData || '\u6682\u65e0\u8bb0\u5f55');
callDeepSeekForSummary(summaryPrompt).then(reply => { storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply); });
return;
}
if (aiResult.action === 'chat') {
storeAndBroadcastText(roomId, '小财', aiResult.reply || '好的。');
storeAndBroadcastText(roomId, '\u5c0f\u8d22', aiResult.reply || '\u597d\u7684\u3002');
addToHistory(roomId, 'assistant', aiResult.reply);
return;
}
@@ -264,7 +184,7 @@ function handleAIResult(aiResult, roomId, username, originalText, attachments) {
async function callDeepSeekForSummary(prompt) {
const { callDeepSeek } = require('./ai/client');
const messages = [
{ role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' },
{ role: 'system', content: '\u4f60\u662f\u4e00\u4e2a\u8d22\u52a1\u52a9\u624b\uff0c\u8bf7\u6839\u636e\u91c7\u8d2d\u8bb0\u5f55\u751f\u6210\u7b80\u6d01\u56de\u590d\u3002' },
{ role: 'user', content: prompt }
];
return callDeepSeek(messages, 0.3);

View File

@@ -4,13 +4,12 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.hidden { display: none !important; }
.header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
.header-left { display: flex; align-items: center; gap: 8px; }
.header-left { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.header-left h2 { font-size: 18px; font-weight: 500; }
.icon-btn { background: none; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; border-radius: 50%; }
.icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
#create-room-btn { visibility: hidden; }
#create-room-btn.visible { visibility: visible; }
.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; }
@@ -58,6 +57,10 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.purchase-panel-body { flex: 1; overflow-y: auto; padding: 12px; }
.purchase-month-group { margin-bottom: 20px; }
.purchase-month-group summary { list-style: none; cursor: pointer; }
.purchase-month-group summary::-webkit-details-marker { display: none; }
.purchase-month-group summary::after { content: '▼'; font-size: 11px; margin-left: 8px; color: #888; }
.purchase-month-group[open] summary::after { content: '▲'; }
.purchase-month-header { background: #f5f5f5; font-size: 16px; font-weight: 600; padding: 10px 8px; border-radius: 4px; display: flex; justify-content: space-between; }
.purchase-item { padding: 12px 8px; border-bottom: 1px solid #f0f0f0; cursor: pointer; }
.purchase-item:active { background: #f9f9f9; }

View File

@@ -20,7 +20,7 @@
<input type="password" id="login-pass" placeholder="密码">
<button class="primary-btn" onclick="login()" style="width:100%;">登录</button>
<p id="login-error" style="color:red; margin-top:8px;"></p>
<div class="version">v2.7</div>
<div class="version" id="version-text"></div>
</div>
</div>
@@ -29,19 +29,16 @@
<!-- 群聊列表页 -->
<div id="list-page" style="display:flex; flex-direction:column; height:100%;">
<div class="header">
<div class="header-left">
<div class="header-left" onclick="Store.logout()">
<svg width="24" height="24" viewBox="0 0 24 24" stroke="#fff" fill="none" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="3"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="12" y2="17"/>
</svg>
<h2>小财记账</h2>
</div>
<div style="display:flex; gap:2px;">
<button class="icon-btn" id="create-room-btn" onclick="openCreateRoom()" title="新建群聊">
<button class="icon-btn visible" id="create-room-btn" onclick="openCreateRoom()" title="新建群聊">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
<button class="icon-btn" id="logout-btn" onclick="Store.logout()" title="注销登录">
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
<div class="chat-list" id="chat-list-container"></div>
@@ -238,6 +235,12 @@
<script src="/js/purchases.js"></script>
<script src="/js/auth.js"></script>
<script>
// 版本号
(function() {
var d = new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
document.getElementById('version-text').textContent = 'v2.8-' + (d.getFullYear() % 100) + pad(d.getMonth() + 1) + pad(d.getDate());
})();
document.addEventListener('input', function(e) {
if (e.target.id === 'msg-input' || e.target.id === 'purchase-msg-input') {
e.target.style.height = 'auto';

View File

@@ -3,16 +3,10 @@ async function init() {
const token = Store.getToken();
if (token) {
try {
const res = await fetch('/api/user', { headers: { 'Authorization': `Bearer ${token}` } });
const res = await fetch('/api/user', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
const user = await res.json();
document.getElementById('login-page').classList.add('hidden');
document.getElementById('main-page').classList.remove('hidden');
if (localStorage.getItem('isAdmin') === 'true') {
document.getElementById('create-room-btn').classList.add('visible');
} else {
document.getElementById('create-room-btn').classList.remove('visible');
}
initWebSocket();
loadRooms();
updateSummaryPreview();
@@ -38,17 +32,12 @@ async function login() {
Store.saveAuth(data);
document.getElementById('login-page').classList.add('hidden');
document.getElementById('main-page').classList.remove('hidden');
if (data.isAdmin) {
document.getElementById('create-room-btn').classList.add('visible');
} else {
document.getElementById('create-room-btn').classList.remove('visible');
}
initWebSocket();
loadRooms();
updateSummaryPreview();
initSwipeGestures();
} else {
const err = await res.json();
document.getElementById('login-error').textContent = err.error || '登录失败';
document.getElementById('login-error').textContent = err.error || '\u767b\u5f55\u5931\u8d25';
}
}

View File

@@ -22,7 +22,7 @@ function renderPurchasePanel(items) {
Object.keys(groups).sort().reverse().forEach(month => {
const monthItems = groups[month];
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
html += '<div class="purchase-month-group"><div class="purchase-month-header"><span>' + month + '</span><span>' + monthTotal + '</span></div>';
html += '<details class="purchase-month-group" open><summary class="purchase-month-header"><span>' + month + '</span><span>' + monthTotal + '</span></summary>';
monthItems.forEach(item => {
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
@@ -31,12 +31,12 @@ function renderPurchasePanel(items) {
<div class="item-main"><span class="item-name">${escapeHtml(item.item)}</span><span class="item-amount">${item.amount}</span></div>
<div class="item-meta">
<span>${dateShort} ${timeShort}</span>
<span>${item.quantity} ${item.invoice_type !== '无票' ? '\u00b7 ' + item.invoice_type : ''}</span>
<span>x${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''}</span>
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
</div>
</div>`;
});
html += '</div>';
html += '</details>';
});
container.innerHTML = html;
}
@@ -279,12 +279,17 @@ async function openSummary() {
function closeSummaryModal() { document.getElementById('summary-modal').classList.add('hidden'); }
function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); }
function openCreateRoom() {
document.getElementById('create-modal').classList.remove('hidden');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
document.getElementById('white-list').style.display = isAdmin ? '' : 'none';
}
function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); }
async function confirmCreateRoom() {
const name = document.getElementById('new-room-name').value.trim();
if (!name) return alert('\u8bf7\u8f93\u5165\u540d\u79f0');
const whiteList = document.getElementById('white-list').value.trim();
if (!name) return alert('请输入名称');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
const whiteList = isAdmin ? document.getElementById('white-list').value.trim() : localStorage.getItem('currentUser');
const token = Store.getToken();
await fetch('/api/rooms', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) });
closeCreateModal();
@@ -294,9 +299,11 @@ async function confirmCreateRoom() {
function openManageRoom(roomId) {
const room = Store.rooms.find(r => r.id === roomId);
if (!room) return;
document.getElementById('manage-title').textContent = '\u7f16\u8f91\u7fa4\u804a';
const isAdmin = localStorage.getItem('isAdmin') === 'true';
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-white-list').style.display = isAdmin ? '' : 'none';
document.getElementById('manage-modal').dataset.roomId = roomId;
document.getElementById('manage-modal').classList.remove('hidden');
}
@@ -306,15 +313,16 @@ function closeManageModal() { document.getElementById('manage-modal').classList.
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('\u8bf7\u8f93\u5165\u7fa4\u804a\u540d\u79f0');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
const whiteList = isAdmin ? document.getElementById('manage-white-list').value.trim() : undefined;
if (!name) return alert('请输入群聊名称');
const token = Store.getToken();
const res = await fetch('/api/rooms/' + roomId, {
method: 'PUT', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, whiteList })
});
if (res.ok) { closeManageModal(); loadRooms(); }
else { const err = await res.json(); alert(err.error || '\u66f4\u65b0\u5931\u8d25'); }
else { const err = await res.json(); alert(err.error || '更新失败'); }
}
async function deleteRoom() {

View File

@@ -15,7 +15,7 @@ router.get('/', authMiddleware, (req, res) => {
if (isAdmin) {
rooms = db.prepare('SELECT * FROM rooms').all();
} else {
rooms = db.prepare("SELECT * FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`);
rooms = db.prepare("SELECT * FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all('%,' + username + ',%');
}
const result = rooms.map(room => {
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(room.id);
@@ -24,22 +24,28 @@ router.get('/', authMiddleware, (req, res) => {
res.json(result);
});
router.post('/', authMiddleware, adminOnly, (req, res) => {
// 普通用户可新建,白名单自动设为用户名
router.post('/', authMiddleware, (req, res) => {
const { name, whiteList } = req.body;
const isAdmin = req.user.isAdmin;
const finalWhiteList = isAdmin ? (whiteList || '') : req.user.username;
const id = uuidv4();
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 || '' } });
db.prepare('INSERT INTO rooms (id, name, created_by, white_list, created_at) VALUES (?,?,?,?,?)').run(id, name, req.user.username, finalWhiteList, now);
broadcast({ type: 'room_created', room: { id, name, white_list: finalWhiteList } });
res.json({ id, name });
});
router.put('/:roomId', authMiddleware, adminOnly, (req, res) => {
// 普通用户可编辑名称,白名单不变
router.put('/:roomId', authMiddleware, (req, res) => {
const { name, whiteList } = req.body;
const roomId = req.params.roomId;
const isAdmin = req.user.isAdmin;
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 || '' } });
const finalWhiteList = isAdmin ? (whiteList !== undefined ? whiteList : room.white_list) : room.white_list;
db.prepare('UPDATE rooms SET name = ?, white_list = ? WHERE id = ?').run(name, finalWhiteList, roomId);
broadcast({ type: 'room_updated', room: { id: roomId, name, white_list: finalWhiteList } });
res.json({ success: true });
});