加多客服登录、导出CSV、离线留言、文件发送、输入状态

This commit is contained in:
songxinnuo
2026-10-28 15:40:09 +08:00
parent fc9897ccb2
commit 761c629788
4 changed files with 151 additions and 14 deletions
+51 -3
View File
@@ -1456,13 +1456,61 @@ 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()
lines = ["标题,角色,内容,时间"]
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"})
@app.route("/admin")
def admin():
return render_template("admin_login.html")
+35 -1
View File
@@ -65,7 +65,7 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
<span>👍 <b id="s4">-</b></span>
<span>👎 <b id="s5">-</b></span>
</div>
<div class="status"><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">
@@ -103,6 +103,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>
@@ -125,6 +129,18 @@ function loadQueue(){
}
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,6 +160,7 @@ function switchSession(sid){
sessions[sid].msgs.forEach(function(m){ appendMsg(m); });
sessions[sid].unread = 0;
renderList();
fetch("/api/human/read",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})});
}
function appendMsg(m){
@@ -190,7 +207,13 @@ 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(); }
@@ -227,6 +250,17 @@ function closeSession(){
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()});
+59 -6
View File
@@ -1322,12 +1322,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,6 +1900,24 @@ 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);
@@ -1905,7 +1928,12 @@ window.showHuman = async function(){
var h = now.getHours();
var work = (day >= 1 && day <= 5) && (h >= 9 && h < 19);
if(!work){
addMsg("人工客服工作时间:周一至周五 9:00-18:00\n当前非工作时间,留言后工作时间内会尽快回复。", false);
addMsg("人工客服工作时间:周一至周五 9:00-19: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);
}
return;
}
addMsg("正在为您转接人工客服...", false);
@@ -1943,9 +1971,24 @@ function startHumanPoll(){
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
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.id <= last) return;
last = m.id;
if(m.side==="agent") addMsg(m.content, false);
var oldT2 = document.getElementById("human-typing");
if(oldT2) oldT2.remove();
});
})
.catch(()=>{});
@@ -1962,6 +2005,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 +2018,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 +2181,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 +2189,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})});
@@ -2154,7 +2207,7 @@ async function doSend(retryText){
if(imgUrl) fullContent += (fullContent ? " " : "") + "[图片]" + imgUrl;
addMsg(fullContent, true);
inputDom.value = "";
window.__pendingImg = "";
window.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
}