38451-vm/admin/customer_service.php
2026-02-21 02:21:16 +00:00

626 lines
24 KiB
PHP
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.

<?php
session_start();
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/layout.php';
// Check if admin
if (!isset($_SESSION['admin_id'])) {
header("Location: /admin/login.php");
exit;
}
ob_start();
?>
<style>
.chat-container {
display: flex;
height: calc(100vh - 120px);
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 10px rgba(0,0,0,0.05);
}
.user-sidebar {
width: 300px;
border-right: 1px solid #eee;
display: flex;
flex-direction: column;
}
.main-chat {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
}
.user-list {
flex: 1;
overflow-y: auto;
}
.user-card {
padding: 12px 15px;
border-bottom: 1px solid #f8f9fa;
cursor: pointer;
transition: all 0.2s;
}
.user-card:hover {
background: #f8f9fa;
}
.user-card.active {
background: #e7f3ff;
border-left: 4px solid #007bff;
}
.chat-header {
padding: 12px 20px;
border-bottom: 1px solid #eee;
background: #fff;
}
.messages-area {
flex: 1;
padding: 20px;
background: #fdfdfd;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.msg {
max-width: 80%;
padding: 8px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
position: relative;
display: flex;
flex-direction: column;
}
.msg-time {
font-size: 10px;
opacity: 0.7;
margin-top: 4px;
}
.msg-admin {
align-self: flex-end;
background: #007bff;
color: #fff;
border-bottom-right-radius: 2px;
}
.msg-admin .msg-time {
text-align: right;
color: #e0e0e0;
}
.msg-user {
align-self: flex-start;
background: #f0f0f0;
color: #333;
border-bottom-left-radius: 2px;
}
.msg-user .msg-time {
color: #888;
}
.recall-btn {
font-size: 10px;
text-decoration: underline;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.msg-admin:hover .recall-btn {
opacity: 1;
}
.chat-input-area {
padding: 15px;
border-top: 1px solid #eee;
}
.remark-area {
width: 250px;
border-left: 1px solid #eee;
padding: 15px;
background: #fcfcfc;
}
.status-online {
width: 8px;
height: 8px;
background: #28a745;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
}
#remark-text {
height: 150px;
font-size: 13px;
}
</style>
<div class="chat-container">
<!-- User List Sidebar -->
<div class="user-sidebar">
<div class="p-3 border-bottom bg-light">
<input type="text" id="user-search" class="form-control form-control-sm" placeholder="搜索用户/IP...">
</div>
<div class="user-list" id="user-list">
<!-- User cards filled by JS -->
</div>
</div>
<!-- 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>
</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>
</div>
</div>
<div class="messages-area" id="messages-area">
<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>
</div>
<div class="chat-input-area" id="input-area" style="display: none;">
<form id="chat-form" class="d-flex gap-2 align-items-center">
<input type="file" id="image-input" style="display: none;" accept="image/*">
<button type="button" class="btn btn-outline-secondary btn-sm rounded-circle" id="plus-btn" style="width: 32px; height: 32px; flex-shrink: 0;">
<i class="bi bi-plus-lg"></i>
</button>
<button type="button" class="btn btn-outline-primary btn-sm rounded-circle" id="payment-btn" style="width: 32px; height: 32px; flex-shrink: 0;" title="发送收款账号">
<i class="bi bi-bank"></i>
</button>
<input type="text" id="msg-input" class="form-control" placeholder="输入回复内容..." autocomplete="off">
<button type="submit" class="btn btn-primary btn-sm px-3">发送</button>
</form>
</div>
</div>
<!-- Payment Info Modal -->
<div class="modal fade" id="paymentModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white border-0">
<h5 class="modal-title fw-bold"><i class="bi bi-bank me-2"></i>匹配收款账户 (法币/USDT)</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
<div class="alert alert-info small border-0 bg-light text-primary">
<i class="bi bi-info-circle-fill me-2"></i>填写后点击发送,前端充值弹窗将自动切换并显示此账户。
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-muted">银行名称 / 支付方式 (Bank Name)</label>
<input type="text" id="pay-bank" class="form-control form-control-lg fs-6" placeholder="例如: 建设银行, Alipay, TRC20, etc.">
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-muted">收款人姓名 (Payee Name)</label>
<input type="text" id="pay-name" class="form-control form-control-lg fs-6" placeholder="收款人姓名或账户别名">
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-muted">收款账号 / 地址 (Account Number)</label>
<input type="text" id="pay-account" class="form-control form-control-lg fs-6 fw-bold text-primary" placeholder="银行卡号或钱包地址">
</div>
<div class="mb-0">
<label class="form-label small fw-bold text-muted">转账说明 / 备注 (Instructions)</label>
<textarea id="pay-note" class="form-control" rows="3" placeholder="告知用户转账时需要注意的事项例如务必备注UID"></textarea>
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-light px-4 fw-bold" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary px-5 fw-bold shadow" onclick="sendPaymentInfo()">
<i class="bi bi-send-fill me-2"></i>立即匹配并发送
</button>
</div>
</div>
</div>
</div>
<!-- Remark Area -->
<div class="remark-area" id="remark-area" style="display: none;">
<h6 class="fw-bold mb-3 mt-1"><i class="bi bi-pencil-square me-1"></i> 用户备注</h6>
<div class="mb-3">
<label class="small text-muted mb-1">当前用户备注信息</label>
<textarea id="remark-text" class="form-control" placeholder="在此输入对该用户的备注..."></textarea>
</div>
<button id="save-remark-btn" class="btn btn-dark btn-sm w-100 fw-bold">保存备注</button>
<hr>
<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>最近活跃:</strong> <span id="info-time">---</span></div>
</div>
</div>
</div>
<script>
let selectedUser = null;
let selectedIp = null;
let lastMsgId = 0;
let lastChatIds = new Set();
let currentUserContext = '';
async function refreshUsers() {
try {
const r = await fetch('/api/chat.php?action=admin_get_all');
const users = await r.json();
if (users.error) {
console.error('API Error:', users.error);
return;
}
if (!Array.isArray(users)) {
console.error('API response is not an array:', users);
return;
}
const list = document.getElementById('user-list');
const searchInput = document.getElementById('user-search');
const search = searchInput ? searchInput.value.toLowerCase() : '';
let html = '';
users.forEach(u => {
try {
const userId = u.user_id;
const username = u.username || '匿名用户';
const uid = u.uid || '---';
const ip = u.ip_address || '---';
const rawRemark = u.remark || '';
const remark = rawRemark.toString().replace(/\n/g, " ");
const userTime = u.user_time || '---';
const lastTime = u.created_at ? new Date(u.created_at.replace(/-/g, "/")) : new Date();
let lastMsgText = u.message || '';
if (lastMsgText.startsWith('[PAYMENT_INFO]')) {
lastMsgText = '[收款账号信息]';
}
const isActive = (selectedIp === ip && selectedUser == userId);
if (isActive) {
const infoUserTime = document.getElementById('info-user-time');
if (infoUserTime) infoUserTime.innerText = userTime;
}
// Using data attributes to avoid escaping issues in onclick
html += `
<div class="user-card ${isActive ? 'active' : ''}"
data-user-id="${userId}"
data-ip="${ip}"
data-name="${username.replace(/"/g, '&quot;')}"
data-uid="${uid}"
data-remark="${remark.replace(/"/g, '&quot;')}"
data-user-time="${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;">${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>
`;
} catch (e) {
console.error('Error rendering user card:', e, u);
}
});
list.innerHTML = html || '<div class="p-4 text-center text-muted small">暂无活跃会话</div>';
} catch (err) {
console.error('Refresh users failed:', err);
}
}
// Handle clicks on user cards using event delegation
document.getElementById('user-list').addEventListener('click', (e) => {
const card = e.target.closest('.user-card');
if (card) {
const d = card.dataset;
openChat(d.userId, d.ip, d.name, d.uid, d.remark, d.userTime);
}
});
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(); // Refresh list to update active state
}
async function recallMessage(msgId) {
if (!confirm('确定撤回该消息吗?')) return;
const fd = new URLSearchParams();
fd.append('message_id', msgId);
const r = await fetch('/api/chat.php?action=admin_recall_message', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
fetchMessages();
}
}
async function deleteUser() {
if (!confirm('确定删除该用户的所有聊天记录吗?此操作不可恢复!')) return;
const fd = new URLSearchParams();
fd.append('user_id', selectedUser);
fd.append('ip_address', selectedIp);
const r = await fetch('/api/chat.php?action=admin_delete_user', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
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 fetchMessages() {
if (!selectedIp && !selectedUser) return;
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;
}
const area = document.getElementById('messages-area');
let hasNew = false;
msgs.forEach(m => {
if (!lastChatIds.has(m.id)) {
appendMessageHTML(m);
lastChatIds.add(m.id);
hasNew = true;
}
});
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');
}
}
} 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' ? `<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;
if (isPaymentInfo) {
try {
const info = JSON.parse(m.message.replace('[PAYMENT_INFO]', ''));
displayMsg = `<div class="p-2 border border-white border-opacity-20 rounded bg-white bg-opacity-10 small">
<div class="fw-bold"><i class="bi bi-bank me-1"></i>已发送收款账号</div>
<div class="mt-1 opacity-75">${info.bank} - ${info.name}</div>
</div>`;
} catch(e) { displayMsg = '[支付信息错误]'; }
}
if (isImage) {
div.style.padding = '5px';
div.style.background = m.sender === 'admin' ? '#007bff' : '#f0f0f0';
div.style.lineHeight = '0';
}
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>
`;
area.appendChild(div);
}
document.getElementById('plus-btn').addEventListener('click', () => {
document.getElementById('image-input').click();
});
const paymentModal = new bootstrap.Modal(document.getElementById('paymentModal'));
document.getElementById('payment-btn').addEventListener('click', () => {
paymentModal.show();
});
async function sendPaymentInfo() {
const bank = document.getElementById('pay-bank').value.trim();
const name = document.getElementById('pay-name').value.trim();
const account = document.getElementById('pay-account').value.trim();
const note = document.getElementById('pay-note').value.trim();
if (!bank || !name || !account) {
alert('请完整填写收款信息');
return;
}
const info = { bank, name, account, note };
const msg = `[PAYMENT_INFO]${JSON.stringify(info)}`;
const fd = new URLSearchParams();
fd.append('message', msg);
fd.append('user_id', selectedUser);
fd.append('ip_address', selectedIp);
try {
const r = await fetch('/api/chat.php?action=admin_send', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
paymentModal.hide();
// Clear inputs
document.getElementById('pay-bank').value = '';
document.getElementById('pay-name').value = '';
document.getElementById('pay-account').value = '';
document.getElementById('pay-note').value = '';
fetchMessages();
}
} catch(err) {}
}
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 = `<img src="${localUrl}" class="img-fluid rounded" style="max-width: 100%; max-height: 250px; object-fit: contain; margin: 5px 0; opacity: 0.6;">`;
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 || 0);
formData.append('ip_address', selectedIp || '');
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) => {
e.preventDefault();
const input = document.getElementById('msg-input');
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);
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 () => {
const remark = document.getElementById('remark-text').value;
const fd = new URLSearchParams();
fd.append('user_id', selectedUser);
fd.append('ip_address', selectedIp);
fd.append('remark', remark);
const r = await fetch('/api/chat.php?action=save_remark', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
alert('备注已保存');
refreshUsers();
}
});
document.getElementById('user-search').addEventListener('input', refreshUsers);
setInterval(refreshUsers, 2000);
setInterval(fetchMessages, 2000);
refreshUsers();
</script>
<?php
$content = ob_get_clean();
renderAdminPage($content, '在线客服');
?>