From 92684bffb2c6c9c36a98660fe09e6f15650b09d2 Mon Sep 17 00:00:00 2001 From: songxinnuo Date: Tue, 3 Nov 2026 15:40:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=88=91=E7=9A=84=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=8A=9F=E8=83=BD=E5=8F=8A=E9=A1=B5=E9=9D=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + deploy/gray_config.py | 2 +- deploy/nginx.conf.example | 2 +- docs/architecture.md | 2 + logistics_chat/customer_order_service.py | 46 ++++ logistics_chat/db.py | 10 +- logistics_chat/order_admin_service.py | 158 ++++++++++++ logistics_chat/order_import_service.py | 297 +++++++++++++++++++++++ logistics_chat/routes/admin.py | 137 ++++++++++- logistics_chat/routes/customer.py | 42 +++- logistics_chat/staff_service.py | 3 +- logistics_chat/visitor_service.py | 5 +- static/css/admin.css | 3 +- static/css/admin_orders.css | 63 +++++ static/css/chat.css | 59 ++++- static/css/theme.css | 25 ++ static/js/admin_orders.js | 163 +++++++++++++ static/js/admin_tools.js | 4 +- static/js/attachments.js | 168 +++++++++++++ static/js/chat.js | 122 +++++++++- static/js/customer.js | 1 + static/js/customer_orders.js | 132 ++++++++++ static/js/support.js | 109 +++++++++ templates/admin.html | 1 + templates/admin_orders.html | 55 +++++ templates/index.html | 28 ++- tests/frontend/admin.test.cjs | 14 +- tests/frontend/admin_orders.test.cjs | 86 +++++++ tests/frontend/chat.test.cjs | 225 ++++++++++++++++- tests/frontend/customer_orders.test.cjs | 96 ++++++++ tests/frontend/support.test.cjs | 122 ++++++++++ tests/test_customer_orders.py | 115 +++++++++ tests/test_frontend.py | 2 +- tests/test_gray_config.py | 2 + tests/test_order_import_admin.py | 248 +++++++++++++++++++ tests/test_staff_sessions.py | 9 +- 36 files changed, 2513 insertions(+), 45 deletions(-) create mode 100644 logistics_chat/customer_order_service.py create mode 100644 logistics_chat/order_admin_service.py create mode 100644 logistics_chat/order_import_service.py create mode 100644 static/css/admin_orders.css create mode 100644 static/js/admin_orders.js create mode 100644 static/js/attachments.js create mode 100644 static/js/customer_orders.js create mode 100644 templates/admin_orders.html create mode 100644 tests/frontend/admin_orders.test.cjs create mode 100644 tests/frontend/customer_orders.test.cjs create mode 100644 tests/frontend/support.test.cjs create mode 100644 tests/test_customer_orders.py create mode 100644 tests/test_order_import_admin.py diff --git a/AGENTS.md b/AGENTS.md index 486eb35..39296fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` | | Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support | | Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout | +| My orders | `logistics_chat/customer_order_service.py`: scoped 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` | @@ -21,6 +22,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | 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/` | diff --git a/deploy/gray_config.py b/deploy/gray_config.py index 9f461fe..64f4bdf 100644 --- a/deploy/gray_config.py +++ b/deploy/gray_config.py @@ -119,7 +119,7 @@ server {{ auth_basic off; proxy_pass http://127.0.0.1:{self.port}; }} - location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|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}; }} diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index 22607ee..ffe1d51 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -47,7 +47,7 @@ server { location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; } location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; } location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; } - location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|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; } diff --git a/docs/architecture.md b/docs/architecture.md index e7f5d17..4254936 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,6 +92,7 @@ tests/ # 按业务模块组织的回归 - 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。 - 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。 - 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。 +- 登录后由 `customer_orders.js` 自动展开只读“我的订单”面板,不切换聊天或改写草稿。`GET /api/customer/orders` 按 ID 倒序每页 20 条,以 `before` 游标继续;`GET /api/customer/orders/` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。退出/401 清空面板,异步结果受请求版本约束;Nginx 与 Flask 的公开路由白名单仅开放这两个客户鉴权入口,旧 `/api/my_orders` 继续受后台保护。 - 客户附件预览由 `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 页面,破图在聊天中显示可读提示。 @@ -129,6 +130,7 @@ tests/ # 按业务模块组织的回归 - 计费档位、单位换算和舍入保持原样。`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 与附件禁止共享缓存。 diff --git a/logistics_chat/customer_order_service.py b/logistics_chat/customer_order_service.py new file mode 100644 index 0000000..6e6eced --- /dev/null +++ b/logistics_chat/customer_order_service.py @@ -0,0 +1,46 @@ +"""Read-only customer order browsing; scope comes exclusively from verified sessions.""" + +import sqlite3 + +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') + + +def scoped_rows(columns, suffix='', values=()): + tenants = effective_tenants(customer_tenant_ids()) + if not tenants: + return [] + connection = None + try: + connection = connect() + connection.row_factory = sqlite3.Row + 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))] + except sqlite3.Error: + raise OrderDataUnavailable from None + finally: + if connection is not None: + connection.close() + + +def list_orders(before=None): + rows = scoped_rows(','.join(SUMMARY_FIELDS), + ('AND id PAGE_SIZE else None} + + +def order_detail(order_id): + if not 0 < order_id <= 9223372036854775807: + return None + rows = scoped_rows('*', 'AND id=? LIMIT 1', (order_id,)) + return build_card(rows[0]) if rows else None diff --git a/logistics_chat/db.py b/logistics_chat/db.py index d45fb7f..4b24d09 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -8,7 +8,7 @@ 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 = 3 +SCHEMA_VERSION = 4 def connect(): @@ -205,7 +205,13 @@ def _upgrade_ticket_history(conn): init_ticket_history(conn) -MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow), (3, _upgrade_ticket_history)) +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(): diff --git a/logistics_chat/order_admin_service.py b/logistics_chat/order_admin_service.py new file mode 100644 index 0000000..61ac311 --- /dev/null +++ b/logistics_chat/order_admin_service.py @@ -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 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} diff --git a/logistics_chat/order_import_service.py b/logistics_chat/order_import_service.py new file mode 100644 index 0000000..aec60c5 --- /dev/null +++ b/logistics_chat/order_import_service.py @@ -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} diff --git a/logistics_chat/routes/admin.py b/logistics_chat/routes/admin.py index 603f5ae..2ba4bea 100644 --- a/logistics_chat/routes/admin.py +++ b/logistics_chat/routes/admin.py @@ -1,8 +1,11 @@ """HTTP adapters for admin; business logic lives in services.""" -from flask import Blueprint, Response, g, render_template, request +import sqlite3 -from .. import business_service, staff_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__) @@ -53,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//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}) diff --git a/logistics_chat/routes/customer.py b/logistics_chat/routes/customer.py index 1bd8546..1e218f4 100644 --- a/logistics_chat/routes/customer.py +++ b/logistics_chat/routes/customer.py @@ -1,8 +1,9 @@ """Public, visitor-bound customer login endpoints. No staff privileges.""" -from flask import Blueprint, current_app, request +from flask import Blueprint, current_app, jsonify, request from werkzeug.exceptions import RequestEntityTooLarge -from .. import customer_service +from .. import customer_service, customer_order_service +from ..order_data import OrderDataUnavailable bp = Blueprint('customer', __name__) @@ -25,3 +26,40 @@ def login(): @bp.post('/api/customer/logout') def logout(): return customer_service.logout() + + +@bp.after_request +def private_response(response): + response.headers['Cache-Control'] = 'no-store' + return response + + +def order_request(action): + if not customer_service.current_customer_id(): + return {'ok': False, 'error': 'customer_login_required'}, 401 + try: + return action() + except OrderDataUnavailable: + return {'ok': False, 'error': '订单暂时无法加载,请稍后重试。'}, 503 + + +@bp.get('/api/customer/orders') +def orders(): + def browse(): + 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) + return order_request(browse) + + +@bp.get('/api/customer/orders/') +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) diff --git a/logistics_chat/staff_service.py b/logistics_chat/staff_service.py index 0d8ffe2..fbacff5 100644 --- a/logistics_chat/staff_service.py +++ b/logistics_chat/staff_service.py @@ -18,7 +18,8 @@ PUBLIC_ENDPOINTS = frozenset({ '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', 'speech.speech_config', 'speech.speech_transcribe', + 'customer.login', 'customer.logout', 'customer.orders', 'customer.order', + 'speech.speech_config', 'speech.speech_transcribe', }) diff --git a/logistics_chat/visitor_service.py b/logistics_chat/visitor_service.py index 666eeb1..b89f8b4 100644 --- a/logistics_chat/visitor_service.py +++ b/logistics_chat/visitor_service.py @@ -15,7 +15,7 @@ CUSTOMER_PATHS = { '/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history', '/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback', '/api/speech/config', '/api/speech/transcribe', - '/api/customer/session', '/api/customer/login', '/api/customer/logout', + '/api/customer/session', '/api/customer/login', '/api/customer/logout', '/api/customer/orders', } @@ -53,7 +53,8 @@ def customer_tenant_ids(): def attach_visitor(): key = request.cookies.get(COOKIE_NAME) g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None - is_customer = request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/') + 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'): diff --git a/static/css/admin.css b/static/css/admin.css index 9f8dba9..e2f7780 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -258,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; } } diff --git a/static/css/admin_orders.css b/static/css/admin_orders.css new file mode 100644 index 0000000..71ba44b --- /dev/null +++ b/static/css/admin_orders.css @@ -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; } } diff --git a/static/css/chat.css b/static/css/chat.css index 7879479..057842a 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -44,6 +44,37 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .clear-all-btn:hover { color: var(--danger); } .customer-account { display: block; width: 100%; min-height: 44px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .mobile-account { display: none; } +.customer-orders { flex: 0 1 auto; min-height: 0; max-height: 48%; display: flex; flex-direction: column; border-bottom: 1px solid var(--border); background: var(--surface); } +.customer-orders-heading { display: flex; align-items: center; gap: 12px; padding: 4px 26px; flex-shrink: 0; background: var(--surface-soft); } +.customer-orders button { min-height: 44px; color: var(--brand-text); font-size: 14px; background: transparent; border: 1px solid transparent; border-radius: 8px; padding: 8px 12px; } +.customer-orders button:hover { background: var(--brand-soft); } +.customer-orders button:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; } +#customer-orders-toggle { flex: 1; text-align: left; font-weight: 600; padding-left: 0; } +#customer-orders-toggle::after { content: '⌄'; margin-left: 10px; display: inline-block; } +#customer-orders-toggle[aria-expanded="true"]::after { transform: rotate(180deg); } +.customer-orders-body { min-height: 0; overflow: auto; padding: 8px 26px 12px; overscroll-behavior: contain; } +.customer-orders-list { display: flex; flex-direction: column; } +.customer-orders .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); } @@ -65,6 +96,11 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .toggle-divider::before { content: "☰"; font-size: 16px; } .toggle-divider:hover { background: var(--surface-soft); color: var(--brand-text); } .title-actions { display: flex; gap: 7px; align-items: center; flex-shrink: 0; } +.settings-control { position: relative; } +.settings-toggle { display: none; } +.settings-panel { display: flex; align-items: center; gap: 7px; } +.settings-row { display: contents; } +.settings-row > span { display: none; } .theme-toggle { height: 44px; min-width: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 14px; } .theme-toggle: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; } @@ -195,6 +231,20 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; } .composer-status.is-error { color: var(--danger); } .message-image { display: block; max-width: min(240px, 100%); max-height: 320px; object-fit: contain; border-radius: 8px; } +.message-image, .image-preview-trigger { cursor: zoom-in; } +.image-preview-trigger { flex-shrink: 0; padding: 0; border: 0; border-radius: 7px; background: transparent; } +.attachment-drop-hint { position: absolute; inset: 12px; z-index: 40; display: grid; place-items: center; padding: 24px; border: 2px dashed var(--brand); border-radius: 14px; background: var(--brand-soft); color: var(--brand-text); font-size: 20px; font-weight: 600; text-align: center; pointer-events: none; } +.image-viewer { width: min(1200px, calc(100% - 32px)); height: calc(100dvh - 32px); max-width: none; max-height: none; margin: auto; padding: 16px; overflow: hidden; border: 1px solid var(--border); border-radius: 14px; color: var(--text); background: var(--surface); } +.image-viewer[open] { display: flex; flex-direction: column; gap: 12px; } +.image-viewer::backdrop { background: rgba(10, 20, 35, .78); } +.image-viewer-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; } +.image-viewer-header h2 { font-size: 18px; } +.image-viewer-controls { display: flex; flex-wrap: wrap; gap: 8px; } +.image-viewer-controls button { min-width: 64px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); } +.image-viewer-controls button:hover { border-color: var(--brand); color: var(--brand-text); } +.image-viewer-stage { display: flex; flex: 1; align-items: flex-start; min-height: 0; overflow: auto; padding: 16px; background: var(--page); border-radius: 8px; } +.image-viewer-stage img { display: block; flex-shrink: 0; margin: auto; object-fit: contain; } +.image-viewer-status { margin: auto; color: var(--secondary); font-size: 16px; } .message-file { display: block; max-width: 100%; overflow-wrap: anywhere; color: inherit; padding: 6px 0; font-size: 13px; text-underline-offset: 3px; } @media (min-width: 901px) and (max-height: 780px) { #chatDom { padding-top: 22px; } @@ -236,7 +286,7 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .ai-welcome-card { padding-top: 20px; } .composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); } .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; } @@ -258,10 +308,15 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .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) { .chat-title { padding: 12px; gap: 8px; } - #font-toggle { display: none; } #chatDom { padding: 20px 16px; } .ai-avatar { display: none; } .ai-welcome-card { padding-top: 12px; } diff --git a/static/css/theme.css b/static/css/theme.css index 56acd66..d9fd248 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -84,9 +84,34 @@ svg { display: block; flex-shrink: 0; } .attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; } +.app-modal-overlay { + position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; + overflow-y: auto; padding: 20px; background: rgba(18, 33, 56, .42); +} +.app-modal { + width: min(420px, 100%); max-height: min(80dvh, 680px); overflow-y: auto; + padding: 22px; border: 1px solid var(--border); border-radius: 14px; + background: var(--surface); color: var(--text); box-shadow: var(--shadow); +} +.app-modal-title { margin: 0; font-size: 18px; line-height: 1.4; } +.app-modal-message { margin-top: 9px; color: var(--secondary); font-size: 14px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; } +.app-modal-input { display: block; width: 100%; min-height: 44px; margin-top: 14px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; font-size: 16px; line-height: 1.6; } +textarea.app-modal-input { min-height: 112px; resize: vertical; } +.app-modal-input:focus { outline: 3px solid var(--brand-soft); border-color: var(--brand); } +.app-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; } +.app-modal-actions button { min-width: 80px; min-height: 44px; padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); cursor: pointer; } +.app-modal-actions .app-modal-confirm { border-color: var(--brand); background: var(--brand); color: #fff; } +.app-modal-actions button:hover { background: var(--surface-hover); color: var(--text); } +.app-modal-actions .app-modal-confirm:hover { background: var(--brand-hover); color: #fff; } +.app-modal-actions button:focus-visible { outline: 3px solid var(--brand-soft); outline-offset: 2px; } ::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; } ::-webkit-scrollbar-track { background: transparent; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } +@media (max-width: 520px) { + .app-modal-overlay { align-items: end; padding: 16px; } + .app-modal { width: 100%; max-height: min(85dvh, 680px); padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); border-radius: 14px 14px 10px 10px; } + .app-modal-actions button { flex: 1; } +} diff --git a/static/js/admin_orders.js b/static/js/admin_orders.js new file mode 100644 index 0000000..f97d82c --- /dev/null +++ b/static/js/admin_orders.js @@ -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 '
'+escapeText(item[0])+''+escapeText(item[1] || 0)+'
'; + }).join(''); + document.getElementById('preview-rows').innerHTML = (data.preview || []).map(function(row){ + return ''+escapeText(row.row)+''+escapeText(row.tenant_id)+''+escapeText(row.order_no)+''+escapeText(row.status)+''+escapeText(row.inbound_code)+''+(row.duplicate ? '已存在,保留原数据' : '可新增')+''; + }).join('') || '没有可预览的有效数据'; + document.getElementById('preview-limit-note').hidden = !data.preview_limited; + const errors = document.getElementById('import-errors'); + errors.innerHTML = (data.errors || []).map(function(item){ + return '

