Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb2e5b4b4 | ||
|
|
92684bffb2 | ||
|
|
eebaba8084 | ||
|
|
04f94ad665 | ||
|
|
fd1ec32dd6 | ||
|
|
9d07793a57 | ||
|
|
d26f6ecfd3 | ||
|
|
fef233344b | ||
|
|
783f930380 | ||
|
|
888f323b23 | ||
|
|
689d55bcf9 | ||
|
|
eba3a1b562 | ||
|
|
b764fcb33c | ||
|
|
78797d8eca | ||
|
|
b8f6ca586d | ||
|
|
767fca6a79 |
@@ -3,8 +3,19 @@
|
||||
# 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.
|
||||
CUSTOMER_DEMO_ENABLED=true
|
||||
CUSTOMER_DEMO_USERNAME=admin
|
||||
# Bind one explicit tenant, or leave empty to use the current local dataset grants.
|
||||
CUSTOMER_DEMO_TENANT_ID=
|
||||
# On initial association, grant the default account the tenants currently in the local table.
|
||||
CUSTOMER_DEMO_LOCAL_ORDERS=true
|
||||
# Optional password hash used only when creating the default account; not plaintext.
|
||||
# CUSTOMER_DEMO_PASSWORD_HASH=
|
||||
# Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable.
|
||||
ASR_PROVIDER=baidu
|
||||
BAIDU_ASR_API_KEY=
|
||||
|
||||
@@ -9,13 +9,20 @@ 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` |
|
||||
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
|
||||
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.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` |
|
||||
| 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 |
|
||||
| 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/` |
|
||||
@@ -54,7 +61,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
|
||||
|
||||
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
|
||||
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, and `visitor.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -62,4 +69,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize their chat history, never customer orders; customer login/linking remains pending. Do not assign unowned legacy records to a new visitor.
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; order and staff tables start empty. The requested default customer account is separate from staff and stores only a password hash. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication 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.
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ 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/
|
||||
COPY scripts/import_order_test_data.py scripts/db_maintenance.py ./scripts/
|
||||
USER 10001:10001
|
||||
EXPOSE 5000
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
||||
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
||||
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
||||
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
||||
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
||||
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
||||
@@ -146,6 +146,7 @@ baiyue-logistics-chat/
|
||||
│ ├── history_service.py 会话 CRUD
|
||||
│ ├── human_service.py 人工会话
|
||||
│ ├── business_service.py 登录、统计、订单、留言、导出
|
||||
│ ├── staff_service.py 客服登录、24 小时会话、接口鉴权及退出
|
||||
│ ├── file_service.py 附件与清理
|
||||
│ ├── hooks.py 限流和缓存头
|
||||
│ ├── runtime.py 服务启动
|
||||
@@ -226,13 +227,30 @@ 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` | 校验客服账号密码 |
|
||||
| POST | `/admin/login` | 校验客服账号密码并签发 24 小时会话 |
|
||||
| GET | `/api/admin/session` | 获取当前客服身份与 CSRF 令牌(需要后台登录) |
|
||||
| POST | `/admin/logout` | 撤销当前后台会话(需要 CSRF 请求头) |
|
||||
| GET | `/admin/dashboard` | 客服工作台 |
|
||||
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
|
||||
|
||||
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
|
||||
|
||||
人工排队等候超过 5 分钟后,排队标签显示超时数量,队列卡片标注超时分钟数;首次进入超时状态时提醒一次。页面切到后台且已授权浏览器通知时会弹出系统通知。
|
||||
|
||||
离线留言处理位于工作台「资料与回复 → 离线留言」。点击留言查看详情,领取后自动设为当前客服负责并进入「处理中」;负责人可保存处理备注,填写处理结果后标记「已完成」。完成记录保留,可按状态筛选查询。重复或并发领取会被拒绝;其他客服只能查看,不能覆盖负责人备注。备注发生版本冲突时保留本页草稿,展示当前保存的内容,人工核对后才允许继续保存。列表每次加载 20 条,支持加载更早留言;查看更早留言期间可点击「刷新」返回最新列表。未保存备注在当前页面按留言保留,刷新整个网页或退出时不会持久保存草稿。
|
||||
|
||||
工作台顶部「留言」显示全局待处理数量,点击直达待处理列表。独立的 5 秒轮询不受搜索、查看旧页或保存备注影响;新留言到达后显示页面提示,首次打开仅显示已有待处理总数,不重复提醒旧记录。开启右上角浏览器通知后,页面在后台时也可收到新留言通知,提醒只含数量。搜索在数据库执行,支持手机号、留言内容、负责人账号/姓名和处理备注,可与状态及分页同时使用;输入 `%`、`_`、`\` 按普通字符匹配,不当作通配符。
|
||||
|
||||
工作台顶部「超时」显示全部未完成超时留言,点击查看;列表可组合「只看超时」、状态和搜索。待处理超过提交时间 30 分钟、处理中超过领取时间 20 分钟即提醒,按服务端本地时间计算;保存备注不重置领取计时,完成后不再计入。详情中的「处理记录」保留提交、领取、备注变更前后和完成结果,操作者取实际登录客服,变更和记录在同一事务提交;记录只对后台开放,不可从接口编辑或删除。旧留言升级时只保留已知状态,不补造历史;原处理中留言以升级前最后更新时间估算领取时间并在页面说明,缺少有效时间则提示无法计算。
|
||||
|
||||
SSE 每帧为一条 JSON,主要事件包括:
|
||||
|
||||
```text
|
||||
@@ -246,10 +264,16 @@ data: {"d": 1}
|
||||
|
||||
## 数据库与运行数据
|
||||
|
||||
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。匿名聊天不读取订单表;客户登录及关联将在后续接入,届时查询必须使用服务端验证的客户范围。`ORDER_DATA_TENANT_ID` 只是额外限制,不能代替客户身份。
|
||||
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。
|
||||
|
||||
默认前台客户账号为 `admin / 123456`,与受 Nginx 保护的客服后台独立。默认按当前本地数据建立客户范围关联:单客户写入 `customer_users.tenant_id`,多客户写入 `customer_tenant_grants`,查询始终按服务端白名单过滤。首次关联完成后不自动加入新客户范围,同一客户下新增订单可直接查询。`CUSTOMER_DEMO_TENANT_ID` 可指定单个范围;`CUSTOMER_DEMO_LOCAL_ORDERS=false` 关闭多客户自动关联。账号保存在 `customer_users`,密码为 PBKDF2 哈希;已有账号启动时不覆盖,可直接维护哈希、`tenant_id`、`enabled` 及客户范围关联表。`CUSTOMER_DEMO_ENABLED=false` 禁用默认账号及其已有登录;真实客户账号不受该开关影响。`CUSTOMER_DEMO_USERNAME` 与 `CUSTOMER_DEMO_PASSWORD_HASH` 用于首次创建默认账号。
|
||||
|
||||
登录凭据通过独立 HttpOnly Cookie 及服务端 `customer_sessions` 校验,绑定当前访客,8 小时有效;退出后撤销当前登录。登录后自动继续刚才的订单问题;含订单事实的会话在退出后隐藏,只有相同访客重新登录同一客户和范围后才能恢复。真实用户认证接入时替换账号校验/关联层,继续使用内部客户 ID、可信客户范围和已有登录会话契约;MCP 数据查询仍通过 `lookup_order` 的客户范围过滤。
|
||||
|
||||
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
|
||||
|
||||
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
|
||||
|
||||
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
|
||||
|
||||
```powershell
|
||||
@@ -258,15 +282,17 @@ data: {"d": 1}
|
||||
|
||||
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。
|
||||
|
||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
|
||||
数据库已提供版本迁移、升级前校验快照、SQLite 在线每日备份及离线恢复工具,详见 [数据库维护指南](docs/database-maintenance.md)。应用运行时按北京时间跨日备份,`AUTO_BACKUP_ENABLED=false` 可关闭后台每日任务;启动备份仍执行。附件与配置须另行备份。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。
|
||||
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
|
||||
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
|
||||
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
|
||||
- 不预置客服账号;客服登录支持现有 PBKDF2/scrypt 密码哈希,兼容旧明文账号记录,不修改已保存密码。登录签发独立的 HttpOnly、SameSite=Lax Cookie,HTTPS 时启用 Secure;服务端 `staff_sessions` 只保存凭证摘要,后台登录从成功登录起 24 小时有效,到期需要重新登录。访问后台和重启服务不会延长有效期;浏览器 Cookie 同样保存 24 小时,退出、清理 Cookie 或浏览器自动清理后需重新登录。升级时撤销旧的无限期会话,重新登录后使用 24 小时规则。后台页面、接口、搜索、导出及客服附件统一校验会话,未明确列入客户白名单的新增路径默认需要客服登录。客户账号不能访问后台。
|
||||
- 工作台右上角“退出登录”撤销当前凭证,不停止服务、不删除数据;密码变更或账号删除使旧会话失效。写操作校验 CSRF 请求头和请求来源;登录接口每个账号与连接来源组合在 5 分钟内最多尝试 5 次,成功后清除计数(进程内计数,多进程部署需代理统一限流)。登录凭证被撤销或丢失时停止后台轮询并回到登录页。Nginx 专用访问密码及上游回环绑定继续保留,客户聊天不要求后台密码。
|
||||
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
|
||||
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
|
||||
- 服务优先使用 Waitress 的 8 个线程;缺少 Waitress 时会回退到 Flask 开发服务器。公网部署前还需完善鉴权及部署配置。
|
||||
|
||||
@@ -119,7 +119,7 @@ server {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout|orders(?:/[0-9]+)?)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
|
||||
@@ -47,7 +47,7 @@ server {
|
||||
location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout|orders(?:/[0-9]+)?)|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;
|
||||
}
|
||||
|
||||
+20
-3
@@ -23,15 +23,18 @@ 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/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 |
|
||||
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
|
||||
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
|
||||
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
|
||||
| 附件 | `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 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
|
||||
|
||||
@@ -87,9 +90,19 @@ tests/ # 按业务模块组织的回归
|
||||
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
|
||||
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||
- 配色、字体基线和通用控件放在 `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` 对每条会话仅在首次进入超时时通知一次,队列计数常驻标签,卡片标红;离开超时队列后清除去重状态。
|
||||
- 离线留言的 `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 分页;前端独立加载记录并隔离迟到响应。旧数据只保留升级状态,领取时间估算有显式标识。
|
||||
|
||||
## 后续结构调整顺序
|
||||
|
||||
@@ -107,18 +120,22 @@ tests/ # 按业务模块组织的回归
|
||||
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
|
||||
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。客户身份尚未接入,匿名 HTTP 查单在读取订单前返回账号关联提示;`g.customer_tenant_id` 只允许后续可信认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
|
||||
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出后清除当前可见订单内容,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
|
||||
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
|
||||
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
- 客服后台 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` 同步维护。
|
||||
- 聊天请求的会话编号仅接受 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 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
|
||||
- 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 小时有效,请求及重启不续期;升级时撤销没有签发时间的旧无限期凭证;退出、密码变化或删除账号使旧会话失效。上游仍只能绑定回环地址;客户登录与游客身份均不能升级为客服权限。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 数据库升级、备份与恢复
|
||||
|
||||
普通启动会先在写锁保护下检查数据库版本。已有数据库先生成 `pre-migration-时间-随机值.db` 并执行 SQLite 完整性检查,再按 `MIGRATIONS` 的版本顺序升级;`schema_meta.schema_version` 记录成功版本。当前版本为 3;版本 2 为离线留言增加负责人、处理备注、更新时间、完成时间和并发版本号,版本 3 增加领取时间及处理记录表,既有留言和客服账号保留。旧留言只生成一条升级状态快照,不补造历史;原处理中留言以有效的最后更新时间估算领取时间并标记,无法估算时保留为空。保留既有旧表兼容检查,因此同版本启动也会先保存快照再校验表结构。更新和版本号提交位于同一事务,失败回滚并终止启动;备份失败不执行升级,未来版本的数据库拒绝降级。首次空库无需升级前快照。
|
||||
|
||||
新增结构变更时,在 `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 文件系统支持;不支持时备份失败会明确报错,不继续升级。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
|
||||
|
||||
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
|
||||
游客历史、人工会话和附件已按访客归属隔离;客户订单按登录账号关联范围校验,匿名不能查询真实订单。客服后台另有独立的应用登录会话,自登录成功起 24 小时有效,访问后台和重启服务不延长期限,写请求校验 CSRF;退出或改密后旧凭证失效。必须继续沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
|
||||
|
||||
## 1. 域名与网络
|
||||
|
||||
@@ -68,7 +68,7 @@ systemd 自动创建并授权以下目录,重启与更新源码不会替换其
|
||||
| 应用日志 | `/var/log/baiyue-logistics-chat/app.log` |
|
||||
| 密钥配置 | `/opt/baiyue-logistics-chat/.env` |
|
||||
|
||||
首次启动创建八张业务表,不插入演示数据、默认客服账号或密码。已有表和记录保持兼容。应用当前仍会在启动时清理超过 7 天的上传文件,并执行启动备份;启动备份不能替代定时备份。
|
||||
首次启动创建所需业务表,已有表和记录保持兼容。已有数据库升级前生成校验快照并记录版本,失败回滚且不启动。启动执行当日备份,运行期间默认每分钟检查北京时间跨日并做每日备份;数据库恢复及服务器路径参数见 [数据库维护](database-maintenance.md)。附件清理只删除未被聊天引用的过期临时文件;附件及配置需另行备份。
|
||||
|
||||
## 5. 申请 HTTPS 证书
|
||||
|
||||
|
||||
+5
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
|
||||
|
||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。
|
||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服采用应用内 24 小时会话鉴权,并保留 Nginx 密码保护,不能裸露上游端口。客户订单访问按服务端客户登录及关联范围校验,匿名查单不会读取订单数据。
|
||||
|
||||
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
|
||||
|
||||
@@ -71,8 +71,12 @@
|
||||
|
||||
更新前执行 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 端口可达。其他域名的证书不能直接用于新域名。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 数据库升级与备份实施计划
|
||||
|
||||
目标:保留当前账号、订单和聊天数据,完善升级前快照、版本记录、在线备份、每日备份及离线恢复。
|
||||
|
||||
技术方案:现有幂等建表逻辑保留为基线迁移,新增 `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 日期保留边界均已补测试修正。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 离线留言处理
|
||||
|
||||
用户已确认采用留言领取、负责人、处理备注和待处理/处理中/已完成三种状态。
|
||||
|
||||
实现与验证顺序:
|
||||
|
||||
- [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。
|
||||
@@ -0,0 +1,17 @@
|
||||
# 后台登录会话实施计划
|
||||
|
||||
已确认范围:保留现有客服账号密码;后台会话有效期 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 秒;后台请求和服务重启均不续期。旧无限期会话无法确定签发时间,升级时撤销并要求重新登录;有效的有限期会话保留原期限,已失效会话不恢复。退出、改密及删除账号继续即时撤销。
|
||||
@@ -5,11 +5,12 @@ def create_app(config=None):
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from .config import ROOT_DIR, default_config
|
||||
from . import db, file_service, hooks, visitor_service
|
||||
from . import customer_service, db, file_service, hooks, staff_service, visitor_service
|
||||
from .routes import register_routes
|
||||
from .routes.speech import MemoryAudioRequest
|
||||
|
||||
@@ -21,8 +22,14 @@ def create_app(config=None):
|
||||
app.config.update(config)
|
||||
app.extensions['human_typing'] = {}
|
||||
app.extensions['rate_hits'] = defaultdict(list)
|
||||
app.extensions['customer_login_attempts'] = {}
|
||||
app.extensions['customer_login_lock'] = Lock()
|
||||
app.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)
|
||||
app.after_request(hooks.no_cache_html)
|
||||
app.teardown_appcontext(db.close_connections)
|
||||
register_routes(app)
|
||||
@@ -45,4 +52,9 @@ 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
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
"""Feedback, reporting, login, orders and offline tickets."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from .db import connect, now_str
|
||||
from .tracking import card_to_text
|
||||
|
||||
|
||||
def feedback(data):
|
||||
"""收集 👍/👎 反馈,存到 feedback 表"""
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False, 'error': '评价格式无效。'}, 400
|
||||
raw_vote = data.get('vote')
|
||||
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
|
||||
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
|
||||
question, answer = data.get('question', ''), data.get('answer', '')
|
||||
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
|
||||
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
|
||||
conn = connect()
|
||||
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
|
||||
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
|
||||
(question[:500], answer[:2000], vote, now_str()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
@@ -19,8 +27,8 @@ def admin_stats():
|
||||
conn = connect()
|
||||
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
|
||||
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
|
||||
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0]
|
||||
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
|
||||
good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
|
||||
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
|
||||
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
|
||||
@@ -39,29 +47,26 @@ def my_orders(phone):
|
||||
|
||||
|
||||
def add_ticket(data):
|
||||
conn = connect()
|
||||
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False, 'error': '留言格式无效。'}, 400
|
||||
content, phone = data.get('content'), data.get('phone', '')
|
||||
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
|
||||
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
|
||||
if not isinstance(phone, str) or len(phone.strip()) > 32:
|
||||
return {'ok': False, 'error': '联系电话格式无效。'}, 400
|
||||
from .ticket_service import create_ticket
|
||||
return create_ticket(content.strip(), phone.strip())
|
||||
|
||||
|
||||
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 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 admin_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)
|
||||
# Retain the service entry point; all credential/session rules live together.
|
||||
from .staff_service import login
|
||||
return login(data)
|
||||
|
||||
|
||||
def agents_list():
|
||||
|
||||
@@ -85,6 +85,9 @@ def create_chat_stream(msg, conv_id):
|
||||
if conv_id:
|
||||
from .visitor_service import owns_conversation
|
||||
if not owns_conversation(conv_id):
|
||||
from .customer_service import needs_customer_login, order_login_response
|
||||
if needs_customer_login(conv_id):
|
||||
return (sse(order_login_response()) + sse({'d':1}), {})
|
||||
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
|
||||
order_answer = answer_query(msg, conv_id)
|
||||
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
|
||||
@@ -102,6 +105,11 @@ def create_chat_stream(msg, conv_id):
|
||||
if order_answer is not None:
|
||||
|
||||
def track_stream():
|
||||
if 'card' in order_answer:
|
||||
from .customer_service import bind_order_conversation, current_customer_id
|
||||
if current_customer_id() and not bind_order_conversation(conv_id):
|
||||
yield sse({'e':'该会话暂时无法访问,请新建对话后重试。','d':1})
|
||||
return
|
||||
save_message(conv_id, 'user', msg)
|
||||
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
|
||||
if 'card' in order_answer else order_answer['t'])
|
||||
|
||||
@@ -18,6 +18,7 @@ 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,6 +30,12 @@ def default_config():
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(),
|
||||
'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(),
|
||||
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
|
||||
'CUSTOMER_DEMO_ENABLED': str(os.environ.get('CUSTOMER_DEMO_ENABLED', local_config.get('CUSTOMER_DEMO_ENABLED', 'true'))).lower() in ('1','true','yes'),
|
||||
'CUSTOMER_DEMO_USERNAME': str(os.environ.get('CUSTOMER_DEMO_USERNAME', local_config.get('CUSTOMER_DEMO_USERNAME', 'admin')) or '').strip(),
|
||||
'CUSTOMER_DEMO_PASSWORD_HASH': str(os.environ.get('CUSTOMER_DEMO_PASSWORD_HASH', local_config.get('CUSTOMER_DEMO_PASSWORD_HASH',
|
||||
'pbkdf2:sha256:600000$vY5fe7GwJQRQbhKV$a4c4eeb96e2bda18bfa6b82d263c0cd61dead2e3f224f694e01fc497152df72f')) or ''),
|
||||
'CUSTOMER_DEMO_TENANT_ID': str(os.environ.get('CUSTOMER_DEMO_TENANT_ID', local_config.get('CUSTOMER_DEMO_TENANT_ID')) or '').strip(),
|
||||
'CUSTOMER_DEMO_LOCAL_ORDERS': str(os.environ.get('CUSTOMER_DEMO_LOCAL_ORDERS', local_config.get('CUSTOMER_DEMO_LOCAL_ORDERS', 'true'))).lower() in ('1','true','yes'),
|
||||
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
|
||||
'MODEL_NAME': 'deepseek-chat',
|
||||
'MAX_CONTEXT': 4,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Customer authentication and order scope, independent of staff authentication."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from flask import g, has_app_context, jsonify, request
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from .config import get_setting
|
||||
from .db import connect
|
||||
from .visitor_service import valid_key, visitor_id
|
||||
|
||||
COOKIE_NAME = 'baiyue_customer'
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
|
||||
|
||||
def demo_tenant(connection):
|
||||
configured = get_setting('CUSTOMER_DEMO_TENANT_ID') or get_setting('ORDER_DATA_TENANT_ID')
|
||||
if configured:
|
||||
return configured
|
||||
rows = connection.execute("SELECT DISTINCT tenant_id FROM order_test_data WHERE tenant_id<>'' LIMIT 2").fetchall()
|
||||
return rows[0][0] if len(rows) == 1 else ''
|
||||
|
||||
|
||||
def user_tenants(connection, row):
|
||||
if row[4]:
|
||||
return (row[4],)
|
||||
rows = connection.execute('SELECT tenant_id FROM customer_tenant_grants WHERE customer_id=? ORDER BY tenant_id LIMIT 257',
|
||||
(row[0],)).fetchall()
|
||||
return tuple(item[0] for item in rows) if len(rows) <= 256 else ()
|
||||
|
||||
|
||||
def seed_local_grants(connection, row):
|
||||
if not row[6] or connection.execute('SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||
return
|
||||
if not row[4] and get_setting('CUSTOMER_DEMO_LOCAL_ORDERS') and not user_tenants(connection,row):
|
||||
connection.execute('INSERT OR IGNORE INTO customer_tenant_grants(customer_id,tenant_id) '
|
||||
"SELECT ?,tenant_id FROM order_test_data WHERE tenant_id<>'' GROUP BY tenant_id",(row[0],))
|
||||
if user_tenants(connection,row):
|
||||
connection.execute('UPDATE customer_users SET scope_initialized=1 WHERE id=?',(row[0],))
|
||||
|
||||
|
||||
def scope_key(tenants):
|
||||
return hashlib.sha256(json.dumps(sorted(tenants),ensure_ascii=False).encode()).hexdigest() if tenants else None
|
||||
|
||||
|
||||
def effective_tenants(tenants):
|
||||
configured = get_setting('ORDER_DATA_TENANT_ID')
|
||||
return tuple(value for value in tenants if not configured or value == configured)
|
||||
|
||||
|
||||
def current_scope_key():
|
||||
from .visitor_service import customer_tenant_ids
|
||||
return scope_key(customer_tenant_ids())
|
||||
|
||||
|
||||
def init_customer_tables(connection):
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, display_name TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_demo INTEGER NOT NULL DEFAULT 0, scope_initialized INTEGER NOT NULL DEFAULT 0)''')
|
||||
if 'scope_initialized' not in [item[1] for item in connection.execute('PRAGMA table_info(customer_users)')]:
|
||||
connection.execute('ALTER TABLE customer_users ADD COLUMN scope_initialized INTEGER NOT NULL DEFAULT 0')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_sessions (
|
||||
token_hash TEXT PRIMARY KEY, customer_id INTEGER NOT NULL,
|
||||
visitor_id TEXT NOT NULL, auth_stamp TEXT NOT NULL, expires_at INTEGER NOT NULL)''')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_customer_session_expiry ON customer_sessions(expires_at)')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_tenant_grants (
|
||||
customer_id INTEGER NOT NULL, tenant_id TEXT NOT NULL, PRIMARY KEY(customer_id,tenant_id))''')
|
||||
if get_setting('CUSTOMER_DEMO_ENABLED'):
|
||||
connection.execute('''INSERT OR IGNORE INTO customer_users
|
||||
(username,password_hash,display_name,tenant_id,is_demo) VALUES(?,?,?,?,1)''',
|
||||
(get_setting('CUSTOMER_DEMO_USERNAME'), get_setting('CUSTOMER_DEMO_PASSWORD_HASH'),
|
||||
get_setting('CUSTOMER_DEMO_USERNAME'), demo_tenant(connection)))
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE username=?',(get_setting('CUSTOMER_DEMO_USERNAME'),)).fetchone()
|
||||
seed_local_grants(connection,row)
|
||||
|
||||
|
||||
def auth_stamp(row, tenants):
|
||||
return hashlib.sha256((row[2] + '\n' + (scope_key(effective_tenants(tenants)) or '')).encode()).hexdigest()
|
||||
|
||||
|
||||
def allowed_user(row):
|
||||
return bool(row and row[5] and (not row[6] or (
|
||||
get_setting('CUSTOMER_DEMO_ENABLED') and row[1] == get_setting('CUSTOMER_DEMO_USERNAME'))))
|
||||
|
||||
|
||||
def attach_customer():
|
||||
g.customer = None
|
||||
g.customer_tenant_id = None
|
||||
g.customer_tenant_ids = ()
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not visitor_id() or not valid_key(token):
|
||||
return
|
||||
connection = connect()
|
||||
try:
|
||||
session = connection.execute('SELECT customer_id,auth_stamp FROM customer_sessions '
|
||||
'WHERE token_hash=? AND visitor_id=? AND expires_at>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), visitor_id(), int(time.time()))).fetchone()
|
||||
if not session:
|
||||
return
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE id=?', (session[0],)).fetchone()
|
||||
tenants = effective_tenants(user_tenants(connection,row)) if row else ()
|
||||
if allowed_user(row) and secrets.compare_digest(auth_stamp(row,tenants), session[1]):
|
||||
g.customer = {'id':row[0], 'username':row[1], 'name':row[3]}
|
||||
g.customer_tenant_id = tenants[0] if len(tenants) == 1 else None
|
||||
g.customer_tenant_ids = tenants
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def current_customer_id():
|
||||
customer = getattr(g, 'customer', None) if has_app_context() else None
|
||||
return customer['id'] if customer else None
|
||||
|
||||
|
||||
def bind_order_conversation(conv_id):
|
||||
"""A conversation containing order facts also requires its customer login."""
|
||||
if not conv_id:
|
||||
return True
|
||||
connection = connect()
|
||||
try:
|
||||
result = connection.execute('UPDATE chat_history SET customer_id=?,customer_scope=? WHERE id=? AND visitor_id=? '
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||
(current_customer_id(),current_scope_key(),conv_id,visitor_id(),current_customer_id(),current_scope_key()))
|
||||
connection.commit()
|
||||
return result.rowcount == 1
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def session_status():
|
||||
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
|
||||
|
||||
|
||||
def order_login_response():
|
||||
if current_customer_id():
|
||||
return {'t':'当前账号尚未关联订单,请联系人工客服。'}
|
||||
return {'t':'请先使用客户账号登录并关联订单,再查询订单或物流轨迹。','login_required':True}
|
||||
|
||||
|
||||
def needs_customer_login(conv_id):
|
||||
if current_customer_id():
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||
'AND customer_id IS NOT NULL',(conv_id,visitor_id())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def login(data, attempts, lock):
|
||||
if not isinstance(data, dict):
|
||||
return {'ok':False, 'error':'请输入账号和密码。'}, 400
|
||||
username, password = data.get('username'), data.get('password')
|
||||
if not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 80 or not 1 <= len(password) <= 256:
|
||||
return {'ok':False, 'error':'请输入有效的账号和密码。'}, 400
|
||||
now = int(time.time())
|
||||
# A separate short failed-login budget supplements the global request limiter.
|
||||
ip = request.remote_addr or 'unknown'
|
||||
with lock:
|
||||
failures = [stamp for stamp in attempts.get(ip, []) if stamp > now - 300]
|
||||
if len(attempts) > 2048:
|
||||
for key in list(attempts):
|
||||
if not attempts[key] or attempts[key][-1] <= now - 300:
|
||||
attempts.pop(key,None)
|
||||
if len(failures) >= 5:
|
||||
return {'ok':False, 'error':'登录尝试过多,请稍后重试。'}, 429
|
||||
attempts[ip] = failures + [now]
|
||||
connection = connect()
|
||||
try:
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE username=?', (username.strip(),)).fetchone()
|
||||
digest = row[2] if row else get_setting('CUSTOMER_DEMO_PASSWORD_HASH')
|
||||
try:
|
||||
valid = check_password_hash(digest, password)
|
||||
except (ValueError, TypeError):
|
||||
valid = False
|
||||
if not valid or not allowed_user(row):
|
||||
return {'ok':False, 'error':'账号或密码不正确。'}, 403
|
||||
with lock:
|
||||
attempts.pop(ip, None)
|
||||
if row[6] and not row[4] and not user_tenants(connection,row) and not connection.execute(
|
||||
'SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||
tenant = demo_tenant(connection)
|
||||
if tenant:
|
||||
connection.execute("UPDATE customer_users SET tenant_id=? WHERE id=? AND tenant_id=''", (tenant,row[0]))
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE id=?', (row[0],)).fetchone()
|
||||
seed_local_grants(connection,row)
|
||||
old_token = request.cookies.get(COOKIE_NAME)
|
||||
if valid_key(old_token):
|
||||
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||
(hashlib.sha256(old_token.encode()).hexdigest(), visitor_id()))
|
||||
token = secrets.token_hex(32)
|
||||
connection.execute('DELETE FROM customer_sessions WHERE expires_at<=?', (now,))
|
||||
connection.execute('INSERT INTO customer_sessions VALUES(?,?,?,?,?)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row,user_tenants(connection,row)),now+SESSION_SECONDS))
|
||||
connection.commit()
|
||||
response = jsonify({'ok':True,'authenticated':True,'user':{'id':row[0],'username':row[1],'name':row[3]}})
|
||||
response.set_cookie(COOKIE_NAME,token,max_age=SESSION_SECONDS,httponly=True,secure=request.is_secure,samesite='Lax',path='/')
|
||||
return response
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def logout():
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if valid_key(token):
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),visitor_id()))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
response = jsonify({'ok':True,'authenticated':False,'user':None})
|
||||
response.delete_cookie(COOKIE_NAME,path='/',secure=request.is_secure,httponly=True,samesite='Lax')
|
||||
return response
|
||||
@@ -0,0 +1,169 @@
|
||||
"""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)
|
||||
+80
-27
@@ -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 = 4
|
||||
|
||||
|
||||
def connect():
|
||||
@@ -36,9 +36,48 @@ 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)
|
||||
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,
|
||||
@@ -52,6 +91,17 @@ def init_db():
|
||||
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
|
||||
if 'visitor_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
|
||||
if 'customer_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER')
|
||||
if 'customer_scope' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
|
||||
if 'customer_tenant_id' in cols:
|
||||
# Preserve conversations created by the earlier local single-tenant login.
|
||||
import hashlib
|
||||
import json
|
||||
for cid, tenant in cur.execute('SELECT id,customer_tenant_id FROM chat_history WHERE customer_id IS NOT NULL').fetchall():
|
||||
scope = hashlib.sha256(json.dumps([tenant],ensure_ascii=False).encode()).hexdigest() if tenant else None
|
||||
cur.execute('UPDATE chat_history SET customer_scope=? WHERE id=?',(scope,cid))
|
||||
cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)')
|
||||
cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads (
|
||||
filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL
|
||||
@@ -136,34 +186,37 @@ def init_db():
|
||||
''')
|
||||
from .order_data import init_order_table
|
||||
init_order_table(conn)
|
||||
from .customer_service import init_customer_tables
|
||||
init_customer_tables(conn)
|
||||
from .staff_service import init_staff_tables
|
||||
init_staff_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()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow),
|
||||
(3, _upgrade_ticket_history), (4, _upgrade_order_admin))
|
||||
|
||||
|
||||
def backup_db():
|
||||
"""启动时把数据库备份到 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}')
|
||||
"""Verified daily online snapshot; failures propagate rather than claiming success."""
|
||||
return daily_backup(get_setting('DATABASE'), get_setting('BACKUP_DIR'))
|
||||
|
||||
|
||||
def cleanup_old_conversations():
|
||||
|
||||
@@ -2,21 +2,35 @@
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from urllib.parse import unquote
|
||||
|
||||
from .config import get_setting
|
||||
|
||||
|
||||
|
||||
def cleanup_uploads():
|
||||
"""删除 uploads/ 里 7 天前的图片"""
|
||||
"""Keep attachments referenced by chat history; expire unused uploads after 7 days."""
|
||||
try:
|
||||
udir = get_setting('UPLOAD_DIR')
|
||||
if not os.path.isdir(udir):
|
||||
return
|
||||
from .db import connect
|
||||
connection = connect()
|
||||
try:
|
||||
referenced = set()
|
||||
rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' "
|
||||
"UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'")
|
||||
for (content,) in rows:
|
||||
referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or ''))
|
||||
finally:
|
||||
connection.close()
|
||||
cutoff = time.time() - 7 * 86400
|
||||
for f in os.listdir(udir):
|
||||
if f in referenced:
|
||||
continue
|
||||
fp = os.path.join(udir, f)
|
||||
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
|
||||
os.remove(fp)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from .db import connect, now_str
|
||||
from .visitor_service import visitor_id, owns_conversation
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
|
||||
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。'
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。'
|
||||
|
||||
|
||||
def new_chat():
|
||||
@@ -24,7 +25,8 @@ def new_chat():
|
||||
def get_history():
|
||||
conn = connect()
|
||||
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? '
|
||||
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),)).fetchall()
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?)) '
|
||||
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),current_customer_id(),current_scope_key())).fetchall()
|
||||
conn.close()
|
||||
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
|
||||
|
||||
@@ -57,12 +59,13 @@ def clear_all():
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
owner = visitor_id()
|
||||
owned = 'SELECT id FROM chat_history WHERE visitor_id=?'
|
||||
owned = 'SELECT id FROM chat_history WHERE visitor_id=? AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))'
|
||||
scope = (owner,current_customer_id(),current_scope_key())
|
||||
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions '
|
||||
'WHERE conv_id IN ('+owned+'))', (owner,))
|
||||
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM chat_history WHERE visitor_id=?', (owner,))
|
||||
'WHERE conv_id IN ('+owned+'))', scope)
|
||||
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', scope)
|
||||
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', scope)
|
||||
cur.execute('DELETE FROM chat_history WHERE id IN ('+owned+')', scope)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import time
|
||||
|
||||
from flask import current_app, jsonify, request
|
||||
from flask import current_app, g, 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
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Human conversation lifecycle; typing state is supplied by the app."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, datetime as DateTime, timedelta, timezone
|
||||
from math import ceil
|
||||
|
||||
from .db import connect
|
||||
|
||||
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
|
||||
QUEUE_TIMEOUT_SECONDS = 5 * 60
|
||||
|
||||
|
||||
def human_request(data):
|
||||
conv_id = data.get('conv_id')
|
||||
if not conv_id:
|
||||
return ({'ok': False, 'error': 'no conv'}, 400)
|
||||
if not 8 <= datetime.now().hour < 22:
|
||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
service_time = datetime.now(SERVICE_TIMEZONE)
|
||||
if not 8 <= service_time.hour < 22:
|
||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
|
||||
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
conn = connect()
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
|
||||
@@ -67,10 +72,24 @@ def human_send(data):
|
||||
|
||||
|
||||
def human_queue():
|
||||
now = datetime.now(SERVICE_TIMEZONE).replace(tzinfo=None)
|
||||
conn = connect()
|
||||
rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [{'id': r[0], 'conv_id': r[1], 'ctime': r[3]} for r in rows]
|
||||
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],
|
||||
'waiting_minutes':elapsed//60, 'overdue':overdue,
|
||||
'overdue_minutes':max(1,ceil((elapsed-QUEUE_TIMEOUT_SECONDS)/60)) if overdue else 0})
|
||||
return queue
|
||||
|
||||
|
||||
def human_active():
|
||||
|
||||
+205
-20
@@ -2,24 +2,209 @@
|
||||
|
||||
|
||||
knowledge_list = [
|
||||
# ==================== 一、订单 ====================
|
||||
{
|
||||
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"],
|
||||
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。"
|
||||
"keys": ["查询订单", "查订单", "订单查询", "订单在哪里", "我的订单在哪", "帮我查订单", "为什么查不到订单"],
|
||||
"text": (
|
||||
"请提供订单号、入仓号或关联运单号,并核验订单归属。"
|
||||
"查询结果以实际记录为准。查不到时,应核对号码、查询范围和数据同步情况,"
|
||||
"不能直接认定没有发货,也不能编造运输或清关结果。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys":["海运","船运","海派","海运快线","樱花快线"],
|
||||
"keys": ["订单号和运单号", "订单号是什么", "运单号是什么", "入仓号是什么", "入仓码是什么", "一票多个单号", "主子订单"],
|
||||
"text": (
|
||||
"订单号识别业务订单,入仓号用于收货归集,运单号识别运输任务或包裹。"
|
||||
"一票货可能因分包、分批或阶段换号产生多个编号,"
|
||||
"应根据实际关联记录分别确认,不能把换号当成新增货物。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["付款后没发货", "付款了没发货", "下单后没发货", "下单后多久发货", "修改订单", "订单能修改吗", "取消订单", "订单能取消吗", "更换渠道"],
|
||||
"text": (
|
||||
"下单或付款成功不代表已出运,还需核对收货、审核和运输安排。"
|
||||
"修改、取消或换渠道需根据当前操作阶段确认。"
|
||||
"变更申请不等于执行成功,是否产生费用按适用规则确认。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 二、客户信息 ====================
|
||||
{
|
||||
"keys": ["手机号查订单", "用手机号查", "客户编号查订单", "客户号查订单", "公司名称查订单", "用公司名查", "代客户查询", "帮别人查订单"],
|
||||
"text": (
|
||||
"可按系统支持的方式辅助查找,但必须核验身份、订单归属及代理权限。"
|
||||
"不能仅凭手机号或公司名称披露他人的完整订单、地址和联系方式。"
|
||||
"查询权限与修改权限需要分别确认。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["联系人填错", "联系人写错", "手机号填错", "电话填错", "邮箱填错", "客户资料修改", "客户信息修改"],
|
||||
"text": (
|
||||
"客户账户资料与本票收发货资料需要分别管理。"
|
||||
"修改账户资料不一定同步修改历史和在途订单。"
|
||||
"在途信息变更需确认相关操作方是否接受并生效。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["订单负责人", "谁负责这票", "客户待办", "没收到通知"],
|
||||
"text": (
|
||||
"负责人、客户待办和通知情况应根据实际记录确认。"
|
||||
"待办需说明具体事项、提交渠道及已确认的期限。"
|
||||
"没有联系或送达记录时,不能认定客户已收到通知;"
|
||||
"没有分配记录时,不能编造负责人和反馈时间。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 三、地址 ====================
|
||||
{
|
||||
"keys": ["收货地址怎么填", "地址怎么填写", "地址怎么写", "邮编不对", "邮编错误", "地址不完整", "地址不全", "缺少门牌"],
|
||||
"text": (
|
||||
"国际收货地址通常需要国家、州省、城市、街道、门牌、邮编、收件人及电话。"
|
||||
"缺失或不匹配可能影响派送,具体要求以目的地和承运商为准。"
|
||||
"补交资料后仍需确认是否修改成功。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["最终送到哪里", "最后送到哪里", "收货地址确认", "目的地址是什么", "提货地址和收货地址", "提货地址一样吗"],
|
||||
"text": (
|
||||
"提货、交仓、中转、清关和最终收货地址用途不同。"
|
||||
"最终目的地应查询本票已生效的地址,并按权限展示。"
|
||||
"到达中转仓或清关场所,不代表到达最终收货地址。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["修改收货地址", "改地址", "地址可以修改吗", "可以改地址吗", "更换收件人", "收件人可以改吗", "收件人改地址", "改送其他仓", "换仓地址", "FBA地址确认"],
|
||||
"text": (
|
||||
"改址需要核对货物阶段和执行方处理能力,"
|
||||
"未确认生效前不能告知新地址已启用。"
|
||||
"FBA或海外仓需核对当前货件完整地址及仓代码,"
|
||||
"不能仅按历史仓代码表安排配送。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 四、节点 ====================
|
||||
{
|
||||
"keys": ["节点是什么意思", "什么叫节点", "节点和轨迹", "节点是什么", "已提交是什么意思", "订单已提交", "已接单是什么意思"],
|
||||
"text": (
|
||||
"节点是某一步发生的事件,轨迹是多个运输事件组成的记录。"
|
||||
"已提交、审核通过、已接单属于业务处理状态,"
|
||||
"本身不能证明仓库收到实物或货物已经出运。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已预报是什么意思", "预报是什么意思", "已入仓是什么意思", "入仓是什么意思", "已装柜是什么意思", "装柜是什么意思", "已出运是什么意思", "出运了吗"],
|
||||
"text": (
|
||||
"已预报通常表示信息已提交,不一定已交实物;"
|
||||
"已入仓表示仓库收货,不一定完成检查;"
|
||||
"已装柜表示完成装载,不代表已经离港。"
|
||||
"已出运需有实际离港、起飞、发车或交运依据。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["到港是什么意思", "到港了吗", "已提取是什么意思", "提取是什么意思", "已交末端是什么意思", "交末端是什么意思", "派送中是什么意思", "开始派送了吗"],
|
||||
"text": (
|
||||
"到港表示到达港口,之后可能仍需清关和交接。"
|
||||
"已提取、已交末端通常表示当地承运商接收货物,需要核对接收方。"
|
||||
"派送中表示进入配送环节,以上均不等于收件人签收。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 五、轨迹 ====================
|
||||
{
|
||||
"keys": ["完整物流轨迹", "物流轨迹怎么查", "运输轨迹怎么查", "有单号没有轨迹", "有单号查不到", "轨迹好几天没更新", "物流几天没更新", "轨迹不更新"],
|
||||
"text": (
|
||||
"轨迹应说明数据来源、覆盖范围及最后有效事件时间。"
|
||||
"暂无记录可能涉及未交接、扫描未回传或查询源异常,需要核实。"
|
||||
"长期没有更新不能直接认定丢失,没有数据的阶段不能自行补齐。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["换单号后怎么查", "单号换了", "新旧单号", "官网轨迹不一样", "轨迹不一致", "节点重复", "节点时间不一致"],
|
||||
"text": (
|
||||
"换号需保留新旧编号关联。"
|
||||
"不同来源或节点时间不一致时,应核对时区、原始事件、回传及更正记录。"
|
||||
"同步时间较新不一定代表事件较新,排查方向不能当作已确认原因。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["实时位置", "货物现在在哪", "预计到达时间", "预计什么时候到", "轨迹能证明签收吗", "轨迹能当到货证明吗", "模拟轨迹"],
|
||||
"text": (
|
||||
"节点地点通常表示某时经过该处,不等于实时定位。"
|
||||
"预计到达时间属于计划,应注明来源和条件。"
|
||||
"签收争议需核对POD等凭证,演示或模拟轨迹不能证明真实运输和交付。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 六、货物状态 ====================
|
||||
{
|
||||
"keys": ["履约中是什么意思", "履约中代表什么", "头程完成是什么意思", "头程完成了吗", "订单完成就是签收吗", "订单完成是不是签收"],
|
||||
"text": (
|
||||
"履约中表示订单正在执行,不能据此判断具体位置或清关阶段。"
|
||||
"当前系统的头程订单已完成,不等于末端已签收,"
|
||||
"也不能直接证明客户已收到货物。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["货物是否完好", "货物完好吗", "有没有少件", "少了几件", "货物受潮", "货物进水", "部分到货", "部分签收", "只到了一部分"],
|
||||
"text": (
|
||||
"货况需依据检查、交接件数和照片等记录确认,运输正常不能证明完好。"
|
||||
"部分到货或签收时,应分别核对总件数及各件进度。"
|
||||
"没有检查依据时说明尚未核实,不能用一件状态代表整票。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已签收但没收到", "显示签收没收到", "签收人不认识", "谁签收的", "货物异常", "物流异常", "滞留是什么意思", "货物滞留"],
|
||||
"text": (
|
||||
"签收争议应核对签收人、交付地点、代收及POD,必要时调查。"
|
||||
"异常或滞留需明确发生环节和实际原因,不能直接等同海关扣货。"
|
||||
"调查前不能擅自认定货物丢失或客户已经收到。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 七、清关 ====================
|
||||
{
|
||||
"keys": ["清关了吗", "我的货清关了吗", "是否清关", "清关完成没有", "清关完成了吗", "有没有放行", "出口放行就是清关完成吗"],
|
||||
"text": (
|
||||
"查询清关需明确国家、出口或进口环节,并核对有效记录。"
|
||||
"出口放行不代表目的国进口清关完成,到港和已付款也不能证明放行。"
|
||||
"没有可靠数据时应说明暂未取得结果,不能把未知当作已完成或未完成。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已申报是什么意思", "申报了吗", "清关中是什么意思", "现在清关中吗", "查验是不是没收", "被查验了吗", "清关为什么慢", "清关怎么还没好"],
|
||||
"text": (
|
||||
"申报动作、申报受理和放行需分别核实;"
|
||||
"清关中表示手续正在处理,查验不等于没收。"
|
||||
"具体等待原因以实际反馈为准,"
|
||||
"不能保证统一天数或一定放行,清关状态也不一定按固定顺序变化。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["清关完为什么没派送", "清关了怎么没派送", "整票都清关了吗", "是不是整票放行", "税款交了吗", "税交了吗", "放行证明", "清关证明"],
|
||||
"text": (
|
||||
"资料、税费和放行状态需分别核实,客户付款不一定代表税款已缴。"
|
||||
"放行范围需核对申报单、批次及包裹,不能用一件代表整票。"
|
||||
"放行后可能仍需提货和末端交接;凭证提供需核对关联、授权和脱敏要求。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家","公司地址","办公地址","洛杉矶地址","纽约地址"],
|
||||
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓,公司地址位于:洛杉矶:10767 7th Street, Rancho Cucamonga, California 91730纽约:Door 13-14,75 Carter Dr Edison, New Jersey 08817义乌:浙江省义乌市福田街道稠州北路 1399 号 8613-8615 室深圳:深圳市南山区粤海街道海珠社区海德三道 1258 号卓越后海金融中心 1101。"
|
||||
},
|
||||
{
|
||||
"keys":["海运","船运","海派","海运快线","樱花快线","欧洲海运价格","海运多少钱","海运多久"],
|
||||
"text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。"
|
||||
},
|
||||
{
|
||||
"keys":["铁路","中欧班列","班列","义乌铁路","火车"],
|
||||
"keys":["铁路","中欧班列","班列","义乌铁路","火车","铁路价格","铁路多久","铁路多少钱"],
|
||||
"text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。"
|
||||
},
|
||||
{
|
||||
"keys":["卡航","卡车","卡派"],
|
||||
"keys":["卡航","卡车","卡派","卡航价格","卡航多久","卡航多少钱"],
|
||||
"text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。"
|
||||
},
|
||||
{
|
||||
"keys":["空运","空派","航空","欧洲空运"],
|
||||
"keys":["空运","空派","航空","欧洲空运","空运价格","空运多久","空运多少钱"],
|
||||
"text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。"
|
||||
},
|
||||
{
|
||||
@@ -27,55 +212,55 @@ knowledge_list = [
|
||||
"text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。"
|
||||
},
|
||||
{
|
||||
"keys":["英国空运","英国","UK","伦敦"],
|
||||
"keys":["英国空运","英国空运价格","英国空运多久","英国空运多少钱","英国","UK","伦敦"],
|
||||
"text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。"
|
||||
},
|
||||
{
|
||||
"keys":["报价","价格","费用","计费","怎么收费","计费重量","多少钱","运费","单价"],
|
||||
"keys":["报价","价格","费用","计费","怎么收费","计费重量","计费重量怎么算","材积重","体积重","多少钱","运费","运费怎么算","单价"],
|
||||
"text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。"
|
||||
},
|
||||
{
|
||||
"keys":["时效","多久","要几天","多长时间"],
|
||||
"keys":["时效","运输时效","多久","要几天","多长时间","几天到","什么时候到"],
|
||||
"text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。"
|
||||
},
|
||||
{
|
||||
"keys":["揽收","上门取件","上门收货","交仓","交货"],
|
||||
"keys":["揽收","上门取件","上门收货","可以上门取货吗","交仓","交货","哪里交仓"],
|
||||
"text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。"
|
||||
},
|
||||
{
|
||||
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","交仓地址","送仓"],
|
||||
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","深圳仓","广州仓","交仓地址","送仓"],
|
||||
"text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。"
|
||||
},
|
||||
{
|
||||
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔"],
|
||||
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔","怎么理赔","丢件怎么赔","理赔多久"],
|
||||
"text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。"
|
||||
},
|
||||
{
|
||||
"keys":["超轻费","计费重量不足","最低计费"],
|
||||
"keys":["超轻费","计费重量不足","最低计费","重量不够怎么收费"],
|
||||
"text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。"
|
||||
},
|
||||
{
|
||||
"keys":["偏远","偏远费","偏远地址"],
|
||||
"keys":["偏远","偏远费","偏远地址","偏远怎么查","偏远收费"],
|
||||
"text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。"
|
||||
},
|
||||
{
|
||||
"keys":["超重","超长","超大","尺寸限制"],
|
||||
"keys":["超重","超长","超大","尺寸限制","尺寸超了","超尺寸怎么收费"],
|
||||
"text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。"
|
||||
},
|
||||
{
|
||||
"keys":["敏感","带电","电池","液体","粉末","禁品","不能寄","违禁品","拒收","拒接"],
|
||||
"keys":["敏感","敏感货能发吗","带电","电池","液体","粉末","禁品","不能寄","什么不能寄","违禁品","拒收","拒接"],
|
||||
"text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。"
|
||||
},
|
||||
{
|
||||
"keys":["清关","递延","VAT","双清","包税","DDP"],
|
||||
"keys":["清关","清关资料","清关要什么资料","递延","VAT","VAT递延","双清","双清包税","包税","DDP"],
|
||||
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
|
||||
},
|
||||
{
|
||||
"keys":["报关","单独报关","报关费"],
|
||||
"keys":["报关","怎么报关","单独报关","报关费","报关多少钱"],
|
||||
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
|
||||
},
|
||||
{
|
||||
"keys":["亚马逊","FBA","海外仓","仓库配送","卡派"],
|
||||
"keys":["亚马逊","亚马逊仓","FBA","FBA头程","海外仓","仓库配送","卡派直送"],
|
||||
"text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。"
|
||||
},
|
||||
{
|
||||
@@ -87,7 +272,7 @@ knowledge_list = [
|
||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||
},
|
||||
{
|
||||
"keys":["退件","退回","退货","退运","改地址"],
|
||||
"keys":["退件","退回","退货","退运","怎么退运"],
|
||||
"text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""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}
|
||||
@@ -7,7 +7,8 @@ from datetime import datetime
|
||||
|
||||
from .config import get_setting
|
||||
from .order_tracking import build_tracking
|
||||
from .visitor_service import customer_tenant_id
|
||||
from .visitor_service import customer_tenant_ids
|
||||
from .customer_service import order_login_response
|
||||
|
||||
|
||||
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||
@@ -17,13 +18,12 @@ FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_
|
||||
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
|
||||
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
|
||||
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
|
||||
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
|
||||
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||
ORDER_LOGIN_TEXT = '订单查询将在客户账号登录并关联后开放。您可以先咨询运费、时效,或联系人工客服。'
|
||||
|
||||
|
||||
class OrderDataUnavailable(Exception):
|
||||
@@ -112,7 +112,7 @@ def import_tracking_records(connection, records, replace=False):
|
||||
return {'imported': imported, 'preserved': preserved, 'missing': missing}
|
||||
|
||||
|
||||
def lookup_order(reference, tenant_id=None):
|
||||
def lookup_order(reference, tenant_id=None, tenant_ids=None):
|
||||
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
|
||||
from .db import connect
|
||||
|
||||
@@ -122,13 +122,25 @@ def lookup_order(reference, tenant_id=None):
|
||||
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)'
|
||||
values = [reference] * 3
|
||||
tenant = get_setting('ORDER_DATA_TENANT_ID')
|
||||
if tenant_id is not None:
|
||||
if tenant_ids is not None:
|
||||
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
|
||||
return []
|
||||
allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128}
|
||||
if tenant_id is not None:
|
||||
allowed.intersection_update({str(tenant_id)})
|
||||
if tenant:
|
||||
allowed.intersection_update({str(tenant)})
|
||||
if not allowed:
|
||||
return []
|
||||
query += ' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')'
|
||||
values.extend(sorted(allowed))
|
||||
elif tenant_id is not None:
|
||||
if not str(tenant_id).strip():
|
||||
return []
|
||||
if tenant and str(tenant_id) != str(tenant):
|
||||
return []
|
||||
tenant = str(tenant_id)
|
||||
if tenant:
|
||||
if tenant and tenant_ids is None:
|
||||
query += ' AND tenant_id=?'
|
||||
values.append(str(tenant))
|
||||
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
|
||||
@@ -164,6 +176,8 @@ def build_card(row):
|
||||
def field(key, value):
|
||||
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
|
||||
field('inbound', row['inbound_code'])
|
||||
if row['inbound_code']:
|
||||
field('number', row['order_no'])
|
||||
field('shipment', row['shipment_id'])
|
||||
field('country', row['country_code'])
|
||||
field('warehouse', row['warehouse_code'])
|
||||
@@ -183,8 +197,9 @@ def build_card(row):
|
||||
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
|
||||
nodes.reverse()
|
||||
status = STATUS_LABELS.get(row['status'], row['status'])
|
||||
summary = f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
|
||||
inbound = row['inbound_code']
|
||||
summary = f"入仓码 {inbound},业务状态:{status}。" if inbound else f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields if item['key'] != 'inbound')
|
||||
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
|
||||
'nodes':nodes, 'summary':summary}
|
||||
tracking = build_tracking(row.get('tracking_json'))
|
||||
@@ -218,11 +233,11 @@ def is_order_followup(message):
|
||||
|
||||
def answer_query(message, conv_id):
|
||||
references = extract_reference(message)
|
||||
tenant = customer_tenant_id()
|
||||
if references and tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
tenants = customer_tenant_ids()
|
||||
if references and not tenants:
|
||||
return order_login_response()
|
||||
if len(references) > 1:
|
||||
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
|
||||
return {'t':'请一次提供一个入仓码,避免查错订单。'}
|
||||
reference = references[0] if references else None
|
||||
if not reference and conv_id and is_order_followup(message):
|
||||
from .db import connect
|
||||
@@ -248,19 +263,23 @@ def answer_query(message, conv_id):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if not reference:
|
||||
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
|
||||
return {'t':ORDER_LOGIN_TEXT if tenant is None else '请提供订单号、入仓码或 Shipment ID。'}
|
||||
if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|'
|
||||
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I):
|
||||
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
|
||||
return None
|
||||
if tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
try:
|
||||
matches = lookup_order(reference, tenant_id=tenant)
|
||||
matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants)
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if not matches:
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
|
||||
if len(matches) > 1:
|
||||
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
|
||||
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
|
||||
if re.search(r'运费|费用|多少钱|价格', message):
|
||||
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
|
||||
return {'card': build_card(matches[0])}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""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}
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
from . import admin, business, chat, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, files, history, human, pages, speech, visitor):
|
||||
from . import admin, business, chat, customer, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor):
|
||||
app.register_blueprint(module.bp)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""HTTP adapters for admin; business logic lives in services."""
|
||||
|
||||
from flask import Blueprint, Response, render_template, request
|
||||
import sqlite3
|
||||
|
||||
from .. import business_service
|
||||
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
|
||||
|
||||
bp = Blueprint('admin', __name__)
|
||||
|
||||
@@ -13,7 +16,18 @@ def admin_stats():
|
||||
|
||||
@bp.route('/admin/login', methods=['POST'])
|
||||
def admin_login():
|
||||
return business_service.admin_login(request.json or {})
|
||||
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()
|
||||
|
||||
|
||||
@bp.route('/api/export_chat')
|
||||
@@ -42,3 +56,133 @@ 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})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""HTTP adapters for business; business logic lives in services."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, g, request
|
||||
|
||||
from .. import business_service, visitor_service
|
||||
from .. import business_service, ticket_service, visitor_service
|
||||
|
||||
bp = Blueprint('business', __name__)
|
||||
|
||||
@@ -13,12 +13,40 @@ def my_orders():
|
||||
|
||||
@bp.route('/api/ticket', methods=['POST'])
|
||||
def add_ticket():
|
||||
return business_service.add_ticket(request.get_json() or {})
|
||||
return business_service.add_ticket(request.get_json(silent=True))
|
||||
|
||||
|
||||
@bp.route('/api/tickets')
|
||||
def list_tickets():
|
||||
return business_service.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)
|
||||
|
||||
|
||||
@bp.route('/api/rate', methods=['POST'])
|
||||
|
||||
@@ -45,4 +45,4 @@ def stream_chat():
|
||||
|
||||
@bp.route('/api/feedback', methods=['POST'])
|
||||
def feedback():
|
||||
return business_service.feedback(request.get_json(silent=True) or {})
|
||||
return business_service.feedback(request.get_json(silent=True))
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Public, visitor-bound customer login endpoints. No staff privileges."""
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
from .. import customer_service, customer_order_service
|
||||
from ..order_data import OrderDataUnavailable
|
||||
|
||||
bp = Blueprint('customer', __name__)
|
||||
|
||||
|
||||
@bp.get('/api/customer/session')
|
||||
def session():
|
||||
return customer_service.session_status()
|
||||
|
||||
|
||||
@bp.post('/api/customer/login')
|
||||
def login():
|
||||
request.max_content_length = 2048
|
||||
try:
|
||||
return customer_service.login(request.get_json(silent=True), current_app.extensions['customer_login_attempts'],
|
||||
current_app.extensions['customer_login_lock'])
|
||||
except RequestEntityTooLarge:
|
||||
return {'ok':False,'error':'登录信息过长。'}, 413
|
||||
|
||||
|
||||
@bp.post('/api/customer/logout')
|
||||
def logout():
|
||||
return customer_service.logout()
|
||||
|
||||
|
||||
@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)
|
||||
@@ -1,12 +1,17 @@
|
||||
"""HTTP adapters for files; business logic lives in services."""
|
||||
|
||||
from flask import Blueprint, abort, request, send_from_directory
|
||||
from flask import Blueprint, render_template, request, send_from_directory
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from .. import file_service, visitor_service
|
||||
from ..config import get_setting
|
||||
|
||||
bp = Blueprint('files', __name__)
|
||||
|
||||
|
||||
def unavailable(staff=False):
|
||||
return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'}
|
||||
|
||||
@bp.route('/api/upload', methods=['POST'])
|
||||
def upload_file():
|
||||
return file_service.upload_file(request.files)
|
||||
@@ -15,14 +20,20 @@ def upload_file():
|
||||
@bp.route('/uploads/<path:name>')
|
||||
def uploaded_file(name):
|
||||
if not visitor_service.owns_upload(name):
|
||||
abort(404)
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
return unavailable()
|
||||
try:
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
except NotFound:
|
||||
return unavailable()
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
@bp.route('/api/staff/uploads/<path:name>')
|
||||
def staff_uploaded_file(name):
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
try:
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
except NotFound:
|
||||
return unavailable(staff=True)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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.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
|
||||
@@ -0,0 +1,243 @@
|
||||
"""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}
|
||||
@@ -15,6 +15,7 @@ CUSTOMER_PATHS = {
|
||||
'/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history',
|
||||
'/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback',
|
||||
'/api/speech/config', '/api/speech/transcribe',
|
||||
'/api/customer/session', '/api/customer/login', '/api/customer/logout', '/api/customer/orders',
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +28,7 @@ def visitor_id():
|
||||
|
||||
|
||||
def customer_tenant_id():
|
||||
# Future login adapters may set this only after validating a customer session.
|
||||
# Set only after validating a server-side customer session.
|
||||
# Visitor cookies, request parameters and headers are deliberately not accepted.
|
||||
value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
@@ -36,10 +37,24 @@ def customer_tenant_id():
|
||||
return tenant if tenant and len(tenant) <= 128 else None
|
||||
|
||||
|
||||
def customer_tenant_ids():
|
||||
if not has_app_context():
|
||||
return ()
|
||||
# Also retain compatibility with a trusted single-tenant authentication adapter.
|
||||
single = customer_tenant_id()
|
||||
if single:
|
||||
return (single,)
|
||||
values = getattr(g,'customer_tenant_ids',())
|
||||
if not isinstance(values,(list,tuple)) or len(values) > 256:
|
||||
return ()
|
||||
return tuple(sorted(set(value for value in values if isinstance(value,str) and 0 < len(value.strip()) <= 128)))
|
||||
|
||||
|
||||
def attach_visitor():
|
||||
key = request.cookies.get(COOKIE_NAME)
|
||||
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
|
||||
is_customer = request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
|
||||
is_customer = (request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
|
||||
or request.path.startswith('/api/customer/orders/'))
|
||||
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'):
|
||||
@@ -65,25 +80,30 @@ def start_session(data):
|
||||
|
||||
|
||||
def owns_conversation(conv_id):
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=?',
|
||||
(conv_id, owner)).fetchone())
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||
(conv_id, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def owns_human_session(sid):
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id '
|
||||
'WHERE s.id=? AND h.visitor_id=?', (sid, owner)).fetchone())
|
||||
'WHERE s.id=? AND h.visitor_id=? AND (h.customer_id IS NULL OR '
|
||||
'(h.customer_id=? AND h.customer_scope=?))',
|
||||
(sid, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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())
|
||||
+68
-5
@@ -10,6 +10,9 @@ 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; }
|
||||
@@ -54,6 +57,9 @@ 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; }
|
||||
@@ -137,8 +143,64 @@ 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-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); }
|
||||
.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; }
|
||||
@media (max-width: 720px) { #ticket-note, #ticket-search, .ticket-toolbar select { font-size: 16px; } }
|
||||
.tools-backdrop, .sessions-back { display: none; }
|
||||
@media (max-width: 1200px) {
|
||||
.layout, .tools-open .layout { grid-template-columns: 280px minmax(0, 1fr); }
|
||||
@@ -156,10 +218,10 @@ body:not(.has-active-session) .close { display: none; }
|
||||
#chatBox { padding-left: 18px; padding-right: 18px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.topbar { height: 52px; min-height: 52px; padding: 0 16px; }
|
||||
.topbar { height: auto; min-height: 52px; padding: 8px 16px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.app-identity h1, .nav-divider { display: none; }
|
||||
.topbar .brand-name { font-size: 14px; }
|
||||
.topbar-actions { gap: 12px; }
|
||||
.topbar-actions { gap: 8px; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
|
||||
.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; }
|
||||
@@ -196,6 +258,7 @@ body:not(.has-active-session) .close { display: none; }
|
||||
.reply-status { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.workspace-link { display: none; }
|
||||
.workspace-link:not(.orders-link) { display: none; }
|
||||
.orders-link { display: block; font-size: 11px; }
|
||||
.input-actions > span { max-width: 185px; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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; } }
|
||||
+123
-45
@@ -42,18 +42,73 @@ 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); }
|
||||
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
|
||||
.customer-account { display: block; width: 100%; min-height: 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-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
|
||||
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
|
||||
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; }
|
||||
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
|
||||
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
|
||||
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
|
||||
#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
|
||||
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
|
||||
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
|
||||
#customer-submit { border-color: var(--brand); background: var(--brand); color: white; }
|
||||
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
|
||||
.main-chat { flex: 1; min-width: 0; 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: 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 { 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::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; }
|
||||
.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; }
|
||||
.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: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; }
|
||||
@@ -64,7 +119,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.ai-avatar svg { width: 27px; height: 27px; }
|
||||
.welcome-eyebrow { color: var(--brand-text); font-size: 11px; font-weight: 600; letter-spacing: .06em; margin-bottom: 9px; }
|
||||
.ai-welcome-card h3 { font-size: clamp(22px, 2.2vw, 30px); line-height: 1.4; letter-spacing: -.035em; font-weight: 650; color: var(--text); }
|
||||
.ai-welcome-card p { color: var(--secondary); font-size: 13px; line-height: 1.8; margin-top: 10px; }
|
||||
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; }
|
||||
.suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; }
|
||||
.suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); }
|
||||
@@ -72,12 +126,11 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.suggest-icon svg { width: 19px; height: 19px; }
|
||||
.suggest-copy { min-width: 0; }
|
||||
.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; }
|
||||
.suggest-copy small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; line-height: 1.6; }
|
||||
.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; }
|
||||
.hot-questions { margin-top: 24px; }
|
||||
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
|
||||
.hot-capsule-wrap { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.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 { 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: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; }
|
||||
@@ -86,8 +139,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.recent-item:hover { color: var(--brand-text); }
|
||||
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
|
||||
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.composer-toolbar .hot-bar { flex: 1; min-width: 0; }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: center; 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; }
|
||||
}
|
||||
.voice-toggle, .voice-hold { display: none; }
|
||||
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
|
||||
.voice-panel p { margin: 9px 0; font-size: 13px; }
|
||||
@@ -97,8 +155,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-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
|
||||
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
|
||||
.hot-bar .hot-capsule { border: 0; padding: 8px 12px; background: var(--surface-soft); font-size: 13px; }
|
||||
.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; }
|
||||
@@ -110,12 +167,10 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
|
||||
#input-text::placeholder { color: var(--muted); }
|
||||
#input-text:placeholder-shown { white-space: nowrap; overflow: hidden; }
|
||||
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
|
||||
#send-button:hover { background: var(--brand-hover); }
|
||||
#send-button.stop { background: var(--danger); }
|
||||
.composer-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 2px 0; }
|
||||
.disclaimer, .keyboard-hint { color: var(--muted); font-size: 11px; }
|
||||
.keyboard-hint { white-space: nowrap; }
|
||||
.msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; }
|
||||
.msg-row.user-line { align-items: flex-end; }
|
||||
.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; }
|
||||
@@ -132,6 +187,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
|
||||
.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; }
|
||||
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
|
||||
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
|
||||
.vote-status.is-error { color: var(--danger); }
|
||||
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
|
||||
.err-line { margin-bottom: 9px; }
|
||||
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
|
||||
@@ -159,7 +216,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
|
||||
.track-expand:hover { background: var(--brand-soft); }
|
||||
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
|
||||
.order-data-notice { margin: 12px 0; padding: 10px; border-radius: 7px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; line-height: 1.8; }
|
||||
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
|
||||
.order-data-fields dt { color: var(--muted); }
|
||||
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
|
||||
@@ -177,19 +233,31 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); }
|
||||
.attachment-copy { flex: 1; min-width: 0; }
|
||||
.attachment-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
|
||||
.attachment-copy small { display: block; font-size: 10px; color: var(--muted); margin-top: 2px; }
|
||||
.attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; }
|
||||
.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
|
||||
.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; }
|
||||
.ai-welcome-card { padding-top: 0; }
|
||||
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
|
||||
.welcome-eyebrow { margin-bottom: 6px; }
|
||||
.ai-welcome-card p { margin-top: 8px; }
|
||||
.suggest-grid { margin-top: 20px; gap: 10px; }
|
||||
.suggest-item { min-height: 72px; padding: 13px 16px; }
|
||||
.hot-questions { margin-top: 18px; }
|
||||
@@ -202,70 +270,80 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.composer { width: calc(100% - 50px); }
|
||||
.service-label { display: none; }
|
||||
.suggest-item { padding: 13px; gap: 10px; }
|
||||
.suggest-copy small { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
|
||||
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||
.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); }
|
||||
.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); padding: 13px 18px; gap: 11px; }
|
||||
.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; }
|
||||
#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-footer { display: none; }
|
||||
.is-composing .composer-toolbar { 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; }
|
||||
.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; }
|
||||
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
|
||||
.user-message, .robot-message { max-width: 94%; }
|
||||
.del-history-btn { opacity: 1; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.ai-welcome-card h3 { font-size: 23px; }
|
||||
.ai-welcome-card p { max-width: 300px; margin: 10px auto 0; font-size: 12px; }
|
||||
.suggest-grid { gap: 9px; margin-top: 22px; }
|
||||
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; }
|
||||
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 88px; padding: 13px; gap: 9px; }
|
||||
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
|
||||
.suggest-copy strong { font-size: 13px; }
|
||||
.suggest-copy small { font-size: 11px; }
|
||||
.suggest-copy strong { font-size: 14px; }
|
||||
.suggest-arrow { display: none; }
|
||||
.hot-questions { margin-top: 22px; }
|
||||
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
|
||||
.hot-questions .hot-capsule { flex-shrink: 0; white-space: nowrap; }
|
||||
.hot-capsule { font-size: 10px; padding: 6px 8px; }
|
||||
.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; }
|
||||
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
|
||||
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; margin-bottom: 8px; }
|
||||
.hot-bar-label { display: none; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.hot-bar .hot-capsule { padding: 8px; }
|
||||
.input-area { gap: 7px; padding: 7px; }
|
||||
#send-button { min-width: 57px; padding: 0 12px; }
|
||||
#upload-btn { flex-basis: 36px; height: 42px; }
|
||||
#input-text { font-size: 16px; }
|
||||
#send-button { min-height: 42px; }
|
||||
.attachment-remove { width: 40px; height: 40px; }
|
||||
.keyboard-hint { display: none; }
|
||||
.composer-footer { justify-content: center; padding-top: 7px; }
|
||||
.disclaimer { font-size: 10px; }
|
||||
.attachment-remove { width: 44px; height: 44px; }
|
||||
.track-text { grid-template-columns: 1fr; gap: 2px; }
|
||||
.track-place { grid-column: 1; }
|
||||
.robot-message.track-card { padding: 16px; }
|
||||
|
||||
@@ -81,11 +81,37 @@ svg { display: block; flex-shrink: 0; }
|
||||
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
|
||||
.emoji-picker button:hover { background: var(--surface-hover); }
|
||||
.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
+97
-21
@@ -2,6 +2,7 @@ var curSid = 0;
|
||||
var sessions = {};
|
||||
var drafts = {};
|
||||
var queueItems = [];
|
||||
var queueOverdueNotified = new Set();
|
||||
var accepting = new Set();
|
||||
var sendingSid = "";
|
||||
var closingSid = "";
|
||||
@@ -10,7 +11,12 @@ 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 replyInput = document.getElementById("replyText");
|
||||
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
|
||||
|
||||
function escapeText(value){
|
||||
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
|
||||
@@ -19,13 +25,66 @@ function escapeText(value){
|
||||
}
|
||||
|
||||
async function adminRequest(path, options){
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)});
|
||||
}
|
||||
@@ -85,11 +144,16 @@ 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);
|
||||
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
|
||||
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.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
|
||||
}).join("")
|
||||
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
|
||||
}
|
||||
@@ -97,6 +161,17 @@ 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 + " 位客户等待接入");
|
||||
}
|
||||
@@ -108,15 +183,7 @@ async function loadQueue(){
|
||||
}
|
||||
|
||||
async function loadTickets(){
|
||||
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>';
|
||||
}
|
||||
return Promise.all([ticketDesk.load(), ticketDesk.refreshSummary()]);
|
||||
}
|
||||
|
||||
async function accept(sid){
|
||||
@@ -298,7 +365,9 @@ async function sendReply(){
|
||||
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
|
||||
poll();
|
||||
}catch(error){
|
||||
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
|
||||
if(curSid === sid) setReplyStatus(error.name === 'AbortError'
|
||||
? "发送超时,内容已保留。请先确认聊天记录,再决定是否重试。"
|
||||
: "发送未成功,内容已保留。请确认后重试。", true);
|
||||
}finally{
|
||||
sendingSid = "";
|
||||
updateComposer();
|
||||
@@ -385,14 +454,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(fromList ? "tools-from-list" : "tools-toggle").focus();
|
||||
document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle"].includes(toolsOpener) ? toolsOpener : 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]), a[href], textarea:not([disabled])'));
|
||||
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 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(); }
|
||||
@@ -413,8 +482,15 @@ 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();
|
||||
setInterval(loadQueue, 3000);
|
||||
setInterval(loadTickets, 5000);
|
||||
setInterval(poll, 1000);
|
||||
staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)];
|
||||
|
||||
@@ -10,6 +10,10 @@ 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();
|
||||
@@ -35,7 +39,8 @@ async function login(){
|
||||
location.href = "/admin/dashboard";
|
||||
return;
|
||||
}
|
||||
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" : "暂时无法登录,请稍后重试");
|
||||
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" :
|
||||
response.status === 429 ? "登录尝试过多,请 5 分钟后重试" : "暂时无法登录,请稍后重试");
|
||||
}catch(error){
|
||||
showLoginError("网络连接失败,请检查网络后重试");
|
||||
}finally{
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
(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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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'); }
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,292 @@
|
||||
/* 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);
|
||||
@@ -42,7 +42,9 @@ async function saveNote(){
|
||||
|
||||
async function transferSession(){
|
||||
if(!curSid || closingSid || sendingSid) return;
|
||||
if(!window.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。')) return;
|
||||
if(!await SupportUI.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。', {
|
||||
title:'请确认转接', confirmText:'转接', cancelText:'取消'
|
||||
})) return;
|
||||
const sid = curSid;
|
||||
closingSid = sid;
|
||||
updateComposer();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// 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};
|
||||
}
|
||||
};
|
||||
+382
-101
@@ -6,6 +6,29 @@ const sendBtn = document.getElementById("send-button");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadInput = document.getElementById("upload-input");
|
||||
const composerStatus = document.getElementById("composer-status");
|
||||
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
|
||||
let draftTimer = null;
|
||||
let draftSnapshot = null;
|
||||
function flushDraft(){
|
||||
if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; }
|
||||
const saved = drafts.write(currentConvId, inputDom.value);
|
||||
drafts.active(currentConvId);
|
||||
if(!saved && inputDom.value && !composerStatus.classList.contains('is-error')) setComposerStatus(t('draftSaveFailed'), true);
|
||||
else if(saved && composerStatus.textContent === t('draftSaveFailed')) setComposerStatus('');
|
||||
}
|
||||
function restoreDraft(){
|
||||
inputDom.value = drafts.read(currentConvId);
|
||||
draftSnapshot = String(currentConvId) + '\n' + inputDom.value;
|
||||
drafts.active(currentConvId);
|
||||
autoGrow();
|
||||
}
|
||||
function queueDraft(){
|
||||
const snapshot = String(currentConvId) + '\n' + inputDom.value;
|
||||
if(snapshot === draftSnapshot) return;
|
||||
draftSnapshot = snapshot;
|
||||
if(draftTimer !== null) clearTimeout(draftTimer);
|
||||
draftTimer = setTimeout(flushDraft, 250);
|
||||
}
|
||||
function focusComposer(force){
|
||||
if(force || !isNarrow() || document.activeElement === inputDom){
|
||||
inputDom.focus({preventScroll:true});
|
||||
@@ -17,6 +40,8 @@ let uploadController = null;
|
||||
let historyRequestVersion = 0;
|
||||
let followLatest = true;
|
||||
let voiceInput = null;
|
||||
let customerLogin = null;
|
||||
let customerOrders = null;
|
||||
let creatingConv = null;
|
||||
let sendPreparing = false;
|
||||
let conversationVersion = 0;
|
||||
@@ -28,8 +53,13 @@ let humanPollTimer = null;
|
||||
let humanSendingToken = null;
|
||||
let humanClosing = false;
|
||||
window.humanSid = 0;
|
||||
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||
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();
|
||||
};
|
||||
|
||||
function setComposerStatus(message, isError){
|
||||
composerStatus.textContent = message || "";
|
||||
@@ -53,6 +83,7 @@ async function requestJson(path, options){
|
||||
}
|
||||
|
||||
function clearAttachment(){
|
||||
imageViewer.close();
|
||||
uploadVersion += 1;
|
||||
if(uploadController){
|
||||
uploadController.abort();
|
||||
@@ -75,8 +106,14 @@ function scrollChatToEnd(force){
|
||||
|
||||
uploadBtn.onclick = ()=> uploadInput.click();
|
||||
uploadInput.onchange = function(){
|
||||
const f = uploadInput.files[0];
|
||||
if(!f || uploadPending) return;
|
||||
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 version = ++uploadVersion;
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
@@ -88,7 +125,7 @@ uploadInput.onchange = function(){
|
||||
const controller = new AbortController();
|
||||
uploadController = controller;
|
||||
const timer = setTimeout(function(){ controller.abort(); }, 15000);
|
||||
fetch("/api/upload", {method:"POST", body:fd, signal:controller.signal})
|
||||
return 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"));
|
||||
@@ -107,7 +144,13 @@ uploadInput.onchange = function(){
|
||||
var image = document.createElement("img");
|
||||
image.src = data.url;
|
||||
image.alt = data.name || "待发送图片";
|
||||
prev.appendChild(image);
|
||||
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);
|
||||
}else{
|
||||
var filename = document.createElement("span");
|
||||
filename.className = "attachment-file-icon";
|
||||
@@ -118,9 +161,7 @@ uploadInput.onchange = function(){
|
||||
copy.className = "attachment-copy";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = data.name;
|
||||
const hint = document.createElement("small");
|
||||
hint.textContent = t("attachmentReady");
|
||||
copy.append(name, hint);
|
||||
copy.append(name);
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "attachment-remove";
|
||||
@@ -148,8 +189,9 @@ uploadInput.onchange = function(){
|
||||
uploadBtn.textContent = "+";
|
||||
}
|
||||
});
|
||||
uploadInput.value = "";
|
||||
};
|
||||
}
|
||||
const attachmentGestures = ChatAttachments.bindUpload({zone:document.querySelector('.main-chat'), input:inputDom,
|
||||
hint:document.getElementById('attachment-drop-hint'), upload:uploadAttachment, status:setComposerStatus, text:t});
|
||||
const leftHistoryWrap = document.getElementById("historyWrap");
|
||||
const recentChatWrap = document.getElementById("recentChatWrap");
|
||||
const clearAllBtn = document.querySelector('.clear-all-btn');
|
||||
@@ -196,6 +238,7 @@ 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){}
|
||||
};
|
||||
}
|
||||
@@ -205,10 +248,45 @@ 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: "退出失败,请重试。",
|
||||
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: "没有匹配的历史对话",
|
||||
@@ -216,9 +294,10 @@ const I18N = {
|
||||
thinking: "正在思考中...",
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。",
|
||||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
|
||||
humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
offlinePrompt: "请输入您的留言:",
|
||||
offlineInputLabel: "留言内容",
|
||||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||||
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
|
||||
humanReply: "客服回复",
|
||||
@@ -226,13 +305,15 @@ const I18N = {
|
||||
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
|
||||
humanActive: "人工客服接待中,消息将发送给人工客服。",
|
||||
returnAI: "返回 AI 问答",
|
||||
humanSent: "已发送给人工客服",
|
||||
humanSending: "正在发送给人工客服…",
|
||||
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
|
||||
humanConnectFailed: "人工客服连接失败,请稍后重试。",
|
||||
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
|
||||
humanReturnFailed: "暂时无法结束人工会话,请重试。",
|
||||
historyLoading: "正在加载这段会话,请稍候…",
|
||||
historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。",
|
||||
draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。",
|
||||
attachmentUnavailable: "图片暂时无法访问,请重新发送附件。",
|
||||
recalled: "该消息已撤回",
|
||||
ratingQuestion: "本次服务满意吗?",
|
||||
ratingThanks: "感谢您的评价!",
|
||||
@@ -240,27 +321,25 @@ const I18N = {
|
||||
networkFail: "网络连接中断,请检查网络后重试",
|
||||
stopped: "已停止生成",
|
||||
stoppedMark: "(已停止生成)",
|
||||
inputPh: "请输入问题、订单号或入仓码…(Enter 发送,Shift+Enter 换行)",
|
||||
inputPh: "输入入仓码或问题…",
|
||||
send: "发送",
|
||||
stop: "停止",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败",
|
||||
voteUp: "这条回复有帮助",
|
||||
voteDown: "这条回复没有帮助",
|
||||
retry: "重答",
|
||||
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
|
||||
clickRetry: "点击重试",
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeSub: "咨询运费、时效或联系人工客服。订单查询需关联客户账号。",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
serviceScope: "中欧与英国专线咨询",
|
||||
serviceTag: "物流咨询服务",
|
||||
quickLabel: "快捷咨询",
|
||||
footerTrack: "查订单",
|
||||
footerTrack: "查入仓码",
|
||||
footerQuote: "专线报价",
|
||||
footerException: "异常处理",
|
||||
footerHuman: "联系人工",
|
||||
disclaimer: "报价与时效仅供参考,请以客服最终确认为准",
|
||||
keyboardHint: "Enter 发送 · Shift + Enter 换行",
|
||||
inputShort: "输入问题、订单号或入仓码…",
|
||||
inputShort: "输入入仓码…",
|
||||
uploading: "附件正在上传,请稍候…",
|
||||
uploadFailed: "附件上传失败,请重新选择文件",
|
||||
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
|
||||
@@ -276,7 +355,6 @@ const I18N = {
|
||||
voiceEmpty: "没有识别到文字,请靠近麦克风重试。", voiceFailed: "语音输入失败,请重试或使用文字输入。",
|
||||
voiceTimeout: "语音处理超时,请重试。", voiceWait: "请等待当前操作完成后,再使用语音输入。",
|
||||
voiceDraft: "输入框还有未发送内容,请先发送或清空后再使用语音。",
|
||||
attachmentReady: "已准备好,发送后将附在消息中",
|
||||
removeAttachment: "移除待发送附件",
|
||||
you: "您",
|
||||
assistant: "柏悦物流助手",
|
||||
@@ -290,15 +368,12 @@ const I18N = {
|
||||
logisticsHistory: "物流轨迹",
|
||||
noLogisticsHistory: "暂无已发生的物流节点",
|
||||
estimatedTracking: "预计节点",
|
||||
legacyTrackingNotice: "历史模拟轨迹,仅供演示,不代表真实订单。",
|
||||
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_inbound: "入仓码", order_number: "订单号", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
|
||||
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
|
||||
order_parent: "父订单号", order_updated: "来源更新时间",
|
||||
moreTracking: "展开全部轨迹",
|
||||
lessTracking: "收起较早轨迹",
|
||||
quickSubs: ["关联客户账号后查询订单与轨迹", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
|
||||
hot: "热点问题",
|
||||
recent: "最近对话记录",
|
||||
sideCollapse: "收起侧栏",
|
||||
@@ -307,7 +382,7 @@ const I18N = {
|
||||
langTip: "中英文切换(Ctrl+L)",
|
||||
toBottom: "回到底部",
|
||||
quick: [
|
||||
"查询订单状态", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
],
|
||||
hotList: [
|
||||
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
|
||||
@@ -315,10 +390,45 @@ const I18N = {
|
||||
},
|
||||
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.",
|
||||
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",
|
||||
@@ -326,9 +436,10 @@ const I18N = {
|
||||
thinking: "Thinking...",
|
||||
busyRetry: "Service busy, please try again later",
|
||||
sessionFail: "Failed to create session, please check the service is running",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support. Order lookup will be available after signing in and linking a customer account.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.",
|
||||
offlinePrompt: "Please leave your message:",
|
||||
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",
|
||||
@@ -336,13 +447,15 @@ const I18N = {
|
||||
humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.",
|
||||
humanActive: "You are chatting with our support team. Messages will be sent to a team member.",
|
||||
returnAI: "Return to AI chat",
|
||||
humanSent: "Sent to our support team",
|
||||
humanSending: "Sending to our support team…",
|
||||
humanSendFailed: "Message not sent. Your text has been kept. Please retry.",
|
||||
humanConnectFailed: "Could not connect to our support team. Please try again.",
|
||||
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
|
||||
humanReturnFailed: "Could not end the support session. Please try again.",
|
||||
historyLoading: "Loading this conversation. Please wait…",
|
||||
historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.",
|
||||
draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.",
|
||||
attachmentUnavailable: "Image unavailable. Please upload the attachment again.",
|
||||
recalled: "This message was recalled",
|
||||
ratingQuestion: "How was your support experience?",
|
||||
ratingThanks: "Thank you for your feedback!",
|
||||
@@ -350,27 +463,25 @@ const I18N = {
|
||||
networkFail: "Connection lost, please check your network and retry",
|
||||
stopped: "Stopped",
|
||||
stoppedMark: "(Generation stopped)",
|
||||
inputPh: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)",
|
||||
inputPh: "Enter an inbound code or question…",
|
||||
send: "Send",
|
||||
stop: "Stop",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
copyFailed: "Copy failed",
|
||||
voteUp: "This reply was helpful",
|
||||
voteDown: "This reply was not helpful",
|
||||
retry: "Regenerate",
|
||||
retryAIOnly: "Return to AI chat before regenerating this answer.",
|
||||
clickRetry: "Retry",
|
||||
welcomeTitle: "Your next shipment, made clearer.",
|
||||
welcomeSub: "Ask about rates, transit times or human support. Order lookup requires a linked customer account.",
|
||||
welcomeEyebrow: "EUROPE & UK LOGISTICS",
|
||||
serviceScope: "Europe & UK freight support",
|
||||
serviceTag: "Logistics support",
|
||||
quickLabel: "Quick help",
|
||||
footerTrack: "Order lookup",
|
||||
footerTrack: "Inbound lookup",
|
||||
footerQuote: "Get a quote",
|
||||
footerException: "Parcel issue",
|
||||
footerHuman: "Human support",
|
||||
disclaimer: "Quotes and transit times are subject to final confirmation",
|
||||
keyboardHint: "Enter to send · Shift + Enter for a new line",
|
||||
inputShort: "Question or tracking number…",
|
||||
inputShort: "Inbound code…",
|
||||
uploading: "Uploading your attachment…",
|
||||
uploadFailed: "Upload failed. Please select the file again.",
|
||||
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
|
||||
@@ -388,7 +499,6 @@ const I18N = {
|
||||
voiceFailed: "Voice input failed. Please try again or type your message.", voiceTimeout: "Voice input timed out. Please try again.",
|
||||
voiceWait: "Please wait for the current operation to finish before recording.",
|
||||
voiceDraft: "Please send or clear your existing draft before recording.",
|
||||
attachmentReady: "Ready to attach to your next message",
|
||||
removeAttachment: "Remove pending attachment",
|
||||
you: "You",
|
||||
assistant: "Baiyue Assistant",
|
||||
@@ -402,15 +512,12 @@ const I18N = {
|
||||
logisticsHistory: "Shipment tracking",
|
||||
noLogisticsHistory: "No actual shipment events available",
|
||||
estimatedTracking: "Estimated milestones",
|
||||
legacyTrackingNotice: "Historical simulated tracking for demonstration only; this is not a real order record.",
|
||||
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_inbound: "Inbound code", order_number: "Order number", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
|
||||
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
|
||||
order_parent: "Parent order", order_updated: "Source updated",
|
||||
moreTracking: "View full tracking history",
|
||||
lessTracking: "Show recent updates",
|
||||
quickSubs: ["Link a customer account to view orders and tracking", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
|
||||
welcomeTip: "Type your question, or pick a quick option below.",
|
||||
hot: "Popular Questions",
|
||||
recent: "Recent Conversations",
|
||||
sideCollapse: "Collapse sidebar",
|
||||
@@ -419,7 +526,7 @@ const I18N = {
|
||||
langTip: "Switch language (Ctrl+L)",
|
||||
toBottom: "Back to bottom",
|
||||
quick: [
|
||||
"Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
],
|
||||
hotList: [
|
||||
"How do I track my shipment?", "How long does Europe sea freight take?",
|
||||
@@ -432,9 +539,22 @@ 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();
|
||||
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
|
||||
document.querySelectorAll("[data-i18n]").forEach(function(element){
|
||||
const value = dict[element.dataset.i18n];
|
||||
@@ -447,21 +567,34 @@ function applyLanguage(lang){
|
||||
const langBtn = document.getElementById("lang-toggle");
|
||||
if(langBtn){
|
||||
langBtn.textContent = LANG === "en" ? "EN" : "中";
|
||||
langBtn.title = dict.langTip;
|
||||
labelControl(langBtn, dict.langTip);
|
||||
}
|
||||
const themeBtn = document.getElementById("theme-toggle");
|
||||
if(themeBtn) themeBtn.title = dict.themeTip;
|
||||
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);
|
||||
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;
|
||||
if(hs){ hs.placeholder = dict.searchPh; hs.setAttribute('aria-label', dict.historySearch); }
|
||||
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");
|
||||
if(tb) tb.title = dict.toBottom;
|
||||
labelControl(tb, dict.toBottom);
|
||||
const tg = document.querySelector(".toggle-divider");
|
||||
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
|
||||
if(tg) labelControl(tg, tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse);
|
||||
updateHumanMode();
|
||||
if(voiceInput) voiceInput.refresh();
|
||||
|
||||
@@ -483,10 +616,12 @@ function applyLanguage(lang){
|
||||
const n = b.dataset.action;
|
||||
if(n === "copy") b.textContent = dict.copy;
|
||||
if(n === "retry") b.textContent = dict.retry;
|
||||
if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]);
|
||||
});
|
||||
document.querySelectorAll(".retry-btn").forEach(function(b){
|
||||
if(!b.dataset.keep) b.textContent = dict.clickRetry;
|
||||
});
|
||||
document.querySelectorAll('.customer-login-cta').forEach(function(button){ button.textContent = dict.customerQuery; });
|
||||
}
|
||||
|
||||
(function initLang(){
|
||||
@@ -590,7 +725,7 @@ function welcomeHTML(){
|
||||
const kinds = ["track", "quote", "issue", "human"];
|
||||
const cards = q.map(function(question, index){
|
||||
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
|
||||
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong><small>${t("quickSubs")[index]}</small></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
|
||||
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="ai-welcome-card">
|
||||
@@ -598,7 +733,6 @@ function welcomeHTML(){
|
||||
<div class="ai-avatar">${welcomeIcon("box")}</div>
|
||||
<div class="welcome-eyebrow">${t("welcomeEyebrow")}</div>
|
||||
<h3>${t("welcomeTitle")}</h3>
|
||||
<p>${t("welcomeSub")}</p>
|
||||
</div>
|
||||
<div class="suggest-grid">${cards}</div>
|
||||
</div>
|
||||
@@ -618,9 +752,9 @@ function welcomeHTML(){
|
||||
|
||||
// 清空全部历史
|
||||
if(clearAllBtn){
|
||||
clearAllBtn.onclick = function(){
|
||||
clearAllBtn.onclick = async function(){
|
||||
if(openingConv === 'clearing') return;
|
||||
if(!confirm(t("confirmClear"))) return;
|
||||
if(!await SupportUI.confirm(t("confirmClear"), {title:t('confirmTitle'), confirmText:t('confirmButton'), cancelText:t('customerCancel')})) return;
|
||||
const version = resetConversation(null);
|
||||
openingConv = 'clearing';
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
@@ -646,11 +780,12 @@ clearAllBtn.onclick = function(){
|
||||
if(newChatBtn){
|
||||
newChatBtn.onclick = function(){
|
||||
if(openingConv === 'clearing') return;
|
||||
const alreadyNew = currentConvId === null;
|
||||
resetConversation(null);
|
||||
inputDom.value = "";
|
||||
if(alreadyNew) inputDom.value = "";
|
||||
autoGrow();
|
||||
clearAttachment();
|
||||
setComposerStatus("");
|
||||
setComposerStatus('');
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
chatDom.scrollTop = 0;
|
||||
followLatest = true;
|
||||
@@ -666,10 +801,13 @@ function loadHistory(){
|
||||
historyList = data;
|
||||
renderSideHistory();
|
||||
renderRecentChat();
|
||||
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
|
||||
return data;
|
||||
}).catch(function(){ return null; /* 保留上次成功加载的历史。 */ });
|
||||
}
|
||||
|
||||
function removeCachedHistory(id){
|
||||
if(id === undefined){ drafts.clear(); flushDraft(); }
|
||||
else drafts.remove(id);
|
||||
historyRequestVersion += 1;
|
||||
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
|
||||
renderSideHistory();
|
||||
@@ -724,7 +862,7 @@ function renderSideHistory(){
|
||||
const pinBtn = document.createElement("button");
|
||||
pinBtn.className = "del-history-btn";
|
||||
pinBtn.innerText = isPinned ? "📍" : "📌";
|
||||
pinBtn.title = isPinned ? "取消置顶" : "置顶";
|
||||
labelControl(pinBtn, t(isPinned ? 'unpinConversation' : 'pinConversation'));
|
||||
pinBtn.style.marginRight = "4px";
|
||||
pinBtn.onclick = (e)=>{
|
||||
e.stopPropagation();
|
||||
@@ -738,9 +876,9 @@ function renderSideHistory(){
|
||||
delBtn.className = "del-history-btn";
|
||||
delBtn.innerText = "×";
|
||||
delBtn.setAttribute("aria-label", LANG === "en" ? "Delete conversation" : "删除对话");
|
||||
delBtn.onclick = (e)=>{
|
||||
delBtn.onclick = async (e)=>{
|
||||
e.stopPropagation();
|
||||
if(confirm(t("confirmDel"))){
|
||||
if(await SupportUI.confirm(t("confirmDel"), {title:t('confirmTitle'), confirmText:t('confirmButton'), cancelText:t('customerCancel')})){
|
||||
const version = conversationVersion;
|
||||
return requestJson(`/api/del_history?id=${item.id}`, {method:"DELETE"})
|
||||
.then(data=>{
|
||||
@@ -816,6 +954,8 @@ function ensureConv(){
|
||||
throw error;
|
||||
}
|
||||
currentConvId = data.id;
|
||||
drafts.remove('new');
|
||||
flushDraft();
|
||||
if(data.welcome) addMsg(t('autoWelcome'), false);
|
||||
return currentConvId;
|
||||
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
|
||||
@@ -824,6 +964,10 @@ function ensureConv(){
|
||||
}
|
||||
|
||||
function resetConversation(id){
|
||||
imageViewer.close();
|
||||
attachmentGestures.clear();
|
||||
flushDraft();
|
||||
emojiPicker.close();
|
||||
if(voiceInput) voiceInput.cancel();
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
if(sseInstance){ sseInstance.close(); sseInstance = null; }
|
||||
@@ -831,6 +975,7 @@ function resetConversation(id){
|
||||
clearAttachment();
|
||||
stopHumanMode();
|
||||
currentConvId = id;
|
||||
restoreDraft();
|
||||
openingConv = null;
|
||||
creatingConv = null;
|
||||
sendPreparing = false;
|
||||
@@ -846,7 +991,9 @@ function openConversation(id){
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv === id) return; // 防连点
|
||||
const version = resetConversation(id);
|
||||
if(isNarrow()){ sideOpen = false; renderSidebar(); }
|
||||
openingConv = id;
|
||||
chatDom.innerHTML = '';
|
||||
setComposerStatus(t('historyLoading'));
|
||||
renderSideHistory();
|
||||
requestJson("/api/get_messages?conv_id=" + id)
|
||||
@@ -860,7 +1007,9 @@ function openConversation(id){
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
return;
|
||||
}
|
||||
let previousQuestion = '';
|
||||
msgs.forEach(m=>{
|
||||
if(m.role === 'user') previousQuestion = m.content;
|
||||
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
|
||||
try{
|
||||
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
|
||||
@@ -871,14 +1020,17 @@ function openConversation(id){
|
||||
}else{
|
||||
const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
|
||||
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
|
||||
if(m.role === 'assistant' && b && (/^请先使用客户账号登录并关联订单/.test(m.content) || /^订单查询将在客户账号登录并关联后开放/.test(m.content))){
|
||||
attachCustomerLogin(b, previousQuestion);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 等渲染完再滚到底部
|
||||
requestAnimationFrame(function(){ scrollChatToEnd(true); });
|
||||
}).catch(function(){
|
||||
if(version !== conversationVersion) return;
|
||||
openingConv = null;
|
||||
setComposerStatus(t('networkFail'), true);
|
||||
openingConv = 'failed';
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -893,8 +1045,9 @@ function fillTrackCard(el, card){
|
||||
head.className = "track-head";
|
||||
const no = document.createElement("div");
|
||||
no.className = "track-no";
|
||||
const numberLabel = isOrder ? 'orderNo' : 'trackingNo';
|
||||
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(card.no);
|
||||
const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
|
||||
const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
|
||||
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(inbound ? inbound.value : card.no);
|
||||
const st = document.createElement("div");
|
||||
st.className = "track-status";
|
||||
st.textContent = card.status;
|
||||
@@ -902,14 +1055,6 @@ function fillTrackCard(el, card){
|
||||
head.appendChild(st);
|
||||
el.appendChild(head);
|
||||
|
||||
if(!isOrder){
|
||||
const legacyNotice = document.createElement('p');
|
||||
legacyNotice.className = 'order-data-notice';
|
||||
legacyNotice.dataset.i18n = 'legacyTrackingNotice';
|
||||
legacyNotice.textContent = t('legacyTrackingNotice');
|
||||
el.appendChild(legacyNotice);
|
||||
}
|
||||
|
||||
if(isOrder){
|
||||
const fields = document.createElement('dl');
|
||||
fields.className = 'order-data-fields is-collapsed';
|
||||
@@ -1038,7 +1183,30 @@ function addTrackCard(card, timeStr){
|
||||
return bubble;
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
const restoreId = drafts.active();
|
||||
currentConvId = restoreId === 'new' ? null : Number(restoreId);
|
||||
restoreDraft();
|
||||
if(currentConvId){
|
||||
const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value;
|
||||
openingConv = 'restoring';
|
||||
loadHistory().then(function(history){
|
||||
if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return;
|
||||
if(Array.isArray(history) && !history.some(item=>item.id === id)){
|
||||
if(inputDom.value !== initialDraft){
|
||||
openingConv = 'failed';
|
||||
flushDraft();
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
return;
|
||||
}
|
||||
drafts.remove(id);
|
||||
currentConvId = null;
|
||||
restoreDraft();
|
||||
openingConv = null;
|
||||
return;
|
||||
}
|
||||
openConversation(id);
|
||||
});
|
||||
}else loadHistory();
|
||||
|
||||
window.fillInput = function(words){
|
||||
inputDom.value = words;
|
||||
@@ -1059,8 +1227,8 @@ window.askNewTopic = function(words){
|
||||
return doSend();
|
||||
}
|
||||
|
||||
window.sendOrder = function(){
|
||||
const no = prompt("请输入运单号:");
|
||||
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();
|
||||
@@ -1104,11 +1272,10 @@ async function humanRequest(path, data){
|
||||
window.showHuman = async function(){
|
||||
if(voiceInput) voiceInput.cancel();
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
|
||||
if(humanRequestToken || humanClosing) return;
|
||||
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
|
||||
if(window.humanSid) stopHumanMode();
|
||||
const hour = new Date().getHours();
|
||||
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
|
||||
const token = {version:conversationVersion};
|
||||
humanRequestToken = token;
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
@@ -1170,7 +1337,7 @@ async function sendHumanMessage(content){
|
||||
addMsg(content, true);
|
||||
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
|
||||
if(window.__pendingImg === attachment) clearAttachment();
|
||||
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
|
||||
setComposerStatus('');
|
||||
focusComposer();
|
||||
return true;
|
||||
}catch(error){
|
||||
@@ -1185,7 +1352,7 @@ async function sendHumanMessage(content){
|
||||
async function leaveOfflineMessage(){
|
||||
const version = conversationVersion;
|
||||
addMsg(t('humanHours'), false);
|
||||
const message = window.prompt(t('offlinePrompt'));
|
||||
const message = (await SupportUI.prompt(t('offlinePrompt'), {title:t('promptTitle'), confirmText:t('confirmButton'), cancelText:t('customerCancel'), inputLabel:t('offlineInputLabel'), multiline:true}) || '').trim();
|
||||
if(!message) return;
|
||||
try{
|
||||
const data = await humanRequest('/api/ticket', {content:message, phone:''});
|
||||
@@ -1193,8 +1360,8 @@ async function leaveOfflineMessage(){
|
||||
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
|
||||
}catch(error){
|
||||
if(version !== conversationVersion) return;
|
||||
if(!inputDom.value) inputDom.value = message;
|
||||
setComposerStatus(t('networkFail'), true);
|
||||
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
|
||||
setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,6 +1460,8 @@ 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 = "附件图片";
|
||||
@@ -1370,6 +1539,19 @@ function mdToPlain(md){
|
||||
.replace(/“|”/g, '"');
|
||||
}
|
||||
|
||||
function attachCustomerLogin(bubble, question){
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'customer-login-cta';
|
||||
button.textContent = t('customerQuery');
|
||||
const version = conversationVersion;
|
||||
button.onclick = function(){
|
||||
customerLogin.require(async function(){
|
||||
if(version === conversationVersion && question) await retryAnswer(bubble, question);
|
||||
});
|
||||
};
|
||||
bubble.appendChild(button);
|
||||
}
|
||||
|
||||
async function retryAnswer(bubble, question){
|
||||
if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; }
|
||||
if(await doSend(question)){
|
||||
@@ -1391,26 +1573,34 @@ function attachActions(bubble, rawText, question, showRetry){
|
||||
copyBtn.dataset.action = "copy";
|
||||
copyBtn.textContent = t("copy");
|
||||
copyBtn.onclick = function(){
|
||||
if(copyBtn.disabled) return;
|
||||
copyBtn.disabled = true;
|
||||
const plain = mdToPlain(rawText);
|
||||
const done = ()=> {
|
||||
copyBtn.textContent = t("copied");
|
||||
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
|
||||
const done = function(copied){
|
||||
copyBtn.disabled = false;
|
||||
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
|
||||
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
|
||||
};
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(plain).then(done).catch(fallback);
|
||||
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
|
||||
}else{
|
||||
fallback();
|
||||
return fallback();
|
||||
}
|
||||
function fallback(){
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = plain;
|
||||
ta.readOnly = true;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
const previousFocus = document.activeElement;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try{ document.execCommand("copy"); }catch(e){}
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
let copied = false;
|
||||
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
|
||||
finally{
|
||||
ta.remove();
|
||||
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
|
||||
}
|
||||
done(copied);
|
||||
}
|
||||
};
|
||||
bar.appendChild(copyBtn);
|
||||
@@ -1425,23 +1615,52 @@ function attachActions(bubble, rawText, question, showRetry){
|
||||
}
|
||||
|
||||
// 👍/👎 反馈
|
||||
let votePending = false;
|
||||
const voteButtons = [];
|
||||
const voteStatus = document.createElement('span');
|
||||
voteStatus.className = 'vote-status';
|
||||
voteStatus.setAttribute('role', 'status');
|
||||
function addVote(vote, label){
|
||||
const b = document.createElement("button");
|
||||
b.className = "msg-action-btn";
|
||||
b.textContent = label;
|
||||
b.onclick = function(){
|
||||
if(b.classList.contains("voted")) return;
|
||||
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
|
||||
b.classList.add("voted");
|
||||
fetch("/api/feedback", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({question: question, answer: rawText, vote: vote})
|
||||
}).catch(function(){});
|
||||
b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown';
|
||||
b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown'));
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
b.onclick = async function(){
|
||||
if(votePending || b.classList.contains('voted')) return;
|
||||
votePending = true;
|
||||
voteButtons.forEach(button => { button.disabled = true; });
|
||||
voteStatus.textContent = '';
|
||||
voteStatus.removeAttribute('data-i18n');
|
||||
voteStatus.classList.remove('is-error');
|
||||
try{
|
||||
const data = await requestJson('/api/feedback', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({question:question, answer:rawText, vote:vote})
|
||||
});
|
||||
if(!data.ok) throw new Error('Feedback failed');
|
||||
voteButtons.forEach(function(button){
|
||||
button.classList.toggle('voted', button === b);
|
||||
button.setAttribute('aria-pressed', String(button === b));
|
||||
});
|
||||
voteStatus.textContent = t('ratingThanks');
|
||||
voteStatus.setAttribute('data-i18n', 'ratingThanks');
|
||||
}catch(error){
|
||||
voteStatus.textContent = t('ratingFailed');
|
||||
voteStatus.setAttribute('data-i18n', 'ratingFailed');
|
||||
voteStatus.classList.add('is-error');
|
||||
}finally{
|
||||
votePending = false;
|
||||
voteButtons.forEach(button => { button.disabled = false; });
|
||||
}
|
||||
};
|
||||
voteButtons.push(b);
|
||||
bar.appendChild(b);
|
||||
}
|
||||
addVote("up", "👍");
|
||||
addVote("down", "👎");
|
||||
bar.appendChild(voteStatus);
|
||||
|
||||
bubble.appendChild(bar);
|
||||
}
|
||||
@@ -1484,7 +1703,7 @@ async function doSend(retryText){
|
||||
var fullContent = sendWord;
|
||||
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
|
||||
setComposerStatus("");
|
||||
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
|
||||
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
|
||||
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
|
||||
// 人工客服模式:直接发给客服,不走AI
|
||||
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
|
||||
@@ -1553,7 +1772,8 @@ async function doSend(retryText){
|
||||
raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
|
||||
}catch(e){ /* 解析失败就按普通文本显示 */ }
|
||||
}
|
||||
attachActions(aiBubble, raw, fullContent, true);
|
||||
attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired);
|
||||
if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent);
|
||||
renderFollowups(aiBubble);
|
||||
updateToBottom();
|
||||
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
|
||||
@@ -1603,6 +1823,7 @@ async function doSend(retryText){
|
||||
}
|
||||
if(!obj || typeof obj !== 'object') return;
|
||||
if(obj.e){ fail(obj.e); return; }
|
||||
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
|
||||
if(obj.card){
|
||||
clearTimeout(firstTimer);
|
||||
aiBubble.classList.add("track-card");
|
||||
@@ -1718,6 +1939,7 @@ sendBtn.addEventListener("click", function(){
|
||||
|
||||
// ===== 多行输入框:自动增高 =====
|
||||
function autoGrow(){
|
||||
queueDraft();
|
||||
if(!inputDom.value){
|
||||
inputDom.style.height = '';
|
||||
return;
|
||||
@@ -1729,6 +1951,8 @@ function autoGrow(){
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
|
||||
}
|
||||
inputDom.addEventListener("input", autoGrow);
|
||||
window.addEventListener('pagehide', flushDraft);
|
||||
document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); });
|
||||
|
||||
inputDom.addEventListener("keydown",(e)=>{
|
||||
// Enter 发送,Shift+Enter 换行
|
||||
@@ -1770,13 +1994,44 @@ 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));
|
||||
toggleBtn.title = t(sideOpen ? 'sideCollapse' : 'sideExpand');
|
||||
labelControl(toggleBtn, t(sideOpen ? 'sideCollapse' : 'sideExpand'));
|
||||
toggleBtn.textContent = "";
|
||||
inputDom.placeholder = t(narrow ? 'inputShort' : 'inputPh');
|
||||
}
|
||||
@@ -1797,7 +2052,9 @@ function syncChatViewport(){
|
||||
chatContainer.style.height = '';
|
||||
chatContainer.style.top = '';
|
||||
}
|
||||
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
|
||||
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
|
||||
if(document.activeElement === inputDom) setSettingsOpen(false);
|
||||
autoGrow();
|
||||
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
|
||||
updateToBottom();
|
||||
@@ -1810,6 +2067,7 @@ function scheduleChatViewport(){
|
||||
}
|
||||
|
||||
toggleBtn.onclick = function(){
|
||||
setSettingsOpen(false);
|
||||
sideOpen = !sideOpen;
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -1829,6 +2087,9 @@ 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;
|
||||
}
|
||||
@@ -1873,6 +2134,26 @@ 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(); },
|
||||
onChange:function(user,previous){
|
||||
if(previous === undefined) return;
|
||||
if(previous && (!user || user.id !== previous.id)){
|
||||
resetConversation(null); chatDom.innerHTML=welcomeHTML();
|
||||
}
|
||||
loadHistory();
|
||||
},
|
||||
onError:function(message){ setComposerStatus(message,true); }
|
||||
});
|
||||
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
|
||||
customerLogin.refresh();
|
||||
window.addEventListener('resize', function(){
|
||||
if(!isNarrow()) voiceInput.setMode(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Customer login has no relationship to the protected staff workspace.
|
||||
var CustomerLogin = {
|
||||
create(options){
|
||||
const overlay=document.getElementById('customer-login');
|
||||
const form=document.getElementById('customer-login-form');
|
||||
const username=document.getElementById('customer-username');
|
||||
const password=document.getElementById('customer-password');
|
||||
const submit=document.getElementById('customer-submit');
|
||||
const cancel=document.getElementById('customer-cancel');
|
||||
const error=document.getElementById('customer-error');
|
||||
const 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;
|
||||
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);
|
||||
});
|
||||
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
|
||||
cancel.textContent=options.text('customerCancel');
|
||||
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
|
||||
document.getElementById('customer-username-label').textContent=options.text('customerUsername');
|
||||
document.getElementById('customer-password-label').textContent=options.text('customerPassword');
|
||||
username.placeholder=options.text('customerUsernamePlaceholder');
|
||||
password.placeholder=options.text('customerPasswordPlaceholder');
|
||||
}
|
||||
function update(next, refreshOrders){
|
||||
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();
|
||||
const timer=setTimeout(()=>controller.abort(),10000);
|
||||
try{
|
||||
const response=await options.fetch(path,{method:data === undefined ? 'GET' : 'POST',
|
||||
headers:{'Content-Type':'application/json'},body:data === undefined ? undefined : JSON.stringify(data),signal:controller.signal});
|
||||
const result=await response.json();
|
||||
if(!response.ok || !result.ok){
|
||||
const failure=new Error(result.error || result.e || options.text('customerLoginFailed'));
|
||||
failure.publicMessage=true;throw failure;
|
||||
}
|
||||
return result;
|
||||
}finally{ clearTimeout(timer); }
|
||||
}
|
||||
async function refresh(refreshOrders){
|
||||
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);
|
||||
}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();
|
||||
}
|
||||
function open(onSuccess){
|
||||
if(busy) return;
|
||||
pending=typeof onSuccess === 'function' ? onSuccess : null;
|
||||
opener=document.activeElement;
|
||||
status(''); password.value=''; overlay.hidden=false;
|
||||
if(options.onOpen) options.onOpen();
|
||||
render(); username.focus({preventScroll:true});
|
||||
}
|
||||
function setBusy(value){
|
||||
busy=value; submit.disabled=value; cancel.disabled=value;
|
||||
accounts.forEach(account=>{account.disabled=value;});
|
||||
username.disabled=value; password.disabled=value; render();
|
||||
if(!busy && overlay.hidden) restoreFocus();
|
||||
}
|
||||
form.addEventListener('submit',async function(event){
|
||||
event.preventDefault();
|
||||
if(busy) return;
|
||||
const token=++version;
|
||||
setBusy(true);status('');
|
||||
try{
|
||||
const result=await request('/api/customer/login',{username:username.value,password:password.value});
|
||||
if(token !== version) return;
|
||||
update(result.user);
|
||||
const resume=pending;close();
|
||||
if(resume) await resume();
|
||||
}catch(failure){
|
||||
if(token === version) status(failure.publicMessage ? failure.message : options.text('customerLoginFailed'));
|
||||
}finally{ if(token === version) setBusy(false); }
|
||||
});
|
||||
cancel.onclick=function(){ if(!busy) close(); };
|
||||
overlay.addEventListener('click',function(event){ if(event.target === overlay && !busy) close(); });
|
||||
overlay.addEventListener('keydown',function(event){
|
||||
if(event.key === 'Escape' && !busy){ event.preventDefault();close(); }
|
||||
if(event.key === 'Tab'){
|
||||
const controls=[username,password,submit,cancel].filter(control=>!control.disabled);
|
||||
if(!controls.length){ event.preventDefault();return; }
|
||||
const index=controls.indexOf(document.activeElement);
|
||||
const next=(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length;
|
||||
event.preventDefault();controls[next].focus({preventScroll:true});
|
||||
}
|
||||
});
|
||||
async function activateAccount(){
|
||||
if(busy) return;
|
||||
if(!user){ open(); return; }
|
||||
const token=++version;setBusy(true);
|
||||
try{
|
||||
await request('/api/customer/logout',{});
|
||||
if(token === version) update(null);
|
||||
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
|
||||
finally{ if(token === version) setBusy(false); }
|
||||
}
|
||||
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);
|
||||
render();
|
||||
return {open:open,refresh:refresh,render:render,require:async function(resume){
|
||||
if(busy) return;
|
||||
await refresh();
|
||||
if(user){ await resume(); }else open(resume);
|
||||
},viewport:function(height,top){
|
||||
overlay.style.height=height || ''; overlay.style.top=top || '';
|
||||
overlay.style.bottom=height ? 'auto' : '';
|
||||
}};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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();}};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Text drafts stay in this visitor's browser, independently for each conversation.
|
||||
var ChatDrafts = {
|
||||
create(options){
|
||||
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
|
||||
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
|
||||
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
|
||||
const memory = new Map();
|
||||
const dirty = new Set();
|
||||
let current = 'new';
|
||||
function id(value){
|
||||
if(value === null || value === 'new') return 'new';
|
||||
const key = String(value);
|
||||
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
|
||||
}
|
||||
function storage(name){
|
||||
try{ return options[name](); }catch(error){ return null; }
|
||||
}
|
||||
function read(value){
|
||||
const key = id(value);
|
||||
if(!key) return '';
|
||||
if(dirty.has(key)) return memory.get(key) || '';
|
||||
if(prefix){
|
||||
try{
|
||||
const saved = storage('storage').getItem(prefix + key);
|
||||
if(saved !== null){
|
||||
const data = JSON.parse(saved);
|
||||
if(data && data.version === 1 && typeof data.text === 'string'){
|
||||
memory.set(key, data.text);
|
||||
return data.text;
|
||||
}
|
||||
}
|
||||
memory.delete(key);
|
||||
return '';
|
||||
}catch(error){}
|
||||
}
|
||||
return memory.get(key) || '';
|
||||
}
|
||||
function write(value, text){
|
||||
const key = id(value);
|
||||
if(!key || typeof text !== 'string') return false;
|
||||
if(text) memory.set(key, text); else memory.delete(key);
|
||||
dirty.add(key);
|
||||
if(!prefix) return false;
|
||||
try{
|
||||
const target = storage('storage');
|
||||
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
|
||||
else target.removeItem(prefix + key);
|
||||
dirty.delete(key);
|
||||
return true;
|
||||
}catch(error){ return false; }
|
||||
}
|
||||
function active(value){
|
||||
if(arguments.length){
|
||||
current = id(value) || 'new';
|
||||
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
|
||||
}else if(activeKey){
|
||||
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
function clear(){
|
||||
memory.clear();
|
||||
dirty.clear();
|
||||
if(prefix){
|
||||
try{
|
||||
const target = storage('storage');
|
||||
for(let index = target.length - 1; index >= 0; index--){
|
||||
const key = target.key(index);
|
||||
if(key && key.startsWith(prefix)) target.removeItem(key);
|
||||
}
|
||||
}catch(error){}
|
||||
}
|
||||
active('new');
|
||||
}
|
||||
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
|
||||
}
|
||||
};
|
||||
+137
-8
@@ -1,5 +1,126 @@
|
||||
// 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});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
initAttachments(container, label){
|
||||
container.addEventListener('error', function(event){
|
||||
const image = event.target;
|
||||
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
|
||||
image.dataset.unavailable = '1';
|
||||
image.hidden = true;
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'attachment-unavailable';
|
||||
hint.textContent = typeof label === 'function' ? label() : label;
|
||||
image.after(hint);
|
||||
}, true);
|
||||
},
|
||||
async enableNotifications(button){
|
||||
if(!('Notification' in window)) return;
|
||||
try{
|
||||
@@ -58,6 +179,10 @@ var SupportUI = {
|
||||
},
|
||||
initEmoji(button, panel, input, onInsert){
|
||||
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
|
||||
function close(){
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
button.onclick = function(){
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute('aria-expanded', String(!panel.hidden));
|
||||
@@ -70,20 +195,24 @@ var SupportUI = {
|
||||
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
|
||||
const end = input.selectionEnd == null ? start : input.selectionEnd;
|
||||
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
input.focus();
|
||||
close();
|
||||
input.focus({preventScroll:true});
|
||||
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||
if(onInsert) onInsert();
|
||||
};
|
||||
panel.appendChild(choice);
|
||||
});
|
||||
panel.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape'){
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.focus();
|
||||
['pointerdown','mousedown'].forEach(function(name){
|
||||
document.addEventListener(name, function(event){
|
||||
if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape' && !panel.hidden){
|
||||
close();
|
||||
button.focus({preventScroll:true});
|
||||
}
|
||||
});
|
||||
return {close:close};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,5 +45,5 @@ var VisitorSession = (function(){
|
||||
}
|
||||
return established ? send() : ensure().then(send);
|
||||
}
|
||||
return {fetch:request};
|
||||
return {fetch:request,localKey:localKey};
|
||||
})();
|
||||
|
||||
+35
-2
@@ -2,6 +2,8 @@
|
||||
<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') }}">
|
||||
@@ -16,7 +18,10 @@
|
||||
</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>
|
||||
@@ -31,9 +36,12 @@
|
||||
<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">
|
||||
@@ -43,7 +51,7 @@
|
||||
</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></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>
|
||||
</div>
|
||||
<div class="session-scroll">
|
||||
<div id="sessionList" role="tabpanel" aria-labelledby="tab-active"><div class="list-empty">暂无进行中的会话</div></div>
|
||||
@@ -89,13 +97,38 @@
|
||||
<button type="button" class="quick" onclick="quickReply('已为您催促,请稍候。')">已为您催促,请稍候</button>
|
||||
<button type="button" class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询,祝您生活愉快</button>
|
||||
</section>
|
||||
<section class="card"><h3>离线留言</h3><div id="ticketList"><div class="list-empty">正在加载留言…</div></div></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>
|
||||
</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.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>附件暂时无法访问 · 柏悦物流</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<style>
|
||||
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
|
||||
main { width: min(420px, 100%); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
|
||||
h1 { font-size: 22px; margin-bottom: 14px; }
|
||||
p { color: var(--secondary); margin: 10px 0; }
|
||||
.english { font-size: 13px; }
|
||||
a { display: inline-block; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>附件暂时无法访问</h1>
|
||||
<p>文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。</p>
|
||||
<p lang="en" class="english">This attachment is unavailable. Please return to your conversation to upload it again or contact support.</p>
|
||||
<a href="{{ url_for('admin.admin_dash') if staff else url_for('pages.index') }}">{{ '返回客服工作台' if staff else '返回聊天' }}</a>
|
||||
</main></body>
|
||||
</html>
|
||||
+46
-9
@@ -21,38 +21,59 @@
|
||||
</div>
|
||||
<div class="history-wrap" id="historyWrap"></div>
|
||||
<div class="sidebar-footer">
|
||||
<button type="button" id="customer-account" class="customer-account">客户登录</button>
|
||||
<button type="button" class="clear-all-btn">清空全部历史</button>
|
||||
<div class="sidebar-note"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceScope">中欧与英国专线咨询</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
|
||||
<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="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
|
||||
<button type="button" id="customer-account-mobile" class="customer-account mobile-account">客户登录</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>
|
||||
<button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button>
|
||||
<button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</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>
|
||||
</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>
|
||||
<h3>物流问题,随时为您解答</h3>
|
||||
<p>咨询运费、时效或联系人工客服。<br>订单查询需登录并关联客户账号。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="composer">
|
||||
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
||||
<div class="composer-toolbar"><div class="hot-bar">
|
||||
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
|
||||
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
||||
@@ -61,20 +82,36 @@
|
||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或入仓码" placeholder="输入入仓码或问题…"></textarea>
|
||||
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
<p id="composer-status" class="composer-status" role="status" hidden></p>
|
||||
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></div>
|
||||
</footer>
|
||||
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
|
||||
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
|
||||
</main>
|
||||
</div>
|
||||
<div id="customer-login" class="customer-login-overlay" hidden>
|
||||
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="customer-login-title">
|
||||
<h2 id="customer-login-title">登录</h2>
|
||||
<form id="customer-login-form">
|
||||
<label for="customer-username" id="customer-username-label">账号</label>
|
||||
<input id="customer-username" name="username" type="text" autocomplete="username" placeholder="请输入账号" maxlength="80" required>
|
||||
<label for="customer-password" id="customer-password-label">密码</label>
|
||||
<input id="customer-password" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" maxlength="256" required>
|
||||
<p id="customer-error" role="alert" hidden></p>
|
||||
<div class="customer-login-actions"><button type="button" id="customer-cancel">取消</button><button type="submit" id="customer-submit">登录</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/customer_orders.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>
|
||||
|
||||
+384
-14
@@ -6,13 +6,14 @@ 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){
|
||||
async function workspace(compact = false, initialHooks = {}){
|
||||
const elements = new Map();
|
||||
const documentListeners = {};
|
||||
let document;
|
||||
@@ -32,6 +33,7 @@ async function workspace(compact = false){
|
||||
setAttribute(name, value){ this.attributes[name] = value; }
|
||||
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
|
||||
appendChild(element){ this.children.push(element); }
|
||||
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
|
||||
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||
querySelectorAll(){ return []; }
|
||||
querySelector(selector){
|
||||
@@ -42,51 +44,410 @@ async function workspace(compact = false){
|
||||
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()];
|
||||
document = {
|
||||
body: new Element(), activeElement: null,
|
||||
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
|
||||
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
|
||||
createElement(){ return new Element(); },
|
||||
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
|
||||
querySelector(selector){
|
||||
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){ documentListeners[name] = callback; },
|
||||
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
const calls = [];
|
||||
const hooks = {};
|
||||
const calls = [], dialogs = [];
|
||||
const hooks = initialHooks;
|
||||
const timers = new Map(), intervals = new Map();
|
||||
let timerId = 0;
|
||||
const context = vm.createContext({
|
||||
document, console,
|
||||
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
|
||||
setInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||
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); },
|
||||
fetch: async (url, options) => {
|
||||
const body = options && options.body ? JSON.parse(options.body) : undefined;
|
||||
calls.push({url, body});
|
||||
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
|
||||
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);
|
||||
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();
|
||||
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
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.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 ? {ok:true} : []);
|
||||
return response(options && options.method ? {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, document, documentListeners};
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,/<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,/<script>/);
|
||||
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);
|
||||
@@ -253,6 +614,15 @@ test('failed transfers retain the conversation and draft', async () => {
|
||||
assert.match(app.element('reply-status').textContent, /转接未成功/);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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);
|
||||
});
|
||||
+634
-18
@@ -5,15 +5,67 @@ 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 elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
|
||||
const storageValues = pageOptions.storage || {};
|
||||
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
|
||||
const tabValues = pageOptions.tabStorage || {};
|
||||
function testStorage(values){return {
|
||||
getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);},
|
||||
removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null,
|
||||
get length(){return Object.keys(values).length;}
|
||||
};}
|
||||
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {}, dialogs = [];
|
||||
let intervalId = 0, timerId = 0, nextConvId = 1;
|
||||
let voiceOptions;
|
||||
class Element {
|
||||
constructor(){
|
||||
constructor(tagName = 'div'){
|
||||
this.tagName = tagName.toUpperCase();
|
||||
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.listeners = {}; this.focusCalls = []; this.isConnected = true;
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -32,8 +84,11 @@ 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(); } }
|
||||
select(){ document.activeElement=this; }
|
||||
querySelector(selector){
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
@@ -50,24 +105,26 @@ async function customerPage(pageOptions = {}){
|
||||
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
|
||||
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
|
||||
}
|
||||
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
}
|
||||
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
|
||||
element('emoji-panel').hidden=true;
|
||||
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
|
||||
const document = {
|
||||
body: new Element(), documentElement: {},
|
||||
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
|
||||
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
|
||||
createElement: () => new Element(),
|
||||
addEventListener: (name, callback) => { listeners[name] = callback; },
|
||||
createElement: tagName => new Element(tagName),
|
||||
execCommand: () => pageOptions.copyResult !== false,
|
||||
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
|
||||
let state = {status:'active', messages:[]};
|
||||
const context = vm.createContext({
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
|
||||
FormData:class { append(){} },
|
||||
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; } },
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
@@ -81,6 +138,7 @@ async function customerPage(pageOptions = {}){
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
|
||||
if(hooks[url]) return hooks[url](options);
|
||||
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
|
||||
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
|
||||
if(url === '/stream_chat'){
|
||||
@@ -93,25 +151,31 @@ async function customerPage(pageOptions = {}){
|
||||
}
|
||||
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
|
||||
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
|
||||
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
|
||||
: url === '/api/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/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
|
||||
: url.startsWith('/api/visitor/human/poll') ? state
|
||||
: ['/api/rate', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: url === '/api/get_history' ? (pageOptions.history || []) : [];
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){
|
||||
for(const file of ['support.js', 'visitor.js', 'customer.js', 'customer_orders.js', 'drafts.js', 'voice.js', 'attachments.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, quickButtons, voiceOptions,
|
||||
return {context, element, calls, hooks, dialogs, 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;
|
||||
@@ -129,6 +193,7 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
},
|
||||
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
|
||||
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
|
||||
setState:value => { state=value; },
|
||||
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
@@ -158,6 +223,79 @@ 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;
|
||||
@@ -287,7 +425,7 @@ 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'})));
|
||||
});
|
||||
app.element('.clear-all-btn').click();
|
||||
await app.click(app.element('.clear-all-btn'));
|
||||
await app.click(app.quickButtons[1]);
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
|
||||
@@ -297,6 +435,24 @@ test('clearing history blocks new conversations until its timed request settles'
|
||||
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]);
|
||||
@@ -319,7 +475,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'));
|
||||
app.element('.clear-all-btn').click();
|
||||
await app.click(app.element('.clear-all-btn'));
|
||||
await flush();
|
||||
assert.doesNotMatch(app.element('historyWrap').textContent,/待清空会话/);
|
||||
assert.ok(app.element('historyWrap').querySelector('.history-empty'));
|
||||
@@ -370,7 +526,7 @@ test('all three footer questions leave the human queue and send to separate AI c
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.equal(requests.length, 3);
|
||||
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['按入仓码查轨迹', '欧洲海运专线报价', '包裹异常处理']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
assert.equal(app.element('human-session').hidden, true);
|
||||
@@ -483,9 +639,11 @@ test('repeated contact clicks create one human request and show a visible queue
|
||||
});
|
||||
|
||||
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
|
||||
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
|
||||
const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
|
||||
assert.equal(app.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);
|
||||
@@ -500,6 +658,13 @@ test('a failed human send preserves the text and makes retry possible', async ()
|
||||
assert.equal(app.element('send-button').disabled, false);
|
||||
});
|
||||
|
||||
test('a visitor whose phone says late at night can still reach an open support service', async () => {
|
||||
const app=await customerPage({human:false,hour:23});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.context.window.humanSid,7);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
});
|
||||
|
||||
test('a duplicate human send cannot submit an in-flight message twice', async () => {
|
||||
const app = await customerPage();
|
||||
let resolve;
|
||||
@@ -589,6 +754,22 @@ test('shipment cards separate forecasts from actual progress and retain both in
|
||||
assert.ok(log.querySelector('.msg-actions'));
|
||||
});
|
||||
|
||||
test('inbound codes are the primary card identity for live replies and restored history', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:8,title:'入仓轨迹',ctime:'now'}]});
|
||||
const card={kind:'order',no:'FL202610290001',status:'履约中',
|
||||
fields:[{key:'inbound',value:'INB20261029001-01'},{key:'number',value:'FL202610290001'}],
|
||||
nodes:[],summary:'入仓码 INB20261029001-01'};
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
|
||||
await app.context.window.fillInput('INB20261029001-01');await flush();
|
||||
let heading=app.element('chatDom').querySelector('.track-no');
|
||||
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
|
||||
assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/);
|
||||
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
heading=app.element('chatDom').querySelector('.track-no');
|
||||
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
|
||||
});
|
||||
|
||||
test('order details can expand without hiding the shipment timeline', async () => {
|
||||
const app = await customerPage({human:false,width:390});
|
||||
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
|
||||
@@ -782,3 +963,438 @@ test('an AI regeneration is not forwarded to a human session', async () => {
|
||||
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
|
||||
assert.match(app.element('composer-status').textContent,/先返回 AI/);
|
||||
});
|
||||
|
||||
test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('反馈测试');await flush();
|
||||
const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn');
|
||||
const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎');
|
||||
let finish;
|
||||
app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;});
|
||||
const sending=up.onclick();
|
||||
await flush();
|
||||
await down.onclick();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1);
|
||||
assert.equal(up.classList.contains('voted'),false);
|
||||
assert.ok(up.disabled && down.disabled);
|
||||
finish(response({ok:false},503));await sending;
|
||||
assert.equal(up.classList.contains('voted'),false);
|
||||
assert.match(log.textContent,/评价未提交/);
|
||||
assert.equal(up.disabled,false);
|
||||
app.hooks['/api/feedback']=()=>response({ok:true});
|
||||
await up.onclick();
|
||||
assert.ok(up.classList.contains('voted'));
|
||||
assert.equal(up.getAttribute('aria-pressed'),'true');
|
||||
assert.match(log.textContent,/感谢您的评价/);
|
||||
await up.onclick();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2);
|
||||
});
|
||||
|
||||
test('clipboard rejection and a failed fallback never report a successful copy', async () => {
|
||||
const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
|
||||
await app.context.window.fillInput('复制测试');await flush();
|
||||
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
|
||||
app.element('input-text').focus();
|
||||
await copy.onclick();await flush();
|
||||
assert.equal(copy.textContent,'复制失败');
|
||||
assert.equal(copy.disabled,false);
|
||||
assert.equal(app.context.document.activeElement,app.element('input-text'));
|
||||
assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true);
|
||||
assert.equal(app.context.document.body.children.length,0);
|
||||
await app.runTimers(3000);
|
||||
assert.equal(copy.textContent,'复制');
|
||||
});
|
||||
|
||||
test('successful clipboard fallback reports copied and keeps text focus stable', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('复制测试');await flush();
|
||||
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
|
||||
await copy.onclick();
|
||||
assert.equal(copy.textContent,'已复制');
|
||||
assert.equal(copy.disabled,false);
|
||||
await app.runTimers(1200);
|
||||
assert.equal(copy.textContent,'复制');
|
||||
});
|
||||
|
||||
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
|
||||
const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '});
|
||||
await empty.context.window.showHuman();await flush();
|
||||
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'});
|
||||
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
|
||||
await app.context.window.showHuman();await flush();
|
||||
assert.equal(app.element('input-text').value,'需要保留的留言');
|
||||
assert.notEqual(app.element('input-text').style.height,'');
|
||||
assert.match(app.element('composer-status').textContent,/1–2000/);
|
||||
});
|
||||
|
||||
test('mobile history selection closes the drawer while desktop navigation stays visible', async () => {
|
||||
for(const width of [390,1200]){
|
||||
const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]});
|
||||
if(width===390) await app.click(app.element('.toggle-divider'));
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true');
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900));
|
||||
}
|
||||
});
|
||||
|
||||
test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel');
|
||||
await app.click(toggle);
|
||||
assert.equal(panel.hidden,false);
|
||||
app.documentEvent('pointerdown',{target:panel.children[0]});
|
||||
assert.equal(panel.hidden,false);
|
||||
app.documentEvent('pointerdown',{target:app.element('input-text')});
|
||||
assert.equal(panel.hidden,true);
|
||||
assert.equal(toggle.getAttribute('aria-expanded'),'false');
|
||||
await app.click(toggle);
|
||||
app.documentEvent('keydown',{key:'Escape'});
|
||||
assert.equal(panel.hidden,true);
|
||||
await app.click(toggle);
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
assert.equal(panel.hidden,true);
|
||||
});
|
||||
|
||||
test('drafts stay with their own conversation and restore across a page reload', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
assert.equal(app.element('input-text').value,'');
|
||||
app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
assert.equal(app.element('input-text').value,'A的未发送草稿');
|
||||
await app.pageHide();
|
||||
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
|
||||
await flush();
|
||||
assert.equal(restored.element('input-text').value,'A的未发送草稿');
|
||||
assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
await restored.click(restored.element('historyWrap').children[1]);
|
||||
assert.equal(restored.element('input-text').value,'B的未发送草稿');
|
||||
});
|
||||
|
||||
test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage});
|
||||
assert.equal(restored.element('input-text').value,'尚未发送');
|
||||
await restored.context.window.fillInput('尚未发送');await restored.pageHide();
|
||||
const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage});
|
||||
assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1');
|
||||
});
|
||||
|
||||
test('draft recovery stays silent but quota failure remains visible until storage recovers', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'}],app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='已保存草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
|
||||
assert.equal(restored.element('input-text').value,'已保存草稿');
|
||||
assert.equal(restored.element('composer-status').textContent,'');
|
||||
const setItem=restored.context.localStorage.setItem;
|
||||
restored.context.localStorage.setItem=()=>{throw Error('quota');};
|
||||
restored.element('input-text').value='新的未保存输入';restored.element('input-text').listeners.input();await restored.runTimers(250);
|
||||
assert.match(restored.element('composer-status').textContent,/无法保存/);
|
||||
assert.equal(restored.element('input-text').value,'新的未保存输入');
|
||||
restored.context.localStorage.setItem=setItem;await restored.pageHide();
|
||||
assert.equal(restored.element('composer-status').textContent,'');
|
||||
});
|
||||
|
||||
test('restoring an old conversation cannot replace a newer user-selected draft', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
let release;
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
await restored.click(restored.element('.new-chat-btn'));
|
||||
restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input();
|
||||
release(response([{id:9,title:'A',ctime:'now'}]));await flush();
|
||||
assert.equal(restored.element('input-text').value,'新话题草稿');
|
||||
});
|
||||
|
||||
test('failed history loading cannot send a question under a hidden conversation context', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.context.window.fillInput('原会话');await flush();
|
||||
app.hooks.messages=()=>response({ok:false},503);
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
|
||||
await app.context.window.fillInput('禁止发到未加载会话');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
|
||||
assert.match(app.element('composer-status').textContent,/会话未加载成功/);
|
||||
assert.doesNotMatch(app.element('chatDom').textContent,/原会话/);
|
||||
});
|
||||
|
||||
test('startup removes a deleted active conversation draft without erasing a newer edit', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}});
|
||||
assert.equal(noEdit.element('input-text').value,'');
|
||||
assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
let release;
|
||||
const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input();
|
||||
release(response([]));await flush();
|
||||
assert.equal(edited.element('input-text').value,'加载期间的新输入');
|
||||
await edited.context.window.fillInput('加载期间的新输入');
|
||||
assert.ok(!edited.calls.some(call=>call.url==='/stream_chat'));
|
||||
assert.match(edited.element('composer-status').textContent,/会话未加载成功/);
|
||||
});
|
||||
|
||||
test('deleting a conversation removes only its saved draft', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
|
||||
const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage});
|
||||
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
|
||||
});
|
||||
|
||||
test('an order login action resumes the original question and preserves a newer draft', async () => {
|
||||
const app=await customerPage({human:false});let authenticated=false;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? {id:1,name:'admin'} : null});
|
||||
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:{id:1,name:'admin'}});};
|
||||
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'账号范围内的订单结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
|
||||
await app.context.window.fillInput('查询ORDER20260001');await flush();
|
||||
const action=app.element('chatDom').querySelector('.customer-login-cta');assert.ok(action);
|
||||
app.element('input-text').value='下一条未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(action);assert.equal(app.element('customer-login').hidden,false);
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
|
||||
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
assert.equal(app.element('input-text').value,'下一条未发送草稿');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
|
||||
assert.match(app.element('chatDom').textContent,/账号范围内的订单结果/);
|
||||
assert.match(app.element('customer-account').textContent,/admin/);
|
||||
});
|
||||
|
||||
test('invalid customer credentials leave the login form open without sending another question', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.hooks['/api/customer/login']=()=>response({ok:false,error:'账号或密码不正确。'},403);
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='wrong';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
assert.equal(app.element('customer-login').hidden,false);
|
||||
assert.match(app.element('customer-error').textContent,/账号或密码/);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
|
||||
await app.click(app.element('customer-cancel'));
|
||||
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
});
|
||||
|
||||
test('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('a late login cannot replay an order into a newer conversation', async () => {
|
||||
const app=await customerPage({human:false});let release;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"先登录","login_required":true}\n\ndata: {"d":1}\n\n']);
|
||||
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
|
||||
await app.context.window.fillInput('查询ORDER20260001');await flush();
|
||||
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
app.element('input-text').value='新话题';app.element('input-text').listeners.input();
|
||||
release(response({ok:true,authenticated:true,user:{id:1,name:'admin'}}));await signing;
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
|
||||
assert.equal(app.element('input-text').value,'新话题');
|
||||
});
|
||||
|
||||
test('signing out clears visible order conversation and returns to guest mode', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}});
|
||||
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
await app.context.window.fillInput('ORDER20260001');await flush();
|
||||
assert.ok(app.element('chatDom').querySelector('.user-message'));
|
||||
await app.click(app.element('customer-account'));
|
||||
assert.equal(app.element('customer-account').textContent,'客户登录');
|
||||
assert.equal(app.element('chatDom').querySelector('.user-message'),null);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
|
||||
});
|
||||
|
||||
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
let shown;
|
||||
const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}};
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(image.hidden,true);
|
||||
assert.match(shown.textContent,/图片暂时无法访问/);
|
||||
const first=shown;
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(shown,first);
|
||||
});
|
||||
|
||||
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;}});
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const vm=require('node:vm');
|
||||
const path=require('node:path');
|
||||
const context=vm.createContext({});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
|
||||
function storage(){
|
||||
const data=new Map();
|
||||
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
|
||||
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
|
||||
}
|
||||
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
|
||||
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
|
||||
}
|
||||
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
|
||||
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
|
||||
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
|
||||
const restored=drafts(undefined,local,tab);
|
||||
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
|
||||
assert.equal(restored.active(),'9');
|
||||
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
|
||||
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
|
||||
otherTab.remove(10);assert.equal(restored.read(10),'');
|
||||
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
|
||||
});
|
||||
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
|
||||
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
|
||||
local.setItem(key,value);assert.equal(a.read(9),'');
|
||||
}
|
||||
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
|
||||
});
|
||||
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
|
||||
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
|
||||
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
|
||||
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
|
||||
});
|
||||
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
|
||||
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
|
||||
const a=drafts(undefined,broken,broken);
|
||||
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
|
||||
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
|
||||
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
|
||||
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
|
||||
assert.equal(drafts(null,local).read(9),'');
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
"""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']
|
||||
|
||||
@@ -14,6 +14,7 @@ from unittest.mock import patch
|
||||
import requests
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class ModelResponse:
|
||||
@@ -45,6 +46,7 @@ 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
|
||||
@@ -313,6 +315,21 @@ 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)
|
||||
@@ -378,6 +395,42 @@ class ApiTests(unittest.TestCase):
|
||||
self.client.delete('/api/clear_all')
|
||||
self.assertEqual(self.client.get('/api/get_history').json, [])
|
||||
|
||||
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
|
||||
for vote in ('up', 'down', 1, -1):
|
||||
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
|
||||
connection.commit(); connection.close()
|
||||
stats=self.client.get('/api/admin/stats').json
|
||||
self.assertEqual((stats['good'],stats['bad']),(3,3))
|
||||
|
||||
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
|
||||
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
|
||||
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
|
||||
with self.subTest(feedback=data):
|
||||
response=self.client.post('/api/feedback',json=data)
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
|
||||
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
|
||||
with self.subTest(ticket=data):
|
||||
response=self.client.post('/api/ticket',json=data)
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
for path in ('/api/feedback','/api/ticket'):
|
||||
response=self.client.post(path,data='{bad',content_type='application/json')
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertFalse(response.json['ok'])
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
for table in ('feedback','tickets'):
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
|
||||
connection.close()
|
||||
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
|
||||
ticket=self.client.get('/api/tickets').json[0]
|
||||
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
|
||||
|
||||
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Customer login, tenant scope and sensitive-history lifecycle."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app, db, order_data
|
||||
from logistics_chat.customer_service import COOKIE_NAME
|
||||
|
||||
|
||||
class CustomerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||||
'ORDER_DATA_TENANT_ID':'','CUSTOMER_DEMO_TENANT_ID':'tenant-a','RATE_LIMIT_PER_MIN':1000})
|
||||
self.a,self.b=self.app.test_client(),self.app.test_client()
|
||||
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||
client.post('/api/visitor/session',json={'key':key})
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
order_data.import_records(connection,[{'order_no':'ORDER20260001','tenant_id':'tenant-a','gross_weight_kg':30},
|
||||
{'order_no':'ORDER20260002','tenant_id':'tenant-b','gross_weight_kg':99}])
|
||||
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
|
||||
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
|
||||
connection.commit();connection.close()
|
||||
|
||||
def login(self, client=None, username='admin'):
|
||||
return (client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'})
|
||||
|
||||
def events(self,message,conv=None,client=None):
|
||||
response=(client or self.a).post('/stream_chat',json={'msg':message,'conv_id':conv})
|
||||
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
|
||||
|
||||
def test_guest_must_login_then_customer_reads_only_the_linked_scope(self):
|
||||
with patch('logistics_chat.order_data.lookup_order') as lookup:
|
||||
self.assertTrue(self.events('查询ORDER20260001')[0]['login_required']);lookup.assert_not_called()
|
||||
self.assertTrue(self.login().json['ok'])
|
||||
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.events('查询ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
self.assertIn('未找到',self.events('查询ORDER20260002')[0]['t'])
|
||||
forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'})
|
||||
self.assertNotIn('99 kg',forged.get_data(as_text=True))
|
||||
|
||||
def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self):
|
||||
secure=self.app.test_client()
|
||||
secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost')
|
||||
response=secure.post('/api/customer/login',json={'username':'admin','password':'123456'},base_url='https://localhost')
|
||||
for flag in ('Secure','HttpOnly','SameSite=Lax'):
|
||||
self.assertIn(flag,response.headers['Set-Cookie'])
|
||||
self.login();token=self.a.get_cookie(COOKIE_NAME).value
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
self.assertNotEqual(connection.execute("SELECT password_hash FROM customer_users WHERE username='admin'").fetchone()[0],'123456')
|
||||
self.assertTrue(connection.execute('SELECT 1 FROM customer_sessions WHERE token_hash=?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone());connection.close()
|
||||
with patch('logistics_chat.customer_service.time.time',return_value=time.time()+9*3600):
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.a.post('/api/customer/logout');self.a.set_cookie(COOKIE_NAME,token)
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
|
||||
def test_customer_cookie_cannot_be_reused_by_another_guest_and_bad_logins_are_limited(self):
|
||||
self.login();self.b.set_cookie(COOKIE_NAME,self.a.get_cookie(COOKIE_NAME).value)
|
||||
self.assertFalse(self.b.get('/api/customer/session').json['authenticated'])
|
||||
self.b.post('/api/customer/logout')
|
||||
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||
for index in range(5):
|
||||
response=self.b.post('/api/customer/login',json={'username':'admin','password':'wrong'})
|
||||
self.assertEqual(response.status_code,403)
|
||||
self.assertEqual(self.login(self.b).status_code,429)
|
||||
|
||||
def test_order_history_is_hidden_after_logout_and_restored_only_to_its_customer_and_guest(self):
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id']
|
||||
self.events('ORDER20260001',cid)
|
||||
self.a.post('/api/customer/logout')
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||
self.assertTrue(self.events('继续查询',cid)[0]['login_required'])
|
||||
self.a.delete('/api/clear_all')
|
||||
self.login(username='other')
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.login()
|
||||
self.assertTrue(any('__CARD__' in row['content'] for row in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
|
||||
self.login(self.b)
|
||||
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
|
||||
def test_cross_site_login_logout_and_oversized_or_malformed_requests_fail(self):
|
||||
for path in ('login','logout'):
|
||||
self.assertEqual(self.a.post('/api/customer/'+path,json={'username':'admin','password':'123456'},
|
||||
headers={'Origin':'https://foreign.example'}).status_code,403)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json=[]).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json={'username':[],'password':'123456'}).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json={'padding':'x'*3000}).status_code,413)
|
||||
|
||||
def test_disabled_accounts_and_password_changes_invalidate_existing_sessions(self):
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.login().status_code,403)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=1 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET password_hash=password_hash||'changed' WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
|
||||
def test_demo_binding_does_not_pick_an_arbitrary_tenant_when_dataset_has_multiple(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
self.app.config['CUSTOMER_DEMO_LOCAL_ORDERS']=False
|
||||
self.assertTrue(self.login().json['ok'])
|
||||
reply=self.events('ORDER20260001')[0]
|
||||
self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||
self.login()
|
||||
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
|
||||
def test_tenant_reassignment_does_not_reveal_the_accounts_old_order_history(self):
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260001',cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='tenant-b' WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
|
||||
def test_demo_disable_revokes_default_login_without_disabling_real_customer_records(self):
|
||||
self.login();self.app.config['CUSTOMER_DEMO_ENABLED']=False
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.login().status_code,403)
|
||||
self.assertTrue(self.login(username='other').json['authenticated'])
|
||||
|
||||
def test_parallel_invalid_logins_cannot_bypass_the_attempt_budget(self):
|
||||
barrier=Barrier(6)
|
||||
def attempt(index):
|
||||
with self.app.test_client() as client:
|
||||
client.set_cookie('baiyue_visitor','1'*64)
|
||||
barrier.wait(timeout=5)
|
||||
return client.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
statuses=list(executor.map(attempt,range(6)))
|
||||
self.assertEqual(statuses.count(403),5);self.assertEqual(statuses.count(429),1)
|
||||
|
||||
def test_default_local_account_has_explicit_existing_tenants_and_real_users_remain_scoped(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertTrue(self.login().json['authenticated'])
|
||||
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
order_data.import_records(connection,[{'order_no':'ORDER20260003','tenant_id':'new-ungranted-tenant'}]);connection.commit();connection.close()
|
||||
self.assertIn('未找到',self.events('ORDER20260003')[0]['t'])
|
||||
self.login(username='other')
|
||||
self.assertIn('未找到',self.events('ORDER20260001')[0]['t'])
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
|
||||
def test_removing_a_tenant_grant_revokes_sessions_and_old_scope_history(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||
|
||||
def test_revoking_every_local_grant_is_not_undone_by_login_or_startup(self):
|
||||
from logistics_chat.customer_service import init_customer_tables
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute('DELETE FROM customer_tenant_grants');connection.commit()
|
||||
init_customer_tables(connection);connection.commit();connection.close()
|
||||
self.login()
|
||||
self.assertIn('尚未关联',self.events('ORDER20260001')[0]['t'])
|
||||
with self.app.app_context():
|
||||
connection=db.connect();self.assertEqual(connection.execute('SELECT COUNT(*) FROM customer_tenant_grants').fetchone()[0],0);connection.close()
|
||||
|
||||
def test_server_scope_narrowing_revokes_the_session_and_hides_broader_history(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||
self.app.config['ORDER_DATA_TENANT_ID']='tenant-a'
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""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())
|
||||
@@ -9,6 +9,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from deploy import wsgi
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class DeploymentTests(unittest.TestCase):
|
||||
@@ -30,9 +31,10 @@ class DeploymentTests(unittest.TestCase):
|
||||
self.addCleanup(close_handlers)
|
||||
client = app.test_client()
|
||||
client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
|
||||
self.assertEqual(client.get(path).status_code, 200, path)
|
||||
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)
|
||||
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')})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Remote dev behavior on the modular backend and the light workspace."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -10,6 +10,7 @@ from unittest.mock import patch
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class DevIntegrationTests(unittest.TestCase):
|
||||
@@ -22,6 +23,7 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
||||
self.client = self.app.test_client()
|
||||
self.client.post('/api/visitor/session', json={'key': '1' * 64})
|
||||
login_staff(self.app, self.client)
|
||||
|
||||
def session(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
@@ -50,6 +52,19 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
|
||||
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
|
||||
|
||||
def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)):
|
||||
instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc)
|
||||
class HostUtcClock(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
|
||||
with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock):
|
||||
result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json
|
||||
self.assertEqual(result['ok'],expected)
|
||||
if not expected: self.assertTrue(result['closed'])
|
||||
|
||||
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
|
||||
sid = self.session()
|
||||
self.client.post('/api/human/accept', json={'sid': sid})
|
||||
@@ -117,6 +132,7 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
connection.commit()
|
||||
connection.close()
|
||||
db.init_db()
|
||||
login_staff(self.app, self.client, 'demo', seed=False)
|
||||
results = self.client.get('/api/search_chat?q=专线').json
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIn('SUM', results[0]['content'])
|
||||
@@ -147,7 +163,7 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
state = self.client.get('/api/human/poll?sid=1').json
|
||||
self.assertEqual(state['messages'][0]['content'], '保留的回复')
|
||||
self.assertEqual(state['note'], '')
|
||||
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
|
||||
self.assertIn('fixture-agent', [row['username'] for row in self.client.get('/api/agents/list').json])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
||||
def test_reply_workflows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs'], cwd=root,
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs', 'tests/frontend/customer_orders.test.cjs'], cwd=root,
|
||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@@ -67,12 +67,16 @@ class GrayConfigTests(unittest.TestCase):
|
||||
self.assertIn('auth_basic off;', body)
|
||||
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
|
||||
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
|
||||
'/api/customer/session','/api/customer/login','/api/customer/logout',
|
||||
'/api/customer/orders','/api/customer/orders/123',
|
||||
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
|
||||
'/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'):
|
||||
with self.subTest(public_path=path):self.assertRegex(path,pattern)
|
||||
for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets',
|
||||
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
|
||||
'/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'):
|
||||
'/api/visitor/human/accept','/api/customer/users','/api/customer/session/extra',
|
||||
'/api/customer/orders/123/edit','/api/customer/orders/export','/api/customer/orders/123/',
|
||||
'/api/get_history/unknown','/api/unknown'):
|
||||
with self.subTest(staff_path=path):self.assertNotRegex(path,pattern)
|
||||
example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8')
|
||||
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
|
||||
|
||||
@@ -22,8 +22,6 @@ class OrderDataTests(unittest.TestCase):
|
||||
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
self.context = self.app.app_context()
|
||||
self.context.push()
|
||||
from flask import g
|
||||
g.customer_tenant_id = 'tenant-a'
|
||||
self.addCleanup(self.context.pop)
|
||||
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
|
||||
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
|
||||
@@ -32,6 +30,8 @@ class OrderDataTests(unittest.TestCase):
|
||||
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
|
||||
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
|
||||
self.import_rows([self.record])
|
||||
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||||
self.client.get('/api/customer/session')
|
||||
|
||||
def import_rows(self, rows, replace=False):
|
||||
connection = db.connect()
|
||||
@@ -61,9 +61,26 @@ class OrderDataTests(unittest.TestCase):
|
||||
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||||
self.assertEqual(order_data.extract_reference(text), [])
|
||||
events = self.events('帮我查询物流轨迹')
|
||||
self.assertIn('订单号', events[0]['t'])
|
||||
self.assertIn('入仓码', events[0]['t'])
|
||||
self.assertEqual(events[-1], {'d':1})
|
||||
|
||||
def test_direct_inbound_lookup_and_followup_keep_the_inbound_identity(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
card=self.events(self.record['inbound_code'],cid)[0]['card']
|
||||
self.assertEqual(card['no'],self.record['order_no'])
|
||||
self.assertTrue(card['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in card['fields'] if item['key']=='number'),self.record['order_no'])
|
||||
self.import_rows([{**self.record,'gross_weight_kg':'42.000'}],replace=True)
|
||||
followup=self.events('现在到哪了',cid)[0]['card']
|
||||
self.assertTrue(followup['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in followup['fields'] if item['key']=='weight'),'42.000 kg')
|
||||
|
||||
def test_missing_inbound_code_is_not_replaced_by_the_internal_order_number(self):
|
||||
self.import_rows([{**self.record,'inbound_code':None}],replace=True)
|
||||
card=self.events(self.record['order_no'])[0]['card']
|
||||
self.assertFalse(any(item['key']=='inbound' for item in card['fields']))
|
||||
self.assertTrue(card['summary'].startswith('订单号 '+self.record['order_no']))
|
||||
|
||||
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||||
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||||
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||||
@@ -86,7 +103,7 @@ class OrderDataTests(unittest.TestCase):
|
||||
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events = self.events('列出所有订单')
|
||||
self.assertIn('请提供订单号', events[0]['t'])
|
||||
self.assertIn('请提供入仓码', events[0]['t'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Staff-only upload, preview, and confirmation for customer query order data."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from logistics_chat import create_app, db, order_data
|
||||
from tests.staff_fixture import login_staff
|
||||
|
||||
|
||||
HEADERS = ['租户ID', '订单号', '业务状态', '入仓码', 'Shipment ID']
|
||||
|
||||
|
||||
def csv_upload(rows):
|
||||
stream = io.StringIO(newline='')
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow(HEADERS)
|
||||
writer.writerows(rows)
|
||||
return (io.BytesIO(stream.getvalue().encode('utf-8-sig')), 'orders.csv')
|
||||
|
||||
|
||||
class OrderImportAdminTests(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, 'RATE_LIMIT_PER_MIN': 1000})
|
||||
self.client = self.app.test_client()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) '
|
||||
"VALUES('tenant-a-user','hash','Tenant A','tenant-a')")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def signed_in(self):
|
||||
login_staff(self.app, self.client)
|
||||
return self.client.environ_base['HTTP_X_CSRF_TOKEN']
|
||||
|
||||
def add_existing(self):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
with connection:
|
||||
order_data.import_records(connection, [{'tenant_id': 'tenant-a',
|
||||
'order_no': 'EXISTING001', 'status': 'PROCESSING', 'inbound_code': 'OLDINB001'}])
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def preview(self, upload, csrf=None):
|
||||
headers = {'X-CSRF-Token': csrf} if csrf is not None else {}
|
||||
return self.client.post('/api/admin/orders/import/preview',
|
||||
data={'file': upload}, headers=headers, content_type='multipart/form-data')
|
||||
|
||||
def test_import_page_and_template_are_staff_protected(self):
|
||||
self.assertEqual(self.client.get('/admin/orders').status_code, 302)
|
||||
self.signed_in()
|
||||
page = self.client.get('/admin/orders')
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertIn('订单数据管理', page.get_data(as_text=True))
|
||||
template = self.client.get('/api/admin/orders/import/template')
|
||||
self.assertEqual(template.status_code, 200)
|
||||
self.assertIn('租户ID,订单号', template.get_data(as_text=True))
|
||||
|
||||
def test_preview_validates_rows_and_reports_existing_orders_without_writing(self):
|
||||
self.signed_in()
|
||||
self.add_existing()
|
||||
response = self.preview(csv_upload([
|
||||
['tenant-a', 'NEW000001', 'PROCESSING', 'INB000001', 'SHIP000001'],
|
||||
['tenant-a', 'EXISTING001', 'COMPLETED', 'NEWINB001', ''],
|
||||
]), self.client.environ_base['HTTP_X_CSRF_TOKEN'])
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
self.assertEqual(response.json['summary'], {'total_rows': 2, 'valid_rows': 2,
|
||||
'duplicate_rows': 1, 'error_rows': 0})
|
||||
self.assertEqual(response.json['preview'][0]['order_no'], 'NEW000001')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 1)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_invalid_file_does_not_partially_import_rows(self):
|
||||
self.signed_in()
|
||||
response = self.preview(csv_upload([
|
||||
['tenant-a', 'NEW000001', 'PROCESSING', 'INB000001', ''],
|
||||
['', 'BROKEN0001', 'PROCESSING', '', ''],
|
||||
]), self.client.environ_base['HTTP_X_CSRF_TOKEN'])
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json['errors'][0]['row'], 3)
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 0)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_confirm_import_requires_csrf_and_preserves_existing_order_values(self):
|
||||
csrf = self.signed_in()
|
||||
self.add_existing()
|
||||
upload = csv_upload([
|
||||
['tenant-a', 'NEW000001', 'PROCESSING', 'INB000001', 'SHIP000001'],
|
||||
['tenant-a', 'EXISTING001', 'COMPLETED', 'NEWINB001', ''],
|
||||
])
|
||||
payload = upload[0].getvalue()
|
||||
blocked = self.client.post('/api/admin/orders/import',
|
||||
data={'file': (io.BytesIO(payload), 'orders.csv')}, headers={'X-CSRF-Token': ''},
|
||||
content_type='multipart/form-data')
|
||||
self.assertEqual(blocked.status_code, 403)
|
||||
response = self.client.post('/api/admin/orders/import',
|
||||
data={'file': (io.BytesIO(payload), 'orders.csv')}, headers={'X-CSRF-Token': csrf},
|
||||
content_type='multipart/form-data')
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
self.assertEqual(response.json['imported'], 1)
|
||||
self.assertEqual(response.json['preserved'], 1)
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
self.assertEqual(connection.execute('SELECT status,inbound_code FROM order_test_data '
|
||||
'WHERE order_no="EXISTING001"').fetchone(), ('PROCESSING', 'OLDINB001'))
|
||||
self.assertEqual(connection.execute('SELECT tenant_id FROM order_test_data '
|
||||
'WHERE order_no="NEW000001"').fetchone(), ('tenant-a',))
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_xlsx_workbook_can_be_previewed(self):
|
||||
csrf = self.signed_in()
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.append(HEADERS)
|
||||
sheet.append(['tenant-a', 'XLSX000001', 'SUBMITTED', 'XLSXINB01', ''])
|
||||
content = io.BytesIO()
|
||||
workbook.save(content)
|
||||
response = self.preview((io.BytesIO(content.getvalue()), 'orders.xlsx'), csrf)
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
self.assertEqual(response.json['preview'][0]['order_no'], 'XLSX000001')
|
||||
|
||||
def test_preview_rejects_unknown_tenant_status_and_malformed_tracking(self):
|
||||
csrf = self.signed_in()
|
||||
response = self.preview(csv_upload([
|
||||
['unknown-tenant', 'ORDER000001', 'PROCESSING', '', ''],
|
||||
['tenant-a', 'ORDER000002', 'NOT_A_STATUS', '', ''],
|
||||
]), csrf)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json['summary']['error_rows'], 2)
|
||||
|
||||
stream = io.StringIO(newline='')
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow(['租户ID', '订单号', '业务状态', '物流轨迹(JSON)'])
|
||||
writer.writerow(['tenant-a', 'ORDER000003', 'PROCESSING', '[{"code":"NOT_A_NODE","actual_at":"2026-11-01"}]'])
|
||||
invalid_tracking = self.preview((io.BytesIO(stream.getvalue().encode('utf-8-sig')), 'orders.csv'), csrf)
|
||||
self.assertEqual(invalid_tracking.status_code, 400)
|
||||
self.assertIn('物流轨迹', invalid_tracking.json['errors'][0]['message'])
|
||||
|
||||
def test_import_records_batch_and_undo_only_deletes_unchanged_inserted_rows(self):
|
||||
csrf = self.signed_in()
|
||||
self.add_existing()
|
||||
response = self.client.post('/api/admin/orders/import',
|
||||
data={'file': csv_upload([
|
||||
['tenant-a', 'NEW000001', 'PROCESSING', 'INB000001', 'SHIP000001'],
|
||||
['tenant-a', 'NEW000002', 'PROCESSING', '', ''],
|
||||
['tenant-a', 'EXISTING001', 'COMPLETED', '', ''],
|
||||
])}, headers={'X-CSRF-Token': csrf}, content_type='multipart/form-data')
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
self.assertEqual(response.json['imported'], 2)
|
||||
self.assertEqual(response.json['preserved'], 1)
|
||||
batch_id = response.json['batch_id']
|
||||
self.assertGreater(batch_id, 0)
|
||||
|
||||
listed = self.client.get('/api/admin/orders/list?q=NEW000001')
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(listed.json['total'], 1)
|
||||
self.assertEqual(listed.json['orders'][0]['order_no'], 'NEW000001')
|
||||
filtered = self.client.get('/api/admin/orders/list?tenant=tenant-a&status=PROCESSING&q=%25')
|
||||
self.assertEqual(filtered.status_code, 200)
|
||||
self.assertEqual(filtered.json['total'], 0)
|
||||
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
connection.execute("UPDATE order_test_data SET data_note='人工备注' WHERE order_no='NEW000002'")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
undone = self.client.post(f'/api/admin/orders/import/batches/{batch_id}/undo',
|
||||
headers={'X-CSRF-Token': csrf})
|
||||
self.assertEqual(undone.status_code, 200, undone.get_data(as_text=True))
|
||||
self.assertEqual(undone.json['deleted'], 1)
|
||||
self.assertEqual(undone.json['preserved'], 1)
|
||||
repeated = self.client.post(f'/api/admin/orders/import/batches/{batch_id}/undo',
|
||||
headers={'X-CSRF-Token': csrf})
|
||||
self.assertEqual(repeated.status_code, 200)
|
||||
self.assertTrue(repeated.json['already_undone'])
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
self.assertIsNone(connection.execute("SELECT 1 FROM order_test_data "
|
||||
"WHERE order_no='NEW000001'").fetchone())
|
||||
self.assertEqual(connection.execute("SELECT data_note FROM order_test_data "
|
||||
"WHERE order_no='NEW000002'").fetchone()[0], '人工备注')
|
||||
self.assertEqual(connection.execute("SELECT status FROM order_test_data "
|
||||
"WHERE order_no='EXISTING001'").fetchone()[0], 'PROCESSING')
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_order_listing_requires_staff_and_csrf_for_undo(self):
|
||||
self.assertEqual(self.client.get('/api/admin/orders/list').status_code, 401)
|
||||
csrf = self.signed_in()
|
||||
response = self.client.post('/api/admin/orders/import',
|
||||
data={'file': csv_upload([['tenant-a', 'NEW000002', 'PROCESSING', '', '']])},
|
||||
headers={'X-CSRF-Token': csrf}, content_type='multipart/form-data')
|
||||
batch_id = response.json['batch_id']
|
||||
denied = self.client.post(f'/api/admin/orders/import/batches/{batch_id}/undo',
|
||||
headers={'X-CSRF-Token': ''})
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
|
||||
def test_schema_upgrade_adds_batch_tables_without_losing_orders(self):
|
||||
self.add_existing()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
try:
|
||||
connection.execute("UPDATE schema_meta SET value='3' WHERE key='schema_version'")
|
||||
connection.execute('DROP TABLE order_import_batch_items')
|
||||
connection.execute('DROP TABLE order_import_batches')
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
db.init_db()
|
||||
connection = db.connect()
|
||||
try:
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], '4')
|
||||
self.assertEqual(connection.execute("SELECT status FROM order_test_data WHERE order_no='EXISTING001'").fetchone()[0], 'PROCESSING')
|
||||
self.assertIsNotNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='order_import_batches'").fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+3
-1
@@ -13,7 +13,9 @@ from logistics_chat import knowledge, pricing, tracking
|
||||
|
||||
class RuleTests(unittest.TestCase):
|
||||
def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self):
|
||||
self.assertIn('22KG+', knowledge.search_knowledge('英国空运价格'))
|
||||
for question in ('英国空运价格','英国空运多久','英国空运多少钱'):
|
||||
self.assertIn('22KG+', knowledge.search_knowledge(question))
|
||||
self.assertIn('21KG+', knowledge.search_knowledge('欧洲空运价格'))
|
||||
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
|
||||
|
||||
def test_sea_tier_boundary_and_minimum(self):
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Existing staff credentials remain usable across password storage formats."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
|
||||
|
||||
class StaffLoginTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
root = Path(directory.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})
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def add_staff(self, password):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('INSERT INTO agents(username,password,name) VALUES(?,?,?)',
|
||||
('staff-fixture', password, '测试客服'))
|
||||
connection.commit()
|
||||
|
||||
def login(self, password):
|
||||
return self.client.post('/admin/login', json={'username': 'staff-fixture', 'password': password})
|
||||
|
||||
def test_existing_hashed_password_accepts_original_password(self):
|
||||
password = 'Fixture-密码-42!'
|
||||
for method in ('pbkdf2:sha256:1000', 'scrypt:1024:8:1'):
|
||||
with self.subTest(method=method):
|
||||
encoded = generate_password_hash(password, method=method)
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.commit()
|
||||
self.add_staff(encoded)
|
||||
response = self.login(password)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json, {'ok': True, 'name': '测试客服'})
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
self.assertEqual(connection.execute('SELECT password FROM agents').fetchone()[0], encoded)
|
||||
|
||||
def test_wrong_password_and_stored_hash_cannot_log_in(self):
|
||||
encoded = generate_password_hash('Fixture-password', method='pbkdf2:sha256:1000')
|
||||
self.add_staff(encoded)
|
||||
self.assertEqual(self.login('wrong-password').status_code, 401)
|
||||
self.assertEqual(self.login(encoded).status_code, 401)
|
||||
|
||||
def test_legacy_plain_password_still_works(self):
|
||||
self.add_staff('旧密码-fixture')
|
||||
self.assertEqual(self.login('旧密码-fixture').status_code, 200)
|
||||
self.assertEqual(self.login('wrong').status_code, 401)
|
||||
|
||||
def test_invalid_credentials_are_rejected_without_server_errors(self):
|
||||
self.add_staff('legacy-fixture')
|
||||
for payload in ({}, [], 'invalid', {'username': [], 'password': {}},
|
||||
{'username': 'staff-fixture', 'password': ''},
|
||||
{'username': 'missing', 'password': 'legacy-fixture'}):
|
||||
with self.subTest(payload=payload):
|
||||
self.app.extensions['staff_login_attempts'].clear()
|
||||
self.assertEqual(self.client.post('/admin/login', json=payload).status_code, 401)
|
||||
|
||||
def test_corrupt_hash_cannot_be_used_as_a_plain_password(self):
|
||||
encoded = 'pbkdf2:invalid$fixture$invalid'
|
||||
self.add_staff(encoded)
|
||||
self.assertEqual(self.login(encoded).status_code, 401)
|
||||
self.assertEqual(self.login('test-password').status_code, 401)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Staff authorization uses real logins against disposable storage."""
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import test_staff_login as credentials
|
||||
from logistics_chat import db
|
||||
|
||||
|
||||
class StaffSessionTests(unittest.TestCase):
|
||||
add_staff = credentials.StaffLoginTests.add_staff
|
||||
login = credentials.StaffLoginTests.login
|
||||
|
||||
def setUp(self):
|
||||
credentials.StaffLoginTests.setUp(self)
|
||||
self.app.config['RATE_LIMIT_PER_MIN'] = 1000
|
||||
|
||||
def signed_in(self):
|
||||
self.add_staff('fixture-password')
|
||||
response = self.login('fixture-password')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return self.client.get('/api/admin/session').json['csrf_token']
|
||||
|
||||
def test_anonymous_staff_pages_and_all_staff_endpoints_are_blocked(self):
|
||||
staff_pages = {'/admin/dashboard', '/admin/orders'}
|
||||
for path in staff_pages:
|
||||
self.assertEqual(self.client.get(path).status_code, 302)
|
||||
public = {'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'}
|
||||
for rule in self.app.url_map.iter_rules():
|
||||
if rule.endpoint in public or rule.rule in staff_pages:
|
||||
continue
|
||||
method = 'POST' if 'POST' in rule.methods else 'GET'
|
||||
path = rule.rule.replace('<path:name>', 'fixture.txt')
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client.open(path, method=method, json={}).status_code, 401)
|
||||
self.assertEqual(self.client.get('/api/future-private-route').status_code, 401)
|
||||
self.assertEqual(self.client.get('/').status_code, 200)
|
||||
self.assertEqual(self.client.get('/healthz').status_code, 200)
|
||||
|
||||
def test_customer_ticket_and_rating_keep_their_existing_guest_contract(self):
|
||||
self.client.post('/api/visitor/session', json={'key':'a'*64})
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content':'fixture ticket'}).status_code, 200)
|
||||
self.assertEqual(self.client.post('/api/rate', json={'sid':999, 'stars':5}).status_code, 404)
|
||||
|
||||
def test_cookie_and_database_store_24_hour_opaque_sessions(self):
|
||||
self.add_staff('fixture-password')
|
||||
response = self.client.post('/admin/login', json={'username':'staff-fixture', 'password':'fixture-password'},
|
||||
base_url='https://localhost')
|
||||
cookie = response.headers['Set-Cookie']
|
||||
for flag in ('HttpOnly', 'Secure', 'SameSite=Lax', 'Max-Age=86400'):
|
||||
self.assertIn(flag, cookie)
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
with self.app.app_context():
|
||||
row = db.connect().execute('SELECT token_hash FROM staff_sessions').fetchone()
|
||||
self.assertEqual(row[0], hashlib.sha256(token.encode()).hexdigest())
|
||||
self.assertNotIn(token, self.client.get('/admin/dashboard').text)
|
||||
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
|
||||
|
||||
def test_staff_writes_require_csrf_and_same_origin(self):
|
||||
csrf = self.signed_in()
|
||||
self.assertEqual(self.client.post('/api/human/note', json={}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':'bad'}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf, 'Origin':'https://evil.test'}).status_code, 403)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
|
||||
def test_logout_revokes_replayed_cookie_and_preserves_data(self):
|
||||
csrf = self.signed_in()
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
|
||||
self.assertIsNone(self.client.get_cookie('baiyue_staff'))
|
||||
self.client.set_cookie('baiyue_staff', token)
|
||||
self.assertEqual(self.client.get('/api/admin/stats').status_code, 401)
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.connect().execute('SELECT COUNT(*) FROM agents').fetchone()[0], 1)
|
||||
|
||||
def test_login_rotates_current_token(self):
|
||||
self.signed_in()
|
||||
old = self.client.get_cookie('baiyue_staff').value
|
||||
self.login('fixture-password')
|
||||
self.assertNotEqual(old, self.client.get_cookie('baiyue_staff').value)
|
||||
self.client.set_cookie('baiyue_staff', old)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_sessions_expire_at_24_hours_without_renewing_on_requests_or_restart(self):
|
||||
from logistics_chat import create_app
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1000):
|
||||
self.signed_in()
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
restarted = self.client
|
||||
for elapsed in (8*3600, 23*3600, 24*3600-1):
|
||||
with self.subTest(elapsed=elapsed), patch('logistics_chat.staff_service.time.time', return_value=1000+elapsed):
|
||||
response = self.client.get('/api/admin/session')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn('Set-Cookie', response.headers)
|
||||
self.assertEqual(self.client.get_cookie('baiyue_staff').value, token)
|
||||
restarted = create_app(dict(self.app.config)).test_client()
|
||||
restarted.set_cookie('baiyue_staff', token)
|
||||
self.assertEqual(restarted.get('/api/admin/session').status_code, 200)
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1000+24*3600):
|
||||
for client in (self.client, restarted):
|
||||
self.assertEqual(client.get('/api/admin/session').status_code, 401)
|
||||
self.assertEqual(client.get('/admin/dashboard').status_code, 302)
|
||||
|
||||
def test_sessions_survive_app_restart_until_explicit_logout(self):
|
||||
from logistics_chat import create_app
|
||||
self.signed_in()
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
restarted = create_app(dict(self.app.config)).test_client()
|
||||
restarted.set_cookie('baiyue_staff', token)
|
||||
response = restarted.get('/api/admin/session')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(restarted.post('/admin/logout', headers={'X-CSRF-Token':response.json['csrf_token']}).status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_another_browser_login_does_not_remove_existing_session(self):
|
||||
csrf = self.signed_in()
|
||||
another = self.app.test_client()
|
||||
self.assertEqual(another.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'}).status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
|
||||
self.assertEqual(another.get('/api/admin/session').status_code, 200)
|
||||
|
||||
def test_upgrade_preserves_deadlines_and_revokes_unbounded_and_expired_sessions(self):
|
||||
self.signed_in()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('UPDATE staff_sessions SET expires_at=2000')
|
||||
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,500 FROM staff_sessions',
|
||||
(hashlib.sha256(('b'*64).encode()).hexdigest(),))
|
||||
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,0 FROM staff_sessions LIMIT 1',
|
||||
(hashlib.sha256(('c'*64).encode()).hexdigest(),))
|
||||
connection.commit()
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1000):
|
||||
db.init_db()
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1500):
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
for token in ('b'*64, 'c'*64):
|
||||
other = self.app.test_client()
|
||||
other.set_cookie('baiyue_staff', token)
|
||||
self.assertEqual(other.get('/api/admin/session').status_code, 401)
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=2000):
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_password_change_and_account_deletion_invalidate_sessions(self):
|
||||
self.signed_in()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute("UPDATE agents SET password='new-fixture-password'")
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
self.login('new-fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_legacy_agent_id_session_schema_is_upgraded_without_changing_credentials(self):
|
||||
from werkzeug.security import generate_password_hash
|
||||
password_hash = generate_password_hash('fixture-password', method='scrypt:1024:8:1')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DROP TABLE staff_sessions')
|
||||
connection.execute('DROP TABLE agents')
|
||||
connection.execute('CREATE TABLE agents(id INTEGER PRIMARY KEY, username TEXT UNIQUE, '
|
||||
"password TEXT, name TEXT, role TEXT NOT NULL DEFAULT 'agent')")
|
||||
connection.execute('INSERT INTO agents VALUES(?,?,?,?,?)', (7,'staff-fixture',password_hash,'测试客服','admin'))
|
||||
connection.execute('CREATE TABLE staff_sessions(id TEXT PRIMARY KEY, agent_id INTEGER NOT NULL, '
|
||||
'created_at REAL NOT NULL, expires_at REAL NOT NULL, password_version TEXT NOT NULL)')
|
||||
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)', ('legacy-cookie',7,1000,9999999999,'legacy-version'))
|
||||
connection.execute("INSERT INTO tickets(content,phone) VALUES('fixture-ticket','fixture-phone')")
|
||||
connection.commit()
|
||||
db.init_db()
|
||||
db.init_db()
|
||||
self.assertEqual(connection.execute('SELECT id,username,password,name,role FROM agents').fetchone(),
|
||||
(7,'staff-fixture',password_hash,'测试客服','admin'))
|
||||
self.assertEqual(connection.execute('SELECT content FROM tickets').fetchone()[0], 'fixture-ticket')
|
||||
self.assertEqual(self.login('fixture-password').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute("UPDATE agents SET password='new-fixture-password'")
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
self.login('new-fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_cross_site_login_and_form_login_are_rejected(self):
|
||||
self.add_staff('fixture-password')
|
||||
self.assertEqual(self.client.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'},
|
||||
headers={'Origin':'https://evil.test'}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/login', data={'username':'staff-fixture','password':'fixture-password'}).status_code, 415)
|
||||
|
||||
def test_restoring_password_or_recreating_account_cannot_restore_old_session(self):
|
||||
for change in ('password', 'account', 'replace'):
|
||||
with self.subTest(change=change):
|
||||
if change == 'password':
|
||||
self.signed_in()
|
||||
else:
|
||||
self.login('fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
if change == 'password':
|
||||
connection.execute("UPDATE agents SET password='temporary-password'")
|
||||
connection.execute("UPDATE agents SET password='fixture-password'")
|
||||
elif change == 'account':
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('staff-fixture','fixture-password','测试客服'))
|
||||
else:
|
||||
for password in ('temporary-password', 'fixture-password'):
|
||||
connection.execute('INSERT OR REPLACE INTO agents VALUES(?,?,?)', ('staff-fixture',password,'测试客服'))
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_failed_logins_are_throttled_separately_from_polling(self):
|
||||
self.add_staff('fixture-password')
|
||||
for _ in range(5):
|
||||
self.assertEqual(self.login('wrong').status_code, 401)
|
||||
self.assertEqual(self.login('wrong').status_code, 429)
|
||||
self.assertEqual(self.client.get('/healthz').status_code, 200)
|
||||
|
||||
def test_random_cookie_and_customer_session_do_not_authorize_staff(self):
|
||||
self.client.set_cookie('baiyue_staff', 'a'*64)
|
||||
self.client.set_cookie('baiyue_customer', 'b'*64)
|
||||
self.client.post('/api/visitor/session', json={'key':'c'*64})
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/tickets').status_code, 401)
|
||||
|
||||
def test_proxy_peer_failures_do_not_lock_other_accounts_or_trust_spoofed_headers(self):
|
||||
self.add_staff('fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('other-fixture','other-password','Other Staff'))
|
||||
connection.commit()
|
||||
for _ in range(5):
|
||||
self.assertEqual(self.login('wrong').status_code, 401)
|
||||
fresh = self.app.test_client()
|
||||
self.assertEqual(fresh.post('/admin/login', json={'username':'staff-fixture','password':'wrong'},
|
||||
headers={'X-Forwarded-For':'203.0.113.42'}).status_code, 429)
|
||||
self.assertEqual(fresh.post('/admin/login', json={'username':'other-fixture','password':'other-password'}).status_code, 200)
|
||||
@@ -8,6 +8,9 @@ import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
from contextlib import closing
|
||||
import sqlite3
|
||||
|
||||
from flask import Flask
|
||||
from werkzeug.datastructures import FileStorage
|
||||
@@ -115,6 +118,95 @@ class StorageTests(unittest.TestCase):
|
||||
self.assertFalse(old.exists())
|
||||
self.assertEqual((uploads/'new.txt').read_text(), 'new')
|
||||
|
||||
def test_schema_version_and_pre_migration_snapshot_are_recorded_before_upgrade(self):
|
||||
connection = db.connect()
|
||||
connection.execute('DROP TABLE IF EXISTS schema_meta')
|
||||
connection.commit()
|
||||
connection.close()
|
||||
db.init_db()
|
||||
connection = db.connect()
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
|
||||
connection.close()
|
||||
snapshots = list((self.root/'backups').glob('pre-migration-*.db'))
|
||||
self.assertEqual(len(snapshots), 1)
|
||||
self.assertEqual(db.check_database(snapshots[0]), 'ok')
|
||||
with closing(sqlite3.connect(snapshots[0])) as snapshot:
|
||||
self.assertIsNone(snapshot.execute("SELECT 1 FROM sqlite_master WHERE name='schema_meta'").fetchone())
|
||||
|
||||
def test_startup_migration_snapshots_keep_only_the_latest_five(self):
|
||||
for _ in range(7):
|
||||
db.init_db()
|
||||
snapshots = sorted((self.root/'backups').glob('pre-migration-*.db'))
|
||||
self.assertEqual(len(snapshots), 5)
|
||||
self.assertTrue(all(path.is_file() for path in snapshots))
|
||||
self.assertTrue(all(db.check_database(path) == 'ok' for path in snapshots))
|
||||
|
||||
def test_backup_failure_prevents_any_schema_changes(self):
|
||||
with closing(db.connect()) as connection:
|
||||
connection.execute('DROP TABLE schema_meta')
|
||||
connection.commit()
|
||||
with patch('logistics_chat.db.snapshot_database', side_effect=OSError('fixture disk full')):
|
||||
with self.assertRaisesRegex(OSError, 'disk full'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertIsNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='schema_meta'").fetchone())
|
||||
|
||||
def test_failed_startup_snapshot_does_not_prune_previous_backups(self):
|
||||
backups = self.root/'backups'
|
||||
backups.mkdir()
|
||||
previous = []
|
||||
for index in range(6):
|
||||
path = backups/f'pre-migration-20260101-00000{index}-000000-{index:08x}.db'
|
||||
db.snapshot_database(self.root/'history.db', path)
|
||||
previous.append(path)
|
||||
with patch('logistics_chat.db.snapshot_database', side_effect=OSError('fixture disk full')):
|
||||
with self.assertRaises(OSError):
|
||||
db.init_db()
|
||||
self.assertTrue(all(path.exists() for path in previous))
|
||||
|
||||
def test_failed_migration_rolls_back_schema_and_data_and_keeps_preimage(self):
|
||||
with closing(db.connect()) as connection:
|
||||
connection.execute('DROP TABLE schema_meta')
|
||||
connection.commit()
|
||||
def broken(connection):
|
||||
connection.execute('CREATE TABLE partial_upgrade(value TEXT)')
|
||||
connection.execute("INSERT INTO tickets(content) VALUES('must roll back')")
|
||||
raise RuntimeError('fixture migration failed')
|
||||
with patch('logistics_chat.db.MIGRATIONS', ((1, broken), *db.MIGRATIONS[1:])):
|
||||
with self.assertRaisesRegex(RuntimeError, 'migration failed'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertIsNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='partial_upgrade'").fetchone())
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM tickets').fetchone()[0], 0)
|
||||
self.assertEqual(len(list((self.root/'backups').glob('pre-migration-*.db'))), 1)
|
||||
|
||||
def test_newer_database_version_is_never_downgraded(self):
|
||||
with closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE schema_meta SET value='999' WHERE key='schema_version'")
|
||||
connection.commit()
|
||||
with self.assertRaisesRegex(RuntimeError, 'newer'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], '999')
|
||||
|
||||
def test_missing_migration_step_cannot_mark_database_as_upgraded(self):
|
||||
with patch('logistics_chat.db.SCHEMA_VERSION', db.SCHEMA_VERSION + 1):
|
||||
with self.assertRaisesRegex(RuntimeError, 'migration'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
|
||||
|
||||
def test_snapshot_backup_is_verified_and_restore_source_is_not_modified(self):
|
||||
destination = self.root/'backups'/'manual.db'
|
||||
db.snapshot_database(self.root/'history.db', destination)
|
||||
self.assertEqual(db.check_database(destination), 'ok')
|
||||
original = destination.read_bytes()
|
||||
connection = db.connect()
|
||||
connection.execute("INSERT INTO chat_history(title,ctime) VALUES('after-backup','now')")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
self.assertEqual(destination.read_bytes(), original)
|
||||
|
||||
def test_upload_uses_generated_name_and_configured_directory(self):
|
||||
upload = FileStorage(stream=io.BytesIO(b'hello'), filename='../report.txt')
|
||||
result = file_service.upload_file({'file': upload})
|
||||
@@ -123,6 +215,20 @@ class StorageTests(unittest.TestCase):
|
||||
self.assertNotIn('..', name)
|
||||
self.assertFalse(result['is_image'])
|
||||
|
||||
def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self):
|
||||
uploads=self.root/'uploads'; uploads.mkdir()
|
||||
for name in ('ai.png','human.pdf','unused.txt'):
|
||||
file=uploads/name;file.write_bytes(b'fixture')
|
||||
os.utime(file,(time.time()-20*86400,time.time()-20*86400))
|
||||
connection=db.connect()
|
||||
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')")
|
||||
connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')")
|
||||
connection.commit();connection.close()
|
||||
file_service.cleanup_uploads()
|
||||
self.assertTrue((uploads/'ai.png').exists())
|
||||
self.assertTrue((uploads/'human.pdf').exists())
|
||||
self.assertFalse((uploads/'unused.txt').exists())
|
||||
|
||||
def test_invalid_and_oversized_uploads_are_rejected(self):
|
||||
self.assertEqual(file_service.upload_file({})[1], 400)
|
||||
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Offline ticket workflow uses authenticated staff and isolated SQLite storage."""
|
||||
|
||||
from contextlib import closing
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class TicketTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.root = Path(directory.name)
|
||||
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
|
||||
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups',
|
||||
'LOG_DIR': self.root/'logs', 'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
||||
self.client = self.app.test_client()
|
||||
self.other = self.app.test_client()
|
||||
login_staff(self.app, self.client, username='alice')
|
||||
login_staff(self.app, self.other, username='bob')
|
||||
self.client.post('/api/visitor/session', json={'key':'a'*64})
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content':'请查询派送', 'phone':'123'}).status_code, 200)
|
||||
self.ticket = self.client.get('/api/tickets').json[0]['id']
|
||||
|
||||
def claim(self, client=None):
|
||||
return (client or self.client).post(f'/api/tickets/{self.ticket}/claim', json={})
|
||||
|
||||
def update(self, payload, client=None):
|
||||
return (client or self.client).patch(f'/api/tickets/{self.ticket}', json=payload)
|
||||
|
||||
def test_claim_notes_and_completion_remain_queryable(self):
|
||||
self.assertEqual(self.update({'version':0,'note':'不能跳过领取','status':'completed'}).status_code, 409)
|
||||
claimed = self.claim()
|
||||
self.assertEqual(claimed.status_code, 200)
|
||||
ticket = claimed.json['ticket']
|
||||
self.assertEqual((ticket['status'], ticket['owner_username']), ('processing', 'alice'))
|
||||
saved = self.update({'version':ticket['version'],'note':'已联系派送站'})
|
||||
self.assertEqual(saved.status_code, 200)
|
||||
done = self.update({'version':saved.json['ticket']['version'],'note':'已联系并处理完成','status':'completed'})
|
||||
self.assertEqual(done.status_code, 200)
|
||||
self.assertTrue(done.json['ticket']['completed_at'])
|
||||
self.assertEqual(self.client.get('/api/tickets').json, [])
|
||||
record = self.client.get('/api/tickets?status=completed').json[0]
|
||||
self.assertEqual(record['note'], '已联系并处理完成')
|
||||
self.assertEqual(record['owner_username'], 'alice')
|
||||
self.assertEqual(self.update({'version':record['version'],'note':'重新修改'}).status_code, 409)
|
||||
|
||||
def test_other_staff_cannot_take_over_or_change_owner(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
self.assertEqual(self.claim(self.other).status_code, 409)
|
||||
self.assertEqual(self.update({'version':ticket['version'],'note':'覆盖'}, self.other).status_code, 403)
|
||||
self.assertEqual(self.update({'version':ticket['version'],'note':'伪造','owner_username':'bob'}).status_code, 400)
|
||||
|
||||
def test_concurrent_claim_has_one_winner(self):
|
||||
gate = Barrier(2)
|
||||
def claim(client):
|
||||
gate.wait(timeout=5)
|
||||
return client.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code
|
||||
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||
results = list(workers.map(claim, (self.client, self.other)))
|
||||
self.assertEqual(sorted(results), [200, 409])
|
||||
events = self.client.get(f'/api/tickets/{self.ticket}/events').json
|
||||
self.assertEqual(sum(event['action'] == 'claimed' for event in events), 1)
|
||||
|
||||
def test_stale_update_and_invalid_input_cannot_overwrite_notes(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
version = ticket['version']
|
||||
self.assertEqual(self.update({'version':version,'note':'第一份备注'}).status_code, 200)
|
||||
self.assertEqual(self.update({'version':version,'note':'旧页面覆盖'}).status_code, 409)
|
||||
for payload in ([], {'note':'缺少版本'}, {'version':True,'note':'坏版本'},
|
||||
{'version':version+1,'note':'x'*2001}, {'version':version+1,'status':'pending'},
|
||||
{'version':version+1,'note':' ','status':'completed'}):
|
||||
with self.subTest(payload=str(payload)[:60]):
|
||||
self.assertEqual(self.update(payload).status_code, 400)
|
||||
self.assertEqual(self.client.get('/api/tickets?status=processing').json[0]['note'], '第一份备注')
|
||||
|
||||
def test_login_csrf_and_missing_ticket_are_enforced(self):
|
||||
guest = self.app.test_client()
|
||||
self.assertEqual(guest.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code, 401)
|
||||
self.assertEqual(guest.patch(f'/api/tickets/{self.ticket}', json={}).status_code, 401)
|
||||
self.assertEqual(self.client.post(f'/api/tickets/{self.ticket}/claim', json={}, headers={'X-CSRF-Token':''}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/api/tickets/9999/claim', json={}).status_code, 404)
|
||||
|
||||
def test_oversized_ids_and_cursors_return_client_errors(self):
|
||||
too_large = 2**63
|
||||
self.assertEqual(self.client.get(f'/api/tickets?before={too_large}').status_code, 400)
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{too_large}').status_code, 404)
|
||||
self.assertEqual(self.client.post(f'/api/tickets/{too_large}/claim', json={}).status_code, 404)
|
||||
self.assertEqual(self.client.patch(f'/api/tickets/{too_large}', json={'version':0,'note':'x'}).status_code, 404)
|
||||
|
||||
def test_filters_and_pagination_do_not_hide_old_tickets(self):
|
||||
for index in range(4):
|
||||
self.client.post('/api/ticket', json={'content':str(index)})
|
||||
first = self.client.get('/api/tickets?status=all&limit=2').json
|
||||
second = self.client.get(f'/api/tickets?status=all&limit=2&before={first[-1]["id"]}').json
|
||||
self.assertEqual(len({row['id'] for row in first + second}), 4)
|
||||
self.assertEqual(len(first), 2)
|
||||
for query in ('status=bad', 'limit=0', 'limit=101', 'before=bad'):
|
||||
self.assertEqual(self.client.get('/api/tickets?' + query).status_code, 400)
|
||||
|
||||
def test_version_one_schema_is_upgraded_without_changing_ticket_or_account(self):
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.executescript('DROP TABLE tickets; CREATE TABLE tickets('
|
||||
'id INTEGER PRIMARY KEY,content TEXT,phone TEXT,ctime TEXT,status TEXT);'
|
||||
"INSERT INTO tickets VALUES(42,'旧留言','123','yesterday','pending');"
|
||||
"UPDATE schema_meta SET value='1' WHERE key='schema_version';")
|
||||
db.init_db()
|
||||
row = connection.execute('SELECT content,status,owner_username,note,version FROM tickets WHERE id=42').fetchone()
|
||||
self.assertEqual(row, ('旧留言','pending',None,'',0))
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM agents').fetchone()[0], 2)
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
|
||||
|
||||
def test_search_supports_content_phone_owner_and_notes_with_status(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
self.update({'version':ticket['version'],'note':'已联系新加坡派送站'})
|
||||
for keyword in ('派送', '23', 'alice', 'Fixture Staff', '新加坡'):
|
||||
with self.subTest(keyword=keyword):
|
||||
result = self.client.get('/api/tickets', query_string={'status':'processing','q':keyword})
|
||||
self.assertEqual([row['id'] for row in result.json], [self.ticket])
|
||||
self.assertEqual(self.client.get('/api/tickets?q=派送').json, [])
|
||||
self.assertEqual(self.client.get('/api/tickets?status=all&q=找不到').json, [])
|
||||
self.assertEqual(self.client.get('/api/tickets', query_string={'q':'x'*101}).status_code, 400)
|
||||
|
||||
def test_search_treats_sql_wildcards_as_literal_and_keeps_pagination(self):
|
||||
for content in ('含有 100%_\\ 的留言', '普通记录', '相同查询甲', '相同查询乙', '相同查询丙'):
|
||||
self.client.post('/api/ticket', json={'content':content})
|
||||
for keyword in ('%', '_', '\\', '100%_\\'):
|
||||
result = self.client.get('/api/tickets', query_string={'q':keyword}).json
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertIn('100%', result[0]['content'])
|
||||
first = self.client.get('/api/tickets?q=相同&limit=2').json
|
||||
second = self.client.get('/api/tickets', query_string={'q':'相同','limit':2,'before':first[-1]['id']}).json
|
||||
self.assertEqual(len(first), 2)
|
||||
self.assertEqual(len(second), 1)
|
||||
self.assertEqual(len({row['id'] for row in first+second}), 3)
|
||||
|
||||
def test_summary_counts_all_pending_and_new_arrivals_independently_of_pages(self):
|
||||
response = self.client.get('/api/tickets/summary')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
baseline = response.json
|
||||
self.assertEqual({key:baseline[key] for key in ('pending_count','latest_id','new_count')},
|
||||
{'pending_count':1,'latest_id':self.ticket,'new_count':0})
|
||||
self.claim()
|
||||
self.client.post('/api/ticket', json={'content':'新留言'})
|
||||
changed = self.client.get('/api/tickets/summary', query_string={'after':baseline['latest_id']}).json
|
||||
self.assertEqual(changed['pending_count'], 1)
|
||||
self.assertEqual(changed['new_count'], 1)
|
||||
self.assertGreater(changed['latest_id'], baseline['latest_id'])
|
||||
self.assertEqual(self.client.get('/api/tickets/summary', query_string={'after':changed['latest_id']}).json['new_count'], 0)
|
||||
for cursor in ('bad', '-1', str(2**63)):
|
||||
self.assertEqual(self.client.get('/api/tickets/summary',query_string={'after':cursor}).status_code, 400)
|
||||
self.assertEqual(self.app.test_client().get('/api/tickets/summary').status_code, 401)
|
||||
|
||||
def test_history_records_actor_and_old_new_notes_only_for_successful_changes(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
saved = self.update({'version':ticket['version'],'note':'原备注'}).json['ticket']
|
||||
self.assertEqual(self.update({'version':ticket['version'],'note':'旧版本'}).status_code, 409)
|
||||
self.assertEqual(self.update({'version':saved['version'],'note':'越权'},self.other).status_code, 403)
|
||||
done = self.update({'version':saved['version'],'note':'完成结果','status':'completed'})
|
||||
self.assertEqual(done.status_code, 200)
|
||||
result = self.client.get(f'/api/tickets/{self.ticket}/events')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
events = list(reversed(result.json))
|
||||
self.assertEqual([event['action'] for event in events], ['submitted','claimed','note_updated','note_updated','completed'])
|
||||
self.assertEqual(events[1]['actor_username'], 'alice')
|
||||
self.assertEqual((events[3]['old_note'],events[3]['new_note']), ('原备注','完成结果'))
|
||||
self.assertTrue(all(event['ctime'] for event in events))
|
||||
self.assertEqual(self.app.test_client().get(f'/api/tickets/{self.ticket}/events').status_code, 401)
|
||||
self.assertEqual(self.client.get('/api/tickets/9999/events').status_code, 404)
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?limit=0').status_code, 400)
|
||||
page = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2').json
|
||||
older = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2&before={page[-1]["id"]}').json
|
||||
self.assertEqual(len({event['id'] for event in page+older}),4)
|
||||
|
||||
def test_audit_failure_rolls_back_ticket_mutation(self):
|
||||
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
|
||||
with self.assertRaisesRegex(RuntimeError,'audit failure'):
|
||||
self.claim()
|
||||
self.assertEqual(self.client.get('/api/tickets').json[0]['status'],'pending')
|
||||
|
||||
def test_audit_failure_rolls_back_submission_and_note_completion(self):
|
||||
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.client.post('/api/ticket',json={'content':'不应留下未审计的记录'})
|
||||
self.assertEqual(len(self.client.get('/api/tickets').json),1)
|
||||
ticket = self.claim().json['ticket']
|
||||
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.update({'version':ticket['version'],'note':'结果','status':'completed'})
|
||||
current = self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
|
||||
self.assertEqual((current['status'],current['note'],current['version']),('processing','',ticket['version']))
|
||||
|
||||
def test_overdue_filters_and_unusable_legacy_times_are_safe(self):
|
||||
for bad_time in ('not a date','2026-02-30 00:00:00'):
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute('UPDATE tickets SET ctime=? WHERE id=?',(bad_time,self.ticket))
|
||||
connection.commit()
|
||||
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
|
||||
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE tickets SET ctime='not a date' WHERE id=?",(self.ticket,))
|
||||
connection.commit()
|
||||
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=bad').status_code,400)
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{2**63}/events').status_code,404)
|
||||
for query in ('limit=101','before=bad',f'before={2**63}'):
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?'+query).status_code,400)
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE tickets SET ctime='2026-01-01 00:00:00'")
|
||||
connection.commit()
|
||||
self.assertEqual(len(self.client.get('/api/tickets?overdue=1&q=派送').json),1)
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=1&q=找不到').json,[])
|
||||
self.assertEqual(self.client.get('/api/tickets?status=completed&overdue=1').json,[])
|
||||
|
||||
def test_overdue_boundaries_and_note_edits_do_not_reset_claim_clock(self):
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE tickets SET ctime='2026-11-02 09:00:00' WHERE id=?",(self.ticket,))
|
||||
connection.commit()
|
||||
with patch('logistics_chat.ticket_service.datetime') as clock:
|
||||
clock.now.return_value=datetime(2026,11,2,9,30)
|
||||
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
|
||||
clock.now.return_value=datetime(2026,11,2,9,31)
|
||||
row=self.client.get('/api/tickets').json[0]
|
||||
self.assertEqual((row['overdue_kind'],row['overdue_minutes']),('pending',1))
|
||||
claimed=self.claim().json['ticket']
|
||||
self.assertEqual(claimed['claimed_at'],'2026-11-02 09:31:00')
|
||||
clock.now.return_value=datetime(2026,11,2,9,51)
|
||||
self.assertFalse(self.client.get('/api/tickets?status=processing').json[0]['overdue_kind'])
|
||||
clock.now.return_value=datetime(2026,11,2,9,52)
|
||||
saved=self.update({'version':claimed['version'],'note':'仍在跟进'}).json['ticket']
|
||||
self.assertEqual(saved['overdue_kind'],'processing')
|
||||
summary=self.client.get('/api/tickets/summary').json
|
||||
self.assertEqual((summary['overdue_count'],summary['processing_overdue_count']), (1,1))
|
||||
self.assertEqual(len(self.client.get('/api/tickets?status=all&overdue=1').json),1)
|
||||
done=self.update({'version':saved['version'],'status':'completed'}).json['ticket']
|
||||
self.assertFalse(done['overdue_kind'])
|
||||
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
|
||||
|
||||
def test_v2_upgrade_marks_legacy_baseline_without_inventing_history(self):
|
||||
ticket=self.claim().json['ticket']
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute('DROP TABLE ticket_events')
|
||||
connection.execute('DROP INDEX IF EXISTS idx_ticket_claimed_time')
|
||||
connection.execute('DROP INDEX IF EXISTS idx_ticket_pending_time')
|
||||
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at')
|
||||
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at_estimated')
|
||||
connection.execute("UPDATE tickets SET updated_at='2026-11-02 08:00:00',note='旧备注'")
|
||||
connection.execute("UPDATE schema_meta SET value='2' WHERE key='schema_version'")
|
||||
connection.commit()
|
||||
db.init_db()
|
||||
db.init_db()
|
||||
events=self.client.get(f'/api/tickets/{self.ticket}/events').json
|
||||
self.assertEqual([event['action'] for event in events],['legacy_snapshot'])
|
||||
self.assertIsNone(events[0]['actor_username'])
|
||||
row=self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
|
||||
self.assertEqual((row['owner_username'],row['note']),('alice','旧备注'))
|
||||
self.assertEqual((row['claimed_at'],row['claimed_at_estimated']),('2026-11-02 08:00:00',1))
|
||||
+18
-3
@@ -8,6 +8,7 @@ from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class VisitorTests(unittest.TestCase):
|
||||
@@ -18,6 +19,8 @@ class VisitorTests(unittest.TestCase):
|
||||
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':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.a, self.b = self.app.test_client(), self.app.test_client()
|
||||
self.staff = self.app.test_client()
|
||||
login_staff(self.app, self.staff)
|
||||
for client, key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
|
||||
|
||||
@@ -96,7 +99,7 @@ class VisitorTests(unittest.TestCase):
|
||||
for action in ('send','close'):
|
||||
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
|
||||
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
|
||||
self.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
|
||||
self.assertEqual(self.staff.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'}).status_code,200)
|
||||
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
|
||||
self.assertNotIn('note',response.json)
|
||||
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
|
||||
@@ -115,16 +118,28 @@ class VisitorTests(unittest.TestCase):
|
||||
root=Path(self.app.config['UPLOAD_DIR'])
|
||||
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
|
||||
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
|
||||
with self.a.get('/api/staff'+url) as staff:
|
||||
with self.staff.get('/api/staff'+url) as staff:
|
||||
self.assertEqual(staff.data,b'A_PRIVATE')
|
||||
|
||||
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status',
|
||||
'按入仓码查轨迹','入仓码查询','入仓号查轨迹','Track by inbound code'):
|
||||
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
|
||||
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
|
||||
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
|
||||
lookup.assert_not_called();model.assert_not_called()
|
||||
|
||||
def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self):
|
||||
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')})
|
||||
url=response.json['url']
|
||||
(Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink()
|
||||
own=self.a.get(url);other=self.b.get(url)
|
||||
self.assertEqual(own.status_code,404)
|
||||
self.assertIn('附件暂时无法访问',own.get_data(as_text=True))
|
||||
self.assertIn('no-store',own.headers['Cache-Control'])
|
||||
self.assertEqual(own.data,other.data)
|
||||
self.assertNotIn(url,own.get_data(as_text=True))
|
||||
|
||||
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
|
||||
cid=self.conversation(self.a,'A_PRIVATE')
|
||||
for key in ('short','x'*64,'1'*65,[],{},123):
|
||||
|
||||
Reference in New Issue
Block a user