Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6806b15d | ||
|
|
761c629788 |
@@ -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")
|
||||
@@ -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 "
|
||||
@@ -937,7 +953,10 @@ def stream_chat():
|
||||
return Response(sse({"e": "问题不能为空"}), mimetype="text/event-stream")
|
||||
# 纯图片消息:不调AI
|
||||
if "[图片]" in msg and len(msg) < 300:
|
||||
return Response(sse({"t": "收到您的图片,请描述一下您的问题。"}), mimetype="text/event-stream")
|
||||
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)
|
||||
@@ -1229,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()
|
||||
@@ -1237,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")
|
||||
@@ -1264,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})
|
||||
|
||||
|
||||
# ===== 人工客服 =====
|
||||
@@ -1275,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()
|
||||
@@ -1293,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]})
|
||||
|
||||
@@ -1317,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)
|
||||
@@ -1349,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"])
|
||||
@@ -1456,13 +1546,87 @@ def uploaded_file(name):
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/api/my_orders")
|
||||
def my_orders():
|
||||
phone = request.args.get("phone", "").strip()
|
||||
if not phone:
|
||||
return jsonify({"orders": []})
|
||||
conn = sqlite3.connect(DB)
|
||||
rows = conn.execute("SELECT track_no, status, created FROM orders WHERE phone=? ORDER BY id DESC", (phone,)).fetchall()
|
||||
conn.close()
|
||||
return jsonify({"orders": [{"track_no": r[0], "status": r[1], "created": r[2]} for r in rows]})
|
||||
|
||||
@app.route("/api/ticket", methods=["POST"])
|
||||
def add_ticket():
|
||||
data = request.get_json() or {}
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.execute("INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)",
|
||||
(data.get("content",""), data.get("phone",""), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "pending"))
|
||||
conn.commit(); conn.close()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.route("/api/tickets")
|
||||
def list_tickets():
|
||||
conn = sqlite3.connect(DB)
|
||||
rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall()
|
||||
conn.close()
|
||||
return jsonify([{"id":r[0],"content":r[1],"phone":r[2],"ctime":r[3]} for r in rows])
|
||||
|
||||
@app.route("/admin/login", methods=["POST"])
|
||||
def admin_login():
|
||||
pwd = request.json.get("password", "")
|
||||
if pwd == "123":
|
||||
return jsonify({"ok": True})
|
||||
data = request.json or {}
|
||||
u = data.get("username", "")
|
||||
p = data.get("password", "")
|
||||
conn = sqlite3.connect(DB)
|
||||
row = conn.execute("SELECT name FROM agents WHERE username=? AND password=?", (u, p)).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return jsonify({"ok": True, "name": row[0]})
|
||||
return jsonify({"ok": False}), 401
|
||||
|
||||
@app.route("/api/export_chat")
|
||||
def export_chat():
|
||||
conn = sqlite3.connect(DB)
|
||||
rows = conn.execute("SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id").fetchall()
|
||||
conn.close()
|
||||
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")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import sqlite3
|
||||
c = sqlite3.connect('history.db')
|
||||
print(c.execute('SELECT * FROM human_messages ORDER BY id DESC LIMIT 5').fetchall())
|
||||
print('---')
|
||||
print(c.execute("SELECT id, status FROM human_sessions WHERE status!='closed'").fetchall())
|
||||
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,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")
|
||||
@@ -64,12 +64,25 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
|
||||
<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 class="status"><span class="dot"></span> 在线</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<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>
|
||||
@@ -81,10 +94,24 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
|
||||
<div id="chatContent" style="display:none;flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||
<div class="chat-header">
|
||||
<span class="ct" id="chatTitle">对话</span>
|
||||
<button class="close" onclick="closeSession()">结束会话</button>
|
||||
<div 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>
|
||||
@@ -96,6 +123,11 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
|
||||
<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>
|
||||
@@ -103,6 +135,10 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
|
||||
<div class="quick" onclick="quickReply('已为您催促,请稍候。')">已催促,请稍候</div>
|
||||
<div class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h4>离线留言</h4>
|
||||
<div id="ticketList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,21 +146,102 @@ body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;h
|
||||
var curSid = 0;
|
||||
var sessions = {};
|
||||
|
||||
// 浏览器通知权限
|
||||
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(){
|
||||
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("");
|
||||
});
|
||||
}
|
||||
|
||||
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>';
|
||||
if(list.length && !sessions[String(list[0].id)]) accept(list[0].id);
|
||||
});
|
||||
}
|
||||
setInterval(loadQueue, 3000);
|
||||
|
||||
function loadTickets(){
|
||||
fetch("/api/tickets").then(function(r){return r.json()}).then(function(list){
|
||||
var el = document.getElementById("ticketList");
|
||||
if(!list.length){ el.innerHTML = '<div style="font-size:12px;color:#ccc;">暂无留言</div>'; return; }
|
||||
el.innerHTML = list.map(function(t){
|
||||
return '<div style="padding:6px;background:#fff7ed;border-radius:6px;margin-bottom:6px;font-size:12px;">'+t.content+'<br><span style="color:#999;">'+t.ctime+'</span></div>';
|
||||
}).join("");
|
||||
});
|
||||
}
|
||||
setInterval(loadTickets, 5000);
|
||||
loadTickets();
|
||||
|
||||
function accept(sid){
|
||||
sid = String(sid);
|
||||
if(sessions[sid]) return;
|
||||
@@ -144,21 +261,60 @@ function switchSession(sid){
|
||||
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 || "";
|
||||
});
|
||||
fetch("/api/human/read",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})});
|
||||
}
|
||||
|
||||
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;">');
|
||||
}
|
||||
div.innerHTML='<div><div class="bubble">'+c+'</div><div class="meta">'+(m.side==="customer"?"客户":"客服")+'</div></div>';
|
||||
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="";
|
||||
@@ -190,10 +346,19 @@ function poll(){
|
||||
if(sid==curSid){ curSid=0; document.getElementById("chatContent").style.display="none"; document.getElementById("emptyTip").style.display="flex"; }
|
||||
renderList(); return;
|
||||
}
|
||||
// 客户正在输入
|
||||
var t = document.getElementById("typing-ind");
|
||||
if(d.typing && sid==curSid){ if(!t){ t=document.createElement("div"); t.id="typing-ind"; t.style.cssText="font-size:12px;color:#999;padding:4px 0;"; t.textContent="客户正在输入..."; document.getElementById("chatBox").appendChild(t); } }
|
||||
else if(t){ t.remove(); }
|
||||
d.messages.forEach(function(m){
|
||||
var exists = sessions[sid].msgs.some(function(x){return x.id===m.id});
|
||||
if(exists) return;
|
||||
sessions[sid].lastId=m.id;
|
||||
sessions[sid].msgs.push(m);
|
||||
if(m.side==="customer" && sid!=curSid){ sessions[sid].unread=(sessions[sid].unread||0)+1; renderList(); }
|
||||
if(m.side==="customer" && document.hidden){
|
||||
notify("客户消息 #"+sid, m.content.substring(0, 50));
|
||||
}
|
||||
if(sid==curSid) appendMsg(m);
|
||||
});
|
||||
}).catch(function(){});
|
||||
@@ -202,6 +367,12 @@ function poll(){
|
||||
}
|
||||
setInterval(poll, 1000);
|
||||
|
||||
function insertEmoji2(e){
|
||||
document.getElementById("replyText").value += e;
|
||||
document.getElementById("replyText").focus();
|
||||
document.getElementById("emojiPanel2").style.display = "none";
|
||||
}
|
||||
|
||||
function sendReply(){
|
||||
var t=document.getElementById("replyText").value.trim();
|
||||
if(!t || !curSid) return;
|
||||
@@ -224,9 +395,32 @@ function closeSession(){
|
||||
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(); }
|
||||
});
|
||||
|
||||
// 客服正在输入通知客户
|
||||
var typeTimer = null;
|
||||
document.getElementById("replyText").addEventListener("input", function(){
|
||||
if(!curSid) return;
|
||||
fetch("/api/human/typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
|
||||
clearTimeout(typeTimer);
|
||||
typeTimer = setTimeout(function(){
|
||||
fetch("/api/human/stop_typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
|
||||
}, 3000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,15 +14,17 @@ button{width:100%;padding:10px;background:#2978e8;color:#fff;border:none;border-
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>🔒 后台登录</h2>
|
||||
<input type="password" id="pwd" placeholder="请输入密码">
|
||||
<p class="err" id="err">密码错误</p>
|
||||
<h2>🔒 客服后台登录</h2>
|
||||
<input type="text" id="user" placeholder="账号">
|
||||
<input type="password" id="pwd" placeholder="密码">
|
||||
<p class="err" id="err">账号或密码错误</p>
|
||||
<button onclick="login()">登录</button>
|
||||
</div>
|
||||
<script>
|
||||
function login(){
|
||||
var u=document.getElementById("user").value;
|
||||
var p=document.getElementById("pwd").value;
|
||||
fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:p})})
|
||||
fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})})
|
||||
.then(function(r){if(r.ok){location.href="/admin/dashboard"}else{document.getElementById("err").style.display="block"}});
|
||||
}
|
||||
document.getElementById("pwd").addEventListener("keydown",function(e){if(e.key==="Enter")login()});
|
||||
|
||||
@@ -653,6 +653,7 @@
|
||||
border-top:solid 1px #dde7f3;
|
||||
background:#ffffff;
|
||||
align-items:flex-end;
|
||||
position:relative;
|
||||
}
|
||||
#input-text:focus{
|
||||
border-color:#2978e8;
|
||||
@@ -1248,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>
|
||||
@@ -1304,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");
|
||||
@@ -1322,12 +1345,17 @@ uploadInput.onchange = function(){
|
||||
uploadBtn.textContent = "📎";
|
||||
if(data.ok){
|
||||
window.__pendingImg = data.url;
|
||||
window.__pendingTag = data.is_image ? "[图片]" : "[文件]";
|
||||
var old = document.getElementById("imgPreview");
|
||||
if(old) old.remove();
|
||||
var prev = document.createElement("div");
|
||||
prev.id = "imgPreview";
|
||||
prev.style.cssText="margin:0 0 8px 50px;";
|
||||
prev.innerHTML = '<img src="'+data.url+'" style="max-height:60px;border-radius:6px;border:1px solid #ddd;">';
|
||||
if(data.is_image){
|
||||
prev.innerHTML = '<img src="'+data.url+'" style="max-height:60px;border-radius:6px;border:1px solid #ddd;">';
|
||||
}else{
|
||||
prev.innerHTML = '<span style="padding:6px 12px;background:#f0f4fa;border-radius:6px;font-size:13px;">📎 '+data.name+'</span>';
|
||||
}
|
||||
var area = document.querySelector(".input-area");
|
||||
area.insertBefore(prev, area.firstChild);
|
||||
inputDom.focus();
|
||||
@@ -1895,17 +1923,39 @@ 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;
|
||||
}
|
||||
var now = new Date();
|
||||
var day = now.getDay();
|
||||
var h = now.getHours();
|
||||
var work = (day >= 1 && day <= 5) && (h >= 9 && h < 19);
|
||||
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);
|
||||
@@ -1919,6 +1969,13 @@ window.showHuman = async function(){
|
||||
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);
|
||||
}
|
||||
@@ -1937,15 +1994,70 @@ function startHumanPoll(){
|
||||
.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);
|
||||
// 满意度评价
|
||||
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;
|
||||
}
|
||||
// 客服正在输入
|
||||
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") addMsg(m.content, false);
|
||||
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();
|
||||
});
|
||||
})
|
||||
.catch(()=>{});
|
||||
@@ -1962,6 +2074,12 @@ function addMsg(content, isUser){
|
||||
const m = content.match(/\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i);
|
||||
if(m) imgUrl = m[0];
|
||||
}
|
||||
// 检测文件附件
|
||||
let fileUrl = null;
|
||||
if(content.indexOf("[文件]") >= 0){
|
||||
const m = content.match(/\/uploads\/\S+\.(?:pdf|txt|csv|doc|docx|xls|xlsx|zip)/i);
|
||||
if(m) fileUrl = m[0];
|
||||
}
|
||||
if(isUser){
|
||||
rowDiv.classList.add("user-line");
|
||||
const userMsg = document.createElement('div');
|
||||
@@ -1969,6 +2087,9 @@ function addMsg(content, isUser){
|
||||
if(imgUrl){
|
||||
var txt = content.replace(/\[图片\]\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i, "").trim();
|
||||
userMsg.innerHTML = '<img src="' + imgUrl + '" style="max-width:220px;border-radius:10px;display:block;background:transparent;cursor:zoom-in;" onclick="window.open(this.src)">' + (txt ? '<div style="margin-top:6px;">'+txt+'</div>' : '');
|
||||
}else if(fileUrl){
|
||||
var fname = fileUrl.split("/").pop();
|
||||
userMsg.innerHTML = '<a href="'+fileUrl+'" target="_blank" style="color:inherit;text-decoration:none;display:inline-block;padding:8px 12px;background:rgba(255,255,255,0.2);border-radius:8px;">📎 '+fname+'</a>';
|
||||
}else{
|
||||
userMsg.innerText = content;
|
||||
}
|
||||
@@ -2129,6 +2250,7 @@ async function doSend(retryText){
|
||||
const isRetry = typeof retryText === "string";
|
||||
const sendWord = (isRetry ? retryText : inputDom.value).trim();
|
||||
var imgUrl = window.__pendingImg || "";
|
||||
var tag = window.__pendingTag || "[图片]";
|
||||
if(!sendWord && !imgUrl){
|
||||
inputDom.focus();
|
||||
return;
|
||||
@@ -2136,10 +2258,10 @@ async function doSend(retryText){
|
||||
// 人工客服模式:直接发给客服,不走AI
|
||||
if(window.humanSid){
|
||||
var content = sendWord;
|
||||
if(imgUrl) content += (content ? " " : "") + "[图片]" + imgUrl;
|
||||
if(imgUrl) content += (content ? " " : "") + tag + imgUrl;
|
||||
if(!isRetry) addMsg(content, true);
|
||||
inputDom.value = "";
|
||||
window.__pendingImg = "";
|
||||
window.__pendingImg = ""; window.__pendingTag = "[图片]";
|
||||
var p = document.getElementById("imgPreview"); if(p) p.remove();
|
||||
inputDom.focus();
|
||||
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:content})});
|
||||
@@ -2152,9 +2274,24 @@ async function doSend(retryText){
|
||||
if(!isRetry){
|
||||
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.__pendingImg = ""; window.__pendingTag = "[图片]";
|
||||
var p = document.getElementById("imgPreview"); if(p) p.remove();
|
||||
inputDom.focus();
|
||||
}
|
||||
|
||||
|
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 |