';
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 => `

`).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);
});