新增我的订单功能及页面优化

This commit is contained in:
songxinnuo
2026-11-03 15:40:34 +08:00
parent eebaba8084
commit 92684bffb2
36 changed files with 2513 additions and 45 deletions
+2
View File
@@ -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/` |
+1 -1
View File
@@ -119,7 +119,7 @@ server {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|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};
}}
+1 -1
View File
@@ -47,7 +47,7 @@ server {
location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|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;
}
+2
View File
@@ -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/<id>` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。退出/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 与附件禁止共享缓存。
+46
View File
@@ -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<? ' if before is not None else '')+'ORDER BY id DESC LIMIT ?',
((before,) if before is not None else ())+(PAGE_SIZE+1,))
return {'ok': True, 'orders': rows[:PAGE_SIZE],
'next_before': rows[PAGE_SIZE-1]['id'] if len(rows) > PAGE_SIZE else None}
def order_detail(order_id):
if not 0 < order_id <= 9223372036854775807:
return None
rows = scoped_rows('*', 'AND id=? LIMIT 1', (order_id,))
return build_card(rows[0]) if rows else None
+8 -2
View File
@@ -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():
+158
View File
@@ -0,0 +1,158 @@
"""Staff order browsing and reversible import batch records."""
import hashlib
import json
import sqlite3
from datetime import datetime
from . import order_data
MAX_ORDER_ID = 9223372036854775807
ORDER_LIST_FIELDS = ('tenant_id', 'order_no', 'status', 'inbound_code', 'shipment_id',
'country_code', 'transport_mode', 'warehouse_code', 'source_updated_at', 'snapshot_at')
def init_order_admin_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS order_import_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
imported_by TEXT NOT NULL,
imported_at TEXT NOT NULL,
total_rows INTEGER NOT NULL,
imported_rows INTEGER NOT NULL,
preserved_rows INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'active' CHECK(state IN ('active','undone')),
undone_by TEXT,
undone_at TEXT,
deleted_rows INTEGER NOT NULL DEFAULT 0,
preserved_on_undo INTEGER NOT NULL DEFAULT 0
)''')
connection.execute('''CREATE TABLE IF NOT EXISTS order_import_batch_items (
batch_id INTEGER NOT NULL,
order_id INTEGER NOT NULL,
row_fingerprint TEXT NOT NULL,
PRIMARY KEY(batch_id, order_id)
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_import_batches_time '
'ON order_import_batches(imported_at DESC, id DESC)')
def _row_fingerprint(row):
values = [row[field] for field in order_data.FIELDS]
encoded = json.dumps(values, ensure_ascii=False, separators=(',', ':'), default=str)
return hashlib.sha256(encoded.encode('utf-8')).hexdigest()
def _safe_filename(filename):
return str(filename or '').replace('\\', '/').split('/')[-1][:255] or 'orders.csv'
def create_import_batch(connection, filename, username, parsed, result, existing_keys):
"""Persist the import audit and fingerprints for newly inserted records."""
now = datetime.now().isoformat(timespec='seconds')
cursor = connection.execute('''INSERT INTO order_import_batches
(filename,imported_by,imported_at,total_rows,imported_rows,preserved_rows)
VALUES(?,?,?,?,?,?)''', (_safe_filename(filename), username, now, parsed['total_rows'],
result['imported'], result['preserved']))
batch_id = cursor.lastrowid
for item in parsed['rows']:
key = (item['tenant_id'], item['order_no'].casefold())
if key in existing_keys:
continue
row = connection.execute('SELECT id,' + ','.join(order_data.FIELDS) +
' FROM order_test_data WHERE tenant_id=? AND order_no=?',
(item['tenant_id'], item['order_no'])).fetchone()
if row is None:
continue
record = dict(zip(('id', *order_data.FIELDS), row))
connection.execute('INSERT INTO order_import_batch_items(batch_id,order_id,row_fingerprint) VALUES(?,?,?)',
(batch_id, record['id'], _row_fingerprint(record)))
return batch_id
def _conditions(query, tenant, status, before):
clauses, values = [], []
if query:
escaped = query.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
clauses.append("(order_no LIKE ? ESCAPE '\\' OR inbound_code LIKE ? ESCAPE '\\' "
"OR shipment_id LIKE ? ESCAPE '\\' OR source_order_id LIKE ? ESCAPE '\\')")
values.extend(['%' + escaped + '%'] * 4)
if tenant:
clauses.append('tenant_id=?')
values.append(tenant)
if status:
clauses.append('status=?')
values.append(status)
if before is not None:
clauses.append('id<?')
values.append(before)
return (' WHERE ' + ' AND '.join(clauses) if clauses else ''), values
def list_orders(connection, query='', tenant='', status='', before=None, limit=50):
query, tenant, status = str(query or '').strip(), str(tenant or '').strip(), str(status or '').strip().upper()
if len(query) > 128 or len(tenant) > 128:
raise ValueError('搜索内容不能超过128个字符。')
if status and status not in order_data.STATUS_LABELS:
raise ValueError('订单状态无效。')
try:
limit = int(limit)
before = int(before) if before is not None else None
except (TypeError, ValueError):
raise ValueError('分页参数无效。') from None
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_ORDER_ID):
raise ValueError('分页参数无效。')
where, values = _conditions(query, tenant, status, None)
total = connection.execute('SELECT COUNT(*) FROM order_test_data' + where, values).fetchone()[0]
page_where, page_values = _conditions(query, tenant, status, before)
rows = connection.execute('SELECT id,' + ','.join(ORDER_LIST_FIELDS) +
' FROM order_test_data' + page_where + ' ORDER BY id DESC LIMIT ?',
(*page_values, limit + 1)).fetchall()
has_more = len(rows) > limit
rows = rows[:limit]
orders = [dict(zip(('id', *ORDER_LIST_FIELDS), row)) for row in rows]
return {'orders': orders, 'total': total,
'next_before': rows[-1][0] if has_more and rows else None,
'limit': limit}
def list_import_batches(connection, limit=50):
limit = max(1, min(100, int(limit)))
rows = connection.execute('''SELECT id,filename,imported_by,imported_at,total_rows,imported_rows,
preserved_rows,state,undone_by,undone_at,deleted_rows,preserved_on_undo
FROM order_import_batches ORDER BY id DESC LIMIT ?''', (limit,)).fetchall()
fields = ('id','filename','imported_by','imported_at','total_rows','imported_rows',
'preserved_rows','state','undone_by','undone_at','deleted_rows','preserved_on_undo')
return [dict(zip(fields, row)) for row in rows]
def undo_import_batch(connection, batch_id, username):
try:
batch_id = int(batch_id)
except (TypeError, ValueError):
raise ValueError('导入批次编号无效。') from None
if not 0 < batch_id <= MAX_ORDER_ID:
raise ValueError('导入批次编号无效。')
batch = connection.execute('SELECT state,deleted_rows,preserved_on_undo FROM order_import_batches WHERE id=?',
(batch_id,)).fetchone()
if not batch:
return None
if batch[0] == 'undone':
return {'batch_id': batch_id, 'deleted': batch[1], 'preserved': batch[2], 'already_undone': True}
deleted = preserved = 0
items = connection.execute('SELECT order_id,row_fingerprint FROM order_import_batch_items WHERE batch_id=?',
(batch_id,)).fetchall()
for order_id, fingerprint in items:
current = connection.execute('SELECT ' + ','.join(order_data.FIELDS) +
' FROM order_test_data WHERE id=?', (order_id,)).fetchone()
if current is None:
continue
record = dict(zip(order_data.FIELDS, current))
if _row_fingerprint(record) != fingerprint:
preserved += 1
continue
deleted += connection.execute('DELETE FROM order_test_data WHERE id=?', (order_id,)).rowcount
now = datetime.now().isoformat(timespec='seconds')
connection.execute('''UPDATE order_import_batches SET state='undone',undone_by=?,undone_at=?,
deleted_rows=?,preserved_on_undo=? WHERE id=?''', (username, now, deleted, preserved, batch_id))
return {'batch_id': batch_id, 'deleted': deleted, 'preserved': preserved, 'already_undone': False}
+297
View File
@@ -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}
+135 -2
View File
@@ -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/<batch_id>/undo')
def undo_order_import(batch_id):
connection = db.connect()
try:
connection.execute('BEGIN IMMEDIATE')
result = order_admin_service.undo_import_batch(connection, batch_id, g.staff['username'])
if result is None:
connection.rollback()
return jsonify({'ok': False, 'error': '找不到此导入批次。'}), 404
connection.commit()
except ValueError as error:
connection.rollback()
return jsonify({'ok': False, 'error': str(error)}), 400
except sqlite3.Error:
connection.rollback()
return jsonify({'ok': False, 'error': '撤销失败,请刷新后重试。'}), 500
finally:
connection.close()
return jsonify({'ok': True, **result})
+40 -2
View File
@@ -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/<int:order_id>')
def order(order_id):
def detail():
card = customer_order_service.order_detail(order_id)
if card is None:
return {'ok': False, 'error': '订单不存在或当前账号无权查看。'}, 404
return jsonify({'ok': True, 'card': card})
return order_request(detail)
+2 -1
View File
@@ -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',
})
+3 -2
View File
@@ -15,7 +15,7 @@ CUSTOMER_PATHS = {
'/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history',
'/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback',
'/api/speech/config', '/api/speech/transcribe',
'/api/customer/session', '/api/customer/login', '/api/customer/logout',
'/api/customer/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'):
+2 -1
View File
@@ -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; }
}
+63
View File
@@ -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; } }
+57 -2
View File
@@ -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; }
+25
View File
@@ -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; }
}
+163
View File
@@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character];
});
}
function showResponse(data){
results.hidden = false;
const summary = data.summary || {};
document.getElementById('import-summary').innerHTML = [
['文件数据行', summary.total_rows], ['有效行', summary.valid_rows],
['重复保留', summary.duplicate_rows], ['错误行', summary.error_rows]
].map(function(item){
return '<div class="summary-item"><span>'+escapeText(item[0])+'</span><strong>'+escapeText(item[1] || 0)+'</strong></div>';
}).join('');
document.getElementById('preview-rows').innerHTML = (data.preview || []).map(function(row){
return '<tr><td>'+escapeText(row.row)+'</td><td>'+escapeText(row.tenant_id)+'</td><td>'+escapeText(row.order_no)+'</td><td>'+escapeText(row.status)+'</td><td>'+escapeText(row.inbound_code)+'</td><td class="'+(row.duplicate ? 'row-duplicate' : 'row-new')+'">'+(row.duplicate ? '已存在,保留原数据' : '可新增')+'</td></tr>';
}).join('') || '<tr><td colspan="6">没有可预览的有效数据</td></tr>';
document.getElementById('preview-limit-note').hidden = !data.preview_limited;
const errors = document.getElementById('import-errors');
errors.innerHTML = (data.errors || []).map(function(item){
return '<p>第 '+escapeText(item.row)+' 行:'+escapeText(item.message)+'</p>';
}).join('');
errors.hidden = !data.errors || !data.errors.length;
document.getElementById('results-caption').textContent = errors.hidden
? '请确认租户、订单号及重复记录处理方式。'
: '发现错误行;请修正文件后重新上传,当前不会写入数据。';
canConfirm = Boolean(data.ok && summary.valid_rows);
confirmButton.hidden = !canConfirm;
}
async function sendFile(path){
const body = new FormData();
body.append('file', selectedFile);
const response = await fetch(path, {method:'POST', body:body, credentials:'same-origin',
headers:{'X-CSRF-Token':csrf, 'Accept':'application/json'}, signal:AbortSignal.timeout(30000)});
const data = await response.json();
if(!response.ok && !data.summary) throw new Error(data.error || '请求失败,请稍后重试。');
return data;
}
async function requestJson(path, options){
options = options || {};
options.credentials = 'same-origin';
options.headers = Object.assign({'Accept':'application/json'}, options.headers || {});
const response = await fetch(path, options);
const data = await response.json();
if(!response.ok) throw new Error(data.error || '请求失败,请稍后重试。');
return data;
}
function renderOrders(data, append){
const body = document.getElementById('order-list-rows');
const rows = (data.orders || []).map(function(row){
return '<tr><td>'+escapeText(row.tenant_id)+'</td><td><strong>'+escapeText(row.order_no)+'</strong></td><td>'+escapeText(row.status)+'</td><td>'+escapeText(row.inbound_code)+'</td><td>'+escapeText(row.shipment_id)+'</td><td>'+escapeText(row.country_code)+'</td><td>'+escapeText(row.source_updated_at)+'</td></tr>';
}).join('');
if(!append) body.innerHTML = '';
body.insertAdjacentHTML('beforeend', rows || (!append ? '<tr><td colspan="7">没有找到订单</td></tr>' : ''));
orderBefore = data.next_before || null;
document.getElementById('load-more-orders').hidden = !orderBefore;
document.getElementById('order-list-status').textContent = '共 '+escapeText(data.total || 0)+' 条订单';
}
async function loadOrders(append){
const params = new URLSearchParams({q:document.getElementById('order-query').value.trim(), tenant:document.getElementById('order-tenant').value.trim(), status:document.getElementById('order-status').value, limit:'50'});
if(append && orderBefore) params.set('before', orderBefore);
const data = await requestJson('/api/admin/orders/list?'+params.toString());
renderOrders(data, append);
}
async function loadBatches(){
const data = await requestJson('/api/admin/orders/import/batches');
document.getElementById('import-batch-rows').innerHTML = (data.batches || []).map(function(batch){
const active = batch.state === 'active';
const action = active ? '<button class="button-secondary batch-undo" data-batch-id="'+escapeText(batch.id)+'" type="button">撤销</button>' : '已撤销(删 '+escapeText(batch.deleted_rows)+',留 '+escapeText(batch.preserved_on_undo)+')';
return '<tr><td>'+escapeText(batch.imported_at)+'</td><td>'+escapeText(batch.filename)+'</td><td>'+escapeText(batch.imported_by)+'</td><td>'+escapeText(batch.imported_rows)+'</td><td>'+escapeText(batch.preserved_rows)+'</td><td>'+escapeText(active ? '可撤销' : '已撤销')+'</td><td>'+action+'</td></tr>';
}).join('') || '<tr><td colspan="7">暂无导入记录</td></tr>';
document.querySelectorAll('.batch-undo').forEach(function(button){
button.addEventListener('click', async function(){
if(!await SupportUI.confirm('撤销后只会删除本批次中仍未被修改的新增订单,确定继续吗?',{title:'撤销导入'})) return;
button.disabled = true;
try{
const result = await requestJson('/api/admin/orders/import/batches/'+encodeURIComponent(button.dataset.batchId)+'/undo',{method:'POST',headers:{'X-CSRF-Token':csrf}});
setStatus('批次已处理:删除 '+result.deleted+' 条,保留 '+result.preserved+' 条。');
await Promise.all([loadBatches(), loadOrders(false)]);
}catch(error){ setStatus(error.message, true); button.disabled = false; }
});
});
}
fileInput.addEventListener('change', function(){
selectedFile = fileInput.files[0] || null;
fileName.textContent = selectedFile ? selectedFile.name : '选择订单文件';
results.hidden = true;
canConfirm = false;
confirmButton.hidden = true;
setStatus('');
});
form.addEventListener('submit', async function(event){
event.preventDefault();
if(!selectedFile) return;
const button = document.getElementById('preview-import');
button.disabled = true;
setStatus('正在读取并校验文件…');
try{
const data = await sendFile('/api/admin/orders/import/preview');
showResponse(data);
setStatus(data.ok ? '校验完成,请检查结果。' : '发现错误行,请先修正文件。', !data.ok);
}catch(error){ setStatus(error.message || '校验失败,请检查网络后重试。', true); }
finally{ button.disabled = false; }
});
confirmButton.addEventListener('click', async function(){
if(!canConfirm || !selectedFile) return;
confirmButton.disabled = true;
setStatus('正在重新校验并导入…');
try{
const data = await sendFile('/api/admin/orders/import');
canConfirm = false;
confirmButton.hidden = true;
fileInput.value = '';
selectedFile = null;
fileName.textContent = '选择订单文件';
setStatus('导入完成:新增 '+data.imported+' 条,重复保留 '+data.preserved+' 条。');
const refreshed = await Promise.allSettled([loadBatches(), loadOrders(false)]);
if(refreshed[0].status === 'rejected'){
document.getElementById('import-batch-rows').innerHTML = '<tr><td colspan="7">导入已完成,但记录刷新失败,请刷新页面重试。</td></tr>';
}
if(refreshed[1].status === 'rejected'){
document.getElementById('order-list-status').textContent = '导入已完成,但订单列表刷新失败:'+refreshed[1].reason.message;
}
if(refreshed.some(function(result){ return result.status === 'rejected'; })){
setStatus('导入已完成,但部分列表刷新失败,请刷新页面查看。', true);
}
}catch(error){ setStatus(error.message || '导入失败,请检查网络后重试。', true); }
finally{ confirmButton.disabled = false; }
});
document.getElementById('order-search-form').addEventListener('submit', async function(event){
event.preventDefault();
try { await loadOrders(false); } catch(error) { document.getElementById('order-list-status').textContent = error.message; }
});
document.getElementById('load-more-orders').addEventListener('click', async function(){
this.disabled = true;
try { await loadOrders(true); } catch(error) { document.getElementById('order-list-status').textContent = error.message; }
finally { this.disabled = false; }
});
Promise.all([loadOrders(false), loadBatches()]).catch(function(error){ document.getElementById('order-list-status').textContent = error.message; });
document.getElementById('staff-logout').addEventListener('click', async function(){
this.disabled = true;
try{
await fetch('/admin/logout', {method:'POST', credentials:'same-origin', headers:{'X-CSRF-Token':csrf}});
}finally{ location.assign('/admin'); }
});
})();
+3 -1
View File
@@ -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();
+168
View File
@@ -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};
}
};
+109 -13
View File
@@ -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;
+1
View File
@@ -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();
+132
View File
@@ -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();}};
}
};
+109
View File
@@ -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;
+1
View File
@@ -21,6 +21,7 @@
<button id="ticket-inbox-toggle" class="ticket-inbox-toggle" type="button" aria-label="查看待处理留言" aria-controls="service-tools">留言 <span id="ticket-pending-count">—</span></button>
<button id="ticket-overdue-toggle" class="ticket-overdue-toggle" type="button" aria-label="查看超时留言" aria-controls="service-tools">超时 <span id="ticket-overdue-count">—</span></button>
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
<a class="workspace-link orders-link" href="/admin/orders">订单数据管理 ↗</a>
<a class="workspace-link" href="/">客户入口 ↗</a>
<details class="data-overview" id="data-overview">
<summary><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 4v16h17M8 16v-5M13 16V7M18 16V3"/></svg>数据概览</summary>
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="staff-csrf-token" content="{{ g.staff.csrf_token }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单数据管理 · 柏悦物流</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin_orders.css') }}">
</head>
<body class="orders-page">
<header class="orders-topbar">
<a class="brand" href="/admin/dashboard"><span class="brand-mark" aria-hidden="true">物流</span><span class="brand-name">柏悦物流</span></a>
<nav aria-label="后台导航"><a href="/admin/dashboard">返回客服工作台</a><button id="staff-logout" type="button">退出登录</button></nav>
</header>
<main class="orders-shell">
<div class="orders-heading"><div><p class="orders-eyebrow">STAFF CONSOLE</p><h1>订单数据管理</h1><p>导入订单后,客户登录并关联对应租户即可查询订单信息。</p></div><a class="template-link" href="/api/admin/orders/import/template">下载CSV模板 ↓</a></div>
<section class="import-card" aria-labelledby="import-title">
<div class="card-heading"><span class="step-number">01</span><div><h2 id="import-title">上传订单文件</h2><p>支持 CSV 和 XLSX,单次最多 5,000 条、文件不超过 5 MB。</p></div></div>
<form id="order-import-form">
<label class="file-picker" for="order-file"><span class="file-icon" aria-hidden="true">↑</span><span><strong id="file-name">选择订单文件</strong><small>点击选择 CSV 或 XLSX 文件</small></span><input id="order-file" name="file" type="file" accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required></label>
<p class="import-hint">必填列:租户ID、订单号。重复订单会保留已有记录,不会覆盖人工修改。</p>
<div class="form-actions"><button id="preview-import" class="button-primary" type="submit">校验并预览</button><span id="import-status" role="status" aria-live="polite"></span></div>
</form>
</section>
<section id="import-results" class="import-card results-card" aria-labelledby="results-title" hidden>
<div class="card-heading"><span class="step-number">02</span><div><h2 id="results-title">校验结果</h2><p id="results-caption">核对后再确认导入。</p></div></div>
<div id="import-summary" class="summary-grid"></div>
<div class="table-scroll"><table><thead><tr><th>行</th><th>租户ID</th><th>订单号</th><th>状态</th><th>入仓码</th><th>检查结果</th></tr></thead><tbody id="preview-rows"></tbody></table></div>
<p id="preview-limit-note" class="import-hint" hidden>表格仅展示前 20 条,其他行仍会执行完整校验。</p>
<div id="import-errors" class="error-list" hidden></div>
<div class="form-actions result-actions"><button id="confirm-import" class="button-primary" type="button" hidden>确认导入</button><span class="import-hint">确认后新增有效订单;文件中的错误行必须先修正。</span></div>
</section>
<section class="import-card" aria-labelledby="orders-title">
<div class="card-heading"><span class="step-number">03</span><div><h2 id="orders-title">订单列表</h2><p>按订单号、入仓码、Shipment ID 或租户筛选。</p></div></div>
<form id="order-search-form" class="order-search-form">
<label>关键词<input id="order-query" type="search" maxlength="128" placeholder="订单号、入仓码或 Shipment ID"></label>
<label>租户ID<input id="order-tenant" type="search" maxlength="128" placeholder="精确租户ID"></label>
<label>业务状态<select id="order-status"><option value="">全部状态</option><option value="DRAFT">草稿</option><option value="SUBMITTED">已提交</option><option value="APPROVED">审核通过</option><option value="ACCEPTED">已接单</option><option value="REJECTED">审核驳回</option><option value="PROCESSING">履约中</option><option value="COMPLETED">头程订单已完成</option><option value="CANCELLED">已取消</option></select></label>
<button class="button-primary" type="submit">搜索</button>
</form>
<p id="order-list-status" class="import-hint" role="status" aria-live="polite"></p>
<div class="table-scroll"><table><thead><tr><th>租户ID</th><th>订单号</th><th>业务状态</th><th>入仓码</th><th>Shipment ID</th><th>目的国</th><th>来源更新时间</th></tr></thead><tbody id="order-list-rows"><tr><td colspan="7">正在加载订单…</td></tr></tbody></table></div>
<div class="form-actions"><button id="load-more-orders" class="button-secondary" type="button" hidden>加载更多</button></div>
</section>
<section class="import-card" aria-labelledby="batches-title">
<div class="card-heading"><span class="step-number">04</span><div><h2 id="batches-title">导入记录</h2><p>记录操作人员和导入数量;撤销只删除仍未被修改的本批新增订单。</p></div></div>
<div class="table-scroll"><table><thead><tr><th>时间</th><th>文件</th><th>操作人</th><th>新增</th><th>重复保留</th><th>状态</th><th>操作</th></tr></thead><tbody id="import-batch-rows"><tr><td colspan="7">正在加载导入记录…</td></tr></tbody></table></div>
</section>
</main>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_orders.js') }}"></script>
</body>
</html>
+25 -3
View File
@@ -27,18 +27,38 @@
</aside>
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
<main class="main-chat">
<div id="attachment-drop-hint" class="attachment-drop-hint" role="status" hidden><span data-i18n="dropAttachment">松开鼠标添加附件</span></div>
<header class="chat-title">
<button class="toggle-divider" type="button" aria-label="切换历史侧栏" aria-expanded="true"></button>
<div class="header-copy"><div class="header-kicker">BAIYUE · CUSTOMER SUPPORT</div><span id="app-title">柏悦物流智能助手</span></div>
<div class="title-actions">
<button type="button" id="customer-account-mobile" class="customer-account mobile-account">客户登录</button>
<button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
<span class="service-label"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceTag">智能客服</span></span>
<button type="button" id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
<button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button>
<button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</button>
<div class="settings-control">
<button type="button" id="settings-toggle" class="theme-toggle settings-toggle" aria-expanded="false" aria-controls="settings-panel" data-i18n="moreSettings">更多设置</button>
<div id="settings-panel" class="settings-panel" role="group" aria-label="更多设置" hidden>
<div class="settings-row"><span data-i18n="notificationSetting">消息通知</span><button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button></div>
<div class="settings-row"><span data-i18n="themeSetting">明暗主题</span><button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</button></div>
<div class="settings-row"><span data-i18n="fontSize">字体大小</span><button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button></div>
</div>
</div>
</div>
</header>
<section id="customer-orders" class="customer-orders" aria-labelledby="customer-orders-toggle" hidden>
<div class="customer-orders-heading">
<button id="customer-orders-toggle" type="button" aria-expanded="false" aria-controls="customer-orders-body">我的订单</button>
<button id="customer-orders-refresh" type="button">刷新</button>
</div>
<div id="customer-orders-body" class="customer-orders-body" hidden>
<button id="customer-orders-back" type="button" hidden>返回订单列表</button>
<p id="customer-orders-message" role="status" hidden></p>
<div id="customer-orders-list" class="customer-orders-list"></div>
<div id="customer-orders-detail" class="track-card" aria-live="polite" hidden></div>
<button id="customer-orders-retry" type="button" hidden>重试</button>
<button id="customer-orders-more" type="button" hidden>加载更多订单</button>
</div>
</section>
<div id="chatDom" role="log" aria-live="polite" aria-label="聊天内容">
<section class="ai-welcome-card">
<div class="ai-welcome-top">
@@ -86,8 +106,10 @@
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer_orders.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/attachments.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
</html>
+12 -2
View File
@@ -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;
+86
View File
@@ -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);
});
+217 -8
View File
@@ -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');
});
+96
View File
@@ -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/);
});
+122
View File
@@ -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);
});
+115
View File
@@ -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))
+1 -1
View File
@@ -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)
+2
View File
@@ -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')
+248
View File
@@ -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()
+6 -3
View File
@@ -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('<path:name>', 'fixture.txt')