Compare commits

...
6 Commits
48 changed files with 869 additions and 156 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")
+210 -8
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()
@@ -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 "
@@ -269,6 +285,21 @@ def backup_db():
print(f"[数据库备份失败] {e}")
def cleanup_uploads():
"""删除 uploads/ 里 7 天前的图片"""
try:
udir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
if not os.path.isdir(udir):
return
cutoff = time.time() - 7 * 86400
for f in os.listdir(udir):
fp = os.path.join(udir, f)
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
os.remove(fp)
except Exception as e:
print(f"[清理上传文件失败] {e}")
def cleanup_old_conversations():
"""自动清理 30 天前的旧对话,防止数据库无限膨胀"""
try:
@@ -290,6 +321,7 @@ def cleanup_old_conversations():
backup_db()
cleanup_uploads()
# 自动清理已关闭,不删旧对话
@@ -919,6 +951,12 @@ def stream_chat():
conv_id = request.args.get("conv_id", type=int)
if not msg:
return Response(sse({"e": "问题不能为空"}), mimetype="text/event-stream")
# 纯图片消息:不调AI
if "[图片]" in msg and len(msg) < 300:
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)
@@ -1210,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()
@@ -1218,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")
@@ -1236,10 +1278,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)
@@ -1249,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})
# ===== 人工客服 =====
@@ -1260,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()
@@ -1278,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]})
@@ -1302,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)
@@ -1309,6 +1370,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")
@@ -1327,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"])
@@ -1434,6 +1546,96 @@ 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():
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")
@app.route("/admin/dashboard")
def admin_dash():
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}
+4
View File
@@ -0,0 +1,4 @@
import sqlite3
c = sqlite3.connect('history.db')
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()])
+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')
+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
View File
@@ -0,0 +1 @@
{"sid":48,"side":"agent","content":"test123"}
+1
View File
@@ -0,0 +1 @@
{"msg":"[图片]/uploads/test.jpg","conv_id":1}
+358 -106
View File
@@ -3,171 +3,423 @@
<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>
<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="layout">
<div class="card">
<h3>进行中对话</h3>
<div id="sessionList"></div>
<h3 style="margin-top:16px;">待接入</h3>
<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>
<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>
<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>
</div>
</div>
<div class="side">
<div class="card">
<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>
<div class="quick" onclick="quickReply('请提供运单号,我帮您查询。')">请提供运单号</div>
<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>
<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=>{
// 浏览器通知权限
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 loadQueue(){
fetch("/api/human/queue").then(r=>r.json()).then(list=>{
document.getElementById("queue").innerHTML = list.length
? list.map(x=>`<button class="btn" onclick="accept(${x.id})">#${x.id} ${x.ctime}</button>`).join(" ")
: "暂无";
// 拖拽搜索弹窗
(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("");
});
}
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);
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>';
});
}, 3000);
}
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){
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);
document.getElementById("cInfo").innerHTML="会话编号:#"+sid;
document.getElementById("chatBox").innerHTML="";
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 || "";
});
renderSessionList();
fetch("/api/human/read",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})});
}
function renderSessionList(){
const el=document.getElementById("sessionList");
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;">');
}
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="";
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;
}
});
// 客户正在输入
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(){});
});
});
setTimeout(poll, 2000);
}
poll();
setInterval(poll, 1000);
function insertEmoji2(e){
document.getElementById("replyText").value += e;
document.getElementById("replyText").focus();
document.getElementById("emojiPanel2").style.display = "none";
}
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();
}
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(); }
});
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>`;
});
// 客服正在输入通知客户
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>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>登录 - 客服后台</title>
<style>
body{font-family:sans-serif;background:#f0f2f5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
.box{background:#fff;padding:40px;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1);width:320px;}
h2{text-align:center;color:#18479c;margin-bottom:24px;}
input{width:100%;padding:10px;border:1px solid #ddd;border-radius:6px;margin-bottom:12px;font-size:14px;box-sizing:border-box;}
button{width:100%;padding:10px;background:#2978e8;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;}
.err{color:#dc2626;font-size:13px;text-align:center;display:none;}
</style>
</head>
<body>
<div class="box">
<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({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()});
</script>
</body>
</html>
+205 -37
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;
@@ -1202,6 +1203,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>
@@ -1247,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>
@@ -1303,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");
@@ -1320,9 +1344,20 @@ uploadInput.onchange = function(){
.then(data=>{
uploadBtn.textContent = "📎";
if(data.ok){
let text = "[附件] " + data.url;
if(data.is_image) text = "[图片] " + data.url;
inputDom.value = text;
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;";
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();
}else{
alert(data.error || "上传失败");
@@ -1361,6 +1396,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: {
@@ -1867,67 +1923,145 @@ 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;
}
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 h = now.getHours();
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);
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 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);
}
})
.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);
var sid = window.humanSid;
window.humanSid=0;
window.__busy=false;
if(typeof setBusy==="function") setBusy(false);
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
inputDom.focus();
// 满意度评价
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;
}
if(typingShown){ typingShown.remove(); typingShown=null; }
if(d.typing){
typingShown = addMsg("对方正在输入...", false);
// 客服正在输入
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"){
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();
});
// 已读回执:标记最后一条用户消息
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){
@@ -1940,12 +2074,22 @@ 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');
userMsg.className = "user-message";
if(imgUrl){
userMsg.innerHTML = '<img src="' + imgUrl + '" style="max-width:220px;border-radius:10px;display:block;padding:0;background:transparent;cursor:zoom-in;" onclick="window.open(this.src)">';
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;
}
@@ -2105,17 +2249,22 @@ function renderFollowups(bubble){
async function doSend(retryText){
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
if(!sendWord){
var imgUrl = window.__pendingImg || "";
var tag = window.__pendingTag || "[图片]";
if(!sendWord && !imgUrl){
inputDom.focus();
return;
}
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid){
if(!isRetry) addMsg(sendWord, true);
var content = sendWord;
if(imgUrl) content += (content ? " " : "") + tag + imgUrl;
if(!isRetry) addMsg(content, true);
inputDom.value = "";
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:sendWord})});
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:content})});
return;
}
@@ -2123,8 +2272,27 @@ async function doSend(retryText){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(!isRetry){
addMsg(sendWord, true);
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.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
}
@@ -2278,7 +2446,7 @@ async function doSend(retryText){
fetch("/stream_chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({msg: sendWord, conv_id: currentConvId}),
body: JSON.stringify({msg: fullContent, conv_id: currentConvId}),
signal: controller.signal
}).then(function(res){
if(!res.ok){ throw new Error("HTTP " + res.status); }
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

+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