Autosave: 20260221-080254

This commit is contained in:
Flatlogic Bot 2026-02-21 08:02:55 +00:00
parent 7cbcf80f62
commit ef3ca58d34
24 changed files with 413 additions and 190 deletions

View File

@ -49,6 +49,28 @@ ob_start();
background: #e7f3ff;
border-left: 4px solid #007bff;
}
.user-card {
position: relative;
}
.delete-chat-btn {
position: absolute;
top: 35px;
right: 10px;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
padding: 5px;
border-radius: 4px;
background: rgba(255,255,255,0.9);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.user-card:hover .delete-chat-btn {
opacity: 1;
}
.delete-chat-btn:hover {
background: #fff0f0;
color: #dc3545 !important;
}
.chat-header {
padding: 12px 20px;
border-bottom: 1px solid #eee;
@ -61,38 +83,56 @@ ob_start();
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
gap: 16px;
}
.msg {
max-width: 80%;
padding: 8px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
max-width: 75%;
padding: 10px 16px;
font-size: 14.5px;
line-height: 1.6;
position: relative;
display: flex;
flex-direction: column;
box-shadow: 0 2px 5px rgba(0,0,0,0.03);
transition: transform 0.2s;
}
.msg:hover {
transform: translateY(-1px);
}
.msg-time {
font-size: 10px;
opacity: 0.7;
margin-top: 4px;
margin-top: 6px;
font-weight: 500;
}
.msg-admin {
align-self: flex-end;
background: #007bff;
margin-left: auto;
background: linear-gradient(135deg, #007bff, #0056b3);
color: #fff;
border-bottom-right-radius: 2px;
border-radius: 18px 18px 2px 18px;
}
.msg-admin .msg-time {
text-align: right;
color: #e0e0e0;
color: rgba(255,255,255,0.8);
}
.msg-user {
align-self: flex-start;
background: #f0f0f0;
color: #333;
border-bottom-left-radius: 2px;
margin-right: auto;
background: #f1f3f5;
color: #212529;
border-radius: 18px 18px 18px 2px;
border: 1px solid #e9ecef;
}
.msg-user::before {
content: attr(data-ip);
position: absolute;
top: -20px;
left: 4px;
font-size: 10px;
font-weight: bold;
color: #adb5bd;
white-space: nowrap;
}
.msg-user .msg-time {
color: #888;
@ -118,13 +158,22 @@ ob_start();
background: #fcfcfc;
}
.status-online {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
background: #28a745;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
}
.pulse-animation {
animation: pulse-green 2s infinite;
box-shadow: 0 0 0 rgba(40, 167, 69, 0.4);
}
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); }
}
#remark-text {
height: 150px;
font-size: 13px;
@ -144,20 +193,26 @@ ob_start();
<!-- Main Chat Window -->
<div class="main-chat">
<div class="chat-header" id="chat-header" style="display: none;">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="m-0 fw-bold"><span id="header-name">用户名称</span> <small class="text-muted fw-normal">(UID: <span id="header-uid">---</span>)</small></h6>
<div class="small" id="header-meta">
<span class="text-muted">实时 IP:</span> <span class="text-primary fw-bold" id="info-ip-header">---</span>
<span class="mx-2 text-muted">|</span>
<span class="text-muted">用户时间:</span> <span class="text-dark fw-bold" id="info-user-time">---</span>
<div class="chat-header" id="chat-header" style="display: none; background: #f8f9fa; border-bottom: 2px solid #e9ecef;">
<div class="d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center gap-3">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center shadow-sm" style="width: 45px; height: 45px;">
<i class="bi bi-person-fill fs-4"></i>
</div>
<div>
<h5 class="m-0 fw-bold text-dark"><span id="header-name">用户名称</span> <small class="text-muted fw-normal" style="font-size: 12px;">(UID: <span id="header-uid">---</span>)</small></h5>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="badge bg-danger px-2 py-1 shadow-sm" style="font-size: 11px;"><i class="bi bi-geo-alt-fill me-1"></i>实时定位: <span id="info-ip-header">---</span></span>
<span class="badge bg-secondary px-2 py-1 shadow-sm" style="font-size: 11px;"><i class="bi bi-clock-fill me-1"></i>本地时间: <span id="info-user-time">---</span></span>
</div>
</div>
</div>
<div>
<span class="status-online"></span>
<span class="small text-success">在线</span>
<button class="btn btn-link btn-sm text-danger text-decoration-none ms-2" onclick="deleteUser()"><i class="bi bi-trash"></i> 删除用户</button>
<div class="text-end">
<div class="d-flex align-items-center gap-2 mb-1 justify-content-end">
<span class="status-online pulse-animation"></span>
<span class="small text-success fw-bold">在线通话中</span>
</div>
<button class="btn btn-outline-danger btn-sm py-0 px-2 border-0" onclick="deleteUser()" style="font-size: 11px;"><i class="bi bi-trash"></i> 清空记录</button>
</div>
</div>
</div>
@ -245,6 +300,7 @@ ob_start();
<div class="user-info-box small">
<div class="mb-2"><strong>UID:</strong> <span id="info-uid">---</span></div>
<div class="mb-2"><strong>当前IP:</strong> <span id="info-ip">---</span></div>
<div class="mb-2"><strong>注册IP:</strong> <span id="info-reg-ip">---</span></div>
<div class="mb-2"><strong>最近活跃:</strong> <span id="info-time">---</span></div>
</div>
</div>
@ -260,113 +316,124 @@ let currentUserContext = '';
let lastMsgCount = 0;
let notifySound = new Audio('https://assets.mixkit.co/active_storage/sfx/2358/2358-preview.mp3');
async function refreshUsers() {
try {
const list = document.getElementById('user-list');
if (!list) return;
async function refreshUsers() {
try {
const list = document.getElementById('user-list');
if (!list) return;
const searchInput = document.getElementById('user-search');
const search = searchInput ? searchInput.value.toLowerCase() : '';
const r = await fetch('/api/chat.php?action=admin_get_all');
if (!r.ok) return;
const users = await r.json();
if (users.error || !Array.isArray(users)) return;
// Sound notification for new users or new messages
let currentTotalMsgs = users.reduce((acc, u) => acc + (u.message ? 1 : 0), 0);
if (lastMsgCount > 0 && currentTotalMsgs > lastMsgCount) {
notifySound.play().catch(e => {});
// Visual feedback
if (document.hidden) {
document.title = "【新消息】客服系统";
}
}
lastMsgCount = currentTotalMsgs;
// Reset title when active
window.onfocus = () => { document.title = "客服系统"; };
if (users.length === 0) {
list.innerHTML = '<div class="p-4 text-center text-muted small">暂无活跃会话 (720h内)</div>';
return;
}
let html = '';
users.forEach(u => {
const userId = u.user_id || 0;
const username = (u.username || '匿名访客').toString();
const uid = (u.uid || '---').toString();
const ip = (u.ip_address || '---').toString();
const rawRemark = (u.remark || '').toString();
const userTime = (u.user_time || '---').toString();
const searchInput = document.getElementById('user-search');
const search = searchInput ? searchInput.value.toLowerCase() : '';
// Search filter
if (search && !username.toLowerCase().includes(search) && !ip.includes(search) && !uid.includes(search)) {
const r = await fetch('/api/chat.php?action=admin_get_all');
if (!r.ok) return;
const users = await r.json();
if (users.error || !Array.isArray(users)) return;
// Sound notification for new users or new messages
let currentTotalMsgs = users.reduce((acc, u) => acc + (u.message ? 1 : 0), 0);
if (lastMsgCount > 0 && currentTotalMsgs > lastMsgCount) {
notifySound.play().catch(e => {});
// Visual feedback
if (document.hidden) {
document.title = "【新消息】客服系统";
}
}
lastMsgCount = currentTotalMsgs;
// Reset title when active
window.onfocus = () => { document.title = "客服系统"; };
if (users.length === 0) {
list.innerHTML = '<div class="p-4 text-center text-muted small">暂无活跃会话</div>';
return;
}
const createdAt = u.created_at || '';
const lastTimeStr = createdAt ? createdAt.replace(/-/g, "/") : new Date().toISOString();
const lastTime = new Date(lastTimeStr);
let lastMsgText = (u.message || '').toString();
if (lastMsgText.startsWith('[PAYMENT_INFO]')) {
lastMsgText = '[收款账号信息]';
let html = '';
users.forEach(u => {
const userId = u.user_id || 0;
const username = (u.username || '匿名访客').toString();
const uid = (u.uid || '---').toString();
const ip = (u.ip_address || '---').toString();
const rawRemark = (u.remark || '').toString();
const userTime = (u.user_time || '---').toString();
const registrationIp = (u.registration_ip || '---').toString();
// Search filter
if (search && !username.toLowerCase().includes(search) && !ip.includes(search) && !uid.includes(search)) {
return;
}
const createdAt = u.created_at || '';
const lastTimeStr = createdAt ? createdAt.replace(/-/g, "/") : new Date().toISOString();
const lastTime = new Date(lastTimeStr);
let lastMsgText = (u.message || '').toString();
if (lastMsgText.startsWith('[PAYMENT_INFO]')) {
lastMsgText = '[收款账号信息]';
}
const isActive = (selectedIp === ip && selectedUser == userId);
// Safe strings for onclick
const jsName = username.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const jsRemark = rawRemark.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
html += `
<div class="user-card ${isActive ? 'active' : ''}"
onclick="openChat('${userId}', '${ip}', '${jsName}', '${uid}', '${jsRemark}', '${userTime}', '${registrationIp}')">
<i class="bi bi-trash text-muted delete-chat-btn" title="删除会话" onclick="deleteChat('${userId}', '${ip}', event)"></i>
<div class="d-flex justify-content-between mb-1">
<span class="fw-bold small text-truncate" style="max-width: 150px;">${username}</span>
<span class="text-muted" style="font-size: 10px;">${isNaN(lastTime.getTime()) ? '---' : lastTime.toLocaleTimeString('zh-CN', {hour: '2-digit', minute:'2-digit'})}</span>
</div>
${rawRemark ? `<div class="small text-danger text-truncate mb-1" style="font-size: 11px;">[备注: ${rawRemark}]</div>` : ''}
<div class="small text-truncate text-muted mb-1" style="font-size: 12px;">${lastMsgText}</div>
<div class="d-flex justify-content-between align-items-center" style="font-size: 10px; margin-top: 4px; padding: 4px; background: rgba(0,123,255,0.05); border-radius: 4px;">
<span class="text-secondary">UID: ${uid}</span>
<span class="text-primary fw-bold"><i class="bi bi-geo-alt-fill me-1"></i>${ip}</span>
</div>
<div class="mt-1 text-muted" style="font-size: 9px;"><i class="bi bi-person-plus me-1"></i>注册IP: ${registrationIp}</div>
</div>
`;
});
list.innerHTML = html || '<div class="p-4 text-center text-muted small">未找到匹配的会话</div>';
} catch (err) {
console.error('Refresh users failed:', err);
const list = document.getElementById('user-list');
if (list) {
list.innerHTML = `<div class="p-4 text-center text-danger small">脚本运行错误: ${err.message}</div>`;
}
const isActive = (selectedIp === ip && selectedUser == userId);
// Safe strings for onclick
const jsName = username.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const jsRemark = rawRemark.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
html += `
<div class="user-card ${isActive ? 'active' : ''}"
onclick="openChat('${userId}', '${ip}', '${jsName}', '${uid}', '${jsRemark}', '${userTime}')">
<div class="d-flex justify-content-between mb-1">
<span class="fw-bold small text-truncate" style="max-width: 150px;">${username}</span>
<span class="text-muted" style="font-size: 10px;">${isNaN(lastTime.getTime()) ? '---' : lastTime.toLocaleTimeString('zh-CN', {hour: '2-digit', minute:'2-digit'})}</span>
</div>
${rawRemark ? `<div class="small text-danger text-truncate mb-1" style="font-size: 11px;">[备注: ${rawRemark}]</div>` : ''}
<div class="small text-truncate text-muted mb-1" style="font-size: 12px;">${lastMsgText}</div>
<div class="d-flex justify-content-between align-items-center" style="font-size: 10px;">
<span class="text-secondary">UID: ${uid}</span>
<span class="text-primary fw-bold">${ip}</span>
</div>
</div>
`;
});
list.innerHTML = html || '<div class="p-4 text-center text-muted small">未找到匹配的会话</div>';
} catch (err) {
console.error('Refresh users failed:', err);
const list = document.getElementById('user-list');
if (list) {
list.innerHTML = `<div class="p-4 text-center text-danger small">脚本运行错误: ${err.message}</div>`;
}
}
}
function openChat(userId, ip, name, uid, remark, userTime) {
selectedUser = userId;
selectedIp = ip;
document.getElementById('header-name').innerText = name;
document.getElementById('header-uid').innerText = uid;
document.getElementById('info-ip-header').innerText = ip;
document.getElementById('info-user-time').innerText = userTime;
document.getElementById('chat-header').style.display = 'block';
document.getElementById('input-area').style.display = 'block';
document.getElementById('remark-area').style.display = 'block';
document.getElementById('remark-text').value = remark;
document.getElementById('info-uid').innerText = uid;
document.getElementById('info-ip').innerText = ip;
lastMsgId = 0;
fetchMessages();
refreshUsers();
}
function openChat(userId, ip, name, uid, remark, userTime, regIp) {
selectedUser = userId;
selectedIp = ip;
document.getElementById('header-name').innerText = name;
document.getElementById('header-uid').innerText = uid;
document.getElementById('info-ip-header').innerText = ip;
document.getElementById('info-user-time').innerText = userTime;
document.getElementById('chat-header').style.display = 'block';
document.getElementById('input-area').style.display = 'block';
document.getElementById('remark-area').style.display = 'block';
// IP Location fetch
fetch(`https://ipapi.co/${ip}/json/`).then(r => r.json()).then(data => {
if (data.city) {
document.getElementById('info-ip-header').innerText = `${ip} (${data.city}, ${data.country_name})`;
}
}).catch(() => {});
document.getElementById('remark-text').value = remark;
document.getElementById('info-uid').innerText = uid;
document.getElementById('info-ip').innerText = ip;
document.getElementById('info-reg-ip').innerText = regIp || '---';
lastMsgId = 0;
fetchMessages();
refreshUsers();
}
async function recallMessage(msgId) {
if (!confirm('确定撤回该消息吗?')) return;
@ -379,6 +446,32 @@ async function recallMessage(msgId) {
}
}
async function deleteChat(userId, ip, event) {
if (event) event.stopPropagation();
if (!confirm('确定删除该会话及其所有记录吗?')) return;
const fd = new URLSearchParams();
fd.append('user_id', userId);
fd.append('ip_address', ip);
const r = await fetch('/api/chat.php?action=admin_delete_user', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
if (selectedUser == userId && selectedIp == ip) {
selectedUser = null;
selectedIp = null;
document.getElementById('chat-header').style.display = 'none';
document.getElementById('input-area').style.display = 'none';
document.getElementById('remark-area').style.display = 'none';
document.getElementById('messages-area').innerHTML = `
<div class="m-auto text-center text-muted">
<i class="bi bi-chat-left-dots fs-1 d-block mb-2"></i>
<p>请从左侧选择一个会话</p>
</div>
`;
}
refreshUsers();
}
}
async function deleteUser() {
if (!confirm('确定删除该用户的所有聊天记录吗?此操作不可恢复!')) return;
const fd = new URLSearchParams();
@ -442,26 +535,41 @@ async function fetchMessages() {
}
}
function appendMessageHTML(m) {
const area = document.getElementById('messages-area');
if (!area) return;
function appendMessageHTML(m) {
const area = document.getElementById('messages-area');
if (!area) 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' ? `<span class="recall-btn text-white-50 ms-2" style="cursor:pointer; text-decoration:underline;" onclick="recallMessage(${m.id})">撤回</span>` : '';
const isImage = typeof m.message === 'string' && m.message.indexOf('<img') !== -1;
const isPaymentInfo = typeof m.message === 'string' && m.message.startsWith('[PAYMENT_INFO]');
const div = document.createElement('div');
div.className = `msg ${m.sender === 'admin' ? 'msg-admin' : 'msg-user'}`;
div.setAttribute('data-id', m.id);
let displayMsg = (m.message || '').toString();
if (isImage && !displayMsg.includes('chat-img-preview')) {
displayMsg = displayMsg.replace('<img ', '<img class="chat-img-preview" ');
}
if (isPaymentInfo) {
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' ? `<span class="recall-btn text-white-50 ms-2" style="cursor:pointer; text-decoration:underline;" onclick="recallMessage(${m.id})">撤回</span>` : '';
let displayMsg = (m.message || '').toString();
const isImage = displayMsg.includes('<img') || displayMsg.includes('/assets/images/chat/') || displayMsg.includes('data:image');
const isPaymentInfo = displayMsg.startsWith('[PAYMENT_INFO]');
if (isImage) {
if (!displayMsg.includes('<img')) {
// It's just a path or data URL, wrap it
displayMsg = `<img src="${displayMsg}" class="img-fluid rounded chat-img-preview" style="max-width: 100%; max-height: 250px; object-fit: contain; margin: 5px 0; display: block; cursor: zoom-in;" onclick="window.showLightbox ? window.showLightbox(this.src) : window.open(this.src)">`;
}
if (!displayMsg.includes('chat-img-preview')) {
displayMsg = displayMsg.replace('<img ', '<img class="chat-img-preview" ');
}
if (displayMsg.includes('src="assets/')) {
displayMsg = displayMsg.replace('src="assets/', 'src="/assets/');
}
}
const div = document.createElement('div');
div.className = `msg ${m.sender === 'admin' ? 'msg-admin' : 'msg-user'}`;
div.setAttribute('data-id', m.id);
if (m.sender === 'user' && m.ip_address) {
div.setAttribute('data-ip', 'IP: ' + m.ip_address);
div.style.marginTop = '18px'; // Make space for IP label
}
if (isPaymentInfo) {
try {
const info = JSON.parse(displayMsg.replace('[PAYMENT_INFO]', ''));
displayMsg = `
@ -496,14 +604,14 @@ function appendMessageHTML(m) {
}
if (isImage) {
div.style.padding = '5px';
div.style.background = m.sender === 'admin' ? '#007bff' : '#f0f0f0';
div.style.lineHeight = '0';
div.style.padding = '6px';
div.style.background = m.sender === 'admin' ? '#007bff' : '#f8f9fa';
div.style.border = '1px solid rgba(0,0,0,0.05)';
}
div.innerHTML = `
<div class="msg-content">${displayMsg}</div>
<div class="msg-time" style="${isImage ? 'position: absolute; bottom: 8px; right: 10px; background: rgba(0,0,0,0.4); color: #fff; padding: 0 6px; border-radius: 4px; font-size: 9px; line-height: 1.5;' : ''}">${timeStr} ${recallHtml}</div>
<div class="msg-time" style="${isImage ? 'position: absolute; bottom: 8px; right: 10px; background: rgba(0,0,0,0.5); color: #fff; padding: 2px 6px; border-radius: 6px; font-size: 9px; line-height: 1; backdrop-filter: blur(4px);' : ''}">${timeStr} ${recallHtml}</div>
`;
area.appendChild(div);
@ -687,7 +795,7 @@ document.getElementById('user-search').addEventListener('input', refreshUsers);
async function startPolling() {
await refreshUsers();
await fetchMessages();
setTimeout(startPolling, 3000);
setTimeout(startPolling, 1000);
}
startPolling();

View File

@ -507,14 +507,17 @@ function renderAdminPage($content, $title = '后台管理') {
}
</style>
<script>
window.showLightbox = function(src) {
const lightbox = document.getElementById('chat-lightbox');
const img = document.getElementById('lightbox-img');
if (lightbox && img) {
img.src = src;
lightbox.classList.add('active');
}
};
document.addEventListener('click', function(e) {
if (e.target.classList.contains('chat-img-preview')) {
const lightbox = document.getElementById('chat-lightbox');
const img = document.getElementById('lightbox-img');
if (lightbox && img) {
img.src = e.target.src;
lightbox.classList.add('active');
}
window.showLightbox(e.target.src);
}
});
</script>

View File

@ -28,11 +28,21 @@ if ($action === 'upload_image' || (isset($_POST['action']) && $_POST['action'] =
$targetPath = $targetDir . $filename;
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
$imageUrl = '/assets/images/chat/' . $filename;
$message = '<img src="' . $imageUrl . '" class="img-fluid rounded cursor-pointer chat-img-preview" style="max-width: 100%; max-height: 250px; object-fit: contain; margin: 5px 0;">';
$message = '<img src="' . $imageUrl . '" class="img-fluid rounded chat-img-preview" style="max-width: 100%; max-height: 250px; object-fit: contain; margin: 5px 0; display: block; cursor: pointer;" onclick="if(window.showLightbox) window.showLightbox(this.src); else window.open(this.src);">';
if (isset($_SESSION['admin_id'])) {
$user_id = (int)($_POST['user_id'] ?? 0);
$ip = $_POST['ip_address'] ?? '';
// If IP is missing, try to get it from messages or fallback to current IP
if (empty($ip)) {
if ($user_id != 0) {
$stmt = db()->prepare("SELECT ip_address FROM messages WHERE user_id = ? AND ip_address != '' ORDER BY id DESC LIMIT 1");
$stmt->execute([$user_id]);
$ip = $stmt->fetchColumn() ?: getRealIP();
} else {
$ip = getRealIP();
}
}
$sender = 'admin';
$admin_id = $_SESSION['admin_id'];
$stmt = db()->prepare("INSERT INTO messages (user_id, admin_id, sender, message, ip_address) VALUES (?, ?, ?, ?, ?)");
@ -40,6 +50,14 @@ if ($action === 'upload_image' || (isset($_POST['action']) && $_POST['action'] =
} else {
$user_id = (int)($_SESSION['user_id'] ?? 0);
$ip = getRealIP();
// Fallback: If user_id is 0 but we find a user with this registration IP, associate it
if ($user_id === 0) {
$stmt = db()->prepare("SELECT id FROM users WHERE registration_ip = ? OR last_login_ip = ? ORDER BY id DESC LIMIT 1");
$stmt->execute([$ip, $ip]);
$user_id = (int)($stmt->fetchColumn() ?: 0);
}
$sender = 'user';
$stmt = db()->prepare("INSERT INTO messages (user_id, sender, message, ip_address) VALUES (?, ?, ?, ?)");
$stmt->execute([$user_id, $sender, $message, $ip]);
@ -47,6 +65,14 @@ if ($action === 'upload_image' || (isset($_POST['action']) && $_POST['action'] =
$newId = db()->lastInsertId();
$createdAt = date('Y-m-d H:i:s');
// Update visitors table to ensure immediate visibility for both users and admins
if ($sender === 'user') {
$user_time = date('H:i:s');
$stmt = db()->prepare("INSERT INTO chat_visitors (user_id, ip_address, user_time) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE last_ping = CURRENT_TIMESTAMP");
$stmt->execute([$user_id, $ip, $user_time]);
}
echo json_encode([
'success' => true,
'url' => $imageUrl,
@ -70,12 +96,23 @@ if ($action === 'get_messages') {
// If admin is requesting, we use the provided user_id and ip
if (isset($_SESSION['admin_id'])) {
$stmt = db()->prepare("SELECT * FROM messages WHERE (user_id = ? AND user_id != 0) OR (user_id = 0 AND ip_address = ?) ORDER BY created_at ASC");
$stmt = db()->prepare("SELECT * FROM messages WHERE (user_id = ? AND user_id != 0) OR (ip_address = ? AND ip_address != '') ORDER BY created_at ASC");
$stmt->execute([$target_user_id, $target_ip]);
} else {
// User requesting their own messages
$stmt = db()->prepare("SELECT * FROM messages WHERE (user_id = ? AND user_id != 0) OR (user_id = 0 AND ip_address = ?) ORDER BY created_at ASC");
$stmt->execute([$user_id, getRealIP()]);
// If logged in, get by user_id. Also get by IP to catch visitor history.
$user_id = (int)($_SESSION['user_id'] ?? 0);
$ip = getRealIP();
// Fallback: If user_id is 0 but we find a user with this IP, associate it
if ($user_id === 0) {
$stmt = db()->prepare("SELECT id FROM users WHERE registration_ip = ? OR last_login_ip = ? ORDER BY id DESC LIMIT 1");
$stmt->execute([$ip, $ip]);
$user_id = (int)($stmt->fetchColumn() ?: 0);
}
$stmt = db()->prepare("SELECT * FROM messages WHERE (user_id = ? AND user_id != 0) OR (ip_address = ? AND ip_address != '') ORDER BY created_at ASC");
$stmt->execute([$user_id, $ip]);
}
$messages = $stmt->fetchAll();
@ -89,6 +126,13 @@ if ($action === 'send_message') {
$user_id = (int)($_SESSION['user_id'] ?? 0);
$ip = getRealIP();
// Fallback: If user_id is 0 but we find a user with this registration IP, associate it
if ($user_id === 0) {
$stmt = db()->prepare("SELECT id FROM users WHERE registration_ip = ? OR last_login_ip = ? ORDER BY id DESC LIMIT 1");
$stmt->execute([$ip, $ip]);
$user_id = (int)($stmt->fetchColumn() ?: 0);
}
$stmt = db()->prepare("INSERT INTO messages (user_id, sender, message, ip_address) VALUES (?, ?, ?, ?)");
$stmt->execute([$user_id, 'user', $message, $ip]);
@ -112,6 +156,17 @@ if ($action === 'admin_send') {
if (!$message) exit(json_encode(['success' => false, 'error' => 'Empty message']));
// Robust IP matching: if target_ip is empty but user_id is set, try to find their last IP
if (empty($target_ip)) {
if ($user_id != 0) {
$stmt = db()->prepare("SELECT ip_address FROM messages WHERE user_id = ? AND ip_address != '' ORDER BY id DESC LIMIT 1");
$stmt->execute([$user_id]);
$target_ip = $stmt->fetchColumn() ?: getRealIP();
} else {
$target_ip = getRealIP();
}
}
$admin_id = $_SESSION['admin_id'] ?? 1;
$sender = 'admin';
@ -141,10 +196,10 @@ if ($action === 'admin_get_all') {
exit;
}
try {
// Robust query to get all active chat sessions
// Robust query to get all active chat sessions, merging guest sessions into user sessions if IP matches
$stmt = db()->query("
SELECT
v.user_id,
v.final_user_id as user_id,
v.ip_address,
CASE
WHEN m.message LIKE '<img%' THEN '[图片消息]'
@ -154,32 +209,53 @@ if ($action === 'admin_get_all') {
COALESCE(m.created_at, v.last_activity) as created_at,
COALESCE(u.username, u.email, CONCAT('访客 ', COALESCE(v.ip_address, '0.0.0.0'))) as username,
IFNULL(u.uid, '---') as uid,
IFNULL(u.registration_ip, '---') as registration_ip,
IFNULL(r.remark, '') as remark,
IFNULL(v.user_time, '---') as user_time
FROM (
SELECT
user_id,
ip_address,
final_user_id,
MAX(ip_address) as ip_address,
MAX(last_activity) as last_activity,
MAX(user_time) as user_time,
MAX(has_recharge) as has_recharge
FROM (
SELECT COALESCE(user_id, 0) as user_id, IFNULL(ip_address, '') as ip_address, created_at as last_activity, NULL as user_time, 0 as has_recharge FROM messages
SELECT
COALESCE(NULLIF(user_id, 0), (SELECT id FROM users WHERE registration_ip = messages.ip_address LIMIT 1), 0) as final_user_id,
IFNULL(ip_address, '') as ip_address,
created_at as last_activity,
NULL as user_time,
0 as has_recharge
FROM messages
UNION ALL
SELECT COALESCE(user_id, 0) as user_id, IFNULL(ip_address, '') as ip_address, last_ping as last_activity, user_time, 0 as has_recharge FROM chat_visitors
SELECT
COALESCE(NULLIF(user_id, 0), (SELECT id FROM users WHERE registration_ip = chat_visitors.ip_address LIMIT 1), 0) as final_user_id,
IFNULL(ip_address, '') as ip_address,
last_ping as last_activity,
user_time,
0 as has_recharge
FROM chat_visitors
UNION ALL
SELECT COALESCE(user_id, 0) as user_id, IFNULL(ip_address, '') as ip_address, created_at as last_activity, NULL as user_time, 1 as has_recharge FROM finance_requests
SELECT
COALESCE(NULLIF(user_id, 0), (SELECT id FROM users WHERE registration_ip = finance_requests.ip_address LIMIT 1), 0) as final_user_id,
IFNULL(ip_address, '') as ip_address,
created_at as last_activity,
NULL as user_time,
1 as has_recharge
FROM finance_requests
) t1
GROUP BY (CASE WHEN user_id = 0 THEN 0 ELSE user_id END), (CASE WHEN user_id = 0 THEN ip_address ELSE '0' END)
GROUP BY (CASE WHEN final_user_id = 0 THEN 0 ELSE final_user_id END), (CASE WHEN final_user_id = 0 THEN ip_address ELSE '0' END)
) v
LEFT JOIN (
SELECT m1.* FROM messages m1
INNER JOIN (
SELECT MAX(id) as max_id FROM messages GROUP BY COALESCE(user_id, 0), (CASE WHEN COALESCE(user_id, 0) = 0 THEN ip_address ELSE '0' END)
SELECT MAX(id) as max_id FROM (
SELECT id, COALESCE(NULLIF(user_id, 0), (SELECT id FROM users WHERE registration_ip = messages.ip_address LIMIT 1), 0) as final_user_id, ip_address FROM messages
) t2 GROUP BY (CASE WHEN final_user_id = 0 THEN 0 ELSE final_user_id END), (CASE WHEN final_user_id = 0 THEN ip_address ELSE '0' END)
) m2 ON m1.id = m2.max_id
) m ON (v.user_id = COALESCE(m.user_id, 0) AND (v.user_id != 0 OR IFNULL(v.ip_address, '') = IFNULL(m.ip_address, '')))
LEFT JOIN users u ON (v.user_id = u.id AND v.user_id != 0)
LEFT JOIN chat_remarks r ON (v.user_id = COALESCE(r.user_id, 0) AND (v.user_id != 0 OR IFNULL(v.ip_address, '') = IFNULL(m.ip_address, '')))
) m ON (v.final_user_id = COALESCE(NULLIF(m.user_id, 0), (SELECT id FROM users WHERE registration_ip = m.ip_address LIMIT 1), 0))
LEFT JOIN users u ON (v.final_user_id = u.id AND v.final_user_id != 0)
LEFT JOIN chat_remarks r ON (v.final_user_id = COALESCE(r.user_id, 0) AND (v.final_user_id != 0 OR IFNULL(v.ip_address, '') = IFNULL(m.ip_address, '')))
ORDER BY created_at DESC
");
echo json_encode($stmt->fetchAll());

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -45,7 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $username . '@user.byro'; // Fallback
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ip = getRealIP();
$stmt = db()->prepare("INSERT INTO users (username, email, password_hash, uid, credit_score, total_recharge, role, registration_ip) VALUES (?, ?, ?, ?, ?, 0, 'user', ?)");
$stmt->execute([$username, $email, $hash, $uid, 80, $ip]);
$userId = db()->lastInsertId();

View File

@ -60,6 +60,7 @@
</div>
</div>
</footer>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Mobile Bottom Navigation -->
@ -335,12 +336,20 @@ function appendMessageHTML(m) {
const sender = m.sender;
const text = (m.message || '').toString();
const time = m.created_at || new Date().toISOString();
const isImage = text.indexOf('<img') !== -1;
const isImage = text.includes('<img') || text.includes('/assets/images/chat/') || text.includes('data:image');
const isPaymentInfo = text.startsWith('[PAYMENT_INFO]');
let displayMsg = text;
if (isImage && !displayMsg.includes('chat-img-preview')) {
displayMsg = displayMsg.replace('<img ', '<img class="chat-img-preview" ');
if (isImage) {
if (!displayMsg.includes('<img')) {
displayMsg = `<img src="${displayMsg}" class="img-fluid rounded chat-img-preview" style="max-width: 100%; max-height: 250px; object-fit: contain; margin: 5px 0; cursor: zoom-in;" onclick="window.showLightbox ? window.showLightbox(this.src) : window.open(this.src)">`;
}
if (!displayMsg.includes('chat-img-preview')) {
displayMsg = displayMsg.replace('<img ', '<img class="chat-img-preview" ');
}
if (displayMsg.includes('src="assets/')) {
displayMsg = displayMsg.replace('src="assets/', 'src="/assets/');
}
}
if (isPaymentInfo) {
try {
@ -385,10 +394,12 @@ function appendMessageHTML(m) {
const timeStr = isNaN(dateObj.getTime()) ? '---' : dateObj.toLocaleTimeString('zh-CN', {hour: '2-digit', minute:'2-digit', second: '2-digit'});
const msgHtml = `
<div class="mb-3 d-flex flex-column ${sender === 'user' ? 'align-items-end' : 'align-items-start'} message-item" data-id="${m.id}">
<div class="p-2 px-3 rounded-4 small ${sender === 'user' ? 'bg-primary text-white' : 'bg-dark text-white border border-secondary'}" style="max-width: 80%; color: #ffffff !important; word-break: break-all; position: relative; padding-bottom: 20px !important; ${isImage ? 'padding: 5px !important; padding-bottom: 5px !important; line-height: 0;' : ''}">
${displayMsg}
<div style="font-size: 9px; opacity: 0.6; position: absolute; bottom: 4px; ${sender === 'user' ? 'right: 10px;' : 'left: 10px;'} ${isImage ? 'background: rgba(0,0,0,0.4); padding: 0 4px; border-radius: 4px; bottom: 8px;' : ''}">${timeStr}</div>
<div class="mb-3 d-flex ${sender === 'user' ? 'justify-content-end' : 'justify-content-start'} message-item w-100 animate__animated animate__fadeInUp animate__faster" data-id="${m.id}" style="--animate-duration: 0.3s;">
<div class="d-flex flex-column ${sender === 'user' ? 'align-items-end' : 'align-items-start'}" style="max-width: 85%;">
<div class="p-2 px-3 shadow-sm ${sender === 'user' ? 'bg-primary text-white bubble-user' : 'bg-dark text-white border border-secondary bubble-admin'}" style="word-break: break-all; position: relative; padding-bottom: 22px !important; min-width: 60px; ${isImage ? 'padding: 6px !important; padding-bottom: 6px !important; border-radius: 12px !important;' : ''}">
<div class="message-text" style="font-size: 14px; line-height: 1.5;">${displayMsg}</div>
<div style="font-size: 9px; opacity: 0.7; position: absolute; bottom: 5px; ${sender === 'user' ? 'right: 12px;' : 'left: 12px;'} ${isImage ? 'background: rgba(0,0,0,0.5); padding: 1px 6px; border-radius: 6px; bottom: 10px; right: 10px; backdrop-filter: blur(4px);' : ''}">${timeStr}</div>
</div>
</div>
</div>
`;
@ -441,6 +452,20 @@ footer a:hover {
color: var(--primary) !important;
}
#cs-messages::-webkit-scrollbar { display: none; }
.bubble-user {
border-radius: 18px 18px 2px 18px !important;
background: linear-gradient(135deg, #00c6ff, #0072ff) !important;
}
.bubble-admin {
border-radius: 18px 18px 18px 2px !important;
background: #1e2329 !important;
}
.chat-img-preview {
transition: transform 0.2s;
}
.chat-img-preview:hover {
transform: scale(1.02);
}
</style>
</body>
</html>

View File

@ -544,14 +544,17 @@ if (isset($_SESSION['user_id'])) {
}
</style>
<script>
window.showLightbox = function(src) {
const lightbox = document.getElementById('chat-lightbox');
const img = document.getElementById('lightbox-img');
if (lightbox && img) {
img.src = src;
lightbox.classList.add('active');
}
};
document.addEventListener('click', function(e) {
if (e.target.classList.contains('chat-img-preview')) {
const lightbox = document.getElementById('chat-lightbox');
const img = document.getElementById('lightbox-img');
if (lightbox && img) {
img.src = e.target.src;
lightbox.classList.add('active');
}
window.showLightbox(e.target.src);
}
});
</script>

View File

@ -752,11 +752,19 @@ function appendModalMessage(m) {
if (!container || document.querySelector(`[data-modal-id="${m.id}"]`)) return;
const sender = m.sender;
const text = m.message;
const text = (m.message || '').toString();
let displayMsg = text;
const isImage = text.indexOf('<img') !== -1;
if (isImage && !displayMsg.includes('chat-img-preview')) {
displayMsg = displayMsg.replace('<img ', '<img class="chat-img-preview" ');
const isImage = text.includes('<img') || text.includes('/assets/images/chat/') || text.includes('data:image');
if (isImage) {
if (!displayMsg.includes('<img')) {
displayMsg = `<img src="${displayMsg}" class="img-fluid rounded chat-img-preview" style="max-width: 100%; max-height: 250px; object-fit: contain; margin: 5px 0; cursor: zoom-in;" onclick="window.showLightbox ? window.showLightbox(this.src) : window.open(this.src)">`;
}
if (!displayMsg.includes('chat-img-preview')) {
displayMsg = displayMsg.replace('<img ', '<img class="chat-img-preview" ');
}
if (displayMsg.includes('src="assets/')) {
displayMsg = displayMsg.replace('src="assets/', 'src="/assets/');
}
}
let timeStr = '';
@ -773,7 +781,7 @@ function appendModalMessage(m) {
const html = `
<div class="mb-3 d-flex flex-column ${sender === 'user' ? 'align-items-end' : 'align-items-start'} modal-msg" data-modal-id="${m.id}">
<div class="msg-bubble p-2 px-3 rounded-4 small ${sender === 'user' ? 'bg-primary text-white' : 'bg-dark text-white border border-secondary border-opacity-30'}" style="max-width: 85%; position: relative; ${isImage ? 'padding: 5px !important; line-height: 0;' : 'padding-bottom: 22px !important;'}">
<div class="msg-bubble p-2 px-3 rounded-4 small ${sender === 'user' ? 'bg-primary text-white' : 'bg-dark text-white border border-secondary border-opacity-30'}" style="max-width: 85%; position: relative; ${isImage ? 'padding: 5px !important;' : 'padding-bottom: 22px !important;'}">
<div class="message-content" style="text-shadow: 0 1px 2px rgba(0,0,0,0.2); font-size: 14px; line-height: 1.5;">
${displayMsg}
</div>