From 99c26a393a1d901bf4486a668cc3cb9e811ee1ff Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 30 Oct 2026 10:33:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96H5=E8=AE=A2=E5=8D=95=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E5=B1=95=E7=A4=BA=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=A7=86?= =?UTF-8?q?=E5=8F=A3=E5=8F=98=E5=8C=96=E5=92=8C=E8=BE=93=E5=85=A5=E5=8C=BA?= =?UTF-8?q?=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/chat.css | 10 ++++- static/js/chat.js | 42 +++++++++++++++++++- tests/frontend/chat.test.cjs | 75 ++++++++++++++++++++++++++++++++++-- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/static/css/chat.css b/static/css/chat.css index 2305038..90ab73b 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -155,6 +155,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; } .order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; } .order-data-fields dt { color: var(--muted); } .order-data-fields dd { margin: 0; overflow-wrap: anywhere; } +.order-details-toggle { display: none; } .order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; } .order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; } .order-tracking-status { margin: 0 0 12px; color: var(--brand-text); font-size: 13px; } @@ -236,7 +237,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; } .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-label { display: none; } - .hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; } + .hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; min-height: 32px; 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; } @@ -249,6 +250,11 @@ body { padding: 20px; height: 100dvh; overflow: hidden; } .track-text { grid-template-columns: 1fr; gap: 2px; } .track-place { grid-column: 1; } .robot-message.track-card { padding: 16px; } - .track-no { font-size: 15px; } + .track-head { align-items: flex-start; gap: 10px; } + .track-no { min-width: 0; flex: 1; font-size: 15px; } + .order-details-toggle { display: block; width: 100%; min-height: 44px; margin: 0 0 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; } + .order-data-fields.is-collapsed { display: none; } + .track-expand { min-height: 44px; } + .msg-action-btn { min-height: 40px; } .to-bottom { right: 15px; } } diff --git a/static/js/chat.js b/static/js/chat.js index b3f37df..f0903b4 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -265,6 +265,8 @@ const I18N = { orderNo: "订单号", orderHistory: "订单业务状态历史", noOrderHistory: "未提供订单状态历史", + moreOrderDetails: "展开订单信息", + lessOrderDetails: "收起订单信息", logisticsHistory: "物流轨迹", noLogisticsHistory: "暂无已发生的物流节点", estimatedTracking: "预计节点", @@ -360,6 +362,8 @@ const I18N = { orderNo: "Order number", orderHistory: "Order workflow history", noOrderHistory: "No order status history provided", + moreOrderDetails: "Show order details", + lessOrderDetails: "Hide order details", logisticsHistory: "Shipment tracking", noLogisticsHistory: "No actual shipment events available", estimatedTracking: "Estimated milestones", @@ -871,7 +875,7 @@ function fillTrackCard(el, card){ if(isOrder){ const fields = document.createElement('dl'); - fields.className = 'order-data-fields'; + fields.className = 'order-data-fields is-collapsed'; (card.fields || []).forEach(function(field){ const label = document.createElement('dt'); label.dataset.i18n = 'order_'+field.key; @@ -880,7 +884,23 @@ function fillTrackCard(el, card){ value.textContent = field.value; fields.append(label, value); }); - el.appendChild(fields); + if((card.fields || []).length){ + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'order-details-toggle'; + toggle.setAttribute('aria-expanded', 'false'); + toggle.dataset.i18n = 'moreOrderDetails'; + toggle.textContent = t('moreOrderDetails'); + toggle.onclick = function(){ + const expanded = toggle.getAttribute('aria-expanded') !== 'true'; + fields.classList.toggle('is-collapsed', !expanded); + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.dataset.i18n = expanded ? 'lessOrderDetails' : 'moreOrderDetails'; + toggle.textContent = t(toggle.dataset.i18n); + }; + el.appendChild(toggle); + el.appendChild(fields); + } const title = document.createElement('p'); title.className = 'order-history-title'; title.dataset.i18n = isLogistics ? 'logisticsHistory' : 'orderHistory'; @@ -1673,6 +1693,10 @@ sendBtn.addEventListener("click", function(){ // ===== 多行输入框:自动增高 ===== function autoGrow(){ + if(!inputDom.value){ + inputDom.style.height = ''; + return; + } inputDom.style.height = "auto"; inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px"; } @@ -1703,6 +1727,8 @@ toBottomBtn.onclick = ()=>{ if(window.ResizeObserver){ new ResizeObserver(function(){ document.querySelector('.main-chat').style.setProperty('--composer-height', document.querySelector('.composer').offsetHeight + 'px'); + if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd(); + updateToBottom(); }).observe(document.querySelector('.composer')); } @@ -1728,6 +1754,15 @@ function renderSidebar(){ } renderSidebar(); +function syncChatViewport(){ + const viewport = window.visualViewport; + chatContainer.style.height = viewport && isNarrow() && viewport.scale === 1 + ? Math.round(viewport.height) + 'px' : ''; + autoGrow(); + if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd(); + updateToBottom(); +} + toggleBtn.onclick = function(){ sideOpen = !sideOpen; renderSidebar(); @@ -1752,7 +1787,10 @@ window.addEventListener("resize", function(){ wasNarrow = narrow; } renderSidebar(); + syncChatViewport(); }); +if(window.visualViewport) window.visualViewport.addEventListener('resize', syncChatViewport); +syncChatViewport(); document.querySelectorAll('[data-question-index]').forEach(function(button){ button.addEventListener('click', function(){ diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 4965f0d..412afbd 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -6,12 +6,12 @@ const vm = require('node:vm'); const flush = () => new Promise(setImmediate); async function customerPage(pageOptions = {}){ - const elements = new Map(), listeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {}; + const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {}; let intervalId = 0, timerId = 0, nextConvId = 1; class Element { constructor(){ this.children = []; this.attributes = {}; this.value = ''; this.style = {}; - this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; + this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0; this.listeners = {}; this.classList = { contains: name => this.className.split(' ').includes(name), @@ -66,7 +66,12 @@ async function customerPage(pageOptions = {}){ FormData:class { append(){} }, requestAnimationFrame:callback => callback(), localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}}, - window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null}, + window:{innerWidth:pageOptions.width || 1200, + 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, + addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined, + prompt:() => pageOptions.offlineMessage || null}, setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; }, clearInterval:id => intervals.delete(id), setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; }, @@ -100,6 +105,16 @@ async function customerPage(pageOptions = {}){ if(pageOptions.human !== false) await context.window.showHuman(); await flush(); return {context, element, calls, hooks, quickButtons, + resize:async 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}); + (viewportListeners.resize || []).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(); }, @@ -559,3 +574,57 @@ test('shipment cards separate forecasts from actual progress and retain both in assert.match(timelines[1].querySelector('.track-text').innerHTML, /<img/); assert.ok(log.querySelector('.msg-actions')); }); + +test('order details can expand without hiding the shipment timeline', async () => { + const app = await customerPage({human:false,width:390}); + const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics', + fields:[{key:'shipment',value:'LONG-SHIPMENT-20261030001'}], + nodes:[{time:'2026-10-29 10:00',text:'海运途中',place:''}],summary:'订单概要'}; + app.hooks['/stream_chat'] = () => streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']); + await app.context.window.fillInput('ORDER2026001'); + await flush(); + const log=app.element('chatDom'), fields=log.querySelector('.order-data-fields'); + const toggle=log.querySelector('.order-details-toggle'); + assert.equal(toggle.getAttribute('aria-expanded'),'false'); + assert.ok(fields.classList.contains('is-collapsed')); + assert.match(log.querySelector('.track-timeline').textContent,/海运途中/); + await app.click(toggle); + assert.equal(toggle.getAttribute('aria-expanded'),'true'); + assert.equal(fields.classList.contains('is-collapsed'),false); + assert.match(fields.textContent,/LONG-SHIPMENT-20261030001/); + await app.click(toggle); + assert.ok(fields.classList.contains('is-collapsed')); +}); + +test('viewport changes keep latest replies visible and preserve an older reading position', async () => { + const app=await customerPage({human:false,width:390,viewportHeight:844}); + await app.context.window.fillInput('查询物流'); + await flush(); + const log=app.element('chatDom'); + Object.assign(log,{scrollHeight:1500,clientHeight:600,scrollTop:900}); + log.listeners.scroll(); + log.clientHeight=260; + await app.resize(500); + assert.equal(app.element('.chat-container').style.height,'500px'); + assert.equal(log.scrollTop,log.scrollHeight); + log.scrollTop=100; + log.listeners.scroll(); + await app.resizeViewport(450); + assert.equal(app.element('.chat-container').style.height,'450px'); + assert.equal(log.scrollTop,100); + await app.resizeViewport(400,2); + assert.equal(app.element('.chat-container').style.height,''); + assert.equal(log.scrollTop,100); +}); + +test('clearing a sent draft restores a single-row composer instead of sizing to its placeholder', async () => { + const app=await customerPage({human:false,width:320}); + const input=app.element('input-text'); + input.value='多行内容\n第二行'; + input.listeners.input(); + assert.equal(input.style.height,'140px'); + await app.context.window.fillInput(input.value); + await flush(); + assert.equal(input.value,''); + assert.equal(input.style.height,''); +});