支持免登录游客聊天并隔离历史与附件
This commit is contained in:
+2
-2
@@ -191,8 +191,8 @@ function appendMsg(message){
|
||||
}
|
||||
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
|
||||
let content = escapeText(message.content);
|
||||
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
|
||||
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
|
||||
content = content.replace(/\[图片\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener"><img src="/api/staff/uploads/$1" alt="客户附件" class="message-image"></a>');
|
||||
content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
|
||||
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
|
||||
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
|
||||
if(message.side === "agent"){
|
||||
|
||||
+13
-29
@@ -1,4 +1,5 @@
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const fetch = VisitorSession.fetch;
|
||||
const chatDom = document.getElementById("chatDom");
|
||||
const inputDom = document.getElementById("input-text");
|
||||
const sendBtn = document.getElementById("send-button");
|
||||
@@ -210,7 +211,7 @@ const I18N = {
|
||||
thinking: "正在思考中...",
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。",
|
||||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
offlinePrompt: "请输入您的留言:",
|
||||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||||
@@ -243,7 +244,7 @@ const I18N = {
|
||||
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
|
||||
clickRetry: "点击重试",
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeSub: "查询订单状态、了解专线报价,或联系人工客服。",
|
||||
welcomeSub: "咨询运费、时效或联系人工客服。订单查询需关联客户账号。",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
serviceScope: "中欧与英国专线咨询",
|
||||
serviceTag: "物流咨询服务",
|
||||
@@ -291,7 +292,7 @@ const I18N = {
|
||||
order_parent: "父订单号", order_updated: "来源更新时间",
|
||||
moreTracking: "展开全部轨迹",
|
||||
lessTracking: "收起较早轨迹",
|
||||
quickSubs: ["输入订单号、入仓码或 Shipment ID", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
quickSubs: ["关联客户账号后查询订单与轨迹", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
|
||||
hot: "热点问题",
|
||||
recent: "最近对话记录",
|
||||
@@ -320,7 +321,7 @@ const I18N = {
|
||||
thinking: "Thinking...",
|
||||
busyRetry: "Service busy, please try again later",
|
||||
sessionFail: "Failed to create session, please check the service is running",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Enter an order number, inbound code or Shipment ID, or choose a quick option below.",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support. Order lookup will be available after signing in and linking a customer account.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
|
||||
offlinePrompt: "Please leave your message:",
|
||||
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
|
||||
@@ -353,7 +354,7 @@ const I18N = {
|
||||
retryAIOnly: "Return to AI chat before regenerating this answer.",
|
||||
clickRetry: "Retry",
|
||||
welcomeTitle: "Your next shipment, made clearer.",
|
||||
welcomeSub: "Track a parcel, explore freight options, or speak to our team.",
|
||||
welcomeSub: "Ask about rates, transit times or human support. Order lookup requires a linked customer account.",
|
||||
welcomeEyebrow: "EUROPE & UK LOGISTICS",
|
||||
serviceScope: "Europe & UK freight support",
|
||||
serviceTag: "Logistics support",
|
||||
@@ -403,7 +404,7 @@ const I18N = {
|
||||
order_parent: "Parent order", order_updated: "Source updated",
|
||||
moreTracking: "View full tracking history",
|
||||
lessTracking: "Show recent updates",
|
||||
quickSubs: ["Check the latest shipment progress", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
|
||||
quickSubs: ["Link a customer account to view orders and tracking", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
|
||||
welcomeTip: "Type your question, or pick a quick option below.",
|
||||
hot: "Popular Questions",
|
||||
recent: "Recent Conversations",
|
||||
@@ -1061,24 +1062,6 @@ window.sendOrder = function(){
|
||||
}
|
||||
}
|
||||
|
||||
function myOrders(){
|
||||
var phone = window.prompt("请输入您的手机号:");
|
||||
if(!phone) return;
|
||||
addMsg("查询手机号 "+phone+" 的包裹...", false);
|
||||
fetch("/api/my_orders?phone="+encodeURIComponent(phone))
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
if(!d.orders.length){
|
||||
addMsg("手机号 "+phone+" 下暂无运单记录。", false);
|
||||
}else{
|
||||
var txt = "您名下有 "+d.orders.length+" 个包裹:\n";
|
||||
d.orders.forEach(function(o){ txt += "• "+o.track_no+"("+o.status+")\n"; });
|
||||
addMsg(txt, false);
|
||||
}
|
||||
})
|
||||
.catch(function(){ addMsg("查询失败,请重试", false); });
|
||||
}
|
||||
|
||||
function updateHumanMode(){
|
||||
const banner = document.getElementById('human-session');
|
||||
const label = document.getElementById('human-session-label');
|
||||
@@ -1129,7 +1112,7 @@ window.showHuman = async function(){
|
||||
try{
|
||||
const cid = await ensureConv();
|
||||
if(humanRequestToken !== token || token.version !== conversationVersion) return;
|
||||
const data = await humanRequest('/api/human/request', {conv_id:cid});
|
||||
const data = await humanRequest('/api/visitor/human/request', {conv_id:cid});
|
||||
if(humanRequestToken !== token || token.version !== conversationVersion) return;
|
||||
if(data.closed){ await leaveOfflineMessage(); return; }
|
||||
if(!data.ok || !data.session_id) throw new Error('Human session failed');
|
||||
@@ -1151,7 +1134,7 @@ async function returnToAI(){
|
||||
humanClosing = true;
|
||||
updateHumanMode();
|
||||
try{
|
||||
const result = await humanRequest('/api/human/close', {sid:sid});
|
||||
const result = await humanRequest('/api/visitor/human/close', {sid:sid});
|
||||
if(!result.ok) throw new Error('Close failed');
|
||||
if(version !== conversationVersion || window.humanSid !== sid) return;
|
||||
stopHumanMode();
|
||||
@@ -1176,7 +1159,7 @@ async function sendHumanMessage(content){
|
||||
updateHumanMode();
|
||||
setComposerStatus(t('humanSending'));
|
||||
try{
|
||||
const result = await humanRequest('/api/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
|
||||
const result = await humanRequest('/api/visitor/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
|
||||
if(!result.ok) throw new Error('Send failed');
|
||||
if(version !== conversationVersion || humanSendingToken !== token) return;
|
||||
addMsg(content, true);
|
||||
@@ -1221,7 +1204,7 @@ function startHumanPoll(){
|
||||
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid){ clearInterval(pollTimer); return; }
|
||||
if(polling) return;
|
||||
polling=true;
|
||||
humanRequest("/api/human/poll?sid="+sid+"&after="+last)
|
||||
humanRequest("/api/visitor/human/poll?sid="+sid+"&after="+last)
|
||||
.then(d=>{
|
||||
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid) return;
|
||||
if(hadPollError){ setComposerStatus(''); hadPollError=false; }
|
||||
@@ -1253,7 +1236,7 @@ function startHumanPoll(){
|
||||
stopHumanMode();
|
||||
setBusy(false);
|
||||
addMsg(t('humanClosed'), false);
|
||||
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
|
||||
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed'), fetch:fetch});
|
||||
scrollChatToEnd();
|
||||
return;
|
||||
}
|
||||
@@ -1831,6 +1814,7 @@ if(window.visualViewport) window.visualViewport.addEventListener('resize', syncC
|
||||
syncChatViewport();
|
||||
|
||||
voiceInput = ChatVoice.create({
|
||||
fetch:fetch,
|
||||
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
|
||||
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
|
||||
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
|
||||
|
||||
@@ -15,6 +15,7 @@ var SupportUI = {
|
||||
}catch(error){}
|
||||
},
|
||||
addRating(container, sid, labels){
|
||||
const request = labels.fetch || fetch;
|
||||
const box = document.createElement('section');
|
||||
box.className = 'service-rating';
|
||||
const question = document.createElement('p');
|
||||
@@ -40,7 +41,7 @@ var SupportUI = {
|
||||
sending = true;
|
||||
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
|
||||
try{
|
||||
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
||||
const response = await request('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
||||
const data = await response.json();
|
||||
if(!response.ok || !data.ok) throw new Error('Rating failed');
|
||||
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// A local random guest key restores this browser's history, never customer order identity.
|
||||
var VisitorSession = (function(){
|
||||
const storageName = 'baiyue-visitor-key';
|
||||
const nativeFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : fetch;
|
||||
let ready = null, established = false;
|
||||
function localKey(){
|
||||
try{
|
||||
const saved = localStorage.getItem(storageName);
|
||||
if(saved && /^[0-9a-f]{64}$/.test(saved)) return saved;
|
||||
const crypto = window.crypto;
|
||||
if(!crypto || !crypto.getRandomValues) return null;
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const key = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
localStorage.setItem(storageName, key);
|
||||
return key;
|
||||
}catch(error){ return null; } // HttpOnly cookie can still retain this guest when storage is disabled.
|
||||
}
|
||||
function ensure(){
|
||||
if(ready) return ready;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10000);
|
||||
ready = nativeFetch('/api/visitor/session', {method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify({key:localKey()}),signal:controller.signal})
|
||||
.then(async response => {
|
||||
const data = await response.json();
|
||||
if(!response.ok || !data.ok || typeof data.key !== 'string' || !/^[0-9a-f]{64}$/.test(data.key)){
|
||||
throw new Error('访客会话暂时无法建立,请刷新后重试。');
|
||||
}
|
||||
try{ localStorage.setItem(storageName, data.key); }catch(error){}
|
||||
established = true;
|
||||
}).catch(error => { ready = null; established = false; throw error; }).finally(() => clearTimeout(timer));
|
||||
return ready;
|
||||
}
|
||||
function request(path, options){
|
||||
const init = Object.assign({}, options, {credentials:'same-origin'});
|
||||
function send(){
|
||||
if(init.signal && init.signal.aborted) return Promise.reject(Object.assign(new Error('Aborted'), {name:'AbortError'}));
|
||||
const session = ready;
|
||||
return nativeFetch(path, init).then(async response => {
|
||||
if(response.status !== 401) return response;
|
||||
if(ready === session){ ready = null; established = false; }
|
||||
await ensure();
|
||||
return nativeFetch(path, init);
|
||||
});
|
||||
}
|
||||
return established ? send() : ensure().then(send);
|
||||
}
|
||||
return {fetch:request};
|
||||
})();
|
||||
@@ -1,6 +1,7 @@
|
||||
// Short recordings use the same server ASR in mobile browsers and embedded browsers.
|
||||
var ChatVoice = {
|
||||
create(options){
|
||||
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
|
||||
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
|
||||
let session = null, mode = false;
|
||||
const active = current => session === current;
|
||||
|
||||
Reference in New Issue
Block a user