完善会话草稿恢复与附件保留,统一人工服务时区
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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const vm=require('node:vm');
|
||||
const path=require('node:path');
|
||||
const context=vm.createContext({});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
|
||||
function storage(){
|
||||
const data=new Map();
|
||||
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
|
||||
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
|
||||
}
|
||||
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
|
||||
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
|
||||
}
|
||||
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
|
||||
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
|
||||
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
|
||||
const restored=drafts(undefined,local,tab);
|
||||
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
|
||||
assert.equal(restored.active(),'9');
|
||||
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
|
||||
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
|
||||
otherTab.remove(10);assert.equal(restored.read(10),'');
|
||||
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
|
||||
});
|
||||
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
|
||||
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
|
||||
local.setItem(key,value);assert.equal(a.read(9),'');
|
||||
}
|
||||
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
|
||||
});
|
||||
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
|
||||
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
|
||||
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
|
||||
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
|
||||
});
|
||||
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
|
||||
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
|
||||
const a=drafts(undefined,broken,broken);
|
||||
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
|
||||
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
|
||||
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
|
||||
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
|
||||
assert.equal(drafts(null,local).read(9),'');
|
||||
});
|
||||
Reference in New Issue
Block a user