完善会话草稿恢复与附件保留,统一人工服务时区

This commit is contained in:
2026-10-30 15:32:31 +08:00
parent 78797d8eca
commit b764fcb33c
20 changed files with 446 additions and 34 deletions
+1
View File
@@ -11,6 +11,7 @@ var typeTimer = null;
var toolsOpen = false;
var toolsOpener = null;
var replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
function escapeText(value){
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
+77 -12
View File
@@ -6,6 +6,28 @@ const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status");
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
let draftTimer = null;
let draftSnapshot = null;
function flushDraft(){
if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; }
const saved = drafts.write(currentConvId, inputDom.value);
drafts.active(currentConvId);
if(!saved && inputDom.value && !composerStatus.textContent) setComposerStatus(t('draftSaveFailed'), true);
}
function restoreDraft(){
inputDom.value = drafts.read(currentConvId);
draftSnapshot = String(currentConvId) + '\n' + inputDom.value;
drafts.active(currentConvId);
autoGrow();
}
function queueDraft(){
const snapshot = String(currentConvId) + '\n' + inputDom.value;
if(snapshot === draftSnapshot) return;
draftSnapshot = snapshot;
if(draftTimer !== null) clearTimeout(draftTimer);
draftTimer = setTimeout(flushDraft, 250);
}
function focusComposer(force){
if(force || !isNarrow() || document.activeElement === inputDom){
inputDom.focus({preventScroll:true});
@@ -29,6 +51,7 @@ let humanSendingToken = null;
let humanClosing = false;
window.humanSid = 0;
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function setComposerStatus(message, isError){
@@ -217,7 +240,7 @@ const I18N = {
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。",
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
@@ -233,6 +256,10 @@ const I18N = {
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…",
historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。",
draftRestored: "已恢复这段会话的未发送草稿。",
draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。",
attachmentUnavailable: "图片暂时无法访问,请重新发送附件。",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
@@ -330,7 +357,7 @@ const I18N = {
busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running",
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.",
humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). 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).",
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
@@ -346,6 +373,10 @@ const I18N = {
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
humanReturnFailed: "Could not end the support session. Please try again.",
historyLoading: "Loading this conversation. Please wait…",
historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.",
draftRestored: "Your unsent draft has been restored.",
draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.",
attachmentUnavailable: "Image unavailable. Please upload the attachment again.",
recalled: "This message was recalled",
ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!",
@@ -653,11 +684,12 @@ clearAllBtn.onclick = function(){
if(newChatBtn){
newChatBtn.onclick = function(){
if(openingConv === 'clearing') return;
const alreadyNew = currentConvId === null;
resetConversation(null);
inputDom.value = "";
if(alreadyNew) inputDom.value = "";
autoGrow();
clearAttachment();
setComposerStatus("");
setComposerStatus(inputDom.value ? t('draftRestored') : '');
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = 0;
followLatest = true;
@@ -673,10 +705,13 @@ function loadHistory(){
historyList = data;
renderSideHistory();
renderRecentChat();
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
return data;
}).catch(function(){ return null; /* 保留上次成功加载的历史。 */ });
}
function removeCachedHistory(id){
if(id === undefined){ drafts.clear(); flushDraft(); }
else drafts.remove(id);
historyRequestVersion += 1;
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
renderSideHistory();
@@ -823,6 +858,8 @@ function ensureConv(){
throw error;
}
currentConvId = data.id;
drafts.remove('new');
flushDraft();
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
@@ -831,6 +868,7 @@ function ensureConv(){
}
function resetConversation(id){
flushDraft();
emojiPicker.close();
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
@@ -839,6 +877,7 @@ function resetConversation(id){
clearAttachment();
stopHumanMode();
currentConvId = id;
restoreDraft();
openingConv = null;
creatingConv = null;
sendPreparing = false;
@@ -856,6 +895,7 @@ function openConversation(id){
const version = resetConversation(id);
if(isNarrow()){ sideOpen = false; renderSidebar(); }
openingConv = id;
chatDom.innerHTML = '';
setComposerStatus(t('historyLoading'));
renderSideHistory();
requestJson("/api/get_messages?conv_id=" + id)
@@ -863,7 +903,7 @@ function openConversation(id){
if(version !== conversationVersion) return;
if(!Array.isArray(msgs)) throw new Error('History failed');
openingConv = null;
setComposerStatus('');
setComposerStatus(inputDom.value ? t('draftRestored') : '');
chatDom.innerHTML = "";
if(!msgs.length){
chatDom.innerHTML = welcomeHTML();
@@ -886,8 +926,8 @@ function openConversation(id){
requestAnimationFrame(function(){ scrollChatToEnd(true); });
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
openingConv = 'failed';
setComposerStatus(t('historyLoadFailed'), true);
});
}
@@ -1047,7 +1087,30 @@ function addTrackCard(card, timeStr){
return bubble;
}
loadHistory();
const restoreId = drafts.active();
currentConvId = restoreId === 'new' ? null : Number(restoreId);
restoreDraft();
if(currentConvId){
const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value;
openingConv = 'restoring';
loadHistory().then(function(history){
if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return;
if(Array.isArray(history) && !history.some(item=>item.id === id)){
if(inputDom.value !== initialDraft){
openingConv = 'failed';
flushDraft();
setComposerStatus(t('historyLoadFailed'), true);
return;
}
drafts.remove(id);
currentConvId = null;
restoreDraft();
openingConv = null;
return;
}
openConversation(id);
});
}else loadHistory();
window.fillInput = function(words){
inputDom.value = words;
@@ -1113,11 +1176,10 @@ async function humanRequest(path, data){
window.showHuman = async function(){
if(voiceInput) voiceInput.cancel();
if(openingConv === 'clearing') return;
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
if(window.humanSid) stopHumanMode();
const hour = new Date().getHours();
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
const token = {version:conversationVersion};
humanRequestToken = token;
if(cancelActive){ cancelActive(); cancelActive = null; }
@@ -1530,7 +1592,7 @@ async function doSend(retryText){
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
setComposerStatus("");
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
@@ -1764,6 +1826,7 @@ sendBtn.addEventListener("click", function(){
// ===== 多行输入框:自动增高 =====
function autoGrow(){
queueDraft();
if(!inputDom.value){
inputDom.style.height = '';
return;
@@ -1775,6 +1838,8 @@ function autoGrow(){
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
}
inputDom.addEventListener("input", autoGrow);
window.addEventListener('pagehide', flushDraft);
document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); });
inputDom.addEventListener("keydown",(e)=>{
// Enter 发送,Shift+Enter 换行
+77
View File
@@ -0,0 +1,77 @@
// Text drafts stay in this visitor's browser, independently for each conversation.
var ChatDrafts = {
create(options){
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
const memory = new Map();
const dirty = new Set();
let current = 'new';
function id(value){
if(value === null || value === 'new') return 'new';
const key = String(value);
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
}
function storage(name){
try{ return options[name](); }catch(error){ return null; }
}
function read(value){
const key = id(value);
if(!key) return '';
if(dirty.has(key)) return memory.get(key) || '';
if(prefix){
try{
const saved = storage('storage').getItem(prefix + key);
if(saved !== null){
const data = JSON.parse(saved);
if(data && data.version === 1 && typeof data.text === 'string'){
memory.set(key, data.text);
return data.text;
}
}
memory.delete(key);
return '';
}catch(error){}
}
return memory.get(key) || '';
}
function write(value, text){
const key = id(value);
if(!key || typeof text !== 'string') return false;
if(text) memory.set(key, text); else memory.delete(key);
dirty.add(key);
if(!prefix) return false;
try{
const target = storage('storage');
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
else target.removeItem(prefix + key);
dirty.delete(key);
return true;
}catch(error){ return false; }
}
function active(value){
if(arguments.length){
current = id(value) || 'new';
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
}else if(activeKey){
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
}
return current;
}
function clear(){
memory.clear();
dirty.clear();
if(prefix){
try{
const target = storage('storage');
for(let index = target.length - 1; index >= 0; index--){
const key = target.key(index);
if(key && key.startsWith(prefix)) target.removeItem(key);
}
}catch(error){}
}
active('new');
}
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
}
};
+12
View File
@@ -1,5 +1,17 @@
// Shared controls for the customer page and service workspace.
var SupportUI = {
initAttachments(container, label){
container.addEventListener('error', function(event){
const image = event.target;
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
image.dataset.unavailable = '1';
image.hidden = true;
const hint = document.createElement('span');
hint.className = 'attachment-unavailable';
hint.textContent = typeof label === 'function' ? label() : label;
image.after(hint);
}, true);
},
async enableNotifications(button){
if(!('Notification' in window)) return;
try{
+1 -1
View File
@@ -45,5 +45,5 @@ var VisitorSession = (function(){
}
return established ? send() : ensure().then(send);
}
return {fetch:request};
return {fetch:request,localKey:localKey};
})();