加会话转接、满意度评价、会话备注、浏览器通知

This commit is contained in:
songxinnuo
2026-10-29 11:24:11 +08:00
parent 761c629788
commit 2d6806b15d
43 changed files with 434 additions and 27 deletions
+13
View File
@@ -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")
+11
View File
@@ -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")
+7
View File
@@ -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")
+11
View File
@@ -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")
+130 -14
View File
@@ -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():
+2 -3
View File
@@ -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()])
+6
View File
@@ -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")
+1 -1
View File
@@ -1 +1 @@
{"conv_id":999}
{"msg":"[图片]/uploads/test.jpg","conv_id":1}
+163 -3
View File
@@ -64,12 +64,25 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
<span>今日 <b id="s3">-</b></span>
<span>👍 <b id="s4">-</b></span>
<span>👎 <b id="s5">-</b></span>
<span>⭐ <b id="s6">-</b></span>
</div>
<div class="status">
<span id="nightBadge" style="display:none;font-size:12px;background:#1e3a5f;color:#fbbf24;padding:3px 10px;border-radius:10px;margin-right:12px;">🌙 夜间时段</span>
<a href="/api/export_chat" style="font-size:13px;color:#2978e8;text-decoration:none;margin-right:16px;">导出Excel</a>
<span class="dot"></span> 在线
</div>
<div class="status"><a href="/api/export_chat" style="font-size:13px;color:#2978e8;text-decoration:none;margin-right:16px;">导出聊天记录</a><span class="dot"></span> 在线</div>
</div>
<div class="layout">
<div class="conv-list">
<div style="padding:10px;border-bottom:1px solid #f0f0f0;">
<input id="searchBox" placeholder="搜索聊天记录..." style="width:100%;padding:6px 8px;border:1px solid #ddd;border-radius:4px;font-size:12px;box-sizing:border-box;" onkeydown="if(event.key==='Enter')doSearch()">
</div>
<div id="searchResult" style="position:fixed;top:70px;right:20px;background:#fff;border:2px solid #dc2626;border-radius:8px;max-height:500px;overflow-y:auto;z-index:9999;display:none;width:500px;box-shadow:0 4px 16px rgba(0,0,0,0.2);resize:both;overflow:auto;min-width:300px;min-height:200px;">
<button onclick="this.parentElement.style.display='none';document.getElementById('searchBox').value='';" style="position:absolute;top:8px;right:12px;border:none;background:#fff;color:#dc2626;border-radius:50%;width:30px;height:30px;font-size:16px;cursor:pointer;z-index:10;font-weight:bold;">✕</button>
<div id="searchHeader" style="padding:12px;background:#dc2626;color:#fff;font-weight:bold;cursor:move;">搜索结果</div>
<div id="searchBody"></div>
</div>
<h3>待接入</h3>
<div id="queue"></div>
<h3>进行中</h3>
@@ -81,10 +94,24 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
<div id="chatContent" style="display:none;flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div class="chat-header">
<span class="ct" id="chatTitle">对话</span>
<div style="display:flex;gap:8px;">
<button onclick="transferSession()" style="background:#f59e0b;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;">转接</button>
<button class="close" onclick="closeSession()">结束会话</button>
</div>
</div>
<div id="chatBox"></div>
<div class="input-bar">
<button onclick="document.getElementById('emojiPanel2').style.display=document.getElementById('emojiPanel2').style.display==='block'?'none':'block'" style="font-size:20px;background:none;border:none;cursor:pointer;">😊</button>
<div id="emojiPanel2" style="display:none;position:absolute;bottom:60px;left:10px;background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:99;">
<span onclick="insertEmoji2('😊')" style="cursor:pointer;font-size:20px;margin:2px;">😊</span>
<span onclick="insertEmoji2('😂')" style="cursor:pointer;font-size:20px;margin:2px;">😂</span>
<span onclick="insertEmoji2('👍')" style="cursor:pointer;font-size:20px;margin:2px;">👍</span>
<span onclick="insertEmoji2('❤️')" style="cursor:pointer;font-size:20px;margin:2px;">❤️</span>
<span onclick="insertEmoji2('🙏')" style="cursor:pointer;font-size:20px;margin:2px;">🙏</span>
<span onclick="insertEmoji2('😅')" style="cursor:pointer;font-size:20px;margin:2px;">😅</span>
<span onclick="insertEmoji2('😢')" style="cursor:pointer;font-size:20px;margin:2px;">😢</span>
<span onclick="insertEmoji2('🎉')" style="cursor:pointer;font-size:20px;margin:2px;">🎉</span>
</div>
<input type="text" id="replyText" placeholder="输入回复,Enter发送">
<button onclick="sendReply()">发送</button>
</div>
@@ -96,6 +123,11 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
<h4>客户信息</h4>
<div class="info-row" id="cInfo">选择对话后显示</div>
</div>
<div class="card">
<h4>会话备注</h4>
<textarea id="noteInput" placeholder="给这个对话加备注..." style="width:100%;height:60px;border:1px solid #ddd;border-radius:4px;padding:6px;font-size:12px;resize:none;box-sizing:border-box;"></textarea>
<button onclick="saveNote()" style="width:100%;margin-top:6px;background:#2978e8;color:#fff;border:none;padding:5px;border-radius:4px;cursor:pointer;font-size:12px;">保存备注</button>
</div>
<div class="card">
<h4>快捷回复</h4>
<div class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,请问有什么可以帮您?</div>
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>"});
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 = '<div style="padding:10px;color:#999;">无结果</div>'; return; }
body.innerHTML = list.map(function(r){
return '<div style="padding:8px 12px;border-bottom:1px solid #f0f0f0;font-size:12px;"><b>'+r.title+'</b> ['+r.role+']<br>'+r.content+'<br><span style="color:#999;">'+r.ctime+'</span></div>';
}).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 '<div class="queue-item"><div class="qt">#'+x.id+' '+x.ctime+'</div><button onclick="accept('+x.id+')">接入</button></div>'}).join("")
: '<div style="padding:10px 14px;font-size:12px;color:#ccc;">暂无排队</div>';
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='<div style="text-align:center;font-size:12px;color:#999;padding:4px 0;">'+m.content+'</div>';
document.getElementById("chatBox").appendChild(div);
document.getElementById("chatBox").scrollTop=999999;
return;
}
div.className="msg "+(m.side==="customer"?"cust":"agent");
var c = m.content.replace(/</g,"&lt;");
var imgMatch = c.match(/\[图片\](\/uploads\/\S+)/);
if(imgMatch){
c = c.replace(imgMatch[0], '<img src="'+imgMatch[1]+'" style="max-width:200px;border-radius:6px;display:block;">');
}
div.innerHTML='<div><div class="bubble">'+c+'</div><div class="meta">'+(m.side==="customer"?"客户":"客服")+'</div></div>';
var recallBtn = m.side==="agent" ? '<span onclick="recallMsg('+m.id+',this)" style="font-size:11px;color:#999;cursor:pointer;margin-left:6px;">撤回</span>' : '';
div.innerHTML='<div><div class="bubble">'+c+'</div><div class="meta">'+(m.side==="customer"?"客户":"客服")+recallBtn+'</div></div>';
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(); }
});
+89 -5
View File
@@ -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 @@
</div>
<div class="input-area">
<button id="upload-btn" title="发送图片">📎</button>
<button id="emoji-btn" title="表情" onclick="document.getElementById('emojiPanel').style.display=document.getElementById('emojiPanel').style.display==='block'?'none':'block'">😊</button>
<div id="emojiPanel" style="display:none;position:absolute;bottom:60px;left:10px;background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:99;">
<span onclick="insertEmoji('😊')" style="cursor:pointer;font-size:20px;margin:2px;">😊</span>
<span onclick="insertEmoji('😂')" style="cursor:pointer;font-size:20px;margin:2px;">😂</span>
<span onclick="insertEmoji('👍')" style="cursor:pointer;font-size:20px;margin:2px;">👍</span>
<span onclick="insertEmoji('❤️')" style="cursor:pointer;font-size:20px;margin:2px;">❤️</span>
<span onclick="insertEmoji('🙏')" style="cursor:pointer;font-size:20px;margin:2px;">🙏</span>
<span onclick="insertEmoji('😅')" style="cursor:pointer;font-size:20px;margin:2px;">😅</span>
<span onclick="insertEmoji('😢')" style="cursor:pointer;font-size:20px;margin:2px;">😢</span>
<span onclick="insertEmoji('🎉')" style="cursor:pointer;font-size:20px;margin:2px;">🎉</span>
<span onclick="insertEmoji('📦')" style="cursor:pointer;font-size:20px;margin:2px;">📦</span>
<span onclick="insertEmoji('🚚')" style="cursor:pointer;font-size:20px;margin:2px;">🚚</span>
</div>
<input type="file" id="upload-input" accept="image/*,.pdf" hidden>
<textarea id="input-text" rows="1" placeholder="请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)"></textarea>
<button id="send-button">➤</button>
@@ -1304,8 +1318,17 @@
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
// 请求浏览器通知权限
if("Notification" in window && Notification.permission === "default"){
Notification.requestPermission();
}
const chatDom = document.getElementById("chatDom");
const inputDom = document.getElementById("input-text");
window.insertEmoji = function(e){
inputDom.value += e;
inputDom.focus();
document.getElementById("emojiPanel").style.display = "none";
};
const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
@@ -1924,15 +1947,14 @@ 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);
addMsg("人工客服工作时间:每天 8:00-22: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);
addMsg("留言已提交,工作时间(8:00-22:00)会尽快回复您。", false);
}
return;
}
@@ -1947,6 +1969,13 @@ window.showHuman = async function(){
window.humanSid = d.session_id;
addMsg("您已加入人工排队,请稍候...", false);
startHumanPoll();
}else if(d.closed){
addMsg(d.msg || "人工客服当前不在线,请留言。", false);
var msg = window.prompt("请输入您的留言:");
if(msg){
fetch("/api/ticket",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:msg, phone:""})});
addMsg("留言已提交,工作时间(8:00-22:00)会尽快回复您。", false);
}
}else{
addMsg("转接失败,请稍后再试。", false);
}
@@ -1965,10 +1994,35 @@ function startHumanPoll(){
.then(d=>{
if(d.status==="closed"){
clearInterval(t);
var sid = window.humanSid;
window.humanSid=0;
window.__busy=false;
if(typeof setBusy==="function") setBusy(false);
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
// 满意度评价
if(sid){
var ratingBox = document.createElement("div");
ratingBox.style.cssText = "text-align:center;padding:12px;margin:8px 0;background:#f8fafc;border-radius:10px;";
ratingBox.innerHTML = '<div style="font-size:13px;color:#666;margin-bottom:8px;">本次服务满意吗?</div>' +
'<div style="font-size:24px;cursor:pointer;letter-spacing:4px;">' +
[1,2,3,4,5].map(function(s){return '<span data-star="'+s+'" style="cursor:pointer;color:#ddd;">★</span>'}).join("") +
'</div>';
chatDom.appendChild(ratingBox);
chatDom.scrollTop = chatDom.scrollHeight;
ratingBox.querySelectorAll("[data-star]").forEach(function(star){
star.onmouseover = function(){
var v = parseInt(this.dataset.star);
ratingBox.querySelectorAll("[data-star]").forEach(function(s){
s.style.color = parseInt(s.dataset.star) <= v ? "#fbbf24" : "#ddd";
});
};
star.onclick = function(){
var v = parseInt(this.dataset.star);
fetch("/api/rate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:sid, stars:v, comment:""})});
ratingBox.innerHTML = '<div style="font-size:13px;color:#666;">感谢您的评价!' + "⭐".repeat(v) + '</div>';
};
});
}
return;
}
// 客服正在输入
@@ -1986,7 +2040,22 @@ function startHumanPoll(){
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
if(m.side==="agent") addMsg(m.content, false);
if(m.side==="system"){
// 撤回:删掉最后一条agent消息
var msgs = chatDom.querySelectorAll(".robot-line");
if(msgs.length) msgs[msgs.length-1].remove();
var sysDiv = document.createElement("div");
sysDiv.style.cssText = "text-align:center;font-size:12px;color:#999;padding:4px 0;";
sysDiv.textContent = m.content;
chatDom.appendChild(sysDiv);
chatDom.scrollTop = chatDom.scrollHeight;
} else if(m.side==="agent"){
addMsg(m.content, false);
if(document.hidden && "Notification" in window && Notification.permission === "granted"){
var n = new Notification("客服回复", {body: m.content.substring(0,50)});
n.onclick = function(){ window.focus(); n.close(); };
}
}
var oldT2 = document.getElementById("human-typing");
if(oldT2) oldT2.remove();
});
@@ -2205,6 +2274,21 @@ async function doSend(retryText){
if(!isRetry){
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + "[图片]" + imgUrl;
// 新对话:先建会话,再显示自动欢迎语
if(!currentConvId){
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(sendWord);
});
return;
}
addMsg("您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。", false);
}
addMsg(fullContent, true);
inputDom.value = "";
window.__pendingImg = ""; window.__pendingTag = "[图片]";
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB