Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f94ad665 | ||
|
|
fd1ec32dd6 | ||
|
|
9d07793a57 | ||
|
|
d26f6ecfd3 | ||
|
|
fef233344b | ||
|
|
783f930380 | ||
|
|
888f323b23 | ||
|
|
689d55bcf9 | ||
|
|
eba3a1b562 | ||
|
|
b764fcb33c | ||
|
|
78797d8eca | ||
|
|
b8f6ca586d | ||
|
|
767fca6a79 | ||
|
|
cfd3fb15cc | ||
|
|
1902949f69 | ||
|
|
a415bcdb88 |
@@ -5,6 +5,15 @@
|
||||
DEEPSEEK_API_KEY=
|
||||
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
|
||||
ORDER_DATA_TENANT_ID=
|
||||
# Default front-end customer account: admin / 123456. It grants no staff access.
|
||||
CUSTOMER_DEMO_ENABLED=true
|
||||
CUSTOMER_DEMO_USERNAME=admin
|
||||
# Bind one explicit tenant, or leave empty to use the current local dataset grants.
|
||||
CUSTOMER_DEMO_TENANT_ID=
|
||||
# On initial association, grant the default account the tenants currently in the local table.
|
||||
CUSTOMER_DEMO_LOCAL_ORDERS=true
|
||||
# Optional password hash used only when creating the default account; not plaintext.
|
||||
# CUSTOMER_DEMO_PASSWORD_HASH=
|
||||
# Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable.
|
||||
ASR_PROVIDER=baidu
|
||||
BAIDU_ASR_API_KEY=
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 |
|
||||
| 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` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
@@ -54,7 +56,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
|
||||
|
||||
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
|
||||
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, and `visitor.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -62,4 +64,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize their chat history, never customer orders; customer login/linking remains pending. Do not assign unowned legacy records to a new visitor.
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; order and staff tables start empty. The requested default customer account is separate from staff and stores only a password hash. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize public chat history, never customer orders. Order facts and sensitive conversation history require the server-validated customer and tenant; never accept tenant claims from client input. Do not assign unowned legacy records to a new visitor.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
||||
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
||||
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
||||
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
||||
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
||||
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
||||
@@ -246,10 +246,16 @@ data: {"d": 1}
|
||||
|
||||
## 数据库与运行数据
|
||||
|
||||
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。匿名聊天不读取订单表;客户登录及关联将在后续接入,届时查询必须使用服务端验证的客户范围。`ORDER_DATA_TENANT_ID` 只是额外限制,不能代替客户身份。
|
||||
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。
|
||||
|
||||
默认前台客户账号为 `admin / 123456`,与受 Nginx 保护的客服后台独立。默认按当前本地数据建立客户范围关联:单客户写入 `customer_users.tenant_id`,多客户写入 `customer_tenant_grants`,查询始终按服务端白名单过滤。首次关联完成后不自动加入新客户范围,同一客户下新增订单可直接查询。`CUSTOMER_DEMO_TENANT_ID` 可指定单个范围;`CUSTOMER_DEMO_LOCAL_ORDERS=false` 关闭多客户自动关联。账号保存在 `customer_users`,密码为 PBKDF2 哈希;已有账号启动时不覆盖,可直接维护哈希、`tenant_id`、`enabled` 及客户范围关联表。`CUSTOMER_DEMO_ENABLED=false` 禁用默认账号及其已有登录;真实客户账号不受该开关影响。`CUSTOMER_DEMO_USERNAME` 与 `CUSTOMER_DEMO_PASSWORD_HASH` 用于首次创建默认账号。
|
||||
|
||||
登录凭据通过独立 HttpOnly Cookie 及服务端 `customer_sessions` 校验,绑定当前访客,8 小时有效;退出后撤销当前登录。登录后自动继续刚才的订单问题;含订单事实的会话在退出后隐藏,只有相同访客重新登录同一客户和范围后才能恢复。真实用户认证接入时替换账号校验/关联层,继续使用内部客户 ID、可信客户范围和已有登录会话契约;MCP 数据查询仍通过 `lookup_order` 的客户范围过滤。
|
||||
|
||||
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
|
||||
|
||||
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
|
||||
|
||||
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
|
||||
|
||||
```powershell
|
||||
@@ -258,13 +264,13 @@ data: {"d": 1}
|
||||
|
||||
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。
|
||||
|
||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。
|
||||
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
|
||||
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
|
||||
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
|
||||
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
|
||||
|
||||
@@ -119,7 +119,7 @@ server {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
|
||||
@@ -47,7 +47,7 @@ server {
|
||||
location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
|
||||
| 历史接口 | `logistics_chat/history_service.py`:`new_chat`、`get_history`、`get_messages`、`del_history`、`clear_all`;对应 `routes/history.py` | `static/js/chat.js`:`ensureConv`、`loadHistory`、`openConversation` |
|
||||
| 知识检索 | `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/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 |
|
||||
| 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 |
|
||||
@@ -87,7 +87,11 @@ tests/ # 按业务模块组织的回归
|
||||
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
|
||||
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
||||
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
|
||||
- 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。
|
||||
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
|
||||
- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。
|
||||
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
|
||||
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
|
||||
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
|
||||
|
||||
@@ -107,7 +111,9 @@ tests/ # 按业务模块组织的回归
|
||||
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
|
||||
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。客户身份尚未接入,匿名 HTTP 查单在读取订单前返回账号关联提示;`g.customer_tenant_id` 只允许后续可信认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 客户认证使用独立 `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 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出后清除当前可见订单内容,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||
- 节点导出:`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` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
|
||||
@@ -5,11 +5,12 @@ def create_app(config=None):
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from .config import ROOT_DIR, default_config
|
||||
from . import db, file_service, hooks, visitor_service
|
||||
from . import customer_service, db, file_service, hooks, visitor_service
|
||||
from .routes import register_routes
|
||||
from .routes.speech import MemoryAudioRequest
|
||||
|
||||
@@ -21,8 +22,11 @@ def create_app(config=None):
|
||||
app.config.update(config)
|
||||
app.extensions['human_typing'] = {}
|
||||
app.extensions['rate_hits'] = defaultdict(list)
|
||||
app.extensions['customer_login_attempts'] = {}
|
||||
app.extensions['customer_login_lock'] = Lock()
|
||||
app.before_request(hooks.rate_limit)
|
||||
app.before_request(visitor_service.attach_visitor)
|
||||
app.before_request(customer_service.attach_customer)
|
||||
app.after_request(hooks.no_cache_html)
|
||||
app.teardown_appcontext(db.close_connections)
|
||||
register_routes(app)
|
||||
|
||||
@@ -8,8 +8,17 @@ from .tracking import card_to_text
|
||||
|
||||
def feedback(data):
|
||||
"""收集 👍/👎 反馈,存到 feedback 表"""
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False, 'error': '评价格式无效。'}, 400
|
||||
raw_vote = data.get('vote')
|
||||
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
|
||||
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
|
||||
question, answer = data.get('question', ''), data.get('answer', '')
|
||||
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
|
||||
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
|
||||
conn = connect()
|
||||
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
|
||||
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
|
||||
(question[:500], answer[:2000], vote, now_str()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
@@ -19,8 +28,8 @@ def admin_stats():
|
||||
conn = connect()
|
||||
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
|
||||
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
|
||||
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0]
|
||||
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
|
||||
good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
|
||||
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
|
||||
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
|
||||
@@ -39,8 +48,16 @@ def my_orders(phone):
|
||||
|
||||
|
||||
def add_ticket(data):
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False, 'error': '留言格式无效。'}, 400
|
||||
content, phone = data.get('content'), data.get('phone', '')
|
||||
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
|
||||
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
|
||||
if not isinstance(phone, str) or len(phone.strip()) > 32:
|
||||
return {'ok': False, 'error': '联系电话格式无效。'}, 400
|
||||
conn = connect()
|
||||
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
|
||||
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
|
||||
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
@@ -85,6 +85,9 @@ def create_chat_stream(msg, conv_id):
|
||||
if conv_id:
|
||||
from .visitor_service import owns_conversation
|
||||
if not owns_conversation(conv_id):
|
||||
from .customer_service import needs_customer_login, order_login_response
|
||||
if needs_customer_login(conv_id):
|
||||
return (sse(order_login_response()) + sse({'d':1}), {})
|
||||
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
|
||||
order_answer = answer_query(msg, conv_id)
|
||||
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
|
||||
@@ -102,6 +105,11 @@ def create_chat_stream(msg, conv_id):
|
||||
if order_answer is not None:
|
||||
|
||||
def track_stream():
|
||||
if 'card' in order_answer:
|
||||
from .customer_service import bind_order_conversation, current_customer_id
|
||||
if current_customer_id() and not bind_order_conversation(conv_id):
|
||||
yield sse({'e':'该会话暂时无法访问,请新建对话后重试。','d':1})
|
||||
return
|
||||
save_message(conv_id, 'user', msg)
|
||||
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
|
||||
if 'card' in order_answer else order_answer['t'])
|
||||
|
||||
@@ -29,6 +29,12 @@ def default_config():
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(),
|
||||
'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(),
|
||||
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
|
||||
'CUSTOMER_DEMO_ENABLED': str(os.environ.get('CUSTOMER_DEMO_ENABLED', local_config.get('CUSTOMER_DEMO_ENABLED', 'true'))).lower() in ('1','true','yes'),
|
||||
'CUSTOMER_DEMO_USERNAME': str(os.environ.get('CUSTOMER_DEMO_USERNAME', local_config.get('CUSTOMER_DEMO_USERNAME', 'admin')) or '').strip(),
|
||||
'CUSTOMER_DEMO_PASSWORD_HASH': str(os.environ.get('CUSTOMER_DEMO_PASSWORD_HASH', local_config.get('CUSTOMER_DEMO_PASSWORD_HASH',
|
||||
'pbkdf2:sha256:600000$vY5fe7GwJQRQbhKV$a4c4eeb96e2bda18bfa6b82d263c0cd61dead2e3f224f694e01fc497152df72f')) or ''),
|
||||
'CUSTOMER_DEMO_TENANT_ID': str(os.environ.get('CUSTOMER_DEMO_TENANT_ID', local_config.get('CUSTOMER_DEMO_TENANT_ID')) or '').strip(),
|
||||
'CUSTOMER_DEMO_LOCAL_ORDERS': str(os.environ.get('CUSTOMER_DEMO_LOCAL_ORDERS', local_config.get('CUSTOMER_DEMO_LOCAL_ORDERS', 'true'))).lower() in ('1','true','yes'),
|
||||
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
|
||||
'MODEL_NAME': 'deepseek-chat',
|
||||
'MAX_CONTEXT': 4,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Customer authentication and order scope, independent of staff authentication."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from flask import g, has_app_context, jsonify, request
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from .config import get_setting
|
||||
from .db import connect
|
||||
from .visitor_service import valid_key, visitor_id
|
||||
|
||||
COOKIE_NAME = 'baiyue_customer'
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
|
||||
|
||||
def demo_tenant(connection):
|
||||
configured = get_setting('CUSTOMER_DEMO_TENANT_ID') or get_setting('ORDER_DATA_TENANT_ID')
|
||||
if configured:
|
||||
return configured
|
||||
rows = connection.execute("SELECT DISTINCT tenant_id FROM order_test_data WHERE tenant_id<>'' LIMIT 2").fetchall()
|
||||
return rows[0][0] if len(rows) == 1 else ''
|
||||
|
||||
|
||||
def user_tenants(connection, row):
|
||||
if row[4]:
|
||||
return (row[4],)
|
||||
rows = connection.execute('SELECT tenant_id FROM customer_tenant_grants WHERE customer_id=? ORDER BY tenant_id LIMIT 257',
|
||||
(row[0],)).fetchall()
|
||||
return tuple(item[0] for item in rows) if len(rows) <= 256 else ()
|
||||
|
||||
|
||||
def seed_local_grants(connection, row):
|
||||
if not row[6] or connection.execute('SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||
return
|
||||
if not row[4] and get_setting('CUSTOMER_DEMO_LOCAL_ORDERS') and not user_tenants(connection,row):
|
||||
connection.execute('INSERT OR IGNORE INTO customer_tenant_grants(customer_id,tenant_id) '
|
||||
"SELECT ?,tenant_id FROM order_test_data WHERE tenant_id<>'' GROUP BY tenant_id",(row[0],))
|
||||
if user_tenants(connection,row):
|
||||
connection.execute('UPDATE customer_users SET scope_initialized=1 WHERE id=?',(row[0],))
|
||||
|
||||
|
||||
def scope_key(tenants):
|
||||
return hashlib.sha256(json.dumps(sorted(tenants),ensure_ascii=False).encode()).hexdigest() if tenants else None
|
||||
|
||||
|
||||
def effective_tenants(tenants):
|
||||
configured = get_setting('ORDER_DATA_TENANT_ID')
|
||||
return tuple(value for value in tenants if not configured or value == configured)
|
||||
|
||||
|
||||
def current_scope_key():
|
||||
from .visitor_service import customer_tenant_ids
|
||||
return scope_key(customer_tenant_ids())
|
||||
|
||||
|
||||
def init_customer_tables(connection):
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, display_name TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_demo INTEGER NOT NULL DEFAULT 0, scope_initialized INTEGER NOT NULL DEFAULT 0)''')
|
||||
if 'scope_initialized' not in [item[1] for item in connection.execute('PRAGMA table_info(customer_users)')]:
|
||||
connection.execute('ALTER TABLE customer_users ADD COLUMN scope_initialized INTEGER NOT NULL DEFAULT 0')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_sessions (
|
||||
token_hash TEXT PRIMARY KEY, customer_id INTEGER NOT NULL,
|
||||
visitor_id TEXT NOT NULL, auth_stamp TEXT NOT NULL, expires_at INTEGER NOT NULL)''')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_customer_session_expiry ON customer_sessions(expires_at)')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_tenant_grants (
|
||||
customer_id INTEGER NOT NULL, tenant_id TEXT NOT NULL, PRIMARY KEY(customer_id,tenant_id))''')
|
||||
if get_setting('CUSTOMER_DEMO_ENABLED'):
|
||||
connection.execute('''INSERT OR IGNORE INTO customer_users
|
||||
(username,password_hash,display_name,tenant_id,is_demo) VALUES(?,?,?,?,1)''',
|
||||
(get_setting('CUSTOMER_DEMO_USERNAME'), get_setting('CUSTOMER_DEMO_PASSWORD_HASH'),
|
||||
get_setting('CUSTOMER_DEMO_USERNAME'), demo_tenant(connection)))
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE username=?',(get_setting('CUSTOMER_DEMO_USERNAME'),)).fetchone()
|
||||
seed_local_grants(connection,row)
|
||||
|
||||
|
||||
def auth_stamp(row, tenants):
|
||||
return hashlib.sha256((row[2] + '\n' + (scope_key(effective_tenants(tenants)) or '')).encode()).hexdigest()
|
||||
|
||||
|
||||
def allowed_user(row):
|
||||
return bool(row and row[5] and (not row[6] or (
|
||||
get_setting('CUSTOMER_DEMO_ENABLED') and row[1] == get_setting('CUSTOMER_DEMO_USERNAME'))))
|
||||
|
||||
|
||||
def attach_customer():
|
||||
g.customer = None
|
||||
g.customer_tenant_id = None
|
||||
g.customer_tenant_ids = ()
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not visitor_id() or not valid_key(token):
|
||||
return
|
||||
connection = connect()
|
||||
try:
|
||||
session = connection.execute('SELECT customer_id,auth_stamp FROM customer_sessions '
|
||||
'WHERE token_hash=? AND visitor_id=? AND expires_at>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), visitor_id(), int(time.time()))).fetchone()
|
||||
if not session:
|
||||
return
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE id=?', (session[0],)).fetchone()
|
||||
tenants = effective_tenants(user_tenants(connection,row)) if row else ()
|
||||
if allowed_user(row) and secrets.compare_digest(auth_stamp(row,tenants), session[1]):
|
||||
g.customer = {'id':row[0], 'username':row[1], 'name':row[3]}
|
||||
g.customer_tenant_id = tenants[0] if len(tenants) == 1 else None
|
||||
g.customer_tenant_ids = tenants
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def current_customer_id():
|
||||
customer = getattr(g, 'customer', None) if has_app_context() else None
|
||||
return customer['id'] if customer else None
|
||||
|
||||
|
||||
def bind_order_conversation(conv_id):
|
||||
"""A conversation containing order facts also requires its customer login."""
|
||||
if not conv_id:
|
||||
return True
|
||||
connection = connect()
|
||||
try:
|
||||
result = connection.execute('UPDATE chat_history SET customer_id=?,customer_scope=? WHERE id=? AND visitor_id=? '
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||
(current_customer_id(),current_scope_key(),conv_id,visitor_id(),current_customer_id(),current_scope_key()))
|
||||
connection.commit()
|
||||
return result.rowcount == 1
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def session_status():
|
||||
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
|
||||
|
||||
|
||||
def order_login_response():
|
||||
if current_customer_id():
|
||||
return {'t':'当前账号尚未关联订单,请联系人工客服。'}
|
||||
return {'t':'请先使用客户账号登录并关联订单,再查询订单或物流轨迹。','login_required':True}
|
||||
|
||||
|
||||
def needs_customer_login(conv_id):
|
||||
if current_customer_id():
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||
'AND customer_id IS NOT NULL',(conv_id,visitor_id())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def login(data, attempts, lock):
|
||||
if not isinstance(data, dict):
|
||||
return {'ok':False, 'error':'请输入账号和密码。'}, 400
|
||||
username, password = data.get('username'), data.get('password')
|
||||
if not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 80 or not 1 <= len(password) <= 256:
|
||||
return {'ok':False, 'error':'请输入有效的账号和密码。'}, 400
|
||||
now = int(time.time())
|
||||
# A separate short failed-login budget supplements the global request limiter.
|
||||
ip = request.remote_addr or 'unknown'
|
||||
with lock:
|
||||
failures = [stamp for stamp in attempts.get(ip, []) if stamp > now - 300]
|
||||
if len(attempts) > 2048:
|
||||
for key in list(attempts):
|
||||
if not attempts[key] or attempts[key][-1] <= now - 300:
|
||||
attempts.pop(key,None)
|
||||
if len(failures) >= 5:
|
||||
return {'ok':False, 'error':'登录尝试过多,请稍后重试。'}, 429
|
||||
attempts[ip] = failures + [now]
|
||||
connection = connect()
|
||||
try:
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE username=?', (username.strip(),)).fetchone()
|
||||
digest = row[2] if row else get_setting('CUSTOMER_DEMO_PASSWORD_HASH')
|
||||
try:
|
||||
valid = check_password_hash(digest, password)
|
||||
except (ValueError, TypeError):
|
||||
valid = False
|
||||
if not valid or not allowed_user(row):
|
||||
return {'ok':False, 'error':'账号或密码不正确。'}, 403
|
||||
with lock:
|
||||
attempts.pop(ip, None)
|
||||
if row[6] and not row[4] and not user_tenants(connection,row) and not connection.execute(
|
||||
'SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||
tenant = demo_tenant(connection)
|
||||
if tenant:
|
||||
connection.execute("UPDATE customer_users SET tenant_id=? WHERE id=? AND tenant_id=''", (tenant,row[0]))
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE id=?', (row[0],)).fetchone()
|
||||
seed_local_grants(connection,row)
|
||||
old_token = request.cookies.get(COOKIE_NAME)
|
||||
if valid_key(old_token):
|
||||
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||
(hashlib.sha256(old_token.encode()).hexdigest(), visitor_id()))
|
||||
token = secrets.token_hex(32)
|
||||
connection.execute('DELETE FROM customer_sessions WHERE expires_at<=?', (now,))
|
||||
connection.execute('INSERT INTO customer_sessions VALUES(?,?,?,?,?)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row,user_tenants(connection,row)),now+SESSION_SECONDS))
|
||||
connection.commit()
|
||||
response = jsonify({'ok':True,'authenticated':True,'user':{'id':row[0],'username':row[1],'name':row[3]}})
|
||||
response.set_cookie(COOKIE_NAME,token,max_age=SESSION_SECONDS,httponly=True,secure=request.is_secure,samesite='Lax',path='/')
|
||||
return response
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def logout():
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if valid_key(token):
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),visitor_id()))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
response = jsonify({'ok':True,'authenticated':False,'user':None})
|
||||
response.delete_cookie(COOKIE_NAME,path='/',secure=request.is_secure,httponly=True,samesite='Lax')
|
||||
return response
|
||||
@@ -52,6 +52,17 @@ def init_db():
|
||||
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
|
||||
if 'visitor_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
|
||||
if 'customer_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER')
|
||||
if 'customer_scope' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
|
||||
if 'customer_tenant_id' in cols:
|
||||
# Preserve conversations created by the earlier local single-tenant login.
|
||||
import hashlib
|
||||
import json
|
||||
for cid, tenant in cur.execute('SELECT id,customer_tenant_id FROM chat_history WHERE customer_id IS NOT NULL').fetchall():
|
||||
scope = hashlib.sha256(json.dumps([tenant],ensure_ascii=False).encode()).hexdigest() if tenant else None
|
||||
cur.execute('UPDATE chat_history SET customer_scope=? WHERE id=?',(scope,cid))
|
||||
cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)')
|
||||
cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads (
|
||||
filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL
|
||||
@@ -136,6 +147,8 @@ def init_db():
|
||||
''')
|
||||
from .order_data import init_order_table
|
||||
init_order_table(conn)
|
||||
from .customer_service import init_customer_tables
|
||||
init_customer_tables(conn)
|
||||
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
|
||||
for cid, title in old:
|
||||
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
|
||||
|
||||
@@ -2,21 +2,35 @@
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from urllib.parse import unquote
|
||||
|
||||
from .config import get_setting
|
||||
|
||||
|
||||
|
||||
def cleanup_uploads():
|
||||
"""删除 uploads/ 里 7 天前的图片"""
|
||||
"""Keep attachments referenced by chat history; expire unused uploads after 7 days."""
|
||||
try:
|
||||
udir = get_setting('UPLOAD_DIR')
|
||||
if not os.path.isdir(udir):
|
||||
return
|
||||
from .db import connect
|
||||
connection = connect()
|
||||
try:
|
||||
referenced = set()
|
||||
rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' "
|
||||
"UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'")
|
||||
for (content,) in rows:
|
||||
referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or ''))
|
||||
finally:
|
||||
connection.close()
|
||||
cutoff = time.time() - 7 * 86400
|
||||
for f in os.listdir(udir):
|
||||
if f in referenced:
|
||||
continue
|
||||
fp = os.path.join(udir, f)
|
||||
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
|
||||
os.remove(fp)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from .db import connect, now_str
|
||||
from .visitor_service import visitor_id, owns_conversation
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
|
||||
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。'
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。'
|
||||
|
||||
|
||||
def new_chat():
|
||||
@@ -24,7 +25,8 @@ def new_chat():
|
||||
def get_history():
|
||||
conn = connect()
|
||||
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? '
|
||||
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),)).fetchall()
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?)) '
|
||||
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),current_customer_id(),current_scope_key())).fetchall()
|
||||
conn.close()
|
||||
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
|
||||
|
||||
@@ -57,12 +59,13 @@ def clear_all():
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
owner = visitor_id()
|
||||
owned = 'SELECT id FROM chat_history WHERE visitor_id=?'
|
||||
owned = 'SELECT id FROM chat_history WHERE visitor_id=? AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))'
|
||||
scope = (owner,current_customer_id(),current_scope_key())
|
||||
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions '
|
||||
'WHERE conv_id IN ('+owned+'))', (owner,))
|
||||
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM chat_history WHERE visitor_id=?', (owner,))
|
||||
'WHERE conv_id IN ('+owned+'))', scope)
|
||||
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', scope)
|
||||
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', scope)
|
||||
cur.execute('DELETE FROM chat_history WHERE id IN ('+owned+')', scope)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Human conversation lifecycle; typing state is supplied by the app."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .db import connect
|
||||
|
||||
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def human_request(data):
|
||||
conv_id = data.get('conv_id')
|
||||
if not conv_id:
|
||||
return ({'ok': False, 'error': 'no conv'}, 400)
|
||||
if not 8 <= datetime.now().hour < 22:
|
||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
service_time = datetime.now(SERVICE_TIMEZONE)
|
||||
if not 8 <= service_time.hour < 22:
|
||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
|
||||
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
conn = connect()
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
|
||||
|
||||
+205
-20
@@ -2,24 +2,209 @@
|
||||
|
||||
|
||||
knowledge_list = [
|
||||
# ==================== 一、订单 ====================
|
||||
{
|
||||
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"],
|
||||
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。"
|
||||
"keys": ["查询订单", "查订单", "订单查询", "订单在哪里", "我的订单在哪", "帮我查订单", "为什么查不到订单"],
|
||||
"text": (
|
||||
"请提供订单号、入仓号或关联运单号,并核验订单归属。"
|
||||
"查询结果以实际记录为准。查不到时,应核对号码、查询范围和数据同步情况,"
|
||||
"不能直接认定没有发货,也不能编造运输或清关结果。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys":["海运","船运","海派","海运快线","樱花快线"],
|
||||
"keys": ["订单号和运单号", "订单号是什么", "运单号是什么", "入仓号是什么", "入仓码是什么", "一票多个单号", "主子订单"],
|
||||
"text": (
|
||||
"订单号识别业务订单,入仓号用于收货归集,运单号识别运输任务或包裹。"
|
||||
"一票货可能因分包、分批或阶段换号产生多个编号,"
|
||||
"应根据实际关联记录分别确认,不能把换号当成新增货物。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["付款后没发货", "付款了没发货", "下单后没发货", "下单后多久发货", "修改订单", "订单能修改吗", "取消订单", "订单能取消吗", "更换渠道"],
|
||||
"text": (
|
||||
"下单或付款成功不代表已出运,还需核对收货、审核和运输安排。"
|
||||
"修改、取消或换渠道需根据当前操作阶段确认。"
|
||||
"变更申请不等于执行成功,是否产生费用按适用规则确认。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 二、客户信息 ====================
|
||||
{
|
||||
"keys": ["手机号查订单", "用手机号查", "客户编号查订单", "客户号查订单", "公司名称查订单", "用公司名查", "代客户查询", "帮别人查订单"],
|
||||
"text": (
|
||||
"可按系统支持的方式辅助查找,但必须核验身份、订单归属及代理权限。"
|
||||
"不能仅凭手机号或公司名称披露他人的完整订单、地址和联系方式。"
|
||||
"查询权限与修改权限需要分别确认。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["联系人填错", "联系人写错", "手机号填错", "电话填错", "邮箱填错", "客户资料修改", "客户信息修改"],
|
||||
"text": (
|
||||
"客户账户资料与本票收发货资料需要分别管理。"
|
||||
"修改账户资料不一定同步修改历史和在途订单。"
|
||||
"在途信息变更需确认相关操作方是否接受并生效。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["订单负责人", "谁负责这票", "客户待办", "没收到通知"],
|
||||
"text": (
|
||||
"负责人、客户待办和通知情况应根据实际记录确认。"
|
||||
"待办需说明具体事项、提交渠道及已确认的期限。"
|
||||
"没有联系或送达记录时,不能认定客户已收到通知;"
|
||||
"没有分配记录时,不能编造负责人和反馈时间。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 三、地址 ====================
|
||||
{
|
||||
"keys": ["收货地址怎么填", "地址怎么填写", "地址怎么写", "邮编不对", "邮编错误", "地址不完整", "地址不全", "缺少门牌"],
|
||||
"text": (
|
||||
"国际收货地址通常需要国家、州省、城市、街道、门牌、邮编、收件人及电话。"
|
||||
"缺失或不匹配可能影响派送,具体要求以目的地和承运商为准。"
|
||||
"补交资料后仍需确认是否修改成功。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["最终送到哪里", "最后送到哪里", "收货地址确认", "目的地址是什么", "提货地址和收货地址", "提货地址一样吗"],
|
||||
"text": (
|
||||
"提货、交仓、中转、清关和最终收货地址用途不同。"
|
||||
"最终目的地应查询本票已生效的地址,并按权限展示。"
|
||||
"到达中转仓或清关场所,不代表到达最终收货地址。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["修改收货地址", "改地址", "地址可以修改吗", "可以改地址吗", "更换收件人", "收件人可以改吗", "收件人改地址", "改送其他仓", "换仓地址", "FBA地址确认"],
|
||||
"text": (
|
||||
"改址需要核对货物阶段和执行方处理能力,"
|
||||
"未确认生效前不能告知新地址已启用。"
|
||||
"FBA或海外仓需核对当前货件完整地址及仓代码,"
|
||||
"不能仅按历史仓代码表安排配送。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 四、节点 ====================
|
||||
{
|
||||
"keys": ["节点是什么意思", "什么叫节点", "节点和轨迹", "节点是什么", "已提交是什么意思", "订单已提交", "已接单是什么意思"],
|
||||
"text": (
|
||||
"节点是某一步发生的事件,轨迹是多个运输事件组成的记录。"
|
||||
"已提交、审核通过、已接单属于业务处理状态,"
|
||||
"本身不能证明仓库收到实物或货物已经出运。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已预报是什么意思", "预报是什么意思", "已入仓是什么意思", "入仓是什么意思", "已装柜是什么意思", "装柜是什么意思", "已出运是什么意思", "出运了吗"],
|
||||
"text": (
|
||||
"已预报通常表示信息已提交,不一定已交实物;"
|
||||
"已入仓表示仓库收货,不一定完成检查;"
|
||||
"已装柜表示完成装载,不代表已经离港。"
|
||||
"已出运需有实际离港、起飞、发车或交运依据。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["到港是什么意思", "到港了吗", "已提取是什么意思", "提取是什么意思", "已交末端是什么意思", "交末端是什么意思", "派送中是什么意思", "开始派送了吗"],
|
||||
"text": (
|
||||
"到港表示到达港口,之后可能仍需清关和交接。"
|
||||
"已提取、已交末端通常表示当地承运商接收货物,需要核对接收方。"
|
||||
"派送中表示进入配送环节,以上均不等于收件人签收。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 五、轨迹 ====================
|
||||
{
|
||||
"keys": ["完整物流轨迹", "物流轨迹怎么查", "运输轨迹怎么查", "有单号没有轨迹", "有单号查不到", "轨迹好几天没更新", "物流几天没更新", "轨迹不更新"],
|
||||
"text": (
|
||||
"轨迹应说明数据来源、覆盖范围及最后有效事件时间。"
|
||||
"暂无记录可能涉及未交接、扫描未回传或查询源异常,需要核实。"
|
||||
"长期没有更新不能直接认定丢失,没有数据的阶段不能自行补齐。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["换单号后怎么查", "单号换了", "新旧单号", "官网轨迹不一样", "轨迹不一致", "节点重复", "节点时间不一致"],
|
||||
"text": (
|
||||
"换号需保留新旧编号关联。"
|
||||
"不同来源或节点时间不一致时,应核对时区、原始事件、回传及更正记录。"
|
||||
"同步时间较新不一定代表事件较新,排查方向不能当作已确认原因。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["实时位置", "货物现在在哪", "预计到达时间", "预计什么时候到", "轨迹能证明签收吗", "轨迹能当到货证明吗", "模拟轨迹"],
|
||||
"text": (
|
||||
"节点地点通常表示某时经过该处,不等于实时定位。"
|
||||
"预计到达时间属于计划,应注明来源和条件。"
|
||||
"签收争议需核对POD等凭证,演示或模拟轨迹不能证明真实运输和交付。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 六、货物状态 ====================
|
||||
{
|
||||
"keys": ["履约中是什么意思", "履约中代表什么", "头程完成是什么意思", "头程完成了吗", "订单完成就是签收吗", "订单完成是不是签收"],
|
||||
"text": (
|
||||
"履约中表示订单正在执行,不能据此判断具体位置或清关阶段。"
|
||||
"当前系统的头程订单已完成,不等于末端已签收,"
|
||||
"也不能直接证明客户已收到货物。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["货物是否完好", "货物完好吗", "有没有少件", "少了几件", "货物受潮", "货物进水", "部分到货", "部分签收", "只到了一部分"],
|
||||
"text": (
|
||||
"货况需依据检查、交接件数和照片等记录确认,运输正常不能证明完好。"
|
||||
"部分到货或签收时,应分别核对总件数及各件进度。"
|
||||
"没有检查依据时说明尚未核实,不能用一件状态代表整票。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已签收但没收到", "显示签收没收到", "签收人不认识", "谁签收的", "货物异常", "物流异常", "滞留是什么意思", "货物滞留"],
|
||||
"text": (
|
||||
"签收争议应核对签收人、交付地点、代收及POD,必要时调查。"
|
||||
"异常或滞留需明确发生环节和实际原因,不能直接等同海关扣货。"
|
||||
"调查前不能擅自认定货物丢失或客户已经收到。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 七、清关 ====================
|
||||
{
|
||||
"keys": ["清关了吗", "我的货清关了吗", "是否清关", "清关完成没有", "清关完成了吗", "有没有放行", "出口放行就是清关完成吗"],
|
||||
"text": (
|
||||
"查询清关需明确国家、出口或进口环节,并核对有效记录。"
|
||||
"出口放行不代表目的国进口清关完成,到港和已付款也不能证明放行。"
|
||||
"没有可靠数据时应说明暂未取得结果,不能把未知当作已完成或未完成。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已申报是什么意思", "申报了吗", "清关中是什么意思", "现在清关中吗", "查验是不是没收", "被查验了吗", "清关为什么慢", "清关怎么还没好"],
|
||||
"text": (
|
||||
"申报动作、申报受理和放行需分别核实;"
|
||||
"清关中表示手续正在处理,查验不等于没收。"
|
||||
"具体等待原因以实际反馈为准,"
|
||||
"不能保证统一天数或一定放行,清关状态也不一定按固定顺序变化。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["清关完为什么没派送", "清关了怎么没派送", "整票都清关了吗", "是不是整票放行", "税款交了吗", "税交了吗", "放行证明", "清关证明"],
|
||||
"text": (
|
||||
"资料、税费和放行状态需分别核实,客户付款不一定代表税款已缴。"
|
||||
"放行范围需核对申报单、批次及包裹,不能用一件代表整票。"
|
||||
"放行后可能仍需提货和末端交接;凭证提供需核对关联、授权和脱敏要求。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家","公司地址","办公地址","洛杉矶地址","纽约地址"],
|
||||
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓,公司地址位于:洛杉矶:10767 7th Street, Rancho Cucamonga, California 91730纽约:Door 13-14,75 Carter Dr Edison, New Jersey 08817义乌:浙江省义乌市福田街道稠州北路 1399 号 8613-8615 室深圳:深圳市南山区粤海街道海珠社区海德三道 1258 号卓越后海金融中心 1101。"
|
||||
},
|
||||
{
|
||||
"keys":["海运","船运","海派","海运快线","樱花快线","欧洲海运价格","海运多少钱","海运多久"],
|
||||
"text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。"
|
||||
},
|
||||
{
|
||||
"keys":["铁路","中欧班列","班列","义乌铁路","火车"],
|
||||
"keys":["铁路","中欧班列","班列","义乌铁路","火车","铁路价格","铁路多久","铁路多少钱"],
|
||||
"text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。"
|
||||
},
|
||||
{
|
||||
"keys":["卡航","卡车","卡派"],
|
||||
"keys":["卡航","卡车","卡派","卡航价格","卡航多久","卡航多少钱"],
|
||||
"text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。"
|
||||
},
|
||||
{
|
||||
"keys":["空运","空派","航空","欧洲空运"],
|
||||
"keys":["空运","空派","航空","欧洲空运","空运价格","空运多久","空运多少钱"],
|
||||
"text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。"
|
||||
},
|
||||
{
|
||||
@@ -27,55 +212,55 @@ knowledge_list = [
|
||||
"text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。"
|
||||
},
|
||||
{
|
||||
"keys":["英国空运","英国","UK","伦敦"],
|
||||
"keys":["英国空运","英国空运价格","英国空运多久","英国空运多少钱","英国","UK","伦敦"],
|
||||
"text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。"
|
||||
},
|
||||
{
|
||||
"keys":["报价","价格","费用","计费","怎么收费","计费重量","多少钱","运费","单价"],
|
||||
"keys":["报价","价格","费用","计费","怎么收费","计费重量","计费重量怎么算","材积重","体积重","多少钱","运费","运费怎么算","单价"],
|
||||
"text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。"
|
||||
},
|
||||
{
|
||||
"keys":["时效","多久","要几天","多长时间"],
|
||||
"keys":["时效","运输时效","多久","要几天","多长时间","几天到","什么时候到"],
|
||||
"text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。"
|
||||
},
|
||||
{
|
||||
"keys":["揽收","上门取件","上门收货","交仓","交货"],
|
||||
"keys":["揽收","上门取件","上门收货","可以上门取货吗","交仓","交货","哪里交仓"],
|
||||
"text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。"
|
||||
},
|
||||
{
|
||||
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","交仓地址","送仓"],
|
||||
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","深圳仓","广州仓","交仓地址","送仓"],
|
||||
"text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。"
|
||||
},
|
||||
{
|
||||
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔"],
|
||||
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔","怎么理赔","丢件怎么赔","理赔多久"],
|
||||
"text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。"
|
||||
},
|
||||
{
|
||||
"keys":["超轻费","计费重量不足","最低计费"],
|
||||
"keys":["超轻费","计费重量不足","最低计费","重量不够怎么收费"],
|
||||
"text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。"
|
||||
},
|
||||
{
|
||||
"keys":["偏远","偏远费","偏远地址"],
|
||||
"keys":["偏远","偏远费","偏远地址","偏远怎么查","偏远收费"],
|
||||
"text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。"
|
||||
},
|
||||
{
|
||||
"keys":["超重","超长","超大","尺寸限制"],
|
||||
"keys":["超重","超长","超大","尺寸限制","尺寸超了","超尺寸怎么收费"],
|
||||
"text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。"
|
||||
},
|
||||
{
|
||||
"keys":["敏感","带电","电池","液体","粉末","禁品","不能寄","违禁品","拒收","拒接"],
|
||||
"keys":["敏感","敏感货能发吗","带电","电池","液体","粉末","禁品","不能寄","什么不能寄","违禁品","拒收","拒接"],
|
||||
"text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。"
|
||||
},
|
||||
{
|
||||
"keys":["清关","递延","VAT","双清","包税","DDP"],
|
||||
"keys":["清关","清关资料","清关要什么资料","递延","VAT","VAT递延","双清","双清包税","包税","DDP"],
|
||||
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
|
||||
},
|
||||
{
|
||||
"keys":["报关","单独报关","报关费"],
|
||||
"keys":["报关","怎么报关","单独报关","报关费","报关多少钱"],
|
||||
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
|
||||
},
|
||||
{
|
||||
"keys":["亚马逊","FBA","海外仓","仓库配送","卡派"],
|
||||
"keys":["亚马逊","亚马逊仓","FBA","FBA头程","海外仓","仓库配送","卡派直送"],
|
||||
"text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。"
|
||||
},
|
||||
{
|
||||
@@ -87,7 +272,7 @@ knowledge_list = [
|
||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||
},
|
||||
{
|
||||
"keys":["退件","退回","退货","退运","改地址"],
|
||||
"keys":["退件","退回","退货","退运","怎么退运"],
|
||||
"text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,7 +7,8 @@ from datetime import datetime
|
||||
|
||||
from .config import get_setting
|
||||
from .order_tracking import build_tracking
|
||||
from .visitor_service import customer_tenant_id
|
||||
from .visitor_service import customer_tenant_ids
|
||||
from .customer_service import order_login_response
|
||||
|
||||
|
||||
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||
@@ -17,13 +18,12 @@ FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_
|
||||
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
|
||||
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
|
||||
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
|
||||
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
|
||||
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||
ORDER_LOGIN_TEXT = '订单查询将在客户账号登录并关联后开放。您可以先咨询运费、时效,或联系人工客服。'
|
||||
|
||||
|
||||
class OrderDataUnavailable(Exception):
|
||||
@@ -112,7 +112,7 @@ def import_tracking_records(connection, records, replace=False):
|
||||
return {'imported': imported, 'preserved': preserved, 'missing': missing}
|
||||
|
||||
|
||||
def lookup_order(reference, tenant_id=None):
|
||||
def lookup_order(reference, tenant_id=None, tenant_ids=None):
|
||||
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
|
||||
from .db import connect
|
||||
|
||||
@@ -122,13 +122,25 @@ def lookup_order(reference, tenant_id=None):
|
||||
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)'
|
||||
values = [reference] * 3
|
||||
tenant = get_setting('ORDER_DATA_TENANT_ID')
|
||||
if tenant_id is not None:
|
||||
if tenant_ids is not None:
|
||||
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
|
||||
return []
|
||||
allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128}
|
||||
if tenant_id is not None:
|
||||
allowed.intersection_update({str(tenant_id)})
|
||||
if tenant:
|
||||
allowed.intersection_update({str(tenant)})
|
||||
if not allowed:
|
||||
return []
|
||||
query += ' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')'
|
||||
values.extend(sorted(allowed))
|
||||
elif tenant_id is not None:
|
||||
if not str(tenant_id).strip():
|
||||
return []
|
||||
if tenant and str(tenant_id) != str(tenant):
|
||||
return []
|
||||
tenant = str(tenant_id)
|
||||
if tenant:
|
||||
if tenant and tenant_ids is None:
|
||||
query += ' AND tenant_id=?'
|
||||
values.append(str(tenant))
|
||||
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
|
||||
@@ -164,6 +176,8 @@ def build_card(row):
|
||||
def field(key, value):
|
||||
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
|
||||
field('inbound', row['inbound_code'])
|
||||
if row['inbound_code']:
|
||||
field('number', row['order_no'])
|
||||
field('shipment', row['shipment_id'])
|
||||
field('country', row['country_code'])
|
||||
field('warehouse', row['warehouse_code'])
|
||||
@@ -183,8 +197,9 @@ def build_card(row):
|
||||
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
|
||||
nodes.reverse()
|
||||
status = STATUS_LABELS.get(row['status'], row['status'])
|
||||
summary = f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
|
||||
inbound = row['inbound_code']
|
||||
summary = f"入仓码 {inbound},业务状态:{status}。" if inbound else f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields if item['key'] != 'inbound')
|
||||
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
|
||||
'nodes':nodes, 'summary':summary}
|
||||
tracking = build_tracking(row.get('tracking_json'))
|
||||
@@ -218,11 +233,11 @@ def is_order_followup(message):
|
||||
|
||||
def answer_query(message, conv_id):
|
||||
references = extract_reference(message)
|
||||
tenant = customer_tenant_id()
|
||||
if references and tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
tenants = customer_tenant_ids()
|
||||
if references and not tenants:
|
||||
return order_login_response()
|
||||
if len(references) > 1:
|
||||
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
|
||||
return {'t':'请一次提供一个入仓码,避免查错订单。'}
|
||||
reference = references[0] if references else None
|
||||
if not reference and conv_id and is_order_followup(message):
|
||||
from .db import connect
|
||||
@@ -248,19 +263,23 @@ def answer_query(message, conv_id):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if not reference:
|
||||
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
|
||||
return {'t':ORDER_LOGIN_TEXT if tenant is None else '请提供订单号、入仓码或 Shipment ID。'}
|
||||
if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|'
|
||||
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I):
|
||||
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
|
||||
return None
|
||||
if tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
try:
|
||||
matches = lookup_order(reference, tenant_id=tenant)
|
||||
matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants)
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if not matches:
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
|
||||
if len(matches) > 1:
|
||||
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
|
||||
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
|
||||
if re.search(r'运费|费用|多少钱|价格', message):
|
||||
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
|
||||
return {'card': build_card(matches[0])}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
from . import admin, business, chat, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, files, history, human, pages, speech, visitor):
|
||||
from . import admin, business, chat, customer, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor):
|
||||
app.register_blueprint(module.bp)
|
||||
|
||||
@@ -13,7 +13,7 @@ def my_orders():
|
||||
|
||||
@bp.route('/api/ticket', methods=['POST'])
|
||||
def add_ticket():
|
||||
return business_service.add_ticket(request.get_json() or {})
|
||||
return business_service.add_ticket(request.get_json(silent=True))
|
||||
|
||||
|
||||
@bp.route('/api/tickets')
|
||||
|
||||
@@ -45,4 +45,4 @@ def stream_chat():
|
||||
|
||||
@bp.route('/api/feedback', methods=['POST'])
|
||||
def feedback():
|
||||
return business_service.feedback(request.get_json(silent=True) or {})
|
||||
return business_service.feedback(request.get_json(silent=True))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Public, visitor-bound customer login endpoints. No staff privileges."""
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
from .. import customer_service
|
||||
|
||||
bp = Blueprint('customer', __name__)
|
||||
|
||||
|
||||
@bp.get('/api/customer/session')
|
||||
def session():
|
||||
return customer_service.session_status()
|
||||
|
||||
|
||||
@bp.post('/api/customer/login')
|
||||
def login():
|
||||
request.max_content_length = 2048
|
||||
try:
|
||||
return customer_service.login(request.get_json(silent=True), current_app.extensions['customer_login_attempts'],
|
||||
current_app.extensions['customer_login_lock'])
|
||||
except RequestEntityTooLarge:
|
||||
return {'ok':False,'error':'登录信息过长。'}, 413
|
||||
|
||||
|
||||
@bp.post('/api/customer/logout')
|
||||
def logout():
|
||||
return customer_service.logout()
|
||||
@@ -1,12 +1,17 @@
|
||||
"""HTTP adapters for files; business logic lives in services."""
|
||||
|
||||
from flask import Blueprint, abort, request, send_from_directory
|
||||
from flask import Blueprint, render_template, request, send_from_directory
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from .. import file_service, visitor_service
|
||||
from ..config import get_setting
|
||||
|
||||
bp = Blueprint('files', __name__)
|
||||
|
||||
|
||||
def unavailable(staff=False):
|
||||
return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'}
|
||||
|
||||
@bp.route('/api/upload', methods=['POST'])
|
||||
def upload_file():
|
||||
return file_service.upload_file(request.files)
|
||||
@@ -15,14 +20,20 @@ def upload_file():
|
||||
@bp.route('/uploads/<path:name>')
|
||||
def uploaded_file(name):
|
||||
if not visitor_service.owns_upload(name):
|
||||
abort(404)
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
return unavailable()
|
||||
try:
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
except NotFound:
|
||||
return unavailable()
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
@bp.route('/api/staff/uploads/<path:name>')
|
||||
def staff_uploaded_file(name):
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
try:
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
except NotFound:
|
||||
return unavailable(staff=True)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
@@ -15,6 +15,7 @@ CUSTOMER_PATHS = {
|
||||
'/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history',
|
||||
'/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback',
|
||||
'/api/speech/config', '/api/speech/transcribe',
|
||||
'/api/customer/session', '/api/customer/login', '/api/customer/logout',
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +28,7 @@ def visitor_id():
|
||||
|
||||
|
||||
def customer_tenant_id():
|
||||
# Future login adapters may set this only after validating a customer session.
|
||||
# Set only after validating a server-side customer session.
|
||||
# Visitor cookies, request parameters and headers are deliberately not accepted.
|
||||
value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
@@ -36,6 +37,19 @@ def customer_tenant_id():
|
||||
return tenant if tenant and len(tenant) <= 128 else None
|
||||
|
||||
|
||||
def customer_tenant_ids():
|
||||
if not has_app_context():
|
||||
return ()
|
||||
# Also retain compatibility with a trusted single-tenant authentication adapter.
|
||||
single = customer_tenant_id()
|
||||
if single:
|
||||
return (single,)
|
||||
values = getattr(g,'customer_tenant_ids',())
|
||||
if not isinstance(values,(list,tuple)) or len(values) > 256:
|
||||
return ()
|
||||
return tuple(sorted(set(value for value in values if isinstance(value,str) and 0 < len(value.strip()) <= 128)))
|
||||
|
||||
|
||||
def attach_visitor():
|
||||
key = request.cookies.get(COOKIE_NAME)
|
||||
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
|
||||
@@ -65,25 +79,30 @@ def start_session(data):
|
||||
|
||||
|
||||
def owns_conversation(conv_id):
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=?',
|
||||
(conv_id, owner)).fetchone())
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||
(conv_id, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def owns_human_session(sid):
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id '
|
||||
'WHERE s.id=? AND h.visitor_id=?', (sid, owner)).fetchone())
|
||||
'WHERE s.id=? AND h.visitor_id=? AND (h.customer_id IS NULL OR '
|
||||
'(h.customer_id=? AND h.customer_scope=?))',
|
||||
(sid, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
+44
-34
@@ -1,5 +1,5 @@
|
||||
/* Customer workspace: navigation, welcome, conversation and composer. */
|
||||
body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.chat-container {
|
||||
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
|
||||
overflow: hidden; position: relative; border: 1px solid var(--border);
|
||||
@@ -42,9 +42,20 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
|
||||
.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
|
||||
.clear-all-btn:hover { color: var(--danger); }
|
||||
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
|
||||
.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; }
|
||||
.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
|
||||
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
|
||||
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; }
|
||||
.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-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; }
|
||||
.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; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.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; }
|
||||
.header-copy { min-width: 0; flex: 1; }
|
||||
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
|
||||
@@ -64,7 +75,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.ai-avatar svg { width: 27px; height: 27px; }
|
||||
.welcome-eyebrow { color: var(--brand-text); font-size: 11px; font-weight: 600; letter-spacing: .06em; margin-bottom: 9px; }
|
||||
.ai-welcome-card h3 { font-size: clamp(22px, 2.2vw, 30px); line-height: 1.4; letter-spacing: -.035em; font-weight: 650; color: var(--text); }
|
||||
.ai-welcome-card p { color: var(--secondary); font-size: 13px; line-height: 1.8; margin-top: 10px; }
|
||||
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; }
|
||||
.suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; }
|
||||
.suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); }
|
||||
@@ -72,7 +82,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.suggest-icon svg { width: 19px; height: 19px; }
|
||||
.suggest-copy { min-width: 0; }
|
||||
.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; }
|
||||
.suggest-copy small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; line-height: 1.6; }
|
||||
.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; }
|
||||
.hot-questions { margin-top: 24px; }
|
||||
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
|
||||
@@ -86,8 +95,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.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-toolbar { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.composer-toolbar .hot-bar { flex: 1; min-width: 0; }
|
||||
.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; }
|
||||
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
|
||||
.voice-panel p { margin: 9px 0; font-size: 13px; }
|
||||
@@ -97,7 +106,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
|
||||
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
|
||||
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
|
||||
.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
|
||||
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
|
||||
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
|
||||
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||
@@ -110,12 +118,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
|
||||
#input-text::placeholder { color: var(--muted); }
|
||||
#input-text:placeholder-shown { white-space: nowrap; overflow: hidden; }
|
||||
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
|
||||
#send-button:hover { background: var(--brand-hover); }
|
||||
#send-button.stop { background: var(--danger); }
|
||||
.composer-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 2px 0; }
|
||||
.disclaimer, .keyboard-hint { color: var(--muted); font-size: 11px; }
|
||||
.keyboard-hint { white-space: nowrap; }
|
||||
.msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; }
|
||||
.msg-row.user-line { align-items: flex-end; }
|
||||
.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; }
|
||||
@@ -132,6 +138,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
|
||||
.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; }
|
||||
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
|
||||
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
|
||||
.vote-status.is-error { color: var(--danger); }
|
||||
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
|
||||
.err-line { margin-bottom: 9px; }
|
||||
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
|
||||
@@ -159,7 +167,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
|
||||
.track-expand:hover { background: var(--brand-soft); }
|
||||
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
|
||||
.order-data-notice { margin: 12px 0; padding: 10px; border-radius: 7px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; line-height: 1.8; }
|
||||
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
|
||||
.order-data-fields dt { color: var(--muted); }
|
||||
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
|
||||
@@ -177,7 +184,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); }
|
||||
.attachment-copy { flex: 1; min-width: 0; }
|
||||
.attachment-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
|
||||
.attachment-copy small { display: block; font-size: 10px; color: var(--muted); margin-top: 2px; }
|
||||
.attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; }
|
||||
.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
|
||||
@@ -189,7 +195,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.ai-welcome-card { padding-top: 0; }
|
||||
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
|
||||
.welcome-eyebrow { margin-bottom: 6px; }
|
||||
.ai-welcome-card p { margin-top: 8px; }
|
||||
.suggest-grid { margin-top: 20px; gap: 10px; }
|
||||
.suggest-item { min-height: 72px; padding: 13px 16px; }
|
||||
.hot-questions { margin-top: 18px; }
|
||||
@@ -202,23 +207,35 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.composer { width: calc(100% - 50px); }
|
||||
.service-label { display: none; }
|
||||
.suggest-item { padding: 13px; gap: 10px; }
|
||||
.suggest-copy small { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 0; }
|
||||
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
|
||||
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
|
||||
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
.left-panel .brand { display: none; }
|
||||
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.main-chat { width: 100%; }
|
||||
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; }
|
||||
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
|
||||
.ai-welcome-card { padding-top: 20px; }
|
||||
.composer { width: calc(100% - 40px); }
|
||||
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.chat-container.is-composing { --chat-header-height: 56px; }
|
||||
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
|
||||
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
|
||||
.is-composing .composer-footer { display: none; }
|
||||
#input-text, .history-search { font-size: max(16px, 1em); }
|
||||
.history-search { height: 44px; }
|
||||
.history-search-wrap svg { top: 14px; }
|
||||
.composer-toolbar { margin-bottom: 8px; }
|
||||
.hot-bar .hot-capsule { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.input-area { align-items: center; }
|
||||
.input-area .emoji-control { display: flex; align-items: center; }
|
||||
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
|
||||
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
|
||||
.user-message, .robot-message { max-width: 94%; }
|
||||
.del-history-btn { opacity: 1; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
|
||||
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
|
||||
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
|
||||
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
|
||||
@@ -234,12 +251,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.ai-avatar { display: none; }
|
||||
.ai-welcome-card { padding-top: 12px; }
|
||||
.ai-welcome-card h3 { font-size: 23px; }
|
||||
.ai-welcome-card p { max-width: 300px; margin: 10px auto 0; font-size: 12px; }
|
||||
.suggest-grid { gap: 9px; margin-top: 22px; }
|
||||
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; }
|
||||
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 88px; padding: 13px; gap: 9px; }
|
||||
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
|
||||
.suggest-copy strong { font-size: 13px; }
|
||||
.suggest-copy small { font-size: 11px; }
|
||||
.suggest-arrow { display: none; }
|
||||
.hot-questions { margin-top: 22px; }
|
||||
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
|
||||
@@ -247,18 +262,13 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.hot-capsule { font-size: 10px; padding: 6px 8px; }
|
||||
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
|
||||
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; margin-bottom: 8px; }
|
||||
.hot-bar-label { display: none; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
|
||||
.input-area { gap: 7px; padding: 7px; }
|
||||
#send-button { min-width: 57px; padding: 0 12px; }
|
||||
#upload-btn { flex-basis: 36px; height: 42px; }
|
||||
#upload-btn { flex-basis: 36px; }
|
||||
#input-text { font-size: 16px; }
|
||||
#send-button { min-height: 42px; }
|
||||
.attachment-remove { width: 40px; height: 40px; }
|
||||
.keyboard-hint { display: none; }
|
||||
.composer-footer { justify-content: center; padding-top: 7px; }
|
||||
.disclaimer { font-size: 10px; }
|
||||
.track-text { grid-template-columns: 1fr; gap: 2px; }
|
||||
.track-place { grid-column: 1; }
|
||||
.robot-message.track-card { padding: 16px; }
|
||||
|
||||
@@ -81,6 +81,7 @@ svg { display: block; flex-shrink: 0; }
|
||||
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
|
||||
.emoji-picker button:hover { background: var(--surface-hover); }
|
||||
.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
|
||||
@@ -11,6 +11,7 @@ var typeTimer = null;
|
||||
var toolsOpen = false;
|
||||
var toolsOpener = null;
|
||||
var replyInput = document.getElementById("replyText");
|
||||
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
|
||||
|
||||
function escapeText(value){
|
||||
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
|
||||
|
||||
+257
-92
@@ -6,12 +6,41 @@ 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 drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
|
||||
let draftTimer = null;
|
||||
let draftSnapshot = null;
|
||||
function flushDraft(){
|
||||
if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; }
|
||||
const saved = drafts.write(currentConvId, inputDom.value);
|
||||
drafts.active(currentConvId);
|
||||
if(!saved && inputDom.value && !composerStatus.classList.contains('is-error')) setComposerStatus(t('draftSaveFailed'), true);
|
||||
else if(saved && composerStatus.textContent === t('draftSaveFailed')) setComposerStatus('');
|
||||
}
|
||||
function restoreDraft(){
|
||||
inputDom.value = drafts.read(currentConvId);
|
||||
draftSnapshot = String(currentConvId) + '\n' + inputDom.value;
|
||||
drafts.active(currentConvId);
|
||||
autoGrow();
|
||||
}
|
||||
function queueDraft(){
|
||||
const snapshot = String(currentConvId) + '\n' + inputDom.value;
|
||||
if(snapshot === draftSnapshot) return;
|
||||
draftSnapshot = snapshot;
|
||||
if(draftTimer !== null) clearTimeout(draftTimer);
|
||||
draftTimer = setTimeout(flushDraft, 250);
|
||||
}
|
||||
function focusComposer(force){
|
||||
if(force || !isNarrow() || document.activeElement === inputDom){
|
||||
inputDom.focus({preventScroll:true});
|
||||
}
|
||||
}
|
||||
let uploadPending = false;
|
||||
let uploadVersion = 0;
|
||||
let uploadController = null;
|
||||
let historyRequestVersion = 0;
|
||||
let followLatest = true;
|
||||
let voiceInput = null;
|
||||
let customerLogin = null;
|
||||
let creatingConv = null;
|
||||
let sendPreparing = false;
|
||||
let conversationVersion = 0;
|
||||
@@ -23,7 +52,8 @@ let humanPollTimer = null;
|
||||
let humanSendingToken = null;
|
||||
let humanClosing = false;
|
||||
window.humanSid = 0;
|
||||
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
|
||||
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||
|
||||
function setComposerStatus(message, isError){
|
||||
@@ -113,20 +143,18 @@ uploadInput.onchange = function(){
|
||||
copy.className = "attachment-copy";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = data.name;
|
||||
const hint = document.createElement("small");
|
||||
hint.textContent = t("attachmentReady");
|
||||
copy.append(name, hint);
|
||||
copy.append(name);
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "attachment-remove";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", t("removeAttachment"));
|
||||
remove.onclick = function(){ clearAttachment(); inputDom.focus(); };
|
||||
remove.onclick = function(){ clearAttachment(); focusComposer(true); };
|
||||
prev.append(copy, remove);
|
||||
var area = document.querySelector(".input-area");
|
||||
area.before(prev);
|
||||
setComposerStatus("");
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
})
|
||||
.catch(function(error){
|
||||
@@ -200,6 +228,11 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
|
||||
const I18N = {
|
||||
zh: {
|
||||
appTitle: "柏悦物流智能助手",
|
||||
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
|
||||
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
|
||||
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
|
||||
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
|
||||
customerLogoutFailed: "退出失败,请重试。",
|
||||
newChat: "新建对话",
|
||||
clearAll: "清空全部历史",
|
||||
history: "历史对话",
|
||||
@@ -211,8 +244,8 @@ const I18N = {
|
||||
thinking: "正在思考中...",
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。",
|
||||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
|
||||
humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
offlinePrompt: "请输入您的留言:",
|
||||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||||
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
|
||||
@@ -221,13 +254,15 @@ const I18N = {
|
||||
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
|
||||
humanActive: "人工客服接待中,消息将发送给人工客服。",
|
||||
returnAI: "返回 AI 问答",
|
||||
humanSent: "已发送给人工客服",
|
||||
humanSending: "正在发送给人工客服…",
|
||||
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
|
||||
humanConnectFailed: "人工客服连接失败,请稍后重试。",
|
||||
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
|
||||
humanReturnFailed: "暂时无法结束人工会话,请重试。",
|
||||
historyLoading: "正在加载这段会话,请稍候…",
|
||||
historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。",
|
||||
draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。",
|
||||
attachmentUnavailable: "图片暂时无法访问,请重新发送附件。",
|
||||
recalled: "该消息已撤回",
|
||||
ratingQuestion: "本次服务满意吗?",
|
||||
ratingThanks: "感谢您的评价!",
|
||||
@@ -235,27 +270,25 @@ const I18N = {
|
||||
networkFail: "网络连接中断,请检查网络后重试",
|
||||
stopped: "已停止生成",
|
||||
stoppedMark: "(已停止生成)",
|
||||
inputPh: "请输入问题、订单号或入仓码…(Enter 发送,Shift+Enter 换行)",
|
||||
inputPh: "输入入仓码或问题…",
|
||||
send: "发送",
|
||||
stop: "停止",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败",
|
||||
voteUp: "这条回复有帮助",
|
||||
voteDown: "这条回复没有帮助",
|
||||
retry: "重答",
|
||||
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
|
||||
clickRetry: "点击重试",
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeSub: "咨询运费、时效或联系人工客服。订单查询需关联客户账号。",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
serviceScope: "中欧与英国专线咨询",
|
||||
serviceTag: "物流咨询服务",
|
||||
quickLabel: "快捷咨询",
|
||||
footerTrack: "查订单",
|
||||
footerTrack: "查入仓码",
|
||||
footerQuote: "专线报价",
|
||||
footerException: "异常处理",
|
||||
footerHuman: "联系人工",
|
||||
disclaimer: "报价与时效仅供参考,请以客服最终确认为准",
|
||||
keyboardHint: "Enter 发送 · Shift + Enter 换行",
|
||||
inputShort: "输入问题、订单号或入仓码…",
|
||||
inputShort: "输入入仓码…",
|
||||
uploading: "附件正在上传,请稍候…",
|
||||
uploadFailed: "附件上传失败,请重新选择文件",
|
||||
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
|
||||
@@ -271,7 +304,6 @@ const I18N = {
|
||||
voiceEmpty: "没有识别到文字,请靠近麦克风重试。", voiceFailed: "语音输入失败,请重试或使用文字输入。",
|
||||
voiceTimeout: "语音处理超时,请重试。", voiceWait: "请等待当前操作完成后,再使用语音输入。",
|
||||
voiceDraft: "输入框还有未发送内容,请先发送或清空后再使用语音。",
|
||||
attachmentReady: "已准备好,发送后将附在消息中",
|
||||
removeAttachment: "移除待发送附件",
|
||||
you: "您",
|
||||
assistant: "柏悦物流助手",
|
||||
@@ -285,15 +317,12 @@ const I18N = {
|
||||
logisticsHistory: "物流轨迹",
|
||||
noLogisticsHistory: "暂无已发生的物流节点",
|
||||
estimatedTracking: "预计节点",
|
||||
legacyTrackingNotice: "历史模拟轨迹,仅供演示,不代表真实订单。",
|
||||
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_inbound: "入仓码", order_number: "订单号", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
|
||||
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
|
||||
order_parent: "父订单号", order_updated: "来源更新时间",
|
||||
moreTracking: "展开全部轨迹",
|
||||
lessTracking: "收起较早轨迹",
|
||||
quickSubs: ["关联客户账号后查询订单与轨迹", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
|
||||
hot: "热点问题",
|
||||
recent: "最近对话记录",
|
||||
sideCollapse: "收起侧栏",
|
||||
@@ -302,7 +331,7 @@ const I18N = {
|
||||
langTip: "中英文切换(Ctrl+L)",
|
||||
toBottom: "回到底部",
|
||||
quick: [
|
||||
"查询订单状态", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
],
|
||||
hotList: [
|
||||
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
|
||||
@@ -310,6 +339,11 @@ const I18N = {
|
||||
},
|
||||
en: {
|
||||
appTitle: "Baiyue Logistics AI Assistant",
|
||||
customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel",
|
||||
customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders",
|
||||
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.",
|
||||
newChat: "New Chat",
|
||||
clearAll: "Clear All History",
|
||||
history: "History",
|
||||
@@ -321,8 +355,8 @@ const I18N = {
|
||||
thinking: "Thinking...",
|
||||
busyRetry: "Service busy, please try again later",
|
||||
sessionFail: "Failed to create session, please check the service is running",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support. Order lookup will be available after signing in and linking a customer account.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.",
|
||||
offlinePrompt: "Please leave your message:",
|
||||
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
|
||||
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
|
||||
@@ -331,13 +365,15 @@ const I18N = {
|
||||
humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.",
|
||||
humanActive: "You are chatting with our support team. Messages will be sent to a team member.",
|
||||
returnAI: "Return to AI chat",
|
||||
humanSent: "Sent to our support team",
|
||||
humanSending: "Sending to our support team…",
|
||||
humanSendFailed: "Message not sent. Your text has been kept. Please retry.",
|
||||
humanConnectFailed: "Could not connect to our support team. Please try again.",
|
||||
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
|
||||
humanReturnFailed: "Could not end the support session. Please try again.",
|
||||
historyLoading: "Loading this conversation. Please wait…",
|
||||
historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.",
|
||||
draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.",
|
||||
attachmentUnavailable: "Image unavailable. Please upload the attachment again.",
|
||||
recalled: "This message was recalled",
|
||||
ratingQuestion: "How was your support experience?",
|
||||
ratingThanks: "Thank you for your feedback!",
|
||||
@@ -345,27 +381,25 @@ const I18N = {
|
||||
networkFail: "Connection lost, please check your network and retry",
|
||||
stopped: "Stopped",
|
||||
stoppedMark: "(Generation stopped)",
|
||||
inputPh: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)",
|
||||
inputPh: "Enter an inbound code or question…",
|
||||
send: "Send",
|
||||
stop: "Stop",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
copyFailed: "Copy failed",
|
||||
voteUp: "This reply was helpful",
|
||||
voteDown: "This reply was not helpful",
|
||||
retry: "Regenerate",
|
||||
retryAIOnly: "Return to AI chat before regenerating this answer.",
|
||||
clickRetry: "Retry",
|
||||
welcomeTitle: "Your next shipment, made clearer.",
|
||||
welcomeSub: "Ask about rates, transit times or human support. Order lookup requires a linked customer account.",
|
||||
welcomeEyebrow: "EUROPE & UK LOGISTICS",
|
||||
serviceScope: "Europe & UK freight support",
|
||||
serviceTag: "Logistics support",
|
||||
quickLabel: "Quick help",
|
||||
footerTrack: "Order lookup",
|
||||
footerTrack: "Inbound lookup",
|
||||
footerQuote: "Get a quote",
|
||||
footerException: "Parcel issue",
|
||||
footerHuman: "Human support",
|
||||
disclaimer: "Quotes and transit times are subject to final confirmation",
|
||||
keyboardHint: "Enter to send · Shift + Enter for a new line",
|
||||
inputShort: "Question or tracking number…",
|
||||
inputShort: "Inbound code…",
|
||||
uploading: "Uploading your attachment…",
|
||||
uploadFailed: "Upload failed. Please select the file again.",
|
||||
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
|
||||
@@ -383,7 +417,6 @@ const I18N = {
|
||||
voiceFailed: "Voice input failed. Please try again or type your message.", voiceTimeout: "Voice input timed out. Please try again.",
|
||||
voiceWait: "Please wait for the current operation to finish before recording.",
|
||||
voiceDraft: "Please send or clear your existing draft before recording.",
|
||||
attachmentReady: "Ready to attach to your next message",
|
||||
removeAttachment: "Remove pending attachment",
|
||||
you: "You",
|
||||
assistant: "Baiyue Assistant",
|
||||
@@ -397,15 +430,12 @@ const I18N = {
|
||||
logisticsHistory: "Shipment tracking",
|
||||
noLogisticsHistory: "No actual shipment events available",
|
||||
estimatedTracking: "Estimated milestones",
|
||||
legacyTrackingNotice: "Historical simulated tracking for demonstration only; this is not a real order record.",
|
||||
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_inbound: "Inbound code", order_number: "Order number", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
|
||||
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
|
||||
order_parent: "Parent order", order_updated: "Source updated",
|
||||
moreTracking: "View full tracking history",
|
||||
lessTracking: "Show recent updates",
|
||||
quickSubs: ["Link a customer account to view orders and tracking", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
|
||||
welcomeTip: "Type your question, or pick a quick option below.",
|
||||
hot: "Popular Questions",
|
||||
recent: "Recent Conversations",
|
||||
sideCollapse: "Collapse sidebar",
|
||||
@@ -414,7 +444,7 @@ const I18N = {
|
||||
langTip: "Switch language (Ctrl+L)",
|
||||
toBottom: "Back to bottom",
|
||||
quick: [
|
||||
"Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
],
|
||||
hotList: [
|
||||
"How do I track my shipment?", "How long does Europe sea freight take?",
|
||||
@@ -430,6 +460,7 @@ function t(key){
|
||||
function applyLanguage(lang){
|
||||
LANG = lang === "en" ? "en" : "zh";
|
||||
const dict = I18N[LANG];
|
||||
if(customerLogin) customerLogin.render();
|
||||
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
|
||||
document.querySelectorAll("[data-i18n]").forEach(function(element){
|
||||
const value = dict[element.dataset.i18n];
|
||||
@@ -478,6 +509,7 @@ function applyLanguage(lang){
|
||||
const n = b.dataset.action;
|
||||
if(n === "copy") b.textContent = dict.copy;
|
||||
if(n === "retry") b.textContent = dict.retry;
|
||||
if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]);
|
||||
});
|
||||
document.querySelectorAll(".retry-btn").forEach(function(b){
|
||||
if(!b.dataset.keep) b.textContent = dict.clickRetry;
|
||||
@@ -585,7 +617,7 @@ function welcomeHTML(){
|
||||
const kinds = ["track", "quote", "issue", "human"];
|
||||
const cards = q.map(function(question, index){
|
||||
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
|
||||
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong><small>${t("quickSubs")[index]}</small></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
|
||||
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 `
|
||||
<div class="ai-welcome-card">
|
||||
@@ -593,7 +625,6 @@ function welcomeHTML(){
|
||||
<div class="ai-avatar">${welcomeIcon("box")}</div>
|
||||
<div class="welcome-eyebrow">${t("welcomeEyebrow")}</div>
|
||||
<h3>${t("welcomeTitle")}</h3>
|
||||
<p>${t("welcomeSub")}</p>
|
||||
</div>
|
||||
<div class="suggest-grid">${cards}</div>
|
||||
</div>
|
||||
@@ -641,11 +672,12 @@ clearAllBtn.onclick = function(){
|
||||
if(newChatBtn){
|
||||
newChatBtn.onclick = function(){
|
||||
if(openingConv === 'clearing') return;
|
||||
const alreadyNew = currentConvId === null;
|
||||
resetConversation(null);
|
||||
inputDom.value = "";
|
||||
if(alreadyNew) inputDom.value = "";
|
||||
autoGrow();
|
||||
clearAttachment();
|
||||
setComposerStatus("");
|
||||
setComposerStatus('');
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
chatDom.scrollTop = 0;
|
||||
followLatest = true;
|
||||
@@ -661,10 +693,13 @@ function loadHistory(){
|
||||
historyList = data;
|
||||
renderSideHistory();
|
||||
renderRecentChat();
|
||||
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
|
||||
return data;
|
||||
}).catch(function(){ return null; /* 保留上次成功加载的历史。 */ });
|
||||
}
|
||||
|
||||
function removeCachedHistory(id){
|
||||
if(id === undefined){ drafts.clear(); flushDraft(); }
|
||||
else drafts.remove(id);
|
||||
historyRequestVersion += 1;
|
||||
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
|
||||
renderSideHistory();
|
||||
@@ -811,6 +846,8 @@ function ensureConv(){
|
||||
throw error;
|
||||
}
|
||||
currentConvId = data.id;
|
||||
drafts.remove('new');
|
||||
flushDraft();
|
||||
if(data.welcome) addMsg(t('autoWelcome'), false);
|
||||
return currentConvId;
|
||||
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
|
||||
@@ -819,6 +856,8 @@ function ensureConv(){
|
||||
}
|
||||
|
||||
function resetConversation(id){
|
||||
flushDraft();
|
||||
emojiPicker.close();
|
||||
if(voiceInput) voiceInput.cancel();
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
if(sseInstance){ sseInstance.close(); sseInstance = null; }
|
||||
@@ -826,6 +865,7 @@ function resetConversation(id){
|
||||
clearAttachment();
|
||||
stopHumanMode();
|
||||
currentConvId = id;
|
||||
restoreDraft();
|
||||
openingConv = null;
|
||||
creatingConv = null;
|
||||
sendPreparing = false;
|
||||
@@ -841,7 +881,9 @@ function openConversation(id){
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv === id) return; // 防连点
|
||||
const version = resetConversation(id);
|
||||
if(isNarrow()){ sideOpen = false; renderSidebar(); }
|
||||
openingConv = id;
|
||||
chatDom.innerHTML = '';
|
||||
setComposerStatus(t('historyLoading'));
|
||||
renderSideHistory();
|
||||
requestJson("/api/get_messages?conv_id=" + id)
|
||||
@@ -855,7 +897,9 @@ function openConversation(id){
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
return;
|
||||
}
|
||||
let previousQuestion = '';
|
||||
msgs.forEach(m=>{
|
||||
if(m.role === 'user') previousQuestion = m.content;
|
||||
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
|
||||
try{
|
||||
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
|
||||
@@ -866,14 +910,17 @@ function openConversation(id){
|
||||
}else{
|
||||
const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
|
||||
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
|
||||
if(m.role === 'assistant' && b && (/^请先使用客户账号登录并关联订单/.test(m.content) || /^订单查询将在客户账号登录并关联后开放/.test(m.content))){
|
||||
attachCustomerLogin(b, previousQuestion);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 等渲染完再滚到底部
|
||||
requestAnimationFrame(function(){ scrollChatToEnd(true); });
|
||||
}).catch(function(){
|
||||
if(version !== conversationVersion) return;
|
||||
openingConv = null;
|
||||
setComposerStatus(t('networkFail'), true);
|
||||
openingConv = 'failed';
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -888,8 +935,9 @@ function fillTrackCard(el, card){
|
||||
head.className = "track-head";
|
||||
const no = document.createElement("div");
|
||||
no.className = "track-no";
|
||||
const numberLabel = isOrder ? 'orderNo' : 'trackingNo';
|
||||
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(card.no);
|
||||
const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
|
||||
const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
|
||||
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(inbound ? inbound.value : card.no);
|
||||
const st = document.createElement("div");
|
||||
st.className = "track-status";
|
||||
st.textContent = card.status;
|
||||
@@ -897,14 +945,6 @@ function fillTrackCard(el, card){
|
||||
head.appendChild(st);
|
||||
el.appendChild(head);
|
||||
|
||||
if(!isOrder){
|
||||
const legacyNotice = document.createElement('p');
|
||||
legacyNotice.className = 'order-data-notice';
|
||||
legacyNotice.dataset.i18n = 'legacyTrackingNotice';
|
||||
legacyNotice.textContent = t('legacyTrackingNotice');
|
||||
el.appendChild(legacyNotice);
|
||||
}
|
||||
|
||||
if(isOrder){
|
||||
const fields = document.createElement('dl');
|
||||
fields.className = 'order-data-fields is-collapsed';
|
||||
@@ -1033,7 +1073,30 @@ function addTrackCard(card, timeStr){
|
||||
return bubble;
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
const restoreId = drafts.active();
|
||||
currentConvId = restoreId === 'new' ? null : Number(restoreId);
|
||||
restoreDraft();
|
||||
if(currentConvId){
|
||||
const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value;
|
||||
openingConv = 'restoring';
|
||||
loadHistory().then(function(history){
|
||||
if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return;
|
||||
if(Array.isArray(history) && !history.some(item=>item.id === id)){
|
||||
if(inputDom.value !== initialDraft){
|
||||
openingConv = 'failed';
|
||||
flushDraft();
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
return;
|
||||
}
|
||||
drafts.remove(id);
|
||||
currentConvId = null;
|
||||
restoreDraft();
|
||||
openingConv = null;
|
||||
return;
|
||||
}
|
||||
openConversation(id);
|
||||
});
|
||||
}else loadHistory();
|
||||
|
||||
window.fillInput = function(words){
|
||||
inputDom.value = words;
|
||||
@@ -1099,11 +1162,10 @@ async function humanRequest(path, data){
|
||||
window.showHuman = async function(){
|
||||
if(voiceInput) voiceInput.cancel();
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
|
||||
if(humanRequestToken || humanClosing) return;
|
||||
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
|
||||
if(window.humanSid) stopHumanMode();
|
||||
const hour = new Date().getHours();
|
||||
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
|
||||
const token = {version:conversationVersion};
|
||||
humanRequestToken = token;
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
@@ -1140,7 +1202,7 @@ async function returnToAI(){
|
||||
stopHumanMode();
|
||||
setComposerStatus('');
|
||||
addMsg(t('humanClosed'), false);
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}catch(error){
|
||||
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
|
||||
}finally{
|
||||
@@ -1165,8 +1227,8 @@ async function sendHumanMessage(content){
|
||||
addMsg(content, true);
|
||||
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
|
||||
if(window.__pendingImg === attachment) clearAttachment();
|
||||
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
|
||||
inputDom.focus();
|
||||
setComposerStatus('');
|
||||
focusComposer();
|
||||
return true;
|
||||
}catch(error){
|
||||
if(version !== conversationVersion || humanSendingToken !== token) return;
|
||||
@@ -1180,7 +1242,7 @@ async function sendHumanMessage(content){
|
||||
async function leaveOfflineMessage(){
|
||||
const version = conversationVersion;
|
||||
addMsg(t('humanHours'), false);
|
||||
const message = window.prompt(t('offlinePrompt'));
|
||||
const message = (window.prompt(t('offlinePrompt')) || '').trim();
|
||||
if(!message) return;
|
||||
try{
|
||||
const data = await humanRequest('/api/ticket', {content:message, phone:''});
|
||||
@@ -1188,8 +1250,8 @@ async function leaveOfflineMessage(){
|
||||
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
|
||||
}catch(error){
|
||||
if(version !== conversationVersion) return;
|
||||
if(!inputDom.value) inputDom.value = message;
|
||||
setComposerStatus(t('networkFail'), true);
|
||||
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
|
||||
setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,6 +1427,19 @@ function mdToPlain(md){
|
||||
.replace(/“|”/g, '"');
|
||||
}
|
||||
|
||||
function attachCustomerLogin(bubble, question){
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'customer-login-cta';
|
||||
button.textContent = t('customerQuery');
|
||||
const version = conversationVersion;
|
||||
button.onclick = function(){
|
||||
customerLogin.require(async function(){
|
||||
if(version === conversationVersion && question) await retryAnswer(bubble, question);
|
||||
});
|
||||
};
|
||||
bubble.appendChild(button);
|
||||
}
|
||||
|
||||
async function retryAnswer(bubble, question){
|
||||
if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; }
|
||||
if(await doSend(question)){
|
||||
@@ -1386,26 +1461,34 @@ function attachActions(bubble, rawText, question, showRetry){
|
||||
copyBtn.dataset.action = "copy";
|
||||
copyBtn.textContent = t("copy");
|
||||
copyBtn.onclick = function(){
|
||||
if(copyBtn.disabled) return;
|
||||
copyBtn.disabled = true;
|
||||
const plain = mdToPlain(rawText);
|
||||
const done = ()=> {
|
||||
copyBtn.textContent = t("copied");
|
||||
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
|
||||
const done = function(copied){
|
||||
copyBtn.disabled = false;
|
||||
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
|
||||
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
|
||||
};
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(plain).then(done).catch(fallback);
|
||||
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
|
||||
}else{
|
||||
fallback();
|
||||
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);
|
||||
ta.select();
|
||||
try{ document.execCommand("copy"); }catch(e){}
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
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);
|
||||
}
|
||||
};
|
||||
bar.appendChild(copyBtn);
|
||||
@@ -1420,23 +1503,52 @@ function attachActions(bubble, rawText, question, showRetry){
|
||||
}
|
||||
|
||||
// 👍/👎 反馈
|
||||
let votePending = false;
|
||||
const voteButtons = [];
|
||||
const voteStatus = document.createElement('span');
|
||||
voteStatus.className = 'vote-status';
|
||||
voteStatus.setAttribute('role', 'status');
|
||||
function addVote(vote, label){
|
||||
const b = document.createElement("button");
|
||||
b.className = "msg-action-btn";
|
||||
b.textContent = label;
|
||||
b.onclick = function(){
|
||||
if(b.classList.contains("voted")) return;
|
||||
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
|
||||
b.classList.add("voted");
|
||||
fetch("/api/feedback", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({question: question, answer: rawText, vote: vote})
|
||||
}).catch(function(){});
|
||||
b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown';
|
||||
b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown'));
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
b.onclick = async function(){
|
||||
if(votePending || b.classList.contains('voted')) return;
|
||||
votePending = true;
|
||||
voteButtons.forEach(button => { button.disabled = true; });
|
||||
voteStatus.textContent = '';
|
||||
voteStatus.removeAttribute('data-i18n');
|
||||
voteStatus.classList.remove('is-error');
|
||||
try{
|
||||
const data = await requestJson('/api/feedback', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({question:question, answer:rawText, vote:vote})
|
||||
});
|
||||
if(!data.ok) throw new Error('Feedback failed');
|
||||
voteButtons.forEach(function(button){
|
||||
button.classList.toggle('voted', button === b);
|
||||
button.setAttribute('aria-pressed', String(button === b));
|
||||
});
|
||||
voteStatus.textContent = t('ratingThanks');
|
||||
voteStatus.setAttribute('data-i18n', 'ratingThanks');
|
||||
}catch(error){
|
||||
voteStatus.textContent = t('ratingFailed');
|
||||
voteStatus.setAttribute('data-i18n', 'ratingFailed');
|
||||
voteStatus.classList.add('is-error');
|
||||
}finally{
|
||||
votePending = false;
|
||||
voteButtons.forEach(button => { button.disabled = false; });
|
||||
}
|
||||
};
|
||||
voteButtons.push(b);
|
||||
bar.appendChild(b);
|
||||
}
|
||||
addVote("up", "👍");
|
||||
addVote("down", "👎");
|
||||
bar.appendChild(voteStatus);
|
||||
|
||||
bubble.appendChild(bar);
|
||||
}
|
||||
@@ -1471,7 +1583,7 @@ async function doSend(retryText){
|
||||
var tag = window.__pendingTag || "[图片]";
|
||||
const draft = inputDom.value, attachmentVersion = uploadVersion;
|
||||
if(!sendWord && !imgUrl){
|
||||
inputDom.focus();
|
||||
focusComposer(true);
|
||||
return;
|
||||
}
|
||||
if(uploadPending){ setComposerStatus(t("uploading")); return; }
|
||||
@@ -1479,7 +1591,7 @@ async function doSend(retryText){
|
||||
var fullContent = sendWord;
|
||||
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
|
||||
setComposerStatus("");
|
||||
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
|
||||
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
|
||||
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
|
||||
// 人工客服模式:直接发给客服,不走AI
|
||||
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
|
||||
@@ -1504,7 +1616,7 @@ async function doSend(retryText){
|
||||
addMsg(fullContent, true);
|
||||
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
|
||||
if(attachmentVersion === uploadVersion) clearAttachment();
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
|
||||
const aiBubble = addMsg(t("thinking"), false);
|
||||
@@ -1537,7 +1649,6 @@ async function doSend(retryText){
|
||||
setBusy(false);
|
||||
aiBubble.classList.remove("typing");
|
||||
if(activeBubble === aiBubble) activeBubble = null;
|
||||
inputDom.focus();
|
||||
|
||||
let raw = aiBubble.dataset.raw || "";
|
||||
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
|
||||
@@ -1549,7 +1660,8 @@ async function doSend(retryText){
|
||||
raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
|
||||
}catch(e){ /* 解析失败就按普通文本显示 */ }
|
||||
}
|
||||
attachActions(aiBubble, raw, fullContent, true);
|
||||
attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired);
|
||||
if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent);
|
||||
renderFollowups(aiBubble);
|
||||
updateToBottom();
|
||||
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
|
||||
@@ -1599,6 +1711,7 @@ async function doSend(retryText){
|
||||
}
|
||||
if(!obj || typeof obj !== 'object') return;
|
||||
if(obj.e){ fail(obj.e); return; }
|
||||
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
|
||||
if(obj.card){
|
||||
clearTimeout(firstTimer);
|
||||
aiBubble.classList.add("track-card");
|
||||
@@ -1714,14 +1827,20 @@ sendBtn.addEventListener("click", function(){
|
||||
|
||||
// ===== 多行输入框:自动增高 =====
|
||||
function autoGrow(){
|
||||
queueDraft();
|
||||
if(!inputDom.value){
|
||||
inputDom.style.height = '';
|
||||
return;
|
||||
}
|
||||
inputDom.style.height = "auto";
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
|
||||
const viewport = window.visualViewport;
|
||||
const height = viewport && Math.abs(viewport.scale - 1) < 0.01 ? viewport.height : window.innerHeight;
|
||||
const limit = isNarrow() && height > 0 ? Math.min(140, Math.max(44, Math.floor(height / 4))) : 140;
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
|
||||
}
|
||||
inputDom.addEventListener("input", autoGrow);
|
||||
window.addEventListener('pagehide', flushDraft);
|
||||
document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); });
|
||||
|
||||
inputDom.addEventListener("keydown",(e)=>{
|
||||
// Enter 发送,Shift+Enter 换行
|
||||
@@ -1777,12 +1896,31 @@ renderSidebar();
|
||||
|
||||
function syncChatViewport(){
|
||||
const viewport = window.visualViewport;
|
||||
chatContainer.style.height = viewport && isNarrow() && viewport.scale === 1
|
||||
? Math.round(viewport.height) + 'px' : '';
|
||||
const narrow = isNarrow();
|
||||
const unzoomed = !viewport || Math.abs(viewport.scale - 1) < 0.01;
|
||||
if(narrow && unzoomed){
|
||||
const height = viewport ? viewport.height : window.innerHeight;
|
||||
if(Number.isFinite(height) && height > 0){
|
||||
chatContainer.style.height = Math.round(height) + 'px';
|
||||
// Keyboard focus may pan the visual viewport without changing its height.
|
||||
chatContainer.style.top = Math.max(0, Math.round(viewport && viewport.offsetTop || 0)) + 'px';
|
||||
}
|
||||
}else{
|
||||
chatContainer.style.height = '';
|
||||
chatContainer.style.top = '';
|
||||
}
|
||||
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
|
||||
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
|
||||
autoGrow();
|
||||
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
|
||||
updateToBottom();
|
||||
}
|
||||
let viewportSyncQueued = false;
|
||||
function scheduleChatViewport(){
|
||||
if(viewportSyncQueued) return;
|
||||
viewportSyncQueued = true;
|
||||
requestAnimationFrame(function(){ viewportSyncQueued = false; syncChatViewport(); });
|
||||
}
|
||||
|
||||
toggleBtn.onclick = function(){
|
||||
sideOpen = !sideOpen;
|
||||
@@ -1810,7 +1948,21 @@ window.addEventListener("resize", function(){
|
||||
renderSidebar();
|
||||
syncChatViewport();
|
||||
});
|
||||
if(window.visualViewport) window.visualViewport.addEventListener('resize', syncChatViewport);
|
||||
if(window.visualViewport){
|
||||
window.visualViewport.addEventListener('resize', scheduleChatViewport);
|
||||
window.visualViewport.addEventListener('scroll', scheduleChatViewport);
|
||||
}
|
||||
window.addEventListener('pageshow', scheduleChatViewport);
|
||||
inputDom.addEventListener('focus', scheduleChatViewport);
|
||||
inputDom.addEventListener('blur', scheduleChatViewport);
|
||||
// Let toolbar clicks complete before blur changes the keyboard and composer layout.
|
||||
['pointerdown', 'mousedown'].forEach(function(name){
|
||||
document.querySelector('.composer').addEventListener(name, function(event){
|
||||
if(event.button === 0 && document.activeElement === inputDom && event.target.closest('button')){
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
syncChatViewport();
|
||||
|
||||
voiceInput = ChatVoice.create({
|
||||
@@ -1834,6 +1986,19 @@ voiceInput = ChatVoice.create({
|
||||
if(!await doSend()) voiceInput.setMode(false);
|
||||
}
|
||||
});
|
||||
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
|
||||
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
|
||||
onChange:function(user,previous){
|
||||
if(previous === undefined) return;
|
||||
if(previous && (!user || user.id !== previous.id)){
|
||||
resetConversation(null); chatDom.innerHTML=welcomeHTML();
|
||||
}
|
||||
loadHistory();
|
||||
},
|
||||
onError:function(message){ setComposerStatus(message,true); }
|
||||
});
|
||||
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
|
||||
customerLogin.refresh();
|
||||
window.addEventListener('resize', function(){
|
||||
if(!isNarrow()) voiceInput.setMode(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Customer login has no relationship to the protected staff workspace.
|
||||
var CustomerLogin = {
|
||||
create(options){
|
||||
const overlay=document.getElementById('customer-login');
|
||||
const form=document.getElementById('customer-login-form');
|
||||
const username=document.getElementById('customer-username');
|
||||
const password=document.getElementById('customer-password');
|
||||
const submit=document.getElementById('customer-submit');
|
||||
const cancel=document.getElementById('customer-cancel');
|
||||
const error=document.getElementById('customer-error');
|
||||
const account=document.getElementById('customer-account');
|
||||
let user, pending=null, opener=null, busy=false, version=0;
|
||||
overlay.hidden=true;
|
||||
function status(message){ error.textContent=message || ''; error.hidden=!message; }
|
||||
function render(){
|
||||
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerLogin');
|
||||
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
|
||||
cancel.textContent=options.text('customerCancel');
|
||||
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
|
||||
document.getElementById('customer-username-label').textContent=options.text('customerUsername');
|
||||
document.getElementById('customer-password-label').textContent=options.text('customerPassword');
|
||||
username.placeholder=options.text('customerUsernamePlaceholder');
|
||||
password.placeholder=options.text('customerPasswordPlaceholder');
|
||||
}
|
||||
function update(next){
|
||||
const previous=user;
|
||||
user=next;
|
||||
render();
|
||||
if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){
|
||||
options.onChange(user,previous);
|
||||
}
|
||||
}
|
||||
async function request(path, data){
|
||||
const controller=new AbortController();
|
||||
const timer=setTimeout(()=>controller.abort(),10000);
|
||||
try{
|
||||
const response=await options.fetch(path,{method:data === undefined ? 'GET' : 'POST',
|
||||
headers:{'Content-Type':'application/json'},body:data === undefined ? undefined : JSON.stringify(data),signal:controller.signal});
|
||||
const result=await response.json();
|
||||
if(!response.ok || !result.ok){
|
||||
const failure=new Error(result.error || result.e || options.text('customerLoginFailed'));
|
||||
failure.publicMessage=true;throw failure;
|
||||
}
|
||||
return result;
|
||||
}finally{ clearTimeout(timer); }
|
||||
}
|
||||
async function refresh(){
|
||||
if(busy) return;
|
||||
const token=++version;
|
||||
try{
|
||||
const result=await request('/api/customer/session');
|
||||
if(token === version) update(result.authenticated && result.user ? result.user : null);
|
||||
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
|
||||
}
|
||||
function close(){
|
||||
overlay.hidden=true;
|
||||
password.value='';
|
||||
pending=null;
|
||||
if(opener && opener.isConnected) opener.focus({preventScroll:true});
|
||||
opener=null;
|
||||
}
|
||||
function open(onSuccess){
|
||||
if(busy) return;
|
||||
pending=typeof onSuccess === 'function' ? onSuccess : null;
|
||||
opener=document.activeElement;
|
||||
status(''); password.value=''; overlay.hidden=false;
|
||||
if(options.onOpen) options.onOpen();
|
||||
render(); username.focus({preventScroll:true});
|
||||
}
|
||||
function setBusy(value){
|
||||
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
|
||||
username.disabled=value; password.disabled=value; render();
|
||||
}
|
||||
form.addEventListener('submit',async function(event){
|
||||
event.preventDefault();
|
||||
if(busy) return;
|
||||
const token=++version;
|
||||
setBusy(true);status('');
|
||||
try{
|
||||
const result=await request('/api/customer/login',{username:username.value,password:password.value});
|
||||
if(token !== version) return;
|
||||
update(result.user);
|
||||
const resume=pending;close();
|
||||
if(resume) await resume();
|
||||
}catch(failure){
|
||||
if(token === version) status(failure.publicMessage ? failure.message : options.text('customerLoginFailed'));
|
||||
}finally{ if(token === version) setBusy(false); }
|
||||
});
|
||||
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(); }
|
||||
if(event.key === 'Tab'){
|
||||
const controls=[username,password,submit,cancel].filter(control=>!control.disabled);
|
||||
if(!controls.length){ event.preventDefault();return; }
|
||||
const index=controls.indexOf(document.activeElement);
|
||||
const next=(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length;
|
||||
event.preventDefault();controls[next].focus({preventScroll:true});
|
||||
}
|
||||
});
|
||||
account.onclick=async function(){
|
||||
if(busy) return;
|
||||
if(!user){ open(); return; }
|
||||
const token=++version;setBusy(true);
|
||||
try{
|
||||
await request('/api/customer/logout',{});
|
||||
if(token === version) update(null);
|
||||
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
|
||||
finally{ if(token === version) setBusy(false); }
|
||||
};
|
||||
window.addEventListener('focus',refresh);
|
||||
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
|
||||
render();
|
||||
return {open:open,refresh:refresh,render:render,require:async function(resume){
|
||||
if(busy) return;
|
||||
await refresh();
|
||||
if(user){ await resume(); }else open(resume);
|
||||
},viewport:function(height,top){
|
||||
overlay.style.height=height || ''; overlay.style.top=top || '';
|
||||
overlay.style.bottom=height ? 'auto' : '';
|
||||
}};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Text drafts stay in this visitor's browser, independently for each conversation.
|
||||
var ChatDrafts = {
|
||||
create(options){
|
||||
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
|
||||
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
|
||||
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
|
||||
const memory = new Map();
|
||||
const dirty = new Set();
|
||||
let current = 'new';
|
||||
function id(value){
|
||||
if(value === null || value === 'new') return 'new';
|
||||
const key = String(value);
|
||||
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
|
||||
}
|
||||
function storage(name){
|
||||
try{ return options[name](); }catch(error){ return null; }
|
||||
}
|
||||
function read(value){
|
||||
const key = id(value);
|
||||
if(!key) return '';
|
||||
if(dirty.has(key)) return memory.get(key) || '';
|
||||
if(prefix){
|
||||
try{
|
||||
const saved = storage('storage').getItem(prefix + key);
|
||||
if(saved !== null){
|
||||
const data = JSON.parse(saved);
|
||||
if(data && data.version === 1 && typeof data.text === 'string'){
|
||||
memory.set(key, data.text);
|
||||
return data.text;
|
||||
}
|
||||
}
|
||||
memory.delete(key);
|
||||
return '';
|
||||
}catch(error){}
|
||||
}
|
||||
return memory.get(key) || '';
|
||||
}
|
||||
function write(value, text){
|
||||
const key = id(value);
|
||||
if(!key || typeof text !== 'string') return false;
|
||||
if(text) memory.set(key, text); else memory.delete(key);
|
||||
dirty.add(key);
|
||||
if(!prefix) return false;
|
||||
try{
|
||||
const target = storage('storage');
|
||||
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
|
||||
else target.removeItem(prefix + key);
|
||||
dirty.delete(key);
|
||||
return true;
|
||||
}catch(error){ return false; }
|
||||
}
|
||||
function active(value){
|
||||
if(arguments.length){
|
||||
current = id(value) || 'new';
|
||||
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
|
||||
}else if(activeKey){
|
||||
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
function clear(){
|
||||
memory.clear();
|
||||
dirty.clear();
|
||||
if(prefix){
|
||||
try{
|
||||
const target = storage('storage');
|
||||
for(let index = target.length - 1; index >= 0; index--){
|
||||
const key = target.key(index);
|
||||
if(key && key.startsWith(prefix)) target.removeItem(key);
|
||||
}
|
||||
}catch(error){}
|
||||
}
|
||||
active('new');
|
||||
}
|
||||
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
|
||||
}
|
||||
};
|
||||
+28
-8
@@ -1,5 +1,17 @@
|
||||
// Shared controls for the customer page and service workspace.
|
||||
var SupportUI = {
|
||||
initAttachments(container, label){
|
||||
container.addEventListener('error', function(event){
|
||||
const image = event.target;
|
||||
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
|
||||
image.dataset.unavailable = '1';
|
||||
image.hidden = true;
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'attachment-unavailable';
|
||||
hint.textContent = typeof label === 'function' ? label() : label;
|
||||
image.after(hint);
|
||||
}, true);
|
||||
},
|
||||
async enableNotifications(button){
|
||||
if(!('Notification' in window)) return;
|
||||
try{
|
||||
@@ -58,6 +70,10 @@ var SupportUI = {
|
||||
},
|
||||
initEmoji(button, panel, input, onInsert){
|
||||
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
|
||||
function close(){
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
button.onclick = function(){
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute('aria-expanded', String(!panel.hidden));
|
||||
@@ -70,20 +86,24 @@ var SupportUI = {
|
||||
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
|
||||
const end = input.selectionEnd == null ? start : input.selectionEnd;
|
||||
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
input.focus();
|
||||
close();
|
||||
input.focus({preventScroll:true});
|
||||
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||
if(onInsert) onInsert();
|
||||
};
|
||||
panel.appendChild(choice);
|
||||
});
|
||||
panel.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape'){
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.focus();
|
||||
['pointerdown','mousedown'].forEach(function(name){
|
||||
document.addEventListener(name, function(event){
|
||||
if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape' && !panel.hidden){
|
||||
close();
|
||||
button.focus({preventScroll:true});
|
||||
}
|
||||
});
|
||||
return {close:close};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,5 +45,5 @@ var VisitorSession = (function(){
|
||||
}
|
||||
return established ? send() : ensure().then(send);
|
||||
}
|
||||
return {fetch:request};
|
||||
return {fetch:request,localKey:localKey};
|
||||
})();
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ var ChatVoice = {
|
||||
toggle.onclick = function(){
|
||||
if(!mode && !supported()) return;
|
||||
setMode(!mode);
|
||||
if(!mode) input.focus();
|
||||
if(!mode) input.focus({preventScroll:true});
|
||||
};
|
||||
if(window.PointerEvent){
|
||||
hold.addEventListener('pointerdown', function(event){
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>附件暂时无法访问 · 柏悦物流</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<style>
|
||||
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
|
||||
main { width: min(420px, 100%); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
|
||||
h1 { font-size: 22px; margin-bottom: 14px; }
|
||||
p { color: var(--secondary); margin: 10px 0; }
|
||||
.english { font-size: 13px; }
|
||||
a { display: inline-block; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>附件暂时无法访问</h1>
|
||||
<p>文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。</p>
|
||||
<p lang="en" class="english">This attachment is unavailable. Please return to your conversation to upload it again or contact support.</p>
|
||||
<a href="{{ url_for('admin.admin_dash') if staff else url_for('pages.index') }}">{{ '返回客服工作台' if staff else '返回聊天' }}</a>
|
||||
</main></body>
|
||||
</html>
|
||||
+19
-7
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="theme-color" content="#315fd5">
|
||||
<title>柏悦智能物流客服系统</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||||
@@ -21,8 +21,8 @@
|
||||
</div>
|
||||
<div class="history-wrap" id="historyWrap"></div>
|
||||
<div class="sidebar-footer">
|
||||
<button type="button" id="customer-account" class="customer-account">登录</button>
|
||||
<button type="button" class="clear-all-btn">清空全部历史</button>
|
||||
<div class="sidebar-note"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceScope">中欧与英国专线咨询</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
|
||||
@@ -44,15 +44,13 @@
|
||||
<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>
|
||||
<h3>物流问题,随时为您解答</h3>
|
||||
<p>咨询运费、时效或联系人工客服。<br>订单查询需登录并关联客户账号。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="composer">
|
||||
<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">
|
||||
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
|
||||
<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" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
||||
@@ -61,19 +59,33 @@
|
||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或入仓码" placeholder="输入入仓码或问题…"></textarea>
|
||||
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
<p id="composer-status" class="composer-status" role="status" hidden></p>
|
||||
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></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>
|
||||
</main>
|
||||
</div>
|
||||
<div id="customer-login" class="customer-login-overlay" hidden>
|
||||
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="customer-login-title">
|
||||
<h2 id="customer-login-title">登录</h2>
|
||||
<form id="customer-login-form">
|
||||
<label for="customer-username" id="customer-username-label">账号</label>
|
||||
<input id="customer-username" name="username" type="text" autocomplete="username" placeholder="请输入账号" maxlength="80" required>
|
||||
<label for="customer-password" id="customer-password-label">密码</label>
|
||||
<input id="customer-password" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" maxlength="256" required>
|
||||
<p id="customer-error" role="alert" hidden></p>
|
||||
<div class="customer-login-actions"><button type="button" id="customer-cancel">取消</button><button type="submit" id="customer-submit">登录</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<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/drafts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
</body>
|
||||
|
||||
@@ -32,6 +32,7 @@ async function workspace(compact = false){
|
||||
setAttribute(name, value){ this.attributes[name] = value; }
|
||||
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
|
||||
appendChild(element){ this.children.push(element); }
|
||||
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
|
||||
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||
querySelectorAll(){ return []; }
|
||||
querySelector(selector){
|
||||
@@ -47,7 +48,7 @@ async function workspace(compact = false){
|
||||
const quickButtons = [new Element(), new Element()];
|
||||
document = {
|
||||
body: new Element(), activeElement: null,
|
||||
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
|
||||
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
|
||||
createElement(){ return new Element(); },
|
||||
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
|
||||
querySelector(selector){
|
||||
@@ -55,7 +56,7 @@ async function workspace(compact = false){
|
||||
if(!match) return null;
|
||||
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
|
||||
},
|
||||
addEventListener(name, callback){ documentListeners[name] = callback; },
|
||||
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
const calls = [];
|
||||
const hooks = {};
|
||||
|
||||
+418
-18
@@ -6,6 +6,14 @@ const vm = require('node:vm');
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
async function customerPage(pageOptions = {}){
|
||||
const storageValues = pageOptions.storage || {};
|
||||
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
|
||||
const tabValues = pageOptions.tabStorage || {};
|
||||
function testStorage(values){return {
|
||||
getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);},
|
||||
removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null,
|
||||
get length(){return Object.keys(values).length;}
|
||||
};}
|
||||
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
|
||||
let intervalId = 0, timerId = 0, nextConvId = 1;
|
||||
let voiceOptions;
|
||||
@@ -13,7 +21,7 @@ async function customerPage(pageOptions = {}){
|
||||
constructor(){
|
||||
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
|
||||
this.listeners = {};
|
||||
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -32,7 +40,10 @@ async function customerPage(pageOptions = {}){
|
||||
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
|
||||
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||
focus(){}
|
||||
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
|
||||
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
|
||||
blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } }
|
||||
select(){ document.activeElement=this; }
|
||||
querySelector(selector){
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
@@ -49,28 +60,30 @@ async function customerPage(pageOptions = {}){
|
||||
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
|
||||
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
|
||||
}
|
||||
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
}
|
||||
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
|
||||
element('emoji-panel').hidden=true;
|
||||
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
|
||||
const document = {
|
||||
body: new Element(), documentElement: {},
|
||||
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
|
||||
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
|
||||
createElement: () => new Element(),
|
||||
addEventListener: (name, callback) => { listeners[name] = callback; },
|
||||
execCommand: () => pageOptions.copyResult !== false,
|
||||
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
|
||||
let state = {status:'active', messages:[]};
|
||||
const context = vm.createContext({
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
|
||||
FormData:class { append(){} },
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||
window:{innerWidth:pageOptions.width || 1200,
|
||||
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:pageOptions.width ? pageOptions.width <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1]) : false}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
|
||||
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
|
||||
prompt:() => pageOptions.offlineMessage || null},
|
||||
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||
@@ -80,6 +93,7 @@ async function customerPage(pageOptions = {}){
|
||||
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(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);
|
||||
if(url === '/stream_chat'){
|
||||
@@ -92,14 +106,14 @@ 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/visitor/human/request' ? {ok:true, session_id:7}
|
||||
: 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/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: url === '/api/get_history' ? (pageOptions.history || []) : [];
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){
|
||||
for(const file of ['support.js', 'visitor.js', 'customer.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;
|
||||
@@ -110,17 +124,26 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
if(pageOptions.human !== false) await context.window.showHuman();
|
||||
await flush();
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
|
||||
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
|
||||
resize:async height => {
|
||||
context.window.innerHeight=height;
|
||||
if(context.window.visualViewport) context.window.visualViewport.height=height;
|
||||
(windowListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
resizeViewport:async (height,scale=1) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale});
|
||||
resizeViewport:async (height,scale=1,offsetTop=0) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale,offsetTop});
|
||||
(viewportListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
panViewport:async top => {
|
||||
context.window.visualViewport.offsetTop=top;
|
||||
(viewportListeners.scroll || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
|
||||
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
|
||||
setState:value => { state=value; },
|
||||
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
@@ -362,7 +385,7 @@ test('all three footer questions leave the human queue and send to separate AI c
|
||||
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.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);
|
||||
@@ -475,9 +498,9 @@ test('repeated contact clicks create one human request and show a visible queue
|
||||
});
|
||||
|
||||
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
|
||||
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
|
||||
const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
|
||||
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
|
||||
assert.match(app.element('chatDom').textContent, /留言已提交/);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
@@ -492,6 +515,13 @@ test('a failed human send preserves the text and makes retry possible', async ()
|
||||
assert.equal(app.element('send-button').disabled, false);
|
||||
});
|
||||
|
||||
test('a visitor whose phone says late at night can still reach an open support service', async () => {
|
||||
const app=await customerPage({human:false,hour:23});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.context.window.humanSid,7);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
});
|
||||
|
||||
test('a duplicate human send cannot submit an in-flight message twice', async () => {
|
||||
const app = await customerPage();
|
||||
let resolve;
|
||||
@@ -581,6 +611,22 @@ test('shipment cards separate forecasts from actual progress and retain both in
|
||||
assert.ok(log.querySelector('.msg-actions'));
|
||||
});
|
||||
|
||||
test('inbound codes are the primary card identity for live replies and restored history', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:8,title:'入仓轨迹',ctime:'now'}]});
|
||||
const card={kind:'order',no:'FL202610290001',status:'履约中',
|
||||
fields:[{key:'inbound',value:'INB20261029001-01'},{key:'number',value:'FL202610290001'}],
|
||||
nodes:[],summary:'入仓码 INB20261029001-01'};
|
||||
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(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/);
|
||||
});
|
||||
|
||||
test('order details can expand without hiding the shipment timeline', async () => {
|
||||
const app = await customerPage({human:false,width:390});
|
||||
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
|
||||
@@ -635,6 +681,93 @@ test('clearing a sent draft restores a single-row composer instead of sizing to
|
||||
assert.equal(input.style.height,'');
|
||||
});
|
||||
|
||||
test('keyboard panning keeps the whole mobile frame in the visual viewport and restores after closing', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text'), frame=app.element('.chat-container');
|
||||
input.focus();
|
||||
await app.resizeViewport(430,1,180);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
assert.equal(frame.style.top,'180px');
|
||||
assert.ok(frame.classList.contains('is-composing'));
|
||||
await app.panViewport(260);
|
||||
assert.equal(frame.style.top,'260px');
|
||||
assert.equal(frame.style.height,'430px');
|
||||
await app.resizeViewport(0,1,0);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
input.blur();
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(frame.style.top,'0px');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
assert.equal(frame.classList.contains('is-composing'),false);
|
||||
});
|
||||
|
||||
test('embedded browsers without visualViewport use the resized window and recover on pageshow', async () => {
|
||||
const app=await customerPage({human:false,width:390,height:844});
|
||||
const frame=app.element('.chat-container');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
await app.resize(410);
|
||||
assert.equal(frame.style.height,'410px');
|
||||
app.context.window.innerHeight=844;
|
||||
await app.pageShow();
|
||||
assert.equal(frame.style.height,'844px');
|
||||
app.context.window.innerWidth=1200;
|
||||
await app.resize(720);
|
||||
assert.equal(frame.style.height,'');
|
||||
assert.equal(frame.style.top,'');
|
||||
});
|
||||
|
||||
test('a multiline draft leaves room for messages when the keyboard reduces the viewport', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
input.value='多行草稿';
|
||||
input.scrollHeight=300;
|
||||
input.listeners.input();
|
||||
assert.equal(input.style.height,'140px');
|
||||
await app.resizeViewport(340);
|
||||
assert.equal(input.style.height,'85px');
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(input.style.height,'140px');
|
||||
});
|
||||
|
||||
test('late AI completion does not steal focus or reopen a dismissed mobile keyboard', async () => {
|
||||
for(const width of [390,1200]){
|
||||
const app=await customerPage({human:false,width,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
let reply;
|
||||
app.hooks['/stream_chat']=()=>new Promise(resolve=>{reply=resolve;});
|
||||
input.focus();
|
||||
await app.context.window.fillInput('报价问题');
|
||||
await flush();
|
||||
input.blur();
|
||||
app.element('history-search').focus();
|
||||
const before=input.focusCalls.length;
|
||||
reply(streamResponse(['data: {"t":"完成回复"}\n\ndata: {"d":1}\n\n']));
|
||||
await flush();await flush();
|
||||
assert.equal(input.focusCalls.length,before);
|
||||
assert.equal(app.context.document.activeElement,app.element('history-search'));
|
||||
assert.match(app.element('chatDom').textContent,/完成回复/);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile quick questions keep the keyboard closed and composer controls finish clicking before blur changes layout', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const input=app.element('input-text');
|
||||
await app.context.window.fillInput('快捷咨询');await flush();
|
||||
assert.equal(input.focusCalls.length,0);
|
||||
input.focus();
|
||||
for(const event of ['pointerdown','mousedown']){
|
||||
for(const name of ['send-button','voice-toggle','emoji-toggle','upload-btn']){
|
||||
let prevented=false;
|
||||
app.element('.composer').listeners[event]({button:0,target:{closest:()=>app.element(name)},preventDefault(){prevented=true;}});
|
||||
assert.ok(prevented);
|
||||
}
|
||||
}
|
||||
let blockedTextSelection=false;
|
||||
app.element('.composer').listeners.pointerdown({button:0,target:{closest:()=>null},preventDefault(){blockedTextSelection=true;}});
|
||||
assert.equal(blockedTextSelection,false);
|
||||
assert.equal(app.context.document.activeElement,input);
|
||||
});
|
||||
|
||||
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
|
||||
for(const human of [false,true]){
|
||||
const app=await customerPage({human,width:390});
|
||||
@@ -687,3 +820,270 @@ test('an AI regeneration is not forwarded to a human session', async () => {
|
||||
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
|
||||
assert.match(app.element('composer-status').textContent,/先返回 AI/);
|
||||
});
|
||||
|
||||
test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('反馈测试');await flush();
|
||||
const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn');
|
||||
const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎');
|
||||
let finish;
|
||||
app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;});
|
||||
const sending=up.onclick();
|
||||
await flush();
|
||||
await down.onclick();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1);
|
||||
assert.equal(up.classList.contains('voted'),false);
|
||||
assert.ok(up.disabled && down.disabled);
|
||||
finish(response({ok:false},503));await sending;
|
||||
assert.equal(up.classList.contains('voted'),false);
|
||||
assert.match(log.textContent,/评价未提交/);
|
||||
assert.equal(up.disabled,false);
|
||||
app.hooks['/api/feedback']=()=>response({ok:true});
|
||||
await up.onclick();
|
||||
assert.ok(up.classList.contains('voted'));
|
||||
assert.equal(up.getAttribute('aria-pressed'),'true');
|
||||
assert.match(log.textContent,/感谢您的评价/);
|
||||
await up.onclick();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2);
|
||||
});
|
||||
|
||||
test('clipboard rejection and a failed fallback never report a successful copy', async () => {
|
||||
const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
|
||||
await app.context.window.fillInput('复制测试');await flush();
|
||||
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
|
||||
app.element('input-text').focus();
|
||||
await copy.onclick();await flush();
|
||||
assert.equal(copy.textContent,'复制失败');
|
||||
assert.equal(copy.disabled,false);
|
||||
assert.equal(app.context.document.activeElement,app.element('input-text'));
|
||||
assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true);
|
||||
assert.equal(app.context.document.body.children.length,0);
|
||||
await app.runTimers(3000);
|
||||
assert.equal(copy.textContent,'复制');
|
||||
});
|
||||
|
||||
test('successful clipboard fallback reports copied and keeps text focus stable', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('复制测试');await flush();
|
||||
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
|
||||
await copy.onclick();
|
||||
assert.equal(copy.textContent,'已复制');
|
||||
assert.equal(copy.disabled,false);
|
||||
await app.runTimers(1200);
|
||||
assert.equal(copy.textContent,'复制');
|
||||
});
|
||||
|
||||
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
|
||||
const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '});
|
||||
await empty.context.window.showHuman();await flush();
|
||||
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'});
|
||||
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
|
||||
await app.context.window.showHuman();await flush();
|
||||
assert.equal(app.element('input-text').value,'需要保留的留言');
|
||||
assert.notEqual(app.element('input-text').style.height,'');
|
||||
assert.match(app.element('composer-status').textContent,/1–2000/);
|
||||
});
|
||||
|
||||
test('mobile history selection closes the drawer while desktop navigation stays visible', async () => {
|
||||
for(const width of [390,1200]){
|
||||
const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]});
|
||||
if(width===390) await app.click(app.element('.toggle-divider'));
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true');
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900));
|
||||
}
|
||||
});
|
||||
|
||||
test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel');
|
||||
await app.click(toggle);
|
||||
assert.equal(panel.hidden,false);
|
||||
app.documentEvent('pointerdown',{target:panel.children[0]});
|
||||
assert.equal(panel.hidden,false);
|
||||
app.documentEvent('pointerdown',{target:app.element('input-text')});
|
||||
assert.equal(panel.hidden,true);
|
||||
assert.equal(toggle.getAttribute('aria-expanded'),'false');
|
||||
await app.click(toggle);
|
||||
app.documentEvent('keydown',{key:'Escape'});
|
||||
assert.equal(panel.hidden,true);
|
||||
await app.click(toggle);
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
assert.equal(panel.hidden,true);
|
||||
});
|
||||
|
||||
test('drafts stay with their own conversation and restore across a page reload', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
assert.equal(app.element('input-text').value,'');
|
||||
app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
assert.equal(app.element('input-text').value,'A的未发送草稿');
|
||||
await app.pageHide();
|
||||
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
|
||||
await flush();
|
||||
assert.equal(restored.element('input-text').value,'A的未发送草稿');
|
||||
assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
await restored.click(restored.element('historyWrap').children[1]);
|
||||
assert.equal(restored.element('input-text').value,'B的未发送草稿');
|
||||
});
|
||||
|
||||
test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage});
|
||||
assert.equal(restored.element('input-text').value,'尚未发送');
|
||||
await restored.context.window.fillInput('尚未发送');await restored.pageHide();
|
||||
const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage});
|
||||
assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1');
|
||||
});
|
||||
|
||||
test('draft recovery stays silent but quota failure remains visible until storage recovers', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'}],app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='已保存草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
|
||||
assert.equal(restored.element('input-text').value,'已保存草稿');
|
||||
assert.equal(restored.element('composer-status').textContent,'');
|
||||
const setItem=restored.context.localStorage.setItem;
|
||||
restored.context.localStorage.setItem=()=>{throw Error('quota');};
|
||||
restored.element('input-text').value='新的未保存输入';restored.element('input-text').listeners.input();await restored.runTimers(250);
|
||||
assert.match(restored.element('composer-status').textContent,/无法保存/);
|
||||
assert.equal(restored.element('input-text').value,'新的未保存输入');
|
||||
restored.context.localStorage.setItem=setItem;await restored.pageHide();
|
||||
assert.equal(restored.element('composer-status').textContent,'');
|
||||
});
|
||||
|
||||
test('restoring an old conversation cannot replace a newer user-selected draft', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
let release;
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
await restored.click(restored.element('.new-chat-btn'));
|
||||
restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input();
|
||||
release(response([{id:9,title:'A',ctime:'now'}]));await flush();
|
||||
assert.equal(restored.element('input-text').value,'新话题草稿');
|
||||
});
|
||||
|
||||
test('failed history loading cannot send a question under a hidden conversation context', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.context.window.fillInput('原会话');await flush();
|
||||
app.hooks.messages=()=>response({ok:false},503);
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
|
||||
await app.context.window.fillInput('禁止发到未加载会话');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
|
||||
assert.match(app.element('composer-status').textContent,/会话未加载成功/);
|
||||
assert.doesNotMatch(app.element('chatDom').textContent,/原会话/);
|
||||
});
|
||||
|
||||
test('startup removes a deleted active conversation draft without erasing a newer edit', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}});
|
||||
assert.equal(noEdit.element('input-text').value,'');
|
||||
assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
let release;
|
||||
const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input();
|
||||
release(response([]));await flush();
|
||||
assert.equal(edited.element('input-text').value,'加载期间的新输入');
|
||||
await edited.context.window.fillInput('加载期间的新输入');
|
||||
assert.ok(!edited.calls.some(call=>call.url==='/stream_chat'));
|
||||
assert.match(edited.element('composer-status').textContent,/会话未加载成功/);
|
||||
});
|
||||
|
||||
test('deleting a conversation removes only its saved draft', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
|
||||
const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage});
|
||||
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
|
||||
});
|
||||
|
||||
test('an order login action resumes the original question and preserves a newer draft', async () => {
|
||||
const app=await customerPage({human:false});let authenticated=false;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? {id:1,name:'admin'} : null});
|
||||
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:{id:1,name:'admin'}});};
|
||||
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'账号范围内的订单结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
|
||||
await app.context.window.fillInput('查询ORDER20260001');await flush();
|
||||
const action=app.element('chatDom').querySelector('.customer-login-cta');assert.ok(action);
|
||||
app.element('input-text').value='下一条未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(action);assert.equal(app.element('customer-login').hidden,false);
|
||||
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('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
assert.equal(app.element('input-text').value,'下一条未发送草稿');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
|
||||
assert.match(app.element('chatDom').textContent,/账号范围内的订单结果/);
|
||||
assert.match(app.element('customer-account').textContent,/admin/);
|
||||
});
|
||||
|
||||
test('invalid customer credentials leave the login form open without sending another question', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.hooks['/api/customer/login']=()=>response({ok:false,error:'账号或密码不正确。'},403);
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='wrong';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
assert.equal(app.element('customer-login').hidden,false);
|
||||
assert.match(app.element('customer-error').textContent,/账号或密码/);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
|
||||
await app.click(app.element('customer-cancel'));
|
||||
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
});
|
||||
|
||||
test('a late login cannot replay an order into a newer conversation', async () => {
|
||||
const app=await customerPage({human:false});let release;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"先登录","login_required":true}\n\ndata: {"d":1}\n\n']);
|
||||
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
|
||||
await app.context.window.fillInput('查询ORDER20260001');await flush();
|
||||
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
|
||||
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,authenticated:true,user:{id:1,name:'admin'}}));await signing;
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
|
||||
assert.equal(app.element('input-text').value,'新话题');
|
||||
});
|
||||
|
||||
test('signing out clears visible order conversation and returns to guest mode', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}});
|
||||
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
await app.context.window.fillInput('ORDER20260001');await flush();
|
||||
assert.ok(app.element('chatDom').querySelector('.user-message'));
|
||||
await app.click(app.element('customer-account'));
|
||||
assert.equal(app.element('customer-account').textContent,'登录');
|
||||
assert.equal(app.element('chatDom').querySelector('.user-message'),null);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
|
||||
});
|
||||
|
||||
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
let shown;
|
||||
const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}};
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(image.hidden,true);
|
||||
assert.match(shown.textContent,/图片暂时无法访问/);
|
||||
const first=shown;
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(shown,first);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const vm=require('node:vm');
|
||||
const path=require('node:path');
|
||||
const context=vm.createContext({});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
|
||||
function storage(){
|
||||
const data=new Map();
|
||||
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
|
||||
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
|
||||
}
|
||||
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
|
||||
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
|
||||
}
|
||||
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
|
||||
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
|
||||
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
|
||||
const restored=drafts(undefined,local,tab);
|
||||
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
|
||||
assert.equal(restored.active(),'9');
|
||||
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
|
||||
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
|
||||
otherTab.remove(10);assert.equal(restored.read(10),'');
|
||||
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
|
||||
});
|
||||
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
|
||||
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
|
||||
local.setItem(key,value);assert.equal(a.read(9),'');
|
||||
}
|
||||
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
|
||||
});
|
||||
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
|
||||
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
|
||||
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
|
||||
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
|
||||
});
|
||||
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
|
||||
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
|
||||
const a=drafts(undefined,broken,broken);
|
||||
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
|
||||
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
|
||||
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
|
||||
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
|
||||
assert.equal(drafts(null,local).read(9),'');
|
||||
});
|
||||
@@ -14,8 +14,8 @@ function voicePage(options={}){
|
||||
setAttribute(name,value){this.attributes[name]=value;}
|
||||
addEventListener(name,callback){this.listeners[name]=callback;}
|
||||
setPointerCapture(){}
|
||||
focus(){}
|
||||
blur(){}
|
||||
focus(options){this.focusOptions=options;}
|
||||
blur(){this.blurred=true;}
|
||||
}
|
||||
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
|
||||
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
|
||||
@@ -70,6 +70,15 @@ test('hold and release transcribes audio and sends text once',async()=>{
|
||||
assert.equal(app.messages.length,1);
|
||||
});
|
||||
|
||||
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
|
||||
const app=voicePage();
|
||||
app.open();
|
||||
assert.ok(app.elements.input.blurred);
|
||||
app.open();
|
||||
assert.equal(app.elements.input.focusOptions.preventScroll,true);
|
||||
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
|
||||
});
|
||||
|
||||
test('sliding up cancels without uploading and supports another recording',async()=>{
|
||||
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
|
||||
assert.equal(app.messages.length,0);
|
||||
|
||||
@@ -378,6 +378,42 @@ class ApiTests(unittest.TestCase):
|
||||
self.client.delete('/api/clear_all')
|
||||
self.assertEqual(self.client.get('/api/get_history').json, [])
|
||||
|
||||
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
|
||||
for vote in ('up', 'down', 1, -1):
|
||||
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
|
||||
connection.commit(); connection.close()
|
||||
stats=self.client.get('/api/admin/stats').json
|
||||
self.assertEqual((stats['good'],stats['bad']),(3,3))
|
||||
|
||||
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
|
||||
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
|
||||
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
|
||||
with self.subTest(feedback=data):
|
||||
response=self.client.post('/api/feedback',json=data)
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
|
||||
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
|
||||
with self.subTest(ticket=data):
|
||||
response=self.client.post('/api/ticket',json=data)
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
for path in ('/api/feedback','/api/ticket'):
|
||||
response=self.client.post(path,data='{bad',content_type='application/json')
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
for table in ('feedback','tickets'):
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
|
||||
connection.close()
|
||||
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
|
||||
ticket=self.client.get('/api/tickets').json[0]
|
||||
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
|
||||
|
||||
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Customer login, tenant scope and sensitive-history lifecycle."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app, db, order_data
|
||||
from logistics_chat.customer_service import COOKIE_NAME
|
||||
|
||||
|
||||
class CustomerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||||
'ORDER_DATA_TENANT_ID':'','CUSTOMER_DEMO_TENANT_ID':'tenant-a','RATE_LIMIT_PER_MIN':1000})
|
||||
self.a,self.b=self.app.test_client(),self.app.test_client()
|
||||
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||
client.post('/api/visitor/session',json={'key':key})
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
order_data.import_records(connection,[{'order_no':'ORDER20260001','tenant_id':'tenant-a','gross_weight_kg':30},
|
||||
{'order_no':'ORDER20260002','tenant_id':'tenant-b','gross_weight_kg':99}])
|
||||
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
|
||||
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
|
||||
connection.commit();connection.close()
|
||||
|
||||
def login(self, client=None, username='admin'):
|
||||
return (client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'})
|
||||
|
||||
def events(self,message,conv=None,client=None):
|
||||
response=(client or self.a).post('/stream_chat',json={'msg':message,'conv_id':conv})
|
||||
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
|
||||
|
||||
def test_guest_must_login_then_customer_reads_only_the_linked_scope(self):
|
||||
with patch('logistics_chat.order_data.lookup_order') as lookup:
|
||||
self.assertTrue(self.events('查询ORDER20260001')[0]['login_required']);lookup.assert_not_called()
|
||||
self.assertTrue(self.login().json['ok'])
|
||||
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.events('查询ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
self.assertIn('未找到',self.events('查询ORDER20260002')[0]['t'])
|
||||
forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'})
|
||||
self.assertNotIn('99 kg',forged.get_data(as_text=True))
|
||||
|
||||
def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self):
|
||||
secure=self.app.test_client()
|
||||
secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost')
|
||||
response=secure.post('/api/customer/login',json={'username':'admin','password':'123456'},base_url='https://localhost')
|
||||
for flag in ('Secure','HttpOnly','SameSite=Lax'):
|
||||
self.assertIn(flag,response.headers['Set-Cookie'])
|
||||
self.login();token=self.a.get_cookie(COOKIE_NAME).value
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
self.assertNotEqual(connection.execute("SELECT password_hash FROM customer_users WHERE username='admin'").fetchone()[0],'123456')
|
||||
self.assertTrue(connection.execute('SELECT 1 FROM customer_sessions WHERE token_hash=?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone());connection.close()
|
||||
with patch('logistics_chat.customer_service.time.time',return_value=time.time()+9*3600):
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.a.post('/api/customer/logout');self.a.set_cookie(COOKIE_NAME,token)
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
|
||||
def test_customer_cookie_cannot_be_reused_by_another_guest_and_bad_logins_are_limited(self):
|
||||
self.login();self.b.set_cookie(COOKIE_NAME,self.a.get_cookie(COOKIE_NAME).value)
|
||||
self.assertFalse(self.b.get('/api/customer/session').json['authenticated'])
|
||||
self.b.post('/api/customer/logout')
|
||||
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||
for index in range(5):
|
||||
response=self.b.post('/api/customer/login',json={'username':'admin','password':'wrong'})
|
||||
self.assertEqual(response.status_code,403)
|
||||
self.assertEqual(self.login(self.b).status_code,429)
|
||||
|
||||
def test_order_history_is_hidden_after_logout_and_restored_only_to_its_customer_and_guest(self):
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id']
|
||||
self.events('ORDER20260001',cid)
|
||||
self.a.post('/api/customer/logout')
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||
self.assertTrue(self.events('继续查询',cid)[0]['login_required'])
|
||||
self.a.delete('/api/clear_all')
|
||||
self.login(username='other')
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.login()
|
||||
self.assertTrue(any('__CARD__' in row['content'] for row in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
|
||||
self.login(self.b)
|
||||
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
|
||||
def test_cross_site_login_logout_and_oversized_or_malformed_requests_fail(self):
|
||||
for path in ('login','logout'):
|
||||
self.assertEqual(self.a.post('/api/customer/'+path,json={'username':'admin','password':'123456'},
|
||||
headers={'Origin':'https://foreign.example'}).status_code,403)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json=[]).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json={'username':[],'password':'123456'}).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json={'padding':'x'*3000}).status_code,413)
|
||||
|
||||
def test_disabled_accounts_and_password_changes_invalidate_existing_sessions(self):
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.login().status_code,403)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=1 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET password_hash=password_hash||'changed' WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
|
||||
def test_demo_binding_does_not_pick_an_arbitrary_tenant_when_dataset_has_multiple(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
self.app.config['CUSTOMER_DEMO_LOCAL_ORDERS']=False
|
||||
self.assertTrue(self.login().json['ok'])
|
||||
reply=self.events('ORDER20260001')[0]
|
||||
self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||
self.login()
|
||||
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
|
||||
def test_tenant_reassignment_does_not_reveal_the_accounts_old_order_history(self):
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260001',cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='tenant-b' WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
|
||||
def test_demo_disable_revokes_default_login_without_disabling_real_customer_records(self):
|
||||
self.login();self.app.config['CUSTOMER_DEMO_ENABLED']=False
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.login().status_code,403)
|
||||
self.assertTrue(self.login(username='other').json['authenticated'])
|
||||
|
||||
def test_parallel_invalid_logins_cannot_bypass_the_attempt_budget(self):
|
||||
barrier=Barrier(6)
|
||||
def attempt(index):
|
||||
with self.app.test_client() as client:
|
||||
client.set_cookie('baiyue_visitor','1'*64)
|
||||
barrier.wait(timeout=5)
|
||||
return client.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
statuses=list(executor.map(attempt,range(6)))
|
||||
self.assertEqual(statuses.count(403),5);self.assertEqual(statuses.count(429),1)
|
||||
|
||||
def test_default_local_account_has_explicit_existing_tenants_and_real_users_remain_scoped(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertTrue(self.login().json['authenticated'])
|
||||
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
order_data.import_records(connection,[{'order_no':'ORDER20260003','tenant_id':'new-ungranted-tenant'}]);connection.commit();connection.close()
|
||||
self.assertIn('未找到',self.events('ORDER20260003')[0]['t'])
|
||||
self.login(username='other')
|
||||
self.assertIn('未找到',self.events('ORDER20260001')[0]['t'])
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
|
||||
def test_removing_a_tenant_grant_revokes_sessions_and_old_scope_history(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||
|
||||
def test_revoking_every_local_grant_is_not_undone_by_login_or_startup(self):
|
||||
from logistics_chat.customer_service import init_customer_tables
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute('DELETE FROM customer_tenant_grants');connection.commit()
|
||||
init_customer_tables(connection);connection.commit();connection.close()
|
||||
self.login()
|
||||
self.assertIn('尚未关联',self.events('ORDER20260001')[0]['t'])
|
||||
with self.app.app_context():
|
||||
connection=db.connect();self.assertEqual(connection.execute('SELECT COUNT(*) FROM customer_tenant_grants').fetchone()[0],0);connection.close()
|
||||
|
||||
def test_server_scope_narrowing_revokes_the_session_and_hides_broader_history(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||
self.app.config['ORDER_DATA_TENANT_ID']='tenant-a'
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Remote dev behavior on the modular backend and the light workspace."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -50,6 +50,19 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
|
||||
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
|
||||
|
||||
def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)):
|
||||
instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc)
|
||||
class HostUtcClock(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
|
||||
with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock):
|
||||
result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json
|
||||
self.assertEqual(result['ok'],expected)
|
||||
if not expected: self.assertTrue(result['closed'])
|
||||
|
||||
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
|
||||
sid = self.session()
|
||||
self.client.post('/api/human/accept', json={'sid': sid})
|
||||
|
||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
||||
def test_reply_workflows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs'], cwd=root,
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
|
||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@@ -67,12 +67,14 @@ class GrayConfigTests(unittest.TestCase):
|
||||
self.assertIn('auth_basic off;', body)
|
||||
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
|
||||
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
|
||||
'/api/customer/session','/api/customer/login','/api/customer/logout',
|
||||
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
|
||||
'/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'):
|
||||
with self.subTest(public_path=path):self.assertRegex(path,pattern)
|
||||
for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets',
|
||||
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
|
||||
'/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'):
|
||||
'/api/visitor/human/accept','/api/customer/users','/api/customer/session/extra',
|
||||
'/api/get_history/unknown','/api/unknown'):
|
||||
with self.subTest(staff_path=path):self.assertNotRegex(path,pattern)
|
||||
example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8')
|
||||
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
|
||||
|
||||
@@ -22,8 +22,6 @@ class OrderDataTests(unittest.TestCase):
|
||||
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
self.context = self.app.app_context()
|
||||
self.context.push()
|
||||
from flask import g
|
||||
g.customer_tenant_id = 'tenant-a'
|
||||
self.addCleanup(self.context.pop)
|
||||
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
|
||||
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
|
||||
@@ -32,6 +30,8 @@ class OrderDataTests(unittest.TestCase):
|
||||
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
|
||||
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
|
||||
self.import_rows([self.record])
|
||||
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||||
self.client.get('/api/customer/session')
|
||||
|
||||
def import_rows(self, rows, replace=False):
|
||||
connection = db.connect()
|
||||
@@ -61,9 +61,26 @@ class OrderDataTests(unittest.TestCase):
|
||||
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||||
self.assertEqual(order_data.extract_reference(text), [])
|
||||
events = self.events('帮我查询物流轨迹')
|
||||
self.assertIn('订单号', events[0]['t'])
|
||||
self.assertIn('入仓码', events[0]['t'])
|
||||
self.assertEqual(events[-1], {'d':1})
|
||||
|
||||
def test_direct_inbound_lookup_and_followup_keep_the_inbound_identity(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
card=self.events(self.record['inbound_code'],cid)[0]['card']
|
||||
self.assertEqual(card['no'],self.record['order_no'])
|
||||
self.assertTrue(card['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in card['fields'] if item['key']=='number'),self.record['order_no'])
|
||||
self.import_rows([{**self.record,'gross_weight_kg':'42.000'}],replace=True)
|
||||
followup=self.events('现在到哪了',cid)[0]['card']
|
||||
self.assertTrue(followup['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in followup['fields'] if item['key']=='weight'),'42.000 kg')
|
||||
|
||||
def test_missing_inbound_code_is_not_replaced_by_the_internal_order_number(self):
|
||||
self.import_rows([{**self.record,'inbound_code':None}],replace=True)
|
||||
card=self.events(self.record['order_no'])[0]['card']
|
||||
self.assertFalse(any(item['key']=='inbound' for item in card['fields']))
|
||||
self.assertTrue(card['summary'].startswith('订单号 '+self.record['order_no']))
|
||||
|
||||
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||||
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||||
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||||
@@ -86,7 +103,7 @@ class OrderDataTests(unittest.TestCase):
|
||||
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events = self.events('列出所有订单')
|
||||
self.assertIn('请提供订单号', events[0]['t'])
|
||||
self.assertIn('请提供入仓码', events[0]['t'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@ from logistics_chat import knowledge, pricing, tracking
|
||||
|
||||
class RuleTests(unittest.TestCase):
|
||||
def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self):
|
||||
self.assertIn('22KG+', knowledge.search_knowledge('英国空运价格'))
|
||||
for question in ('英国空运价格','英国空运多久','英国空运多少钱'):
|
||||
self.assertIn('22KG+', knowledge.search_knowledge(question))
|
||||
self.assertIn('21KG+', knowledge.search_knowledge('欧洲空运价格'))
|
||||
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
|
||||
|
||||
def test_sea_tier_boundary_and_minimum(self):
|
||||
|
||||
@@ -123,6 +123,20 @@ class StorageTests(unittest.TestCase):
|
||||
self.assertNotIn('..', name)
|
||||
self.assertFalse(result['is_image'])
|
||||
|
||||
def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self):
|
||||
uploads=self.root/'uploads'; uploads.mkdir()
|
||||
for name in ('ai.png','human.pdf','unused.txt'):
|
||||
file=uploads/name;file.write_bytes(b'fixture')
|
||||
os.utime(file,(time.time()-20*86400,time.time()-20*86400))
|
||||
connection=db.connect()
|
||||
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')")
|
||||
connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')")
|
||||
connection.commit();connection.close()
|
||||
file_service.cleanup_uploads()
|
||||
self.assertTrue((uploads/'ai.png').exists())
|
||||
self.assertTrue((uploads/'human.pdf').exists())
|
||||
self.assertFalse((uploads/'unused.txt').exists())
|
||||
|
||||
def test_invalid_and_oversized_uploads_are_rejected(self):
|
||||
self.assertEqual(file_service.upload_file({})[1], 400)
|
||||
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
|
||||
|
||||
+13
-1
@@ -119,12 +119,24 @@ class VisitorTests(unittest.TestCase):
|
||||
self.assertEqual(staff.data,b'A_PRIVATE')
|
||||
|
||||
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status',
|
||||
'按入仓码查轨迹','入仓码查询','入仓号查轨迹','Track by inbound code'):
|
||||
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
|
||||
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
|
||||
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
|
||||
lookup.assert_not_called();model.assert_not_called()
|
||||
|
||||
def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self):
|
||||
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')})
|
||||
url=response.json['url']
|
||||
(Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink()
|
||||
own=self.a.get(url);other=self.b.get(url)
|
||||
self.assertEqual(own.status_code,404)
|
||||
self.assertIn('附件暂时无法访问',own.get_data(as_text=True))
|
||||
self.assertIn('no-store',own.headers['Cache-Control'])
|
||||
self.assertEqual(own.data,other.data)
|
||||
self.assertNotIn(url,own.get_data(as_text=True))
|
||||
|
||||
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
|
||||
cid=self.conversation(self.a,'A_PRIVATE')
|
||||
for key in ('short','x'*64,'1'*65,[],{},123):
|
||||
|
||||
Reference in New Issue
Block a user