Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df688844d3 | ||
|
|
64041893a5 | ||
|
|
06b80803c4 | ||
|
|
2d6806b15d |
@@ -11,9 +11,9 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
|
||||
| Human support | `logistics_chat/human_service.py`; `logistics_chat/routes/human.py` |
|
||||
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
|
||||
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
|
||||
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView` |
|
||||
| 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/` |
|
||||
| Deployment | `Jenkinsfile`, `Dockerfile`; `deploy/gray_config.py`: `GrayConfig`; publish/activate/TLS scripts; `docs/deployment.md` |
|
||||
@@ -51,7 +51,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
|
||||
|
||||
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
|
||||
|
||||
Tests run `tests/frontend/admin.test.cjs` when Node.js is available; no npm dependencies.
|
||||
Tests run `tests/frontend/admin.test.cjs` and `chat.test.cjs` when Node.js is available; no npm dependencies.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -59,4 +59,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ RUN python -m pytest -q -p no:cacheprovider
|
||||
|
||||
FROM ${PYTHON_BASE_IMAGE} AS certbot
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
||||
RUN python -m pip install --no-cache-dir certbot==5.9.0
|
||||
RUN python -m pip install --no-cache-dir certbot==5.8.0
|
||||
ENTRYPOINT ["certbot"]
|
||||
|
||||
FROM runtime AS production
|
||||
|
||||
Vendored
+1
-1
@@ -23,7 +23,7 @@ pipeline {
|
||||
}
|
||||
sh '''set -eu
|
||||
for file in deploy/*.sh; do bash -n "$file"; done
|
||||
node --test tests/frontend/admin.test.cjs
|
||||
node --test tests/frontend/*.test.cjs
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ py -3 scripts/close_active_sessions.py
|
||||
|---|---|---|
|
||||
| GET | `/` | 客户聊天页面 |
|
||||
| GET / POST | `/stream_chat` | SSE 流式对话;POST JSON 接收 `msg`、`conv_id` |
|
||||
| POST | `/api/new_chat` | 新建会话,返回 `id` |
|
||||
| POST | `/api/new_chat` | 新建会话并保存欢迎语,返回 `id`、`welcome` |
|
||||
| GET | `/api/get_history` | 会话列表 |
|
||||
| GET | `/api/get_messages?conv_id=` | 单个会话的消息 |
|
||||
| DELETE | `/api/del_history?id=` | 删除单个会话和消息 |
|
||||
@@ -212,10 +212,18 @@ py -3 scripts/close_active_sessions.py
|
||||
| POST | `/api/human/send` | 发送人工消息 |
|
||||
| GET | `/api/human/poll` | 轮询人工消息 |
|
||||
| POST | `/api/human/close` | 关闭人工会话 |
|
||||
| POST | `/api/human/transfer` | 将接待中的会话转回排队列表 |
|
||||
| POST | `/api/human/note` | 保存会话备注 |
|
||||
| POST | `/api/human/recall` | 按 `mid` 撤回客服消息 |
|
||||
| POST | `/api/rate` | 提交人工服务评价(`sid`、1–5 的 `stars`、`comment`) |
|
||||
| GET | `/api/agents/list` | 客服列表,不包含密码 |
|
||||
| GET | `/api/search_chat?q=` | 搜索历史消息,最多返回 50 条 |
|
||||
| GET | `/admin` | 客服登录页面 |
|
||||
| POST | `/admin/login` | 校验客服账号密码 |
|
||||
| GET | `/admin/dashboard` | 客服工作台 |
|
||||
| GET | `/api/export_chat` | 导出聊天记录为 CSV |
|
||||
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
|
||||
|
||||
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
|
||||
|
||||
SSE 每帧为一条 JSON,主要事件包括:
|
||||
|
||||
@@ -230,7 +238,7 @@ data: {"d": 1}
|
||||
|
||||
## 数据库与运行数据
|
||||
|
||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`agents`、`orders` 和 `tickets` 八张表,不自动创建客服账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders` 和 `tickets` 九张表,并兼容已有数据,不预置账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ services:
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"
|
||||
env_file:
|
||||
|
||||
@@ -28,11 +28,11 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
|
||||
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
|
||||
| 运单轨迹 | `logistics_chat/tracking.py`:`extract_tracking_no`、`build_track`、`CARD_PREFIX`、`card_to_text` | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;模拟节点时间取当前时刻,回归对照需固定时钟 |
|
||||
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
|
||||
| 人工会话 | `logistics_chat/human_service.py`:`human_*`;对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply` |
|
||||
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
|
||||
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
|
||||
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
|
||||
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`,再加载对应 `chat`、`admin`、`admin_login` 的 CSS/JS;保留全局事件入口 |
|
||||
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证异步交互 |
|
||||
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
|
||||
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_dev_integration.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs`、`chat.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
|
||||
|
||||
除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。
|
||||
|
||||
@@ -53,7 +53,7 @@ logistics_chat/
|
||||
├── chat_service.py # 问答编排与 SSE 帧
|
||||
├── history_service.py # 历史会话 CRUD
|
||||
├── human_service.py # 人工状态与消息
|
||||
├── business_service.py # 反馈、统计、登录、订单、留言、CSV
|
||||
├── business_service.py # 反馈、评价、统计、登录、订单、留言、历史搜索、Excel/CSV
|
||||
├── file_service.py # 附件校验、保存、清理
|
||||
├── hooks.py # 限流及 HTML/SSE 缓存头
|
||||
├── runtime.py # Waitress / Flask 启动
|
||||
@@ -104,9 +104,11 @@ tests/ # 按业务模块组织的回归
|
||||
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。
|
||||
- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。
|
||||
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
|
||||
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent`。
|
||||
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
|
||||
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
|
||||
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
|
||||
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
|
||||
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
|
||||
|
||||
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
|
||||
|
||||
## Jenkins 任务
|
||||
|
||||
任务名为 `baiyue-logistics-chat-gray`。定义选择 Pipeline script from SCM,本项目 Git 地址为仓库,分支字段为 `${SOURCE_REF}`,脚本路径为 `Jenkinsfile`。公开仓库可匿名读取;转为私有后配置只读 SCM 凭据。
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from .db import connect, now_str
|
||||
from .tracking import card_to_text
|
||||
|
||||
|
||||
def feedback(data):
|
||||
@@ -22,8 +23,10 @@ def admin_stats():
|
||||
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
|
||||
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
|
||||
conn.close()
|
||||
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv}
|
||||
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv,
|
||||
'rating_avg': round(rating_avg, 1), 'rating_count': rating_count}
|
||||
|
||||
|
||||
def my_orders(phone):
|
||||
@@ -61,15 +64,72 @@ def admin_login(data):
|
||||
return ({'ok': False}, 401)
|
||||
|
||||
|
||||
def export_chat():
|
||||
def agents_list():
|
||||
conn = connect()
|
||||
# rowid also works with the legacy agents table that has an explicit INTEGER id.
|
||||
rows = conn.execute('SELECT rowid, username, name FROM agents ORDER BY rowid').fetchall()
|
||||
conn.close()
|
||||
return [{'id': row[0], 'username': row[1], 'name': row[2]} for row in rows]
|
||||
|
||||
|
||||
def rate_chat(data):
|
||||
sid, stars = data.get('sid'), data.get('stars', 0)
|
||||
comment = data.get('comment', '')
|
||||
if not sid or type(stars) is not int or not 1 <= stars <= 5 or not isinstance(comment, str):
|
||||
return ({'ok': False}, 400)
|
||||
conn = connect()
|
||||
if not conn.execute('SELECT 1 FROM human_sessions WHERE id=?', (sid,)).fetchone():
|
||||
conn.close()
|
||||
return ({'ok': False}, 404)
|
||||
conn.execute('INSERT INTO ratings(sid, stars, comment, ctime) VALUES(?,?,?,?)',
|
||||
(sid, stars, comment, datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
def search_chat(keyword):
|
||||
if not keyword:
|
||||
return {'results': []}
|
||||
conn = connect()
|
||||
rows = conn.execute('''SELECT h.title, m.role, m.content, m.ctime FROM messages m
|
||||
JOIN chat_history h ON m.conv_id=h.id WHERE m.content LIKE ? ORDER BY m.id DESC LIMIT 50''',
|
||||
('%' + keyword + '%',)).fetchall()
|
||||
conn.close()
|
||||
return [{'title': row[0], 'role': row[1], 'content': card_to_text(row[2]), 'ctime': row[3]} for row in rows]
|
||||
|
||||
|
||||
def export_chat(format='xlsx'):
|
||||
conn = connect()
|
||||
rows = conn.execute('SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id').fetchall()
|
||||
conn.close()
|
||||
import csv
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(['标题', '角色', '内容', '时间'])
|
||||
for r in rows:
|
||||
w.writerow([r[0], r[1], r[2] or '', r[3]])
|
||||
if format == 'csv':
|
||||
import csv
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(['标题', '角色', '内容', '时间'])
|
||||
for r in rows:
|
||||
w.writerow([r[0], r[1], r[2] or '', r[3]])
|
||||
return buf.getvalue()
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = '聊天记录'
|
||||
sheet.append(['对话标题', '角色', '内容', '时间'])
|
||||
for cell in sheet[1]:
|
||||
cell.font = Font(bold=True, color='FFFFFF')
|
||||
cell.fill = PatternFill('solid', fgColor='315FD5')
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
roles = {'user': '客户', 'assistant': '客服', 'system': '系统'}
|
||||
for title, role, content, ctime in rows:
|
||||
sheet.append([title, roles.get(role, role), content or '', ctime])
|
||||
for cell in sheet[sheet.max_row]:
|
||||
cell.data_type = 's'
|
||||
for column, width in zip('ABCD', (20, 8, 80, 20)):
|
||||
sheet.column_dimensions[column].width = width
|
||||
buf = io.BytesIO()
|
||||
workbook.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
@@ -24,7 +24,7 @@ def create_chat_stream(msg, conv_id):
|
||||
if not msg:
|
||||
return (sse({'e': '问题不能为空'}), {})
|
||||
if '[图片]' in msg and len(msg) < 300:
|
||||
return (sse({'t': '收到您的图片,请描述一下您的问题。'}), {})
|
||||
return (sse({'t': '收到您的图片,请描述一下您的问题。'}) + sse({'d': 1}), {})
|
||||
track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg))
|
||||
if track_no:
|
||||
card = build_track(track_no)
|
||||
@@ -94,7 +94,7 @@ def create_chat_stream(msg, conv_id):
|
||||
if know_text:
|
||||
follow_src = prev_q
|
||||
_c = connect()
|
||||
_msg_count = _c.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?', (conv_id,)).fetchone()[0]
|
||||
_msg_count = _c.execute("SELECT COUNT(*) FROM messages WHERE conv_id=? AND role='user'", (conv_id,)).fetchone()[0]
|
||||
_c.close()
|
||||
is_first_turn = _msg_count == 0
|
||||
save_message(conv_id, 'user', msg)
|
||||
|
||||
@@ -87,6 +87,20 @@ def init_db():
|
||||
hm_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_messages)').fetchall()]
|
||||
if 'read_at' not in hm_cols:
|
||||
cur.execute('ALTER TABLE human_messages ADD COLUMN read_at TEXT')
|
||||
if 'recalled_id' not in hm_cols:
|
||||
cur.execute('ALTER TABLE human_messages ADD COLUMN recalled_id INTEGER')
|
||||
hs_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_sessions)').fetchall()]
|
||||
if 'note' not in hs_cols:
|
||||
cur.execute("ALTER TABLE human_sessions ADD COLUMN note TEXT DEFAULT ''")
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sid INTEGER NOT NULL,
|
||||
stars INTEGER NOT NULL,
|
||||
comment TEXT DEFAULT '',
|
||||
ctime TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
username TEXT PRIMARY KEY,
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
from .db import connect, now_str
|
||||
|
||||
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。'
|
||||
|
||||
|
||||
def new_chat():
|
||||
now = now_str()
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
|
||||
conv_id = cur.lastrowid
|
||||
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
|
||||
(conv_id, 'assistant', WELCOME_TEXT, now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'id': conv_id}
|
||||
return {'id': conv_id, 'welcome': WELCOME_TEXT}
|
||||
|
||||
|
||||
def get_history():
|
||||
|
||||
@@ -10,6 +10,8 @@ def human_request(data):
|
||||
conv_id = data.get('conv_id')
|
||||
if not conv_id:
|
||||
return ({'ok': False, 'error': 'no conv'}, 400)
|
||||
if not 8 <= datetime.now().hour < 22:
|
||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
conn = connect()
|
||||
row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
|
||||
@@ -25,14 +27,16 @@ def human_request(data):
|
||||
|
||||
def human_poll(sid, after, typing_map):
|
||||
conn = connect()
|
||||
msgs = conn.execute('SELECT id, side, content, ctime, read_at FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
|
||||
st = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()
|
||||
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
|
||||
st = conn.execute("SELECT status, COALESCE(note, '') FROM human_sessions WHERE id=?", (sid,)).fetchone()
|
||||
conn.close()
|
||||
is_typing = False
|
||||
ts = typing_map.get(str(sid))
|
||||
if ts and time.time() - ts < 5:
|
||||
is_typing = True
|
||||
return {'status': st[0] if st else 'closed', 'typing': is_typing, 'messages': [{'id': m[0], 'side': m[1], 'content': m[2], 'ctime': m[3], 'read': bool(m[4])} for m in msgs]}
|
||||
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
|
||||
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
|
||||
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
|
||||
|
||||
|
||||
def human_send(data):
|
||||
@@ -81,6 +85,55 @@ def human_close(data):
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
def human_transfer(data, typing_map):
|
||||
sid = data.get('sid')
|
||||
conn = connect()
|
||||
changed = conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=? AND status='active'", (sid,)).rowcount
|
||||
if not changed:
|
||||
conn.close()
|
||||
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
|
||||
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
|
||||
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
typing_map.pop(str(sid), None)
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
def human_note(data):
|
||||
note = data.get('note', '')
|
||||
if not isinstance(note, str):
|
||||
return ({'ok': False}, 400)
|
||||
conn = connect()
|
||||
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True} if changed else ({'ok': False}, 404)
|
||||
|
||||
|
||||
def human_recall(data):
|
||||
conn = connect()
|
||||
mid = data.get('mid')
|
||||
# Only the first request can change the original message; retries emit no duplicate event.
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
row = conn.execute('SELECT session_id, side, content FROM human_messages WHERE id=?', (mid,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return ({'ok': False}, 404)
|
||||
if row[1] == 'system' and row[2] == '该消息已撤回':
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
if row[1] != 'agent':
|
||||
conn.close()
|
||||
return ({'ok': False}, 400)
|
||||
conn.execute("UPDATE human_messages SET side='system', content='该消息已撤回' WHERE id=?", (mid,))
|
||||
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime, recalled_id) VALUES(?,?,?,?,?)',
|
||||
(row[0], 'system', '该消息已撤回', datetime.now().strftime('%Y-%m-%d %H:%M:%S'), mid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
def human_typing(data, typing_map):
|
||||
sid = data.get('sid')
|
||||
typing_map[str(sid)] = time.time()
|
||||
|
||||
@@ -18,7 +18,20 @@ def admin_login():
|
||||
|
||||
@bp.route('/api/export_chat')
|
||||
def export_chat():
|
||||
return Response(business_service.export_chat(), mimetype='text/csv', headers={'Content-Disposition': 'attachment;filename=chat_export.csv'})
|
||||
format = 'csv' if request.args.get('format') == 'csv' else 'xlsx'
|
||||
mimetype = 'text/csv' if format == 'csv' else 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
return Response(business_service.export_chat(format), mimetype=mimetype,
|
||||
headers={'Content-Disposition': 'attachment;filename=chat_export.' + format})
|
||||
|
||||
|
||||
@bp.route('/api/agents/list')
|
||||
def agents_list():
|
||||
return business_service.agents_list()
|
||||
|
||||
|
||||
@bp.route('/api/search_chat')
|
||||
def search_chat():
|
||||
return business_service.search_chat(request.args.get('q', '').strip())
|
||||
|
||||
|
||||
@bp.route('/admin')
|
||||
|
||||
@@ -19,3 +19,8 @@ def add_ticket():
|
||||
@bp.route('/api/tickets')
|
||||
def list_tickets():
|
||||
return business_service.list_tickets()
|
||||
|
||||
|
||||
@bp.route('/api/rate', methods=['POST'])
|
||||
def rate_chat():
|
||||
return business_service.rate_chat(request.json or {})
|
||||
|
||||
@@ -41,6 +41,21 @@ def human_close():
|
||||
return human_service.human_close(request.json)
|
||||
|
||||
|
||||
@bp.route('/api/human/transfer', methods=['POST'])
|
||||
def human_transfer():
|
||||
return human_service.human_transfer(request.json or {}, current_app.extensions['human_typing'])
|
||||
|
||||
|
||||
@bp.route('/api/human/note', methods=['POST'])
|
||||
def human_note():
|
||||
return human_service.human_note(request.json or {})
|
||||
|
||||
|
||||
@bp.route('/api/human/recall', methods=['POST'])
|
||||
def human_recall():
|
||||
return human_service.human_recall(request.json or {})
|
||||
|
||||
|
||||
@bp.route('/api/human/typing', methods=['POST'])
|
||||
def human_typing():
|
||||
return human_service.human_typing(request.json, current_app.extensions['human_typing'])
|
||||
|
||||
@@ -9,6 +9,7 @@ iniconfig==2.3.1
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.4
|
||||
openpyxl>=3.1,<4
|
||||
packaging==26.3
|
||||
pluggy==1.7.0
|
||||
Pygments==2.21.0
|
||||
|
||||
@@ -60,7 +60,7 @@ if not errorlevel 1 (
|
||||
echo OK - port is free.
|
||||
|
||||
echo [2/3] Checking dependencies ...
|
||||
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv" >nul 2>nul
|
||||
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv, openpyxl" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Missing packages, installing ...
|
||||
"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt
|
||||
|
||||
@@ -107,6 +107,21 @@ body:not(.has-active-session) .close { display: none; }
|
||||
#reply-send:hover:enabled { background: var(--brand-hover); }
|
||||
.reply-status { font-size: 12px; line-height: 1.6; color: var(--success); padding-top: 7px; }
|
||||
.reply-status.is-error { color: var(--danger); }
|
||||
.system-msg { display: block; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
.recall-button { border: 0; background: transparent; padding: 0 0 0 10px; color: var(--muted); font-size: 11px; }
|
||||
.night-badge { padding: 4px 7px; border-radius: 5px; background: var(--brand-soft); color: var(--secondary); font-size: 11px; }
|
||||
.note-input { width: 100%; padding: 9px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); resize: vertical; font-size: 13px; }
|
||||
.search-dialog { width: min(660px, calc(100vw - 32px)); max-height: calc(100dvh - 60px); padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
|
||||
.search-dialog::backdrop { background: #19263d55; }
|
||||
.search-dialog header, .search-dialog form { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
|
||||
.search-dialog h2 { font-size: 17px; }
|
||||
.search-dialog input { flex: 1; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.search-dialog .quick { width: auto; margin: 0; }
|
||||
.search-result { padding: 14px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
||||
.search-result h3 { font-size: 14px; }
|
||||
.search-result p { margin: 8px 0; white-space: pre-wrap; font-size: 13px; }
|
||||
.search-result small { color: var(--muted); }
|
||||
@media (max-width: 720px) { .night-badge { display: none; } .participant-copy { min-width: 55px; } .chat-header-actions { gap: 4px; } }
|
||||
.side { display: none; min-height: 0; min-width: 0; overflow-y: auto; background: var(--surface); border-left: 1px solid var(--border); }
|
||||
.tools-open .layout { grid-template-columns: 300px minmax(0, 1fr) 310px; }
|
||||
.tools-open .side { display: block; }
|
||||
|
||||
@@ -93,6 +93,11 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||
#upload-btn { flex: 0 0 36px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--secondary); font-size: 18px; }
|
||||
#upload-btn:hover { color: var(--brand-text); }
|
||||
.service-rating { margin: 12px auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); text-align: center; color: var(--secondary); font-size: 13px; }
|
||||
.rating-choices { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
|
||||
.rating-choices button { padding: 3px 8px; background: transparent; border: 0; color: var(--muted); font-size: 28px; }
|
||||
.rating-choices button.selected, .rating-choices button:focus-visible { color: #bd8610; }
|
||||
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
|
||||
#input-text::placeholder { color: var(--muted); }
|
||||
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
|
||||
|
||||
@@ -76,6 +76,12 @@ svg { display: block; flex-shrink: 0; }
|
||||
border: 1px solid var(--border); border-radius: 9px; color: var(--secondary); background: var(--surface);
|
||||
}
|
||||
.icon-button:hover { color: var(--brand-text); background: var(--brand-soft); }
|
||||
.text-button { border: 0; padding: 5px 0; background: transparent; color: var(--brand-text); font-size: 12px; }
|
||||
.emoji-control { position: relative; flex-shrink: 0; }
|
||||
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
|
||||
.emoji-picker button:hover { background: var(--surface-hover); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; }
|
||||
|
||||
+33
-6
@@ -48,6 +48,7 @@ function updateComposer(){
|
||||
document.querySelectorAll(".quick").forEach(function(button){
|
||||
button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||
});
|
||||
updateAdminTools();
|
||||
}
|
||||
|
||||
function resizeReply(){
|
||||
@@ -95,11 +96,12 @@ function renderQueue(){
|
||||
|
||||
async function loadQueue(){
|
||||
try{
|
||||
queueItems = await adminRequest("/api/human/queue");
|
||||
renderQueue();
|
||||
if(!curSid && queueItems.length && !sessions[String(queueItems[0].id)]){
|
||||
accept(queueItems[0].id);
|
||||
const list = await adminRequest("/api/human/queue");
|
||||
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
|
||||
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
|
||||
}
|
||||
queueItems = list;
|
||||
renderQueue();
|
||||
}catch(error){
|
||||
document.getElementById("queue").innerHTML = '<div class="list-empty">队列暂时无法加载,稍后重试</div>';
|
||||
}
|
||||
@@ -153,6 +155,7 @@ function switchSession(sid){
|
||||
resizeReply();
|
||||
document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight;
|
||||
setReplyStatus("");
|
||||
loadSessionNote(sid);
|
||||
updateComposer();
|
||||
renderList();
|
||||
setSessionTab("active");
|
||||
@@ -168,6 +171,7 @@ function showEmptySession(){
|
||||
document.getElementById("chatTitle").textContent = "会话接待";
|
||||
document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户";
|
||||
replyInput.value = "";
|
||||
document.getElementById("noteInput").value = "";
|
||||
setReplyStatus("");
|
||||
updateComposer();
|
||||
setChatView(false);
|
||||
@@ -177,12 +181,28 @@ function appendMsg(message){
|
||||
const box = document.getElementById("chatBox");
|
||||
const follow = box.scrollHeight - box.scrollTop - box.clientHeight < 100;
|
||||
const div = document.createElement("div");
|
||||
div.setAttribute("data-message-id", String(message.id));
|
||||
if(message.side === "system"){
|
||||
div.className = "msg system-msg";
|
||||
div.textContent = message.content;
|
||||
box.appendChild(div);
|
||||
if(follow) box.scrollTop = box.scrollHeight;
|
||||
return;
|
||||
}
|
||||
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
|
||||
let content = escapeText(message.content);
|
||||
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
|
||||
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
|
||||
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
|
||||
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
|
||||
if(message.side === "agent"){
|
||||
const recall = document.createElement("button");
|
||||
recall.type = "button";
|
||||
recall.className = "recall-button";
|
||||
recall.textContent = "撤回";
|
||||
recall.onclick = function(){ recallMsg(message.id); };
|
||||
div.querySelector(".meta").appendChild(recall);
|
||||
}
|
||||
box.appendChild(div);
|
||||
if(follow || message.side === "agent") box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
@@ -234,7 +254,7 @@ async function poll(){
|
||||
const session = sessions[sid];
|
||||
const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId);
|
||||
if(sessions[sid] !== session) return;
|
||||
if(state.status === "closed"){
|
||||
if(state.status !== "active"){
|
||||
delete sessions[sid];
|
||||
delete drafts[sid];
|
||||
if(sid === curSid) showEmptySession();
|
||||
@@ -243,8 +263,10 @@ async function poll(){
|
||||
state.messages.forEach(function(message){
|
||||
if(session.msgs.some(function(existing){ return existing.id === message.id; })) return;
|
||||
session.lastId = Math.max(session.lastId, message.id);
|
||||
if(message.recalled_id && applyAdminRecall(sid, message.recalled_id)) return;
|
||||
session.msgs.push(message);
|
||||
if(message.side === "customer" && sid !== curSid) session.unread += 1;
|
||||
if(message.side === "customer") SupportUI.notify("客户消息 #"+sid, message.content.substring(0, 50));
|
||||
if(sid === curSid) appendMsg(message);
|
||||
});
|
||||
}));
|
||||
@@ -370,7 +392,7 @@ document.addEventListener("keydown", function(event){
|
||||
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
|
||||
if(event.key === "Escape") document.getElementById("data-overview").open = false;
|
||||
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
|
||||
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href]'));
|
||||
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
|
||||
const first = items[0], last = items[items.length-1];
|
||||
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
|
||||
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
|
||||
@@ -381,7 +403,12 @@ adminRequest("/api/admin/stats").then(function(data){
|
||||
[data.total_conv, data.total_msg, data.today_conv, data.good, data.bad].forEach(function(value, index){
|
||||
document.getElementById("s"+(index+1)).textContent = value;
|
||||
});
|
||||
document.getElementById("s6").textContent = (data.rating_avg || 0) + " / 5("+(data.rating_count || 0)+")";
|
||||
}).catch(function(){});
|
||||
SupportUI.initEmoji(document.getElementById("agent-emoji-toggle"), document.getElementById("agent-emoji-panel"), replyInput, function(){
|
||||
if(curSid) drafts[curSid] = replyInput.value;
|
||||
resizeReply();
|
||||
});
|
||||
setToolsOpen(false);
|
||||
setSessionTab("active");
|
||||
updateComposer();
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Workspace notes, transfers, recalls and historical message search.
|
||||
function updateAdminTools(){
|
||||
document.getElementById('transfer-session').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||
document.getElementById('agent-emoji-toggle').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||
document.getElementById('noteInput').disabled = !curSid || Boolean(sessions[curSid].noteLoading);
|
||||
document.getElementById('note-save').disabled = !curSid || !sessions[curSid].noteLoaded || Boolean(sessions[curSid].noteLoading);
|
||||
}
|
||||
|
||||
async function loadSessionNote(sid){
|
||||
const session = sessions[sid];
|
||||
const input = document.getElementById('noteInput');
|
||||
input.value = session.noteDraft || '';
|
||||
if(session.noteLoaded){ updateAdminTools(); return; }
|
||||
session.noteLoading = true;
|
||||
updateAdminTools();
|
||||
try{
|
||||
const state = await adminRequest('/api/human/poll?sid='+Number(sid)+'&after=0');
|
||||
if(sessions[sid] !== session) return;
|
||||
session.noteDraft = state.note || '';
|
||||
session.noteLoaded = true;
|
||||
if(curSid === sid) input.value = session.noteDraft;
|
||||
}catch(error){
|
||||
if(curSid === sid) setReplyStatus('备注加载失败,请重新选择会话后重试。', true);
|
||||
}finally{
|
||||
session.noteLoading = false;
|
||||
updateAdminTools();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote(){
|
||||
if(!curSid || !sessions[curSid].noteLoaded) return;
|
||||
const sid = curSid, session = sessions[sid];
|
||||
const note = document.getElementById('noteInput').value;
|
||||
session.noteDraft = note;
|
||||
try{
|
||||
await postAdmin('/api/human/note', {sid:Number(sid), note:note});
|
||||
if(curSid === sid) setReplyStatus(session.noteDraft === note ? '备注已保存' : '先前备注已保存,当前修改尚未保存');
|
||||
}catch(error){
|
||||
if(curSid === sid) setReplyStatus('备注保存失败,内容已保留。', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function transferSession(){
|
||||
if(!curSid || closingSid || sendingSid) return;
|
||||
if(!window.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。')) return;
|
||||
const sid = curSid;
|
||||
closingSid = sid;
|
||||
updateComposer();
|
||||
try{
|
||||
await postAdmin('/api/human/transfer', {sid:Number(sid)});
|
||||
delete sessions[sid];
|
||||
if(curSid === sid) showEmptySession();
|
||||
renderList();
|
||||
loadQueue();
|
||||
}catch(error){
|
||||
if(curSid === sid) setReplyStatus('转接未成功,会话仍保留。', true);
|
||||
}finally{
|
||||
closingSid = '';
|
||||
updateComposer();
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdminRecall(sid, mid){
|
||||
const message = sessions[sid].msgs.find(function(item){ return item.id === mid; });
|
||||
if(message){ message.side = 'system'; message.content = '该消息已撤回'; }
|
||||
if(curSid === sid){
|
||||
const row = document.querySelector('#chatBox [data-message-id="'+Number(mid)+'"]');
|
||||
if(row){ row.className = 'msg system-msg'; row.textContent = '该消息已撤回'; }
|
||||
}
|
||||
return Boolean(message);
|
||||
}
|
||||
|
||||
async function recallMsg(mid){
|
||||
if(!curSid) return;
|
||||
const sid = curSid;
|
||||
try{
|
||||
await postAdmin('/api/human/recall', {mid:mid});
|
||||
if(sessions[sid]){ applyAdminRecall(sid, mid); renderList(); }
|
||||
}catch(error){
|
||||
if(curSid === sid) setReplyStatus('撤回失败,请稍后重试。', true);
|
||||
}
|
||||
}
|
||||
|
||||
let searchVersion = 0;
|
||||
async function doSearch(){
|
||||
const keyword = document.getElementById('searchBox').value.trim();
|
||||
const version = ++searchVersion;
|
||||
const body = document.getElementById('searchBody');
|
||||
if(!keyword){ body.textContent = '请输入关键词搜索历史消息'; return; }
|
||||
body.textContent = '正在搜索…';
|
||||
try{
|
||||
const list = await adminRequest('/api/search_chat?q='+encodeURIComponent(keyword));
|
||||
if(version !== searchVersion) return;
|
||||
const roles = {user:'客户', assistant:'客服', system:'系统'};
|
||||
body.innerHTML = list.length ? list.map(function(item){
|
||||
return '<article class="search-result"><h3>'+escapeText(item.title)+'</h3><p>'+escapeText(item.content)+'</p><small>'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'</small></article>';
|
||||
}).join('') : '没有匹配的历史消息';
|
||||
}catch(error){
|
||||
if(version === searchVersion) body.textContent = '搜索失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('history-search-toggle').onclick = function(){
|
||||
document.getElementById('searchResult').showModal();
|
||||
document.getElementById('searchBox').focus();
|
||||
};
|
||||
document.getElementById('search-close').onclick = function(){ document.getElementById('searchResult').close(); };
|
||||
document.getElementById('history-search-form').addEventListener('submit', function(event){ event.preventDefault(); doSearch(); });
|
||||
document.getElementById('note-save').onclick = saveNote;
|
||||
document.getElementById('noteInput').addEventListener('input', function(){
|
||||
if(curSid) sessions[curSid].noteDraft = this.value;
|
||||
});
|
||||
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||
function updateNightBadge(){
|
||||
const hour = new Date().getHours();
|
||||
document.getElementById('nightBadge').hidden = hour >= 8 && hour < 22;
|
||||
}
|
||||
updateNightBadge();
|
||||
setInterval(updateNightBadge, 60000);
|
||||
+97
-39
@@ -8,6 +8,10 @@ const composerStatus = document.getElementById("composer-status");
|
||||
let uploadPending = false;
|
||||
let uploadVersion = 0;
|
||||
let followLatest = true;
|
||||
let creatingConv = null;
|
||||
let sendPreparing = false;
|
||||
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||
|
||||
function setComposerStatus(message, isError){
|
||||
composerStatus.textContent = message || "";
|
||||
@@ -165,6 +169,16 @@ const I18N = {
|
||||
thinking: "正在思考中...",
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。",
|
||||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
offlinePrompt: "请输入您的留言:",
|
||||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||||
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
|
||||
humanReply: "客服回复",
|
||||
recalled: "该消息已撤回",
|
||||
ratingQuestion: "本次服务满意吗?",
|
||||
ratingThanks: "感谢您的评价!",
|
||||
ratingFailed: "评价未提交,请重试。",
|
||||
networkFail: "网络连接中断,请检查网络后重试",
|
||||
stopped: "已停止生成",
|
||||
stoppedMark: "(已停止生成)",
|
||||
@@ -228,6 +242,16 @@ const I18N = {
|
||||
thinking: "Thinking...",
|
||||
busyRetry: "Service busy, please try again later",
|
||||
sessionFail: "Failed to create session, please check the service is running",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. How can we help? Enter a tracking number or choose a quick option below.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
|
||||
offlinePrompt: "Please leave your message:",
|
||||
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
|
||||
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
|
||||
humanReply: "Support reply",
|
||||
recalled: "This message was recalled",
|
||||
ratingQuestion: "How was your support experience?",
|
||||
ratingThanks: "Thank you for your feedback!",
|
||||
ratingFailed: "Your rating was not submitted. Please try again.",
|
||||
networkFail: "Connection lost, please check your network and retry",
|
||||
stopped: "Stopped",
|
||||
stoppedMark: "(Generation stopped)",
|
||||
@@ -622,12 +646,19 @@ function renderRecentChat(){
|
||||
// 没有会话就先建一个,拿到 conv_id
|
||||
function ensureConv(){
|
||||
if(currentConvId) return Promise.resolve(currentConvId);
|
||||
return fetch("/api/new_chat", {method:"POST"})
|
||||
.then(res=>res.json())
|
||||
if(creatingConv) return creatingConv;
|
||||
creatingConv = fetch("/api/new_chat", {method:"POST"})
|
||||
.then(async function(res){
|
||||
const data = await res.json();
|
||||
if(!res.ok || !data.id) throw new Error('Session failed');
|
||||
return data;
|
||||
})
|
||||
.then(data=>{
|
||||
currentConvId = data.id;
|
||||
if(data.welcome) addMsg(t('autoWelcome'), false);
|
||||
return currentConvId;
|
||||
});
|
||||
}).finally(function(){ creatingConv = null; });
|
||||
return creatingConv;
|
||||
}
|
||||
|
||||
// 打开历史会话:还原整段对话,而不是把标题重发一遍
|
||||
@@ -791,21 +822,15 @@ window.showHuman = async function(){
|
||||
return;
|
||||
}
|
||||
var now = new Date();
|
||||
var day = now.getDay();
|
||||
var h = now.getHours();
|
||||
var work = (day >= 1 && day <= 5) && (h >= 9 && h < 19);
|
||||
var work = h >= 8 && h < 22;
|
||||
if(!work){
|
||||
addMsg("人工客服工作时间:周一至周五 9:00-19:00\n当前非工作时间,请留言,工作时间会尽快回复。", false);
|
||||
var msg = window.prompt("请输入您的留言:");
|
||||
if(msg){
|
||||
fetch("/api/ticket",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:msg, phone:""})});
|
||||
addMsg("留言已提交,工作时间会尽快回复您。", false);
|
||||
}
|
||||
leaveOfflineMessage();
|
||||
return;
|
||||
}
|
||||
addMsg("正在为您转接人工客服...", false);
|
||||
if(!currentConvId){
|
||||
try{ await ensureConv(); }catch(e){}
|
||||
try{ await ensureConv(); }catch(e){ setComposerStatus(t('sessionFail'), true); return; }
|
||||
}
|
||||
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
|
||||
.then(function(r){return r.json()})
|
||||
@@ -814,6 +839,8 @@ window.showHuman = async function(){
|
||||
window.humanSid = d.session_id;
|
||||
addMsg("您已加入人工排队,请稍候...", false);
|
||||
startHumanPoll();
|
||||
}else if(d.closed){
|
||||
leaveOfflineMessage();
|
||||
}else{
|
||||
addMsg("转接失败,请稍后再试。", false);
|
||||
}
|
||||
@@ -823,19 +850,61 @@ window.showHuman = async function(){
|
||||
});
|
||||
}
|
||||
window.humanSid = 0;
|
||||
async function leaveOfflineMessage(){
|
||||
addMsg(t('humanHours'), false);
|
||||
const message = window.prompt(t('offlinePrompt'));
|
||||
if(!message) return;
|
||||
try{
|
||||
const response = await fetch('/api/ticket', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({content:message, phone:''})});
|
||||
const data = await response.json();
|
||||
if(!response.ok || !data.ok) throw new Error('Ticket failed');
|
||||
addMsg(t('offlineSaved'), false);
|
||||
}catch(error){ setComposerStatus(t('networkFail'), true); }
|
||||
}
|
||||
|
||||
function startHumanPoll(){
|
||||
let last=0;
|
||||
const t=setInterval(()=>{
|
||||
if(!window.humanSid){ clearInterval(t); return; }
|
||||
fetch("/api/human/poll?sid="+window.humanSid+"&after="+last)
|
||||
let polling=false;
|
||||
const sid=window.humanSid;
|
||||
const pollTimer=setInterval(()=>{
|
||||
if(window.humanSid !== sid){ clearInterval(pollTimer); return; }
|
||||
if(polling) return;
|
||||
polling=true;
|
||||
fetch("/api/human/poll?sid="+sid+"&after="+last)
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
if(window.humanSid !== sid) return;
|
||||
d.messages.forEach(m=>{
|
||||
if(m.id <= last) return;
|
||||
last = m.id;
|
||||
if(m.recalled_id){
|
||||
const recalled = chatDom.querySelector('[data-human-message-id="'+Number(m.recalled_id)+'"]');
|
||||
if(recalled){ recalled.className='human-system-message'; recalled.textContent=t('recalled'); return; }
|
||||
}
|
||||
if(m.side === 'system'){
|
||||
const system = document.createElement('div');
|
||||
system.className = 'human-system-message';
|
||||
system.setAttribute('data-human-message-id', String(m.id));
|
||||
system.textContent = m.content;
|
||||
chatDom.appendChild(system);
|
||||
}else if(m.side === 'agent'){
|
||||
const bubble = addMsg(m.content, false);
|
||||
bubble.closest('.msg-row').setAttribute('data-human-message-id', String(m.id));
|
||||
SupportUI.notify(t('humanReply'), m.content.substring(0, 50));
|
||||
}
|
||||
const typing = document.getElementById('human-typing');
|
||||
if(typing) typing.remove();
|
||||
});
|
||||
if(d.status==="closed"){
|
||||
clearInterval(t);
|
||||
clearInterval(pollTimer);
|
||||
window.humanSid=0;
|
||||
window.__busy=false;
|
||||
if(typeof setBusy==="function") setBusy(false);
|
||||
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
|
||||
const typing = document.getElementById('human-typing');
|
||||
if(typing) typing.remove();
|
||||
addMsg(t('humanClosed'), false);
|
||||
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
|
||||
scrollChatToEnd();
|
||||
return;
|
||||
}
|
||||
// 客服正在输入
|
||||
@@ -850,15 +919,8 @@ function startHumanPoll(){
|
||||
}else if(!d.typing && oldT){
|
||||
oldT.remove();
|
||||
}
|
||||
d.messages.forEach(m=>{
|
||||
if(m.id <= last) return;
|
||||
last = m.id;
|
||||
if(m.side==="agent") addMsg(m.content, false);
|
||||
var oldT2 = document.getElementById("human-typing");
|
||||
if(oldT2) oldT2.remove();
|
||||
});
|
||||
})
|
||||
.catch(()=>{});
|
||||
.catch(()=>{}).finally(function(){ polling=false; });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@@ -1089,9 +1151,18 @@ async function doSend(retryText){
|
||||
return;
|
||||
}
|
||||
|
||||
if(window.__busy && !retryText) return; // 防连点
|
||||
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
|
||||
sendPreparing = true;
|
||||
try{
|
||||
await ensureConv();
|
||||
}catch(error){
|
||||
setComposerStatus(t('sessionFail'), true);
|
||||
return;
|
||||
}finally{
|
||||
sendPreparing = false;
|
||||
}
|
||||
if(!isRetry){
|
||||
addMsg(fullContent, true);
|
||||
inputDom.value = "";
|
||||
@@ -1101,19 +1172,6 @@ async function doSend(retryText){
|
||||
inputDom.focus();
|
||||
}
|
||||
|
||||
// 没有会话就先建一个,后面所有消息都挂在它下面
|
||||
try{
|
||||
await ensureConv();
|
||||
}catch(err){
|
||||
const bubble = addMsg("", false);
|
||||
renderError(bubble, t("sessionFail"), function(){
|
||||
const row = bubble.closest(".msg-row");
|
||||
if(row) row.remove();
|
||||
doSend(fullContent);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const aiBubble = addMsg(t("thinking"), false);
|
||||
aiBubble.classList.add("typing");
|
||||
activeBubble = aiBubble;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Shared controls for the customer page and service workspace.
|
||||
var SupportUI = {
|
||||
async enableNotifications(button){
|
||||
if(!('Notification' in window)) return;
|
||||
try{
|
||||
const permission = await Notification.requestPermission();
|
||||
button.setAttribute('aria-pressed', String(permission === 'granted'));
|
||||
}catch(error){}
|
||||
},
|
||||
notify(title, body){
|
||||
if(!document.hidden || !('Notification' in window) || Notification.permission !== 'granted') return;
|
||||
try{
|
||||
const notice = new Notification(title, {body:body});
|
||||
notice.onclick = function(){ window.focus(); notice.close(); };
|
||||
}catch(error){}
|
||||
},
|
||||
addRating(container, sid, labels){
|
||||
const box = document.createElement('section');
|
||||
box.className = 'service-rating';
|
||||
const question = document.createElement('p');
|
||||
question.textContent = labels.question;
|
||||
box.appendChild(question);
|
||||
const choices = document.createElement('div');
|
||||
choices.className = 'rating-choices';
|
||||
box.appendChild(choices);
|
||||
const status = document.createElement('p');
|
||||
status.setAttribute('role', 'status');
|
||||
box.appendChild(status);
|
||||
let sending = false;
|
||||
[1, 2, 3, 4, 5].forEach(function(stars){
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = '★';
|
||||
button.setAttribute('aria-label', stars+' / 5');
|
||||
button.onmouseenter = function(){
|
||||
Array.from(choices.children).forEach(function(item, index){ item.classList.toggle('selected', index < stars); });
|
||||
};
|
||||
button.onclick = async function(){
|
||||
if(sending) return;
|
||||
sending = true;
|
||||
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
|
||||
try{
|
||||
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
||||
const data = await response.json();
|
||||
if(!response.ok || !data.ok) throw new Error('Rating failed');
|
||||
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
|
||||
}catch(error){
|
||||
sending = false;
|
||||
status.textContent = labels.failed;
|
||||
Array.from(choices.children).forEach(function(item){ item.disabled = false; });
|
||||
}
|
||||
};
|
||||
choices.appendChild(button);
|
||||
});
|
||||
container.appendChild(box);
|
||||
return box;
|
||||
},
|
||||
initEmoji(button, panel, input, onInsert){
|
||||
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
|
||||
button.onclick = function(){
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute('aria-expanded', String(!panel.hidden));
|
||||
};
|
||||
emojis.forEach(function(emoji){
|
||||
const choice = document.createElement('button');
|
||||
choice.type = 'button';
|
||||
choice.textContent = emoji;
|
||||
choice.onclick = function(){
|
||||
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
|
||||
const end = input.selectionEnd == null ? start : input.selectionEnd;
|
||||
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
input.focus();
|
||||
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||
if(onInsert) onInsert();
|
||||
};
|
||||
panel.appendChild(choice);
|
||||
});
|
||||
panel.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape'){
|
||||
panel.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+11
-2
@@ -15,6 +15,8 @@
|
||||
<span class="nav-divider" aria-hidden="true"></span><h1>客服工作台</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="nightBadge" class="night-badge" hidden>非工作时段</span>
|
||||
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
|
||||
<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>
|
||||
@@ -25,7 +27,8 @@
|
||||
<div class="stat"><span>今日新对话</span><b id="s3">—</b></div>
|
||||
<div class="stat"><span>有帮助</span><b id="s4">—</b></div>
|
||||
<div class="stat"><span>需改进</span><b id="s5">—</b></div>
|
||||
<a class="export-link" href="/api/export_chat">导出记录 ↗</a>
|
||||
<div class="stat"><span>服务评价</span><b id="s6">—</b></div>
|
||||
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -36,6 +39,7 @@
|
||||
<div class="list-heading">
|
||||
<h2>客户会话</h2><p>查看咨询,及时接待与回复</p>
|
||||
<div class="session-search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg><input id="session-search" type="search" aria-label="搜索客户会话" placeholder="搜索会话或消息" autocomplete="off"></div>
|
||||
<button id="history-search-toggle" class="text-button" type="button">搜索历史记录 ↗</button>
|
||||
</div>
|
||||
<div class="session-tabs" role="tablist" aria-label="会话状态">
|
||||
<button id="tab-active" type="button" role="tab" aria-selected="true" aria-controls="sessionList">接待中 <span id="active-count" class="section-count">0</span></button>
|
||||
@@ -54,6 +58,7 @@
|
||||
<div class="participant-copy"><h2 id="chatTitle">会话接待</h2><p id="chat-subtitle">选择一段会话,开始回复客户</p></div>
|
||||
<div class="chat-header-actions">
|
||||
<button id="tools-toggle" class="tools-toggle" type="button" aria-label="资料与快捷回复" aria-controls="service-tools" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="M6 16a3 3 0 0 1 6 0M14 8h3M14 12h3M14 16h3"/></svg><span>资料与回复</span></button>
|
||||
<button id="transfer-session" class="close" type="button" onclick="transferSession()">转接</button>
|
||||
<button id="close-session" class="close" type="button" onclick="closeSession()">结束会话</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -64,7 +69,7 @@
|
||||
<div id="chatContent" class="session-panel" style="display:none">
|
||||
<div id="chatBox" role="log" aria-live="polite" aria-label="会话消息"></div>
|
||||
<footer class="agent-composer">
|
||||
<div class="composer-heading"><label for="replyText">回复客户</label><span>Shift + Enter 换行</span></div>
|
||||
<div class="composer-heading"><label for="replyText">回复客户</label><div class="emoji-control"><button id="agent-emoji-toggle" class="text-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="agent-emoji-panel">😊 表情</button><div id="agent-emoji-panel" class="emoji-picker" hidden></div></div><span>Shift + Enter 换行</span></div>
|
||||
<div class="input-bar">
|
||||
<textarea id="replyText" rows="2" aria-label="回复客户" placeholder="输入回复内容,按 Enter 发送…"></textarea>
|
||||
<div class="input-actions"><span>常用回复可在「资料与回复」中选择</span><button id="reply-send" type="button" onclick="sendReply()">发送</button></div>
|
||||
@@ -76,6 +81,7 @@
|
||||
<aside class="side" id="service-tools" aria-label="客户信息与回复" aria-hidden="true" inert>
|
||||
<header class="side-heading"><h2>服务资料</h2><button id="tools-close" type="button" class="icon-button" aria-label="关闭服务资料">×</button></header>
|
||||
<section class="card"><h3>客户信息</h3><div class="info-row" id="cInfo">选择会话后查看客户信息</div><p class="info-hint">咨询运费时,可先确认运单号、货物重量、尺寸与目的地。</p></section>
|
||||
<section class="card"><h3><label for="noteInput">会话备注</label></h3><textarea id="noteInput" class="note-input" rows="3" placeholder="填写本次会话的跟进事项" disabled></textarea><button id="note-save" type="button" class="quick" disabled>保存备注</button></section>
|
||||
<section class="card"><h3>常用回复</h3>
|
||||
<p class="card-caption">点击填入草稿,编辑后再发送</p>
|
||||
<button type="button" class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,有什么可以帮您?</button>
|
||||
@@ -87,6 +93,9 @@
|
||||
</aside>
|
||||
</main>
|
||||
<button class="tools-backdrop" id="tools-backdrop" type="button" aria-label="关闭服务资料"></button>
|
||||
<dialog id="searchResult" class="search-dialog" aria-labelledby="search-title"><header><h2 id="search-title">搜索历史记录</h2><button id="search-close" class="icon-button" type="button" aria-label="关闭搜索">×</button></header><form id="history-search-form"><label class="sr-only" for="searchBox">历史消息关键词</label><input id="searchBox" type="search" placeholder="输入关键词搜索聊天记录" required><button type="submit" class="quick">搜索</button></form><div id="searchBody" aria-live="polite">请输入关键词搜索历史消息</div></dialog>
|
||||
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/admin_tools.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<button class="toggle-divider" type="button" aria-label="切换历史侧栏" aria-expanded="true"></button>
|
||||
<div class="header-copy"><div class="header-kicker">BAIYUE · CUSTOMER SUPPORT</div><span id="app-title">柏悦物流智能助手</span></div>
|
||||
<div class="title-actions">
|
||||
<button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
|
||||
<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>
|
||||
@@ -57,6 +58,7 @@
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
@@ -67,6 +69,7 @@
|
||||
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
|
||||
</main>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,8 @@ 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.js'), 'utf8');
|
||||
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
|
||||
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
function response(data, status = 200){
|
||||
@@ -32,6 +34,13 @@ async function workspace(compact = false){
|
||||
appendChild(element){ this.children.push(element); }
|
||||
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||
querySelectorAll(){ return []; }
|
||||
querySelector(selector){
|
||||
if(selector === '.meta') return this.meta || (this.meta = new Element());
|
||||
return null;
|
||||
}
|
||||
setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; }
|
||||
showModal(){ this.open = true; }
|
||||
close(){ this.open = false; }
|
||||
focus(){ document.activeElement = this; }
|
||||
click(){ if(this.onclick) return this.onclick(); }
|
||||
}
|
||||
@@ -41,24 +50,36 @@ async function workspace(compact = false){
|
||||
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
|
||||
createElement(){ return new Element(); },
|
||||
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
|
||||
querySelector(selector){
|
||||
const match = selector.match(/data-message-id="(\d+)"/);
|
||||
if(!match) return null;
|
||||
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
|
||||
},
|
||||
addEventListener(name, callback){ documentListeners[name] = callback; },
|
||||
};
|
||||
const calls = [];
|
||||
const hooks = {};
|
||||
const context = vm.createContext({
|
||||
document, console,
|
||||
window: {matchMedia: () => ({matches: compact}), addEventListener(){}},
|
||||
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
|
||||
setInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||
fetch: async (url, options) => {
|
||||
const body = options && options.body ? JSON.parse(options.body) : undefined;
|
||||
calls.push({url, body});
|
||||
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
|
||||
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
|
||||
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
|
||||
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
|
||||
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
|
||||
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
|
||||
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
|
||||
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
|
||||
return response(options ? {ok:true} : []);
|
||||
},
|
||||
});
|
||||
vm.runInContext(sharedSource, context, {filename:'support.js'});
|
||||
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
|
||||
vm.runInContext(source, context, {filename:'admin.js'});
|
||||
await flush();
|
||||
await context.accept(7);
|
||||
@@ -211,3 +232,67 @@ test('mobile drafts are measured after the chat view becomes visible', async ()
|
||||
assert.equal(input.style.height, '128px');
|
||||
assert.equal(input.value, '需要恢复的多行草稿');
|
||||
});
|
||||
|
||||
test('transfer returns a conversation to the queue without auto-accepting it again', async () => {
|
||||
const app = await workspace();
|
||||
app.hooks.queue = () => response([{id:7, ctime:'now'}]);
|
||||
await app.context.transferSession();
|
||||
await flush();
|
||||
assert.equal(app.context.curSid, 0);
|
||||
assert.equal(app.context.sessions['7'], undefined);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1);
|
||||
});
|
||||
|
||||
test('failed transfers retain the conversation and draft', async () => {
|
||||
const app = await workspace();
|
||||
app.hooks.transfer = () => response({ok:false}, 503);
|
||||
app.element('replyText').value = '等待确认的草稿';
|
||||
await app.context.transferSession();
|
||||
assert.equal(app.context.curSid, '7');
|
||||
assert.equal(app.element('replyText').value, '等待确认的草稿');
|
||||
assert.match(app.element('reply-status').textContent, /转接未成功/);
|
||||
});
|
||||
|
||||
test('a late note response cannot overwrite a different session note', async () => {
|
||||
const app = await workspace();
|
||||
app.context.sessions['7'].noteLoaded = false;
|
||||
let resolve;
|
||||
app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]});
|
||||
const first = app.context.loadSessionNote('7');
|
||||
app.context.sessions['8'] = {lastId:0, msgs:[], unread:0};
|
||||
app.context.switchSession('8');
|
||||
await flush();
|
||||
resolve(response({status:'active', note:'客户 7 的备注', messages:[]}));
|
||||
await first;
|
||||
assert.equal(app.element('noteInput').value, '客户 8 的备注');
|
||||
});
|
||||
|
||||
test('failed note saves keep the edited note available', async () => {
|
||||
const app = await workspace();
|
||||
app.hooks.note = () => response({ok:false}, 503);
|
||||
app.element('noteInput').value = '下个班次继续跟进';
|
||||
await app.context.saveNote();
|
||||
assert.equal(app.element('noteInput').value, '下个班次继续跟进');
|
||||
assert.match(app.element('reply-status').textContent, /备注保存失败/);
|
||||
});
|
||||
|
||||
test('recalling an older reply leaves the newest reply intact', async () => {
|
||||
const app = await workspace();
|
||||
app.context.sessions['7'].msgs = [
|
||||
{id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'},
|
||||
];
|
||||
app.context.sessions['7'].msgs.forEach(app.context.appendMsg);
|
||||
await app.context.recallMsg(1);
|
||||
assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回');
|
||||
assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复');
|
||||
assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回');
|
||||
});
|
||||
|
||||
test('historical search renders customer text without interpreting HTML', async () => {
|
||||
const app = await workspace();
|
||||
app.hooks.search = () => response([{title:'<img onerror=bad()>', content:'<script>bad()</script>', role:'user', ctime:'now'}]);
|
||||
app.element('searchBox').value = '物流';
|
||||
await app.context.doSearch();
|
||||
assert.match(app.element('searchBody').innerHTML, /<script>/);
|
||||
assert.doesNotMatch(app.element('searchBody').innerHTML, /<script>/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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 flush = () => new Promise(setImmediate);
|
||||
|
||||
async function customerPage(){
|
||||
const elements = new Map(), listeners = {}, intervals = [], calls = [];
|
||||
class Element {
|
||||
constructor(){
|
||||
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
|
||||
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
|
||||
};
|
||||
}
|
||||
set innerHTML(value){ this.html = value; this.children = []; }
|
||||
get innerHTML(){ return this.html || ''; }
|
||||
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
|
||||
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
|
||||
setAttribute(name, value){ this.attributes[name] = value; }
|
||||
getAttribute(name){ return this.attributes[name]; }
|
||||
addEventListener(){}
|
||||
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||
focus(){}
|
||||
querySelector(selector){
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
querySelectorAll(selector){
|
||||
const matches = element => {
|
||||
if(selector.startsWith('.')) return element.classList.contains(selector.slice(1));
|
||||
const id = selector.match(/data-human-message-id="(\d+)"/);
|
||||
return id && element.attributes['data-human-message-id'] === id[1];
|
||||
};
|
||||
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
|
||||
}
|
||||
closest(selector){ return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector); }
|
||||
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
}
|
||||
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
|
||||
const document = {
|
||||
body: new Element(), documentElement: {},
|
||||
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
|
||||
querySelector: selector => element(selector), querySelectorAll: () => [],
|
||||
createElement: () => new Element(),
|
||||
addEventListener: (name, callback) => { listeners[name] = callback; },
|
||||
};
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, 12])); } }
|
||||
let state = {status:'active', messages:[]};
|
||||
const context = vm.createContext({
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN'},
|
||||
localStorage:{getItem:() => null, setItem(){}},
|
||||
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false})},
|
||||
setInterval: callback => { intervals.push(callback); return intervals.length; },
|
||||
clearInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && JSON.parse(options.body)});
|
||||
const data = url === '/api/new_chat' ? {id:1, welcome:'welcome'}
|
||||
: url === '/api/human/request' ? {ok:true, session_id:7}
|
||||
: url.startsWith('/api/human/poll') ? state
|
||||
: url === '/api/rate' ? {ok:true} : [];
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'chat.js']){
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
|
||||
}
|
||||
listeners.DOMContentLoaded();
|
||||
await flush();
|
||||
await context.window.showHuman();
|
||||
await flush();
|
||||
return {context, element, calls, setState:value => { state=value; }, tick:async () => { intervals[0](); await flush(); }};
|
||||
}
|
||||
|
||||
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
|
||||
const app = await customerPage();
|
||||
app.setState({status:'closed', messages:[{id:1, side:'agent', content:'最后一条回复'}]});
|
||||
await app.tick();
|
||||
const log = app.element('chatDom');
|
||||
assert.match(log.textContent, /最后一条回复/);
|
||||
assert.match(log.textContent, /人工客服已结束/);
|
||||
const rating = log.querySelector('.service-rating');
|
||||
assert.ok(rating);
|
||||
await rating.querySelector('.rating-choices').children[4].onclick();
|
||||
assert.match(rating.textContent, /感谢您的评价/);
|
||||
assert.equal(app.calls.find(call => call.url === '/api/rate').body.stars, 5);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
});
|
||||
|
||||
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
|
||||
const app = await customerPage();
|
||||
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});
|
||||
await app.tick();
|
||||
app.setState({status:'active', messages:[{id:3, side:'system', content:'该消息已撤回', recalled_id:1}]});
|
||||
await app.tick();
|
||||
const log = app.element('chatDom');
|
||||
assert.match(log.textContent, /该消息已撤回/);
|
||||
assert.match(log.textContent, /最新回复/);
|
||||
assert.doesNotMatch(log.textContent, /早先的回复/);
|
||||
});
|
||||
+12
-7
@@ -77,7 +77,7 @@ class ApiTests(unittest.TestCase):
|
||||
for path in ('/', '/admin', '/admin/dashboard'):
|
||||
parser = Assets()
|
||||
parser.feed(self.client.get(path).get_data(as_text=True))
|
||||
self.assertEqual(len(parser.urls), 3)
|
||||
self.assertGreaterEqual(len(parser.urls), 3)
|
||||
for url in parser.urls:
|
||||
resource = self.client.get(url)
|
||||
self.assertEqual(resource.status_code, 200, url)
|
||||
@@ -91,15 +91,17 @@ class ApiTests(unittest.TestCase):
|
||||
self.assertEqual(events[-1], {'d': 1})
|
||||
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
|
||||
self.assertEqual(len(messages), 2)
|
||||
self.assertTrue(messages[1]['content'].startswith('__CARD__'))
|
||||
self.assertEqual(len(messages), 3)
|
||||
self.assertTrue(messages[-1]['content'].startswith('__CARD__'))
|
||||
self.assertEqual(self.client.get('/api/get_history').json[0]['title'], '15252125')
|
||||
self.assertEqual(self.client.delete(f'/api/del_history?id={cid}').json, {'ok': True})
|
||||
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json, [])
|
||||
|
||||
def test_empty_image_and_missing_key_streams(self):
|
||||
self.assertIn('e', frames(self.client.post('/stream_chat', json={'msg': ''}))[0])
|
||||
self.assertIn('收到您的图片', frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))[0]['t'])
|
||||
image_reply = frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))
|
||||
self.assertIn('收到您的图片', image_reply[0]['t'])
|
||||
self.assertEqual(image_reply[-1], {'d': 1})
|
||||
self.assertIn('API Key', frames(self.client.get('/stream_chat?msg=hello'))[0]['e'])
|
||||
|
||||
def test_attachment_filename_is_not_used_as_a_tracking_number(self):
|
||||
@@ -110,11 +112,14 @@ class ApiTests(unittest.TestCase):
|
||||
cid = self.conversation()
|
||||
events = frames(self.client.post('/stream_chat', json={'msg': '查询15252125 ' + attachment, 'conv_id': cid}))
|
||||
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[0]['content'], '查询15252125 ' + attachment)
|
||||
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
|
||||
|
||||
def test_human_lifecycle_typing_and_read_receipts(self):
|
||||
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
|
||||
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
|
||||
with patch('logistics_chat.human_service.datetime') as clock:
|
||||
from datetime import datetime
|
||||
clock.now.return_value = datetime(2026, 10, 29, 12)
|
||||
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
|
||||
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
|
||||
self.client.post('/api/human/accept', json={'sid': sid})
|
||||
self.client.post('/api/human/send', json={'sid': sid, 'side': 'customer', 'content': 'hello'})
|
||||
@@ -190,7 +195,7 @@ class ApiTests(unittest.TestCase):
|
||||
db.save_message(cid, 'user', 'hello, "quoted"\nworld')
|
||||
self.client.post('/api/feedback', json={'vote': 1, 'question': 'hello', 'answer': 'world'})
|
||||
self.assertEqual(self.client.get('/api/admin/stats').json['good'], 1)
|
||||
response = self.client.get('/api/export_chat')
|
||||
response = self.client.get('/api/export_chat?format=csv')
|
||||
self.assertEqual(response.mimetype, 'text/csv')
|
||||
self.assertIn('chat_export.csv', response.headers['Content-Disposition'])
|
||||
self.assertIn('"hello, ""quoted""\nworld"', response.get_data(as_text=True))
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Remote dev behavior on the modular backend and the light workspace."""
|
||||
|
||||
from datetime import datetime
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
|
||||
|
||||
class DevIntegrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
root = Path(directory.name)
|
||||
self.app = create_app({'TESTING': True, 'DATABASE': root/'test.db',
|
||||
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
|
||||
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def session(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
with patch('logistics_chat.human_service.datetime') as clock:
|
||||
clock.now.return_value = datetime(2026, 10, 31, 8)
|
||||
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
|
||||
return result['session_id']
|
||||
|
||||
def test_new_conversation_persists_one_welcome_before_user_message(self):
|
||||
data = self.client.post('/api/new_chat').json
|
||||
self.assertIn('欢迎', data['welcome'])
|
||||
self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': data['id']}, buffered=True)
|
||||
messages = self.client.get('/api/get_messages?conv_id='+str(data['id'])).json
|
||||
self.assertEqual([message['role'] for message in messages], ['assistant', 'user', 'assistant'])
|
||||
self.assertEqual(messages[0]['content'], data['welcome'])
|
||||
|
||||
def test_daily_service_hours_and_offline_ticket_on_fresh_database(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
for hour, open_now in ((7, False), (8, True), (21, True), (22, False)):
|
||||
with self.subTest(hour=hour), patch('logistics_chat.human_service.datetime') as clock:
|
||||
clock.now.return_value = datetime(2026, 10, 31, hour)
|
||||
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
|
||||
self.assertEqual(result['ok'], open_now)
|
||||
if not open_now:
|
||||
self.assertTrue(result['closed'])
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
|
||||
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
|
||||
|
||||
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
|
||||
sid = self.session()
|
||||
self.client.post('/api/human/accept', json={'sid': sid})
|
||||
self.client.post('/api/human/note', json={'sid': sid, 'note': '请跟进派送'})
|
||||
self.client.post('/api/human/send', json={'sid': sid, 'side': 'agent', 'content': '正在处理'})
|
||||
self.client.post('/api/human/typing', json={'sid': sid})
|
||||
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).json, {'ok': True})
|
||||
self.assertEqual(self.client.get('/api/human/active').json, [])
|
||||
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
|
||||
state = self.client.get(f'/api/human/poll?sid={sid}').json
|
||||
self.assertEqual(state['note'], '请跟进派送')
|
||||
self.assertFalse(state['typing'])
|
||||
self.assertEqual(state['messages'][0]['content'], '正在处理')
|
||||
self.assertEqual(state['messages'][-1]['side'], 'system')
|
||||
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).status_code, 409)
|
||||
self.client.post('/api/human/accept', json={'sid': sid})
|
||||
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
|
||||
|
||||
def test_recall_targets_older_agent_message_and_is_idempotent(self):
|
||||
sid = self.session()
|
||||
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
|
||||
self.client.post('/api/human/send', json={'sid': sid, 'side': side, 'content': text})
|
||||
before = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
|
||||
mid = before[0]['id']
|
||||
for _ in range(2):
|
||||
self.assertEqual(self.client.post('/api/human/recall', json={'mid': mid}).json, {'ok': True})
|
||||
self.assertEqual(self.client.post('/api/human/recall', json={'mid': before[1]['id']}).status_code, 400)
|
||||
self.assertEqual(self.client.post('/api/human/recall', json={'mid': 99999}).status_code, 404)
|
||||
updates = self.client.get(f'/api/human/poll?sid={sid}&after={before[-1]["id"]}').json['messages']
|
||||
self.assertEqual(len(updates), 1)
|
||||
self.assertEqual(updates[0]['recalled_id'], mid)
|
||||
all_messages = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
|
||||
self.assertEqual(all_messages[0]['content'], '该消息已撤回')
|
||||
self.assertEqual(all_messages[2]['content'], '最新回复')
|
||||
|
||||
def test_ratings_validate_stars_and_feed_admin_stats(self):
|
||||
sid = self.session()
|
||||
for stars in (0, 6, True, '5'):
|
||||
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).status_code, 400)
|
||||
self.assertEqual(self.client.post('/api/rate', json={'sid': 999, 'stars': 5}).status_code, 404)
|
||||
for stars in (4, 5):
|
||||
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).json, {'ok': True})
|
||||
stats = self.client.get('/api/admin/stats').json
|
||||
self.assertEqual((stats['rating_avg'], stats['rating_count']), (4.5, 2))
|
||||
|
||||
def test_excel_export_search_and_legacy_agent_schema(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
with self.app.app_context():
|
||||
db.save_message(cid, 'user', '=SUM(1,2)\n查询专线')
|
||||
connection = db.connect()
|
||||
connection.executescript('DROP TABLE agents; CREATE TABLE agents '
|
||||
'(id INTEGER PRIMARY KEY, username TEXT, password TEXT, name TEXT);'
|
||||
"INSERT INTO agents VALUES(7, 'demo', 'fixture-password', '测试客服');")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
db.init_db()
|
||||
results = self.client.get('/api/search_chat?q=专线').json
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIn('SUM', results[0]['content'])
|
||||
self.assertEqual(self.client.get('/api/search_chat').json, {'results': []})
|
||||
self.assertEqual(self.client.get('/api/agents/list').json, [{'id': 7, 'username': 'demo', 'name': '测试客服'}])
|
||||
exported = self.client.get('/api/export_chat')
|
||||
self.assertIn('chat_export.xlsx', exported.headers['Content-Disposition'])
|
||||
workbook = load_workbook(io.BytesIO(exported.data))
|
||||
sheet = workbook.active
|
||||
self.assertEqual(sheet['C3'].value, '=SUM(1,2)\n查询专线')
|
||||
self.assertEqual(sheet['C3'].data_type, 's')
|
||||
self.assertEqual(sheet['B3'].value, '客户')
|
||||
workbook.close()
|
||||
|
||||
def test_existing_schema_is_upgraded_without_losing_business_data(self):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.executescript('DROP TABLE human_sessions; DROP TABLE human_messages;'
|
||||
'CREATE TABLE human_sessions(id INTEGER PRIMARY KEY, conv_id INTEGER, status TEXT, ctime TEXT);'
|
||||
'CREATE TABLE human_messages(id INTEGER PRIMARY KEY, session_id INTEGER, side TEXT, content TEXT, ctime TEXT);'
|
||||
"INSERT INTO human_sessions VALUES(1, 1, 'active', 'now');"
|
||||
"INSERT INTO human_messages VALUES(1, 1, 'agent', '保留的回复', 'now');"
|
||||
"INSERT INTO agents VALUES('fixture-agent', 'fixture-password', 'Fixture Agent');")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
db.init_db()
|
||||
db.init_db()
|
||||
state = self.client.get('/api/human/poll?sid=1').json
|
||||
self.assertEqual(state['messages'][0]['content'], '保留的回复')
|
||||
self.assertEqual(state['note'], '')
|
||||
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
||||
def test_reply_workflows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs'], cwd=root,
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs'], cwd=root,
|
||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
Reference in New Issue
Block a user