diff --git a/server/ai/prompt.js b/server/ai/prompt.js index e7146e3..c2a89d9 100644 --- a/server/ai/prompt.js +++ b/server/ai/prompt.js @@ -2,63 +2,73 @@ function buildSystemPrompt(username, isAdmin) { const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); - const role = `你是智能财务助手"小财"。当前服务器时间:${now}。结合对话历史理解用户意图,只返回JSON。`; + const role = '你是智能财务助手"小财"。当前服务器时间:' + now + '。结合对话历史理解用户意图,只返回JSON。'; - const intent = ` -意图分类: -- 采购/付款/发票相关:action="purchase",提取字段:purchase_item, quantity(默认1), unit_price(默认0), freight(默认0), payment_method(淘宝默认支付宝), invoice_type(默认无票), status, applicant, remarks, created_time -- 查询汇总:action="query" -- 聊天:action="chat",reply简短回复 -- 删除指定物品:action="delete",delete_item为物品名 -- 清空所有采购数据:action="clear_all" -- 忽略(仅当完全无关):action="ignore"`; + const intent = [ + '意图分类:', + '- 采购/付款/发票相关(新建):action="purchase",提取字段:purchase_item, quantity(默认1), unit_price(默认0), freight(默认0), payment_method(淘宝默认支付宝), invoice_type(默认无票), status, applicant, remarks, created_time', + '- 查询汇总:action="query"', + '- 聊天:action="chat",reply简短回复', + '- 删除指定物品:action="delete",delete_item为物品名', + '- 清空所有采购数据:action="clear_all"', + '- 忽略(仅当完全无关):action="ignore"' + ].join('\n'); - const permission = ` -⚠️ 权限规则: -- 当前用户${username},管理员状态:${isAdmin}。仅管理员可执行删除/清空操作。 -- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明"仅管理员可操作"。 -- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。`; + const permission = [ + '⚠️ 权限规则:', + '- 当前用户' + username + ',管理员状态:' + isAdmin + '。仅管理员可执行删除/清空操作。', + '- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明"仅管理员可操作"。', + '- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。' + ].join('\n'); - const deleteRules = ` -⚠️ 删除规则: -- 特殊批量删除:delete_item="__AMOUNT_ZERO__"(金额为0)、"__NULL_NAME__"(空名称) -- 精确删除:用户指定数量/金额时同时返回 quantity/amount 字段,只匹配一条 -- 不要返回 action="ask" 处理删除请求`; + const deleteRules = [ + '⚠️ 删除规则:', + '- 特殊批量删除:delete_item="__AMOUNT_ZERO__"(金额为0)、"__NULL_NAME__"(空名称)', + '- 精确删除:用户指定数量/金额时同时返回 quantity/amount 字段,只匹配一条', + '- 不要返回 action="ask" 处理删除请求' + ].join('\n'); - const attachmentRules = ` -⚠️ 附件规则: -- 图片+物品名("这是XX的图片")→ 只返回 purchase_item,绝对不要 is_new/quantity/unit_price -- 引用历史图片("上面/前面/刚刚那张""存入附件/添加到附件/加到XX的附件/关联到")→ use_recent_image: true,**同时必须提供** purchase_item -- 纯图片+物品名不是新建采购,系统自动关联附件`; + const attachmentRules = [ + '⚠️ 附件规则:', + '- 图片+物品名("这是XX的图片")→ 只返回 purchase_item,绝对不要 is_new/quantity/unit_price', + '- 引用历史图片("上面/前面/刚刚那张""存入附件/添加到附件/加到XX的附件/关联到")→ use_recent_image: true,**同时必须提供** purchase_item', + '- 纯图片+物品名不是新建采购,系统自动关联附件' + ].join('\n'); - const prohibitionRules = ` -⚠️ 绝对禁止: -- 图片+物品名请求 → 只能返回 purchase_item,禁止返回 is_new、quantity、unit_price、amount`; + const prohibitionRules = [ + '⚠️ 绝对禁止:', + '- 图片+物品名请求 → 只能返回 purchase_item,禁止返回 is_new、quantity、unit_price、amount' + ].join('\n'); - const purchaseRules = ` -⚠️ 采购规则: -- 系统会在 Prompt 末尾提供“当前房间采购清单”,你必须根据清单和对话历史判断用户是要更新已有记录还是新建。 -- **新建采购**(用户首次购买、重复购买、明确说“再买/又/新/另一批/这次”等)→ 必须返回 "is_new": true。 -- **更新已有记录**(用户要求修改、更正、调整,如“改一下/更新/修改/更正/搞错了/不是XX是YY”)→ 返回 "is_new": false。 -- **纯图片+物品名**(如“这是xxx的图片”)→ 不是新建采购,不要返回 is_new、quantity、unit_price 等字段,只返回 purchase_item。 -- 如果无法明确判断新建/更新,优先按新建处理 → 返回 "is_new": true。 -- 采购状态(status)只能从以下 4 个值中选择,绝不允许自创: - * "待付款" — 采购已记录,等待付款 - * "已付款" — 已完成支付,等待收货 - * "已收货" — 已收到货物 - * "已完成" — 发票已收,全部结束 -- 状态映射: - * "付了/已付/付款了" → "已付款" - * "到了/收到货了" → "已收货" - * "发票到/全搞定/完结" → "已完成" - * 新建不指定 → "待付款" -- 只有明确消费意图(购买/采购/下单/付款/买了/花了)才返回 action="purchase" -- 纯陈述 → action="ignore" -- 信息不完整 → action="ask" 追问 -- amount 由系统自动计算(quantity × unit_price + freight),AI 无需提供 -- 无法确定物品名 → action="ask" 追问`; + const modifyGuide = [ + '⚠️ 修改引导规则(极其重要!):', + '- 聊天窗口仅用于**新建**采购记录和查询汇总。', + '- 如果用户要求**修改/更正/调整/改一下/更新/纠正**已有采购记录 → 你必须返回 action="chat",reply引导用户去采购清单页面手动修改。', + ' 示例回复:"请在采购清单中找到对应记录,点击进入详情页面直接修改。修改后点击保存即可。"', + '- 如果用户要求修改,你绝不要尝试去匹配或更新已有记录,只能引导用户手动操作。' + ].join('\n'); - return [role, intent, permission, deleteRules, attachmentRules, prohibitionRules, purchaseRules].join('\n'); + const purchaseRules = [ + '⚠️ 采购规则:', + '- 聊天默认为**新建**采购记录。只要用户提到购买/采购/下单/付款/买了/花了,就创建新记录。', + '- 采购状态(status)只能从以下 4 个值中选择,绝不允许自创:', + ' * "待付款" — 采购已记录,等待付款', + ' * "已付款" — 已完成支付,等待收货', + ' * "已收货" — 已收到货物', + ' * "已完成" — 发票已收,全部结束', + '- 状态映射:', + ' * "付了/已付/付款了" → "已付款"', + ' * "到了/收到货了" → "已收货"', + ' * "发票到/全搞定/完结" → "已完成"', + ' * 新建不指定 → "待付款"', + '- 只有明确消费意图(购买/采购/下单/付款/买了/花了)才返回 action="purchase"', + '- 纯陈述 → action="ignore"', + '- 信息不完整 → action="ask" 追问', + '- amount 由系统自动计算(quantity × unit_price + freight),AI 无需提供', + '- 无法确定物品名 → action="ask" 追问' + ].join('\n'); + + return [role, intent, permission, deleteRules, attachmentRules, prohibitionRules, modifyGuide, purchaseRules].join('\n'); } module.exports = { buildSystemPrompt }; diff --git a/server/public/css/style.css b/server/public/css/style.css index ce235b7..5635a56 100644 --- a/server/public/css/style.css +++ b/server/public/css/style.css @@ -108,6 +108,43 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans- .detail-row { margin-bottom: 8px; } +/* 可编辑详情弹窗 */ +.detail-modal { max-height: 90vh; overflow-y: auto; padding: 0; } +.detail-modal .modal-close-btn { display: none; } +.detail-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 8px 16px; display: flex; align-items: center; gap: 8px; border-radius: 12px 12px 0 0; margin: -16px -16px 0 -16px; } +.detail-header .icon-btn { flex-shrink: 0; } +.detail-header .icon-btn svg { stroke: #fff; } +.detail-title-input { flex: 1; font-size: 18px; font-weight: 600; color: #fff; background: transparent; border: none; outline: none; padding: 4px 0; min-width: 0; } +.detail-title-input::placeholder { color: rgba(255,255,255,0.6); } +.detail-title-input:focus { border-bottom: 2px solid rgba(255,255,255,0.5); } +.edit-form { padding: 12px 0 0 0; } +.edit-row { display: flex; align-items: center; margin-bottom: 6px; gap: 8px; } +.edit-row label { min-width: 48px; font-size: 13px; color: #777; text-align: right; } +.edit-row input, .edit-row select, .edit-row textarea { flex: 1; padding: 6px 8px; border: 1px solid #e0e0e0; border-radius: 6px; font-size: 14px; outline: none; } +.edit-row input:focus, .edit-row select:focus, .edit-row textarea:focus { border-color: #3b82f6; } +.edit-row select { appearance: auto; } +.edit-row textarea { resize: vertical; min-height: 40px; } +.amount-row { font-weight: 600; } +.amount-row label { color: #333; } +.edit-amount { flex: 1; font-size: 17px; color: #e53e3e; font-weight: 700; } +.edit-section { margin-top: 8px; border-top: 1px solid #eee; padding-top: 8px; } +.edit-section-header { font-size: 13px; font-weight: 600; color: #555; cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 2px 0; } +.edit-section-header svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; } +.history-summary { list-style: none; } +.history-summary::-webkit-details-marker { display: none; } +.history-summary::after { content: '展开'; font-size: 12px; color: #3b82f6; font-weight: 400; } +details[open] .history-summary::after { content: '收起'; } +.attach-gallery { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.attach-thumb-wrap { position: relative; display: inline-block; } +.attach-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid #eee; } +.attach-del-btn { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #e53e3e; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; } +.attach-del-btn svg { width: 11px; height: 11px; stroke: #fff; fill: none; stroke-width: 2; } +.attach-add-btn { cursor: pointer; display: flex; align-items: center; } +.attach-add-btn svg { width: 18px; height: 18px; stroke: #3b82f6; fill: none; stroke-width: 2; } +.edit-buttons { display: flex; gap: 8px; margin-top: 12px; } +.save-btn { flex: 1; background: #3b82f6; color: #fff; border: none; padding: 8px 0; border-radius: 6px; font-size: 15px; cursor: pointer; font-weight: 500; } +.danger-btn { background: transparent; color: #e53e3e; border: 1px solid #e53e3e; padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; } + .white-list-tag { font-size: 12px; color: #555; diff --git a/server/public/index.html b/server/public/index.html index 75ff7f8..155fd47 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -20,7 +20,7 @@

-
v2.6
+
v2.7
@@ -66,6 +66,17 @@
+ +
+ + + +
@@ -75,6 +86,9 @@ +
@@ -116,13 +130,91 @@
- @@ -134,13 +226,20 @@ diff --git a/server/public/js/purchases.js b/server/public/js/purchases.js index d6f1109..c45e514 100644 --- a/server/public/js/purchases.js +++ b/server/public/js/purchases.js @@ -1,8 +1,10 @@ // 采购相关功能 +let editPid = null; // 当前正在编辑的采购ID + async function loadPurchases() { if (!Store.currentRoom) return; const token = Store.getToken(); - const res = await fetch('/api/rooms/' + Store.currentRoom + '/purchases', { headers: { 'Authorization': `Bearer ${token}` } }); + const res = await fetch('/api/rooms/' + Store.currentRoom + '/purchases', { headers: { 'Authorization': 'Bearer ' + token } }); const items = await res.json(); renderPurchasePanel(items); } @@ -20,16 +22,16 @@ 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 += `
${month}¥${monthTotal}
`; + html += '
' + month + '' + monthTotal + '
'; monthItems.forEach(item => { const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || ''; const dateShort = item.created_at.split(' ')[0]?.substring(5) || ''; html += `
-
${escapeHtml(item.item)}¥${item.amount}
+
${escapeHtml(item.item)}${item.amount}
${dateShort} ${timeShort} - ×${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''} + ${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''} ${item.status}
`; @@ -42,54 +44,210 @@ function renderPurchasePanel(items) { function formatMonth(dateStr) { const clean = dateStr.replace(/-/g, '/'); const parts = clean.split('/'); - if (parts.length >= 2) return `${parts[0]}年${parseInt(parts[1], 10)}月`; + if (parts.length >= 2) return parts[0] + '年' + parseInt(parts[1], 10) + '月'; return dateStr.substring(0,7); } +// ============ 可编辑详情弹窗 ============ + async function openDetail(pid) { const token = Store.getToken(); - const res = await fetch('/api/purchases/' + pid, { headers: { 'Authorization': `Bearer ${token}` } }); + const res = await fetch('/api/purchases/' + pid, { headers: { 'Authorization': 'Bearer ' + token } }); const p = await res.json(); - document.getElementById('detail-title').textContent = p.item; - 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.quantity}
    -
    单价:¥${p.unit_price}
    -
    邮费:¥${p.freight}
    -
    总金额:¥${p.amount}
    -
    付款方式:${p.payment_method || '未指定'}
    -
    发票:${p.invoice_type || '无票'}
    -
    状态:${p.status}
    -
    申请人:${p.applicant}
    -
    采购时间:${p.created_at}
    -
    备注:${p.remarks || '无'}
    - ${attachHtml} -

    操作历史

    - `; + editPid = pid; + + document.getElementById('edit-item').value = p.item || ''; + document.getElementById('edit-quantity').value = p.quantity || 0; + document.getElementById('edit-unit-price').value = p.unit_price || 0; + document.getElementById('edit-freight').value = p.freight || 0; + updateEditAmount(); + document.getElementById('edit-payment-method').value = p.payment_method || '未指定'; + document.getElementById('edit-invoice-type').value = p.invoice_type || '无票'; + document.getElementById('edit-status').value = p.status || '待付款'; + document.getElementById('edit-applicant').value = p.applicant || ''; + document.getElementById('edit-time').value = p.created_at || ''; + document.getElementById('edit-remarks').value = p.remarks || ''; + + // 附件 + renderEditAttachments(p.attachments || []); + + // 操作历史 + const historyHtml = (p.history || []).map(h => + '
  • ' + h.timestamp + ' ' + escapeHtml(h.user) + ': ' + escapeHtml(h.action) + '
  • ' + ).join(''); + document.getElementById('edit-history').innerHTML = historyHtml || '
  • 暂无操作记录
  • '; + document.getElementById('edit-history-section').open = false; + + // 删除按钮仅管理员可见 + const isAdmin = localStorage.getItem('isAdmin') === 'true'; + document.getElementById('delete-purchase-btn').style.display = isAdmin ? '' : 'none'; + document.getElementById('detail-modal').classList.remove('hidden'); } -function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); } +function renderEditAttachments(attachments) { + const container = document.getElementById('edit-attachments'); + if (!attachments || attachments.length === 0) { + container.innerHTML = '暂无附件'; + return; + } + container.innerHTML = attachments.map(a => + '
    ' + + '' + + '' + + '
    ' + ).join(''); +} + +function updateEditAmount() { + const q = parseFloat(document.getElementById('edit-quantity').value) || 0; + const up = parseFloat(document.getElementById('edit-unit-price').value) || 0; + const f = parseFloat(document.getElementById('edit-freight').value) || 0; + document.getElementById('edit-amount').textContent = (q * up + f).toFixed(2); +} + +async function savePurchase() { + if (!editPid) return; + const token = Store.getToken(); + const body = { + item: document.getElementById('edit-item').value, + quantity: parseFloat(document.getElementById('edit-quantity').value) || 0, + unit_price: parseFloat(document.getElementById('edit-unit-price').value) || 0, + freight: parseFloat(document.getElementById('edit-freight').value) || 0, + payment_method: document.getElementById('edit-payment-method').value, + invoice_type: document.getElementById('edit-invoice-type').value, + status: document.getElementById('edit-status').value, + applicant: document.getElementById('edit-applicant').value, + remarks: document.getElementById('edit-remarks').value, + created_at: document.getElementById('edit-time').value + }; + const res = await fetch('/api/purchases/' + editPid, { + method: 'PUT', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const result = await res.json(); + if (result.success) { + closeDetailModal(); + loadPurchases(); + } else { + alert('保存失败:' + (result.error || '未知错误')); + } +} + +async function deletePurchaseDetail() { + if (!editPid) return; + if (!confirm('确定要删除此采购记录吗?')) return; + const token = Store.getToken(); + const res = await fetch('/api/purchases/' + editPid, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token } + }); + if (res.ok) { + closeDetailModal(); + loadPurchases(); + } else { + const err = await res.json(); + alert('删除失败:' + (err.error || '未知错误')); + } +} + +async function deleteAttachment(event, encodedPath) { + event.stopPropagation(); + const filePath = decodeURIComponent(encodedPath); + if (!confirm('确定删除此附件吗?')) return; + const token = Store.getToken(); + const res = await fetch('/api/purchases/' + editPid + '/attachments', { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + if (res.ok) { + // 重新加载详情 + openDetail(editPid); + } +} + +async function handleDetailFileUpload(event) { + const files = event.target.files; + if (!files.length) return; + const token = Store.getToken(); + const paths = []; + 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/upload', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData }); + const data = await res.json(); + if (data.path) paths.push(data.path); + } + if (paths.length) { + await fetch('/api/purchases/' + editPid + '/attachments', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_paths: paths }) + }); + openDetail(editPid); + } + } catch(e) { alert('上传失败,请重试'); } +} + +function closeDetailModal() { + document.getElementById('detail-modal').classList.add('hidden'); + editPid = null; +} + +// ============ 采购页面输入区 ============ + +async function handlePurchaseFileSelect(event) { + const files = event.target.files; + if (!files.length) return; + const token = Store.getToken(); + 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/upload', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData }); + const data = await res.json(); + if (data.path) Store.pendingUploads.push(data.path); + } + } catch(e) { alert('图片上传失败'); } +} + +function sendFromPurchase() { + const input = document.getElementById('purchase-msg-input'); + const text = input.value.trim(); + if (!text && Store.pendingUploads.length === 0) return; + // 记录文本,跳转到聊天页面后发送 + Store.pendingPurchaseText = text; + input.value = ''; + openChatFromPurchase(); +} + +// ============ 汇总 & 群聊管理 ============ async function openSummary() { const token = Store.getToken(); - const res = await fetch('/api/summary', { headers: { 'Authorization': `Bearer ${token}` } }); + const res = await fetch('/api/summary', { headers: { 'Authorization': 'Bearer ' + token } }); const summary = await res.json(); let html = ''; if (summary.length === 0) html = '

    暂无采购记录

    '; else { summary.forEach(s => { - html += `

    ${escapeHtml(s.room_name)}

    ${s.purchase_count} 条,合计 ¥${s.total_amount}

    `; + html += '

    ' + escapeHtml(s.room_name) + '

    ' + s.purchase_count + ' 条,合计 ' + s.total_amount + '

    '; }); } - document.getElementById('detail-title').textContent = '采购汇总'; - document.getElementById('detail-content').innerHTML = html; - document.getElementById('detail-modal').classList.remove('hidden'); + document.getElementById('summary-title').textContent = '采购汇总'; + document.getElementById('summary-content').innerHTML = html; + document.getElementById('summary-modal').classList.remove('hidden'); } -// 群聊管理 +function closeSummaryModal() { document.getElementById('summary-modal').classList.add('hidden'); } + function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); } function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); } async function confirmCreateRoom() { @@ -97,7 +255,7 @@ async function confirmCreateRoom() { if (!name) return alert('请输入名称'); const whiteList = document.getElementById('white-list').value.trim(); const token = Store.getToken(); - await fetch('/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) }); + await fetch('/api/rooms', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) }); closeCreateModal(); loadRooms(); } @@ -121,7 +279,7 @@ async function saveRoom() { 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' }, + method: 'PUT', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) }); if (res.ok) { closeManageModal(); loadRooms(); } @@ -132,7 +290,7 @@ async function deleteRoom() { if (!confirm('确定要删除该群聊吗?所有消息和采购数据将被永久删除。')) return; const roomId = document.getElementById('manage-modal').dataset.roomId; const token = Store.getToken(); - const res = await fetch('/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); + const res = await fetch('/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (res.ok) { closeManageModal(); loadRooms(); @@ -140,17 +298,31 @@ async function deleteRoom() { } else { const err = await res.json(); alert(err.error || '删除失败'); } } -// 滑动手势 +// ============ 滑动手势 ============ + function initSwipeGestures() { + const purchasePage = document.getElementById('purchase-page'); const chatPage = document.getElementById('chat-page'); let startX = 0; + // 采购页:右滑回列表,左滑进聊天 + purchasePage.addEventListener('touchstart', (e) => { startX = e.touches[0].clientX; }, { passive: true }); + purchasePage.addEventListener('touchend', (e) => { + if (!startX) return; + const diffX = e.changedTouches[0].clientX - startX; + if (Math.abs(diffX) > 50) { + if (diffX > 30) showList(); + else if (diffX < -30) openChatFromPurchase(); + } + startX = 0; + }, { passive: true }); + + // 聊天页:左右滑回采购页 chatPage.addEventListener('touchstart', (e) => { startX = e.touches[0].clientX; }, { passive: true }); chatPage.addEventListener('touchend', (e) => { if (!startX) return; - const endX = e.changedTouches[0].clientX; - const diffX = endX - startX; - if (diffX > 50) backToPurchase(); + const diffX = e.changedTouches[0].clientX - startX; + if (Math.abs(diffX) > 50) backToPurchase(); startX = 0; }, { passive: true }); } diff --git a/server/public/js/store.js b/server/public/js/store.js index 4d08421..7ceb524 100644 --- a/server/public/js/store.js +++ b/server/public/js/store.js @@ -4,6 +4,7 @@ const Store = { currentRoom: null, rooms: [], pendingUploads: [], + pendingPurchaseText: '', messageCache: [], tempBubbleTimer: null, wsReconnectTimer: null, diff --git a/server/public/js/ws.js b/server/public/js/ws.js index 0abedab..c10cad1 100644 --- a/server/public/js/ws.js +++ b/server/public/js/ws.js @@ -109,7 +109,7 @@ async function openPurchasePage(roomId) { async function openChatFromPurchase() { if (!Store.currentRoom) return; - openChat(Store.currentRoom); + await openChat(Store.currentRoom, true); } function backToPurchase() { @@ -119,7 +119,7 @@ function backToPurchase() { loadPurchases(); } -async function openChat(roomId) { +async function openChat(roomId, fromPurchase = false) { Store.currentRoom = roomId; clearTempBubble(); document.getElementById('list-page').classList.add('hidden'); @@ -128,10 +128,20 @@ async function openChat(roomId) { const room = Store.rooms.find(r => r.id === roomId); document.getElementById('current-chat-name').textContent = room?.name || ''; const token = Store.getToken(); - const res = await fetch('/api/rooms/' + roomId + '/messages', { headers: { 'Authorization': `Bearer ${token}` } }); + const res = await fetch('/api/rooms/' + roomId + '/messages', { headers: { 'Authorization': 'Bearer ' + token } }); const msgs = await res.json(); renderAllMessages(msgs); if (Store.ws && Store.ws.readyState === WebSocket.OPEN) Store.ws.send(JSON.stringify({ type: 'join', roomId })); + + // 从采购页跳转过来时,自动发送缓存的文本 + if (fromPurchase && Store.pendingPurchaseText) { + const text = Store.pendingPurchaseText; + Store.pendingPurchaseText = ''; + document.getElementById('msg-input').value = text; + document.getElementById('msg-input').style.height = 'auto'; + document.getElementById('msg-input').style.height = document.getElementById('msg-input').scrollHeight + 'px'; + setTimeout(() => sendMessage(), 300); + } } function showList() { diff --git a/server/routes/purchases.js b/server/routes/purchases.js index 0bf5364..f5f94c0 100644 --- a/server/routes/purchases.js +++ b/server/routes/purchases.js @@ -1,7 +1,9 @@ // 采购相关路由 const express = require('express'); -const { authMiddleware } = require('../middleware/auth'); +const { authMiddleware, adminOnly } = require('../middleware/auth'); +const { broadcastToRoom } = require('../ws'); const db = require('../db'); +const { timestamp } = require('../utils'); const router = express.Router(); @@ -18,6 +20,90 @@ router.get('/purchases/:id', authMiddleware, (req, res) => { res.json({ ...pur, history, attachments }); }); +// 更新采购记录 +router.put('/purchases/:id', authMiddleware, (req, res) => { + const old = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); + if (!old) return res.status(404).json({ error: '未找到' }); + + const now = timestamp(); + const username = req.user.username; + const { item, quantity, unit_price, freight, payment_method, invoice_type, status, applicant, remarks, created_at } = req.body; + + const newVals = { + item: item !== undefined ? String(item) : old.item, + quantity: quantity !== undefined ? Number(quantity) : old.quantity, + unit_price: unit_price !== undefined ? Number(unit_price) : old.unit_price, + freight: freight !== undefined ? Number(freight) : old.freight, + payment_method: payment_method !== undefined ? String(payment_method) : old.payment_method, + invoice_type: invoice_type !== undefined ? String(invoice_type) : old.invoice_type, + status: status !== undefined ? String(status) : old.status, + applicant: applicant !== undefined ? String(applicant) : old.applicant, + remarks: remarks !== undefined ? String(remarks) : old.remarks, + created_at: created_at !== undefined ? String(created_at) : old.created_at + }; + newVals.amount = newVals.quantity * newVals.unit_price + newVals.freight; + + const fieldLabels = { + item: '物品', quantity: '数量', unit_price: '单价', freight: '邮费', amount: '金额', + payment_method: '付款方式', invoice_type: '发票', status: '状态', applicant: '申请人', remarks: '备注', created_at: '时间' + }; + + const changes = []; + for (const [field, label] of Object.entries(fieldLabels)) { + if (String(newVals[field]) !== String(old[field])) { + changes.push(label + ': ' + (old[field] || '空') + ' -> ' + newVals[field]); + db.prepare('UPDATE purchases SET ' + field + ' = ? WHERE id = ?').run(newVals[field], old.id); + } + } + + if (changes.length > 0) { + db.prepare('UPDATE purchases SET updated_at = ? WHERE id = ?').run(now, old.id); + db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(old.id, '更新:' + changes.join(';'), username, now); + broadcastToRoom(old.room_id, { type: 'purchase_updated' }); + res.json({ success: true, changes }); + } else { + res.json({ success: true, changes: [] }); + } +}); + +// 删除采购记录(管理员) +router.delete('/purchases/:id', authMiddleware, adminOnly, (req, res) => { + const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); + if (!pur) return res.status(404).json({ error: '未找到' }); + db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?').run(pur.id); + db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?').run(pur.id); + db.prepare('DELETE FROM purchases WHERE id = ?').run(pur.id); + broadcastToRoom(pur.room_id, { type: 'purchase_updated' }); + res.json({ success: true }); +}); + +// 管理附件 +router.post('/purchases/:id/attachments', authMiddleware, (req, res) => { + const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); + if (!pur) return res.status(404).json({ error: '未找到' }); + const now = timestamp(); + const username = req.user.username; + const { file_paths } = req.body; + if (!file_paths || !file_paths.length) return res.status(400).json({ error: '缺少文件路径' }); + const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)'); + file_paths.forEach(fp => insertAttach.run(pur.id, fp, username, now)); + db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(pur.id, '添加附件:' + file_paths.length + ' 个', username, now); + broadcastToRoom(pur.room_id, { type: 'purchase_updated' }); + res.json({ success: true }); +}); + +router.delete('/purchases/:id/attachments', authMiddleware, (req, res) => { + const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); + if (!pur) return res.status(404).json({ error: '未找到' }); + const username = req.user.username; + const { file_path } = req.body; + if (!file_path) return res.status(400).json({ error: '缺少文件路径' }); + db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ? AND file_path = ?').run(pur.id, file_path); + db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(pur.id, '删除附件:' + file_path.split('/').pop(), username, timestamp()); + broadcastToRoom(pur.room_id, { type: 'purchase_updated' }); + res.json({ success: true }); +}); + router.get('/summary', authMiddleware, (req, res) => { const username = req.user.username; const isAdmin = req.user.isAdmin; @@ -25,7 +111,7 @@ router.get('/summary', authMiddleware, (req, res) => { if (isAdmin) { rooms = db.prepare('SELECT id, name FROM rooms').all(); } else { - rooms = db.prepare("SELECT id, name FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`); + rooms = db.prepare("SELECT id, name FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all('%,' + username + ',%'); } const summary = rooms.map(room => { const stats = db.prepare('SELECT COUNT(*) as count, SUM(amount) as total FROM purchases WHERE room_id = ?').get(room.id); @@ -38,10 +124,10 @@ router.get('/rooms/:roomId/purchases/export', authMiddleware, (req, res) => { const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ?').all(req.params.roomId); let csv = '时间,事项,数量,单价,金额,邮费,付款方式,发票类型,状态,申请人,备注\n'; purchases.forEach(p => { - 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`; + 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"`); + res.setHeader('Content-Disposition', 'attachment; filename="purchases-' + req.params.roomId + '.csv"'); res.send('\uFEFF' + csv); });