第 '+escapeText(item.row)+' 行:'+escapeText(item.message)+'

'; + }).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 ''+escapeText(row.tenant_id)+''+escapeText(row.order_no)+''+escapeText(row.status)+''+escapeText(row.inbound_code)+''+escapeText(row.shipment_id)+''+escapeText(row.country_code)+''+escapeText(row.source_updated_at)+''; + }).join(''); + if(!append) body.innerHTML = ''; + body.insertAdjacentHTML('beforeend', rows || (!append ? '没有找到订单' : '')); + 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 ? '' : '已撤销(删 '+escapeText(batch.deleted_rows)+',留 '+escapeText(batch.preserved_on_undo)+')'; + return ''+escapeText(batch.imported_at)+''+escapeText(batch.filename)+''+escapeText(batch.imported_by)+''+escapeText(batch.imported_rows)+''+escapeText(batch.preserved_rows)+''+escapeText(active ? '可撤销' : '已撤销')+''+action+''; + }).join('') || '暂无导入记录'; + 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 = '导入已完成,但记录刷新失败,请刷新页面重试。'; + } + 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'); } + }); +})(); diff --git a/static/js/admin_tools.js b/static/js/admin_tools.js index 26f1642..4a27232 100644 --- a/static/js/admin_tools.js +++ b/static/js/admin_tools.js @@ -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(); diff --git a/static/js/attachments.js b/static/js/attachments.js new file mode 100644 index 0000000..1d8d4e0 --- /dev/null +++ b/static/js/attachments.js @@ -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}; + } +}; diff --git a/static/js/chat.js b/static/js/chat.js index 695d396..de69d1d 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -41,6 +41,7 @@ let historyRequestVersion = 0; let followLatest = true; let voiceInput = null; let customerLogin = null; +let customerOrders = null; let creatingConv = null; let sendPreparing = false; let conversationVersion = 0; @@ -54,6 +55,7 @@ let humanClosing = false; window.humanSid = 0; const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); }); SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable')); +const imageViewer = ChatAttachments.createViewer({container:document.querySelector('.main-chat'), text:t}); document.getElementById('notifications-toggle').onclick = async function(){ await SupportUI.enableNotifications(this); refreshNotificationLabel(); @@ -81,6 +83,7 @@ async function requestJson(path, options){ } function clearAttachment(){ + imageViewer.close(); uploadVersion += 1; if(uploadController){ uploadController.abort(); @@ -103,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); @@ -116,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")); @@ -135,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"; @@ -174,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'); @@ -237,6 +253,14 @@ const I18N = { customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询", customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码", customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。", + myOrders: "我的订单", ordersRefresh: "刷新", ordersBack: "← 返回订单列表", + ordersMore: "加载更多订单", ordersRetry: "重试", ordersTrack: "查看轨迹", + ordersLoading: "正在加载订单…", ordersEmpty: "当前账号暂无可查看的订单。", + ordersFailed: "订单加载失败,请检查网络后重试。", + 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: "清空全部历史", @@ -245,8 +269,15 @@ const I18N = { 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: "没有匹配的历史对话", @@ -257,6 +288,7 @@ const I18N = { autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。", humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。", offlinePrompt: "请输入您的留言:", + offlineInputLabel: "留言内容", offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。", humanClosed: "人工客服已结束,您可以继续向 AI 提问。", humanReply: "客服回复", @@ -354,6 +386,14 @@ const I18N = { 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. Check your connection and retry.", + 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", @@ -362,8 +402,15 @@ const I18N = { 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", @@ -374,6 +421,7 @@ const I18N = { autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.", humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.", offlinePrompt: "Please leave your message:", + offlineInputLabel: "Message", offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).", humanClosed: "This support session has ended. You can continue chatting with the AI assistant.", humanReply: "Support reply", @@ -488,6 +536,7 @@ 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]; @@ -507,6 +556,9 @@ function applyLanguage(lang){ 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); @@ -682,9 +734,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(); @@ -806,9 +858,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=>{ @@ -894,6 +946,8 @@ function ensureConv(){ } function resetConversation(id){ + imageViewer.close(); + attachmentGestures.clear(); flushDraft(); emojiPicker.close(); if(voiceInput) voiceInput.cancel(); @@ -1155,8 +1209,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(); @@ -1280,7 +1334,7 @@ async function sendHumanMessage(content){ async function leaveOfflineMessage(){ const version = conversationVersion; addMsg(t('humanHours'), false); - const message = (window.prompt(t('offlinePrompt')) || '').trim(); + 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:''}); @@ -1388,6 +1442,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 = "附件图片"; @@ -1920,6 +1976,37 @@ 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); @@ -1949,6 +2036,7 @@ function syncChatViewport(){ } 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(); @@ -1961,6 +2049,7 @@ function scheduleChatViewport(){ } toggleBtn.onclick = function(){ + setSettingsOpen(false); sideOpen = !sideOpen; renderSidebar(); } @@ -1980,6 +2069,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; } @@ -2024,7 +2116,11 @@ 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){ customerOrders.setUser(user); }, onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); }, onChange:function(user,previous){ if(previous === undefined) return; diff --git a/static/js/customer.js b/static/js/customer.js index 0084dab..1230afd 100644 --- a/static/js/customer.js +++ b/static/js/customer.js @@ -33,6 +33,7 @@ var CustomerLogin = { if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){ options.onChange(user,previous); } + if(options.onSession) options.onSession(user); } async function request(path, data){ const controller=new AbortController(); diff --git a/static/js/customer_orders.js b/static/js/customer_orders.js new file mode 100644 index 0000000..c629230 --- /dev/null +++ b/static/js/customer_orders.js @@ -0,0 +1,132 @@ +// 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'); + 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')+(rows.length ? ' · '+rows.length : ''); + 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; + more.hidden=selected !== null || !cursor; + more.disabled=loading; + more.textContent=options.text('ordersMore'); + retry.hidden=!errorKey; + retry.textContent=options.text('ordersRetry'); + message.textContent=errorKey ? options.text(errorKey) : loading ? options.text('ordersLoading') + : !rows.length ? options.text('ordersEmpty') : ''; + 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(); + const timer=setTimeout(()=>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; + } + if(!response.ok) throw new Error('orders unavailable'); + const result=await response.json(); + if(token !== version) return; + apply(result); + }catch(error){ + if(token === version) errorKey='ordersFailed'; + }finally{ + clearTimeout(timer); + if(token === version){ controller=null;loading=false;render(); } + } + } + function loadList(append){ + if(!user || (append && (loading || !cursor))) return; + if(!append){ selected=null;card=null;rows=[];cursor=null;body.scrollTop=0;renderCard();renderRows(); } + const suffix=append ? '?before='+cursor : ''; + return request('/api/customer/orders'+suffix,function(result){ + if(!Array.isArray(result.orders)) throw new Error('invalid order list'); + rows=append ? rows.concat(result.orders) : result.orders; + cursor=result.next_before || null; + renderRows(); + },()=>loadList(append)); + } + 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; + 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,render:function(){renderRows();renderCard();render();}}; + } +}; diff --git a/static/js/support.js b/static/js/support.js index a3a97d7..b875a5e 100644 --- a/static/js/support.js +++ b/static/js/support.js @@ -1,5 +1,114 @@ // 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; diff --git a/templates/admin.html b/templates/admin.html index 13db223..1119a5e 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -21,6 +21,7 @@ + 订单数据管理 ↗ 客户入口 ↗
数据概览 diff --git a/templates/admin_orders.html b/templates/admin_orders.html new file mode 100644 index 0000000..3407502 --- /dev/null +++ b/templates/admin_orders.html @@ -0,0 +1,55 @@ + + + + + + + 订单数据管理 · 柏悦物流 + + + + + +
+ 柏悦物流 + +
+
+

STAFF CONSOLE

订单数据管理

导入订单后,客户登录并关联对应租户即可查询订单信息。

下载CSV模板 ↓
+
+
01

上传订单文件

支持 CSV 和 XLSX,单次最多 5,000 条、文件不超过 5 MB。

+
+ +

必填列:租户ID、订单号。重复订单会保留已有记录,不会覆盖人工修改。

+
+
+
+ +
+
03

订单列表

按订单号、入仓码、Shipment ID 或租户筛选。

+
+ + + + +
+

+
租户ID订单号业务状态入仓码Shipment ID目的国来源更新时间
正在加载订单…
+
+
+
+
04

导入记录

记录操作人员和导入数量;撤销只删除仍未被修改的本批新增订单。

+
时间文件操作人新增重复保留状态操作
正在加载导入记录…
+
+
+ + + + diff --git a/templates/index.html b/templates/index.html index cfbaffb..5b524bb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -27,18 +27,38 @@
+
BAIYUE · CUSTOMER SUPPORT
柏悦物流智能助手
- 智能客服 - - +
+ + +
+
@@ -86,8 +106,10 @@ + + diff --git a/tests/frontend/admin.test.cjs b/tests/frontend/admin.test.cjs index 6b4639d..8190640 100644 --- a/tests/frontend/admin.test.cjs +++ b/tests/frontend/admin.test.cjs @@ -62,7 +62,7 @@ async function workspace(compact = false, initialHooks = {}){ }, addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; }, }; - const calls = []; + const calls = [], dialogs = []; const hooks = initialHooks; const timers = new Map(), intervals = new Map(); let timerId = 0; @@ -95,13 +95,14 @@ async function workspace(compact = false, initialHooks = {}){ }, }); 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, timers, + 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(); } @@ -613,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; diff --git a/tests/frontend/admin_orders.test.cjs b/tests/frontend/admin_orders.test.cjs new file mode 100644 index 0000000..d5b82bd --- /dev/null +++ b/tests/frontend/admin_orders.test.cjs @@ -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); +}); diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 1b50567..08a8855 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -5,6 +5,49 @@ const path = require('node:path'); const vm = require('node:vm'); const flush = () => new Promise(setImmediate); +test('mobile settings opens, dismisses with Escape and restores focus', async () => { + const app = await customerPage({human:false, width:390}); + const toggle = app.element('settings-toggle'), panel = app.element('settings-panel'); + assert.equal(panel.hidden, true); + await app.click(toggle); + assert.equal(panel.hidden, false); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + assert.equal(app.context.document.activeElement, app.element('notifications-toggle')); + app.documentEvent('keydown', {key:'Escape', preventDefault(){}}); + assert.equal(panel.hidden, true); + assert.equal(app.context.document.activeElement, toggle); + await app.click(toggle); + app.documentEvent('pointerdown', {target:app.element('chatDom')}); + assert.equal(panel.hidden, true); +}); + +test('settings closes for typing and desktop settings remain available after resizing', async () => { + const app = await customerPage({human:false, width:390}); + const panel = app.element('settings-panel'); + await app.click(app.element('settings-toggle')); + app.element('input-text').focus(); + assert.equal(panel.hidden, true); + assert.ok(app.element('.chat-container').classList.contains('is-composing')); + app.context.window.innerWidth = 1200; + await app.resize(800); + assert.equal(panel.hidden, false); + assert.equal(app.element('.chat-container').classList.contains('is-composing'), false); + app.context.window.innerWidth = 390; + await app.resize(800); + assert.equal(panel.hidden, true); +}); + +test('mobile settings keeps text size working and translates its accessible name', async () => { + const app = await customerPage({human:false, width:390}); + await app.click(app.element('settings-toggle')); + const font = app.element('font-toggle'), before = font.textContent; + await app.click(font); + assert.notEqual(font.textContent, before); + await app.click(app.element('lang-toggle')); + assert.equal(app.element('settings-toggle').getAttribute('aria-label'), 'More settings'); + assert.equal(app.element('settings-panel').getAttribute('aria-label'), 'More settings'); +}); + async function customerPage(pageOptions = {}){ const storageValues = pageOptions.storage || {}; if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64); @@ -14,11 +57,12 @@ async function customerPage(pageOptions = {}){ removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null, get length(){return Object.keys(values).length;} };} - const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {}; + 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.isConnected = true; @@ -40,6 +84,7 @@ 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(); } } @@ -69,7 +114,7 @@ async function customerPage(pageOptions = {}){ body: new Element(), documentElement: {}, getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id), querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [], - createElement: () => new Element(), + 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; }, }; @@ -77,7 +122,7 @@ async function customerPage(pageOptions = {}){ let state = {status:'active', messages:[]}; const context = vm.createContext({ document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true, - FormData:class { append(){} }, + FormData:class { constructor(){ this.values = {}; } append(key,value){ this.values[key] = value; } }, requestAnimationFrame:callback => callback(), localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues), window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800, @@ -106,6 +151,8 @@ async function customerPage(pageOptions = {}){ } const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'} : url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)} + : url === '/api/customer/session' && pageOptions.customer ? {ok:true,authenticated:true,user:pageOptions.customer} + : url === '/api/customer/orders' ? {ok:true,orders:pageOptions.orders || [],next_before:null} : url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7}) : url.startsWith('/api/visitor/human/poll') ? state : ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true} @@ -113,18 +160,20 @@ async function customerPage(pageOptions = {}){ return {ok:true, json:async () => data}; }, }); - for(const file of ['support.js', 'visitor.js', 'customer.js', 'drafts.js', 'voice.js', 'chat.js']){ + for(const file of ['support.js', 'visitor.js', 'customer.js', '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,storage:storageValues,tabStorage:tabValues, + return {context, element, calls, hooks, dialogs, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues, documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); }, resize:async height => { context.window.innerHeight=height; @@ -173,6 +222,20 @@ function streamResponse(chunks){ : {done:true}})}}; } +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'}]}); + assert.equal(app.element('customer-orders').hidden,false); + assert.match(app.element('customer-orders-list').textContent,/IN20260041/); + 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/); + 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('AI send preserves a newer draft typed while the conversation is being created', async () => { const app=await customerPage({human:false}); let resolve; @@ -302,7 +365,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); @@ -312,6 +375,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]); @@ -334,7 +415,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')); @@ -501,6 +582,8 @@ test('the contact shortcut outside service hours submits a ticket without enteri const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'}); await app.context.window.showHuman(); assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1); + assert.equal(app.dialogs[0].type, 'prompt'); + assert.equal(app.dialogs[0].options.multiline, true); assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价'); assert.match(app.element('chatDom').textContent, /留言已提交/); assert.equal(app.context.window.humanSid, 0); @@ -1129,3 +1212,129 @@ test('an unavailable image gets one readable replacement instead of a broken ima 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'); +}); diff --git a/tests/frontend/customer_orders.test.cjs b/tests/frontend/customer_orders.test.cjs new file mode 100644 index 0000000..fcb9813 --- /dev/null +++ b/tests/frontend/customer_orders.test.cjs @@ -0,0 +1,96 @@ +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=[]; + const el=id=>ids[id] ||= new Element(); + const context=vm.createContext({document:{getElementById:el,createElement:()=>new Element()}, AbortController, setTimeout, clearTimeout}); + vm.runInContext(fs.readFileSync('static/js/customer_orders.js','utf8'),context); + const orders=context.CustomerOrders.create({text:key=>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,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'}); + +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/); +}); diff --git a/tests/frontend/support.test.cjs b/tests/frontend/support.test.cjs new file mode 100644 index 0000000..f399c3f --- /dev/null +++ b/tests/frontend/support.test.cjs @@ -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); +}); diff --git a/tests/test_customer_orders.py b/tests/test_customer_orders.py new file mode 100644 index 0000000..d166114 --- /dev/null +++ b/tests/test_customer_orders.py @@ -0,0 +1,115 @@ +"""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.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/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/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) + 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)) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 55cdff4..80e2dc4 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -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', 'tests/frontend/drafts.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) diff --git a/tests/test_gray_config.py b/tests/test_gray_config.py index 99b92bc..95ac26b 100644 --- a/tests/test_gray_config.py +++ b/tests/test_gray_config.py @@ -68,12 +68,14 @@ class GrayConfigTests(unittest.TestCase): 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/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') diff --git a/tests/test_order_import_admin.py b/tests/test_order_import_admin.py new file mode 100644 index 0000000..619c02d --- /dev/null +++ b/tests/test_order_import_admin.py @@ -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() diff --git a/tests/test_staff_sessions.py b/tests/test_staff_sessions.py index ea5124b..79e83b3 100644 --- a/tests/test_staff_sessions.py +++ b/tests/test_staff_sessions.py @@ -23,15 +23,18 @@ class StaffSessionTests(unittest.TestCase): return self.client.get('/api/admin/session').json['csrf_token'] def test_anonymous_staff_pages_and_all_staff_endpoints_are_blocked(self): - self.assertEqual(self.client.get('/admin/dashboard').status_code, 302) + 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', 'speech.speech_config', 'speech.speech_transcribe'} + '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 == '/admin/dashboard': + 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('', 'fixture.txt')