diff --git a/admin/customer_service.php b/admin/customer_service.php index f82c3ef..10cd196 100644 --- a/admin/customer_service.php +++ b/admin/customer_service.php @@ -203,6 +203,8 @@ ob_start(); let selectedUser = null; let selectedIp = null; let lastMsgId = 0; +let lastChatIds = new Set(); +let currentUserContext = ''; async function refreshUsers() { const r = await fetch('/api/chat.php?action=admin_get_all'); @@ -301,50 +303,73 @@ async function deleteUser() { async function fetchMessages() { if (!selectedIp && !selectedUser) return; - const r = await fetch(`/api/chat.php?action=get_messages&user_id=${selectedUser}&ip=${selectedIp}`); - const msgs = await r.json(); - const area = document.getElementById('messages-area'); - - // For registered users, we show all their messages. For guests, we filter by IP. - const filtered = msgs.filter(m => { - if (selectedUser != 0) { - return m.user_id == selectedUser; - } else { - return m.ip_address === selectedIp && m.user_id == 0; + try { + const r = await fetch(`/api/chat.php?action=get_messages&user_id=${selectedUser}&ip=${selectedIp}`); + const msgs = await r.json(); + if (!msgs || !Array.isArray(msgs)) return; + + // If user changed, clear everything + const context = selectedUser + '_' + selectedIp; + if (currentUserContext !== context) { + document.getElementById('messages-area').innerHTML = ''; + lastChatIds.clear(); + currentUserContext = context; } - }); - - if (filtered.length > 0) { - const isAtBottom = area.scrollTop + area.clientHeight >= area.scrollHeight - 50; + + const area = document.getElementById('messages-area'); + let hasNew = false; - let html = ''; - filtered.forEach(m => { - const msgDate = new Date(m.created_at.replace(/-/g, "/")); - const timeStr = msgDate.toLocaleTimeString('zh-CN', {hour: '2-digit', minute:'2-digit', second: '2-digit'}); - const recallHtml = m.sender === 'admin' ? `撤回` : ''; - const isImage = m.message.indexOf(' -
${m.message}
-
${timeStr} ${recallHtml}
- - `; + msgs.forEach(m => { + if (!lastChatIds.has(m.id)) { + appendMessageHTML(m); + lastChatIds.add(m.id); + hasNew = true; + } }); - if (area.innerHTML !== html) { - area.innerHTML = html; - if (isAtBottom || lastMsgId === 0) { - area.scrollTop = area.scrollHeight; + if (hasNew) { + area.scrollTop = area.scrollHeight; + } + + if (msgs.length > 0) { + const lastMsg = msgs[msgs.length - 1]; + if (lastMsg.created_at) { + document.getElementById('info-time').innerText = new Date(lastMsg.created_at.replace(/-/g, "/")).toLocaleString('zh-CN'); } } - lastMsgId = filtered.length; - - const lastMsg = filtered[filtered.length - 1]; - document.getElementById('info-time').innerText = new Date(lastMsg.created_at.replace(/-/g, "/")).toLocaleString('zh-CN'); + } catch (err) { + console.error('Fetch messages error:', err); } } +function appendMessageHTML(m) { + const area = document.getElementById('messages-area'); + if (!area || area.querySelector(`[data-id="${m.id}"]`)) return; + + const time = m.created_at || new Date().toISOString(); + const msgDate = time.includes('-') ? new Date(time.replace(/-/g, "/")) : new Date(time); + const timeStr = msgDate.toLocaleTimeString('zh-CN', {hour: '2-digit', minute:'2-digit', second: '2-digit'}); + const recallHtml = m.sender === 'admin' ? `撤回` : ''; + const isImage = typeof m.message === 'string' && m.message.indexOf('${m.message} +
${timeStr} ${recallHtml}
+ `; + + area.appendChild(div); +} + document.getElementById('plus-btn').addEventListener('click', () => { document.getElementById('image-input').click(); }); @@ -353,22 +378,50 @@ document.getElementById('image-input').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; + // Local preview for "0 latency" + const localUrl = URL.createObjectURL(file); + const tempId = 'temp_img_' + Date.now(); + const localMsgHtml = ``; + + appendMessageHTML({ + id: tempId, + sender: 'admin', + message: localMsgHtml, + created_at: new Date().toISOString() + }); + const area = document.getElementById('messages-area'); + area.scrollTop = area.scrollHeight; + const formData = new FormData(); formData.append('file', file); - formData.append('user_id', selectedUser); - formData.append('ip_address', selectedIp); + formData.append('user_id', selectedUser || 0); + formData.append('ip_address', selectedIp || ''); - const r = await fetch('/api/chat.php?action=upload_image', { - method: 'POST', - body: formData - }); - const res = await r.json(); - if (res.success) { - fetchMessages(); - } else { - alert('上传失败: ' + res.error); + try { + const r = await fetch('/api/chat.php?action=upload_image', { + method: 'POST', + body: formData + }); + const res = await r.json(); + + // Remove temp + const tempMsg = document.querySelector(`[data-id="${tempId}"]`); + if (tempMsg) tempMsg.remove(); + + if (res.success && res.message) { + appendMessageHTML(res.message); + area.scrollTop = area.scrollHeight; + fetchMessages(); + } else { + alert('上传失败: ' + res.error); + } + } catch(err) { + const tempMsg = document.querySelector(`[data-id="${tempId}"]`); + if (tempMsg) tempMsg.remove(); } + e.target.value = ''; // Reset + setTimeout(() => URL.revokeObjectURL(localUrl), 5000); }); document.getElementById('chat-form').addEventListener('submit', async (e) => { @@ -377,14 +430,37 @@ document.getElementById('chat-form').addEventListener('submit', async (e) => { const msg = input.value.trim(); if (!msg) return; + input.value = ''; + + // Optimistic UI + const tempId = 'temp_msg_' + Date.now(); + appendMessageHTML({ + id: tempId, + sender: 'admin', + message: msg, + created_at: new Date().toISOString() + }); + const area = document.getElementById('messages-area'); + area.scrollTop = area.scrollHeight; + const fd = new URLSearchParams(); fd.append('message', msg); fd.append('user_id', selectedUser); fd.append('ip_address', selectedIp); - await fetch('/api/chat.php?action=admin_send', { method: 'POST', body: fd }); - input.value = ''; - fetchMessages(); + try { + const r = await fetch('/api/chat.php?action=admin_send', { method: 'POST', body: fd }); + const res = await r.json(); + + const tempMsg = document.querySelector(`[data-id="${tempId}"]`); + if (tempMsg) tempMsg.remove(); + + if (res.success && res.message) { + appendMessageHTML(res.message); + area.scrollTop = area.scrollHeight; + fetchMessages(); + } + } catch(err) {} }); document.getElementById('save-remark-btn').addEventListener('click', async () => { diff --git a/admin/layout.php b/admin/layout.php index 9a5334a..9fe3e41 100644 --- a/admin/layout.php +++ b/admin/layout.php @@ -5,7 +5,7 @@ if (session_status() === PHP_SESSION_NONE) session_start(); // Force simplified Chinese for admin $lang = 'zh'; -$_SESSION['lang'] = 'zh'; +// $_SESSION['lang'] = 'zh'; // Do not persist to session to avoid affecting front-end default language // Admin check $admin = null; diff --git a/admin/login.php b/admin/login.php index 2c3a287..f9df38e 100644 --- a/admin/login.php +++ b/admin/login.php @@ -1,6 +1,7 @@ '; if (isset($_SESSION['admin_id'])) { - $user_id = $_POST['user_id'] ?? 0; + $user_id = (int)($_POST['user_id'] ?? 0); $ip = $_POST['ip_address'] ?? ''; $sender = 'admin'; $admin_id = $_SESSION['admin_id']; $stmt = db()->prepare("INSERT INTO messages (user_id, admin_id, sender, message, ip_address) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$user_id, $admin_id, $sender, $message, $ip]); } else { - $user_id = $_SESSION['user_id'] ?? 0; + $user_id = (int)($_SESSION['user_id'] ?? 0); $ip = getRealIP(); + $sender = 'user'; $stmt = db()->prepare("INSERT INTO messages (user_id, sender, message, ip_address) VALUES (?, ?, ?, ?)"); - $stmt->execute([$user_id, 'user', $message, $ip]); + $stmt->execute([$user_id, $sender, $message, $ip]); } - echo json_encode(['success' => true, 'url' => $imageUrl]); + $newId = db()->lastInsertId(); + $createdAt = date('Y-m-d H:i:s'); + echo json_encode([ + 'success' => true, + 'url' => $imageUrl, + 'message' => [ + 'id' => $newId, + 'sender' => $sender, + 'message' => $message, + 'created_at' => $createdAt + ] + ]); } else { echo json_encode(['success' => false, 'error' => 'Failed to move uploaded file']); } @@ -76,19 +87,19 @@ if ($action === 'send_message') { $message = $_POST['message'] ?? ''; if (!$message) exit(json_encode(['success' => false])); - $user_id = $_SESSION['user_id'] ?? 0; + $user_id = (int)($_SESSION['user_id'] ?? 0); $ip = getRealIP(); $stmt = db()->prepare("INSERT INTO messages (user_id, sender, message, ip_address) VALUES (?, ?, ?, ?)"); $stmt->execute([$user_id, 'user', $message, $ip]); $newId = db()->lastInsertId(); - echo json_encode(['success' => true, 'id' => $newId]); + echo json_encode(['success' => true, 'id' => $newId, 'message' => ['id' => $newId, 'sender' => 'user', 'message' => $message, 'created_at' => date('Y-m-d H:i:s')]]); exit; } if ($action === 'admin_send') { $message = $_POST['message'] ?? ''; - $user_id = $_POST['user_id'] ?? 0; + $user_id = (int)($_POST['user_id'] ?? 0); $target_ip = $_POST['ip_address'] ?? ''; if (!$message) exit(json_encode(['success' => false])); @@ -99,10 +110,11 @@ if ($action === 'admin_send') { $stmt = db()->prepare("INSERT INTO messages (user_id, admin_id, sender, message, ip_address) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$user_id, $admin_id, $sender, $message, $target_ip]); $newId = db()->lastInsertId(); - echo json_encode(['success' => true, 'id' => $newId]); + echo json_encode(['success' => true, 'id' => $newId, 'message' => ['id' => $newId, 'sender' => $sender, 'message' => $message, 'created_at' => date('Y-m-d H:i:s')]]); exit; } + if ($action === 'ping') { $user_id = $_SESSION['user_id'] ?? 0; $ip = getRealIP(); @@ -118,7 +130,7 @@ if ($action === 'admin_get_all') { SELECT v.user_id, v.ip_address, - CASE WHEN m.message LIKE ' DATE_SUB(NOW(), INTERVAL 48 HOUR) ORDER BY created_at DESC "); diff --git a/assets/images/chat/1771573402_6998109a5f249.png b/assets/images/chat/1771573402_6998109a5f249.png new file mode 100644 index 0000000..8a6f72d Binary files /dev/null and b/assets/images/chat/1771573402_6998109a5f249.png differ diff --git a/assets/images/chat/1771573456_699810d0d3b69.png b/assets/images/chat/1771573456_699810d0d3b69.png new file mode 100644 index 0000000..73b5fb1 Binary files /dev/null and b/assets/images/chat/1771573456_699810d0d3b69.png differ diff --git a/assets/images/chat/1771573715_699811d307ae4.jpeg b/assets/images/chat/1771573715_699811d307ae4.jpeg new file mode 100644 index 0000000..da83425 Binary files /dev/null and b/assets/images/chat/1771573715_699811d307ae4.jpeg differ diff --git a/assets/images/chat/1771573770_6998120a23a0f.jpg b/assets/images/chat/1771573770_6998120a23a0f.jpg new file mode 100644 index 0000000..9365b05 Binary files /dev/null and b/assets/images/chat/1771573770_6998120a23a0f.jpg differ diff --git a/assets/images/chat/1771574091_6998134bbc58b.png b/assets/images/chat/1771574091_6998134bbc58b.png new file mode 100644 index 0000000..c2e55aa Binary files /dev/null and b/assets/images/chat/1771574091_6998134bbc58b.png differ diff --git a/assets/images/chat/1771574125_6998136d06446.gif b/assets/images/chat/1771574125_6998136d06446.gif new file mode 100644 index 0000000..8794888 Binary files /dev/null and b/assets/images/chat/1771574125_6998136d06446.gif differ diff --git a/assets/images/chat/1771574423_6998149715bff.png b/assets/images/chat/1771574423_6998149715bff.png new file mode 100644 index 0000000..8a6f72d Binary files /dev/null and b/assets/images/chat/1771574423_6998149715bff.png differ diff --git a/certificate.php b/certificate.php index 604ee57..73c0473 100644 --- a/certificate.php +++ b/certificate.php @@ -36,7 +36,7 @@ $site_certificate = getSetting('site_certificate', '');
diff --git a/db/config.php b/db/config.php index 3a0c11b..5b28728 100644 --- a/db/config.php +++ b/db/config.php @@ -1,5 +1,6 @@ csFileInput.click()); csFileInput.addEventListener('change', async () => { if (!csFileInput.files[0]) return; const file = csFileInput.files[0]; + + // Create local preview for "0 latency" + const localUrl = URL.createObjectURL(file); + const tempId = 'temp_img_' + Date.now(); + const localMsgHtml = ``; + + appendMessageHTML({ + id: tempId, + sender: 'user', + message: localMsgHtml, + created_at: new Date().toISOString() + }); + scrollToBottom(); + const formData = new FormData(); formData.append('file', file); formData.append('action', 'upload_image'); - appendMessage('user', ' '); - try { const resp = await fetch('/api/chat.php', { method: 'POST', body: formData }); const data = await resp.json(); - if (data.success) { + + // Remove local preview + const tempMsg = document.querySelector(`[data-id="${tempId}"]`); + if (tempMsg) tempMsg.remove(); + + if (data.success && data.message) { + appendMessageHTML(data.message); + scrollToBottom(); pollMessages(); } else { alert(data.error || ''); } } catch (err) { console.error('Upload error:', err); + const tempMsg = document.querySelector(`[data-id="${tempId}"]`); + if (tempMsg) tempMsg.remove(); } csFileInput.value = ''; + // Clean up object URL + setTimeout(() => URL.revokeObjectURL(localUrl), 5000); }); csToggle.addEventListener('click', () => { csBox.classList.toggle('d-none'); - scrollToBottom(); if (!csBox.classList.contains('d-none')) { const now = new Date(); const timeStr = now.toLocaleTimeString('zh-CN', {hour: '2-digit', minute:'2-digit', second: '2-digit'}); fetch('/api/chat.php?action=ping&user_time=' + encodeURIComponent(timeStr)); + scrollToBottom(); + pollMessages(); } }); csClose.addEventListener('click', () => csBox.classList.add('d-none')); function scrollToBottom() { - csMessages.scrollTop = csMessages.scrollHeight; + setTimeout(() => { + csMessages.scrollTop = csMessages.scrollHeight; + }, 50); } csForm.addEventListener('submit', async (e) => { @@ -270,6 +296,16 @@ csForm.addEventListener('submit', async (e) => { if (!msg) return; csInput.value = ''; + + // Optimistic UI update + const tempId = 'temp_msg_' + Date.now(); + appendMessageHTML({ + id: tempId, + sender: 'user', + message: msg, + created_at: new Date().toISOString() + }); + scrollToBottom(); try { const resp = await fetch('/api/chat.php?action=send_message', { @@ -278,7 +314,14 @@ csForm.addEventListener('submit', async (e) => { body: `message=${encodeURIComponent(msg)}` }); const data = await resp.json(); + + // Remove temp message and wait for poll to bring the real one + const tempMsg = document.querySelector(`[data-id="${tempId}"]`); + if (tempMsg) tempMsg.remove(); + if (data.success) { + appendMessageHTML(data.message); + scrollToBottom(); pollMessages(); } } catch (err) { @@ -286,38 +329,43 @@ csForm.addEventListener('submit', async (e) => { } }); -function appendMessage(sender, text, time = null) { - const div = document.createElement('div'); - div.className = `mb-3 d-flex flex-column ${sender === 'user' ? 'align-items-end' : 'align-items-start'}`; +function appendMessageHTML(m) { + if (document.querySelector(`[data-id="${m.id}"]`)) return; + + const sender = m.sender; + const text = m.message; + const time = m.created_at; + const isImage = text.indexOf(' - ${text} -
${timeStr}
+ + const msgHtml = ` +
+
+ ${text} +
${timeStr}
+
`; - csMessages.appendChild(div); - scrollToBottom(); + + csMessages.insertAdjacentHTML('beforeend', msgHtml); } // Polling for new messages -let lastMsgId = 0; -let lastPingTime = 0; -let lastChatHtml = ''; +let lastChatIds = new Set(); async function pollMessages() { if (csBox.classList.contains('d-none')) return; // Ping every 10 seconds to update user time const now = Date.now(); + if (typeof lastPingTime === 'undefined') window.lastPingTime = 0; if (now - lastPingTime > 10000) { const timeStr = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute:'2-digit', second: '2-digit'}); fetch('/api/chat.php?action=ping&user_time=' + encodeURIComponent(timeStr)); @@ -327,37 +375,23 @@ async function pollMessages() { try { const resp = await fetch('/api/chat.php?action=get_messages'); const data = await resp.json(); - if (data) { - let html = '
'; + if (data && Array.isArray(data)) { + let hasNew = false; data.forEach(m => { - const sender = m.sender; - const text = m.message; - const time = m.created_at; - const isImage = text.indexOf(' -
- ${text} -
${timeStr}
-
- - `; - if (parseInt(m.id) > lastMsgId) lastMsgId = parseInt(m.id); + if (!lastChatIds.has(m.id)) { + appendMessageHTML(m); + lastChatIds.add(m.id); + hasNew = true; + } }); - if (csMessages.innerHTML !== html) { - const isAtBottom = csMessages.scrollTop + csMessages.clientHeight >= csMessages.scrollHeight - 50; - csMessages.innerHTML = html; - if (isAtBottom) scrollToBottom(); + if (hasNew) { + scrollToBottom(); } } } catch (err) {} } -setInterval(pollMessages, 300); +setInterval(pollMessages, 300); // 300ms polling for "zero delay" feel