diff --git a/add_agent.py b/add_agent.py new file mode 100644 index 0000000..bed5eb6 --- /dev/null +++ b/add_agent.py @@ -0,0 +1,13 @@ +import sqlite3 +c = sqlite3.connect('history.db') +c.execute('''CREATE TABLE IF NOT EXISTS agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE, + password TEXT, + name TEXT +)''') +c.execute("INSERT OR IGNORE INTO agents(username,password,name) VALUES('admin','123','管理员')") +c.execute("INSERT OR IGNORE INTO agents(username,password,name) VALUES('agent1','123','客服小王')") +c.execute("INSERT OR IGNORE INTO agents(username,password,name) VALUES('agent2','123','客服小李')") +c.commit() +print("ok") diff --git a/add_orders.py b/add_orders.py new file mode 100644 index 0000000..b860c93 --- /dev/null +++ b/add_orders.py @@ -0,0 +1,11 @@ +import sqlite3 +c = sqlite3.connect('history.db') +c.execute('''CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT, + track_no TEXT, + status TEXT DEFAULT "运输中", + created TEXT +)''') +c.commit() +print("ok") diff --git a/add_test.py b/add_test.py new file mode 100644 index 0000000..6cb255a --- /dev/null +++ b/add_test.py @@ -0,0 +1,7 @@ +import sqlite3 +c = sqlite3.connect('history.db') +c.execute("INSERT INTO orders(phone, track_no, status, created) VALUES('13800138000','YT20261028001','运输中','2026-10-28')") +c.execute("INSERT INTO orders(phone, track_no, status, created) VALUES('13800138000','YT20261028002','派送中','2026-10-27')") +c.execute("INSERT INTO orders(phone, track_no, status, created) VALUES('13900139000','YT20261028003','已签收','2026-10-26')") +c.commit() +print("ok") diff --git a/add_ticket.py b/add_ticket.py new file mode 100644 index 0000000..9fee2e4 --- /dev/null +++ b/add_ticket.py @@ -0,0 +1,11 @@ +import sqlite3 +c = sqlite3.connect('history.db') +c.execute('''CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT, + phone TEXT, + ctime TEXT, + status TEXT DEFAULT "pending" +)''') +c.commit() +print("ok") diff --git a/am.py b/am.py index 7d6be69..0acbab0 100644 --- a/am.py +++ b/am.py @@ -227,6 +227,22 @@ def init_db(): if "read_at" not in hm_cols: cur.execute("ALTER TABLE human_messages ADD COLUMN read_at TEXT") + # 补 note 列(会话备注) + 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 + ) + ''') + # 老数据只有标题,补一条 user 消息,点开还能接着聊 old = cur.execute( "SELECT id, title FROM chat_history " @@ -937,7 +953,10 @@ def stream_chat(): return Response(sse({"e": "问题不能为空"}), mimetype="text/event-stream") # 纯图片消息:不调AI if "[图片]" in msg and len(msg) < 300: - return Response(sse({"t": "收到您的图片,请描述一下您的问题。"}), mimetype="text/event-stream") + def _img_reply(): + yield sse({"t": "收到您的图片,请描述一下您的问题。"}) + yield sse({"d": 1}) + return Response(stream_with_context(_img_reply()), mimetype="text/event-stream") # 运单号:直接给轨迹卡片,不让模型编 track_no = extract_tracking_no(msg) @@ -1229,6 +1248,8 @@ def stream_chat(): headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} ) +WELCOME_TEXT = "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。" + @app.route("/api/new_chat", methods=["POST"]) def new_chat(): now = now_str() @@ -1237,9 +1258,11 @@ def new_chat(): 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 jsonify({"id": conv_id}) + return jsonify({"id": conv_id, "welcome": WELCOME_TEXT}) @app.route("/api/get_history") @@ -1264,9 +1287,12 @@ 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 = conn.execute("SELECT AVG(stars) FROM ratings").fetchone()[0] or 0 + rating_count = conn.execute("SELECT COUNT(*) FROM ratings").fetchone()[0] conn.close() return jsonify({"total_conv": total_conv, "total_msg": total_msg, - "good": good, "bad": bad, "today_conv": today_conv}) + "good": good, "bad": bad, "today_conv": today_conv, + "rating_avg": round(rating_avg, 1), "rating_count": rating_count}) # ===== 人工客服 ===== @@ -1275,6 +1301,11 @@ def human_request(): conv_id = request.json.get("conv_id") if not conv_id: return jsonify({"ok": False, "error": "no conv"}), 400 + # 工作时间 8:00-22:00,其他时间不接人工 + _h = datetime.now().hour + if _h < 8 or _h >= 22: + return jsonify({"ok": False, "closed": True, + "msg": "人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言"}), 200 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn = sqlite3.connect(DB) row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone() @@ -1293,13 +1324,14 @@ def human_poll(): after = request.args.get("after", 0, type=int) conn = sqlite3.connect(DB) 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() + 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 jsonify({"status": st[0] if st else "closed", + "note": st[1] if st else "", "typing": is_typing, "messages": [{"id": m[0], "side": m[1], "content": m[2], "ctime": m[3], "read": bool(m[4])} for m in msgs]}) @@ -1317,6 +1349,20 @@ def human_send(): conn.close() return jsonify({"ok": True}) +@app.route("/api/human/recall", methods=["POST"]) +def human_recall(): + data = request.json or {} + mid = data.get("mid") + conn = sqlite3.connect(DB) + row = conn.execute("SELECT session_id, side FROM human_messages WHERE id=?", (mid,)).fetchone() + if row: + sid, side = row + conn.execute("UPDATE human_messages SET content='', side='system' WHERE id=?", (mid,)) + 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() + return jsonify({"ok": True}) + @app.route("/api/human/queue") def human_queue(): conn = sqlite3.connect(DB) @@ -1349,6 +1395,50 @@ def human_close(): conn.close() return jsonify({"ok": True}) +@app.route("/api/human/transfer", methods=["POST"]) +def human_transfer(): + sid = request.json.get("sid") + conn = sqlite3.connect(DB) + conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=?", (sid,)) + 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() + return jsonify({"ok": True}) + +@app.route("/api/agents/list") +def agents_list(): + conn = sqlite3.connect(DB) + rows = conn.execute("SELECT id, username, name FROM agents ORDER BY id").fetchall() + conn.close() + return jsonify([{"id": r[0], "username": r[1], "name": r[2]} for r in rows]) + +@app.route("/api/rate", methods=["POST"]) +def rate_chat(): + data = request.json or {} + sid = data.get("sid") + stars = data.get("stars", 0) + comment = data.get("comment", "") + if not sid or not isinstance(stars, int) or stars < 1 or stars > 5: + return jsonify({"ok": False}), 400 + conn = sqlite3.connect(DB) + 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 jsonify({"ok": True}) + +@app.route("/api/human/note", methods=["POST"]) +def human_note(): + data = request.json or {} + sid = data.get("sid") + note = data.get("note", "") + conn = sqlite3.connect(DB) + conn.execute("UPDATE human_sessions SET note=? WHERE id=?", (note, sid)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + # 客服正在输入(简单内存标记) typing_map = {} @app.route("/api/human/typing", methods=["POST"]) @@ -1499,17 +1589,43 @@ def export_chat(): conn = sqlite3.connect(DB) 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() - lines = ["标题,角色,内容,时间"] + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + wb = Workbook() + ws = wb.active + ws.title = "聊天记录" + headers = ["对话标题", "角色", "内容", "时间"] + ws.append(headers) + header_fill = PatternFill(start_color="2978e8", end_color="2978e8", fill_type="solid") + for cell in ws[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center") + role_map = {"user": "客户", "assistant": "客服", "system": "系统"} for r in rows: - safe = (r[2] or "").replace(",", ",").replace("\n", " ") - lines.append(f"{r[0]},{r[1]},{safe},{r[3]}") - import csv, 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]]) - return Response(buf.getvalue(), mimetype="text/csv", headers={"Content-Disposition":"attachment;filename=chat_export.csv"}) + ws.append([r[0], role_map.get(r[1], r[1]), (r[2] or ""), r[3]]) + ws.column_dimensions["A"].width = 20 + ws.column_dimensions["B"].width = 8 + ws.column_dimensions["C"].width = 80 + ws.column_dimensions["D"].width = 20 + import io + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + return Response(buf.getvalue(), mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment;filename=chat_export.xlsx"}) + +@app.route("/api/search_chat") +def search_chat(): + kw = request.args.get("q", "").strip() + if not kw: + return jsonify({"results": []}) + conn = sqlite3.connect(DB) + 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""", ("%"+kw+"%",)).fetchall() + conn.close() + return jsonify([{"title":r[0],"role":r[1],"content":r[2],"ctime":r[3]} for r in rows]) @app.route("/admin") def admin(): diff --git a/check.py b/check.py index d4106e5..48168a9 100644 --- a/check.py +++ b/check.py @@ -1,5 +1,4 @@ import sqlite3 c = sqlite3.connect('history.db') -print(c.execute('SELECT * FROM human_messages ORDER BY id DESC LIMIT 5').fetchall()) -print('---') -print(c.execute("SELECT id, status FROM human_sessions WHERE status!='closed'").fetchall()) +print("messages:", [d[1] for d in c.execute("PRAGMA table_info(messages)").fetchall()]) +print("chat_history:", [d[1] for d in c.execute("PRAGMA table_info(chat_history)").fetchall()]) diff --git a/fix_user.py b/fix_user.py new file mode 100644 index 0000000..5c40284 --- /dev/null +++ b/fix_user.py @@ -0,0 +1,6 @@ +import sqlite3 +c = sqlite3.connect('history.db') +for u in ['123','456','789']: + c.execute("INSERT OR REPLACE INTO agents(username,password,name) VALUES(?,?,?)", (u,'123',u)) +c.commit() +print("ok") diff --git a/t.json b/t.json index 6347d41..db2be74 100644 --- a/t.json +++ b/t.json @@ -1 +1 @@ -{"conv_id":999} +{"msg":"[图片]/uploads/test.jpg","conv_id":1} diff --git a/templates/admin.html b/templates/admin.html index 69245b4..177ad55 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -64,12 +64,25 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h 今日 - 👍 - 👎 - + ⭐ - + +
+ + 导出Excel + 在线
-
导出聊天记录 在线
+
+ +
+

