Compare commits

..
Author SHA1 Message Date
ouyangxq 0f01a4a833 优化连续追问的报价条件与订单指代 2026-11-06 12:32:41 +08:00
ouyangxq 33e166b104 补齐多项订单统计中的节点分布 2026-11-06 11:52:44 +08:00
ouyangxq 7b3b3d30ba 修复订单多项统计和海运快慢报价上下文 2026-11-06 11:48:41 +08:00
ouyangxq 311b9bd57d 移除常驻概览并通过聊天保存订单汇总 2026-11-06 11:17:50 +08:00
ouyangxq d5692434ac 完善美国首页和登录订单概览并修复语音提示抖动 2026-11-06 10:52:03 +08:00
ouyangxq 5f1c4043d4 修复报价上下文并完善订单节点和异常统计 2026-11-05 17:18:00 +08:00
ouyangxq 97ec6ed44b 隔离美国报价口径,完善条件更正和订单分页 2026-11-05 15:11:06 +08:00
ouyangxq f777867749 优化分仓报价和发货咨询,完善订单状态解释 2026-11-05 14:09:31 +08:00
ouyangxq 9c9c2c9cd5 补齐附加费用同义追问的报价上下文 2026-11-05 13:10:01 +08:00
ouyangxq 79fb675af5 完善报价业务问答与连续追问,补齐订单列表和人工转接 2026-11-05 13:06:01 +08:00
ouyangxq 880a8fb57c 接入真实美国通用价表并直接回复报价 2026-11-05 11:23:52 +08:00
ouyangxq a9404f01e2 优化入仓码查询入口与卡片交互 2026-11-04 19:37:17 +08:00
ouyangxq f17f355366 支持登录后按客户范围统计运单数量 2026-11-04 19:09:17 +08:00
ouyangxq 1a82894b6a 放行镜像测试使用的 Dockerfile 2026-11-04 18:49:52 +08:00
ouyangxq 6181760045 补齐代理回归所需的镜像测试资料 2026-11-04 18:48:55 +08:00
ouyangxq 80bb27515c 修复反向代理后客户共用限流与登录预算 2026-11-04 18:41:50 +08:00
ouyangxq af7b56b14b 区分订单状态追问与查货操作说明 2026-11-04 18:25:16 +08:00
ouyangxq a80895b75f 修复查货识别与登录过期后的会话恢复 2026-11-04 18:21:07 +08:00
117 changed files with 4974 additions and 6957 deletions
+1
View File
@@ -1,4 +1,5 @@
**
!Dockerfile
!requirements.txt
!am.py
!rates_extra.py
-2
View File
@@ -3,8 +3,6 @@
# The process environment takes precedence, including an explicitly empty value.
# Keep real credentials in .env; never put them in this example or source files.
DEEPSEEK_API_KEY=
# Daily verified SQLite backup while the application is running (Beijing date).
AUTO_BACKUP_ENABLED=true
# 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.
+9 -11
View File
@@ -9,21 +9,19 @@ 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 |
| My orders | `logistics_chat/customer_order_service.py`: scoped overview counts, status filters, read-only list and exact detail; `routes/customer.py`: `/api/customer/orders`; `static/js/customer_orders.js`: automatic login panel, pagination, tracking, logout cleanup |
| Staff login | `logistics_chat/staff_service.py`: opaque 24-hour sessions, default-deny endpoint allowlist, CSRF, login/logout; `routes/admin.py`; `static/js/admin.js` |
| Customer login / overview | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout; `order_listing.py`: `read_overview`, `answer_overview` for chat replies |
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees |
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
| Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries |
| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals and subset questions; `order_tracking.py`: `current_stage` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
| DB maintenance | `logistics_chat/db.py`: `SCHEMA_VERSION`, `MIGRATIONS`; `database_backup.py`: verified snapshots, daily worker, offline restore; `scripts/db_maintenance.py`; `docs/database-maintenance.md` |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall, five-minute queue timeout; `logistics_chat/routes/human.py` |
| Staff routing | `logistics_chat/routing_service.py`: groups, presence leases, one-active-customer automatic assignment, manual queue acceptance, owner/version checks and audit; `routes/routing.py`; `static/js/admin_routing.js`; [routing workflow](docs/staff-routing.md) |
| Offline tickets | `logistics_chat/ticket_service.py`: schema v3, `init_ticket_history`, owner-only claim/notes/completion, search, `ticket_summary`, `ticket_events`, overdue timers; `routes/business.py`; `static/js/admin_tickets.js`: drafts, alerts, timeline |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
| Customer attachments | `static/js/attachments.js`: paste/drop gestures, image viewer; `chat.js`: `uploadAttachment`, upload lifecycle |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView`; `admin_tools.js`: notes, transfer, search; `support.js`: emoji, notifications, rating |
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package |
| CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` |
@@ -70,4 +68,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`; 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 uses independent server-side sessions with absolute 24-hour expiry and CSRF checks; keep the Nginx perimeter, protect staff and unknown routes by default, and expose only the reviewed customer endpoint allowlist. Tests authenticate staff through the real login flow; never bypass authentication in testing. 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.
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.
+3 -2
View File
@@ -22,14 +22,15 @@ COPY logistics_chat/ ./logistics_chat/
COPY templates/ ./templates/
COPY static/ ./static/
COPY deploy/__init__.py deploy/wsgi.py ./deploy/
COPY scripts/import_order_test_data.py scripts/db_maintenance.py ./scripts/
COPY scripts/import_order_test_data.py scripts/import_quote_data.py ./scripts/
USER 10001:10001
EXPOSE 5000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=2).close()"]
CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--call", "deploy.wsgi:create_app"]
CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--trusted-proxy=*", "--trusted-proxy-count=1", "--trusted-proxy-headers=x-forwarded-for", "--call", "deploy.wsgi:create_app"]
FROM runtime AS test
COPY Dockerfile ./
COPY deploy/ ./deploy/
COPY tests/ ./tests/
COPY scripts/ ./scripts/
Vendored
+10
View File
@@ -26,6 +26,9 @@ pipeline {
if (!((params.DEBIAN_MIRROR ?: 'https://deb.debian.org') in ['https://deb.debian.org', 'https://mirrors.tuna.tsinghua.edu.cn'])) {
error('Select a supported Debian mirror')
}
if (params.IMPORT_QUOTE_DATA && !(params.QUOTE_SNAPSHOT_CREDENTIAL_ID ==~ /[A-Za-z0-9_.-]+/)) {
error('Select the explicitly supplied private quote snapshot credential')
}
}
sh '''set -eu
for file in deploy/*.sh; do bash -n "$file"; done
@@ -70,6 +73,13 @@ pipeline {
]) {
sh 'bash deploy/publish-gray.sh'
script {
if (params.IMPORT_QUOTE_DATA) {
withCredentials([
file(credentialsId: params.QUOTE_SNAPSHOT_CREDENTIAL_ID, variable: 'QUOTE_SNAPSHOT_FILE')
]) {
sh 'bash deploy/import-gray-quotes.sh'
}
}
if (params.IMPORT_ORDER_SAMPLES) {
withCredentials([
file(credentialsId: params.ORDER_SNAPSHOT_CREDENTIAL_ID, variable: 'ORDER_SNAPSHOT_FILE'),
+21 -26
View File
@@ -1,6 +1,6 @@
# 柏悦智能物流客服系统
基于 Flask + DeepSeek 的中欧/英国跨境物流智能客服,支持多轮对话、运费试算、运单查询、历史会话管理。
基于 Flask + DeepSeek 的跨境物流智能客服,支持美国通用报价、中欧/英国运费试算、多轮对话、运单查询、历史会话管理。
## 功能
@@ -22,9 +22,20 @@
## 报价数据
美国通用报价通过 `scripts/import_quote_data.py` 将本地 Excel 明细导出为私有快照,再导入运行 SQLite;不读取「快速查询」的外部工作簿,不按文件日期或旧船期停用。原始文件、价格快照及运行数据库均不进入 Git 或镜像。
`quote_books` 保存版本,`quote_rates` 保存线路/交货仓/目的仓或分区/税费方式/重量档位/计费单位,`quote_warehouses` 保留仓码的多种分区,`quote_postcodes` 保存带前导零的邮编及偏远类别,`quote_documents` 保存当前资料中的业务说明。每条价格保留源工作表和单元格。指定 `--activate` 才原子切换美国报价版本,导入不修改订单和客户范围。
```powershell
python scripts/import_quote_data.py --xlsx <本地报价.xlsx> --output <新私有快照.json>
python scripts/import_quote_data.py --snapshot <私有快照.json> --db <运行数据库.db> --activate
```
美国报价先由本地规则计算并通过既有 SSE 返回,不等待模型调用。仅给「美国30KG」可先看到不同海派、交货仓和美西/美中/美东的基础价格;提供仓码、邮编、箱数和尺寸后在当前会话继续核算。同一仓码有多种分区时并列展示;更换目的仓或邮编后不重用旧目的地。体积、附加费和承运限制尚不明确时继续补充条件,不编造缺失费率。现有欧洲/英国报价使用下述原有来源。
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。美国使用已激活的本地价表;未导入美国价表或其他未覆盖目的地时补充条件或由人工确认。英国按方报价不套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---|
@@ -146,7 +157,6 @@ baiyue-logistics-chat/
│ ├── history_service.py 会话 CRUD
│ ├── human_service.py 人工会话
│ ├── business_service.py 登录、统计、订单、留言、导出
│ ├── staff_service.py 客服登录、24 小时会话、接口鉴权及退出
│ ├── file_service.py 附件与清理
│ ├── hooks.py 限流和缓存头
│ ├── runtime.py 服务启动
@@ -227,30 +237,13 @@ py -3 scripts/close_active_sessions.py
| POST | `/api/rate` | 提交人工服务评价(`sid`、1–5 的 `stars`、`comment`) |
| GET | `/api/agents/list` | 客服列表,不包含密码 |
| GET | `/api/search_chat?q=` | 搜索历史消息,最多返回 50 条 |
| POST | `/api/ticket` | 客户提交离线留言,保留访客身份校验 |
| GET | `/api/tickets` | 后台留言列表,默认待处理;`status=all/pending/processing/completed`、`limit=1..100`、`before=id` 分页;`q` 按内容、电话、负责人和备注搜索,最多 100 字;`overdue=1` 只看超时 |
| GET | `/api/tickets/summary` | 后台全局待处理数量、超时总量及分类数量与最新留言编号;`after=id` 返回其后新增的留言数,不受搜索及分页影响 |
| GET | `/api/tickets/<id>` | 后台查看留言详情、负责人、备注和完成时间 |
| GET | `/api/tickets/<id>/events` | 后台处理记录(最新在前),含操作者、时间、备注前后值;`limit=1..100`、`before=事件id` 分页,默认 20 条 |
| POST | `/api/tickets/<id>/claim` | 当前登录客服领取待处理留言,需要 CSRF |
| PATCH | `/api/tickets/<id>` | 负责人更新备注或完成;传 `version`、`note` 和可选 `status=completed`,需要 CSRF |
| GET | `/admin` | 客服登录页面 |
| POST | `/admin/login` | 校验客服账号密码并签发 24 小时会话 |
| GET | `/api/admin/session` | 获取当前客服身份与 CSRF 令牌(需要后台登录) |
| POST | `/admin/logout` | 撤销当前后台会话(需要 CSRF 请求头) |
| POST | `/admin/login` | 校验客服账号密码 |
| GET | `/admin/dashboard` | 客服工作台 |
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
人工排队等候超过 5 分钟后,排队标签显示超时数量,队列卡片标注超时分钟数;首次进入超时状态时提醒一次。页面切到后台且已授权浏览器通知时会弹出系统通知。
离线留言处理位于工作台「资料与回复 → 离线留言」。点击留言查看详情,领取后自动设为当前客服负责并进入「处理中」;负责人可保存处理备注,填写处理结果后标记「已完成」。完成记录保留,可按状态筛选查询。重复或并发领取会被拒绝;其他客服只能查看,不能覆盖负责人备注。备注发生版本冲突时保留本页草稿,展示当前保存的内容,人工核对后才允许继续保存。列表每次加载 20 条,支持加载更早留言;查看更早留言期间可点击「刷新」返回最新列表。未保存备注在当前页面按留言保留,刷新整个网页或退出时不会持久保存草稿。
工作台顶部「留言」显示全局待处理数量,点击直达待处理列表。独立的 5 秒轮询不受搜索、查看旧页或保存备注影响;新留言到达后显示页面提示,首次打开仅显示已有待处理总数,不重复提醒旧记录。开启右上角浏览器通知后,页面在后台时也可收到新留言通知,提醒只含数量。搜索在数据库执行,支持手机号、留言内容、负责人账号/姓名和处理备注,可与状态及分页同时使用;输入 `%`、`_`、`\` 按普通字符匹配,不当作通配符。
工作台顶部「超时」显示全部未完成超时留言,点击查看;列表可组合「只看超时」、状态和搜索。待处理超过提交时间 30 分钟、处理中超过领取时间 20 分钟即提醒,按服务端本地时间计算;保存备注不重置领取计时,完成后不再计入。详情中的「处理记录」保留提交、领取、备注变更前后和完成结果,操作者取实际登录客服,变更和记录在同一事务提交;记录只对后台开放,不可从接口编辑或删除。旧留言升级时只保留已知状态,不补造历史;原处理中留言以升级前最后更新时间估算领取时间并在页面说明,缺少有效时间则提示无法计算。
SSE 每帧为一条 JSON,主要事件包括:
```text
@@ -280,19 +273,21 @@ data: {"d": 1}
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
```
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--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` 的取数实现即可保留聊天契约。
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--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`、`count_orders` 的取数实现即可保留聊天契约。
登录后可以直接问“我有多少个运单”“我的订单数量”“已完成的订单有多少”。统计每次按当前账号的授权范围查表,返回订单票数和其中已登记入仓码的票数;不要求先提供入仓码,也不沿用上一票订单作为统计范围。支持订单总量和明确的业务状态;其他筛选条件不冒充总量返回,头程订单完成不等于签收。统计会话与订单卡片一样绑定客户和范围,退出后隐藏,重新登录同一身份及范围后恢复。
点击“查入仓码”会打开查询框,填写号码后查询;游客先登录。回复及历史卡片中的入仓码也可点击,自动带入号码,可复制或查询最新轨迹。查询结果接在当前聊天中,草稿和待发送附件保留,人工接待中需要先返回 AI 问答。
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
数据库已提供版本迁移、升级前校验快照、SQLite 在线每日备份及离线恢复工具,详见 [数据库维护指南](docs/database-maintenance.md)。应用运行时按北京时间跨日备份,`AUTO_BACKUP_ENABLED=false` 可关闭后台每日任务;启动备份仍执行。附件与配置须另行备份。
## 当前限制
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
- 不预置客服账号;客服登录支持现有 PBKDF2/scrypt 密码哈希,兼容旧明文账号记录,不修改已保存密码。登录签发独立的 HttpOnly、SameSite=Lax Cookie,HTTPS 时启用 Secure;服务端 `staff_sessions` 只保存凭证摘要,后台登录从成功登录起 24 小时有效,到期需要重新登录。访问后台和重启服务不会延长有效期;浏览器 Cookie 同样保存 24 小时,退出、清理 Cookie 或浏览器自动清理后需重新登录。升级时撤销旧的无限期会话,重新登录后使用 24 小时规则。后台页面、接口、搜索、导出及客服附件统一校验会话,未明确列入客户白名单的新增路径默认需要客服登录。客户账号不能访问后台。
- 工作台右上角“退出登录”撤销当前凭证,不停止服务、不删除数据;密码变更或账号删除使旧会话失效。写操作校验 CSRF 请求头和请求来源;登录接口每个账号与连接来源组合在 5 分钟内最多尝试 5 次,成功后清除计数(进程内计数,多进程部署需代理统一限流)。登录凭证被撤销或丢失时停止后台轮询并回到登录页。Nginx 专用访问密码及上游回环绑定继续保留,客户聊天不要求后台密码。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
- 服务优先使用 Waitress 的 8 个线程;缺少 Waitress 时会回退到 Flask 开发服务器。公网部署前还需完善鉴权及部署配置。
+1 -1
View File
@@ -10,7 +10,7 @@ Group=baiyue
WorkingDirectory=/opt/baiyue-logistics-chat
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --call deploy.wsgi:create_app
ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --trusted-proxy=127.0.0.1 --trusted-proxy-count=1 --trusted-proxy-headers=x-forwarded-for --call deploy.wsgi:create_app
Restart=on-failure
RestartSec=3
TimeoutStopSec=90
+1 -1
View File
@@ -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))|customer/(?:session|login|logout|orders(?:/[0-9]+)?)|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};
}}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 077
: "${QUOTE_SNAPSHOT_FILE:?}"
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
: "${GRAY_DEPLOY_DIR:?}" "${SOURCE_COMMIT:?}" "${BUILD_NUMBER:?}"
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ && "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 2
root=$GRAY_DEPLOY_DIR
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
release_id="${SOURCE_COMMIT:0:12}-b$BUILD_NUMBER"
release="$root/releases/$release_id"
quote_sha=$(sha256sum "$QUOTE_SNAPSHOT_FILE" | awk '{print $1}')
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
scp "${ssh_options[@]}" "$QUOTE_SNAPSHOT_FILE" "$target:$release/quote-input.json"
ssh "${ssh_options[@]}" "$target" bash -s -- "$root" "$release_id" "$SOURCE_COMMIT" "$quote_sha" <<'IMPORT'
set -Eeuo pipefail
umask 077
root=$1
release_id=$2
source_commit=$3
quote_sha=$4
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release_id" =~ ^[a-f0-9]{12}-b[0-9]+$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$quote_sha" =~ ^[a-f0-9]{64}$ ]] || exit 2
release="$root/releases/$release_id"
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
input="$root/data/imports/quotes-$release_id"
owned_input=0
cleanup() {
rm -f -- "$release/quote-input.json"
if [[ "$owned_input" == 1 ]]; then
rm -f -- "$input/quotes.json"
rmdir -- "$input" 2>/dev/null || true
fi
}
trap cleanup EXIT
[[ "$(cat "$root/current-release")" == "$release" ]] || exit 2
container=baiyue-logistics-chat-gray
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container")" == "$source_commit" ]] || exit 2
printf '%s %s\n' "$quote_sha" "$release/quote-input.json" | sha256sum --check --status
[[ ! -e "$input" ]] || { echo 'Existing quote import input requires inspection.' >&2; exit 2; }
install -d -m 700 -o 10001 -g 10001 "$root/data/imports" "$input"
owned_input=1
install -m 600 -o 10001 -g 10001 "$release/quote-input.json" "$input/quotes.json"
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups")/("pre-quote-import-"+sys.argv[1]+".db"); p.parent.mkdir(exist_ok=True); assert not p.exists(); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p)); source.backup(target); target.close(); source.close(); print("Chat SQLite backup completed before quote import.")' "$release_id"
docker exec "$container" python scripts/import_quote_data.py \
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/quotes-$release_id/quotes.json" --activate
echo 'General quote version activated; order data and customer grants preserved.'
IMPORT
+1 -1
View File
@@ -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))|customer/(?:session|login|logout|orders(?:/[0-9]+)?)|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;
}
+41 -17
View File
@@ -23,19 +23,16 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| Jenkins 灰度部署 | `Jenkinsfile`、`Dockerfile`;`deploy/gray_config.py`:`GrayConfig`;`publish-gray.sh`、`activate-gray.sh`、`prepare-gray-tls.sh`、`renew-gray-tls.sh` | [部署指南](deployment.md);独立持久化数据、证书和回环端口;`wsgi.py` 提供容器内路径 |
| 请求钩子 | `logistics_chat/hooks.py`:`rate_limit`、`no_cache_html` | 工厂注册钩子;限流状态在 `app.extensions['rate_hits']` |
| SQLite 与维护 | `logistics_chat/db.py`:`connect`、`close_connections`、`init_db`、`backup_db`、`save_message`、`last_user_question`、`load_context` | 维护 CLI 在 `scripts/`;旧会话自动清理仍未启用 |
| 版本、备份、恢复 | `logistics_chat/db.py`:`SCHEMA_VERSION`、`MIGRATIONS`;`database_backup.py`:`snapshot_database`、`daily_backup`、`restore_database`、`DailyBackupWorker` | `scripts/db_maintenance.py`;[数据库维护](database-maintenance.md);启动先快照再事务迁移,测试不启动后台线程 |
| 历史接口 | `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/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`count_orders`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`、`count_orders`,不让模型拼 SQL |
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 客服分组与分配 | `logistics_chat/routing_service.py`:`assign_new_session`、`update_presence`、`reassign_session`;`routes/routing.py` | `static/js/admin_routing.js`;版本 5;默认综合客服,手动上线,20 秒心跳与 90 秒在线期限,空闲时自动接一个新客户、队列手动领取、原子领取与改派记录;[使用说明](staff-routing.md) |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
| 离线留言处理 | `logistics_chat/ticket_service.py`:`init_ticket_tables`、`init_ticket_history`、`get_ticket`、`list_tickets`、`claim_ticket`、`update_ticket`、`ticket_events` | `routes/business.py`;`static/js/admin_tickets.js`:`AdminTickets.create`;`tickets` 负责人、乐观锁与领取计时,`ticket_events` 同事务处理记录,迁移版本 3 |
| 客服登录会话 | `logistics_chat/staff_service.py`:`login`、`logout`、`attach_staff`、`PUBLIC_ENDPOINTS` | `staff_sessions` 只存凭证摘要;24 小时绝对有效期,密码指纹校验;工厂最先注册默认拒绝鉴权;`admin.js` 携带 CSRF、401 停止轮询并跳转 |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_dev_integration.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs`、`chat.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
@@ -81,6 +78,8 @@ tests/ # 按业务模块组织的回归
## 模块边界与依赖方向
连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
@@ -93,18 +92,11 @@ tests/ # 按业务模块组织的回归
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
- 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。
- 登录后由 `customer_orders.js` 自动展开只读“我的订单”面板,不切换聊天或改写草稿。`GET /api/customer/orders` 按 ID 倒序每页 20 条,以 `before` 游标继续;同时返回同一数据库快照下的全量授权范围 `summary`,不受分页或 `status` 筛选影响。状态分组为 `pending`(草稿、已提交、审核通过、已接单)、`processing`(履约中)、`completed`(头程订单已完成)、`cancelled`(已取消)与 `other`(驳回及未识别状态),`all` 查看全部;履约状态不推断实际运输状态。页面标题显示真实总数,列表另标已加载数量,点击概况重置游标并在服务端筛选。`GET /api/customer/orders/<id>` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。页面重新可见或窗口重新获得焦点时,先验证客户登录,再自动刷新当前状态筛选的第一页与全量概况;同次唤醒事件合并,已有订单请求进行中时不重复刷新。自动刷新保持面板展开状态、轨迹详情及聊天草稿,失败保留原列表并提供重试;退出/401 清空面板及概况,异步结果受请求版本约束;Nginx 与 Flask 的公开路由白名单仅开放这两个客户鉴权入口,旧 `/api/my_orders` 继续受后台保护。
- 客户订单错误按网络失败、10 秒本地超时/408/504、429 限流、5xx 服务异常及成功响应格式异常区分中英文提示。400/403/详情 404 不提供原请求重试,分别引导刷新、联系人工、返回订单列表;401 沿用清空数据并复核登录的流程。HTTP 状态优先于错误响应体,不直接显示服务端内部诊断;取消和迟到请求不得覆盖当前视图。
- 客户附件预览由 `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 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
- 人工排队接口按服务端北京时间计算 `waiting_minutes`,等候超过 5 分钟返回超时状态和超时分钟数。`loadQueue` 对每条会话仅在首次进入超时时通知一次,队列计数常驻标签,卡片标红;离开超时队列后清除去重状态。
- 人工会话持久化分组和负责人;仅在新会话创建时自动分配,选择有效登录且心跳有效、当前无活动会话的同组在线客服,依次比较上次分配时间与账号。每位客服自动接待一个进行中的客户,手动领取和指定负责人改派可多接;已排队或转回队列的会话始终等待手动接入。分配/领取/改派/记录在同一写事务内完成。后台活动列表仅返回本人负责会话;无负责人的旧活动会话在队列中供人工领取。改派版本与前端异步代次分别防止并发覆盖和迟到响应覆盖;旧负责人草稿保留在当前页面恢复区。分组管理沿用同级客服权限,不能代替客户租户授权;所有新接口默认受后台会话与 CSRF 保护。
- 离线留言的 `admin_tickets.js` 在 `admin.js` 之前加载,由后者注入带超时、CSRF 和登录失效处理的请求函数。后台按当前登录账号领取,领取原子比较待处理状态;仅负责人可更新处理中留言,版本冲突返回 409,已完成只读。页面草稿按留言 ID 和原始版本保留,轮询不会隐式重置草稿版本;冲突须人工核对。客户提交接口不接受负责人或处理状态,不能访问后台处理接口。
- 留言搜索由 `ticket_service.list_tickets(query=...)` 统一叠加状态及游标条件,LIKE 参数转义通配符;列表继续返回数组。`ticket_summary(after)` 单独提供不受筛选影响的总待处理数量与新增数量,使用状态索引和主键范围查询。`refreshSummary` 与列表轮询独立,不因查看旧页、搜索或写请求停止;首次结果作为提醒基线,之后只提醒超过已见编号的新记录。
- 留言超时由服务端按待领取 30 分钟、领取后处理 20 分钟计算,备注修改不重置 `claimed_at`;列表 `overdue=1` 与全局 summary 使用一致截止时间,完成记录不计入。处理事件与操作同事务保存,只有后台可读,以事件 ID 分页;前端独立加载记录并隔离迟到响应。旧数据只保留升级状态,领取时间估算有显式标识。
## 后续结构调整顺序
@@ -123,24 +115,56 @@ tests/ # 按业务模块组织的回归
- `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` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
- 登录后的订单数量由 `parse_count_query` / `order_query.parse_query` 识别,汇总、筛选和列表共用 `order_listing.read_orders` 的服务端 `tenant_filter` 交集与同一只读快照;`count_orders` 保留原聚合调用契约。默认给总数、已登记入仓码数及最多5条按来源更新时间排序的摘要;支持业务状态、明确标记的异常和当前实际节点。未指定节点的轨迹统计按订单分组,每票只计一次,多柜不同进度单列,预计节点不推进进度,业务完成不当作签收。未知条件不默认为全部,也不交给模型猜数。内部 `_customer_data` 和 `_order_list_state` 在发送前移除,事实历史绑定客户与范围;无新增公开API。
- 客户认证使用独立 `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 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出后清除当前可见订单内容,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 客服后台 JSON 请求由 `adminRequest` 统一设置 10 秒超时,包含响应正文读取;完成或失败后清理计时器。轮询超时释放锁、显示连接中断提示,后续每秒轮询自动恢复;发送超时保留当前草稿并恢复按钮,不自动重发,需先确认聊天记录再手动重试。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。美国使用已激活的本地通用价表;未导入美国价表或其他未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 客户页 `attachments.js` 管理输入框粘贴文件、聊天区拖放和图片预览;文件仍经 `chat.js` 的 `uploadAttachment` 走原上传接口,上传后等待手动发送,一次只接收一个附件。普通文字粘贴不拦截,聊天区外拖入文件只阻止浏览器跳转。图片通过页面内 `dialog` 放大、缩小和适应窗口,保留附件原有访问权限;切换会话或退出客户账号时关闭预览。
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 游客由 `visitor.js` 用安全随机数生成本地 key,经 `/api/visitor/session` 换取 HttpOnly、SameSite Cookie,HTTPS 时设置 Secure;浏览器存储不可用时可用 Cookie 恢复。数据库 `chat_history.visitor_id` 仅存 key 的 SHA-256,历史增删查、SSE 上下文和 `visitor_uploads` 附件索引均按归属校验。旧数据归属为空时保留,不自动授予新访客。客户端公共人工接口为 `/api/visitor/human/{request,poll,send,close}`,校验会话归属、只接受 customer 消息并去掉内部备注;原 `/api/human/*` 留给受保护客服。客服附件用 `/api/staff/uploads/`,所有客户 JSON 与附件禁止共享缓存。
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免代理密码。应用内 `staff_service.PUBLIC_ENDPOINTS` 按已注册端点列出公共入口,其余页面及接口统一校验独立客服会话;后台未登录时跳转 `/admin`,API 返回 401,写操作另校验 CSRF 与来源。`GET /api/admin/session` 返回当前客服和 CSRF 令牌,`POST /admin/logout` 撤销凭证;Cookie 为 HttpOnly、SameSite=Lax、HTTPS Secure,服务端及浏览器 Cookie 从登录起 24 小时有效,请求及重启不续期;升级时撤销没有签发时间的旧无限期凭证;退出、密码变化或删除账号使旧会话失效。上游仍只能绑定回环地址;客户登录与游客身份均不能升级为客服权限。
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录独立校验,游客身份不能升级为订单权限。
## 验证与维护
美国报价由 `quote_workbook.py` 导出当前 Excel 本地明细;`quote_data.py` 管理版本与 SQLite;`quote_intent.py` 解析单位、逐箱明细和业务意图;`quote_engine.py` 负责纯匹配/Decimal 计价;`quote_answers.py` 和 `quote_fees.py` 从当前版本说明读取业务收费;`quote_language.py` 翻译常规英文报价;`quote_service.py` 续接当前会话并通过 `answer_quote` 返回既有 `t`/`d` 事件。分流位置在订单权限检查之后、旧报价/模型分流之前。
新建库不预置价格;私有快照事务导入、显式激活,读取使用同一事务固定版本,后续查询读取更新后的价格。不访问外部工作簿,不按文件日期或旧船期停用。仓码重复分区并列;邮编存文本;上下文仅来自当前访客拥有的同一会话,新的完整问题、其他国家及订单/理赔话题构成边界。回复不包含原始快照、联系人、文件路径或旧船期,不新增公开导入/管理接口。
`quote_documents` 保存当前资料说明,复杂业务说明及尚未实现的附加费仍需逐项扩展匹配规则,不代表已全部自动结算。生产镜像必须显式包含 `scripts/import_quote_data.py`。
已确认口径:同件超重/超尺寸只取最高适用档,其他附加费分别计算;洛杉矶普船海卡体积保留两位、四舍五入;海外进口清关超过5个品名的续页按美元/个。原表大尺寸长度条件有误,不猜测下限,只有其他两条明确条件同时满足时自动应用该档,其余疑似大件转人工确认。报价不把提取时效称为签收时效。金额仍读取激活版本,不把私有费率写入源码。
订单列表使用与逐单查询相同的服务端租户交集,每次最多展示20票并保护其会话历史;未知筛选条件不默认为全部订单。改址/取消尚无业务写入接口,只明确引导人工,不声称修改成功。输入明确的转人工指令复用现有申请接口,失败保留草稿,不增加SSE事件类型。
`quote_allocations.py` 只识别明确分配给各仓的重量、箱数及箱规,逐仓计费及计算每箱最低重量;仅给总重和多个仓码时保留比较语义,不擅自分配重量。`quote_context.py` 在当前会话的有效上下文内按时间顺序应用更正,保留已算出的总重:重量纠正会清除旧分箱明细,单改箱数时统一箱重可沿用,混合箱重需重新确认;“总重不变”优先于旧每箱重量,新尺寸不沿用旧体积。按方报价不把按公斤的材质费直接乘CBM,金额未确定时明确单列待核算项。
`shipping_faq.py` 从激活版本回答交仓、标签、报关资料及查验费用问题;具体船期和计划发货日期需人工核对,不使用旧表日期承诺必达。`order_explanations.py` 只解释授权订单的业务状态和实际节点,不把预计节点当实际位置,不推断逐箱签收或亚马逊上架。文字事实回复与订单卡片同样绑定客户及范围;催单或拦截不声称已提交。本轮不扩展售后理赔规则。
`customer_faq.py` 在报价分流前处理下单、短信查单、开票、导出及附件读取等能力说明,不生成业务操作成功的假回复。美国常见费用/毛净重/议价/总价追问由当前价表直接回答;`quote_scope.py` 将尚未匹配的美国咨询保留为明确的核对回复,不送入旧欧洲/英国知识库。明确切换其他国家时仍走该地区原有分流,理赔规则未扩展。
`quote_edits.py` 先移除被明确否定的仓码或交货仓,再解析替换条件。共享尺寸解析支持英寸转换和口语长宽高,“一个箱子”可形成单箱条件;总计立方米不作为尺寸单位“米”。
`order_listing.py` 负责数量摘要、查看全部和翻页。数量摘要最多5票,直接翻页继续每页5票;明确列出订单或“查看全部”按每页20票。`chat_history.order_list_state` 保存筛选、页码、页大小和成功回复的消息ID,绑定客户与范围;只保存仍为最新消息的状态,迟到回复不能覆盖新话题。中间插问同条件总数保留列表页码,改筛选重置,新话题停止沿用。每次重新查询授权数据,不缓存数量;计数及分页来自同一只读快照,无有效上下文先引导查询,页码有界。
`order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。
用户询问“订单概览”“我的订单情况”时,`answer_overview` 在现有聊天流中回复,沿用订单事实的登录校验与会话保存。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。结果仅出现在聊天消息及对应历史中,登录和恢复登录只更新身份状态。
语音反馈使用输入框上方的绝对定位浮层,不占输入区布局高度,4秒后收起。切换对话清除反馈,同一手势失败后须松开才能重新录音,Pointer、Touch 与键盘入口遵循同一规则。
共享尺寸解析将单位与长宽高绑定,支持每边单位和混合单位,不受“12英寸显示器”等商品描述影响。报价沿当前会话回溯到最近话题边界,不按10轮截断;其他国家、新一票及订单话题隔离不变。“铁铝部分还是10KG”等口语仅在先前已明确计费重时更新该基数,既不覆盖整票总重,也不把未确认毛重直接当计费重。
已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。
铁铝附加费只按铁铝货物计费重计算。`quote_material.py` 区分整票铁铝、部分铁铝与未明确材质的金属,并将明确的“铁铝部分计费重”从整票毛重解析中分离。部分货物未提供计费重时不按整票加费;提供明确基数后可将该项人民币费用加到公斤或按方运费中。更改整票重量、箱数或箱规后清除旧的部分计费重,防止沿用过期基数。未明确金属是否属于铁铝时先核实材质,不把所有金属都认作铁铝。
先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。
路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
-40
View File
@@ -1,40 +0,0 @@
# 数据库升级、备份与恢复
普通启动会先在写锁保护下检查数据库版本。已有数据库先生成 `pre-migration-时间-随机值.db` 并执行 SQLite 完整性检查,再按 `MIGRATIONS` 的版本顺序升级;`schema_meta.schema_version` 记录成功版本。当前版本为 5;版本 2 为离线留言增加负责人、处理备注、更新时间、完成时间和并发版本号,版本 3 增加领取时间及处理记录表,版本 4 增加订单导入批次及撤销记录,版本 5 增加客服分组、在线心跳、会话负责人及分配记录,既有留言和客服账号保留。旧留言只生成一条升级状态快照,不补造历史;原处理中留言以有效的最后更新时间估算领取时间并标记,无法估算时保留为空。旧人工会话不猜测负责人,无负责人的活动会话可从排队列表手动接入。保留既有旧表兼容检查,因此同版本启动也会先保存快照再校验表结构。更新和版本号提交位于同一事务,失败回滚并终止启动;备份失败不执行升级,未来版本的数据库拒绝降级。首次空库无需升级前快照。
新增结构变更时,在 `logistics_chat/db.py` 增加顺序迁移函数并递增 `SCHEMA_VERSION`,加入 `MIGRATIONS`;迁移函数必须使用传入连接,不得自行提交、使用 `executescript` 隐式提交或执行外部副作用。补充旧库升级、失败回滚和重复启动测试。
备份采用 SQLite 在线 backup API,可包含 WAL 中已提交的数据,先写同目录临时文件,完整性检查通过后才发布为正式文件;默认备份文件由临时文件继承私有权限。目标文件不得覆盖。普通每日备份命名 `history_YYYYMMDD.db`,同一天跨进程只保留一个经过校验的快照,保留含当天在内最近 7 个日期的每日备份。启动前 `pre-migration-时间-随机值.db` 快照按文件名中的创建时间只保留最近 5 份,每次成功生成并校验新快照后自动清理更早的同类文件;生成失败不清理,规则从下次启动起生效。`pre-restore-*`、`manual-*` 不自动删除,按需另行归档清理。
启动成功后立即做当日备份;`AUTO_BACKUP_ENABLED=true`(默认)时启动独立后台线程,每分钟检查北京时间日期,跨日自动备份,失败写入应用日志并在下一分钟重试。不依赖网页访问,Windows、Linux 和 Docker 共用同一实现。应用停止期间后台任务不会运行,下次启动补做当日备份;如需脱离应用定时运行,可在系统任务计划中调用下方 `daily` 命令。测试模式不会启动后台线程。
本机数据库为项目根目录 `history.db`,备份目录为 `backups/`。下面命令不初始化应用,不调用模型,也不创建丢失的源数据库。
```powershell
# 手动创建并校验一个快照
.\.venv\Scripts\python.exe scripts/db_maintenance.py backup
# 当日备份(可供系统定时任务调用)
.\.venv\Scripts\python.exe scripts/db_maintenance.py daily
# 列出备份,不输出客户内容
.\.venv\Scripts\python.exe scripts/db_maintenance.py list
# 校验当前数据库或指定备份
.\.venv\Scripts\python.exe scripts/db_maintenance.py check
.\.venv\Scripts\python.exe scripts/db_maintenance.py check --input "D:\1\wuliu_chat\backups\manual-实际文件名.db"
```
服务器需显式指定运行数据库路径,避免误用源码目录下的空库:
```bash
python scripts/db_maintenance.py --db /var/lib/baiyue-logistics-chat/history.db --backup-dir /var/lib/baiyue-logistics-chat/backups daily
```
恢复会将数据库回到备份时刻,之后新增的记录仅保留在恢复前安全快照。先停止应用、定时写入脚本和所有 SQLite 工具,再执行;`--service-stopped` 是操作者对停机状态的显式声明,程序不会自动停止未知进程。输入必须是配置备份目录内的 `.db` 文件,拒绝损坏文件和路径越界。存在 `-wal`、`-shm`、`-journal` 时拒绝恢复:关闭所有数据库使用者并正常 checkpoint 后重试,不要直接删除正在使用的边车文件。
```powershell
# 停止服务后,用 list 中的真实文件名替换占位名称
.\.venv\Scripts\python.exe scripts/db_maintenance.py restore --input "D:\1\wuliu_chat\backups\manual-实际文件名.db" --service-stopped
```
恢复先生成并验证 `pre-restore-*` 当前库快照,然后暂存、校验目标备份,再原子替换数据库,最后手动重新启动项目。源备份不改变;当前库无法完成安全备份时拒绝覆盖。恢复旧版本后,正常启动会按版本规则升级。
数据库备份包含账号、订单、聊天、留言等 SQLite 数据,**不包含 `uploads/` 附件及 `.env` 配置**。附件需要另行复制到私有备份位置;运行数据库、快照和配置不能提交 Git。文件系统需支持同目录硬链接及原子替换,本机 NTFS 和常用 Linux 文件系统支持;不支持时备份失败会明确报错,不继续升级。
+2 -2
View File
@@ -4,7 +4,7 @@
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
游客历史、人工会话和附件已按访客归属隔离;客户订单按登录账号关联范围校验,匿名不能查询真实订单。客服后台另有独立的应用登录会话,自登录成功起 24 小时有效,访问后台和重启服务不延长期限,写请求校验 CSRF;退出或改密后旧凭证失效。必须继续沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
## 1. 域名与网络
@@ -68,7 +68,7 @@ systemd 自动创建并授权以下目录,重启与更新源码不会替换其
| 应用日志 | `/var/log/baiyue-logistics-chat/app.log` |
| 密钥配置 | `/opt/baiyue-logistics-chat/.env` |
首次启动创建所需业务表,已有表和记录保持兼容。已有数据库升级前生成校验快照并记录版本,失败回滚且不启动。启动执行当日备份,运行期间默认每分钟检查北京时间跨日并做每日备份;数据库恢复及服务器路径参数见 [数据库维护](database-maintenance.md)。附件清理只删除未被聊天引用的过期临时文件;附件及配置需另行备份。
首次启动创建八张业务表,不插入演示数据、默认客服账号或密码。已有表和记录保持兼容。应用当前仍会在启动时清理超过 7 天的上传文件,并执行启动备份;启动备份不能替代定时备份。
## 5. 申请 HTTPS 证书
+7 -5
View File
@@ -2,7 +2,9 @@
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服采用应用内 24 小时会话鉴权,并保留 Nginx 密码保护,不能裸露上游端口。客户订单访问按服务端客户登录及关联范围校验,匿名查单不会读取订单数据。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录独立验证账号及订单范围,匿名查单不会读取订单数据。
限流和登录失败次数按客户端 IP 计算。Nginx 必须用 `$remote_addr` 覆盖 `X-Forwarded-For`;Waitress 只信任这一项代理头和最后一跳地址,不接受代理头里的 Host、协议或更早的地址。容器镜像在仅由本机 Nginx 接入、宿主端口保持回环绑定的前提下使用 `--trusted-proxy=*`,以兼容 Docker 网关地址;systemd 入口只信任 `127.0.0.1`。直接运行 `am.py` 不接受客户端自报的代理 IP。代理规则参见 [Waitress 文档](https://docs.pylonsproject.org/projects/waitress/en/stable/reverse-proxy.html)。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
@@ -33,6 +35,8 @@
| `ASR_PROVIDER` | `baidu` 启用语音;空值关闭 |
| `BAIDU_ASR_API_KEY_CREDENTIAL_ID` / `BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID` | 本服务百度语音专用 Secret text 引用 |
| `IMPORT_ORDER_SAMPLES` | 默认关闭;明确需要补样本时才启用 |
| `IMPORT_QUOTE_DATA` | 默认关闭;本次明确导入通用美国报价才启用 |
| `QUOTE_SNAPSHOT_CREDENTIAL_ID` | 默认空;本次私有报价快照的临时 Secret file 引用 |
| `ORDER_SNAPSHOT_CREDENTIAL_ID` / `TRACKING_SNAPSHOT_CREDENTIAL_ID` | 本次导入的临时 Secret file 引用 |
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
@@ -43,6 +47,8 @@
## 发布步骤
报价导入使用 `IMPORT_QUOTE_DATA` 与临时 Secret file,调用 `deploy/import-gray-quotes.sh`:核对快照 SHA 和运行提交、备份 SQLite,再通过生产镜像中的 `scripts/import_quote_data.py --activate` 事务导入。该开关默认关闭,后续普通发布保留已导入数据;导入不修改订单、轨迹或客户范围。原始 Excel 与快照不进入 Git、Docker 上下文或镜像;导入结束清除临时文件,验收后移除临时 Jenkins 文件凭据。新增参数只追加,不改变既有构建参数及其默认值。
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
@@ -71,12 +77,8 @@
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
应用内另有升级前快照、版本迁移和运行中的每日数据库备份。维护 CLI 已包含在运行镜像,使用时指定 `/var/lib/baiyue-logistics-chat/history.db`;恢复必须停机并先保留当前数据安全快照,详见 [数据库维护](database-maintenance.md)。
新环境自动创建业务表,不创建默认客服账号或演示数据。专用访问密码用于客服工作台及受保护接口;Nginx 账号不是应用内客服账号。客户首页无需密码,客户端先自动建立访客会话,再访问自己的历史。
进入客服工作台还需使用 `agents` 中的应用账号登录。升级自动新增 `staff_sessions` 表,不覆盖已有账号密码;登录会话存储于数据库,自登录成功起 24 小时有效,浏览器 Cookie 同步保存 24 小时;访问后台或重启服务不会重置期限。升级时撤销旧无限期会话,需重新登录一次。退出、改密或删除账号后旧凭证不可再用。工作台写请求必须携带页面提供的 `X-CSRF-Token`;脚本调用后台 API 也必须先登录、保存 Cookie 并通过 `/api/admin/session` 获取 CSRF 令牌。每个账号与连接来源组合的登录限速为单进程计数,不直接信任客户端转发 IP 头,避免同一代理下某个账号输错导致其他账号被锁,多进程部署需配置统一代理限流。
## HTTPS 及续期
域名须解析到已核实的灰度入口,公网 80、443 端口可达。其他域名的证书不能直接用于新域名。
+2 -2
View File
@@ -34,11 +34,11 @@
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。
人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。
### 电话、联系方式、怎么联系、联系你们、微信
可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。
可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
---
@@ -1,16 +0,0 @@
# 数据库升级与备份实施计划
目标:保留当前账号、订单和聊天数据,完善升级前快照、版本记录、在线备份、每日备份及离线恢复。
技术方案:现有幂等建表逻辑保留为基线迁移,新增 `schema_meta` 记录版本;每次已有数据库启动校验前创建完整性校验过的快照。迁移在同一 SQLite 写事务内执行,失败回滚并拒绝启动。备份采用 SQLite backup API、临时文件及原子发布。应用运行期间每天北京时间跨日检查一次;失败记录日志并重试。恢复仅提供离线 CLI,要求显式声明服务已停止,先保护当前库,再从已校验快照恢复。
- [x] 编写升级顺序、回滚、损坏文件、WAL、并发及恢复测试,确认旧实现失败。
- [x] `logistics_chat/database_backup.py` 实现快照、校验、保留策略、每日任务和恢复。
- [x] `logistics_chat/db.py` 抽取现有建表逻辑,版本化事务升级、失败保护;启动工厂接入每日任务。
- [x] `scripts/db_maintenance.py` 提供 backup/daily/list/check/restore,支持独立目录及服务器路径。
- [x] 更新配置、维护文档和模块导航,使用临时数据验证恢复,不恢复真实业务库。
- [x] 运行相关测试和全量回归,复核后交付;不提交 Git,不推送。
验收:升级前快照包含升级前结构;备份失败不改库;迁移失败不留下半升级结构;拒绝未来版本;备份包含 WAL 中已提交记录;坏快照不可恢复;恢复前保留现有库;重复启动不丢数据;每日任务不依赖请求访问。
验证结果:存储与维护 27 项测试通过,全套 208 项测试通过(14 项 Linux 专用测试在 Windows 跳过);真实本机数据库副本升级演练保留账号密码和业务数量。人工备份和完整性检查已在本机成功运行,真实库未执行恢复。复核发现的 WAL 临时边车清理、恢复 checkpoint、版本步骤完整性与 7 日期保留边界均已补测试修正。
-42
View File
@@ -1,42 +0,0 @@
# 离线留言处理
用户已确认采用留言领取、负责人、处理备注和待处理/处理中/已完成三种状态。
实现与验证顺序:
- [x] 增加隔离数据库接口测试:旧数据迁移、登录与 CSRF、并发领取、负责人限制、备注与完成、版本冲突和分页。
- [x] 数据库升级至版本 2;迁移前沿用已校验快照和最近 5 份保留规则。增加负责人账号与姓名、备注、更新时间、完成时间和并发版本。
- [x] 新增 ticket_service,业务路由保持客户提交接口及留言列表默认返回数组的契约;新增后台领取和更新接口。
- [x] 后台服务资料增加状态筛选、留言详情、领取、保存备注、完成及加载更多;只有当前负责人可处理,已完成只读。
- [x] 前端沿用后台请求超时、CSRF 和登录失效处理;轮询不覆盖草稿,旧响应不覆盖新选择,冲突明确提示并保留草稿。
- [x] 执行接口、前端与全套回归;在临时数据库和客服账号上做桌面、手机浏览器验证。
验证:218 项 unittest 通过,14 项 Linux 测试跳过;admin 前端 31 项通过(含延迟列表响应、写请求超时与备注保留)。Playwright 临时服务在 1440/390/320px 验证真实登录、领取、负责人、备注轮询保留、保存、完成和刷新后的持久记录,未发现脚本错误或横向溢出。
独立审查无阻塞问题;另补 SQLite 64 位编号与游标范围校验,相关 8 项留言接口测试通过。临时浏览器和测试服务已关闭,真实服务未启动或重启。
## 后续:新留言提醒和搜索(用户确认预览后实施)
- [x] 顶部待处理数量入口及页内新留言提示;独立 summary 轮询,首次建立基线,新记录去重提醒。浏览器后台通知复用既有铃铛权限,不包含客户内容。
- [x] 数据库按手机号、内容、负责人账号/姓名、备注搜索;保留列表数组契约,搜索可组合状态与分页,转义 LIKE 通配符。
- [x] 真实浏览器验证新留言到达、搜索与全局数量、领取后数量更新、无结果状态、直达入口和键盘焦点。独立审查无阻塞问题,修正入口焦点落在加载期间被禁用按钮的问题。
- [x] 全套 222 项 unittest 完成(14 项 Linux 测试跳过);34 项 admin 前端测试通过,含中文输入法 Enter、独立提醒和分页搜索。桌面 1440px、手机 390/320px 无横向溢出,截图保存于 output/playwright/。
本次不变更数据库结构,无需新增迁移版本;正式服务未启动或重启。
## 本轮:处理记录和超时提醒
- [x] 数据库升级至版本 3:增加领取时间、领取时间估算标识及只追加的留言事件;提交、领取、备注前后值、完成结果与状态变更在同一事务记录。
- [x] 旧留言保留升级时状态快照,不伪造既有过程;旧处理中留言以规范有效的最后更新时间估算领取时间并明确标识。
- [x] 增加全局超时统计和筛选:待领取超过 30 分钟、处理中超过领取后 2 小时;备注修改不重置时钟,完成后清除超时。
- [x] 后台展示超时入口、筛选、详情超时说明及按时间排序的可分页处理记录;历史加载失败可重试且不阻塞留言处理。
- [x] 修复并测试无效旧日期在超时筛选、数量与详情中的一致性;独立只读审查未发现 Critical/Important 问题。
- [x] 17 项离线留言后端测试、38 项后台前端测试通过;完整 unittest 228 项通过、14 项按平台跳过,`git diff --check` 通过。Playwright 已验证桌面及 390/320px 手机宽度无横向溢出、记录刷新后保留、完成清除超时。
- [x] 后续将待领取阈值调整为 30 分钟(处理中仍为 20 分钟),并更新边界测试和用户说明。
- [x] 人工排队超过 5 分钟后标红显示超时分钟数,队列标签显示超时数;首次进入超时时去重提醒。34 项接口测试、39 项后台前端测试及全套 229 项 unittest(14 项按平台跳过)通过;浏览器验证 1440/390/320px 无横向溢出。
截图:`output/playwright/ticket-history-success.png`、`ticket-history-mobile-success.png`、`ticket-history-completed-success.png`、`ticket-overdue-success.png`。浏览器使用临时数据库和本地测试客服,测试服务已停止;正式服务未重启,升级真实数据库会在下次常规启动时执行。
人工排队截图:`output/playwright/human-queue-overdue-success.png`、`output/playwright/human-queue-overdue-mobile-success.png`。浏览器使用临时数据库和测试客服;测试服务已停止。
不变更真实客服账号、客户数据,不自动启动正式服务,不提交 Git。
-17
View File
@@ -1,17 +0,0 @@
# 后台登录会话实施计划
已确认范围:保留现有客服账号密码;后台会话有效期 8 小时;保护后台页面及接口;增加退出按钮;客户登录独立;保留 Nginx 保护。
1. 先加入未登录、退出、过期、密码变更、CSRF、凭证重放测试,确认原实现不能满足要求。
2. 新增 `staff_service.py` 和仅存凭证摘要的 `staff_sessions` 表,统一默认拒绝规则、登录限速及 Cookie 签发/撤销。
3. 工作台加入退出按钮、CSRF 请求头、401 跳转和轮询停止;保留原账号验证兼容行为。
4. 既有后台测试通过真实登录获取会话,公共客户测试保持独立。更新文档,运行全套测试。
5. 使用临时数据库和临时端口验证浏览器登录、退出、过期及移动端布局,关闭测试服务,不启动原本已停止的 5000 服务。
实现及复核:已加入服务端会话、CSRF、退出与过期处理;账号删除、更新及 SQLite REPLACE 均撤销旧凭证。后台登录限速按账号与连接来源组合计算,避免同一代理下账号互相锁定。
验证:全套 unittest 187 项,14 项 Linux 专用测试跳过;前端包含 26 项工作台测试。临时服务使用独立数据库,通过真实浏览器登录、退出失败重试、旧 Cookie 重放及过期跳转;320、390、1440px 布局无横向溢出,退出触控高度 44px。测试服务已关闭,5000 未启动。
用户后续调整:后台取消 8 小时限制,改为持续登录直到主动退出;改密、删除账号仍撤销凭证。Cookie 按浏览器支持的 400 天上限持久保存并在后台请求成功时续期,服务端凭证不按时间失效。启动时将仍有效的旧会话迁移为无限期,已经失效的会话删除,不恢复旧凭证。客户登录的 8 小时规则保持独立。
最新用户调整:后台登录改为从登录成功起 24 小时绝对有效,Cookie 同步设置 86400 秒;后台请求和服务重启均不续期。旧无限期会话无法确定签发时间,升级时撤销并要求重新登录;有效的有限期会话保留原期限,已失效会话不恢复。退出、改密及删除账号继续即时撤销。
-21
View File
@@ -1,21 +0,0 @@
# 客服分组与会话分配
客服登录后台后默认离线,在左侧「上线接待」手动开启自动分配;刷新页面保留当前登录会话已确认的状态。切换离线停止接收新会话,已接待的会话仍由原负责人处理。页面每 20 秒发送心跳,超过 90 秒无有效心跳,或登录过期、退出后,不再参与新分配;心跳恢复不会把已超时的客服自动上线,需要手动确认。
「客服分组与分配」打开服务资料中的配置区,可以创建分组、把已有客服加入某一组、查看在线状态及当前会话数。新咨询默认进入「综合客服」;目前不根据客户文字猜测业务组,客服可将会话改派至其他组。每个客服只属于一个组;改组不搬移已经接待的会话。新建客服账号沿用现有开户工具,本功能不创建账号或修改密码。
新人工咨询创建时只尝试一次自动分配:仅选择同组在线且当前没有活动会话的客服,先比较上次分配时间,再按账号名稳定排序。在线状态同时要求有效的后台登录会话。每位客服有一个进行中的客户后,不再自动接入新客户;手动领取和明确指定负责人的改派允许同时多接。没有合适客服时进入队列,保留现有五分钟排队超时提醒。已排队的客户始终等待手动接入;客服结束会话、上线、心跳、改组、刷新或客户重复申请均不会自动领取旧队列。客服再次空闲后仍可自动接入随后到来的新客户。
人工「接入会话」仍可手动领取未分配会话,在线状态仅控制自动分配。领取、自动分配和改派均在 SQLite 写事务中确认,竞争领取只有一人成功。只有会话负责人可发送、修改备注、撤回、转接或标记已读;待接入会话可由后台结束,客户仍可结束自己的会话。旧版无负责人的活动会话显示在排队列表供人工领取,不猜测历史负责人。
后台「管理分组与改派」选择会话、目标组及负责人;负责人只能选择目标组内的在线客服。未指定负责人时只转入目标组队列,等待手动接入。改派使用独立版本号,信息已改变时返回 409,需要重新选择会话确认,后台刷新不会偷偷更新正在编辑的版本。普通「转接」也回到队列,等待客服手动接入。
改派后旧负责人不能继续回复。当前页面检测到改派会移除旧接待入口,并在服务资料的「改派后保留的未发送草稿」中保留文字和备注,供复制;这些草稿只在当前页面内存中保存,刷新或退出不保留,也不会发给新负责人。
自动分配、领取、改派、转回队列和结束会话都与业务操作同事务写入 `routing_events`,记录操作者、分组、负责人及北京时间;后台展示最近 50 条,数据库保留全部记录。客户轮询不返回内部备注、客服账号或分配记录。当前后台沿用所有客服同级管理的权限模型,分组用于业务分派,不作为客户租户数据授权;管理层级及主管专属权限不在本次范围内。
数据库版本 5 新增 `support_groups`、`staff_routing`、`staff_presence`、`routing_events` 及 `human_sessions` 的分组、负责人、领取时间、版本号和上一负责人字段。`agents` 原有三列不变,兼容旧账号结构。正常启动时沿用升级前快照、完整性检查和事务迁移;本次不修改真实账号、订单或留言内容。
服务端实现:`logistics_chat/routing_service.py`、`routes/routing.py` 与 `human_service.py`。页面控制:`static/js/admin_routing.js`,由 `admin.js` 注入统一超时、CSRF 和登录失效处理,退出时统一停止心跳。
验证:`python -m unittest discover -s tests -p test_staff_routing.py -v`;前端运行 `node --test tests/frontend/admin.test.cjs tests/frontend/admin_routing.test.cjs`。所有客服测试通过真实登录接口获取会话和 CSRF,使用独立临时数据库。
+1 -9
View File
@@ -10,7 +10,7 @@ def create_app(config=None):
from flask import Flask
from .config import ROOT_DIR, default_config
from . import customer_service, db, file_service, hooks, staff_service, visitor_service
from . import customer_service, db, file_service, hooks, visitor_service
from .routes import register_routes
from .routes.speech import MemoryAudioRequest
@@ -24,9 +24,6 @@ def create_app(config=None):
app.extensions['rate_hits'] = defaultdict(list)
app.extensions['customer_login_attempts'] = {}
app.extensions['customer_login_lock'] = Lock()
app.extensions['staff_login_attempts'] = {}
app.extensions['staff_login_lock'] = Lock()
app.before_request(staff_service.attach_staff)
app.before_request(hooks.rate_limit)
app.before_request(visitor_service.attach_visitor)
app.before_request(customer_service.attach_customer)
@@ -52,9 +49,4 @@ def create_app(config=None):
db.init_db()
db.backup_db()
file_service.cleanup_uploads()
if not app.testing and app.config['AUTO_BACKUP_ENABLED']:
from .database_backup import DailyBackupWorker
worker = DailyBackupWorker(app)
app.extensions['database_backup_worker'] = worker
worker.start()
return app
+20 -8
View File
@@ -1,6 +1,7 @@
"""Feedback, reporting, login, orders and offline tickets."""
from datetime import datetime
from .db import connect, now_str
from .tracking import card_to_text
@@ -54,19 +55,30 @@ def add_ticket(data):
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
if not isinstance(phone, str) or len(phone.strip()) > 32:
return {'ok': False, 'error': '联系电话格式无效。'}, 400
from .ticket_service import create_ticket
return create_ticket(content.strip(), phone.strip())
conn = connect()
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}
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
from .ticket_service import list_tickets as query_tickets
return query_tickets(status, limit, before, query, overdue)
def list_tickets():
conn = connect()
rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall()
conn.close()
return [{'id': r[0], 'content': r[1], 'phone': r[2], 'ctime': r[3]} for r in rows]
def admin_login(data):
# Retain the service entry point; all credential/session rules live together.
from .staff_service import login
return login(data)
u = data.get('username', '')
p = data.get('password', '')
conn = connect()
row = conn.execute('SELECT name FROM agents WHERE username=? AND password=?', (u, p)).fetchone()
conn.close()
if row:
return {'ok': True, 'name': row[0]}
return ({'ok': False}, 401)
def agents_list():
+18 -2
View File
@@ -13,6 +13,7 @@ from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
is_shipping_destination_reply)
from .tracking import CARD_PREFIX
from .order_data import answer_query, extract_reference
from .quote_service import answer_quote
@@ -90,8 +91,18 @@ def create_chat_stream(msg, 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:
from .customer_faq import answer_capability
order_answer = answer_capability(msg)
if order_answer is None:
order_answer = answer_quote(msg, conv_id)
if order_answer is None and re.search(r'(?:转|联系|接入|找).*人工|人工客服',msg):
order_answer = {'t':'点击“联系人工”可发起人工客服申请;非工作时间可留言。'}
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
if order_answer is None:
from .quote_scope import answer_us_fallback
order_answer = answer_us_fallback(msg,conv_id)
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or
re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
@@ -103,9 +114,11 @@ def create_chat_stream(msg, conv_id):
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
if order_answer is not None:
customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer
order_list_state=order_answer.pop('_order_list_state',None)
def track_stream():
if 'card' in order_answer:
if customer_data:
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})
@@ -113,7 +126,10 @@ def create_chat_stream(msg, conv_id):
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'])
save_message(conv_id, 'assistant', content)
message_id=save_message(conv_id, 'assistant', content)
if order_list_state is not None:
from .order_listing import save_state
save_state(conv_id,order_list_state,message_id)
yield sse(order_answer)
yield sse({'d': 1})
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
-1
View File
@@ -18,7 +18,6 @@ def default_config():
'DATABASE': ROOT_DIR / 'history.db',
'UPLOAD_DIR': ROOT_DIR / 'uploads',
'BACKUP_DIR': ROOT_DIR / 'backups',
'AUTO_BACKUP_ENABLED': str(os.environ.get('AUTO_BACKUP_ENABLED', local_config.get('AUTO_BACKUP_ENABLED', 'true'))).lower() in ('1','true','yes'),
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'ASR_API_KEY': str(os.environ.get('ASR_API_KEY', local_config.get('ASR_API_KEY')) or '').strip(),
+29
View File
@@ -0,0 +1,29 @@
"""Describe supported customer actions without inventing operational integrations."""
import re
def is_acknowledgement(message):
text=re.sub(r'[\s,,。.!!??]','',message).lower()
return bool(re.fullmatch(r'(?:好的?|嗯+|明白了?|了解了?|知道了?|收到|谢谢(?:你|您)?|多谢|没问题|'
r'ok(?:ay)?|thanks?|thankyou|gotit|understood)+',text))
def answer_capability(message):
if is_acknowledgement(message):
return {'t':'好的,有需要随时告诉我。' if re.search(r'[\u4e00-\u9fff]',message) else 'You are welcome. Let me know if you need anything else.'}
if re.search(r'照片|图片',message) and re.search(r'报价|价格|判断|识别|尺寸',message):
return {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
if re.search(r'(?:手机号|手机号码|电话号码).*(?:查|订单|运单)|(?:查|查询).*手机号',message):
return {'t':'当前请使用客户账号登录后,按入仓码查询订单;暂不支持手机号或短信验证码查单。没有入仓码可联系人工核实订单归属。'}
if re.search(r'下单(?!号|时间|日期)|订舱|生成订单|创建订单|预约提货|安排提货|\b(?:place|create)\s+(?:an?\s+)?order\b',message,re.I):
return {'t':'下单、订舱和安排提货需要联系人工办理。当前可查询报价及已关联订单,尚未创建订单或安排提货;请提供目的仓、品名、箱数、重量和尺寸供人工核对。'}
if re.search(r'开(?:发票|票)|发票.*(?:税点|几个点|怎么开|申请)|月结|账期|付款账号|收款账号|银行账号|打款|(?:怎么|如何)付款',message):
return {'t':'开票税点、月结资格及付款方式需要由专属业务员确认。请联系人工核对对应订单和结算信息;当前聊天不提供未经确认的税点或收款账号。'}
if re.search(r'导出.*(?:订单|运单|Excel)|(?:订单|运单).*导出',message,re.I):
return {'t':'当前聊天暂不提供订单Excel导出,可联系人工核对导出需求。可先查看账号内订单列表,或发送入仓码查询单票详情。'}
if re.search(r'Excel|表格',message,re.I) and re.search(r'直接读|自动读|读取|识别|读里面',message):
return {'t':'当前聊天不会自动读取Excel附件里的箱规。请将每箱重量、长宽高及目的仓码粘贴为文字,我可据此核算;批量表格可交人工整理。'}
if re.search(r'换.*手机|换.*设备|跨设备|清除.*(?:浏览器|缓存)',message) and re.search(r'聊天|记录|历史',message):
return {'t':'当前聊天记录与原浏览器的访客身份关联,换手机或清除浏览器数据后不会自动同步恢复。请先在原设备查看;客户账号目前用于订单权限校验,尚未提供跨设备聊天同步。'}
return None
-74
View File
@@ -1,74 +0,0 @@
"""Read-only customer order browsing; scope comes exclusively from verified sessions."""
import sqlite3
from contextlib import contextmanager
from .customer_service import effective_tenants
from .db import connect
from .order_data import OrderDataUnavailable, build_card
from .visitor_service import customer_tenant_ids
PAGE_SIZE = 20
SUMMARY_FIELDS = ('id', 'order_no', 'inbound_code', 'status', 'country_code',
'warehouse_code', 'transport_mode')
STATUS_GROUPS = {
'pending': ('DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED'),
'processing': ('PROCESSING',), 'completed': ('COMPLETED',), 'cancelled': ('CANCELLED',),
}
FILTERS = ('all', *STATUS_GROUPS, 'other')
@contextmanager
def order_connection():
connection = None
try:
connection = connect()
connection.row_factory = sqlite3.Row
# Summary and list must describe the same snapshot, including during imports.
connection.execute('BEGIN')
yield connection
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
if connection is not None:
connection.close()
def scoped_rows(connection, columns, suffix='', values=()):
tenants = effective_tenants(customer_tenant_ids())
if not tenants:
return []
placeholders = ','.join('?' for _ in tenants)
return [dict(row) for row in connection.execute(
f'SELECT {columns} FROM order_test_data WHERE tenant_id IN ({placeholders}) '+suffix,
(*tenants, *values))]
def list_orders(before=None, status='all'):
if status not in FILTERS:
raise ValueError('Invalid order status filter')
summary = dict.fromkeys(('total', *STATUS_GROUPS, 'other'), 0)
suffix, values = '', ()
if status in STATUS_GROUPS or status == 'other':
codes = STATUS_GROUPS.get(status, tuple(code for codes in STATUS_GROUPS.values() for code in codes))
suffix = 'AND status '+('NOT IN' if status == 'other' else 'IN')+' ('+','.join('?' for _ in codes)+') '
values = codes
if before is not None:
suffix += 'AND id<? '
values += (before,)
with order_connection() as connection:
for row in scoped_rows(connection, 'status,COUNT(*) AS count', 'GROUP BY status'):
group = next((key for key, codes in STATUS_GROUPS.items() if row['status'] in codes), 'other')
summary[group] += row['count']
summary['total'] += row['count']
rows = scoped_rows(connection, ','.join(SUMMARY_FIELDS), suffix+'ORDER BY id DESC LIMIT ?', values+(PAGE_SIZE+1,))
return {'ok': True, 'orders': rows[:PAGE_SIZE], 'summary': summary,
'next_before': rows[PAGE_SIZE-1]['id'] if len(rows) > PAGE_SIZE else None}
def order_detail(order_id):
if not 0 < order_id <= 9223372036854775807:
return None
with order_connection() as connection:
rows = scoped_rows(connection, '*', 'AND id=? LIMIT 1', (order_id,))
return build_card(rows[0]) if rows else None
-169
View File
@@ -1,169 +0,0 @@
"""Verified SQLite snapshots and offline restore, independent of Flask lifecycle."""
from contextlib import closing
from datetime import datetime, timedelta, timezone
import os
from pathlib import Path
import re
import sqlite3
import tempfile
from threading import Event, Thread
import time
import uuid
CHINA_TIME = timezone(timedelta(hours=8))
def read_connection(path):
return sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro', uri=True, timeout=30)
def check_database(path):
"""Read-only full integrity check; never create a missing source."""
with closing(read_connection(path)) as connection:
rows = connection.execute('PRAGMA integrity_check').fetchall()
if rows != [('ok',)]:
raise sqlite3.DatabaseError('Database integrity check failed')
return 'ok'
def snapshot_database(source, destination):
"""Include committed WAL data, verify, then publish without overwriting a file."""
source, destination = Path(source).resolve(), Path(destination).resolve()
if source == destination:
raise ValueError('Backup must differ from source database')
if destination.exists():
raise FileExistsError(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix='.sqlite-backup-', suffix='.tmp', dir=destination.parent)
os.close(descriptor)
temporary = Path(temporary)
deadline = time.monotonic() + 120
def progress(status, remaining, total):
if time.monotonic() > deadline:
raise TimeoutError('SQLite backup exceeded 120 seconds')
try:
with closing(read_connection(source)) as original, closing(sqlite3.connect(temporary)) as target:
original.backup(target, pages=256, progress=progress, sleep=0.05)
# Publish a standalone file even when the source database uses WAL.
target.execute('PRAGMA journal_mode=DELETE')
check_database(temporary)
with temporary.open('r+b') as handle:
os.fsync(handle.fileno())
# Hard-link publication is atomic and refuses an existing destination.
os.link(temporary, destination)
return destination
finally:
temporary.unlink(missing_ok=True)
for suffix in ('-wal', '-shm', '-journal'):
Path(str(temporary) + suffix).unlink(missing_ok=True)
def unique_backup_path(directory, prefix):
timestamp = datetime.now(CHINA_TIME).strftime('%Y%m%d-%H%M%S-%f')
return Path(directory) / f'{prefix}-{timestamp}-{uuid.uuid4().hex[:8]}.db'
def prune_migration_snapshots(directory, keep=5):
"""Call after publishing a verified snapshot, under the startup write lock."""
if keep < 1:
raise ValueError('At least one migration snapshot must be retained')
snapshots = []
for candidate in Path(directory).iterdir():
if (re.fullmatch(r'pre-migration-\d{8}-\d{6}-\d{6}-[0-9a-f]{8}\.db', candidate.name)
and not candidate.is_symlink() and candidate.is_file()):
snapshots.append(candidate)
# Generated names sort by their creation timestamps, independently of copied mtimes.
for candidate in sorted(snapshots, key=lambda path: path.name, reverse=True)[keep:]:
candidate.unlink(missing_ok=True)
def daily_backup(database, directory, now=None):
now = now or datetime.now(CHINA_TIME)
directory = Path(directory)
destination = directory / ('history_' + now.strftime('%Y%m%d') + '.db')
if destination.exists():
check_database(destination)
else:
try:
snapshot_database(database, destination)
except FileExistsError:
# Another worker published the same day's complete snapshot.
check_database(destination)
cutoff = now.date() - timedelta(days=6)
for candidate in directory.iterdir():
match = re.fullmatch(r'history_(\d{8})\.db', candidate.name)
if not match or candidate.is_symlink() or not candidate.is_file():
continue
try:
old = datetime.strptime(match[1], '%Y%m%d').date()
except ValueError:
continue
if old < cutoff:
candidate.unlink(missing_ok=True)
return destination
def restore_database(database, backup, directory):
"""Offline only: keep current state, stage a verified copy, and replace atomically."""
database, backup, directory = Path(database).resolve(), Path(backup).resolve(), Path(directory).resolve()
if backup.parent != directory or backup == database or backup.suffix != '.db':
raise ValueError('Restore input must be a .db file inside the configured backup directory')
check_database(backup)
# Avoid silently detaching a live WAL/journal from a replacement database.
for suffix in ('-wal', '-shm', '-journal'):
if Path(str(database) + suffix).exists():
raise RuntimeError('Database has SQLite sidecar files; stop all users and checkpoint before restoring')
safety = None
if database.exists():
safety = snapshot_database(database, unique_backup_path(directory, 'pre-restore'))
# Reading a clean WAL database may create coordination files. Let SQLite
# checkpoint/remove these under the declared offline maintenance window.
with closing(sqlite3.connect(database, timeout=30)) as current:
current.execute('PRAGMA wal_checkpoint(TRUNCATE)')
current.execute('PRAGMA journal_mode=DELETE')
for suffix in ('-wal', '-shm', '-journal'):
if Path(str(database) + suffix).exists():
raise RuntimeError('SQLite sidecars remain; database is not ready for offline restore')
staged = database.with_name('.restore-' + uuid.uuid4().hex + '.db')
try:
snapshot_database(backup, staged)
os.replace(staged, database)
finally:
staged.unlink(missing_ok=True)
return safety
class DailyBackupWorker:
"""One stoppable task per app; daily files also coordinate independent processes."""
def __init__(self, app):
self.app = app
self.stop_event = Event()
# First tick rechecks startup's backup, including a startup crossing midnight.
self.last_date = None
self.thread = Thread(target=self.run, name='sqlite-daily-backup', daemon=True)
def tick(self, now=None):
now = now or datetime.now(CHINA_TIME)
if now.date() == self.last_date:
return False
daily_backup(self.app.config['DATABASE'], self.app.config['BACKUP_DIR'], now)
self.last_date = now.date()
return True
def run(self):
while not self.stop_event.wait(60):
try:
self.tick()
except Exception:
self.app.logger.exception('Daily SQLite backup failed; will retry in 60 seconds')
def start(self):
self.thread.start()
def stop(self):
self.stop_event.set()
self.thread.join(timeout=2)
+33 -73
View File
@@ -1,14 +1,14 @@
"""SQLite schema, history persistence and backups; initialized explicitly by create_app."""
import os
import re
import shutil
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from .config import get_setting
from .tracking import card_to_text
from .database_backup import check_database, daily_backup, prune_migration_snapshots, snapshot_database, unique_backup_path
SCHEMA_VERSION = 5
def connect():
@@ -36,49 +36,9 @@ def now_str():
def init_db():
Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True)
conn = connect()
try:
# A reserved write lock serializes snapshots and schema migration across workers.
conn.execute('BEGIN IMMEDIATE')
has_tables = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT 1").fetchone()
version = 0
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_meta'").fetchone():
row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
if row:
version = int(row[0])
if version > SCHEMA_VERSION:
raise RuntimeError('Database schema is newer than this application; refusing downgrade')
if version < 0:
raise RuntimeError('Invalid database schema version')
if [number for number, operation in MIGRATIONS] != list(range(1, SCHEMA_VERSION + 1)):
raise RuntimeError('Database migration registry has missing or unordered versions')
if has_tables:
snapshot_database(get_setting('DATABASE'), unique_backup_path(get_setting('BACKUP_DIR'), 'pre-migration'))
prune_migration_snapshots(get_setting('BACKUP_DIR'), keep=5)
conn.execute('CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
for target_version, migration in MIGRATIONS:
if version < target_version <= SCHEMA_VERSION:
migration(conn)
conn.execute("INSERT OR REPLACE INTO schema_meta VALUES('schema_version',?)", (str(target_version),))
if version == SCHEMA_VERSION:
# Retain legacy schema reconciliation for tables maintained by older tools.
_initialize_schema(conn)
_upgrade_ticket_workflow(conn)
_upgrade_ticket_history(conn)
_upgrade_staff_routing(conn)
conn.execute("INSERT OR REPLACE INTO schema_meta VALUES('schema_version',?)", (str(SCHEMA_VERSION),))
if conn.execute('PRAGMA quick_check').fetchall() != [('ok',)]:
raise sqlite3.DatabaseError('Database validation failed after migration')
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _initialize_schema(conn):
"""Version 1 baseline reconciliation preserves supported legacy table layouts."""
cur = conn.cursor()
# Serialize schema inspection and migration when multiple workers start together.
cur.execute('BEGIN IMMEDIATE')
cur.execute('''
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -94,6 +54,8 @@ def _initialize_schema(conn):
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 'order_list_state' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN order_list_state TEXT')
if 'customer_scope' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
if 'customer_tenant_id' in cols:
@@ -189,40 +151,36 @@ def _initialize_schema(conn):
init_order_table(conn)
from .customer_service import init_customer_tables
init_customer_tables(conn)
from .staff_service import init_staff_tables
init_staff_tables(conn)
from .quote_data import init_quote_tables
init_quote_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()))
def _upgrade_ticket_workflow(conn):
from .ticket_service import init_ticket_tables
init_ticket_tables(conn)
def _upgrade_ticket_history(conn):
from .ticket_service import init_ticket_history
init_ticket_history(conn)
def _upgrade_order_admin(conn):
from .order_admin_service import init_order_admin_tables
init_order_admin_tables(conn)
def _upgrade_staff_routing(conn):
from .routing_service import init_routing_tables
init_routing_tables(conn)
MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow),
(3, _upgrade_ticket_history), (4, _upgrade_order_admin), (5, _upgrade_staff_routing))
conn.commit()
conn.close()
def backup_db():
"""Verified daily online snapshot; failures propagate rather than claiming success."""
return daily_backup(get_setting('DATABASE'), get_setting('BACKUP_DIR'))
"""启动时把数据库备份到 backups/,文件名带日期,只保留最近 7 天"""
try:
bdir = get_setting('BACKUP_DIR')
os.makedirs(bdir, exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
dst = os.path.join(bdir, f'history_{today}.db')
if not os.path.exists(dst):
shutil.copy2(get_setting('DATABASE'), dst)
cutoff = datetime.now() - timedelta(days=7)
for f in os.listdir(bdir):
m = re.match('history_(\\d{8})\\.db$', f)
if not m:
continue
try:
if datetime.strptime(m.group(1), '%Y%m%d') < cutoff:
os.remove(os.path.join(bdir, f))
except Exception:
pass
except Exception as e:
print(f'[数据库备份失败] {e}')
def cleanup_old_conversations():
@@ -276,6 +234,7 @@ def save_message(conv_id, role, content):
if not inserted:
conn.close()
return 'deleted'
message_id=cur.lastrowid
if role == 'user':
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
if row and (not row[0] or row[0] == '新对话'):
@@ -283,6 +242,7 @@ def save_message(conv_id, role, content):
cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id))
conn.commit()
conn.close()
return message_id
def last_user_question(conv_id):
+1 -4
View File
@@ -2,13 +2,10 @@
import time
from flask import current_app, g, jsonify, request
from flask import current_app, jsonify, request
def rate_limit():
# Authenticated staff polling has a separate trust boundary from public traffic.
if getattr(g, 'staff', None):
return None
path = request.path
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
return None
+12 -95
View File
@@ -1,16 +1,11 @@
"""Human conversation lifecycle; typing state is supplied by the app."""
import time
from datetime import datetime, datetime as DateTime, timedelta, timezone
from math import ceil
from flask import g
from datetime import datetime, timedelta, timezone
from .db import connect
from . import routing_service
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
QUEUE_TIMEOUT_SECONDS = 5 * 60
def human_request(data):
@@ -32,24 +27,21 @@ def human_request(data):
else:
cur = conn.execute('INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)', (conv_id, 'waiting', now))
sid = cur.lastrowid
routing_service.assign_new_session(conn, sid)
status = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()[0]
conn.commit()
conn.close()
return {'ok': True, 'session_id': sid, 'status': status}
return {'ok': True, 'session_id': sid}
def human_poll(sid, after, typing_map):
conn = connect()
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
st = conn.execute("SELECT status, COALESCE(note, ''),owner_username FROM human_sessions WHERE id=?", (sid,)).fetchone()
st = conn.execute("SELECT status, COALESCE(note, '') FROM human_sessions WHERE id=?", (sid,)).fetchone()
conn.close()
is_typing = False
ts = typing_map.get(str(sid))
if ts and time.time() - ts < 5:
is_typing = True
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
'owner_username': st[2] if st else None,
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
@@ -71,11 +63,6 @@ def human_send(data):
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
conn.close()
return ({'ok': False, 'error': 'conversation_changed'}, 409)
if getattr(g, 'staff', None):
error = routing_service.owner_error(conn, sid, g.staff['username'])
if error:
conn.close()
return error
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
conn.commit()
conn.close()
@@ -83,68 +70,33 @@ def human_send(data):
def human_queue():
now = datetime.now(SERVICE_TIMEZONE).replace(tzinfo=None)
conn = connect()
rows = conn.execute("SELECT h.id,h.conv_id,h.status,h.ctime,h.group_id,g.name FROM human_sessions h "
"JOIN support_groups g ON g.id=h.group_id WHERE h.status='waiting' "
"OR (h.status='active' AND h.owner_username IS NULL) ORDER BY h.id").fetchall()
rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall()
conn.close()
queue = []
for row in rows:
try:
created = DateTime.fromisoformat(row[3])
if created.strftime('%Y-%m-%d %H:%M:%S') != row[3]:
raise ValueError('Invalid queue timestamp')
elapsed = max(0, int((now-created).total_seconds()))
except (ValueError, TypeError):
elapsed = 0
overdue = elapsed > QUEUE_TIMEOUT_SECONDS
queue.append({'id':row[0], 'conv_id':row[1], 'ctime':row[3],
'group_id':row[4], 'group_name':row[5],
'waiting_minutes':elapsed//60, 'overdue':overdue,
'overdue_minutes':max(1,ceil((elapsed-QUEUE_TIMEOUT_SECONDS)/60)) if overdue else 0})
return queue
return [{'id': r[0], 'conv_id': r[1], 'ctime': r[3]} for r in rows]
def human_active():
conn = connect()
rows = conn.execute("SELECT h.id,h.conv_id,h.owner_username,h.group_id,g.name FROM human_sessions h "
"JOIN support_groups g ON g.id=h.group_id WHERE h.status='active' "
"AND h.owner_username=? ORDER BY h.id", (g.staff['username'],)).fetchall()
rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall()
conn.close()
return [{'id': r[0], 'conv_id': r[1], 'owner_username':r[2], 'group_id':r[3], 'group_name':r[4]} for r in rows]
return [{'id': r[0], 'conv_id': r[1]} for r in rows]
def human_accept(data):
sid = data.get('sid')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
row = conn.execute("SELECT group_id,owner_username,status FROM human_sessions WHERE id=? "
"AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)", (sid,)).fetchone()
if not row or row[2] == 'closed' or (row[1] and row[1] != g.staff['username']):
conn.close()
return {'ok': False, 'error': '会话已由其他客服接入或已结束,请刷新。'}, 409
if not row[1]:
# Explicit manual claims remain available when staff are offline.
routing_service.assign_owner(conn, sid, g.staff['username'], row[0], 'claim', g.staff['username'])
changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') "
'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount
conn.commit()
conn.close()
return {'ok': True}
return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
def human_close(data):
sid = data.get('sid')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
if getattr(g, 'staff', None):
error = routing_service.owner_error(conn, sid, g.staff['username'], allow_unassigned=True)
if error:
conn.close()
return error
changed = conn.execute("UPDATE human_sessions SET status='closed',routing_version=routing_version+1 "
"WHERE id=? AND status!='closed'", (sid,)).rowcount
if changed:
routing_service.record_event(conn, sid, 'close', g.staff['username'] if getattr(g, 'staff', None) else 'customer')
conn.execute("UPDATE human_sessions SET status='closed' WHERE id=?", (sid,))
conn.commit()
conn.close()
return {'ok': True}
@@ -153,19 +105,12 @@ def human_close(data):
def human_transfer(data, typing_map):
sid = data.get('sid')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
error = routing_service.owner_error(conn, sid, g.staff['username'])
if error:
conn.close()
return error
changed = conn.execute("UPDATE human_sessions SET status='waiting',previous_owner=owner_username,owner_username=NULL,"
"assigned_at=NULL,routing_version=routing_version+1 WHERE id=? AND status='active'", (sid,)).rowcount
changed = conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=? AND status='active'", (sid,)).rowcount
if not changed:
conn.close()
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
routing_service.record_event(conn, sid, 'transfer', g.staff['username'])
conn.commit()
conn.close()
typing_map.pop(str(sid), None)
@@ -177,11 +122,6 @@ def human_note(data):
if not isinstance(note, str):
return ({'ok': False}, 400)
conn = connect()
conn.execute('BEGIN IMMEDIATE')
error = routing_service.owner_error(conn, data.get('sid'), g.staff['username'])
if error:
conn.close()
return error
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
conn.commit()
conn.close()
@@ -197,10 +137,6 @@ def human_recall(data):
if not row:
conn.close()
return ({'ok': False}, 404)
error = routing_service.owner_error(conn, row[0], g.staff['username'])
if error:
conn.close()
return error
if row[1] == 'system' and row[2] == '该消息已撤回':
conn.close()
return {'ok': True}
@@ -217,18 +153,12 @@ def human_recall(data):
def human_typing(data, typing_map):
sid = data.get('sid')
error = _typing_owner_error(sid)
if error:
return error
typing_map[str(sid)] = time.time()
return {'ok': True}
def human_stop_typing(data, typing_map):
sid = data.get('sid')
error = _typing_owner_error(sid)
if error:
return error
typing_map.pop(str(sid), None)
return {'ok': True}
@@ -236,20 +166,7 @@ def human_stop_typing(data, typing_map):
def human_read(data):
sid = data.get('sid')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
error = routing_service.owner_error(conn, sid, g.staff['username'])
if error:
conn.close()
return error
conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), sid))
conn.commit()
conn.close()
return {'ok': True}
def _typing_owner_error(sid):
connection = connect()
try:
return routing_service.owner_error(connection, sid, g.staff['username'])
finally:
connection.close()
+2 -2
View File
@@ -265,11 +265,11 @@ knowledge_list = [
},
{
"keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"],
"text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。"
"text":"人工客服在线时间:每天北京时间 8:00—22:00,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。"
},
{
"keys":["电话","联系方式","怎么联系","联系你们","微信"],
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
"text":"可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
},
{
"keys":["退件","退回","退货","退运","怎么退运"],
-158
View File
@@ -1,158 +0,0 @@
"""Staff order browsing and reversible import batch records."""
import hashlib
import json
import sqlite3
from datetime import datetime
from . import order_data
MAX_ORDER_ID = 9223372036854775807
ORDER_LIST_FIELDS = ('tenant_id', 'order_no', 'status', 'inbound_code', 'shipment_id',
'country_code', 'transport_mode', 'warehouse_code', 'source_updated_at', 'snapshot_at')
def init_order_admin_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS order_import_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
imported_by TEXT NOT NULL,
imported_at TEXT NOT NULL,
total_rows INTEGER NOT NULL,
imported_rows INTEGER NOT NULL,
preserved_rows INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'active' CHECK(state IN ('active','undone')),
undone_by TEXT,
undone_at TEXT,
deleted_rows INTEGER NOT NULL DEFAULT 0,
preserved_on_undo INTEGER NOT NULL DEFAULT 0
)''')
connection.execute('''CREATE TABLE IF NOT EXISTS order_import_batch_items (
batch_id INTEGER NOT NULL,
order_id INTEGER NOT NULL,
row_fingerprint TEXT NOT NULL,
PRIMARY KEY(batch_id, order_id)
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_import_batches_time '
'ON order_import_batches(imported_at DESC, id DESC)')
def _row_fingerprint(row):
values = [row[field] for field in order_data.FIELDS]
encoded = json.dumps(values, ensure_ascii=False, separators=(',', ':'), default=str)
return hashlib.sha256(encoded.encode('utf-8')).hexdigest()
def _safe_filename(filename):
return str(filename or '').replace('\\', '/').split('/')[-1][:255] or 'orders.csv'
def create_import_batch(connection, filename, username, parsed, result, existing_keys):
"""Persist the import audit and fingerprints for newly inserted records."""
now = datetime.now().isoformat(timespec='seconds')
cursor = connection.execute('''INSERT INTO order_import_batches
(filename,imported_by,imported_at,total_rows,imported_rows,preserved_rows)
VALUES(?,?,?,?,?,?)''', (_safe_filename(filename), username, now, parsed['total_rows'],
result['imported'], result['preserved']))
batch_id = cursor.lastrowid
for item in parsed['rows']:
key = (item['tenant_id'], item['order_no'].casefold())
if key in existing_keys:
continue
row = connection.execute('SELECT id,' + ','.join(order_data.FIELDS) +
' FROM order_test_data WHERE tenant_id=? AND order_no=?',
(item['tenant_id'], item['order_no'])).fetchone()
if row is None:
continue
record = dict(zip(('id', *order_data.FIELDS), row))
connection.execute('INSERT INTO order_import_batch_items(batch_id,order_id,row_fingerprint) VALUES(?,?,?)',
(batch_id, record['id'], _row_fingerprint(record)))
return batch_id
def _conditions(query, tenant, status, before):
clauses, values = [], []
if query:
escaped = query.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
clauses.append("(order_no LIKE ? ESCAPE '\\' OR inbound_code LIKE ? ESCAPE '\\' "
"OR shipment_id LIKE ? ESCAPE '\\' OR source_order_id LIKE ? ESCAPE '\\')")
values.extend(['%' + escaped + '%'] * 4)
if tenant:
clauses.append('tenant_id=?')
values.append(tenant)
if status:
clauses.append('status=?')
values.append(status)
if before is not None:
clauses.append('id<?')
values.append(before)
return (' WHERE ' + ' AND '.join(clauses) if clauses else ''), values
def list_orders(connection, query='', tenant='', status='', before=None, limit=50):
query, tenant, status = str(query or '').strip(), str(tenant or '').strip(), str(status or '').strip().upper()
if len(query) > 128 or len(tenant) > 128:
raise ValueError('搜索内容不能超过128个字符。')
if status and status not in order_data.STATUS_LABELS:
raise ValueError('订单状态无效。')
try:
limit = int(limit)
before = int(before) if before is not None else None
except (TypeError, ValueError):
raise ValueError('分页参数无效。') from None
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_ORDER_ID):
raise ValueError('分页参数无效。')
where, values = _conditions(query, tenant, status, None)
total = connection.execute('SELECT COUNT(*) FROM order_test_data' + where, values).fetchone()[0]
page_where, page_values = _conditions(query, tenant, status, before)
rows = connection.execute('SELECT id,' + ','.join(ORDER_LIST_FIELDS) +
' FROM order_test_data' + page_where + ' ORDER BY id DESC LIMIT ?',
(*page_values, limit + 1)).fetchall()
has_more = len(rows) > limit
rows = rows[:limit]
orders = [dict(zip(('id', *ORDER_LIST_FIELDS), row)) for row in rows]
return {'orders': orders, 'total': total,
'next_before': rows[-1][0] if has_more and rows else None,
'limit': limit}
def list_import_batches(connection, limit=50):
limit = max(1, min(100, int(limit)))
rows = connection.execute('''SELECT id,filename,imported_by,imported_at,total_rows,imported_rows,
preserved_rows,state,undone_by,undone_at,deleted_rows,preserved_on_undo
FROM order_import_batches ORDER BY id DESC LIMIT ?''', (limit,)).fetchall()
fields = ('id','filename','imported_by','imported_at','total_rows','imported_rows',
'preserved_rows','state','undone_by','undone_at','deleted_rows','preserved_on_undo')
return [dict(zip(fields, row)) for row in rows]
def undo_import_batch(connection, batch_id, username):
try:
batch_id = int(batch_id)
except (TypeError, ValueError):
raise ValueError('导入批次编号无效。') from None
if not 0 < batch_id <= MAX_ORDER_ID:
raise ValueError('导入批次编号无效。')
batch = connection.execute('SELECT state,deleted_rows,preserved_on_undo FROM order_import_batches WHERE id=?',
(batch_id,)).fetchone()
if not batch:
return None
if batch[0] == 'undone':
return {'batch_id': batch_id, 'deleted': batch[1], 'preserved': batch[2], 'already_undone': True}
deleted = preserved = 0
items = connection.execute('SELECT order_id,row_fingerprint FROM order_import_batch_items WHERE batch_id=?',
(batch_id,)).fetchall()
for order_id, fingerprint in items:
current = connection.execute('SELECT ' + ','.join(order_data.FIELDS) +
' FROM order_test_data WHERE id=?', (order_id,)).fetchone()
if current is None:
continue
record = dict(zip(order_data.FIELDS, current))
if _row_fingerprint(record) != fingerprint:
preserved += 1
continue
deleted += connection.execute('DELETE FROM order_test_data WHERE id=?', (order_id,)).rowcount
now = datetime.now().isoformat(timespec='seconds')
connection.execute('''UPDATE order_import_batches SET state='undone',undone_by=?,undone_at=?,
deleted_rows=?,preserved_on_undo=? WHERE id=?''', (username, now, deleted, preserved, batch_id))
return {'batch_id': batch_id, 'deleted': deleted, 'preserved': preserved, 'already_undone': False}
+185 -54
View File
@@ -14,7 +14,7 @@ from .customer_service import order_login_response
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
'history_json', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at')
'history_json', 'tracking_json', 'exception_reason', 'data_note', 'source_updated_at', 'snapshot_at')
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
@@ -23,7 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipmen
'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_DETAIL = r'状态|进度|轨迹|异常|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
class OrderDataUnavailable(Exception):
@@ -39,13 +39,15 @@ def init_order_table(connection):
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
order_role TEXT, parent_order_no TEXT,
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', exception_reason TEXT, data_note TEXT,
source_updated_at TEXT, snapshot_at TEXT,
UNIQUE(tenant_id, order_no)
)''')
columns = {row[1] for row in connection.execute('PRAGMA table_info(order_test_data)')}
if 'tracking_json' not in columns:
connection.execute("ALTER TABLE order_test_data ADD COLUMN tracking_json TEXT NOT NULL DEFAULT '[]'")
if 'exception_reason' not in columns:
connection.execute('ALTER TABLE order_test_data ADD COLUMN exception_reason TEXT')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
@@ -62,6 +64,10 @@ def import_records(connection, records, replace=False):
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
row['order_no'] = str(row['order_no']).strip()
row['status'] = str(row['status'] or 'DRAFT')
if row['exception_reason'] is not None:
if not isinstance(row['exception_reason'],str) or len(row['exception_reason'])>2000:
raise ValueError('exception_reason must be customer-visible text up to 2000 characters')
row['exception_reason']=row['exception_reason'].strip() or None
history = row['history_json'] or []
if isinstance(history, str): history = json.loads(history)
if not isinstance(history, list): raise ValueError('history_json must contain a list')
@@ -78,7 +84,8 @@ def import_records(connection, records, replace=False):
continue
columns = ','.join(FIELDS)
placeholders = ','.join('?' for _ in FIELDS)
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no'))
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no')
and (key!='exception_reason' or key in source))
connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) '
f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS])
inserted += 1
@@ -112,56 +119,116 @@ def import_tracking_records(connection, records, replace=False):
return {'imported': imported, 'preserved': preserved, 'missing': missing}
def tenant_filter(tenant_id=None, tenant_ids=None):
"""Share the same server-side scope intersection for exact and aggregate reads."""
tenant = get_setting('ORDER_DATA_TENANT_ID')
if tenant_ids is not None:
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
return None
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)})
return (' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')', sorted(allowed)) if allowed else None
if tenant_id is not None:
if not str(tenant_id).strip() or (tenant and str(tenant_id) != str(tenant)):
return None
tenant = str(tenant_id)
return (' AND tenant_id=?', [str(tenant)]) if tenant else ('', [])
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
connection = connect()
connection.row_factory = sqlite3.Row
scope = tenant_filter(tenant_id, tenant_ids)
if scope is None:
return []
connection = None
try:
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_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 and tenant_ids is None:
query += ' AND tenant_id=?'
values.append(str(tenant))
connection = connect()
connection.row_factory = sqlite3.Row
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' + scope[0]
values = [reference] * 3 + scope[1]
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
connection.close()
if connection is not None:
connection.close()
def count_orders(tenant_ids, status=None):
"""Fresh totals require an explicit authorized scope; never fall back to all rows."""
from .db import connect
empty = {'total':0, 'with_inbound':0}
scope = tenant_filter(tenant_ids=tenant_ids if tenant_ids is not None else ())
if scope is None or (status is not None and status not in STATUS_LABELS):
return empty
connection = None
try:
connection = connect()
query = ("SELECT COUNT(*),COUNT(NULLIF(TRIM(inbound_code,char(9)||char(10)||char(13)||' '),'')) "
'FROM order_test_data WHERE 1=1' + scope[0])
values = scope[1]
if status is not None:
query += ' AND status=?'
values = values + [status]
total, with_inbound = connection.execute(query,values).fetchone()
return {'total':total, 'with_inbound':with_inbound}
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
if connection is not None:
connection.close()
def parse_count_query(message):
if is_lookup_help(message):return None
from .order_query import parse_query
return parse_query(message)
def answer_count_query(intent, tenants,conv_id=None):
from .order_listing import answer_summary
return answer_summary(intent,tenants,conv_id)
def answer_list_query(message, tenants, conv_id=None):
from .order_listing import answer_list
return answer_list(message,tenants,conv_id)
def extract_reference(message):
# Explicit postal/monetary fields are not order identifiers, even if invalid.
message = re.sub(r'(?:手机号|手机号码|电话号码|联系电话|电话)\s*[::是为]?\s*(?:\+?86[-\s]?)?1[3-9]\d{9}', ' ', message)
message = re.sub(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*[0-9-]+', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*(?:万)?(?:USD|RMB|CNY|美元|美金|人民币|元)(?![A-Za-z0-9_-])',' ',message,flags=re.I)
message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
r'(?![A-Za-z0-9_-])', ' ', message)
from .pricing import DIMENSION_PATTERNS
for pattern in DIMENSION_PATTERNS:
def remove_dimensions(match):
end=match.start()+len(match[0].rstrip())
before=message[match.start()-1:match.start()] if match.start() else ''
after=message[end:end+1]
return match[0] if re.search(r'[A-Za-z0-9_.-]',before+after) else ' '
message=pattern.sub(remove_dimensions,message)
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
r'(?:\s*(?:inches|inch|in|cm|mm|m|英寸|厘米|毫米|米|["″]))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?:kilograms?|kgs?|grams?|g|tons?|lbs?|磅|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
references = []
for token in IDENTIFIER.findall(message):
if (re.fullmatch(r'[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2}',token)
and re.search(r'美国|报价|海派|海卡|海运|\bquote\b',message,re.I)
and not re.search(r'订单|运单|入仓|轨迹|查单|\btrack\b',message,re.I)):
continue
if sum(character.isdigit() for character in token) < 3:
continue
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
@@ -212,8 +279,40 @@ def build_card(row):
return card
def is_lookup_help(message):
if re.search(r'是什么意思|指什么|怎么(?:查|操作|使用)|如何(?:查|操作|使用)|怎样(?:查|操作|使用)|'
r'\bhow\s+(?:do|can|to)\b', message, re.I):
return True
if re.fullmatch(r'\s*(?:请问)?(?:物流轨迹|物流状态|订单状态|入仓码|入仓号)是什么[。?!?!\s]*', message):
return True
return bool(re.search(r'\bwhat\s+(?:is|are)\b', message, re.I) and
not re.search(r'\b(?:my|this|that|our)\b', message, re.I))
def is_tracking_request(message):
"""Bounded tracking commands also work without an identifier or prior context."""
question = re.sub(r'[\s,,。.!!??、::]', '', message)
command = (r'(?:请问|请|麻烦)?(?:帮我)?(?:查(?:询|一下|下)?(?:物流(?:轨迹|进度|状态)?|轨迹|进度|货)|'
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
r'(?:已)?(?:清关|开船|到港|出库|提柜)(?:了)?(?:吗|没|了吗|没有)?|签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|'
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
return bool(re.fullmatch(command+'|'+personal, question) or
re.fullmatch(english, message.strip(' \t\r\n.!?'), re.I))
def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question."""
from .order_explanations import detail_question
if detail_question(message) and not re.search(r'报价|价格|多少钱|公斤|\bKG\b|海派|海卡',message,re.I):
return True
if is_lookup_help(message):
return False
if is_tracking_request(message):
return True
if not re.search(ORDER_DETAIL, message):
return False
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
@@ -234,6 +333,25 @@ def is_order_followup(message):
def answer_query(message, conv_id):
references = extract_reference(message)
tenants = customer_tenant_ids()
from .order_explanations import general_explanation, detail_question, explain_order
if not references:
from .order_listing import answer_overview
overview=answer_overview(message,tenants)
if overview is not None:return overview
explanation=general_explanation(message)
if explanation:return {'t':explanation}
from .order_followups import answer_followup
followup=answer_followup(message,tenants,conv_id)
if followup is not None:
if '_order_reference' not in followup:return followup
references=[followup['_order_reference']]
count_intent = parse_count_query(message) if not references else None
if count_intent is not None:
return answer_count_query(count_intent,tenants,conv_id)
if not references:
listed=answer_list_query(message,tenants,conv_id)
if listed is not None:return listed
mutation=bool(re.search(r'改址|改.*(?:收货|收件).*地址|修改.*(?:收件|地址|订单)|(?:收货|收件)地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
if references and not tenants:
return order_login_response()
if len(references) > 1:
@@ -241,33 +359,41 @@ def answer_query(message, conv_id):
reference = references[0] if references else None
if not reference and conv_id and is_order_followup(message):
from .db import connect
from .customer_faq import is_acknowledgement
connection = connect()
try:
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC', (conv_id,))
for role, content in recent:
if role == 'user':
if is_acknowledgement(content):continue
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
finally:
connection.close()
for role, content in recent:
if role == 'user':
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
if not reference:
if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
if mutation:
return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
if detail_question(message) and not is_lookup_help(message):
return order_login_response() if not tenants else {'t':'请提供入仓码,我先核对该票订单的实际物流记录。'}
if re.search(r'哪些.*(?:订单|运单)|(?:订单|运单).*(?:筛选|还没开船|未发货|已发货|未签收)',message) and not is_lookup_help(message):
return order_login_response() if not tenants else {'t':'暂不支持按这个物流条件批量筛选订单。可按业务状态查看列表,或提供入仓码逐票核对实际物流节点。'}
if is_tracking_request(message) or (not is_lookup_help(message) and 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):
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 not tenants:
@@ -280,6 +406,11 @@ def answer_query(message, conv_id):
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
if mutation:
return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True}
if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
return {'card': build_card(matches[0])}
return {'t':'该订单未记录实际运费,请联系人工客服核实。','_customer_data':True}
card=build_card(matches[0])
explanation=explain_order(message,matches[0],card)
if explanation:return {'t':explanation,'_customer_data':True}
return {'card':card}
+49
View File
@@ -0,0 +1,49 @@
"""Explain recorded order facts without inferring delivery or operational writes."""
import re
def detail_question(message):
if re.search(r'报价|运费|海派|海卡|\d+\s*(?:KG|公斤)',message,re.I) and not re.search(r'这票|该订单|我的订单',message):
return False
return bool(re.search(r'异常原因|为什么异常|什么异常|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|改.*(?:收货|收件).*地址|(?:收货|收件)地址.*改|快递单号|付过款|付款状态|'
r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message))
def general_explanation(message):
if re.search(r'我的|这票|这单|这个订单|该订单',message):return None
if re.search(r'已完成|履约中|上架',message) and re.search(r'是什么意思|是否代表|是不是|等于|有什么区别',message):
return '“履约中”“头程订单已完成”属于业务状态;出库、开船、到港等以实际物流节点为准。业务完成不等于收件人签收,签收也不等于亚马逊已上架;上架需以亚马逊入库记录核实。'
return None
def explain_order(message,row,card):
if not detail_question(message):return None
reference=row.get('inbound_code') or row['order_no']
lines=[f'入仓码 {reference}。' if row.get('inbound_code') else f'订单号 {reference}。']
if re.search(r'异常原因|为什么异常|什么异常',message):
reason=(row.get('exception_reason') or '').strip()
return lines[0]+('已记录的异常原因:'+reason if reason else '当前未登记异常原因,请联系人工核实。')
if re.search(r'催单|催一下|拦截',message):
return lines[0]+'催单或拦截需联系人工客服办理;当前仅查询订单,尚未提交催单或拦截申请。'
if re.search(r'付过款|付款状态',message):
return lines[0]+'当前订单未接入付款状态,不能根据业务状态判断是否付款,请联系人工核对账单。'
if '快递单号' in message:
return lines[0]+'当前未记录可核实的UPS/FedEx尾程快递单号,入仓码和内部订单号不能直接当作快递单号,请联系人工核对。'
lines.append(f'登记的业务状态为“{card["status"]}”。')
actual=card.get('nodes',[]) if card.get('node_kind')=='logistics' else []
if actual:
latest=actual[0]
lines.append('最近一条实际物流记录:'+' '.join(str(latest.get(k) or '') for k in ('time','text','place')).strip()+'。')
else:lines.append('当前没有已记录的实际物流节点,不能根据业务状态推断货物位置。')
if re.search(r'上架|已完成|签收|送达',message):
lines.append('头程业务完成不等于收件人签收;当前节点不能用于确认亚马逊上架。上架结果需核对亚马逊入库记录。')
if re.search(r'没更新|未更新|没有更新|不更新',message):
lines.append('未更新期间的实际位置和原因无法从现有记录确定,可提供入仓码联系人工核查。')
if re.search(r'发货|发走|出库|开船',message):
lines.append('计划发货或开船安排需联系人工核对,不能根据业务状态承诺未来发货日期。')
if re.search(r'少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收',message):
if row.get('package_count') is not None:lines.append(f'登记总件数为{row["package_count"]}件。')
lines.append('当前没有逐箱签收明细,不能把整票业务状态当作每箱均已送达,请联系人工核实各箱情况。')
return '\n'.join(lines)
+100
View File
@@ -0,0 +1,100 @@
"""Resolve bounded list references and subset questions from scoped saved replies."""
import re
import sqlite3
from .db import connect
from .order_query import COUNT,NOUN,CONDITION,parse_query
ORDINALS={word:index for index,word in enumerate(
'一 二 三 四 五 六 七 八 九 十 十一 十二 十三 十四 十五 十六 十七 十八 十九 二十'.split(),1)}
RELATIVE=r'^(?:这其中|其中|这些(?:订单|运单)?(?:中|里)?|这几(?:票|条)(?:中|里)?|里面|当中)'
def is_relative_question(message):
return (bool(re.match(RELATIVE,message))
and bool(re.search(COUNT+'|'+NOUN+'|'+CONDITION+r'|轨迹|节点|阶段|到哪|在哪',message,re.I))
and not re.search(r'报价|海运|海派|海卡|空运|运费|费用|价格|KG|公斤|千克|CBM|立方',message,re.I))
def ordinal_request(message):
from .order_data import is_order_followup
text=re.sub(r'[\s,,。.!!??]','',message)
match=re.fullmatch(r'(?:请|帮我|看看|看一下|查询)?第(\d{1,5}|[一二三四五六七八九十]{1,3})'
r'(?:个|票|条|笔)(?:订单|运单)?(?:的)?(.*)',text)
if not match:return None
detail=match[2]
if detail not in ('','呢','详情') and not is_order_followup(detail):return None
number=int(match[1]) if match[1].isdigit() else ORDINALS.get(match[1],0)
return number
def shown_references(connection,conv_id,state):
if 'shown' in state:
return [(item['inbound_code'] or '未登记',item['order_no']) for item in state['shown']]
row=connection.execute("SELECT content FROM messages WHERE conv_id=? AND id=? AND role='assistant'",
(conv_id,state['message_id'])).fetchone()
# Older states stored only the reply ID. Accept its fixed, unambiguous row format.
lines=[line for line in (row[0] if row else '').splitlines() if line.startswith('- 入仓码:')]
result=[]
for line in lines:
match=re.match(r'^- 入仓码:([^\r\n;]+);订单号:([^\r\n;]+);(?:业务状态|异常原因):',line)
if not match or any(line.count(label)>1 for label in (';订单号:',';业务状态:',';异常原因:')):
return []
result.append(match.groups())
return result
def answer_followup(message,tenants,conv_id):
from .customer_service import order_login_response
from .order_listing import load_state,answer_summary
text=message.strip(' ,,。.!!??')
ordinal=ordinal_request(text)
relative=is_relative_question(text)
reason=bool(re.fullmatch(r'(?:那|这些|这几票|它们|上述|上面)?(?:订单|运单)?'
r'(?:为什么异常|为什么有异常|异常的?原因(?:是什么|有哪些|呢)?|异常是怎么回事)',text))
query_text=re.sub(RELATIVE,'',text)
query_text=re.sub(r'^(?:那么|那)','',query_text)
intent=parse_query(query_text+'订单有多少') if not (re.search(NOUN,query_text,re.I) or re.search(COUNT,query_text,re.I)) else parse_query(query_text)
if relative and intent is None and re.search(COUNT,query_text,re.I):intent=parse_query(query_text+'订单')
short_count=(not re.search(NOUN,text,re.I) and not re.search(COUNT,text,re.I)
and intent is not None and not intent['unsupported']
and any(intent.get(key) for key in ('status','stage','exception','grouped')))
if ordinal is None and not relative and not reason and not short_count:return None
if not tenants:return order_login_response()
try:
previous=load_state(conv_id)
if ordinal is not None:
if not previous:return {'t':'请先查看需要查询的订单列表,或直接提供入仓码。'}
connection=connect()
try:
shown=shown_references(connection,conv_id,previous)
finally:connection.close()
if not shown:return {'t':'当前列表没有可查询的订单,请重新指定筛选条件,或直接提供入仓码。'}
if not 1<=ordinal<=len(shown):
return {'t':f'刚才的列表展示了{len(shown)}票,请选择其中一票,或回复“查看全部”展开列表后再查询。'}
inbound,number=shown[ordinal-1]
return {'_order_reference':number if inbound=='未登记' else inbound}
if short_count and not relative and (not previous or previous.get('selected')):return None
if reason:
if not previous or previous.get('selected'):return None
intent={**previous['query'],'exception':True,'unsupported':False}
elif relative:
if not previous:return {'t':'请先查询要筛选的订单范围,再继续问“其中”的数量。'}
if previous.get('selected'):
return {'t':'您想统计刚才列表中的订单,还是查询当前这一票?请说明订单范围或提供入仓码。'}
if intent is None or intent['unsupported'] or 'summaries' in intent:
return {'t':'请明确要在当前订单范围内筛选的一个条件,例如“其中异常的有多少票”。'}
for key in ('status','stage'):
if previous['query'][key] and intent.get(key) and previous['query'][key]!=intent[key]:
return {'t':'当前范围与这次条件对应不同状态或节点,请确认要切换筛选条件,还是继续查看原范围。'}
intent={**intent,**{key:intent.get(key) or value for key,value in previous['query'].items()}}
elif intent.get('grouped'):
if not previous or previous.get('selected'):return None
intent={**intent,**previous['query']}
if intent is None or intent['unsupported']:return None
return answer_summary(intent,tenants,conv_id,reasons=reason)
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
-297
View File
@@ -1,297 +0,0 @@
"""Safe parsing and row validation for staff-managed order imports."""
import csv
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
import io
import json
from pathlib import Path
import zipfile
from openpyxl import load_workbook
from .order_data import STATUS_LABELS
from .order_tracking import NODE_LABELS, parse_time
MAX_UPLOAD_BYTES = 5 * 1024 * 1024
MAX_ROWS = 5000
MAX_ERRORS = 100
MAX_XLSX_UNPACKED_BYTES = 25 * 1024 * 1024
IMPORT_COLUMNS = (
('source_order_id', '来源订单ID'), ('tenant_id', '租户ID'), ('order_no', '订单号'),
('inbound_code', '入仓码'), ('shipment_id', 'Shipment ID'), ('status', '业务状态'),
('country_code', '目的国'), ('transport_mode', '运输方式'), ('warehouse_code', '目的仓'),
('order_role', '订单层级'), ('parent_order_no', '父订单号'), ('package_count', '件数'),
('gross_weight_kg', '毛重(kg)'), ('volume_cbm', '体积(m³)'),
('history_json', '业务状态历史(JSON)'), ('tracking_json', '物流轨迹(JSON)'),
('data_note', '数据备注'), ('source_updated_at', '来源更新时间'), ('snapshot_at', '数据快照时间'),
)
HEADER_TO_FIELD = {header.casefold(): field for field, header in IMPORT_COLUMNS}
HEADER_TO_FIELD.update({field.casefold(): field for field, _ in IMPORT_COLUMNS})
REQUIRED_FIELDS = {'tenant_id', 'order_no'}
TEXT_LIMITS = {
'source_order_id': 128, 'tenant_id': 128, 'order_no': 128, 'inbound_code': 128,
'shipment_id': 128, 'status': 64, 'country_code': 16, 'transport_mode': 32,
'warehouse_code': 64, 'order_role': 32, 'parent_order_no': 128, 'data_note': 2000,
'source_updated_at': 64, 'snapshot_at': 64,
}
class ImportFileError(ValueError):
"""The uploaded file cannot be interpreted as an order table."""
def template_csv():
"""Return an Excel-friendly UTF-8 CSV template."""
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow([header for _, header in IMPORT_COLUMNS])
return output.getvalue().encode('utf-8-sig')
def _cell_text(value):
if value is None:
return ''
if isinstance(value, (datetime, date)):
return value.isoformat(sep=' ') if isinstance(value, datetime) else value.isoformat()
if isinstance(value, bool):
return 'TRUE' if value else 'FALSE'
if isinstance(value, float):
if not value.is_integer():
return format(value, '.15g')
return str(int(value))
return str(value).strip()
def _validate_row(raw, line):
errors = []
row = {field: raw.get(field, '') for field, _ in IMPORT_COLUMNS}
for field, limit in TEXT_LIMITS.items():
value = _cell_text(row[field])
row[field] = value or None
if field in REQUIRED_FIELDS and not value:
errors.append(f'{dict(IMPORT_COLUMNS)[field]}不能为空')
elif len(value) > limit:
errors.append(f'{dict(IMPORT_COLUMNS)[field]}不能超过{limit}个字符')
if not row['status']:
row['status'] = 'DRAFT'
row['status'] = row['status'].upper()
if row['status'] not in STATUS_LABELS:
errors.append('业务状态不在允许范围内')
if row['order_role']:
row['order_role'] = row['order_role'].upper()
if row['order_role'] not in ('MASTER', 'CHILD'):
errors.append('订单层级只能是MASTER或CHILD')
package_value = _cell_text(row['package_count'])
if package_value:
try:
number = Decimal(package_value)
if not number.is_finite() or number < 0 or number != number.to_integral_value():
raise InvalidOperation
row['package_count'] = int(number)
except (InvalidOperation, ValueError):
errors.append('件数必须是大于或等于0的整数')
else:
row['package_count'] = None
for field, label in (('gross_weight_kg', '毛重'), ('volume_cbm', '体积')):
value = _cell_text(row[field])
if not value:
row[field] = None
continue
try:
number = Decimal(value)
if not number.is_finite() or number < 0:
raise InvalidOperation
row[field] = format(number.normalize(), 'f')
except (InvalidOperation, ValueError):
errors.append(f'{label}必须是大于或等于0的数字')
for field, label in (('history_json', '业务状态历史'), ('tracking_json', '物流轨迹')):
value = _cell_text(row[field])
if not value:
row[field] = []
continue
try:
parsed = json.loads(value)
if not isinstance(parsed, list) or len(parsed) > 500:
raise ValueError
if field == 'history_json':
for event in parsed:
if (not isinstance(event, dict) or event.get('status') not in STATUS_LABELS
or not isinstance(event.get('time'), str) or not parse_time(event['time'])):
raise ValueError
else:
for event in parsed:
if not isinstance(event, dict) or event.get('code') not in NODE_LABELS:
raise ValueError
actual = event.get('actual_at')
estimated = event.get('estimated_at')
if not any(parse_time(item) for item in (actual, estimated)):
raise ValueError
for key in ('actual_at', 'estimated_at', 'observed_at', 'created_at'):
if event.get(key) is not None and not parse_time(event[key]):
raise ValueError
for key, limit in (('event_id', 128), ('shipment_key', 128), ('shipment_no', 128), ('place', 256)):
if event.get(key) is not None and (not isinstance(event[key], str) or len(event[key]) > limit):
raise ValueError
row[field] = parsed
except (ValueError, TypeError):
errors.append(f'{label}格式无效,请按模板要求填写有效JSON数组')
return {'row': line, **row}, errors
def _read_csv(content):
try:
text = content.decode('utf-8-sig')
except UnicodeDecodeError:
try:
text = content.decode('gb18030')
except UnicodeDecodeError as error:
raise ImportFileError('CSV编码无法识别,请另存为UTF-8格式。') from error
try:
rows = []
for row in csv.reader(io.StringIO(text, newline='')):
rows.append(row)
if len(rows) > MAX_ROWS + 1:
raise ImportFileError(f'单次最多导入{MAX_ROWS}条订单。')
return rows
except csv.Error as error:
raise ImportFileError('CSV格式无效,请检查引号或分隔符。') from error
def _read_xlsx(content):
try:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
if len(archive.infolist()) > 100 or sum(item.file_size for item in archive.infolist()) > MAX_XLSX_UNPACKED_BYTES:
raise ImportFileError('Excel文件内容过大,无法安全读取。')
workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=False)
sheet = workbook.active
if sheet.max_column > len(IMPORT_COLUMNS):
raise ImportFileError(f'Excel最多允许{len(IMPORT_COLUMNS)}列。')
rows = []
for cells in sheet.iter_rows(max_col=len(IMPORT_COLUMNS)):
values = []
for cell in cells:
if cell.data_type == 'f':
raise ImportFileError(f'Excel第{cell.row}行含公式,请粘贴为纯文本或数值后再上传。')
values.append(cell.value)
rows.append(values)
if len(rows) > MAX_ROWS + 1:
raise ImportFileError(f'单次最多导入{MAX_ROWS}条订单。')
return rows
except ImportFileError:
raise
except (zipfile.BadZipFile, OSError, ValueError, KeyError) as error:
raise ImportFileError('Excel文件无法读取,请检查文件是否损坏。') from error
finally:
if 'workbook' in locals():
workbook.close()
def parse_order_upload(filename, content):
"""Parse a CSV/XLSX file. Return valid rows with source line numbers and row errors."""
suffix = Path(filename or '').suffix.lower()
if suffix not in ('.csv', '.xlsx'):
raise ImportFileError('只支持CSV或XLSX文件。')
if not content:
raise ImportFileError('请选择非空文件。')
if len(content) > MAX_UPLOAD_BYTES:
raise ImportFileError('文件不能超过5 MB。')
table = _read_csv(content) if suffix == '.csv' else _read_xlsx(content)
if not table:
raise ImportFileError('文件内容为空。')
header_line, headers = None, None
for line, values in enumerate(table, start=1):
if any(_cell_text(value) for value in values):
header_line, headers = line, values
break
if headers is None:
raise ImportFileError('文件内容为空。')
fields = []
seen_fields = set()
for value in headers:
name = _cell_text(value).strip().casefold()
if not name:
fields.append(None)
continue
field = HEADER_TO_FIELD.get(name)
if not field:
raise ImportFileError(f'第{header_line}行包含未知列“{_cell_text(value)}”,请使用模板中的列名。')
if field in seen_fields:
raise ImportFileError(f'第{header_line}行的“{_cell_text(value)}”列重复。')
seen_fields.add(field)
fields.append(field)
missing = REQUIRED_FIELDS.difference(seen_fields)
if missing:
labels = dict(IMPORT_COLUMNS)
raise ImportFileError('缺少必填列:' + '、'.join(labels[item] for item in sorted(missing)) + '。')
rows, errors, seen = [], [], set()
total = 0
for line, values in enumerate(table[header_line:], start=header_line + 1):
if not any(_cell_text(value) for value in values):
continue
total += 1
if total > MAX_ROWS:
raise ImportFileError(f'单次最多导入{MAX_ROWS}条订单。')
raw = {field: values[index] if index < len(values) else '' for index, field in enumerate(fields) if field}
if any(_cell_text(value) for value in values[len(fields):]):
if len(errors) < MAX_ERRORS:
errors.append({'row': line, 'message': '数据列数多于表头,请检查是否有多余单元格。'})
continue
if any(_cell_text(values[index]) for index, field in enumerate(fields) if field is None and index < len(values)):
if len(errors) < MAX_ERRORS:
errors.append({'row': line, 'message': '存在未命名的数据列,请删除该列或填写有效表头。'})
continue
parsed, row_errors = _validate_row(raw, line)
if not row_errors:
key = (parsed['tenant_id'], parsed['order_no'].casefold())
if key in seen:
row_errors.append('文件中出现重复的租户ID和订单号')
else:
seen.add(key)
if row_errors:
if len(errors) < MAX_ERRORS:
errors.append({'row': line, 'message': ';'.join(row_errors)})
else:
rows.append(parsed)
if total == 0:
raise ImportFileError('文件中没有订单数据。')
return {'rows': rows, 'errors': errors, 'total_rows': total}
def mark_existing_rows(connection, rows):
"""Annotate preview rows already present; import_records preserves those rows."""
duplicate_count = 0
for row in rows:
exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
(row['tenant_id'], row['order_no'])).fetchone()
row['duplicate'] = bool(exists)
duplicate_count += bool(exists)
return duplicate_count
def validate_tenants(connection, parsed):
"""Require an imported tenant to be registered by config or a customer account."""
known = {row[0] for row in connection.execute(
"SELECT tenant_id FROM order_test_data WHERE tenant_id<>'' "
"UNION SELECT tenant_id FROM customer_users WHERE tenant_id<>'' "
"UNION SELECT tenant_id FROM customer_tenant_grants WHERE tenant_id<>''")}
from .config import get_setting
for key in ('ORDER_DATA_TENANT_ID', 'CUSTOMER_DEMO_TENANT_ID'):
configured = get_setting(key)
if configured:
known.add(str(configured))
rows, errors = [], list(parsed['errors'])
for row in parsed['rows']:
if row['tenant_id'] not in known:
if len(errors) < MAX_ERRORS:
errors.append({'row': row['row'], 'message': '租户ID未登记,请先关联客户账号或配置租户。'})
else:
rows.append(row)
return {**parsed, 'rows': rows, 'errors': errors}
+286
View File
@@ -0,0 +1,286 @@
"""Scoped summaries, five-row previews and durable conversational list pages."""
from collections import Counter,deque
import json
import re
import sqlite3
from .db import connect
from .order_query import parse_query,filters,STAGE_LABELS
from .order_tracking import current_stage
def page_command(message):
text=re.sub(r'[\s,,。.!!??]','',message)
match=re.fullmatch(r'(?:请)?(?:订单)?第(\d{1,5})页',text)
if match:return int(match.group(1))
if re.fullmatch(r'(?:请|继续)?(?:下一页|下页|继续显示|接着显示(?:剩下的)?订单)',text):return 'next'
if re.fullmatch(r'(?:请)?(?:上一页|上页)',text):return 'previous'
return None
def valid_filters(query):
from .order_data import STATUS_LABELS
return (isinstance(query,dict) and set(query)=={'status','stage','exception'}
and (query['status'] is None or query['status'] in STATUS_LABELS)
and (query['stage'] is None or query['stage'] in STAGE_LABELS)
and isinstance(query['exception'],bool))
def load_state(conv_id):
from .customer_service import current_customer_id,current_scope_key
from .visitor_service import visitor_id
from .customer_faq import is_acknowledgement
from .order_followups import ordinal_request,shown_references,is_relative_question
from .order_data import is_order_followup
if not conv_id:return None
connection=connect()
try:
row=connection.execute('SELECT order_list_state FROM chat_history WHERE id=? AND visitor_id=? '
'AND customer_id=? AND customer_scope=?',
(conv_id,visitor_id(),current_customer_id(),current_scope_key())).fetchone()
state=json.loads(row[0]) if row and row[0] else None
if not isinstance(state,dict) or not valid_filters(state.get('query')):return None
if type(state.get('page')) is not int or not 1<=state['page']<=10000:return None
if state.get('size') not in (5,20) or type(state.get('message_id')) is not int:return None
if 'shown' in state:
shown=state['shown']
if not isinstance(shown,list) or len(shown)>20:return None
if any(not isinstance(item,dict) or set(item)!={'inbound_code','order_no'}
or not isinstance(item['order_no'],str) or not item['order_no']
or item['inbound_code'] is not None and not isinstance(item['inbound_code'],str) for item in shown):
return None
state['selected']=False
for (text,) in connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id",
(conv_id,state['message_id'])):
if is_acknowledgement(text):continue
if is_relative_question(text):continue
ordinal=ordinal_request(text)
if ordinal is not None:
state['selected']=1<=ordinal<=len(shown_references(connection,conv_id,state))
continue
if state['selected'] and is_order_followup(text):continue
if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'):
return None
return state
except (ValueError,TypeError):return None
finally:connection.close()
def save_state(conv_id,state,message_id):
"""Called only after the reply and customer binding have been persisted."""
from .customer_service import current_customer_id,current_scope_key
from .visitor_service import visitor_id
if not conv_id or type(message_id) is not int:return
connection=connect()
try:
state=dict(state)
state['message_id']=message_id
connection.execute('UPDATE chat_history SET order_list_state=? WHERE id=? AND visitor_id=? '
'AND customer_id=? AND customer_scope=? '
'AND NOT EXISTS(SELECT 1 FROM messages WHERE conv_id=? AND id>?)',
(json.dumps(state,ensure_ascii=False),conv_id,visitor_id(),current_customer_id(),current_scope_key(),conv_id,message_id))
connection.commit()
finally:connection.close()
def scoped_orders(connection,tenants,status=None):
"""Use one authorized projection and normalization for lists and overviews."""
from .order_data import tenant_filter
scope=tenant_filter(tenant_ids=tenants)
if scope is None:raise ValueError('Explicit customer scope required')
where=' WHERE 1=1'+scope[0]
values=scope[1]
if status:
where+=' AND status=?'
values=values+[status]
rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason '
'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values)
for row in rows:
item=dict(row)
item['exception_reason']=(item['exception_reason'] or '').strip()
item['stage']=current_stage(item.pop('tracking_json'))
yield item
def read_overview(tenants):
"""Summarize the customer's current orders in one database snapshot."""
connection=connect()
try:
connection.row_factory=sqlite3.Row
connection.execute('BEGIN')
total=processing=exceptions=0
stages=Counter()
abnormal_rows=[]
processing_rows=[]
for row in scoped_orders(connection,tenants):
total+=1
abnormal=bool(row['exception_reason'])
if abnormal:
exceptions+=1
if len(abnormal_rows)<5:abnormal_rows.append(row)
if row['status']=='PROCESSING':
processing+=1
stages[row['stage']]+=1
if not abnormal and len(processing_rows)<5:processing_rows.append(row)
return {'total':total,'processing':processing,'exceptions':exceptions,
'stages':[{'code':code,'count':stages[code]} for code in STAGE_LABELS if stages[code]],
'orders':(abnormal_rows+processing_rows)[:5]}
finally:
connection.close()
def answer_overview(message,tenants):
question=re.sub(r'[\s,,。.!!??]','',message)
chinese=re.fullmatch(r'(?:请|帮我|请帮我)?(?:看一下|看看|查看|查询|显示)?(?:一下)?'
r'(?:(?:我|当前账号)的?|当前|现在)?(?:订单|运单)(?:概览|概况|总体情况|整体情况|情况|汇总)(?:呢|吧|吗)?',question)
english=re.fullmatch(r'(?:please\s+)?(?:(?:show|give)\s+(?:me\s+)?)?(?:my\s+)?(?:order|shipment)s?\s+(?:overview|summary)',message.strip(' .!?'),re.I)
if not chinese and not english:return None
if not tenants:
from .customer_service import order_login_response
return order_login_response()
try:
data=read_overview(tenants)
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
lines=[f'您当前共有 {data["total"]} 票订单,履约中 {data["processing"]} 票,异常订单 {data["exceptions"]} 票。']
if data['stages']:
lines.append('履约中订单的当前实际节点:')
lines.extend(f'- {STAGE_LABELS[stage["code"]]}:{stage["count"]}票' for stage in data['stages'])
if data['orders']:
lines.append('重点订单(异常优先,最多5票):')
lines.extend(row_text(order) for order in data['orders'])
return {'t':'\n'.join(lines),'_customer_data':True}
def matches_filters(row,query):
return ((not query['status'] or row['status']==query['status'])
and (not query['stage'] or row['stage']==query['stage'])
and (not query['exception'] or bool(row['exception_reason'])))
def read_orders(tenants,query,page=1,size=5,comparisons=()):
"""One scoped snapshot; each order occupies one actual-node bucket."""
if not valid_filters(query) or any(not valid_filters(part) for part in comparisons):
raise ValueError('Explicit filters required')
connection=connect()
try:
connection.row_factory=sqlite3.Row
connection.execute('BEGIN')
total=with_inbound=0;groups=Counter();selected=[];tail=deque(maxlen=size)
comparison_groups=[Counter() for _ in comparisons]
for item in scoped_orders(connection,tenants):
for index,comparison in enumerate(comparisons):
if matches_filters(item,comparison):comparison_groups[index][item['stage']]+=1
if not matches_filters(item,query):continue
total+=1;with_inbound+=bool((item['inbound_code'] or '').strip());groups[item['stage']]+=1
tail.append(item)
if (page-1)*size<total<=page*size:selected.append(item)
last=max(1,(total+size-1)//size)
if page>last:
page=last;selected=list(tail)[-(total-(last-1)*size):] if total else []
return {'total':total,'with_inbound':with_inbound,'groups':groups,'rows':selected,'page':page,
'counts':[sum(group.values()) for group in comparison_groups],'comparison_groups':comparison_groups}
finally:connection.close()
def row_text(row,reason_only=False):
from .order_data import STATUS_LABELS
def clean(value):return re.sub(r'[\r\n\t]+',' ',str(value or '未登记'))
text=f'- 入仓码:{clean(row["inbound_code"])};订单号:{clean(row["order_no"])}'
if not reason_only:
text+=f';业务状态:{STATUS_LABELS.get(row["status"],row["status"])};目的仓:{clean(row["warehouse_code"])};当前节点:{STAGE_LABELS[row["stage"]]}'
if row['exception_reason']:text+=';异常原因:'+clean(row['exception_reason'])
return text
def unsupported(listing=False):
return {'t':f'暂不支持按这个条件{"列出订单" if listing else "统计"}。可以查询订单总数、业务状态、明确标记的异常,或按当前实际物流节点统计;请一次指定一个节点。'}
def summary_text(query,total):
from .order_data import STATUS_LABELS
if query['stage']:
text=f'您当前有 {total} 票当前节点为“{STAGE_LABELS[query["stage"]]}”的订单'
elif query['status']:
text=f'您当前有 {total} 票业务状态为“{STATUS_LABELS[query["status"]]}”的订单'
elif query['exception']:text=f'您当前有 {total} 票明确标记异常的订单'
else:text=f'您当前共有 {total} 票订单'
if query['exception'] and (query['stage'] or query['status']):text+='(已明确标记异常)'
if query['stage'] and query['status']:text+=f'(业务状态为{STATUS_LABELS[query["status"]]})'
return text
def stage_distribution(groups):
return ['按当前实际物流节点分布(每票只计一次):']+[
f'- {label}:{groups[code]}票' for code,label in STAGE_LABELS.items() if groups[code]]
def answer_summary(intent,tenants,conv_id=None,reasons=False):
from .customer_service import order_login_response
if not tenants:return order_login_response()
if intent['unsupported']:return unsupported()
requested=intent.get('summaries') or [intent]
summaries=[part for part in requested if not part['unsupported']]
if not summaries:return unsupported()
query=filters(summaries[-1])
comparisons=[filters(part) for part in summaries[:-1]]
try:
data=read_orders(tenants,query,comparisons=comparisons)
total=data['total']
lines=[]
for part,count,groups in zip(summaries[:-1],data['counts'],data['comparison_groups']):
lines.append(summary_text(part,count)+'。')
if part.get('grouped'):lines.extend(stage_distribution(groups))
lines.append(summary_text(query,total)+f',其中 {data["with_inbound"]} 票已登记入仓码。')
if summaries[-1].get('grouped'):
lines.extend(stage_distribution(data['groups']))
for part in requested:
if part['unsupported']:
question=re.sub(r'\s+',' ',part['question'])[:120]
lines.append(f'“{question}”的筛选条件尚未明确,请确认具体含义,可按业务状态、异常或当前实际节点查询。')
if data['rows']:
lines.append('已记录的异常原因(最多5票)。' if reasons else f'最近更新的{len(data["rows"])}票:')
lines.extend(row_text(row,reason_only=reasons) for row in data['rows'])
lines.append('回复“查看全部”展开列表,或发送入仓码查询详情。')
state={'query':query,'page':1,'size':5}
previous=load_state(conv_id)
if previous and previous['query']==query:state={key:previous[key] for key in ('query','page','size')}
state['shown']=[{'inbound_code':row['inbound_code'],'order_no':row['order_no']} for row in data['rows']]
return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state}
except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'}
def answer_list(message,tenants,conv_id=None):
from .order_data import is_lookup_help,STATUS_LABELS
from .customer_service import order_login_response
command=page_command(message)
expand=message.strip(' ,,。.!!??') in ('查看全部','查看全部订单')
initial=(not is_lookup_help(message) and re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I)
and re.search(r'列出|列表|全部|所有|\blist\b',message,re.I))
if command is None and not initial and not expand:return None
if not tenants:return order_login_response()
try:
previous=load_state(conv_id)
if command is not None or expand:
if not previous:return {'t':'请先在当前对话中发送“列出我的订单”或查询订单数量,再翻页查看。'}
query=previous['query'];size=20 if expand else previous['size']
page=1 if expand else int(command) if isinstance(command,int) else max(1,previous['page']+(1 if command=='next' else -1))
if not 1<=page<=10000:return {'t':'请提供1至10000之间的有效页码。'}
else:
intent=parse_query(message,listing=True)
if intent is None or intent['unsupported']:return unsupported(True)
query=filters(intent);page=1;size=20
data=read_orders(tenants,query,page,size);page=data['page'];total=data['total']
state={'query':query,'page':page,'size':size,
'shown':[{'inbound_code':row['inbound_code'],'order_no':row['order_no']} for row in data['rows']]}
if not total:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True,'_order_list_state':state}
label=STATUS_LABELS[query['status']] if query['status'] else '全部'
first=f'先列出{size}票;' if page==1 and total>size else ''
extra=(';当前节点:'+STAGE_LABELS[query['stage']] if query['stage'] else '')+(';明确标记异常' if query['exception'] else '')
lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}{extra}):',f'共{total}票,当前显示第{(page-1)*size+1}至{(page-1)*size+len(data["rows"])}票。']
lines.extend(row_text(row) for row in data['rows'])
lines.append('回复“下一页”查看后续订单。' if page*size<total else '当前已是最后一页。')
lines.append('发送入仓码可查询该票订单和物流轨迹。')
return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state}
except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'}
+74
View File
@@ -0,0 +1,74 @@
"""Recognize explicit, whitelisted order summary and list filters."""
import re
from .order_tracking import NODE_LABELS
NODE_PHRASES = (
('DOMESTIC_RECEIVED',r'已入国内仓|已入仓'),
('LOADING_COMPLETED',r'已完成装柜|已装柜'),
('DEPARTED_WAREHOUSE',r'已离开国内仓库|已离仓|已出库'),
('AT_ORIGIN_PORT',r'重箱已进入起运港或码头|已到起运港|已进起运港'),
('LOADED_ON_VESSEL',r'已装船'),
('DEPARTED_ORIGIN_PORT',r'船舶已离开起运港|已开船|开船'),
('IN_TRANSIT',r'海运途中|运输中|在途|海上'),
('ARRIVED_DESTINATION_PORT',r'船舶已到目的港|已到目的港|已到港|到港'),
('DISCHARGED_FROM_VESSEL',r'已卸船、未清关|卸船未清关|已卸船|卸船'),
('PICKUP_READY',r'已清关、可提柜|已清关|清关完成|可提柜'),
('PICKED_UP',r'已提柜|提柜'),
('EMPTY_RETURNED',r'已还柜|还柜'),
('NO_ACTUAL',r'没有实际轨迹|暂无实际节点|没有轨迹|无轨迹'),
('MIXED',r'多柜进度不一致|多柜进度不同'),
)
STAGE_LABELS = {code:labels[1] for code,labels in NODE_LABELS.items()}
STAGE_LABELS.update(NO_ACTUAL='暂无实际节点',MIXED='多柜进度不同')
STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT'),
('processing','PROCESSING'))
NOUN = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
COUNT = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
CONDITION = r'异常|\b(?:exception|abnormal)\b|'+'|'.join(pattern for _,pattern in NODE_PHRASES)+'|'+'|'.join(re.escape(phrase) for phrase,_ in STATUSES)
def parse_query(message,listing=False):
message=re.sub(r'^(?:(?:你好|您好)[,,!!。\s]*)+','',message.strip())
message=re.sub(r'^(?:或者|那么|那)[,,\s]*','',message)
message=re.sub(r'^(?:当前|目前|现在)[,,\s]+','',message)
has_noun=bool(re.search(NOUN,message,re.I))
if not has_noun and (listing or not re.search(COUNT,message,re.I) or not re.search(CONDITION,message,re.I)):
return None
if not listing and not (re.search(COUNT,message,re.I) or re.search(r'分布|(?:都|分别).*到哪',message)):return None
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):return None
if not listing:
parts=[part.strip() for part in re.split(r'[,,。;;??]+|以及|还有|并且|和|\band\b',message,flags=re.I) if part.strip()]
if len(parts)>1 and all(re.search(COUNT,part,re.I) for part in parts):
queries=[parse_query(part if re.search(NOUN,part,re.I) else part+'订单') for part in parts[:5]]
for question,query in zip(parts,queries):query['question']=question
return {'summaries':queries,'unsupported':len(parts)>5}
remaining=message
statuses=[];nodes=[]
# Unknown/negated logistics conditions must never silently become a total.
unsupported=bool(re.search(r'未到港|没到港|还没|未开船|未发货|未签收|已签收|已送达|曾经|曾到|到过|正常订单|无异常',remaining))
for code,pattern in NODE_PHRASES:
if re.search(pattern,remaining):
nodes.append(code);remaining=re.sub(pattern,'',remaining)
for phrase,status in STATUSES:
if re.search(re.escape(phrase),remaining,re.I):
statuses.append(status);remaining=re.sub(re.escape(phrase),'',remaining,flags=re.I)
exception=bool(re.search(r'异常|\b(?:exception|abnormal)\b',remaining,re.I))
remaining=re.sub(r'物流异常|异常|\b(?:exception|abnormal)\b','',remaining,flags=re.I)
grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪|在哪|\btracking (?:stages?|nodes?)\b',remaining,re.I))
remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|在哪里|在哪儿|在哪|各自|各个|每个|分别|分布|各|都|了|\btracking (?:stages?|nodes?)\b', '',remaining,flags=re.I)
remaining=re.sub(COUNT+'|'+NOUN,'',remaining,flags=re.I)
remaining=re.sub(r'其中|当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|列出|列表|显示|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
r'\b(?:list|what|is|of|do|i|have|are|there|my|in|all|the|currently|at|each)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
return {'status':statuses[0] if statuses else None,'stage':nodes[0] if nodes else None,
'exception':exception,'grouped':grouped,
'unsupported':bool(unsupported or remaining or len(set(statuses))>1 or len(set(nodes))>1)}
def filters(intent):
return {key:intent.get(key) for key in ('status','stage','exception')}
+25 -6
View File
@@ -33,14 +33,14 @@ def parse_time(value):
return None
def build_tracking(raw):
"""Latest observation corrects a milestone; forecasts never advance progress."""
def tracking_facts(raw):
"""Share the same corrections and actual/estimated split with aggregation."""
try:
facts = json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError):
return None
return {},{}
if not isinstance(facts, list):
return None
return {},{}
actual, estimated = {}, {}
for index, fact in enumerate(facts):
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
@@ -55,8 +55,27 @@ def build_tracking(raw):
candidate = (observation, index, when, fact)
if key not in target or candidate[:2] > target[key][:2]:
target[key] = candidate
if not actual and not estimated:
return None
return actual,estimated
def current_stage(raw):
actual,estimated=tracking_facts(raw)
progress={}
for (shipment,code) in actual:
if shipment not in progress or NODE_LABELS[code][0]>NODE_LABELS[progress[shipment]][0]:
progress[shipment]=code
shipments={shipment for shipment,_ in actual.keys() | estimated.keys() if shipment}
if shipments:
if not any(shipment in progress for shipment in shipments) and '' in progress:return progress['']
progress={shipment:progress.get(shipment,'NO_ACTUAL') for shipment in shipments}
codes=set(progress.values())
return next(iter(codes)) if len(codes)==1 else 'MIXED' if codes else 'NO_ACTUAL'
def build_tracking(raw):
"""Latest observation corrects a milestone; forecasts never advance progress."""
actual,estimated=tracking_facts(raw)
if not actual and not estimated:return None
shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
multiple = len(shipments) > 1
+37 -17
View File
@@ -94,15 +94,36 @@ RATES = {
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
DIMENSION_NUMBER = r'(?<![\d.+-])(\d+(?:\.\d+)?)(?![\d.])'
DIMENSION_UNIT = r'\s*(英寸|毫米|厘米|公分|米|(?:inches|inch|in|cm|mm|m)(?=$|[^A-Za-z0-9²³]|[xX])|["″])?\s*'
DIMENSION_AXIS = DIMENSION_NUMBER + DIMENSION_UNIT
DIMENSION_PATTERNS = tuple(re.compile(pattern,re.I) for pattern in (
DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS,
r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高\s*'+DIMENSION_AXIS,
DIMENSION_AXIS+r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高',
))
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
return [float(value) for value in match.groups()] if match else None
def dimension_measurement(text):
"""Keep units attached to the three packaging axes, not product descriptions."""
matches=[match for pattern in DIMENSION_PATTERNS if (match:=pattern.search(text))]
if not matches:return None
match=min(matches,key=lambda item:item.start())
parts=match.groups()
values=[float(parts[index]) for index in (0,2,4)]
aliases={'英寸':'in','inches':'in','inch':'in','"':'in','″':'in',
'毫米':'mm','厘米':'cm','公分':'cm','米':'m'}
units=[aliases.get(value.lower(),value.lower()) if value else None for value in parts[1::2]]
default=next((unit for unit in reversed(units) if unit),'cm')
units=[unit or default for unit in units]
if len(set(units))==1:return values,units[0]
factors={'cm':1,'mm':0.1,'m':100,'in':2.54}
return [value*factors[unit] for value,unit in zip(values,units)],'cm'
def parse_dims(t):
measurement=dimension_measurement(t)
return measurement[0] if measurement else None
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
@@ -142,11 +163,6 @@ def parse_shipping_intent(text):
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
if not intent["dims"]:
# 支持“长100宽80高60”
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
if dl:
intent["dims"] = [float(x) for x in dl.groups()]
# 重量:带单位的数字。去掉空格后在原始文本上匹配,
# 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
@@ -177,20 +193,24 @@ def parse_shipping_intent(text):
return intent
def detect_dim_unit(text):
"""识别尺寸单位:cm(默认)/ m / mm"""
"""Recognize explicit dimensions; aggregate CBM does not imply meters."""
measurement=dimension_measurement(text)
if measurement:return measurement[1]
low = text.lower()
if re.search(r'英寸|\binch(?:es)?(?![A-Za-z])|\d\s*in(?![A-Za-z0-9])|\d\s*["″]',low):
return 'in'
if re.search(r"毫米|\d\s*mm", low):
return "mm"
if re.search(r"米", low) and not re.search(r"厘米", low):
return "m"
if re.search(r"\d\s*cm|\d\s*厘米", low):
return "cm"
if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断
if re.search(r"\d\s*(?:m(?![A-Za-z0-9²³])|米)", low):
return "m"
return "cm"
def dims_to_cm(dims, unit):
"""统一换算成厘米,体积重公式按 cm 计算"""
if unit == 'in':
return [d * 2.54 for d in dims]
if unit == "m":
return [d * 100 for d in dims]
if unit == "mm":
+38
View File
@@ -0,0 +1,38 @@
"""Explicit weight-to-warehouse assignments; unassigned comparisons stay separate."""
from decimal import Decimal
import re
def allocations(text,codes,weight_pattern,to_kg):
if len(codes)<2:return None
code='('+'|'.join(re.escape(code) for code in sorted(codes,key=len,reverse=True))+r')(?![A-Za-z0-9])'
forward=weight_pattern.pattern+r'\s*(?:货物)?\s*(?:发到|发往|送到|发|到|去)?\s*'+code
reverse=code+r'\s*(?:[::]|分配|发|重)?\s*'+weight_pattern.pattern
entries=[];used=[]
for pattern,backwards in ((forward,False),(reverse,True)):
for match in re.finditer(pattern,text,re.I):
if any(match.start()<end and match.end()>start for start,end in used):continue
values=match.groups()
destination,value,unit=values if backwards else (values[2],values[0],values[1])
piece_weight=to_kg(value,unit)
weight_start=match.start(2 if backwards else 1)
carton=re.search(r'(第)?(\d+)\s*(?:箱|件)\s*(每(?:箱|件))?\s*$',text[:weight_start])
boxes=(1 if carton.group(1) else int(carton.group(2))) if carton else None
pieces=([{'count':boxes,'weight':str(piece_weight),'dims':None,'dim_unit':'cm','position':weight_start}]
if carton and (boxes==1 or carton.group(3)) else None)
weight=piece_weight*boxes if pieces else piece_weight
entries.append({'warehouse':destination.upper(),'weight':weight,'boxes':boxes,'packages':pieces})
used.append(match.span())
if len(entries)<2:return None
totals={}
for entry in entries:
code=entry['warehouse']
if code not in totals:
totals[code]=entry
continue
total=totals[code]
total['weight']+=entry['weight']
total['boxes']=total['boxes']+entry['boxes'] if total['boxes'] is not None and entry['boxes'] is not None else None
total['packages']=(total['packages']+entry['packages']) if total['packages'] and entry['packages'] else None
return [{**entry,'weight':str(entry['weight'])} for entry in totals.values()]
+131
View File
@@ -0,0 +1,131 @@
"""Answer business questions from the active local book, without old regional FAQs."""
from decimal import Decimal, ROUND_HALF_UP
import re
from .quote_fees import document, amount, material_fee, limit_fees
def answer_business(intent,book):
from .quote_engine import number,exact_number
text=intent['text'];mode=intent.get('mode')
if mode=='weight_basis':
return '运费按计费重量计算,不是只看净重。实重按含包装的毛重,与材积重取较大值;美国海派材积重为长×宽×高(cm)÷6000,每箱不足12KG按12KG计费。海卡包税按对应公斤规则,不包税按方方案以实际体积和毛重÷363取大;请按所选方案核算。'
if mode=='rounding':
weight=(f'若{exact_number(intent["weight"])}KG已是计费重,就按{exact_number(intent["weight"])}KG计算。' if intent.get('weight') else '')
return '美国海派按原小数计费,不向上取整;每箱不足12KG仍按12KG计费。'+weight+'计费重先按各箱实重与材积重取大,再按渠道对应的重量门槛匹配档位。海卡等其他方案的进位方式需另行核对。'
if mode=='mixing':
return '请分别提供各产品的品名、材质、数量、重量,以及是否带电、带磁、液体或粉末,并说明是否放在同一箱。需先核对各品类的承运与申报要求,再确认混装方案,不能仅凭“两种产品”判断可以混装。'
if mode=='metal_scope':
from .quote_fees import material_charge
from .quote_engine import quantity
per_kg=material_fee(book)
if per_kg is None:return '铁铝附加费只按铁铝货物的计费重收取,当前未匹配到费率,请联系人工核对。'
lines=[f'铁铝附加费为{per_kg:g}元/KG,只按铁铝货物的计费重收取;一票只有部分铁铝货物时,不按整票计费重加收。']
unit='cbm' if intent.get('tax')=='excluded' and intent.get('cbm') else 'kg'
rate={'kind':'truck' if intent.get('kinds')==['truck'] else 'parcel','unit':unit,'service':''}
details=material_charge(intent,quantity(intent,rate),unit,book)
lines+=details['items']+details['notes']
if intent.get('metal_scope')!='partial' and details['items'] and not intent.get('dims'):
lines.append('以上按当前提供的重量条件估算,仍需核对各箱尺寸及材积重。')
return '\n'.join(dict.fromkeys(lines))
if mode=='currency':
return '当前海卡、海派运价中的“元/KG”和“元/CBM”按人民币(RMB/CNY)计;一件代发等资料中标注“$”“USD”“美金”的费用按美元计。不同币种分别列明,未提供结算汇率时不自动换算。'
if mode=='insurance':
raw=document(book,('保险费','投保金额'))
percent=amount(raw,r'百分之\s*(\d+(?:\.\d+)?)')
minimum=amount(raw,r'min\s*(\d+(?:\.\d+)?)\s*USD')
multiplier=amount(raw,r'货值乘以\s*(\d+(?:\.\d+)?)')
if percent is None or minimum is None or multiplier is None:
return '当前资料未明确保险的完整计费条件,请提供货值和币种,由人工确认保险费。'
lines=[f'当前资料保险费率为保额的{percent:g}%,最低{minimum:g}USD;按货值投保时,投保金额为货值×{multiplier:g}。']
money=re.search(r'(-?\d+(?:\.\d+)?)\s*(万)?\s*(美元|美金|USD|人民币|元)',text,re.I)
if money and money.group(3).lower() in ('美元','美金','usd'):
value=Decimal(money.group(1))*(10000 if money.group(2) else 1)
if not 0<value<=1000000000:return '请提供大于0的有效货值或保额及币种。'
insured=value if re.search(r'保价|保额',text) and not re.search(r'货值',text) else value*multiplier
total=max(insured*percent/100,minimum).quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
lines.append(f'按保额{number(insured)}USD计算:max({number(insured)}×{percent:g}%,{minimum:g})={number(total)}USD。')
else:
lines.append('请提供货值/保额及币种;人民币货值还需确认适用结算汇率。')
return '\n'.join(lines)
if mode=='fulfillment':
tokens=[t for t in ('20GP','40GP','45HQ','散箱','散托','贴标','仓储','仓租','分拣','SKU') if t.lower() in text.lower()]
if '仓租' in tokens:tokens.append('仓储')
if '卸货' in text and not tokens:tokens=['20GP','40GP','45HQ']
rows=[]
for d in book['documents']:
if d['sheet']!='一件代发收费' or not re.search(r'\$|USD|美金',d['content'],re.I):continue
score=sum(t.lower() in d['content'].lower() for t in tokens) if tokens else 1
if score:rows.append((score,d['content']))
rows=sorted(rows,key=lambda row:(-row[0],-(';' in row[1])))
facts=list(dict.fromkeys(row[1] for row in rows))[:4]
return '美国仓一件代发收费:\n'+'\n'.join('- '+r for r in facts) if facts else '当前资料中未匹配到该一件代发收费项,请补充服务项目、箱数或体积,由人工核实。'
if mode=='customs':
if intent.get('services') and '美森加班' in intent['services'] and document(book,('不可接一般贸易',),'美森加班海卡'):
return '当前资料中美森加班海卡不可接一般贸易,请由人工确认其他渠道,不能直接按该线路加报关费办理。'
if re.search(r'FDA',text,re.I):
raw=document(book,('FDA','申报费'))
fee=amount(raw,r'申报费[::]?\s*RMB\s*(\d+(?:\.\d+)?)')
if fee is not None:return f'当前资料中需做FDA Filing等申报的产品,申报费为{fee:g}人民币/票。请补充具体品名和渠道,确认是否接收及需要的资料。'
return '当前资料未匹配到FDA申报费,请提供具体品名和渠道,由人工确认是否接收及费用。'
raw=document(book,('海外进口清关','续页'))
extra=amount(raw,r'加收USD\s*(\d+(?:\.\d+)?)')
lines=[]
if re.search(r'续页|品名',text) and extra is not None:
lines.append(f'海外进口清关:每票超过5个品名的部分,按{extra:g}美元/个收取。')
count=re.search(r'(\d+)\s*个品名',text)
if count:lines.append(f'{count.group(1)}个品名的该项费用:max({count.group(1)}−5,0)×{extra:g}={max(int(count.group(1))-5,0)*extra:g}美元。')
lines.append('国内出口报关续项与海外进口清关分别收费;国内条款的品名计数方式需按票确认。')
raw_fee=document(book,('一般贸易出口报关费',))
declaration=amount(raw_fee,r'出口报关费[::]\s*RMB\s*(\d+(?:\.\d+)?)')
if declaration is not None:lines.append(f'一般贸易出口报关费为{declaration:g}人民币/票。')
if re.search(r'单独报关',text):
minimum=document(book,('单独报关货物','300KG'))
if minimum:lines.append('单独报关货物最低按300KG计费;还需确认所选渠道是否接受一般贸易。')
return '\n'.join(lines) or '请提供具体申报项目、品名数量及渠道,当前资料无法确定该项费用。'
if mode=='acceptance':
raw=document(book,('禁止运输的货物',))
if raw:
prohibited=next((t for t in ('纯电池','液体','粉末','食品','医疗用品','仿牌') if t in text),None)
if prohibited:return f'当前海派资料将{prohibited}列为不接收/禁止运输的类别,不能按普通货直接报价。若咨询其他渠道,请先由人工核实承运条件。'
if re.search(r'带电|电池',text):return '当前海派资料中内置电池需要提供产品图片、MSDS及海运运输鉴定报告后单独确认;纯电池不接收。请补充电池类型和产品信息,确认后再匹配运价。'
return '请提供品名、材质、是否带电及所选渠道,先核对承运条件,再计算对应费用。'
if mode=='fees':
lines=[]
if re.search(r'含税|包税|税费',text):
from .quote_engine import variants,match_rates
taxes={rate['tax'] for destination in variants(intent,book) for rate in match_rates(intent,book,destination)}
if taxes=={'included'}:lines.append('当前匹配方案标注包税,按该方案的税费口径核算。')
elif taxes=={'excluded'}:lines.append('当前匹配方案标注不包税,税费需单独核对。')
else:lines.append('当前存在不同税费口径,请指定包税或不包税方案继续核对。')
if re.search(r'含派送|全包|包不包',text):
lines.append('报价行标注“包税”或“不包税”的方案按各自税费方式计算;海派的派送还需核对目的仓/邮编,住宅、商业地址及偏远等附加费不能直接当作已全部包含。')
metal=material_fee(book)
if metal is not None and re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text):
from .quote_fees import material_charge
from .quote_engine import quantity
unit='cbm' if intent.get('tax')=='excluded' and intent.get('cbm') else 'kg'
charge=quantity(intent,{'kind':'truck' if intent.get('kinds')==['truck'] else 'parcel','unit':unit,'service':''})
details=material_charge(intent,charge,unit,book)
lines.append(f'铁铝附加费为{metal:g}元/KG,只按铁铝货物的计费重收取。')
lines+=details['items']+details['notes']
if re.search(r'超重|超尺寸|超长',text):
raw=document(book,('超重收',))
fee=amount(raw,r'超重收\s*[++]?\s*(\d+(?:\.\d+)?)')
if fee is not None:lines.append(f'海派明确的普通超重附加费为{fee:g}元/件;同箱同时超重、超尺寸时只收适用的最高一项。')
limits=limit_fees(intent,book)
lines+=limits['items']+limits['notes']
if re.search(r'住宅|私人|商业地址|非亚马逊',text):
lines.append('住宅或商业等非亚马逊地址的派送附加费在原表中需单独询价;请提供完整邮编、地址类型和箱规,不能直接当作FBA仓价格。')
if not lines or re.search(r'包含|包括|还[要需]|什么费用',text):
raw=document(book,('偏远地址加收','超偏远'))
remote=amount(raw,r'(?<!超)偏远地址加收\s*(\d+(?:\.\d+)?)元/kg')
extended=amount(raw,r'超偏远地址加收\s*(\d+(?:\.\d+)?)元/kg')
if remote is not None and extended is not None:
lines.append(f'当前美国海派资料:偏远地址加{remote:g}元/KG,超偏远地址加{extended:g}元/KG,遥远地址单询;海派每箱不足12KG按12KG计费。')
lines.append('报价中的包税/不包税是不同费率口径;材质、报关申报、偏远及超重超尺寸等按条件单列,不代表所有服务费用都已包含。')
if metal is not None:lines.append(f'铁铝制品附加费{metal:g}元/KG,只按铁铝货物计费重收取;多项费用分别列明,同箱超限费用只取最高项。')
return '\n'.join(lines)
return None
+77
View File
@@ -0,0 +1,77 @@
"""Apply quote edits in chronological order, including derived carton quantities."""
from copy import deepcopy
from decimal import Decimal
from .quote_intent import kg,MISSING_WEIGHT_UNIT
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
def merge_intent(current,previous):
result=deepcopy(current)
excluded={'mode','selection','clear_selection','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'}
unit=current.get('weight_unit')
pending=previous.get('pending_weight')
weight_change=current['weight'] is not None or bool(unit and pending is not None)
if unit:
if pending is None:result['errors'].append('请同时提供重量数值和单位,例如800KG。')
else:result['weight']=str(kg(pending,unit))
elif pending is not None and current['weight'] is None and current.get('pending_weight') is None:
result['pending_weight']=pending
result['errors'].append(MISSING_WEIGHT_UNIT)
if not current.get('selection') and not current.get('clear_selection'):
result['selection']=previous.get('selection')
if current.get('clear_selection'):
excluded.update(('services','kinds','transport','air_only','air_requested'))
if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None:
result['metal_chargeable_weight']=None
result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
if current.get('warehouse') or current.get('unknown_warehouses'):
excluded.update(('postal','zone','zones'))
elif current.get('postal'):
excluded.update(('warehouse','warehouses','unknown_warehouses','zone','zones'))
elif current.get('zone'):
excluded.update(('warehouse','warehouses','unknown_warehouses','postal'))
if any(current.get(k) for k in ('warehouse','postal','zone')) and not current.get('unknown_warehouses'):
excluded.add('unknown_warehouses')
if any(current.get(k) for k in ('warehouse','unknown_warehouses','postal','zone')) and not current.get('allocations'):
excluded.add('allocations')
if current.get('allocations'):excluded.update(('packages','boxes','dims','cbm'))
if current['transport'] or current['kinds'] or current['services']:excluded.update(('air_only','air_requested'))
if current['air_only'] or current['tax']=='excluded':excluded.add('kinds')
if weight_change:
if not current.get('packages'):excluded.add('packages')
if not current.get('allocations'):excluded.add('allocations')
if current['dims']:excluded.add('cbm')
if current['cbm'] is not None:excluded.add('dims')
if (weight_change or any(current.get(k) is not None for k in ('dims','cbm','boxes','packages','allocations','transport','kinds','services','tax'))) and current.get('metal_chargeable_weight') is None:
excluded.add('metal_chargeable_weight')
if current.get('material')=='ordinary':excluded.update(('metal_scope','metal_chargeable_weight'))
if current.get('metal_scope')=='all' and current.get('metal_chargeable_weight') is None:
excluded.add('metal_chargeable_weight')
for key,value in previous.items():
if result.get(key) is None and key not in excluded:result[key]=deepcopy(value)
if current['dims'] is None and 'dims' not in excluded and previous['dims']:
result['dim_unit']=previous['dim_unit']
pieces=result.get('packages') or []
if current['dims']:
for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit'])
elif current['cbm'] is not None:
for piece in pieces:piece.update(dims=None,dim_unit='cm')
if previous.get('quantity_uncertain') and not weight_change and not current.get('preserve_total'):
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
if pieces and result['boxes']!=sum(p['count'] for p in pieces):
if current.get('preserve_total'):
result['packages']=None
elif len(pieces)==1:
pieces[0]['count']=result['boxes']
result['weight']=str(Decimal(pieces[0]['weight'])*result['boxes'])
result['quantity_note']=f'按每箱{pieces[0]["weight"]}KG不变,更新为{result["boxes"]}箱。'
else:
result['packages']=None
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
return result
+144
View File
@@ -0,0 +1,144 @@
"""Versioned, private quote imports and read-only runtime access."""
import json
import re
import sqlite3
from decimal import Decimal, InvalidOperation
def init_quote_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS quote_books (
id INTEGER PRIMARY KEY, source_sha256 TEXT NOT NULL UNIQUE,
source_name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 0)''')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_rates (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, service TEXT NOT NULL,
kind TEXT NOT NULL, origin TEXT NOT NULL, zone TEXT NOT NULL,
unit TEXT NOT NULL, tax TEXT NOT NULL, min_weight TEXT NOT NULL,
price TEXT NOT NULL, eta TEXT NOT NULL, destinations_json TEXT NOT NULL,
source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_rates_book ON quote_rates(book_id)')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_warehouses (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, code TEXT NOT NULL,
zone TEXT NOT NULL, state TEXT NOT NULL, postal TEXT NOT NULL,
address TEXT NOT NULL, source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_warehouse_code ON quote_warehouses(book_id,code)')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_postcodes (
book_id INTEGER NOT NULL, postal TEXT NOT NULL, severity TEXT NOT NULL,
fee TEXT, PRIMARY KEY(book_id,postal))''')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_documents (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, sheet TEXT NOT NULL,
source_cell TEXT NOT NULL, content TEXT NOT NULL)''')
def decimal_text(value, positive=False):
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
raise ValueError('Invalid quote amount') from None
if not number.is_finite() or number < 0 or number > 100000000 or (positive and not number):
raise ValueError('Invalid quote amount')
return format(number, 'f')
def import_snapshot(connection, snapshot, *, activate=False):
"""Validate before writing; replace an active book atomically, never order data."""
if not isinstance(snapshot, dict) or snapshot.get('schema') != 1:
raise ValueError('Unsupported quote snapshot')
digest = snapshot.get('source_sha256', '')
if not re.fullmatch('[0-9a-f]{64}', digest):
raise ValueError('Invalid quote source checksum')
source_name = str(snapshot.get('source_name', ''))[:240]
if not source_name:
raise ValueError('Missing quote source name')
limits = {'rates':10000, 'warehouses':10000, 'postcodes':50000, 'documents':5000}
for key, limit in limits.items():
if not isinstance(snapshot.get(key), list) or len(snapshot[key]) > limit:
raise ValueError('Invalid quote records')
rates = []
for row in snapshot['rates']:
if (row.get('kind') not in ('truck', 'parcel') or row.get('unit') not in ('kg', 'cbm')
or row.get('tax') not in ('included', 'excluded') or not row.get('service')
or not isinstance(row.get('destinations'), list)
or len(row['destinations']) > 200):
raise ValueError('Invalid quote rate')
rates.append((str(row['service'])[:100], row['kind'], str(row.get('origin', ''))[:40],
str(row.get('zone', ''))[:40], row['unit'], row['tax'],
decimal_text(row.get('min_weight', 0)), decimal_text(row['price'], positive=True),
str(row.get('eta', ''))[:200], json.dumps(row['destinations'], ensure_ascii=False),
str(row['source_sheet'])[:100], str(row['source_cell'])[:20]))
warehouses = [(str(r.get('code', '')).strip().upper()[:40], str(r.get('zone', ''))[:40],
str(r.get('state', ''))[:40], str(r.get('postal', ''))[:20], str(r.get('address', ''))[:1000],
str(r['source_sheet'])[:100], str(r['source_cell'])[:20]) for r in snapshot['warehouses']]
postcodes = []
for row in snapshot['postcodes']:
if not re.fullmatch('[0-9]{5}', row.get('postal', '')) or row.get('severity') not in ('remote','extended','extreme'):
raise ValueError('Invalid quote postcode')
postcodes.append((row['postal'], row['severity'], None if row.get('fee') is None else decimal_text(row['fee'])))
documents = [(str(r['sheet'])[:100], str(r['cell'])[:20], str(r['text'])[:16000]) for r in snapshot['documents']]
if not rates:
raise ValueError('Quote snapshot contains no rates')
init_quote_tables(connection)
if not connection.in_transaction:
connection.execute('BEGIN IMMEDIATE')
connection.execute('SAVEPOINT quote_import')
try:
existing = connection.execute('SELECT id FROM quote_books WHERE source_sha256=?', (digest,)).fetchone()
if existing:
book_id = existing[0]
else:
book_id = connection.execute('INSERT INTO quote_books(source_sha256,source_name) VALUES(?,?)',
(digest, source_name)).lastrowid
connection.executemany('''INSERT INTO quote_rates(book_id,service,kind,origin,zone,unit,tax,
min_weight,price,eta,destinations_json,source_sheet,source_cell) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)''',
[(book_id, *r) for r in rates])
connection.executemany('''INSERT INTO quote_warehouses(book_id,code,zone,state,postal,address,source_sheet,source_cell)
VALUES(?,?,?,?,?,?,?,?)''', [(book_id, *r) for r in warehouses])
connection.executemany('INSERT INTO quote_postcodes(book_id,postal,severity,fee) VALUES(?,?,?,?)',
[(book_id, *r) for r in postcodes])
connection.executemany('INSERT INTO quote_documents(book_id,sheet,source_cell,content) VALUES(?,?,?,?)',
[(book_id, *r) for r in documents])
if activate:
connection.execute('UPDATE quote_books SET active=0 WHERE active<>0')
connection.execute('UPDATE quote_books SET active=1 WHERE id=?', (book_id,))
connection.execute('RELEASE quote_import')
except Exception:
connection.execute('ROLLBACK TO quote_import')
connection.execute('RELEASE quote_import')
raise
return {'book_id':book_id, 'rates':len(rates), 'warehouses':len(warehouses),
'postcodes':len(postcodes), 'documents':len(documents), 'active':bool(activate), 'existing':bool(existing)}
def load_book():
"""One read transaction prevents mixed versions during a concurrent import."""
from .db import connect
connection = connect()
try:
connection.row_factory = sqlite3.Row
connection.execute('BEGIN')
book = connection.execute('SELECT id FROM quote_books WHERE active=1 ORDER BY id DESC LIMIT 1').fetchone()
if not book:
return None
book_id = book['id']
result = {'id':book_id}
for table, name in (('quote_rates','rates'), ('quote_warehouses','warehouses'), ('quote_documents','documents')):
result[name] = [dict(r) for r in connection.execute(f'SELECT * FROM {table} WHERE book_id=?', (book_id,))]
for rate in result['rates']:
rate['destinations'] = json.loads(rate.pop('destinations_json'))
return result
finally:
connection.close()
def postcode_fee(book_id, postal):
from .db import connect
connection = connect()
try:
connection.row_factory = sqlite3.Row
row = connection.execute('SELECT severity,fee FROM quote_postcodes WHERE book_id=? AND postal=?',
(book_id, postal)).fetchone()
return dict(row) if row else None
finally:
connection.close()
+14
View File
@@ -0,0 +1,14 @@
"""Discard explicitly rejected quote conditions before parsing their replacements."""
import re
def clean_conditions(text):
rejected=r'(?:不是|不要|并非|不从)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到)?\s*'
value=(r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|'
r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品')
return re.sub(rejected+'(?:'+value+')',' ',text,flags=re.I)
def weight_comparison(text):
return bool(re.search(r'会不会按|是不是按|是否按|向上进位|小数公斤|计费.*(?:进位|取整)|公斤.*(?:进位|取整)',text))
+251
View File
@@ -0,0 +1,251 @@
"""Bounded quote matching and Decimal arithmetic, independent of Flask/models."""
from decimal import Decimal, ROUND_HALF_UP
import re
from .pricing import dims_to_cm
from .quote_intent import US, QUOTE_WORDS, parse_intent, is_followup
def broad_zone(postal):
if not postal:
return None
if postal.startswith(('8','9')):
return '美西'
if postal.startswith('46') or postal[0] in '0123':
return '美东'
return '美中'
def variants(intent, book):
if intent.get('allocations'):
return [{**destination,'allocation':part} for part in intent['allocations']
for destination in variants({**intent,'allocations':None,'warehouse':part['warehouse'],
'warehouses':None,'postal':None},book)]
if len(intent.get('warehouses') or [])>1:
return [variant for warehouse in intent['warehouses'] for variant in
variants({**intent,'warehouse':warehouse,'warehouses':None,'postal':None},book)]
if not intent['warehouse'] and len(intent.get('zones') or [])>1:
return [variant for zone in intent['zones'] for variant in
variants({**intent,'zone':zone,'zones':None},book)]
warehouse = intent['warehouse']
found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else []
zoned = [r for r in found if r['zone']]
if zoned:
unique = {}
for row in zoned:
postal = intent['postal'] or row['postal'][:5]
key = (row['zone'], postal)
unique.setdefault(key, {'label':f"{warehouse} · {row['zone']}", 'postal':postal,
'zone':broad_zone(postal), 'warehouse':warehouse})
return list(unique.values())
postal = intent['postal']
if not postal and found:
match = re.search(r'(?<![0-9])([0-9]{5})(?:-[0-9]{4})?(?![0-9])', found[0]['address'])
postal = match.group(1) if match else None
zone = intent['zone']
if zone and zone[0] in '东中西':
zone = {'东':'美东','中':'美中','西':'美西'}[zone[0]]
return [{'label':warehouse or postal or zone or '美国', 'postal':postal, 'zone':zone or broad_zone(postal), 'warehouse':warehouse}]
def quantity(intent, rate):
real = Decimal(intent['weight'] or '0')
volume = Decimal(intent['cbm']) if intent['cbm'] else None
if volume is None and intent['dims']:
cm = [Decimal(str(value)) for value in dims_to_cm(intent['dims'], intent['dim_unit'])]
volume = cm[0]*cm[1]*cm[2]/Decimal(1000000)*(intent['boxes'] or 1)
package_charge=Decimal(0)
if intent.get('packages'):
package_volume=Decimal(0)
all_dimensions=True
for package in intent['packages']:
dims=package.get('dims') or intent['dims']
cm=[Decimal(str(value)) for value in dims_to_cm(dims,package.get('dim_unit') or intent['dim_unit'])] if dims else None
box_volume=cm[0]*cm[1]*cm[2]/1000000 if cm else Decimal(0)
all_dimensions=all_dimensions and bool(cm)
package_volume+=box_volume*package['count']
piece=max(Decimal(package['weight']),box_volume*1000000/6000 if rate['kind']=='parcel' else box_volume*167)
package_charge+=max(piece,Decimal(12) if rate['kind']=='parcel' else Decimal(0))*package['count']
if all_dimensions and not intent['cbm']:volume=package_volume
if rate['unit'] == 'cbm':
if volume is None:
return None
result=max(volume, real/Decimal(363))
if rate['service']=='洛杉矶普船海卡':
result=result.quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
return result if result>0 else None
volume_weight = ((volume or Decimal(0))*1000000/6000 if rate['kind']=='parcel'
else (volume or Decimal(0))*167)
charge = max(real, volume_weight,package_charge)
if rate['kind'] == 'parcel':
charge = max(charge, Decimal(12)*(intent['boxes'] or 1))
return charge if real or volume else None
def match_rates(intent, book, variant):
selected = []
kinds = intent['kinds'] or (['truck','parcel'] if variant['warehouse'] else ['parcel'])
for rate in book['rates']:
if rate['kind'] not in kinds or (intent['origin'] and intent['origin'] != rate['origin']):
continue
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
continue
if intent['tax'] and rate['tax'] != intent['tax']:
continue
if rate['kind'] == 'truck':
if not variant['warehouse'] or variant['warehouse'] not in [value.upper() for value in rate['destinations']]:
continue
elif variant['zone'] and rate['zone'] != variant['zone']:
continue
selected.append(rate)
groups = {}
for rate in selected:
key = (rate['service'],rate['origin'],rate['zone'],rate['unit'],rate['tax'])
charge = quantity(intent,rate)
if rate['kind'] == 'parcel' and Decimal(rate['min_weight']) > (charge or Decimal(12)):
continue
if key not in groups or Decimal(rate['min_weight']) > Decimal(groups[key]['min_weight']):
groups[key] = rate
return list(groups.values())
def number(value, places=2):
return format(Decimal(value).quantize(Decimal(10)**-places, rounding=ROUND_HALF_UP), 'f').rstrip('0').rstrip('.') if places else str(value)
def exact_number(value):
text=format(Decimal(value),'f')
return text.rstrip('0').rstrip('.') if '.' in text else text
def quantity_number(value,intent):
if Decimal(value)==Decimal(intent.get('weight') or '0') or Decimal(value).as_tuple().exponent>=-6:
return exact_number(value)
return '约'+number(value,6)
def select_timed_rates(rates,slowest=False):
"""Compare numeric day ranges within one region, service kind and billing basis."""
ranked=[]
best={}
for rate in rates:
match=re.search(r'(?<![\d.])(\d+(?:\.\d+)?)\s*(?:[-—–~~至]\s*(\d+(?:\.\d+)?))?\s*(?:天|days?)',rate['eta'],re.I)
if not match:continue
low=Decimal(match[1])
high=Decimal(match[2] or match[1])
if low<=0 or high<low:continue
key=(rate['zone'],rate['kind'],rate['unit'],rate['tax'])
timing=(high,low) if slowest else (low,high)
ranked.append((rate,key,timing))
choose=max if slowest else min
best[key]=choose(best.get(key,timing),timing)
return [rate for rate,key,timing in ranked if timing==best[key]]
def render_rates(intent, book, destinations, remote_fees):
from .quote_fees import charge_details
from .quote_language import translate_quote
lines = []
rate_selection=intent.get('selection') or intent.get('mode')
if intent.get('mode')=='time':
lines.append('美国线路参考时效:')
elif intent['weight']:
lines.append(f"美国 {exact_number(intent['weight'])}KG 基础运费:")
else:
lines.append('美国线路报价:')
if intent.get('quantity_note'):lines.append(intent['quantity_note'])
if intent.get('allocations'):
lines.append('按各目的仓分票计算,箱规需逐票核对:')
if intent.get('metal_chargeable_weight') is not None:
lines.append('请分别提供各目的仓的铁铝货物计费重,整票铁铝基数不能重复套用到每个仓。')
elif len(intent.get('warehouses') or [])>1:
lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。')
if intent.get('air_requested') and not intent.get('air_only'):
lines.append('当前资料没有美国空运费率,下面列出海运方案。')
for destination, fees in zip(destinations, remote_fees):
part=destination.get('allocation')
billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')}
if part else intent)
if part and intent.get('metal_chargeable_weight') is not None:
billing.update(metal_chargeable_weight=None,metal_scope='partial')
rates = match_rates(billing, book, destination)
if rate_selection=='cheapest' and rates:
eligible=[r for r in rates if quantity(billing,r) is not None]
best={}
for rate in eligible:
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
value=quantity(billing,rate)*Decimal(rate['price'])+charge_details(billing,rate,fees,book)['cny']
best[key]=min(best.get(key,value),value)
rates=[r for r in eligible if quantity(billing,r)*Decimal(r['price'])+charge_details(billing,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
if not rates:
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
continue
if destination['label'] != '美国':
lines.append(destination['label']+':')
selections=[('',rates)]
if rate_selection in ('fastest','slowest','speed_compare'):
selections=[]
if rate_selection in ('fastest','speed_compare'):
selections.append(('最快参考方案',select_timed_rates(rates)))
if rate_selection in ('slowest','speed_compare'):
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True)))
lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
grouped = {}
for selection,selected_rates in selections:
if selection and not selected_rates:
lines.append(selection+'暂时无法确定,匹配线路未列明可比较的天数。')
for rate in selected_rates:
key = (selection, rate['service'], rate['origin'], rate['unit'], rate['tax'], rate['eta'])
grouped.setdefault(key, []).append(rate)
last_selection=None
for (selection,service, origin, unit, tax,eta_source), rows in grouped.items():
if selection and selection!=last_selection:
lines.append(selection)
last_selection=selection
eta = re.sub(r'\s*无时效赔偿', '', eta_source).strip()
if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取'
elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta
if intent.get('mode')=='time':
lines.append(f'- {service} · {origin},{eta or "当前资料未列明时效"}。')
continue
parts = []
for rate in rows:
amount = quantity(billing,rate)
price = Decimal(rate['price'])
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
if amount is not None:
total = amount*price
details=charge_details(billing,rate,fees,book)
total+=details['cny']
rate_text += f",{quantity_number(amount,billing)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
if details['items']:rate_text+='('+ ';'.join(details['items'])+')'
for note in details['notes']:
if note not in lines:lines.append(note)
elif intent.get('cbm') and rate['unit']=='cbm':
lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。')
parts.append(rate_text)
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
+ (f'({eta})' if eta else ''))
if fees:
if fees['fee'] is None:
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
else:
lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。")
if not any(intent.get(key) for key in ('warehouse','postal')):
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
if intent.get('mode')=='time':
lines.append('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。')
elif not intent['weight'] and not intent['cbm'] and not intent['dims']:
lines.append('货物多少公斤、几箱?')
elif not intent['boxes'] or not intent['dims']:
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
if intent.get('mode')=='calculation':
lines.append('计算过程:各箱实重与体积重取大;海派每箱不足12KG按12KG计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
text='\n'.join(lines)
return translate_quote(text) if intent.get('english') else text
+122
View File
@@ -0,0 +1,122 @@
"""Amounts come from the active book; explicit customer-approved fee policies live here."""
from decimal import Decimal
import re
from .pricing import dims_to_cm
def document(book,required,service=None):
rows=[d for d in book['documents'] if (not service or d['sheet'] in (service,'海派'))
and all(term in d['content'] for term in required)]
return rows[0]['content'] if rows else ''
def amount(text,pattern):
match=re.search(pattern,text,re.I)
return Decimal(match.group(1)) if match else None
def material_fee(book):
text=document(book,('铁铝','附加费'))
return amount(text,r'铁铝制品[^\n]*?[¥¥]\s*(\d+(?:\.\d+)?)\s*/KG')
def material_charge(intent,charged,unit,book):
result={'cny':Decimal(0),'items':[],'notes':[]}
material=intent.get('material')
if material not in ('metal','unknown_metal'):return result
per_kg=material_fee(book)
if per_kg is None:
result['notes'].append('铁铝附加费费率尚未明确,当前金额未包含该项,请联系人工确认。')
return result
if material=='unknown_metal':
result['notes'].append('请先确认金属是否属于铁铝制品;铁铝附加费只按铁铝货物的计费重收取,当前金额未包含待核实的材质费用。')
return result
basis=intent.get('metal_chargeable_weight')
if basis is None and intent.get('metal_scope')=='partial':
result['notes'].append(f'铁铝附加费{per_kg:g}元/KG只按铁铝货物的计费重收取,不能按整票重量加费;请提供铁铝部分的计费重,当前金额未包含该项。')
return result
if basis is None and unit!='kg':
result['notes'].append(f'铁铝附加费为{per_kg:g}元/KG,当前按方运费未包含该项;需确认铁铝货物计费重量后单列,不能把CBM直接乘公斤附加费。')
return result
basis=Decimal(basis) if basis is not None else charged
if basis is None:return result
if unit=='kg' and charged is not None and basis>charged:
result['notes'].append('铁铝货物计费重高于当前整票可计算的计费重,请补充各箱实重和尺寸核对;当前金额未包含该项。')
return result
fee=basis*per_kg
result['cny']=fee
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{basis:g}KG={fee:g}元')
return result
def specimens(intent):
if intent.get('packages'):
return [{**p,'dims':p.get('dims') or intent.get('dims'),
'dim_unit':p.get('dim_unit') if p.get('dims') else intent.get('dim_unit','cm')} for p in intent['packages']]
if intent.get('boxes')==1 and intent.get('weight'):
return [{'count':1,'weight':intent['weight'],'dims':intent.get('dims'),'dim_unit':intent.get('dim_unit','cm')}]
return []
def limit_fees(intent,book):
"""Maximum per-piece limit fee, not the sum of overlapping weight/size charges.
The malformed 0<length<274 rule does not create a new threshold. Only two
independently explicit large-piece conditions can establish that tier.
"""
normal=document(book,('正常货物',))
cleaned=re.sub(r'\s+','',normal).replace('<','<').upper()
limits=[amount(cleaned,p) for p in (r'0KG<单件重量<(\d+)KG',r'0CM<最长单边长度<(\d+)CM',
r'0CM<第二边<(\d+)CM',r'最长边<(\d+)CM')]
normal_fee=amount(document(book,('超重收',)),r'超重收\s*[++]?\s*(\d+(?:\.\d+)?)')
size_text=document(book,('普通超尺寸',))
size_fee=amount(size_text,r'加收\s*(\d+(?:\.\d+)?)\s*RMB/件')
large_text=document(book,('大尺寸货物','任意2个'))
large_fee=amount(large_text,r'加收\s*(\d+(?:\.\d+)?)\s*RMB/件')
huge=document(book,('超大件货物',))
huge_fee=amount(huge,r'加收\s*USD\s*(\d+(?:\.\d+)?)')
result={'cny':Decimal(0),'items':[],'notes':[]}
if not all(limits) or normal_fee is None:return result
max_weight,max_length,max_second,max_girth=limits
for piece in specimens(intent):
weight=Decimal(piece['weight']);count=piece['count']
dims=sorted([Decimal(str(x)) for x in dims_to_cm(piece['dims'],piece['dim_unit'])],reverse=True) if piece['dims'] else []
length,second,girth=(dims[0],dims[1],dims[0]+2*(dims[1]+dims[2])) if dims else (None,None,None)
if weight>68 or (dims and (length>270 or girth>419)):
result['notes'].append('该件可能超出承运尺寸或重量,需人工确认是否接收。'+(f'原表超大件若接收的附加费为{huge_fee:g}USD/件。' if huge_fee else ''))
continue
candidates=[]
if weight>max_weight:candidates.append(normal_fee)
if dims and (length>max_length or second>max_second or girth>max_girth) and size_fee is not None:candidates.append(size_fee)
if large_fee is not None and max_weight<weight<68 and dims and 330<girth<419:
candidates.append(large_fee)
elif dims and (length>=243 or girth>=330):
result['notes'].append('该件接近大尺寸档,原表有一处长度条件不清,适用档位需人工确认。')
if candidates:
fee=max(candidates)*count
result['cny']+=fee
result['items'].append(f'超重/超尺寸按最高项{max(candidates):g}元/件×{count}件={fee:g}元')
return result
def charge_details(intent,rate,remote,book):
from .quote_engine import quantity
result={'cny':Decimal(0),'items':[],'notes':[]}
charged=quantity(intent,rate)
if charged is None:return result
if rate['kind']=='parcel' and remote:
if remote['fee'] is not None:
fee=charged*Decimal(remote['fee']);result['cny']+=fee
result['items'].append(f'偏远费{remote["fee"]}元/KG×{charged:g}KG={fee:g}元')
else:result['notes'].append('遥远地址的附加费需单独确认,当前金额未包含该项。')
metal=material_charge(intent,charged,rate['unit'],book)
result['cny']+=metal['cny'];result['items']+=metal['items'];result['notes']+=metal['notes']
if rate['kind']=='parcel':
limits=limit_fees(intent,book)
result['cny']+=limits['cny'];result['items']+=limits['items'];result['notes']+=limits['notes']
if intent.get('address_type')=='non_fba':
result['notes'].append('非亚马逊地址还需核对住宅/商业派送附加费,原表此项需单独询价。')
return result
+211
View File
@@ -0,0 +1,211 @@
"""Normalize customer wording into bounded quote fields without model inference."""
from decimal import Decimal
import re
from .pricing import parse_shipping_intent, detect_dim_unit, shipping_region
US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|'
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))',re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|收费|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',re.I)
WEIGHT_UNITS = r'kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅'
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*('+WEIGHT_UNITS+r')(?![A-Za-z])',re.I)
MISSING_WEIGHT_UNIT = '请补充修改后重量的单位,例如KG或吨,我再按新重量核算。'
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
def resumes_quote(message):
return bool(re.search(r'(?:回到|回头|继续)(?:刚才|之前|前面|上面)(?:的)?(?:美国)?(?:海运)?(?:报价|价格|运费)',message))
def chinese_number(text):
digits={c:i for i,c in enumerate('零一二三四五六七八九')};digits.update({'两':2,'〇':0})
if all(c in digits for c in text):return ''.join(str(digits[c]) for c in text)
if '点' in text:
left,right=text.split('点',1)
return str(chinese_number(left or '零'))+'.'+''.join(str(digits[c]) for c in right)
total=section=number=0
for character in text:
if character in digits:
number=digits[character]
elif character=='万':
total+=(section+number)*10000;section=number=0
else:
section+=(number or 1)*{'十':10,'百':100,'千':1000}[character];number=0
return str(total+section+number)
def normalize(text):
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|(?:个)?箱|件|立方米|立方|方|kg|cbm))',
lambda m:chinese_number(m.group(1)),text,flags=re.I)
return re.sub(r'负(?=\d)','-',result)
def kg(value,unit):
value=Decimal(value)
unit=unit.lower()
if unit in ('克','g','gram','grams'):return value/1000
if unit in ('吨','ton','tons'):return value*1000
if unit in ('磅','lb','lbs'):return (value*Decimal('0.4536')).quantize(Decimal('.01'))
return value
def request_selection(text):
if re.search(r'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
fastest=bool(re.search(r'最快|快的|fastest',text,re.I))
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
if fastest and slowest:return 'speed_compare'
if fastest:return 'fastest'
if slowest:return 'slowest'
return None
def request_mode(text):
# Business topics outrank generic verbs such as “怎么算”.
if re.search(r'保险|保价|投保|insured|insurance',text,re.I):return 'insurance'
from .quote_edits import weight_comparison
if weight_comparison(text):return 'rounding'
if re.search(r'毛重|净重|计费重.*(?:什么|区别)|材积重.*(?:什么|区别)',text):return 'weight_basis'
if re.search(r'混装|混箱|不同产品.*一箱',text):return 'mixing'
if (re.search(r'(?:铁|钢|铝|金属).*(?:附加|加费|加钱|整票)|整票.*(?:铁|钢|铝|金属)',text)
and not re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text)):
return 'metal_scope'
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
from .shipping_faq import preparation_topic
if preparation_topic(text):return 'preparation'
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text):return 'total'
if re.search(r'一件代发|散箱入库|整柜卸货|卸货费|仓租|仓储|贴标费',text):return 'fulfillment'
if re.search(r'FDA|申报费|报关|续页|品名',text,re.I):return 'customs'
if re.search(r'附加费|含税|含派送|全包|包不包|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
if re.search(r'电池|带电|液体|粉末|食品|医疗|能(?:不能|发|走)|可以.*(?:发|走)',text):return 'acceptance'
if re.search(r'人民币|美元|美金|币种|currency|\b(?:RMB|CNY|USD)\b',text,re.I) and not WEIGHT.search(text):return 'currency'
if re.search(r'怎么(?:算|计算)|计算(?:过程|出来)|怎么算出来|calculation',text,re.I):return 'calculation'
if re.search(r'时效|多久|几天|多快(?:能|会)?到',text) and not WEIGHT.search(text):return 'time'
return request_selection(text) or 'quote'
def parse_intent(message,warehouse_codes):
text=normalize(message)
from .quote_edits import clean_conditions
text=clean_conditions(text)
text=re.sub(r'(?:而?不是|并非|不要按|不是按)\s*(?:每[箱件]|单[箱件])?\s*'+WEIGHT.pattern,' ',text,flags=re.I)
from .quote_material import material_intent
materials=material_intent(text,WEIGHT,kg)
quantity_text=materials['quantity_text']
parsed=parse_shipping_intent(quantity_text)
country='US' if US.search(text) else ('other' if shipping_region(text) is not None else None)
candidates=re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])',text.upper())
warehouses=list(dict.fromkeys(t for t in candidates if t in warehouse_codes))
unknown=[t for t in candidates if t not in warehouse_codes and re.fullmatch(r'[A-Z]{2,4}[0-9][A-Z0-9]{0,2}',t)
and not t.startswith(('USD','RMB','CNY'))]
errors=materials['errors'][:]
pending_weight=re.search(r'(?:总重量|总重|重量|毛重|实重)\s*(?:改成|改为|改到|换成|调整为|是|为)?\s*'
r'(-?\d+(?:\.\d+)?)(?![\d.])(?!\s*(?:'+WEIGHT_UNITS+r')(?![A-Za-z]))',text,re.I)
if pending_weight:errors.append(MISSING_WEIGHT_UNIT)
weight_unit=re.fullmatch(r'(?:单位(?:是|用|为)?|按)?\s*('+WEIGHT_UNITS+r')(?:计算|算|计)?[。.!!??\s]*',text,re.I)
from .quote_allocations import allocations
assigned=allocations(quantity_text,warehouses,WEIGHT,kg)
weights=[m for m in WEIGHT.finditer(quantity_text) if not quantity_text[:m.start()].rstrip().endswith(('x','X','×','*'))]
weight=str(kg(*weights[0].groups())) if weights else None
boxes=re.search(r'(\d+)\s*(?:个)?(?:箱(?:子)?|件|cartons?|boxes?)',quantity_text,re.I)
count=int(boxes.group(1)) if boxes else None
packages=[]
package_re=re.compile(r'(第)?(\d+)\s*(?:箱|件)\s*(?:[,,]?\s*每(?:箱|件))?\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),re.I)
matches=list(package_re.finditer(quantity_text))
package_dimensions={}
for index,match in enumerate(matches):
ordinal,n,value,unit=match.groups()
segment=quantity_text[match.end():matches[index+1].start() if index+1<len(matches) else len(quantity_text)]
dims=parse_shipping_intent(segment)['dims']
packages.append({'count':1 if ordinal else int(n),'weight':str(kg(value,unit)),
'dims':dims,'dim_unit':detect_dim_unit(segment)})
package_dimensions[match.start(3)]={'dims':dims,'dim_unit':detect_dim_unit(segment)}
for part in assigned or []:
for piece in part.get('packages') or []:
piece.update(package_dimensions.get(piece.pop('position'),{}))
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
if each and count and not packages:
packages=[{'count':count,'weight':str(kg(*each.groups())),'dims':parsed['dims'],'dim_unit':detect_dim_unit(text)}]
if packages:
package_count=sum(p['count'] for p in packages)
aggregate_count=boxes and not any(m.start()<=boxes.start()<m.end() for m in matches)
if count and aggregate_count and count!=package_count:
errors.append('箱数与分箱明细不一致,请确认总箱数及每箱重量。')
count=package_count
weight=str(sum(Decimal(p['weight'])*p['count'] for p in packages))
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
# Per-carton dimensions must not spill onto another carton without dimensions.
if len(packages)>1:parsed['dims']=None
elif assigned:
weight=str(sum(Decimal(a['weight']) for a in assigned))
if set(a['warehouse'] for a in assigned)!=set(warehouses):errors.append('请分别提供每个目的仓的重量,避免遗漏分票。')
total=re.search(r'(?:总重|合计重量)\s*'+WEIGHT.pattern,quantity_text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与各目的仓分配重量不一致,请确认。')
elif len(weights)>1 and request_mode(text)!='rounding':
errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。')
if assigned and packages and Decimal(weight)!=sum(Decimal(a['weight']) for a in assigned):
errors.append('分仓重量与分箱重量不一致,请分别确认每仓的箱数和重量。')
if any(kg(*w.groups())<=0 for w in weights):errors.append('重量必须大于 0,请确认有效重量。')
labeled=re.search(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*([0-9-]+)',text,re.I)
postal=None
if labeled:
if re.fullmatch(r'[0-9]{5}(?:-[0-9]{4})?',labeled.group(1)):postal=labeled.group(1)[:5]
else:errors.append('美国邮编请提供5位数字,可附加4位扩展码;当前邮编格式不正确。')
else:
remaining=WEIGHT.sub(' ',text)
remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining)
match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining)
if match:postal=match.group(1)
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I)
origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None)
if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None
services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()]
if '美森' in text and not any(n.startswith('美森') for n in services):services+=['美森正班','美森加班']
kinds=[]
if re.search(r'海派|快递|UPS|FEDEX',text,re.I):kinds.append('parcel')
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路',text):kinds.append('truck')
zones=[z for z in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if z in text]
if not zones and '洛杉矶' in text:zones=['美西']
if not zones and re.search(r'纽约|萨凡纳',text):zones=['美东']
air=bool(re.search(r'空运|空派|\bair\b',text,re.I))
sea=bool(re.search(r'海运|海派|海卡|海铁|\bsea\b',text,re.I))
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}\s+[A-Za-z]',message))
mode=request_mode(text)
return {'country':country,'weight':weight,'dims':parsed['dims'],'dim_unit':detect_dim_unit(text),
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'allocations':assigned,'origin':origin,
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
'air_only':True if air and not sea else None,'air_requested':True if air else None,
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
'errors':errors,'mode':mode,'selection':request_selection(text),
'pending_weight':pending_weight[1] if pending_weight else None,
'weight_unit':weight_unit[1] if weight_unit else None,
'clear_selection':bool(re.search(r'(?:全部|所有|各个?)(?:的)?(?:渠道|线路|方案)|(?:全部|所有)(?:报价|价格|运价)',text)),
'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
'material':materials['material'],'metal_scope':materials['metal_scope'],
'metal_chargeable_weight':materials['metal_chargeable_weight'],
'metal_weight_update':materials['metal_weight_update'],
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text}
def is_followup(message,intent):
if resumes_quote(message):return True
if intent.get('weight_unit'):return True
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
if TOPIC_BOUNDARY.search(message):return False
text=message.strip().strip(',,。.!!?? ')
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True
if intent.get('warehouse') or intent.get('unknown_warehouses'):
return not (intent['country'] and intent['weight'])
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
if re.fullmatch(r'具体(?:多少|多少钱)(?:呢)?',text):return True
if re.fullmatch(r'(?:请|请问|帮我|给我|请给我|麻烦)?(?:再|继续)?(?:提供|显示|列出|看一下|看看|查询)?'
r'(?:具体|详细|全部渠道|所有渠道|各渠道)?(?:报价|价格|运费|费用)(?:呢|是多少|多少|怎么样|如何|多少钱)?',text):return True
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin','zone','material','metal_scope','metal_chargeable_weight')))
+37
View File
@@ -0,0 +1,37 @@
"""English rendering for deterministic quotes; numerical facts remain unchanged."""
import re
def translate_quote(text):
replacements=(
('美森正班海派','Matson regular sea parcel'),('美森加班海派','Matson extra sea parcel'),
('美森正班海卡','Matson regular sea truck'),('美森加班海卡','Matson extra sea truck'),
('纽约EXX海铁','New York EXX sea rail'),('洛杉矶普船全卡','Los Angeles regular sea truck direct'),
('洛杉矶普船海卡','Los Angeles regular sea truck'),('芝加哥普船海铁','Chicago regular sea rail'),
('休斯顿普船海卡','Houston regular sea truck'),('纽约普船海卡','New York regular sea truck'),
('萨凡纳普船海卡','Savannah regular sea truck'),('EXX海卡','EXX sea truck'),('合德海卡','Hede sea truck'),
('合德海派','Hede sea parcel'),('普船海派','Regular sea parcel'),
('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。','Please provide the carton count, weight and dimensions of each carton to calculate chargeable weight and size surcharges.'),
('发我目的仓码或邮编,可继续匹配到仓线路。','Send the destination warehouse code or ZIP code to match the route.'),
('货物多少公斤、几箱?','What are the total weight and carton count?'),
('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。','These times run from sailing to pickup. Delivery timing depends on last-mile progress.'),
('当前资料没有美国空运费率,下面列出海运方案。','The current rate sheet has no US air rates. Sea options are listed below.'),
('以下按各目的仓分别比较;实际分票请提供每个仓的重量。','Each destination is compared separately; provide the weight assigned to each shipment.'),
('按相同区域、计费单位及税费方式比较的较低价方案:','Lower-priced options within the same region, billing unit and tax basis:'),
('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。','Reference times are compared within the same region, service kind, billing unit and tax basis. Fastest uses the lower day bound and slowest uses the upper day bound.'),
('暂时无法确定,匹配线路未列明可比较的天数。',' cannot be determined because matching routes have no comparable numeric day estimates.'),
('最快参考方案','Fastest reference options'),('最慢参考方案','Slowest reference options'),
('按资料中的参考时效,较快方案如下;提取不等于送达或签收:','Faster reference options follow. Pickup timing is not a delivery or receipt commitment:'),
('该邮编属于遥远地址,派送附加费需单独确认。','This ZIP code is extremely remote; the delivery surcharge requires confirmation.'),
('海派已计入偏远附加费','Sea parcel totals include the remote surcharge of '),
('当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。','No matching route rate was found. Please provide the destination warehouse or ZIP code.'),
('美国线路参考时效','US reference transit times'),('美国线路报价','US route rates'),('基础运费','base freight'),('美国','US'),('义乌','Yiwu'),('华南','South China'),
('不包税','tax excluded'),('包税','tax included'),('美西','US West'),('美中','US Central'),('美东','US East'),
('开船后参考','Reference timing after sailing: '),('船开后','After sailing: '),('开船后','After sailing: '),
('若干','several '),
('提取',' until pickup'),('天左右',' days approximately'),('天',' days'),('元/KG',' CNY/kg'),('元/CBM',' CNY/CBM'),
('元',' CNY'),(':',': '),(';','; '),(',',', '),('(',' ('),(')',')'))
for source,target in replacements:text=text.replace(source,target)
text=re.sub(r'([东中西])(\d)区',lambda m:{'东':'East','中':'Central','西':'West'}[m[1]]+' zone '+m[2],text)
return text
+33
View File
@@ -0,0 +1,33 @@
"""Separate metal chargeable-weight declarations from the shipment's gross weight."""
import re
from decimal import Decimal
def material_intent(text,weight_pattern,to_kg):
declarations=re.sub(r'铁铝(?:附加费|加费|费用)','',text)
metal=bool(re.search(r'铁制|钢制|铝制|铁铝|不锈钢|(?:铁|钢|铝)(?:件|货|扣|罐|盒|板|管|架|材)',declarations))
generic='金属' in text
ordinary=bool(re.search(r'普通货|普货|非铁铝',text))
partial=bool(re.search(r'只有|部分|其中|有些|只是|一部分',text) or
metal and re.search(r'塑料|木制|布料',text))
scope='partial' if partial and (metal or generic) else ('all' if re.search(r'全是|全部是|都是|整票是|货物是|换成|改成',text) and metal else None)
material=('ordinary' if '非铁铝' in text else 'metal' if metal else 'unknown_metal' if generic
else 'ordinary' if ordinary or re.search(r'塑料|木制',text) else None)
name=r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)(?:部分|货物)?(?:的)?'
patterns=(name+r'\s*计费重(?:量)?\s*(?:为|是|[::])?\s*'+weight_pattern.pattern,
name+r'\s*按\s*'+weight_pattern.pattern+r'\s*计费',
r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)部分(?:的计费重(?:量)?)?\s*'
r'(?:还是|仍然是|仍是|改为|改成|换成)\s*'+weight_pattern.pattern)
charged=None;cleaned=text;errors=[];oral=False
for index,pattern in enumerate(patterns):
matches=list(re.finditer(pattern,text,re.I))
for match in matches:
value=to_kg(*match.groups())
if charged is not None and Decimal(charged)!=value:errors.append('铁铝货物的计费重不一致,请确认。')
charged=str(value);scope='partial'
oral=oral or (index==2 and '计费重' not in match[0])
if not 0<value<=1000000:errors.append('请提供大于0的有效铁铝货物计费重。')
cleaned=cleaned[:match.start()]+' '*(match.end()-match.start())+cleaned[match.end():]
return {'material':material,'metal_scope':scope,'metal_chargeable_weight':charged,
'quantity_text':cleaned,'errors':errors,'metal_weight_update':True if oral else None}
+25
View File
@@ -0,0 +1,25 @@
"""Keep unanswered US shipping questions outside legacy regional model knowledge."""
import re
from .quote_intent import US
from .pricing import shipping_region
def answer_us_fallback(message,conv_id):
if re.search(r'理赔|赔偿|索赔|破损|丢失|退件|投诉',message):return None
if re.search(r'换个话题|另外问',message):return None
from .quote_edits import clean_conditions
current=clean_conditions(message)
active=bool(US.search(current))
if not active and shipping_region(current) is not None:return None
if not active and conv_id:
from .quote_service import previous_quote_intents
active=any(intent['country']=='US' for intent in previous_quote_intents(conv_id,set()))
if not active:return None
from .quote_data import load_book
if not load_book():return {'t':'当前尚未包含可用的美国报价表,请提供目的地仓码或邮编及货物信息,联系人工确认。'}
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}',message))
if english:
return {'t':'Please specify the US route or destination warehouse/ZIP code and the point you want to confirm. Unlisted charges or service conditions require confirmation by customer support.'}
return {'t':'请说明您想确认的美国渠道、目的仓码或邮编,以及具体的费用或发货要求;未明确的服务条件需要联系人工核对。'}
+135
View File
@@ -0,0 +1,135 @@
"""Resolve same-conversation quote edits and return local facts without model I/O."""
from decimal import Decimal, InvalidOperation
import re
import sqlite3
from .db import connect
from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
from .quote_intent import resumes_quote
from .customer_faq import is_acknowledgement
def previous_quote_intents(conv_id,codes,resume=False):
"""Read the current quote back to its topic boundary, without a turn cutoff."""
earlier_intents=[]
resume_updates=[]
if conv_id:
connection=connect()
try:
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,))
for (previous,) in recent:
if is_acknowledgement(previous):continue
earlier=parse_intent(previous,codes)
if resumes_quote(previous):
earlier_intents.append(earlier)
# Retain edits made after an explicit return while crossing other topics.
resume_updates=earlier_intents[:]
resume=True
continue
boundary=bool(re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous))
quote_root=bool((earlier['country'] or earlier['warehouse'] or earlier.get('unknown_warehouses'))
and QUOTE_WORDS.search(previous))
if quote_root and not re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):
boundary=False
boundary=boundary or bool(re.search(r'订单|运单|入仓码',previous) and not re.search(r'报价|海运|海派|海卡|空运',previous))
candidate=(QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal')))
if boundary or not candidate:
if not resume:break
earlier_intents=resume_updates[:]
continue
if earlier['country']=='other':break
earlier_intents.append(earlier)
if not is_followup(previous,earlier):break
finally:
connection.close()
return earlier_intents
def intent_with_context(message, conv_id, book):
from .quote_context import merge_intent
codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes)
earlier_intents=previous_quote_intents(conv_id,codes,resume=resumes_quote(message)) if is_followup(message,intent) else []
if intent.get('metal_weight_update') and not earlier_intents:
intent['metal_chargeable_weight']=None
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
if earlier_intents:
state=earlier_intents[-1]
if state.get('metal_weight_update'):state['metal_chargeable_weight']=None
for update in list(reversed(earlier_intents[:-1]))+[intent]:
state=merge_intent(update,state)
intent=state
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:
intent['country']='US'
return intent
def answer_quote(message, conv_id):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return None
# Avoid a new read for ordinary chat; short followups need their local context.
from .shipping_faq import preparation_topic
if not (US.search(message) or QUOTE_WORDS.search(message) or preparation_topic(message) or conv_id or
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
return None
try:
book = load_book()
if not book:
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
if resumes_quote(message):
return {'t':'请确认要继续报价的目的国家、重量和渠道,我再核对对应条件。'}
if intent.get('weight_unit'):
return {'t':'请提供重量数值和目的国家,例如800KG发往美国。'}
if intent['country'] is None and intent.get('mode')=='preparation':
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
return None
if intent.get('errors'):
return {'t':'\n'.join(intent['errors'])}
if intent.get('unknown_warehouses'):
return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'}
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
return None
for key in ('weight','cbm'):
if intent[key] is not None and not (Decimal(0) < Decimal(intent[key]) <= Decimal(1000000)):
return {'t':'请提供大于 0 的有效重量或体积。'}
if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000:
return {'t':'请提供有效的货物箱数。'}
dimensions=[intent['dims']] if intent['dims'] else []
dimensions += [piece['dims'] for piece in intent.get('packages') or [] if piece.get('dims')]
if any(not 0 < value <= 100000 for dims in dimensions for value in dims):
return {'t':'请提供大于 0 的有效长宽高。'}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出,空运承运条件也需单独确认。'}
from .quote_answers import answer_business
preparation=None
if intent.get('mode')=='preparation':
from .shipping_faq import answer_preparation
preparation=answer_preparation(intent,book)
if preparation_topic(message)!='schedule' or not re.search(r'\d+\s*(?:KG|公斤|千克|克|吨|方|CBM)',intent['text'],re.I):
return {'t':preparation}
intent['mode']='cheapest' if re.search(r'最便宜|最划算|最低价',message) else 'quote'
business=answer_business(intent,book)
if business is not None:return {'t':business}
destinations = variants(intent,book)
fees = []
for destination in destinations:
postal = destination['postal']
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None)
reply=render_rates(intent,book,destinations,fees)
if intent.get('mode')=='total':
reply+='\n以上为已提供条件下可计算的运费,各方案按所列包税/不包税方式分别核算;目的仓、箱规及未确定附加费需核实后才能确认最终应付总价。'
if intent.get('mode')=='cheapest' and re.search(r'便宜一?点|折扣|优惠|打折',message):
reply+='\n以上按当前价表比较,折扣或合作价格需人工确认,尚未修改单价。'
return {'t':reply+('\n'+preparation if preparation else '')}
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
return {'t':'报价数据暂时无法读取,请稍后重试。'}
+119
View File
@@ -0,0 +1,119 @@
"""Extract only this workbook's local detail cells; never evaluate external links."""
import hashlib
from pathlib import Path
import posixpath
import re
from xml.etree import ElementTree as ET
from zipfile import ZipFile
from .quote_data import decimal_text
NS = {'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
TRUCK_SHEETS = ('美森正班海卡','美森加班海卡','EXX海卡','合德海卡','纽约EXX海铁',
'洛杉矶普船海卡','洛杉矶普船全卡','芝加哥普船海铁','休斯顿普船海卡','纽约普船海卡','萨凡纳普船海卡')
def read_sheets(path):
path = Path(path)
if path.stat().st_size > 20*1024*1024:
raise ValueError('Quote workbook is too large')
with ZipFile(path) as archive:
if sum(info.file_size for info in archive.infolist()) > 100*1024*1024:
raise ValueError('Expanded quote workbook is too large')
strings = []
if 'xl/sharedStrings.xml' in archive.namelist():
for entry in ET.fromstring(archive.read('xl/sharedStrings.xml')):
strings.append(''.join(t.text or '' for t in entry.findall('.//s:t', NS)))
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
rels = {r.attrib['Id']:r.attrib['Target'] for r in ET.fromstring(archive.read('xl/_rels/workbook.xml.rels'))}
result = {}
for sheet in workbook.find('s:sheets', NS):
target = rels[sheet.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']]
target = target.lstrip('/') if target.startswith('/') else posixpath.normpath('xl/'+target)
rows = {}
for cell in ET.fromstring(archive.read(target)).findall('s:sheetData/s:row/s:c', NS):
ref = cell.attrib['r']
column = re.sub('[0-9]', '', ref)
if len(column) != 1 or column > 'Q':
continue
raw = cell.find('s:v', NS)
value = raw.text if raw is not None else ''
if cell.attrib.get('t') == 's':
value = strings[int(value)]
elif cell.attrib.get('t') == 'inlineStr':
value = ''.join(t.text or '' for t in cell.findall('.//s:t', NS))
formula = cell.find('s:f', NS)
if formula is not None and formula.text and re.search(r'\[[0-9]+\]', formula.text):
continue
if value:
rows.setdefault(int(re.sub('[^0-9]', '', ref)), {})[column] = value
result[sheet.attrib['name']] = rows
return result
def export_snapshot(path):
sheets = read_sheets(path)
missing = set(TRUCK_SHEETS + ('海派','FBA分区表','亚马逊仓库代码表','UPS&FedEx偏远地区表')) - sheets.keys()
if missing:
raise ValueError('Quote workbook is missing required local detail sheets')
result = {'schema':1, 'source_name':Path(path).name,
'source_sha256':hashlib.sha256(Path(path).read_bytes()).hexdigest(),
'rates':[], 'warehouses':[], 'postcodes':[], 'documents':[]}
for name in TRUCK_SHEETS:
rows = sheets[name]
destination_col = 'A' if name in TRUCK_SHEETS[:4] else 'B'
price_cols = ('B','C','D') if destination_col == 'A' else ('C','D','E')
eta_col = 'E' if destination_col == 'A' else 'F'
for number, row in rows.items():
if number < 6 or not re.fullmatch(r'\d+(?:\.\d+)?', row.get(price_cols[0], '')):
continue
destinations = [p.strip() for p in re.split(r'[/\n、]+', row.get(destination_col, '')) if p.strip()]
if not destinations:
raise ValueError('Price row is missing destinations')
for column, origin, unit, tax in zip(price_cols, ('义乌','义乌','华南'), ('cbm','kg','kg'), ('excluded','included','included')):
result['rates'].append({'service':name, 'kind':'truck', 'origin':origin, 'zone':'',
'unit':unit, 'tax':tax, 'min_weight':'0', 'price':decimal_text(row[column], positive=True),
'eta':row.get(eta_col, ''), 'destinations':destinations, 'source_sheet':name, 'source_cell':f'{column}{number}'})
parcel = sheets['海派']
for origin, header, first in (('义乌',4,9), ('华南',13,18)):
for start_col in ('C','F','I','L'):
for offset, tier in enumerate(('12','51','101')):
column = chr(ord(start_col)+offset)
for index, zone in enumerate(('美西','美中','美东')):
number = first+index
result['rates'].append({'service':parcel[header][start_col], 'kind':'parcel', 'origin':origin,
'zone':zone, 'unit':'kg', 'tax':'included', 'min_weight':tier,
'price':decimal_text(parcel[number][column], positive=True),
'eta':parcel[header+2][start_col], 'destinations':[], 'source_sheet':'海派', 'source_cell':f'{column}{number}'})
for name, first in (('FBA分区表',3), ('亚马逊仓库代码表',2)):
for number, row in sheets[name].items():
if number < first or not row.get('A'):
continue
is_zone = name == 'FBA分区表'
result['warehouses'].append({'code':row['A'].strip().upper(),
'zone':row.get('B','') if is_zone else '', 'state':row.get('C','') if is_zone else '',
'postal':row.get('D','') if is_zone else '', 'address':row.get('B','') if not is_zone else '',
'source_sheet':name, 'source_cell':f'A{number}'})
for number, row in sheets['UPS&FedEx偏远地区表'].items():
if number <= 23:
continue
for column, severity, fee in (('B','remote','1'), ('F','extended','2'), ('J','extreme',None)):
postal = row.get(column, '')
if postal:
result['postcodes'].append({'postal':postal, 'severity':severity, 'fee':fee})
for name, rows in sheets.items():
if name in ('快速查询','UPS&FedEx偏远地区表','FBA分区表','亚马逊仓库代码表'):
continue
for number, row in rows.items():
if number <= 1 or (name in TRUCK_SHEETS and number <= 5):
continue
# Long detail text contains rules; short lookup rows contain service fees.
for column, value in row.items():
if len(value) >= 20 and not re.search(r'公司地址|TEL:|船名航次|预计开船日期|关税查询网址', value):
result['documents'].append({'sheet':name, 'cell':f'{column}{number}', 'text':value})
if name in ('一件代发收费','FDA申报清单'):
text = ';'.join(row.values())
result['documents'].append({'sheet':name,'cell':f'A{number}','text':text})
return result
+2 -2
View File
@@ -2,6 +2,6 @@
def register_routes(app):
from . import admin, business, chat, customer, files, history, human, pages, routing, speech, visitor
for module in (admin, business, chat, customer, files, history, human, pages, routing, 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)
+3 -147
View File
@@ -1,11 +1,8 @@
"""HTTP adapters for admin; business logic lives in services."""
import sqlite3
from flask import Blueprint, Response, render_template, request
from flask import Blueprint, Response, g, jsonify, render_template, request
from werkzeug.exceptions import RequestEntityTooLarge
from .. import business_service, db, order_admin_service, order_data, order_import_service, staff_service
from .. import business_service
bp = Blueprint('admin', __name__)
@@ -16,18 +13,7 @@ def admin_stats():
@bp.route('/admin/login', methods=['POST'])
def admin_login():
request.max_content_length = 4096
return staff_service.login(request.get_json(silent=True))
@bp.get('/api/admin/session')
def staff_session():
return {'ok': True, **g.staff}
@bp.post('/admin/logout')
def staff_logout():
return staff_service.logout()
return business_service.admin_login(request.json or {})
@bp.route('/api/export_chat')
@@ -56,133 +42,3 @@ def admin():
@bp.route('/admin/dashboard')
def admin_dash():
return render_template('admin.html')
@bp.get('/admin/orders')
def admin_orders():
return render_template('admin_orders.html')
@bp.get('/api/admin/orders/import/template')
def order_import_template():
return Response(order_import_service.template_csv(), mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': "attachment; filename*=UTF-8''order-import-template.csv"})
def _read_order_import():
request.max_content_length = order_import_service.MAX_UPLOAD_BYTES + 64 * 1024
uploaded = request.files.get('file')
if uploaded is None:
raise order_import_service.ImportFileError('请选择CSV或XLSX文件。')
parsed = order_import_service.parse_order_upload(uploaded.filename, uploaded.read())
parsed['filename'] = uploaded.filename
return parsed
def _order_preview(parsed, connection):
duplicate_count = order_import_service.mark_existing_rows(connection, parsed['rows'])
return {
'summary': {'total_rows': parsed['total_rows'], 'valid_rows': len(parsed['rows']),
'duplicate_rows': duplicate_count, 'error_rows': len(parsed['errors'])},
'preview': [{key: row.get(key) for key in ('row', 'tenant_id', 'order_no', 'status',
'inbound_code', 'shipment_id', 'country_code', 'transport_mode',
'warehouse_code', 'package_count', 'gross_weight_kg', 'volume_cbm', 'duplicate')}
for row in parsed['rows'][:20]],
'preview_limited': len(parsed['rows']) > 20,
'errors': parsed['errors'],
}
@bp.post('/api/admin/orders/import/preview')
def preview_order_import():
try:
parsed = _read_order_import()
except order_import_service.ImportFileError as error:
return jsonify({'ok': False, 'error': str(error)}), 400
except RequestEntityTooLarge:
return jsonify({'ok': False, 'error': '文件不能超过5 MB。'}), 413
connection = db.connect()
try:
parsed = order_import_service.validate_tenants(connection, parsed)
result = _order_preview(parsed, connection)
finally:
connection.close()
return jsonify({'ok': not parsed['errors'], **result}), 400 if parsed['errors'] else 200
@bp.post('/api/admin/orders/import')
def confirm_order_import():
try:
parsed = _read_order_import()
except order_import_service.ImportFileError as error:
return jsonify({'ok': False, 'error': str(error)}), 400
except RequestEntityTooLarge:
return jsonify({'ok': False, 'error': '文件不能超过5 MB。'}), 413
if parsed['errors']:
return jsonify({'ok': False, 'error': '文件中存在错误行,请修正后重新上传。',
'errors': parsed['errors']}), 400
connection = db.connect()
try:
connection.execute('BEGIN IMMEDIATE')
parsed = order_import_service.validate_tenants(connection, parsed)
if parsed['errors']:
connection.rollback()
return jsonify({'ok': False, 'error': '文件中存在错误行,请修正后重新上传。',
**_order_preview(parsed, connection)}), 400
existing_keys = {(row['tenant_id'], str(row['order_no']).casefold()) for row in parsed['rows']
if connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
(row['tenant_id'], row['order_no'])).fetchone()}
imported = order_data.import_records(connection, parsed['rows'])
batch_id = order_admin_service.create_import_batch(connection, parsed.get('filename'),
g.staff['username'], parsed, imported, existing_keys)
connection.commit()
except (ValueError, sqlite3.Error):
connection.rollback()
return jsonify({'ok': False, 'error': '订单导入失败,数据库未写入,请检查文件后重试。'}), 400
finally:
connection.close()
return jsonify({'ok': True, **imported, 'total': parsed['total_rows'], 'batch_id': batch_id})
@bp.get('/api/admin/orders/list')
def list_admin_orders():
connection = db.connect()
try:
result = order_admin_service.list_orders(connection, request.args.get('q', ''),
request.args.get('tenant', ''), request.args.get('status', ''),
request.args.get('before'), request.args.get('limit', 50))
except ValueError as error:
return jsonify({'ok': False, 'error': str(error)}), 400
finally:
connection.close()
return jsonify({'ok': True, **result})
@bp.get('/api/admin/orders/import/batches')
def list_order_import_batches():
connection = db.connect()
try:
return jsonify({'ok': True, 'batches': order_admin_service.list_import_batches(connection)})
finally:
connection.close()
@bp.post('/api/admin/orders/import/batches/<batch_id>/undo')
def undo_order_import(batch_id):
connection = db.connect()
try:
connection.execute('BEGIN IMMEDIATE')
result = order_admin_service.undo_import_batch(connection, batch_id, g.staff['username'])
if result is None:
connection.rollback()
return jsonify({'ok': False, 'error': '找不到此导入批次。'}), 404
connection.commit()
except ValueError as error:
connection.rollback()
return jsonify({'ok': False, 'error': str(error)}), 400
except sqlite3.Error:
connection.rollback()
return jsonify({'ok': False, 'error': '撤销失败,请刷新后重试。'}), 500
finally:
connection.close()
return jsonify({'ok': True, **result})
+3 -31
View File
@@ -1,8 +1,8 @@
"""HTTP adapters for business; business logic lives in services."""
from flask import Blueprint, g, request
from flask import Blueprint, request
from .. import business_service, ticket_service, visitor_service
from .. import business_service, visitor_service
bp = Blueprint('business', __name__)
@@ -18,35 +18,7 @@ def add_ticket():
@bp.route('/api/tickets')
def list_tickets():
return business_service.list_tickets(request.args.get('status', 'pending'),
request.args.get('limit', 20), request.args.get('before'),
request.args.get('q', ''), request.args.get('overdue', '0'))
@bp.get('/api/tickets/summary')
def ticket_summary():
return ticket_service.ticket_summary(request.args.get('after'))
@bp.get('/api/tickets/<int:ticket_id>')
def get_ticket(ticket_id):
return ticket_service.get_ticket(ticket_id)
@bp.get('/api/tickets/<int:ticket_id>/events')
def ticket_events(ticket_id):
return ticket_service.ticket_events(ticket_id,request.args.get('limit',20),request.args.get('before'))
@bp.post('/api/tickets/<int:ticket_id>/claim')
def claim_ticket(ticket_id):
return ticket_service.claim_ticket(ticket_id, g.staff)
@bp.patch('/api/tickets/<int:ticket_id>')
def update_ticket(ticket_id):
request.max_content_length = 16384
return ticket_service.update_ticket(ticket_id, request.get_json(silent=True), g.staff)
return business_service.list_tickets()
@bp.route('/api/rate', methods=['POST'])
+2 -43
View File
@@ -1,9 +1,8 @@
"""Public, visitor-bound customer login endpoints. No staff privileges."""
from flask import Blueprint, current_app, jsonify, request
from flask import Blueprint, current_app, request
from werkzeug.exceptions import RequestEntityTooLarge
from .. import customer_service, customer_order_service
from ..order_data import OrderDataUnavailable
from .. import customer_service
bp = Blueprint('customer', __name__)
@@ -26,43 +25,3 @@ def login():
@bp.post('/api/customer/logout')
def logout():
return customer_service.logout()
@bp.after_request
def private_response(response):
response.headers['Cache-Control'] = 'no-store'
return response
def order_request(action):
if not customer_service.current_customer_id():
return {'ok': False, 'error': 'customer_login_required'}, 401
try:
return action()
except OrderDataUnavailable:
return {'ok': False, 'error': '订单暂时无法加载,请稍后重试。'}, 503
@bp.get('/api/customer/orders')
def orders():
def browse():
status = request.args.get('status', 'all')
if status not in customer_order_service.FILTERS:
return {'ok': False, 'error': '订单状态筛选无效。'}, 400
before = request.args.get('before')
if before is not None:
if not before.isascii() or not before.isdigit() or len(before) > 19 or not 0 < int(before) <= 9223372036854775807:
return {'ok': False, 'error': '分页参数无效。'}, 400
before = int(before)
return customer_order_service.list_orders(before, status)
return order_request(browse)
@bp.get('/api/customer/orders/<int:order_id>')
def order(order_id):
def detail():
card = customer_order_service.order_detail(order_id)
if card is None:
return {'ok': False, 'error': '订单不存在或当前账号无权查看。'}, 404
return jsonify({'ok': True, 'card': card})
return order_request(detail)
-32
View File
@@ -1,32 +0,0 @@
"""Staff-only routing adapters; default-deny authentication and CSRF run first."""
from flask import Blueprint, g, request
from .. import routing_service, staff_service
bp = Blueprint('routing', __name__)
@bp.get('/api/admin/routing')
def snapshot():
return routing_service.routing_snapshot(g.staff['username'], staff_service.token_hash())
@bp.post('/api/admin/routing/presence')
def presence():
return routing_service.update_presence(g.staff['username'], staff_service.token_hash(), request.get_json(silent=True))
@bp.post('/api/admin/routing/groups')
def create_group():
return routing_service.create_group(request.get_json(silent=True))
@bp.post('/api/admin/routing/members')
def move_member():
return routing_service.move_member(request.get_json(silent=True))
@bp.post('/api/admin/routing/assign')
def assign():
return routing_service.reassign_session(request.get_json(silent=True), g.staff['username'])
-1
View File
@@ -39,7 +39,6 @@ def visitor_human(action):
return {'ok': False}, 400
result = human_service.human_poll(sid, after, current_app.extensions['human_typing'])
result.pop('note', None)
result.pop('owner_username', None)
return result
if action == 'send':
if data.get('side', 'customer') != 'customer':
-232
View File
@@ -1,232 +0,0 @@
"""Persistent staff availability, group membership and serialized conversation assignment."""
from datetime import datetime, timedelta, timezone
import sqlite3
import time
from .db import connect
PRESENCE_SECONDS = 90
CHINA_TIME = timezone(timedelta(hours=8))
def init_routing_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS support_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE
)''')
connection.execute("INSERT OR IGNORE INTO support_groups(id,name) VALUES(1,'综合客服')")
# Separate profiles retain compatibility with legacy three-column agents tables.
connection.execute('''CREATE TABLE IF NOT EXISTS staff_routing (
username TEXT PRIMARY KEY, group_id INTEGER NOT NULL DEFAULT 1,
last_assigned REAL NOT NULL DEFAULT 0
)''')
connection.execute('''CREATE TABLE IF NOT EXISTS staff_presence (
token_hash TEXT PRIMARY KEY, username TEXT NOT NULL,
online INTEGER NOT NULL DEFAULT 0, last_seen REAL NOT NULL
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_presence_username ON staff_presence(username)')
columns = {row[1] for row in connection.execute('PRAGMA table_info(human_sessions)')}
for name, definition in (
('group_id', 'INTEGER NOT NULL DEFAULT 1'), ('owner_username', 'TEXT'),
('assigned_at', 'TEXT'), ('routing_version', 'INTEGER NOT NULL DEFAULT 0'),
('previous_owner', 'TEXT')):
if name not in columns:
connection.execute(f'ALTER TABLE human_sessions ADD COLUMN {name} {definition}')
connection.execute('CREATE INDEX IF NOT EXISTS idx_human_routing ON human_sessions(status,group_id,owner_username,id)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_human_owner ON human_sessions(owner_username,status)')
connection.execute('''CREATE TABLE IF NOT EXISTS routing_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL,
action TEXT NOT NULL, actor TEXT NOT NULL, group_id INTEGER NOT NULL,
owner_username TEXT, ctime TEXT NOT NULL
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_routing_events_session ON routing_events(session_id,id)')
connection.execute('''CREATE TRIGGER IF NOT EXISTS routing_delete_agent AFTER DELETE ON agents BEGIN
DELETE FROM staff_routing WHERE username=OLD.username;
DELETE FROM staff_presence WHERE username=OLD.username;
UPDATE human_sessions SET status='waiting', owner_username=NULL, assigned_at=NULL,
previous_owner=NULL, routing_version=routing_version+1
WHERE owner_username=OLD.username AND status='active';
END''')
def timestamp():
return datetime.now(CHINA_TIME).strftime('%Y-%m-%d %H:%M:%S')
def record_event(connection, sid, action, actor):
connection.execute('''INSERT INTO routing_events(session_id,action,actor,group_id,owner_username,ctime)
SELECT id,?,?,group_id,owner_username,? FROM human_sessions WHERE id=?''',
(action, actor, timestamp(), sid))
def _online_agents(connection, instant):
return {row[0] for row in connection.execute('''SELECT DISTINCT p.username
FROM staff_presence p JOIN staff_sessions s ON s.token_hash=p.token_hash AND s.username=p.username
JOIN agents a ON a.username=p.username
WHERE p.online=1 AND p.last_seen>? AND s.expires_at>?''',
(instant - PRESENCE_SECONDS, instant))}
def _agents(connection, instant):
online = _online_agents(connection, instant)
return [dict(username=row[0], name=row[1], group_id=row[2], group_name=row[3],
active_count=row[4], online=row[0] in online, last_assigned=row[5])
for row in connection.execute('''SELECT a.username,a.name,COALESCE(r.group_id,1),g.name,
(SELECT COUNT(*) FROM human_sessions h WHERE h.owner_username=a.username AND h.status='active'),
COALESCE(r.last_assigned,0)
FROM agents a LEFT JOIN staff_routing r ON r.username=a.username
JOIN support_groups g ON g.id=COALESCE(r.group_id,1) ORDER BY a.username''')]
def assign_owner(connection, sid, username, group_id, action, actor):
connection.execute('''UPDATE human_sessions SET owner_username=?,group_id=?,status='active',
assigned_at=?,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''',
(username, group_id, timestamp(), sid))
connection.execute('''INSERT INTO staff_routing(username,group_id,last_assigned) VALUES(?,1,?)
ON CONFLICT(username) DO UPDATE SET last_assigned=excluded.last_assigned''',
(username, time.time()))
record_event(connection, sid, action, actor)
def assign_new_session(connection, sid):
"""Try once at creation under BEGIN IMMEDIATE; existing queue needs manual acceptance."""
row = connection.execute('''SELECT group_id FROM human_sessions
WHERE id=? AND status='waiting' AND owner_username IS NULL
AND EXISTS(SELECT 1 FROM chat_history c WHERE c.id=human_sessions.conv_id)''', (sid,)).fetchone()
if not row:
return
eligible = [agent for agent in _agents(connection, time.time())
if agent['online'] and agent['group_id'] == row[0] and agent['active_count'] == 0]
if eligible:
selected = min(eligible, key=lambda agent: (agent['last_assigned'], agent['username']))
assign_owner(connection, sid, selected['username'], row[0], 'auto_assign', 'system')
def _presence(connection, token, instant):
row = connection.execute('''SELECT online,last_seen FROM staff_presence WHERE token_hash=?''', (token,)).fetchone()
return {'online': bool(row and row[0] and row[1] > instant - PRESENCE_SECONDS)}
def routing_snapshot(username, token):
connection = connect()
try:
connection.execute('BEGIN')
groups = [dict(id=row[0], name=row[1]) for row in connection.execute('SELECT id,name FROM support_groups ORDER BY id')]
sessions = [dict(id=row[0], status=row[1], group_id=row[2], group_name=row[3],
owner_username=row[4], owner_name=row[5], version=row[6], ctime=row[7])
for row in connection.execute('''SELECT h.id,h.status,h.group_id,g.name,h.owner_username,
a.name,h.routing_version,h.ctime FROM human_sessions h
JOIN support_groups g ON g.id=h.group_id LEFT JOIN agents a ON a.username=h.owner_username
WHERE h.status IN ('waiting','active') ORDER BY h.id''')]
events = [dict(id=row[0], session_id=row[1], action=row[2], actor=row[3], group_name=row[4],
owner_username=row[5], ctime=row[6]) for row in connection.execute('''SELECT e.id,e.session_id,
e.action,e.actor,g.name,e.owner_username,e.ctime FROM routing_events e
JOIN support_groups g ON g.id=e.group_id ORDER BY e.id DESC LIMIT 50''')]
return {'ok': True, 'username': username, 'presence': _presence(connection, token, time.time()),
'groups': groups, 'agents': _agents(connection, time.time()), 'sessions': sessions, 'events': events}
finally:
connection.close()
def update_presence(username, token, data):
if not isinstance(data, dict) or ('online' in data and type(data['online']) is not bool):
return {'ok': False, 'error': '请选择在线或离线。'}, 400
instant = time.time()
connection = connect()
try:
connection.execute('BEGIN IMMEDIATE')
if 'online' in data:
# Going offline applies across this account's logged-in browsers.
if not data['online']:
connection.execute('UPDATE staff_presence SET online=0 WHERE username=?', (username,))
connection.execute('''INSERT INTO staff_presence VALUES(?,?,?,?)
ON CONFLICT(token_hash) DO UPDATE SET online=excluded.online,last_seen=excluded.last_seen''',
(token, username, int(data['online']), instant))
else:
# Heartbeats never turn an offline/expired presence back on implicitly.
connection.execute('''UPDATE staff_presence SET online=CASE WHEN last_seen>? THEN online ELSE 0 END,
last_seen=? WHERE token_hash=?''', (instant - PRESENCE_SECONDS, instant, token))
connection.execute('DELETE FROM staff_presence WHERE NOT EXISTS '
'(SELECT 1 FROM staff_sessions s WHERE s.token_hash=staff_presence.token_hash AND s.expires_at>?)', (instant,))
result = {'ok': True, **_presence(connection, token, instant)}
connection.commit()
return result
finally:
connection.close()
def create_group(data):
name = data.get('name') if isinstance(data, dict) else None
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 40:
return {'ok': False, 'error': '分组名称需为 1–40 字。'}, 400
connection = connect()
try:
try:
cursor = connection.execute('INSERT INTO support_groups(name) VALUES(?)', (name.strip(),))
connection.commit()
except sqlite3.IntegrityError:
return {'ok': False, 'error': '已存在同名分组。'}, 409
return {'ok': True, 'group': {'id': cursor.lastrowid, 'name': name.strip()}}
finally:
connection.close()
def _positive_id(value):
return type(value) is int and 0 < value <= 9223372036854775807
def move_member(data):
if not isinstance(data, dict) or not isinstance(data.get('username'), str) or not _positive_id(data.get('group_id')):
return {'ok': False, 'error': '请选择客服与分组。'}, 400
connection = connect()
try:
connection.execute('BEGIN IMMEDIATE')
if not connection.execute('SELECT 1 FROM agents WHERE username=?', (data['username'],)).fetchone() or not connection.execute(
'SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone():
return {'ok': False, 'error': '客服或分组已不存在,请刷新。'}, 404
connection.execute('''INSERT INTO staff_routing(username,group_id) VALUES(?,?)
ON CONFLICT(username) DO UPDATE SET group_id=excluded.group_id''', (data['username'], data['group_id']))
connection.commit()
return {'ok': True}
finally:
connection.close()
def reassign_session(data, actor):
if not isinstance(data, dict) or not _positive_id(data.get('sid')) or not _positive_id(data.get('group_id')) or (
type(data.get('version')) is not int or data['version'] < 0 or data['version'] > 9223372036854775807) or (
data.get('username') is not None and not isinstance(data['username'], str)):
return {'ok': False, 'error': '请选择会话、目标分组与负责人。'}, 400
connection = connect()
try:
connection.execute('BEGIN IMMEDIATE')
row = connection.execute('SELECT status,routing_version FROM human_sessions WHERE id=?', (data['sid'],)).fetchone()
if not row or row[0] == 'closed' or row[1] != data['version']:
return {'ok': False, 'error': '会话已更新或结束,请刷新后重新选择。'}, 409
if not connection.execute('SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone():
return {'ok': False, 'error': '目标分组已不存在,请刷新。'}, 404
username = data.get('username') or None
if username:
selected = next((agent for agent in _agents(connection, time.time()) if agent['username'] == username), None)
if not selected or not selected['online'] or selected['group_id'] != data['group_id']:
return {'ok': False, 'error': '目标客服需在所选组内且处于在线状态。'}, 409
assign_owner(connection, data['sid'], username, data['group_id'], 'reassign', actor)
else:
connection.execute('''UPDATE human_sessions SET group_id=?,owner_username=NULL,status='waiting',
assigned_at=NULL,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''',
(data['group_id'], data['sid']))
record_event(connection, data['sid'], 'queue', actor)
connection.commit()
return {'ok': True}
finally:
connection.close()
def owner_error(connection, sid, username, allow_unassigned=False):
"""Call inside a write transaction; assigned sessions cannot be operated by another staff member."""
row = connection.execute('SELECT owner_username FROM human_sessions WHERE id=?', (sid,)).fetchone()
if row and not row[0] and not allow_unassigned:
return {'ok': False, 'error': '请先接入会话,再进行操作。'}, 409
if row and row[0] and row[0] != username:
return {'ok': False, 'error': '会话已由其他客服负责,请刷新会话列表。'}, 409
return None
+57
View File
@@ -0,0 +1,57 @@
"""Pre-shipment answers from the active US book, without live operational claims."""
import re
def preparation_topic(text):
if re.search(r'理赔|赔偿|索赔|货损|丢件|破损',text):return None
if re.search(r'截仓|截单|船期|排仓|哪天开船|(?:什么时候|几号).*开船|能.*开船|'
r'(?:今天|明天|这周|下周).*(?:发货|发走|开船)|赶得上|来得及|(?:保证|必须|月底).*到',text):return 'schedule'
if re.search(r'上门.*(?:提货|取货|收货|揽收)|提货服务|交仓地址|仓库地址|仓.*(?:地址|电话|联系人|几点)|送哪个仓|仓库在哪|收货时间|\bpick\s*up\b|\bpickup\b',text,re.I):return 'delivery'
if re.search(r'报关资料|清关资料|报关.*(?:准备|材料)|申报比例|低申报|怎么申报|如何申报|认证资料|熏蒸',text):return 'documents'
if re.search(r'查验.*(?:费|承担)|谁.*承担.*查验',text):return 'inspection'
if re.search(r'(?:东|西|中)[12](?:区)?.*(?:区别|分区|哪区)|分区.*(?:什么意思|怎么分)',text):return 'zones'
if '一件代发' not in text and re.search(r'贴标|FBA标|标签|唛头|产地标|外箱.*贴|贴.*条码|made\s+in\s+china',text,re.I):return 'labels'
if re.search(r'哪些渠道|哪些线路|什么渠道|能发哪些仓|支持.*仓|(?:能发|能走|可以发).*美国',text):return 'coverage'
return None
def answer_preparation(intent,book):
topic=preparation_topic(intent['text'])
if topic is None:return None
from .quote_fees import document
if topic=='coverage':
services=list(dict.fromkeys(rate['service'] for rate in book['rates']))
return '美国可查询的线路:'+ '、'.join(services)+'。请提供目的仓码或邮编、重量及交货仓,我再匹配具体可用线路。'
if topic=='zones':
return '东1、东2等是报价资料中的不同目的地区域,需要按目的仓码或邮编匹配;同一仓码存在多个分区记录时会分别列出。请提供目的仓码或邮编,我按现有记录核对。'
if topic=='schedule':
return '请提供计划交仓日期、交货仓和目的仓码。具体截仓、装柜和开船安排需联系人工核对;当前参考时效不能作为月底或指定日期必达的承诺。'
if topic=='delivery':
raw=document(book,('不提供上门提货服务',))
if intent.get('english'):
return ('The current US route sheet requires delivery to the designated warehouse and does not offer pickup. ' if raw else '')+'Please confirm the route and Yiwu or South China origin with customer support before delivery; warehouse address, contact and receiving hours need confirmation.'
lines=[]
if raw:lines.append('当前美国渠道资料要求客户自行送货至对应仓库,不提供上门提货服务。')
lines.append('请确认所选渠道及义乌或华南交仓,仓库地址、联系人和收货时间请联系人工核对后再送仓。')
return '\n'.join(lines)
if topic=='labels':
label=document(book,('外箱','入仓条码','唛头'))
origin=document(book,('产品上和包装上','产地标识'))
lines=[]
if label:lines.append(re.sub(r'^\s*\d+[、..]\s*','',label.strip())+'\n代贴标签具体费用需按所选服务确认。')
if origin:lines.append('产品和包装还需有“Made in China”产地标识。')
return '\n'.join(lines) or '请提供渠道和目的仓码,由人工核对外箱标签及代贴要求。'
if topic=='documents':
if '熏蒸' in intent['text']:
raw=document(book,('熏蒸','备货阶段'))
return (raw.strip()+'\n' if raw else '')+'请确认是木制成品还是木质包装,并提供具体品名及渠道,核对是否需要熏蒸及证明材料后再安排发货。'
if re.search(r'申报比例|低申报|怎么申报|如何申报',intent['text']):
return '请提供品名、材质、用途、实际交易金额及销售链接,由业务核对申报资料。申报价需结合实际交易信息确认,不能按统一比例直接代算。'
raw=document(book,('单独报关需提供资料',))
if raw:return raw.strip()+'\n请补充具体品名及渠道,涉及商检或认证的资料需逐项核对。'
return '请提供具体品名和渠道,由人工核对装箱单、发票及对应报关、认证材料。'
raw=document(book,('查验','不包税货物','比例均摊'))
if raw:return raw.strip()+'\n具体金额按实际查验费用核算;包税渠道的承担方式需另行确认,不能直接套用不包税条款。'
return '查验费用和承担方式需按渠道、税费方式及实际费用确认,当前无法给出固定金额。'
-163
View File
@@ -1,163 +0,0 @@
"""Independent, revocable staff sessions; only token digests are persisted."""
import hashlib
from hmac import compare_digest
import secrets
import time
from flask import current_app, g, jsonify, redirect, request
from werkzeug.security import check_password_hash
from .db import connect
COOKIE_NAME = 'baiyue_staff'
SESSION_SECONDS = 24 * 3600
PUBLIC_ENDPOINTS = frozenset({
'static', 'pages.index', 'pages.health', 'admin.admin', 'admin.admin_login',
'chat.stream_chat', 'chat.feedback', 'history.new_chat', 'history.get_history',
'history.get_messages', 'history.del_history', 'history.clear_all',
'files.upload_file', 'files.uploaded_file', 'business.add_ticket', 'business.rate_chat',
'visitor.visitor_session', 'visitor.visitor_human', 'customer.session',
'customer.login', 'customer.logout', 'customer.orders', 'customer.order',
'speech.speech_config', 'speech.speech_transcribe',
})
def digest(value):
return hashlib.sha256(value.encode('utf-8')).hexdigest()
def init_staff_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(staff_sessions)')}
required = {'token_hash', 'username', 'auth_stamp', 'csrf_token', 'expires_at'}
if columns and not required.issubset(columns):
# Older versions keyed sessions by agent_id. Revoke incompatible credentials
# and rebuild this disposable auth table; never alter accounts/business data.
connection.execute('DROP TABLE staff_sessions')
connection.execute('''CREATE TABLE IF NOT EXISTS staff_sessions (
token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, auth_stamp TEXT NOT NULL,
csrf_token TEXT NOT NULL, expires_at REAL NOT NULL
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_staff_expiry ON staff_sessions(expires_at)')
# Legacy unlimited sessions have no issue time; require a fresh bounded login.
# Leave valid deadlines untouched so restarts never extend a session.
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=?', (time.time(),))
# Recreating a username or restoring an earlier password must not revive cookies.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_delete_agent
AFTER DELETE ON agents BEGIN
DELETE FROM staff_sessions WHERE username=OLD.username;
END''')
# SQLite REPLACE may skip DELETE triggers; INSERT still fires in every connection.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_insert_agent
AFTER INSERT ON agents BEGIN
DELETE FROM staff_sessions WHERE username=NEW.username;
END''')
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_change_credentials
AFTER UPDATE OF username,password ON agents
WHEN OLD.username IS NOT NEW.username OR OLD.password IS NOT NEW.password
BEGIN DELETE FROM staff_sessions WHERE username=OLD.username; END''')
def token_hash():
token = request.cookies.get(COOKIE_NAME, '')
return digest(token) if len(token) == 64 and all(c in '0123456789abcdef' for c in token) else None
def same_origin():
origin = request.headers.get('Origin')
return (not origin or origin.rstrip('/') == request.host_url.rstrip('/')) and request.headers.get('Sec-Fetch-Site') != 'cross-site'
def attach_staff():
"""New/unknown endpoints are staff-only until explicitly reviewed as public."""
g.staff = None
if request.endpoint in PUBLIC_ENDPOINTS:
return None
hashed = token_hash()
if hashed:
connection = connect()
try:
row = connection.execute(
'SELECT s.username,a.name,s.csrf_token,s.auth_stamp,a.password '
'FROM staff_sessions s JOIN agents a ON a.username=s.username '
'WHERE s.token_hash=? AND s.expires_at>?', (hashed, time.time())).fetchone()
finally:
connection.close()
if row and compare_digest(row[3], digest(row[4])):
g.staff = {'username': row[0], 'name': row[1], 'csrf_token': row[2]}
if not g.staff:
if request.path.startswith('/admin/') and request.method in ('GET', 'HEAD'):
return redirect('/admin')
return jsonify({'ok': False, 'error': 'staff_login_required', 'e': '后台登录已失效,请重新登录。'}), 401
if request.method not in ('GET', 'HEAD', 'OPTIONS'):
csrf = request.headers.get('X-CSRF-Token', '')
if not same_origin() or not compare_digest(csrf.encode('utf-8'), g.staff['csrf_token'].encode('utf-8')):
return jsonify({'ok': False, 'error': 'csrf_invalid', 'e': '请求校验失败,请刷新后台页面。'}), 403
def login(data):
if not same_origin():
return {'ok': False}, 403
if not request.is_json:
return {'ok': False}, 415
if not isinstance(data, dict):
return {'ok': False}, 401
username, password = data.get('username'), data.get('password')
if not isinstance(username, str) or not isinstance(password, str) or not 0 < len(username) <= 256 or not 0 < len(password) <= 1024:
return {'ok': False}, 401
attempts = current_app.extensions['staff_login_attempts']
lock = current_app.extensions['staff_login_lock']
# Proxy traffic can share a peer IP. Failures for one account must not lock all staff.
attempt_key = (request.remote_addr or 'unknown', username)
now = time.time()
# Reserve attempts before expensive password hashing, including concurrent requests.
with lock:
for key in list(attempts):
attempts[key] = [instant for instant in attempts[key] if now - instant < 300]
if not attempts[key]:
del attempts[key]
if len(attempts.get(attempt_key, [])) >= 5:
return {'ok': False, 'e': '登录尝试过多,请 5 分钟后重试。'}, 429, {'Retry-After': '300'}
attempts.setdefault(attempt_key, []).append(now)
connection = connect()
try:
row = connection.execute('SELECT name,password FROM agents WHERE username=?', (username,)).fetchone()
valid = False
if row and isinstance(row[1], str) and row[1]:
if row[1].startswith(('pbkdf2:', 'scrypt:')):
try:
valid = check_password_hash(row[1], password)
except (ValueError, TypeError):
pass
else:
valid = compare_digest(row[1].encode('utf-8'), password.encode('utf-8'))
if not valid:
return {'ok': False}, 401
token, csrf = secrets.token_hex(32), secrets.token_hex(32)
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=? OR token_hash=?', (now, token_hash()))
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)',
(digest(token), username, digest(row[1]), csrf, now + SESSION_SECONDS))
connection.commit()
finally:
connection.close()
with lock:
attempts.pop(attempt_key, None)
response = jsonify({'ok': True, 'name': row[0]})
response.set_cookie(COOKIE_NAME, token, max_age=SESSION_SECONDS, httponly=True,
secure=request.is_secure, samesite='Lax', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
def logout():
connection = connect()
try:
connection.execute('DELETE FROM staff_sessions WHERE token_hash=?', (token_hash(),))
connection.execute('DELETE FROM staff_presence WHERE token_hash=?', (token_hash(),))
connection.commit()
finally:
connection.close()
response = jsonify({'ok': True})
response.delete_cookie(COOKIE_NAME, path='/', httponly=True, secure=request.is_secure, samesite='Lax')
response.headers['Cache-Control'] = 'no-store'
return response
-243
View File
@@ -1,243 +0,0 @@
"""Staff-owned offline ticket lifecycle and optimistic updates."""
from contextlib import closing
from datetime import datetime, datetime as DateTime, timedelta
from math import ceil
from .db import connect
STATES = ('pending', 'processing', 'completed')
MAX_TICKET_ID = 2**63 - 1
COLUMNS = ('id', 'content', 'phone', 'ctime', 'status', 'owner_username', 'owner_name',
'note', 'updated_at', 'completed_at', 'version', 'claimed_at', 'claimed_at_estimated')
EVENT_COLUMNS = ('id', 'ticket_id', 'action', 'actor_username', 'actor_name', 'ctime',
'old_status', 'new_status', 'old_note', 'new_note')
PENDING_TIMEOUT = 30 * 60
PROCESSING_TIMEOUT = 20 * 60
def init_ticket_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
additions = {'owner_username':'TEXT', 'owner_name':"TEXT NOT NULL DEFAULT ''",
'note':"TEXT NOT NULL DEFAULT ''", 'updated_at':'TEXT',
'completed_at':'TEXT', 'version':'INTEGER NOT NULL DEFAULT 0'}
for name, declaration in additions.items():
if name not in columns:
connection.execute(f'ALTER TABLE tickets ADD COLUMN {name} {declaration}')
connection.execute("UPDATE tickets SET status='pending' WHERE status IS NULL OR status=''")
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_status_id ON tickets(status,id)')
def init_ticket_history(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
if 'claimed_at' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at TEXT')
if 'claimed_at_estimated' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at_estimated INTEGER NOT NULL DEFAULT 0')
connection.execute('''CREATE TABLE IF NOT EXISTS ticket_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL,
action TEXT NOT NULL, actor_username TEXT, actor_name TEXT NOT NULL,
ctime TEXT NOT NULL, old_status TEXT, new_status TEXT,
old_note TEXT NOT NULL DEFAULT '', new_note TEXT NOT NULL DEFAULT ''
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_events ON ticket_events(ticket_id,id)')
# A legacy baseline records only the known state at upgrade, never invented actions.
connection.execute('''UPDATE tickets SET claimed_at=updated_at,claimed_at_estimated=1
WHERE status='processing' AND claimed_at IS NULL AND datetime(updated_at,'+0 seconds')=updated_at''')
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_name,ctime,new_status,new_note)
SELECT id,'legacy_snapshot','系统',?,status,COALESCE(note,'') FROM tickets
WHERE NOT EXISTS(SELECT 1 FROM ticket_events e WHERE e.ticket_id=tickets.id)''',
(datetime.now().strftime('%Y-%m-%d %H:%M:%S'),))
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_pending_time ON tickets(status,ctime)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_claimed_time ON tickets(status,claimed_at)')
def record_event(connection, ticket_id, action, now, staff=None, old_status=None,
new_status=None, old_note='', new_note=''):
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_username,actor_name,ctime,
old_status,new_status,old_note,new_note) VALUES(?,?,?,?,?,?,?,?,?)''',
(ticket_id, action, staff['username'] if staff else None, staff['name'] if staff else '客户',
now, old_status, new_status, old_note, new_note))
def create_ticket(content, phone):
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
cursor = connection.execute('INSERT INTO tickets(content,phone,ctime,status) VALUES(?,?,?,?)',
(content, phone, now, 'pending'))
record_event(connection, cursor.lastrowid, 'submitted', now, new_status='pending')
connection.commit()
return {'ok': True}
def _with_overdue(ticket, now=None):
ticket.update(overdue_kind=None, overdue_minutes=0)
anchor = ticket['ctime'] if ticket['status'] == 'pending' else ticket['claimed_at']
threshold = PENDING_TIMEOUT if ticket['status'] == 'pending' else PROCESSING_TIMEOUT
if ticket['status'] not in ('pending', 'processing') or not anchor:
return ticket
try:
parsed = DateTime.fromisoformat(anchor)
if parsed.strftime('%Y-%m-%d %H:%M:%S') != anchor:
return ticket
elapsed = ((now or datetime.now()).replace(microsecond=0) - parsed).total_seconds()
except (ValueError, TypeError):
return ticket
if elapsed > threshold:
ticket.update(overdue_kind=ticket['status'], overdue_minutes=max(1,ceil((elapsed-threshold)/60)))
return ticket
def _overdue_predicate():
return "((status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?) OR " \
"(status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?))"
def _cutoffs(now):
return tuple((now-timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
for seconds in (PENDING_TIMEOUT, PROCESSING_TIMEOUT))
def _ticket(connection, ticket_id):
if not 0 < ticket_id <= MAX_TICKET_ID:
return None
row = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets WHERE id=?', (ticket_id,)).fetchone()
return _with_overdue(dict(zip(COLUMNS, row))) if row else None
def get_ticket(ticket_id):
with closing(connect()) as connection:
ticket = _ticket(connection, ticket_id)
return {'ok': True, 'ticket': ticket} if ticket else ({'ok': False, 'error':'留言不存在。'}, 404)
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
if overdue not in ('0','1'):
return {'ok': False, 'error':'超时筛选参数无效。'}, 400
if not isinstance(query, str) or len(query) > 100:
return {'ok': False, 'error':'搜索关键词最多 100 字。'}, 400
if status not in (*STATES, 'all'):
return {'ok': False, 'error':'留言状态无效。'}, 400
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok': False, 'error':'分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok': False, 'error':'分页参数无效。'}, 400
clauses, parameters = [], []
now = datetime.now()
if overdue == '1':
clauses.append(_overdue_predicate())
parameters.extend(_cutoffs(now))
if status != 'all':
clauses.append('status=?')
parameters.append(status)
if before is not None:
clauses.append('id<?')
parameters.append(before)
if query.strip():
literal = query.strip().replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
fields = ('content', 'phone', 'owner_username', 'owner_name', 'note')
clauses.append('(' + ' OR '.join(f"{field} LIKE ? ESCAPE '\\'" for field in fields) + ')')
parameters.extend(['%' + literal + '%'] * len(fields))
where = ' WHERE ' + ' AND '.join(clauses) if clauses else ''
with closing(connect()) as connection:
rows = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets' + where +
' ORDER BY id DESC LIMIT ?', (*parameters, limit)).fetchall()
return [_with_overdue(dict(zip(COLUMNS, row)), now) for row in rows]
def ticket_summary(after=None):
if after is not None:
try:
after = int(after)
except (TypeError, ValueError):
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
if not 0 <= after <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
# One snapshot, independent of the list's status, search and page cursor.
pending_cutoff, processing_cutoff = _cutoffs(datetime.now())
with closing(connect()) as connection:
pending, latest, new, pending_overdue, processing_overdue = connection.execute(
"SELECT (SELECT COUNT(*) FROM tickets WHERE status='pending'),"
'(SELECT COALESCE(MAX(id),0) FROM tickets),'
'(SELECT COUNT(*) FROM tickets WHERE id>?),'
"(SELECT COUNT(*) FROM tickets WHERE status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?),"
"(SELECT COUNT(*) FROM tickets WHERE status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?)",
(after,pending_cutoff,processing_cutoff)).fetchone()
return {'pending_count':pending, 'latest_id':latest, 'new_count':new,
'overdue_count':pending_overdue+processing_overdue,
'pending_overdue_count':pending_overdue, 'processing_overdue_count':processing_overdue}
def ticket_events(ticket_id, limit=20, before=None):
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
with closing(connect()) as connection:
if not _ticket(connection,ticket_id):
return {'ok':False, 'error':'留言不存在。'}, 404
rows = connection.execute('SELECT '+','.join(EVENT_COLUMNS)+' FROM ticket_events WHERE ticket_id=?'+
(' AND id<?' if before is not None else '')+' ORDER BY id DESC LIMIT ?',
(ticket_id, before, limit) if before is not None else (ticket_id,limit)).fetchall()
return [dict(zip(EVENT_COLUMNS,row)) for row in rows]
def claim_ticket(ticket_id, staff):
if not 0 < ticket_id <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言不存在。'}, 404
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
# The guarded write makes simultaneous claims select exactly one owner.
changed = connection.execute("UPDATE tickets SET status='processing',owner_username=?,"
"owner_name=?,updated_at=?,claimed_at=?,claimed_at_estimated=0,version=version+1 WHERE id=? AND status='pending' "
'AND owner_username IS NULL', (staff['username'], staff['name'], now, now, ticket_id)).rowcount
ticket = _ticket(connection, ticket_id)
if not changed:
return ({'ok': False, 'error':'留言已被领取或已完成,请刷新查看。'}, 409) if ticket else (
{'ok': False, 'error':'留言不存在。'}, 404)
record_event(connection,ticket_id,'claimed',now,staff,old_status='pending',new_status='processing')
connection.commit()
return {'ok': True, 'ticket': ticket}
def update_ticket(ticket_id, data, staff):
if (not isinstance(data, dict) or set(data) - {'version', 'note', 'status'}
or type(data.get('version')) is not int or data['version'] < 0
or not ({'note', 'status'} & set(data))):
return {'ok': False, 'error':'留言更新格式无效,请刷新后重试。'}, 400
if 'note' in data and (not isinstance(data['note'], str) or len(data['note']) > 2000):
return {'ok': False, 'error':'处理备注最多 2000 字。'}, 400
status = data.get('status', 'processing')
if status not in ('processing', 'completed'):
return {'ok': False, 'error':'处理状态无效。'}, 400
with closing(connect()) as connection:
connection.execute('BEGIN IMMEDIATE')
ticket = _ticket(connection, ticket_id)
if not ticket:
return {'ok': False, 'error':'留言不存在。'}, 404
if ticket['status'] != 'processing':
return {'ok': False, 'error':'请先领取留言,已完成留言不能修改。'}, 409
if ticket['owner_username'] != staff['username']:
return {'ok': False, 'error':'只有负责人可以处理这条留言。'}, 403
if ticket['version'] != data['version']:
return {'ok': False, 'error':'留言已在其他页面更新,请刷新后核对备注。'}, 409
note = data.get('note', ticket['note']).strip()
if status == 'completed' and not note:
return {'ok': False, 'error':'请先填写处理结果,再标记完成。'}, 400
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
connection.execute('UPDATE tickets SET note=?,status=?,updated_at=?,completed_at=?,version=version+1 '
'WHERE id=?', (note, status, now, now if status == 'completed' else None, ticket_id))
if note != ticket['note']:
record_event(connection,ticket_id,'note_updated',now,staff,old_note=ticket['note'],new_note=note)
if status == 'completed':
record_event(connection,ticket_id,'completed',now,staff,old_status='processing',new_status='completed',
new_note=note)
ticket = _ticket(connection, ticket_id)
connection.commit()
return {'ok': True, 'ticket': ticket}
+2 -3
View File
@@ -15,7 +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', '/api/customer/orders',
'/api/customer/session', '/api/customer/login', '/api/customer/logout',
}
@@ -53,8 +53,7 @@ def customer_tenant_ids():
def attach_visitor():
key = request.cookies.get(COOKIE_NAME)
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
is_customer = (request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
or request.path.startswith('/api/customer/orders/'))
is_customer = request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
if is_customer and not visitor_id():
return jsonify({'ok': False, 'e': '访客会话已失效,请刷新页面重试。'}), 401
if (is_customer or request.path == '/api/visitor/session') and request.method not in ('GET', 'HEAD', 'OPTIONS'):
-54
View File
@@ -1,54 +0,0 @@
"""Database backup/check/list and explicitly offline restore; no app initialization."""
import argparse
import json
from pathlib import Path
import sqlite3
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from logistics_chat.database_backup import (check_database, daily_backup, restore_database,
snapshot_database, unique_backup_path)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--db', type=Path, default=ROOT/'history.db')
parser.add_argument('--backup-dir', type=Path, default=None)
parser.add_argument('command', choices=('backup', 'daily', 'list', 'check', 'restore'))
parser.add_argument('--input', type=Path)
parser.add_argument('--service-stopped', action='store_true', help='Confirm all database users are stopped before restore')
args = parser.parse_args(argv)
database = args.db.resolve()
directory = (args.backup_dir or database.parent/'backups').resolve()
try:
if args.command == 'backup':
path = snapshot_database(database, unique_backup_path(directory, 'manual'))
print('Backup: ' + str(path))
elif args.command == 'daily':
print('Backup: ' + str(daily_backup(database, directory)))
elif args.command == 'list':
for path in sorted(directory.glob('*.db'), reverse=True):
print(json.dumps({'file':str(path), 'bytes':path.stat().st_size}, ensure_ascii=False))
elif args.command == 'check':
print(check_database(args.input or database))
elif args.command == 'restore':
if not args.service_stopped:
parser.error('Restore requires --service-stopped; stop the app and all database users first')
if args.input is None:
parser.error('Restore requires --input')
safety = restore_database(database, args.input, directory)
print('Restored: ' + str(database))
if safety:
print('Safety backup: ' + str(safety))
except (OSError, ValueError, RuntimeError, sqlite3.Error) as error:
print('Database operation failed: ' + str(error), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
+40
View File
@@ -0,0 +1,40 @@
"""Export local workbook details or import a private quote snapshot into SQLite."""
import argparse
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from logistics_chat.quote_data import import_snapshot
from logistics_chat.quote_workbook import export_snapshot
def main():
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--xlsx', type=Path)
source.add_argument('--snapshot', type=Path)
parser.add_argument('--output', type=Path, help='New private snapshot output for --xlsx')
parser.add_argument('--db', type=Path)
parser.add_argument('--activate', action='store_true', help='Use this version as general US rates without expiry')
args = parser.parse_args()
if args.xlsx:
if not args.output or args.db or args.output.exists():
parser.error('--xlsx requires a new --output path, without --db')
snapshot = export_snapshot(args.xlsx)
with args.output.open('x', encoding='utf-8') as target:
json.dump(snapshot, target, ensure_ascii=False, separators=(',', ':'))
print(json.dumps({key:len(snapshot[key]) for key in ('rates','warehouses','postcodes','documents')}))
return
if not args.db or not args.db.is_file() or not args.snapshot.is_file() or args.snapshot.stat().st_size > 20*1024*1024:
parser.error('Import requires an existing database and a private snapshot smaller than 20 MB')
snapshot = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
with closing(sqlite3.connect(args.db)) as connection, connection:
print(json.dumps(import_snapshot(connection, snapshot, activate=args.activate)))
if __name__ == '__main__':
main()
+5 -95
View File
@@ -10,9 +10,6 @@ body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; ove
.app-identity h1 { font-size: 14px; font-weight: 500; color: var(--secondary); white-space: nowrap; }
.workspace-link { font-size: 12px; color: var(--secondary); text-decoration: none; white-space: nowrap; }
.workspace-link:hover { color: var(--brand); }
.staff-logout { min-width: 76px; min-height: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--secondary); background: var(--surface); font-size: 13px; white-space: nowrap; cursor: pointer; }
.staff-logout:hover, .staff-logout:focus-visible { color: var(--brand); border-color: var(--brand); background: var(--brand-soft); }
.staff-logout:disabled { opacity: .6; cursor: wait; }
.data-overview { position: relative; }
.data-overview summary { display: flex; align-items: center; gap: 7px; padding: 6px 10px; font-size: 12px; color: var(--secondary); cursor: pointer; border-radius: 7px; list-style: none; border: 1px solid var(--border); }
.data-overview summary::-webkit-details-marker { display: none; }
@@ -57,9 +54,6 @@ body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; ove
.badge { flex-shrink: 0; padding: 0 5px; border-radius: 5px; background: var(--brand); color: white; font-size: 10px; }
.queue-item { border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 8px; background: var(--surface); }
.queue-item .qt { font-size: 13px; font-weight: 600; color: var(--text); }
.queue-overdue-count, .queue-overdue { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 5px; color: var(--danger); background: #fde1dc; font-size: 11px; font-weight: 600; }
.queue-wait { display: inline-block; margin-left: 7px; color: var(--secondary); font-size: 11px; font-weight: 400; }
.queue-item.is-overdue { border-color: #e7a29a; background: #fffaf9; }
.queue-item small { display: block; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 5px; }
.queue-item button { margin-top: 13px; padding: 7px 12px; min-height: 34px; width: 100%; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 12px; }
.list-footer { flex-shrink: 0; display: flex; align-items: center; gap: 7px; padding: 14px 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; }
@@ -143,91 +137,8 @@ body:not(.has-active-session) .close { display: none; }
.quick { display: block; width: 100%; margin-bottom: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; text-align: left; background: var(--surface); color: var(--secondary); font-size: 12px; }
.quick:hover:enabled { border-color: #a5bdee; color: var(--brand); background: var(--brand-soft); }
.quick:disabled { cursor: default; opacity: .5; }
.ticket-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.ticket-overdue-filter { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; color: var(--secondary); font-size: 12px; }
.ticket-overdue-filter input { width: 16px; height: 16px; }
.ticket-inbox-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 7px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--brand-text); white-space: nowrap; }
#ticket-pending-count { padding: 0 5px; min-width: 22px; border-radius: 5px; background: var(--brand-soft); color: var(--brand-text); font-variant-numeric: tabular-nums; }
.ticket-overdue-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 7px 10px; border: 1px solid #f2c2bb; border-radius: 7px; background: #fff7f5; color: var(--danger); white-space: nowrap; }
#ticket-overdue-count { padding: 0 5px; min-width: 22px; border-radius: 5px; background: #fde1dc; color: var(--danger); font-variant-numeric: tabular-nums; }
.ticket-alert { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding: 8px 22px; background: var(--brand-soft); color: var(--brand-text); border-bottom: 1px solid var(--border); }
.ticket-alert .ticket-action { background: var(--surface); color: var(--brand-text); }
.ticket-section-count { color: var(--brand-text); font-size: 12px; }
.ticket-summary-status { color: var(--danger); font-size: 12px; margin-bottom: 12px; }
.ticket-summary-status:empty { display: none; }
.ticket-timing-rule { color: var(--secondary); font-size: 12px; line-height: 1.6; margin: 0 0 14px; }
.ticket-toolbar .ticket-overdue-filter { min-height: 44px; }
.ticket-search-form { margin-bottom: 16px; }
.ticket-search-form label { display: block; margin-bottom: 8px; }
#ticket-search { width: 100%; min-height: 44px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
.ticket-search-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
#tickets-title { scroll-margin-top: 80px; }
.ticket-toolbar label { color: var(--secondary); }
.ticket-toolbar select { flex: 1; min-width: 0; min-height: 42px; padding: 6px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--surface); font: inherit; }
.ticket-item { display: block; width: 100%; text-align: left; padding: 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 14px; overflow-wrap: anywhere; }
.ticket-item[aria-pressed="true"] { background: var(--brand-soft); }
.ticket-summary { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; }
.ticket-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.ticket-row > span:not(.ticket-state), .ticket-time, .ticket-meta { color: var(--secondary); font-size: 12px; overflow-wrap: anywhere; }
.ticket-time { display: block; margin-top: 6px; }
.ticket-state { display: inline-block; padding: 2px 7px; border-radius: 5px; color: var(--secondary); background: var(--surface-soft); font-size: 12px; }
.ticket-state[data-state="processing"] { color: var(--brand-text); background: var(--brand-soft); }
.ticket-state[data-state="completed"] { color: var(--success); background: var(--success-soft); }
.ticket-detail { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.ticket-detail h4 { font-size: 14px; font-weight: 600; }
.ticket-meta { margin-top: 8px; }
.ticket-content { margin: 14px 0; padding: 12px; border-radius: 8px; background: var(--surface-soft); white-space: pre-wrap; overflow-wrap: anywhere; }
.ticket-owner, .ticket-note-label { display: block; margin-bottom: 10px; }
.ticket-note-label { margin-top: 16px; font-weight: 500; }
#ticket-note { display: block; width: 100%; padding: 10px; resize: vertical; min-height: 100px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
#ticket-note:read-only { background: var(--surface-soft); }
.ticket-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.ticket-action { min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--surface); }
.ticket-action:hover { background: var(--surface-hover); }
.ticket-primary { color: #fff; background: var(--brand); border-color: var(--brand); }
.ticket-primary:hover { background: var(--brand-hover); }
.ticket-feedback, .ticket-server-note { margin-top: 10px; font-size: 12px; overflow-wrap: anywhere; white-space: pre-wrap; }
.ticket-feedback { color: var(--success); }
.ticket-feedback.is-error, .ticket-server-note { color: var(--danger); }
.ticket-overdue-hint { margin-top: 8px; padding: 8px 10px; border-radius: 6px; color: var(--danger); background: #fff7f5; font-size: 12px; line-height: 1.6; }
.ticket-overdue-badge { display: inline-block; margin-left: 5px; padding: 2px 6px; border-radius: 5px; color: var(--danger); background: #fde1dc; font-size: 11px; }
.ticket-history { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.ticket-history h4 { margin: 0; }
.ticket-history-list { margin: 10px 0 0; padding: 0; list-style: none; }
.ticket-history-item { padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.ticket-history-item:last-child { border-bottom: 0; }
.ticket-history-action { color: var(--text); font-weight: 600; }
.ticket-history-meta { color: var(--secondary); }
.ticket-history-detail { display: block; margin-top: 3px; white-space: pre-wrap; }
.ticket-empty { padding: 16px 0; color: var(--secondary); font-size: 13px; }
.routing-heading, .routing-presence { display: flex; align-items: center; gap: 8px; }
.routing-heading { justify-content: space-between; }
.routing-heading > span { color: var(--secondary); font-size: 13px; overflow-wrap: anywhere; }
.routing-presence { margin-top: 12px; flex-wrap: wrap; }
#routing-presence-text { flex: 1; font-size: 13px; }
.routing-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.routing-dot[data-online="true"] { background: var(--success); }
.routing-presence .ticket-action { min-height: 44px; padding: 7px 10px; font-size: 13px; white-space: nowrap; }
.routing-feedback { margin-top: 6px; color: var(--secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.routing-feedback:empty { display: none; }
.routing-feedback.is-error { color: var(--danger); }
.routing-members { margin: 12px 0 0; padding: 0; list-style: none; font-size: 14px; }
.routing-members li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
.routing-members li span:last-child { color: var(--secondary); font-size: 13px; }
.routing-members small { color: var(--muted); }
.routing-settings, .routing-events { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
.routing-settings summary, .routing-events summary { cursor: pointer; color: var(--secondary); font-size: 14px; min-height: 44px; padding: 10px 0; }
.routing-form { display: grid; gap: 7px; margin-top: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
.routing-form label { color: var(--secondary); font-size: 14px; }
.routing-form input, .routing-form select, #routing-retired-drafts textarea { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 14px; }
.routing-form button { margin: 0; }
.routing-form :is(input,select):focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.routing-events ul { margin: 8px 0 0; padding: 0; list-style: none; }
.routing-events li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; overflow-wrap: anywhere; }
.routing-events li span, .routing-events li small { color: var(--muted); }
#routing-retired-drafts label { display: block; margin: 8px 0; font-size: 13px; }
@media (max-width: 720px) { .routing-form input, .routing-form select, #routing-retired-drafts textarea { font-size: 16px; } }
@media (max-width: 720px) { #ticket-note, #ticket-search, .ticket-toolbar select { font-size: 16px; } }
.ticket-item { padding: 11px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--secondary); font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; }
.ticket-item span { display: block; margin-top: 6px; font-size: 10px; color: var(--muted); }
.tools-backdrop, .sessions-back { display: none; }
@media (max-width: 1200px) {
.layout, .tools-open .layout { grid-template-columns: 280px minmax(0, 1fr); }
@@ -245,10 +156,10 @@ body:not(.has-active-session) .close { display: none; }
#chatBox { padding-left: 18px; padding-right: 18px; }
}
@media (max-width: 720px) {
.topbar { height: auto; min-height: 52px; padding: 8px 16px; flex-wrap: wrap; gap: 4px 12px; }
.topbar { height: 52px; min-height: 52px; padding: 0 16px; }
.app-identity h1, .nav-divider { display: none; }
.topbar .brand-name { font-size: 14px; }
.topbar-actions { gap: 8px; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
.topbar-actions { gap: 12px; }
.data-overview summary { font-size: 11px; padding: 5px 8px; }
.stats { width: min(310px, calc(100vw - 32px)); }
.layout, .tools-open .layout { display: flex; flex-direction: column; }
@@ -285,7 +196,6 @@ body:not(.has-active-session) .close { display: none; }
.reply-status { font-size: 11px; }
}
@media (max-width: 420px) {
.workspace-link:not(.orders-link) { display: none; }
.orders-link { display: block; font-size: 11px; }
.workspace-link { display: none; }
.input-actions > span { max-width: 185px; }
}
-63
View File
@@ -1,63 +0,0 @@
body.orders-page { min-height: 100vh; color: var(--text); background: #f5f7fa; }
.orders-topbar { height: 62px; display: flex; justify-content: space-between; align-items: center; padding: 0 clamp(18px, 5vw, 64px); border-bottom: 1px solid var(--border); background: var(--surface); }
.orders-topbar .brand { display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; }
.orders-topbar .brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--brand-soft); color: var(--brand); font-size: 11px; font-weight: 700; }
.orders-topbar .brand-name { font-size: 14px; font-weight: 650; }
.orders-topbar nav { display: flex; align-items: center; gap: 20px; }
.orders-topbar nav a { color: var(--secondary); font-size: 13px; text-decoration: none; }
.orders-topbar nav a:hover { color: var(--brand); }
.orders-topbar nav button { min-height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--secondary); cursor: pointer; }
.orders-shell { width: min(980px, calc(100% - 40px)); margin: 44px auto 80px; }
.orders-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-bottom: 26px; }
.orders-eyebrow { color: var(--brand); font-size: 10px; font-weight: 700; letter-spacing: .14em; }
.orders-heading h1 { margin-top: 6px; font-size: clamp(25px, 4vw, 31px); letter-spacing: -.03em; }
.orders-heading p:last-child { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.7; }
.template-link { flex-shrink: 0; padding: 10px 13px; border: 1px solid #d4e0f9; border-radius: 8px; background: #f3f7ff; color: var(--brand); font-size: 12px; text-decoration: none; }
.template-link:hover { background: #eaf1ff; }
.import-card { margin-bottom: 18px; padding: clamp(20px, 4vw, 30px); border: 1px solid var(--border); border-radius: 13px; background: var(--surface); box-shadow: 0 8px 24px #19263d08; }
.card-heading { display: flex; align-items: flex-start; gap: 13px; margin-bottom: 23px; }
.step-number { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 30px; border-radius: 9px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 700; }
.card-heading h2 { font-size: 16px; font-weight: 650; }
.card-heading p { margin-top: 5px; color: var(--muted); font-size: 12px; line-height: 1.7; }
.file-picker { position: relative; display: flex; align-items: center; gap: 13px; min-height: 84px; padding: 15px 18px; border: 1px dashed #c7d2e2; border-radius: 9px; background: #fbfcfe; cursor: pointer; transition: border-color .15s ease, background .15s ease; }
.file-picker:hover, .file-picker:focus-within { border-color: #91ace5; background: #f7f9ff; }
.file-picker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.file-icon { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 10px; background: var(--brand-soft); color: var(--brand); font-size: 20px; }
.file-picker strong, .file-picker small { display: block; }
.file-picker strong { font-size: 13px; font-weight: 600; }
.file-picker small { margin-top: 4px; color: var(--muted); font-size: 11px; }
.import-hint { color: var(--muted); font-size: 11px; line-height: 1.7; }
.import-hint { margin-top: 12px; }
.form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 13px; margin-top: 20px; }
.button-primary { min-height: 40px; padding: 0 16px; border: 1px solid var(--brand); border-radius: 7px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; cursor: pointer; }
.button-primary:hover:not(:disabled) { filter: brightness(.96); }
.button-primary:disabled { opacity: .55; cursor: wait; }
#import-status { color: var(--secondary); font-size: 12px; }
#import-status.is-error { color: var(--danger); }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 18px; }
.summary-item { padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; background: #fafbfd; }
.summary-item span { display: block; color: var(--muted); font-size: 11px; }
.summary-item strong { display: block; margin-top: 5px; font-size: 20px; font-variant-numeric: tabular-nums; }
.table-scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
.table-scroll table { width: 100%; min-width: 660px; border-collapse: collapse; text-align: left; }
.table-scroll th, .table-scroll td { padding: 11px 12px; border-bottom: 1px solid #edf0f4; font-size: 11px; white-space: nowrap; }
.table-scroll th { color: var(--muted); background: #fafbfd; font-weight: 550; }
.table-scroll tr:last-child td { border-bottom: 0; }
.row-duplicate { color: #99621a; }
.row-new { color: #39745f; }
.error-list { margin-top: 14px; padding: 12px 14px; border: 1px solid #f1d1d1; border-radius: 8px; background: #fff8f8; color: #9f3f43; font-size: 11px; line-height: 1.8; }
.error-list p + p { margin-top: 4px; }
.result-actions { justify-content: space-between; align-items: center; }
.result-actions .import-hint { margin-top: 0; }
.order-search-form { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(150px, 1fr) minmax(140px, .8fr) auto; align-items: end; gap: 12px; margin-bottom: 14px; }
.order-search-form label { display: grid; gap: 6px; color: var(--secondary); font-size: 12px; }
.order-search-form input, .order-search-form select { width: 100%; min-height: 40px; padding: 0 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font: inherit; }
.button-secondary { min-height: 38px; padding: 0 13px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--secondary); font-size: 12px; cursor: pointer; }
.button-secondary:hover:not(:disabled) { border-color: #bac9e3; background: #f7f9fd; }
.button-secondary:disabled { opacity: .55; cursor: wait; }
#order-list-rows strong { font-weight: 600; }
#order-list-status { min-height: 18px; margin: 0 0 10px; }
#import-batch-rows button { white-space: nowrap; }
@media (max-width: 620px) { .orders-topbar { height: 56px; padding: 0 16px; } .orders-topbar nav { gap: 10px; } .orders-topbar nav a { font-size: 11px; } .orders-shell { width: calc(100% - 28px); margin: 28px auto 52px; } .orders-heading { align-items: flex-start; } .template-link { padding: 9px 10px; font-size: 11px; } .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .result-actions { align-items: flex-start; flex-direction: column; } }
@media (max-width: 760px) { .order-search-form { grid-template-columns: 1fr 1fr; } }
@media (max-width: 460px) { .order-search-form { grid-template-columns: 1fr; } }
+56 -106
View File
@@ -42,46 +42,7 @@ body { padding: 20px; height: 100vh; 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); }
.customer-account { display: block; width: 100%; min-height: 44px; 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: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobile-account { display: none; }
.customer-orders { flex: 0 1 auto; min-height: 0; max-height: 48%; display: flex; flex-direction: column; border-bottom: 1px solid var(--border); background: var(--surface); }
.customer-orders-heading { display: flex; align-items: center; gap: 12px; padding: 4px 26px; flex-shrink: 0; background: var(--surface-soft); }
.customer-orders button { min-height: 44px; color: var(--brand-text); font-size: 14px; background: transparent; border: 1px solid transparent; border-radius: 8px; padding: 8px 12px; }
.customer-orders button:hover { background: var(--brand-soft); }
.customer-orders button:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
#customer-orders-toggle { flex: 1; text-align: left; font-weight: 600; padding-left: 0; }
#customer-orders-toggle::after { content: '⌄'; margin-left: 10px; display: inline-block; }
#customer-orders-toggle[aria-expanded="true"]::after { transform: rotate(180deg); }
.customer-orders-body { min-height: 0; overflow: auto; padding: 8px 26px 12px; overscroll-behavior: contain; }
.customer-orders-list { display: flex; flex-direction: column; }
.customer-orders-summary { display: grid; grid-template-columns: repeat(6,minmax(0,1fr)); gap: 6px; padding: 4px 0 10px; }
.customer-orders .customer-order-filter { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 6px; color: var(--secondary); border-color: var(--border); }
.customer-order-filter strong { color: var(--text); font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
.customer-orders .customer-order-filter[aria-pressed="true"] { color: var(--brand-text); background: var(--brand-soft); border-color: var(--brand); }
.customer-order-filter[aria-pressed="true"] strong { color: var(--brand-text); }
.customer-orders-progress { margin: 10px 4px 0; color: var(--secondary); text-align: center; font-size: 13px; }
@media (max-width: 1100px) { .customer-orders-summary { grid-template-columns: repeat(3,minmax(0,1fr)); } }
.customer-orders .customer-order-row { display: grid; grid-template-columns: minmax(130px,1.2fr) minmax(60px,1fr) auto auto; gap: 16px; align-items: center; text-align: left; width: 100%; border-bottom: 1px solid var(--border); border-radius: 0; padding: 12px 6px; color: var(--text); }
.customer-order-identity { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.customer-order-identity strong { overflow-wrap: anywhere; font-size: 15px; font-weight: 600; }
.customer-order-label, .customer-order-route { color: var(--secondary); font-size: 13px; overflow-wrap: anywhere; }
.customer-order-status { background: var(--brand-soft); color: var(--brand-text); padding: 5px 8px; border-radius: 5px; font-size: 13px; }
.customer-order-action { color: var(--brand-text); font-size: 13px; }
#customer-orders-message { padding: 12px 4px; color: var(--secondary); font-size: 14px; }
#customer-orders-back { margin-bottom: 8px; }
#customer-orders-detail { max-width: 760px; margin: 0 auto; }
#customer-orders-more, #customer-orders-retry { display: block; margin: 8px auto 0; border-color: var(--border); }
@media (max-width: 900px) {
.customer-orders { max-height: 44%; }
.customer-orders-heading { padding: 2px 16px; }
.customer-orders-body { padding: 4px 16px 12px; }
.customer-orders .customer-order-row { grid-template-columns: minmax(0,1fr) auto; gap: 7px 12px; }
.customer-order-identity { grid-column: 1; grid-row: 1; }
.customer-order-status { grid-column: 2; grid-row: 1; }
.customer-order-route { grid-column: 1; grid-row: 2; }
.customer-order-action { grid-column: 2; grid-row: 2; text-align: right; }
.is-composing .customer-orders-body { display: none; }
}
.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); }
@@ -89,26 +50,24 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
#customer-error, #order-lookup-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
#customer-submit { border-color: var(--brand); background: var(--brand); color: white; }
#customer-submit, #order-lookup-submit { border-color: var(--brand); background: var(--brand); color: white; }
.order-reference-field { display: flex; align-items: center; gap: 8px; }
.order-reference-field input { min-width: 0; flex: 1; }
#order-reference-copy { flex-shrink: 0; min-width: 60px; min-height: 44px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--brand-text); }
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
.header-copy { min-width: 0; flex: 1; }
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
#app-title { display: block; color: var(--text); font-weight: 650; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.toggle-divider { width: 44px; height: 44px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; }
.toggle-divider { width: 32px; height: 32px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; }
.toggle-divider::before { content: "☰"; font-size: 16px; }
.toggle-divider:hover { background: var(--surface-soft); color: var(--brand-text); }
.title-actions { display: flex; gap: 7px; align-items: center; flex-shrink: 0; }
.settings-control { position: relative; }
.settings-toggle { display: none; }
.settings-panel { display: flex; align-items: center; gap: 7px; }
.settings-row { display: contents; }
.settings-row > span { display: none; }
.theme-toggle { height: 44px; min-width: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 14px; }
.theme-toggle { height: 32px; min-width: 33px; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 11px; }
.theme-toggle:hover { border-color: var(--brand); color: var(--brand-text); background: var(--brand-soft); }
.service-label { display: flex; gap: 7px; align-items: center; color: var(--secondary); font-size: 11px; margin-right: 8px; }
#chatDom { flex: 1; min-height: 0; overflow: auto; overflow-x: hidden; padding: 30px 38px 20px; }
@@ -130,7 +89,7 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.hot-questions { margin-top: 24px; }
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
.hot-capsule-wrap { display: flex; flex-wrap: wrap; gap: 7px; }
.hot-capsule { display: inline-flex; align-items: center; min-height: 40px; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 8px 12px; color: var(--secondary); font-size: 13px; cursor: pointer; }
.hot-capsule { display: inline-flex; align-items: center; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 6px 10px; color: var(--secondary); font-size: 11px; cursor: pointer; }
.hot-capsule:hover { color: var(--brand-text); background: var(--brand-soft); border-color: #a8bfef; }
.recent-chat { margin-top: 24px; }
#recentChatWrap { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 18px; }
@@ -138,14 +97,29 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.recent-item > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.composer-toolbar { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
/* Welcome cards already offer these actions; keep shortcuts available in conversations. */
.main-chat:has(.ai-welcome-card) .hot-bar { display: none; }
@media (min-width: 901px) {
.main-chat:has(.ai-welcome-card) .composer-toolbar { display: none; }
.composer { position: relative; flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.voice-feedback {
position: absolute;
z-index: 36;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
padding: 9px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
text-align: center;
font-size: 13px;
line-height: 1.5;
pointer-events: none;
}
.voice-feedback.is-error { border-color: var(--danger); color: var(--danger); }
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
.voice-toggle, .voice-hold { display: none; }
.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; }
@@ -155,7 +129,7 @@ body { padding: 20px; height: 100vh; 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 .hot-capsule { border: 0; padding: 8px 12px; background: var(--surface-soft); font-size: 13px; }
.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); }
#upload-btn { flex: 0 0 36px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--secondary); font-size: 18px; }
@@ -199,6 +173,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; }
.track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; }
.track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; }
.order-reference { display: block; max-width: 100%; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--brand-text); font: inherit; text-align: left; overflow-wrap: anywhere; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.order-reference:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 3px; }
.track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; }
.track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; }
.track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); }
@@ -238,20 +214,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
.composer-status.is-error { color: var(--danger); }
.message-image { display: block; max-width: min(240px, 100%); max-height: 320px; object-fit: contain; border-radius: 8px; }
.message-image, .image-preview-trigger { cursor: zoom-in; }
.image-preview-trigger { flex-shrink: 0; padding: 0; border: 0; border-radius: 7px; background: transparent; }
.attachment-drop-hint { position: absolute; inset: 12px; z-index: 40; display: grid; place-items: center; padding: 24px; border: 2px dashed var(--brand); border-radius: 14px; background: var(--brand-soft); color: var(--brand-text); font-size: 20px; font-weight: 600; text-align: center; pointer-events: none; }
.image-viewer { width: min(1200px, calc(100% - 32px)); height: calc(100dvh - 32px); max-width: none; max-height: none; margin: auto; padding: 16px; overflow: hidden; border: 1px solid var(--border); border-radius: 14px; color: var(--text); background: var(--surface); }
.image-viewer[open] { display: flex; flex-direction: column; gap: 12px; }
.image-viewer::backdrop { background: rgba(10, 20, 35, .78); }
.image-viewer-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
.image-viewer-header h2 { font-size: 18px; }
.image-viewer-controls { display: flex; flex-wrap: wrap; gap: 8px; }
.image-viewer-controls button { min-width: 64px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); }
.image-viewer-controls button:hover { border-color: var(--brand); color: var(--brand-text); }
.image-viewer-stage { display: flex; flex: 1; align-items: flex-start; min-height: 0; overflow: auto; padding: 16px; background: var(--page); border-radius: 8px; }
.image-viewer-stage img { display: block; flex-shrink: 0; margin: auto; object-fit: contain; }
.image-viewer-status { margin: auto; color: var(--secondary); font-size: 16px; }
.message-file { display: block; max-width: 100%; overflow-wrap: anywhere; color: inherit; padding: 6px 0; font-size: 13px; text-underline-offset: 3px; }
@media (min-width: 901px) and (max-height: 780px) {
#chatDom { padding-top: 22px; }
@@ -273,57 +235,43 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
}
@media (max-width: 900px) {
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
.chat-container { --chat-header-height: 120px; 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); }
.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: 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: var(--chat-header-height); height: var(--chat-header-height); display: grid; grid-template-columns: 44px minmax(0, 1fr); grid-template-rows: 44px 44px; padding: 12px 18px; gap: 8px 12px; }
.header-kicker { font-size: 12px; }
#app-title { font-size: 16px; }
.title-actions { grid-column: 1 / -1; min-width: 0; gap: 8px; }
.mobile-account { display: block; flex: 1; min-width: 0; width: auto; margin: 0; background: var(--brand-soft); border-color: var(--brand); font-weight: 600; }
.sidebar-footer .customer-account { display: none; }
.new-chat-btn, .clear-all-btn { min-height: 44px; }
.panel-title, .history-empty, .clear-all-btn { font-size: 14px; }
.history-title { font-size: 15px; }
.history-time { font-size: 12px; }
.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); 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-toolbar { display: none; }
.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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.hot-bar .hot-capsule { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; }
.hot-questions h4, .recent-chat h4 { font-size: 14px; }
.hot-questions .hot-capsule { min-height: 44px; }
.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; min-width: 44px; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
#upload-btn { flex-basis: 44px; }
#emoji-toggle { width: 44px; }
#send-button { font-size: 14px; }
#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; width: 44px; height: 44px; }
.voice-toggle { display: grid; place-items: center; flex: 0 0 44px; height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
.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; 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); }
.settings-toggle { display: inline-flex; align-items: center; justify-content: center; }
.settings-panel { position: absolute; top: calc(100% + 8px); right: 0; width: min(260px, calc(100vw - 24px)); padding: 8px 12px; display: flex; flex-direction: column; align-items: stretch; gap: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 60px; }
.settings-row + .settings-row { border-top: 1px solid var(--border); }
.settings-row > span { display: block; color: var(--text); font-size: 16px; }
.settings-row .theme-toggle { min-width: 64px; }
}
@media (max-width: 520px) {
.header-kicker { font-size: 9px; }
#app-title { font-size: 13px; }
.chat-title { padding: 12px; gap: 8px; }
.title-actions { gap: 4px; }
.theme-toggle { padding: 0 6px; min-width: 29px; font-size: 10px; }
#font-toggle { display: none; }
#chatDom { padding: 20px 16px; }
.ai-avatar { display: none; }
.ai-welcome-card { padding-top: 12px; }
@@ -331,19 +279,21 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.suggest-grid { gap: 9px; margin-top: 22px; }
.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: 14px; }
.suggest-copy strong { font-size: 13px; }
.suggest-arrow { display: none; }
.hot-questions { margin-top: 22px; }
.hot-questions .hot-capsule-wrap { flex-wrap: wrap; }
.hot-questions .hot-capsule { max-width: 100%; white-space: normal; text-align: left; }
.hot-capsule { font-size: 13px; padding: 8px 10px; }
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
.hot-questions .hot-capsule { flex-shrink: 0; white-space: nowrap; }
.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 .hot-capsule { padding: 8px; }
.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; }
#input-text { font-size: 16px; }
.attachment-remove { width: 44px; height: 44px; }
.attachment-remove { width: 40px; height: 40px; }
.track-text { grid-template-columns: 1fr; gap: 2px; }
.track-place { grid-column: 1; }
.robot-message.track-card { padding: 16px; }
-25
View File
@@ -84,34 +84,9 @@ svg { display: block; flex-shrink: 0; }
.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; }
.app-modal-overlay {
position: fixed; inset: 0; z-index: 200; display: grid; place-items: center;
overflow-y: auto; padding: 20px; background: rgba(18, 33, 56, .42);
}
.app-modal {
width: min(420px, 100%); max-height: min(80dvh, 680px); overflow-y: auto;
padding: 22px; border: 1px solid var(--border); border-radius: 14px;
background: var(--surface); color: var(--text); box-shadow: var(--shadow);
}
.app-modal-title { margin: 0; font-size: 18px; line-height: 1.4; }
.app-modal-message { margin-top: 9px; color: var(--secondary); font-size: 14px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.app-modal-input { display: block; width: 100%; min-height: 44px; margin-top: 14px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; font-size: 16px; line-height: 1.6; }
textarea.app-modal-input { min-height: 112px; resize: vertical; }
.app-modal-input:focus { outline: 3px solid var(--brand-soft); border-color: var(--brand); }
.app-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.app-modal-actions button { min-width: 80px; min-height: 44px; padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); cursor: pointer; }
.app-modal-actions .app-modal-confirm { border-color: var(--brand); background: var(--brand); color: #fff; }
.app-modal-actions button:hover { background: var(--surface-hover); color: var(--text); }
.app-modal-actions .app-modal-confirm:hover { background: var(--brand-hover); color: #fff; }
.app-modal-actions button:focus-visible { outline: 3px solid var(--brand-soft); outline-offset: 2px; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; }
::-webkit-scrollbar-track { background: transparent; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
@media (max-width: 520px) {
.app-modal-overlay { align-items: end; padding: 16px; }
.app-modal { width: 100%; max-height: min(85dvh, 680px); padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); border-radius: 14px 14px 10px 10px; }
.app-modal-actions button { flex: 1; }
}
+23 -134
View File
@@ -1,9 +1,7 @@
var curSid = 0;
var sessions = {};
var drafts = {};
var reassignedNotes = {};
var queueItems = [];
var queueOverdueNotified = new Set();
var accepting = new Set();
var sendingSid = "";
var closingSid = "";
@@ -12,11 +10,6 @@ var listSignature = "";
var typeTimer = null;
var toolsOpen = false;
var toolsOpener = null;
var staffExpired = false;
var staffLoggingOut = false;
var staffPollTimers = [];
var staffCsrf = document.querySelector('meta[name="staff-csrf-token"]').content;
var staffUsername = document.querySelector('meta[name="staff-username"]').content;
var replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
@@ -27,66 +20,13 @@ function escapeText(value){
}
async function adminRequest(path, options){
if(staffExpired) throw new Error("Staff login expired");
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
try{
const headers = Object.assign({}, options && options.headers);
if(options && options.method && options.method !== "GET") headers["X-CSRF-Token"] = staffCsrf;
const response = await fetch(path, Object.assign({}, options, {headers:headers, signal:controller.signal}));
if(response.status === 401){
if(!staffExpired){
staffExpired = true;
staffPollTimers.forEach(clearInterval);
window.location.replace("/admin?expired=1");
}
throw new Error("Staff login expired");
}
if(staffExpired) throw new Error("Staff login expired");
const data = await response.json();
if(!response.ok || (data && data.ok === false)){
const error = new Error("HTTP " + response.status);
error.status = response.status;
error.userMessage = data && (data.e || data.error);
throw error;
}
return data;
}finally{
clearTimeout(timer);
}
const response = await fetch(path, options);
if(!response.ok) throw new Error("HTTP " + response.status);
const data = await response.json();
if(data && data.ok === false) throw new Error("Request rejected");
return data;
}
async function logoutStaff(){
if(staffLoggingOut || staffExpired) return;
staffLoggingOut = true;
const button = document.getElementById("staff-logout");
const status = document.getElementById("staff-logout-status");
button.disabled = true;
button.textContent = "正在退出…";
status.hidden = true;
try{
await postAdmin("/admin/logout", {});
staffExpired = true;
staffPollTimers.forEach(clearInterval);
window.location.replace("/admin");
}catch(error){
if(!staffExpired){
status.textContent = "退出失败,请检查网络后重试。";
status.hidden = false;
}
}finally{
staffLoggingOut = false;
button.disabled = false;
button.textContent = "退出登录";
}
}
document.getElementById("staff-logout").onclick = logoutStaff;
window.addEventListener("pageshow", function(event){
if(event.persisted){
adminRequest("/api/admin/session").then(function(data){ staffCsrf = data.csrf_token; }).catch(function(){});
}
});
function postAdmin(path, data){
return adminRequest(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(data)});
}
@@ -146,16 +86,11 @@ function renderQueue(){
return ("#" + item.id + " " + item.ctime).toLowerCase().includes(term);
});
document.getElementById("queue-count").textContent = queueItems.length;
document.getElementById("queue-overdue-count").textContent = "超时 " + queueItems.filter(function(item){ return item.overdue; }).length;
document.getElementById("session-browser").classList.toggle("queue-empty", queueItems.length === 0);
document.getElementById("queue").innerHTML = visible.length
? visible.map(function(item){
const sid = Number(item.id);
const age = Number(item.waiting_minutes) || 0;
const wait = item.overdue
? '<span class="queue-overdue">排队超时 '+escapeText(item.overdue_minutes)+' 分钟</span>'
: '<span class="queue-wait">已等待 '+escapeText(age)+' 分钟</span>';
return '<div class="queue-item'+(item.overdue ? ' is-overdue' : '')+'"><div class="qt">对话 #'+sid+wait+'<small>'+escapeText(item.group_name || '综合客服')+' · '+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
}).join("")
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
}
@@ -163,17 +98,6 @@ function renderQueue(){
async function loadQueue(){
try{
const list = await adminRequest("/api/human/queue");
const waitingOverdue = new Set(list.filter(function(item){ return item.overdue; }).map(function(item){ return String(item.id); }));
queueOverdueNotified.forEach(function(id){ if(!waitingOverdue.has(id)) queueOverdueNotified.delete(id); });
const newlyOverdue = list.filter(function(item){
const id = String(item.id);
if(!item.overdue || queueOverdueNotified.has(id)) return false;
queueOverdueNotified.add(id);
return true;
});
if(newlyOverdue.length){
SupportUI.notify("人工排队超时", newlyOverdue.length+" 位客户已等待超过 5 分钟,请尽快接入。 ");
}
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
}
@@ -185,7 +109,15 @@ async function loadQueue(){
}
async function loadTickets(){
return Promise.all([ticketDesk.load(), ticketDesk.refreshSummary()]);
const element = document.getElementById("ticketList");
try{
const list = await adminRequest("/api/tickets");
element.innerHTML = list.length ? list.map(function(ticket){
return '<div class="ticket-item">'+escapeText(ticket.content)+'<span>'+escapeText(ticket.ctime)+'</span></div>';
}).join("") : '<div class="list-empty">暂时没有客户留言</div>';
}catch(error){
element.innerHTML = '<div class="list-empty">留言暂时无法加载</div>';
}
}
async function accept(sid){
@@ -200,7 +132,7 @@ async function accept(sid){
loadQueue();
poll();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">'+escapeText(error.userMessage || '接入失败,请稍后重试')+'</div>';
document.getElementById("queue").innerHTML = '<div class="list-empty">接入失败,请稍后重试</div>';
}finally{
accepting.delete(sid);
}
@@ -215,7 +147,7 @@ function switchSession(sid){
document.getElementById("chatContent").style.display = "flex";
setChatView(true);
document.getElementById("chatTitle").textContent = "客户 #" + sid;
document.getElementById("chat-subtitle").textContent = "我负责 · "+(sessions[sid].group_name || "综合客服");
document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话";
document.getElementById("cInfo").textContent = "会话编号:#" + sid;
document.getElementById("chatBox").innerHTML = "";
sessions[sid].msgs.forEach(appendMsg);
@@ -318,23 +250,11 @@ async function poll(){
const list = await adminRequest("/api/human/active");
list.forEach(function(session){
if(!sessions[String(session.id)]) sessions[String(session.id)] = {lastId:0, msgs:[], unread:0};
Object.assign(sessions[String(session.id)], {group_name:session.group_name, owner_username:session.owner_username});
});
await Promise.all(Object.keys(sessions).map(async function(sid){
const session = sessions[sid];
const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId);
if(sessions[sid] !== session) return;
if(state.status === 'waiting' || (state.owner_username && state.owner_username !== staffUsername)){
if(!Object.prototype.hasOwnProperty.call(drafts,sid)) drafts[sid] = '';
if(sid === curSid){
drafts[sid] = replyInput.value;
reassignedNotes[sid] = document.getElementById('noteInput').value;
}else if(session.noteDraft){ reassignedNotes[sid] = session.noteDraft; }
delete sessions[sid];
if(sid === curSid) showEmptySession();
renderRecoveredDrafts();
return;
}
if(state.status !== "active"){
delete sessions[sid];
delete drafts[sid];
@@ -353,7 +273,6 @@ async function poll(){
}));
document.getElementById("sync-status").hidden = true;
renderList();
renderRecoveredDrafts();
}catch(error){
document.getElementById("sync-status").hidden = false;
}finally{
@@ -361,15 +280,6 @@ async function poll(){
}
}
function renderRecoveredDrafts(){
const ids = Object.keys(drafts).filter(function(sid){ return !sessions[sid] && (drafts[sid] || reassignedNotes[sid]); });
document.getElementById('routing-draft-recovery').hidden = ids.length === 0;
document.getElementById('routing-retired-drafts').innerHTML = ids.map(function(sid){
return '<label>会话 #'+Number(sid)+' · 未发送草稿<textarea readonly rows="3">'+escapeText(drafts[sid] || '')+'</textarea></label>'+
(reassignedNotes[sid] ? '<label>备注草稿<textarea readonly rows="3">'+escapeText(reassignedNotes[sid])+'</textarea></label>' : '');
}).join('');
}
async function sendReply(){
if(!curSid || sendingSid || closingSid) return;
const sid = curSid;
@@ -389,9 +299,7 @@ async function sendReply(){
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
poll();
}catch(error){
if(curSid === sid) setReplyStatus(error.name === 'AbortError'
? "发送超时,内容已保留。请先确认聊天记录,再决定是否重试。"
: "发送未成功,内容已保留。请确认后重试。", true);
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
}finally{
sendingSid = "";
updateComposer();
@@ -478,14 +386,14 @@ document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "
document.getElementById("tools-close").onclick = function(){
setToolsOpen(false);
const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches;
document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle", "routing-open"].includes(toolsOpener) ? toolsOpener : fromList ? "tools-from-list" : "tools-toggle").focus();
document.getElementById(fromList ? "tools-from-list" : "tools-toggle").focus();
};
document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick;
document.addEventListener("keydown", function(event){
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
if(event.key === "Escape") document.getElementById("data-overview").open = false;
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]):not([hidden]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])')).filter(function(item){ return item.getClientRects().length; });
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
const first = items[0], last = items[items.length-1];
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
@@ -506,27 +414,8 @@ setToolsOpen(false);
setSessionTab("active");
updateComposer();
loadQueue();
var ticketDesk = window.AdminTickets.create({request:adminRequest, escape:escapeText,
username:document.querySelector('meta[name="staff-username"]').content,
notify:function(title, body){ SupportUI.notify(title, body); },
open:function(opener){
toolsOpener = opener || "ticket-inbox-toggle";
setToolsOpen(true);
document.getElementById("tickets-title").scrollIntoView({block:"start"});
document.getElementById("ticket-search").focus({preventScroll:true});
}});
loadTickets();
poll();
staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)];
if(window.AdminRouting){
var routingDesk = window.AdminRouting.create({username:staffUsername,request:adminRequest,
changed:function(){ loadQueue(); poll(); }});
routingDesk.refresh();
staffPollTimers.push(setInterval(routingDesk.heartbeat, 20000));
document.getElementById('routing-open').onclick = function(){
toolsOpener = 'routing-open'; setToolsOpen(true);
document.getElementById('routing-title').scrollIntoView({block:'start'});
document.getElementById('routing-refresh').focus({preventScroll:true});
routingDesk.refresh();
};
}
setInterval(loadQueue, 3000);
setInterval(loadTickets, 5000);
setInterval(poll, 1000);
+1 -6
View File
@@ -10,10 +10,6 @@ function showLoginError(message){
loginError.hidden = false;
}
if(new URLSearchParams(window.location.search).has("expired")){
showLoginError("后台登录已失效,请重新登录。");
}
async function login(){
if(loginPending) return;
const username = userInput.value.trim();
@@ -39,8 +35,7 @@ async function login(){
location.href = "/admin/dashboard";
return;
}
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" :
response.status === 429 ? "登录尝试过多,请 5 分钟后重试" : "暂时无法登录,请稍后重试");
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" : "暂时无法登录,请稍后重试");
}catch(error){
showLoginError("网络连接失败,请检查网络后重试");
}finally{
-163
View File
@@ -1,163 +0,0 @@
(function(){
"use strict";
const csrf = document.querySelector('meta[name="staff-csrf-token"]').content;
const fileInput = document.getElementById('order-file');
const fileName = document.getElementById('file-name');
const form = document.getElementById('order-import-form');
const status = document.getElementById('import-status');
const results = document.getElementById('import-results');
const confirmButton = document.getElementById('confirm-import');
let selectedFile = null;
let canConfirm = false;
let orderBefore = null;
function setStatus(message, isError){
status.textContent = message || '';
status.classList.toggle('is-error', Boolean(isError));
}
function escapeText(value){
return String(value == null ? '' : value).replace(/[&<>"']/g, function(character){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character];
});
}
function showResponse(data){
results.hidden = false;
const summary = data.summary || {};
document.getElementById('import-summary').innerHTML = [
['文件数据行', summary.total_rows], ['有效行', summary.valid_rows],
['重复保留', summary.duplicate_rows], ['错误行', summary.error_rows]
].map(function(item){
return '<div class="summary-item"><span>'+escapeText(item[0])+'</span><strong>'+escapeText(item[1] || 0)+'</strong></div>';
}).join('');
document.getElementById('preview-rows').innerHTML = (data.preview || []).map(function(row){
return '<tr><td>'+escapeText(row.row)+'</td><td>'+escapeText(row.tenant_id)+'</td><td>'+escapeText(row.order_no)+'</td><td>'+escapeText(row.status)+'</td><td>'+escapeText(row.inbound_code)+'</td><td class="'+(row.duplicate ? 'row-duplicate' : 'row-new')+'">'+(row.duplicate ? '已存在,保留原数据' : '可新增')+'</td></tr>';
}).join('') || '<tr><td colspan="6">没有可预览的有效数据</td></tr>';
document.getElementById('preview-limit-note').hidden = !data.preview_limited;
const errors = document.getElementById('import-errors');
errors.innerHTML = (data.errors || []).map(function(item){
return '<p>第 '+escapeText(item.row)+' 行:'+escapeText(item.message)+'</p>';
}).join('');
errors.hidden = !data.errors || !data.errors.length;
document.getElementById('results-caption').textContent = errors.hidden
? '请确认租户、订单号及重复记录处理方式。'
: '发现错误行;请修正文件后重新上传,当前不会写入数据。';
canConfirm = Boolean(data.ok && summary.valid_rows);
confirmButton.hidden = !canConfirm;
}
async function sendFile(path){
const body = new FormData();
body.append('file', selectedFile);
const response = await fetch(path, {method:'POST', body:body, credentials:'same-origin',
headers:{'X-CSRF-Token':csrf, 'Accept':'application/json'}, signal:AbortSignal.timeout(30000)});
const data = await response.json();
if(!response.ok && !data.summary) throw new Error(data.error || '请求失败,请稍后重试。');
return data;
}
async function requestJson(path, options){
options = options || {};
options.credentials = 'same-origin';
options.headers = Object.assign({'Accept':'application/json'}, options.headers || {});
const response = await fetch(path, options);
const data = await response.json();
if(!response.ok) throw new Error(data.error || '请求失败,请稍后重试。');
return data;
}
function renderOrders(data, append){
const body = document.getElementById('order-list-rows');
const rows = (data.orders || []).map(function(row){
return '<tr><td>'+escapeText(row.tenant_id)+'</td><td><strong>'+escapeText(row.order_no)+'</strong></td><td>'+escapeText(row.status)+'</td><td>'+escapeText(row.inbound_code)+'</td><td>'+escapeText(row.shipment_id)+'</td><td>'+escapeText(row.country_code)+'</td><td>'+escapeText(row.source_updated_at)+'</td></tr>';
}).join('');
if(!append) body.innerHTML = '';
body.insertAdjacentHTML('beforeend', rows || (!append ? '<tr><td colspan="7">没有找到订单</td></tr>' : ''));
orderBefore = data.next_before || null;
document.getElementById('load-more-orders').hidden = !orderBefore;
document.getElementById('order-list-status').textContent = '共 '+escapeText(data.total || 0)+' 条订单';
}
async function loadOrders(append){
const params = new URLSearchParams({q:document.getElementById('order-query').value.trim(), tenant:document.getElementById('order-tenant').value.trim(), status:document.getElementById('order-status').value, limit:'50'});
if(append && orderBefore) params.set('before', orderBefore);
const data = await requestJson('/api/admin/orders/list?'+params.toString());
renderOrders(data, append);
}
async function loadBatches(){
const data = await requestJson('/api/admin/orders/import/batches');
document.getElementById('import-batch-rows').innerHTML = (data.batches || []).map(function(batch){
const active = batch.state === 'active';
const action = active ? '<button class="button-secondary batch-undo" data-batch-id="'+escapeText(batch.id)+'" type="button">撤销</button>' : '已撤销(删 '+escapeText(batch.deleted_rows)+',留 '+escapeText(batch.preserved_on_undo)+')';
return '<tr><td>'+escapeText(batch.imported_at)+'</td><td>'+escapeText(batch.filename)+'</td><td>'+escapeText(batch.imported_by)+'</td><td>'+escapeText(batch.imported_rows)+'</td><td>'+escapeText(batch.preserved_rows)+'</td><td>'+escapeText(active ? '可撤销' : '已撤销')+'</td><td>'+action+'</td></tr>';
}).join('') || '<tr><td colspan="7">暂无导入记录</td></tr>';
document.querySelectorAll('.batch-undo').forEach(function(button){
button.addEventListener('click', async function(){
if(!await SupportUI.confirm('撤销后只会删除本批次中仍未被修改的新增订单,确定继续吗?',{title:'撤销导入'})) return;
button.disabled = true;
try{
const result = await requestJson('/api/admin/orders/import/batches/'+encodeURIComponent(button.dataset.batchId)+'/undo',{method:'POST',headers:{'X-CSRF-Token':csrf}});
setStatus('批次已处理:删除 '+result.deleted+' 条,保留 '+result.preserved+' 条。');
await Promise.all([loadBatches(), loadOrders(false)]);
}catch(error){ setStatus(error.message, true); button.disabled = false; }
});
});
}
fileInput.addEventListener('change', function(){
selectedFile = fileInput.files[0] || null;
fileName.textContent = selectedFile ? selectedFile.name : '选择订单文件';
results.hidden = true;
canConfirm = false;
confirmButton.hidden = true;
setStatus('');
});
form.addEventListener('submit', async function(event){
event.preventDefault();
if(!selectedFile) return;
const button = document.getElementById('preview-import');
button.disabled = true;
setStatus('正在读取并校验文件…');
try{
const data = await sendFile('/api/admin/orders/import/preview');
showResponse(data);
setStatus(data.ok ? '校验完成,请检查结果。' : '发现错误行,请先修正文件。', !data.ok);
}catch(error){ setStatus(error.message || '校验失败,请检查网络后重试。', true); }
finally{ button.disabled = false; }
});
confirmButton.addEventListener('click', async function(){
if(!canConfirm || !selectedFile) return;
confirmButton.disabled = true;
setStatus('正在重新校验并导入…');
try{
const data = await sendFile('/api/admin/orders/import');
canConfirm = false;
confirmButton.hidden = true;
fileInput.value = '';
selectedFile = null;
fileName.textContent = '选择订单文件';
setStatus('导入完成:新增 '+data.imported+' 条,重复保留 '+data.preserved+' 条。');
const refreshed = await Promise.allSettled([loadBatches(), loadOrders(false)]);
if(refreshed[0].status === 'rejected'){
document.getElementById('import-batch-rows').innerHTML = '<tr><td colspan="7">导入已完成,但记录刷新失败,请刷新页面重试。</td></tr>';
}
if(refreshed[1].status === 'rejected'){
document.getElementById('order-list-status').textContent = '导入已完成,但订单列表刷新失败:'+refreshed[1].reason.message;
}
if(refreshed.some(function(result){ return result.status === 'rejected'; })){
setStatus('导入已完成,但部分列表刷新失败,请刷新页面查看。', true);
}
}catch(error){ setStatus(error.message || '导入失败,请检查网络后重试。', true); }
finally{ confirmButton.disabled = false; }
});
document.getElementById('order-search-form').addEventListener('submit', async function(event){
event.preventDefault();
try { await loadOrders(false); } catch(error) { document.getElementById('order-list-status').textContent = error.message; }
});
document.getElementById('load-more-orders').addEventListener('click', async function(){
this.disabled = true;
try { await loadOrders(true); } catch(error) { document.getElementById('order-list-status').textContent = error.message; }
finally { this.disabled = false; }
});
Promise.all([loadOrders(false), loadBatches()]).catch(function(error){ document.getElementById('order-list-status').textContent = error.message; });
document.getElementById('staff-logout').addEventListener('click', async function(){
this.disabled = true;
try{
await fetch('/admin/logout', {method:'POST', credentials:'same-origin', headers:{'X-CSRF-Token':csrf}});
}finally{ location.assign('/admin'); }
});
})();
-170
View File
@@ -1,170 +0,0 @@
/* Group configuration, explicit availability and versioned manual reassignment. */
(function(root){
function create(options){
const el = id => document.getElementById(id);
const escape = value => String(value == null ? '' : value).replace(/[&<>"']/g,
character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
let snapshot = null, busy = false, beating = false, generation = 0, selectedVersion = null;
const controls = ['routing-presence-toggle','routing-refresh','routing-create-group','routing-save-member','routing-save-assignment'];
function message(text, error){
['routing-status','routing-management-status'].forEach(id=>{
el(id).textContent = text || '';
el(id).classList.toggle('is-error', Boolean(error));
});
}
function setBusy(value){
busy = value;
controls.forEach(id => { el(id).disabled = value || !snapshot; });
}
function fill(id, items, placeholder){
const input = el(id), previous = input.value;
input.innerHTML = (placeholder ? '<option value="">'+escape(placeholder)+'</option>' : '') +
items.map(item => '<option value="'+escape(item.value)+'">'+escape(item.label)+'</option>').join('');
input.value = items.some(item => String(item.value) === previous) ? previous :
(placeholder ? '' : items.length ? String(items[0].value) : '');
}
function renderPresence(online){
el('routing-presence-text').textContent = online ? '在线,参与自动分配' : '离线,不自动接待';
el('routing-presence-dot').dataset.online = String(online);
el('routing-presence-toggle').textContent = online ? '切换离线' : '上线接待';
el('routing-presence-toggle').setAttribute('aria-pressed', String(online));
}
function targets(preserveUnavailable){
if(!snapshot) return;
const group = Number(el('routing-target-group').value);
const previous = el('routing-target-owner').value;
const items = snapshot.agents.filter(agent => agent.online && agent.group_id === group)
.map(agent => ({value:agent.username,label:(agent.name || agent.username)+' · '+agent.active_count+' 个会话'}));
if(preserveUnavailable && previous && !items.some(item=>item.value === previous)){
items.push({value:previous,label:previous+' · 状态已改变,请重新选择'});
}
fill('routing-target-owner',items,'转入队列,等待手动接入');
}
function selectSession(){
const session = snapshot && snapshot.sessions.find(item => String(item.id) === el('routing-session').value);
selectedVersion = session ? session.version : null;
if(session){
el('routing-target-group').value = String(session.group_id);
targets();
el('routing-target-owner').value = session.owner_username || '';
}
}
function render(data){
snapshot = data;
renderPresence(Boolean(data.presence.online));
const mine = data.agents.find(agent => agent.username === options.username);
el('routing-group-label').textContent = mine ? mine.group_name : '综合客服';
el('routing-members').innerHTML = data.agents.length ? data.agents.map(agent =>
'<li><span>'+escape(agent.name || agent.username)+' <small>'+escape(agent.username)+'</small></span>'+
'<span>'+escape(agent.group_name)+' · '+(agent.online ? '在线' : '离线')+' · '+agent.active_count+' 个会话</span></li>').join('') : '<li>暂无客服账号</li>';
const groups = data.groups.map(group => ({value:group.id,label:group.name}));
fill('routing-member-group', groups);
fill('routing-target-group', groups);
fill('routing-member', data.agents.map(agent => ({value:agent.username,label:(agent.name || agent.username)+'('+agent.username+')'})), '选择客服');
fill('routing-session', data.sessions.map(session => ({value:session.id,
label:'#'+session.id+' · '+session.group_name+' · '+(session.owner_name || session.owner_username || '排队中')})), '选择会话');
if(!el('routing-session').value) selectedVersion = null;
targets(true);
const actions = {auto_assign:'自动分配',claim:'手动接入',reassign:'手动改派',queue:'转入分组队列',transfer:'转回队列',close:'结束会话'};
el('routing-events').innerHTML = data.events.length ? data.events.map(event =>
'<li><strong>#'+event.session_id+' · '+escape(actions[event.action] || event.action)+'</strong><span>'+
escape(event.group_name)+' → '+escape(event.owner_username || '等待分配')+'</span><small>'+escape(event.actor === 'system' ? '系统' : event.actor === 'customer' ? '客户' : event.actor)+' · '+escape(event.ctime)+'</small></li>').join('') : '<li>暂无分配记录</li>';
setBusy(busy);
}
async function refresh(afterWrite){
if(busy && afterWrite !== true) return;
const current = ++generation;
try{
const data = await options.request('/api/admin/routing');
if(current !== generation) return;
if(!data || !Array.isArray(data.groups) || !Array.isArray(data.agents) ||
!Array.isArray(data.sessions) || !Array.isArray(data.events) || !data.presence) throw new Error('Invalid routing data');
render(data);
}catch(error){
if(current === generation) message('分配状态加载失败,已保留现有信息;请点击刷新重试。', true);
}
}
async function heartbeat(){
if(busy || beating) return;
beating = true;
const current = generation;
try{
const data = await options.request('/api/admin/routing/presence', {method:'POST',
headers:{'Content-Type':'application/json'},body:'{}'});
if(current !== generation) return;
if(snapshot) snapshot.presence.online = Boolean(data.online);
renderPresence(Boolean(data.online));
await refresh();
}catch(error){
if(current === generation){
el('routing-presence-text').textContent = '接待状态待确认';
message('接待状态同步失败,断线超过 90 秒将停止自动分配;请刷新核对。', true);
}
}finally{ beating = false; }
}
async function setPresence(){
if(busy || !snapshot) return;
++generation;
setBusy(true);
const online = !snapshot.presence.online;
try{
const data = await options.request('/api/admin/routing/presence', {method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({online})});
snapshot.presence.online = Boolean(data.online);
renderPresence(Boolean(data.online));
message(data.online ? '已上线,空闲时自动接入一个新客户;多接请手动领取。' : '已离线,已接待的会话继续保留。');
if(options.changed) options.changed();
}catch(error){ message('状态更新未确认成功,请点击刷新核对后重试。', true); }
finally{ setBusy(false); }
}
async function write(path, data, success){
if(busy || !snapshot) return false;
++generation;
setBusy(true);
try{
await options.request('/api/admin/routing/'+path, {method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
message(success);
if(options.changed) options.changed();
return true;
}catch(error){
message(error.userMessage || '操作未确认成功,请刷新核对后重试。', true);
return false;
}finally{ await refresh(true); setBusy(false); }
}
async function addGroup(){
const name = el('routing-group-name').value.trim();
if(!name || name.length > 40){ message('请输入 1–40 字的分组名称。', true); return; }
if(await write('groups',{name},'分组已创建,可将客服加入此组。')){
if(el('routing-group-name').value.trim() === name) el('routing-group-name').value = '';
}
}
async function moveMember(){
const username = el('routing-member').value, group_id = Number(el('routing-member-group').value);
if(!username){ message('请先选择客服。', true); return; }
await write('members',{username,group_id},'客服分组已更新,现有会话负责人不变。');
}
async function assign(){
const sid = Number(el('routing-session').value), group_id = Number(el('routing-target-group').value);
if(!sid || selectedVersion === null){ message('请先选择会话。', true); return; }
const version = selectedVersion, username = el('routing-target-owner').value || null;
await write('assign',{sid,group_id,version,username},'会话分配已更新。');
el('routing-session').value = ''; selectedVersion = null;
}
el('routing-presence-toggle').addEventListener('click',setPresence);
el('routing-refresh').addEventListener('click',refresh);
el('routing-session').addEventListener('change',selectSession);
el('routing-target-group').addEventListener('change',()=>targets(false));
el('routing-member').addEventListener('change',()=>{
const member = snapshot && snapshot.agents.find(agent => agent.username === el('routing-member').value);
if(member) el('routing-member-group').value = String(member.group_id);
});
[['routing-group-form',addGroup],['routing-member-form',moveMember],['routing-assign-form',assign]].forEach(([id,handler])=>{
el(id).addEventListener('submit',event=>{event.preventDefault();return handler();});
});
setBusy(false);
return {refresh,heartbeat,setPresence,assign};
}
root.AdminRouting = {create};
})(window);
-292
View File
@@ -1,292 +0,0 @@
/* Offline tickets: server ownership, versioned notes and polling-safe drafts. */
(function(global){
function create(options){
const el = id => document.getElementById(id);
const states = {pending:"待处理", processing:"处理中", completed:"已完成"};
let rows = [], selected = null, busy = false, loading = false;
let generation = 0, expanded = false, hasMore = false;
let activeQuery = "", latestSeen = null, unreadNew = 0, summaryLoading = false, overdueOnly = false;
let historyRows = [], historyHasMore = false, historyTicketId = null, historyLoading = false, historyRequest = 0, historyError = "";
const drafts = new Map();
const filter = el("ticket-filter"), note = el("ticket-note");
filter.value = "all";
async function refreshSummary(){
if(summaryLoading) return;
summaryLoading = true;
try{
const data = await options.request("/api/tickets/summary"+(latestSeen === null ? "" : "?after="+latestSeen));
el("ticket-pending-count").textContent = String(data.pending_count);
const overdueCount = Number(data.overdue_count || 0);
el("ticket-overdue-count").textContent = String(overdueCount);
el("ticket-overdue-toggle").setAttribute("aria-label", "查看超时留言 "+overdueCount+" 条,点击筛选");
el("ticket-section-count").textContent = "待处理 "+data.pending_count+" · 超时 "+overdueCount;
el("ticket-inbox-toggle").setAttribute("aria-label", "待处理留言 "+data.pending_count+" 条,点击查看");
el("ticket-summary-status").textContent = "";
if(latestSeen !== null && data.latest_id > latestSeen && data.new_count > 0){
unreadNew += data.new_count;
el("ticket-alert-text").textContent = "收到 "+unreadNew+" 条新留言";
el("ticket-alert").hidden = false;
if(options.notify) options.notify("新离线留言", "有 "+data.new_count+" 条新留言,请进入后台查看。");
}
latestSeen = Math.max(latestSeen || 0, data.latest_id);
}catch(error){
el("ticket-summary-status").textContent = "待处理数量暂时无法更新,稍后自动重试。";
}finally{ summaryLoading = false; }
}
function message(text, failed){
el("ticket-status-message").textContent = text || "";
el("ticket-status-message").classList.toggle("is-error", Boolean(failed));
}
const eventLabels = {submitted:"提交留言", claimed:"领取留言", note_updated:"修改备注", completed:"标记完成", legacy_snapshot:"升级时保留状态"};
function eventDetail(event){
if(event.action === "note_updated") return "旧备注:"+(event.old_note || "(空)")+"\n新备注:"+(event.new_note || "(空)");
if(event.action === "completed") return event.new_note ? "处理结果:"+event.new_note : "";
if(event.action === "legacy_snapshot") return "升级前的处理过程未记录,仅保留当时已知状态。";
return "";
}
function renderHistory(){
const list = el("ticket-history-list"), status = el("ticket-history-status");
el("ticket-history-more").hidden = !historyHasMore;
el("ticket-history-more").disabled = historyLoading;
if(historyLoading && !historyRows.length){ status.textContent = "正在加载处理记录…"; list.innerHTML = ""; return; }
if(!historyRows.length){ status.textContent = historyError || "暂无处理记录。"; list.innerHTML = ""; return; }
status.textContent = historyError;
list.innerHTML = historyRows.map(function(event){
const detail = eventDetail(event);
return '<li class="ticket-history-item"><span class="ticket-history-action">'+options.escape(eventLabels[event.action] || event.action)+'</span> · '+options.escape(event.actor_name || "系统")+'<span class="ticket-history-meta"> · '+options.escape(event.ctime || "")+'</span>'+
(detail ? '<span class="ticket-history-detail">'+options.escape(detail)+'</span>' : '')+'</li>';
}).join("");
}
async function loadHistory(reset){
if(!selected) return;
const id = selected.id, request = ++historyRequest;
if(reset || historyTicketId !== id){ historyRows = []; historyHasMore = false; historyTicketId = id; }
historyLoading = true; historyError = ""; renderHistory();
const before = !reset && historyRows.length ? "&before="+historyRows[historyRows.length-1].id : "";
try{
const data = await options.request("/api/tickets/"+id+"/events?limit=20"+before);
if(request !== historyRequest || !selected || selected.id !== id) return;
const list = Array.isArray(data) ? data : [];
historyRows = reset ? list : historyRows.concat(list.filter(function(item){ return !historyRows.some(function(row){ return row.id === item.id; }); }));
historyHasMore = list.length === 20;
el("ticket-history-status").textContent = "";
}catch(error){
if(request === historyRequest) historyError = "处理记录加载失败,请点击刷新记录重试。";
}finally{
if(request === historyRequest){ historyLoading = false; renderHistory(); }
}
}
function renderDetail(){
el("ticket-selected").hidden = !selected;
if(!selected) return;
const mine = selected.status === "processing" && selected.owner_username === options.username;
const draft = drafts.get(selected.id);
const conflict = Boolean(draft && draft.version !== selected.version);
el("ticket-content").textContent = selected.content;
el("ticket-meta").textContent = "#"+selected.id+" · "+selected.ctime+" · 电话:"+(selected.phone || "未提供");
el("ticket-owner").textContent = "负责人:"+(selected.owner_name || selected.owner_username || "未领取")+
(selected.owner_username === options.username ? "(我)" : "");
const overdue = el("ticket-overdue-hint");
if(selected.overdue_kind){
overdue.hidden = false;
overdue.textContent = (selected.overdue_kind === "pending" ? "尚未领取,已超时 " : "领取后处理已超时 ")+selected.overdue_minutes+" 分钟。";
}else if(selected.status === "processing" && !selected.claimed_at){
overdue.hidden = false;
overdue.textContent = "缺少领取时间,无法计算处理超时。";
}else{ overdue.hidden = true; overdue.textContent = ""; }
if(selected.status === "processing" && selected.claimed_at_estimated){
overdue.hidden = false;
overdue.textContent += " 旧留言按升级前最后更新时间估算领取时间。";
}
el("ticket-state").textContent = states[selected.status] || selected.status;
el("ticket-state").setAttribute("data-state", selected.status);
note.value = draft ? draft.text : (selected.note || "");
note.readOnly = !mine || busy;
el("ticket-claim").hidden = selected.status !== "pending";
el("ticket-save").hidden = !mine;
el("ticket-complete").hidden = !mine;
["ticket-claim", "ticket-save", "ticket-complete"].forEach(id => {
el(id).disabled = busy || (id !== "ticket-claim" && conflict);
});
el("ticket-server-note").hidden = !conflict;
el("ticket-server-note").textContent = conflict ? "这条留言已更新,当前保存的备注:"+(selected.note || "(空)") : "";
el("ticket-rebase").hidden = !conflict || !mine;
el("ticket-rebase").disabled = busy;
el("ticket-activity").textContent = selected.completed_at ? "完成时间:"+selected.completed_at :
(selected.updated_at ? "更新时间:"+selected.updated_at : "");
renderHistory();
}
function render(){
el("ticketList").innerHTML = rows.length ? rows.map(ticket =>
'<button type="button" class="ticket-item" data-ticket-id="'+ticket.id+'" aria-pressed="'+Boolean(selected && ticket.id === selected.id)+'"'+(busy ? ' disabled' : '')+'>'+
'<span class="ticket-summary">'+options.escape(ticket.content)+'</span><span class="ticket-row"><span><span class="ticket-state" data-state="'+options.escape(ticket.status)+'">'+options.escape(states[ticket.status] || ticket.status)+'</span>'+(ticket.overdue_kind ? '<span class="ticket-overdue-badge">'+options.escape(ticket.overdue_kind === "pending" ? "领取超时 "+ticket.overdue_minutes+" 分钟" : "处理超时 "+ticket.overdue_minutes+" 分钟")+'</span>' : '')+'</span><span>'+options.escape(ticket.owner_name || ticket.owner_username || "未领取")+'</span></span>'+
'<span class="ticket-time">'+options.escape(ticket.ctime)+'</span></button>'
).join("") : '<p class="ticket-empty">'+(activeQuery ? "没有匹配的留言,请更换关键词或状态" : "该状态下暂无留言")+'</p>';
el("ticket-more").hidden = !hasMore;
el("ticket-more").disabled = loading || busy;
el("ticket-refresh").disabled = loading || busy;
filter.disabled = busy;
el("ticket-search").disabled = busy;
el("ticket-search-submit").disabled = busy;
el("ticket-search-clear").hidden = !activeQuery;
el("ticket-search-clear").disabled = busy;
el("ticket-overdue-filter").disabled = busy;
renderDetail();
}
async function load(more, manual){
if(busy || (loading && !manual) || (expanded && !more && !manual)) return;
const current = ++generation;
loading = true;
el("ticket-refresh").disabled = true;
el("ticket-more").disabled = true;
const before = more && rows.length ? "&before="+rows[rows.length-1].id : "";
const previousId = selected && selected.id, previousVersion = selected && selected.version;
try{
const list = await options.request("/api/tickets?status="+filter.value+"&limit=20"+before+
(activeQuery ? "&q="+encodeURIComponent(activeQuery) : "")+(overdueOnly ? "&overdue=1" : ""));
if(current !== generation) return;
rows = more ? rows.concat(list.filter(item => !rows.some(row => row.id === item.id))) : list;
hasMore = list.length === 20;
expanded = Boolean(more);
if(selected){
const found = rows.find(item => item.id === selected.id);
if(found) selected = found;
else{
const id = selected.id;
const data = await options.request("/api/tickets/"+id);
if(current !== generation) return;
if(selected && selected.id === id) selected = data.ticket;
}
}else if(rows.length){ selected = rows[0]; }
if(current === generation){
render();
if(selected && (selected.id !== previousId || selected.version !== previousVersion || historyTicketId !== selected.id)) loadHistory(true);
}
}catch(error){
if(current === generation) message("留言加载失败,请点击刷新重试;未保存的备注仍保留。", true);
}finally{
if(current === generation){ loading = false; render(); }
}
}
function select(id){
if(busy) return;
const ticket = rows.find(row => row.id === Number(id));
if(!ticket) return;
selected = ticket;
message("");
render();
loadHistory(true);
}
async function update(action){
if(busy || !selected) return;
const ticket = selected;
const draft = drafts.get(ticket.id);
if(action !== "claim" && (ticket.status !== "processing" || ticket.owner_username !== options.username)) return;
if(action !== "claim" && draft && draft.version !== ticket.version){
message("留言已更新,请核对当前保存的备注后再处理。", true); return;
}
const text = note.value;
if(action === "completed" && !text.trim()){
message("请先填写处理结果,再标记完成。", true); note.focus(); return;
}
if(action !== "claim") drafts.set(ticket.id, {text:text, version:draft ? draft.version : ticket.version});
++generation;
loading = false;
busy = true;
render();
message(action === "claim" ? "正在领取…" : "正在保存…");
try{
const result = await options.request("/api/tickets/"+ticket.id+(action === "claim" ? "/claim" : ""), {
method: action === "claim" ? "POST" : "PATCH", headers:{"Content-Type":"application/json"},
body:JSON.stringify(action === "claim" ? {} : {version:draft ? draft.version : ticket.version,
note:text, status:action === "completed" ? "completed" : "processing"})
});
selected = result.ticket;
rows = rows.map(row => row.id === ticket.id ? result.ticket : row);
drafts.delete(ticket.id);
message(action === "claim" ? "已领取,由你负责跟进。" : action === "completed" ? "已完成,记录已保留。" : "备注已保存。");
}catch(error){
message(error.userMessage || "操作未确认成功,请刷新核对后重试;备注已保留。", true);
if(error.status === 409 || error.status === 403){
try{
const data = await options.request("/api/tickets/"+ticket.id);
selected = data.ticket;
rows = rows.map(row => row.id === ticket.id ? selected : row);
}catch(refreshError){ /* Keep the local draft and error until explicit retry. */ }
}
}finally{ busy = false; render(); loadHistory(true); await refreshSummary(); }
}
function search(){
if(busy) return;
const query = el("ticket-search").value.trim();
if(query.length > 100){ message("搜索关键词最多 100 字。", true); return; }
activeQuery = query;
selected = null; rows = []; expanded = false; hasMore = false;
message(""); render();
return load(false, true);
}
function viewPending(){
if(busy){ message("正在保存,请稍后查看新留言。"); return; }
unreadNew = 0;
el("ticket-alert").hidden = true;
filter.value = "pending";
overdueOnly = false; el("ticket-overdue-filter").checked = false;
el("ticket-search").value = "";
if(options.open) options.open();
return search();
}
note.addEventListener("input", function(){
if(!selected || note.readOnly) return;
const previous = drafts.get(selected.id);
drafts.set(selected.id, {text:note.value, version:previous ? previous.version : selected.version});
});
el("ticketList").addEventListener("click", function(event){
const button = event.target.closest("[data-ticket-id]");
if(button) select(button.getAttribute("data-ticket-id"));
});
filter.addEventListener("change", function(){ selected = null; rows = []; expanded = false; message(""); render(); load(false, true); });
el("ticket-search-form").addEventListener("submit", function(event){ event.preventDefault(); return search(); });
el("ticket-search").addEventListener("keydown", function(event){
if(event.key === "Enter" && event.isComposing) event.preventDefault();
});
el("ticket-search-clear").onclick = function(){ el("ticket-search").value = ""; return search(); };
el("ticket-inbox-toggle").onclick = viewPending;
el("ticket-alert-view").onclick = viewPending;
el("ticket-overdue-toggle").onclick = function(){
if(busy) return;
filter.value = "all"; overdueOnly = true; el("ticket-overdue-filter").checked = true;
el("ticket-search").value = "";
if(options.open) options.open("ticket-overdue-toggle");
return search();
};
el("ticket-overdue-filter").addEventListener("change", function(){
overdueOnly = Boolean(this.checked); selected = null; rows = []; expanded = false;
return load(false, true);
});
el("ticket-history-reload").onclick = function(){ return loadHistory(true); };
el("ticket-history-more").onclick = function(){ if(!historyLoading && historyHasMore) return loadHistory(false); };
el("ticket-refresh").onclick = function(){ return load(false, true); };
el("ticket-more").onclick = function(){ return load(true); };
el("ticket-claim").onclick = function(){ return update("claim"); };
el("ticket-save").onclick = function(){ return update("save"); };
el("ticket-complete").onclick = function(){ return update("completed"); };
el("ticket-rebase").onclick = function(){
if(!selected || busy) return;
const draft = drafts.get(selected.id);
if(draft){ draft.version = selected.version; render(); message("已保留你的草稿,请确认内容后保存。"); }
};
return {load:load, select:select, refreshSummary:refreshSummary};
}
global.AdminTickets = {create:create};
})(window);
+1 -3
View File
@@ -42,9 +42,7 @@ async function saveNote(){
async function transferSession(){
if(!curSid || closingSid || sendingSid) return;
if(!await SupportUI.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。', {
title:'请确认转接', confirmText:'转接', cancelText:'取消'
})) return;
if(!window.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。')) return;
const sid = curSid;
closingSid = sid;
updateComposer();
-168
View File
@@ -1,168 +0,0 @@
// Customer attachment gestures and image viewing; upload ownership stays on the server.
var ChatAttachments = {
bindUpload(options){
const zone = options.zone, hint = options.hint;
let dragDepth = 0;
function hasFiles(event){
return event.dataTransfer && Array.from(event.dataTransfer.types || []).includes('Files');
}
function clearDrag(){ dragDepth = 0; hint.hidden = true; }
function hasFolder(data){
return Array.from(data.items || []).some(function(item){
const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
return entry && entry.isDirectory;
});
}
function accept(files){
if(!files.length) return;
if(files.length !== 1){ options.status(options.text('singleAttachment'), true); return; }
options.upload(files[0]);
}
options.input.addEventListener('paste', function(event){
const data = event.clipboardData;
if(!data) return;
if(hasFolder(data)){
event.preventDefault();
options.status(options.text('folderAttachment'), true);
return;
}
let files = Array.from(data.files || []);
if(!files.length){
files = Array.from(data.items || []).filter(item=>item.kind === 'file').map(item=>item.getAsFile()).filter(Boolean);
}
if(!files.length) return;
event.preventDefault();
accept(files);
});
zone.addEventListener('dragenter', function(event){
if(!hasFiles(event)) return;
event.preventDefault();
dragDepth += 1;
hint.hidden = false;
});
zone.addEventListener('dragover', function(event){
if(!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
zone.addEventListener('dragleave', function(event){
if(!dragDepth) return;
dragDepth = Math.max(0, dragDepth - 1);
if(!dragDepth) clearDrag();
});
zone.addEventListener('drop', function(event){
clearDrag();
if(!hasFiles(event)) return;
event.preventDefault();
if(hasFolder(event.dataTransfer)){
options.status(options.text('folderAttachment'), true);
return;
}
accept(Array.from(event.dataTransfer.files || []));
});
// Dropping outside the chat must not navigate away and lose a draft.
document.addEventListener('dragover', function(event){ if(hasFiles(event)) event.preventDefault(); });
document.addEventListener('drop', function(event){
clearDrag();
if(hasFiles(event)) event.preventDefault();
});
document.addEventListener('dragend', clearDrag);
window.addEventListener('blur', clearDrag);
return {clear:clearDrag};
},
createViewer(options){
let active = null;
function close(){ if(active) active.close(); }
function open(source, trigger){
if(source.hidden || source.dataset.unavailable) return;
close();
const dialog = document.createElement('dialog');
dialog.className = 'image-viewer';
dialog.setAttribute('aria-label', options.text('imagePreview'));
const header = document.createElement('div');
header.className = 'image-viewer-header';
const title = document.createElement('h2');
title.textContent = options.text('imagePreview');
header.appendChild(title);
const controls = document.createElement('div');
controls.className = 'image-viewer-controls';
header.appendChild(controls);
const stage = document.createElement('div');
stage.className = 'image-viewer-stage';
const image = document.createElement('img');
image.alt = source.alt || options.text('imagePreview');
const status = document.createElement('p');
status.className = 'image-viewer-status';
status.setAttribute('role', 'status');
status.textContent = options.text('imageLoading');
stage.appendChild(image);
stage.appendChild(status);
dialog.appendChild(header);
dialog.appendChild(stage);
let scale = 1, loaded = false, finished = false;
function button(key, action){
const control = document.createElement('button');
control.type = 'button';
control.textContent = options.text(key);
control.setAttribute('aria-label', options.text(key));
control.onclick = action;
controls.appendChild(control);
return control;
}
const smaller = button('imageZoomOut', ()=>zoom(scale - .5));
const larger = button('imageZoomIn', ()=>zoom(scale + .5));
const fit = button('imageFit', ()=>zoom(1));
const dismiss = button('imageClose', ()=>dialog.close());
function zoom(next){
scale = Math.max(1, Math.min(3, next));
smaller.disabled = !loaded || scale <= 1;
larger.disabled = !loaded || scale >= 3;
fit.disabled = !loaded;
if(!loaded) return;
const ratio = Math.min(1, Math.max(1, stage.clientWidth - 32) / image.naturalWidth,
Math.max(1, stage.clientHeight - 32) / image.naturalHeight);
image.style.width = Math.round(image.naturalWidth * ratio * scale) + 'px';
image.style.height = Math.round(image.naturalHeight * ratio * scale) + 'px';
}
function resized(){ zoom(scale); }
function cleanup(){
if(finished) return;
finished = true;
window.removeEventListener('resize', resized);
dialog.remove();
if(active === dialog){
active = null;
if(trigger.isConnected) trigger.focus({preventScroll:true});
}
}
dialog.addEventListener('close', cleanup);
dialog.addEventListener('cancel', function(event){ event.preventDefault(); dialog.close(); });
dialog.addEventListener('click', function(event){ if(event.target === dialog) dialog.close(); });
image.onload = function(){ loaded = true; status.hidden = true; zoom(1); };
image.onerror = function(){
loaded = false;
image.hidden = true;
status.hidden = false;
status.textContent = options.text('attachmentUnavailable');
zoom(1);
};
zoom(1);
document.body.appendChild(dialog);
active = dialog;
dialog.showModal();
dismiss.focus({preventScroll:true});
image.src = source.currentSrc || source.src;
window.addEventListener('resize', resized);
}
options.container.addEventListener('click', function(event){
if(event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button > 0) return;
const trigger = event.target.closest('a, .image-preview-trigger');
if(!trigger) return;
const source = trigger.querySelector('img');
if(!source || (!source.classList.contains('message-image') && !trigger.classList.contains('image-preview-trigger'))) return;
event.preventDefault();
open(source, trigger);
});
return {close:close};
}
};
+174 -226
View File
@@ -6,6 +6,8 @@ const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status");
const voiceFeedback = document.getElementById('voice-feedback');
let voiceFeedbackTimer = null;
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
let draftTimer = null;
let draftSnapshot = null;
@@ -41,7 +43,9 @@ let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let customerLogin = null;
let customerOrders = null;
let orderLookup = null;
let lookupRequestVersion = 0;
let customerRecovery = null;
let creatingConv = null;
let sendPreparing = false;
let conversationVersion = 0;
@@ -55,11 +59,7 @@ let humanClosing = false;
window.humanSid = 0;
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
const imageViewer = ChatAttachments.createViewer({container:document.querySelector('.main-chat'), text:t});
document.getElementById('notifications-toggle').onclick = async function(){
await SupportUI.enableNotifications(this);
refreshNotificationLabel();
};
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function setComposerStatus(message, isError){
composerStatus.textContent = message || "";
@@ -67,6 +67,21 @@ function setComposerStatus(message, isError){
composerStatus.classList.toggle("is-error", Boolean(isError));
}
function setVoiceStatus(message,isError){
if(voiceFeedbackTimer!==null) clearTimeout(voiceFeedbackTimer);
voiceFeedbackTimer=null;
if(voiceFeedback.textContent!==message) voiceFeedback.textContent=message || '';
voiceFeedback.hidden=!message;
voiceFeedback.classList.toggle('is-error',Boolean(isError));
if(message){
voiceFeedbackTimer=setTimeout(function(){
voiceFeedback.hidden=true;
voiceFeedback.textContent='';
voiceFeedbackTimer=null;
},4000);
}
}
async function requestJson(path, options){
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
@@ -83,7 +98,6 @@ async function requestJson(path, options){
}
function clearAttachment(){
imageViewer.close();
uploadVersion += 1;
if(uploadController){
uploadController.abort();
@@ -106,14 +120,8 @@ function scrollChatToEnd(force){
uploadBtn.onclick = ()=> uploadInput.click();
uploadInput.onchange = function(){
const files = Array.from(uploadInput.files || []);
uploadInput.value = '';
if(files.length > 1){ setComposerStatus(t('singleAttachment'), true); return; }
uploadAttachment(files[0]);
};
function uploadAttachment(f){
if(!f) return;
if(uploadPending){ setComposerStatus(t('uploading')); return; }
const f = uploadInput.files[0];
if(!f || uploadPending) return;
const version = ++uploadVersion;
const fd = new FormData();
fd.append("file", f);
@@ -125,7 +133,7 @@ function uploadAttachment(f){
const controller = new AbortController();
uploadController = controller;
const timer = setTimeout(function(){ controller.abort(); }, 15000);
return fetch("/api/upload", {method:"POST", body:fd, signal:controller.signal})
fetch("/api/upload", {method:"POST", body:fd, signal:controller.signal})
.then(async function(response){
const data = await response.json();
if(!response.ok || !data.ok) throw new Error(data.error || t("uploadFailed"));
@@ -144,13 +152,7 @@ function uploadAttachment(f){
var image = document.createElement("img");
image.src = data.url;
image.alt = data.name || "待发送图片";
const preview = document.createElement('button');
preview.type = 'button';
preview.className = 'image-preview-trigger';
preview.dataset.imagePreview = '1';
preview.setAttribute('aria-label', t('imagePreview'));
preview.appendChild(image);
prev.appendChild(preview);
prev.appendChild(image);
}else{
var filename = document.createElement("span");
filename.className = "attachment-file-icon";
@@ -189,9 +191,8 @@ function uploadAttachment(f){
uploadBtn.textContent = "+";
}
});
}
const attachmentGestures = ChatAttachments.bindUpload({zone:document.querySelector('.main-chat'), input:inputDom,
hint:document.getElementById('attachment-drop-hint'), upload:uploadAttachment, status:setComposerStatus, text:t});
uploadInput.value = "";
};
const leftHistoryWrap = document.getElementById("historyWrap");
const recentChatWrap = document.getElementById("recentChatWrap");
const clearAllBtn = document.querySelector('.clear-all-btn');
@@ -238,7 +239,6 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
btn.onclick = function(){
idx = (idx + 1) % SIZES.length;
apply();
labelControl(btn, t('fontSize') + ': ' + btn.textContent);
try{ localStorage.setItem(KEY, idx); }catch(e){}
};
}
@@ -248,45 +248,20 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
const I18N = {
zh: {
appTitle: "柏悦物流智能助手",
customerAccount: "客户登录",
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
myOrders: "我的订单", ordersRefresh: "刷新", ordersBack: "← 返回订单列表",
ordersMore: "加载更多订单", ordersRetry: "重试", ordersTrack: "查看轨迹",
ordersLoading: "正在加载订单…", ordersEmpty: "当前账号暂无可查看的订单。",
ordersFailed: "订单加载失败,请稍后重试。",
ordersNetworkError: "网络连接失败,请检查网络后重试。", ordersTimeout: "订单请求超时,请重试。",
ordersUnavailable: "订单不存在或当前账号无权查看,请返回订单列表并刷新。",
ordersForbidden: "当前账号无权查看,请联系人工客服核对订单权限。",
ordersRateLimited: "操作过于频繁,请稍后再试。", ordersServerError: "订单服务暂时不可用,请稍后重试。",
ordersInvalidRequest: "订单请求无效,请点击订单区的“刷新”重新加载。",
ordersInvalidResponse: "订单数据暂时无法读取,请重试;若仍失败,请联系人工客服。",
ordersOverview: "订单概况", ordersGroup_all: "全部", ordersGroup_processing: "履约中",
ordersGroup_completed: "已完成", ordersGroup_pending: "待履约", ordersGroup_cancelled: "已取消", ordersGroup_other: "其他",
ordersProgress: "已加载 {loaded} / {total} 笔", ordersFilterEmpty: "当前状态下暂无订单。",
orderStatus_DRAFT: "草稿", orderStatus_SUBMITTED: "已提交", orderStatus_APPROVED: "审核通过",
orderStatus_ACCEPTED: "已接单", orderStatus_REJECTED: "审核驳回", orderStatus_PROCESSING: "履约中",
orderStatus_COMPLETED: "头程订单已完成", orderStatus_CANCELLED: "已取消",
orderMode_SEA: "海运", orderMode_AIR: "空运", orderMode_RAIL: "铁路", orderMode_TRUCK: "卡航",
customerLogoutFailed: "退出失败,请重试。",
lookupTitle: "查入仓码", lookupOrderTitle: "查询订单", lookupPlaceholder: "请输入入仓码",
lookupOrderPlaceholder: "请输入订单号", lookupSubmit: "查询轨迹", lookupSending: "查询中…",
lookupInvalid: "请输入完整的入仓码或订单号。", lookupFailed: "查询未发送成功,请重试。",
lookupBusy: "请等待当前操作完成后再查询。", lookupAIOnly: "请先返回 AI 问答,再查询订单轨迹。",
lookupAction: "查询或复制",
newChat: "新建对话",
clearAll: "清空全部历史",
history: "历史对话",
searchPh: "搜索历史对话...",
historySearch: "搜索历史对话", closeSidebar: "关闭历史侧栏", home: "柏悦物流首页",
chatLog: "聊天内容", inputLabel: "输入问题或入仓码",
uploadAttachment: "发送附件", chooseEmoji: "选择表情", fontSize: "字体大小",
moreSettings: "更多设置", notificationSetting: "消息通知", themeSetting: "明暗主题",
singleAttachment: "一次只能添加一个附件,请分次发送。", dropAttachment: "松开鼠标添加附件",
folderAttachment: "暂不支持上传文件夹,请选择里面的单个文件。",
imagePreview: "图片预览", imageZoomIn: "放大", imageZoomOut: "缩小", imageFit: "适应窗口",
imageClose: "关闭", imageLoading: "正在加载图片…",
enableNotifications: "开启浏览器通知", notificationsEnabled: "浏览器通知已开启",
pinConversation: "置顶对话", unpinConversation: "取消置顶",
confirmTitle: "请确认", confirmButton: "确定", promptTitle: "请输入内容",
trackingPrompt: "请输入运单号:",
confirmClear: "确定清空全部历史对话吗?无法恢复!",
confirmDel: "确定要删除这条历史对话吗?",
emptySearch: "没有匹配的历史对话",
@@ -297,7 +272,6 @@ const I18N = {
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:",
offlineInputLabel: "留言内容",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
humanReply: "客服回复",
@@ -333,11 +307,12 @@ const I18N = {
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeEyebrow: "中欧 · 英国物流服务",
welcomeEyebrow: "中美物流服务",
serviceTag: "物流咨询服务",
footerTrack: "查入仓码",
footerQuote: "专线报价",
footerException: "异常处理",
footerQuote: "美国报价",
footerException: "异常订单",
exceptionQuery: "我有多少个异常订单?",
footerHuman: "联系人工",
inputShort: "输入入仓码…",
uploading: "附件正在上传,请稍候…",
@@ -382,53 +357,28 @@ const I18N = {
langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部",
quick: [
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
"按入仓码查轨迹", "美国海运专线报价", "查询异常订单", "转接人工客服"
],
hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
"如何查询物流轨迹?", "美国海运时效是多久?", "我有多少个异常订单?", "各个轨迹节点分别有多少订单?"
]
},
en: {
appTitle: "Baiyue Logistics AI Assistant",
customerAccount: "Customer sign in",
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.",
myOrders: "My orders", ordersRefresh: "Refresh", ordersBack: "← Back to orders",
ordersMore: "Load more orders", ordersRetry: "Retry", ordersTrack: "View tracking",
ordersLoading: "Loading orders…", ordersEmpty: "No orders are available for this account.",
ordersFailed: "Could not load orders. Please try again later.",
ordersNetworkError: "Connection failed. Check your network and retry.", ordersTimeout: "The order request timed out. Please retry.",
ordersUnavailable: "This order does not exist or you cannot access it. Return to the order list and refresh.",
ordersForbidden: "You cannot access these orders. Contact support to check your permissions.",
ordersRateLimited: "Too many requests. Please wait and try again.", ordersServerError: "The order service is temporarily unavailable. Please try again later.",
ordersInvalidRequest: "The order request is invalid. Use Refresh in the orders panel to reload.",
ordersInvalidResponse: "Could not read the order data. Retry, or contact support if the problem continues.",
ordersOverview: "Order overview", ordersGroup_all: "All", ordersGroup_processing: "In progress",
ordersGroup_completed: "Completed", ordersGroup_pending: "Pending", ordersGroup_cancelled: "Cancelled", ordersGroup_other: "Other",
ordersProgress: "Loaded {loaded} of {total} orders", ordersFilterEmpty: "No orders with this status.",
orderStatus_DRAFT: "Draft", orderStatus_SUBMITTED: "Submitted", orderStatus_APPROVED: "Approved",
orderStatus_ACCEPTED: "Accepted", orderStatus_REJECTED: "Rejected", orderStatus_PROCESSING: "In progress",
orderStatus_COMPLETED: "First leg completed", orderStatus_CANCELLED: "Cancelled",
orderMode_SEA: "Sea", orderMode_AIR: "Air", orderMode_RAIL: "Rail", orderMode_TRUCK: "Truck",
customerLogoutFailed: "Could not sign out. Please try again.",
lookupTitle: "Track by inbound code", lookupOrderTitle: "Look up an order", lookupPlaceholder: "Enter an inbound code",
lookupOrderPlaceholder: "Enter an order number", lookupSubmit: "Track shipment", lookupSending: "Sending…",
lookupInvalid: "Enter a complete inbound code or order number.", lookupFailed: "Could not send the lookup. Please retry.",
lookupBusy: "Wait for the current action to finish before looking up an order.", lookupAIOnly: "Return to AI chat before looking up an order.",
lookupAction: "Look up or copy",
newChat: "New Chat",
clearAll: "Clear All History",
history: "History",
searchPh: "Search history...",
historySearch: "Search conversation history", closeSidebar: "Close history sidebar", home: "Baiyue Logistics home",
chatLog: "Conversation messages", inputLabel: "Enter a question or inbound code",
uploadAttachment: "Attach a file", chooseEmoji: "Choose an emoji", fontSize: "Text size",
moreSettings: "More settings", notificationSetting: "Notifications", themeSetting: "Appearance",
singleAttachment: "Add one attachment at a time. Please send files separately.", dropAttachment: "Drop to attach a file",
folderAttachment: "Folders cannot be uploaded. Please select an individual file inside the folder.",
imagePreview: "Image preview", imageZoomIn: "Zoom in", imageZoomOut: "Zoom out", imageFit: "Fit to window",
imageClose: "Close", imageLoading: "Loading image…",
enableNotifications: "Enable browser notifications", notificationsEnabled: "Browser notifications enabled",
pinConversation: "Pin conversation", unpinConversation: "Unpin conversation",
confirmTitle: "Please confirm", confirmButton: "Confirm", promptTitle: "Enter details",
trackingPrompt: "Enter the tracking number:",
confirmClear: "Clear all conversation history? This cannot be undone.",
confirmDel: "Delete this conversation?",
emptySearch: "No matching conversations",
@@ -439,7 +389,6 @@ const I18N = {
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:",
offlineInputLabel: "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.",
humanReply: "Support reply",
@@ -475,11 +424,12 @@ const I18N = {
retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.",
welcomeEyebrow: "EUROPE & UK LOGISTICS",
welcomeEyebrow: "CHINA–US LOGISTICS",
serviceTag: "Logistics support",
footerTrack: "Inbound lookup",
footerQuote: "Get a quote",
footerException: "Parcel issue",
footerQuote: "US quote",
footerException: "Exception orders",
exceptionQuery: "How many exception orders do I have?",
footerHuman: "Human support",
inputShort: "Inbound code…",
uploading: "Uploading your attachment…",
@@ -526,11 +476,11 @@ const I18N = {
langTip: "Switch language (Ctrl+L)",
toBottom: "Back to bottom",
quick: [
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
"Track by inbound code", "US sea freight quote", "View exception orders", "Talk to a human agent"
],
hotList: [
"How do I track my shipment?", "How long does Europe sea freight take?",
"My parcel shows delivered but I didn't get it", "How do I file a claim?"
"How do I track my shipment?", "How long does US sea freight take?",
"How many exception orders do I have?", "How many orders are at each tracking stage?"
]
}
};
@@ -539,22 +489,14 @@ function t(key){
return (I18N[LANG] || I18N.zh)[key];
}
function labelControl(element, label){
if(!element) return;
element.title = label;
element.setAttribute('aria-label', label);
}
function refreshNotificationLabel(){
const button = document.getElementById('notifications-toggle');
labelControl(button, t(button.getAttribute('aria-pressed') === 'true' ? 'notificationsEnabled' : 'enableNotifications'));
}
function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG];
if(customerLogin) customerLogin.render();
if(customerOrders) customerOrders.render();
if(orderLookup) orderLookup.render();
document.querySelectorAll('.order-reference').forEach(function(button){
button.setAttribute('aria-label',t('lookupAction')+' '+t(button.dataset.referenceLabel)+' '+button.textContent);
});
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
document.querySelectorAll("[data-i18n]").forEach(function(element){
const value = dict[element.dataset.i18n];
@@ -567,34 +509,21 @@ function applyLanguage(lang){
const langBtn = document.getElementById("lang-toggle");
if(langBtn){
langBtn.textContent = LANG === "en" ? "EN" : "中";
labelControl(langBtn, dict.langTip);
langBtn.title = dict.langTip;
}
const themeBtn = document.getElementById("theme-toggle");
labelControl(themeBtn, dict.themeTip);
const fontBtn = document.getElementById('font-toggle');
if(fontBtn) labelControl(fontBtn, dict.fontSize + ': ' + fontBtn.textContent);
refreshNotificationLabel();
labelControl(document.getElementById('settings-toggle'), dict.moreSettings);
document.getElementById('settings-panel').setAttribute('aria-label', dict.moreSettings);
document.querySelectorAll('[data-image-preview]').forEach(function(element){ element.setAttribute('aria-label', dict.imagePreview); });
labelControl(uploadBtn, dict.uploadAttachment);
labelControl(document.getElementById('emoji-toggle'), dict.chooseEmoji);
labelControl(document.querySelector('.sidebar-backdrop'), dict.closeSidebar);
labelControl(document.querySelector('.left-panel .brand'), dict.home);
document.querySelector('.left-panel').setAttribute('aria-label', dict.history);
chatDom.setAttribute('aria-label', dict.chatLog);
inputDom.setAttribute('aria-label', dict.inputLabel);
if(themeBtn) themeBtn.title = dict.themeTip;
document.querySelectorAll(".new-chat-btn").forEach(function(b){ b.textContent = dict.newChat; });
document.querySelectorAll(".clear-all-btn").forEach(function(b){ b.textContent = dict.clearAll; });
document.querySelectorAll(".panel-title").forEach(function(e){ e.textContent = dict.history; });
const hs = document.getElementById("history-search");
if(hs){ hs.placeholder = dict.searchPh; hs.setAttribute('aria-label', dict.historySearch); }
if(hs) hs.placeholder = dict.searchPh;
if(inputDom) inputDom.placeholder = window.innerWidth > 900 ? dict.inputPh : dict.inputShort;
sendBtn.textContent = window.__busy ? dict.stop : dict.send;
const tb = document.getElementById("to-bottom");
labelControl(tb, dict.toBottom);
if(tb) tb.title = dict.toBottom;
const tg = document.querySelector(".toggle-divider");
if(tg) labelControl(tg, tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse);
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
if(voiceInput) voiceInput.refresh();
@@ -621,7 +550,6 @@ function applyLanguage(lang){
document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry;
});
document.querySelectorAll('.customer-login-cta').forEach(function(button){ button.textContent = dict.customerQuery; });
}
(function initLang(){
@@ -724,7 +652,8 @@ function welcomeHTML(){
const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
const query = index === 2 ? t('exceptionQuery') : question;
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(query)}"`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join("");
return `
@@ -752,9 +681,9 @@ function welcomeHTML(){
// 清空全部历史
if(clearAllBtn){
clearAllBtn.onclick = async function(){
clearAllBtn.onclick = function(){
if(openingConv === 'clearing') return;
if(!await SupportUI.confirm(t("confirmClear"), {title:t('confirmTitle'), confirmText:t('confirmButton'), cancelText:t('customerCancel')})) return;
if(!confirm(t("confirmClear"))) return;
const version = resetConversation(null);
openingConv = 'clearing';
chatDom.innerHTML = welcomeHTML();
@@ -862,7 +791,7 @@ function renderSideHistory(){
const pinBtn = document.createElement("button");
pinBtn.className = "del-history-btn";
pinBtn.innerText = isPinned ? "📍" : "📌";
labelControl(pinBtn, t(isPinned ? 'unpinConversation' : 'pinConversation'));
pinBtn.title = isPinned ? "取消置顶" : "置顶";
pinBtn.style.marginRight = "4px";
pinBtn.onclick = (e)=>{
e.stopPropagation();
@@ -876,9 +805,9 @@ function renderSideHistory(){
delBtn.className = "del-history-btn";
delBtn.innerText = "×";
delBtn.setAttribute("aria-label", LANG === "en" ? "Delete conversation" : "删除对话");
delBtn.onclick = async (e)=>{
delBtn.onclick = (e)=>{
e.stopPropagation();
if(await SupportUI.confirm(t("confirmDel"), {title:t('confirmTitle'), confirmText:t('confirmButton'), cancelText:t('customerCancel')})){
if(confirm(t("confirmDel"))){
const version = conversationVersion;
return requestJson(`/api/del_history?id=${item.id}`, {method:"DELETE"})
.then(data=>{
@@ -964,10 +893,10 @@ function ensureConv(){
}
function resetConversation(id){
imageViewer.close();
attachmentGestures.clear();
setVoiceStatus('');
flushDraft();
emojiPicker.close();
if(orderLookup) orderLookup.cancel();
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
@@ -987,7 +916,7 @@ function resetConversation(id){
// 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null;
function openConversation(id){
function openConversation(id, afterLoad){
if(openingConv === 'clearing') return;
if(openingConv === id) return; // 防连点
const version = resetConversation(id);
@@ -1005,6 +934,7 @@ function openConversation(id){
chatDom.innerHTML = "";
if(!msgs.length){
chatDom.innerHTML = welcomeHTML();
if(afterLoad) afterLoad(false);
return;
}
let previousQuestion = '';
@@ -1027,10 +957,12 @@ function openConversation(id){
});
// 等渲染完再滚到底部
requestAnimationFrame(function(){ scrollChatToEnd(true); });
if(afterLoad) afterLoad(true);
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = 'failed';
setComposerStatus(t('historyLoadFailed'), true);
if(afterLoad) afterLoad(false);
});
}
@@ -1047,7 +979,16 @@ function fillTrackCard(el, card){
no.className = "track-no";
const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(inbound ? inbound.value : card.no);
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>";
if(isOrder){
const reference=document.createElement('button');
const value=String(inbound ? inbound.value : card.no), version=conversationVersion;
reference.type='button';reference.className='order-reference';reference.textContent=value;
reference.dataset.referenceLabel=numberLabel;
reference.setAttribute('aria-label',t('lookupAction')+' '+t(numberLabel)+' '+value);
reference.onclick=function(){if(version === conversationVersion) return showOrderLookup(value,inbound ? 'inbound' : 'order',reference);};
no.appendChild(reference);
}else no.innerHTML += escHtml(card.no);
const st = document.createElement("div");
st.className = "track-status";
st.textContent = card.status;
@@ -1227,12 +1168,33 @@ window.askNewTopic = function(words){
return doSend();
}
window.sendOrder = async function(){
const no = await SupportUI.prompt(t('trackingPrompt'), {title:t('promptTitle'), confirmText:t('confirmButton'), cancelText:t('customerCancel'), inputLabel:t('trackingNo')});
if(no && no.trim()){
inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹";
doSend();
}
window.sendOrder = function(){ return showOrderLookup(); };
function lookupBlocked(){
if(window.humanSid || humanRequestToken || humanClosing || humanSendingToken) return t('lookupAIOnly');
if(openingConv !== null) return t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading');
if(uploadPending || sendPreparing || window.__busy) return t('lookupBusy');
return '';
}
function sameCustomerContext(version,user){
return version === conversationVersion || (customerRecovery &&
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
(!user || user.id === customerRecovery.customerId));
}
async function showOrderLookup(reference,kind,source){
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
const version=conversationVersion;
const requestVersion=++lookupRequestVersion;
function isCurrent(user){return requestVersion === lookupRequestVersion && sameCustomerContext(version,user);}
return customerLogin.require(function(user){
if(!isCurrent(user)) return;
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
orderLookup.open(reference,kind,source);
},isCurrent);
}
function updateHumanMode(){
@@ -1286,13 +1248,14 @@ window.showHuman = async function(){
if(humanRequestToken !== token || token.version !== conversationVersion) return;
const data = await humanRequest('/api/visitor/human/request', {conv_id:cid});
if(humanRequestToken !== token || token.version !== conversationVersion) return;
if(data.closed){ await leaveOfflineMessage(); return; }
if(data.closed) return await leaveOfflineMessage();
if(!data.ok || !data.session_id) throw new Error('Human session failed');
window.humanSid = data.session_id;
humanConvId = cid;
humanStatus = 'waiting';
addMsg(t('humanWaiting'), false);
startHumanPoll();
return true;
}catch(error){
if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true);
}finally{
@@ -1352,12 +1315,12 @@ async function sendHumanMessage(content){
async function leaveOfflineMessage(){
const version = conversationVersion;
addMsg(t('humanHours'), false);
const message = (await SupportUI.prompt(t('offlinePrompt'), {title:t('promptTitle'), confirmText:t('confirmButton'), cancelText:t('customerCancel'), inputLabel:t('offlineInputLabel'), multiline:true}) || '').trim();
const message = (window.prompt(t('offlinePrompt')) || '').trim();
if(!message) return;
try{
const data = await humanRequest('/api/ticket', {content:message, phone:''});
if(!data.ok) throw new Error('Ticket failed');
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
if(version === conversationVersion){ addMsg(t('offlineSaved'), false); return true; }
}catch(error){
if(version !== conversationVersion) return;
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
@@ -1460,8 +1423,6 @@ function addMsg(content, isUser, authorRole, timeStr){
link.href = imgUrl;
link.target = "_blank";
link.rel = "noopener";
link.dataset.imagePreview = '1';
link.setAttribute('aria-label', t('imagePreview'));
const image = document.createElement("img");
image.src = imgUrl;
image.alt = "附件图片";
@@ -1544,10 +1505,33 @@ function attachCustomerLogin(bubble, question){
button.type = 'button'; button.className = 'customer-login-cta';
button.textContent = t('customerQuery');
const version = conversationVersion;
const convId = currentConvId;
function sameContext(user){
return sameCustomerContext(version,user);
}
button.onclick = function(){
customerLogin.require(async function(){
if(version === conversationVersion && question) await retryAnswer(bubble, question);
});
if(version !== conversationVersion) return;
customerLogin.require(async function(user){
if(!sameContext(user) || !question) return;
if(version === conversationVersion){ await retryAnswer(bubble, question); return; }
// Expiry cleared the private screen. Re-open it through the owner-checked
// endpoint before continuing a short followup under the same customer.
const newerDraft = inputDom.value;
customerRecovery = null;
let restoredDraft;
openConversation(convId, function(loaded){
const draft = inputDom.value !== restoredDraft ? inputDom.value : newerDraft;
if(!loaded){
resetConversation(null); chatDom.innerHTML=welcomeHTML();
setComposerStatus(t('historyLoadFailed'), true);
}
inputDom.value=draft; autoGrow();
if(!loaded) return;
addMsg(question, true);
retryAnswer(bubble, question);
});
restoredDraft = inputDom.value;
}, sameContext);
};
bubble.appendChild(button);
}
@@ -1581,27 +1565,7 @@ function attachActions(bubble, rawText, question, showRetry){
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
};
if(navigator.clipboard && navigator.clipboard.writeText){
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
}else{
return fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.readOnly = true;
ta.style.position = "fixed";
ta.style.opacity = "0";
const previousFocus = document.activeElement;
document.body.appendChild(ta);
let copied = false;
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
finally{
ta.remove();
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
}
done(copied);
}
return SupportUI.copyText(plain).then(done);
};
bar.appendChild(copyBtn);
@@ -1687,7 +1651,8 @@ function renderFollowups(bubble){
bubble.appendChild(wrap);
}
async function doSend(retryText){
async function doSend(retryText, showQuestion){
if(showQuestion && lookupBlocked()) return false;
if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; }
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
@@ -1710,6 +1675,15 @@ async function doSend(retryText){
if(window.humanSid) stopHumanMode();
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
const humanCommand = sendWord.replace(/[\s,,。.!!??]/g, '');
if(!imgUrl && /^(?:(?:请|麻烦)?(?:帮我)?)?(?:转(?:接|到)?|联系|找)(?:一下|个)?人工(?:客服)?(?:吧|谢谢)?$/.test(humanCommand)){
const version = conversationVersion;
const connected = await window.showHuman();
if(connected && version === conversationVersion && !isRetry && inputDom.value === draft){
inputDom.value = ''; autoGrow(); flushDraft();
}
return !!connected;
}
if(cancelActive){ cancelActive(); cancelActive = null; }
sendPreparing = true;
@@ -1723,9 +1697,9 @@ async function doSend(retryText){
}finally{
if(version === conversationVersion) sendPreparing = false;
}
if(version !== conversationVersion || humanRequestToken) return;
if(version !== conversationVersion || humanRequestToken || (showQuestion && window.humanSid)) return;
if(!isRetry || showQuestion) addMsg(fullContent, true);
if(!isRetry){
addMsg(fullContent, true);
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
if(attachmentVersion === uploadVersion) clearAttachment();
focusComposer();
@@ -1994,44 +1968,13 @@ function isNarrow(){
return window.matchMedia("(max-width: 900px)").matches;
}
const settingsToggle = document.getElementById('settings-toggle');
const settingsPanel = document.getElementById('settings-panel');
let settingsOpen = false;
function setSettingsOpen(open, restoreFocus){
settingsOpen = isNarrow() && open;
settingsPanel.hidden = isNarrow() && !settingsOpen;
settingsToggle.setAttribute('aria-expanded', String(settingsOpen));
if(restoreFocus) settingsToggle.focus({preventScroll:true});
}
setSettingsOpen(false);
settingsToggle.onclick = function(){
setSettingsOpen(!settingsOpen);
if(settingsOpen){
sideOpen = false;
renderSidebar();
document.getElementById('notifications-toggle').focus({preventScroll:true});
}
};
document.addEventListener('pointerdown', function(event){
if(settingsOpen && !settingsPanel.contains(event.target) && !settingsToggle.contains(event.target)) setSettingsOpen(false);
});
settingsPanel.addEventListener('focusout', function(event){
if(settingsOpen && !settingsPanel.contains(event.relatedTarget) && event.relatedTarget !== settingsToggle) setSettingsOpen(false);
});
document.addEventListener('keydown', function(event){
if(event.key === 'Escape' && settingsOpen){
event.preventDefault();
setSettingsOpen(false, true);
}
});
function renderSidebar(){
const narrow = isNarrow();
chatContainer.classList.toggle('side-closed', narrow && !sideOpen);
sidePanel.classList.toggle('left-panel-hide', !narrow && !sideOpen);
sidePanel.setAttribute('aria-hidden', String(!sideOpen));
toggleBtn.setAttribute('aria-expanded', String(sideOpen));
labelControl(toggleBtn, t(sideOpen ? 'sideCollapse' : 'sideExpand'));
toggleBtn.title = t(sideOpen ? 'sideCollapse' : 'sideExpand');
toggleBtn.textContent = "";
inputDom.placeholder = t(narrow ? 'inputShort' : 'inputPh');
}
@@ -2053,8 +1996,8 @@ function syncChatViewport(){
chatContainer.style.top = '';
}
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
if(orderLookup) orderLookup.viewport(chatContainer.style.height, chatContainer.style.top);
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
if(document.activeElement === inputDom) setSettingsOpen(false);
autoGrow();
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
updateToBottom();
@@ -2067,7 +2010,6 @@ function scheduleChatViewport(){
}
toggleBtn.onclick = function(){
setSettingsOpen(false);
sideOpen = !sideOpen;
renderSidebar();
}
@@ -2087,9 +2029,6 @@ document.addEventListener('keydown', function(event){
window.addEventListener("resize", function(){
const narrow = isNarrow();
if(narrow !== wasNarrow){
const settingsHadFocus = settingsPanel.contains(document.activeElement);
setSettingsOpen(false);
if(narrow && settingsHadFocus) settingsToggle.focus({preventScroll:true});
sideOpen = !narrow;
wasNarrow = narrow;
}
@@ -2118,7 +2057,7 @@ voiceInput = ChatVoice.create({
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
text:t, status:setComposerStatus, language:() => LANG,
text:t, status:setVoiceStatus, language:() => LANG,
context:() => conversationVersion + ':' + currentConvId + ':' + window.humanSid,
canStart:function(){
if(uploadPending || sendPreparing || window.__busy || openingConv !== null || humanRequestToken || humanSendingToken || humanClosing) return t('voiceWait');
@@ -2134,19 +2073,18 @@ voiceInput = ChatVoice.create({
if(!await doSend()) voiceInput.setMode(false);
}
});
customerOrders = CustomerOrders.create({fetch:fetch, text:t, renderCard:fillTrackCard,
onUnauthorized:function(){ if(customerLogin) customerLogin.refresh(); }
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onSession:function(user,refreshOrders){
customerOrders.setUser(user);
if(refreshOrders) customerOrders.refresh();
},
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); },
onChange:function(user,previous){
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
const from = conversationVersion;
resetConversation(null); chatDom.innerHTML=welcomeHTML();
customerRecovery = !user ? {from:from,to:conversationVersion,customerId:previous.id} : null;
// Customer titles must disappear even if the next history request fails.
historyRequestVersion += 1;
historyList=[];
renderSideHistory(); renderRecentChat();
}
loadHistory();
},
@@ -2154,16 +2092,26 @@ customerLogin = CustomerLogin.create({fetch:fetch, text:t,
});
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
customerLogin.refresh();
orderLookup = OrderLookup.create({text:t,canQuery:lookupBlocked,
onOpen:function(){emojiPicker.close();if(voiceInput) voiceInput.cancel();if(isNarrow()){sideOpen=false;renderSidebar();}syncChatViewport();},
query:function(reference,kind){return doSend((kind === 'order' ? '查询订单 ' : '查询入仓码 ')+reference,true);}
});
orderLookup.viewport(chatContainer.style.height,chatContainer.style.top);
window.addEventListener('resize', function(){
if(!isNarrow()) voiceInput.setMode(false);
});
document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){
window.askNewTopic(t('quick')[Number(button.dataset.questionIndex)]);
const index=Number(button.dataset.questionIndex);
if(index === 0) return showOrderLookup(undefined,undefined,button);
const question = index === 2 ? t('exceptionQuery') : t('quick')[index];
return window.askNewTopic(question);
});
});
chatDom.addEventListener('click', function(event){
const lookup=event.target.closest('button[data-order-lookup]');
if(lookup){showOrderLookup(undefined,undefined,lookup);return;}
const question = event.target.closest('button[data-question]');
if(question) window.askNewTopic(question.dataset.question);
const human = event.target.closest('button[data-human-support]');
+16 -35
View File
@@ -8,16 +8,12 @@ var CustomerLogin = {
const submit=document.getElementById('customer-submit');
const cancel=document.getElementById('customer-cancel');
const error=document.getElementById('customer-error');
const accounts=['customer-account','customer-account-mobile'].map(id=>document.getElementById(id)).filter(Boolean);
let user, pending=null, opener=null, busy=false, version=0, resumePending=false;
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(){
accounts.forEach(function(account){
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerAccount');
account.title=account.textContent;
account.setAttribute('aria-label',account.textContent);
});
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');
@@ -26,14 +22,13 @@ var CustomerLogin = {
username.placeholder=options.text('customerUsernamePlaceholder');
password.placeholder=options.text('customerPasswordPlaceholder');
}
function update(next, refreshOrders){
function update(next){
const previous=user;
user=next;
render();
if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){
options.onChange(user,previous);
}
if(options.onSession) options.onSession(user,Boolean(refreshOrders));
}
async function request(path, data){
const controller=new AbortController();
@@ -49,25 +44,20 @@ var CustomerLogin = {
return result;
}finally{ clearTimeout(timer); }
}
async function refresh(refreshOrders){
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,refreshOrders);
if(token === version) update(result.authenticated && result.user ? result.user : null);
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
}
function restoreFocus(){
if(opener && opener.isConnected && !opener.disabled){
opener.focus({preventScroll:true});
opener=null;
}
}
function close(){
overlay.hidden=true;
password.value='';
pending=null;
restoreFocus();
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
}
function open(onSuccess){
if(busy) return;
@@ -78,10 +68,8 @@ var CustomerLogin = {
render(); username.focus({preventScroll:true});
}
function setBusy(value){
busy=value; submit.disabled=value; cancel.disabled=value;
accounts.forEach(account=>{account.disabled=value;});
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
username.disabled=value; password.disabled=value; render();
if(!busy && overlay.hidden) restoreFocus();
}
form.addEventListener('submit',async function(event){
event.preventDefault();
@@ -110,7 +98,7 @@ var CustomerLogin = {
event.preventDefault();controls[next].focus({preventScroll:true});
}
});
async function activateAccount(){
account.onclick=async function(){
if(busy) return;
if(!user){ open(); return; }
const token=++version;setBusy(true);
@@ -119,22 +107,15 @@ var CustomerLogin = {
if(token === version) update(null);
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
finally{ if(token === version) setBusy(false); }
}
accounts.forEach(account=>{account.onclick=activateAccount;});
async function resume(){
// Browsers often emit both events for one return to the page.
if(document.hidden || resumePending) return;
resumePending=true;
try{ await refresh(true); }
finally{ resumePending=false; }
}
window.addEventListener('focus',resume);
document.addEventListener('visibilitychange',resume);
};
window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
render();
return {open:open,refresh:refresh,render:render,require:async function(resume){
return {open:open,refresh:refresh,render:render,require:async function(resume, canContinue){
if(busy) return;
await refresh();
if(user){ await resume(); }else open(resume);
if(canContinue && !canContinue(user)) return;
if(user){ await resume(user); }else open(function(){ return resume(user); });
},viewport:function(height,top){
overlay.style.height=height || ''; overlay.style.top=top || '';
overlay.style.bottom=height ? 'auto' : '';
-177
View File
@@ -1,177 +0,0 @@
// A private, read-only order panel. It never replaces chat drafts or sends AI requests.
var CustomerOrders = {
create(options){
const node = name => document.getElementById('customer-orders'+(name ? '-'+name : ''));
const root=node(''), toggle=node('toggle'), body=node('body'), list=node('list');
const detail=node('detail'), message=node('message'), more=node('more');
const refresh=node('refresh'), back=node('back'), retry=node('retry');
const overview=node('summary'), progress=node('progress');
const groups=['all','processing','completed','pending','cancelled','other'];
const filters=groups.map(function(group){
const button=document.createElement('button');
button.type='button';button.className='customer-order-filter';button.dataset.filter=group;
button.onclick=function(){
if(activeFilter === group && selected === null && !errorKey) return;
activeFilter=group;loadList(false);
};
overview.appendChild(button);
return button;
});
let summary=null, activeFilter='all';
let user=null, rows=[], cursor=null, card=null, selected=null;
let expanded=false, loading=false, errorKey='', retryAction=null, version=0, controller=null, listScroll=0;
function cancel(){
version++;
if(controller) controller.abort();
controller=null;
loading=false;
}
function render(){
root.hidden=!user;
toggle.textContent=options.text('myOrders')+(summary ? ' · '+summary.total : '');
toggle.setAttribute('aria-expanded',String(expanded));
body.hidden=!expanded;
refresh.hidden=!expanded;
refresh.textContent=options.text('ordersRefresh');
back.textContent=options.text('ordersBack');
back.hidden=selected === null;
list.hidden=selected !== null;
detail.hidden=selected === null;
overview.hidden=!summary || selected !== null;
overview.setAttribute('aria-label',options.text('ordersOverview'));
filters.forEach(function(button){
const group=button.dataset.filter;
button.textContent='';
button.setAttribute('aria-pressed',String(activeFilter === group));
if(!summary) return;
const label=document.createElement('span');label.textContent=options.text('ordersGroup_'+group);
const count=document.createElement('strong');count.textContent=String(summary[group === 'all' ? 'total' : group]);
button.append(label,count);
});
progress.hidden=!summary || selected !== null || !rows.length || loading;
progress.textContent=summary ? options.text('ordersProgress').replace('{loaded}',rows.length)
.replace('{total}',summary[activeFilter === 'all' ? 'total' : activeFilter]) : '';
more.hidden=selected !== null || !cursor;
more.disabled=loading;
more.textContent=options.text('ordersMore');
retry.hidden=!errorKey || !retryAction;
retry.textContent=options.text('ordersRetry');
message.textContent=errorKey ? options.text(errorKey) : loading ? options.text('ordersLoading')
: !rows.length ? options.text(activeFilter === 'all' ? 'ordersEmpty' : 'ordersFilterEmpty') : '';
message.hidden=!message.textContent;
body.setAttribute('aria-busy',String(loading));
}
function renderRows(){
list.textContent='';
rows.forEach(function(row){
const button=document.createElement('button');
button.type='button';button.className='customer-order-row';
button.dataset.orderId=String(row.id);
const identity=document.createElement('span');identity.className='customer-order-identity';
const label=document.createElement('span');label.className='customer-order-label';
label.textContent=options.text(row.inbound_code ? 'order_inbound' : 'orderNo');
const number=document.createElement('strong');number.textContent=row.inbound_code || row.order_no;
identity.append(label,number);
const route=document.createElement('span');route.className='customer-order-route';
route.textContent=[row.country_code,row.warehouse_code,
row.transport_mode && (options.text('orderMode_'+row.transport_mode) || row.transport_mode)].filter(Boolean).join(' · ');
const status=document.createElement('span');status.className='customer-order-status';
status.textContent=options.text('orderStatus_'+row.status) || row.status;
const action=document.createElement('span');action.className='customer-order-action';
action.textContent=options.text('ordersTrack')+' →';
button.append(identity,route,status,action);
button.onclick=()=>loadDetail(row.id);
list.appendChild(button);
});
}
function renderCard(){
detail.textContent='';
if(card) options.renderCard(detail,card);
}
async function request(url,apply,retryRequest){
cancel();
const token=version;
const active=new AbortController();controller=active;
loading=true;errorKey='';retryAction=retryRequest;render();
let timedOut=false, receivedResponse=false;
const timer=setTimeout(()=>{timedOut=true;active.abort();},10000);
try{
const response=await options.fetch(url,{signal:active.signal,cache:'no-store'});
if(token !== version) return;
if(response.status === 401){
setUser(null);
if(options.onUnauthorized) options.onUnauthorized();
return;
}
receivedResponse=true;
if(!response.ok){
const errors={400:'ordersInvalidRequest',403:'ordersForbidden',408:'ordersTimeout',
429:'ordersRateLimited',504:'ordersTimeout'};
errorKey=response.status === 404 && /\/orders\/\d+$/.test(url) ? 'ordersUnavailable'
: errors[response.status] || (response.status >= 500 ? 'ordersServerError' : 'ordersFailed');
if([400,403,404].includes(response.status)) retryAction=null;
return;
}
const result=await response.json();
if(token !== version) return;
apply(result);
}catch(error){
if(token === version) errorKey=timedOut ? 'ordersTimeout'
: receivedResponse ? 'ordersInvalidResponse' : 'ordersNetworkError';
}finally{
clearTimeout(timer);
if(token === version){ controller=null;loading=false;render(); }
}
}
function loadList(append, preserveView){
if(!user || (append && (loading || !cursor))) return;
if(!append && !preserveView){ selected=null;card=null;rows=[];cursor=null;body.scrollTop=0;renderCard();renderRows(); }
const params=[];
if(activeFilter !== 'all') params.push('status='+activeFilter);
if(append) params.push('before='+cursor);
const suffix=params.length ? '?'+params.join('&') : '';
return request('/api/customer/orders'+suffix,function(result){
if(!Array.isArray(result.orders)) throw new Error('invalid order list');
// Older responses may lack an overview; never infer totals from one page.
summary=result.summary || null;
rows=append ? rows.concat(result.orders) : result.orders;
cursor=result.next_before || null;
renderRows();
},()=>loadList(append,preserveView));
}
function loadDetail(id){
if(!user) return;
if(selected === null) listScroll=body.scrollTop;
selected=id;card=null;renderCard();
const pending=request('/api/customer/orders/'+id,function(result){
if(!result.card) throw new Error('invalid order detail');
card=result.card;renderCard();
},()=>loadDetail(id));
back.focus({preventScroll:true});
body.scrollTop=0;
return pending;
}
function setUser(next){
if(user && next && user.id === next.id) return;
cancel();user=next;rows=[];cursor=null;card=null;selected=null;errorKey='';retryAction=null;
summary=null;activeFilter='all';
expanded=Boolean(user);renderRows();renderCard();render();
if(user) loadList(false);
}
toggle.onclick=function(){ expanded=!expanded;render(); };
refresh.onclick=()=>loadList(false);
more.onclick=()=>loadList(true);
retry.onclick=()=>retryAction && retryAction();
back.onclick=function(){
const id=selected;
cancel();selected=null;card=null;errorKey='';renderCard();render();
const button=Array.from(list.children).find(item=>item.dataset.orderId === String(id));
body.scrollTop=listScroll;
if(button) button.focus({preventScroll:true});
};
render();
return {setUser:setUser,refresh:function(){
if(user && !loading) return loadList(false,true);
},render:function(){renderRows();renderCard();render();}};
}
};
+91
View File
@@ -0,0 +1,91 @@
// One lookup form for shortcuts and saved order cards; no draft or chat ownership changes.
var OrderLookup = {
create(options){
const overlay=document.getElementById('order-lookup');
const form=document.getElementById('order-lookup-form');
const input=document.getElementById('order-reference');
const label=document.getElementById('order-reference-label');
const title=document.getElementById('order-lookup-title');
const submit=document.getElementById('order-lookup-submit');
const cancel=document.getElementById('order-lookup-cancel');
const copy=document.getElementById('order-reference-copy');
const error=document.getElementById('order-lookup-error');
let kind='inbound', opener=null, busy=false, copying=false, copyState='', version=0, timer=null;
overlay.hidden=true;
function valid(value){
return /^[A-Za-z0-9][A-Za-z0-9_-]{5,63}$/.test(value) && (value.match(/[0-9]/g) || []).length >= 3;
}
function status(message){ error.textContent=message || ''; error.hidden=!message; }
function render(){
title.textContent=options.text(kind === 'inbound' ? 'lookupTitle' : 'lookupOrderTitle');
label.textContent=options.text(kind === 'inbound' ? 'order_inbound' : 'orderNo');
input.placeholder=options.text(kind === 'inbound' ? 'lookupPlaceholder' : 'lookupOrderPlaceholder');
submit.textContent=options.text(busy ? 'lookupSending' : 'lookupSubmit');
cancel.textContent=options.text('customerCancel');
copy.textContent=options.text(copyState || 'copy');
submit.disabled=busy; cancel.disabled=busy; input.disabled=busy;
copy.disabled=busy || copying || !valid(input.value.trim());
}
function clearTimer(){ if(timer !== null) clearTimeout(timer); timer=null; }
function close(){
version+=1;clearTimer();overlay.hidden=true;
busy=false;copying=false;copyState='';input.value='';status('');render();
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
}
function open(reference, referenceKind, source){
version+=1;clearTimer();kind=referenceKind === 'order' ? 'order' : 'inbound';
busy=false;copying=false;copyState='';
opener=source || document.activeElement;input.value=reference || '';status('');render();overlay.hidden=false;
if(options.onOpen) options.onOpen();
input.focus({preventScroll:true});
}
input.addEventListener('input',function(){ clearTimer();copyState='';status('');render(); });
copy.onclick=async function(){
const reference=input.value.trim(), token=version;
if(busy || copying || !valid(reference)) return;
copying=true;copyState='';status('');render();
const copied=await SupportUI.copyText(reference);
if(token !== version) return;
copying=false;
if(input.value.trim() === reference){
copyState=copied ? 'copied' : 'copyFailed';
if(!copied) status(options.text('copyFailed'));
timer=setTimeout(function(){copyState='';timer=null;render();},copied ? 1200 : 3000);
}
render();
};
form.addEventListener('submit',async function(event){
event.preventDefault();
if(busy) return;
const reference=input.value.trim(), token=version;
if(!valid(reference)){status(options.text('lookupInvalid'));input.focus({preventScroll:true});return;}
const blocked=options.canQuery();
if(blocked){status(blocked);return;}
busy=true;status('');render();
try{
const accepted=await options.query(reference,kind);
if(token !== version) return;
if(accepted) close();else status(options.text('lookupFailed'));
}catch(error){if(token === version) status(options.text('lookupFailed'));}
finally{if(token === version){busy=false;render();}}
});
cancel.onclick=function(){if(!busy) close();};
overlay.addEventListener('click',function(event){if(event.target === overlay && !busy) close();});
overlay.addEventListener('keydown',function(event){
if(event.key === 'Escape' && !busy){event.preventDefault();close();return;}
if(event.key === 'Tab'){
const controls=[input,copy,cancel,submit].filter(control=>!control.disabled);
event.preventDefault();
if(controls.length){
const index=controls.indexOf(document.activeElement);
controls[(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length].focus({preventScroll:true});
}
}
});
render();
return {open:open,cancel:close,render:render,viewport:function(height,top){
overlay.style.height=height || '';overlay.style.top=top || '';overlay.style.bottom=height ? 'auto' : '';
}};
}
};
+20 -108
View File
@@ -1,113 +1,25 @@
// Shared controls for the customer page and service workspace.
var supportModalQueue = Promise.resolve();
var supportModalId = 0;
var SupportUI = {
confirm(message, options){
return this.openModal(message, options, false);
},
prompt(message, options){
return this.openModal(message, options, true);
},
openModal(message, options, withInput){
options = options || {};
const invoker = document.activeElement;
return new Promise(function(resolve){
supportModalQueue = supportModalQueue.then(function(){
return new Promise(function(finishQueue){
const overlay = document.createElement('div');
overlay.className = 'app-modal-overlay';
const dialog = document.createElement('section');
dialog.className = 'app-modal';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('tabindex', '-1');
const id = ++supportModalId;
const title = document.createElement('h2');
title.className = 'app-modal-title';
title.id = 'app-modal-title-' + id;
title.textContent = options.title || (withInput ? '请输入内容' : '请确认');
dialog.setAttribute('aria-labelledby', title.id);
dialog.appendChild(title);
if(message){
const detail = document.createElement('div');
detail.className = 'app-modal-message';
detail.id = 'app-modal-message-' + id;
detail.textContent = message;
dialog.setAttribute('aria-describedby', detail.id);
dialog.appendChild(detail);
}
let input = null;
if(withInput){
input = document.createElement(options.multiline ? 'textarea' : 'input');
input.className = 'app-modal-input';
input.setAttribute('aria-label', options.inputLabel || (typeof message === 'string' ? message : title.textContent));
if(!options.multiline) input.type = 'text';
input.value = options.value || '';
if(options.placeholder) input.placeholder = options.placeholder;
dialog.appendChild(input);
}
const actions = document.createElement('div');
actions.className = 'app-modal-actions';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = options.cancelText || '取消';
const confirm = document.createElement('button');
confirm.type = 'button';
confirm.className = 'app-modal-confirm';
confirm.textContent = options.confirmText || '确定';
actions.appendChild(cancel);
actions.appendChild(confirm);
dialog.appendChild(actions);
overlay.appendChild(dialog);
let closed = false;
function close(value){
if(closed) return;
closed = true;
overlay.remove();
if(invoker && invoker.isConnected !== false && document.body.contains(invoker)){
invoker.focus({preventScroll:true});
}
resolve(value);
finishQueue();
}
function accept(){ close(withInput ? input.value : true); }
cancel.onclick = function(){ close(withInput ? null : false); };
confirm.onclick = accept;
overlay.onclick = function(event){
if(event.target === overlay) close(withInput ? null : false);
};
dialog.addEventListener('keydown', function(event){
if(event.key === 'Escape'){
event.preventDefault();
close(withInput ? null : false);
return;
}
if(event.key === 'Enter' && (!input || !options.multiline || event.ctrlKey || event.metaKey)){
event.preventDefault();
accept();
return;
}
if(event.key !== 'Tab') return;
const focusable = dialog.querySelectorAll('button, input, textarea');
if(!focusable.length){ event.preventDefault(); dialog.focus(); return; }
const first = focusable[0], last = focusable[focusable.length - 1];
if(event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))){
event.preventDefault(); last.focus();
}else if(!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))){
event.preventDefault(); first.focus();
}
});
document.body.appendChild(overlay);
(input || cancel).focus({preventScroll:true});
});
});
});
async copyText(text){
try{
if(navigator.clipboard && navigator.clipboard.writeText){
await navigator.clipboard.writeText(text);
return true;
}
}catch(error){}
const field = document.createElement('textarea');
field.value=text; field.readOnly=true;
field.style.position='fixed'; field.style.opacity='0';
const previous=document.activeElement;
document.body.appendChild(field);
try{
field.focus({preventScroll:true}); field.select();
return Boolean(document.execCommand('copy'));
}catch(error){ return false; }
finally{
field.remove();
if(previous && previous.isConnected) previous.focus({preventScroll:true});
}
},
initAttachments(container, label){
container.addEventListener('error', function(event){
+23 -9
View File
@@ -4,6 +4,7 @@ var ChatVoice = {
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
let session = null, mode = false;
let pressedId = null;
const active = current => session === current;
function stopTracks(current){
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
@@ -39,6 +40,7 @@ var ChatVoice = {
}
function setMode(on){
cancel();
pressedId=null;
mode = on;
render();
if(on && input.blur) input.blur();
@@ -172,24 +174,30 @@ var ChatVoice = {
};
if(window.PointerEvent){
hold.addEventListener('pointerdown', function(event){
if(event.button !== 0) return;
if(event.button !== 0 || pressedId!==null) return;
event.preventDefault();
pressedId=event.pointerId;
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
start(event.pointerId, event.clientY);
});
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
hold.addEventListener('pointerup', function(event){
event.preventDefault();
if(pressedId===event.pointerId) pressedId=null;
if(session && session.id === event.pointerId) finish(session, false);
});
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
pressedId=null;
if(session && session.holding) finish(session, true);
}));
}else{
hold.addEventListener('touchstart', function(event){
event.preventDefault();
if(event.touches.length !== 1) return;
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
if(pressedId!==null) return;
const touch=event.touches[0];
pressedId=touch.identifier;
start(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchmove', function(event){
event.preventDefault();
@@ -197,23 +205,29 @@ var ChatVoice = {
}, {passive:false});
hold.addEventListener('touchend', function(event){
event.preventDefault();
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
for(const touch of event.changedTouches){
if(pressedId===touch.identifier) pressedId=null;
if(session && session.id === touch.identifier) finish(session, false);
}
}, {passive:false});
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
hold.addEventListener('touchcancel', function(){ pressedId=null;if(session) finish(session, true); });
}
hold.addEventListener('contextmenu', event => event.preventDefault());
hold.addEventListener('keydown', function(event){
if(![' ','Enter'].includes(event.key) || event.repeat) return;
if(![' ','Enter'].includes(event.key) || event.repeat || pressedId!==null) return;
pressedId='keyboard';
event.preventDefault(); start('keyboard', 0);
});
hold.addEventListener('keyup', function(event){
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
event.preventDefault(); finish(session, false);
if([' ','Enter'].includes(event.key)){
if(pressedId==='keyboard') pressedId=null;
event.preventDefault();
if(session && session.id === 'keyboard') finish(session, false);
}
});
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
window.addEventListener('pagehide', function(){ cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden){pressedId=null;cancel();} });
window.addEventListener('pagehide', function(){ pressedId=null;cancel(); });
render();
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
}
+2 -70
View File
@@ -2,8 +2,6 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="staff-csrf-token" content="{{ g.staff.csrf_token }}">
<meta name="staff-username" content="{{ g.staff.username }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客服工作台 · 柏悦物流</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
@@ -18,10 +16,7 @@
</div>
<div class="topbar-actions">
<span id="nightBadge" class="night-badge" hidden>非工作时段</span>
<button id="ticket-inbox-toggle" class="ticket-inbox-toggle" type="button" aria-label="查看待处理留言" aria-controls="service-tools">留言 <span id="ticket-pending-count">—</span></button>
<button id="ticket-overdue-toggle" class="ticket-overdue-toggle" type="button" aria-label="查看超时留言" aria-controls="service-tools">超时 <span id="ticket-overdue-count">—</span></button>
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
<a class="workspace-link orders-link" href="/admin/orders">订单数据管理 ↗</a>
<a class="workspace-link" href="/">客户入口 ↗</a>
<details class="data-overview" id="data-overview">
<summary><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 4v16h17M8 16v-5M13 16V7M18 16V3"/></svg>数据概览</summary>
@@ -36,29 +31,19 @@
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
</div>
</details>
<button id="staff-logout" class="staff-logout" type="button">退出登录</button>
</div>
</header>
<div id="staff-logout-status" class="sync-status" role="alert" hidden></div>
<div id="sync-status" class="sync-status" role="status" hidden>会话连接中断,正在重试…</div>
<div id="ticket-alert" class="ticket-alert" role="status" aria-live="polite" hidden><span id="ticket-alert-text"></span><button id="ticket-alert-view" type="button" class="ticket-action">查看待处理留言</button></div>
<main class="layout">
<aside class="conv-list" id="session-browser" aria-label="会话列表">
<div class="list-heading">
<h2>客户会话</h2><p>查看咨询,及时接待与回复</p>
<div class="routing-presence">
<span id="routing-presence-dot" class="routing-dot" data-online="false" aria-hidden="true"></span>
<span id="routing-presence-text" role="status">正在检查接待状态…</span>
<button id="routing-presence-toggle" type="button" class="ticket-action" aria-pressed="false" disabled>上线接待</button>
</div>
<div class="routing-heading"><span id="routing-group-label">综合客服</span><button id="routing-open" type="button" class="text-button" aria-controls="service-tools">客服分组与分配</button></div>
<p id="routing-status" class="routing-feedback" role="status" aria-live="polite"></p>
<div class="session-search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg><input id="session-search" type="search" aria-label="搜索客户会话" placeholder="搜索会话或消息" autocomplete="off"></div>
<button id="history-search-toggle" class="text-button" type="button">搜索历史记录 ↗</button>
</div>
<div class="session-tabs" role="tablist" aria-label="会话状态">
<button id="tab-active" type="button" role="tab" aria-selected="true" aria-controls="sessionList">接待中 <span id="active-count" class="section-count">0</span></button>
<button id="tab-queue" type="button" role="tab" aria-selected="false" aria-controls="queue" tabindex="-1">排队中 <span id="queue-count" class="section-count">0</span><span id="queue-overdue-count" class="queue-overdue-count">超时 0</span></button>
<button id="tab-queue" type="button" role="tab" aria-selected="false" aria-controls="queue" tabindex="-1">排队中 <span id="queue-count" class="section-count">0</span></button>
</div>
<div class="session-scroll">
<div id="sessionList" role="tabpanel" aria-labelledby="tab-active"><div class="list-empty">暂无进行中的会话</div></div>
@@ -95,33 +80,6 @@
</section>
<aside class="side" id="service-tools" aria-label="客户信息与回复" aria-hidden="true" inert>
<header class="side-heading"><h2>服务资料</h2><button id="tools-close" type="button" class="icon-button" aria-label="关闭服务资料">×</button></header>
<section class="card routing-card" aria-labelledby="routing-title">
<div class="routing-heading"><h3 id="routing-title">客服分组与分配</h3><button id="routing-refresh" type="button" class="ticket-action">刷新</button></div>
<p class="info-hint">每位在线客服空闲时自动接入一个新客户;忙碌时不再自动接入。排队客户需手动领取,也可手动多接。可在下方改派到其他组。</p>
<ul id="routing-members" class="routing-members" aria-label="客服在线状态"></ul>
<p id="routing-management-status" class="routing-feedback" role="status" aria-live="polite"></p>
<details class="routing-settings">
<summary>管理分组与改派</summary>
<form id="routing-group-form" class="routing-form">
<label for="routing-group-name">新分组名称</label>
<input id="routing-group-name" maxlength="40" placeholder="例如:售后组" required>
<button id="routing-create-group" class="ticket-action" type="submit">创建分组</button>
</form>
<form id="routing-member-form" class="routing-form">
<label for="routing-member">客服账号</label><select id="routing-member" required></select>
<label for="routing-member-group">所属分组</label><select id="routing-member-group" required></select>
<button id="routing-save-member" class="ticket-action" type="submit">保存客服分组</button>
</form>
<form id="routing-assign-form" class="routing-form">
<label for="routing-session">需要改派的会话</label><select id="routing-session" required></select>
<label for="routing-target-group">目标分组</label><select id="routing-target-group" required></select>
<label for="routing-target-owner">负责人</label><select id="routing-target-owner"></select>
<button id="routing-save-assignment" class="ticket-action" type="submit">确认改派</button>
</form>
</details>
<details class="routing-events"><summary>最近 50 条分配记录</summary><ul id="routing-events"></ul></details>
<details id="routing-draft-recovery" class="routing-events" hidden><summary>改派后保留的未发送草稿</summary><div id="routing-retired-drafts"></div></details>
</section>
<section class="card"><h3>客户信息</h3><div class="info-row" id="cInfo">选择会话后查看客户信息</div><p class="info-hint">咨询运费时,可先确认运单号、货物重量、尺寸与目的地。</p></section>
<section class="card"><h3><label for="noteInput">会话备注</label></h3><textarea id="noteInput" class="note-input" rows="3" placeholder="填写本次会话的跟进事项" disabled></textarea><button id="note-save" type="button" class="quick" disabled>保存备注</button></section>
<section class="card"><h3>常用回复</h3>
@@ -131,39 +89,13 @@
<button type="button" class="quick" onclick="quickReply('已为您催促,请稍候。')">已为您催促,请稍候</button>
<button type="button" class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询,祝您生活愉快</button>
</section>
<section class="card ticket-workspace" aria-labelledby="tickets-title">
<div class="ticket-row"><h3 id="tickets-title">离线留言</h3><span id="ticket-section-count" class="ticket-section-count">待处理 —</span></div>
<p id="ticket-summary-status" class="ticket-summary-status" role="status"></p>
<p class="ticket-timing-rule">超时提醒:30 分钟未领取,或领取后 20 分钟未完成。</p>
<form id="ticket-search-form" class="ticket-search-form" role="search" aria-label="搜索离线留言">
<label for="ticket-search">搜索留言</label><input id="ticket-search" type="search" maxlength="100" placeholder="手机号、关键词、负责人或备注" autocomplete="off">
<div class="ticket-search-actions"><button id="ticket-search-submit" type="submit" class="ticket-action ticket-primary">搜索</button><button id="ticket-search-clear" type="button" class="ticket-action" hidden>清除搜索</button></div>
</form>
<div class="ticket-toolbar"><label for="ticket-filter">状态</label><select id="ticket-filter"><option value="all">全部</option><option value="pending">待处理</option><option value="processing">处理中</option><option value="completed">已完成</option></select><label class="ticket-overdue-filter"><input id="ticket-overdue-filter" type="checkbox"> 只看超时</label><button id="ticket-refresh" type="button" class="ticket-action">刷新</button></div>
<div id="ticketList"><div class="list-empty">正在加载留言…</div></div>
<button id="ticket-more" type="button" class="ticket-action" hidden>加载更早留言</button>
<div id="ticket-selected" class="ticket-detail" hidden>
<div class="ticket-row"><h4>留言详情</h4><span id="ticket-state" class="ticket-state"></span></div>
<p id="ticket-meta" class="ticket-meta"></p><p id="ticket-content" class="ticket-content"></p>
<p id="ticket-owner" class="ticket-owner"></p><p id="ticket-overdue-hint" class="ticket-overdue-hint" hidden></p>
<label for="ticket-note" class="ticket-note-label">处理备注</label>
<textarea id="ticket-note" rows="4" maxlength="2000" placeholder="记录联系情况、处理结果或后续跟进事项" readonly></textarea>
<p id="ticket-server-note" class="ticket-server-note" hidden></p>
<button id="ticket-rebase" type="button" class="ticket-action" hidden>已核对,使用我的备注</button>
<div class="ticket-actions"><button id="ticket-claim" type="button" class="ticket-action ticket-primary" hidden>领取留言</button><button id="ticket-save" type="button" class="ticket-action" hidden>保存备注</button><button id="ticket-complete" type="button" class="ticket-action ticket-primary" hidden>标记已完成</button></div>
<p id="ticket-activity" class="ticket-meta"></p>
<section id="ticket-history" class="ticket-history" aria-labelledby="ticket-history-title"><div class="ticket-row"><h4 id="ticket-history-title">处理记录</h4><button id="ticket-history-reload" type="button" class="ticket-action">刷新记录</button></div><p id="ticket-history-status" class="ticket-meta" role="status"></p><ol id="ticket-history-list" class="ticket-history-list"></ol><button id="ticket-history-more" type="button" class="ticket-action" hidden>加载更早记录</button></section>
</div>
<p id="ticket-status-message" class="ticket-feedback" role="status" aria-live="polite"></p>
</section>
<section class="card"><h3>离线留言</h3><div id="ticketList"><div class="list-empty">正在加载留言…</div></div></section>
</aside>
</main>
<button class="tools-backdrop" id="tools-backdrop" type="button" aria-label="关闭服务资料"></button>
<dialog id="searchResult" class="search-dialog" aria-labelledby="search-title"><header><h2 id="search-title">搜索历史记录</h2><button id="search-close" class="icon-button" type="button" aria-label="关闭搜索">×</button></header><form id="history-search-form"><label class="sr-only" for="searchBox">历史消息关键词</label><input id="searchBox" type="search" placeholder="输入关键词搜索聊天记录" required><button type="submit" class="quick">搜索</button></form><div id="searchBody" aria-live="polite">请输入关键词搜索历史消息</div></dialog>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_tools.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_tickets.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_routing.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
</body>
</html>
-55
View File
@@ -1,55 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="staff-csrf-token" content="{{ g.staff.csrf_token }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单数据管理 · 柏悦物流</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin_orders.css') }}">
</head>
<body class="orders-page">
<header class="orders-topbar">
<a class="brand" href="/admin/dashboard"><span class="brand-mark" aria-hidden="true">物流</span><span class="brand-name">柏悦物流</span></a>
<nav aria-label="后台导航"><a href="/admin/dashboard">返回客服工作台</a><button id="staff-logout" type="button">退出登录</button></nav>
</header>
<main class="orders-shell">
<div class="orders-heading"><div><p class="orders-eyebrow">STAFF CONSOLE</p><h1>订单数据管理</h1><p>导入订单后,客户登录并关联对应租户即可查询订单信息。</p></div><a class="template-link" href="/api/admin/orders/import/template">下载CSV模板 ↓</a></div>
<section class="import-card" aria-labelledby="import-title">
<div class="card-heading"><span class="step-number">01</span><div><h2 id="import-title">上传订单文件</h2><p>支持 CSV 和 XLSX,单次最多 5,000 条、文件不超过 5 MB。</p></div></div>
<form id="order-import-form">
<label class="file-picker" for="order-file"><span class="file-icon" aria-hidden="true">↑</span><span><strong id="file-name">选择订单文件</strong><small>点击选择 CSV 或 XLSX 文件</small></span><input id="order-file" name="file" type="file" accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required></label>
<p class="import-hint">必填列:租户ID、订单号。重复订单会保留已有记录,不会覆盖人工修改。</p>
<div class="form-actions"><button id="preview-import" class="button-primary" type="submit">校验并预览</button><span id="import-status" role="status" aria-live="polite"></span></div>
</form>
</section>
<section id="import-results" class="import-card results-card" aria-labelledby="results-title" hidden>
<div class="card-heading"><span class="step-number">02</span><div><h2 id="results-title">校验结果</h2><p id="results-caption">核对后再确认导入。</p></div></div>
<div id="import-summary" class="summary-grid"></div>
<div class="table-scroll"><table><thead><tr><th>行</th><th>租户ID</th><th>订单号</th><th>状态</th><th>入仓码</th><th>检查结果</th></tr></thead><tbody id="preview-rows"></tbody></table></div>
<p id="preview-limit-note" class="import-hint" hidden>表格仅展示前 20 条,其他行仍会执行完整校验。</p>
<div id="import-errors" class="error-list" hidden></div>
<div class="form-actions result-actions"><button id="confirm-import" class="button-primary" type="button" hidden>确认导入</button><span class="import-hint">确认后新增有效订单;文件中的错误行必须先修正。</span></div>
</section>
<section class="import-card" aria-labelledby="orders-title">
<div class="card-heading"><span class="step-number">03</span><div><h2 id="orders-title">订单列表</h2><p>按订单号、入仓码、Shipment ID 或租户筛选。</p></div></div>
<form id="order-search-form" class="order-search-form">
<label>关键词<input id="order-query" type="search" maxlength="128" placeholder="订单号、入仓码或 Shipment ID"></label>
<label>租户ID<input id="order-tenant" type="search" maxlength="128" placeholder="精确租户ID"></label>
<label>业务状态<select id="order-status"><option value="">全部状态</option><option value="DRAFT">草稿</option><option value="SUBMITTED">已提交</option><option value="APPROVED">审核通过</option><option value="ACCEPTED">已接单</option><option value="REJECTED">审核驳回</option><option value="PROCESSING">履约中</option><option value="COMPLETED">头程订单已完成</option><option value="CANCELLED">已取消</option></select></label>
<button class="button-primary" type="submit">搜索</button>
</form>
<p id="order-list-status" class="import-hint" role="status" aria-live="polite"></p>
<div class="table-scroll"><table><thead><tr><th>租户ID</th><th>订单号</th><th>业务状态</th><th>入仓码</th><th>Shipment ID</th><th>目的国</th><th>来源更新时间</th></tr></thead><tbody id="order-list-rows"><tr><td colspan="7">正在加载订单…</td></tr></tbody></table></div>
<div class="form-actions"><button id="load-more-orders" class="button-secondary" type="button" hidden>加载更多</button></div>
</section>
<section class="import-card" aria-labelledby="batches-title">
<div class="card-heading"><span class="step-number">04</span><div><h2 id="batches-title">导入记录</h2><p>记录操作人员和导入数量;撤销只删除仍未被修改的本批新增订单。</p></div></div>
<div class="table-scroll"><table><thead><tr><th>时间</th><th>文件</th><th>操作人</th><th>新增</th><th>重复保留</th><th>状态</th><th>操作</th></tr></thead><tbody id="import-batch-rows"><tr><td colspan="7">正在加载导入记录…</td></tr></tbody></table></div>
</section>
</main>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_orders.js') }}"></script>
</body>
</html>
+23 -32
View File
@@ -21,51 +21,28 @@
</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" id="customer-account" class="customer-account">登录</button>
<button type="button" class="clear-all-btn">清空全部历史</button>
</div>
</aside>
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
<main class="main-chat">
<div id="attachment-drop-hint" class="attachment-drop-hint" role="status" hidden><span data-i18n="dropAttachment">松开鼠标添加附件</span></div>
<header class="chat-title">
<button class="toggle-divider" type="button" aria-label="切换历史侧栏" aria-expanded="true"></button>
<div class="header-copy"><div class="header-kicker">BAIYUE · CUSTOMER SUPPORT</div><span id="app-title">柏悦物流智能助手</span></div>
<div class="title-actions">
<button type="button" id="customer-account-mobile" class="customer-account mobile-account">客户登录</button>
<button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
<span class="service-label"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceTag">智能客服</span></span>
<button type="button" id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
<div class="settings-control">
<button type="button" id="settings-toggle" class="theme-toggle settings-toggle" aria-expanded="false" aria-controls="settings-panel" data-i18n="moreSettings">更多设置</button>
<div id="settings-panel" class="settings-panel" role="group" aria-label="更多设置" hidden>
<div class="settings-row"><span data-i18n="notificationSetting">消息通知</span><button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button></div>
<div class="settings-row"><span data-i18n="themeSetting">明暗主题</span><button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</button></div>
<div class="settings-row"><span data-i18n="fontSize">字体大小</span><button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button></div>
</div>
</div>
<button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button>
<button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</button>
</div>
</header>
<section id="customer-orders" class="customer-orders" aria-labelledby="customer-orders-toggle" hidden>
<div class="customer-orders-heading">
<button id="customer-orders-toggle" type="button" aria-expanded="false" aria-controls="customer-orders-body">我的订单</button>
<button id="customer-orders-refresh" type="button">刷新</button>
</div>
<div id="customer-orders-body" class="customer-orders-body" hidden>
<div id="customer-orders-summary" class="customer-orders-summary" role="group" aria-label="订单概况" hidden></div>
<button id="customer-orders-back" type="button" hidden>返回订单列表</button>
<p id="customer-orders-message" role="status" hidden></p>
<div id="customer-orders-list" class="customer-orders-list"></div>
<div id="customer-orders-detail" class="track-card" aria-live="polite" hidden></div>
<p id="customer-orders-progress" class="customer-orders-progress" role="status" hidden></p>
<button id="customer-orders-retry" type="button" hidden>重试</button>
<button id="customer-orders-more" type="button" hidden>加载更多订单</button>
</div>
</section>
<div id="chatDom" role="log" aria-live="polite" aria-label="聊天内容">
<section class="ai-welcome-card">
<div class="ai-welcome-top">
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
<div class="welcome-eyebrow">中美物流服务</div>
<h3>物流问题,随时为您解答</h3>
</div>
</section>
@@ -74,8 +51,8 @@
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
<div class="composer-toolbar"><div class="hot-bar">
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">美国报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常订单</button>
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></div>
<div class="input-area">
@@ -87,6 +64,7 @@
<button type="button" id="send-button">发送</button>
</div>
<p id="composer-status" class="composer-status" role="status" hidden></p>
<div id="voice-feedback" class="voice-feedback" role="status" aria-live="polite" hidden></div>
</footer>
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
@@ -105,13 +83,26 @@
</form>
</section>
</div>
<div id="order-lookup" class="customer-login-overlay" hidden>
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="order-lookup-title">
<h2 id="order-lookup-title">查入仓码</h2>
<form id="order-lookup-form">
<label id="order-reference-label" for="order-reference">入仓码</label>
<div class="order-reference-field">
<input id="order-reference" name="reference" type="text" placeholder="请输入入仓码" maxlength="64" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" required>
<button id="order-reference-copy" type="button">复制</button>
</div>
<p id="order-lookup-error" role="alert" hidden></p>
<div class="customer-login-actions"><button id="order-lookup-cancel" type="button">取消</button><button id="order-lookup-submit" type="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/customer_orders.js') }}"></script>
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/attachments.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
</html>
+12 -403
View File
@@ -6,14 +6,13 @@ const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
const ticketsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tickets.js'), 'utf8');
const flush = () => new Promise(setImmediate);
function response(data, status = 200){
return {ok: status >= 200 && status < 300, status, json: async () => data};
}
async function workspace(compact = false, initialHooks = {}){
async function workspace(compact = false){
const elements = new Map();
const documentListeners = {};
let document;
@@ -44,7 +43,6 @@ async function workspace(compact = false, initialHooks = {}){
showModal(){ this.open = true; }
close(){ this.open = false; }
focus(){ document.activeElement = this; }
scrollIntoView(){}
click(){ if(this.onclick) return this.onclick(); }
}
const quickButtons = [new Element(), new Element()];
@@ -54,400 +52,42 @@ async function workspace(compact = false, initialHooks = {}){
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){
if(selector === 'meta[name="staff-csrf-token"]') return {content:'fixture-csrf'};
if(selector === 'meta[name="staff-username"]') return {content:'alice'};
const match = selector.match(/data-message-id="(\d+)"/);
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
const calls = [], dialogs = [];
const hooks = initialHooks;
const timers = new Map(), intervals = new Map();
let timerId = 0;
const calls = [];
const hooks = {};
const context = vm.createContext({
document, console, AbortController,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; },
location:{replace(url){ calls.push({redirect:url}); }}},
setInterval(callback, delay){ const id=++timerId; intervals.set(id,{callback,delay}); return id; },
setTimeout(callback, delay){ const id=++timerId; timers.set(id,{callback,delay}); return id; },
clearTimeout(id){ timers.delete(id); },
clearInterval(id){ intervals.delete(id); },
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
setInterval(){}, setTimeout(){}, clearTimeout(){},
fetch: async (url, options) => {
const body = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url, body, signal:options && options.signal, headers:options && options.headers});
if(url.startsWith('/api/tickets/summary')) return hooks.ticketSummary ? hooks.ticketSummary(url, options) : response({pending_count:0,latest_id:0,new_count:0});
if(/^\/api\/tickets\/\d+\/events/.test(url)) return hooks.ticketEvents ? hooks.ticketEvents(url, options) : response([]);
if(url === '/admin/logout' && hooks.logout) return hooks.logout(options);
if(url.startsWith('/api/tickets') && hooks.tickets) return hooks.tickets(url, body, options);
if(url === '/api/human/send' && hooks.send) return hooks.send(body, options);
calls.push({url, body});
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue(options);
if(url === '/api/human/active' && hooks.active) return hooks.active(options);
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url, options);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
return response(options && options.method ? {ok:true} : []);
return response(options ? {ok:true} : []);
},
});
vm.runInContext(sharedSource, context, {filename:'support.js'});
context.SupportUI.confirm = async (message, options) => { dialogs.push({message, options}); return initialHooks.confirmResult ?? true; };
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
vm.runInContext(ticketsSource, context, {filename:'admin_tickets.js'});
vm.runInContext(source, context, {filename:'admin.js'});
await flush();
await context.accept(7);
await flush();
return {context, element: id => document.getElementById(id), calls, hooks, dialogs, document, documentListeners, timers,
runTimers:async delay=>{
for(const [id,timer] of Array.from(timers)){
if(timer.delay===delay){ timers.delete(id); timer.callback(); }
}
await flush();
},
tick:async delay=>{
for(const timer of intervals.values()) if(timer.delay===delay) timer.callback();
await flush();
},
};
return {context, element: id => document.getElementById(id), calls, hooks, document, documentListeners};
}
function waitForAbort(options){
return new Promise((resolve,reject)=>{
if(!options || !options.signal) return;
options.signal.addEventListener('abort',()=>{
const error=new Error('Request aborted'); error.name='AbortError'; reject(error);
},{once:true});
});
}
test('ticket polling preserves drafts and stale versions require explicit review', async () => {
const app = await workspace();
let ticket = {id:10,content:'查询派送',phone:'123',ctime:'now',status:'processing',owner_username:'alice',owner_name:'Alice',note:'原备注',version:1};
app.hooks.tickets = async url => response(url.includes('?') ? [ticket] : {ok:true,ticket});
await app.context.loadTickets();
app.element('ticket-note').value='我的未保存备注';
app.element('ticket-note').listeners.input();
ticket = {...ticket, note:'另一页面更新', version:2};
await app.context.loadTickets();
assert.equal(app.element('ticket-note').value,'我的未保存备注');
assert.equal(app.element('ticket-save').disabled,true);
assert.match(app.element('ticket-server-note').textContent,/另一页面更新/);
app.element('ticket-rebase').onclick();
assert.equal(app.element('ticket-save').disabled,false);
});
test('human queue displays five-minute overdue state and notifies once when it crosses the threshold', async () => {
const app=await workspace();
let overdue=false;
app.hooks.queue=async()=>response([{id:119,ctime:'2026-11-02 16:47:39',waiting_minutes:overdue?5:4,overdue,overdue_minutes:overdue?1:0}]);
const notices=[];
app.context.SupportUI.notify=(...args)=>notices.push(args);
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/等待 4 分钟/);
assert.equal(app.element('queue-overdue-count').textContent,'超时 0');
assert.equal(notices.filter(item=>item[0]==='新客户排队').length,1);
overdue=true;
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/排队超时 1 分钟/);
assert.equal(app.element('queue-overdue-count').textContent,'超时 1');
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
await app.context.loadQueue();
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
});
test('ticket writes use current owner, CSRF and preserve the draft on network failure', async () => {
const app = await workspace();
const ticket = {id:10,content:'待处理留言',status:'processing',owner_username:'alice',note:'',version:1};
app.hooks.tickets = async (url, body, options) => options && options.method ? response({ok:false,error:'版本冲突'},409) : response(url.includes('?') ? [ticket] : {ok:true,ticket});
await app.context.loadTickets();
app.element('ticket-note').value='处理结果';
app.element('ticket-note').listeners.input();
await app.element('ticket-complete').onclick();
const call=app.calls.find(call=>call.url==='/api/tickets/10' && call.body);
assert.equal(call.headers['X-CSRF-Token'],'fixture-csrf');
assert.equal(call.body.status,'completed');
assert.equal(call.body.version,1);
assert.equal(app.element('ticket-note').value,'处理结果');
assert.match(app.element('ticket-status-message').textContent,/版本冲突/);
assert.equal(app.element('ticket-complete').disabled,false);
});
test('tickets assigned to other staff are read-only and escaped in the list', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response([{id:12,content:'<img src=x onerror=alert(1)>',status:'processing',owner_username:'bob',owner_name:'Bob',version:1,note:'备注'}]);
await app.context.loadTickets();
assert.equal(app.element('ticket-note').readOnly,true);
assert.equal(app.element('ticket-save').hidden,true);
assert.match(app.element('ticketList').innerHTML,/&lt;img/);
assert.doesNotMatch(app.element('ticketList').innerHTML,/<img/);
});
test('a delayed ticket list cannot overwrite a newer status filter', async () => {
const app=await workspace();
let resolveOld, count=0;
app.hooks.tickets=async()=>{
if(++count===1) return new Promise(resolve=>{resolveOld=resolve;});
return response([{id:20,content:'已完成留言',status:'completed',owner_username:'alice',note:'处理结果',version:2}]);
};
const pending=app.context.loadTickets();
app.element('ticket-filter').value='completed';
app.element('ticket-filter').listeners.change();
await flush();
resolveOld(response([{id:10,content:'旧列表',status:'pending',version:0}]));
await pending;
assert.equal(app.element('ticket-state').textContent,'已完成');
assert.doesNotMatch(app.element('ticketList').innerHTML,/旧列表/);
});
test('a hung ticket save releases controls and retains notes after the request timeout', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',note:'',version:1};
app.hooks.tickets=async(url,body,options)=>options && options.method ? waitForAbort(options) : response([ticket]);
await app.context.loadTickets();
app.element('ticket-note').value='不能丢失的备注';
app.element('ticket-note').listeners.input();
const pending=app.element('ticket-save').onclick();
assert.equal(app.element('ticket-save').disabled,true);
await app.runTimers(10000);
await pending;
assert.equal(app.element('ticket-save').disabled,false);
assert.equal(app.element('ticket-note').value,'不能丢失的备注');
assert.match(app.element('ticket-status-message').textContent,/未确认成功.*备注已保留/);
});
test('ticket search is sent to the server and retained for pagination and status changes', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:100-index,content:'查询派送',status:'pending',version:0})));
app.element('ticket-search').value='138 &小林';
await app.element('ticket-search-form').listeners.submit({preventDefault(){}});
let listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
await app.element('ticket-more').onclick();
listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.match(listCalls.at(-1).url,/before=81/);
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
app.element('ticket-filter').value='pending';
app.element('ticket-filter').listeners.change();
await flush();
assert.equal(new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
});
test('new ticket alerts skip old records, deduplicate new arrivals and keep polling on older pages', async () => {
let version=1;
const app=await workspace(false,{ticketSummary:async()=>response({pending_count:24,latest_id:version,new_count:version===2?1:0})});
const notices=[];
app.context.SupportUI.notify=(...args)=>notices.push(args);
await app.context.loadTickets();
assert.equal(app.element('ticket-pending-count').textContent,'24');
assert.equal(notices.length,0);
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:40-index,content:'旧留言',status:'pending',version:0})));
await app.context.loadTickets();
await app.element('ticket-more').onclick();
version=2;
await app.context.loadTickets();
assert.match(app.element('ticket-alert-text').textContent,/1 条新留言/);
assert.equal(app.element('ticket-alert').hidden,false);
assert.equal(notices.length,1);
await app.context.loadTickets();
assert.equal(notices.length,1);
version=3;
await app.context.loadTickets();
assert.equal(notices.length,1);
await app.element('ticket-alert-view').onclick();
assert.equal(app.element('ticket-filter').value,'pending');
assert.equal(app.element('ticket-search').value,'');
assert.equal(app.element('ticket-alert').hidden,true);
});
test('ticket header entry focuses the search field and IME confirmation does not submit', async () => {
const app=await workspace(true);
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.document.body.classList.contains('tools-open'),true);
assert.equal(app.document.activeElement,app.element('ticket-search'));
let prevented=false;
app.element('ticket-search').listeners.keydown({key:'Enter',isComposing:true,preventDefault(){prevented=true;}});
assert.equal(prevented,true);
});
test('overdue count stays global and the header opens all overdue records', async () => {
const app=await workspace(true,{ticketSummary:async()=>response({pending_count:8,latest_id:10,new_count:0,overdue_count:3})});
assert.equal(app.element('ticket-overdue-count').textContent,'3');
app.hooks.tickets=async()=>response([{id:10,content:'超时件',status:'pending',version:0,overdue_kind:'pending',overdue_minutes:7}]);
await app.element('ticket-overdue-toggle').onclick();
const query=new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local');
assert.equal(query.searchParams.get('status'),'all');
assert.equal(query.searchParams.get('overdue'),'1');
assert.match(app.element('ticketList').innerHTML,/领取超时 7 分钟/);
assert.match(app.element('ticket-overdue-hint').textContent,/7 分钟/);
app.element('tools-close').onclick();
assert.equal(app.document.activeElement,app.element('ticket-overdue-toggle'));
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.element('ticket-overdue-filter').checked,false);
});
test('history is escaped, paginated and not reloaded by an unchanged poll', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response([{id:10,content:'留言',status:'processing',version:1}]);
let historyCalls=0;
app.hooks.ticketEvents=async()=>{historyCalls++;return response(Array.from({length:20},(_,index)=>({id:40-index,action:'note_updated',actor_name:'<img>',ctime:'today',old_note:'旧备注',new_note:'<script>evil</script>'})));};
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/&lt;script&gt;/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/<script>/);
assert.match(app.element('ticket-history-list').innerHTML,/旧备注/);
await app.context.loadTickets(); await flush();
assert.equal(historyCalls,1);
await app.element('ticket-history-more').onclick();
assert.match(app.calls.filter(call=>call.url.includes('/events?')).at(-1).url,/before=21/);
});
test('a late history response cannot replace the newly selected ticket history', async () => {
const app=await workspace();
let resolveOld;
app.hooks.tickets=async()=>response([{id:10,content:'A',status:'pending',version:0},{id:11,content:'B',status:'pending',version:0}]);
app.hooks.ticketEvents=async url=>url.includes('/10/') ? new Promise(resolve=>{resolveOld=resolve;}) : response([{id:4,action:'claimed',actor_name:'B客服',ctime:'today'}]);
await app.context.loadTickets();
app.element('ticketList').listeners.click({target:{closest(){return {getAttribute(){return '11';}};}}});
await flush();
resolveOld(response([{id:1,action:'claimed',actor_name:'A客服',ctime:'today'}])); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/B客服/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/A客服/);
});
test('history failures offer retry without blocking a ticket save', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',version:1};
app.hooks.tickets=async(url,body)=>body ? response({ok:true,ticket:{...ticket,version:2,note:body.note}}) : response([ticket]);
app.hooks.ticketEvents=async()=>response({},500);
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-status').textContent,/失败.*重试/);
app.element('ticket-note').value='处理结果'; app.element('ticket-note').listeners.input();
await app.element('ticket-save').onclick(); await flush();
assert.match(app.element('ticket-status-message').textContent,/备注已保存/);
app.hooks.ticketEvents=async()=>response([{id:2,action:'completed',actor_name:'客服',ctime:'today',new_note:'结果'}]);
await app.element('ticket-history-reload').onclick();
assert.match(app.element('ticket-history-list').innerHTML,/处理结果:结果/);
});
test('staff writes include CSRF and logout revokes the session before redirecting', async () => {
const app = await workspace();
await app.context.logoutStaff();
const logout = app.calls.find(call=>call.url==='/admin/logout');
assert.equal(logout.headers['X-CSRF-Token'], 'fixture-csrf');
assert.equal(app.calls.filter(call=>call.redirect==='/admin').length, 1);
});
test('a rejected logout remains actionable and does not claim success', async () => {
const app = await workspace();
app.hooks.logout=async()=>response({},500);
await app.context.logoutStaff();
assert.equal(app.element('staff-logout').disabled, false);
assert.match(app.element('staff-logout-status').textContent,/退出失败/);
assert.equal(app.calls.filter(call=>call.redirect).length,0);
});
test('expired staff sessions redirect once and stop sending further polling requests', async () => {
const app = await workspace();
app.hooks.queue=async()=>response({},401);
app.hooks.active=async()=>response({},401);
await app.context.loadQueue();
await app.tick(1000);
await app.tick(3000);
const count=app.calls.length;
await app.context.loadQueue();
assert.equal(app.calls.length,count);
assert.equal(app.calls.filter(call=>call.redirect==='/admin?expired=1').length,1);
});
test('duplicate logout clicks send only one request', async () => {
const app=await workspace();
let finish;
app.hooks.logout=()=>new Promise(resolve=>{ finish=resolve; });
const first=app.context.logoutStaff();
await app.context.logoutStaff();
assert.equal(app.calls.filter(call=>call.url==='/admin/logout').length,1);
finish(response({ok:true}));
await first;
});
test('a hanging reply times out, preserves the newer draft and waits for manual retry', async () => {
const app=await workspace();
app.hooks.send=(body,options)=>waitForAbort(options);
app.element('replyText').value='先发送的回复';
const sending=app.context.sendReply();
app.element('replyText').value='继续补充的草稿';
app.element('replyText').listeners.input();
await app.runTimers(10000);
assert.equal(app.element('reply-send').disabled,false);
await sending;
assert.equal(app.context.sendingSid,'');
assert.equal(app.element('replyText').value,'继续补充的草稿');
assert.match(app.element('reply-status').textContent,/发送超时.*内容已保留.*确认聊天记录/);
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,1);
app.hooks.send=()=>response({ok:true});
await app.context.sendReply();
assert.equal(app.element('replyText').value,'');
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,2);
});
test('a hung message poll times out and the next interval resumes from the last received message', async () => {
const app=await workspace();
app.context.sessions['7'].lastId=12;
app.hooks.poll=(url,options)=>waitForAbort(options);
const polling=app.context.poll();
await flush();
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/poll?sid=7&after=12').length,1);
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.poll=()=>response({status:'active',messages:[{id:13,side:'customer',content:'断线恢复后的新消息',ctime:'2026-11-02 12:00'}]});
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
assert.equal(app.context.sessions['7'].lastId,13);
assert.equal(app.context.sessions['7'].msgs.length,1);
await app.tick(1000);
assert.equal(app.context.sessions['7'].msgs.length,1);
assert.ok(app.calls.some(call=>call.url==='/api/human/poll?sid=7&after=13'));
});
test('a hung active-session list releases the poll lock on timeout and reconnects', async () => {
const app=await workspace();
app.hooks.active=options=>waitForAbort(options);
const polling=app.context.poll();
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.active=()=>response([{id:7}]);
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
});
test('the timeout also covers a response body that never finishes and is cleared after failures', async () => {
const app=await workspace();
app.hooks.queue=options=>({ok:true,json:()=>waitForAbort(options)});
const loading=app.context.loadQueue();
await flush();
await app.runTimers(10000);
assert.match(app.element('queue').innerHTML,/队列暂时无法加载/);
await loading;
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([],503);
await app.context.loadQueue();
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([]);
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/当前没有排队会话/);
assert.equal(app.timers.size,0);
});
test('failed replies preserve the draft and make retry available', async () => {
const app = await workspace();
app.hooks.send = () => response({ok:false}, 503);
@@ -614,37 +254,6 @@ test('failed transfers retain the conversation and draft', async () => {
assert.match(app.element('reply-status').textContent, /转接未成功/);
});
test('reassignment removes the old owners composer but retains unsent text for recovery', async () => {
const app = await workspace();
app.element('replyText').value = '尚未发送的处理意见';
app.hooks.poll = () => response({status:'active',owner_username:'bob',messages:[]});
await app.context.poll();
assert.equal(app.context.curSid,0);
assert.equal(app.context.sessions['7'],undefined);
assert.equal(app.context.drafts['7'],'尚未发送的处理意见');
assert.equal(app.element('routing-draft-recovery').hidden,false);
assert.match(app.element('routing-retired-drafts').innerHTML,/尚未发送的处理意见/);
});
test('moving a session to an offline group preserves the previous owners draft', async () => {
const app = await workspace();
app.element('replyText').value = '需要转交的草稿';
app.hooks.poll = () => response({status:'waiting',owner_username:null,messages:[]});
await app.context.poll();
assert.equal(app.context.curSid,0);
assert.equal(app.context.drafts['7'],'需要转交的草稿');
assert.match(app.element('routing-retired-drafts').innerHTML,/需要转交的草稿/);
});
test('cancelling the in-page transfer confirmation leaves the session untouched', async () => {
const app = await workspace(false, {confirmResult:false});
await app.context.transferSession();
assert.equal(app.dialogs.length, 1);
assert.match(app.dialogs[0].message, /确定转接/);
assert.equal(app.calls.filter(call => call.url === '/api/human/transfer').length, 0);
assert.equal(app.context.curSid, '7');
});
test('a late note response cannot overwrite a different session note', async () => {
const app = await workspace();
app.context.sessions['7'].noteLoaded = false;
-86
View File
@@ -1,86 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin_orders.js'), 'utf8');
const flush = () => new Promise(setImmediate);
class Element {
constructor(){
this.value = '';
this.files = [];
this.hidden = false;
this.disabled = false;
this.listeners = {};
this.dataset = {};
this.textContent = '';
this.classList = {toggle(){}};
}
addEventListener(name, callback){ this.listeners[name] = callback; }
insertAdjacentHTML(_position, html){ this.html = (this.html || '') + html; }
}
function response(data, status = 200){
return {ok: status >= 200 && status < 300, status, json: async () => data};
}
async function workspace(){
const elements = new Map();
const document = {
querySelector(selector){
return selector === 'meta[name="staff-csrf-token"]' ? {content:'csrf-test'} : null;
},
getElementById(id){
if(!elements.has(id)) elements.set(id, new Element());
return elements.get(id);
},
querySelectorAll(){ return []; },
};
let listLoads = 0;
let refreshOrdersFails = false;
const fetchCalls = [];
const context = vm.createContext({
document,
window:{location:{assign(){}}},
location:{assign(){}},
URLSearchParams,
FormData:class { append(){} },
AbortSignal,
SupportUI:{confirm:async()=>true},
fetch:async (url, options = {})=>{
fetchCalls.push({url, options});
if(url.startsWith('/api/admin/orders/list')){
listLoads++;
if(refreshOrdersFails && listLoads > 1) return response({error:'暂时无法刷新订单列表'}, 503);
return response({orders:[],total:0,next_before:null});
}
if(url === '/api/admin/orders/import/batches') return response({batches:[]});
if(url === '/api/admin/orders/import/preview') return response({ok:true,
summary:{total_rows:1,valid_rows:1,duplicate_rows:0,error_rows:0},
preview:[{row:2,tenant_id:'tenant-a',order_no:'ORDER000001',status:'DRAFT'}],errors:[]});
if(url === '/api/admin/orders/import') return response({ok:true,imported:1,preserved:0});
return response({ok:true});
},
});
vm.runInContext(source, context, {filename:'admin_orders.js'});
await flush();
return {element:id=>document.getElementById(id), fetchCalls,
failNextRefresh(){ refreshOrdersFails = true; }};
}
test('successful import stays reported as successful when refreshing the order list fails', async () => {
const app = await workspace();
const file = app.element('order-file');
file.files = [{name:'orders.csv'}];
file.listeners.change();
await app.element('order-import-form').listeners.submit({preventDefault(){}});
app.failNextRefresh();
await app.element('confirm-import').listeners.click();
assert.match(app.element('import-status').textContent, /导入已完成/);
assert.match(app.element('order-list-status').textContent, /暂时无法刷新订单列表/);
assert.equal(app.fetchCalls.filter(call=>call.url === '/api/admin/orders/import').length, 1);
});
-128
View File
@@ -1,128 +0,0 @@
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 source = fs.readFileSync(path.join(__dirname, '../../static/js/admin_routing.js'), 'utf8');
function snapshot(online = false){
return {ok:true, username:'alice', presence:{online},
groups:[{id:1,name:'综合客服'},{id:2,name:'售后组'}],
agents:[{username:'alice',name:'客服甲',group_id:1,group_name:'综合客服',online,active_count:1},
{username:'bob',name:'客服乙',group_id:2,group_name:'售后组',online:true,active_count:0}],
sessions:[{id:7,group_id:1,group_name:'综合客服',owner_username:'alice',owner_name:'客服甲',status:'active',version:3}],
events:[]};
}
function desk(){
const elements = new Map(), calls = [], hooks = {};
function element(id){
if(!elements.has(id)) elements.set(id, {value:'',innerHTML:'',textContent:'',hidden:false,disabled:false,
dataset:{},attributes:{},listeners:{},classList:{toggle(){},add(){},remove(){}},
setAttribute(name,value){this.attributes[name]=value;},
addEventListener(name,listener){this.listeners[name]=listener;}});
return elements.get(id);
}
const context = vm.createContext({window:{},document:{getElementById:element},console});
vm.runInContext(source,context);
const api = context.window.AdminRouting.create({username:'alice',
request:async(url,options)=>{
const data = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url,data});
if(hooks.request) return hooks.request(url,data);
if(url === '/api/admin/routing') return snapshot();
return {ok:true,online:data && data.online};
},changed(){calls.push({changed:true});}});
return {api,element,calls,hooks};
}
test('loading the desk shows offline without silently enabling automatic assignment',async()=>{
const app = desk();
await app.api.refresh();
assert.match(app.element('routing-presence-text').textContent,/离线/);
assert.equal(app.calls.filter(item=>item.data && item.data.online === true).length,0);
assert.match(app.element('routing-members').innerHTML,/客服乙/);
assert.match(app.element('routing-members').innerHTML,/售后组/);
});
test('heartbeat continues while offline and never sends an online claim',async()=>{
const app = desk();
await app.api.refresh();
await app.api.heartbeat();
const sent = app.calls.find(item=>item.url === '/api/admin/routing/presence');
assert.deepEqual(sent.data,{});
assert.match(app.element('routing-presence-text').textContent,/离线/);
});
test('failed presence change retains the confirmed status and provides retry feedback',async()=>{
const app = desk();
await app.api.refresh();
app.hooks.request=async(url)=>{ if(url.endsWith('/presence')) throw new Error('network'); return snapshot(); };
await app.api.setPresence();
assert.match(app.element('routing-presence-text').textContent,/离线/);
assert.match(app.element('routing-status').textContent,/未确认/);
assert.equal(app.element('routing-presence-toggle').disabled,false);
});
test('changing group only offers online members of that group for reassignment',async()=>{
const app = desk();
await app.api.refresh();
app.element('routing-session').value='7';
app.element('routing-session').listeners.change();
app.element('routing-target-group').value='2';
app.element('routing-target-group').listeners.change();
assert.match(app.element('routing-target-owner').innerHTML,/bob/);
assert.doesNotMatch(app.element('routing-target-owner').innerHTML,/alice/);
});
test('refresh does not silently rebase the selected assignment version',async()=>{
const app = desk();
await app.api.refresh();
app.element('routing-session').value='7';
app.element('routing-session').listeners.change();
app.element('routing-target-group').value='2';
app.element('routing-target-owner').value='bob';
const updated = snapshot(); updated.sessions[0].version=4;
app.hooks.request=async(url)=>url === '/api/admin/routing' ? updated : {ok:true};
await app.api.refresh();
await app.api.assign();
const sent=app.calls.find(item=>item.url === '/api/admin/routing/assign');
assert.equal(sent.data.version,3);
assert.equal(sent.data.username,'bob');
});
test('a late background refresh cannot override a confirmed presence switch',async()=>{
const app=desk(); await app.api.refresh();
let resolve;
app.hooks.request=async(url)=>url === '/api/admin/routing' ? new Promise(done=>{resolve=done;}) : {ok:true,online:true};
const pending=app.api.refresh();
const switching=app.api.setPresence();
await switching;
resolve(snapshot(false)); await pending;
assert.match(app.element('routing-presence-text').textContent,/在线/);
});
test('a manual refresh during a presence write cannot restore an older availability state',async()=>{
const app=desk(); await app.api.refresh();
let confirmWrite, confirmRead;
app.hooks.request=(url)=>url === '/api/admin/routing' ? new Promise(done=>{confirmRead=done;}) :
new Promise(done=>{confirmWrite=done;});
const switching=app.api.setPresence();
const refreshing=app.api.refresh();
confirmWrite({ok:true,online:true}); await switching;
if(confirmRead) confirmRead(snapshot(false));
await refreshing;
assert.match(app.element('routing-presence-text').textContent,/在线/);
});
test('an unavailable selected owner is not silently replaced with automatic queueing',async()=>{
const app=desk(); await app.api.refresh();
app.element('routing-session').value='7'; app.element('routing-session').listeners.change();
app.element('routing-target-group').value='2'; app.element('routing-target-group').listeners.change();
app.element('routing-target-owner').value='bob';
const updated=snapshot(); updated.agents[1].online=false;
app.hooks.request=async(url)=>url === '/api/admin/routing' ? updated : {ok:true};
await app.api.refresh(); await app.api.assign();
const sent=app.calls.find(item=>item.url === '/api/admin/routing/assign');
assert.equal(sent.data.username,'bob');
});
+351 -332
View File
@@ -5,49 +5,6 @@ const path = require('node:path');
const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
test('mobile settings opens, dismisses with Escape and restores focus', async () => {
const app = await customerPage({human:false, width:390});
const toggle = app.element('settings-toggle'), panel = app.element('settings-panel');
assert.equal(panel.hidden, true);
await app.click(toggle);
assert.equal(panel.hidden, false);
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
assert.equal(app.context.document.activeElement, app.element('notifications-toggle'));
app.documentEvent('keydown', {key:'Escape', preventDefault(){}});
assert.equal(panel.hidden, true);
assert.equal(app.context.document.activeElement, toggle);
await app.click(toggle);
app.documentEvent('pointerdown', {target:app.element('chatDom')});
assert.equal(panel.hidden, true);
});
test('settings closes for typing and desktop settings remain available after resizing', async () => {
const app = await customerPage({human:false, width:390});
const panel = app.element('settings-panel');
await app.click(app.element('settings-toggle'));
app.element('input-text').focus();
assert.equal(panel.hidden, true);
assert.ok(app.element('.chat-container').classList.contains('is-composing'));
app.context.window.innerWidth = 1200;
await app.resize(800);
assert.equal(panel.hidden, false);
assert.equal(app.element('.chat-container').classList.contains('is-composing'), false);
app.context.window.innerWidth = 390;
await app.resize(800);
assert.equal(panel.hidden, true);
});
test('mobile settings keeps text size working and translates its accessible name', async () => {
const app = await customerPage({human:false, width:390});
await app.click(app.element('settings-toggle'));
const font = app.element('font-toggle'), before = font.textContent;
await app.click(font);
assert.notEqual(font.textContent, before);
await app.click(app.element('lang-toggle'));
assert.equal(app.element('settings-toggle').getAttribute('aria-label'), 'More settings');
assert.equal(app.element('settings-panel').getAttribute('aria-label'), 'More settings');
});
async function customerPage(pageOptions = {}){
const storageValues = pageOptions.storage || {};
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
@@ -57,12 +14,11 @@ async function customerPage(pageOptions = {}){
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 = {}, dialogs = [];
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
let voiceOptions;
class Element {
constructor(tagName = 'div'){
this.tagName = tagName.toUpperCase();
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.focusCalls = []; this.isConnected = true;
@@ -84,7 +40,6 @@ 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)); }
before(child){ this.beforeElement = child; }
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(); } }
@@ -103,6 +58,7 @@ async function customerPage(pageOptions = {}){
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
if(selector === 'button[data-order-lookup]' && this.attributes['data-order-lookup'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
@@ -114,15 +70,16 @@ async function customerPage(pageOptions = {}){
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: tagName => new Element(tagName),
createElement: () => new Element(),
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:[]};
let customerState=pageOptions.customer || null;
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
FormData:class { constructor(){ this.values = {}; } append(key,value){ this.values[key] = value; } },
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
@@ -137,7 +94,14 @@ async function customerPage(pageOptions = {}){
clearTimeout:id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(hooks[url]){
const result=await hooks[url](options);
if(url==='/api/customer/login' || url==='/api/customer/logout'){
const data=await result.json();
if(result.ok && data.ok) customerState=url==='/api/customer/login' ? data.user : null;
}
return result;
}
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
@@ -151,8 +115,7 @@ async function customerPage(pageOptions = {}){
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/customer/session' && pageOptions.customer ? {ok:true,authenticated:true,user:pageOptions.customer}
: url === '/api/customer/orders' ? {ok:true,orders:pageOptions.orders || [],next_before:null,summary:pageOptions.orderSummary}
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(customerState),user:customerState}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
@@ -160,22 +123,19 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.js', 'customer_orders.js', 'drafts.js', 'voice.js', 'attachments.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
context.ChatVoice.create=options => {voiceOptions=options; return createVoice(options);};
}
}
context.SupportUI.confirm = async (message, options) => { dialogs.push({type:'confirm', message, options}); return pageOptions.confirmValue ?? true; };
context.SupportUI.prompt = async (message, options) => { dialogs.push({type:'prompt', message, options}); return pageOptions.promptValue ?? pageOptions.offlineMessage ?? null; };
listeners.DOMContentLoaded();
await flush();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, hooks, dialogs, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
windowEvent:(name,event)=>{ (windowListeners[name] || []).forEach(callback=>callback(event)); },
resize:async height => {
context.window.innerHeight=height;
if(context.window.visualViewport) context.window.visualViewport.height=height;
@@ -223,79 +183,6 @@ function streamResponse(chunks){
: {done:true}})}};
}
test('returning to the page refreshes orders after validating the same customer and coalesces foreground events', async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[
{id:41,order_no:'ORDER41',status:'PROCESSING'}],orderSummary:{total:1,processing:1}});
const before=app.calls.filter(call=>call.url==='/api/customer/orders').length;
app.element('input-text').value='正在输入的草稿';
app.hooks['/api/customer/orders']=()=>response({orders:[{id:42,order_no:'ORDER42',status:'COMPLETED'}],
next_before:null,summary:{total:2,processing:1,completed:1,pending:0,cancelled:0,other:0}});
app.context.document.hidden=true;
app.documentEvent('visibilitychange');app.windowEvent('focus');await flush();
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before);
app.context.document.hidden=false;
app.documentEvent('visibilitychange');app.windowEvent('focus');await flush();
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before+1);
assert.match(app.element('customer-orders-toggle').textContent,/2/);
assert.match(app.element('customer-orders-list').textContent,/ORDER42/);
assert.equal(app.element('input-text').value,'正在输入的草稿');
});
test('returning with an expired login clears orders instead of refreshing protected data',async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[{id:41,order_no:'ORDER41'}]});
const before=app.calls.filter(call=>call.url==='/api/customer/orders').length;
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
app.windowEvent('focus');await flush();
assert.equal(app.element('customer-orders').hidden,true);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before);
});
test('a failed session check on return retains orders and a later return retries normally',async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[{id:41,order_no:'ORDER41'}]});
const before=app.calls.filter(call=>call.url==='/api/customer/orders').length;
app.hooks['/api/customer/session']=()=>Promise.reject(new Error('offline'));
app.windowEvent('focus');await flush();
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before);
assert.match(app.element('customer-orders-list').textContent,/ORDER41/);
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:true,user:{id:22,name:'客户'}});
app.hooks['/api/customer/orders']=()=>response({orders:[{id:42,order_no:'ORDER42'}],next_before:null});
app.windowEvent('focus');await flush();
assert.match(app.element('customer-orders-list').textContent,/ORDER42/);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before+1);
});
test('an existing customer session automatically shows scoped orders and translates the panel', async () => {
const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[
{id:41,order_no:'ORDER20260041',inbound_code:'IN20260041',status:'PROCESSING',transport_mode:'SEA'}],
orderSummary:{total:26,processing:20,completed:1,pending:4,cancelled:0,other:1}});
assert.equal(app.element('customer-orders').hidden,false);
assert.match(app.element('customer-orders-list').textContent,/IN20260041/);
assert.match(app.element('customer-orders-toggle').textContent,/26/);
assert.match(app.element('customer-orders-summary').textContent,/履约中20/);
await app.click(app.element('lang-toggle'));
assert.match(app.element('customer-orders-toggle').textContent,/My orders/);
assert.match(app.element('customer-orders-list').textContent,/In progress/);
assert.match(app.element('customer-orders-summary').textContent,/Completed1/);
assert.match(app.element('customer-orders-progress').textContent,/Loaded 1 of 26/);
app.hooks['/api/customer/logout']=()=>response({ok:true});
await app.click(app.element('customer-account'));
assert.equal(app.element('customer-orders').hidden,true);
assert.equal(app.element('customer-orders-list').textContent,'');
});
test('order errors explain the actual failure and translate without displaying server diagnostics',async()=>{
const app=await customerPage({human:false,customer:{id:22,name:'客户'}});
app.hooks['/api/customer/orders']=()=>response({error:'private server traceback'},503);
await app.click(app.element('customer-orders-refresh'));
assert.match(app.element('customer-orders-message').textContent,/订单服务暂时不可用/);
assert.doesNotMatch(app.element('customer-orders-message').textContent,/网络|private|traceback/);
await app.click(app.element('lang-toggle'));
assert.match(app.element('customer-orders-message').textContent,/temporarily unavailable/);
app.hooks['/api/customer/orders']=()=>Promise.reject(new TypeError('Failed to fetch'));
await app.click(app.element('customer-orders-retry'));
assert.match(app.element('customer-orders-message').textContent,/Check your network/);
});
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
const app=await customerPage({human:false});
let resolve;
@@ -314,7 +201,53 @@ test('switching to a quick topic discards the previous topic attachment', async
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
app.context.window.__pendingTag='[文件]';
await app.click(app.quickButtons[1]);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '美国海运专线报价');
});
test('the homepage and exception shortcut match the US service in both languages', async () => {
for(const language of ['zh','en']){
const app=await customerPage({human:false,storage:{'wuliu-lang':language}});
await app.click(app.element('.new-chat-btn'));
const html=app.element('chatDom').innerHTML;
assert.doesNotMatch(html,/欧洲|中欧|英国物流|EUROPE|Europe|理赔|file a claim/);
assert.match(html,language==='zh'?/中美物流服务/:/CHINA–US LOGISTICS/);
assert.match(html,language==='zh'?/美国海运专线报价/:/US sea freight quote/);
assert.match(html,language==='zh'?/查询异常订单/:/View exception orders/);
const question=language==='zh'?'我有多少个异常订单?':'How many exception orders do I have?';
assert.ok(html.includes('data-question="'+question+'"'));
await app.click(app.quickButtons[2]);
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,question);
}
});
test('login keeps the chat layout and an overview is requested only by a chat question', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
assert.equal(app.calls.filter(call=>call.url.includes('overview=1') || call.url==='/stream_chat').length,0);
const template=fs.readFileSync(path.join(__dirname,'../../templates/index.html'),'utf8');
assert.doesNotMatch(template,/customer-overview|js\/overview\.js/);
await app.context.window.fillInput('我的订单概览');
await flush();
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,'我的订单概览');
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
});
test('voice errors use a floating notice without changing the composer status', async () => {
const app=await customerPage({human:false,width:390});
const status=app.element('composer-status');
app.voiceOptions.status('语音输入失败,请重试。',true);
const feedback=app.element('voice-feedback');
assert.equal(feedback.hidden,false);
assert.equal(feedback.textContent,'语音输入失败,请重试。');
assert.ok(feedback.classList.contains('is-error'));
assert.equal(status.textContent,'');
app.voiceOptions.status('语音输入失败,请重试。',true);
assert.equal(feedback.hidden,false);
await app.runTimers(4000);
assert.equal(feedback.hidden,true);
app.voiceOptions.status('语音输入失败,请重试。',true);
await app.click(app.element('.new-chat-btn'));
assert.equal(feedback.hidden,true);
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
@@ -393,7 +326,7 @@ test('switching topics aborts an upload and does not erase the new conversation
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await app.click(app.quickButtons[1]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
@@ -425,34 +358,16 @@ test('clearing history blocks new conversations until its timed request settles'
app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'})));
});
await app.click(app.element('.clear-all-btn'));
app.element('.clear-all-btn').click();
await app.click(app.quickButtons[1]);
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
await app.click(app.quickButtons[1]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('history clearing can be cancelled through the in-page confirmation', async () => {
const app = await customerPage({human:false, confirmValue:false, history:[{id:9,title:'保留历史',ctime:'now'}]});
await app.click(app.element('.clear-all-btn'));
assert.equal(app.dialogs.length, 1);
assert.equal(app.dialogs[0].type, 'confirm');
assert.match(app.dialogs[0].message, /清空全部历史/);
assert.equal(app.calls.filter(call => call.url === '/api/clear_all').length, 0);
});
test('tracking lookup uses the in-page input prompt', async () => {
const app = await customerPage({human:false, promptValue:'UK12345'});
await app.context.window.sendOrder();
await flush();
assert.equal(app.dialogs[0].type, 'prompt');
assert.match(app.dialogs[0].message, /运单号/);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('a failed history deletion preserves the active conversation for the next message', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
@@ -475,7 +390,7 @@ test('successful clearing removes cached history when the followup refresh fails
const app=await customerPage({human:false,history:[{id:9,title:'待清空会话',ctime:'now'}]});
app.hooks['/api/clear_all']=() => response({ok:true});
app.hooks['/api/get_history']=() => Promise.reject(new Error('offline'));
await app.click(app.element('.clear-all-btn'));
app.element('.clear-all-btn').click();
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/待清空会话/);
assert.ok(app.element('historyWrap').querySelector('.history-empty'));
@@ -520,13 +435,16 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
test('tracking does not interrupt human support while quote and exception shortcuts start AI topics', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
await app.click(app.quickButtons[0]);
assert.equal(app.context.window.humanSid,7);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
for(const button of app.quickButtons.slice(1)) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['按入仓码查轨迹', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(requests.length, 2);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2);
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '我有多少个异常订单?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
@@ -534,14 +452,14 @@ test('all three footer questions leave the human queue and send to separate AI c
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
for(const question of ['美国海运专线报价', '各个轨迹节点分别有多少订单?']){
const button = app.element('question-'+question);
button.dataset.question = question;
app.element('chatDom').listeners.click({target:button});
await flush();
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '各个轨迹节点分别有多少订单?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
});
@@ -581,8 +499,8 @@ test('a repeated quick click while a new conversation is being created sends onl
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
app.quickButtons[1].click();
app.quickButtons[1].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
@@ -593,14 +511,14 @@ test('a late conversation creation cannot replace the newest quick question', as
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
app.quickButtons[1].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 1);
assert.equal(requests[0].body.conv_id, 22);
assert.equal(requests[0].body.msg, '包裹异常处理');
assert.equal(requests[0].body.msg, '我有多少个异常订单?');
assert.equal(app.context.window.__busy, false);
});
@@ -642,8 +560,6 @@ test('the contact shortcut outside service hours submits a ticket without enteri
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, 1);
assert.equal(app.dialogs[0].type, 'prompt');
assert.equal(app.dialogs[0].options.multiline, true);
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);
@@ -762,12 +678,188 @@ test('inbound codes are the primary card identity for live replies and restored
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB20261029001-01');await flush();
let heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/);
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
});
async function submitOrderLookup(app,reference){
app.element('order-reference').value=reference;
app.element('order-reference').listeners.input();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});await flush();
}
test('the lookup shortcut keeps the current conversation, draft and attachment until a real reference is submitted', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
await app.context.window.fillInput('原来的聊天');await flush();
app.element('input-text').value='尚未发送的草稿';app.element('input-text').listeners.input();
app.context.window.__pendingImg='/uploads/next-image.png';
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('.left-panel').classList.contains('left-panel-hide'),false);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
await submitOrderLookup(app,'hello');
assert.match(app.element('order-lookup-error').textContent,/完整/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
await submitOrderLookup(app,' INB202611040001 ');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(last.body.conv_id,1);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
assert.equal(app.element('order-lookup').hidden,true);
assert.match(app.element('chatDom').textContent,/查询入仓码 INB202611040001/);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
assert.equal(app.context.window.__pendingImg,'/uploads/next-image.png');
});
test('the guest shortcut signs in before opening lookup and sends no empty order question', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:true,user:{id:1,name:'admin'}});
app.element('input-text').value='游客原草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('customer-login').hidden,false);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat' || call.url==='/stream_chat').length,0);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('input-text').value,'游客原草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.element('input-text').value,'游客原草稿');
});
test('live and saved inbound references open a prefilled form and copy only the code', async () => {
const copied=[];
const app=await customerPage({human:false,customer:{id:1,name:'admin'},history:[{id:9,title:'历史订单',ctime:'now'}],
clipboard:{writeText:async value=>{copied.push(value);}}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[{key:'inbound',value:'INB202611040001'}],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB202611040001');await flush();
await app.click(app.element('chatDom').querySelector('.order-reference'));
assert.equal(app.element('order-reference').value,'INB202611040001');
await app.click(app.element('order-reference-copy'));
assert.deepEqual(copied,['INB202611040001']);
assert.equal(app.element('order-reference-copy').textContent,'已复制');
await app.click(app.element('order-lookup-cancel'));
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
await app.click(app.element('chatDom').querySelector('.order-reference'));
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card:{...card,nodes:[{time:'2026-11-04 12:00',text:'最新物流节点',place:''}]}})+'\n\ndata: {"d":1}\n\n']);
await submitOrderLookup(app,'INB202611040001');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.conv_id,9);assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(app.element('chatDom').querySelectorAll('.track-card').length,2);
assert.match(app.element('chatDom').querySelectorAll('.track-card').at(-1).textContent,/最新物流节点/);
});
test('an order without an inbound code keeps its truthful order-number label and lookup kind', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput(card.no);await flush();
const header=app.element('chatDom').querySelector('.track-no');
assert.match(header.textContent,/订单号/);assert.doesNotMatch(header.textContent,/入仓码/);
await app.click(header.querySelector('.order-reference'));
assert.equal(app.element('order-reference-label').textContent,'订单号');
await submitOrderLookup(app,card.no);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').at(-1).body.msg,'查询订单 '+card.no);
});
test('lookup copy failure is visible and a retry succeeds without changing the typed code', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},copyResult:false,
clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.click(app.quickButtons[0]);
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'复制失败');
assert.match(app.element('order-lookup-error').textContent,/复制失败/);
app.context.document.execCommand=()=>true;
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'已复制');
assert.equal(app.element('order-lookup-error').hidden,true);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.context.document.body.children.length,0);
});
test('a duplicated lookup submit sends once and a failed creation keeps the reference editable', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});let release;
await app.click(app.quickButtons[0]);
app.hooks['/api/new_chat']=()=>new Promise(resolve=>{release=resolve;});
const pending=submitOrderLookup(app,'INB202611040001');await flush();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
release(response({ok:false},503));await pending;
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.element('order-reference').disabled,false);
app.hooks['/api/new_chat']=()=>response({id:11,welcome:'welcome'});
await submitOrderLookup(app,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
});
test('lookup does not stop an active AI reply or clear a pending draft', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
app.hooks['/stream_chat']=()=>({ok:true,body:{getReader:()=>({read:()=>new Promise(()=>{})})}});
await app.context.window.fillInput('正在回复的问题');await flush();
app.element('input-text').value='下一条草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.context.window.__busy,true);
assert.match(app.element('composer-status').textContent,/等待当前操作/);
assert.equal(app.element('input-text').value,'下一条草稿');
await app.click(app.element('send-button'));
});
test('a late shortcut sign-in cannot open a private lookup after the user changes topics', async () => {
const app=await customerPage({human:false});let release;
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
await app.click(app.quickButtons[0]);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='新话题草稿';app.element('input-text').listeners.input();
release(response({ok:true,user:{id:1,name:'admin'}}));await signing;
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.element('input-text').value,'新话题草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('welcome lookup uses the same form and follows the mobile keyboard viewport', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390,viewportHeight:844});
const welcome=app.element('lookup-welcome');welcome.setAttribute('data-order-lookup','');
app.element('chatDom').listeners.click({target:welcome});await flush();
assert.equal(app.element('order-lookup').hidden,false);
await app.resizeViewport(430,1,80);
assert.equal(app.element('order-lookup').style.height,'430px');
assert.equal(app.element('order-lookup').style.top,'80px');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('a late repeated shortcut status response cannot erase the latest lookup input', async () => {
const user={id:1,name:'admin'}, app=await customerPage({human:false,customer:user}), releases=[];
app.hooks['/api/customer/session']=()=>new Promise(resolve=>{releases.push(resolve);});
const first=app.quickButtons[0].click(), second=app.quickButtons[0].click();await flush();
releases[1](response({ok:true,authenticated:true,user}));await second;await flush();
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
releases[0](response({ok:true,authenticated:true,user}));await first;await flush();
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('closing lookup restores the shortcut instead of reopening the composer keyboard', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390});
const input=app.element('input-text');input.focus();
await app.click(app.quickButtons[0]);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.context.document.activeElement,app.quickButtons[0]);
assert.equal(input.focusCalls.length,1);
assert.equal(app.quickButtons[0].focusCalls.at(-1).preventScroll,true);
});
test('order details can expand without hiding the shipment timeline', async () => {
@@ -1188,46 +1280,50 @@ test('invalid customer credentials leave the login form open without sending ano
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
});
test('mobile account entry shares login state and prevents duplicate actions while signing in', async () => {
const app=await customerPage({human:false,width:390});
let finishLogin;
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{finishLogin=resolve;});
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
const mobile=app.element('customer-account-mobile');
mobile.focus();
await app.click(mobile);
assert.equal(app.element('customer-login').hidden,false);
app.element('customer-username').value='customer';app.element('customer-password').value='test-password';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
assert.equal(mobile.disabled,true);
assert.equal(app.element('customer-account').disabled,true);
await app.click(mobile);
finishLogin(response({ok:true,authenticated:true,user:{id:3,name:'测试客户'}}));
await signing;
assert.equal(app.element('customer-login').hidden,true);
assert.equal(mobile.textContent,app.element('customer-account').textContent);
assert.match(mobile.textContent,/测试客户/);
assert.equal(app.context.document.activeElement,mobile);
await app.click(mobile);
assert.equal(mobile.textContent,'客户登录');
assert.equal(app.element('customer-account').textContent,'客户登录');
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
});
test('language switching updates input controls and sidebar accessible labels', async () => {
const app=await customerPage({human:false,width:390});
await app.click(app.element('lang-toggle'));
assert.equal(app.element('upload-btn').getAttribute('aria-label'),'Attach a file');
assert.equal(app.element('emoji-toggle').getAttribute('aria-label'),'Choose an emoji');
assert.equal(app.element('history-search').getAttribute('aria-label'),'Search conversation history');
assert.equal(app.element('.sidebar-backdrop').getAttribute('aria-label'),'Close history sidebar');
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'Expand sidebar');
assert.equal(app.element('customer-account-mobile').textContent,'Customer sign in');
await app.click(app.element('.toggle-divider'));
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'Collapse sidebar');
await app.click(app.element('lang-toggle'));
assert.equal(app.element('upload-btn').getAttribute('aria-label'),'发送附件');
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'收起侧栏');
test('reauthentication resumes an expired order followup only for the same customer and conversation', async () => {
for(const scenario of [{customer:1},{customer:2},{customer:1,denied:true},
{customer:1,edit:true},{customer:1,switched:true}]){
const nextCustomer=scenario.customer;
const app=await customerPage({human:false});let authenticated=false, customer=1;
let releaseHistory;
const user=()=>({id:customer,name:customer===1 ? 'admin' : 'other'});
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? user() : null});
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:user()});};
app.hooks['/api/get_history']=()=>response(authenticated ? [{id:1,title:'订单',ctime:'now'}] : []);
const history=scenario.denied ? [] : [{role:'user',content:'INBOUND20260001'},
{role:'assistant',content:'已查询的订单轨迹'}];
app.hooks.messages=()=>scenario.edit || scenario.switched
? new Promise(resolve=>{releaseHistory=()=>resolve(response(history));}) : response(history);
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'订单轨迹结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
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('INBOUND20260001');await flush();
authenticated=false;
await app.context.window.fillInput('现在到哪了?');await flush();
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
assert.equal(app.element('customer-login').hidden,false);
assert.doesNotMatch(app.element('chatDom').textContent,/订单轨迹结果/);
app.element('input-text').value='下一条未发送';app.element('input-text').listeners.input();
customer=nextCustomer;
app.element('customer-username').value=customer===1 ? 'admin' : 'other';
app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();await flush();
if(releaseHistory){
if(scenario.switched) await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='恢复期间的新输入';app.element('input-text').listeners.input();
releaseHistory();await flush();await flush();
}
const resumed=nextCustomer===1 && !scenario.denied && !scenario.switched;
const sends=app.calls.filter(call=>call.url==='/stream_chat');
assert.equal(sends.length,resumed ? 3 : 2);
if(resumed){
assert.equal(sends[2].body.conv_id,1);assert.equal(sends[2].body.msg,'现在到哪了?');
assert.match(app.element('chatDom').textContent,/订单轨迹结果/);
assert.match(app.element('chatDom').textContent,/现在到哪了?/);
}
assert.equal(app.element('input-text').value,releaseHistory ? '恢复期间的新输入' : '下一条未发送');
}
});
test('a late login cannot replay an order into a newer conversation', async () => {
@@ -1256,11 +1352,25 @@ test('signing out clears visible order conversation and returns to guest mode',
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('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('signing out removes customer history titles even if refreshing guest history fails', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'INBOUND20260001 客户订单',ctime:'now'}]});
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 flush();
assert.match(app.element('historyWrap').textContent,/INBOUND20260001/);
app.hooks['/api/get_history']=()=>response({ok:false},503);
await app.click(app.element('customer-account'));
assert.doesNotMatch(app.element('historyWrap').textContent,/INBOUND20260001/);
assert.equal(app.element('customer-account').textContent,'登录');
});
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
const app=await customerPage({human:false});
let shown;
@@ -1273,128 +1383,37 @@ test('an unavailable image gets one readable replacement instead of a broken ima
assert.equal(shown,first);
});
test('pasting a screenshot uses the upload endpoint without sending and preserves the draft', async () => {
const app = await customerPage({human:false});
const file = {name:'screenshot.png',type:'image/png'};
let uploaded, prevented = false;
app.hooks['/api/upload'] = options => {
uploaded = options.body.values.file;
return response({ok:true,url:'/uploads/screenshot.png',name:file.name,is_image:true});
};
const input = app.element('input-text');
input.value = '请帮我看一下';
input.listeners.paste({clipboardData:{files:[file]},preventDefault(){prevented=true;}});
test('an explicit typed human request opens one real queue without an AI question', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('请帮我转人工客服');
assert.equal(app.calls.filter(c=>c.url==='/api/visitor/human/request').length,1);
assert.equal(app.calls.filter(c=>c.url==='/stream_chat').length,0);
assert.equal(app.context.window.humanSid,7);
assert.equal(app.element('input-text').value,'');
});
test('a failed typed transfer keeps the command and an information question uses AI', async () => {
const app=await customerPage({human:false});
app.hooks['/api/visitor/human/request']=()=>response({ok:false},503);
await app.context.window.fillInput('帮我转人工客服');
assert.equal(app.element('input-text').value,'帮我转人工客服');
assert.equal(app.context.window.humanSid,0);
await app.context.window.fillInput('不需要人工客服,人工几点上班?');
assert.equal(app.calls.filter(c=>c.url==='/api/visitor/human/request').length,1);
assert.equal(app.calls.filter(c=>c.url==='/stream_chat').length,1);
});
test('a typed transfer preserves a newer draft while connecting and cancelled offline input', async () => {
const app=await customerPage({human:false});
let resolve;
app.hooks['/api/visitor/human/request']=()=>new Promise(done=>{resolve=done;});
const sending=app.context.window.fillInput('帮我转人工客服');
await flush();
assert.equal(prevented,true);
assert.equal(uploaded,file);
assert.equal(app.context.window.__pendingImg,'/uploads/screenshot.png');
assert.equal(input.value,'请帮我看一下');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
assert.ok(app.element('.input-area').beforeElement);
});
test('plain text paste and text dragging keep their normal editing behavior', async () => {
const app=await customerPage({human:false});
const event={clipboardData:{files:[]},preventDefault(){throw new Error('Plain text paste was intercepted');}};
app.element('input-text').listeners.paste(event);
app.element('.main-chat').listeners.dragover({dataTransfer:{types:['text/plain']},preventDefault(){throw new Error('Text drag was intercepted');}});
assert.equal(app.calls.filter(call=>call.url==='/api/upload').length,0);
});
test('dropping one file uploads once and clears the drop hint', async () => {
const app=await customerPage({human:false});
const zone=app.element('.main-chat'), file={name:'orders.pdf',type:'application/pdf'};
let uploaded;
app.hooks['/api/upload']=options=>{
uploaded=options.body.values.file;
return response({ok:true,url:'/uploads/orders.pdf',name:file.name,is_image:false});
};
const event={dataTransfer:{types:['Files'],files:[file]},preventDefault(){}};
zone.listeners.dragenter(event);
assert.equal(app.element('attachment-drop-hint').hidden,false);
zone.listeners.drop(event);
app.documentEvent('drop',event);
await flush();
assert.equal(uploaded,file);
assert.equal(app.calls.filter(call=>call.url==='/api/upload').length,1);
assert.equal(app.element('attachment-drop-hint').hidden,true);
assert.equal(app.context.window.__pendingTag,'[文件]');
});
test('multiple dropped files are rejected explicitly instead of silently discarding extras', async () => {
const app=await customerPage({human:false});
app.element('.main-chat').listeners.drop({dataTransfer:{types:['Files'],files:[{name:'a.png'},{name:'b.png'}]},preventDefault(){}});
assert.equal(app.calls.filter(call=>call.url==='/api/upload').length,0);
assert.match(app.element('composer-status').textContent,/一次.*一个/);
});
test('dropping a folder explains the restriction without trying to upload it as a file', async () => {
const app=await customerPage({human:false});
const zone=app.element('.main-chat');
const transfer={types:['Files'],files:[{name:'css',type:''}],
items:[{kind:'file',webkitGetAsEntry:()=>({isDirectory:true})}]};
zone.listeners.dragenter({dataTransfer:transfer,preventDefault(){}});
zone.listeners.drop({dataTransfer:transfer,preventDefault(){}});
assert.equal(app.calls.filter(call=>call.url==='/api/upload').length,0);
assert.match(app.element('composer-status').textContent,/不支持上传文件夹/);
assert.equal(app.element('attachment-drop-hint').hidden,true);
});
test('a folder mixed with a file is rejected together and the folder message translates', async () => {
const app=await customerPage({human:false});
await app.click(app.element('lang-toggle'));
app.element('.main-chat').listeners.drop({dataTransfer:{types:['Files'],files:[{name:'fa.jpg'}],
items:[{kind:'file',webkitGetAsEntry:()=>({isDirectory:false})},{kind:'file',webkitGetAsEntry:()=>({isDirectory:true})}]},preventDefault(){}});
assert.equal(app.calls.filter(call=>call.url==='/api/upload').length,0);
assert.match(app.element('composer-status').textContent,/Folders cannot be uploaded/);
});
test('another paste cannot start a parallel upload and a rejected upload preserves the draft', async () => {
const app=await customerPage({human:false});
let release;
app.hooks['/api/upload']=()=>new Promise(resolve=>{release=resolve;});
const event={clipboardData:{files:[{name:'bad.exe'}]},preventDefault(){}};
app.element('input-text').value='保留我的问题';
app.element('input-text').listeners.paste(event);
app.element('input-text').listeners.paste(event);
assert.equal(app.calls.filter(call=>call.url==='/api/upload').length,1);
release(response({ok:false,error:'不支持的格式'},400));
await flush();
assert.match(app.element('composer-status').textContent,/不支持的格式/);
assert.equal(app.element('input-text').value,'保留我的问题');
assert.equal(app.element('upload-btn').disabled,false);
});
test('dropping outside the chat prevents browser navigation without uploading', async () => {
const app=await customerPage({human:false});
let prevented=false;
app.documentEvent('drop',{dataTransfer:{types:['Files'],files:[{name:'a.png'}]},preventDefault(){prevented=true;}});
assert.equal(prevented,true);
assert.equal(app.calls.filter(call=>call.url==='/api/upload').length,0);
});
test('nested file dragging keeps its hint until leaving the zone even if dragleave hides file types', async () => {
const app=await customerPage({human:false});
const zone=app.element('.main-chat'), hint=app.element('attachment-drop-hint');
const enter={dataTransfer:{types:['Files']},preventDefault(){}};
zone.listeners.dragenter(enter);
zone.listeners.dragenter(enter);
zone.listeners.dragleave({dataTransfer:{types:[]}});
assert.equal(hint.hidden,false);
zone.listeners.dragleave({dataTransfer:{types:[]}});
assert.equal(hint.hidden,true);
});
test('clipboard file items upload when the browser omits the files list', async () => {
const app=await customerPage({human:false});
const file={name:'clipboard.png',type:'image/png'};
let uploaded;
app.hooks['/api/upload']=options=>{
uploaded=options.body.values.file;
return response({ok:true,url:'/uploads/clipboard.png',name:file.name,is_image:true});
};
app.element('input-text').listeners.paste({clipboardData:{items:[{kind:'file',getAsFile:()=>file}]},preventDefault(){}});
await flush();
assert.equal(uploaded,file);
assert.equal(app.context.window.__pendingImg,'/uploads/clipboard.png');
app.element('input-text').value='新输入的订单问题';
resolve(response({ok:true,session_id:8}));await sending;
assert.equal(app.element('input-text').value,'新输入的订单问题');
const closed=await customerPage({human:false,serviceClosed:true});
await closed.context.window.fillInput('帮我转人工客服');
assert.equal(closed.element('input-text').value,'帮我转人工客服');
assert.equal(closed.calls.filter(c=>c.url==='/api/ticket').length,0);
});
-241
View File
@@ -1,241 +0,0 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const flush = async () => { for(let i=0;i<12;i++) await Promise.resolve(); };
function page(){
class Element {
constructor(){ this.children=[]; this.attributes={}; this.hidden=false; this.dataset={}; }
set textContent(value){ this.text=value; this.children=[]; }
get textContent(){ return (this.text || '')+this.children.map(x=>x.textContent).join(''); }
set innerHTML(value){ this.children=[]; this.text=value; }
append(...nodes){ this.children.push(...nodes); }
appendChild(node){ this.append(node); }
setAttribute(key,value){ this.attributes[key]=String(value); }
focus(){ this.focused=true; }
}
const ids={}, requests=[], timers=new Map();
let timerId=0;
const el=id=>ids[id] ||= new Element();
const context=vm.createContext({document:{getElementById:el,createElement:()=>new Element()}, AbortController,
setTimeout:callback=>{const id=++timerId;timers.set(id,callback);return id;},
clearTimeout:id=>timers.delete(id)});
vm.runInContext(fs.readFileSync('static/js/customer_orders.js','utf8'),context);
const orders=context.CustomerOrders.create({text:key=>key === 'ordersProgress' ? 'Loaded {loaded} of {total}' : key,
fetch:(url,options)=>new Promise((resolve,reject)=>requests.push({url,options,resolve,reject})),
renderCard:(element,card)=>{element.textContent=card.no;},
onUnauthorized:()=>{page.expired=true;}});
return {el,orders,requests,timeout:()=>{for(const callback of Array.from(timers.values())) callback();},reply:async (i,data,status=200)=>{
requests[i].resolve({ok:status===200,status,json:async()=>data}); await flush();
}};
}
const row=(id=1)=>({id,order_no:'ORDER'+id,inbound_code:'INBOUND'+id,status:'PROCESSING'});
const counts={total:26,pending:4,processing:20,completed:1,cancelled:0,other:1};
const filter=(p,name)=>p.el('customer-orders-summary').children.find(button=>button.dataset.filter===name);
test('overview uses full server totals; a status click filters from the first page',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:1,summary:counts});
assert.match(p.el('customer-orders-toggle').textContent,/26/);
assert.match(filter(p,'processing').textContent,/20/);
filter(p,'completed').onclick();
assert.equal(p.requests[1].url,'/api/customer/orders?status=completed');
assert.equal(p.el('customer-orders-list').textContent,'');
await p.reply(1,{orders:[row(9)],next_before:null,summary:counts});
assert.equal(filter(p,'completed').attributes['aria-pressed'],'true');
assert.equal(filter(p,'all').attributes['aria-pressed'],'false');
assert.match(p.el('customer-orders-progress').textContent,/1/);
filter(p,'processing').onclick();
await p.reply(2,{orders:[row()],next_before:1,summary:counts});
p.el('customer-orders-more').onclick();
assert.equal(p.requests[3].url,'/api/customer/orders?status=processing&before=1');
await p.reply(3,{orders:[row(2)],next_before:null,summary:counts});
assert.match(p.el('customer-orders-list').textContent,/INBOUND1.*INBOUND2/);
});
test('network failure and timeout show different recoverable messages',async()=>{
const p=page();p.orders.setUser({id:1});
p.requests[0].reject(new TypeError('Failed to fetch'));await flush();
assert.equal(p.el('customer-orders-message').textContent,'ordersNetworkError');
assert.equal(p.el('customer-orders-retry').hidden,false);
p.el('customer-orders-retry').onclick();p.timeout();
assert.equal(p.requests[1].options.signal.aborted,true);
p.requests[1].reject(Object.assign(new Error('abort'),{name:'AbortError'}));await flush();
assert.equal(p.el('customer-orders-message').textContent,'ordersTimeout');
assert.equal(p.el('customer-orders-retry').hidden,false);
p.el('customer-orders-retry').onclick();
await p.reply(2,{orders:[row()],next_before:null});
assert.equal(p.el('customer-orders-message').hidden,true);
});
test('HTTP errors are classified without showing raw server output',async()=>{
for(const [status,key,retryable] of [[400,'ordersInvalidRequest',false],[403,'ordersForbidden',false],
[429,'ordersRateLimited',true],[500,'ordersServerError',true],[502,'ordersServerError',true],
[503,'ordersServerError',true],[408,'ordersTimeout',true],[504,'ordersTimeout',true],[418,'ordersFailed',true]]){
const p=page();p.orders.setUser({id:1});
await p.reply(0,{error:'private traceback with secret'},status);
assert.equal(p.el('customer-orders-message').textContent,key,'status '+status);
assert.equal(p.el('customer-orders-retry').hidden,!retryable,'status '+status);
assert.doesNotMatch(p.el('customer-orders-message').textContent,/private|secret/);
}
});
test('missing order directs the customer back to the list instead of retrying a missing record',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
p.el('customer-orders-list').children[0].onclick();
await p.reply(1,{error:'not found or forbidden'},404);
assert.equal(p.el('customer-orders-message').textContent,'ordersUnavailable');
assert.equal(p.el('customer-orders-back').hidden,false);
assert.equal(p.el('customer-orders-retry').hidden,true);
p.el('customer-orders-back').onclick();
assert.equal(p.el('customer-orders-message').hidden,true);
assert.equal(p.el('customer-orders-list').hidden,false);
});
test('malformed successful responses are not reported as a network outage',async()=>{
const p=page();p.orders.setUser({id:1});
p.requests[0].resolve({ok:true,status:200,json:async()=>{throw new SyntaxError('Unexpected <');}});await flush();
assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse');
p.el('customer-orders-retry').onclick();
await p.reply(1,{orders:'invalid'});
assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse');
});
test('an error page from a proxy uses HTTP status even when the body is not JSON',async()=>{
const p=page();p.orders.setUser({id:1});
let parsed=false;
p.requests[0].resolve({ok:false,status:502,json:async()=>{parsed=true;throw new SyntaxError('html');}});
await flush();
assert.equal(parsed,false);
assert.equal(p.el('customer-orders-message').textContent,'ordersServerError');
});
test('switching filters ignores late results; logout erases counts and selection',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
filter(p,'completed').onclick();filter(p,'pending').onclick();
await p.reply(2,{orders:[],next_before:null,summary:counts});
await p.reply(1,{orders:[row(9)],next_before:null,summary:{...counts,total:999}});
assert.doesNotMatch(p.el('customer-orders-toggle').textContent,/999/);
assert.equal(p.el('customer-orders-list').textContent,'');
assert.equal(p.el('customer-orders-message').textContent,'ordersFilterEmpty');
p.orders.setUser(null);
assert.equal(p.el('customer-orders-summary').hidden,true);
assert.doesNotMatch(p.el('customer-orders-summary').textContent,/26|20/);
});
test('login automatically opens list; collapse/reopen and paging keep drafts outside panel untouched',async()=>{
const p=page();p.orders.setUser({id:1});
assert.equal(p.el('customer-orders').hidden,false);
assert.equal(p.el('customer-orders-toggle').attributes['aria-expanded'],'true');
await p.reply(0,{orders:[row()],next_before:1});
assert.match(p.el('customer-orders-list').textContent,/INBOUND1/);
p.el('customer-orders-toggle').onclick();
assert.equal(p.el('customer-orders-body').hidden,true);
p.el('customer-orders-toggle').onclick();
p.el('customer-orders-more').onclick();
assert.equal(p.requests[1].url,'/api/customer/orders?before=1');
await p.reply(1,{orders:[row(2)],next_before:null});
assert.match(p.el('customer-orders-list').textContent,/INBOUND1.*INBOUND2/);
assert.equal(p.el('customer-orders-more').hidden,true);
});
test('logout clears list and ignores delayed list/detail responses from a previous account',async()=>{
const p=page();p.orders.setUser({id:1});p.orders.setUser(null);
await p.reply(0,{orders:[row()],next_before:null});
assert.equal(p.el('customer-orders').hidden,true);
assert.equal(p.el('customer-orders-list').textContent,'');
p.orders.setUser({id:2});await p.reply(1,{orders:[row(2)],next_before:null});
p.el('customer-orders-list').children[0].onclick();
p.orders.setUser(null);p.orders.setUser({id:3});
await p.reply(2,{card:{no:'private-old-account'}});
assert.doesNotMatch(p.el('customer-orders-detail').textContent,/private/);
p.orders.setUser(null);
await p.reply(3,{orders:[],next_before:null});
});
test('exact order detail and back preserve list without a new AI request',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row(9)],next_before:null});
p.el('customer-orders-list').children[0].onclick();
assert.equal(p.requests[1].url,'/api/customer/orders/9');
await p.reply(1,{card:{no:'ORDER9'}});
assert.equal(p.el('customer-orders-detail').textContent,'ORDER9');
p.el('customer-orders-back').onclick();
assert.equal(p.el('customer-orders-list').hidden,false);
assert.equal(p.requests.length,2);
});
test('failed request offers retry; expired login immediately clears protected data',async()=>{
const p=page();p.orders.setUser({id:1});
p.requests[0].reject(new Error('offline'));await flush();
assert.equal(p.el('customer-orders-retry').hidden,false);
p.el('customer-orders-retry').onclick();
await p.reply(1,{orders:[row()],next_before:null});
p.el('customer-orders-refresh').onclick();
await p.reply(2,{error:'customer_login_required'},401);
assert.equal(p.el('customer-orders').hidden,true);
assert.equal(p.el('customer-orders-list').textContent,'');
});
test('refresh cancels pagination; stale errors cannot replace the refreshed list',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:1});
p.el('customer-orders-more').onclick();
p.el('customer-orders-refresh').onclick();
await p.reply(2,{orders:[row(3)],next_before:null});
p.requests[1].reject(new Error('late error'));await flush();
assert.equal(p.el('customer-orders-retry').hidden,true);
assert.doesNotMatch(p.el('customer-orders-list').textContent,/INBOUND1/);
assert.match(p.el('customer-orders-list').textContent,/INBOUND3/);
});
test('automatic refresh keeps the filter, old rows, scroll and collapsed state until success',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
filter(p,'processing').onclick();
await p.reply(1,{orders:[row(2)],next_before:2,summary:counts});
p.el('customer-orders-body').scrollTop=85;
p.el('customer-orders-toggle').onclick();
p.orders.refresh();p.orders.refresh();
assert.equal(p.requests.length,3);
assert.equal(p.requests[2].url,'/api/customer/orders?status=processing');
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
await p.reply(2,{orders:[row(3)],next_before:null,summary:{...counts,total:27,processing:21}});
assert.match(p.el('customer-orders-toggle').textContent,/27/);
assert.match(p.el('customer-orders-list').textContent,/INBOUND3/);
assert.equal(p.el('customer-orders-body').scrollTop,85);
assert.equal(p.el('customer-orders-body').hidden,true);
assert.equal(filter(p,'processing').attributes['aria-pressed'],'true');
});
test('failed automatic refresh retains previous data and retry can update it',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:1,summary:counts});
p.orders.refresh();p.requests[1].reject(new Error('offline'));await flush();
assert.match(p.el('customer-orders-list').textContent,/INBOUND1/);
assert.match(p.el('customer-orders-toggle').textContent,/26/);
assert.equal(p.el('customer-orders-retry').hidden,false);
p.el('customer-orders-retry').onclick();
await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}});
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
});
test('automatic refresh keeps the open tracking detail and logout invalidates late responses',async()=>{
const p=page();p.orders.setUser({id:1});
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
p.el('customer-orders-list').children[0].onclick();
await p.reply(1,{card:{no:'ORDER1'}});
p.orders.refresh();
await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}});
assert.equal(p.el('customer-orders-detail').textContent,'ORDER1');
assert.equal(p.el('customer-orders-list').hidden,true);
p.el('customer-orders-back').onclick();
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
p.orders.refresh();p.orders.setUser(null);
await p.reply(3,{orders:[row(99)],summary:counts});
assert.equal(p.el('customer-orders-list').textContent,'');
assert.equal(p.el('customer-orders').hidden,true);
});
-122
View File
@@ -1,122 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
function createSupportUI(){
let document;
class Element {
constructor(tagName = 'div'){
this.tagName = tagName.toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.style = {};
this.value = '';
this.parent = null;
this.className = '';
this.text = '';
}
set textContent(value){ this.text = String(value); this.children = []; }
get textContent(){ return this.text + this.children.map(child => child.textContent).join(''); }
appendChild(child){ child.parent = this; this.children.push(child); return child; }
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); this.parent = null; }
setAttribute(name, value){ this.attributes[name] = String(value); }
getAttribute(name){ return this.attributes[name] ?? null; }
addEventListener(name, callback){ (this.listeners[name] ||= []).push(callback); }
contains(target){ return this === target || this.children.some(child => child.contains(target)); }
querySelectorAll(selector){
const tags = selector.split(',').map(value => value.trim().toUpperCase());
return this.children.flatMap(child => [
...(tags.includes(child.tagName) ? [child] : []),
...child.querySelectorAll(selector),
]);
}
focus(){ document.activeElement = this; }
dispatch(name, event = {}){
event.target ||= this;
event.preventDefault ||= function(){ this.defaultPrevented = true; };
event.stopPropagation ||= function(){};
if(this['on' + name]) this['on' + name](event);
(this.listeners[name] || []).forEach(callback => callback(event));
}
click(){ this.dispatch('click'); }
}
document = {
body: new Element('body'),
activeElement: null,
createElement: tagName => new Element(tagName),
};
const context = vm.createContext({document, window:{}});
vm.runInContext(source, context, {filename:'support.js'});
return {ui:context.SupportUI, document};
}
test('confirm resolves from the in-page dialog and restores focus', async () => {
const {ui, document} = createSupportUI();
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
const result = ui.confirm('确定清空记录?', {title:'请确认', confirmText:'清空', cancelText:'取消'});
await Promise.resolve();
const dialog = document.body.children[1].children[0];
assert.equal(dialog.getAttribute('role'), 'dialog');
assert.equal(dialog.getAttribute('aria-modal'), 'true');
assert.match(dialog.textContent, /确定清空记录?/);
const [cancel, confirm] = dialog.querySelectorAll('button');
assert.equal(document.activeElement, cancel);
confirm.click();
assert.equal(await result, true);
assert.equal(document.body.children.length, 1);
assert.equal(document.activeElement, trigger);
});
test('confirm returns false when cancelled with Escape', async () => {
const {ui, document} = createSupportUI();
const result = ui.confirm('是否继续?');
await Promise.resolve();
const dialog = document.body.children[0].children[0];
const event = {key:'Escape'};
dialog.dispatch('keydown', event);
assert.equal(event.defaultPrevented, true);
assert.equal(await result, false);
assert.equal(document.body.children.length, 0);
});
test('prompt returns entered text and focuses its labelled input', async () => {
const {ui, document} = createSupportUI();
const result = ui.prompt('请输入运单号', {title:'查询物流', inputLabel:'运单号'});
await Promise.resolve();
const dialog = document.body.children[0].children[0];
const input = dialog.querySelectorAll('input')[0];
assert.ok(input);
assert.equal(input.getAttribute('aria-label'), '运单号');
assert.equal(document.activeElement, input);
input.value = 'AB123';
dialog.querySelectorAll('button')[1].click();
assert.equal(await result, 'AB123');
});
test('prompt returns null when the backdrop is clicked', async () => {
const {ui, document} = createSupportUI();
const result = ui.prompt('请输入留言');
await Promise.resolve();
document.body.children[0].click();
assert.equal(await result, null);
});
test('dialog keeps keyboard focus inside while tabbing', async () => {
const {ui, document} = createSupportUI();
ui.confirm('确认操作?');
await Promise.resolve();
const dialog = document.body.children[0].children[0];
const [cancel, confirm] = dialog.querySelectorAll('button');
cancel.focus();
dialog.dispatch('keydown', {key:'Tab', shiftKey:true});
assert.equal(document.activeElement, confirm);
dialog.dispatch('keydown', {key:'Tab'});
assert.equal(document.activeElement, cancel);
});
+20
View File
@@ -97,6 +97,26 @@ test('short presses and unavailable service do not upload recordings',async()=>{
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
});
test('a failed physical hold cannot restart until it has been released',async()=>{
for(const touch of [false,true]){
const app=voicePage({enabled:false,touch});
app.open();
const point={identifier:7,clientY:300};
const down=async()=>{
if(touch) app.elements.hold.listeners.touchstart({touches:[point],preventDefault(){}});
else app.elements.hold.listeners.pointerdown(app.event());
await flush();
};
await down();await down();
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,1);
if(touch) app.elements.hold.listeners.touchend({changedTouches:[point],preventDefault(){}});
else app.elements.hold.listeners.pointerup(app.event());
await down();
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,2);
assert.equal(app.permissionCalls,0);
}
});
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
app.open();await app.down();await app.up();grant(app.stream);await flush();
-18
View File
@@ -1,18 +0,0 @@
"""Authenticate test clients through the production login and CSRF contracts."""
from logistics_chat import db
def login_staff(app, client, username='staff-test-fixture', password='fixture-password', seed=True):
if seed:
with app.app_context():
connection = db.connect()
connection.execute('INSERT INTO agents(username,password,name) VALUES(?,?,?)',
(username, password, 'Fixture Staff'))
connection.commit()
response = client.post('/admin/login', json={'username':username, 'password':password})
assert response.status_code == 200, response.status_code
session = client.get('/api/admin/session')
assert session.status_code == 200, session.status_code
client.environ_base['HTTP_X_CSRF_TOKEN'] = session.json['csrf_token']
-17
View File
@@ -14,7 +14,6 @@ from unittest.mock import patch
import requests
from logistics_chat import create_app, db
from staff_fixture import login_staff
class ModelResponse:
@@ -46,7 +45,6 @@ class ApiTests(unittest.TestCase):
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
login_staff(self.app, self.client)
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.app.extensions['rate_hits'].clear()
from logistics_chat.order_data import import_records
@@ -315,21 +313,6 @@ class ApiTests(unittest.TestCase):
self.assertEqual(self.client.get('/api/human/active').json, [])
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'closed')
def test_human_queue_marks_items_over_five_minutes_using_server_clock(self):
from datetime import datetime, timezone, timedelta
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 29, 12, 0, tzinfo=timezone(timedelta(hours=8)))
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
before = self.client.get('/api/human/queue').json[0]
self.assertEqual((before['waiting_minutes'],before['overdue']), (0,False))
clock.now.return_value = datetime(2026, 10, 29, 12, 5, tzinfo=timezone(timedelta(hours=8)))
boundary = self.client.get('/api/human/queue').json[0]
self.assertFalse(boundary['overdue'])
clock.now.return_value = datetime(2026, 10, 29, 12, 5, 1, tzinfo=timezone(timedelta(hours=8)))
after = self.client.get('/api/human/queue').json[0]
self.assertEqual(after['id'], sid)
self.assertEqual((after['waiting_minutes'],after['overdue'],after['overdue_minutes']), (5,True,1))
def test_application_state_is_not_shared_between_factories(self):
self.app.extensions['human_typing']['1'] = 123
self.app.extensions['rate_hits']['127.0.0.1'].append(123)
+14
View File
@@ -49,6 +49,20 @@ class CustomerTests(unittest.TestCase):
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_common_tracking_requests_require_login_without_calling_the_model(self):
questions = ('查物流', '帮我查一下物流', '物流查询', '查货', '帮我查下轨迹',
'我的货到哪了', '我的包裹到哪里了?', '这票货什么时候到?',
'Where is my parcel?', 'Track my package', 'What is my shipment status?')
with patch('logistics_chat.ai_client.post_completion') as model:
for question in questions:
with self.subTest(question=question):
self.assertTrue(self.events(question)[0].get('login_required'))
self.login()
for question in questions:
with self.subTest(authenticated_question=question):
self.assertIn('请提供入仓码', self.events(question)[0]['t'])
model.assert_not_called()
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')
+254
View File
@@ -0,0 +1,254 @@
"""Oral-language, US-region isolation and scoped-page regressions using synthetic facts."""
from decimal import Decimal
from pathlib import Path
import re
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.order_data import import_records
from logistics_chat.pricing import detect_dim_unit,dims_to_cm
from logistics_chat.quote_data import import_snapshot
from logistics_chat.quote_intent import parse_intent
from logistics_chat.quote_engine import quantity
from test_quotes import fixture_snapshot,frames
class CustomerGapTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'qa-a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client();self.client.post('/api/visitor/session',json={'key':'1'*64})
book=fixture_snapshot()
book['warehouses'].append({'code':'YYY2','zone':'西1区','state':'AA','postal':'90001',
'address':'','source_sheet':'合成分区','source_cell':'A8'})
texts=['偏远地址加收4元/kg;超偏远地址加收6元/kg','铁铝制品另外加附加费¥5/KG',
'正常货物 0KG<单件重量<22KG 0CM<最长单边长度<120CM 0CM<第二边<75CM 最长边<260CM',
'超重收+70/件','客户自行交货,不提供上门提货服务',
'禁止运输的货物:纯电池、液体','需熏蒸的货物由客户在备货阶段自行完成']
book['documents']=[{'sheet':'合成渠道','cell':f'A{i+1}','text':text} for i,text in enumerate(texts)]
self.order={'tenant_id':'qa-a','order_no':'FOCUS20260001','inbound_code':'QAFOCUS20260001',
'country_code':'US','status':'COMPLETED','package_count':2,
'tracking_json':[{'code':'IN_TRANSIT','actual_at':'2026-10-01 10:00'}]}
with self.app.app_context():
c=db.connect();import_snapshot(c,book,activate=True)
import_records(c,[self.order,{**self.order,'tenant_id':'qa-b','order_no':'OTHER20260099','inbound_code':'QAOTHER20260099'}])
c.commit();c.close()
def conversation(self):return self.client.post('/api/new_chat').json['id']
def events(self,q,cid=None,client=None):
with patch('logistics_chat.ai_client.post_completion') as model:
result=frames((client or self.client).post('/stream_chat',json={'msg':q,'conv_id':cid}))
model.assert_not_called()
self.assertEqual(result[-1],{'d':1},result)
return result
def ask(self,q,cid=None):return self.events(q,cid)[0]['t']
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def test_us_natural_questions_never_enter_old_regional_model_faqs(self):
cases=('你们这个报价是按毛重还是净重?','运费能便宜一点吗?','这个价格含税含派送吗?',
'一共多少钱,给我最终总价','这个物流服务范围具体怎么执行?')
for q in cases:
with self.subTest(q=q):
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
text=self.ask(q,cid)
self.assertNotIn('21KG',text);self.assertNotIn('250',text);self.assertNotIn('7元/KG',text)
text=self.ask('我想发点货到美国,怎么收费?')
self.assertIn('元/KG',text);self.assertIn('多少公斤',text)
def test_final_total_respects_tax_labels_and_keeps_unconfirmed_fees_separate(self):
cid=self.conversation();self.ask('美国美西2箱每箱15KG,40x30x20cm,义乌普船海派',cid)
text=self.ask('一共多少钱,给我最终总价',cid)
self.assertIn('30KG = 60元',text);self.assertIn('· 包税',text)
self.assertNotIn('价格不含税',text);self.assertIn('最终应付总价',text)
self.assertIn('折扣或合作价格需人工确认',self.ask('运费能便宜一点吗',cid))
def test_negative_warehouse_and_origin_edits_do_not_keep_rejected_values(self):
cid=self.conversation();self.ask('美国30KG发ZZZ1,深圳普船海派',cid)
text=self.ask('不是发ZZZ1,是YYY2',cid)
self.assertNotIn('ZZZ1',text);self.assertIn('YYY2',text)
text=self.ask('不要深圳,还是义乌交仓',cid)
self.assertIn('· 义乌',text);self.assertNotIn('· 华南',text)
text=self.ask('50KG呢',cid)
self.assertIn('YYY2',text);self.assertNotIn('ZZZ1',text);self.assertIn('美国 50KG',text)
def test_inches_convert_before_charge_weight_and_acceptance_limits(self):
intent=parse_intent('美国30KG,60x40x30英寸,1箱普船海派',set())
self.assertEqual(intent['dim_unit'],'in')
self.assertEqual(dims_to_cm([60,40,30],'in'),[152.4,101.6,76.2])
self.assertGreater(quantity(intent,{'kind':'parcel','unit':'kg'}),Decimal('190'))
text=self.ask('美国美西30KG,60x40x30英寸,1箱普船海派,义乌')
self.assertIn('人工确认是否接收',text)
for text in ('60x40x30 inches','60x40x30in','60x40x30"'):
with self.subTest(text=text):
self.assertEqual(detect_dim_unit(text),'in')
self.assertIn('人工确认是否接收',self.ask('美国美西30KG,'+text+',1箱普船海派,义乌'))
self.assertEqual(detect_dim_unit('60x40x30,合计1立方米'),'cm')
self.assertEqual(detect_dim_unit('60x40x30,合计1m3'),'cm')
self.assertEqual(detect_dim_unit('1.2x0.8x0.6m呢'),'m')
def test_spoken_carton_and_dimensions_are_used_without_reasking(self):
intent=parse_intent('美国30KG一个箱子,60长40宽30高,怎么算?',set())
self.assertEqual(intent['boxes'],1);self.assertEqual(intent['dims'],[60,40,30])
text=self.ask('美国美西30KG一个箱子,60长40宽30高,义乌普船海派')
self.assertIn('最高项70元/件',text)
self.assertNotIn('请补充箱数',text)
def test_box_units_are_local_and_each_axis_can_carry_its_own_unit(self):
cases=(('12英寸显示器,包装60x40x30cm',[60,40,30],'cm',Decimal('30')),
('80cm×70cm×60cm',[80,70,60],'cm',Decimal('56')),
('80cmx70cmx60cm',[80,70,60],'cm',Decimal('56')),
('800mm*700mm*600mm',[800,700,600],'mm',Decimal('56')),
('长80厘米宽70厘米高60厘米',[80,70,60],'cm',Decimal('56')),
('0.8m×70cm×600mm',[80,70,60],'cm',Decimal('56')),
('12英寸显示器,包装60x40x30',[60,40,30],'cm',Decimal('30')))
for wording,dims,unit,charge in cases:
with self.subTest(wording=wording):
intent=parse_intent('美国美西30KG,'+wording+',1箱义乌普船海派',set())
self.assertEqual(intent['dims'],dims);self.assertEqual(intent['dim_unit'],unit)
self.assertEqual(quantity(intent,{'kind':'parcel','unit':'kg'}),charge)
text=self.ask('美国美西30KG,'+wording+',1箱义乌普船海派')
self.assertIn(f'{charge}KG =',text)
self.assertNotIn('请补充箱数',text)
def test_oral_partial_weight_edits_never_overwrite_the_whole_shipment(self):
cid=self.conversation()
self.ask('美国美西30KG,部分铁制品,铁铝部分计费重10KG,义乌普船海派',cid)
for q,amount in (('铁铝部分还是10KG',110),('铁铝部分改成8KG',100),('铁铝部分仍是8KG',100)):
text=self.ask(q,cid)
self.assertIn('美国 30KG',text);self.assertIn(f'30KG = {amount}元',text)
self.assertIn('大于0',self.ask('铁铝部分改为-1KG',cid))
fresh=self.conversation()
self.assertIn('请确认铁铝部分的计费重',self.ask('美国美西30KG,铁铝部分还是10KG,义乌普船海派',fresh))
self.assertNotIn('×10KG',self.ask('一共多少钱,给我最终总价',fresh))
def test_long_quote_chain_retains_conditions_but_topic_changes_stop_reuse(self):
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
for _ in range(15):self.assertIn('每箱不足12KG',self.ask('这个报价按毛重还是净重?',cid))
self.assertIn('美国 50KG',self.ask('50KG呢',cid))
self.assertIn('美国渠道',self.ask('这个物流服务范围具体怎么执行?',cid))
self.assertIn('请先',self.ask('下一页',cid))
from logistics_chat.quote_service import answer_quote
from logistics_chat.quote_scope import answer_us_fallback
with self.app.test_request_context('/'):
self.assertIsNone(answer_quote('新的一票50KG发德国报价',cid))
db.save_message(cid,'user','换个话题,我要查订单')
self.assertIsNone(answer_us_fallback('50KG呢',cid))
def test_comparing_two_weights_is_not_treated_as_two_cartons(self):
cid=self.conversation();self.ask('美国30KG海派报价',cid)
text=self.ask('单箱12.5KG会不会按13KG收费?',cid)
self.assertIn('按原小数计费,不向上取整',text)
self.assertIn('12.5KG',text)
self.assertNotIn('请明确总重',text)
def test_confirmed_fractional_weight_and_partial_metal_basis_are_used(self):
self.assertIn('12.5KG = 25元',self.ask('美国美西1箱12.5KG,40x30x20cm,义乌普船海派'))
self.assertIn('12.3456KG = 24.69元',self.ask('美国美西1箱12.3456KG,40x30x20cm,义乌普船海派'))
cid=self.conversation()
text=self.ask('美国美西30KG,部分铁制品,义乌普船海派',cid)
self.assertIn('30KG = 60元',text);self.assertIn('不能按整票重量加费',text)
self.assertNotIn('30KG = 210元',text)
text=self.ask('铁铝部分计费重10KG',cid)
self.assertIn('美国 30KG',text);self.assertIn('30KG = 110元',text)
self.assertIn('×10KG=50元',text)
self.assertIn('30KG = 110元',self.ask('一共多少钱,给我最终总价',cid))
text=self.ask('改成40KG',cid)
self.assertIn('当前金额未包含该项',text);self.assertNotIn('×10KG',text)
text=self.ask('全部换成铁制品40KG',cid)
self.assertIn('40KG = 280元',text)
def test_unknown_metal_and_cbm_quotes_do_not_spread_partial_fee_over_the_whole_load(self):
text=self.ask('美国美西30KG,只是金属扣子,整票都按铁铝加费吗,义乌普船海派')
self.assertIn('只按铁铝货物的计费重',text);self.assertIn('确认金属是否属于铁铝',text)
text=self.ask('美国30KG发ZZZ1,1方不包税海卡,部分铁制品,铁铝部分计费重10KG,义乌')
self.assertIn('1CBM = 150元',text);self.assertIn('×10KG=50元',text)
self.assertIn('大于0',self.ask('美国30KG,部分铁制品,铁铝部分计费重-1KG,义乌海派'))
text=self.ask('美国20KG发ZZZ1,10KG发YYY2,部分铁制品,铁铝部分计费重10KG,义乌普船海派')
self.assertIn('不能重复套用到每个仓',text)
self.assertIn('20KG = 100元',text);self.assertIn('12KG = 24元',text)
self.assertNotIn('铁铝附加费5元/KG×10KG',text)
def test_booking_phone_invoice_and_file_capabilities_do_not_invent_workflows(self):
cases=(('就按刚才方案帮我下单','尚未创建订单'),('手机号能查吗','暂不支持手机号'),
('你们可以开发票吗,开票加几个点','开票税点'),('付款账号发我一下','收款账号'),
('可以月结吗','月结资格'),('帮我导出订单Excel','暂不提供订单Excel导出'),
('Excel能直接读里面箱规报价吗','不会自动读取Excel'),
('我换了个手机,之前聊天记录还在吗','不会自动同步恢复'))
for q,expected in cases:
with self.subTest(q=q):self.assertIn(expected,self.ask(q))
with patch('logistics_chat.order_data.lookup_order') as lookup:
self.assertIn('暂不支持手机号',self.ask('手机号19900000000能查吗'))
lookup.assert_not_called()
with self.app.app_context():
c=db.connect();self.assertEqual(c.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0],2);c.close()
def test_address_and_pickup_questions_are_not_prices_and_english_stays_english(self):
cid=self.conversation();self.ask('美国30KG海派报价',cid)
self.assertIn('地址、联系人和收货时间',self.ask('义乌仓具体地址是什么?几点可以送?',cid))
text=self.ask('Can you pick up my goods in Shenzhen for US sea freight?')
self.assertIn('does not offer pickup',text);self.assertNotIn('CNY/kg',text)
self.assertFalse(re.search(r'[\u4e00-\u9fff]',text))
def test_air_battery_question_does_not_apply_sea_battery_requirements(self):
text=self.ask('想走美国空运,包税带电的,有吗?')
self.assertIn('空运费率当前资料没有列出',text);self.assertNotIn('MSDS',text)
self.assertIn('备货阶段',self.ask('美国木制品需要熏蒸吗'))
def seed_pages(self):
with self.app.app_context():
c=db.connect();import_records(c,[{'tenant_id':'qa-a','order_no':f'PAGE2026{i:06d}',
'inbound_code':f'QAPAGE2026{i:06d}','status':'PROCESSING'} for i in range(1,26)])
c.commit();c.close()
def test_pages_use_fresh_authorized_count_and_preserve_filter_and_private_history(self):
self.seed_pages()
self.assertTrue(self.events('下一页')[0]['login_required'])
self.login();cid=self.conversation()
text=self.ask('列出我的全部订单',cid)
self.assertIn('共26票',text);self.assertEqual(text.count('- 入仓码:'),20)
self.assertNotIn('QAOTHER',text)
text=self.ask('下一页',cid)
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6);self.assertIn('共26票',text)
self.assertIn('最后一页',self.ask('下一页',cid))
self.assertIn('第1页',self.ask('上一页',cid))
self.ask('列出履约中的订单',cid)
text=self.ask('下一页',cid)
self.assertIn('共25票',text);self.assertEqual(text.count('- 入仓码:'),5);self.assertNotIn('QAFOCUS',text)
self.assertIn('第1页',self.ask('第1页',cid))
self.client.post('/api/customer/logout')
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_page_commands_cannot_reuse_another_topic_or_unbounded_offset(self):
self.login();cid=self.conversation();self.ask('列出我的订单',cid)
self.assertIn('有效页码',self.ask('第0页',cid))
self.ask('列出我的订单',cid)
self.ask('美国30KG海派报价',cid)
self.assertIn('请先',self.ask('下一页',cid))
def test_count_interruptions_do_not_erase_the_active_list_page(self):
self.seed_pages();self.login();cid=self.conversation()
self.ask('列出我的全部订单',cid)
self.ask('我有多少个运单',cid)
text=self.ask('下一页',cid)
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6)
def test_clearance_followup_modification_and_unknown_payment_do_not_call_model(self):
self.assertTrue(self.events('我的订单已清关了吗')[0]['login_required'])
self.login();cid=self.conversation();self.events('QAFOCUS20260001',cid)
event=self.events('我的订单已清关了吗?',cid)[0]
self.assertEqual(event['card']['no'],'FOCUS20260001')
self.assertEqual(event['card']['tracking_status'],'海运途中')
self.assertIn('尚未修改订单',self.ask('帮我把收货地址改一下',cid))
self.assertIn('未接入付款状态',self.ask('这票订单付过款了吗',cid))
self.assertIn('不能直接当作快递单号',self.ask('这票对应的UPS快递单号是多少',cid))
self.assertIn('暂不支持按这个物流条件',self.ask('哪些订单还没开船',cid))
-159
View File
@@ -1,159 +0,0 @@
"""Customer order browsing uses real login and server-side tenant grants."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from werkzeug.security import generate_password_hash
from logistics_chat import create_app, db, order_data
class CustomerOrderTests(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': '', 'CUSTOMER_DEMO_ENABLED': False,
'ORDER_DATA_TENANT_ID': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key': '1'*64})
with self.app.app_context():
con = db.connect()
password = generate_password_hash('fixture-password')
for name, tenant in [('alice', 'a'), ('bob', 'b'), ('empty', '')]:
con.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
(name, password, name, tenant))
order_data.import_records(con, [dict(tenant_id='a', order_no=f'A2026{i:04}',
inbound_code=f'IN2026{i:04}', status='PROCESSING', data_note='private-staff-note',
source_order_id='private-source-id') for i in range(25)] +
[dict(tenant_id='b', order_no='A20260024', inbound_code='IN20260024', status='DRAFT')])
self.foreign_id = con.execute("SELECT id FROM order_test_data WHERE tenant_id='b'").fetchone()[0]
con.commit()
con.close()
def login(self, name='alice'):
result = self.client.post('/api/customer/login', json={'username': name, 'password': 'fixture-password'})
self.assertEqual(result.status_code, 200)
def test_login_required_and_logout_revokes_browsing(self):
self.assertEqual(self.client.get('/api/customer/orders').status_code, 401)
self.login()
self.assertEqual(self.client.get('/api/customer/orders').status_code, 200)
self.client.post('/api/customer/logout')
for path in ['/api/customer/orders', '/api/customer/orders/1']:
response = self.client.get(path)
self.assertEqual(response.status_code, 401)
self.assertIn('no-store', response.headers['Cache-Control'])
def test_scope_cannot_be_forged_and_summary_is_minimal(self):
self.login()
response = self.client.get('/api/customer/orders?tenant_id=b', headers={'X-Tenant-Id': 'b'})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data['orders']), 20)
self.assertNotIn(self.foreign_id, [item['id'] for item in data['orders']])
for item in data['orders']:
self.assertEqual(set(item), {'id', 'order_no', 'inbound_code', 'status', 'country_code', 'warehouse_code', 'transport_mode'})
self.assertNotIn('private', response.get_data(as_text=True))
self.assertIn('no-store', response.headers['Cache-Control'])
self.assertEqual(self.client.get(f'/api/customer/orders/{self.foreign_id}?tenant_id=b').status_code, 404)
def test_cursor_paging_and_invalid_cursor(self):
self.login()
first = self.client.get('/api/customer/orders').json
second = self.client.get('/api/customer/orders', query_string={'before': first['next_before']}).json
self.assertEqual(len(second['orders']), 5)
self.assertIsNone(second['next_before'])
self.assertEqual(first['summary'], {'total': 25, 'pending': 0, 'processing': 25,
'completed': 0, 'cancelled': 0, 'other': 0})
self.assertEqual(second['summary'], first['summary'])
self.assertFalse({x['id'] for x in first['orders']} & {x['id'] for x in second['orders']})
for value in ['-1', '0', 'NaN', '9223372036854775808']:
self.assertEqual(self.client.get('/api/customer/orders', query_string={'before': value}).status_code, 400)
def test_detail_selects_exact_order_even_with_duplicate_identifiers(self):
self.login()
row = self.client.get('/api/customer/orders').json['orders'][0]
result = self.client.get('/api/customer/orders/'+str(row['id']))
self.assertEqual(result.status_code, 200)
self.assertEqual(result.json['card']['no'], row['order_no'])
self.assertNotIn('private', result.get_data(as_text=True))
self.assertIn('no-store', result.headers['Cache-Control'])
self.assertEqual(self.client.get('/api/customer/orders/99999').status_code, 404)
def test_empty_scope_and_configured_tenant_restriction(self):
self.login('empty')
self.assertEqual(self.client.get('/api/customer/orders').json['orders'], [])
self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 0)
self.assertEqual(self.client.get('/api/customer/orders/1').status_code, 404)
self.app.config['ORDER_DATA_TENANT_ID'] = 'b'
self.login()
self.assertEqual(self.client.get('/api/customer/orders').json['orders'], [])
self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 0)
self.assertEqual(self.client.get('/api/customer/orders/1').status_code, 404)
def test_multiple_grants_and_revocation(self):
with self.app.app_context():
con = db.connect()
con.execute("UPDATE customer_users SET tenant_id='' WHERE username='alice'")
con.execute("INSERT INTO customer_tenant_grants SELECT id,'a' FROM customer_users WHERE username='alice'")
con.execute("INSERT INTO customer_tenant_grants SELECT id,'b' FROM customer_users WHERE username='alice'")
con.commit()
con.close()
self.login()
self.assertEqual(self.client.get('/api/customer/orders').json['orders'][0]['id'], self.foreign_id)
self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 26)
with self.app.app_context():
con = db.connect()
con.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='b'")
con.commit()
con.close()
self.assertEqual(self.client.get('/api/customer/orders').status_code, 401)
def test_unavailable_storage_has_recoverable_error(self):
self.login()
with patch('logistics_chat.customer_order_service.list_orders', side_effect=order_data.OrderDataUnavailable):
response = self.client.get('/api/customer/orders')
self.assertEqual(response.status_code, 503)
self.assertNotIn('sqlite', response.get_data(as_text=True))
def test_overview_counts_full_scope_and_filters_before_paging(self):
statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'COMPLETED',
'CANCELLED', 'REJECTED', 'UNKNOWN_FUTURE_STATUS']
with self.app.app_context():
con = db.connect()
for index, status in enumerate(statuses):
con.execute('UPDATE order_test_data SET status=? WHERE tenant_id=? AND order_no=?',
(status, 'a', f'A2026{index:04}'))
con.commit()
con.close()
self.login()
expected = {'total': 25, 'pending': 4, 'processing': 17, 'completed': 1, 'cancelled': 1, 'other': 2}
all_data = self.client.get('/api/customer/orders?tenant_id=b').json
self.assertEqual(all_data['summary'], expected)
for status, count in [('pending', 4), ('processing', 17), ('completed', 1), ('cancelled', 1), ('other', 2)]:
with self.subTest(status=status):
response = self.client.get('/api/customer/orders', query_string={'status': status, 'tenant_id': 'b'})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json['orders']), count)
self.assertEqual(response.json['summary'], expected)
self.assertIsNone(response.json['next_before'])
self.assertNotIn(self.foreign_id, [item['id'] for item in response.json['orders']])
self.assertEqual({x['status'] for x in self.client.get('/api/customer/orders?status=other').json['orders']},
{'REJECTED', 'UNKNOWN_FUTURE_STATUS'})
for status in ['bad', 'PROCESSING', "pending' OR 1=1", '']:
self.assertEqual(self.client.get('/api/customer/orders', query_string={'status': status}).status_code, 400)
def test_filtered_cursor_keeps_summary_and_does_not_include_other_statuses(self):
self.login()
first = self.client.get('/api/customer/orders?status=processing').json
second = self.client.get('/api/customer/orders', query_string={'status': 'processing', 'before': first['next_before']}).json
self.assertEqual(len(first['orders']), 20)
self.assertEqual(len(second['orders']), 5)
self.assertEqual(second['summary']['processing'], 25)
empty = self.client.get('/api/customer/orders?status=completed').json
self.assertEqual(empty['orders'], [])
self.assertEqual(empty['summary']['total'], 25)
+154
View File
@@ -0,0 +1,154 @@
"""Common pre-sale and order questions; all data and monetary values are synthetic."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.order_data import import_records
from logistics_chat.quote_data import import_snapshot
from test_quotes import fixture_snapshot,frames
class CustomerQuestionTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'qa-a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'1'*64})
book=fixture_snapshot()
book['warehouses'].append({'code':'YYY2','zone':'西1区','state':'AA','postal':'90001',
'address':'','source_sheet':'合成分区','source_cell':'A10'})
documents=['铁铝制品另外加附加费¥5/KG','客户自行送货,不提供上门提货服务',
'外箱要贴两张入仓条码与我司唛头标签,代贴收操作费',
'产品上和包装上必须有Made in China产地标识',
'单独报关需提供资料:装箱单、发票、报关委托书、合同',
'如遇查验,不包税货物的查验费用按客户所占货物比例均摊',
'正常货物 0KG<单件重量<22KG 0CM<最长单边长度<120CM 0CM<第二边<75CM 最长边<260CM',
'超重收+70/件']
book['documents']=[{'sheet':'合成渠道','cell':f'A{i}','text':text} for i,text in enumerate(documents)]
self.order={'tenant_id':'qa-a','order_no':'COMMON20260001','inbound_code':'QACOMMON20260001',
'status':'COMPLETED','package_count':2,'country_code':'US',
'tracking_json':[{'code':'DEPARTED_ORIGIN_PORT','actual_at':'2026-10-01 10:00'},
{'code':'ARRIVED_DESTINATION_PORT','estimated_at':'2026-10-20 10:00'}]}
with self.app.app_context():
c=db.connect();import_snapshot(c,book,activate=True)
import_records(c,[self.order,{**self.order,'tenant_id':'qa-b','order_no':'COMMON20260099','inbound_code':'QACOMMON20260099'}])
c.commit();c.close()
def conversation(self):return self.client.post('/api/new_chat').json['id']
def events(self,text,cid=None):
with patch('logistics_chat.ai_client.post_completion') as model:
result=frames(self.client.post('/stream_chat',json={'msg':text,'conv_id':cid}))
model.assert_not_called()
self.assertEqual(result[-1],{'d':1},result)
return result
def ask(self,text,cid=None):return self.events(text,cid)[0]['t']
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def test_explicit_weights_are_billed_by_destination_and_not_reused_as_each_total(self):
for text in ('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',
'美国ZZZ1:20KG,YYY2:10KG,义乌普船海派'):
answer=self.ask(text)
self.assertIn('美国 30KG',answer)
self.assertIn('20KG = 100元',answer)
self.assertIn('12KG = 24元',answer)
self.assertNotIn('30KG =',answer)
self.assertIn('分配重量不一致',self.ask('美国总重40KG,20KG发ZZZ1,10KG发YYY2,海派'))
def test_allocation_follows_origin_changes_but_a_single_new_destination_clears_it(self):
cid=self.conversation()
self.ask('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',cid)
text=self.ask('深圳交仓呢',cid)
self.assertIn('20KG = 120元',text);self.assertIn('12KG = 36元',text)
text=self.ask('换成YYY2',cid)
self.assertNotIn('ZZZ1',text);self.assertIn('美国 30KG',text)
def test_allocated_cartons_keep_individual_minimums_and_overweight_fees(self):
text=self.ask('美国2箱每箱5KG发ZZZ1,1箱20KG发YYY2,义乌普船海派')
self.assertIn('美国 30KG',text);self.assertIn('24KG = 120元',text);self.assertIn('20KG = 40元',text)
text=self.ask('美国1箱30KG发ZZZ1,1箱10KG发YYY2,义乌普船海派')
self.assertIn('30KG = 220元',text);self.assertIn('最高项70元/件',text)
text=self.ask('美国1箱10KG发ZZZ1,60x50x40cm,1箱20KG发YYY2,40x30x20cm,义乌普船海派')
self.assertIn('20KG = 100元',text);self.assertIn('20KG = 40元',text)
def test_total_weight_correction_removes_old_per_carton_weight_even_on_next_followup(self):
cid=self.conversation()
self.ask('美国美西2箱每箱30KG,义乌普船海派',cid)
text=self.ask('30公斤是两箱加起来的,不是每箱30公斤',cid)
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
text=self.ask('深圳交仓呢',cid)
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
self.assertIn('美国 50KG',self.ask('不是30KG,是50KG',cid))
def test_uniform_carton_count_change_preserves_piece_weight_across_followups(self):
cid=self.conversation()
self.ask('美国美西2箱每箱15KG,义乌普船海派',cid)
self.assertIn('美国 60KG',self.ask('改成4箱',cid))
self.assertIn('美国 60KG',self.ask('深圳交仓呢',cid))
self.assertIn('美国 60KG',self.ask('改成6箱,总重不变',cid))
self.assertIn('美国 60KG',self.ask('义乌交仓呢',cid))
def test_mixed_carton_count_requires_new_weights_then_accepts_a_new_total(self):
cid=self.conversation()
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',cid)
self.assertIn('请确认新的总重',self.ask('改成4箱',cid))
text=self.ask('总重40KG',cid)
self.assertIn('美国 40KG',text);self.assertIn('48KG = 96元',text)
other=self.conversation()
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',other)
self.ask('改成4箱,总重不变',other)
self.assertIn('美国 30KG',self.ask('深圳交仓呢',other))
def test_new_dimensions_do_not_resurrect_stale_volume(self):
cid=self.conversation()
self.ask('美国363KG发ZZZ1,1箱,1.5方,义乌不包税海卡',cid)
self.assertIn('1CBM = 100元',self.ask('改成每箱50x50x40cm',cid))
self.assertIn('1CBM = 100元',self.ask('深圳交仓呢',cid))
def test_cbm_metal_surcharge_is_separate_instead_of_silently_included(self):
text=self.ask('美国铁制品30KG发ZZZ1,1方,义乌不包税海卡')
self.assertIn('1CBM = 100元',text)
self.assertIn('铁铝附加费为5元/KG',text)
self.assertIn('当前按方运费未包含',text)
self.assertNotIn('= 105元',text)
def test_preparation_questions_use_us_material_without_promising_ship_dates(self):
cases=(('美国可以上门提货吗','不提供上门提货'),('美国FBA外箱怎么贴标签','两张入仓条码'),
('美国单独报关资料需要什么','装箱单'),('美国申报比例怎么算','不能按统一比例'),
('美国不包税查验费用谁承担','比例均摊'),('美国这周哪天开船','联系人工核对'))
for question,expected in cases:
with self.subTest(question=question):self.assertIn(expected,self.ask(question))
cid=self.conversation();self.ask('美国30KG海派报价',cid)
self.assertIn('唛头',self.ask('外箱标签呢',cid))
self.assertIn('目的国家',self.ask('可以上门提货吗'))
self.assertIn('普船海派',self.ask('你们能发美国吗,有哪些渠道'))
text=self.ask('美国美西30KG最便宜的,月底必须到,义乌海派')
self.assertIn('30KG = 60元',text);self.assertIn('不能作为',text)
def test_order_business_status_does_not_imply_signing_shelving_or_piece_delivery(self):
self.assertIn('业务完成不等于',self.ask('头程订单已完成是不是签收了'))
self.assertTrue(self.events('这票上架了吗')[0]['login_required'])
self.login();cid=self.conversation()
self.events(self.order['inbound_code'],cid)
text=self.ask('这票已完成为什么还没上架',cid)
self.assertIn('已离开起运港',text)
self.assertIn('不等于收件人签收',text)
self.assertNotIn('已经到港',text)
text=self.ask('怎么三天都没更新',cid)
self.assertIn('2026-10-01',text);self.assertIn('原因无法',text)
text=self.ask('到了几箱?少了一箱',cid)
self.assertIn('登记总件数为2件',text);self.assertIn('没有逐箱签收明细',text)
text=self.ask('帮我催一下',cid)
self.assertIn('尚未提交',text)
self.assertIn('未找到',self.ask('QACOMMON20260099上架了吗'))
self.client.post('/api/customer/logout')
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
-154
View File
@@ -1,154 +0,0 @@
"""CLI database backup, integrity and restore operations."""
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from contextlib import closing
from datetime import datetime, timedelta
from unittest.mock import patch
from logistics_chat import database_backup
from pathlib import Path
class DatabaseMaintenanceTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.database = self.root/'history.db'
self.backups = self.root/'backups'
self.backups.mkdir()
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('CREATE TABLE records(value TEXT NOT NULL)')
connection.execute("INSERT INTO records VALUES('original')")
connection.commit()
def run_cli(self, *args):
return subprocess.run([sys.executable, 'scripts/db_maintenance.py', '--db', str(self.database),
'--backup-dir', str(self.backups), *args], capture_output=True, text=True,
encoding='utf-8', timeout=15)
def test_backup_and_check_create_verified_snapshot(self):
result = self.run_cli('backup')
self.assertEqual(result.returncode, 0, result.stderr)
backup = Path(result.stdout.strip().split(': ', 1)[-1])
self.assertTrue(backup.is_file())
checked = self.run_cli('check', '--input', str(backup))
self.assertEqual(checked.returncode, 0, checked.stderr)
self.assertIn('ok', checked.stdout.lower())
def test_restore_keeps_current_safety_backup_and_replaces_database(self):
source = self.backups/'source.db'
with closing(sqlite3.connect(source)) as connection:
connection.execute('CREATE TABLE records(value TEXT NOT NULL)')
connection.execute("INSERT INTO records VALUES('restored')")
connection.commit()
result = self.run_cli('restore', '--input', str(source), '--service-stopped')
self.assertEqual(result.returncode, 0, result.stderr)
with closing(sqlite3.connect(self.database)) as connection:
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'restored')
self.assertTrue(list(self.backups.glob('pre-restore-*.db')))
def test_restore_rejects_a_backup_outside_configured_directory(self):
outside = self.root/'outside.db'
outside.write_bytes(self.database.read_bytes())
result = self.run_cli('restore', '--input', str(outside), '--service-stopped')
self.assertNotEqual(result.returncode, 0)
with closing(sqlite3.connect(self.database)) as connection:
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'original')
def test_restore_requires_explicit_stopped_service_declaration(self):
result = self.run_cli('restore', '--input', str(self.backups/'source.db'))
self.assertNotEqual(result.returncode, 0)
def test_corrupt_backup_cannot_overwrite_current_data(self):
corrupt = self.backups/'corrupt.db'
corrupt.write_bytes(b'not sqlite')
result = self.run_cli('restore', '--input', str(corrupt), '--service-stopped')
self.assertNotEqual(result.returncode, 0)
with closing(sqlite3.connect(self.database)) as connection:
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'original')
def test_wal_records_are_present_and_snapshot_does_not_overwrite_existing_file(self):
destination = self.backups/'wal.db'
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('PRAGMA journal_mode=WAL')
connection.execute("INSERT INTO records VALUES('committed-wal')")
connection.commit()
database_backup.snapshot_database(self.database, destination)
with closing(sqlite3.connect(destination)) as copied:
self.assertEqual(copied.execute('SELECT COUNT(*) FROM records').fetchone()[0], 2)
with self.assertRaises(FileExistsError):
database_backup.snapshot_database(self.database, destination)
self.assertEqual(list(self.backups.glob('*.tmp*')), [])
def test_restore_of_clean_wal_database_does_not_leave_or_reuse_sidecars(self):
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('PRAGMA journal_mode=WAL')
source = self.backups/'source.db'
database_backup.snapshot_database(self.database, source)
# A read-only WAL source can leave empty coordination sidecars.
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('PRAGMA wal_checkpoint(TRUNCATE)')
database_backup.restore_database(self.database, source, self.backups)
for suffix in ('-wal','-shm'):
self.assertFalse(Path(str(self.database)+suffix).exists())
database_backup.restore_database(self.database, source, self.backups)
def test_daily_worker_works_without_requests_retries_failures_and_deduplicates(self):
from flask import Flask
app = Flask(__name__)
app.config.update(DATABASE=self.database, BACKUP_DIR=self.backups)
worker = database_backup.DailyBackupWorker(app)
tomorrow = datetime.now(database_backup.CHINA_TIME) + timedelta(days=1)
with patch('logistics_chat.database_backup.daily_backup', side_effect=OSError('fixture disk full')):
with self.assertRaises(OSError):
worker.tick(tomorrow)
self.assertTrue(worker.tick(tomorrow))
self.assertFalse(worker.tick(tomorrow))
self.assertEqual(len(list(self.backups.glob('history_*.db'))), 1)
def test_missing_database_and_invalid_snapshot_are_never_created_or_published(self):
missing = self.root/'missing.db'
with self.assertRaises(sqlite3.Error):
database_backup.snapshot_database(missing, self.backups/'bad.db')
self.assertFalse(missing.exists())
self.assertFalse((self.backups/'bad.db').exists())
self.assertEqual(list(self.backups.glob('*.tmp')), [])
def test_daily_retention_leaves_upgrade_and_manual_backups_untouched(self):
for name in ('history_20000101.db','pre-migration-old.db','manual-old.db','history_invalid.db'):
(self.backups/name).write_bytes(b'fixture')
database_backup.daily_backup(self.database, self.backups)
self.assertFalse((self.backups/'history_20000101.db').exists())
for name in ('pre-migration-old.db','manual-old.db','history_invalid.db'):
self.assertTrue((self.backups/name).exists())
def test_daily_backup_keeps_exactly_seven_calendar_dates(self):
today = datetime.now(database_backup.CHINA_TIME)
for offset in range(1, 9):
(self.backups/('history_' + (today-timedelta(days=offset)).strftime('%Y%m%d') + '.db')).write_bytes(b'old')
database_backup.daily_backup(self.database, self.backups, today)
self.assertEqual(len(list(self.backups.glob('history_*.db'))), 7)
def test_migration_retention_removes_only_oldest_recognized_snapshots(self):
names = []
for index in range(7):
name = f'pre-migration-20261102-12000{index}-000000-{index:08x}.db'
names.append(name)
(self.backups/name).write_bytes(b'fixture')
(self.backups/'pre-migration-old.db').write_bytes(b'keep')
(self.backups/'manual-old.db').write_bytes(b'keep')
(self.backups/'pre-migration-20261102-120000-000000-badname.db').write_bytes(b'keep')
(self.backups/'pre-migration-20261102-120000-000000-ffffffff.db').mkdir()
database_backup.prune_migration_snapshots(self.backups, keep=5)
self.assertEqual([name for name in names if (self.backups/name).exists()], names[2:])
self.assertTrue((self.backups/'pre-migration-old.db').exists())
self.assertTrue((self.backups/'manual-old.db').exists())
self.assertTrue((self.backups/'pre-migration-20261102-120000-000000-badname.db').exists())
self.assertTrue((self.backups/'pre-migration-20261102-120000-000000-ffffffff.db').is_dir())
+82 -4
View File
@@ -1,15 +1,19 @@
"""Exercise the server factory using disposable state/log paths and no model key."""
import io
import json
import logging
import os
from pathlib import Path
import shlex
import tempfile
import unittest
from unittest.mock import patch
from deploy import wsgi
from staff_fixture import login_staff
from logistics_chat import create_app
from waitress.adjustments import Adjustments
from waitress.proxy_headers import proxy_headers_middleware
class DeploymentTests(unittest.TestCase):
@@ -31,10 +35,9 @@ class DeploymentTests(unittest.TestCase):
self.addCleanup(close_handlers)
client = app.test_client()
client.post('/api/visitor/session', json={'key':'1'*64})
for path, status in (('/',200), ('/admin/dashboard',302), ('/api/tickets',401), ('/api/my_orders?phone=123',401)):
self.assertEqual(client.get(path).status_code, status, path)
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
self.assertEqual(client.get(path).status_code, 200, path)
self.assertEqual(client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
login_staff(app, client)
self.assertEqual(client.post('/api/ticket', json={'content': 'Acceptance ticket', 'phone': '123'}).status_code, 200)
self.assertEqual(client.get('/api/tickets').json[0]['content'], 'Acceptance ticket')
uploaded = client.post('/api/upload', data={'file': (io.BytesIO(b'acceptance'), 'note.txt')})
@@ -46,5 +49,80 @@ class DeploymentTests(unittest.TestCase):
self.assertEqual(app.config['HOST'], '127.0.0.1')
class ProxyIdentityTests(unittest.TestCase):
def application(self, runtime):
root = Path(__file__).resolve().parents[1]
if runtime == 'docker':
command = next(line[4:] for line in (root/'Dockerfile').read_text(encoding='utf-8').splitlines()
if line.startswith('CMD '))
args = json.loads(command)[3:]
else:
command = next(line[len('ExecStart='):] for line in
(root/'deploy/baiyue-logistics-chat.service').read_text(encoding='utf-8').splitlines()
if line.startswith('ExecStart='))
args = shlex.split(command)[3:]
options, _ = Adjustments.parse_args(args)
options.pop('call'); options.pop('help')
settings = Adjustments(**options)
folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup)
data = Path(folder.name)
app = create_app({'TESTING':True,'DATABASE':data/'history.db','UPLOAD_DIR':data/'uploads',
'BACKUP_DIR':data/'backups','LOG_DIR':data/'logs','RATE_LIMIT_PER_MIN':1000})
app.config['PROXY_TEST_PEER'] = '172.18.0.1' if runtime == 'docker' else '127.0.0.1'
app.wsgi_app = proxy_headers_middleware(app.wsgi_app, trusted_proxy=settings.trusted_proxy,
trusted_proxy_count=settings.trusted_proxy_count, trusted_proxy_headers=settings.trusted_proxy_headers)
return app
def clients(self, app):
clients = []
for address in ('198.51.100.10', '198.51.100.20'):
client = app.test_client()
client.environ_base['REMOTE_ADDR'] = app.config['PROXY_TEST_PEER']
client.environ_base['HTTP_X_FORWARDED_FOR'] = address
client.post('/api/visitor/session', json={'key':str(len(clients)+1)*64})
clients.append(client)
app.extensions['rate_hits'].clear()
return clients
def test_proxy_clients_do_not_share_request_rate_limits(self):
for runtime in ('docker', 'systemd'):
with self.subTest(runtime=runtime):
app = self.application(runtime)
a, b = self.clients(app)
app.config['RATE_LIMIT_PER_MIN'] = 2
self.assertEqual(a.get('/api/get_history').status_code, 200)
self.assertEqual(a.get('/api/get_history').status_code, 200)
self.assertEqual(a.get('/api/get_history').status_code, 429)
self.assertEqual(b.get('/api/get_history').status_code, 200)
def test_proxy_clients_do_not_share_failed_login_budgets(self):
for runtime in ('docker', 'systemd'):
with self.subTest(runtime=runtime):
app = self.application(runtime)
a, b = self.clients(app)
for _ in range(5):
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code,403)
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'123456'}).status_code,429)
self.assertTrue(b.post('/api/customer/login',json={'username':'admin','password':'123456'}).json.get('authenticated'))
def test_only_the_last_forwarded_address_is_used_and_other_proxy_claims_are_ignored(self):
from flask import request
for runtime in ('docker', 'systemd'):
with self.subTest(runtime=runtime):
app = self.application(runtime)
@app.get('/proxy-probe')
def probe():
return {'ip':request.remote_addr,'host':request.host,'scheme':request.scheme}
response = app.test_client().get('/proxy-probe',base_url='https://chat.example.com',
environ_overrides={'REMOTE_ADDR':app.config['PROXY_TEST_PEER']},headers={
'X-Forwarded-For':'203.0.113.99, 198.51.100.10',
'X-Forwarded-Host':'evil.example','X-Forwarded-Proto':'http'})
self.assertEqual(response.json,{'ip':'198.51.100.10','host':'chat.example.com','scheme':'https'})
if runtime == 'systemd':
untrusted = app.test_client().get('/proxy-probe',environ_overrides={'REMOTE_ADDR':'203.0.113.1'},
headers={'X-Forwarded-For':'198.51.100.10'})
self.assertEqual(untrusted.json['ip'],'203.0.113.1')
if __name__ == '__main__':
unittest.main()

Some files were not shown because too many files have changed in this diff Show More