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', ' = __("uploading") ?>');
-
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 || '= __("send_failed") ?>');
}
} 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 = `
+
`;
- 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 = '= __("welcome_support") ?>
';
+ 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('
-
-
- `;
- 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