加多客服登录、导出CSV、离线留言、文件发送、输入状态
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user