Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6806b15d | ||
|
|
761c629788 | ||
|
|
fc9897ccb2 | ||
|
|
c4ac4f513c | ||
|
|
d8d4ee0a31 | ||
|
|
7abd0e898c |
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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 额度
|
||||
|
||||
@@ -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()])
|
||||
@@ -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')
|
||||
@@ -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")
|
||||
@@ -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,"<");
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |