diff --git a/AGENTS.md b/AGENTS.md index a8972d5..0067ff3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` | | Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support | | Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout | +| 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` | | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` | diff --git a/README.md b/README.md index 67dfeeb..9598550 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,8 @@ data: {"d": 1} 登录后可以直接问“我有多少个运单”“我的订单数量”“已完成的订单有多少”。统计每次按当前账号的授权范围查表,返回订单票数和其中已登记入仓码的票数;不要求先提供入仓码,也不沿用上一票订单作为统计范围。支持订单总量和明确的业务状态;其他筛选条件不冒充总量返回,头程订单完成不等于签收。统计会话与订单卡片一样绑定客户和范围,退出后隐藏,重新登录同一身份及范围后恢复。 +点击“查入仓码”会打开查询框,填写号码后查询;游客先登录。回复及历史卡片中的入仓码也可点击,自动带入号码,可复制或查询最新轨迹。查询结果接在当前聊天中,草稿和待发送附件保留,人工接待中需要先返回 AI 问答。 + 首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。 diff --git a/docs/architecture.md b/docs/architecture.md index 2b88cba..e6a29df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,8 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整 | 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 | | 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 | | 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 | -| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL | +| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`count_orders`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`、`count_orders`,不让模型拼 SQL | +| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 | | AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` | | 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 | | 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` | @@ -115,6 +116,7 @@ tests/ # 按业务模块组织的回归 - 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。 - 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。 - 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。 +- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。 - 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。 - 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 - 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。 diff --git a/static/css/chat.css b/static/css/chat.css index 3cd5484..966297f 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -50,10 +50,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; } .customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; } .customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); } -#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; } +#customer-error, #order-lookup-error { margin-top: 12px; color: var(--danger); font-size: 14px; } .customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; } .customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); } -#customer-submit { border-color: var(--brand); background: var(--brand); color: white; } +#customer-submit, #order-lookup-submit { border-color: var(--brand); background: var(--brand); color: white; } +.order-reference-field { display: flex; align-items: center; gap: 8px; } +.order-reference-field input { min-width: 0; flex: 1; } +#order-reference-copy { flex-shrink: 0; min-width: 60px; min-height: 44px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--brand-text); } .left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; } .main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; } .chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; } @@ -150,6 +153,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; } .track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; } .track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; } +.order-reference { display: block; max-width: 100%; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--brand-text); font: inherit; text-align: left; overflow-wrap: anywhere; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } +.order-reference:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 3px; } .track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; } .track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; } .track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); } diff --git a/static/js/chat.js b/static/js/chat.js index c692103..3f42b3d 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -41,6 +41,8 @@ let historyRequestVersion = 0; let followLatest = true; let voiceInput = null; let customerLogin = null; +let orderLookup = null; +let lookupRequestVersion = 0; let customerRecovery = null; let creatingConv = null; let sendPreparing = false; @@ -234,6 +236,11 @@ const I18N = { customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码", customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。", customerLogoutFailed: "退出失败,请重试。", + lookupTitle: "查入仓码", lookupOrderTitle: "查询订单", lookupPlaceholder: "请输入入仓码", + lookupOrderPlaceholder: "请输入订单号", lookupSubmit: "查询轨迹", lookupSending: "查询中…", + lookupInvalid: "请输入完整的入仓码或订单号。", lookupFailed: "查询未发送成功,请重试。", + lookupBusy: "请等待当前操作完成后再查询。", lookupAIOnly: "请先返回 AI 问答,再查询订单轨迹。", + lookupAction: "查询或复制", newChat: "新建对话", clearAll: "清空全部历史", history: "历史对话", @@ -345,6 +352,11 @@ const I18N = { customerUsernamePlaceholder: "Enter username", customerPasswordPlaceholder: "Enter password", customerSigningIn: "Signing in…", customerLoginFailed: "Could not sign in. Please try again.", customerLogoutFailed: "Could not sign out. Please try again.", + lookupTitle: "Track by inbound code", lookupOrderTitle: "Look up an order", lookupPlaceholder: "Enter an inbound code", + lookupOrderPlaceholder: "Enter an order number", lookupSubmit: "Track shipment", lookupSending: "Sending…", + lookupInvalid: "Enter a complete inbound code or order number.", lookupFailed: "Could not send the lookup. Please retry.", + lookupBusy: "Wait for the current action to finish before looking up an order.", lookupAIOnly: "Return to AI chat before looking up an order.", + lookupAction: "Look up or copy", newChat: "New Chat", clearAll: "Clear All History", history: "History", @@ -462,6 +474,10 @@ function applyLanguage(lang){ LANG = lang === "en" ? "en" : "zh"; const dict = I18N[LANG]; if(customerLogin) customerLogin.render(); + if(orderLookup) orderLookup.render(); + document.querySelectorAll('.order-reference').forEach(function(button){ + button.setAttribute('aria-label',t('lookupAction')+' '+t(button.dataset.referenceLabel)+' '+button.textContent); + }); document.documentElement.lang = LANG === "en" ? "en" : "zh-CN"; document.querySelectorAll("[data-i18n]").forEach(function(element){ const value = dict[element.dataset.i18n]; @@ -617,7 +633,7 @@ 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' : `data-question="${escHtml(question)}"`; + const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`; return ``; }).join(""); return ` @@ -859,6 +875,7 @@ function ensureConv(){ function resetConversation(id){ flushDraft(); emojiPicker.close(); + if(orderLookup) orderLookup.cancel(); if(voiceInput) voiceInput.cancel(); if(cancelActive){ cancelActive(); cancelActive = null; } if(sseInstance){ sseInstance.close(); sseInstance = null; } @@ -941,7 +958,16 @@ function fillTrackCard(el, card){ no.className = "track-no"; const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value); const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo'; - no.innerHTML = '' + t(numberLabel) + "" + escHtml(inbound ? inbound.value : card.no); + no.innerHTML = '' + t(numberLabel) + ""; + if(isOrder){ + const reference=document.createElement('button'); + const value=String(inbound ? inbound.value : card.no), version=conversationVersion; + reference.type='button';reference.className='order-reference';reference.textContent=value; + reference.dataset.referenceLabel=numberLabel; + reference.setAttribute('aria-label',t('lookupAction')+' '+t(numberLabel)+' '+value); + reference.onclick=function(){if(version === conversationVersion) return showOrderLookup(value,inbound ? 'inbound' : 'order',reference);}; + no.appendChild(reference); + }else no.innerHTML += escHtml(card.no); const st = document.createElement("div"); st.className = "track-status"; st.textContent = card.status; @@ -1121,12 +1147,33 @@ window.askNewTopic = function(words){ return doSend(); } -window.sendOrder = function(){ - const no = prompt("请输入运单号:"); - if(no && no.trim()){ - inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹"; - doSend(); - } +window.sendOrder = function(){ return showOrderLookup(); }; + +function lookupBlocked(){ + if(window.humanSid || humanRequestToken || humanClosing || humanSendingToken) return t('lookupAIOnly'); + if(openingConv !== null) return t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'); + if(uploadPending || sendPreparing || window.__busy) return t('lookupBusy'); + return ''; +} + +function sameCustomerContext(version,user){ + return version === conversationVersion || (customerRecovery && + customerRecovery.from === version && customerRecovery.to === conversationVersion && + (!user || user.id === customerRecovery.customerId)); +} + +async function showOrderLookup(reference,kind,source){ + const blocked=lookupBlocked(); + if(blocked){setComposerStatus(blocked,true);return;} + const version=conversationVersion; + const requestVersion=++lookupRequestVersion; + function isCurrent(user){return requestVersion === lookupRequestVersion && sameCustomerContext(version,user);} + return customerLogin.require(function(user){ + if(!isCurrent(user)) return; + const blocked=lookupBlocked(); + if(blocked){setComposerStatus(blocked,true);return;} + orderLookup.open(reference,kind,source); + },isCurrent); } function updateHumanMode(){ @@ -1438,9 +1485,7 @@ function attachCustomerLogin(bubble, question){ const version = conversationVersion; const convId = currentConvId; function sameContext(user){ - return version === conversationVersion || (customerRecovery && - customerRecovery.from === version && customerRecovery.to === conversationVersion && - (!user || user.id === customerRecovery.customerId)); + return sameCustomerContext(version,user); } button.onclick = function(){ if(version !== conversationVersion) return; @@ -1498,27 +1543,7 @@ function attachActions(bubble, rawText, question, showRetry){ copyBtn.textContent = t(copied ? 'copied' : 'copyFailed'); setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000); }; - if(navigator.clipboard && navigator.clipboard.writeText){ - return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback); - }else{ - return fallback(); - } - function fallback(){ - const ta = document.createElement("textarea"); - ta.value = plain; - ta.readOnly = true; - ta.style.position = "fixed"; - ta.style.opacity = "0"; - const previousFocus = document.activeElement; - document.body.appendChild(ta); - let copied = false; - try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){} - finally{ - ta.remove(); - if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true}); - } - done(copied); - } + return SupportUI.copyText(plain).then(done); }; bar.appendChild(copyBtn); @@ -1604,7 +1629,8 @@ function renderFollowups(bubble){ bubble.appendChild(wrap); } -async function doSend(retryText){ +async function doSend(retryText, showQuestion){ + if(showQuestion && lookupBlocked()) return false; if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; } const isRetry = typeof retryText === "string"; const sendWord = (isRetry ? retryText : inputDom.value).trim(); @@ -1640,9 +1666,9 @@ async function doSend(retryText){ }finally{ if(version === conversationVersion) sendPreparing = false; } - if(version !== conversationVersion || humanRequestToken) return; + if(version !== conversationVersion || humanRequestToken || (showQuestion && window.humanSid)) return; + if(!isRetry || showQuestion) addMsg(fullContent, true); if(!isRetry){ - addMsg(fullContent, true); if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); } if(attachmentVersion === uploadVersion) clearAttachment(); focusComposer(); @@ -1939,6 +1965,7 @@ function syncChatViewport(){ chatContainer.style.top = ''; } if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top); + if(orderLookup) orderLookup.viewport(chatContainer.style.height, chatContainer.style.top); chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom); autoGrow(); if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd(); @@ -2016,7 +2043,7 @@ voiceInput = ChatVoice.create({ } }); customerLogin = CustomerLogin.create({fetch:fetch, text:t, - onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); }, + onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); }, onChange:function(user,previous){ if(previous === undefined) return; if(previous && (!user || user.id !== previous.id)){ @@ -2034,16 +2061,25 @@ customerLogin = CustomerLogin.create({fetch:fetch, text:t, }); customerLogin.viewport(chatContainer.style.height,chatContainer.style.top); customerLogin.refresh(); +orderLookup = OrderLookup.create({text:t,canQuery:lookupBlocked, + onOpen:function(){emojiPicker.close();if(voiceInput) voiceInput.cancel();if(isNarrow()){sideOpen=false;renderSidebar();}syncChatViewport();}, + query:function(reference,kind){return doSend((kind === 'order' ? '查询订单 ' : '查询入仓码 ')+reference,true);} +}); +orderLookup.viewport(chatContainer.style.height,chatContainer.style.top); window.addEventListener('resize', function(){ if(!isNarrow()) voiceInput.setMode(false); }); document.querySelectorAll('[data-question-index]').forEach(function(button){ button.addEventListener('click', function(){ - window.askNewTopic(t('quick')[Number(button.dataset.questionIndex)]); + const index=Number(button.dataset.questionIndex); + if(index === 0) return showOrderLookup(undefined,undefined,button); + return window.askNewTopic(t('quick')[index]); }); }); chatDom.addEventListener('click', function(event){ + const lookup=event.target.closest('button[data-order-lookup]'); + if(lookup){showOrderLookup(undefined,undefined,lookup);return;} const question = event.target.closest('button[data-question]'); if(question) window.askNewTopic(question.dataset.question); const human = event.target.closest('button[data-human-support]'); diff --git a/static/js/order_lookup.js b/static/js/order_lookup.js new file mode 100644 index 0000000..40ff1fa --- /dev/null +++ b/static/js/order_lookup.js @@ -0,0 +1,91 @@ +// One lookup form for shortcuts and saved order cards; no draft or chat ownership changes. +var OrderLookup = { + create(options){ + const overlay=document.getElementById('order-lookup'); + const form=document.getElementById('order-lookup-form'); + const input=document.getElementById('order-reference'); + const label=document.getElementById('order-reference-label'); + const title=document.getElementById('order-lookup-title'); + const submit=document.getElementById('order-lookup-submit'); + const cancel=document.getElementById('order-lookup-cancel'); + const copy=document.getElementById('order-reference-copy'); + const error=document.getElementById('order-lookup-error'); + let kind='inbound', opener=null, busy=false, copying=false, copyState='', version=0, timer=null; + overlay.hidden=true; + function valid(value){ + return /^[A-Za-z0-9][A-Za-z0-9_-]{5,63}$/.test(value) && (value.match(/[0-9]/g) || []).length >= 3; + } + function status(message){ error.textContent=message || ''; error.hidden=!message; } + function render(){ + title.textContent=options.text(kind === 'inbound' ? 'lookupTitle' : 'lookupOrderTitle'); + label.textContent=options.text(kind === 'inbound' ? 'order_inbound' : 'orderNo'); + input.placeholder=options.text(kind === 'inbound' ? 'lookupPlaceholder' : 'lookupOrderPlaceholder'); + submit.textContent=options.text(busy ? 'lookupSending' : 'lookupSubmit'); + cancel.textContent=options.text('customerCancel'); + copy.textContent=options.text(copyState || 'copy'); + submit.disabled=busy; cancel.disabled=busy; input.disabled=busy; + copy.disabled=busy || copying || !valid(input.value.trim()); + } + function clearTimer(){ if(timer !== null) clearTimeout(timer); timer=null; } + function close(){ + version+=1;clearTimer();overlay.hidden=true; + busy=false;copying=false;copyState='';input.value='';status('');render(); + if(opener && opener.isConnected) opener.focus({preventScroll:true}); + opener=null; + } + function open(reference, referenceKind, source){ + version+=1;clearTimer();kind=referenceKind === 'order' ? 'order' : 'inbound'; + busy=false;copying=false;copyState=''; + opener=source || document.activeElement;input.value=reference || '';status('');render();overlay.hidden=false; + if(options.onOpen) options.onOpen(); + input.focus({preventScroll:true}); + } + input.addEventListener('input',function(){ clearTimer();copyState='';status('');render(); }); + copy.onclick=async function(){ + const reference=input.value.trim(), token=version; + if(busy || copying || !valid(reference)) return; + copying=true;copyState='';status('');render(); + const copied=await SupportUI.copyText(reference); + if(token !== version) return; + copying=false; + if(input.value.trim() === reference){ + copyState=copied ? 'copied' : 'copyFailed'; + if(!copied) status(options.text('copyFailed')); + timer=setTimeout(function(){copyState='';timer=null;render();},copied ? 1200 : 3000); + } + render(); + }; + form.addEventListener('submit',async function(event){ + event.preventDefault(); + if(busy) return; + const reference=input.value.trim(), token=version; + if(!valid(reference)){status(options.text('lookupInvalid'));input.focus({preventScroll:true});return;} + const blocked=options.canQuery(); + if(blocked){status(blocked);return;} + busy=true;status('');render(); + try{ + const accepted=await options.query(reference,kind); + if(token !== version) return; + if(accepted) close();else status(options.text('lookupFailed')); + }catch(error){if(token === version) status(options.text('lookupFailed'));} + finally{if(token === version){busy=false;render();}} + }); + cancel.onclick=function(){if(!busy) close();}; + overlay.addEventListener('click',function(event){if(event.target === overlay && !busy) close();}); + overlay.addEventListener('keydown',function(event){ + if(event.key === 'Escape' && !busy){event.preventDefault();close();return;} + if(event.key === 'Tab'){ + const controls=[input,copy,cancel,submit].filter(control=>!control.disabled); + event.preventDefault(); + if(controls.length){ + const index=controls.indexOf(document.activeElement); + controls[(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length].focus({preventScroll:true}); + } + } + }); + render(); + return {open:open,cancel:close,render:render,viewport:function(height,top){ + overlay.style.height=height || '';overlay.style.top=top || '';overlay.style.bottom=height ? 'auto' : ''; + }}; + } +}; diff --git a/static/js/support.js b/static/js/support.js index a3a97d7..83bd8c7 100644 --- a/static/js/support.js +++ b/static/js/support.js @@ -1,5 +1,26 @@ // Shared controls for the customer page and service workspace. var SupportUI = { + async copyText(text){ + try{ + if(navigator.clipboard && navigator.clipboard.writeText){ + await navigator.clipboard.writeText(text); + return true; + } + }catch(error){} + const field = document.createElement('textarea'); + field.value=text; field.readOnly=true; + field.style.position='fixed'; field.style.opacity='0'; + const previous=document.activeElement; + document.body.appendChild(field); + try{ + field.focus({preventScroll:true}); field.select(); + return Boolean(document.execCommand('copy')); + }catch(error){ return false; } + finally{ + field.remove(); + if(previous && previous.isConnected) previous.focus({preventScroll:true}); + } + }, initAttachments(container, label){ container.addEventListener('error', function(event){ const image = event.target; diff --git a/templates/index.html b/templates/index.html index 94e3d38..e3415e6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -82,9 +82,24 @@ +
+ diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 14f9a71..1cba531 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -58,6 +58,7 @@ async function customerPage(pageOptions = {}){ closest(selector){ if(selector === 'button[data-question]' && this.dataset.question) return this; if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this; + if(selector === 'button[data-order-lookup]' && this.attributes['data-order-lookup'] != null) return this; return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector); } remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); } @@ -106,6 +107,7 @@ async function customerPage(pageOptions = {}){ } const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'} : url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)} + : url === '/api/customer/session' ? {ok:true,authenticated:Boolean(pageOptions.customer),user:pageOptions.customer || null} : 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} @@ -113,7 +115,7 @@ async function customerPage(pageOptions = {}){ return {ok:true, json:async () => data}; }, }); - for(const file of ['support.js', 'visitor.js', 'customer.js', 'drafts.js', 'voice.js', 'chat.js']){ + for(const file of ['support.js', 'visitor.js', 'customer.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; @@ -270,7 +272,7 @@ test('switching topics aborts an upload and does not erase the new conversation }); app.element('upload-input').files=[{name:'old.png'}]; app.element('upload-input').onchange(); - await app.click(app.quickButtons[0]); + await app.click(app.quickButtons[1]); await flush(); assert.equal(aborted,true); assert.equal(app.element('upload-btn').disabled,false); @@ -308,7 +310,7 @@ test('clearing history blocks new conversations until its timed request settles' assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0); await app.runTimers(10000); assert.match(app.element('historyWrap').textContent,/保留历史/); - await app.click(app.quickButtons[0]); + await app.click(app.quickButtons[1]); assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1); }); @@ -379,13 +381,16 @@ test('recalling an earlier reply preserves newer replies in the customer log', a function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; } -test('all three footer questions leave the human queue and send to separate AI conversations', async () => { +test('tracking does not interrupt human support while quote and exception shortcuts start AI topics', async () => { const app = await customerPage(); - for(const button of app.quickButtons) await app.click(button); + await app.click(app.quickButtons[0]); + assert.equal(app.context.window.humanSid,7); + assert.match(app.element('composer-status').textContent,/先返回 AI/); + for(const button of app.quickButtons.slice(1)) await app.click(button); const requests = app.calls.filter(call => call.url === '/stream_chat'); - assert.equal(requests.length, 3); - assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3); - assert.deepEqual(requests.map(call => call.body.msg), ['按入仓码查轨迹', '欧洲海运专线报价', '包裹异常处理']); + 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.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); @@ -440,8 +445,8 @@ test('a repeated quick click while a new conversation is being created sends onl const app = await customerPage({human:false}); let resolve; app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; }); - app.quickButtons[0].click(); - app.quickButtons[0].click(); + app.quickButtons[1].click(); + app.quickButtons[1].click(); assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1); resolve(response({id:12, welcome:'welcome'})); await flush(); @@ -452,7 +457,7 @@ test('a late conversation creation cannot replace the newest quick question', as const app = await customerPage({human:false}); let resolve, count=0; app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'}); - app.quickButtons[0].click(); + app.quickButtons[1].click(); await app.click(app.quickButtons[2]); resolve(response({id:21, welcome:'welcome'})); await flush(); @@ -619,12 +624,188 @@ test('inbound codes are the primary card identity for live replies and restored app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']); await app.context.window.fillInput('INB20261029001-01');await flush(); let heading=app.element('chatDom').querySelector('.track-no'); - assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/); + assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/); assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/); app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]); await app.click(app.element('historyWrap').children[0]); heading=app.element('chatDom').querySelector('.track-no'); - assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/); + assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/); +}); + +async function submitOrderLookup(app,reference){ + app.element('order-reference').value=reference; + app.element('order-reference').listeners.input(); + await app.element('order-lookup-form').listeners.submit({preventDefault(){}});await flush(); +} + +test('the lookup shortcut keeps the current conversation, draft and attachment until a real reference is submitted', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'}}); + await app.context.window.fillInput('原来的聊天');await flush(); + app.element('input-text').value='尚未发送的草稿';app.element('input-text').listeners.input(); + app.context.window.__pendingImg='/uploads/next-image.png'; + const before=app.calls.filter(call=>call.url==='/stream_chat').length; + await app.click(app.quickButtons[0]); + assert.equal(app.element('order-lookup').hidden,false); + assert.equal(app.element('.left-panel').classList.contains('left-panel-hide'),false); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before); + assert.equal(app.element('input-text').value,'尚未发送的草稿'); + await submitOrderLookup(app,'hello'); + assert.match(app.element('order-lookup-error').textContent,/完整/); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before); + await submitOrderLookup(app,' INB202611040001 '); + const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1); + assert.equal(last.body.msg,'查询入仓码 INB202611040001'); + assert.equal(last.body.conv_id,1); + assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1); + assert.equal(app.element('order-lookup').hidden,true); + assert.match(app.element('chatDom').textContent,/查询入仓码 INB202611040001/); + assert.equal(app.element('input-text').value,'尚未发送的草稿'); + assert.equal(app.context.window.__pendingImg,'/uploads/next-image.png'); +}); + +test('the guest shortcut signs in before opening lookup and sends no empty order question', async () => { + const app=await customerPage({human:false}); + app.hooks['/api/customer/login']=()=>response({ok:true,user:{id:1,name:'admin'}}); + app.element('input-text').value='游客原草稿';app.element('input-text').listeners.input(); + await app.click(app.quickButtons[0]); + assert.equal(app.element('customer-login').hidden,false); + assert.equal(app.element('order-lookup').hidden,true); + assert.equal(app.calls.filter(call=>call.url==='/api/new_chat' || call.url==='/stream_chat').length,0); + app.element('customer-username').value='admin';app.element('customer-password').value='123456'; + await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush(); + assert.equal(app.element('order-lookup').hidden,false); + assert.equal(app.element('input-text').value,'游客原草稿'); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0); + await app.click(app.element('order-lookup-cancel')); + assert.equal(app.element('input-text').value,'游客原草稿'); +}); + +test('live and saved inbound references open a prefilled form and copy only the code', async () => { + const copied=[]; + const app=await customerPage({human:false,customer:{id:1,name:'admin'},history:[{id:9,title:'历史订单',ctime:'now'}], + clipboard:{writeText:async value=>{copied.push(value);}}}); + const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[{key:'inbound',value:'INB202611040001'}],nodes:[],summary:'订单概要'}; + app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']); + await app.context.window.fillInput('INB202611040001');await flush(); + await app.click(app.element('chatDom').querySelector('.order-reference')); + assert.equal(app.element('order-reference').value,'INB202611040001'); + await app.click(app.element('order-reference-copy')); + assert.deepEqual(copied,['INB202611040001']); + assert.equal(app.element('order-reference-copy').textContent,'已复制'); + await app.click(app.element('order-lookup-cancel')); + app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]); + await app.click(app.element('historyWrap').children[0]); + await app.click(app.element('chatDom').querySelector('.order-reference')); + app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card:{...card,nodes:[{time:'2026-11-04 12:00',text:'最新物流节点',place:''}]}})+'\n\ndata: {"d":1}\n\n']); + await submitOrderLookup(app,'INB202611040001'); + const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1); + assert.equal(last.body.conv_id,9);assert.equal(last.body.msg,'查询入仓码 INB202611040001'); + assert.equal(app.element('chatDom').querySelectorAll('.track-card').length,2); + assert.match(app.element('chatDom').querySelectorAll('.track-card').at(-1).textContent,/最新物流节点/); +}); + +test('an order without an inbound code keeps its truthful order-number label and lookup kind', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'}}); + const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[],nodes:[],summary:'订单概要'}; + app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']); + await app.context.window.fillInput(card.no);await flush(); + const header=app.element('chatDom').querySelector('.track-no'); + assert.match(header.textContent,/订单号/);assert.doesNotMatch(header.textContent,/入仓码/); + await app.click(header.querySelector('.order-reference')); + assert.equal(app.element('order-reference-label').textContent,'订单号'); + await submitOrderLookup(app,card.no); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').at(-1).body.msg,'查询订单 '+card.no); +}); + +test('lookup copy failure is visible and a retry succeeds without changing the typed code', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'},copyResult:false, + clipboard:{writeText:()=>Promise.reject(new Error('denied'))}}); + await app.click(app.quickButtons[0]); + app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input(); + await app.click(app.element('order-reference-copy')); + assert.equal(app.element('order-reference-copy').textContent,'复制失败'); + assert.match(app.element('order-lookup-error').textContent,/复制失败/); + app.context.document.execCommand=()=>true; + await app.click(app.element('order-reference-copy')); + assert.equal(app.element('order-reference-copy').textContent,'已复制'); + assert.equal(app.element('order-lookup-error').hidden,true); + assert.equal(app.element('order-reference').value,'INB202611040001'); + assert.equal(app.context.document.body.children.length,0); +}); + +test('a duplicated lookup submit sends once and a failed creation keeps the reference editable', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'}});let release; + await app.click(app.quickButtons[0]); + app.hooks['/api/new_chat']=()=>new Promise(resolve=>{release=resolve;}); + const pending=submitOrderLookup(app,'INB202611040001');await flush(); + await app.element('order-lookup-form').listeners.submit({preventDefault(){}}); + assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1); + release(response({ok:false},503));await pending; + assert.equal(app.element('order-lookup').hidden,false); + assert.equal(app.element('order-reference').value,'INB202611040001'); + assert.equal(app.element('order-reference').disabled,false); + app.hooks['/api/new_chat']=()=>response({id:11,welcome:'welcome'}); + await submitOrderLookup(app,'INB202611040001'); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1); +}); + +test('lookup does not stop an active AI reply or clear a pending draft', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'}}); + app.hooks['/stream_chat']=()=>({ok:true,body:{getReader:()=>({read:()=>new Promise(()=>{})})}}); + await app.context.window.fillInput('正在回复的问题');await flush(); + app.element('input-text').value='下一条草稿';app.element('input-text').listeners.input(); + await app.click(app.quickButtons[0]); + assert.equal(app.element('order-lookup').hidden,true); + assert.equal(app.context.window.__busy,true); + assert.match(app.element('composer-status').textContent,/等待当前操作/); + assert.equal(app.element('input-text').value,'下一条草稿'); + await app.click(app.element('send-button')); +}); + +test('a late shortcut sign-in cannot open a private lookup after the user changes topics', async () => { + const app=await customerPage({human:false});let release; + app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;}); + await app.click(app.quickButtons[0]); + app.element('customer-username').value='admin';app.element('customer-password').value='123456'; + const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}}); + await app.click(app.element('.new-chat-btn')); + app.element('input-text').value='新话题草稿';app.element('input-text').listeners.input(); + release(response({ok:true,user:{id:1,name:'admin'}}));await signing; + assert.equal(app.element('order-lookup').hidden,true); + assert.equal(app.element('input-text').value,'新话题草稿'); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0); +}); + +test('welcome lookup uses the same form and follows the mobile keyboard viewport', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390,viewportHeight:844}); + const welcome=app.element('lookup-welcome');welcome.setAttribute('data-order-lookup',''); + app.element('chatDom').listeners.click({target:welcome});await flush(); + assert.equal(app.element('order-lookup').hidden,false); + await app.resizeViewport(430,1,80); + assert.equal(app.element('order-lookup').style.height,'430px'); + assert.equal(app.element('order-lookup').style.top,'80px'); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0); +}); + +test('a late repeated shortcut status response cannot erase the latest lookup input', async () => { + const user={id:1,name:'admin'}, app=await customerPage({human:false,customer:user}), releases=[]; + app.hooks['/api/customer/session']=()=>new Promise(resolve=>{releases.push(resolve);}); + const first=app.quickButtons[0].click(), second=app.quickButtons[0].click();await flush(); + releases[1](response({ok:true,authenticated:true,user}));await second;await flush(); + app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input(); + releases[0](response({ok:true,authenticated:true,user}));await first;await flush(); + assert.equal(app.element('order-reference').value,'INB202611040001'); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0); +}); + +test('closing lookup restores the shortcut instead of reopening the composer keyboard', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390}); + const input=app.element('input-text');input.focus(); + await app.click(app.quickButtons[0]); + await app.click(app.element('order-lookup-cancel')); + assert.equal(app.context.document.activeElement,app.quickButtons[0]); + assert.equal(input.focusCalls.length,1); + assert.equal(app.quickButtons[0].focusCalls.at(-1).preventScroll,true); }); test('order details can expand without hiding the shipment timeline', async () => {