From d5692434ace130f8362f9c49cc8951cbd1cb2782 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 6 Nov 2026 10:52:03 +0800
Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BE=8E=E5=9B=BD=E9=A6=96?=
=?UTF-8?q?=E9=A1=B5=E5=92=8C=E7=99=BB=E5=BD=95=E8=AE=A2=E5=8D=95=E6=A6=82?=
=?UTF-8?q?=E8=A7=88=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=AF=AD=E9=9F=B3=E6=8F=90?=
=?UTF-8?q?=E7=A4=BA=E6=8A=96=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
docs/architecture.md | 4 +
logistics_chat/customer_service.py | 17 +++-
logistics_chat/order_listing.py | 59 ++++++++++---
logistics_chat/order_query.py | 17 ++--
logistics_chat/routes/customer.py | 2 +-
static/css/chat.css | 101 +++++++++++++++++++++-
static/js/chat.js | 112 +++++++++++++++++++++----
static/js/overview.js | 130 +++++++++++++++++++++++++++++
static/js/voice.js | 32 +++++--
templates/index.html | 12 ++-
tests/frontend/chat.test.cjs | 108 ++++++++++++++++++++++--
tests/frontend/voice.test.cjs | 20 +++++
tests/test_order_statistics.py | 53 ++++++++++++
14 files changed, 611 insertions(+), 58 deletions(-)
create mode 100644 static/js/overview.js
diff --git a/AGENTS.md b/AGENTS.md
index 5c42e6b..8711274 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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` |
diff --git a/docs/architecture.md b/docs/architecture.md
index 6297b62..935f7d5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.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精度,不把显示近似当作计费进位。
diff --git a/logistics_chat/customer_service.py b/logistics_chat/customer_service.py
index 331a203..56f64bf 100644
--- a/logistics_chat/customer_service.py
+++ b/logistics_chat/customer_service.py
@@ -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():
diff --git a/logistics_chat/order_listing.py b/logistics_chat/order_listing.py
index 6db0f4a..bba5556 100644
--- a/logistics_chat/order_listing.py
+++ b/logistics_chat/order_listing.py
@@ -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)
diff --git a/logistics_chat/order_query.py b/logistics_chat/order_query.py
index c3b1942..a75fadf 100644
--- a/logistics_chat/order_query.py
+++ b/logistics_chat/order_query.py
@@ -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)}
diff --git a/logistics_chat/routes/customer.py b/logistics_chat/routes/customer.py
index 1bd8546..ddd90db 100644
--- a/logistics_chat/routes/customer.py
+++ b/logistics_chat/routes/customer.py
@@ -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')
diff --git a/static/css/chat.css b/static/css/chat.css
index 966297f..6ed07c7 100644
--- a/static/css/chat.css
+++ b/static/css/chat.css
@@ -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; }
+}
diff --git a/static/js/chat.js b/static/js/chat.js
index 6353d91..e6c985f 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -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 ``;
}).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){
diff --git a/static/js/overview.js b/static/js/overview.js
new file mode 100644
index 0000000..b3f41c8
--- /dev/null
+++ b/static/js/overview.js
@@ -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};
+ }
+};
diff --git a/static/js/voice.js b/static/js/voice.js
index 7f195a3..2d29fba 100644
--- a/static/js/voice.js
+++ b/static/js/voice.js
@@ -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)};
}
diff --git a/templates/index.html b/templates/index.html
index e3415e6..8bcd534 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -38,11 +38,15 @@
+ 订单概览
+
+