待接入

进行中

@@ -81,10 +94,24 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h +
+

会话备注

+ + +

快捷回复

您好,请问有什么可以帮您?
@@ -114,17 +146,86 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h var curSid = 0; var sessions = {}; +// 浏览器通知权限 +if("Notification" in window && Notification.permission === "default"){ + Notification.requestPermission(); +} +function notify(title, body){ + if(!("Notification" in window) || Notification.permission !== "granted") return; + if(!document.hidden) return; // 页面可见时不弹 + var n = new Notification(title, {body: body, icon: "data:image/svg+xml,🎧"}); + n.onclick = function(){ window.focus(); n.close(); }; +} + +// 夜间时段提示(22:00-8:00) +(function checkNight(){ + var h = new Date().getHours(); + var badge = document.getElementById("nightBadge"); + if(h >= 22 || h < 8){ badge.style.display = "inline-block"; } + setInterval(checkNight, 60000); +})(); + fetch("/api/admin/stats").then(function(r){return r.json()}).then(function(d){ s1.textContent=d.total_conv; s2.textContent=d.total_msg; s3.textContent=d.today_conv; s4.textContent=d.good; s5.textContent=d.bad; + s6.textContent=d.rating_avg+"("+d.rating_count+")"; }); +// 拖拽搜索弹窗 +(function(){ + var header = document.getElementById("searchHeader"); + var box = document.getElementById("searchResult"); + var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; + header.onmousedown = dragMouseDown; + function dragMouseDown(e){ + e = e || window.event; + e.preventDefault(); + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + document.onmousemove = elementDrag; + } + function elementDrag(e){ + e = e || window.event; + e.preventDefault(); + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + box.style.right = "auto"; + box.style.left = (box.offsetLeft - pos1) + "px"; + box.style.top = (box.offsetTop - pos2) + "px"; + } + function closeDragElement(){ + document.onmouseup = null; + document.onmousemove = null; + } +})(); + +function doSearch(){ + var q = document.getElementById("searchBox").value.trim(); + if(!q) return; + fetch("/api/search_chat?q="+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(list){ + document.getElementById("searchResult").style.display = "block"; + var body = document.getElementById("searchBody"); + if(!list.length){ body.innerHTML = '
无结果
'; return; } + body.innerHTML = list.map(function(r){ + return '
'+r.title+' ['+r.role+']
'+r.content+'
'+r.ctime+'
'; + }).join(""); + }); +} + function loadQueue(){ fetch("/api/human/queue").then(function(r){return r.json()}).then(function(list){ + var prevCount = document.getElementById("queue").dataset.count || 0; + var curCount = list.length; + document.getElementById("queue").dataset.count = curCount; + if(curCount > prevCount && document.hidden){ + notify("新客户排队", "有 " + curCount + " 位客户等待接入"); + } document.getElementById("queue").innerHTML = list.length ? list.map(function(x){return '
#'+x.id+' '+x.ctime+'
'}).join("") : '
暂无排队
'; - if(list.length && !sessions[String(list[0].id)]) accept(list[0].id); }); } setInterval(loadQueue, 3000); @@ -160,22 +261,60 @@ function switchSession(sid){ sessions[sid].msgs.forEach(function(m){ appendMsg(m); }); sessions[sid].unread = 0; renderList(); + // 从后端拉备注 + document.getElementById("noteInput").value = "加载中..."; + fetch("/api/human/poll?sid="+parseInt(sid)+"&after=0").then(function(r){return r.json()}).then(function(d){ + document.getElementById("noteInput").value = d.note || ""; + }); fetch("/api/human/read",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})}); } +function saveNote(){ + if(!curSid){ alert("请先选择一个对话"); return; } + var note = document.getElementById("noteInput").value; + sessions[curSid].note = note; + fetch("/api/human/note",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid), note:note})}).then(function(){ + var btn = event.target; + var old = btn.textContent; + btn.textContent = "已保存 ✓"; + btn.style.background = "#22c55e"; + setTimeout(function(){ btn.textContent = old; btn.style.background = "#2978e8"; }, 1500); + }); +} + function appendMsg(m){ var div=document.createElement("div"); + if(m.side==="system"){ + div.className="msg system-msg"; + div.innerHTML='
'+m.content+'
'; + document.getElementById("chatBox").appendChild(div); + document.getElementById("chatBox").scrollTop=999999; + return; + } div.className="msg "+(m.side==="customer"?"cust":"agent"); var c = m.content.replace(/'); } - div.innerHTML='
'+c+'
'+(m.side==="customer"?"客户":"客服")+'
'; + var recallBtn = m.side==="agent" ? '撤回' : ''; + div.innerHTML='
'+c+'
'+(m.side==="customer"?"客户":"客服")+recallBtn+'
'; document.getElementById("chatBox").appendChild(div); document.getElementById("chatBox").scrollTop=999999; } +function recallMsg(mid, el){ + fetch("/api/human/recall",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({mid:mid})}).then(function(){ + var msgDiv = el.closest(".msg"); + if(msgDiv){ + var bubble = msgDiv.querySelector(".bubble"); + if(bubble) bubble.innerHTML = ""; + var meta = msgDiv.querySelector(".meta"); + if(meta) meta.innerHTML = ""; + } + }); +} + function renderList(){ var el=document.getElementById("sessionList"); el.innerHTML=""; @@ -217,6 +356,9 @@ function poll(){ sessions[sid].lastId=m.id; sessions[sid].msgs.push(m); if(m.side==="customer" && sid!=curSid){ sessions[sid].unread=(sessions[sid].unread||0)+1; renderList(); } + if(m.side==="customer" && document.hidden){ + notify("客户消息 #"+sid, m.content.substring(0, 50)); + } if(sid==curSid) appendMsg(m); }); }).catch(function(){}); @@ -225,6 +367,12 @@ function poll(){ } setInterval(poll, 1000); +function insertEmoji2(e){ + document.getElementById("replyText").value += e; + document.getElementById("replyText").focus(); + document.getElementById("emojiPanel2").style.display = "none"; +} + function sendReply(){ var t=document.getElementById("replyText").value.trim(); if(!t || !curSid) return; @@ -247,6 +395,18 @@ function closeSession(){ renderList(); } +function transferSession(){ + if(!curSid) return; + if(!confirm("确定转接这个对话?转接后其他客服可在排队列表接入。")) return; + fetch("/api/human/transfer",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})}).then(function(){ + delete sessions[curSid]; + curSid=0; + document.getElementById("chatContent").style.display="none"; + document.getElementById("emptyTip").style.display="flex"; + renderList(); + }); +} + document.getElementById("replyText").addEventListener("keydown", function(e){ if(e.key==="Enter"){ e.preventDefault(); sendReply(); } }); diff --git a/templates/index.html b/templates/index.html index 1ee228a..9c9df3c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -653,6 +653,7 @@ border-top:solid 1px #dde7f3; background:#ffffff; align-items:flex-end; + position:relative; } #input-text:focus{ border-color:#2978e8; @@ -1248,6 +1249,19 @@
+ + @@ -1304,8 +1318,17 @@