完善美国首页和登录订单概览并修复语音提示抖动
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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精度,不把显示近似当作计费进位。
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
@@ -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)};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user