新增订单概况并优化自动刷新和错误提示

This commit is contained in:
songxinnuo
2026-11-03 17:39:15 +08:00
parent 92684bffb2
commit efb2e5b4b4
12 changed files with 407 additions and 44 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
| My orders | `logistics_chat/customer_order_service.py`: scoped read-only list and exact detail; `routes/customer.py`: `/api/customer/orders`; `static/js/customer_orders.js`: automatic login panel, pagination, tracking, logout cleanup |
| My orders | `logistics_chat/customer_order_service.py`: scoped overview counts, status filters, read-only list and exact detail; `routes/customer.py`: `/api/customer/orders`; `static/js/customer_orders.js`: automatic login panel, pagination, tracking, logout cleanup |
| Staff login | `logistics_chat/staff_service.py`: opaque 24-hour sessions, default-deny endpoint allowlist, CSRF, login/logout; `routes/admin.py`; `static/js/admin.js` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
+2 -1
View File
@@ -92,7 +92,8 @@ tests/ # 按业务模块组织的回归
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
- 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。
- 登录后由 `customer_orders.js` 自动展开只读“我的订单”面板,不切换聊天或改写草稿。`GET /api/customer/orders` 按 ID 倒序每页 20 条,以 `before` 游标继续;`GET /api/customer/orders/<id>` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。退出/401 清空面板,异步结果受请求版本约束;Nginx 与 Flask 的公开路由白名单仅开放这两个客户鉴权入口,旧 `/api/my_orders` 继续受后台保护。
- 登录后由 `customer_orders.js` 自动展开只读“我的订单”面板,不切换聊天或改写草稿。`GET /api/customer/orders` 按 ID 倒序每页 20 条,以 `before` 游标继续;同时返回同一数据库快照下的全量授权范围 `summary`,不受分页或 `status` 筛选影响。状态分组为 `pending`(草稿、已提交、审核通过、已接单)、`processing`(履约中)、`completed`(头程订单已完成)、`cancelled`(已取消)与 `other`(驳回及未识别状态),`all` 查看全部;履约状态不推断实际运输状态。页面标题显示真实总数,列表另标已加载数量,点击概况重置游标并在服务端筛选。`GET /api/customer/orders/<id>` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。页面重新可见或窗口重新获得焦点时,先验证客户登录,再自动刷新当前状态筛选的第一页与全量概况;同次唤醒事件合并,已有订单请求进行中时不重复刷新。自动刷新保持面板展开状态、轨迹详情及聊天草稿,失败保留原列表并提供重试;退出/401 清空面板及概况,异步结果受请求版本约束;Nginx 与 Flask 的公开路由白名单仅开放这两个客户鉴权入口,旧 `/api/my_orders` 继续受后台保护。
- 客户订单错误按网络失败、10 秒本地超时/408/504、429 限流、5xx 服务异常及成功响应格式异常区分中英文提示。400/403/详情 404 不提供原请求重试,分别引导刷新、联系人工、返回订单列表;401 沿用清空数据并复核登录的流程。HTTP 状态优先于错误响应体,不直接显示服务端内部诊断;取消和迟到请求不得覆盖当前视图。
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
+42 -14
View File
@@ -1,6 +1,7 @@
"""Read-only customer order browsing; scope comes exclusively from verified sessions."""
import sqlite3
from contextlib import contextmanager
from .customer_service import effective_tenants
from .db import connect
@@ -10,20 +11,22 @@ from .visitor_service import customer_tenant_ids
PAGE_SIZE = 20
SUMMARY_FIELDS = ('id', 'order_no', 'inbound_code', 'status', 'country_code',
'warehouse_code', 'transport_mode')
STATUS_GROUPS = {
'pending': ('DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED'),
'processing': ('PROCESSING',), 'completed': ('COMPLETED',), 'cancelled': ('CANCELLED',),
}
FILTERS = ('all', *STATUS_GROUPS, 'other')
def scoped_rows(columns, suffix='', values=()):
tenants = effective_tenants(customer_tenant_ids())
if not tenants:
return []
@contextmanager
def order_connection():
connection = None
try:
connection = connect()
connection.row_factory = sqlite3.Row
placeholders = ','.join('?' for _ in tenants)
return [dict(row) for row in connection.execute(
f'SELECT {columns} FROM order_test_data WHERE tenant_id IN ({placeholders}) '+suffix,
(*tenants, *values))]
# Summary and list must describe the same snapshot, including during imports.
connection.execute('BEGIN')
yield connection
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
@@ -31,16 +34,41 @@ def scoped_rows(columns, suffix='', values=()):
connection.close()
def list_orders(before=None):
rows = scoped_rows(','.join(SUMMARY_FIELDS),
('AND id<? ' if before is not None else '')+'ORDER BY id DESC LIMIT ?',
((before,) if before is not None else ())+(PAGE_SIZE+1,))
return {'ok': True, 'orders': rows[:PAGE_SIZE],
def scoped_rows(connection, columns, suffix='', values=()):
tenants = effective_tenants(customer_tenant_ids())
if not tenants:
return []
placeholders = ','.join('?' for _ in tenants)
return [dict(row) for row in connection.execute(
f'SELECT {columns} FROM order_test_data WHERE tenant_id IN ({placeholders}) '+suffix,
(*tenants, *values))]
def list_orders(before=None, status='all'):
if status not in FILTERS:
raise ValueError('Invalid order status filter')
summary = dict.fromkeys(('total', *STATUS_GROUPS, 'other'), 0)
suffix, values = '', ()
if status in STATUS_GROUPS or status == 'other':
codes = STATUS_GROUPS.get(status, tuple(code for codes in STATUS_GROUPS.values() for code in codes))
suffix = 'AND status '+('NOT IN' if status == 'other' else 'IN')+' ('+','.join('?' for _ in codes)+') '
values = codes
if before is not None:
suffix += 'AND id<? '
values += (before,)
with order_connection() as connection:
for row in scoped_rows(connection, 'status,COUNT(*) AS count', 'GROUP BY status'):
group = next((key for key, codes in STATUS_GROUPS.items() if row['status'] in codes), 'other')
summary[group] += row['count']
summary['total'] += row['count']
rows = scoped_rows(connection, ','.join(SUMMARY_FIELDS), suffix+'ORDER BY id DESC LIMIT ?', values+(PAGE_SIZE+1,))
return {'ok': True, 'orders': rows[:PAGE_SIZE], 'summary': summary,
'next_before': rows[PAGE_SIZE-1]['id'] if len(rows) > PAGE_SIZE else None}
def order_detail(order_id):
if not 0 < order_id <= 9223372036854775807:
return None
rows = scoped_rows('*', 'AND id=? LIMIT 1', (order_id,))
with order_connection() as connection:
rows = scoped_rows(connection, '*', 'AND id=? LIMIT 1', (order_id,))
return build_card(rows[0]) if rows else None
+4 -1
View File
@@ -46,12 +46,15 @@ def order_request(action):
@bp.get('/api/customer/orders')
def orders():
def browse():
status = request.args.get('status', 'all')
if status not in customer_order_service.FILTERS:
return {'ok': False, 'error': '订单状态筛选无效。'}, 400
before = request.args.get('before')
if before is not None:
if not before.isascii() or not before.isdigit() or len(before) > 19 or not 0 < int(before) <= 9223372036854775807:
return {'ok': False, 'error': '分页参数无效。'}, 400
before = int(before)
return customer_order_service.list_orders(before)
return customer_order_service.list_orders(before, status)
return order_request(browse)
+7
View File
@@ -54,6 +54,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
#customer-orders-toggle[aria-expanded="true"]::after { transform: rotate(180deg); }
.customer-orders-body { min-height: 0; overflow: auto; padding: 8px 26px 12px; overscroll-behavior: contain; }
.customer-orders-list { display: flex; flex-direction: column; }
.customer-orders-summary { display: grid; grid-template-columns: repeat(6,minmax(0,1fr)); gap: 6px; padding: 4px 0 10px; }
.customer-orders .customer-order-filter { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 6px; color: var(--secondary); border-color: var(--border); }
.customer-order-filter strong { color: var(--text); font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
.customer-orders .customer-order-filter[aria-pressed="true"] { color: var(--brand-text); background: var(--brand-soft); border-color: var(--brand); }
.customer-order-filter[aria-pressed="true"] strong { color: var(--brand-text); }
.customer-orders-progress { margin: 10px 4px 0; color: var(--secondary); text-align: center; font-size: 13px; }
@media (max-width: 1100px) { .customer-orders-summary { grid-template-columns: repeat(3,minmax(0,1fr)); } }
.customer-orders .customer-order-row { display: grid; grid-template-columns: minmax(130px,1.2fr) minmax(60px,1fr) auto auto; gap: 16px; align-items: center; text-align: left; width: 100%; border-bottom: 1px solid var(--border); border-radius: 0; padding: 12px 6px; color: var(--text); }
.customer-order-identity { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.customer-order-identity strong { overflow-wrap: anywhere; font-size: 15px; font-weight: 600; }
+24 -3
View File
@@ -256,7 +256,16 @@ const I18N = {
myOrders: "我的订单", ordersRefresh: "刷新", ordersBack: "← 返回订单列表",
ordersMore: "加载更多订单", ordersRetry: "重试", ordersTrack: "查看轨迹",
ordersLoading: "正在加载订单…", ordersEmpty: "当前账号暂无可查看的订单。",
ordersFailed: "订单加载失败,请检查网络后重试。",
ordersFailed: "订单加载失败,请稍后重试。",
ordersNetworkError: "网络连接失败,请检查网络后重试。", ordersTimeout: "订单请求超时,请重试。",
ordersUnavailable: "订单不存在或当前账号无权查看,请返回订单列表并刷新。",
ordersForbidden: "当前账号无权查看,请联系人工客服核对订单权限。",
ordersRateLimited: "操作过于频繁,请稍后再试。", ordersServerError: "订单服务暂时不可用,请稍后重试。",
ordersInvalidRequest: "订单请求无效,请点击订单区的“刷新”重新加载。",
ordersInvalidResponse: "订单数据暂时无法读取,请重试;若仍失败,请联系人工客服。",
ordersOverview: "订单概况", ordersGroup_all: "全部", ordersGroup_processing: "履约中",
ordersGroup_completed: "已完成", ordersGroup_pending: "待履约", ordersGroup_cancelled: "已取消", ordersGroup_other: "其他",
ordersProgress: "已加载 {loaded} / {total} 笔", ordersFilterEmpty: "当前状态下暂无订单。",
orderStatus_DRAFT: "草稿", orderStatus_SUBMITTED: "已提交", orderStatus_APPROVED: "审核通过",
orderStatus_ACCEPTED: "已接单", orderStatus_REJECTED: "审核驳回", orderStatus_PROCESSING: "履约中",
orderStatus_COMPLETED: "头程订单已完成", orderStatus_CANCELLED: "已取消",
@@ -389,7 +398,16 @@ const I18N = {
myOrders: "My orders", ordersRefresh: "Refresh", ordersBack: "← Back to orders",
ordersMore: "Load more orders", ordersRetry: "Retry", ordersTrack: "View tracking",
ordersLoading: "Loading orders…", ordersEmpty: "No orders are available for this account.",
ordersFailed: "Could not load orders. Check your connection and retry.",
ordersFailed: "Could not load orders. Please try again later.",
ordersNetworkError: "Connection failed. Check your network and retry.", ordersTimeout: "The order request timed out. Please retry.",
ordersUnavailable: "This order does not exist or you cannot access it. Return to the order list and refresh.",
ordersForbidden: "You cannot access these orders. Contact support to check your permissions.",
ordersRateLimited: "Too many requests. Please wait and try again.", ordersServerError: "The order service is temporarily unavailable. Please try again later.",
ordersInvalidRequest: "The order request is invalid. Use Refresh in the orders panel to reload.",
ordersInvalidResponse: "Could not read the order data. Retry, or contact support if the problem continues.",
ordersOverview: "Order overview", ordersGroup_all: "All", ordersGroup_processing: "In progress",
ordersGroup_completed: "Completed", ordersGroup_pending: "Pending", ordersGroup_cancelled: "Cancelled", ordersGroup_other: "Other",
ordersProgress: "Loaded {loaded} of {total} orders", ordersFilterEmpty: "No orders with this status.",
orderStatus_DRAFT: "Draft", orderStatus_SUBMITTED: "Submitted", orderStatus_APPROVED: "Approved",
orderStatus_ACCEPTED: "Accepted", orderStatus_REJECTED: "Rejected", orderStatus_PROCESSING: "In progress",
orderStatus_COMPLETED: "First leg completed", orderStatus_CANCELLED: "Cancelled",
@@ -2120,7 +2138,10 @@ customerOrders = CustomerOrders.create({fetch:fetch, text:t, renderCard:fillTrac
onUnauthorized:function(){ if(customerLogin) customerLogin.refresh(); }
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onSession:function(user){ customerOrders.setUser(user); },
onSession:function(user,refreshOrders){
customerOrders.setUser(user);
if(refreshOrders) customerOrders.refresh();
},
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
onChange:function(user,previous){
if(previous === undefined) return;
+14 -7
View File
@@ -9,7 +9,7 @@ var CustomerLogin = {
const cancel=document.getElementById('customer-cancel');
const error=document.getElementById('customer-error');
const accounts=['customer-account','customer-account-mobile'].map(id=>document.getElementById(id)).filter(Boolean);
let user, pending=null, opener=null, busy=false, version=0;
let user, pending=null, opener=null, busy=false, version=0, resumePending=false;
overlay.hidden=true;
function status(message){ error.textContent=message || ''; error.hidden=!message; }
function render(){
@@ -26,14 +26,14 @@ var CustomerLogin = {
username.placeholder=options.text('customerUsernamePlaceholder');
password.placeholder=options.text('customerPasswordPlaceholder');
}
function update(next){
function update(next, refreshOrders){
const previous=user;
user=next;
render();
if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){
options.onChange(user,previous);
}
if(options.onSession) options.onSession(user);
if(options.onSession) options.onSession(user,Boolean(refreshOrders));
}
async function request(path, data){
const controller=new AbortController();
@@ -49,12 +49,12 @@ var CustomerLogin = {
return result;
}finally{ clearTimeout(timer); }
}
async function refresh(){
async function refresh(refreshOrders){
if(busy) return;
const token=++version;
try{
const result=await request('/api/customer/session');
if(token === version) update(result.authenticated && result.user ? result.user : null);
if(token === version) update(result.authenticated && result.user ? result.user : null,refreshOrders);
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
}
function restoreFocus(){
@@ -121,8 +121,15 @@ var CustomerLogin = {
finally{ if(token === version) setBusy(false); }
}
accounts.forEach(account=>{account.onclick=activateAccount;});
window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
async function resume(){
// Browsers often emit both events for one return to the page.
if(document.hidden || resumePending) return;
resumePending=true;
try{ await refresh(true); }
finally{ resumePending=false; }
}
window.addEventListener('focus',resume);
document.addEventListener('visibilitychange',resume);
render();
return {open:open,refresh:refresh,render:render,require:async function(resume){
if(busy) return;
+56 -11
View File
@@ -5,6 +5,19 @@ var CustomerOrders = {
const root=node(''), toggle=node('toggle'), body=node('body'), list=node('list');
const detail=node('detail'), message=node('message'), more=node('more');
const refresh=node('refresh'), back=node('back'), retry=node('retry');
const overview=node('summary'), progress=node('progress');
const groups=['all','processing','completed','pending','cancelled','other'];
const filters=groups.map(function(group){
const button=document.createElement('button');
button.type='button';button.className='customer-order-filter';button.dataset.filter=group;
button.onclick=function(){
if(activeFilter === group && selected === null && !errorKey) return;
activeFilter=group;loadList(false);
};
overview.appendChild(button);
return button;
});
let summary=null, activeFilter='all';
let user=null, rows=[], cursor=null, card=null, selected=null;
let expanded=false, loading=false, errorKey='', retryAction=null, version=0, controller=null, listScroll=0;
function cancel(){
@@ -15,7 +28,7 @@ var CustomerOrders = {
}
function render(){
root.hidden=!user;
toggle.textContent=options.text('myOrders')+(rows.length ? ' · '+rows.length : '');
toggle.textContent=options.text('myOrders')+(summary ? ' · '+summary.total : '');
toggle.setAttribute('aria-expanded',String(expanded));
body.hidden=!expanded;
refresh.hidden=!expanded;
@@ -24,13 +37,27 @@ var CustomerOrders = {
back.hidden=selected === null;
list.hidden=selected !== null;
detail.hidden=selected === null;
overview.hidden=!summary || selected !== null;
overview.setAttribute('aria-label',options.text('ordersOverview'));
filters.forEach(function(button){
const group=button.dataset.filter;
button.textContent='';
button.setAttribute('aria-pressed',String(activeFilter === group));
if(!summary) return;
const label=document.createElement('span');label.textContent=options.text('ordersGroup_'+group);
const count=document.createElement('strong');count.textContent=String(summary[group === 'all' ? 'total' : group]);
button.append(label,count);
});
progress.hidden=!summary || selected !== null || !rows.length || loading;
progress.textContent=summary ? options.text('ordersProgress').replace('{loaded}',rows.length)
.replace('{total}',summary[activeFilter === 'all' ? 'total' : activeFilter]) : '';
more.hidden=selected !== null || !cursor;
more.disabled=loading;
more.textContent=options.text('ordersMore');
retry.hidden=!errorKey;
retry.hidden=!errorKey || !retryAction;
retry.textContent=options.text('ordersRetry');
message.textContent=errorKey ? options.text(errorKey) : loading ? options.text('ordersLoading')
: !rows.length ? options.text('ordersEmpty') : '';
: !rows.length ? options.text(activeFilter === 'all' ? 'ordersEmpty' : 'ordersFilterEmpty') : '';
message.hidden=!message.textContent;
body.setAttribute('aria-busy',String(loading));
}
@@ -66,7 +93,8 @@ var CustomerOrders = {
const token=version;
const active=new AbortController();controller=active;
loading=true;errorKey='';retryAction=retryRequest;render();
const timer=setTimeout(()=>active.abort(),10000);
let timedOut=false, receivedResponse=false;
const timer=setTimeout(()=>{timedOut=true;active.abort();},10000);
try{
const response=await options.fetch(url,{signal:active.signal,cache:'no-store'});
if(token !== version) return;
@@ -75,27 +103,41 @@ var CustomerOrders = {
if(options.onUnauthorized) options.onUnauthorized();
return;
}
if(!response.ok) throw new Error('orders unavailable');
receivedResponse=true;
if(!response.ok){
const errors={400:'ordersInvalidRequest',403:'ordersForbidden',408:'ordersTimeout',
429:'ordersRateLimited',504:'ordersTimeout'};
errorKey=response.status === 404 && /\/orders\/\d+$/.test(url) ? 'ordersUnavailable'
: errors[response.status] || (response.status >= 500 ? 'ordersServerError' : 'ordersFailed');
if([400,403,404].includes(response.status)) retryAction=null;
return;
}
const result=await response.json();
if(token !== version) return;
apply(result);
}catch(error){
if(token === version) errorKey='ordersFailed';
if(token === version) errorKey=timedOut ? 'ordersTimeout'
: receivedResponse ? 'ordersInvalidResponse' : 'ordersNetworkError';
}finally{
clearTimeout(timer);
if(token === version){ controller=null;loading=false;render(); }
}
}
function loadList(append){
function loadList(append, preserveView){
if(!user || (append && (loading || !cursor))) return;
if(!append){ selected=null;card=null;rows=[];cursor=null;body.scrollTop=0;renderCard();renderRows(); }
const suffix=append ? '?before='+cursor : '';
if(!append && !preserveView){ selected=null;card=null;rows=[];cursor=null;body.scrollTop=0;renderCard();renderRows(); }
const params=[];
if(activeFilter !== 'all') params.push('status='+activeFilter);
if(append) params.push('before='+cursor);
const suffix=params.length ? '?'+params.join('&') : '';
return request('/api/customer/orders'+suffix,function(result){
if(!Array.isArray(result.orders)) throw new Error('invalid order list');
// Older responses may lack an overview; never infer totals from one page.
summary=result.summary || null;
rows=append ? rows.concat(result.orders) : result.orders;
cursor=result.next_before || null;
renderRows();
},()=>loadList(append));
},()=>loadList(append,preserveView));
}
function loadDetail(id){
if(!user) return;
@@ -112,6 +154,7 @@ var CustomerOrders = {
function setUser(next){
if(user && next && user.id === next.id) return;
cancel();user=next;rows=[];cursor=null;card=null;selected=null;errorKey='';retryAction=null;
summary=null;activeFilter='all';
expanded=Boolean(user);renderRows();renderCard();render();
if(user) loadList(false);
}
@@ -127,6 +170,8 @@ var CustomerOrders = {
if(button) button.focus({preventScroll:true});
};
render();
return {setUser:setUser,render:function(){renderRows();renderCard();render();}};
return {setUser:setUser,refresh:function(){
if(user && !loading) return loadList(false,true);
},render:function(){renderRows();renderCard();render();}};
}
};
+2
View File
@@ -51,10 +51,12 @@
<button id="customer-orders-refresh" type="button">刷新</button>
</div>
<div id="customer-orders-body" class="customer-orders-body" hidden>
<div id="customer-orders-summary" class="customer-orders-summary" role="group" aria-label="订单概况" hidden></div>
<button id="customer-orders-back" type="button" hidden>返回订单列表</button>
<p id="customer-orders-message" role="status" hidden></p>
<div id="customer-orders-list" class="customer-orders-list"></div>
<div id="customer-orders-detail" class="track-card" aria-live="polite" hidden></div>
<p id="customer-orders-progress" class="customer-orders-progress" role="status" hidden></p>
<button id="customer-orders-retry" type="button" hidden>重试</button>
<button id="customer-orders-more" type="button" hidden>加载更多订单</button>
</div>
+62 -2
View File
@@ -152,7 +152,7 @@ async function customerPage(pageOptions = {}){
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/customer/session' && pageOptions.customer ? {ok:true,authenticated:true,user:pageOptions.customer}
: url === '/api/customer/orders' ? {ok:true,orders:pageOptions.orders || [],next_before:null}
: url === '/api/customer/orders' ? {ok:true,orders:pageOptions.orders || [],next_before:null,summary:pageOptions.orderSummary}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
@@ -175,6 +175,7 @@ async function customerPage(pageOptions = {}){
await flush();
return {context, element, calls, hooks, dialogs, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
windowEvent:(name,event)=>{ (windowListeners[name] || []).forEach(callback=>callback(event)); },
resize:async height => {
context.window.innerHeight=height;
if(context.window.visualViewport) context.window.visualViewport.height=height;
@@ -222,20 +223,79 @@ function streamResponse(chunks){
: {done:true}})}};
}
test('returning to the page refreshes orders after validating the same customer and coalesces foreground events', async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[
{id:41,order_no:'ORDER41',status:'PROCESSING'}],orderSummary:{total:1,processing:1}});
const before=app.calls.filter(call=>call.url==='/api/customer/orders').length;
app.element('input-text').value='正在输入的草稿';
app.hooks['/api/customer/orders']=()=>response({orders:[{id:42,order_no:'ORDER42',status:'COMPLETED'}],
next_before:null,summary:{total:2,processing:1,completed:1,pending:0,cancelled:0,other:0}});
app.context.document.hidden=true;
app.documentEvent('visibilitychange');app.windowEvent('focus');await flush();
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before);
app.context.document.hidden=false;
app.documentEvent('visibilitychange');app.windowEvent('focus');await flush();
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before+1);
assert.match(app.element('customer-orders-toggle').textContent,/2/);
assert.match(app.element('customer-orders-list').textContent,/ORDER42/);
assert.equal(app.element('input-text').value,'正在输入的草稿');
});
test('returning with an expired login clears orders instead of refreshing protected data',async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[{id:41,order_no:'ORDER41'}]});
const before=app.calls.filter(call=>call.url==='/api/customer/orders').length;
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
app.windowEvent('focus');await flush();
assert.equal(app.element('customer-orders').hidden,true);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before);
});
test('a failed session check on return retains orders and a later return retries normally',async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[{id:41,order_no:'ORDER41'}]});
const before=app.calls.filter(call=>call.url==='/api/customer/orders').length;
app.hooks['/api/customer/session']=()=>Promise.reject(new Error('offline'));
app.windowEvent('focus');await flush();
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before);
assert.match(app.element('customer-orders-list').textContent,/ORDER41/);
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:true,user:{id:22,name:'客户'}});
app.hooks['/api/customer/orders']=()=>response({orders:[{id:42,order_no:'ORDER42'}],next_before:null});
app.windowEvent('focus');await flush();
assert.match(app.element('customer-orders-list').textContent,/ORDER42/);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before+1);
});
test('an existing customer session automatically shows scoped orders and translates the panel', async () => {
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[
{id:41,order_no:'ORDER20260041',inbound_code:'IN20260041',status:'PROCESSING',transport_mode:'SEA'}]});
{id:41,order_no:'ORDER20260041',inbound_code:'IN20260041',status:'PROCESSING',transport_mode:'SEA'}],
orderSummary:{total:26,processing:20,completed:1,pending:4,cancelled:0,other:1}});
assert.equal(app.element('customer-orders').hidden,false);
assert.match(app.element('customer-orders-list').textContent,/IN20260041/);
assert.match(app.element('customer-orders-toggle').textContent,/26/);
assert.match(app.element('customer-orders-summary').textContent,/履约中20/);
await app.click(app.element('lang-toggle'));
assert.match(app.element('customer-orders-toggle').textContent,/My orders/);
assert.match(app.element('customer-orders-list').textContent,/In progress/);
assert.match(app.element('customer-orders-summary').textContent,/Completed1/);
assert.match(app.element('customer-orders-progress').textContent,/Loaded 1 of 26/);
app.hooks['/api/customer/logout']=()=>response({ok:true});
await app.click(app.element('customer-account'));
assert.equal(app.element('customer-orders').hidden,true);
assert.equal(app.element('customer-orders-list').textContent,'');
});
test('order errors explain the actual failure and translate without displaying server diagnostics',async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'}});
app.hooks['/api/customer/orders']=()=>response({error:'private server traceback'},503);
await app.click(app.element('customer-orders-refresh'));
assert.match(app.element('customer-orders-message').textContent,/订单服务暂时不可用/);
assert.doesNotMatch(app.element('customer-orders-message').textContent,/网络|private|traceback/);
await app.click(app.element('lang-toggle'));
assert.match(app.element('customer-orders-message').textContent,/temporarily unavailable/);
app.hooks['/api/customer/orders']=()=>Promise.reject(new TypeError('Failed to fetch'));
await app.click(app.element('customer-orders-retry'));
assert.match(app.element('customer-orders-message').textContent,/Check your network/);
});
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
const app=await customerPage({human:false});
let resolve;
+149 -4
View File
@@ -15,19 +15,116 @@ function page(){
setAttribute(key,value){ this.attributes[key]=String(value); }
focus(){ this.focused=true; }
}
const ids={}, requests=[];
const ids={}, requests=[], timers=new Map();
let timerId=0;
const el=id=>ids[id] ||= new Element();
const context=vm.createContext({document:{getElementById:el,createElement:()=>new Element()}, AbortController, setTimeout, clearTimeout});
const context=vm.createContext({document:{getElementById:el,createElement:()=>new Element()}, AbortController,
setTimeout:callback=>{const id=++timerId;timers.set(id,callback);return id;},
clearTimeout:id=>timers.delete(id)});
vm.runInContext(fs.readFileSync('static/js/customer_orders.js','utf8'),context);
const orders=context.CustomerOrders.create({text:key=>key,
const orders=context.CustomerOrders.create({text:key=>key === 'ordersProgress' ? 'Loaded {loaded} of {total}' : key,
fetch:(url,options)=>new Promise((resolve,reject)=>requests.push({url,options,resolve,reject})),
renderCard:(element,card)=>{element.textContent=card.no;},
onUnauthorized:()=>{page.expired=true;}});
return {el,orders,requests,reply:async (i,data,status=200)=>{
return {el,orders,requests,timeout:()=>{for(const callback of Array.from(timers.values())) callback();},reply:async (i,data,status=200)=>{
requests[i].resolve({ok:status===200,status,json:async()=>data}); await flush();
}};
}
const row=(id=1)=>({id,order_no:'ORDER'+id,inbound_code:'INBOUND'+id,status:'PROCESSING'});
const counts={total:26,pending:4,processing:20,completed:1,cancelled:0,other:1};
const filter=(p,name)=>p.el('customer-orders-summary').children.find(button=>button.dataset.filter===name);
test('overview uses full server totals; a status click filters from the first page',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:1,summary:counts});
assert.match(p.el('customer-orders-toggle').textContent,/26/);
assert.match(filter(p,'processing').textContent,/20/);
filter(p,'completed').onclick();
assert.equal(p.requests[1].url,'/api/customer/orders?status=completed');
assert.equal(p.el('customer-orders-list').textContent,'');
await p.reply(1,{orders:[row(9)],next_before:null,summary:counts});
assert.equal(filter(p,'completed').attributes['aria-pressed'],'true');
assert.equal(filter(p,'all').attributes['aria-pressed'],'false');
assert.match(p.el('customer-orders-progress').textContent,/1/);
filter(p,'processing').onclick();
await p.reply(2,{orders:[row()],next_before:1,summary:counts});
p.el('customer-orders-more').onclick();
assert.equal(p.requests[3].url,'/api/customer/orders?status=processing&before=1');
await p.reply(3,{orders:[row(2)],next_before:null,summary:counts});
assert.match(p.el('customer-orders-list').textContent,/INBOUND1.*INBOUND2/);
});
test('network failure and timeout show different recoverable messages',async()=>{
const p=page();p.orders.setUser({id:1});
p.requests[0].reject(new TypeError('Failed to fetch'));await flush();
assert.equal(p.el('customer-orders-message').textContent,'ordersNetworkError');
assert.equal(p.el('customer-orders-retry').hidden,false);
p.el('customer-orders-retry').onclick();p.timeout();
assert.equal(p.requests[1].options.signal.aborted,true);
p.requests[1].reject(Object.assign(new Error('abort'),{name:'AbortError'}));await flush();
assert.equal(p.el('customer-orders-message').textContent,'ordersTimeout');
assert.equal(p.el('customer-orders-retry').hidden,false);
p.el('customer-orders-retry').onclick();
await p.reply(2,{orders:[row()],next_before:null});
assert.equal(p.el('customer-orders-message').hidden,true);
});
test('HTTP errors are classified without showing raw server output',async()=>{
for(const [status,key,retryable] of [[400,'ordersInvalidRequest',false],[403,'ordersForbidden',false],
[429,'ordersRateLimited',true],[500,'ordersServerError',true],[502,'ordersServerError',true],
[503,'ordersServerError',true],[408,'ordersTimeout',true],[504,'ordersTimeout',true],[418,'ordersFailed',true]]){
const p=page();p.orders.setUser({id:1});
await p.reply(0,{error:'private traceback with secret'},status);
assert.equal(p.el('customer-orders-message').textContent,key,'status '+status);
assert.equal(p.el('customer-orders-retry').hidden,!retryable,'status '+status);
assert.doesNotMatch(p.el('customer-orders-message').textContent,/private|secret/);
}
});
test('missing order directs the customer back to the list instead of retrying a missing record',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
p.el('customer-orders-list').children[0].onclick();
await p.reply(1,{error:'not found or forbidden'},404);
assert.equal(p.el('customer-orders-message').textContent,'ordersUnavailable');
assert.equal(p.el('customer-orders-back').hidden,false);
assert.equal(p.el('customer-orders-retry').hidden,true);
p.el('customer-orders-back').onclick();
assert.equal(p.el('customer-orders-message').hidden,true);
assert.equal(p.el('customer-orders-list').hidden,false);
});
test('malformed successful responses are not reported as a network outage',async()=>{
const p=page();p.orders.setUser({id:1});
p.requests[0].resolve({ok:true,status:200,json:async()=>{throw new SyntaxError('Unexpected <');}});await flush();
assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse');
p.el('customer-orders-retry').onclick();
await p.reply(1,{orders:'invalid'});
assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse');
});
test('an error page from a proxy uses HTTP status even when the body is not JSON',async()=>{
const p=page();p.orders.setUser({id:1});
let parsed=false;
p.requests[0].resolve({ok:false,status:502,json:async()=>{parsed=true;throw new SyntaxError('html');}});
await flush();
assert.equal(parsed,false);
assert.equal(p.el('customer-orders-message').textContent,'ordersServerError');
});
test('switching filters ignores late results; logout erases counts and selection',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
filter(p,'completed').onclick();filter(p,'pending').onclick();
await p.reply(2,{orders:[],next_before:null,summary:counts});
await p.reply(1,{orders:[row(9)],next_before:null,summary:{...counts,total:999}});
assert.doesNotMatch(p.el('customer-orders-toggle').textContent,/999/);
assert.equal(p.el('customer-orders-list').textContent,'');
assert.equal(p.el('customer-orders-message').textContent,'ordersFilterEmpty');
p.orders.setUser(null);
assert.equal(p.el('customer-orders-summary').hidden,true);
assert.doesNotMatch(p.el('customer-orders-summary').textContent,/26|20/);
});
test('login automatically opens list; collapse/reopen and paging keep drafts outside panel untouched',async()=>{
const p=page();p.orders.setUser({id:1});
@@ -94,3 +191,51 @@ test('refresh cancels pagination; stale errors cannot replace the refreshed list
assert.doesNotMatch(p.el('customer-orders-list').textContent,/INBOUND1/);
assert.match(p.el('customer-orders-list').textContent,/INBOUND3/);
});
test('automatic refresh keeps the filter, old rows, scroll and collapsed state until success',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
filter(p,'processing').onclick();
await p.reply(1,{orders:[row(2)],next_before:2,summary:counts});
p.el('customer-orders-body').scrollTop=85;
p.el('customer-orders-toggle').onclick();
p.orders.refresh();p.orders.refresh();
assert.equal(p.requests.length,3);
assert.equal(p.requests[2].url,'/api/customer/orders?status=processing');
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
await p.reply(2,{orders:[row(3)],next_before:null,summary:{...counts,total:27,processing:21}});
assert.match(p.el('customer-orders-toggle').textContent,/27/);
assert.match(p.el('customer-orders-list').textContent,/INBOUND3/);
assert.equal(p.el('customer-orders-body').scrollTop,85);
assert.equal(p.el('customer-orders-body').hidden,true);
assert.equal(filter(p,'processing').attributes['aria-pressed'],'true');
});
test('failed automatic refresh retains previous data and retry can update it',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:1,summary:counts});
p.orders.refresh();p.requests[1].reject(new Error('offline'));await flush();
assert.match(p.el('customer-orders-list').textContent,/INBOUND1/);
assert.match(p.el('customer-orders-toggle').textContent,/26/);
assert.equal(p.el('customer-orders-retry').hidden,false);
p.el('customer-orders-retry').onclick();
await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}});
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
});
test('automatic refresh keeps the open tracking detail and logout invalidates late responses',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
p.el('customer-orders-list').children[0].onclick();
await p.reply(1,{card:{no:'ORDER1'}});
p.orders.refresh();
await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}});
assert.equal(p.el('customer-orders-detail').textContent,'ORDER1');
assert.equal(p.el('customer-orders-list').hidden,true);
p.el('customer-orders-back').onclick();
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
p.orders.refresh();p.orders.setUser(null);
await p.reply(3,{orders:[row(99)],summary:counts});
assert.equal(p.el('customer-orders-list').textContent,'');
assert.equal(p.el('customer-orders').hidden,true);
});
+44
View File
@@ -67,6 +67,9 @@ class CustomerOrderTests(unittest.TestCase):
second = self.client.get('/api/customer/orders', query_string={'before': first['next_before']}).json
self.assertEqual(len(second['orders']), 5)
self.assertIsNone(second['next_before'])
self.assertEqual(first['summary'], {'total': 25, 'pending': 0, 'processing': 25,
'completed': 0, 'cancelled': 0, 'other': 0})
self.assertEqual(second['summary'], first['summary'])
self.assertFalse({x['id'] for x in first['orders']} & {x['id'] for x in second['orders']})
for value in ['-1', '0', 'NaN', '9223372036854775808']:
self.assertEqual(self.client.get('/api/customer/orders', query_string={'before': value}).status_code, 400)
@@ -84,10 +87,12 @@ class CustomerOrderTests(unittest.TestCase):
def test_empty_scope_and_configured_tenant_restriction(self):
self.login('empty')
self.assertEqual(self.client.get('/api/customer/orders').json['orders'], [])
self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 0)
self.assertEqual(self.client.get('/api/customer/orders/1').status_code, 404)
self.app.config['ORDER_DATA_TENANT_ID'] = 'b'
self.login()
self.assertEqual(self.client.get('/api/customer/orders').json['orders'], [])
self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 0)
self.assertEqual(self.client.get('/api/customer/orders/1').status_code, 404)
def test_multiple_grants_and_revocation(self):
@@ -100,6 +105,7 @@ class CustomerOrderTests(unittest.TestCase):
con.close()
self.login()
self.assertEqual(self.client.get('/api/customer/orders').json['orders'][0]['id'], self.foreign_id)
self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 26)
with self.app.app_context():
con = db.connect()
con.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='b'")
@@ -113,3 +119,41 @@ class CustomerOrderTests(unittest.TestCase):
response = self.client.get('/api/customer/orders')
self.assertEqual(response.status_code, 503)
self.assertNotIn('sqlite', response.get_data(as_text=True))
def test_overview_counts_full_scope_and_filters_before_paging(self):
statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'COMPLETED',
'CANCELLED', 'REJECTED', 'UNKNOWN_FUTURE_STATUS']
with self.app.app_context():
con = db.connect()
for index, status in enumerate(statuses):
con.execute('UPDATE order_test_data SET status=? WHERE tenant_id=? AND order_no=?',
(status, 'a', f'A2026{index:04}'))
con.commit()
con.close()
self.login()
expected = {'total': 25, 'pending': 4, 'processing': 17, 'completed': 1, 'cancelled': 1, 'other': 2}
all_data = self.client.get('/api/customer/orders?tenant_id=b').json
self.assertEqual(all_data['summary'], expected)
for status, count in [('pending', 4), ('processing', 17), ('completed', 1), ('cancelled', 1), ('other', 2)]:
with self.subTest(status=status):
response = self.client.get('/api/customer/orders', query_string={'status': status, 'tenant_id': 'b'})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json['orders']), count)
self.assertEqual(response.json['summary'], expected)
self.assertIsNone(response.json['next_before'])
self.assertNotIn(self.foreign_id, [item['id'] for item in response.json['orders']])
self.assertEqual({x['status'] for x in self.client.get('/api/customer/orders?status=other').json['orders']},
{'REJECTED', 'UNKNOWN_FUTURE_STATUS'})
for status in ['bad', 'PROCESSING', "pending' OR 1=1", '']:
self.assertEqual(self.client.get('/api/customer/orders', query_string={'status': status}).status_code, 400)
def test_filtered_cursor_keeps_summary_and_does_not_include_other_statuses(self):
self.login()
first = self.client.get('/api/customer/orders?status=processing').json
second = self.client.get('/api/customer/orders', query_string={'status': 'processing', 'before': first['next_before']}).json
self.assertEqual(len(first['orders']), 20)
self.assertEqual(len(second['orders']), 5)
self.assertEqual(second['summary']['processing'], 25)
empty = self.client.get('/api/customer/orders?status=completed').json
self.assertEqual(empty['orders'], [])
self.assertEqual(empty['summary']['total'], 25)