Files
ai-xiaocai/server/public/index.html
2026-07-22 12:03:30 +08:00

789 lines
44 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>
<meta name="theme-color" content="#1e3c72">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<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: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
.header-left { display: flex; align-items: center; gap: 8px; }
.header-left h2 { font-size: 18px; font-weight: 500; }
.icon-btn { background: none; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; border-radius: 50%; }
.icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
#create-room-btn { visibility: hidden; }
#create-room-btn.visible { visibility: visible; }
.chat-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
.chat-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; cursor: pointer; }
.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-right { display: flex; flex-direction: column; align-items: flex-end; margin-left: 8px; flex-shrink: 0; min-width: 56px; }
.chat-time { font-size: 12px; color: #aaa; white-space: nowrap; }
.room-actions { display: flex; gap: 4px; margin-top: 4px; }
.edit-room-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; padding: 2px; }
.edit-room-btn svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; }
.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: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; align-items: center; min-height: 48px; }
.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; cursor: pointer; }
.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: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
.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; flex-wrap: wrap; gap: 4px; }
.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; }
.version { text-align: center; font-size: 12px; color: #aaa; margin-top: 16px; }
.fullscreen-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center;
z-index: 200; cursor: pointer;
}
.fullscreen-overlay img { max-width: 100%; max-height: 100%; object-fit: contain; }
.fullscreen-overlay .fullscreen-text {
background: #fff; padding: 20px; border-radius: 8px; max-width: 90%; max-height: 80%;
overflow: auto; font-size: 16px; line-height: 1.5; color: #333;
}
.fullscreen-overlay .fullscreen-text img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
.detail-row { margin-bottom: 8px; }
</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 class="version">v2.4</div>
</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" 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="manage-modal" class="modal-overlay hidden">
<div class="modal">
<button class="modal-close-btn" onclick="closeManageModal()">
<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="manage-title">编辑群聊</h3>
<input type="text" id="manage-room-name" placeholder="群聊名称">
<input type="text" id="manage-white-list" placeholder="白名单成员(逗号分隔)">
<button class="primary-btn" onclick="saveRoom()">保存</button>
<button class="close-modal" style="background:#e53e3e;color:#fff;margin-top:8px;" onclick="deleteRoom()">删除群聊</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 messagesContainer = document.getElementById('messages-container');
let messageCache = [];
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, 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);
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderMessageHTML(msg) {
const currentUser = localStorage.getItem('currentUser');
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">`;
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>`;
}
function renderAllMessages(msgs) {
messageCache = msgs || [];
messagesContainer.innerHTML = messageCache.map(renderMessageHTML).join('');
scrollToBottom();
bindMessageEvents();
}
function appendMessage(msg) {
messageCache.push(msg);
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg));
scrollToBottom();
bindMessageEvents();
}
function replaceMessage(msgId, newMsg) {
const idx = messageCache.findIndex(m => m.id == msgId);
if (idx !== -1) {
messageCache[idx] = newMsg;
const oldEl = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`);
if (oldEl) oldEl.outerHTML = renderMessageHTML(newMsg);
} else {
appendMessage(newMsg);
}
bindMessageEvents();
}
function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
function bindMessageEvents() {
document.querySelectorAll('.msg-img').forEach(img => {
img.removeEventListener('dblclick', onImageDblClick);
img.addEventListener('dblclick', onImageDblClick);
});
document.querySelectorAll('.msg-bubble').forEach(bubble => {
bubble.removeEventListener('dblclick', onBubbleDblClick);
bubble.addEventListener('dblclick', onBubbleDblClick);
});
}
function onImageDblClick(e) {
e.stopPropagation();
const overlay = document.createElement('div');
overlay.className = 'fullscreen-overlay';
overlay.innerHTML = `<img src="${e.target.src}" alt="">`;
overlay.addEventListener('click', () => overlay.remove());
document.body.appendChild(overlay);
}
function onBubbleDblClick(e) {
e.stopPropagation();
const bubble = e.currentTarget;
const clone = bubble.cloneNode(true);
const overlay = document.createElement('div');
overlay.className = 'fullscreen-overlay';
const container = document.createElement('div');
container.className = 'fullscreen-text';
container.innerHTML = clone.innerHTML;
overlay.appendChild(container);
overlay.addEventListener('click', () => overlay.remove());
document.body.appendChild(overlay);
}
// 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();
} else if (data.type === 'room_updated') {
const room = rooms.find(r => r.id === data.room.id);
if (room) { room.name = data.room.name; room.white_list = data.room.white_list; renderChatList(); }
} else if (data.type === 'room_deleted') {
rooms = rooms.filter(r => r.id !== data.roomId);
renderChatList();
if (currentRoom === data.roomId) showList();
}
};
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 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').classList.add('visible');
} else {
document.getElementById('create-room-btn').classList.remove('visible');
}
initWebSocket();
loadRooms();
updateSummaryPreview();
initSwipeGestures();
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').classList.add('visible');
} else {
document.getElementById('create-room-btn').classList.remove('visible');
}
initWebSocket();
loadRooms();
updateSummaryPreview();
initSwipeGestures();
} else {
const err = await res.json();
document.getElementById('login-error').textContent = err.error || '登录失败';
}
}
function initSwipeGestures() {
const chatPage = document.getElementById('chat-page');
const purchaseOverlay = document.getElementById('purchase-panel-overlay');
let startX = 0, startY = 0;
function handleTouchStart(e) {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}
function handleTouchEnd(e, target) {
if (!startX) return;
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const diffX = endX - startX;
const diffY = endY - startY;
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) {
if (Math.abs(diffX) > 30) closePurchasePanel();
} else if (target === chatPage) {
if (diffX > 30) showList();
else if (diffX < -30) openPurchasePanel();
} else if (target === purchaseOverlay) {
if (Math.abs(diffX) > 30) closePurchasePanel();
}
}
startX = 0; startY = 0;
}
chatPage.addEventListener('touchstart', handleTouchStart, { passive: true });
chatPage.addEventListener('touchend', (e) => handleTouchEnd(e, chatPage), { passive: true });
purchaseOverlay.addEventListener('touchstart', handleTouchStart, { passive: true });
purchaseOverlay.addEventListener('touchend', (e) => handleTouchEnd(e, purchaseOverlay), { passive: true });
}
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');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
container.innerHTML = rooms.map(room => {
const lastMsg = room.last_message || '暂无消息';
const lastTime = room.last_time || '';
return `
<div class="chat-item" data-room-id="${room.id}">
<div class="avatar" onclick="openChat('${room.id}')">${room.name.charAt(0)}</div>
<div class="chat-info" onclick="openChat('${room.id}')">
<div class="chat-name">${room.name}</div>
<div class="last-msg">${escapeHtml(lastMsg)}</div>
</div>
<div class="chat-right">
<div class="chat-time" onclick="openChat('${room.id}')">${lastTime}</div>
${isAdmin ? `<div class="room-actions">
<button class="edit-room-btn" onclick="event.stopPropagation(); openManageRoom('${room.id}')" title="编辑群聊">
<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>
</button>
</div>` : ''}
</div>
</div>`;
}).join('');
}
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('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(() => openPurchasePanel(), 200);
const token = getToken();
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
const msgs = await res.json();
renderAllMessages(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();
}
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();
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
};
appendMessage(placeholderMsg);
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);
}
messageCache = messageCache.filter(m => m.id !== placeholderId);
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
if (placeholderEl) placeholderEl.remove();
sendMessage();
} catch(e) {
messageCache = messageCache.filter(m => m.id !== placeholderId);
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
if (placeholderEl) placeholderEl.remove();
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.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''}</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) return `${parts[0]}${parseInt(parts[1], 10)}`;
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 = `
<div class="detail-row"><strong>数量:</strong>${p.quantity}</div>
<div class="detail-row"><strong>单价:</strong>¥${p.unit_price}</div>
<div class="detail-row"><strong>邮费:</strong>¥${p.freight}</div>
<div class="detail-row"><strong>总金额:</strong>¥${p.amount}</div>
<div class="detail-row"><strong>付款方式:</strong>${p.payment_method || '未指定'}</div>
<div class="detail-row"><strong>发票:</strong>${p.invoice_type || '无票'}</div>
<div class="detail-row"><strong>状态:</strong>${p.status}</div>
<div class="detail-row"><strong>申请人:</strong>${p.applicant}</div>
<div class="detail-row"><strong>采购时间:</strong>${p.created_at}</div>
<div class="detail-row"><strong>备注:</strong>${p.remarks || '无'}</div>
${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();
}
function openManageRoom(roomId) {
const room = rooms.find(r => r.id === roomId);
if (!room) return;
document.getElementById('manage-title').textContent = '编辑群聊';
document.getElementById('manage-room-name').value = room.name;
document.getElementById('manage-white-list').value = room.white_list || '';
document.getElementById('manage-modal').dataset.roomId = roomId;
document.getElementById('manage-modal').classList.remove('hidden');
}
function closeManageModal() { document.getElementById('manage-modal').classList.add('hidden'); }
async function saveRoom() {
const roomId = document.getElementById('manage-modal').dataset.roomId;
const name = document.getElementById('manage-room-name').value.trim();
const whiteList = document.getElementById('manage-white-list').value.trim();
if (!name) return alert('请输入群聊名称');
const token = getToken();
const res = await fetch(API + '/api/rooms/' + roomId, {
method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, whiteList })
});
if (res.ok) { closeManageModal(); loadRooms(); }
else { const err = await res.json(); alert(err.error || '更新失败'); }
}
async function deleteRoom() {
if (!confirm('确定要删除该群聊吗?所有消息和采购数据将被永久删除。')) return;
const roomId = document.getElementById('manage-modal').dataset.roomId;
const token = getToken();
const res = await fetch(API + '/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
if (res.ok) {
closeManageModal();
loadRooms();
if (currentRoom === roomId) showList();
} else { const err = await res.json(); alert(err.error || '删除失败'); }
}
document.addEventListener('keydown', function(e) {
if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) { e.preventDefault(); sendMessage(); }
});
init();
</script>
</body>
</html>