Files
ai-xiaocai/server/public/index.html

674 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>小财记账</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; height: 100vh; display: flex; justify-content: center; align-items: center; }
.app { width: 100%; max-width: 420px; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); position: relative; }
.hidden { display: none !important; }
.header { background: #1a1a1a; color: #fff; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; }
.header-left { display: flex; align-items: center; gap: 8px; }
.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; }
.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; }
.chat-item:active { background: #f9f9f9; }
.avatar { width: 44px; height: 44px; border-radius: 50%; background: #e0e0e0; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #555; flex-shrink: 0; }
.chat-info { flex: 1; min-width: 0; }
.chat-name { font-size: 16px; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.last-msg { font-size: 14px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-time { font-size: 12px; color: #aaa; margin-left: 8px; white-space: nowrap; }
.summary-card { background: #f0f7ff; margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.summary-card h4 { font-size: 16px; font-weight: 500; }
.summary-card .summary-preview { font-size: 14px; color: #555; }
.chat-window { display: flex; flex-direction: column; height: 100%; }
.chat-header { background: #1a1a1a; color: #fff; padding: 12px 16px; display: flex; align-items: center; }
.chat-header .back-btn { margin-right: 12px; }
.chat-header .chat-title { font-size: 17px; font-weight: 500; flex: 1; }
.messages { flex: 1; overflow-y: auto; padding: 12px; background: #fafafa; }
.msg { margin-bottom: 12px; display: flex; flex-direction: column; }
.msg.me { align-items: flex-end; }
.msg-bubble { max-width: 75%; padding: 10px 14px; border-radius: 20px; font-size: 16px; line-height: 1.5; word-wrap: break-word; overflow-wrap: break-word; }
.msg.me .msg-bubble { background: #d1f0d1; border-bottom-right-radius: 4px; }
.msg.other .msg-bubble { background: #fff; border: 1px solid #eee; border-bottom-left-radius: 4px; }
.msg-user { font-size: 12px; color: #888; margin-bottom: 2px; margin-left: 4px; }
.msg.me .msg-user { text-align: right; margin-right: 4px; }
.msg-time { font-size: 11px; color: #aaa; margin-top: 2px; }
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3 { font-size: 16px; margin: 5px 0; }
.msg-bubble table { width: 100%; border-collapse: collapse; margin: 8px 0; }
.msg-bubble th, .msg-bubble td { border: 1px solid #ddd; padding: 4px; text-align: left; font-size: 14px; }
.msg-bubble th { background: #f0f0f0; }
.msg-bubble pre { background: #f0f0f0; padding: 8px; border-radius: 4px; overflow-x: auto; }
.msg-bubble code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-size: 14px; }
.input-area { padding: 8px 12px; border-top: 1px solid #eee; display: flex; align-items: center; background: #fff; gap: 8px; }
.input-area textarea { flex: 1; border: 1px solid #ddd; border-radius: 12px; padding: 10px 12px; font-size: 16px; outline: none; resize: none; height: 40px; line-height: 1.4; }
.file-upload-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; }
.file-upload-btn svg { width: 24px; height: 24px; stroke: #666; fill: none; stroke-width: 2; }
.send-btn { background: #3b82f6; border: none; color: #fff; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
.send-btn svg { width: 18px; height: 18px; fill: #fff; }
.purchase-panel-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #fff; z-index: 10; display: flex; flex-direction: column; }
.purchase-panel-header { background: #1a1a1a; color: #fff; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; }
.purchase-panel-body { flex: 1; overflow-y: auto; padding: 12px; }
.purchase-month-group { margin-bottom: 20px; }
.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; }
.purchase-item .item-main { display: flex; justify-content: space-between; align-items: baseline; }
.purchase-item .item-name { font-size: 16px; font-weight: 500; }
.purchase-item .item-amount { font-size: 16px; font-weight: 600; }
.purchase-item .item-meta { display: flex; justify-content: space-between; margin-top: 4px; font-size: 13px; color: #888; }
.status-badge { background: #fef3c7; padding: 1px 6px; border-radius: 8px; font-size: 12px; }
.status-badge.done { background: #d1fae5; }
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 100; }
.modal { background: #fff; width: 90%; max-width: 400px; max-height: 80vh; border-radius: 12px; overflow: auto; padding: 16px; position: relative; }
.modal .modal-close-btn { position: absolute; top: 10px; right: 12px; background: none; border: none; cursor: pointer; }
.modal .modal-close-btn svg { width: 20px; height: 20px; stroke: #888; fill: none; stroke-width: 2; }
.modal h3 { margin-bottom: 12px; font-size: 18px; padding-right: 30px; }
.modal input { width: 100%; padding: 8px; margin: 8px 0; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; }
.modal button { margin-right: 8px; padding: 8px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 16px; }
.modal .primary-btn { background: #3b82f6; color: #fff; }
.history-list { list-style: none; font-size: 14px; }
.history-list li { padding: 6px 0; border-bottom: 1px solid #f0f0f0; display: flex; }
.history-time { min-width: 80px; color: #888; margin-right: 10px; font-size: 13px; }
.img-thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin: 4px; border: 1px solid #eee; }
.attachments { display: flex; flex-wrap: wrap; margin: 8px 0; }
.msg-img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
.msg-placeholder .msg-bubble { opacity: 0.6; }
</style>
</head>
<body>
<div class="app" id="app">
<!-- 登录 -->
<div id="login-page" class="modal-overlay">
<div class="modal">
<h2>登录小财记账</h2>
<input type="text" id="login-user" placeholder="用户名">
<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>
</div>
<!-- 主界面 -->
<div id="main-page" class="hidden" style="display:flex; flex-direction:column; height:100%;">
<!-- 群聊列表页 -->
<div id="list-page" style="display:flex; flex-direction:column; height:100%;">
<div class="header">
<div class="header-left">
<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>
<button class="icon-btn" id="create-room-btn" style="display:none;" 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>
</div>
<div class="chat-list" id="chat-list-container"></div>
<div class="summary-card" onclick="openSummary()">
<div>
<h4>采购汇总</h4>
<div class="summary-preview" id="summary-preview">加载中...</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#888" fill="none" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>
</div>
</div>
<!-- 聊天窗口 -->
<div id="chat-page" class="chat-window hidden">
<div class="chat-header">
<button class="icon-btn back-btn" onclick="showList()">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<span class="chat-title" id="current-chat-name"></span>
<button class="icon-btn" onclick="openPurchasePanel()" title="采购清单">
<svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
</button>
</div>
<div class="messages" id="messages-container"></div>
<div class="input-area">
<label class="file-upload-btn" title="上传图片">
<input type="file" id="file-input" accept="image/*" multiple style="display:none;" onchange="handleFileSelect(event)">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
</label>
<textarea id="msg-input" placeholder="输入消息..." rows="1"></textarea>
<button class="send-btn" onclick="sendMessage()">
<svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
</button>
</div>
<!-- 采购清单覆盖层 -->
<div id="purchase-panel-overlay" class="purchase-panel-overlay hidden">
<div class="purchase-panel-header">
<span>采购清单</span>
<button class="icon-btn close-btn" onclick="closePurchasePanel()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="purchase-panel-body" id="purchase-panel-body"></div>
</div>
</div>
</div>
<!-- 创建群聊弹窗 -->
<div id="create-modal" class="modal-overlay hidden">
<div class="modal">
<button class="modal-close-btn" onclick="closeCreateModal()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<h3>新建项目群聊</h3>
<input type="text" id="new-room-name" placeholder="群聊名称">
<input type="text" id="white-list" placeholder="白名单成员(逗号分隔,留空全员可见)">
<button class="primary-btn" onclick="confirmCreateRoom()">创建</button>
</div>
</div>
<!-- 采购详情弹窗 -->
<div id="detail-modal" class="modal-overlay hidden">
<div class="modal">
<button class="modal-close-btn" onclick="closeDetailModal()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<h3 id="detail-title"></h3>
<div id="detail-content"></div>
</div>
</div>
</div>
<script>
const API = '';
let ws;
let currentRoom = null;
let rooms = [];
let pendingUploads = [];
const sentMsgIds = new Set(); // 本地已处理的消息 ID防止广播重复
function getToken() { return localStorage.getItem('token'); }
function saveAuth(data) {
localStorage.setItem('token', data.token);
localStorage.setItem('currentUser', data.username);
localStorage.setItem('isAdmin', data.isAdmin);
}
function clearAuth() {
localStorage.removeItem('token');
localStorage.removeItem('currentUser');
localStorage.removeItem('isAdmin');
}
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true, sanitize: false });
}
// 图片压缩
function compressImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > 1200) {
height = height * (1200 / width);
width = 1200;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
async function init() {
const token = getToken();
if (token) {
try {
const res = await fetch(API + '/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').style.display = 'flex';
}
initWebSocket();
loadRooms();
updateSummaryPreview();
return;
}
} catch(e) {}
clearAuth();
}
document.getElementById('login-page').classList.remove('hidden');
document.getElementById('main-page').classList.add('hidden');
}
async function login() {
const u = document.getElementById('login-user').value;
const p = document.getElementById('login-pass').value;
const res = await fetch(API + '/api/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
});
if (res.ok) {
const data = await res.json();
saveAuth(data);
document.getElementById('login-page').classList.add('hidden');
document.getElementById('main-page').classList.remove('hidden');
if (data.isAdmin) document.getElementById('create-room-btn').style.display = 'flex';
initWebSocket();
loadRooms();
updateSummaryPreview();
} else {
const err = await res.json();
document.getElementById('login-error').textContent = err.error || '登录失败';
}
}
// WebSocket 重连管理
let wsReconnectTimer = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000;
function initWebSocket() {
const token = getToken();
if (!token) return;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}?token=${token}`);
ws.onopen = () => {
console.log('WebSocket 已连接');
reconnectAttempts = 0;
if (currentRoom) {
ws.send(JSON.stringify({ type: 'join', roomId: currentRoom }));
}
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'new_message') {
const msg = data.message;
// 发送者本人不会收到自己的广播(已由后端过滤),因此直接添加
if (currentRoom === msg.room_id) {
appendMessage(msg);
}
if (data.room_preview) {
const room = rooms.find(r => r.id === data.room_preview.room_id);
if (room) {
room.last_message = data.room_preview.last_message;
room.last_time = data.room_preview.last_time;
renderChatList();
}
}
} else if (data.type === 'purchase_updated') {
if (currentRoom) loadPurchases();
updateSummaryPreview();
} else if (data.type === 'room_created') {
loadRooms();
}
};
ws.onerror = (e) => console.error('WebSocket 错误', e);
ws.onclose = (event) => {
console.log('WebSocket 关闭,代码:', event.code);
if (event.code === 1000) return;
scheduleReconnect();
};
}
function scheduleReconnect() {
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY);
reconnectAttempts++;
wsReconnectTimer = setTimeout(() => {
console.log('尝试重连...');
initWebSocket();
}, delay);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (!ws || ws.readyState !== WebSocket.OPEN) {
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
initWebSocket();
}
}
});
// 群聊列表
async function loadRooms() {
const token = getToken();
const res = await fetch(API + '/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } });
if (res.status === 401) { clearAuth(); location.reload(); return; }
rooms = await res.json();
renderChatList();
}
function renderChatList() {
const container = document.getElementById('chat-list-container');
container.innerHTML = rooms.map(room => {
const lastMsg = room.last_message || '暂无消息';
const lastTime = room.last_time || '';
return `
<div class="chat-item" onclick="openChat('${room.id}')">
<div class="avatar">${room.name.charAt(0)}</div>
<div class="chat-info">
<div class="chat-name">${room.name}</div>
<div class="last-msg">${escapeHtml(lastMsg)}</div>
</div>
<div class="chat-time">${lastTime}</div>
</div>`;
}).join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function updateSummaryPreview() {
const token = getToken();
try {
const res = await fetch(API + '/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
const summary = await res.json();
const totalItems = summary.reduce((s, r) => s + r.purchase_count, 0);
const totalAmount = summary.reduce((s, r) => s + r.total_amount, 0);
const preview = document.getElementById('summary-preview');
if (preview) preview.innerText = `${totalItems} 条,合计 ¥${totalAmount}`;
} catch(e) {}
}
// 打开聊天室(可自动展开采购面板)
async function openChat(roomId, autoOpenPurchase = false) {
currentRoom = roomId;
document.getElementById('messages-container').innerHTML = '';
document.getElementById('list-page').classList.add('hidden');
document.getElementById('chat-page').classList.remove('hidden');
const room = rooms.find(r => r.id === roomId);
document.getElementById('current-chat-name').textContent = room?.name || '';
document.getElementById('purchase-panel-overlay').classList.add('hidden');
if (autoOpenPurchase) {
setTimeout(() => {
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
loadPurchases();
}, 200);
}
const token = getToken();
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
const msgs = await res.json();
window._messagesCache = msgs;
renderMessages(msgs);
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'join', roomId }));
}
function showList() {
document.getElementById('chat-page').classList.add('hidden');
document.getElementById('list-page').classList.remove('hidden');
currentRoom = null;
loadRooms();
updateSummaryPreview();
}
function renderMessages(msgs) {
const container = document.getElementById('messages-container');
const currentUser = localStorage.getItem('currentUser');
container.innerHTML = msgs.map(msg => {
const isMe = msg.user === currentUser;
const name = msg.user === '小财' ? '小财' : msg.user;
let contentHtml = msg.text ? escapeHtml(msg.text) : '';
if (msg.user === '小财' && typeof marked !== 'undefined' && msg.text) {
try { contentHtml = marked.parse(msg.text); } catch(e) {}
}
let attachHtml = '';
if (msg.attachments && msg.attachments.length) {
try {
const files = typeof msg.attachments === 'string' ? JSON.parse(msg.attachments) : msg.attachments;
attachHtml = files.map(f => {
const isImage = /\.(jpg|jpeg|png|gif)$/i.test(f);
if (isImage) return `<img class="msg-img" src="${f}" alt="${f}" loading="lazy" onload="scrollToBottom()">`;
return `<div><a href="${f}" target="_blank">📎 ${f.split('/').pop()}</a></div>`;
}).join('');
} catch(e) {}
}
return `
<div class="msg ${isMe ? 'me' : 'other'}" data-msg-id="${msg.id}">
<div class="msg-user">${name}</div>
<div class="msg-bubble">${contentHtml}${attachHtml}</div>
<div class="msg-time">${msg.timestamp}</div>
</div>`;
}).join('');
container.scrollTop = container.scrollHeight;
}
function scrollToBottom() {
const container = document.getElementById('messages-container');
if (container) container.scrollTop = container.scrollHeight;
}
function appendMessage(msg) {
if (!window._messagesCache) window._messagesCache = [];
window._messagesCache.push(msg);
renderMessages(window._messagesCache);
}
// 发送消息(文本/图片统一入口)
async function sendMessage() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text && pendingUploads.length === 0) return;
if (!currentRoom) return;
const token = getToken();
try {
const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text || '', attachments: pendingUploads })
});
const newMsg = await res.json();
// 不记录 sentMsgIds因为后端已经过滤我们直接 append
appendMessage(newMsg);
input.value = '';
pendingUploads = [];
} catch(e) {
alert('发送失败,请重试');
}
}
// 选图后自动压缩上传,显示占位,然后发送
async function handleFileSelect(event) {
const files = event.target.files;
if (!files.length) return;
const token = getToken();
const placeholderId = 'placeholder_' + Date.now();
const placeholderMsg = {
id: placeholderId,
room_id: currentRoom,
user: localStorage.getItem('currentUser'),
text: '图片发送中...',
attachments: [],
timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }),
isPlaceholder: true
};
if (!window._messagesCache) window._messagesCache = [];
window._messagesCache.push(placeholderMsg);
renderMessages(window._messagesCache);
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 + '/api/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (data.path) pendingUploads.push(data.path);
}
// 移除占位
window._messagesCache = window._messagesCache.filter(m => m.id !== placeholderId);
// 发送正式消息
sendMessage();
} catch(e) {
window._messagesCache = window._messagesCache.filter(m => m.id !== placeholderId);
renderMessages(window._messagesCache);
alert('图片发送失败,请重试');
}
}
// 采购清单相关
async function loadPurchases() {
if (!currentRoom) return;
const token = getToken();
const res = await fetch(API + `/api/rooms/${currentRoom}/purchases`, { headers: { 'Authorization': `Bearer ${token}` } });
const items = await res.json();
renderPurchasePanel(items);
}
function renderPurchasePanel(items) {
const container = document.getElementById('purchase-panel-body');
if (items.length === 0) {
container.innerHTML = '<p style="text-align:center;color:#888;padding:40px;">暂无采购记录</p>';
return;
}
const groups = {};
items.forEach(item => {
const month = formatMonth(item.created_at);
if (!groups[month]) groups[month] = [];
groups[month].push(item);
});
let html = '';
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>`;
monthItems.forEach(item => {
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
html += `
<div class="purchase-item" onclick="openDetail('${item.id}')">
<div class="item-main"><span class="item-name">${item.item}</span><span class="item-amount">¥${item.amount}</span></div>
<div class="item-meta">
<span>${dateShort} ${timeShort}</span>
<span>${item.applicant || ''} / ${item.method || ''}</span>
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
</div>
</div>`;
});
html += '</div>';
});
container.innerHTML = html;
}
function formatMonth(dateStr) {
const parts = dateStr.split('/');
if (parts.length >= 2) {
const year = parts[0];
const month = parseInt(parts[1], 10);
return `${year}${month}`;
}
return dateStr.substring(0,7);
}
function openPurchasePanel() {
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
loadPurchases();
}
function closePurchasePanel() {
document.getElementById('purchase-panel-overlay').classList.add('hidden');
}
async function openDetail(pid) {
const token = getToken();
const res = await fetch(API + `/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 = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}">`).join('') + '</div>';
}
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || '';
document.getElementById('detail-content').innerHTML = `
<p><strong>金额:</strong>¥${p.amount} | <strong>方式:</strong>${p.method}</p>
<p><strong>申请人:</strong>${p.applicant}</p>
${attachHtml}
<h4 style="margin-top:12px;">操作历史</h4>
<ul class="history-list">${historyHtml}</ul>`;
document.getElementById('detail-modal').classList.remove('hidden');
}
function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); }
async function openSummary() {
const token = getToken();
const res = await fetch(API + '/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
const summary = await res.json();
let html = '';
if (summary.length === 0) html = '<p>暂无采购记录</p>';
else {
summary.forEach(s => {
html += `<div class="summary-card" onclick="event.stopPropagation(); closeDetailModal(); openChat('${s.room_id}', true)" style="margin:8px 0;">
<h4>${s.room_name}</h4>
<p>${s.purchase_count} 条,合计 ¥${s.total_amount}</p>
</div>`;
});
}
document.getElementById('detail-title').textContent = '采购汇总';
document.getElementById('detail-content').innerHTML = html;
document.getElementById('detail-modal').classList.remove('hidden');
}
function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); }
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('请输入名称');
const whiteList = document.getElementById('white-list').value.trim();
const token = getToken();
await fetch(API + '/api/rooms', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, whiteList })
});
closeCreateModal();
loadRooms();
}
document.addEventListener('keydown', function(e) {
if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) {
e.preventDefault();
sendMessage();
}
});
init();
</script>
</body>
</html>