Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f6ca586d | ||
|
|
767fca6a79 | ||
|
|
cfd3fb15cc | ||
|
|
1902949f69 | ||
|
|
a415bcdb88 |
+29
-16
@@ -1,5 +1,5 @@
|
||||
/* Customer workspace: navigation, welcome, conversation and composer. */
|
||||
body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.chat-container {
|
||||
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
|
||||
overflow: hidden; position: relative; border: 1px solid var(--border);
|
||||
@@ -44,7 +44,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.clear-all-btn:hover { color: var(--danger); }
|
||||
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
|
||||
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
|
||||
.main-chat { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
|
||||
.header-copy { min-width: 0; flex: 1; }
|
||||
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
|
||||
@@ -86,8 +86,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.recent-item:hover { color: var(--brand-text); }
|
||||
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
|
||||
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.composer-toolbar .hot-bar { flex: 1; min-width: 0; }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
|
||||
.voice-toggle, .voice-hold { display: none; }
|
||||
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
|
||||
.voice-panel p { margin: 9px 0; font-size: 13px; }
|
||||
@@ -110,6 +110,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
|
||||
#input-text::placeholder { color: var(--muted); }
|
||||
#input-text:placeholder-shown { white-space: nowrap; overflow: hidden; }
|
||||
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
|
||||
#send-button:hover { background: var(--brand-hover); }
|
||||
#send-button.stop { background: var(--danger); }
|
||||
@@ -205,20 +206,33 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.suggest-copy small { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 0; }
|
||||
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
|
||||
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
|
||||
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
.left-panel .brand { display: none; }
|
||||
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.main-chat { width: 100%; }
|
||||
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; }
|
||||
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
|
||||
.ai-welcome-card { padding-top: 20px; }
|
||||
.composer { width: calc(100% - 40px); }
|
||||
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.chat-container.is-composing { --chat-header-height: 56px; }
|
||||
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
|
||||
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
|
||||
.is-composing .composer-footer { display: none; }
|
||||
#input-text, .history-search { font-size: max(16px, 1em); }
|
||||
.history-search { height: 44px; }
|
||||
.history-search-wrap svg { top: 14px; }
|
||||
.composer-toolbar { margin-bottom: 8px; }
|
||||
.hot-bar .hot-capsule { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.input-area { align-items: center; }
|
||||
.input-area .emoji-control { display: flex; align-items: center; }
|
||||
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
|
||||
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
|
||||
.user-message, .robot-message { max-width: 94%; }
|
||||
.del-history-btn { opacity: 1; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
|
||||
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
|
||||
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
|
||||
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
|
||||
@@ -247,14 +261,13 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.hot-capsule { font-size: 10px; padding: 6px 8px; }
|
||||
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
|
||||
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; margin-bottom: 8px; }
|
||||
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
|
||||
.hot-bar-label { display: none; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
|
||||
.input-area { gap: 7px; padding: 7px; }
|
||||
#send-button { min-width: 57px; padding: 0 12px; }
|
||||
#upload-btn { flex-basis: 36px; height: 42px; }
|
||||
#upload-btn { flex-basis: 36px; }
|
||||
#input-text { font-size: 16px; }
|
||||
#send-button { min-height: 42px; }
|
||||
.attachment-remove { width: 40px; height: 40px; }
|
||||
.keyboard-hint { display: none; }
|
||||
.composer-footer { justify-content: center; padding-top: 7px; }
|
||||
|
||||
+52
-13
@@ -6,6 +6,11 @@ const sendBtn = document.getElementById("send-button");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadInput = document.getElementById("upload-input");
|
||||
const composerStatus = document.getElementById("composer-status");
|
||||
function focusComposer(force){
|
||||
if(force || !isNarrow() || document.activeElement === inputDom){
|
||||
inputDom.focus({preventScroll:true});
|
||||
}
|
||||
}
|
||||
let uploadPending = false;
|
||||
let uploadVersion = 0;
|
||||
let uploadController = null;
|
||||
@@ -121,12 +126,12 @@ uploadInput.onchange = function(){
|
||||
remove.className = "attachment-remove";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", t("removeAttachment"));
|
||||
remove.onclick = function(){ clearAttachment(); inputDom.focus(); };
|
||||
remove.onclick = function(){ clearAttachment(); focusComposer(true); };
|
||||
prev.append(copy, remove);
|
||||
var area = document.querySelector(".input-area");
|
||||
area.before(prev);
|
||||
setComposerStatus("");
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
})
|
||||
.catch(function(error){
|
||||
@@ -255,7 +260,7 @@ const I18N = {
|
||||
footerHuman: "联系人工",
|
||||
disclaimer: "报价与时效仅供参考,请以客服最终确认为准",
|
||||
keyboardHint: "Enter 发送 · Shift + Enter 换行",
|
||||
inputShort: "输入问题、订单号或入仓码…",
|
||||
inputShort: "输入问题…",
|
||||
uploading: "附件正在上传,请稍候…",
|
||||
uploadFailed: "附件上传失败,请重新选择文件",
|
||||
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
|
||||
@@ -365,7 +370,7 @@ const I18N = {
|
||||
footerHuman: "Human support",
|
||||
disclaimer: "Quotes and transit times are subject to final confirmation",
|
||||
keyboardHint: "Enter to send · Shift + Enter for a new line",
|
||||
inputShort: "Question or tracking number…",
|
||||
inputShort: "Message…",
|
||||
uploading: "Uploading your attachment…",
|
||||
uploadFailed: "Upload failed. Please select the file again.",
|
||||
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
|
||||
@@ -1140,7 +1145,7 @@ async function returnToAI(){
|
||||
stopHumanMode();
|
||||
setComposerStatus('');
|
||||
addMsg(t('humanClosed'), false);
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}catch(error){
|
||||
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
|
||||
}finally{
|
||||
@@ -1166,7 +1171,7 @@ async function sendHumanMessage(content){
|
||||
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
|
||||
if(window.__pendingImg === attachment) clearAttachment();
|
||||
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
return true;
|
||||
}catch(error){
|
||||
if(version !== conversationVersion || humanSendingToken !== token) return;
|
||||
@@ -1471,7 +1476,7 @@ async function doSend(retryText){
|
||||
var tag = window.__pendingTag || "[图片]";
|
||||
const draft = inputDom.value, attachmentVersion = uploadVersion;
|
||||
if(!sendWord && !imgUrl){
|
||||
inputDom.focus();
|
||||
focusComposer(true);
|
||||
return;
|
||||
}
|
||||
if(uploadPending){ setComposerStatus(t("uploading")); return; }
|
||||
@@ -1504,7 +1509,7 @@ async function doSend(retryText){
|
||||
addMsg(fullContent, true);
|
||||
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
|
||||
if(attachmentVersion === uploadVersion) clearAttachment();
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
|
||||
const aiBubble = addMsg(t("thinking"), false);
|
||||
@@ -1537,7 +1542,6 @@ async function doSend(retryText){
|
||||
setBusy(false);
|
||||
aiBubble.classList.remove("typing");
|
||||
if(activeBubble === aiBubble) activeBubble = null;
|
||||
inputDom.focus();
|
||||
|
||||
let raw = aiBubble.dataset.raw || "";
|
||||
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
|
||||
@@ -1719,7 +1723,10 @@ function autoGrow(){
|
||||
return;
|
||||
}
|
||||
inputDom.style.height = "auto";
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
|
||||
const viewport = window.visualViewport;
|
||||
const height = viewport && Math.abs(viewport.scale - 1) < 0.01 ? viewport.height : window.innerHeight;
|
||||
const limit = isNarrow() && height > 0 ? Math.min(140, Math.max(44, Math.floor(height / 4))) : 140;
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
|
||||
}
|
||||
inputDom.addEventListener("input", autoGrow);
|
||||
|
||||
@@ -1777,12 +1784,30 @@ renderSidebar();
|
||||
|
||||
function syncChatViewport(){
|
||||
const viewport = window.visualViewport;
|
||||
chatContainer.style.height = viewport && isNarrow() && viewport.scale === 1
|
||||
? Math.round(viewport.height) + 'px' : '';
|
||||
const narrow = isNarrow();
|
||||
const unzoomed = !viewport || Math.abs(viewport.scale - 1) < 0.01;
|
||||
if(narrow && unzoomed){
|
||||
const height = viewport ? viewport.height : window.innerHeight;
|
||||
if(Number.isFinite(height) && height > 0){
|
||||
chatContainer.style.height = Math.round(height) + 'px';
|
||||
// Keyboard focus may pan the visual viewport without changing its height.
|
||||
chatContainer.style.top = Math.max(0, Math.round(viewport && viewport.offsetTop || 0)) + 'px';
|
||||
}
|
||||
}else{
|
||||
chatContainer.style.height = '';
|
||||
chatContainer.style.top = '';
|
||||
}
|
||||
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
|
||||
autoGrow();
|
||||
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
|
||||
updateToBottom();
|
||||
}
|
||||
let viewportSyncQueued = false;
|
||||
function scheduleChatViewport(){
|
||||
if(viewportSyncQueued) return;
|
||||
viewportSyncQueued = true;
|
||||
requestAnimationFrame(function(){ viewportSyncQueued = false; syncChatViewport(); });
|
||||
}
|
||||
|
||||
toggleBtn.onclick = function(){
|
||||
sideOpen = !sideOpen;
|
||||
@@ -1810,7 +1835,21 @@ window.addEventListener("resize", function(){
|
||||
renderSidebar();
|
||||
syncChatViewport();
|
||||
});
|
||||
if(window.visualViewport) window.visualViewport.addEventListener('resize', syncChatViewport);
|
||||
if(window.visualViewport){
|
||||
window.visualViewport.addEventListener('resize', scheduleChatViewport);
|
||||
window.visualViewport.addEventListener('scroll', scheduleChatViewport);
|
||||
}
|
||||
window.addEventListener('pageshow', scheduleChatViewport);
|
||||
inputDom.addEventListener('focus', scheduleChatViewport);
|
||||
inputDom.addEventListener('blur', scheduleChatViewport);
|
||||
// Let toolbar clicks complete before blur changes the keyboard and composer layout.
|
||||
['pointerdown', 'mousedown'].forEach(function(name){
|
||||
document.querySelector('.composer').addEventListener(name, function(event){
|
||||
if(event.button === 0 && document.activeElement === inputDom && event.target.closest('button')){
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
syncChatViewport();
|
||||
|
||||
voiceInput = ChatVoice.create({
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ var ChatVoice = {
|
||||
toggle.onclick = function(){
|
||||
if(!mode && !supported()) return;
|
||||
setMode(!mode);
|
||||
if(!mode) input.focus();
|
||||
if(!mode) input.focus({preventScroll:true});
|
||||
};
|
||||
if(window.PointerEvent){
|
||||
hold.addEventListener('pointerdown', function(event){
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="theme-color" content="#315fd5">
|
||||
<title>柏悦智能物流客服系统</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||||
@@ -61,7 +61,7 @@
|
||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
|
||||
@@ -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.listeners = {}; this.focusCalls = [];
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -32,7 +32,8 @@ 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)); }
|
||||
focus(){}
|
||||
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(); } }
|
||||
querySelector(selector){
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
@@ -67,10 +68,10 @@ async function customerPage(pageOptions = {}){
|
||||
FormData:class { append(){} },
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||
window:{innerWidth:pageOptions.width || 1200,
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:pageOptions.width ? pageOptions.width <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1]) : false}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
|
||||
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
|
||||
prompt:() => pageOptions.offlineMessage || null},
|
||||
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||
@@ -112,15 +113,22 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
resize:async height => {
|
||||
context.window.innerHeight=height;
|
||||
if(context.window.visualViewport) context.window.visualViewport.height=height;
|
||||
(windowListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
resizeViewport:async (height,scale=1) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale});
|
||||
resizeViewport:async (height,scale=1,offsetTop=0) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale,offsetTop});
|
||||
(viewportListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
panViewport:async top => {
|
||||
context.window.visualViewport.offsetTop=top;
|
||||
(viewportListeners.scroll || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
pageShow:async () => { (windowListeners.pageshow || []).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(); },
|
||||
@@ -635,6 +643,93 @@ test('clearing a sent draft restores a single-row composer instead of sizing to
|
||||
assert.equal(input.style.height,'');
|
||||
});
|
||||
|
||||
test('keyboard panning keeps the whole mobile frame in the visual viewport and restores after closing', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text'), frame=app.element('.chat-container');
|
||||
input.focus();
|
||||
await app.resizeViewport(430,1,180);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
assert.equal(frame.style.top,'180px');
|
||||
assert.ok(frame.classList.contains('is-composing'));
|
||||
await app.panViewport(260);
|
||||
assert.equal(frame.style.top,'260px');
|
||||
assert.equal(frame.style.height,'430px');
|
||||
await app.resizeViewport(0,1,0);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
input.blur();
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(frame.style.top,'0px');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
assert.equal(frame.classList.contains('is-composing'),false);
|
||||
});
|
||||
|
||||
test('embedded browsers without visualViewport use the resized window and recover on pageshow', async () => {
|
||||
const app=await customerPage({human:false,width:390,height:844});
|
||||
const frame=app.element('.chat-container');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
await app.resize(410);
|
||||
assert.equal(frame.style.height,'410px');
|
||||
app.context.window.innerHeight=844;
|
||||
await app.pageShow();
|
||||
assert.equal(frame.style.height,'844px');
|
||||
app.context.window.innerWidth=1200;
|
||||
await app.resize(720);
|
||||
assert.equal(frame.style.height,'');
|
||||
assert.equal(frame.style.top,'');
|
||||
});
|
||||
|
||||
test('a multiline draft leaves room for messages when the keyboard reduces the viewport', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
input.value='多行草稿';
|
||||
input.scrollHeight=300;
|
||||
input.listeners.input();
|
||||
assert.equal(input.style.height,'140px');
|
||||
await app.resizeViewport(340);
|
||||
assert.equal(input.style.height,'85px');
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(input.style.height,'140px');
|
||||
});
|
||||
|
||||
test('late AI completion does not steal focus or reopen a dismissed mobile keyboard', async () => {
|
||||
for(const width of [390,1200]){
|
||||
const app=await customerPage({human:false,width,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
let reply;
|
||||
app.hooks['/stream_chat']=()=>new Promise(resolve=>{reply=resolve;});
|
||||
input.focus();
|
||||
await app.context.window.fillInput('报价问题');
|
||||
await flush();
|
||||
input.blur();
|
||||
app.element('history-search').focus();
|
||||
const before=input.focusCalls.length;
|
||||
reply(streamResponse(['data: {"t":"完成回复"}\n\ndata: {"d":1}\n\n']));
|
||||
await flush();await flush();
|
||||
assert.equal(input.focusCalls.length,before);
|
||||
assert.equal(app.context.document.activeElement,app.element('history-search'));
|
||||
assert.match(app.element('chatDom').textContent,/完成回复/);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile quick questions keep the keyboard closed and composer controls finish clicking before blur changes layout', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const input=app.element('input-text');
|
||||
await app.context.window.fillInput('快捷咨询');await flush();
|
||||
assert.equal(input.focusCalls.length,0);
|
||||
input.focus();
|
||||
for(const event of ['pointerdown','mousedown']){
|
||||
for(const name of ['send-button','voice-toggle','emoji-toggle','upload-btn']){
|
||||
let prevented=false;
|
||||
app.element('.composer').listeners[event]({button:0,target:{closest:()=>app.element(name)},preventDefault(){prevented=true;}});
|
||||
assert.ok(prevented);
|
||||
}
|
||||
}
|
||||
let blockedTextSelection=false;
|
||||
app.element('.composer').listeners.pointerdown({button:0,target:{closest:()=>null},preventDefault(){blockedTextSelection=true;}});
|
||||
assert.equal(blockedTextSelection,false);
|
||||
assert.equal(app.context.document.activeElement,input);
|
||||
});
|
||||
|
||||
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
|
||||
for(const human of [false,true]){
|
||||
const app=await customerPage({human,width:390});
|
||||
|
||||
@@ -14,8 +14,8 @@ function voicePage(options={}){
|
||||
setAttribute(name,value){this.attributes[name]=value;}
|
||||
addEventListener(name,callback){this.listeners[name]=callback;}
|
||||
setPointerCapture(){}
|
||||
focus(){}
|
||||
blur(){}
|
||||
focus(options){this.focusOptions=options;}
|
||||
blur(){this.blurred=true;}
|
||||
}
|
||||
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
|
||||
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
|
||||
@@ -70,6 +70,15 @@ test('hold and release transcribes audio and sends text once',async()=>{
|
||||
assert.equal(app.messages.length,1);
|
||||
});
|
||||
|
||||
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
|
||||
const app=voicePage();
|
||||
app.open();
|
||||
assert.ok(app.elements.input.blurred);
|
||||
app.open();
|
||||
assert.equal(app.elements.input.focusOptions.preventScroll,true);
|
||||
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
|
||||
});
|
||||
|
||||
test('sliding up cancels without uploading and supports another recording',async()=>{
|
||||
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
|
||||
assert.equal(app.messages.length,0);
|
||||
|
||||
Reference in New Issue
Block a user