优化H5订单轨迹展示并修复视口变化和输入区高度

This commit is contained in:
2026-10-30 10:33:04 +08:00
parent e051ab2e9b
commit 99c26a393a
3 changed files with 120 additions and 7 deletions
+8 -2
View File
@@ -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; }
}
+40 -2
View File
@@ -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(){
+72 -3
View File
@@ -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, /&lt;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,'');
});