加会话转接、满意度评价、会话备注、浏览器通知
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -227,6 +227,22 @@ def init_db():
|
|||||||
if "read_at" not in hm_cols:
|
if "read_at" not in hm_cols:
|
||||||
cur.execute("ALTER TABLE human_messages ADD COLUMN read_at TEXT")
|
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 消息,点开还能接着聊
|
# 老数据只有标题,补一条 user 消息,点开还能接着聊
|
||||||
old = cur.execute(
|
old = cur.execute(
|
||||||
"SELECT id, title FROM chat_history "
|
"SELECT id, title FROM chat_history "
|
||||||
@@ -937,7 +953,10 @@ def stream_chat():
|
|||||||
return Response(sse({"e": "问题不能为空"}), mimetype="text/event-stream")
|
return Response(sse({"e": "问题不能为空"}), mimetype="text/event-stream")
|
||||||
# 纯图片消息:不调AI
|
# 纯图片消息:不调AI
|
||||||
if "[图片]" in msg and len(msg) < 300:
|
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)
|
track_no = extract_tracking_no(msg)
|
||||||
@@ -1229,6 +1248,8 @@ def stream_chat():
|
|||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WELCOME_TEXT = "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。"
|
||||||
|
|
||||||
@app.route("/api/new_chat", methods=["POST"])
|
@app.route("/api/new_chat", methods=["POST"])
|
||||||
def new_chat():
|
def new_chat():
|
||||||
now = now_str()
|
now = now_str()
|
||||||
@@ -1237,9 +1258,11 @@ def new_chat():
|
|||||||
cur.execute("INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)",
|
cur.execute("INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)",
|
||||||
("新对话", now, now))
|
("新对话", now, now))
|
||||||
conv_id = cur.lastrowid
|
conv_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)",
|
||||||
|
(conv_id, "assistant", WELCOME_TEXT, now))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"id": conv_id})
|
return jsonify({"id": conv_id, "welcome": WELCOME_TEXT})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/get_history")
|
@app.route("/api/get_history")
|
||||||
@@ -1264,9 +1287,12 @@ def admin_stats():
|
|||||||
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote=-1").fetchone()[0]
|
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote=-1").fetchone()[0]
|
||||||
today = datetime.now().strftime("%Y-%m-%d")
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
today_conv = conn.execute("SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)", (today,)).fetchone()[0]
|
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()
|
conn.close()
|
||||||
return jsonify({"total_conv": total_conv, "total_msg": total_msg,
|
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")
|
conv_id = request.json.get("conv_id")
|
||||||
if not conv_id:
|
if not conv_id:
|
||||||
return jsonify({"ok": False, "error": "no conv"}), 400
|
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")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
conn = sqlite3.connect(DB)
|
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()
|
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)
|
after = request.args.get("after", 0, type=int)
|
||||||
conn = sqlite3.connect(DB)
|
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()
|
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()
|
conn.close()
|
||||||
is_typing = False
|
is_typing = False
|
||||||
ts = typing_map.get(str(sid))
|
ts = typing_map.get(str(sid))
|
||||||
if ts and time.time() - ts < 5:
|
if ts and time.time() - ts < 5:
|
||||||
is_typing = True
|
is_typing = True
|
||||||
return jsonify({"status": st[0] if st else "closed",
|
return jsonify({"status": st[0] if st else "closed",
|
||||||
|
"note": st[1] if st else "",
|
||||||
"typing": is_typing,
|
"typing": is_typing,
|
||||||
"messages": [{"id": m[0], "side": m[1], "content": m[2], "ctime": m[3], "read": bool(m[4])} for m in msgs]})
|
"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()
|
conn.close()
|
||||||
return jsonify({"ok": True})
|
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")
|
@app.route("/api/human/queue")
|
||||||
def human_queue():
|
def human_queue():
|
||||||
conn = sqlite3.connect(DB)
|
conn = sqlite3.connect(DB)
|
||||||
@@ -1349,6 +1395,50 @@ def human_close():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": True})
|
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 = {}
|
typing_map = {}
|
||||||
@app.route("/api/human/typing", methods=["POST"])
|
@app.route("/api/human/typing", methods=["POST"])
|
||||||
@@ -1499,17 +1589,43 @@ def export_chat():
|
|||||||
conn = sqlite3.connect(DB)
|
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()
|
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()
|
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:
|
for r in rows:
|
||||||
safe = (r[2] or "").replace(",", ",").replace("\n", " ")
|
ws.append([r[0], role_map.get(r[1], r[1]), (r[2] or ""), r[3]])
|
||||||
lines.append(f"{r[0]},{r[1]},{safe},{r[3]}")
|
ws.column_dimensions["A"].width = 20
|
||||||
import csv, io
|
ws.column_dimensions["B"].width = 8
|
||||||
buf = io.StringIO()
|
ws.column_dimensions["C"].width = 80
|
||||||
w = csv.writer(buf)
|
ws.column_dimensions["D"].width = 20
|
||||||
w.writerow(["标题","角色","内容","时间"])
|
import io
|
||||||
for r in rows:
|
buf = io.BytesIO()
|
||||||
w.writerow([r[0], r[1], (r[2] or ""), r[3]])
|
wb.save(buf)
|
||||||
return Response(buf.getvalue(), mimetype="text/csv", headers={"Content-Disposition":"attachment;filename=chat_export.csv"})
|
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")
|
@app.route("/admin")
|
||||||
def admin():
|
def admin():
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
c = sqlite3.connect('history.db')
|
c = sqlite3.connect('history.db')
|
||||||
print(c.execute('SELECT * FROM human_messages ORDER BY id DESC LIMIT 5').fetchall())
|
print("messages:", [d[1] for d in c.execute("PRAGMA table_info(messages)").fetchall()])
|
||||||
print('---')
|
print("chat_history:", [d[1] for d in c.execute("PRAGMA table_info(chat_history)").fetchall()])
|
||||||
print(c.execute("SELECT id, status FROM human_sessions WHERE status!='closed'").fetchall())
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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="s3">-</b></span>
|
||||||
<span>👍 <b id="s4">-</b></span>
|
<span>👍 <b id="s4">-</b></span>
|
||||||
<span>👎 <b id="s5">-</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>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="conv-list">
|
<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>
|
<h3>待接入</h3>
|
||||||
<div id="queue"></div>
|
<div id="queue"></div>
|
||||||
<h3>进行中</h3>
|
<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 id="chatContent" style="display:none;flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<span class="ct" id="chatTitle">对话</span>
|
<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>
|
<button class="close" onclick="closeSession()">结束会话</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="chatBox"></div>
|
<div id="chatBox"></div>
|
||||||
<div class="input-bar">
|
<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发送">
|
<input type="text" id="replyText" placeholder="输入回复,Enter发送">
|
||||||
<button onclick="sendReply()">发送</button>
|
<button onclick="sendReply()">发送</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +123,11 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
|
|||||||
<h4>客户信息</h4>
|
<h4>客户信息</h4>
|
||||||
<div class="info-row" id="cInfo">选择对话后显示</div>
|
<div class="info-row" id="cInfo">选择对话后显示</div>
|
||||||
</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">
|
<div class="card">
|
||||||
<h4>快捷回复</h4>
|
<h4>快捷回复</h4>
|
||||||
<div class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,请问有什么可以帮您?</div>
|
<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 curSid = 0;
|
||||||
var sessions = {};
|
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){
|
fetch("/api/admin/stats").then(function(r){return r.json()}).then(function(d){
|
||||||
s1.textContent=d.total_conv; s2.textContent=d.total_msg;
|
s1.textContent=d.total_conv; s2.textContent=d.total_msg;
|
||||||
s3.textContent=d.today_conv; s4.textContent=d.good; s5.textContent=d.bad;
|
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(){
|
function loadQueue(){
|
||||||
fetch("/api/human/queue").then(function(r){return r.json()}).then(function(list){
|
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
|
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("")
|
? 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>';
|
: '<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);
|
setInterval(loadQueue, 3000);
|
||||||
@@ -160,22 +261,60 @@ function switchSession(sid){
|
|||||||
sessions[sid].msgs.forEach(function(m){ appendMsg(m); });
|
sessions[sid].msgs.forEach(function(m){ appendMsg(m); });
|
||||||
sessions[sid].unread = 0;
|
sessions[sid].unread = 0;
|
||||||
renderList();
|
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)})});
|
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){
|
function appendMsg(m){
|
||||||
var div=document.createElement("div");
|
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");
|
div.className="msg "+(m.side==="customer"?"cust":"agent");
|
||||||
var c = m.content.replace(/</g,"<");
|
var c = m.content.replace(/</g,"<");
|
||||||
var imgMatch = c.match(/\[图片\](\/uploads\/\S+)/);
|
var imgMatch = c.match(/\[图片\](\/uploads\/\S+)/);
|
||||||
if(imgMatch){
|
if(imgMatch){
|
||||||
c = c.replace(imgMatch[0], '<img src="'+imgMatch[1]+'" style="max-width:200px;border-radius:6px;display:block;">');
|
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").appendChild(div);
|
||||||
document.getElementById("chatBox").scrollTop=999999;
|
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(){
|
function renderList(){
|
||||||
var el=document.getElementById("sessionList");
|
var el=document.getElementById("sessionList");
|
||||||
el.innerHTML="";
|
el.innerHTML="";
|
||||||
@@ -217,6 +356,9 @@ function poll(){
|
|||||||
sessions[sid].lastId=m.id;
|
sessions[sid].lastId=m.id;
|
||||||
sessions[sid].msgs.push(m);
|
sessions[sid].msgs.push(m);
|
||||||
if(m.side==="customer" && sid!=curSid){ sessions[sid].unread=(sessions[sid].unread||0)+1; renderList(); }
|
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);
|
if(sid==curSid) appendMsg(m);
|
||||||
});
|
});
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
@@ -225,6 +367,12 @@ function poll(){
|
|||||||
}
|
}
|
||||||
setInterval(poll, 1000);
|
setInterval(poll, 1000);
|
||||||
|
|
||||||
|
function insertEmoji2(e){
|
||||||
|
document.getElementById("replyText").value += e;
|
||||||
|
document.getElementById("replyText").focus();
|
||||||
|
document.getElementById("emojiPanel2").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
function sendReply(){
|
function sendReply(){
|
||||||
var t=document.getElementById("replyText").value.trim();
|
var t=document.getElementById("replyText").value.trim();
|
||||||
if(!t || !curSid) return;
|
if(!t || !curSid) return;
|
||||||
@@ -247,6 +395,18 @@ function closeSession(){
|
|||||||
renderList();
|
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){
|
document.getElementById("replyText").addEventListener("keydown", function(e){
|
||||||
if(e.key==="Enter"){ e.preventDefault(); sendReply(); }
|
if(e.key==="Enter"){ e.preventDefault(); sendReply(); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -653,6 +653,7 @@
|
|||||||
border-top:solid 1px #dde7f3;
|
border-top:solid 1px #dde7f3;
|
||||||
background:#ffffff;
|
background:#ffffff;
|
||||||
align-items:flex-end;
|
align-items:flex-end;
|
||||||
|
position:relative;
|
||||||
}
|
}
|
||||||
#input-text:focus{
|
#input-text:focus{
|
||||||
border-color:#2978e8;
|
border-color:#2978e8;
|
||||||
@@ -1248,6 +1249,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<button id="upload-btn" title="发送图片">📎</button>
|
<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>
|
<input type="file" id="upload-input" accept="image/*,.pdf" hidden>
|
||||||
<textarea id="input-text" rows="1" placeholder="请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)"></textarea>
|
<textarea id="input-text" rows="1" placeholder="请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)"></textarea>
|
||||||
<button id="send-button">➤</button>
|
<button id="send-button">➤</button>
|
||||||
@@ -1304,8 +1318,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function(){
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
|
// 请求浏览器通知权限
|
||||||
|
if("Notification" in window && Notification.permission === "default"){
|
||||||
|
Notification.requestPermission();
|
||||||
|
}
|
||||||
const chatDom = document.getElementById("chatDom");
|
const chatDom = document.getElementById("chatDom");
|
||||||
const inputDom = document.getElementById("input-text");
|
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 sendBtn = document.getElementById("send-button");
|
||||||
const uploadBtn = document.getElementById("upload-btn");
|
const uploadBtn = document.getElementById("upload-btn");
|
||||||
const uploadInput = document.getElementById("upload-input");
|
const uploadInput = document.getElementById("upload-input");
|
||||||
@@ -1924,15 +1947,14 @@ window.showHuman = async function(){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var day = now.getDay();
|
|
||||||
var h = now.getHours();
|
var h = now.getHours();
|
||||||
var work = (day >= 1 && day <= 5) && (h >= 9 && h < 19);
|
var work = (h >= 8 && h < 22);
|
||||||
if(!work){
|
if(!work){
|
||||||
addMsg("人工客服工作时间:周一至周五 9:00-19:00\n当前非工作时间,请留言,工作时间会尽快回复。", false);
|
addMsg("人工客服工作时间:每天 8:00-22:00\n当前为非工作时间,请留言,工作时间会尽快回复您。", false);
|
||||||
var msg = window.prompt("请输入您的留言:");
|
var msg = window.prompt("请输入您的留言:");
|
||||||
if(msg){
|
if(msg){
|
||||||
fetch("/api/ticket",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:msg, phone:""})});
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1947,6 +1969,13 @@ window.showHuman = async function(){
|
|||||||
window.humanSid = d.session_id;
|
window.humanSid = d.session_id;
|
||||||
addMsg("您已加入人工排队,请稍候...", false);
|
addMsg("您已加入人工排队,请稍候...", false);
|
||||||
startHumanPoll();
|
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{
|
}else{
|
||||||
addMsg("转接失败,请稍后再试。", false);
|
addMsg("转接失败,请稍后再试。", false);
|
||||||
}
|
}
|
||||||
@@ -1965,10 +1994,35 @@ function startHumanPoll(){
|
|||||||
.then(d=>{
|
.then(d=>{
|
||||||
if(d.status==="closed"){
|
if(d.status==="closed"){
|
||||||
clearInterval(t);
|
clearInterval(t);
|
||||||
|
var sid = window.humanSid;
|
||||||
window.humanSid=0;
|
window.humanSid=0;
|
||||||
window.__busy=false;
|
window.__busy=false;
|
||||||
if(typeof setBusy==="function") setBusy(false);
|
if(typeof setBusy==="function") setBusy(false);
|
||||||
addMsg("人工客服已结束,您可以继续向 AI 提问。", 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;
|
return;
|
||||||
}
|
}
|
||||||
// 客服正在输入
|
// 客服正在输入
|
||||||
@@ -1986,7 +2040,22 @@ function startHumanPoll(){
|
|||||||
d.messages.forEach(m=>{
|
d.messages.forEach(m=>{
|
||||||
if(m.id <= last) return;
|
if(m.id <= last) return;
|
||||||
last = m.id;
|
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");
|
var oldT2 = document.getElementById("human-typing");
|
||||||
if(oldT2) oldT2.remove();
|
if(oldT2) oldT2.remove();
|
||||||
});
|
});
|
||||||
@@ -2205,6 +2274,21 @@ async function doSend(retryText){
|
|||||||
if(!isRetry){
|
if(!isRetry){
|
||||||
var fullContent = sendWord;
|
var fullContent = sendWord;
|
||||||
if(imgUrl) fullContent += (fullContent ? " " : "") + "[图片]" + imgUrl;
|
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);
|
addMsg(fullContent, true);
|
||||||
inputDom.value = "";
|
inputDom.value = "";
|
||||||
window.__pendingImg = ""; window.__pendingTag = "[图片]";
|
window.__pendingImg = ""; window.__pendingTag = "[图片]";
|
||||||
|
|||||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |