This commit is contained in:
2026-07-28 19:26:26 +08:00
parent 2e9da6c1ac
commit 05266703e8
5 changed files with 64 additions and 57 deletions

View File

@@ -59,9 +59,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.purchase-month-group { margin-bottom: 20px; } .purchase-month-group { margin-bottom: 20px; }
.purchase-month-group summary { list-style: none; cursor: pointer; } .purchase-month-group summary { list-style: none; cursor: pointer; }
.purchase-month-group summary::-webkit-details-marker { display: none; } .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-header { background: #f5f5f5; font-size: 16px; font-weight: 600; padding: 10px 8px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; }
.purchase-month-group[open] summary::after { content: '▲'; } .purchase-month-header span:last-child { margin-left: auto; margin-right: 4px; }
.purchase-month-header { background: #f5f5f5; font-size: 16px; font-weight: 600; padding: 10px 8px; border-radius: 4px; display: flex; justify-content: space-between; } .month-arrow { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; flex-shrink: 0; margin-left: 6px; transition: transform 0.2s; }
.purchase-month-group[open] .month-arrow { transform: rotate(180deg); }
.purchase-month-header span:last-child { margin-left: auto; margin-right: 4px; }
.purchase-item { padding: 12px 8px; border-bottom: 1px solid #f0f0f0; cursor: pointer; } .purchase-item { padding: 12px 8px; border-bottom: 1px solid #f0f0f0; cursor: pointer; }
.purchase-item:active { background: #f9f9f9; } .purchase-item:active { background: #f9f9f9; }
.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; }
@@ -140,10 +142,10 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.edit-section { margin-top: 8px; border-top: 1px solid #eee; padding-top: 8px; } .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 { 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; } .edit-section-header svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; }
.history-summary { list-style: none; } .history-summary { list-style: none; display: flex; align-items: center; gap: 6px; }
.history-summary::-webkit-details-marker { display: none; } .history-summary::-webkit-details-marker { display: none; }
.history-summary::after { content: '展开'; font-size: 12px; color: #3b82f6; font-weight: 400; } .history-summary .section-arrow { width: 14px; height: 14px; stroke: #888; fill: none; stroke-width: 2; flex-shrink: 0; transition: transform 0.2s; }
details[open] .history-summary::after { content: '收起'; } details[open] .history-summary .section-arrow { transform: rotate(180deg); }
.attach-gallery { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } .attach-gallery { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.attach-thumb-wrap { position: relative; display: inline-block; } .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-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid #eee; }

View File

@@ -215,7 +215,7 @@
<!-- 操作历史(折叠) --> <!-- 操作历史(折叠) -->
<details class="edit-section" id="edit-history-section"> <details class="edit-section" id="edit-history-section">
<summary class="edit-section-header history-summary"><span>操作历史</span></summary> <summary class="edit-section-header history-summary"><span>操作历史</span><svg class="section-arrow" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></summary>
<ul class="history-list" id="edit-history"></ul> <ul class="history-list" id="edit-history"></ul>
</details> </details>
@@ -237,9 +237,7 @@
<script> <script>
// 版本号 // 版本号
(function() { (function() {
var d = new Date(); document.getElementById('version-text').textContent = 'v2.8-260728';
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) { document.addEventListener('input', function(e) {
if (e.target.id === 'msg-input' || e.target.id === 'purchase-msg-input') { if (e.target.id === 'msg-input' || e.target.id === 'purchase-msg-input') {

View File

@@ -22,7 +22,7 @@ function renderPurchasePanel(items) {
Object.keys(groups).sort().reverse().forEach(month => { Object.keys(groups).sort().reverse().forEach(month => {
const monthItems = groups[month]; const monthItems = groups[month];
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0); const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
html += '<details class="purchase-month-group" open><summary class="purchase-month-header"><span>' + month + '</span><span>¥' + monthTotal + '</span></summary>'; html += '<details class="purchase-month-group" open><summary class="purchase-month-header"><span>' + month + '</span><span style="display:flex;align-items:center;gap:6px;">¥' + monthTotal + '<svg class="month-arrow" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></span></summary>';
monthItems.forEach(item => { monthItems.forEach(item => {
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || ''; const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
const dateShort = item.created_at.split(' ')[0]?.substring(5) || ''; const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
@@ -79,16 +79,9 @@ async function openDetail(pid) {
document.getElementById('edit-history').innerHTML = historyHtml || '<li style="color:#888;">暂无操作记录</li>'; document.getElementById('edit-history').innerHTML = historyHtml || '<li style="color:#888;">暂无操作记录</li>';
document.getElementById('edit-history-section').open = false; document.getElementById('edit-history-section').open = false;
// 删除按钮仅管理员可见 // 删除按钮
const isAdmin = localStorage.getItem('isAdmin') === 'true'; document.getElementById('delete-purchase-btn').style.display = '';
const delBtn = document.getElementById('delete-purchase-btn'); document.getElementById('delete-purchase-btn').style.marginLeft = '';
if (isAdmin) {
delBtn.style.display = '';
delBtn.style.marginLeft = '';
} else {
delBtn.style.display = 'none';
delBtn.style.marginLeft = '0';
}
// 折叠所有编辑组 // 折叠所有编辑组
document.querySelectorAll('.edit-group-detail').forEach(el => el.classList.add('hidden')); document.querySelectorAll('.edit-group-detail').forEach(el => el.classList.add('hidden'));

View File

@@ -69,32 +69,35 @@ async function loadRooms() {
function renderChatList() { function renderChatList() {
const container = document.getElementById('chat-list-container'); const container = document.getElementById('chat-list-container');
const isAdmin = localStorage.getItem('isAdmin') === 'true'; const isAdmin = localStorage.getItem('isAdmin') === 'true';
const currentUser = localStorage.getItem('currentUser');
container.innerHTML = Store.rooms.map(room => { container.innerHTML = Store.rooms.map(room => {
const lastMsg = room.last_message || '暂无消息'; const lastMsg = room.last_message || '暂无消息';
const lastTime = room.last_time || ''; const lastTime = room.last_time || '';
// 处理白名单管理员可见 // 白名单标签仅管理员可见
let whiteListHtml = ''; let whiteListHtml = '';
if (isAdmin && room.white_list && room.white_list.trim() !== '') { if (isAdmin && room.white_list && room.white_list.trim() !== '') {
const members = room.white_list.split(',').map(s => s.trim()).filter(Boolean).join(', '); const members = room.white_list.split(',').map(s => s.trim()).filter(Boolean).join(', ');
whiteListHtml = `<span class="white-list-tag" title="${escapeHtml(members)}">${escapeHtml(members)}</span>`; whiteListHtml = '<span class="white-list-tag" title="' + escapeHtml(members) + '">' + escapeHtml(members) + '</span>';
} }
return ` // 编辑按钮:管理员始终可见,普通用户仅自己群聊可见
<div class="chat-item" data-room-id="${room.id}"> const canEdit = isAdmin || (room.white_list && room.white_list.trim() === currentUser);
<div class="avatar" onclick="openPurchasePage('${room.id}')">${escapeHtml(room.name.charAt(0))}</div> const editBtnHtml = canEdit ? '<div class="room-actions">' +
<div class="chat-info" onclick="openPurchasePage('${room.id}')"> whiteListHtml +
<div class="chat-name">${escapeHtml(room.name)}</div> '<button class="edit-room-btn" onclick="event.stopPropagation(); openManageRoom(\'' + room.id + '\')" title="编辑群聊">' +
<div class="last-msg">${escapeHtml(lastMsg)}</div> '<svg viewBox="0 0 24 24"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>' +
</div> '</button>' +
<div class="chat-right"> '</div>' : '';
<div class="chat-time" onclick="openPurchasePage('${room.id}')">${lastTime}</div> return '<div class="chat-item" data-room-id="' + room.id + '">' +
${isAdmin ? `<div class="room-actions"> '<div class="avatar" onclick="openPurchasePage(\'' + room.id + '\')">' + escapeHtml(room.name.charAt(0)) + '</div>' +
${whiteListHtml} '<div class="chat-info" onclick="openPurchasePage(\'' + room.id + '\')">' +
<button class="edit-room-btn" onclick="event.stopPropagation(); openManageRoom('${room.id}')" title="编辑群聊"> '<div class="chat-name">' + escapeHtml(room.name) + '</div>' +
<svg viewBox="0 0 24 24"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg> '<div class="last-msg">' + escapeHtml(lastMsg) + '</div>' +
</button> '</div>' +
</div>` : ''} '<div class="chat-right">' +
</div> '<div class="chat-time" onclick="openPurchasePage(\'' + room.id + '\')">' + lastTime + '</div>' +
</div>`; editBtnHtml +
'</div>' +
'</div>';
}).join(''); }).join('');
} }

View File

@@ -1,12 +1,19 @@
// 采购相关路由 // 采购相关路由
const express = require('express'); const express = require('express');
const { authMiddleware, adminOnly } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
const { broadcastToRoom } = require('../ws'); const { broadcastToRoom } = require('../ws');
const db = require('../db'); const db = require('../db');
const { timestamp } = require('../utils'); const { timestamp } = require('../utils');
const router = express.Router(); const router = express.Router();
function insertChatMsg(roomId, user, text) {
const now = timestamp();
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, now);
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp: now };
broadcastToRoom(roomId, { type: 'new_message', message: msg });
}
router.get('/rooms/:roomId/purchases', authMiddleware, (req, res) => { router.get('/rooms/:roomId/purchases', authMiddleware, (req, res) => {
const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(req.params.roomId); const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(req.params.roomId);
res.json(purchases); res.json(purchases);
@@ -14,7 +21,7 @@ router.get('/rooms/:roomId/purchases', authMiddleware, (req, res) => {
router.get('/purchases/:id', authMiddleware, (req, res) => { router.get('/purchases/:id', authMiddleware, (req, res) => {
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!pur) return res.status(404).json({ error: '未找到' }); if (!pur) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const history = db.prepare('SELECT * FROM purchase_history WHERE purchase_id = ? ORDER BY id ASC').all(req.params.id); const history = db.prepare('SELECT * FROM purchase_history WHERE purchase_id = ? ORDER BY id ASC').all(req.params.id);
const attachments = db.prepare('SELECT * FROM purchase_attachments WHERE purchase_id = ?').all(req.params.id); const attachments = db.prepare('SELECT * FROM purchase_attachments WHERE purchase_id = ?').all(req.params.id);
res.json({ ...pur, history, attachments }); res.json({ ...pur, history, attachments });
@@ -23,7 +30,7 @@ router.get('/purchases/:id', authMiddleware, (req, res) => {
// 更新采购记录 // 更新采购记录
router.put('/purchases/:id', authMiddleware, (req, res) => { router.put('/purchases/:id', authMiddleware, (req, res) => {
const old = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); const old = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!old) return res.status(404).json({ error: '未找到' }); if (!old) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const now = timestamp(); const now = timestamp();
const username = req.user.username; const username = req.user.username;
@@ -44,21 +51,22 @@ router.put('/purchases/:id', authMiddleware, (req, res) => {
newVals.amount = newVals.quantity * newVals.unit_price + newVals.freight; newVals.amount = newVals.quantity * newVals.unit_price + newVals.freight;
const fieldLabels = { const fieldLabels = {
item: '物品', quantity: '数量', unit_price: '单价', freight: '邮费', amount: '金额', item: '\u7269\u54c1', quantity: '\u6570\u91cf', unit_price: '\u5355\u4ef7', freight: '\u90ae\u8d39', amount: '\u91d1\u989d',
payment_method: '付款方式', invoice_type: '发票', status: '状态', applicant: '申请人', remarks: '备注', created_at: '时间' payment_method: '\u4ed8\u6b3e\u65b9\u5f0f', invoice_type: '\u53d1\u7968', status: '\u72b6\u6001', applicant: '\u7533\u8bf7\u4eba', remarks: '\u5907\u6ce8', created_at: '\u65f6\u95f4'
}; };
const changes = []; const changes = [];
for (const [field, label] of Object.entries(fieldLabels)) { for (const [field, label] of Object.entries(fieldLabels)) {
if (String(newVals[field]) !== String(old[field])) { if (String(newVals[field]) !== String(old[field])) {
changes.push(label + ': ' + (old[field] || '') + ' -> ' + newVals[field]); changes.push(label + ': ' + (old[field] || '\u7a7a') + ' \u2192 ' + newVals[field]);
db.prepare('UPDATE purchases SET ' + field + ' = ? WHERE id = ?').run(newVals[field], old.id); db.prepare('UPDATE purchases SET ' + field + ' = ? WHERE id = ?').run(newVals[field], old.id);
} }
} }
if (changes.length > 0) { if (changes.length > 0) {
db.prepare('UPDATE purchases SET updated_at = ? WHERE id = ?').run(now, old.id); 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); db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(old.id, '\u66f4\u65b0\uff1a' + changes.join('\uff1b'), username, now);
insertChatMsg(old.room_id, '\u5c0f\u8d22', '\ud83d\udd04 ' + username + ' \u66f4\u65b0\u4e86\u91c7\u8d2d\u300c' + newVals.item + '\u300d\uff1a' + changes.join('\uff0c'));
broadcastToRoom(old.room_id, { type: 'purchase_updated' }); broadcastToRoom(old.room_id, { type: 'purchase_updated' });
res.json({ success: true, changes }); res.json({ success: true, changes });
} else { } else {
@@ -66,13 +74,16 @@ router.put('/purchases/:id', authMiddleware, (req, res) => {
} }
}); });
// 删除采购记录(管理员) // 删除采购记录
router.delete('/purchases/:id', authMiddleware, adminOnly, (req, res) => { router.delete('/purchases/:id', authMiddleware, (req, res) => {
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!pur) return res.status(404).json({ error: '未找到' }); if (!pur) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const username = req.user.username;
const itemName = pur.item;
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?').run(pur.id); 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 purchase_history WHERE purchase_id = ?').run(pur.id);
db.prepare('DELETE FROM purchases WHERE id = ?').run(pur.id); db.prepare('DELETE FROM purchases WHERE id = ?').run(pur.id);
insertChatMsg(pur.room_id, '\u5c0f\u8d22', '\ud83d\uddd1 ' + username + ' \u5220\u9664\u4e86\u91c7\u8d2d\u8bb0\u5f55\u300c' + itemName + '\u300d');
broadcastToRoom(pur.room_id, { type: 'purchase_updated' }); broadcastToRoom(pur.room_id, { type: 'purchase_updated' });
res.json({ success: true }); res.json({ success: true });
}); });
@@ -80,26 +91,26 @@ router.delete('/purchases/:id', authMiddleware, adminOnly, (req, res) => {
// 管理附件 // 管理附件
router.post('/purchases/:id/attachments', authMiddleware, (req, res) => { router.post('/purchases/:id/attachments', authMiddleware, (req, res) => {
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!pur) return res.status(404).json({ error: '未找到' }); if (!pur) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const now = timestamp(); const now = timestamp();
const username = req.user.username; const username = req.user.username;
const { file_paths } = req.body; const { file_paths } = req.body;
if (!file_paths || !file_paths.length) return res.status(400).json({ error: '缺少文件路径' }); if (!file_paths || !file_paths.length) return res.status(400).json({ error: '\u7f3a\u5c11\u6587\u4ef6\u8def\u5f84' });
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 (?,?,?,?)');
file_paths.forEach(fp => insertAttach.run(pur.id, fp, username, now)); 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); db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(pur.id, '\u6dfb\u52a0\u9644\u4ef6\uff1a' + file_paths.length + ' \u4e2a', username, now);
broadcastToRoom(pur.room_id, { type: 'purchase_updated' }); broadcastToRoom(pur.room_id, { type: 'purchase_updated' });
res.json({ success: true }); res.json({ success: true });
}); });
router.delete('/purchases/:id/attachments', authMiddleware, (req, res) => { router.delete('/purchases/:id/attachments', authMiddleware, (req, res) => {
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!pur) return res.status(404).json({ error: '未找到' }); if (!pur) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const username = req.user.username; const username = req.user.username;
const { file_path } = req.body; const { file_path } = req.body;
if (!file_path) return res.status(400).json({ error: '缺少文件路径' }); if (!file_path) return res.status(400).json({ error: '\u7f3a\u5c11\u6587\u4ef6\u8def\u5f84' });
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ? AND file_path = ?').run(pur.id, file_path); 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()); db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(pur.id, '\u5220\u9664\u9644\u4ef6\uff1a' + file_path.split('/').pop(), username, timestamp());
broadcastToRoom(pur.room_id, { type: 'purchase_updated' }); broadcastToRoom(pur.room_id, { type: 'purchase_updated' });
res.json({ success: true }); res.json({ success: true });
}); });
@@ -122,7 +133,7 @@ router.get('/summary', authMiddleware, (req, res) => {
router.get('/rooms/:roomId/purchases/export', authMiddleware, (req, res) => { router.get('/rooms/:roomId/purchases/export', authMiddleware, (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 = '\u65f6\u95f4,\u4e8b\u9879,\u6570\u91cf,\u5355\u4ef7,\u91d1\u989d,\u90ae\u8d39,\u4ed8\u6b3e\u65b9\u5f0f,\u53d1\u7968\u7c7b\u578b,\u72b6\u6001,\u7533\u8bf7\u4eba,\u5907\u6ce8\n';
purchases.forEach(p => { 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';
}); });