修复评价统计、留言校验和聊天操作反馈
This commit is contained in:
@@ -8,8 +8,17 @@ from .tracking import card_to_text
|
||||
|
||||
def feedback(data):
|
||||
"""收集 👍/👎 反馈,存到 feedback 表"""
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False, 'error': '评价格式无效。'}, 400
|
||||
raw_vote = data.get('vote')
|
||||
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
|
||||
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
|
||||
question, answer = data.get('question', ''), data.get('answer', '')
|
||||
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
|
||||
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
|
||||
conn = connect()
|
||||
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
|
||||
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
|
||||
(question[:500], answer[:2000], vote, now_str()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
@@ -19,8 +28,8 @@ def admin_stats():
|
||||
conn = connect()
|
||||
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
|
||||
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
|
||||
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0]
|
||||
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
|
||||
good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
|
||||
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
|
||||
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
|
||||
@@ -39,8 +48,16 @@ def my_orders(phone):
|
||||
|
||||
|
||||
def add_ticket(data):
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False, 'error': '留言格式无效。'}, 400
|
||||
content, phone = data.get('content'), data.get('phone', '')
|
||||
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
|
||||
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
|
||||
if not isinstance(phone, str) or len(phone.strip()) > 32:
|
||||
return {'ok': False, 'error': '联系电话格式无效。'}, 400
|
||||
conn = connect()
|
||||
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
|
||||
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
|
||||
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
@@ -13,7 +13,7 @@ def my_orders():
|
||||
|
||||
@bp.route('/api/ticket', methods=['POST'])
|
||||
def add_ticket():
|
||||
return business_service.add_ticket(request.get_json() or {})
|
||||
return business_service.add_ticket(request.get_json(silent=True))
|
||||
|
||||
|
||||
@bp.route('/api/tickets')
|
||||
|
||||
@@ -45,4 +45,4 @@ def stream_chat():
|
||||
|
||||
@bp.route('/api/feedback', methods=['POST'])
|
||||
def feedback():
|
||||
return business_service.feedback(request.get_json(silent=True) or {})
|
||||
return business_service.feedback(request.get_json(silent=True))
|
||||
|
||||
@@ -133,6 +133,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
|
||||
.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; }
|
||||
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
|
||||
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
|
||||
.vote-status.is-error { color: var(--danger); }
|
||||
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
|
||||
.err-line { margin-bottom: 9px; }
|
||||
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
|
||||
|
||||
+67
-21
@@ -28,7 +28,7 @@ let humanPollTimer = null;
|
||||
let humanSendingToken = null;
|
||||
let humanClosing = false;
|
||||
window.humanSid = 0;
|
||||
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||
|
||||
function setComposerStatus(message, isError){
|
||||
@@ -245,6 +245,9 @@ const I18N = {
|
||||
stop: "停止",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败",
|
||||
voteUp: "这条回复有帮助",
|
||||
voteDown: "这条回复没有帮助",
|
||||
retry: "重答",
|
||||
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
|
||||
clickRetry: "点击重试",
|
||||
@@ -355,6 +358,9 @@ const I18N = {
|
||||
stop: "Stop",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
copyFailed: "Copy failed",
|
||||
voteUp: "This reply was helpful",
|
||||
voteDown: "This reply was not helpful",
|
||||
retry: "Regenerate",
|
||||
retryAIOnly: "Return to AI chat before regenerating this answer.",
|
||||
clickRetry: "Retry",
|
||||
@@ -483,6 +489,7 @@ function applyLanguage(lang){
|
||||
const n = b.dataset.action;
|
||||
if(n === "copy") b.textContent = dict.copy;
|
||||
if(n === "retry") b.textContent = dict.retry;
|
||||
if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]);
|
||||
});
|
||||
document.querySelectorAll(".retry-btn").forEach(function(b){
|
||||
if(!b.dataset.keep) b.textContent = dict.clickRetry;
|
||||
@@ -824,6 +831,7 @@ function ensureConv(){
|
||||
}
|
||||
|
||||
function resetConversation(id){
|
||||
emojiPicker.close();
|
||||
if(voiceInput) voiceInput.cancel();
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
if(sseInstance){ sseInstance.close(); sseInstance = null; }
|
||||
@@ -846,6 +854,7 @@ function openConversation(id){
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv === id) return; // 防连点
|
||||
const version = resetConversation(id);
|
||||
if(isNarrow()){ sideOpen = false; renderSidebar(); }
|
||||
openingConv = id;
|
||||
setComposerStatus(t('historyLoading'));
|
||||
renderSideHistory();
|
||||
@@ -1185,7 +1194,7 @@ async function sendHumanMessage(content){
|
||||
async function leaveOfflineMessage(){
|
||||
const version = conversationVersion;
|
||||
addMsg(t('humanHours'), false);
|
||||
const message = window.prompt(t('offlinePrompt'));
|
||||
const message = (window.prompt(t('offlinePrompt')) || '').trim();
|
||||
if(!message) return;
|
||||
try{
|
||||
const data = await humanRequest('/api/ticket', {content:message, phone:''});
|
||||
@@ -1193,8 +1202,8 @@ async function leaveOfflineMessage(){
|
||||
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
|
||||
}catch(error){
|
||||
if(version !== conversationVersion) return;
|
||||
if(!inputDom.value) inputDom.value = message;
|
||||
setComposerStatus(t('networkFail'), true);
|
||||
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
|
||||
setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1391,26 +1400,34 @@ function attachActions(bubble, rawText, question, showRetry){
|
||||
copyBtn.dataset.action = "copy";
|
||||
copyBtn.textContent = t("copy");
|
||||
copyBtn.onclick = function(){
|
||||
if(copyBtn.disabled) return;
|
||||
copyBtn.disabled = true;
|
||||
const plain = mdToPlain(rawText);
|
||||
const done = ()=> {
|
||||
copyBtn.textContent = t("copied");
|
||||
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
|
||||
const done = function(copied){
|
||||
copyBtn.disabled = false;
|
||||
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
|
||||
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
|
||||
};
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(plain).then(done).catch(fallback);
|
||||
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
|
||||
}else{
|
||||
fallback();
|
||||
return fallback();
|
||||
}
|
||||
function fallback(){
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = plain;
|
||||
ta.readOnly = true;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
const previousFocus = document.activeElement;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try{ document.execCommand("copy"); }catch(e){}
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
let copied = false;
|
||||
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
|
||||
finally{
|
||||
ta.remove();
|
||||
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
|
||||
}
|
||||
done(copied);
|
||||
}
|
||||
};
|
||||
bar.appendChild(copyBtn);
|
||||
@@ -1425,23 +1442,52 @@ function attachActions(bubble, rawText, question, showRetry){
|
||||
}
|
||||
|
||||
// 👍/👎 反馈
|
||||
let votePending = false;
|
||||
const voteButtons = [];
|
||||
const voteStatus = document.createElement('span');
|
||||
voteStatus.className = 'vote-status';
|
||||
voteStatus.setAttribute('role', 'status');
|
||||
function addVote(vote, label){
|
||||
const b = document.createElement("button");
|
||||
b.className = "msg-action-btn";
|
||||
b.textContent = label;
|
||||
b.onclick = function(){
|
||||
if(b.classList.contains("voted")) return;
|
||||
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
|
||||
b.classList.add("voted");
|
||||
fetch("/api/feedback", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({question: question, answer: rawText, vote: vote})
|
||||
}).catch(function(){});
|
||||
b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown';
|
||||
b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown'));
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
b.onclick = async function(){
|
||||
if(votePending || b.classList.contains('voted')) return;
|
||||
votePending = true;
|
||||
voteButtons.forEach(button => { button.disabled = true; });
|
||||
voteStatus.textContent = '';
|
||||
voteStatus.removeAttribute('data-i18n');
|
||||
voteStatus.classList.remove('is-error');
|
||||
try{
|
||||
const data = await requestJson('/api/feedback', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({question:question, answer:rawText, vote:vote})
|
||||
});
|
||||
if(!data.ok) throw new Error('Feedback failed');
|
||||
voteButtons.forEach(function(button){
|
||||
button.classList.toggle('voted', button === b);
|
||||
button.setAttribute('aria-pressed', String(button === b));
|
||||
});
|
||||
voteStatus.textContent = t('ratingThanks');
|
||||
voteStatus.setAttribute('data-i18n', 'ratingThanks');
|
||||
}catch(error){
|
||||
voteStatus.textContent = t('ratingFailed');
|
||||
voteStatus.setAttribute('data-i18n', 'ratingFailed');
|
||||
voteStatus.classList.add('is-error');
|
||||
}finally{
|
||||
votePending = false;
|
||||
voteButtons.forEach(button => { button.disabled = false; });
|
||||
}
|
||||
};
|
||||
voteButtons.push(b);
|
||||
bar.appendChild(b);
|
||||
}
|
||||
addVote("up", "👍");
|
||||
addVote("down", "👎");
|
||||
bar.appendChild(voteStatus);
|
||||
|
||||
bubble.appendChild(bar);
|
||||
}
|
||||
|
||||
+16
-8
@@ -58,6 +58,10 @@ var SupportUI = {
|
||||
},
|
||||
initEmoji(button, panel, input, onInsert){
|
||||
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
|
||||
function close(){
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
button.onclick = function(){
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute('aria-expanded', String(!panel.hidden));
|
||||
@@ -70,20 +74,24 @@ var SupportUI = {
|
||||
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
|
||||
const end = input.selectionEnd == null ? start : input.selectionEnd;
|
||||
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
input.focus();
|
||||
close();
|
||||
input.focus({preventScroll:true});
|
||||
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||
if(onInsert) onInsert();
|
||||
};
|
||||
panel.appendChild(choice);
|
||||
});
|
||||
panel.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape'){
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.focus();
|
||||
['pointerdown','mousedown'].forEach(function(name){
|
||||
document.addEventListener(name, function(event){
|
||||
if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape' && !panel.hidden){
|
||||
close();
|
||||
button.focus({preventScroll:true});
|
||||
}
|
||||
});
|
||||
return {close:close};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ async function workspace(compact = false){
|
||||
setAttribute(name, value){ this.attributes[name] = value; }
|
||||
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
|
||||
appendChild(element){ this.children.push(element); }
|
||||
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
|
||||
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||
querySelectorAll(){ return []; }
|
||||
querySelector(selector){
|
||||
@@ -47,7 +48,7 @@ async function workspace(compact = false){
|
||||
const quickButtons = [new Element(), new Element()];
|
||||
document = {
|
||||
body: new Element(), activeElement: null,
|
||||
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
|
||||
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
|
||||
createElement(){ return new Element(); },
|
||||
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
|
||||
querySelector(selector){
|
||||
@@ -55,7 +56,7 @@ async function workspace(compact = false){
|
||||
if(!match) return null;
|
||||
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
|
||||
},
|
||||
addEventListener(name, callback){ documentListeners[name] = callback; },
|
||||
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
const calls = [];
|
||||
const hooks = {};
|
||||
|
||||
@@ -13,7 +13,7 @@ async function customerPage(pageOptions = {}){
|
||||
constructor(){
|
||||
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
|
||||
this.listeners = {}; this.focusCalls = [];
|
||||
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -32,8 +32,10 @@ async function customerPage(pageOptions = {}){
|
||||
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
|
||||
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
|
||||
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
|
||||
blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } }
|
||||
select(){ document.activeElement=this; }
|
||||
querySelector(selector){
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
@@ -50,21 +52,23 @@ async function customerPage(pageOptions = {}){
|
||||
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
|
||||
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
|
||||
}
|
||||
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
}
|
||||
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
|
||||
element('emoji-panel').hidden=true;
|
||||
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
|
||||
const document = {
|
||||
body: new Element(), documentElement: {},
|
||||
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
|
||||
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
|
||||
createElement: () => new Element(),
|
||||
addEventListener: (name, callback) => { listeners[name] = callback; },
|
||||
execCommand: () => pageOptions.copyResult !== false,
|
||||
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
|
||||
let state = {status:'active', messages:[]};
|
||||
const context = vm.createContext({
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
|
||||
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(){}},
|
||||
@@ -95,7 +99,7 @@ async function customerPage(pageOptions = {}){
|
||||
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
|
||||
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
|
||||
: url.startsWith('/api/visitor/human/poll') ? state
|
||||
: ['/api/rate', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: ['/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};
|
||||
},
|
||||
@@ -112,6 +116,7 @@ async function customerPage(pageOptions = {}){
|
||||
if(pageOptions.human !== false) await context.window.showHuman();
|
||||
await flush();
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
|
||||
resize:async height => {
|
||||
context.window.innerHeight=height;
|
||||
if(context.window.visualViewport) context.window.visualViewport.height=height;
|
||||
@@ -782,3 +787,95 @@ test('an AI regeneration is not forwarded to a human session', async () => {
|
||||
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
|
||||
assert.match(app.element('composer-status').textContent,/先返回 AI/);
|
||||
});
|
||||
|
||||
test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('反馈测试');await flush();
|
||||
const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn');
|
||||
const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎');
|
||||
let finish;
|
||||
app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;});
|
||||
const sending=up.onclick();
|
||||
await flush();
|
||||
await down.onclick();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1);
|
||||
assert.equal(up.classList.contains('voted'),false);
|
||||
assert.ok(up.disabled && down.disabled);
|
||||
finish(response({ok:false},503));await sending;
|
||||
assert.equal(up.classList.contains('voted'),false);
|
||||
assert.match(log.textContent,/评价未提交/);
|
||||
assert.equal(up.disabled,false);
|
||||
app.hooks['/api/feedback']=()=>response({ok:true});
|
||||
await up.onclick();
|
||||
assert.ok(up.classList.contains('voted'));
|
||||
assert.equal(up.getAttribute('aria-pressed'),'true');
|
||||
assert.match(log.textContent,/感谢您的评价/);
|
||||
await up.onclick();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2);
|
||||
});
|
||||
|
||||
test('clipboard rejection and a failed fallback never report a successful copy', async () => {
|
||||
const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
|
||||
await app.context.window.fillInput('复制测试');await flush();
|
||||
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
|
||||
app.element('input-text').focus();
|
||||
await copy.onclick();await flush();
|
||||
assert.equal(copy.textContent,'复制失败');
|
||||
assert.equal(copy.disabled,false);
|
||||
assert.equal(app.context.document.activeElement,app.element('input-text'));
|
||||
assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true);
|
||||
assert.equal(app.context.document.body.children.length,0);
|
||||
await app.runTimers(3000);
|
||||
assert.equal(copy.textContent,'复制');
|
||||
});
|
||||
|
||||
test('successful clipboard fallback reports copied and keeps text focus stable', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('复制测试');await flush();
|
||||
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
|
||||
await copy.onclick();
|
||||
assert.equal(copy.textContent,'已复制');
|
||||
assert.equal(copy.disabled,false);
|
||||
await app.runTimers(1200);
|
||||
assert.equal(copy.textContent,'复制');
|
||||
});
|
||||
|
||||
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:' '});
|
||||
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:'需要保留的留言'});
|
||||
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,'需要保留的留言');
|
||||
assert.notEqual(app.element('input-text').style.height,'');
|
||||
assert.match(app.element('composer-status').textContent,/1–2000/);
|
||||
});
|
||||
|
||||
test('mobile history selection closes the drawer while desktop navigation stays visible', async () => {
|
||||
for(const width of [390,1200]){
|
||||
const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]});
|
||||
if(width===390) await app.click(app.element('.toggle-divider'));
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true');
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900));
|
||||
}
|
||||
});
|
||||
|
||||
test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel');
|
||||
await app.click(toggle);
|
||||
assert.equal(panel.hidden,false);
|
||||
app.documentEvent('pointerdown',{target:panel.children[0]});
|
||||
assert.equal(panel.hidden,false);
|
||||
app.documentEvent('pointerdown',{target:app.element('input-text')});
|
||||
assert.equal(panel.hidden,true);
|
||||
assert.equal(toggle.getAttribute('aria-expanded'),'false');
|
||||
await app.click(toggle);
|
||||
app.documentEvent('keydown',{key:'Escape'});
|
||||
assert.equal(panel.hidden,true);
|
||||
await app.click(toggle);
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
assert.equal(panel.hidden,true);
|
||||
});
|
||||
|
||||
@@ -378,6 +378,42 @@ class ApiTests(unittest.TestCase):
|
||||
self.client.delete('/api/clear_all')
|
||||
self.assertEqual(self.client.get('/api/get_history').json, [])
|
||||
|
||||
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
|
||||
for vote in ('up', 'down', 1, -1):
|
||||
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
|
||||
connection.commit(); connection.close()
|
||||
stats=self.client.get('/api/admin/stats').json
|
||||
self.assertEqual((stats['good'],stats['bad']),(3,3))
|
||||
|
||||
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
|
||||
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
|
||||
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
|
||||
with self.subTest(feedback=data):
|
||||
response=self.client.post('/api/feedback',json=data)
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
|
||||
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
|
||||
with self.subTest(ticket=data):
|
||||
response=self.client.post('/api/ticket',json=data)
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
for path in ('/api/feedback','/api/ticket'):
|
||||
response=self.client.post(path,data='{bad',content_type='application/json')
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
for table in ('feedback','tickets'):
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
|
||||
connection.close()
|
||||
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
|
||||
ticket=self.client.get('/api/tickets').json[0]
|
||||
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
|
||||
|
||||
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
|
||||
Reference in New Issue
Block a user