2 Commits
9 changed files with 236 additions and 148 deletions
+13 -5
View File
@@ -44,7 +44,7 @@ _hits = defaultdict(list)
@app.before_request
def rate_limit():
p = request.path
if p.startswith("/static"):
if p.startswith("/static") or p.startswith("/api/human/"):
return None
ip = request.remote_addr or "unknown"
now = time.time()
@@ -1236,10 +1236,6 @@ def get_history():
# ===== 管理员后台 =====
@app.route("/admin")
def admin():
return render_template("admin.html")
@app.route("/api/admin/stats")
def admin_stats():
conn = sqlite3.connect(DB)
@@ -1309,6 +1305,13 @@ def human_queue():
conn.close()
return jsonify([{"id": r[0], "conv_id": r[1], "ctime": r[3]} for r in rows])
@app.route("/api/human/active")
def human_active():
conn = sqlite3.connect(DB)
rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall()
conn.close()
return jsonify([{"id": r[0], "conv_id": r[1]} for r in rows])
@app.route("/api/human/accept", methods=["POST"])
def human_accept():
sid = request.json.get("sid")
@@ -1434,6 +1437,11 @@ def uploaded_file(name):
def index():
return render_template("index.html")
@app.route("/admin")
def admin():
return render_template("admin.html")
if __name__ == "__main__":
# 0.0.0.0 = 接受局域网内所有设备访问(手机、其他电脑);
# 只在演示时开启,演示完建议改回 127.0.0.1,避免别人误用消耗 API 额度
+1
View File
@@ -0,0 +1 @@
{"sid":24}
+5
View File
@@ -0,0 +1,5 @@
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())
+5
View File
@@ -0,0 +1,5 @@
import sqlite3
c = sqlite3.connect('history.db')
c.execute("UPDATE human_sessions SET status='closed' WHERE status='active'")
c.commit()
print('ok')
+1
View File
@@ -0,0 +1 @@
{"sid":48,"side":"agent","content":"test123"}
+1
View File
@@ -0,0 +1 @@
{"conv_id":999}
+163 -110
View File
@@ -3,172 +3,225 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>柏悦物流 - 管理后台</title>
<title>柏悦物流 - 客服工作台</title>
<style>
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f4fa;margin:0;padding:20px;}
h1{color:#18479c;}
.card{background:#fff;border-radius:12px;padding:20px;margin-bottom:16px;box-shadow:0 2px 8px rgba(0,0,0,0.06);}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;}
.stat{background:linear-gradient(135deg,#e8f0ff,#fff);border-radius:10px;padding:14px;text-align:center;}
.stat .num{font-size:24px;font-weight:bold;color:#2978e8;}
.stat .label{font-size:12px;color:#666;}
.layout{display:grid;grid-template-columns:220px 1fr;gap:16px;}
#sessionList{max-height:400px;overflow-y:auto;}
.session-item{padding:10px;border-radius:8px;cursor:pointer;margin-bottom:6px;background:#f5f8fc;font-size:13px;}
.session-item.active{background:#2978e8;color:white;}
.session-item .badge{background:#dc2626;color:white;border-radius:10px;padding:1px 6px;font-size:11px;margin-left:4px;}
#chatBox{height:350px;overflow-y:auto;background:#f8fafc;border-radius:8px;padding:10px;margin-bottom:10px;}
.msg{margin-bottom:8px;padding:6px 10px;border-radius:8px;max-width:70%;font-size:14px;}
.cust{background:#dbeafe;margin-right:auto;}
.agent{background:#dcfce7;margin-left:auto;}
.btn{background:#2978e8;color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;}
.btn:hover{background:#18479c;}
.btn.red{background:#dc2626;}
input[type=text]{width:75%;padding:8px;border:1px solid #ddd;border-radius:6px;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{padding:6px 10px;border-bottom:1px solid #eee;text-align:left;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;height:100vh;display:flex;flex-direction:column;}
.topbar{background:#fff;padding:10px 20px;display:flex;align-items:center;gap:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10;}
.topbar .logo{font-size:18px;font-weight:bold;color:#18479c;}
.topbar .stats{display:flex;gap:20px;font-size:13px;color:#666;margin-left:20px;}
.topbar .stats b{color:#2978e8;font-size:16px;}
.topbar .status{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:13px;color:#666;}
.topbar .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;}
.layout{flex:1;display:grid;grid-template-columns:260px 1fr 240px;overflow:hidden;}
/* 左栏:对话列表 */
.conv-list{background:#fff;border-right:1px solid #e8e8e8;overflow-y:auto;}
.conv-list h3{padding:12px 14px;font-size:13px;color:#999;border-bottom:1px solid #f0f0f0;}
.conv-item{padding:12px 14px;cursor:pointer;border-bottom:1px solid #f5f5f5;display:flex;align-items:center;gap:10px;}
.conv-item:hover{background:#f5f8fc;}
.conv-item.active{background:#e8f0ff;border-left:3px solid #2978e8;}
.conv-item .avatar{width:36px;height:36px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;}
.conv-item .info{flex:1;min-width:0;}
.conv-item .name{font-size:14px;font-weight:500;}
.conv-item .preview{font-size:12px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.conv-item .badge{background:#dc2626;color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;}
.queue-item{padding:10px 14px;background:#fff7ed;border-bottom:1px solid #ffedd5;}
.queue-item .qt{font-size:13px;color:#ea580c;font-weight:500;}
.queue-item button{margin-top:6px;width:100%;background:#ea580c;color:#fff;border:none;padding:5px;border-radius:4px;cursor:pointer;font-size:12px;}
/* 中间:聊天 */
.chat-area{display:flex;flex-direction:column;background:#f8fafc;}
.chat-header{background:#fff;padding:12px 20px;border-bottom:1px solid #e8e8e8;display:flex;align-items:center;justify-content:space-between;}
.chat-header .ct{font-size:15px;font-weight:500;}
.chat-header .close{background:#dc2626;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;}
#chatBox{flex:1;overflow-y:auto;padding:20px;}
.msg{margin-bottom:12px;display:flex;}
.msg .bubble{max-width:60%;padding:10px 14px;border-radius:12px;font-size:14px;line-height:1.5;overflow-wrap:break-word;}
.msg.cust{justify-content:flex-start;}
.msg.cust .bubble{background:#fff;border:1px solid #e8e8e8;border-top-left-radius:4px;}
.msg.agent{justify-content:flex-end;}
.msg.agent .bubble{background:#2978e8;color:#fff;border-top-right-radius:4px;}
.msg .meta{font-size:11px;color:#aaa;margin-top:3px;}
.input-bar{background:#fff;border-top:1px solid #e8e8e8;padding:12px 20px;display:flex;gap:10px;}
.input-bar input{flex:1;border:1px solid #ddd;border-radius:6px;padding:10px 12px;font-size:14px;outline:none;}
.input-bar input:focus{border-color:#2978e8;}
.input-bar button{background:#2978e8;color:#fff;border:none;padding:0 20px;border-radius:6px;cursor:pointer;font-size:14px;}
/* 右栏:客户信息+快捷回复 */
.side{background:#fff;border-left:1px solid #e8e8e8;overflow-y:auto;}
.side .card{padding:16px;border-bottom:1px solid #f0f0f0;}
.side h4{font-size:13px;color:#999;margin-bottom:10px;}
.side .info-row{font-size:13px;padding:4px 0;color:#333;}
.side .quick{padding:8px 12px;background:#f5f8fc;border-radius:6px;margin-bottom:6px;font-size:13px;cursor:pointer;}
.side .quick:hover{background:#e8f0ff;}
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:#bbb;font-size:14px;}
</style>
</head>
<body>
<h1>📊 柏悦物流客服后台</h1>
<div class="grid">
<div class="stat"><div class="num" id="s1">-</div><div class="label">总对话数</div></div>
<div class="stat"><div class="num" id="s2">-</div><div class="label">总消息数</div></div>
<div class="stat"><div class="num" id="s3">-</div><div class="label">今日新增</div></div>
<div class="stat"><div class="num" id="s4">-</div><div class="label">👍</div></div>
<div class="stat"><div class="num" id="s5">-</div><div class="label">👎</div></div>
<div class="topbar">
<div class="logo">🎧 柏悦客服工作台</div>
<div class="stats">
<span>对话 <b id="s1">-</b></span>
<span>消息 <b id="s2">-</b></span>
<span>今日 <b id="s3">-</b></span>
<span>👍 <b id="s4">-</b></span>
<span>👎 <b id="s5">-</b></span>
</div>
<div class="status"><span class="dot"></span> 在线</div>
</div>
<div class="layout">
<div class="card">
<h3>进行中对话</h3>
<div id="sessionList"></div>
<h3 style="margin-top:16px;">待接入</h3>
<div class="conv-list">
<h3>待接入</h3>
<div id="queue"></div>
<h3>进行中</h3>
<div id="sessionList"></div>
</div>
<div class="card" id="chatCard" style="display:none;">
<h3 id="chatTitle">对话</h3>
<div id="chatBox"></div>
<input type="text" id="replyText" placeholder="输入回复,Enter发送...">
<button class="btn" onclick="sendReply()">发送</button>
<button class="btn red" onclick="closeSession()">结束会话</button>
</div>
</div>
<div class="card">
<h3>最近对话</h3>
<table><thead><tr><th>ID</th><th>标题</th><th>时间</th></tr></thead><tbody id="tbl"></tbody></table>
<div class="chat-area" id="chatArea">
<div class="empty" id="emptyTip">← 选择左侧对话开始服务</div>
<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>
<div id="chatBox"></div>
<div class="input-bar">
<input type="text" id="replyText" placeholder="输入回复,Enter发送">
<button onclick="sendReply()">发送</button>
</div>
</div>
</div>
<div class="side">
<div class="card">
<h4>客户信息</h4>
<div class="info-row" id="cInfo">选择对话后显示</div>
</div>
<div class="card">
<h4>快捷回复</h4>
<div class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,请问有什么可以帮您?</div>
<div class="quick" onclick="quickReply('请提供运单号,我帮您查询。')">请提供运单号</div>
<div class="quick" onclick="quickReply('已为您催促,请稍候。')">已催促,请稍候</div>
<div class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询</div>
</div>
</div>
</div>
<a href="/">← 返回客服页</a>
<script>
let curSid = 0;
let lastId = 0;
const sessions = {}; // sid -> {lastId, msgs:[]}
var curSid = 0;
var sessions = {};
fetch("/api/admin/stats").then(r=>r.json()).then(d=>{
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;
});
function loadQueue(){
fetch("/api/human/queue").then(r=>r.json()).then(list=>{
fetch("/api/human/queue").then(function(r){return r.json()}).then(function(list){
document.getElementById("queue").innerHTML = list.length
? list.map(x=>`<button class="btn" onclick="accept(${x.id})">#${x.id} ${x.ctime}</button>`).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>';
if(list.length && !sessions[String(list[0].id)]) accept(list[0].id);
});
}
loadQueue(); setInterval(loadQueue, 3000);
// 自动接入等待的客户
setInterval(()=>{
fetch("/api/human/queue").then(r=>r.json()).then(list=>{
if(list.length && !sessions[list[0].id]){
accept(list[0].id);
}
});
}, 3000);
setInterval(loadQueue, 3000);
function accept(sid){
fetch("/api/human/accept",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid})}).then(()=>{
sessions[sid] = {lastId:0};
sid = String(sid);
if(sessions[sid]) return;
fetch("/api/human/accept",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})}).then(function(){
sessions[sid] = {lastId:0, msgs:[], unread:0};
switchSession(sid);
});
}
function switchSession(sid){
curSid = sid;
lastId = sessions[sid].lastId;
document.getElementById("chatCard").style.display="block";
curSid = String(sid);
document.getElementById("emptyTip").style.display="none";
document.getElementById("chatContent").style.display="flex";
document.getElementById("chatTitle").textContent="对话 #"+sid;
// 渲染已有消息
document.getElementById("chatBox").innerHTML = "";
sessions[sid].msgs.forEach(m=>{
const div=document.createElement("div");
div.className="msg "+(m.side==="customer"?"cust":"agent");
div.textContent=(m.side==="customer"?"客户: ":"客服: ")+m.content;
document.getElementById("chatBox").appendChild(div);
});
renderSessionList();
document.getElementById("cInfo").innerHTML="会话编号:#"+sid;
document.getElementById("chatBox").innerHTML="";
sessions[sid].msgs.forEach(function(m){ appendMsg(m); });
sessions[sid].unread = 0;
renderList();
}
function renderSessionList(){
const el=document.getElementById("sessionList");
function appendMsg(m){
var div=document.createElement("div");
div.className="msg "+(m.side==="customer"?"cust":"agent");
div.innerHTML='<div><div class="bubble">'+m.content.replace(/</g,"&lt;")+'</div><div class="meta">'+(m.side==="customer"?"客户":"客服")+'</div></div>';
document.getElementById("chatBox").appendChild(div);
document.getElementById("chatBox").scrollTop=999999;
}
function renderList(){
var el=document.getElementById("sessionList");
el.innerHTML="";
Object.keys(sessions).forEach(sid=>{
const div=document.createElement("div");
div.className="session-item"+(sid==curSid?" active":"");
div.textContent="对话 #"+sid;
div.onclick=()=>switchSession(sid);
Object.keys(sessions).forEach(function(sid){
var last = sessions[sid].msgs[sessions[sid].msgs.length-1];
var preview = last ? last.content.substring(0,20) : "等待中...";
var unread = sessions[sid].unread || 0;
var div=document.createElement("div");
div.className="conv-item"+(sid==curSid?" active":"");
div.innerHTML='<div class="avatar">'+(sid==curSid?"🟢":"👤")+'</div><div class="info"><div class="name">对话 #'+sid+'</div><div class="preview">'+preview+'</div></div>'+(unread>0?'<span class="badge">'+unread+'</span>':"");
div.onclick=function(){ switchSession(sid); };
el.appendChild(div);
});
}
function poll(){
Object.keys(sessions).forEach(sid=>{
fetch("/api/human/poll?sid="+sid+"&after="+sessions[sid].lastId).then(r=>r.json()).then(d=>{
d.messages.forEach(m=>{
sessions[sid].lastId=m.id;
sessions[sid].msgs.push(m);
if(sid==curSid){
const div=document.createElement("div");
div.className="msg "+(m.side==="customer"?"cust":"agent");
div.textContent=(m.side==="customer"?"客户: ":"客服: ")+m.content;
document.getElementById("chatBox").appendChild(div);
document.getElementById("chatBox").scrollTop=999999;
fetch("/api/human/active").then(function(r){return r.json()}).then(function(list){
list.forEach(function(s){
if(!sessions[String(s.id)]) sessions[String(s.id)] = {lastId:0, msgs:[], unread:0};
});
renderList();
}).then(function(){
Object.keys(sessions).forEach(function(sid){
fetch("/api/human/poll?sid="+parseInt(sid)+"&after="+sessions[sid].lastId)
.then(function(r){return r.json()})
.then(function(d){
if(d.status==="closed"){
delete sessions[sid];
if(sid==curSid){ curSid=0; document.getElementById("chatContent").style.display="none"; document.getElementById("emptyTip").style.display="flex"; }
renderList(); return;
}
});
d.messages.forEach(function(m){
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(sid==curSid) appendMsg(m);
});
}).catch(function(){});
});
});
setTimeout(poll, 2000);
}
poll();
setInterval(poll, 1000);
function sendReply(){
const t=document.getElementById("replyText").value.trim();
var t=document.getElementById("replyText").value.trim();
if(!t || !curSid) return;
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:curSid,side:"agent",content:t})});
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid),side:"agent",content:t})});
document.getElementById("replyText").value="";
document.getElementById("replyText").focus();
}
function quickReply(t){
document.getElementById("replyText").value=t;
sendReply();
}
function closeSession(){
fetch("/api/human/close",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:curSid})});
fetch("/api/human/close",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
delete sessions[curSid];
curSid=0;
document.getElementById("chatCard").style.display="none";
renderSessionList();
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(); }
});
fetch("/api/get_history").then(r=>r.json()).then(list=>{
const tb=document.getElementById("tbl");
list.slice(0,20).forEach(c=>{
tb.innerHTML+=`<tr><td>${c.id}</td><td>${c.title}</td><td>${c.utime||c.ctime}</td></tr>`;
});
});
</script>
</body>
</html>
+44 -28
View File
@@ -1202,6 +1202,7 @@
<span><span class="ai-icon">🤖</span><span id="app-title">柏悦物流智能助手</span></span>
<span class="title-actions">
<button id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
<button id="font-toggle" class="theme-toggle" title="字体大小">A</button>
<button id="theme-toggle" class="theme-toggle" title="切换深色/浅色">🌙</button>
</span>
</div>
@@ -1361,6 +1362,27 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
}
})();
// ===== 字体大小切换 =====
(function(){
var KEY = "wuliu-fontsize";
var SIZES = [14, 16, 18, 20];
var idx = 1;
try{ var s = localStorage.getItem(KEY); if(s) idx = parseInt(s); }catch(e){}
var btn = document.getElementById("font-toggle");
function apply(){
document.body.style.fontSize = SIZES[idx] + "px";
if(btn) btn.textContent = SIZES[idx] + "px";
}
apply();
if(btn){
btn.onclick = function(){
idx = (idx + 1) % SIZES.length;
apply();
try{ localStorage.setItem(KEY, idx); }catch(e){}
};
}
})();
// ===== 中英文切换 =====
const I18N = {
zh: {
@@ -1872,62 +1894,56 @@ window.showHuman = async function(){
addMsg("您已在人工客服会话中,请直接提问。", false);
return;
}
const now = new Date();
const day = now.getDay();
const h = now.getHours();
const work = (day >= 1 && day <= 5) && (h >= 9 && h < 18);
var now = new Date();
var day = now.getDay();
var h = now.getHours();
var work = (day >= 1 && day <= 5) && (h >= 9 && h < 19);
if(!work){
addMsg("人工客服工作时间:周一至周五 9:00-18:00\n当前非工作时间,留言后工作时间内会尽快回复。", false);
return;
}
addMsg("正在为您转接人工客服...", false);
if(!currentConvId) await ensureConv();
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId})})
.then(r=>r.json()).then(d=>{
if(!currentConvId){
try{ await ensureConv(); }catch(e){}
}
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
.then(function(r){return r.json()})
.then(function(d){
if(d.ok){
window.humanSid = d.session_id;
addMsg("您已加入人工排队,请稍候...", false);
startHumanPoll();
}else{
addMsg("转接失败,请稍后再试。", false);
}
})
.catch(function(){
addMsg("网络错误,请稍后再试。", false);
});
}
window.humanSid = 0;
function startHumanPoll(){
let last=0;
let typingShown=null;
const t=setInterval(()=>{
if(!window.humanSid){ clearInterval(t); return; }
fetch("/api/human/poll?sid="+window.humanSid+"&after="+last).then(r=>r.json()).then(d=>{
fetch("/api/human/poll?sid="+window.humanSid+"&after="+last)
.then(r=>r.json())
.then(d=>{
if(d.status==="closed"){
clearInterval(t);
window.humanSid=0;
window.__busy=false;
if(typeof setBusy==="function") setBusy(false);
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
inputDom.focus();
return;
}
if(typingShown){ typingShown.remove(); typingShown=null; }
if(d.typing){
typingShown = addMsg("对方正在输入...", false);
}
d.messages.forEach(m=>{
last=m.id;
if(m.side==="agent"){
addMsg(m.content, false);
}
if(m.side==="agent") addMsg(m.content, false);
});
// 已读回执:标记最后一条用户消息
if(d.messages.some(m=>m.side==="customer" && m.read)){
const rows=document.querySelectorAll(".msg-row.user-line");
if(rows.length){
const last=rows[rows.length-1];
const ts=last.querySelector(".msg-time");
if(ts && !ts.textContent.includes("已读")) ts.textContent += " ✓已读";
}
}
});
}, 2000);
})
.catch(()=>{});
}, 1000);
}
function addMsg(content, isUser){
+3 -5
View File
@@ -1,8 +1,6 @@
@echo off
cd /d D:\1\wuliu_chat
set DEEPSEEK_API_KEY=sk-529a6318230641a2b45bc28a716a1697
echo 柏悦物流客服系统已启动
echo 关闭这个黑窗口就停止服务
echo 浏览器打开 http://127.0.0.1:5000/
python am.py
pause
echo Starting...
echo Close this window to stop
start /wait python am.py