修复手机键盘弹出时页面位移与焦点跳转

This commit is contained in:
2026-10-30 12:53:32 +08:00
parent 8853ea2dfa
commit a415bcdb88
6 changed files with 171 additions and 32 deletions
+16 -9
View File
@@ -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; }
+46 -11
View File
@@ -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({
+1 -1
View File
@@ -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 -2
View File
@@ -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>
+95 -7
View File
@@ -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});
+11 -2
View File
@@ -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);