chore: 从汽车AI客服项目拷贝骨架并清理(blog页/redis)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VOLT - 管理后台</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; height: 100vh; overflow: hidden; }
|
||||
|
||||
.login-wrap { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-box { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 360px; }
|
||||
.login-box h2 { text-align: center; margin-bottom: 24px; color: #1a73e8; }
|
||||
.login-box input { width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; outline: none; }
|
||||
.login-box input:focus { border-color: #1a73e8; }
|
||||
.login-box button { width: 100%; padding: 10px; background: #1a73e8; color: #fff; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; margin-top: 16px; }
|
||||
.login-box button:hover { background: #1557b0; }
|
||||
.login-error { color: #e53935; font-size: 13px; text-align: center; margin-top: 12px; display: none; }
|
||||
|
||||
.app { display: none; height: 100vh; flex-direction: column; }
|
||||
.header { background: #fff; padding: 12px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.header h1 { font-size: 16px; color: #1a73e8; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; }
|
||||
.header .logout { color: #999; cursor: pointer; font-size: 13px; }
|
||||
.header .logout:hover { color: #e53935; }
|
||||
.header select { padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; }
|
||||
.header .stat { font-size: 13px; color: #666; }
|
||||
|
||||
.main { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
/* 左侧会话列表 */
|
||||
.left-panel { width: 340px; border-right: 1px solid #e0e0e0; background: #fff; display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.conv-list { flex: 1; overflow-y: auto; }
|
||||
.conv-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.15s; }
|
||||
.conv-item:hover { background: #f8faff; }
|
||||
.conv-item.active { background: #e8f0fe; border-left: 3px solid #1a73e8; }
|
||||
.conv-item .conv-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.conv-item .conv-time { font-size: 11px; color: #999; }
|
||||
.conv-item .conv-ip { font-size: 12px; color: #333; font-weight: 500; }
|
||||
.conv-item .conv-bottom { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
|
||||
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500; }
|
||||
.tag-interested { background: #e8f5e9; color: #2e7d32; }
|
||||
.tag-not_interested { background: #fce4ec; color: #c62828; }
|
||||
.tag-pending { background: #fff3e0; color: #e65100; }
|
||||
.tag-active { background: #e3f2fd; color: #1565c0; }
|
||||
.tag-completed { background: #f5f5f5; color: #666; }
|
||||
.conv-meta { font-size: 11px; color: #aaa; }
|
||||
.conv-device { font-size: 11px; color: #bbb; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conv-preview { font-size: 12px; color: #888; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 右侧聊天区 */
|
||||
.right-panel { flex: 1; display: flex; flex-direction: column; background: #f0f2f5; }
|
||||
.chat-header { padding: 12px 20px; background: #fff; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
|
||||
.chat-header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.chat-header-info { font-size: 13px; color: #666; }
|
||||
.chat-header-info span { margin-right: 12px; }
|
||||
.contact-info { font-size: 12px; color: #1a73e8; }
|
||||
.btn-delete { padding: 4px 12px; border: 1px solid #e53935; color: #e53935; background: #fff; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.btn-delete:hover { background: #e53935; color: #fff; }
|
||||
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: #bbb; font-size: 15px; }
|
||||
|
||||
.msg-row { display: flex; gap: 8px; max-width: 70%; }
|
||||
.msg-row.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.msg-row.assistant { align-self: flex-start; }
|
||||
.msg-avatar { width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0; }
|
||||
.msg-avatar.user { background: #1a73e8; color: #fff; }
|
||||
.msg-avatar.assistant { background: #34a853; color: #fff; }
|
||||
.msg-content { padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.6; word-break: break-word; }
|
||||
.msg-row.user .msg-content { background: #1a73e8; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.msg-row.assistant .msg-content { background: #fff; color: #333; border-bottom-left-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
|
||||
.msg-time { font-size: 10px; color: #bbb; margin-top: 2px; }
|
||||
.msg-row.user .msg-time { text-align: right; }
|
||||
|
||||
/* 客户信息栏 */
|
||||
.profile-bar { padding: 12px 20px; background: #fff; border-top: 1px solid #e0e0e0; flex-shrink: 0; }
|
||||
.profile-title { font-size: 12px; color: #999; margin-bottom: 6px; }
|
||||
.profile-items { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.profile-chip { font-size: 11px; padding: 3px 8px; background: #f5f5f5; border-radius: 4px; color: #555; }
|
||||
.profile-chip .pk { color: #999; }
|
||||
|
||||
.load-more { text-align: center; padding: 12px; }
|
||||
.load-more button { padding: 6px 16px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer; font-size: 13px; color: #666; }
|
||||
.load-more button:hover { border-color: #1a73e8; color: #1a73e8; }
|
||||
|
||||
.back-btn { display: none; background: none; border: none; font-size: 18px; cursor: pointer; padding: 4px 8px; color: #666; }
|
||||
|
||||
/* 推送日志视图(Task9 配套入口) */
|
||||
.btn-logs { padding: 6px 14px; border: 1px solid #1a73e8; color: #1a73e8; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.btn-logs:hover { background: #e8f0fe; }
|
||||
.btn-logs.active { background: #1a73e8; color: #fff; }
|
||||
.logs-view { flex: 1; overflow-y: auto; background: #fff; padding: 20px; }
|
||||
.logs-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.logs-toolbar select { padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; }
|
||||
.logs-count { font-size: 13px; color: #666; }
|
||||
.logs-pager { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: 13px; color: #666; }
|
||||
.logs-pager button { padding: 4px 12px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer; font-size: 13px; color: #666; }
|
||||
.logs-pager button:disabled { opacity: .4; cursor: default; }
|
||||
.logs-wrap { overflow-x: auto; }
|
||||
.logs-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 900px; }
|
||||
.logs-table th { text-align: left; padding: 10px 12px; background: #f8f9fa; color: #666; font-weight: 500; border-bottom: 1px solid #e0e0e0; white-space: nowrap; }
|
||||
.logs-table td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: top; word-break: break-all; }
|
||||
.tag-success { background: #e8f5e9; color: #2e7d32; }
|
||||
.tag-failed { background: #fce4ec; color: #c62828; }
|
||||
.tag-skipped { background: #f5f5f5; color: #666; }
|
||||
.logs-err { color: #c62828; font-size: 12px; max-width: 280px; }
|
||||
.logs-key { color: #999; font-size: 12px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.left-panel { width: 100%; }
|
||||
.right-panel { display: none; }
|
||||
.right-panel.mobile-show { display: flex; position: fixed; inset: 0; z-index: 100; }
|
||||
.back-btn { display: inline-block !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-wrap" id="loginWrap">
|
||||
<div class="login-box">
|
||||
<h2>VOLT 管理后台</h2>
|
||||
<input type="password" id="loginPwd" placeholder="请输入管理密码" autofocus>
|
||||
<button onclick="doLogin()">登录</button>
|
||||
<div class="login-error" id="loginErr">密码错误</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app" id="app">
|
||||
<div class="header">
|
||||
<h1>VOLT - 沟通记录</h1>
|
||||
<div class="header-right">
|
||||
<button class="btn-logs" id="btnLogs" onclick="toggleLogs()">推送日志</button>
|
||||
<select id="filterIntent" onchange="resetAndLoad()">
|
||||
<option value="">全部意向</option>
|
||||
<option value="interested">有意向</option>
|
||||
<option value="not_interested">无意向</option>
|
||||
<option value="pending">待定</option>
|
||||
</select>
|
||||
<span class="stat" id="statText"></span>
|
||||
<span class="logout" onclick="doLogout()">退出登录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="logs-view" id="logsView" style="display:none;">
|
||||
<div class="logs-toolbar">
|
||||
<select id="filterPushStatus" onchange="pushPage=1;loadPushLogs()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="pending">推送中</option>
|
||||
<option value="skipped">跳过</option>
|
||||
</select>
|
||||
<span class="logs-count" id="pushStat"></span>
|
||||
<div class="logs-pager">
|
||||
<button id="pushPrev" onclick="pushPage--;loadPushLogs()">上一页</button>
|
||||
<span id="pushPageInfo"></span>
|
||||
<button id="pushNext" onclick="pushPage++;loadPushLogs()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-wrap">
|
||||
<table class="logs-table">
|
||||
<thead><tr>
|
||||
<th>时间</th><th>类型</th><th>状态</th><th>对方结论</th><th>尝试</th><th>HTTP</th><th>失败/跳过原因</th><th>会话ID</th>
|
||||
</tr></thead>
|
||||
<tbody id="pushRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="left-panel">
|
||||
<div class="conv-list" id="convList"></div>
|
||||
<div class="load-more" id="loadMore"></div>
|
||||
</div>
|
||||
<div class="right-panel" id="rightPanel">
|
||||
<div class="chat-header" id="chatHeader" style="display:none;">
|
||||
<div class="chat-header-left">
|
||||
<button class="back-btn" onclick="closeMobile()">←</button>
|
||||
<span class="chat-header-info" id="chatInfo"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="contact-info" id="contactInfo"></span>
|
||||
<button class="btn-delete" id="btnDelete" onclick="deleteConv()">删除会话</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
<div class="chat-empty">点击左侧会话查看完整对话记录</div>
|
||||
</div>
|
||||
<div class="profile-bar" id="profileBar" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var currentPage = 1;
|
||||
var allConvs = [];
|
||||
var selectedId = null;
|
||||
var totalConvs = 0;
|
||||
|
||||
function doLogin() {
|
||||
var pwd = document.getElementById('loginPwd').value;
|
||||
document.getElementById('loginErr').style.display = 'none';
|
||||
fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({password: pwd})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) {
|
||||
document.getElementById('loginWrap').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'flex';
|
||||
loadData();
|
||||
} else {
|
||||
document.getElementById('loginErr').textContent = d.error || '密码错误';
|
||||
document.getElementById('loginErr').style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('loginPwd').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
|
||||
function doLogout() {
|
||||
fetch('/api/admin/logout', {method: 'POST', credentials: 'same-origin'})
|
||||
.finally(function() { location.reload(); });
|
||||
}
|
||||
|
||||
function resetAndLoad() {
|
||||
currentPage = 1;
|
||||
allConvs = [];
|
||||
selectedId = null;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function loadData(append) {
|
||||
var intent = document.getElementById('filterIntent').value;
|
||||
var url = '/api/admin/conversations?page=' + currentPage + '&size=20';
|
||||
if (intent) url += '&intent=' + intent;
|
||||
|
||||
fetch(url, {credentials: 'same-origin'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.error === 'unauthorized') { doLogout(); return; }
|
||||
if (!d.items) return;
|
||||
|
||||
totalConvs = d.total || 0;
|
||||
|
||||
if (append) {
|
||||
allConvs = allConvs.concat(d.items);
|
||||
} else {
|
||||
allConvs = d.items;
|
||||
}
|
||||
|
||||
renderConvList();
|
||||
document.getElementById('statText').textContent = '共 ' + totalConvs + ' 个会话';
|
||||
|
||||
var hasMore = allConvs.length < totalConvs;
|
||||
document.getElementById('loadMore').innerHTML = hasMore
|
||||
? '<button onclick="loadMoreConvs()">加载更多</button>'
|
||||
: '';
|
||||
});
|
||||
}
|
||||
|
||||
function loadMoreConvs() {
|
||||
currentPage++;
|
||||
loadData(true);
|
||||
}
|
||||
|
||||
function renderConvList() {
|
||||
var html = '';
|
||||
allConvs.forEach(function(item) {
|
||||
var time = item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', {month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit'}) : '-';
|
||||
|
||||
var tag = '';
|
||||
if (item.intent_level) {
|
||||
tag = '<span class="tag tag-' + item.intent_level + '">' + intentLabel(item.intent_level) + '</span>';
|
||||
} else {
|
||||
tag = '<span class="tag tag-' + item.status + '">' + statusLabel(item.status) + '</span>';
|
||||
}
|
||||
|
||||
var preview = item.preview ? escHtml(item.preview) : '';
|
||||
if (preview.length > 40) preview = preview.substring(0, 40) + '...';
|
||||
|
||||
html += '<div class="conv-item' + (selectedId === item.id ? ' active' : '') + '" onclick="selectConv(\'' + item.id + '\')">';
|
||||
html += '<div class="conv-top">';
|
||||
html += '<span class="conv-ip">' + escHtml(item.country || item.ip || '未知') + '</span>';
|
||||
html += '<span class="conv-time">' + time + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="conv-bottom">';
|
||||
html += tag;
|
||||
html += '<span class="conv-meta">' + item.message_count + ' 条消息</span>';
|
||||
html += '</div>';
|
||||
if (preview) {
|
||||
html += '<div class="conv-preview">' + preview + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('convList').innerHTML = html || '<div style="padding:40px;text-align:center;color:#bbb;">暂无会话记录</div>';
|
||||
}
|
||||
|
||||
function selectConv(convId) {
|
||||
selectedId = convId;
|
||||
renderConvList();
|
||||
|
||||
document.getElementById('rightPanel').classList.add('mobile-show');
|
||||
|
||||
// Find conv info
|
||||
var conv = allConvs.find(function(c) { return c.id === convId; });
|
||||
|
||||
fetch('/api/admin/conversations/' + convId + '/messages', {credentials: 'same-origin'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.error) return;
|
||||
|
||||
// Header info
|
||||
document.getElementById('chatHeader').style.display = 'flex';
|
||||
var info = '';
|
||||
if (conv) {
|
||||
info += '<span>' + escHtml(conv.country || conv.ip || '未知') + '</span>';
|
||||
info += '<span>' + sourceLabel(conv.source) + '</span>';
|
||||
var time = conv.created_at ? new Date(conv.created_at).toLocaleString('zh-CN') : '';
|
||||
if (time) info += '<span>' + time + '</span>';
|
||||
}
|
||||
document.getElementById('chatInfo').innerHTML = info;
|
||||
|
||||
// Contact info
|
||||
var contactHtml = '';
|
||||
if (conv && conv.contact_info) {
|
||||
var ci = conv.contact_info;
|
||||
if (ci.WhatsApp) contactHtml += 'WhatsApp: ' + escHtml(ci.WhatsApp) + ' ';
|
||||
if (ci['邮箱'] || ci.email) contactHtml += '邮箱: ' + escHtml(ci['邮箱'] || ci.email) + ' ';
|
||||
if (ci['手机'] || ci.phone) contactHtml += '电话: ' + escHtml(ci['手机'] || ci.phone);
|
||||
}
|
||||
document.getElementById('contactInfo').innerHTML = contactHtml;
|
||||
|
||||
// Messages - show ALL messages in this conversation
|
||||
var html = '';
|
||||
d.messages.forEach(function(m) {
|
||||
var cls = m.role === 'user' ? 'user' : 'assistant';
|
||||
var avatar = m.role === 'user' ? '客' : 'V';
|
||||
var roleLabel = m.role === 'user' ? '客户' : 'VOLT';
|
||||
|
||||
html += '<div class="msg-row ' + cls + '">';
|
||||
html += '<div class="msg-avatar ' + cls + '">' + avatar + '</div>';
|
||||
html += '<div>';
|
||||
html += '<div class="msg-content">' + escHtml(m.content) + '</div>';
|
||||
if (m.time) {
|
||||
html += '<div class="msg-time">' + new Date(m.time).toLocaleString('zh-CN', {hour:'2-digit', minute:'2-digit', second:'2-digit'}) + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('chatMessages').innerHTML = html || '<div class="chat-empty">该会话暂无消息</div>';
|
||||
|
||||
// Scroll to bottom
|
||||
var chatEl = document.getElementById('chatMessages');
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
|
||||
// Profile bar
|
||||
if (d.profile && Object.keys(d.profile).length > 0) {
|
||||
var ph = '<div class="profile-title">客户信息</div><div class="profile-items">';
|
||||
Object.keys(d.profile).forEach(function(k) {
|
||||
var v = d.profile[k];
|
||||
if (typeof v === 'object') v = JSON.stringify(v);
|
||||
if (v && v !== '{}' && v !== '') {
|
||||
ph += '<span class="profile-chip"><span class="pk">' + escHtml(k) + ':</span> ' + escHtml(String(v)) + '</span>';
|
||||
}
|
||||
});
|
||||
ph += '</div>';
|
||||
document.getElementById('profileBar').innerHTML = ph;
|
||||
document.getElementById('profileBar').style.display = ph ? 'block' : 'none';
|
||||
} else {
|
||||
document.getElementById('profileBar').style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeMobile() {
|
||||
document.getElementById('rightPanel').classList.remove('mobile-show');
|
||||
}
|
||||
|
||||
function deleteConv() {
|
||||
if (!selectedId) return;
|
||||
if (!confirm('确定要删除这条会话记录吗?删除后无法恢复。')) return;
|
||||
fetch('/api/admin/conversations/' + selectedId, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) {
|
||||
selectedId = null;
|
||||
document.getElementById('chatHeader').style.display = 'none';
|
||||
document.getElementById('profileBar').style.display = 'none';
|
||||
document.getElementById('chatMessages').innerHTML = '<div class="chat-empty">点击左侧会话查看完整对话记录</div>';
|
||||
resetAndLoad();
|
||||
} else {
|
||||
alert(d.error || '删除失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 推送日志视图(Task9 配套) ----
|
||||
var pushPage = 1;
|
||||
var logsShown = false;
|
||||
|
||||
function toggleLogs() {
|
||||
logsShown = !logsShown;
|
||||
var btn = document.getElementById('btnLogs');
|
||||
document.getElementById('logsView').style.display = logsShown ? '' : 'none';
|
||||
document.querySelector('.left-panel').style.display = logsShown ? 'none' : '';
|
||||
document.getElementById('rightPanel').style.display = logsShown ? 'none' : '';
|
||||
if (logsShown) {
|
||||
btn.textContent = '返回会话';
|
||||
btn.classList.add('active');
|
||||
pushPage = 1;
|
||||
loadPushLogs();
|
||||
} else {
|
||||
btn.textContent = '推送日志';
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function loadPushLogs() {
|
||||
var st = document.getElementById('filterPushStatus').value;
|
||||
var url = '/api/admin/push-logs?page=' + pushPage + '&size=20';
|
||||
if (st) url += '&status=' + st;
|
||||
|
||||
fetch(url, {credentials: 'same-origin'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.error === 'unauthorized') { doLogout(); return; }
|
||||
if (!d.items) return;
|
||||
|
||||
document.getElementById('pushStat').textContent = '共 ' + (d.total || 0) + ' 条';
|
||||
|
||||
var html = '';
|
||||
d.items.forEach(function(it) {
|
||||
// 后端时间是 naive UTC(无时区标记)——补 Z 避免浏览器误当本地时间(差8小时)
|
||||
var iso = it.created_at || '';
|
||||
if (iso && iso.indexOf('Z') === -1 && iso.indexOf('+') === -1) iso += 'Z';
|
||||
var time = iso ? new Date(iso).toLocaleString('zh-CN') : '-';
|
||||
|
||||
html += '<tr>';
|
||||
html += '<td style="white-space:nowrap;">' + time + '</td>';
|
||||
html += '<td>' + pushTypeLabel(it.push_type) + '</td>';
|
||||
html += '<td><span class="tag tag-' + it.status + '">' + pushStatusLabel(it.status) + '</span></td>';
|
||||
html += '<td>' + (it.outcome ? pushOutcomeLabel(it.outcome) : '-') + '</td>';
|
||||
html += '<td>' + (it.attempts || 0) + '</td>';
|
||||
html += '<td>' + (it.response_status || '-') + '</td>';
|
||||
html += '<td class="logs-err">' + escHtml(it.error_message || '') + '</td>';
|
||||
html += '<td class="logs-key" title="' + escHtml(it.conversation_id || '') + '">' + escHtml((it.conversation_id || '').substring(0, 8)) + '…</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
document.getElementById('pushRows').innerHTML = html
|
||||
|| '<tr><td colspan="8" style="text-align:center;color:#bbb;padding:40px;">暂无推送记录</td></tr>';
|
||||
|
||||
var totalPages = Math.max(1, Math.ceil((d.total || 0) / (d.size || 20)));
|
||||
document.getElementById('pushPageInfo').textContent = pushPage + ' / ' + totalPages;
|
||||
document.getElementById('pushPrev').disabled = pushPage <= 1;
|
||||
document.getElementById('pushNext').disabled = pushPage >= totalPages;
|
||||
});
|
||||
}
|
||||
|
||||
function pushTypeLabel(t) { return {lead:'首询盘', returning:'回访', correction:'更正'}[t] || t; }
|
||||
function pushStatusLabel(s) { return {success:'成功', failed:'失败', pending:'推送中', skipped:'跳过'}[s] || s; }
|
||||
function pushOutcomeLabel(o) { return {accepted:'已入库', duplicated:'重复跳过'}[o] || o; }
|
||||
|
||||
function intentLabel(l) { return {interested:'有意向', not_interested:'无意向', pending:'待定'}[l] || l; }
|
||||
function statusLabel(s) { return {active:'进行中', completed:'已结束', abandoned:'已离开'}[s] || s; }
|
||||
function sourceLabel(s) { return {standalone:'独立页面', embedded:'嵌入', widget:'Widget'}[s] || s; }
|
||||
function escHtml(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; }
|
||||
|
||||
// Auto-check auth
|
||||
fetch('/api/admin/conversations?page=1&size=1', {credentials: 'same-origin'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.error !== 'unauthorized') {
|
||||
document.getElementById('loginWrap').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'flex';
|
||||
loadData();
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>VOLT</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<div class="chat-header">
|
||||
<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="/static/kefu-small.png"></div>
|
||||
<div class="info">
|
||||
<h1>HESU Support</h1>
|
||||
<div class="availability">Online now - Usually replies in minutes</div>
|
||||
</div>
|
||||
<!-- <div class="actions">
|
||||
<button class="action-btn" aria-label="Minimize">−</button>
|
||||
<button class="action-btn" aria-label="Close">×</button>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div class="chat-ended" id="chatEnded">
|
||||
Chat ended. We'll be in touch soon!
|
||||
</div>
|
||||
<div class="panel-top">
|
||||
<!-- How can we help? 区块 -->
|
||||
<div class="help-block">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div class="help-content">
|
||||
<div class="help-title">How can we help?</div>
|
||||
<div class="help-description">Ask about vehicles, market support, quotation, shipping and delivery.</div>
|
||||
</div>
|
||||
<div class="help-actions">
|
||||
<a href="https://wa.me/+8619037179250" target="_blank" class="call-btn whatsapp-btn">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
<a href="mailto:info@borderlesscar.com" class="call-btn email-btn">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<span>Email</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-separator"></div>
|
||||
<div class="chat-messages" id="messages">
|
||||
<div class="message assistant">
|
||||
<div class="bubble">Hey there! Looking for a ride? Tell me what you need.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="typing-indicator" id="typing">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- POPULAR TOPICS 区块 -->
|
||||
<div class="popular-topics-fixed" id="popular-topics">
|
||||
<div class="popular-topics-label">POPULAR TOPICS</div>
|
||||
<div class="popular-topics-grid">
|
||||
<button class="popular-topic-btn" data-topic="Get Quotation">Get Quotation</button>
|
||||
<button class="popular-topic-btn" data-topic="Distributor Cooperation">Distributor Cooperation</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-separator-topics"></div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<div class="input-wrapper">
|
||||
<input id="input" type="text" placeholder="Type a message..." autocomplete="off">
|
||||
<button id="sendBtn" class="send-btn" aria-label="Send message">
|
||||
<span class="send-icon">➤</span>
|
||||
<span class="send-text">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,468 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--w-primary: #f08a31;
|
||||
--w-primary-rgb: 240, 138, 49;
|
||||
--w-gradient-end: #f08e2c;
|
||||
--w-deep-text: #8a4c1d;
|
||||
--w-caption-text: #c16d2d;
|
||||
--w-soft-bg: #fef8f4;
|
||||
--w-border-soft: #f0d3bd;
|
||||
--bg: #f5f5f5;
|
||||
--chat-bg: #ffffff;
|
||||
--ai-bubble: #f0f0f0;
|
||||
--text: #333333;
|
||||
--text-light: #666666;
|
||||
--text-white: #ffffff;
|
||||
--border: #e0e0e0;
|
||||
--success: #34a853;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: var(--chat-bg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
background: var(--w-primary);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 头像通用:emoji 兜底层 + 品牌图片层 */
|
||||
.avatar-emoji { line-height: 1; }
|
||||
.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; border-radius: 50%; }
|
||||
|
||||
/* 在线状态点 */
|
||||
.chat-header .avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: 1px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--w-primary);
|
||||
}
|
||||
|
||||
.chat-header .info h1 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-header .info .status {
|
||||
font-size: 13px;
|
||||
opacity: 0.95;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 头部操作按钮 */
|
||||
.chat-header .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-header .action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
transition: background 0.2s;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-header .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Messages area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
padding-bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message .bubble {
|
||||
padding: 10px 16px;
|
||||
border-radius: 16px;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: var(--w-primary);
|
||||
color: var(--text-white);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.assistant .avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--w-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(var(--w-primary-rgb),0.3);
|
||||
}
|
||||
|
||||
.message.assistant .bubble {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
padding: 12px 16px;
|
||||
border-bottom-left-radius: 4px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.message .time {
|
||||
font-size: 11px;
|
||||
color: var(--text-light);
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.message.user .time {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing-indicator {
|
||||
display: none;
|
||||
align-self: flex-start;
|
||||
padding: 10px 16px;
|
||||
background: var(--ai-bubble);
|
||||
border-radius: 16px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator.active {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #999;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-6px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 消息区分隔线 */
|
||||
.chat-separator {
|
||||
height: 1px;
|
||||
background: var(--w-primary);
|
||||
opacity: 0.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 输入区域容器 */
|
||||
.chat-input-container {
|
||||
padding: 10px 10px;
|
||||
background: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 输入容器 */
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 8px 16px;
|
||||
background: var(--w-soft-bg); /* 主题浅底 */
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.chat-input-container input {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
min-height: 20px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.chat-input-container input:focus {
|
||||
box-shadow: inset 0 0 0 2px rgba(var(--w-primary-rgb),0.2);
|
||||
}
|
||||
|
||||
.chat-input-container input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.input-separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: #e5e7eb;
|
||||
margin: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 发送按钮 */
|
||||
.send-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--w-primary), var(--w-gradient-end));
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(var(--w-primary-rgb),0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
box-shadow: 0 4px 12px rgba(var(--w-primary-rgb),0.35);
|
||||
}
|
||||
|
||||
.send-btn .send-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.send-btn .send-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Ended banner */
|
||||
.chat-ended {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #e8f5e9;
|
||||
color: var(--success);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-ended.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Help 区块 */
|
||||
.help-block {
|
||||
background: var(--w-soft-bg);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--w-border-soft);
|
||||
}
|
||||
.panel-top {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 16px;
|
||||
background: var(--w-soft-bg);
|
||||
}
|
||||
.help-block .help-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.help-block .help-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.help-block .help-description {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.help-block .help-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-block .call-btn {
|
||||
background: #fff;
|
||||
color: var(--w-deep-text);
|
||||
border: 1px solid var(--w-border-soft);
|
||||
padding: 6px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.help-block .call-btn:hover {
|
||||
background: var(--w-soft-bg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.help-block .call-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* POPULAR TOPICS 区块 */
|
||||
.popular-topics-fixed {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.chat-separator-topics {
|
||||
height: 1px;
|
||||
background: var(--w-primary);
|
||||
opacity: 0.15;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popular-topics-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--w-caption-text);
|
||||
letter-spacing: 0.14em;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.popular-topics-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.popular-topic-btn {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--w-border-soft);
|
||||
background: var(--w-soft-bg);
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--w-deep-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.popular-topic-btn:hover {
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* 底部信息栏 */
|
||||
.chat-footer {
|
||||
padding: 10px 10px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--w-caption-text);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chat-footer .footer-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-footer .footer-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 600px) {
|
||||
.chat-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
.message {
|
||||
max-width: 88%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// ---- 动态主题色 bootstrap(spec §3):?color= → 派生 → :root 变量 ----
|
||||
(function () {
|
||||
if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 默认橙兜底
|
||||
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
|
||||
function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return ""; } }
|
||||
var color = m ? safeDecode(m[1]).trim() : "";
|
||||
var palette = ChatTheme.derivePalette(color || ChatTheme.DEFAULT_COLOR);
|
||||
var root = document.documentElement.style;
|
||||
root.setProperty("--w-primary", palette.primary);
|
||||
root.setProperty("--w-primary-rgb", palette.primaryRgb);
|
||||
root.setProperty("--w-gradient-end", palette.gradientEnd);
|
||||
root.setProperty("--w-deep-text", palette.deepText);
|
||||
root.setProperty("--w-caption-text", palette.captionText);
|
||||
root.setProperty("--w-soft-bg", palette.softBg);
|
||||
root.setProperty("--w-border-soft", palette.borderSoft);
|
||||
|
||||
// 头像兜底(与挂件同机制)
|
||||
Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) {
|
||||
function hideEmoji() {
|
||||
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
|
||||
if (s) s.style.display = "none";
|
||||
}
|
||||
if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; }
|
||||
img.addEventListener("load", hideEmoji);
|
||||
img.addEventListener("error", function () { img.remove(); });
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
// 联系方式配置(与 widget.js 的 data-* 机制一致)
|
||||
var chatScript = document.currentScript || document.querySelector('script[src*="chat.js"]');
|
||||
var whatsapp = (chatScript && chatScript.getAttribute("data-whatsapp")) || "https://wa.me/+8619037179250";
|
||||
var email = (chatScript && chatScript.getAttribute("data-email")) || "mailto:info@borderlesscar.com";
|
||||
var waBtn = document.querySelector('.whatsapp-btn');
|
||||
var emBtn = document.querySelector('.email-btn');
|
||||
if (waBtn) waBtn.href = whatsapp;
|
||||
if (emBtn) emBtn.href = email;
|
||||
var sessionId = localStorage.getItem("cc_session_id");
|
||||
var messagesEl = document.getElementById("messages");
|
||||
var inputEl = document.getElementById("input");
|
||||
var sendBtn = document.getElementById("sendBtn");
|
||||
var typingEl = document.getElementById("typing");
|
||||
var endedEl = document.getElementById("chatEnded");
|
||||
var popularTopicsEl = document.getElementById("popular-topics");
|
||||
var ws = null;
|
||||
var ended = false;
|
||||
|
||||
// Current streaming message element
|
||||
var streamBubble = null;
|
||||
var statusTimer = null;
|
||||
|
||||
function getWsUrl() {
|
||||
var loc = window.location;
|
||||
var proto = loc.protocol === "https:" ? "wss:" : "ws:";
|
||||
return proto + "//" + loc.host + "/ws/chat";
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(getWsUrl());
|
||||
|
||||
ws.onopen = function () {
|
||||
if (sessionId) {
|
||||
ws.send(JSON.stringify({ session_id: sessionId }));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ source: "standalone" }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = function (e) {
|
||||
var data = JSON.parse(e.data);
|
||||
|
||||
if (data.type === "init" || data.type === "resumed") {
|
||||
sessionId = data.session_id;
|
||||
localStorage.setItem("cc_session_id", sessionId);
|
||||
if (data.type === "resumed") {
|
||||
loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "stream_start") {
|
||||
clearStatusTimer();
|
||||
hideTyping();
|
||||
streamBubble = addStreamBubble();
|
||||
}
|
||||
|
||||
if (data.type === "stream_chunk") {
|
||||
if (streamBubble) {
|
||||
streamBubble.textContent += data.content;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "stream_end") {
|
||||
streamBubble = null;
|
||||
}
|
||||
|
||||
if (data.type === "evaluated") {
|
||||
if (data.ended) {
|
||||
ended = true;
|
||||
endedEl.classList.add("active");
|
||||
inputEl.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "error") {
|
||||
clearStatusTimer();
|
||||
hideTyping();
|
||||
streamBubble = null;
|
||||
addMessage("assistant", "抱歉,出了点问题,请稍后再试。");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
if (!ended) {
|
||||
setTimeout(connect, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
function send() {
|
||||
var text = inputEl.value.trim();
|
||||
if (!text || ended || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
addMessage("user", text);
|
||||
inputEl.value = "";
|
||||
hidePopularTopics();
|
||||
|
||||
// Immediate feedback: show status then typing
|
||||
showStatusThenTyping();
|
||||
|
||||
ws.send(JSON.stringify({ content: text }));
|
||||
}
|
||||
|
||||
function sendTopicMessage(topic) {
|
||||
if (ended || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
addMessage("user", topic);
|
||||
hidePopularTopics();
|
||||
|
||||
// Immediate feedback: show status then typing
|
||||
showStatusThenTyping();
|
||||
|
||||
ws.send(JSON.stringify({ content: topic }));
|
||||
}
|
||||
|
||||
function hidePopularTopics() {
|
||||
if (popularTopicsEl) {
|
||||
popularTopicsEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function showStatusThenTyping() {
|
||||
clearStatusTimer();
|
||||
showTyping();
|
||||
// Auto-hide typing is handled by stream_start replacing it
|
||||
}
|
||||
|
||||
function clearStatusTimer() {
|
||||
if (statusTimer) {
|
||||
clearTimeout(statusTimer);
|
||||
statusTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(role, content) {
|
||||
var div = document.createElement("div");
|
||||
div.className = "message " + role;
|
||||
|
||||
var wrapper = document.createElement("div");
|
||||
var bubble = document.createElement("div");
|
||||
bubble.className = "bubble";
|
||||
bubble.textContent = content;
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
div.appendChild(wrapper);
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function addStreamBubble() {
|
||||
var div = document.createElement("div");
|
||||
div.className = "message assistant";
|
||||
|
||||
var wrapper = document.createElement("div");
|
||||
var bubble = document.createElement("div");
|
||||
bubble.className = "bubble";
|
||||
bubble.textContent = "";
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
div.appendChild(wrapper);
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function showTyping() {
|
||||
typingEl.classList.add("active");
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function hideTyping() {
|
||||
typingEl.classList.remove("active");
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
}
|
||||
|
||||
sendBtn.addEventListener("click", send);
|
||||
inputEl.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
|
||||
// Popular topics buttons
|
||||
var topicBtns = document.querySelectorAll(".popular-topic-btn");
|
||||
topicBtns.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var topic = this.getAttribute("data-topic");
|
||||
sendTopicMessage(topic);
|
||||
});
|
||||
});
|
||||
|
||||
connect();
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
/* 客服主题派生——单品牌色 → 7 槽位色板(spec 2026-08-31 §5)。
|
||||
* 注意:此文件与 static/widget.js 内联拷贝必须逐字符一致(空白归一化后),
|
||||
* 由 tests/test_theme_static.py 强制;修改务必两边同步。 */
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_COLOR = "#f08a31";
|
||||
|
||||
var DEFAULT_PALETTE = {
|
||||
primary: "#f08a31",
|
||||
primaryRgb: "240,138,49",
|
||||
gradientEnd: "#f08e2c",
|
||||
deepText: "#8a4c1d",
|
||||
captionText: "#c16d2d",
|
||||
softBg: "#fef8f4",
|
||||
borderSoft: "#f0d3bd"
|
||||
};
|
||||
|
||||
function clampNum(v, lo, hi) {
|
||||
return Math.min(hi, Math.max(lo, v));
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
hex = String(hex || "").replace(/^#/, "");
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split("").map(function (ch) { return ch + ch; }).join("");
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
var max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
var l = (max + min) / 2;
|
||||
var d = max - min;
|
||||
var h = 0, s = 0;
|
||||
if (d !== 0) {
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d) % 6;
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
if (h < 0) h += 360;
|
||||
}
|
||||
return { h: h, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
function toHex2(v) {
|
||||
var s = Math.round(clampNum(v, 0, 255)).toString(16);
|
||||
return s.length < 2 ? "0" + s : s;
|
||||
}
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = clampNum(s, 0, 100) / 100;
|
||||
l = clampNum(l, 0, 100) / 100;
|
||||
var c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
var m = l - c / 2;
|
||||
var rp = 0, gp = 0, bp = 0;
|
||||
if (h < 60) { rp = c; gp = x; }
|
||||
else if (h < 120) { rp = x; gp = c; }
|
||||
else if (h < 180) { gp = c; bp = x; }
|
||||
else if (h < 240) { gp = x; bp = c; }
|
||||
else if (h < 300) { rp = x; bp = c; }
|
||||
else { rp = c; bp = x; }
|
||||
return "#" + toHex2((rp + m) * 255) + toHex2((gp + m) * 255) + toHex2((bp + m) * 255);
|
||||
}
|
||||
|
||||
function derivePalette(color) {
|
||||
try {
|
||||
var rgb = hexToRgb(color);
|
||||
if (!rgb) {
|
||||
if (typeof console !== "undefined" && console.warn) console.warn("[chat-theme] invalid color, fallback:", color);
|
||||
return DEFAULT_PALETTE;
|
||||
}
|
||||
var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
var h = hsl.h, s = hsl.s, l = hsl.l;
|
||||
return {
|
||||
primary: "#" + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b),
|
||||
primaryRgb: rgb.r + "," + rgb.g + "," + rgb.b,
|
||||
gradientEnd: hslToHex(h + 2, s, l - 1),
|
||||
deepText: hslToHex(h - 2, s * 0.76, clampNum(l - 24, 28, 40)),
|
||||
captionText: hslToHex(h - 2, s * 0.72, clampNum(l - 10, 40, 52)),
|
||||
softBg: hslToHex(h, s * 0.9, 97.5),
|
||||
borderSoft: hslToHex(h - 2, s * 0.72, 84)
|
||||
};
|
||||
} catch (e) {
|
||||
return DEFAULT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
global.ChatTheme = {
|
||||
derivePalette: derivePalette,
|
||||
DEFAULT_COLOR: DEFAULT_COLOR,
|
||||
DEFAULT_PALETTE: DEFAULT_PALETTE
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 666 KiB |
@@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Widget Test — Invite Card + Theme Color + Assertions</title>
|
||||
<!-- 断言面板依赖 window.ChatTheme(与 widget.js 内联拷贝做一致性比对) -->
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 40px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.info {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 { color: #333; margin-bottom: 10px; }
|
||||
h2 { color: #333; margin-bottom: 10px; }
|
||||
p { color: #666; line-height: 1.6; }
|
||||
ul { color: #666; }
|
||||
.test-list li { margin-bottom: 8px; }
|
||||
.test-list li::before { content: "☐ "; color: #f08a31; font-weight: bold; }
|
||||
#assertions div { padding: 4px 0; border-bottom: 1px solid #eee; font-family: Consolas, monospace; font-size: 13px; }
|
||||
#assertions div:last-child { border-bottom: none; }
|
||||
#colorPicker { width: 48px; height: 32px; padding: 0; border: 1px solid #ccc; border-radius: 4px; vertical-align: middle; cursor: pointer; }
|
||||
.btn { padding: 8px 16px; background: #f08a31; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 6px; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="info">
|
||||
<h1>Widget Invite Card Test Page</h1>
|
||||
<p>This page tests the auto-popup invite card feature.</p>
|
||||
<p><strong>Expected behavior:</strong></p>
|
||||
<ul class="test-list">
|
||||
<li>Invite card appears after 5 seconds</li>
|
||||
<li>Invite card has orange gradient header with robot emoji</li>
|
||||
<li>Two topic buttons: "Get Quotation" and "Distributor Cooperation"</li>
|
||||
<li>Action buttons: "Start Chat" and "No Thanks"</li>
|
||||
<li>Click "Get Quotation" → invite closes → chat opens → message auto-sends</li>
|
||||
<li>Click "Distributor Cooperation" → invite closes → chat opens → message auto-sends</li>
|
||||
<li>Click "Start Chat" → invite closes → chat opens</li>
|
||||
<li>Click "No Thanks" → invite permanently closes</li>
|
||||
<li>Click "No Thanks" or X → refresh → invite stays hidden AND the old card area stays clickable (no invisible overlay; regression 2026-09-01)</li>
|
||||
<li>Click toggle button (while invite visible) → invite closes → chat opens</li>
|
||||
<li>Refresh page → invite appears again after 5s (unless "No Thanks" was clicked)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>Popular Topics in Chat Panel</h2>
|
||||
<ul class="test-list">
|
||||
<li>Chat panel shows "POPULAR TOPICS" section with two buttons</li>
|
||||
<li>Click topic → message auto-sends → topics section hides</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>Theme Color Preview</h2>
|
||||
<p>Pick a color and Apply, or use a preset. Each click tears down the old widget (host + script + invite dismissal state) and re-embeds with <code>data-color</code>. "Test Broken Avatar" points <code>data-avatar</code> at a missing image to verify the 🤖 emoji fallback.</p>
|
||||
<p>
|
||||
<input type="color" id="colorPicker" value="#f08a31">
|
||||
<button class="btn" onclick="applyColor()">Apply</button>
|
||||
<button class="btn" onclick="applyColor('#f08a31')">Orange</button>
|
||||
<button class="btn" onclick="applyColor('#1a73e8')">Blue</button>
|
||||
<button class="btn" onclick="applyColor('#16a34a')">Green</button>
|
||||
<button class="btn" onclick="applyColor('#7c3aed')">Purple</button>
|
||||
<button class="btn" onclick="rebuildWidget({'data-mobile':'false','data-color':document.getElementById('colorPicker').value})">Desktop Pill</button>
|
||||
<button class="btn" onclick="applyBrokenAvatar()">Test Broken Avatar</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>Assertion Panel</h2>
|
||||
<p>Runs on every widget (re)embed. Checks: derived default palette vs current hardcoded values (max per-channel Δ ≤ 8), derive(default) === DEFAULT_PALETTE, inline widget.js copy === theme.js copy, invalid input falls back to DEFAULT_PALETTE.</p>
|
||||
<div id="assertions"></div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<p><strong>Current Page:</strong> <span id="pathname"></span></p>
|
||||
<p><strong>Storage Key:</strong> widget_invite_dismissed_<span id="storage-key"></span></p>
|
||||
<p><button class="btn" onclick="clearStorage()">Clear Storage & Refresh</button></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('pathname').textContent = window.location.pathname;
|
||||
document.getElementById('storage-key').textContent = window.location.pathname;
|
||||
|
||||
function clearStorage() {
|
||||
sessionStorage.removeItem('widget_invite_dismissed_' + window.location.pathname);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
/* ===== Teardown / 重建(spec §7.1 三个陷阱:旧 host、旧 script、sessionStorage) ===== */
|
||||
var BASE_ATTRS = {
|
||||
"data-position": "bottom-right",
|
||||
"data-theme": "light",
|
||||
"data-mobile": "true",
|
||||
"data-debug": "true",
|
||||
"data-whatsapp": "https://wa.me/8613800138000",
|
||||
"data-email": "mailto:demo@hesu-auto.com"
|
||||
};
|
||||
|
||||
var rebuildLoading = false, rebuildPending = null;
|
||||
function rebuildWidget(extra) {
|
||||
if (rebuildLoading) { rebuildPending = extra || {}; return; } // 快速连点:只记最新意图,当前加载完接力重建
|
||||
rebuildLoading = true;
|
||||
document.getElementById("assertions").innerHTML = "<div>…rebuilding…</div>";
|
||||
document.querySelectorAll("#cc-widget-host").forEach(function (h) { h.remove(); });
|
||||
document.querySelectorAll("script[src*='widget.js']").forEach(function (s) { s.remove(); });
|
||||
try { sessionStorage.removeItem("widget_invite_dismissed_" + window.location.pathname); } catch (e) {}
|
||||
var s = document.createElement("script");
|
||||
s.src = "/static/widget.js?" + Date.now();
|
||||
var attrs = Object.assign({}, BASE_ATTRS, extra || {});
|
||||
Object.keys(attrs).forEach(function (k) { s.setAttribute(k, attrs[k]); });
|
||||
s.onload = function () {
|
||||
rebuildLoading = false;
|
||||
var hosts = document.querySelectorAll("#cc-widget-host");
|
||||
for (var i = 0; i < hosts.length - 1; i++) hosts[i].remove(); // 竞态兜底:只保留最后挂载
|
||||
runAssertions();
|
||||
var next = rebuildPending; rebuildPending = null;
|
||||
if (next) rebuildWidget(next);
|
||||
};
|
||||
s.onerror = function () {
|
||||
rebuildLoading = false;
|
||||
document.getElementById("assertions").innerHTML = "<div>❌ widget.js 加载失败</div>";
|
||||
var next = rebuildPending; rebuildPending = null;
|
||||
if (next) rebuildWidget(next);
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
function applyColor(c) {
|
||||
rebuildWidget({ "data-color": c || document.getElementById("colorPicker").value });
|
||||
}
|
||||
function applyBrokenAvatar() {
|
||||
rebuildWidget({ "data-color": "#1a73e8", "data-avatar": "/static/nope.png" });
|
||||
}
|
||||
|
||||
/* ===== 断言面板(spec §8.2) ===== */
|
||||
var CURRENT_HARDCODED = { // 现状代表值(spec §4),Δ ≤ 8/通道
|
||||
primary: "#f08a31", gradientEnd: "#F08A34", deepText: "#8C4D1D",
|
||||
captionText: "#C16C2E", softBg: "#FFF8F3", borderSoft: "#F0D5BF"
|
||||
};
|
||||
function hexToRgb6(h) {
|
||||
h = String(h).replace(/^#/, "");
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||
}
|
||||
function channelDelta(a, b) {
|
||||
var ra = hexToRgb6(a), rb = hexToRgb6(b);
|
||||
return Math.max(Math.abs(ra[0] - rb[0]), Math.abs(ra[1] - rb[1]), Math.abs(ra[2] - rb[2]));
|
||||
}
|
||||
function runAssertions() {
|
||||
var rows = [];
|
||||
function check(name, ok, detail) { rows.push((ok ? "✅ " : "❌ ") + name + (detail ? " — " + detail : "")); }
|
||||
var t = window.ChatTheme, d = window.__chatWidgetDebug;
|
||||
if (!t || !d) {
|
||||
rows.push("❌ theme.js / widget debug 钩子未就绪");
|
||||
} else {
|
||||
var p = t.derivePalette("#f08a31");
|
||||
Object.keys(CURRENT_HARDCODED).forEach(function (k) {
|
||||
var delta = channelDelta(p[k], CURRENT_HARDCODED[k]);
|
||||
check("default " + k + " Δ≤8", delta <= 8, p[k] + " vs " + CURRENT_HARDCODED[k] + ", Δ=" + delta);
|
||||
});
|
||||
check("derive(default) === DEFAULT_PALETTE",
|
||||
JSON.stringify(p) === JSON.stringify(t.DEFAULT_PALETTE));
|
||||
check("inline copy === theme.js copy",
|
||||
d.derivePalette.toString().replace(/\s+/g, "") === t.derivePalette.toString().replace(/\s+/g, ""));
|
||||
check("invalid color → DEFAULT_PALETTE",
|
||||
JSON.stringify(t.derivePalette("not-a-color")) === JSON.stringify(t.DEFAULT_PALETTE));
|
||||
}
|
||||
document.getElementById("assertions").innerHTML = rows.map(function (r) { return "<div>" + r + "</div>"; }).join("");
|
||||
}
|
||||
|
||||
/* 初始嵌入:打开页面即有挂件 + 断言跑一轮 */
|
||||
document.addEventListener("DOMContentLoaded", function () { rebuildWidget({}); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,687 @@
|
||||
(function () {
|
||||
var script = document.currentScript || (function () {
|
||||
var scripts = document.getElementsByTagName("script");
|
||||
return scripts[scripts.length - 1];
|
||||
})();
|
||||
|
||||
// Save config immediately (before DOM ready)
|
||||
var position = script.getAttribute("data-position") || "bottom-right";
|
||||
var theme = script.getAttribute("data-theme") || "light";
|
||||
var title = script.getAttribute("data-title") || "HESU Support";
|
||||
var autoOpen = script.getAttribute("data-auto-open") === "true";
|
||||
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250";
|
||||
var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com";
|
||||
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
|
||||
var isMobile = script.getAttribute("data-mobile") === "true";
|
||||
var wsBase = new URL(script.src).origin;
|
||||
|
||||
/* ===== 主题派生单元:与 static/js/theme.js 逐字同步,tests/test_theme_static.py 强制;只可整块替换,不可单边修改 ===== */
|
||||
var DEFAULT_COLOR = "#f08a31";
|
||||
|
||||
var DEFAULT_PALETTE = {
|
||||
primary: "#f08a31",
|
||||
primaryRgb: "240,138,49",
|
||||
gradientEnd: "#f08e2c",
|
||||
deepText: "#8a4c1d",
|
||||
captionText: "#c16d2d",
|
||||
softBg: "#fef8f4",
|
||||
borderSoft: "#f0d3bd"
|
||||
};
|
||||
|
||||
function clampNum(v, lo, hi) {
|
||||
return Math.min(hi, Math.max(lo, v));
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
hex = String(hex || "").replace(/^#/, "");
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split("").map(function (ch) { return ch + ch; }).join("");
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
var max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
var l = (max + min) / 2;
|
||||
var d = max - min;
|
||||
var h = 0, s = 0;
|
||||
if (d !== 0) {
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d) % 6;
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
if (h < 0) h += 360;
|
||||
}
|
||||
return { h: h, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
function toHex2(v) {
|
||||
var s = Math.round(clampNum(v, 0, 255)).toString(16);
|
||||
return s.length < 2 ? "0" + s : s;
|
||||
}
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = clampNum(s, 0, 100) / 100;
|
||||
l = clampNum(l, 0, 100) / 100;
|
||||
var c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
var m = l - c / 2;
|
||||
var rp = 0, gp = 0, bp = 0;
|
||||
if (h < 60) { rp = c; gp = x; }
|
||||
else if (h < 120) { rp = x; gp = c; }
|
||||
else if (h < 180) { gp = c; bp = x; }
|
||||
else if (h < 240) { gp = x; bp = c; }
|
||||
else if (h < 300) { rp = x; bp = c; }
|
||||
else { rp = c; bp = x; }
|
||||
return "#" + toHex2((rp + m) * 255) + toHex2((gp + m) * 255) + toHex2((bp + m) * 255);
|
||||
}
|
||||
|
||||
function derivePalette(color) {
|
||||
try {
|
||||
var rgb = hexToRgb(color);
|
||||
if (!rgb) {
|
||||
if (typeof console !== "undefined" && console.warn) console.warn("[chat-theme] invalid color, fallback:", color);
|
||||
return DEFAULT_PALETTE;
|
||||
}
|
||||
var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
var h = hsl.h, s = hsl.s, l = hsl.l;
|
||||
return {
|
||||
primary: "#" + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b),
|
||||
primaryRgb: rgb.r + "," + rgb.g + "," + rgb.b,
|
||||
gradientEnd: hslToHex(h + 2, s, l - 1),
|
||||
deepText: hslToHex(h - 2, s * 0.76, clampNum(l - 24, 28, 40)),
|
||||
captionText: hslToHex(h - 2, s * 0.72, clampNum(l - 10, 40, 52)),
|
||||
softBg: hslToHex(h, s * 0.9, 97.5),
|
||||
borderSoft: hslToHex(h - 2, s * 0.72, 84)
|
||||
};
|
||||
} catch (e) {
|
||||
return DEFAULT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 主题派生单元结束 ===== */
|
||||
var DEFAULT_AVATAR_PATH = "/static/kefu-small.png";
|
||||
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
|
||||
var avatarUrl = avatarAttr
|
||||
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
|
||||
: wsBase + DEFAULT_AVATAR_PATH;
|
||||
var colorAttr = (script.getAttribute("data-color") || "").trim();
|
||||
var palette = derivePalette(colorAttr || DEFAULT_COLOR);
|
||||
if (script.getAttribute("data-debug") === "true") {
|
||||
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
|
||||
}
|
||||
|
||||
var positionStyles = {
|
||||
"bottom-right": { bottom: "80px", right: "10px" },
|
||||
"bottom-left": { bottom: "20px", left: "20px" },
|
||||
"top-right": { top: "20px", right: "20px" },
|
||||
"top-left": { top: "20px", left: "20px" },
|
||||
};
|
||||
var positionStylesMobile = {
|
||||
"bottom-right": { bottom: "20px", right: "16px" },
|
||||
"bottom-left": { bottom: "20px", left: "16px" },
|
||||
"top-right": { top: "20px", right: "16px" },
|
||||
"top-left": { top: "20px", left: "16px" },
|
||||
};
|
||||
|
||||
var colors = {
|
||||
light: { bg: "#ffffff", text: "#333", bubble: "#f0f0f0" },
|
||||
dark: { bg: "#1e1e1e", text: "#e0e0e0", bubble: "#2d2d2d" },
|
||||
};
|
||||
|
||||
var c = colors[theme] || colors.light;
|
||||
var pos = (isMobile ? positionStylesMobile : positionStyles)[position]
|
||||
|| (isMobile ? positionStylesMobile : positionStyles)["bottom-right"];
|
||||
|
||||
function init() {
|
||||
var host = document.createElement("div");
|
||||
host.id = "cc-widget-host";
|
||||
document.body.appendChild(host);
|
||||
|
||||
var shadow = host.attachShadow({ mode: "open" });
|
||||
|
||||
// Inject styles
|
||||
var style = document.createElement("style");
|
||||
style.textContent = [
|
||||
":host{--w-primary:" + palette.primary + ";--w-primary-rgb:" + palette.primaryRgb + ";--w-gradient-end:" + palette.gradientEnd + ";--w-deep-text:" + palette.deepText + ";--w-caption-text:" + palette.captionText + ";--w-soft-bg:" + palette.softBg + ";--w-border-soft:" + palette.borderSoft + "}",
|
||||
"* { margin:0; padding:0; box-sizing:border-box; }",
|
||||
".widget-container { position:fixed; z-index:99999; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }",
|
||||
".widget-container { " + Object.keys(pos).map(function (k) { return k + ":" + pos[k]; }).join(";") + "; }",
|
||||
|
||||
// Toggle button
|
||||
".toggle-btn { width:auto; height:auto; background:var(--w-primary); border-radius:30px; padding:8px 20px; display:flex; align-items:center; gap:12px; box-shadow:0 4px 16px rgba(var(--w-primary-rgb),0.4); transition:transform 0.2s; cursor:pointer; border:none; }",
|
||||
".toggle-btn:hover { transform:scale(1.1); }",
|
||||
".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }",
|
||||
".toggle-btn.mobile:hover { transform:scale(1.05); }",
|
||||
".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:var(--w-primary); display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(var(--w-primary-rgb),0.45); }",
|
||||
".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }",
|
||||
".toggle-btn.mobile .mobile-label { color:var(--w-deep-text); font-size:10px; font-weight:600; line-height:1.1; }",
|
||||
".toggle-avatar-wrap { position:relative; }",
|
||||
".toggle-avatar-circle { position:relative; width:36px; height:36px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; }",
|
||||
".toggle-avatar-dot { position:absolute; top:0; right:0; width:10px; height:10px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }",
|
||||
".avatar-emoji { line-height:1; }",
|
||||
".avatar-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; border-radius:50%; }",
|
||||
// 移动端 D2 按钮比桌面药丸高(72px vs 52px),抬高面板底边避免压住按钮(覆盖面板默认 80px 与 @media 60px)
|
||||
".widget-container.mobile-host .chat-panel { bottom:84px; }",
|
||||
".widget-container.mobile-host { bottom:70px; }",
|
||||
|
||||
// Chat panel
|
||||
".chat-panel { position:absolute; bottom:80px; right:0; width:400px; height:540px; background:" + c.bg + "; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,0.15); display:none; flex-direction:column; overflow:hidden; }",
|
||||
".chat-panel.open { display:flex; }",
|
||||
|
||||
// Panel header
|
||||
".panel-header { display:flex; align-items:center; gap:12px; padding:14px 18px; background:var(--w-primary); color:white; }",
|
||||
".panel-header .avatar { width:48px; height:48px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:22px; position:relative; }",
|
||||
".panel-header .avatar::after { content:''; position:absolute; bottom:1px; right:1px; width:14px; height:14px; background:#22c55e; border-radius:50%; border:3px solid var(--w-primary); }",
|
||||
".panel-header .info { flex:1; }",
|
||||
".panel-header .info h3 { font-size:17px; font-weight:600; margin:0; }",
|
||||
".panel-header .info .status { font-size:13px; opacity:0.95; margin:0; }",
|
||||
".panel-header .actions { display:flex; gap:8px; }",
|
||||
".panel-header .action-btn { width:32px; height:32px; border-radius:8px; background:rgba(255,255,255,0.15); cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background 0.2s; border:none; color:white; }",
|
||||
".panel-header .action-btn:hover { background:rgba(255,255,255,0.25); }",
|
||||
|
||||
// Messages
|
||||
".panel-top { flex-shrink:0; padding:10px 16px; background:var(--w-soft-bg); }",
|
||||
".panel-messages { flex:1; overflow-y:auto; padding:16px; background:#ffffff; display:flex; flex-direction:column; gap:8px; padding-bottom:8px; }",
|
||||
".popular-topics-fixed { flex-shrink:0; padding:12px 16px 8px; background:#ffffff; }",
|
||||
".chat-separator-topics { height:1px; background:var(--w-primary); opacity:0.15; flex-shrink:0; }",
|
||||
".help-block { background:var(--w-soft-bg); border-radius:12px; padding:10px 14px; border:1px solid var(--w-border-soft); }",
|
||||
".help-block .help-content { flex:1; min-width:0; }",
|
||||
".help-block .help-title { font-size:16px; font-weight:700; color:#111827; margin-bottom:3px; }",
|
||||
".help-block .help-description { font-size:12px; color:#6b7280; line-height:1.4; }",
|
||||
".help-block .help-actions { display:grid; gap:8px; flex-shrink:0; }",
|
||||
".help-block .call-btn { justify-content: center;background: #fff; color: var(--w-deep-text);border: 1px solid var(--w-border-soft);padding: 6px 10px; border-radius:20px; font-size:13px; font-weight:600; cursor:pointer; display:flex; align-items:center; gap:5px; text-decoration:none; flex-shrink:0; transition:all 0.2s; }",
|
||||
// ".help-block .whatsapp-btn:hover { box-shadow:0 4px 12px rgba(37,211,102,0.4); transform:translateY(-1px); }",
|
||||
// ".help-block .email-btn { background:linear-gradient(135deg, #EA4335 0%, #C5221F 100%); }",
|
||||
// ".help-block .email-btn:hover { box-shadow:0 4px 12px rgba(234,67,53,0.4); transform:translateY(-1px); }",
|
||||
".popular-topics-label { font-size:11px; font-weight:600; color:var(--w-caption-text); letter-spacing:0.14em; margin-bottom:8px; text-transform:uppercase; }",
|
||||
".popular-topics-grid { display:flex; gap:8px; flex-wrap:wrap; }",
|
||||
".popular-topic-btn { border-radius:20px; border:1px solid var(--w-border-soft); background:var(--w-soft-bg); padding:8px 16px; font-size:12px; font-weight:500; color:var(--w-deep-text); cursor:pointer; transition:background 0.2s; white-space:nowrap; }",
|
||||
".popular-topic-btn:hover { background:white; }",
|
||||
".msg-wrapper { display:flex; gap:10px; }",
|
||||
".msg-wrapper .avatar { width:32px; height:32px; background:var(--w-primary); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; color:white; flex-shrink:0; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.3); }",
|
||||
".msg { max-width:280px; padding:12px 16px; border-radius:16px; font-size:14px; line-height:1.5; word-break:break-word; }",
|
||||
".msg.user { align-self:flex-end; background:var(--w-primary); color:#fff; border-bottom-right-radius:4px; }",
|
||||
".msg.ai { background:#f3f4f6; color:#1f2937; border-bottom-left-radius:4px; }",
|
||||
|
||||
// Separator
|
||||
".chat-separator { height:1px; background:var(--w-primary); opacity:0.3; flex-shrink:0; }",
|
||||
|
||||
// Footer
|
||||
".chat-footer { padding:10px 10px; background:white; display:flex; align-items:center; justify-content:space-between; font-size:11px; color:var(--w-caption-text); gap:20px; }",
|
||||
".chat-footer .footer-left { flex:1; }",
|
||||
".chat-footer .footer-right { flex-shrink:0; }",
|
||||
|
||||
// Typing
|
||||
".typing { display:none; align-self:flex-start; padding:8px 12px; background:" + c.bubble + "; border-radius:12px; }",
|
||||
".typing.active { display:flex; gap:4px; align-items:center; }",
|
||||
".typing span { width:6px; height:6px; background:#999; border-radius:50%; animation:blink 1.4s infinite; }",
|
||||
".typing span:nth-child(2){animation-delay:0.2s} .typing span:nth-child(3){animation-delay:0.4s}",
|
||||
"@keyframes blink { 0%,60%,100%{transform:translateY(0);opacity:0.4} 30%{transform:translateY(-4px);opacity:1} }",
|
||||
|
||||
// Input
|
||||
".input-container { padding:10px 10px; background:white; }",
|
||||
".input-wrapper { display:flex; align-items:center; gap:0; padding:8px 16px; background:var(--w-soft-bg); border-radius:28px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }",
|
||||
".input-wrapper input { flex:1; padding:0; border:none; font-size:14px; background:transparent; outline:none; min-height:20px; color:#1f2937; }",
|
||||
".input-wrapper input::placeholder { color:#9ca3af; }",
|
||||
".input-separator { width:1px; height:24px; background:#e5e7eb; margin:0 8px; flex-shrink:0; }",
|
||||
".input-wrapper button { padding:8px 16px; border:none; border-radius:12px; background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); color:#fff; font-size:14px; font-weight:500; cursor:pointer; display:flex; align-items:center; gap:6px; white-space:nowrap; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.25); flex-shrink:0; }",
|
||||
".input-wrapper button:disabled { background:#ccc; box-shadow:none; }",
|
||||
|
||||
// Ended
|
||||
".ended { display:none; text-align:center; padding:8px; background:#e8f5e9; color:#34a853; font-size:12px; }",
|
||||
".ended.active { display:block; }",
|
||||
|
||||
// Invite card container
|
||||
// pointer-events:none:容器永不拦截点击,展示态由 .invite-panel.show 的 auto 恢复交互
|
||||
".invite-container { z-index:99998;position: absolute;bottom: 80px;right: 0;pointer-events:none; }",
|
||||
".invite-panel { width:360px; max-width:calc(100vw - 24px); overflow:hidden; border-radius:26px; border:1px solid var(--w-border-soft); background:white; box-shadow:0 28px 80px rgba(var(--w-primary-rgb),0.22); opacity:0; transform:translateY(20px); transition:opacity 0.3s, transform 0.3s; pointer-events:none; }",
|
||||
".invite-panel.show { opacity:1; transform:translateY(0); pointer-events:auto; }",
|
||||
".invite-panel.hide { display:none; }",
|
||||
|
||||
// Invite header
|
||||
".invite-header { background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); padding:16px 20px; color:white; display:flex; gap:12px; }",
|
||||
".invite-icon-wrapper { flex-shrink:0; }",
|
||||
".invite-icon { position:relative;width:40px; height:40px; background:rgba(255,255,255,0.18); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; backdrop-filter:blur(10px); }",
|
||||
".invite-status-dot { position:absolute; bottom:-2px; right:-2px; width:12px; height:12px; background:#22c55e; border-radius:50%; border:3px solid var(--w-primary); }",
|
||||
".invite-header-content { flex:1; min-width:0; }",
|
||||
".invite-title { font-size:15px; font-weight:600; line-height:1.3; }",
|
||||
".invite-subtitle { font-size:12px; margin-top:2px; opacity:0.9; line-height:1.4; }",
|
||||
".invite-header-actions { display:flex; flex-direction:column; gap:6px; flex-shrink:0; margin-left:auto; }",
|
||||
".invite-close-btn { align-self: flex-end; width:28px; height:28px; border-radius:6px; background:rgba(255,255,255,0.1); border:none; color:white; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background 0.2s; flex-shrink:0; }",
|
||||
".invite-close-btn:hover { background:rgba(255,255,255,0.2); }",
|
||||
".invite-action-btn { justify-content: center; background:#fff; color:var(--w-deep-text); border:1px solid var(--w-border-soft); padding:5px 10px; border-radius:20px; font-size:12px; font-weight:600; cursor:pointer; display:flex; align-items:center; gap:5px; text-decoration:none; flex-shrink:0; transition:all 0.2s; white-space:nowrap; }",
|
||||
".invite-action-btn:hover { background:var(--w-soft-bg); }",
|
||||
|
||||
// Invite body
|
||||
".invite-body { padding:16px 8px; background:var(--w-soft-bg); }",
|
||||
|
||||
// Topic buttons
|
||||
".invite-topics { display:grid; grid-template-columns:1fr 1fr; gap:5px; margin-bottom:16px; }",
|
||||
".invite-topic-btn { border-radius:22px; border:1px solid var(--w-border-soft); background:white; padding:14px 10px; text-align:center; font-size:13px; font-weight:500; color:var(--w-deep-text); cursor:pointer; transition:background 0.2s; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }",
|
||||
".invite-topic-btn:hover { background:var(--w-soft-bg); }",
|
||||
|
||||
// Action buttons
|
||||
".invite-actions { display:flex; gap:10px; }",
|
||||
".invite-start-btn { flex:1; border-radius:20px; border:none; background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); padding:14px 20px; color:white; font-size:14px; font-weight:600; cursor:pointer; display:flex; align-items:center; justify-content:center; gap:8px; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.25); transition:opacity 0.2s; }",
|
||||
".invite-start-btn:hover { opacity:0.95; }",
|
||||
".invite-dismiss-btn { border-radius:20px; border:1px solid var(--w-border-soft); background:white; padding:14px 20px; color:var(--w-deep-text); font-size:14px; font-weight:500; cursor:pointer; transition:background 0.2s; }",
|
||||
".invite-dismiss-btn:hover { background:#FEFEFE; }",
|
||||
|
||||
// Responsive
|
||||
"@media(max-width:450px){ .chat-panel{width:calc(100vw - 20px);height:calc(100vh - 200px);bottom:60px;right:0px;} .invite-panel{width:calc(100vw - 24px);} .invite-topics{grid-template-columns:1fr;} }",
|
||||
].join("\n");
|
||||
shadow.appendChild(style);
|
||||
|
||||
// Build DOM
|
||||
var container = document.createElement("div");
|
||||
container.className = "widget-container" + (isMobile ? " mobile-host" : "");
|
||||
|
||||
var panel = document.createElement("div");
|
||||
panel.className = "chat-panel";
|
||||
panel.innerHTML = [
|
||||
'<div class="panel-header">',
|
||||
'<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"></div>',
|
||||
'<div class="info"><h3>' + title + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
|
||||
'<div class="actions">',
|
||||
'<button class="action-btn" aria-label="Minimize">−</button>',
|
||||
// '<button class="close-btn action-btn" aria-label="Close">×</button>',
|
||||
'</div></div>',
|
||||
'<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>',
|
||||
'<div class="panel-top">',
|
||||
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about vehicles, market support, quotation, shipping and delivery.</div></div><div class="help-actions"><a href="' + whatsapp + '" target="_blank" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a><a href="' + email + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a></div></div></div>',
|
||||
'</div>',
|
||||
'<div class="chat-separator"></div>',
|
||||
'<div class="panel-messages" id="w-msgs">',
|
||||
'<div class="msg-wrapper"><div class="msg ai">Hey! What kind of car are you looking for?</div></div>',
|
||||
'</div>',
|
||||
'<div class="typing" id="w-typing"><span></span><span></span><span></span></div>',
|
||||
'<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid"><button class="popular-topic-btn" data-topic="Get Quotation">Get Quotation</button><button class="popular-topic-btn" data-topic="Distributor Cooperation">Distributor Cooperation</button></div></div>',
|
||||
'<div class="chat-separator-topics"></div>',
|
||||
'<div class="input-container"><div class="input-wrapper">',
|
||||
'<input type="text" id="w-input" placeholder="Type a message...">',
|
||||
'<button id="w-send"><span style="font-size:16px;">➤</span><span>Send</span></button>',
|
||||
'</div></div>',
|
||||
].join("");
|
||||
|
||||
// Invite card HTML template
|
||||
var inviteTemplate = [
|
||||
'<div class="invite-panel hide">',
|
||||
'<div class="invite-header">',
|
||||
'<div class="invite-icon-wrapper">',
|
||||
'<div class="invite-icon"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"><span class="invite-status-dot"></span></div>',
|
||||
'</div>',
|
||||
'<div class="invite-header-content">',
|
||||
'<div class="invite-title">Chat with us for available vehicles, delivery timing and distributor cooperation.</div>',
|
||||
'</div>',
|
||||
'<div class="invite-header-actions">',
|
||||
'<button class="invite-close-btn" aria-label="Close">',
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
|
||||
'</button>',
|
||||
'<a href="' + whatsapp + '" target="_blank" class="invite-action-btn whatsapp-btn">',
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>',
|
||||
'<span>WhatsApp</span>',
|
||||
'</a>',
|
||||
'<a href="' + email + '" class="invite-action-btn email-btn">',
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>',
|
||||
'<span>Email</span>',
|
||||
'</a>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div class="invite-body">',
|
||||
'<div class="invite-topics">',
|
||||
'<button class="invite-topic-btn" data-topic="Get Quotation">Get Quotation</button>',
|
||||
'<button class="invite-topic-btn" data-topic="Distributor Cooperation">Distributor Cooperation</button>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div class="chat-separator"></div>',
|
||||
'<div class="input-container">',
|
||||
'<div class="invite-actions">',
|
||||
'<button class="invite-start-btn">Start Chat',
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>',
|
||||
'</button>',
|
||||
'<button class="invite-dismiss-btn">No Thanks</button>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
|
||||
var inviteEl = document.createElement("div");
|
||||
inviteEl.className = "invite-container";
|
||||
inviteEl.innerHTML = inviteTemplate;
|
||||
// 不变量:面板出厂即 hide(display:none)—— 任何不经过 showInvite 的路径都零占位。
|
||||
// 2026-09-01 修复:此前 permanent 关闭后刷新时 showInvite 不运行、invitePanel 恒为
|
||||
// null、hideInvite 提前返回,隐形 invite-panel(display:block + opacity:0)在
|
||||
// z-index:99998 容器里拦截右下角点击。出厂 hide 取代原 !autoInvite 特判。
|
||||
|
||||
var toggleBtn = document.createElement("button");
|
||||
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
|
||||
if (isMobile) {
|
||||
toggleBtn.innerHTML = `
|
||||
<div class="mobile-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"><span class="mobile-dot"></span></div>
|
||||
<span class="mobile-label">Chat with us</span>
|
||||
`;
|
||||
} else {
|
||||
toggleBtn.innerHTML = `
|
||||
<div class="toggle-avatar-wrap">
|
||||
<div class="toggle-avatar-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"></div>
|
||||
<div class="toggle-avatar-dot"></div>
|
||||
</div>
|
||||
<div style="color:white;">
|
||||
<div style="font-size:14px;font-weight:600;">Chat with us</div>
|
||||
<div style="font-size:11px;opacity:0.9;">Usually replies quickly</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.appendChild(panel);
|
||||
container.appendChild(inviteEl);
|
||||
container.appendChild(toggleBtn);
|
||||
shadow.appendChild(container);
|
||||
|
||||
// 头像兜底:load 成功隐藏底层 emoji;error 移除 img 露出 emoji(spec §12.4)
|
||||
Array.prototype.forEach.call(shadow.querySelectorAll(".avatar-img"), function (img) {
|
||||
function hideEmoji() {
|
||||
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
|
||||
if (s) s.style.display = "none";
|
||||
}
|
||||
if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; }
|
||||
img.addEventListener("load", hideEmoji);
|
||||
img.addEventListener("error", function () { img.remove(); });
|
||||
});
|
||||
|
||||
// Widget state
|
||||
var isOpen = autoOpen;
|
||||
var wSessionId = null;
|
||||
var wWs = null;
|
||||
var wEnded = false;
|
||||
var streamBubble = null;
|
||||
var pageToken = crypto.randomUUID();
|
||||
var pendingMessage = null; // Queue message to send after WebSocket opens
|
||||
|
||||
// Invite card state management
|
||||
var inviteTimer = null;
|
||||
var invitePanel = null;
|
||||
var inviteShown = false;
|
||||
var inviteStorageKey = "widget_invite_dismissed_" + window.location.pathname;
|
||||
var isPermanentlyClosed = false;
|
||||
|
||||
function checkInviteStorage() {
|
||||
try {
|
||||
var dismissed = sessionStorage.getItem(inviteStorageKey);
|
||||
if (dismissed === "permanent") {
|
||||
isPermanentlyClosed = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false; // Assume not dismissed on error
|
||||
}
|
||||
}
|
||||
|
||||
function setInviteDismissed(type) {
|
||||
try {
|
||||
if (type !== "permanent" && type !== "temporary") {
|
||||
type = "temporary"; // Default fallback
|
||||
}
|
||||
if (type === "permanent") {
|
||||
sessionStorage.setItem(inviteStorageKey, "permanent");
|
||||
isPermanentlyClosed = true;
|
||||
} else {
|
||||
sessionStorage.setItem(inviteStorageKey, "temporary");
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function clearInviteDismissed() {
|
||||
try {
|
||||
sessionStorage.removeItem(inviteStorageKey);
|
||||
isPermanentlyClosed = false;
|
||||
inviteShown = false; // Reset shown flag
|
||||
} catch (e) {
|
||||
// Silently fail - storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function startInviteTimer() {
|
||||
if (!autoInvite) return; // 配置关闭则不启动计时器,邀请卡永不自动弹
|
||||
if (checkInviteStorage()) {
|
||||
return; // Already dismissed, don't show
|
||||
}
|
||||
|
||||
inviteTimer = window.setTimeout(function() {
|
||||
if (!isOpen) {
|
||||
showInvite();
|
||||
}
|
||||
}, 5000); // 5 seconds
|
||||
}
|
||||
|
||||
function showInvite() {
|
||||
if (inviteShown || isPermanentlyClosed) return;
|
||||
inviteShown = true;
|
||||
invitePanel = shadow.querySelector(".invite-panel");
|
||||
if (invitePanel) {
|
||||
invitePanel.classList.remove("hide");
|
||||
void invitePanel.offsetWidth; // 强制回流:remove 与 add 同帧会跳过 0.3s 淡入过渡
|
||||
invitePanel.classList.add("show");
|
||||
}
|
||||
}
|
||||
|
||||
function hideInvite(type) {
|
||||
if (!invitePanel) return;
|
||||
invitePanel.classList.remove("show");
|
||||
invitePanel.classList.add("hide");
|
||||
setInviteDismissed(type || "temporary");
|
||||
}
|
||||
|
||||
var msgsEl = shadow.getElementById("w-msgs");
|
||||
var inputEl = shadow.getElementById("w-input");
|
||||
var sendEl = shadow.getElementById("w-send");
|
||||
var typingEl = shadow.getElementById("w-typing");
|
||||
var endedEl = shadow.getElementById("w-ended");
|
||||
var inviteCloseBtn = shadow.querySelector(".invite-close-btn");
|
||||
var inviteStartBtn = shadow.querySelector(".invite-start-btn");
|
||||
var inviteDismissBtn = shadow.querySelector(".invite-dismiss-btn");
|
||||
var inviteTopicBtns = shadow.querySelectorAll(".invite-topic-btn");
|
||||
|
||||
if (isOpen) panel.classList.add("open");
|
||||
|
||||
function toggle() {
|
||||
isOpen = !isOpen;
|
||||
panel.classList.toggle("open", isOpen);
|
||||
|
||||
if (isOpen) {
|
||||
if (inviteTimer) {
|
||||
window.clearTimeout(inviteTimer);
|
||||
inviteTimer = null;
|
||||
}
|
||||
hideInvite("temporary");
|
||||
if (!wWs) wConnect();
|
||||
} else {
|
||||
// Only restart timer if not permanently closed
|
||||
if (!isPermanentlyClosed && !checkInviteStorage()) {
|
||||
inviteShown = false;
|
||||
startInviteTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener("click", toggle);
|
||||
// panel.querySelector(".close-btn").addEventListener("click", toggle);
|
||||
panel.querySelector(".action-btn:not(.close-btn)")?.addEventListener("click", toggle);
|
||||
|
||||
// Invite close button
|
||||
// 2026-08-20 用户决策: 点 X 与点 No Thanks 同效(permanent)——本标签页会话+本页面
|
||||
// 路径内不再自动弹(含关面板后/刷新后);temporary 写了从未被 checkInviteStorage
|
||||
// 消费,等于无抑制,原行为即"关面板5s后又弹"。
|
||||
inviteCloseBtn.addEventListener("click", function() {
|
||||
hideInvite("permanent");
|
||||
});
|
||||
|
||||
// Invite Start Chat button
|
||||
inviteStartBtn.addEventListener("click", function() {
|
||||
hideInvite("temporary");
|
||||
if (!isOpen) {
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
|
||||
// Invite No Thanks button
|
||||
inviteDismissBtn.addEventListener("click", function() {
|
||||
hideInvite("permanent");
|
||||
});
|
||||
|
||||
// Invite topic buttons - auto send message
|
||||
Array.prototype.forEach.call(inviteTopicBtns, function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
var topic = this.getAttribute("data-topic");
|
||||
hideInvite("temporary");
|
||||
|
||||
if (!isOpen) {
|
||||
toggle();
|
||||
}
|
||||
|
||||
// Queue message to send after WebSocket opens
|
||||
pendingMessage = topic;
|
||||
});
|
||||
});
|
||||
|
||||
// Popular topics buttons
|
||||
var popularTopicBtns = shadow.querySelectorAll(".popular-topic-btn");
|
||||
Array.prototype.forEach.call(popularTopicBtns, function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
var topic = this.getAttribute("data-topic");
|
||||
|
||||
// Hide popular topics after first interaction
|
||||
var popularTopicsEl = shadow.getElementById("w-popular-topics");
|
||||
if (popularTopicsEl) {
|
||||
popularTopicsEl.style.display = "none";
|
||||
}
|
||||
|
||||
// Send message
|
||||
autoSendMessage(topic);
|
||||
});
|
||||
});
|
||||
|
||||
function wConnect() {
|
||||
var proto = wsBase.indexOf("https") === 0 ? "wss" : "ws";
|
||||
var wsHost = wsBase.replace(/^https?/, proto);
|
||||
wWs = new WebSocket(wsHost + "/ws/chat");
|
||||
|
||||
wWs.onopen = function () {
|
||||
if (wSessionId) {
|
||||
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken }));
|
||||
} else {
|
||||
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken }));
|
||||
}
|
||||
// Send any pending message after WebSocket opens
|
||||
if (pendingMessage) {
|
||||
wWs.send(JSON.stringify({ content: pendingMessage }));
|
||||
addMsg("user", pendingMessage);
|
||||
typingEl.classList.add("active");
|
||||
pendingMessage = null;
|
||||
}
|
||||
};
|
||||
|
||||
wWs.onmessage = function (e) {
|
||||
var data = JSON.parse(e.data);
|
||||
if (data.type === "init") {
|
||||
wSessionId = data.session_id;
|
||||
}
|
||||
|
||||
if (data.type === "stream_start") {
|
||||
typingEl.classList.remove("active");
|
||||
streamBubble = addStreamBubble();
|
||||
}
|
||||
|
||||
if (data.type === "stream_chunk") {
|
||||
if (streamBubble) {
|
||||
streamBubble.textContent += data.content;
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "stream_end") {
|
||||
streamBubble = null;
|
||||
}
|
||||
|
||||
if (data.type === "evaluated") {
|
||||
if (data.ended) {
|
||||
wEnded = true;
|
||||
endedEl.classList.add("active");
|
||||
inputEl.disabled = true;
|
||||
sendEl.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "error") {
|
||||
typingEl.classList.remove("active");
|
||||
streamBubble = null;
|
||||
addMsg("ai", "Sorry, something went wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
wWs.onclose = function () {
|
||||
if (!wEnded && host.isConnected) setTimeout(wConnect, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function addMsg(role, text) {
|
||||
var div = document.createElement("div");
|
||||
div.className = "msg " + role;
|
||||
div.textContent = text;
|
||||
msgsEl.appendChild(div);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
function addStreamBubble() {
|
||||
var div = document.createElement("div");
|
||||
div.className = "msg ai";
|
||||
div.textContent = "";
|
||||
msgsEl.appendChild(div);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function autoSendMessage(text) {
|
||||
if (!text || !wWs || wWs.readyState !== WebSocket.OPEN) return;
|
||||
addMsg("user", text);
|
||||
typingEl.classList.add("active");
|
||||
wWs.send(JSON.stringify({ content: text }));
|
||||
}
|
||||
|
||||
function wSend() {
|
||||
var text = inputEl.value.trim();
|
||||
if (!text || wEnded || !wWs || wWs.readyState !== WebSocket.OPEN) return;
|
||||
addMsg("user", text);
|
||||
inputEl.value = "";
|
||||
typingEl.classList.add("active");
|
||||
wWs.send(JSON.stringify({ content: text }));
|
||||
}
|
||||
|
||||
sendEl.addEventListener("click", wSend);
|
||||
inputEl.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); wSend(); }
|
||||
});
|
||||
|
||||
// Start invite timer
|
||||
startInviteTimer();
|
||||
}
|
||||
|
||||
// Wait for body if script is in <head>
|
||||
if (document.body) {
|
||||
init();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user