完善美国首页和登录订单概览并修复语音提示抖动

This commit is contained in:
2026-11-06 10:52:03 +08:00
parent 5f1c4043d4
commit d5692434ac
14 changed files with 611 additions and 58 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
| 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 |
| Customer login / overview | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout; `static/js/overview.js`: automatic scoped overview; `order_listing.py`: `read_overview` |
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| 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` |
+4
View File
@@ -153,6 +153,10 @@ tests/ # 按业务模块组织的回归
`order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。
登录成功或恢复有效登录时,`overview.js` 自动请求既有 `/api/customer/session?overview=1` 并展开可收起的订单概览。普通会话状态查询保持原响应契约,游客响应不含订单事实。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。错误状态保留重试入口,退出或换账号立即清空概览,迟到响应受请求版本和客户身份约束。概览查询不创建聊天消息,查看计数及入仓码复用现有查询入口。
语音反馈使用输入框上方的绝对定位浮层,不占输入区布局高度,4秒后收起。切换对话清除反馈,同一手势失败后须松开才能重新录音,Pointer、Touch 与键盘入口遵循同一规则。
共享尺寸解析将单位与长宽高绑定,支持每边单位和混合单位,不受“12英寸显示器”等商品描述影响。报价沿当前会话回溯到最近话题边界,不按10轮截断;其他国家、新一票及订单话题隔离不变。“铁铝部分还是10KG”等口语仅在先前已明确计费重时更新该基数,既不覆盖整票总重,也不把未确认毛重直接当计费重。
已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。
+15 -2
View File
@@ -3,6 +3,7 @@
import hashlib
import json
import secrets
import sqlite3
import time
from flask import g, has_app_context, jsonify, request
@@ -134,8 +135,20 @@ def bind_order_conversation(conv_id):
connection.close()
def session_status():
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
def session_status(include_overview=False):
result={'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
if include_overview and g.customer:
from .visitor_service import customer_tenant_ids
from .order_listing import read_overview
tenants=customer_tenant_ids()
if not tenants:
result['overview_error']='unlinked'
else:
try:
result['overview']=read_overview(tenants)
except sqlite3.Error:
result['overview_error']='unavailable'
return result
def order_login_response():
+48 -11
View File
@@ -66,25 +66,62 @@ def save_state(conv_id,state,message_id):
finally:connection.close()
def read_orders(tenants,query,page=1,size=5):
"""One scoped snapshot; each order occupies one actual-node bucket."""
def scoped_orders(connection,tenants,status=None):
"""Use one authorized projection and normalization for lists and overviews."""
from .order_data import tenant_filter
scope=tenant_filter(tenant_ids=tenants)
if scope is None or not valid_filters(query):raise ValueError('Explicit customer scope and filters required')
if scope is None:raise ValueError('Explicit customer scope required')
where=' WHERE 1=1'+scope[0]
values=scope[1]
if status:
where+=' AND status=?'
values=values+[status]
rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason '
'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values)
for row in rows:
item=dict(row)
item['exception_reason']=(item['exception_reason'] or '').strip()
item['stage']=current_stage(item.pop('tracking_json'))
yield item
def read_overview(tenants):
"""Summarize the customer's current orders in one database snapshot."""
connection=connect()
try:
connection.row_factory=sqlite3.Row
connection.execute('BEGIN')
total=processing=exceptions=0
stages=Counter()
abnormal_rows=[]
processing_rows=[]
for row in scoped_orders(connection,tenants):
total+=1
abnormal=bool(row['exception_reason'])
if abnormal:
exceptions+=1
if len(abnormal_rows)<5:abnormal_rows.append(row)
if row['status']=='PROCESSING':
processing+=1
stages[row['stage']]+=1
if not abnormal and len(processing_rows)<5:processing_rows.append(row)
return {'total':total,'processing':processing,'exceptions':exceptions,
'stages':[{'code':code,'count':stages[code]} for code in STAGE_LABELS if stages[code]],
'orders':(abnormal_rows+processing_rows)[:5]}
finally:
connection.close()
def read_orders(tenants,query,page=1,size=5):
"""One scoped snapshot; each order occupies one actual-node bucket."""
if not valid_filters(query):raise ValueError('Explicit filters required')
connection=connect()
try:
connection.row_factory=sqlite3.Row
where=' WHERE 1=1'+scope[0];values=scope[1]
if query['status']:where+=' AND status=?';values=values+[query['status']]
connection.execute('BEGIN')
rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason '
'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values)
total=with_inbound=0;groups=Counter();selected=[];tail=deque(maxlen=size)
for row in rows:
item=dict(row)
item['exception_reason']=(item['exception_reason'] or '').strip()
for item in scoped_orders(connection,tenants,query['status']):
if query['exception'] and not item['exception_reason']:continue
item['stage']=current_stage(item.pop('tracking_json'))
if query['stage'] and query['stage']!=item['stage']:continue
total+=1;with_inbound+=bool((item['inbound_code'] or '').strip());groups[item['stage']]+=1
tail.append(item)
+9 -8
View File
@@ -25,7 +25,8 @@ STAGE_LABELS = {code:labels[1] for code,labels in NODE_LABELS.items()}
STAGE_LABELS.update(NO_ACTUAL='暂无实际节点',MIXED='多柜进度不同')
STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT'))
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT'),
('processing','PROCESSING'))
NOUN = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
COUNT = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
@@ -42,16 +43,16 @@ def parse_query(message,listing=False):
if re.search(pattern,remaining):
nodes.append(code);remaining=re.sub(pattern,'',remaining)
for phrase,status in STATUSES:
if phrase in remaining:
statuses.append(status);remaining=remaining.replace(phrase,'')
exception=bool(re.search(r'异常',remaining))
remaining=re.sub(r'物流异常|异常','',remaining)
grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪',remaining))
remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|各自|各个|每个|分别|分布|各|都|了', '',remaining)
if re.search(re.escape(phrase),remaining,re.I):
statuses.append(status);remaining=re.sub(re.escape(phrase),'',remaining,flags=re.I)
exception=bool(re.search(r'异常|\b(?:exception|abnormal)\b',remaining,re.I))
remaining=re.sub(r'物流异常|异常|\b(?:exception|abnormal)\b','',remaining,flags=re.I)
grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪|\btracking (?:stages?|nodes?)\b',remaining,re.I))
remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|各自|各个|每个|分别|分布|各|都|了|\btracking (?:stages?|nodes?)\b', '',remaining,flags=re.I)
remaining=re.sub(COUNT+'|'+NOUN,'',remaining,flags=re.I)
remaining=re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|列出|列表|显示|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
r'\b(?:list|what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
r'\b(?:list|what|is|of|do|i|have|are|there|my|in|all|the|currently|at|each)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
return {'status':statuses[0] if statuses else None,'stage':nodes[0] if nodes else None,
'exception':exception,'grouped':grouped,
'unsupported':bool(unsupported or remaining or len(set(statuses))>1 or len(set(nodes))>1)}
+1 -1
View File
@@ -9,7 +9,7 @@ bp = Blueprint('customer', __name__)
@bp.get('/api/customer/session')
def session():
return customer_service.session_status()
return customer_service.session_status(include_overview=request.args.get('overview')=='1')
@bp.post('/api/customer/login')
+100 -1
View File
@@ -97,7 +97,27 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.recent-item > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.composer { position: relative; flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.voice-feedback {
position: absolute;
z-index: 36;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
padding: 9px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
text-align: center;
font-size: 13px;
line-height: 1.5;
pointer-events: none;
}
.voice-feedback.is-error { border-color: var(--danger); color: var(--danger); }
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
.voice-toggle, .voice-hold { display: none; }
@@ -285,3 +305,82 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.msg-action-btn { min-height: 40px; }
.to-bottom { right: 15px; }
}
.customer-overview {
flex-shrink: 0;
max-height: 36%;
margin: 12px 32px 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.customer-overview > summary {
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 1;
min-height: 44px;
padding: 10px 16px;
background: var(--surface);
cursor: pointer;
list-style: none;
}
.customer-overview > summary::-webkit-details-marker { display: none; }
.customer-overview > summary::after { content: '⌄'; color: var(--muted); }
.customer-overview[open] > summary::after { transform: rotate(180deg); }
#customer-overview-title { flex-shrink: 0; font-weight: 650; }
#customer-overview-summary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-size: 12px;
color: var(--secondary);
}
.customer-overview-body { padding: 0 16px 12px; }
.overview-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.overview-metric {
display: flex;
flex-direction: column;
min-width: 0;
padding: 8px;
border: 0;
border-radius: 8px;
background: var(--surface-soft);
color: var(--text);
text-align: left;
}
.overview-metric strong { font-size: 21px; line-height: 1.3; }
.overview-metric span { color: var(--secondary); font-size: 12px; }
.overview-metric:last-child strong { color: var(--danger); }
.overview-stages { display: flex; flex-wrap: wrap; gap: 6px 12px; margin: 10px 0; color: var(--secondary); font-size: 12px; }
.overview-orders { display: grid; gap: 6px; }
.overview-order {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 10px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
text-align: left;
}
.overview-reference { overflow-wrap: anywhere; font-size: 13px; }
.overview-stage { max-width: 180px; color: var(--secondary); font-size: 12px; text-align: right; }
.overview-reason { grid-column: 1 / -1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--danger); font-size: 12px; }
.overview-order.has-exception { border-left: 3px solid var(--danger); }
.overview-refresh { margin-top: 8px; padding: 4px 0; border: 0; background: transparent; color: var(--brand-text); font-size: 12px; }
.overview-empty { color: var(--secondary); font-size: 13px; }
@media (max-width: 720px) {
.customer-overview { max-height: 32%; margin: 8px 12px 0; }
.customer-overview > summary { padding: 9px 12px; gap: 8px; }
.customer-overview-body { padding: 0 12px 10px; }
.overview-stage { max-width: 120px; }
}
+98 -14
View File
@@ -6,6 +6,8 @@ const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status");
const voiceFeedback = document.getElementById('voice-feedback');
let voiceFeedbackTimer = null;
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
let draftTimer = null;
let draftSnapshot = null;
@@ -41,6 +43,7 @@ let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let customerLogin = null;
let customerOverview = null;
let orderLookup = null;
let lookupRequestVersion = 0;
let customerRecovery = null;
@@ -65,6 +68,21 @@ function setComposerStatus(message, isError){
composerStatus.classList.toggle("is-error", Boolean(isError));
}
function setVoiceStatus(message,isError){
if(voiceFeedbackTimer!==null) clearTimeout(voiceFeedbackTimer);
voiceFeedbackTimer=null;
if(voiceFeedback.textContent!==message) voiceFeedback.textContent=message || '';
voiceFeedback.hidden=!message;
voiceFeedback.classList.toggle('is-error',Boolean(isError));
if(message){
voiceFeedbackTimer=setTimeout(function(){
voiceFeedback.hidden=true;
voiceFeedback.textContent='';
voiceFeedbackTimer=null;
},4000);
}
}
async function requestJson(path, options){
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
@@ -290,11 +308,39 @@ const I18N = {
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeEyebrow: "中欧 · 英国物流服务",
welcomeEyebrow: "中美物流服务",
serviceTag: "物流咨询服务",
footerTrack: "查入仓码",
footerQuote: "专线报价",
footerException: "异常处理",
footerQuote: "美国报价",
footerException: "异常订单",
exceptionQuery: "我有多少个异常订单?",
overviewTitle: "订单概览",
overviewTotal: "全部订单",
overviewProcessing: "履约中",
overviewExceptions: "异常订单",
overviewLoading: "正在查询订单…",
overviewUnlinked: "当前账号尚未关联订单",
overviewFailed: "订单概览暂时无法加载",
overviewRefresh: "刷新概览",
overviewEmpty: "当前没有履约中或已标记异常的订单。",
overviewAllQuery: "列出我的全部订单",
overviewProcessingQuery: "列出履约中的订单",
overviewStageLabels: {
DOMESTIC_RECEIVED: "已入国内仓",
LOADING_COMPLETED: "已完成装柜",
DEPARTED_WAREHOUSE: "已离开国内仓库",
AT_ORIGIN_PORT: "已进入起运港",
LOADED_ON_VESSEL: "已装船",
DEPARTED_ORIGIN_PORT: "已开船",
IN_TRANSIT: "海运途中",
ARRIVED_DESTINATION_PORT: "已到目的港",
DISCHARGED_FROM_VESSEL: "已卸船、未清关",
PICKUP_READY: "已清关、可提柜",
PICKED_UP: "已提柜",
EMPTY_RETURNED: "已还柜",
NO_ACTUAL: "暂无实际节点",
MIXED: "多柜进度不同"
},
footerHuman: "联系人工",
inputShort: "输入入仓码…",
uploading: "附件正在上传,请稍候…",
@@ -339,10 +385,10 @@ const I18N = {
langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部",
quick: [
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
"按入仓码查轨迹", "美国海运专线报价", "查询异常订单", "转接人工客服"
],
hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
"如何查询物流轨迹?", "美国海运时效是多久?", "我有多少个异常订单?", "各个轨迹节点分别有多少订单?"
]
},
en: {
@@ -406,11 +452,39 @@ const I18N = {
retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.",
welcomeEyebrow: "EUROPE & UK LOGISTICS",
welcomeEyebrow: "CHINA–US LOGISTICS",
serviceTag: "Logistics support",
footerTrack: "Inbound lookup",
footerQuote: "Get a quote",
footerException: "Parcel issue",
footerQuote: "US quote",
footerException: "Exception orders",
exceptionQuery: "How many exception orders do I have?",
overviewTitle: "Order overview",
overviewTotal: "All orders",
overviewProcessing: "In progress",
overviewExceptions: "Exceptions",
overviewLoading: "Loading orders…",
overviewUnlinked: "No orders are linked to this account",
overviewFailed: "Order overview could not load",
overviewRefresh: "Refresh overview",
overviewEmpty: "No orders are in progress or flagged with an exception.",
overviewAllQuery: "List all my orders",
overviewProcessingQuery: "List my processing orders",
overviewStageLabels: {
DOMESTIC_RECEIVED: "Received at origin warehouse",
LOADING_COMPLETED: "Container loaded",
DEPARTED_WAREHOUSE: "Left origin warehouse",
AT_ORIGIN_PORT: "At origin port",
LOADED_ON_VESSEL: "Loaded on vessel",
DEPARTED_ORIGIN_PORT: "Vessel departed",
IN_TRANSIT: "In transit",
ARRIVED_DESTINATION_PORT: "At destination port",
DISCHARGED_FROM_VESSEL: "Discharged, awaiting clearance",
PICKUP_READY: "Cleared, ready for pickup",
PICKED_UP: "Container picked up",
EMPTY_RETURNED: "Empty container returned",
NO_ACTUAL: "No actual tracking events",
MIXED: "Containers at different stages"
},
footerHuman: "Human support",
inputShort: "Inbound code…",
uploading: "Uploading your attachment…",
@@ -457,11 +531,11 @@ const I18N = {
langTip: "Switch language (Ctrl+L)",
toBottom: "Back to bottom",
quick: [
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
"Track by inbound code", "US sea freight quote", "View exception orders", "Talk to a human agent"
],
hotList: [
"How do I track my shipment?", "How long does Europe sea freight take?",
"My parcel shows delivered but I didn't get it", "How do I file a claim?"
"How do I track my shipment?", "How long does US sea freight take?",
"How many exception orders do I have?", "How many orders are at each tracking stage?"
]
}
};
@@ -507,6 +581,7 @@ function applyLanguage(lang){
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
if(voiceInput) voiceInput.refresh();
if(customerOverview) customerOverview.render();
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
@@ -633,7 +708,8 @@ function welcomeHTML(){
const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`;
const query = index === 2 ? t('exceptionQuery') : question;
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(query)}"`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join("");
return `
@@ -873,6 +949,7 @@ function ensureConv(){
}
function resetConversation(id){
setVoiceStatus('');
flushDraft();
emojiPicker.close();
if(orderLookup) orderLookup.cancel();
@@ -2036,7 +2113,7 @@ voiceInput = ChatVoice.create({
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
text:t, status:setComposerStatus, language:() => LANG,
text:t, status:setVoiceStatus, language:() => LANG,
context:() => conversationVersion + ':' + currentConvId + ':' + window.humanSid,
canStart:function(){
if(uploadPending || sendPreparing || window.__busy || openingConv !== null || humanRequestToken || humanSendingToken || humanClosing) return t('voiceWait');
@@ -2052,9 +2129,15 @@ voiceInput = ChatVoice.create({
if(!await doSend()) voiceInput.setMode(false);
}
});
customerOverview = CustomerOverview.create({fetch:fetch,text:t,
onQuery:function(question){window.askNewTopic(question);},
onLookup:function(order,button){showOrderLookup(order.inbound_code || order.order_no,order.inbound_code ? 'inbound' : 'order',button);},
onExpired:function(){if(customerLogin) customerLogin.refresh();}
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); },
onChange:function(user,previous){
customerOverview.setUser(user);
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
const from = conversationVersion;
@@ -2084,7 +2167,8 @@ document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){
const index=Number(button.dataset.questionIndex);
if(index === 0) return showOrderLookup(undefined,undefined,button);
return window.askNewTopic(t('quick')[index]);
const question = index === 2 ? t('exceptionQuery') : t('quick')[index];
return window.askNewTopic(question);
});
});
chatDom.addEventListener('click', function(event){
+130
View File
@@ -0,0 +1,130 @@
// Display a fresh, customer-scoped order overview after login or session recovery.
var CustomerOverview = {
create(options){
const panel=document.getElementById('customer-overview');
const title=document.getElementById('customer-overview-title');
const summary=document.getElementById('customer-overview-summary');
const body=document.getElementById('customer-overview-body');
let user=null;
let overview=null;
let failure='';
let loading=false;
let requestVersion=0;
let controller=null;
panel.hidden=true;
function element(tag,className,text){
const node=document.createElement(tag);
if(className) node.className=className;
if(text !== undefined) node.textContent=text;
return node;
}
function queryButton(label,question,className){
const button=element('button',className,label);
button.type='button';
button.onclick=function(){
panel.open=false;
options.onQuery(question);
};
return button;
}
function render(){
title.textContent=options.text('overviewTitle');
body.innerHTML='';
panel.hidden=!user;
if(!user){summary.textContent='';return;}
if(!overview){
const key=loading ? 'overviewLoading' : failure==='unlinked' ? 'overviewUnlinked' : 'overviewFailed';
summary.textContent=options.text(key);
}else{
summary.textContent=options.text('overviewProcessing')+' '+overview.processing+' · '+options.text('overviewExceptions')+' '+overview.exceptions;
const metrics=element('div','overview-metrics');
for(const [key,count,question] of [
['overviewTotal',overview.total,options.text('overviewAllQuery')],
['overviewProcessing',overview.processing,options.text('overviewProcessingQuery')],
['overviewExceptions',overview.exceptions,options.text('exceptionQuery')]
]){
const button=queryButton('',question,'overview-metric');
button.append(element('strong','',String(count)),element('span','',options.text(key)));
metrics.appendChild(button);
}
body.appendChild(metrics);
const stages=element('div','overview-stages');
const labels=options.text('overviewStageLabels');
for(const stage of overview.stages){
stages.appendChild(element('span','',labels[stage.code]+' '+stage.count));
}
body.appendChild(stages);
const orders=element('div','overview-orders');
for(const order of overview.orders){
const button=element('button','overview-order');
button.type='button';
const reference=order.inbound_code || order.order_no;
button.appendChild(element('strong','overview-reference',reference));
button.appendChild(element('span','overview-stage',labels[order.stage]));
if(order.exception_reason){
button.classList.add('has-exception');
button.appendChild(element('span','overview-reason',order.exception_reason));
}
button.onclick=function(){options.onLookup(order,button);};
orders.appendChild(button);
}
if(!overview.orders.length) orders.appendChild(element('p','overview-empty',options.text('overviewEmpty')));
body.appendChild(orders);
}
const refresh=element('button','overview-refresh',options.text(loading ? 'overviewLoading' : 'overviewRefresh'));
refresh.type='button';
refresh.disabled=loading;
refresh.onclick=refreshOverview;
body.appendChild(refresh);
}
async function refreshOverview(){
if(!user || loading) return;
const token=++requestVersion;
const customerId=user.id;
controller=new AbortController();
const activeController=controller;
const timer=setTimeout(()=>activeController.abort(),10000);
loading=true;
failure='';
render();
try{
const response=await options.fetch('/api/customer/session?overview=1',{signal:activeController.signal});
const result=await response.json();
if(token!==requestVersion || !user || user.id!==customerId) return;
if(!response.ok || !result.ok) throw new Error('Overview unavailable');
if(!result.authenticated || !result.user || result.user.id!==customerId){
setUser(null);
if(options.onExpired) options.onExpired();
return;
}
overview=result.overview || null;
failure=result.overview_error || (overview ? '' : 'unavailable');
}catch(error){
if(token===requestVersion){overview=null;failure='unavailable';}
}finally{
clearTimeout(timer);
if(token===requestVersion){controller=null;loading=false;render();}
}
}
function setUser(next){
if(user && next && user.id===next.id) return;
requestVersion+=1;
if(controller) controller.abort();
controller=null;
user=next;
overview=null;
failure='';
loading=false;
panel.open=Boolean(user);
render();
if(user) refreshOverview();
}
return {setUser:setUser,render:render};
}
};
+23 -9
View File
@@ -4,6 +4,7 @@ var ChatVoice = {
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
let session = null, mode = false;
let pressedId = null;
const active = current => session === current;
function stopTracks(current){
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
@@ -39,6 +40,7 @@ var ChatVoice = {
}
function setMode(on){
cancel();
pressedId=null;
mode = on;
render();
if(on && input.blur) input.blur();
@@ -172,24 +174,30 @@ var ChatVoice = {
};
if(window.PointerEvent){
hold.addEventListener('pointerdown', function(event){
if(event.button !== 0) return;
if(event.button !== 0 || pressedId!==null) return;
event.preventDefault();
pressedId=event.pointerId;
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
start(event.pointerId, event.clientY);
});
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
hold.addEventListener('pointerup', function(event){
event.preventDefault();
if(pressedId===event.pointerId) pressedId=null;
if(session && session.id === event.pointerId) finish(session, false);
});
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
pressedId=null;
if(session && session.holding) finish(session, true);
}));
}else{
hold.addEventListener('touchstart', function(event){
event.preventDefault();
if(event.touches.length !== 1) return;
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
if(pressedId!==null) return;
const touch=event.touches[0];
pressedId=touch.identifier;
start(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchmove', function(event){
event.preventDefault();
@@ -197,23 +205,29 @@ var ChatVoice = {
}, {passive:false});
hold.addEventListener('touchend', function(event){
event.preventDefault();
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
for(const touch of event.changedTouches){
if(pressedId===touch.identifier) pressedId=null;
if(session && session.id === touch.identifier) finish(session, false);
}
}, {passive:false});
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
hold.addEventListener('touchcancel', function(){ pressedId=null;if(session) finish(session, true); });
}
hold.addEventListener('contextmenu', event => event.preventDefault());
hold.addEventListener('keydown', function(event){
if(![' ','Enter'].includes(event.key) || event.repeat) return;
if(![' ','Enter'].includes(event.key) || event.repeat || pressedId!==null) return;
pressedId='keyboard';
event.preventDefault(); start('keyboard', 0);
});
hold.addEventListener('keyup', function(event){
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
event.preventDefault(); finish(session, false);
if([' ','Enter'].includes(event.key)){
if(pressedId==='keyboard') pressedId=null;
event.preventDefault();
if(session && session.id === 'keyboard') finish(session, false);
}
});
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
window.addEventListener('pagehide', function(){ cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden){pressedId=null;cancel();} });
window.addEventListener('pagehide', function(){ pressedId=null;cancel(); });
render();
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
}
+9 -3
View File
@@ -38,11 +38,15 @@
<button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</button>
</div>
</header>
<details id="customer-overview" class="customer-overview" hidden>
<summary><span id="customer-overview-title">订单概览</span><span id="customer-overview-summary"></span></summary>
<div id="customer-overview-body" class="customer-overview-body"></div>
</details>
<div id="chatDom" role="log" aria-live="polite" aria-label="聊天内容">
<section class="ai-welcome-card">
<div class="ai-welcome-top">
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
<div class="welcome-eyebrow">中美物流服务</div>
<h3>物流问题,随时为您解答</h3>
</div>
</section>
@@ -51,8 +55,8 @@
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
<div class="composer-toolbar"><div class="hot-bar">
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">美国报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常订单</button>
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></div>
<div class="input-area">
@@ -64,6 +68,7 @@
<button type="button" id="send-button">发送</button>
</div>
<p id="composer-status" class="composer-status" role="status" hidden></p>
<div id="voice-feedback" class="voice-feedback" role="status" aria-live="polite" hidden></div>
</footer>
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
@@ -99,6 +104,7 @@
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
<script src="{{ url_for('static', filename='js/overview.js') }}"></script>
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
+100 -8
View File
@@ -76,6 +76,7 @@ async function customerPage(pageOptions = {}){
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
let customerState=pageOptions.customer || null;
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
@@ -93,7 +94,14 @@ async function customerPage(pageOptions = {}){
clearTimeout:id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(hooks[url]){
const result=await hooks[url](options);
if(url==='/api/customer/login' || url==='/api/customer/logout'){
const data=await result.json();
if(result.ok && data.ok) customerState=url==='/api/customer/login' ? data.user : null;
}
return result;
}
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
@@ -107,7 +115,8 @@ 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' ? {ok:true,authenticated:Boolean(pageOptions.customer),user:pageOptions.customer || null}
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(customerState),user:customerState}
: url === '/api/customer/session?overview=1' ? {ok:true,authenticated:Boolean(customerState),user:customerState,overview:pageOptions.overview || {total:0,processing:0,exceptions:0,stages:[],orders:[]}}
: 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}
@@ -115,7 +124,7 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'customer.js', 'overview.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
@@ -193,7 +202,90 @@ test('switching to a quick topic discards the previous topic attachment', async
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
app.context.window.__pendingTag='[文件]';
await app.click(app.quickButtons[1]);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '美国海运专线报价');
});
test('the homepage and exception shortcut match the US service in both languages', async () => {
for(const language of ['zh','en']){
const app=await customerPage({human:false,storage:{'wuliu-lang':language}});
await app.click(app.element('.new-chat-btn'));
const html=app.element('chatDom').innerHTML;
assert.doesNotMatch(html,/欧洲|中欧|英国物流|EUROPE|Europe|理赔|file a claim/);
assert.match(html,language==='zh'?/中美物流服务/:/CHINA–US LOGISTICS/);
assert.match(html,language==='zh'?/美国海运专线报价/:/US sea freight quote/);
assert.match(html,language==='zh'?/查询异常订单/:/View exception orders/);
const question=language==='zh'?'我有多少个异常订单?':'How many exception orders do I have?';
assert.ok(html.includes('data-question="'+question+'"'));
await app.click(app.quickButtons[2]);
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,question);
}
});
test('authenticated entry loads one overview with actionable counts and safe exception text', async () => {
const overview={total:9,processing:3,exceptions:2,stages:[{code:'IN_TRANSIT',count:3}],orders:[
{order_no:'ORDER20260001',inbound_code:'INB20260001',stage:'IN_TRANSIT',exception_reason:'<img src=x onerror=bad()>'}
]};
const app=await customerPage({human:false,customer:{id:1,name:'admin'},overview});
const panel=app.element('customer-overview');
assert.equal(panel.hidden,false);assert.equal(panel.open,true);
assert.match(app.element('customer-overview-summary').textContent,/履约中 3 · 异常订单 2/);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/session?overview=1').length,1);
const body=app.element('customer-overview-body');
assert.equal(body.querySelector('.overview-reason').innerHTML,'');
assert.equal(body.querySelector('.overview-reason').textContent,'<img src=x onerror=bad()>');
await app.click(body.querySelector('.overview-order'));
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('order-reference').value,'INB20260001');
await app.click(app.element('order-lookup-cancel'));
await app.click(body.querySelectorAll('.overview-metric')[1]);
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,'列出履约中的订单');
assert.equal(panel.open,false);
});
test('guest entry skips private overview and a late refresh cannot restore it after logout', async () => {
const guest=await customerPage({human:false});
assert.equal(guest.element('customer-overview').hidden,true);
assert.equal(guest.calls.filter(call=>call.url==='/api/customer/session?overview=1').length,0);
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
let resolve;
app.hooks['/api/customer/session?overview=1']=()=>new Promise(done=>{resolve=done;});
app.element('customer-overview-body').querySelector('.overview-refresh').click();
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
await app.click(app.element('customer-account'));
resolve(response({ok:true,authenticated:true,user:{id:1,name:'admin'},overview:{total:99,processing:99,exceptions:99,stages:[],orders:[]}}));
await flush();
assert.equal(app.element('customer-overview').hidden,true);
assert.equal(app.element('customer-overview-body').textContent,'');
assert.doesNotMatch(app.element('customer-overview-summary').textContent,/99/);
});
test('overview failure can retry and account data stays out of its error state', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
app.hooks['/api/customer/session?overview=1']=()=>response({ok:false},503);
await app.element('customer-overview-body').querySelector('.overview-refresh').click();
assert.match(app.element('customer-overview-summary').textContent,/暂时无法加载/);
assert.equal(app.element('customer-overview-body').querySelector('.overview-metric'),null);
delete app.hooks['/api/customer/session?overview=1'];
await app.element('customer-overview-body').querySelector('.overview-refresh').click();
assert.match(app.element('customer-overview-summary').textContent,/履约中 0/);
});
test('voice errors use a floating notice without changing the composer status', async () => {
const app=await customerPage({human:false,width:390});
const status=app.element('composer-status');
app.voiceOptions.status('语音输入失败,请重试。',true);
const feedback=app.element('voice-feedback');
assert.equal(feedback.hidden,false);
assert.equal(feedback.textContent,'语音输入失败,请重试。');
assert.ok(feedback.classList.contains('is-error'));
assert.equal(status.textContent,'');
app.voiceOptions.status('语音输入失败,请重试。',true);
assert.equal(feedback.hidden,false);
await app.runTimers(4000);
assert.equal(feedback.hidden,true);
app.voiceOptions.status('语音输入失败,请重试。',true);
await app.click(app.element('.new-chat-btn'));
assert.equal(feedback.hidden,true);
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
@@ -390,7 +482,7 @@ test('tracking does not interrupt human support while quote and exception shortc
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 2);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2);
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '包裹异常处理']);
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '我有多少个异常订单?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
@@ -398,14 +490,14 @@ test('tracking does not interrupt human support while quote and exception shortc
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
for(const question of ['美国海运专线报价', '各个轨迹节点分别有多少订单?']){
const button = app.element('question-'+question);
button.dataset.question = question;
app.element('chatDom').listeners.click({target:button});
await flush();
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '各个轨迹节点分别有多少订单?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
});
@@ -464,7 +556,7 @@ test('a late conversation creation cannot replace the newest quick question', as
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 1);
assert.equal(requests[0].body.conv_id, 22);
assert.equal(requests[0].body.msg, '包裹异常处理');
assert.equal(requests[0].body.msg, '我有多少个异常订单?');
assert.equal(app.context.window.__busy, false);
});
+20
View File
@@ -97,6 +97,26 @@ test('short presses and unavailable service do not upload recordings',async()=>{
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
});
test('a failed physical hold cannot restart until it has been released',async()=>{
for(const touch of [false,true]){
const app=voicePage({enabled:false,touch});
app.open();
const point={identifier:7,clientY:300};
const down=async()=>{
if(touch) app.elements.hold.listeners.touchstart({touches:[point],preventDefault(){}});
else app.elements.hold.listeners.pointerdown(app.event());
await flush();
};
await down();await down();
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,1);
if(touch) app.elements.hold.listeners.touchend({changedTouches:[point],preventDefault(){}});
else app.elements.hold.listeners.pointerup(app.event());
await down();
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,2);
assert.equal(app.permissionCalls,0);
}
});
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
app.open();await app.down();await app.up();grant(app.stream);await flush();
+53
View File
@@ -57,6 +57,51 @@ class OrderStatisticsTests(unittest.TestCase):
self.assertTrue(self.ask(q)['login_required'])
read.assert_not_called()
def test_session_overview_is_opt_in_scoped_prioritized_and_not_cached(self):
with patch('logistics_chat.order_listing.read_overview') as read:
guest=self.client.get('/api/customer/session?overview=1&tenant_id=b',headers={'X-Tenant-Id':'b'})
self.assertFalse(guest.json['authenticated'])
self.assertNotIn('overview',guest.json)
read.assert_not_called()
self.login()
self.assertNotIn('overview',self.client.get('/api/customer/session').json)
response=self.client.get('/api/customer/session?overview=1&tenant_id=b',headers={'X-Tenant-Id':'b'})
overview=response.json['overview']
self.assertIn('no-store',response.headers['Cache-Control'])
self.assertEqual((overview['total'],overview['processing'],overview['exceptions']),(16,12,7))
self.assertEqual(sum(stage['count'] for stage in overview['stages']),12)
self.assertEqual(len(overview['orders']),5)
self.assertTrue(all(order['exception_reason'] for order in overview['orders']))
self.assertNotIn('SECRET',response.get_data(as_text=True))
self.assertNotIn('tenant_id',response.get_data(as_text=True))
self.client.post('/api/customer/logout')
self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json)
def test_overview_keeps_old_exception_ahead_of_new_processing_orders_and_refreshes(self):
self.login()
with self.app.app_context():
c=db.connect()
c.execute("UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id='a' AND order_no<>'STAT20260000'")
c.commit();c.close()
overview=self.client.get('/api/customer/session?overview=1').json['overview']
self.assertEqual(overview['orders'][0]['order_no'],'STAT20260000')
self.assertEqual(len({order['order_no'] for order in overview['orders']}),5)
with self.app.app_context():
c=db.connect()
order_data.import_records(c,[{'tenant_id':'a','order_no':'NEW20260001','status':'PROCESSING','exception_reason':'资料待核实'}])
c.commit();c.close()
refreshed=self.client.get('/api/customer/session?overview=1').json['overview']
self.assertEqual((refreshed['total'],refreshed['processing'],refreshed['exceptions']),(17,13,2))
def test_overview_read_failure_keeps_login_available_and_no_facts_leak(self):
self.login()
with patch('logistics_chat.order_listing.read_overview',side_effect=sqlite3.OperationalError('private details')):
result=self.client.get('/api/customer/session?overview=1').json
self.assertTrue(result['authenticated'])
self.assertEqual(result['overview_error'],'unavailable')
self.assertNotIn('overview',result)
self.assertNotIn('private details',str(result))
def test_summaries_show_at_most_five_recent_rows_and_keep_intersecting_filters(self):
self.login()
total=self.ask('我有多少个订单')['t']
@@ -79,6 +124,14 @@ class OrderStatisticsTests(unittest.TestCase):
self.assertIn(token,text)
self.assertNotIn('已还柜:',text);self.assertNotIn('已签收:',text)
def test_english_homepage_questions_apply_their_intended_filters(self):
self.assertTrue(self.ask('How many exception orders do I have?')['login_required'])
self.login()
self.assertIn('7 票明确标记异常',self.ask('How many exception orders do I have?')['t'])
text=self.ask('How many orders are at each tracking stage?')['t']
self.assertIn('船舶已到目的港:10票',text)
self.assertIn('暂无实际节点:3票',text)
def test_expand_and_pages_keep_filters_and_refresh_counts(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)