762 lines
32 KiB
JavaScript
762 lines
32 KiB
JavaScript
/* ============================================================
|
||
* 微信工作台模块(核心)
|
||
* - 状态管理 / 生命周期(mount / unmount)
|
||
* - 通讯录渲染 / WebSocket / 微信个人信息
|
||
* - AI 助手设置
|
||
* 子模块:wx_login.js(登录/在线检测)、wx_chat.js(聊天/会话/消息)
|
||
* 依赖:core.js(window.Core)
|
||
* 用法:Wx.mount(document.getElementById('view-wx'))
|
||
* ============================================================ */
|
||
(function (global) {
|
||
"use strict";
|
||
|
||
var $ = function (sel, root) { return (root || document).querySelector(sel); };
|
||
var $$ = function (sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); };
|
||
|
||
var state = null;
|
||
|
||
// WebSocket 连接状态
|
||
var wsConn = null; // WebSocket 实例
|
||
var wsReconnectTimer = null; // 重连定时器
|
||
var wsReconnectDelay = 1000; // 当前重连延迟(ms)
|
||
var WS_MAX_RECONNECT = 30000; // 最大重连延迟
|
||
|
||
/* ==================== 共享命名空间 ==================== */
|
||
// 子模块(wx_login.js / wx_chat.js)通过 WxN 访问共享状态和函数
|
||
var WxN = global.WxN || (global.WxN = {});
|
||
Object.defineProperty(WxN, 'state', {
|
||
get: function () { return state; },
|
||
set: function (v) { state = v; },
|
||
configurable: true
|
||
});
|
||
WxN.Core = Core;
|
||
WxN.$ = $;
|
||
WxN.$$ = $$;
|
||
|
||
/* ==================== brief 本地缓存 ==================== */
|
||
|
||
// 缓存结构:{ [appId]: { wxid: briefInfo, ... } }
|
||
function loadBriefCache() {
|
||
return Core.store(Core.KEYS.wxBrief) || {};
|
||
}
|
||
function saveBriefCache() {
|
||
var all = loadBriefCache();
|
||
var appId = state.app && state.app.appId;
|
||
if (!appId) return;
|
||
all[appId] = state.brief;
|
||
Core.store(Core.KEYS.wxBrief, all);
|
||
}
|
||
function clearBriefCache() {
|
||
var appId = state.app && state.app.appId;
|
||
if (!appId) { Core.store(Core.KEYS.wxBrief, null); return; }
|
||
var all = loadBriefCache();
|
||
delete all[appId];
|
||
Core.store(Core.KEYS.wxBrief, all);
|
||
}
|
||
|
||
function freshState(container) {
|
||
var app = Core.store(Core.KEYS.wxApp) || null;
|
||
// 从本地缓存恢复 brief 数据
|
||
var cachedBrief = {};
|
||
if (app && app.appId) {
|
||
var all = loadBriefCache();
|
||
cachedBrief = all[app.appId] || {};
|
||
}
|
||
return {
|
||
container: container,
|
||
app: app,
|
||
online: false,
|
||
tab: "conversations", // conversations | friends | chatrooms
|
||
contacts: { friends: [], chatrooms: [], ghs: [] },
|
||
conversations: [], // 会话列表(从 MongoDB 聚合)
|
||
convMsgs: {}, // {wxid: [{direction, msgType, content, createAt}]}
|
||
groupMembers: {}, // {chatroomWxid: {memberWxid: {nickName, bigHeadImgUrl, smallHeadImgUrl}}}
|
||
brief: cachedBrief, // wxid -> info(从缓存恢复)
|
||
briefLoading: false,
|
||
briefLoadedCount: 0, // 已加载 brief 的数量
|
||
briefTotalCount: 0, // 需要加载的总数
|
||
search: "",
|
||
active: null, // {wxid, name, avatar, type}
|
||
msgs: {}, // {wxid: [{role:'self', text, time}]}
|
||
qr: null, // {uuid, img, status, expired}
|
||
loginTimer: null,
|
||
ai: { sessionId: null, mission: "", versionDesc: "" },
|
||
aiTip: null,
|
||
wxAdvicer: null // {advicerId, name}
|
||
};
|
||
}
|
||
|
||
/* ==================== 挂载 ==================== */
|
||
function mount(container) {
|
||
// 防御性清理:防止外部未先调用 unmount 导致旧连接/定时器泄漏
|
||
unmount();
|
||
state = freshState(container);
|
||
container.innerHTML = shellHtml();
|
||
bindEvents();
|
||
renderContacts();
|
||
WxN.renderChat();
|
||
|
||
// 从 URL hash 获取 wx-advicer 值,调用 advicer/info 获取 wx_device_id(即 app_id),再检测在线状态
|
||
var advicerId = parseHashAdvicerId();
|
||
if (advicerId) {
|
||
console.log("[Wx.mount] hash wx-advicer=" + advicerId + ", fetching fresh device info");
|
||
WxN.renderLoginCard();
|
||
fetchDeviceAndCheck(advicerId);
|
||
} else {
|
||
console.log("[Wx.mount] no wx-advicer in hash, showing QR login");
|
||
WxN.renderLoginCard();
|
||
}
|
||
return { unmount: unmount };
|
||
}
|
||
|
||
/** 从 URL hash 解析 advicerId(格式:#wx-advicer=123) */
|
||
function parseHashAdvicerId() {
|
||
var hash = location.hash.replace(/^#/, '');
|
||
var match = hash.match(/wx-advicer=(\d+)/);
|
||
return match ? Number(match[1]) : 0;
|
||
}
|
||
|
||
/** 通过 API 获取销售的微信设备 ID,然后检测在线状态 */
|
||
function fetchDeviceAndCheck(advicerId) {
|
||
Core.api("advicer/info", { advicerId: advicerId }).then(function (info) {
|
||
if (!info || !info.wx_device_id) {
|
||
console.log("[Wx.mount] advicer not found or no wx_device_id");
|
||
return;
|
||
}
|
||
// 用设备 ID 作为 appId,检测在线状态
|
||
state.app = { appId: info.wx_device_id };
|
||
// 更新销售个人信息(姓名等,供提示栏显示)
|
||
if (state.wxAdvicer) {
|
||
state.wxAdvicer.name = info.name || state.wxAdvicer.name;
|
||
// 同步更新提示栏中的销售姓名显示
|
||
var nameEl = document.querySelector('#wx-mount-point .ci-name');
|
||
if (nameEl) nameEl.textContent = info.name || '';
|
||
}
|
||
console.log("[Wx.mount] got wxDeviceId=" + info.wx_device_id + ", checking online");
|
||
WxN.checkOnline().then(function (online) {
|
||
console.log("[Wx.mount] checkOnline result: " + online);
|
||
if (online) {
|
||
WxN.checkOnlineLoop();
|
||
loadProfile().then(function () { WxN.loadConversations(); });
|
||
WxN.loadContacts();
|
||
} else {
|
||
WxN.getQrCode();
|
||
}
|
||
});
|
||
}).catch(function (e) {
|
||
console.error("[Wx.mount] fetchDeviceAndCheck error:", e);
|
||
});
|
||
}
|
||
|
||
function unmount() {
|
||
if (!state) return;
|
||
if (state.loginTimer) clearInterval(state.loginTimer);
|
||
disconnectWs();
|
||
// 清理联系人详情缓存
|
||
WxN.clearDetailCache();
|
||
WxN.hideContactDetail();
|
||
state = null;
|
||
}
|
||
|
||
function shellHtml() {
|
||
return (
|
||
'<div class="wx-shell">' +
|
||
'<div class="wx-left">' +
|
||
'<div id="wxLoginCard"></div>' +
|
||
'<div class="wx-search">' +
|
||
'<input id="wxSearch" class="input sm" placeholder="搜索联系人 / 群聊">' +
|
||
'<button class="btn ghost sm" id="wxRefresh" title="刷新通讯录">⟳</button>' +
|
||
"</div>" +
|
||
'<div class="wx-contacts" id="wxContacts"></div>' +
|
||
"</div>" +
|
||
'<div class="wx-right">' +
|
||
'<div class="wx-chat-head" id="wxChatHead"></div>' +
|
||
'<div id="wxAiTipWrap"></div>' +
|
||
'<div class="wx-msgs" id="wxMsgs"></div>' +
|
||
'<div class="wx-input-area">' +
|
||
'<textarea class="wx-textarea" id="wxText" placeholder="选择左侧联系人后输入消息内容…(Ctrl + Enter 发送)"></textarea>' +
|
||
'<div class="wx-send-row">' +
|
||
'<span class="wx-hint">发送身份:<span id="wxSelfName" class="c-cyan">-</span></span>' +
|
||
'<span class="spacer"></span>' +
|
||
'<span class="wx-hint" id="wxSendHint"></span>' +
|
||
'<button class="btn success" id="wxAiAsk" title="基于 AI 会话给出话术建议">✦ AI 建议</button>' +
|
||
'<button class="btn primary" id="wxSendBtn">发送 (Ctrl+Enter)</button>' +
|
||
"</div>" +
|
||
"</div>" +
|
||
"</div>" +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function bindEvents() {
|
||
$("#wxRefresh").onclick = function () {
|
||
// 刷新:清除 brief 缓存,重新拉取通讯录 + brief + 会话
|
||
clearBriefCache();
|
||
state.brief = {};
|
||
WxN.loadContacts(true);
|
||
WxN.loadConversations();
|
||
};
|
||
$("#wxSearch").oninput = function (e) { state.search = e.target.value.trim(); renderContacts(); };
|
||
$("#wxSendBtn").onclick = WxN.doSend;
|
||
$("#wxAiAsk").onclick = doAskAI;
|
||
$("#wxText").addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); WxN.doSend(); }
|
||
});
|
||
}
|
||
|
||
/* ==================== 微信个人信息 ==================== */
|
||
/** 获取微信个人信息(昵称、头像、wxid 等) */
|
||
function loadProfile() {
|
||
if (!state || !state.app || !state.app.appId) return Promise.resolve();
|
||
console.log("[loadProfile] calling with appId=" + state.app.appId);
|
||
return Core.wxApi("personal/getProfile", { appId: state.app.appId })
|
||
.then(function (profile) {
|
||
console.log("[loadProfile] response:", profile);
|
||
if (!profile) return;
|
||
// 合并微信个人信息到 state.app
|
||
state.app.nickName = profile.nickName || state.app.nickName || "";
|
||
state.app.wxid = profile.wxid || state.app.wxid || "";
|
||
state.app.headImgUrl = profile.headImgUrl || profile.headImageUrl || state.app.headImgUrl || "";
|
||
// 持久化到 localStorage
|
||
Core.store(Core.KEYS.wxApp, state.app);
|
||
WxN.renderLoginCard();
|
||
// 更新发送身份显示
|
||
var selfName = $("#wxSelfName", state.container);
|
||
if (selfName) selfName.textContent = state.app.nickName || state.app.wxid || "-";
|
||
})
|
||
.catch(function (e) { console.error("[loadProfile] error:", e); });
|
||
}
|
||
|
||
/* ==================== 通讯录 ==================== */
|
||
|
||
function loadContacts(forceRefresh) {
|
||
if (!state.app || !state.app.appId) { Core.toast("请先登录微信", "err"); return; }
|
||
Core.loading(true, "拉取通讯录…");
|
||
Core.wxApi("contacts/fetchContactsList", { appId: state.app.appId })
|
||
.then(function (data) {
|
||
Core.loading(false);
|
||
state.contacts = {
|
||
friends: (data && data.friends) || [],
|
||
chatrooms: (data && data.chatrooms) || [],
|
||
ghs: (data && data.ghs) || []
|
||
};
|
||
renderContacts();
|
||
// 群聊仍需 getBriefInfo 拉取
|
||
loadBriefInfoForChatrooms();
|
||
// 好友信息统一由 diff 接口提供:库中已有的从 list 填充 brief,差集部分调 getBriefInfo 补充后入库
|
||
syncCustomerDiff();
|
||
})
|
||
.catch(function (e) { Core.loading(false); Core.toast("拉取通讯录失败:" + e.message, "err"); });
|
||
}
|
||
|
||
/** 仅拉取群聊的 brief info */
|
||
function loadBriefInfoForChatrooms() {
|
||
var chatrooms = state.contacts.chatrooms;
|
||
if (!chatrooms.length) return;
|
||
var batches = [];
|
||
for (var i = 0; i < chatrooms.length; i += 20) batches.push(chatrooms.slice(i, i + 20));
|
||
var chain = Promise.resolve();
|
||
batches.forEach(function (batch) {
|
||
chain = chain.then(function () {
|
||
return getBriefInfoWithRetry(batch)
|
||
.then(function (list) {
|
||
(list || []).forEach(function (info) { if (info && info.userName) state.brief[info.userName] = info; });
|
||
renderContacts();
|
||
})
|
||
.catch(function () {});
|
||
});
|
||
});
|
||
}
|
||
|
||
/** getBriefInfo 带重试(最多 3 次,间隔 2 秒) */
|
||
function getBriefInfoWithRetry(wxids, retries) {
|
||
retries = retries || 3;
|
||
return Core.wxApi("contacts/getBriefInfo", { appId: state.app.appId, wxids: wxids })
|
||
.catch(function (e) {
|
||
if (retries <= 1) throw e;
|
||
console.warn("[getBriefInfo] retrying, " + (retries - 1) + " attempts left");
|
||
return new Promise(function (resolve) { setTimeout(resolve, 2000); })
|
||
.then(function () { return getBriefInfoWithRetry(wxids, retries - 1); });
|
||
});
|
||
}
|
||
|
||
/** 同步好友差集到 ai_advice_customer 表 */
|
||
function syncCustomerDiff() {
|
||
var selfWxid = state.app && state.app.wxid;
|
||
if (!selfWxid || !state.contacts.friends.length) return;
|
||
Core.api("advicer/customer/diff", { selfWxid: selfWxid, userNames: state.contacts.friends })
|
||
.then(function (res) {
|
||
if (!res) return;
|
||
// 将库中已有的好友记录写入 brief
|
||
(res.list || []).forEach(function (item) {
|
||
if (item.user_name && !state.brief[item.user_name]) {
|
||
state.brief[item.user_name] = {
|
||
userName: item.user_name,
|
||
nickName: item.nick_name,
|
||
remark: item.remark,
|
||
sex: item.sex,
|
||
bigHeadImgUrl: item.big_head_img_url,
|
||
smallHeadImgUrl: item.small_head_img_url,
|
||
alias: item.alias,
|
||
signature: item.signature,
|
||
province: item.province,
|
||
city: item.city
|
||
};
|
||
}
|
||
});
|
||
renderContacts();
|
||
var diff = res.diff;
|
||
if (!diff || !diff.length) return;
|
||
console.log("[syncCustomerDiff] missing " + diff.length + " friends, fetching brief info");
|
||
// 每批 20 个拉取 getBriefInfo(带重试)
|
||
var batches = [];
|
||
for (var i = 0; i < diff.length; i += 20) batches.push(diff.slice(i, i + 20));
|
||
var allInfo = [];
|
||
var chain = Promise.resolve();
|
||
batches.forEach(function (batch) {
|
||
chain = chain.then(function () {
|
||
return getBriefInfoWithRetry(batch)
|
||
.then(function (list) {
|
||
(list || []).forEach(function (info) {
|
||
if (info && info.userName) {
|
||
allInfo.push(info);
|
||
state.brief[info.userName] = info;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|
||
chain.then(function () {
|
||
if (!allInfo.length) return;
|
||
// 组装入库数据
|
||
var records = allInfo.map(function (info) {
|
||
return {
|
||
selfWxid: selfWxid,
|
||
userName: info.userName || "",
|
||
nickName: info.nickName || "",
|
||
pyInitial: info.pyInitial || "",
|
||
quanPin: info.quanPin || "",
|
||
sex: info.sex || 0,
|
||
remark: info.remark || "",
|
||
remarkPyInitial: info.remarkPyInitial || "",
|
||
remarkQuanPin: info.remarkQuanPin || "",
|
||
signature: info.signature || "",
|
||
alias: info.alias || "",
|
||
snsBgImg: info.snsBgImg || "",
|
||
country: info.country || "",
|
||
bigHeadImgUrl: info.bigHeadImgUrl || "",
|
||
smallHeadImgUrl: info.smallHeadImgUrl || "",
|
||
description: info.description || "",
|
||
cardImgUrl: info.cardImgUrl || "",
|
||
labelList: info.labelList || "",
|
||
province: info.province || "",
|
||
city: info.city || "",
|
||
phoneNumList: info.phoneNumList || ""
|
||
};
|
||
});
|
||
Core.api("advicer/customer/batch_add", { list: records })
|
||
.then(function () { console.log("[syncCustomerDiff] synced " + records.length + " friends"); })
|
||
.catch(function (e) { console.warn("[syncCustomerDiff] batch_add failed:", e); });
|
||
renderContacts();
|
||
});
|
||
})
|
||
.catch(function (e) { console.warn("[syncCustomerDiff] diff failed:", e); });
|
||
}
|
||
|
||
function contactDisplay(wxid) {
|
||
var info = state.brief[wxid] || {};
|
||
var name = info.remark || info.nickName || wxid;
|
||
return { name: name, avatar: info.bigHeadImgUrl || info.smallHeadImgUrl || "" };
|
||
}
|
||
|
||
function renderContacts() {
|
||
var box = $("#wxContacts", state.container);
|
||
if (!state.app || !state.app.appId) {
|
||
box.innerHTML = '<div class="empty" style="padding:30px 14px"><div class="big">◇</div>登录微信后自动拉取通讯录</div>';
|
||
return;
|
||
}
|
||
var kw = state.search.toLowerCase();
|
||
var tabs =
|
||
'<div class="seg" style="margin:10px 12px 4px">' +
|
||
'<div class="seg-item ' + (state.tab === "conversations" ? "active" : "") + '" data-tab="conversations">会话 ' + state.conversations.length + "</div>" +
|
||
'<div class="seg-item ' + (state.tab === "friends" ? "active" : "") + '" data-tab="friends">好友</div>' +
|
||
'<div class="seg-item ' + (state.tab === "chatrooms" ? "active" : "") + '" data-tab="chatrooms">群聊 ' + state.contacts.chatrooms.length + "</div>" +
|
||
"</div>";
|
||
var items;
|
||
if (state.tab === "conversations") {
|
||
// 会话列表
|
||
var convList = state.conversations;
|
||
if (kw) {
|
||
convList = convList.filter(function (c) {
|
||
var d = WxN.conversationDisplay(c.wxid);
|
||
return d.name.toLowerCase().indexOf(kw) >= 0 || c.wxid.toLowerCase().indexOf(kw) >= 0;
|
||
});
|
||
}
|
||
if (!convList.length) {
|
||
items = '<div class="empty" style="padding:30px 14px"><div class="big">✉</div>' + (kw ? "无匹配会话" : "暂无会话,收到消息后自动显示") + "</div>";
|
||
} else {
|
||
items = convList.map(function (c) {
|
||
var d = WxN.conversationDisplay(c.wxid);
|
||
var active = state.active && state.active.wxid === c.wxid ? " active" : "";
|
||
var timeStr = c.lastMsgTime ? WxN.timeAgo(c.lastMsgTime * 1000) : "";
|
||
var dirIcon = c.lastDirection === "customer" ? "←" : c.lastDirection === "self" ? "→" : "";
|
||
var unreadBadge = (c.unreadCount > 0)
|
||
? '<span class="conv-unread">' + (c.unreadCount > 99 ? "99+" : c.unreadCount) + '</span>'
|
||
: '';
|
||
return '<div class="contact-item conv-item' + active + '" data-wxid="' + Core.esc(c.wxid) + '">' +
|
||
'<div class="conv-avatar-wrap">' +
|
||
(d.avatar
|
||
? '<img class="wx-avatar" style="width:36px;height:36px" src="' + Core.esc(d.avatar) + '" alt="" onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'wx-avatar\',style:\'width:36px;height:36px\',textContent:\'?\'}))">'
|
||
: '<div class="wx-avatar" style="width:36px;height:36px">' + Core.esc(Core.initial(d.name)) + "</div>") +
|
||
unreadBadge + '</div>' +
|
||
'<div class="ci-main"><div class="ci-name-row"><span class="ci-name">' + Core.esc(d.name) + '</span>' +
|
||
(c.isGroup ? '<span class="ci-tag">群</span>' : '') +
|
||
'<span class="ci-time">' + Core.esc(timeStr) + "</span></div>" +
|
||
'<div class="ci-sub">' + Core.esc(dirIcon + " " + (c.lastMsg || "")) + "</div></div></div>";
|
||
}).join("");
|
||
}
|
||
} else {
|
||
// 好友/群聊列表(原有逻辑)
|
||
var list = state.tab === "friends" ? state.contacts.friends : state.contacts.chatrooms;
|
||
if (state.tab === "friends") {
|
||
list = list.filter(function (w) {
|
||
var info = state.brief[w] || {};
|
||
return info.sex !== 0;
|
||
});
|
||
}
|
||
if (kw) {
|
||
list = list.filter(function (w) {
|
||
var d = contactDisplay(w);
|
||
return d.name.toLowerCase().indexOf(kw) >= 0 || w.toLowerCase().indexOf(kw) >= 0;
|
||
});
|
||
}
|
||
if (state.briefLoading && !Object.keys(state.brief).length) {
|
||
items = '<div class="empty" style="padding:26px"><span class="spin"></span><div class="mt-1 fs-12">正在加载联系人信息…</div></div>';
|
||
} else if (!list.length) {
|
||
items = '<div class="empty" style="padding:30px 14px"><div class="big">☰</div>' + (kw ? "无匹配联系人" : "暂无联系人,点击右上角 ⟳ 刷新") + "</div>";
|
||
} else {
|
||
items = list.map(function (w) {
|
||
var d = contactDisplay(w);
|
||
var active = state.active && state.active.wxid === w ? " active" : "";
|
||
return '<div class="contact-item' + active + '" data-wxid="' + Core.esc(w) + '">' +
|
||
(d.avatar
|
||
? '<img class="wx-avatar" style="width:36px;height:36px" src="' + Core.esc(d.avatar) + '" alt="" onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'wx-avatar\',style:\'width:36px;height:36px\',textContent:\'?\'}))">'
|
||
: '<div class="wx-avatar" style="width:36px;height:36px">' + Core.esc(Core.initial(d.name)) + "</div>") +
|
||
'<div class="ci-main"><div class="ci-name">' + Core.esc(d.name) + "</div>" +
|
||
'<div class="ci-sub mono">' + Core.esc(w) + "</div></div></div>";
|
||
}).join("");
|
||
}
|
||
}
|
||
// 加载中进度提示
|
||
var progressHint = '';
|
||
if (state.briefLoading && state.briefTotalCount > 0) {
|
||
progressHint = '<div class="fs-11 c-dim" style="text-align:center;padding:4px 0">加载联系人信息 ' +
|
||
Math.min(state.briefLoadedCount, state.briefTotalCount) + ' / ' + state.briefTotalCount + '</div>';
|
||
}
|
||
box.innerHTML = tabs + items + progressHint;
|
||
$$(".seg-item", box).forEach(function (s) {
|
||
s.onclick = function () {
|
||
state.tab = s.dataset.tab;
|
||
if (state.tab === "conversations") WxN.loadConversations();
|
||
renderContacts();
|
||
};
|
||
});
|
||
$$(".contact-item", box).forEach(function (c) {
|
||
c.onclick = function () {
|
||
var wxid = c.dataset.wxid;
|
||
if (state.tab === "conversations") {
|
||
WxN.openConversationChat(wxid);
|
||
} else {
|
||
WxN.openChat(wxid);
|
||
}
|
||
};
|
||
// 悬浮显示详情气泡(仅好友/群聊 tab)
|
||
if (state.tab !== "conversations") {
|
||
c.onmouseenter = function (e) { WxN.showContactDetail(c.dataset.wxid, c, e); };
|
||
c.onmouseleave = function () { WxN.hideContactDetail(); };
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ==================== WebSocket 实时通信 ==================== */
|
||
|
||
function connectWs() {
|
||
if (!state || !state.app || !state.app.appId) return;
|
||
disconnectWs(); // 先断开已有连接
|
||
|
||
var token = Core.store(Core.KEYS.token);
|
||
if (!token) return;
|
||
|
||
var protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||
var url = protocol + "//" + location.host + "/api/v1/advicer/ws?token=" + encodeURIComponent(token);
|
||
|
||
try {
|
||
wsConn = new WebSocket(url);
|
||
} catch (e) {
|
||
scheduleWsReconnect();
|
||
return;
|
||
}
|
||
|
||
wsConn.onopen = function () {
|
||
wsReconnectDelay = 1000; // 重置重连延迟
|
||
// 订阅当前 appId 的事件
|
||
wsConn.send(JSON.stringify({ action: "subscribe", appId: state.app.appId }));
|
||
// 启动心跳(每 20 秒发一次 ping)
|
||
startWsHeartbeat();
|
||
};
|
||
|
||
wsConn.onmessage = function (e) {
|
||
try {
|
||
var msg = JSON.parse(e.data);
|
||
handleWsEvent(msg);
|
||
} catch (err) { /* 忽略非 JSON 消息 */ }
|
||
};
|
||
|
||
wsConn.onclose = function () {
|
||
stopWsHeartbeat();
|
||
wsConn = null;
|
||
scheduleWsReconnect();
|
||
};
|
||
|
||
wsConn.onerror = function () {
|
||
// onclose 会紧随触发,无需额外处理
|
||
};
|
||
}
|
||
|
||
function disconnectWs() {
|
||
stopWsHeartbeat();
|
||
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
|
||
if (wsConn) {
|
||
wsConn.onclose = null; // 避免触发重连
|
||
wsConn.close();
|
||
wsConn = null;
|
||
}
|
||
wsReconnectDelay = 1000;
|
||
}
|
||
|
||
function scheduleWsReconnect() {
|
||
if (!state || !state.app || !state.app.appId) return;
|
||
if (wsReconnectTimer) return;
|
||
wsReconnectTimer = setTimeout(function () {
|
||
wsReconnectTimer = null;
|
||
connectWs();
|
||
}, wsReconnectDelay);
|
||
wsReconnectDelay = Math.min(wsReconnectDelay * 2, WS_MAX_RECONNECT);
|
||
}
|
||
|
||
var wsHeartbeatTimer = null;
|
||
function startWsHeartbeat() {
|
||
stopWsHeartbeat();
|
||
wsHeartbeatTimer = setInterval(function () {
|
||
if (wsConn && wsConn.readyState === WebSocket.OPEN) {
|
||
wsConn.send(JSON.stringify({ action: "ping" }));
|
||
}
|
||
}, 20000);
|
||
}
|
||
function stopWsHeartbeat() {
|
||
if (wsHeartbeatTimer) { clearInterval(wsHeartbeatTimer); wsHeartbeatTimer = null; }
|
||
}
|
||
|
||
function handleWsEvent(msg) {
|
||
if (!msg || !msg.event || !state) return;
|
||
switch (msg.event) {
|
||
case "new_message":
|
||
WxN.handleNewMessage(msg.data);
|
||
break;
|
||
case "online_status":
|
||
WxN.handleOnlineStatus(msg.data);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* ==================== AI 助手 ==================== */
|
||
function openAiConfig() {
|
||
var proj = Core.store(Core.KEYS.project);
|
||
if (!proj || !proj.projectId) {
|
||
Core.toast("请先在「项目管理」进入一个项目,AI 会话基于项目的销售版本/技巧注册", "err");
|
||
return;
|
||
}
|
||
var projectId = proj.projectId;
|
||
var m = Core.modal({
|
||
title: "⚙ AI 助手设置(销售话术演练会话)",
|
||
width: "wide",
|
||
body: '<div class="empty" style="padding:20px"><span class="spin"></span></div>'
|
||
});
|
||
Core.api("advicer/list", { projectId: projectId }).then(function (advicers) {
|
||
advicers = advicers || [];
|
||
var advOptions = advicers.map(function (a) {
|
||
return '<option value="' + a.advicer_id + '">#' + a.advicer_id + " " + Core.esc(a.name || "") + "</option>";
|
||
}).join("");
|
||
m.body.innerHTML =
|
||
'<div class="card-sub" style="margin-top:0">选择销售版本 + 聊天技巧 + 客户,注册一个 AI 演练会话;在聊天窗口点击「AI 建议」即可生成回复话术。</div>' +
|
||
'<div class="field"><label class="label">销售人员<span class="req">*</span></label><select id="ai-advicer" class="select">' + advOptions + "</select></div>" +
|
||
'<div class="grid cols-2">' +
|
||
'<div class="field"><label class="label">销售版本<span class="req">*</span></label><select id="ai-version" class="select"><option>加载中…</option></select></div>' +
|
||
'<div class="field"><label class="label">聊天技巧<span class="req">*</span></label><select id="ai-skill" class="select"><option>加载中…</option></select></div>' +
|
||
"</div>" +
|
||
'<div class="field"><label class="label">客户(可选)</label><select id="ai-client" class="select"><option value="">不指定客户</option></select></div>' +
|
||
'<div class="field"><label class="label">任务 Mission<span class="req">*</span></label><textarea id="ai-mission" class="textarea" placeholder="本轮对话任务,如:了解客户预算与购房目的,邀约到访">' + Core.esc(state.ai.mission || "") + "</textarea></div>" +
|
||
'<div class="row"><span class="badge ' + (state.ai.sessionId ? "green" : "gray") + '" id="ai-session-badge">' + (state.ai.sessionId ? "会话已就绪(重注册将覆盖)" : "尚未注册会话") + "</span></div>";
|
||
$(".modal-foot", m.root).innerHTML = '<button class="btn ghost mf-cancel">关闭</button><button class="btn primary mf-regis">注册 AI 会话</button>';
|
||
$(".mf-cancel", m.root).onclick = m.close;
|
||
|
||
if (!advicers.length) {
|
||
m.body.innerHTML += '<div class="mt-1 fs-12 c-warn">当前项目暂无销售人员,请先到「数据中心 → 销售人员」新增。</div>';
|
||
} else {
|
||
loadVersions(advicers[0].advicer_id);
|
||
loadSkills(advicers[0].advicer_id);
|
||
}
|
||
loadClients();
|
||
|
||
$("#ai-advicer", m.root).onchange = function () {
|
||
loadVersions(this.value);
|
||
loadSkills(this.value);
|
||
};
|
||
|
||
function loadVersions(advicerId) {
|
||
Core.api("advicer/version/list", { advicerId: Number(advicerId) }).then(function (list) {
|
||
list = list || [];
|
||
$("#ai-version", m.root).innerHTML = list.length
|
||
? list.map(function (v) { return '<option value="' + v.id + '">' + Core.esc(v.versionDesc || v.id) + "</option>"; }).join("")
|
||
: '<option value="">无版本(请到数据中心创建)</option>';
|
||
}).catch(function (e) { Core.toast(e.message, "err"); });
|
||
}
|
||
function loadSkills(advicerId) {
|
||
Core.api("skill/list", { projectId: projectId, advicerId: Number(advicerId) }).then(function (list) {
|
||
list = list || [];
|
||
$("#ai-skill", m.root).innerHTML = list.length
|
||
? list.map(function (s) { return '<option value="' + s.id + '">' + Core.esc((s.desc || "技巧") + " · " + s.id.slice(-6)) + "</option>"; }).join("")
|
||
: '<option value="">无技巧(请到数据中心创建)</option>';
|
||
}).catch(function (e) { Core.toast(e.message, "err"); });
|
||
}
|
||
function loadClients() {
|
||
Core.api("client/list", { projectId: projectId }).then(function (list) {
|
||
list = list || [];
|
||
$("#ai-client", m.root).innerHTML = '<option value="">不指定客户</option>' +
|
||
list.map(function (c) {
|
||
var name = (c.personalInfo && c.personalInfo.name) || "客户";
|
||
return '<option value="' + c.id + '">' + Core.esc(name) + " · " + c.id.slice(-6) + "</option>";
|
||
}).join("");
|
||
}).catch(function () {});
|
||
}
|
||
|
||
$(".mf-regis", m.root).onclick = function () {
|
||
var versionId = $("#ai-version", m.root).value;
|
||
var skillId = $("#ai-skill", m.root).value;
|
||
var mission = $("#ai-mission", m.root).value.trim();
|
||
if (!versionId) { Core.toast("请选择销售版本", "err"); return; }
|
||
if (!skillId) { Core.toast("请选择聊天技巧", "err"); return; }
|
||
if (!mission) { Core.toast("请填写任务 Mission", "err"); return; }
|
||
Core.loading(true, "注册 AI 会话…");
|
||
Core.api("chat/regis", {
|
||
advicerVersionId: versionId,
|
||
clientId: $("#ai-client", m.root).value || "",
|
||
talkSkillId: skillId,
|
||
mission: mission
|
||
}).then(function (sessionId) {
|
||
Core.loading(false);
|
||
state.ai.sessionId = sessionId;
|
||
state.ai.mission = mission;
|
||
Core.toast("AI 会话注册成功", "ok");
|
||
m.close();
|
||
WxN.renderChatHead();
|
||
}).catch(function (e) { Core.loading(false); Core.toast("注册失败:" + e.message, "err"); });
|
||
};
|
||
}).catch(function (e) {
|
||
m.body.innerHTML = '<div class="empty">' + Core.esc(e.message) + "</div>";
|
||
});
|
||
}
|
||
|
||
function clearAiTip() {
|
||
state.aiTip = null;
|
||
var wrap = $("#wxAiTipWrap", state.container);
|
||
if (wrap) wrap.innerHTML = "";
|
||
}
|
||
|
||
function renderAiTip(tip) {
|
||
state.aiTip = tip;
|
||
var wrap = $("#wxAiTipWrap", state.container);
|
||
var badge = tip.missionStatus
|
||
? '<span class="badge ' + (tip.missionStatus === "completed" ? "green" : tip.missionStatus === "fail" ? "red" : "yellow") + '">' + Core.esc(tip.missionStatus) + "</span>"
|
||
: "";
|
||
wrap.innerHTML =
|
||
'<div class="ai-tip">' +
|
||
'<div class="ai-head">✦ AI 话术建议 ' + badge + '<span class="spacer"></span><span class="fs-12 c-dim">' + Core.esc(tip.missionDesc || "") + "</span></div>" +
|
||
'<div class="ai-body">' + Core.splitBubbles(tip.text).map(function (b) {
|
||
return '<div class="msg-bubble" style="border-color:rgba(45,224,255,.35);margin:0 0 6px">' + Core.esc(b) + "</div>";
|
||
}).join("") + "</div>" +
|
||
'<div class="ai-foot">' +
|
||
'<button class="btn success sm" id="aiFill">填入输入框</button>' +
|
||
(state.active ? '<button class="btn ghost sm" id="aiSendNow">直接发送</button>' : "") +
|
||
'<button class="btn ghost sm" id="aiClose">关闭</button>' +
|
||
"</div></div>";
|
||
$("#aiFill", wrap).onclick = function () {
|
||
$("#wxText", state.container).value = tip.text;
|
||
$("#wxText", state.container).focus();
|
||
clearAiTip();
|
||
};
|
||
var sendNow = $("#aiSendNow", wrap);
|
||
if (sendNow) sendNow.onclick = function () {
|
||
if (!state.active) return;
|
||
if (sendNow.disabled) return;
|
||
sendNow.disabled = true;
|
||
sendNow.textContent = "发送中…";
|
||
WxN.sendBubbles(state.active.wxid, tip.text).then(function () {
|
||
sendNow.disabled = false;
|
||
sendNow.textContent = "直接发送";
|
||
clearAiTip();
|
||
});
|
||
};
|
||
$("#aiClose", wrap).onclick = function () {
|
||
if (state.active) {
|
||
var bs = Core.splitBubbles(tip.text);
|
||
if (!bs.length) bs = ["(空消息)"];
|
||
bs.forEach(function (b) {
|
||
state.msgs[state.active.wxid].push({ role: "ai", text: b, time: Core.nowTime() });
|
||
});
|
||
}
|
||
clearAiTip();
|
||
WxN.renderChat();
|
||
};
|
||
}
|
||
|
||
function doAskAI() {
|
||
if (!state.ai.sessionId) {
|
||
Core.toast("尚未注册 AI 会话,请点击右上角「AI 助手设置」", "err");
|
||
openAiConfig();
|
||
return;
|
||
}
|
||
var draft = $("#wxText", state.container).value.trim();
|
||
if (!draft) { Core.toast("请先在输入框粘贴客户消息或沟通上下文", "err"); return; }
|
||
Core.loading(true, "AI 生成话术中…");
|
||
Core.api("chat/chat", { sessionId: state.ai.sessionId, content: draft })
|
||
.then(function (assistant) {
|
||
Core.loading(false);
|
||
assistant = assistant || {};
|
||
var text = assistant.result || "";
|
||
if (typeof assistant === "string") text = assistant;
|
||
if (!text) { Core.toast("AI 未返回内容", "err"); return; }
|
||
renderAiTip({
|
||
text: text,
|
||
missionStatus: assistant.mission_status || "",
|
||
missionDesc: assistant.mission_complete_desc || ""
|
||
});
|
||
})
|
||
.catch(function (e) { Core.loading(false); Core.toast("生成失败:" + e.message, "err"); });
|
||
}
|
||
|
||
/* ==================== 导出 ==================== */
|
||
WxN.clearBriefCache = clearBriefCache;
|
||
WxN.loadProfile = loadProfile;
|
||
WxN.loadContacts = loadContacts;
|
||
WxN.renderContacts = renderContacts;
|
||
WxN.renderChatHead = WxN.renderChatHead || function () {}; // 由 wx_chat.js 覆盖
|
||
WxN.renderChat = WxN.renderChat || function () {}; // 由 wx_chat.js 覆盖
|
||
WxN.contactDisplay = contactDisplay;
|
||
WxN.connectWs = connectWs;
|
||
WxN.disconnectWs = disconnectWs;
|
||
WxN.openAiConfig = openAiConfig;
|
||
|
||
global.Wx = { mount: mount, unmount: unmount };
|
||
})(window);
|