diff --git a/AGENTS.md b/AGENTS.md index cfd1fbf..3650540 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` | | DB maintenance | `logistics_chat/db.py`: `SCHEMA_VERSION`, `MIGRATIONS`; `database_backup.py`: verified snapshots, daily worker, offline restore; `scripts/db_maintenance.py`; `docs/database-maintenance.md` | | Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall, five-minute queue timeout; `logistics_chat/routes/human.py` | +| Staff routing | `logistics_chat/routing_service.py`: groups, presence leases, one-active-customer automatic assignment, manual queue acceptance, owner/version checks and audit; `routes/routing.py`; `static/js/admin_routing.js`; [routing workflow](docs/staff-routing.md) | | Offline tickets | `logistics_chat/ticket_service.py`: schema v3, `init_ticket_history`, owner-only claim/notes/completion, search, `ticket_summary`, `ticket_events`, overdue timers; `routes/business.py`; `static/js/admin_tickets.js`: drafts, alerts, timeline | | 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 | diff --git a/docs/architecture.md b/docs/architecture.md index 59d647b..c4b014c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,6 +31,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整 | 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL | | AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` | | 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 | +| 客服分组与分配 | `logistics_chat/routing_service.py`:`assign_new_session`、`update_presence`、`reassign_session`;`routes/routing.py` | `static/js/admin_routing.js`;版本 5;默认综合客服,手动上线,20 秒心跳与 90 秒在线期限,空闲时自动接一个新客户、队列手动领取、原子领取与改派记录;[使用说明](staff-routing.md) | | 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` | | 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` | | 离线留言处理 | `logistics_chat/ticket_service.py`:`init_ticket_tables`、`init_ticket_history`、`get_ticket`、`list_tickets`、`claim_ticket`、`update_ticket`、`ticket_events` | `routes/business.py`;`static/js/admin_tickets.js`:`AdminTickets.create`;`tickets` 负责人、乐观锁与领取计时,`ticket_events` 同事务处理记录,迁移版本 3 | @@ -100,6 +101,7 @@ tests/ # 按业务模块组织的回归 - 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。 - 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。 - 人工排队接口按服务端北京时间计算 `waiting_minutes`,等候超过 5 分钟返回超时状态和超时分钟数。`loadQueue` 对每条会话仅在首次进入超时时通知一次,队列计数常驻标签,卡片标红;离开超时队列后清除去重状态。 +- 人工会话持久化分组和负责人;仅在新会话创建时自动分配,选择有效登录且心跳有效、当前无活动会话的同组在线客服,依次比较上次分配时间与账号。每位客服自动接待一个进行中的客户,手动领取和指定负责人改派可多接;已排队或转回队列的会话始终等待手动接入。分配/领取/改派/记录在同一写事务内完成。后台活动列表仅返回本人负责会话;无负责人的旧活动会话在队列中供人工领取。改派版本与前端异步代次分别防止并发覆盖和迟到响应覆盖;旧负责人草稿保留在当前页面恢复区。分组管理沿用同级客服权限,不能代替客户租户授权;所有新接口默认受后台会话与 CSRF 保护。 - 离线留言的 `admin_tickets.js` 在 `admin.js` 之前加载,由后者注入带超时、CSRF 和登录失效处理的请求函数。后台按当前登录账号领取,领取原子比较待处理状态;仅负责人可更新处理中留言,版本冲突返回 409,已完成只读。页面草稿按留言 ID 和原始版本保留,轮询不会隐式重置草稿版本;冲突须人工核对。客户提交接口不接受负责人或处理状态,不能访问后台处理接口。 - 留言搜索由 `ticket_service.list_tickets(query=...)` 统一叠加状态及游标条件,LIKE 参数转义通配符;列表继续返回数组。`ticket_summary(after)` 单独提供不受筛选影响的总待处理数量与新增数量,使用状态索引和主键范围查询。`refreshSummary` 与列表轮询独立,不因查看旧页、搜索或写请求停止;首次结果作为提醒基线,之后只提醒超过已见编号的新记录。 - 留言超时由服务端按待领取 30 分钟、领取后处理 20 分钟计算,备注修改不重置 `claimed_at`;列表 `overdue=1` 与全局 summary 使用一致截止时间,完成记录不计入。处理事件与操作同事务保存,只有后台可读,以事件 ID 分页;前端独立加载记录并隔离迟到响应。旧数据只保留升级状态,领取时间估算有显式标识。 diff --git a/docs/database-maintenance.md b/docs/database-maintenance.md index 64f2406..e4807e6 100644 --- a/docs/database-maintenance.md +++ b/docs/database-maintenance.md @@ -1,6 +1,6 @@ # 数据库升级、备份与恢复 -普通启动会先在写锁保护下检查数据库版本。已有数据库先生成 `pre-migration-时间-随机值.db` 并执行 SQLite 完整性检查,再按 `MIGRATIONS` 的版本顺序升级;`schema_meta.schema_version` 记录成功版本。当前版本为 3;版本 2 为离线留言增加负责人、处理备注、更新时间、完成时间和并发版本号,版本 3 增加领取时间及处理记录表,既有留言和客服账号保留。旧留言只生成一条升级状态快照,不补造历史;原处理中留言以有效的最后更新时间估算领取时间并标记,无法估算时保留为空。保留既有旧表兼容检查,因此同版本启动也会先保存快照再校验表结构。更新和版本号提交位于同一事务,失败回滚并终止启动;备份失败不执行升级,未来版本的数据库拒绝降级。首次空库无需升级前快照。 +普通启动会先在写锁保护下检查数据库版本。已有数据库先生成 `pre-migration-时间-随机值.db` 并执行 SQLite 完整性检查,再按 `MIGRATIONS` 的版本顺序升级;`schema_meta.schema_version` 记录成功版本。当前版本为 5;版本 2 为离线留言增加负责人、处理备注、更新时间、完成时间和并发版本号,版本 3 增加领取时间及处理记录表,版本 4 增加订单导入批次及撤销记录,版本 5 增加客服分组、在线心跳、会话负责人及分配记录,既有留言和客服账号保留。旧留言只生成一条升级状态快照,不补造历史;原处理中留言以有效的最后更新时间估算领取时间并标记,无法估算时保留为空。旧人工会话不猜测负责人,无负责人的活动会话可从排队列表手动接入。保留既有旧表兼容检查,因此同版本启动也会先保存快照再校验表结构。更新和版本号提交位于同一事务,失败回滚并终止启动;备份失败不执行升级,未来版本的数据库拒绝降级。首次空库无需升级前快照。 新增结构变更时,在 `logistics_chat/db.py` 增加顺序迁移函数并递增 `SCHEMA_VERSION`,加入 `MIGRATIONS`;迁移函数必须使用传入连接,不得自行提交、使用 `executescript` 隐式提交或执行外部副作用。补充旧库升级、失败回滚和重复启动测试。 diff --git a/docs/staff-routing.md b/docs/staff-routing.md new file mode 100644 index 0000000..cc8f6ba --- /dev/null +++ b/docs/staff-routing.md @@ -0,0 +1,21 @@ +# 客服分组与会话分配 + +客服登录后台后默认离线,在左侧「上线接待」手动开启自动分配;刷新页面保留当前登录会话已确认的状态。切换离线停止接收新会话,已接待的会话仍由原负责人处理。页面每 20 秒发送心跳,超过 90 秒无有效心跳,或登录过期、退出后,不再参与新分配;心跳恢复不会把已超时的客服自动上线,需要手动确认。 + +「客服分组与分配」打开服务资料中的配置区,可以创建分组、把已有客服加入某一组、查看在线状态及当前会话数。新咨询默认进入「综合客服」;目前不根据客户文字猜测业务组,客服可将会话改派至其他组。每个客服只属于一个组;改组不搬移已经接待的会话。新建客服账号沿用现有开户工具,本功能不创建账号或修改密码。 + +新人工咨询创建时只尝试一次自动分配:仅选择同组在线且当前没有活动会话的客服,先比较上次分配时间,再按账号名稳定排序。在线状态同时要求有效的后台登录会话。每位客服有一个进行中的客户后,不再自动接入新客户;手动领取和明确指定负责人的改派允许同时多接。没有合适客服时进入队列,保留现有五分钟排队超时提醒。已排队的客户始终等待手动接入;客服结束会话、上线、心跳、改组、刷新或客户重复申请均不会自动领取旧队列。客服再次空闲后仍可自动接入随后到来的新客户。 + +人工「接入会话」仍可手动领取未分配会话,在线状态仅控制自动分配。领取、自动分配和改派均在 SQLite 写事务中确认,竞争领取只有一人成功。只有会话负责人可发送、修改备注、撤回、转接或标记已读;待接入会话可由后台结束,客户仍可结束自己的会话。旧版无负责人的活动会话显示在排队列表供人工领取,不猜测历史负责人。 + +后台「管理分组与改派」选择会话、目标组及负责人;负责人只能选择目标组内的在线客服。未指定负责人时只转入目标组队列,等待手动接入。改派使用独立版本号,信息已改变时返回 409,需要重新选择会话确认,后台刷新不会偷偷更新正在编辑的版本。普通「转接」也回到队列,等待客服手动接入。 + +改派后旧负责人不能继续回复。当前页面检测到改派会移除旧接待入口,并在服务资料的「改派后保留的未发送草稿」中保留文字和备注,供复制;这些草稿只在当前页面内存中保存,刷新或退出不保留,也不会发给新负责人。 + +自动分配、领取、改派、转回队列和结束会话都与业务操作同事务写入 `routing_events`,记录操作者、分组、负责人及北京时间;后台展示最近 50 条,数据库保留全部记录。客户轮询不返回内部备注、客服账号或分配记录。当前后台沿用所有客服同级管理的权限模型,分组用于业务分派,不作为客户租户数据授权;管理层级及主管专属权限不在本次范围内。 + +数据库版本 5 新增 `support_groups`、`staff_routing`、`staff_presence`、`routing_events` 及 `human_sessions` 的分组、负责人、领取时间、版本号和上一负责人字段。`agents` 原有三列不变,兼容旧账号结构。正常启动时沿用升级前快照、完整性检查和事务迁移;本次不修改真实账号、订单或留言内容。 + +服务端实现:`logistics_chat/routing_service.py`、`routes/routing.py` 与 `human_service.py`。页面控制:`static/js/admin_routing.js`,由 `admin.js` 注入统一超时、CSRF 和登录失效处理,退出时统一停止心跳。 + +验证:`python -m unittest discover -s tests -p test_staff_routing.py -v`;前端运行 `node --test tests/frontend/admin.test.cjs tests/frontend/admin_routing.test.cjs`。所有客服测试通过真实登录接口获取会话和 CSRF,使用独立临时数据库。 diff --git a/logistics_chat/db.py b/logistics_chat/db.py index 4b24d09..e67c9cb 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -8,7 +8,7 @@ from .config import get_setting from .tracking import card_to_text from .database_backup import check_database, daily_backup, prune_migration_snapshots, snapshot_database, unique_backup_path -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 def connect(): @@ -64,6 +64,7 @@ def init_db(): _initialize_schema(conn) _upgrade_ticket_workflow(conn) _upgrade_ticket_history(conn) + _upgrade_staff_routing(conn) conn.execute("INSERT OR REPLACE INTO schema_meta VALUES('schema_version',?)", (str(SCHEMA_VERSION),)) if conn.execute('PRAGMA quick_check').fetchall() != [('ok',)]: raise sqlite3.DatabaseError('Database validation failed after migration') @@ -210,8 +211,13 @@ def _upgrade_order_admin(conn): init_order_admin_tables(conn) +def _upgrade_staff_routing(conn): + from .routing_service import init_routing_tables + init_routing_tables(conn) + + MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow), - (3, _upgrade_ticket_history), (4, _upgrade_order_admin)) + (3, _upgrade_ticket_history), (4, _upgrade_order_admin), (5, _upgrade_staff_routing)) def backup_db(): diff --git a/logistics_chat/human_service.py b/logistics_chat/human_service.py index c6ee251..5d25b6e 100644 --- a/logistics_chat/human_service.py +++ b/logistics_chat/human_service.py @@ -4,7 +4,10 @@ import time from datetime import datetime, datetime as DateTime, timedelta, timezone from math import ceil +from flask import g + from .db import connect +from . import routing_service SERVICE_TIMEZONE = timezone(timedelta(hours=8)) QUEUE_TIMEOUT_SECONDS = 5 * 60 @@ -29,21 +32,24 @@ def human_request(data): else: cur = conn.execute('INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)', (conv_id, 'waiting', now)) sid = cur.lastrowid + routing_service.assign_new_session(conn, sid) + status = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()[0] conn.commit() conn.close() - return {'ok': True, 'session_id': sid} + return {'ok': True, 'session_id': sid, 'status': status} def human_poll(sid, after, typing_map): conn = connect() msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall() - st = conn.execute("SELECT status, COALESCE(note, '') FROM human_sessions WHERE id=?", (sid,)).fetchone() + st = conn.execute("SELECT status, COALESCE(note, ''),owner_username FROM human_sessions WHERE id=?", (sid,)).fetchone() conn.close() is_typing = False ts = typing_map.get(str(sid)) if ts and time.time() - ts < 5: is_typing = True return {'status': st[0] if st else 'closed', 'note': st[1] if st else '', + 'owner_username': st[2] if st else None, 'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3], read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]} @@ -65,6 +71,11 @@ def human_send(data): if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]): conn.close() return ({'ok': False, 'error': 'conversation_changed'}, 409) + if getattr(g, 'staff', None): + error = routing_service.owner_error(conn, sid, g.staff['username']) + if error: + conn.close() + return error conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now)) conn.commit() conn.close() @@ -74,7 +85,9 @@ def human_send(data): def human_queue(): now = datetime.now(SERVICE_TIMEZONE).replace(tzinfo=None) conn = connect() - rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall() + rows = conn.execute("SELECT h.id,h.conv_id,h.status,h.ctime,h.group_id,g.name FROM human_sessions h " + "JOIN support_groups g ON g.id=h.group_id WHERE h.status='waiting' " + "OR (h.status='active' AND h.owner_username IS NULL) ORDER BY h.id").fetchall() conn.close() queue = [] for row in rows: @@ -87,6 +100,7 @@ def human_queue(): elapsed = 0 overdue = elapsed > QUEUE_TIMEOUT_SECONDS queue.append({'id':row[0], 'conv_id':row[1], 'ctime':row[3], + 'group_id':row[4], 'group_name':row[5], 'waiting_minutes':elapsed//60, 'overdue':overdue, 'overdue_minutes':max(1,ceil((elapsed-QUEUE_TIMEOUT_SECONDS)/60)) if overdue else 0}) return queue @@ -94,25 +108,43 @@ def human_queue(): def human_active(): conn = connect() - rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall() + rows = conn.execute("SELECT h.id,h.conv_id,h.owner_username,h.group_id,g.name FROM human_sessions h " + "JOIN support_groups g ON g.id=h.group_id WHERE h.status='active' " + "AND h.owner_username=? ORDER BY h.id", (g.staff['username'],)).fetchall() conn.close() - return [{'id': r[0], 'conv_id': r[1]} for r in rows] + return [{'id': r[0], 'conv_id': r[1], 'owner_username':r[2], 'group_id':r[3], 'group_name':r[4]} for r in rows] def human_accept(data): sid = data.get('sid') conn = connect() - changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') " - 'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount + conn.execute('BEGIN IMMEDIATE') + row = conn.execute("SELECT group_id,owner_username,status FROM human_sessions WHERE id=? " + "AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)", (sid,)).fetchone() + if not row or row[2] == 'closed' or (row[1] and row[1] != g.staff['username']): + conn.close() + return {'ok': False, 'error': '会话已由其他客服接入或已结束,请刷新。'}, 409 + if not row[1]: + # Explicit manual claims remain available when staff are offline. + routing_service.assign_owner(conn, sid, g.staff['username'], row[0], 'claim', g.staff['username']) conn.commit() conn.close() - return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409) + return {'ok': True} def human_close(data): sid = data.get('sid') conn = connect() - conn.execute("UPDATE human_sessions SET status='closed' WHERE id=?", (sid,)) + conn.execute('BEGIN IMMEDIATE') + if getattr(g, 'staff', None): + error = routing_service.owner_error(conn, sid, g.staff['username'], allow_unassigned=True) + if error: + conn.close() + return error + changed = conn.execute("UPDATE human_sessions SET status='closed',routing_version=routing_version+1 " + "WHERE id=? AND status!='closed'", (sid,)).rowcount + if changed: + routing_service.record_event(conn, sid, 'close', g.staff['username'] if getattr(g, 'staff', None) else 'customer') conn.commit() conn.close() return {'ok': True} @@ -121,12 +153,19 @@ def human_close(data): 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 + conn.execute('BEGIN IMMEDIATE') + error = routing_service.owner_error(conn, sid, g.staff['username']) + if error: + conn.close() + return error + changed = conn.execute("UPDATE human_sessions SET status='waiting',previous_owner=owner_username,owner_username=NULL," + "assigned_at=NULL,routing_version=routing_version+1 WHERE id=? AND status='active'", (sid,)).rowcount if not changed: conn.close() return ({'ok': False, 'error': '会话当前无法转接'}, 409) conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))) + routing_service.record_event(conn, sid, 'transfer', g.staff['username']) conn.commit() conn.close() typing_map.pop(str(sid), None) @@ -138,6 +177,11 @@ def human_note(data): if not isinstance(note, str): return ({'ok': False}, 400) conn = connect() + conn.execute('BEGIN IMMEDIATE') + error = routing_service.owner_error(conn, data.get('sid'), g.staff['username']) + if error: + conn.close() + return error changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount conn.commit() conn.close() @@ -153,6 +197,10 @@ def human_recall(data): if not row: conn.close() return ({'ok': False}, 404) + error = routing_service.owner_error(conn, row[0], g.staff['username']) + if error: + conn.close() + return error if row[1] == 'system' and row[2] == '该消息已撤回': conn.close() return {'ok': True} @@ -169,12 +217,18 @@ def human_recall(data): def human_typing(data, typing_map): sid = data.get('sid') + error = _typing_owner_error(sid) + if error: + return error typing_map[str(sid)] = time.time() return {'ok': True} def human_stop_typing(data, typing_map): sid = data.get('sid') + error = _typing_owner_error(sid) + if error: + return error typing_map.pop(str(sid), None) return {'ok': True} @@ -182,7 +236,20 @@ def human_stop_typing(data, typing_map): def human_read(data): sid = data.get('sid') conn = connect() + conn.execute('BEGIN IMMEDIATE') + error = routing_service.owner_error(conn, sid, g.staff['username']) + if error: + conn.close() + return error conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), sid)) conn.commit() conn.close() return {'ok': True} + + +def _typing_owner_error(sid): + connection = connect() + try: + return routing_service.owner_error(connection, sid, g.staff['username']) + finally: + connection.close() diff --git a/logistics_chat/routes/__init__.py b/logistics_chat/routes/__init__.py index 1c25175..47f3033 100644 --- a/logistics_chat/routes/__init__.py +++ b/logistics_chat/routes/__init__.py @@ -2,6 +2,6 @@ def register_routes(app): - from . import admin, business, chat, customer, files, history, human, pages, speech, visitor - for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor): + from . import admin, business, chat, customer, files, history, human, pages, routing, speech, visitor + for module in (admin, business, chat, customer, files, history, human, pages, routing, speech, visitor): app.register_blueprint(module.bp) diff --git a/logistics_chat/routes/routing.py b/logistics_chat/routes/routing.py new file mode 100644 index 0000000..f900074 --- /dev/null +++ b/logistics_chat/routes/routing.py @@ -0,0 +1,32 @@ +"""Staff-only routing adapters; default-deny authentication and CSRF run first.""" + +from flask import Blueprint, g, request + +from .. import routing_service, staff_service + +bp = Blueprint('routing', __name__) + + +@bp.get('/api/admin/routing') +def snapshot(): + return routing_service.routing_snapshot(g.staff['username'], staff_service.token_hash()) + + +@bp.post('/api/admin/routing/presence') +def presence(): + return routing_service.update_presence(g.staff['username'], staff_service.token_hash(), request.get_json(silent=True)) + + +@bp.post('/api/admin/routing/groups') +def create_group(): + return routing_service.create_group(request.get_json(silent=True)) + + +@bp.post('/api/admin/routing/members') +def move_member(): + return routing_service.move_member(request.get_json(silent=True)) + + +@bp.post('/api/admin/routing/assign') +def assign(): + return routing_service.reassign_session(request.get_json(silent=True), g.staff['username']) diff --git a/logistics_chat/routes/visitor.py b/logistics_chat/routes/visitor.py index c165fc5..1859de9 100644 --- a/logistics_chat/routes/visitor.py +++ b/logistics_chat/routes/visitor.py @@ -39,6 +39,7 @@ def visitor_human(action): return {'ok': False}, 400 result = human_service.human_poll(sid, after, current_app.extensions['human_typing']) result.pop('note', None) + result.pop('owner_username', None) return result if action == 'send': if data.get('side', 'customer') != 'customer': diff --git a/logistics_chat/routing_service.py b/logistics_chat/routing_service.py new file mode 100644 index 0000000..afa948f --- /dev/null +++ b/logistics_chat/routing_service.py @@ -0,0 +1,232 @@ +"""Persistent staff availability, group membership and serialized conversation assignment.""" + +from datetime import datetime, timedelta, timezone +import sqlite3 +import time + +from .db import connect + +PRESENCE_SECONDS = 90 +CHINA_TIME = timezone(timedelta(hours=8)) + + +def init_routing_tables(connection): + connection.execute('''CREATE TABLE IF NOT EXISTS support_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE + )''') + connection.execute("INSERT OR IGNORE INTO support_groups(id,name) VALUES(1,'综合客服')") + # Separate profiles retain compatibility with legacy three-column agents tables. + connection.execute('''CREATE TABLE IF NOT EXISTS staff_routing ( + username TEXT PRIMARY KEY, group_id INTEGER NOT NULL DEFAULT 1, + last_assigned REAL NOT NULL DEFAULT 0 + )''') + connection.execute('''CREATE TABLE IF NOT EXISTS staff_presence ( + token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, + online INTEGER NOT NULL DEFAULT 0, last_seen REAL NOT NULL + )''') + connection.execute('CREATE INDEX IF NOT EXISTS idx_presence_username ON staff_presence(username)') + columns = {row[1] for row in connection.execute('PRAGMA table_info(human_sessions)')} + for name, definition in ( + ('group_id', 'INTEGER NOT NULL DEFAULT 1'), ('owner_username', 'TEXT'), + ('assigned_at', 'TEXT'), ('routing_version', 'INTEGER NOT NULL DEFAULT 0'), + ('previous_owner', 'TEXT')): + if name not in columns: + connection.execute(f'ALTER TABLE human_sessions ADD COLUMN {name} {definition}') + connection.execute('CREATE INDEX IF NOT EXISTS idx_human_routing ON human_sessions(status,group_id,owner_username,id)') + connection.execute('CREATE INDEX IF NOT EXISTS idx_human_owner ON human_sessions(owner_username,status)') + connection.execute('''CREATE TABLE IF NOT EXISTS routing_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, + action TEXT NOT NULL, actor TEXT NOT NULL, group_id INTEGER NOT NULL, + owner_username TEXT, ctime TEXT NOT NULL + )''') + connection.execute('CREATE INDEX IF NOT EXISTS idx_routing_events_session ON routing_events(session_id,id)') + connection.execute('''CREATE TRIGGER IF NOT EXISTS routing_delete_agent AFTER DELETE ON agents BEGIN + DELETE FROM staff_routing WHERE username=OLD.username; + DELETE FROM staff_presence WHERE username=OLD.username; + UPDATE human_sessions SET status='waiting', owner_username=NULL, assigned_at=NULL, + previous_owner=NULL, routing_version=routing_version+1 + WHERE owner_username=OLD.username AND status='active'; + END''') + + +def timestamp(): + return datetime.now(CHINA_TIME).strftime('%Y-%m-%d %H:%M:%S') + + +def record_event(connection, sid, action, actor): + connection.execute('''INSERT INTO routing_events(session_id,action,actor,group_id,owner_username,ctime) + SELECT id,?,?,group_id,owner_username,? FROM human_sessions WHERE id=?''', + (action, actor, timestamp(), sid)) + + +def _online_agents(connection, instant): + return {row[0] for row in connection.execute('''SELECT DISTINCT p.username + FROM staff_presence p JOIN staff_sessions s ON s.token_hash=p.token_hash AND s.username=p.username + JOIN agents a ON a.username=p.username + WHERE p.online=1 AND p.last_seen>? AND s.expires_at>?''', + (instant - PRESENCE_SECONDS, instant))} + + +def _agents(connection, instant): + online = _online_agents(connection, instant) + return [dict(username=row[0], name=row[1], group_id=row[2], group_name=row[3], + active_count=row[4], online=row[0] in online, last_assigned=row[5]) + for row in connection.execute('''SELECT a.username,a.name,COALESCE(r.group_id,1),g.name, + (SELECT COUNT(*) FROM human_sessions h WHERE h.owner_username=a.username AND h.status='active'), + COALESCE(r.last_assigned,0) + FROM agents a LEFT JOIN staff_routing r ON r.username=a.username + JOIN support_groups g ON g.id=COALESCE(r.group_id,1) ORDER BY a.username''')] + + +def assign_owner(connection, sid, username, group_id, action, actor): + connection.execute('''UPDATE human_sessions SET owner_username=?,group_id=?,status='active', + assigned_at=?,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''', + (username, group_id, timestamp(), sid)) + connection.execute('''INSERT INTO staff_routing(username,group_id,last_assigned) VALUES(?,1,?) + ON CONFLICT(username) DO UPDATE SET last_assigned=excluded.last_assigned''', + (username, time.time())) + record_event(connection, sid, action, actor) + + +def assign_new_session(connection, sid): + """Try once at creation under BEGIN IMMEDIATE; existing queue needs manual acceptance.""" + row = connection.execute('''SELECT group_id FROM human_sessions + WHERE id=? AND status='waiting' AND owner_username IS NULL + AND EXISTS(SELECT 1 FROM chat_history c WHERE c.id=human_sessions.conv_id)''', (sid,)).fetchone() + if not row: + return + eligible = [agent for agent in _agents(connection, time.time()) + if agent['online'] and agent['group_id'] == row[0] and agent['active_count'] == 0] + if eligible: + selected = min(eligible, key=lambda agent: (agent['last_assigned'], agent['username'])) + assign_owner(connection, sid, selected['username'], row[0], 'auto_assign', 'system') + + +def _presence(connection, token, instant): + row = connection.execute('''SELECT online,last_seen FROM staff_presence WHERE token_hash=?''', (token,)).fetchone() + return {'online': bool(row and row[0] and row[1] > instant - PRESENCE_SECONDS)} + + +def routing_snapshot(username, token): + connection = connect() + try: + connection.execute('BEGIN') + groups = [dict(id=row[0], name=row[1]) for row in connection.execute('SELECT id,name FROM support_groups ORDER BY id')] + sessions = [dict(id=row[0], status=row[1], group_id=row[2], group_name=row[3], + owner_username=row[4], owner_name=row[5], version=row[6], ctime=row[7]) + for row in connection.execute('''SELECT h.id,h.status,h.group_id,g.name,h.owner_username, + a.name,h.routing_version,h.ctime FROM human_sessions h + JOIN support_groups g ON g.id=h.group_id LEFT JOIN agents a ON a.username=h.owner_username + WHERE h.status IN ('waiting','active') ORDER BY h.id''')] + events = [dict(id=row[0], session_id=row[1], action=row[2], actor=row[3], group_name=row[4], + owner_username=row[5], ctime=row[6]) for row in connection.execute('''SELECT e.id,e.session_id, + e.action,e.actor,g.name,e.owner_username,e.ctime FROM routing_events e + JOIN support_groups g ON g.id=e.group_id ORDER BY e.id DESC LIMIT 50''')] + return {'ok': True, 'username': username, 'presence': _presence(connection, token, time.time()), + 'groups': groups, 'agents': _agents(connection, time.time()), 'sessions': sessions, 'events': events} + finally: + connection.close() + + +def update_presence(username, token, data): + if not isinstance(data, dict) or ('online' in data and type(data['online']) is not bool): + return {'ok': False, 'error': '请选择在线或离线。'}, 400 + instant = time.time() + connection = connect() + try: + connection.execute('BEGIN IMMEDIATE') + if 'online' in data: + # Going offline applies across this account's logged-in browsers. + if not data['online']: + connection.execute('UPDATE staff_presence SET online=0 WHERE username=?', (username,)) + connection.execute('''INSERT INTO staff_presence VALUES(?,?,?,?) + ON CONFLICT(token_hash) DO UPDATE SET online=excluded.online,last_seen=excluded.last_seen''', + (token, username, int(data['online']), instant)) + else: + # Heartbeats never turn an offline/expired presence back on implicitly. + connection.execute('''UPDATE staff_presence SET online=CASE WHEN last_seen>? THEN online ELSE 0 END, + last_seen=? WHERE token_hash=?''', (instant - PRESENCE_SECONDS, instant, token)) + connection.execute('DELETE FROM staff_presence WHERE NOT EXISTS ' + '(SELECT 1 FROM staff_sessions s WHERE s.token_hash=staff_presence.token_hash AND s.expires_at>?)', (instant,)) + result = {'ok': True, **_presence(connection, token, instant)} + connection.commit() + return result + finally: + connection.close() + + +def create_group(data): + name = data.get('name') if isinstance(data, dict) else None + if not isinstance(name, str) or not 1 <= len(name.strip()) <= 40: + return {'ok': False, 'error': '分组名称需为 1–40 字。'}, 400 + connection = connect() + try: + try: + cursor = connection.execute('INSERT INTO support_groups(name) VALUES(?)', (name.strip(),)) + connection.commit() + except sqlite3.IntegrityError: + return {'ok': False, 'error': '已存在同名分组。'}, 409 + return {'ok': True, 'group': {'id': cursor.lastrowid, 'name': name.strip()}} + finally: + connection.close() + + +def _positive_id(value): + return type(value) is int and 0 < value <= 9223372036854775807 + + +def move_member(data): + if not isinstance(data, dict) or not isinstance(data.get('username'), str) or not _positive_id(data.get('group_id')): + return {'ok': False, 'error': '请选择客服与分组。'}, 400 + connection = connect() + try: + connection.execute('BEGIN IMMEDIATE') + if not connection.execute('SELECT 1 FROM agents WHERE username=?', (data['username'],)).fetchone() or not connection.execute( + 'SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone(): + return {'ok': False, 'error': '客服或分组已不存在,请刷新。'}, 404 + connection.execute('''INSERT INTO staff_routing(username,group_id) VALUES(?,?) + ON CONFLICT(username) DO UPDATE SET group_id=excluded.group_id''', (data['username'], data['group_id'])) + connection.commit() + return {'ok': True} + finally: + connection.close() + + +def reassign_session(data, actor): + if not isinstance(data, dict) or not _positive_id(data.get('sid')) or not _positive_id(data.get('group_id')) or ( + type(data.get('version')) is not int or data['version'] < 0 or data['version'] > 9223372036854775807) or ( + data.get('username') is not None and not isinstance(data['username'], str)): + return {'ok': False, 'error': '请选择会话、目标分组与负责人。'}, 400 + connection = connect() + try: + connection.execute('BEGIN IMMEDIATE') + row = connection.execute('SELECT status,routing_version FROM human_sessions WHERE id=?', (data['sid'],)).fetchone() + if not row or row[0] == 'closed' or row[1] != data['version']: + return {'ok': False, 'error': '会话已更新或结束,请刷新后重新选择。'}, 409 + if not connection.execute('SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone(): + return {'ok': False, 'error': '目标分组已不存在,请刷新。'}, 404 + username = data.get('username') or None + if username: + selected = next((agent for agent in _agents(connection, time.time()) if agent['username'] == username), None) + if not selected or not selected['online'] or selected['group_id'] != data['group_id']: + return {'ok': False, 'error': '目标客服需在所选组内且处于在线状态。'}, 409 + assign_owner(connection, data['sid'], username, data['group_id'], 'reassign', actor) + else: + connection.execute('''UPDATE human_sessions SET group_id=?,owner_username=NULL,status='waiting', + assigned_at=NULL,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''', + (data['group_id'], data['sid'])) + record_event(connection, data['sid'], 'queue', actor) + connection.commit() + return {'ok': True} + finally: + connection.close() + + +def owner_error(connection, sid, username, allow_unassigned=False): + """Call inside a write transaction; assigned sessions cannot be operated by another staff member.""" + row = connection.execute('SELECT owner_username FROM human_sessions WHERE id=?', (sid,)).fetchone() + if row and not row[0] and not allow_unassigned: + return {'ok': False, 'error': '请先接入会话,再进行操作。'}, 409 + if row and row[0] and row[0] != username: + return {'ok': False, 'error': '会话已由其他客服负责,请刷新会话列表。'}, 409 + return None diff --git a/logistics_chat/staff_service.py b/logistics_chat/staff_service.py index fbacff5..9dab5d6 100644 --- a/logistics_chat/staff_service.py +++ b/logistics_chat/staff_service.py @@ -153,6 +153,7 @@ def logout(): connection = connect() try: connection.execute('DELETE FROM staff_sessions WHERE token_hash=?', (token_hash(),)) + connection.execute('DELETE FROM staff_presence WHERE token_hash=?', (token_hash(),)) connection.commit() finally: connection.close() diff --git a/static/css/admin.css b/static/css/admin.css index e2f7780..03b5f86 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -200,6 +200,33 @@ body:not(.has-active-session) .close { display: none; } .ticket-history-meta { color: var(--secondary); } .ticket-history-detail { display: block; margin-top: 3px; white-space: pre-wrap; } .ticket-empty { padding: 16px 0; color: var(--secondary); font-size: 13px; } +.routing-heading, .routing-presence { display: flex; align-items: center; gap: 8px; } +.routing-heading { justify-content: space-between; } +.routing-heading > span { color: var(--secondary); font-size: 13px; overflow-wrap: anywhere; } +.routing-presence { margin-top: 12px; flex-wrap: wrap; } +#routing-presence-text { flex: 1; font-size: 13px; } +.routing-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } +.routing-dot[data-online="true"] { background: var(--success); } +.routing-presence .ticket-action { min-height: 44px; padding: 7px 10px; font-size: 13px; white-space: nowrap; } +.routing-feedback { margin-top: 6px; color: var(--secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; } +.routing-feedback:empty { display: none; } +.routing-feedback.is-error { color: var(--danger); } +.routing-members { margin: 12px 0 0; padding: 0; list-style: none; font-size: 14px; } +.routing-members li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; } +.routing-members li span:last-child { color: var(--secondary); font-size: 13px; } +.routing-members small { color: var(--muted); } +.routing-settings, .routing-events { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); } +.routing-settings summary, .routing-events summary { cursor: pointer; color: var(--secondary); font-size: 14px; min-height: 44px; padding: 10px 0; } +.routing-form { display: grid; gap: 7px; margin-top: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--border); } +.routing-form label { color: var(--secondary); font-size: 14px; } +.routing-form input, .routing-form select, #routing-retired-drafts textarea { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 14px; } +.routing-form button { margin: 0; } +.routing-form :is(input,select):focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } +.routing-events ul { margin: 8px 0 0; padding: 0; list-style: none; } +.routing-events li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; overflow-wrap: anywhere; } +.routing-events li span, .routing-events li small { color: var(--muted); } +#routing-retired-drafts label { display: block; margin: 8px 0; font-size: 13px; } +@media (max-width: 720px) { .routing-form input, .routing-form select, #routing-retired-drafts textarea { font-size: 16px; } } @media (max-width: 720px) { #ticket-note, #ticket-search, .ticket-toolbar select { font-size: 16px; } } .tools-backdrop, .sessions-back { display: none; } @media (max-width: 1200px) { diff --git a/static/js/admin.js b/static/js/admin.js index 281c12c..2299142 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,6 +1,7 @@ var curSid = 0; var sessions = {}; var drafts = {}; +var reassignedNotes = {}; var queueItems = []; var queueOverdueNotified = new Set(); var accepting = new Set(); @@ -15,6 +16,7 @@ var staffExpired = false; var staffLoggingOut = false; var staffPollTimers = []; var staffCsrf = document.querySelector('meta[name="staff-csrf-token"]').content; +var staffUsername = document.querySelector('meta[name="staff-username"]').content; var replyInput = document.getElementById("replyText"); SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。'); @@ -153,7 +155,7 @@ function renderQueue(){ const wait = item.overdue ? '排队超时 '+escapeText(item.overdue_minutes)+' 分钟' : '已等待 '+escapeText(age)+' 分钟'; - return '
对话 #'+sid+wait+''+escapeText(item.ctime)+'
'; + return '
对话 #'+sid+wait+''+escapeText(item.group_name || '综合客服')+' · '+escapeText(item.ctime)+'
'; }).join("") : '
'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'
'; } @@ -198,7 +200,7 @@ async function accept(sid){ loadQueue(); poll(); }catch(error){ - document.getElementById("queue").innerHTML = '
接入失败,请稍后重试
'; + document.getElementById("queue").innerHTML = '
'+escapeText(error.userMessage || '接入失败,请稍后重试')+'
'; }finally{ accepting.delete(sid); } @@ -213,7 +215,7 @@ function switchSession(sid){ document.getElementById("chatContent").style.display = "flex"; setChatView(true); document.getElementById("chatTitle").textContent = "客户 #" + sid; - document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话"; + document.getElementById("chat-subtitle").textContent = "我负责 · "+(sessions[sid].group_name || "综合客服"); document.getElementById("cInfo").textContent = "会话编号:#" + sid; document.getElementById("chatBox").innerHTML = ""; sessions[sid].msgs.forEach(appendMsg); @@ -316,11 +318,23 @@ async function poll(){ const list = await adminRequest("/api/human/active"); list.forEach(function(session){ if(!sessions[String(session.id)]) sessions[String(session.id)] = {lastId:0, msgs:[], unread:0}; + Object.assign(sessions[String(session.id)], {group_name:session.group_name, owner_username:session.owner_username}); }); await Promise.all(Object.keys(sessions).map(async function(sid){ const session = sessions[sid]; const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId); if(sessions[sid] !== session) return; + if(state.status === 'waiting' || (state.owner_username && state.owner_username !== staffUsername)){ + if(!Object.prototype.hasOwnProperty.call(drafts,sid)) drafts[sid] = ''; + if(sid === curSid){ + drafts[sid] = replyInput.value; + reassignedNotes[sid] = document.getElementById('noteInput').value; + }else if(session.noteDraft){ reassignedNotes[sid] = session.noteDraft; } + delete sessions[sid]; + if(sid === curSid) showEmptySession(); + renderRecoveredDrafts(); + return; + } if(state.status !== "active"){ delete sessions[sid]; delete drafts[sid]; @@ -339,6 +353,7 @@ async function poll(){ })); document.getElementById("sync-status").hidden = true; renderList(); + renderRecoveredDrafts(); }catch(error){ document.getElementById("sync-status").hidden = false; }finally{ @@ -346,6 +361,15 @@ async function poll(){ } } +function renderRecoveredDrafts(){ + const ids = Object.keys(drafts).filter(function(sid){ return !sessions[sid] && (drafts[sid] || reassignedNotes[sid]); }); + document.getElementById('routing-draft-recovery').hidden = ids.length === 0; + document.getElementById('routing-retired-drafts').innerHTML = ids.map(function(sid){ + return ''+ + (reassignedNotes[sid] ? '' : ''); + }).join(''); +} + async function sendReply(){ if(!curSid || sendingSid || closingSid) return; const sid = curSid; @@ -454,7 +478,7 @@ document.getElementById("tools-from-list").onclick = function(){ toolsOpener = " document.getElementById("tools-close").onclick = function(){ setToolsOpen(false); const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches; - document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle"].includes(toolsOpener) ? toolsOpener : fromList ? "tools-from-list" : "tools-toggle").focus(); + document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle", "routing-open"].includes(toolsOpener) ? toolsOpener : fromList ? "tools-from-list" : "tools-toggle").focus(); }; document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick; document.addEventListener("keydown", function(event){ @@ -494,3 +518,15 @@ var ticketDesk = window.AdminTickets.create({request:adminRequest, escape:escape loadTickets(); poll(); staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)]; +if(window.AdminRouting){ + var routingDesk = window.AdminRouting.create({username:staffUsername,request:adminRequest, + changed:function(){ loadQueue(); poll(); }}); + routingDesk.refresh(); + staffPollTimers.push(setInterval(routingDesk.heartbeat, 20000)); + document.getElementById('routing-open').onclick = function(){ + toolsOpener = 'routing-open'; setToolsOpen(true); + document.getElementById('routing-title').scrollIntoView({block:'start'}); + document.getElementById('routing-refresh').focus({preventScroll:true}); + routingDesk.refresh(); + }; +} diff --git a/static/js/admin_routing.js b/static/js/admin_routing.js new file mode 100644 index 0000000..a720047 --- /dev/null +++ b/static/js/admin_routing.js @@ -0,0 +1,170 @@ +/* Group configuration, explicit availability and versioned manual reassignment. */ +(function(root){ + function create(options){ + const el = id => document.getElementById(id); + const escape = value => String(value == null ? '' : value).replace(/[&<>"']/g, + character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])); + let snapshot = null, busy = false, beating = false, generation = 0, selectedVersion = null; + const controls = ['routing-presence-toggle','routing-refresh','routing-create-group','routing-save-member','routing-save-assignment']; + + function message(text, error){ + ['routing-status','routing-management-status'].forEach(id=>{ + el(id).textContent = text || ''; + el(id).classList.toggle('is-error', Boolean(error)); + }); + } + function setBusy(value){ + busy = value; + controls.forEach(id => { el(id).disabled = value || !snapshot; }); + } + function fill(id, items, placeholder){ + const input = el(id), previous = input.value; + input.innerHTML = (placeholder ? '' : '') + + items.map(item => '').join(''); + input.value = items.some(item => String(item.value) === previous) ? previous : + (placeholder ? '' : items.length ? String(items[0].value) : ''); + } + function renderPresence(online){ + el('routing-presence-text').textContent = online ? '在线,参与自动分配' : '离线,不自动接待'; + el('routing-presence-dot').dataset.online = String(online); + el('routing-presence-toggle').textContent = online ? '切换离线' : '上线接待'; + el('routing-presence-toggle').setAttribute('aria-pressed', String(online)); + } + function targets(preserveUnavailable){ + if(!snapshot) return; + const group = Number(el('routing-target-group').value); + const previous = el('routing-target-owner').value; + const items = snapshot.agents.filter(agent => agent.online && agent.group_id === group) + .map(agent => ({value:agent.username,label:(agent.name || agent.username)+' · '+agent.active_count+' 个会话'})); + if(preserveUnavailable && previous && !items.some(item=>item.value === previous)){ + items.push({value:previous,label:previous+' · 状态已改变,请重新选择'}); + } + fill('routing-target-owner',items,'转入队列,等待手动接入'); + } + function selectSession(){ + const session = snapshot && snapshot.sessions.find(item => String(item.id) === el('routing-session').value); + selectedVersion = session ? session.version : null; + if(session){ + el('routing-target-group').value = String(session.group_id); + targets(); + el('routing-target-owner').value = session.owner_username || ''; + } + } + function render(data){ + snapshot = data; + renderPresence(Boolean(data.presence.online)); + const mine = data.agents.find(agent => agent.username === options.username); + el('routing-group-label').textContent = mine ? mine.group_name : '综合客服'; + el('routing-members').innerHTML = data.agents.length ? data.agents.map(agent => + '
  • '+escape(agent.name || agent.username)+' '+escape(agent.username)+''+ + ''+escape(agent.group_name)+' · '+(agent.online ? '在线' : '离线')+' · '+agent.active_count+' 个会话
  • ').join('') : '
  • 暂无客服账号
  • '; + const groups = data.groups.map(group => ({value:group.id,label:group.name})); + fill('routing-member-group', groups); + fill('routing-target-group', groups); + fill('routing-member', data.agents.map(agent => ({value:agent.username,label:(agent.name || agent.username)+'('+agent.username+')'})), '选择客服'); + fill('routing-session', data.sessions.map(session => ({value:session.id, + label:'#'+session.id+' · '+session.group_name+' · '+(session.owner_name || session.owner_username || '排队中')})), '选择会话'); + if(!el('routing-session').value) selectedVersion = null; + targets(true); + const actions = {auto_assign:'自动分配',claim:'手动接入',reassign:'手动改派',queue:'转入分组队列',transfer:'转回队列',close:'结束会话'}; + el('routing-events').innerHTML = data.events.length ? data.events.map(event => + '
  • #'+event.session_id+' · '+escape(actions[event.action] || event.action)+''+ + escape(event.group_name)+' → '+escape(event.owner_username || '等待分配')+''+escape(event.actor === 'system' ? '系统' : event.actor === 'customer' ? '客户' : event.actor)+' · '+escape(event.ctime)+'
  • ').join('') : '
  • 暂无分配记录
  • '; + setBusy(busy); + } + async function refresh(afterWrite){ + if(busy && afterWrite !== true) return; + const current = ++generation; + try{ + const data = await options.request('/api/admin/routing'); + if(current !== generation) return; + if(!data || !Array.isArray(data.groups) || !Array.isArray(data.agents) || + !Array.isArray(data.sessions) || !Array.isArray(data.events) || !data.presence) throw new Error('Invalid routing data'); + render(data); + }catch(error){ + if(current === generation) message('分配状态加载失败,已保留现有信息;请点击刷新重试。', true); + } + } + async function heartbeat(){ + if(busy || beating) return; + beating = true; + const current = generation; + try{ + const data = await options.request('/api/admin/routing/presence', {method:'POST', + headers:{'Content-Type':'application/json'},body:'{}'}); + if(current !== generation) return; + if(snapshot) snapshot.presence.online = Boolean(data.online); + renderPresence(Boolean(data.online)); + await refresh(); + }catch(error){ + if(current === generation){ + el('routing-presence-text').textContent = '接待状态待确认'; + message('接待状态同步失败,断线超过 90 秒将停止自动分配;请刷新核对。', true); + } + }finally{ beating = false; } + } + async function setPresence(){ + if(busy || !snapshot) return; + ++generation; + setBusy(true); + const online = !snapshot.presence.online; + try{ + const data = await options.request('/api/admin/routing/presence', {method:'POST', + headers:{'Content-Type':'application/json'},body:JSON.stringify({online})}); + snapshot.presence.online = Boolean(data.online); + renderPresence(Boolean(data.online)); + message(data.online ? '已上线,空闲时自动接入一个新客户;多接请手动领取。' : '已离线,已接待的会话继续保留。'); + if(options.changed) options.changed(); + }catch(error){ message('状态更新未确认成功,请点击刷新核对后重试。', true); } + finally{ setBusy(false); } + } + async function write(path, data, success){ + if(busy || !snapshot) return false; + ++generation; + setBusy(true); + try{ + await options.request('/api/admin/routing/'+path, {method:'POST', + headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); + message(success); + if(options.changed) options.changed(); + return true; + }catch(error){ + message(error.userMessage || '操作未确认成功,请刷新核对后重试。', true); + return false; + }finally{ await refresh(true); setBusy(false); } + } + async function addGroup(){ + const name = el('routing-group-name').value.trim(); + if(!name || name.length > 40){ message('请输入 1–40 字的分组名称。', true); return; } + if(await write('groups',{name},'分组已创建,可将客服加入此组。')){ + if(el('routing-group-name').value.trim() === name) el('routing-group-name').value = ''; + } + } + async function moveMember(){ + const username = el('routing-member').value, group_id = Number(el('routing-member-group').value); + if(!username){ message('请先选择客服。', true); return; } + await write('members',{username,group_id},'客服分组已更新,现有会话负责人不变。'); + } + async function assign(){ + const sid = Number(el('routing-session').value), group_id = Number(el('routing-target-group').value); + if(!sid || selectedVersion === null){ message('请先选择会话。', true); return; } + const version = selectedVersion, username = el('routing-target-owner').value || null; + await write('assign',{sid,group_id,version,username},'会话分配已更新。'); + el('routing-session').value = ''; selectedVersion = null; + } + el('routing-presence-toggle').addEventListener('click',setPresence); + el('routing-refresh').addEventListener('click',refresh); + el('routing-session').addEventListener('change',selectSession); + el('routing-target-group').addEventListener('change',()=>targets(false)); + el('routing-member').addEventListener('change',()=>{ + const member = snapshot && snapshot.agents.find(agent => agent.username === el('routing-member').value); + if(member) el('routing-member-group').value = String(member.group_id); + }); + [['routing-group-form',addGroup],['routing-member-form',moveMember],['routing-assign-form',assign]].forEach(([id,handler])=>{ + el(id).addEventListener('submit',event=>{event.preventDefault();return handler();}); + }); + setBusy(false); + return {refresh,heartbeat,setPresence,assign}; + } + root.AdminRouting = {create}; +})(window); diff --git a/templates/admin.html b/templates/admin.html index 1119a5e..a6267c5 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -46,6 +46,13 @@