From a415bcdb88b6db403363efe9f828aab8fc9b16bf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 30 Oct 2026 12:53:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E6=9C=BA=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E5=BC=B9=E5=87=BA=E6=97=B6=E9=A1=B5=E9=9D=A2=E4=BD=8D?= =?UTF-8?q?=E7=A7=BB=E4=B8=8E=E7=84=A6=E7=82=B9=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/chat.css | 25 ++++++--- static/js/chat.js | 57 +++++++++++++++---- static/js/voice.js | 2 +- templates/index.html | 4 +- tests/frontend/chat.test.cjs | 102 +++++++++++++++++++++++++++++++--- tests/frontend/voice.test.cjs | 13 ++++- 6 files changed, 171 insertions(+), 32 deletions(-) diff --git a/static/css/chat.css b/static/css/chat.css index 881e73c..11488ba 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -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; } @@ -205,17 +205,24 @@ 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; } .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; } diff --git a/static/js/chat.js b/static/js/chat.js index a546933..57295cf 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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){ @@ -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 && viewport.scale === 1 ? 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,17 @@ 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); +// Keep an already open keyboard while tapping Send; keyboard navigation is unchanged. +sendBtn.addEventListener('pointerdown', function(event){ + if(event.button === 0 && document.activeElement === inputDom) event.preventDefault(); +}); syncChatViewport(); voiceInput = ChatVoice.create({ diff --git a/static/js/voice.js b/static/js/voice.js index f723ef9..7f195a3 100644 --- a/static/js/voice.js +++ b/static/js/voice.js @@ -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){ diff --git a/templates/index.html b/templates/index.html index 45c122b..3f2a7a0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,7 +2,7 @@ - + 柏悦智能物流客服系统 @@ -61,7 +61,7 @@
- + diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 40eb29c..cc49c0b 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -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,86 @@ 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 do not summon the keyboard and tapping Send retains an active draft focus', 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(); + let prevented=false; + app.element('send-button').listeners.pointerdown({button:0,preventDefault(){prevented=true;}}); + assert.ok(prevented); + 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}); diff --git a/tests/frontend/voice.test.cjs b/tests/frontend/voice.test.cjs index 7d42987..cbb2ef8 100644 --- a/tests/frontend/voice.test.cjs +++ b/tests/frontend/voice.test.cjs @@ -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);