完善会话草稿恢复与附件保留,统一人工服务时区
This commit is contained in:
@@ -6,6 +6,14 @@ const vm = require('node:vm');
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
async function customerPage(pageOptions = {}){
|
||||
const storageValues = pageOptions.storage || {};
|
||||
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
|
||||
const tabValues = pageOptions.tabStorage || {};
|
||||
function testStorage(values){return {
|
||||
getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);},
|
||||
removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null,
|
||||
get length(){return Object.keys(values).length;}
|
||||
};}
|
||||
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
|
||||
let intervalId = 0, timerId = 0, nextConvId = 1;
|
||||
let voiceOptions;
|
||||
@@ -71,7 +79,7 @@ async function customerPage(pageOptions = {}){
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
|
||||
FormData:class { append(){} },
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
@@ -85,6 +93,7 @@ async function customerPage(pageOptions = {}){
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
|
||||
if(hooks[url]) return hooks[url](options);
|
||||
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
|
||||
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
|
||||
if(url === '/stream_chat'){
|
||||
@@ -97,14 +106,14 @@ async function customerPage(pageOptions = {}){
|
||||
}
|
||||
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
|
||||
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
|
||||
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
|
||||
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
|
||||
: url.startsWith('/api/visitor/human/poll') ? state
|
||||
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: url === '/api/get_history' ? (pageOptions.history || []) : [];
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){
|
||||
for(const file of ['support.js', 'visitor.js', 'drafts.js', 'voice.js', 'chat.js']){
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
|
||||
if(file === 'voice.js'){
|
||||
const createVoice=context.ChatVoice.create;
|
||||
@@ -115,7 +124,7 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
if(pageOptions.human !== false) await context.window.showHuman();
|
||||
await flush();
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
|
||||
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
|
||||
resize:async height => {
|
||||
context.window.innerHeight=height;
|
||||
@@ -134,6 +143,7 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
},
|
||||
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
|
||||
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
|
||||
setState:value => { state=value; },
|
||||
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
@@ -488,9 +498,9 @@ test('repeated contact clicks create one human request and show a visible queue
|
||||
});
|
||||
|
||||
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
|
||||
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
|
||||
const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
|
||||
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
|
||||
assert.match(app.element('chatDom').textContent, /留言已提交/);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
@@ -505,6 +515,13 @@ test('a failed human send preserves the text and makes retry possible', async ()
|
||||
assert.equal(app.element('send-button').disabled, false);
|
||||
});
|
||||
|
||||
test('a visitor whose phone says late at night can still reach an open support service', async () => {
|
||||
const app=await customerPage({human:false,hour:23});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.context.window.humanSid,7);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
});
|
||||
|
||||
test('a duplicate human send cannot submit an in-flight message twice', async () => {
|
||||
const app = await customerPage();
|
||||
let resolve;
|
||||
@@ -841,10 +858,10 @@ test('successful clipboard fallback reports copied and keeps text focus stable',
|
||||
});
|
||||
|
||||
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
|
||||
const empty=await customerPage({human:false,hour:23,offlineMessage:' '});
|
||||
const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '});
|
||||
await empty.context.window.showHuman();await flush();
|
||||
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
const app=await customerPage({human:false,hour:23,offlineMessage:'需要保留的留言'});
|
||||
const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'});
|
||||
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
|
||||
await app.context.window.showHuman();await flush();
|
||||
assert.equal(app.element('input-text').value,'需要保留的留言');
|
||||
@@ -879,3 +896,99 @@ test('emoji panel dismisses outside or on Escape, stays open for its own control
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
assert.equal(panel.hidden,true);
|
||||
});
|
||||
|
||||
test('drafts stay with their own conversation and restore across a page reload', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
assert.equal(app.element('input-text').value,'');
|
||||
app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
assert.equal(app.element('input-text').value,'A的未发送草稿');
|
||||
await app.pageHide();
|
||||
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
|
||||
await flush();
|
||||
assert.equal(restored.element('input-text').value,'A的未发送草稿');
|
||||
assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
await restored.click(restored.element('historyWrap').children[1]);
|
||||
assert.equal(restored.element('input-text').value,'B的未发送草稿');
|
||||
});
|
||||
|
||||
test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage});
|
||||
assert.equal(restored.element('input-text').value,'尚未发送');
|
||||
await restored.context.window.fillInput('尚未发送');await restored.pageHide();
|
||||
const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage});
|
||||
assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1');
|
||||
});
|
||||
|
||||
test('restoring an old conversation cannot replace a newer user-selected draft', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
let release;
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
await restored.click(restored.element('.new-chat-btn'));
|
||||
restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input();
|
||||
release(response([{id:9,title:'A',ctime:'now'}]));await flush();
|
||||
assert.equal(restored.element('input-text').value,'新话题草稿');
|
||||
});
|
||||
|
||||
test('failed history loading cannot send a question under a hidden conversation context', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.context.window.fillInput('原会话');await flush();
|
||||
app.hooks.messages=()=>response({ok:false},503);
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
|
||||
await app.context.window.fillInput('禁止发到未加载会话');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
|
||||
assert.match(app.element('composer-status').textContent,/会话未加载成功/);
|
||||
assert.doesNotMatch(app.element('chatDom').textContent,/原会话/);
|
||||
});
|
||||
|
||||
test('startup removes a deleted active conversation draft without erasing a newer edit', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}});
|
||||
assert.equal(noEdit.element('input-text').value,'');
|
||||
assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
let release;
|
||||
const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input();
|
||||
release(response([]));await flush();
|
||||
assert.equal(edited.element('input-text').value,'加载期间的新输入');
|
||||
await edited.context.window.fillInput('加载期间的新输入');
|
||||
assert.ok(!edited.calls.some(call=>call.url==='/stream_chat'));
|
||||
assert.match(edited.element('composer-status').textContent,/会话未加载成功/);
|
||||
});
|
||||
|
||||
test('deleting a conversation removes only its saved draft', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
|
||||
const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage});
|
||||
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
|
||||
});
|
||||
|
||||
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
let shown;
|
||||
const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}};
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(image.hidden,true);
|
||||
assert.match(shown.textContent,/图片暂时无法访问/);
|
||||
const first=shown;
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(shown,first);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user