Compare commits

...
2 Commits
44 changed files with 572 additions and 28 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")
+171 -7
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"])
@@ -1456,13 +1546,87 @@ def uploaded_file(name):
def index():
return render_template("index.html")
@app.route("/api/my_orders")
def my_orders():
phone = request.args.get("phone", "").strip()
if not phone:
return jsonify({"orders": []})
conn = sqlite3.connect(DB)
rows = conn.execute("SELECT track_no, status, created FROM orders WHERE phone=? ORDER BY id DESC", (phone,)).fetchall()
conn.close()
return jsonify({"orders": [{"track_no": r[0], "status": r[1], "created": r[2]} for r in rows]})
@app.route("/api/ticket", methods=["POST"])
def add_ticket():
data = request.get_json() or {}
conn = sqlite3.connect(DB)
conn.execute("INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)",
(data.get("content",""), data.get("phone",""), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "pending"))
conn.commit(); conn.close()
return jsonify({"ok": True})
@app.route("/api/tickets")
def list_tickets():
conn = sqlite3.connect(DB)
rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall()
conn.close()
return jsonify([{"id":r[0],"content":r[1],"phone":r[2],"ctime":r[3]} for r in rows])
@app.route("/admin/login", methods=["POST"])
def admin_login():
pwd = request.json.get("password", "")
if pwd == "123":
return jsonify({"ok": True})
data = request.json or {}
u = data.get("username", "")
p = data.get("password", "")
conn = sqlite3.connect(DB)
row = conn.execute("SELECT name FROM agents WHERE username=? AND password=?", (u, p)).fetchone()
conn.close()
if row:
return jsonify({"ok": True, "name": row[0]})
return jsonify({"ok": False}), 401
@app.route("/api/export_chat")
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()
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:
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():
return render_template("admin_login.html")
+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}
+198 -4
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"><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>
<button class="close" onclick="closeSession()">结束会话</button>
<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>
@@ -103,6 +135,10 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
<div class="quick" onclick="quickReply('已为您催促,请稍候。')">已催促,请稍候</div>
<div class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询</div>
</div>
<div class="card">
<h4>离线留言</h4>
<div id="ticketList"></div>
</div>
</div>
</div>
@@ -110,21 +146,102 @@ 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);
function loadTickets(){
fetch("/api/tickets").then(function(r){return r.json()}).then(function(list){
var el = document.getElementById("ticketList");
if(!list.length){ el.innerHTML = '<div style="font-size:12px;color:#ccc;">暂无留言</div>'; return; }
el.innerHTML = list.map(function(t){
return '<div style="padding:6px;background:#fff7ed;border-radius:6px;margin-bottom:6px;font-size:12px;">'+t.content+'<br><span style="color:#999;">'+t.ctime+'</span></div>';
}).join("");
});
}
setInterval(loadTickets, 5000);
loadTickets();
function accept(sid){
sid = String(sid);
if(sessions[sid]) return;
@@ -144,21 +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="";
@@ -190,10 +346,19 @@ function poll(){
if(sid==curSid){ curSid=0; document.getElementById("chatContent").style.display="none"; document.getElementById("emptyTip").style.display="flex"; }
renderList(); return;
}
// 客户正在输入
var t = document.getElementById("typing-ind");
if(d.typing && sid==curSid){ if(!t){ t=document.createElement("div"); t.id="typing-ind"; t.style.cssText="font-size:12px;color:#999;padding:4px 0;"; t.textContent="客户正在输入..."; document.getElementById("chatBox").appendChild(t); } }
else if(t){ t.remove(); }
d.messages.forEach(function(m){
var exists = sessions[sid].msgs.some(function(x){return x.id===m.id});
if(exists) return;
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(){});
@@ -202,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;
@@ -224,9 +395,32 @@ 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(); }
});
// 客服正在输入通知客户
var typeTimer = null;
document.getElementById("replyText").addEventListener("input", function(){
if(!curSid) return;
fetch("/api/human/typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
clearTimeout(typeTimer);
typeTimer = setTimeout(function(){
fetch("/api/human/stop_typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
}, 3000);
});
</script>
</body>
</html>
+6 -4
View File
@@ -14,15 +14,17 @@ button{width:100%;padding:10px;background:#2978e8;color:#fff;border:none;border-
</head>
<body>
<div class="box">
<h2>🔒 后台登录</h2>
<input type="password" id="pwd" placeholder="请输入密码">
<p class="err" id="err">密码错误</p>
<h2>🔒 客服后台登录</h2>
<input type="text" id="user" placeholder="账号">
<input type="password" id="pwd" placeholder="密码">
<p class="err" id="err">账号或密码错误</p>
<button onclick="login()">登录</button>
</div>
<script>
function login(){
var u=document.getElementById("user").value;
var p=document.getElementById("pwd").value;
fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:p})})
fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})})
.then(function(r){if(r.ok){location.href="/admin/dashboard"}else{document.getElementById("err").style.display="block"}});
}
document.getElementById("pwd").addEventListener("keydown",function(e){if(e.key==="Enter")login()});
+146 -9
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");
@@ -1322,12 +1345,17 @@ uploadInput.onchange = function(){
uploadBtn.textContent = "📎";
if(data.ok){
window.__pendingImg = data.url;
window.__pendingTag = data.is_image ? "[图片]" : "[文件]";
var old = document.getElementById("imgPreview");
if(old) old.remove();
var prev = document.createElement("div");
prev.id = "imgPreview";
prev.style.cssText="margin:0 0 8px 50px;";
prev.innerHTML = '<img src="'+data.url+'" style="max-height:60px;border-radius:6px;border:1px solid #ddd;">';
if(data.is_image){
prev.innerHTML = '<img src="'+data.url+'" style="max-height:60px;border-radius:6px;border:1px solid #ddd;">';
}else{
prev.innerHTML = '<span style="padding:6px 12px;background:#f0f4fa;border-radius:6px;font-size:13px;">📎 '+data.name+'</span>';
}
var area = document.querySelector(".input-area");
area.insertBefore(prev, area.firstChild);
inputDom.focus();
@@ -1895,17 +1923,39 @@ window.sendOrder = function(){
}
}
function myOrders(){
var phone = window.prompt("请输入您的手机号:");
if(!phone) return;
addMsg("查询手机号 "+phone+" 的包裹...", false);
fetch("/api/my_orders?phone="+encodeURIComponent(phone))
.then(r=>r.json())
.then(d=>{
if(!d.orders.length){
addMsg("手机号 "+phone+" 下暂无运单记录。", false);
}else{
var txt = "您名下有 "+d.orders.length+" 个包裹:\n";
d.orders.forEach(function(o){ txt += "• "+o.track_no+"("+o.status+")\n"; });
addMsg(txt, false);
}
})
.catch(function(){ addMsg("查询失败,请重试", false); });
}
window.showHuman = async function(){
if(window.humanSid){
addMsg("您已在人工客服会话中,请直接提问。", false);
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-18: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("留言已提交,工作时间(8:00-22:00)会尽快回复您。", false);
}
return;
}
addMsg("正在为您转接人工客服...", false);
@@ -1919,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);
}
@@ -1937,15 +1994,70 @@ 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;
}
// 客服正在输入
var oldT = document.getElementById("human-typing");
if(d.typing && !oldT){
var td = document.createElement("div");
td.id = "human-typing";
td.style.cssText = "font-size:12px;color:#999;padding:4px 0;";
td.textContent = "客服正在输入...";
chatDom.appendChild(td);
chatDom.scrollTop = chatDom.scrollHeight;
}else if(!d.typing && oldT){
oldT.remove();
}
d.messages.forEach(m=>{
last=m.id;
if(m.side==="agent") addMsg(m.content, false);
if(m.id <= last) return;
last = m.id;
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();
});
})
.catch(()=>{});
@@ -1962,6 +2074,12 @@ function addMsg(content, isUser){
const m = content.match(/\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i);
if(m) imgUrl = m[0];
}
// 检测文件附件
let fileUrl = null;
if(content.indexOf("[文件]") >= 0){
const m = content.match(/\/uploads\/\S+\.(?:pdf|txt|csv|doc|docx|xls|xlsx|zip)/i);
if(m) fileUrl = m[0];
}
if(isUser){
rowDiv.classList.add("user-line");
const userMsg = document.createElement('div');
@@ -1969,6 +2087,9 @@ function addMsg(content, isUser){
if(imgUrl){
var txt = content.replace(/\[图片\]\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i, "").trim();
userMsg.innerHTML = '<img src="' + imgUrl + '" style="max-width:220px;border-radius:10px;display:block;background:transparent;cursor:zoom-in;" onclick="window.open(this.src)">' + (txt ? '<div style="margin-top:6px;">'+txt+'</div>' : '');
}else if(fileUrl){
var fname = fileUrl.split("/").pop();
userMsg.innerHTML = '<a href="'+fileUrl+'" target="_blank" style="color:inherit;text-decoration:none;display:inline-block;padding:8px 12px;background:rgba(255,255,255,0.2);border-radius:8px;">📎 '+fname+'</a>';
}else{
userMsg.innerText = content;
}
@@ -2129,6 +2250,7 @@ async function doSend(retryText){
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
var imgUrl = window.__pendingImg || "";
var tag = window.__pendingTag || "[图片]";
if(!sendWord && !imgUrl){
inputDom.focus();
return;
@@ -2136,10 +2258,10 @@ async function doSend(retryText){
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid){
var content = sendWord;
if(imgUrl) content += (content ? " " : "") + "[图片]" + imgUrl;
if(imgUrl) content += (content ? " " : "") + tag + imgUrl;
if(!isRetry) addMsg(content, true);
inputDom.value = "";
window.__pendingImg = "";
window.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:content})});
@@ -2152,9 +2274,24 @@ 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.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
}
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