1207 lines
52 KiB
JavaScript
1207 lines
52 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 缓存(不使用 localStorage) ==================== */
|
||
function loadBriefCache() { return {}; }
|
||
function saveBriefCache() {}
|
||
function clearBriefCache() {}
|
||
|
||
/** 将上游可能返回数组或字符串的字段统一转为字符串 */
|
||
function normalizeStr(val) {
|
||
if (val == null) return "";
|
||
if (Array.isArray(val)) {
|
||
return val.map(function (item) {
|
||
// 去掉控制字符(\n \u000b 等)和空白,只保留数字、+、-、空格
|
||
return String(item).replace(/[\x00-\x1F\x7F]/g, "").trim();
|
||
}).filter(Boolean).join(",");
|
||
}
|
||
return String(val).replace(/[\x00-\x1F\x7F]/g, "").trim();
|
||
}
|
||
|
||
function freshState(container) {
|
||
// 不使用缓存的 wxApp,每次 mount 都从 API 重新获取设备信息
|
||
// 避免不同销售之间 appId 混用的问题
|
||
var cachedBrief = {};
|
||
return {
|
||
container: container,
|
||
app: null,
|
||
online: false,
|
||
tab: "conversations", // conversations | friends | labels
|
||
contacts: { friends: [], chatrooms: [], ghs: [] },
|
||
labels: [], // 标签列表 [{labelId, labelName}] 从 DB 加载
|
||
labelMap: {}, // {labelId: labelName} 标签 ID→名称映射
|
||
labelFriends: {}, // {labelId: [customer records]} 展开标签下的好友
|
||
labelCounts: {}, // {labelId: count} 各标签下好友数量
|
||
loadingLabels: {}, // {labelId: true} 正在加载中的标签
|
||
expandedLabels: {}, // {labelId: true/false} 哪些标签已展开
|
||
syncing: false, // 是否正在全量同步(刷新 forceRefresh 期间为 true)
|
||
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, showing QR login");
|
||
state.app = null;
|
||
WxN.renderLoginCard();
|
||
return;
|
||
}
|
||
// 每次都用最新的 wx_device_id,不使用缓存
|
||
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 (no cache)");
|
||
// 直接调 checkOnline 检测登录状态
|
||
WxN.checkOnline().then(function (online) {
|
||
console.log("[Wx.mount] checkOnline result: " + online);
|
||
if (online) {
|
||
WxN.checkOnlineLoop();
|
||
loadProfile().then(function () { WxN.loadConversations(); });
|
||
WxN.loadContacts();
|
||
loadLabels();
|
||
} else {
|
||
// 设备不在线,清空登录态,走未登录流程
|
||
state.app = null;
|
||
WxN.renderLoginCard();
|
||
}
|
||
});
|
||
}).catch(function (e) {
|
||
console.error("[Wx.mount] fetchDeviceAndCheck error:", e);
|
||
});
|
||
}
|
||
|
||
function unmount() {
|
||
if (!state) return;
|
||
if (state.loginTimer) clearInterval(state.loginTimer);
|
||
disconnectWs();
|
||
closeLabelEditor();
|
||
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 () {
|
||
// 刷新:全量获取微信好友信息和标签信息,差异更新数据库
|
||
clearBriefCache();
|
||
state.brief = {};
|
||
state.labelFriends = {};
|
||
state.labelCounts = {};
|
||
state.expandedLabels = {};
|
||
state.loadingLabels = {};
|
||
state.syncing = true;
|
||
WxN.loadContacts(true);
|
||
WxN.loadConversations();
|
||
loadLabels();
|
||
};
|
||
$("#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,避免不同销售 appId 混用
|
||
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); });
|
||
}
|
||
|
||
/* ==================== 标签 ==================== */
|
||
/** 从微信获取标签并同步到 DB,然后更新 state */
|
||
function loadLabels() {
|
||
if (!state.app || !state.app.appId) return Promise.resolve();
|
||
// 从微信获取最新标签,同步到 DB 后直接更新 state
|
||
return Core.wxApi("label/list", { appId: state.app.appId })
|
||
.then(function (data) {
|
||
var wxLabels = (data && data.labelList) || [];
|
||
if (!wxLabels.length) return;
|
||
var selfWxid = state.app.wxid;
|
||
if (!selfWxid) return;
|
||
// 同步到 DB
|
||
return Core.api("advicer/label/sync", { selfWxid: selfWxid, labels: wxLabels })
|
||
.then(function () {
|
||
// 同步完成后从 DB 重新加载(保证与 DB 一致)
|
||
loadLabelsFromDb();
|
||
});
|
||
})
|
||
.catch(function (e) { console.warn("[loadLabels] error:", e); });
|
||
}
|
||
|
||
/** 从 DB 加载标签列表 */
|
||
function loadLabelsFromDb() {
|
||
var selfWxid = state.app && state.app.wxid;
|
||
if (!selfWxid) return;
|
||
return Core.api("advicer/label/list", { selfWxid: selfWxid })
|
||
.then(function (list) {
|
||
state.labels = (list || []).map(function (l) {
|
||
return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 };
|
||
});
|
||
// 构建 labelId→labelName 映射
|
||
state.labelMap = {};
|
||
state.labels.forEach(function (l) { state.labelMap[String(l.labelId)] = l.labelName; });
|
||
renderContacts();
|
||
loadLabelCounts();
|
||
})
|
||
.catch(function (e) { console.warn("[loadLabelsFromDb] error:", e); });
|
||
}
|
||
|
||
/** 获取所有标签下的好友数量统计 */
|
||
function loadLabelCounts() {
|
||
var selfWxid = state.app && state.app.wxid;
|
||
if (!selfWxid || !state.labels.length) return;
|
||
state.labels.forEach(function (l) {
|
||
var lid = String(l.labelId);
|
||
Core.api("advicer/customer/list", { selfWxid: selfWxid, labelId: lid, page: 1, page_size: 0 })
|
||
.then(function (list) {
|
||
state.labelCounts[lid] = (list || []).length;
|
||
renderContacts();
|
||
})
|
||
.catch(function () {});
|
||
});
|
||
}
|
||
|
||
/** 加载某个标签下的好友(从 DB 查询 label_list 包含该 labelId 的记录) */
|
||
function loadLabelFriends(labelId) {
|
||
var selfWxid = state.app && state.app.wxid;
|
||
if (!selfWxid) return Promise.resolve();
|
||
state.loadingLabels[String(labelId)] = true;
|
||
return Core.api("advicer/customer/list", { selfWxid: selfWxid, labelId: String(labelId), page: 1, page_size: 0 })
|
||
.then(function (list) {
|
||
state.labelFriends[String(labelId)] = list || [];
|
||
state.labelCounts[String(labelId)] = (list || []).length;
|
||
delete state.loadingLabels[String(labelId)];
|
||
renderContacts();
|
||
})
|
||
.catch(function (e) {
|
||
delete state.loadingLabels[String(labelId)];
|
||
console.warn("[loadLabelFriends] 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();
|
||
// 好友信息统一由 diff 接口提供:库中已有的从 list 填充 brief,差集部分调 getBriefInfo 补充后入库
|
||
syncCustomerDiff(!!forceRefresh);
|
||
})
|
||
.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 表
|
||
* forceRefresh=true(刷新按钮):获取所有好友最新 brief,与 DB 比对,只更新有变化的 + 新增缺失
|
||
* forceRefresh=false(正常登录):只新增缺失好友,不更新已有记录 */
|
||
function syncCustomerDiff(forceRefresh) {
|
||
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;
|
||
var existingList = res.list || [];
|
||
var diff = res.diff || [];
|
||
// 从 diff 响应中直接加载标签(进入工作台时一并获取)
|
||
if (res.labels) {
|
||
state.labels = res.labels.map(function (l) {
|
||
return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 };
|
||
});
|
||
state.labelMap = {};
|
||
state.labels.forEach(function (l) { state.labelMap[String(l.labelId)] = l.labelName; });
|
||
loadLabelCounts();
|
||
}
|
||
// 将库中已有的好友记录写入 brief
|
||
existingList.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,
|
||
labelList: item.label_list || ""
|
||
};
|
||
}
|
||
});
|
||
renderContacts();
|
||
|
||
// 辅助函数:新增缺失好友到 DB
|
||
function addNewFriends() {
|
||
if (!diff.length) { state.syncing = false; renderContacts(); return Promise.resolve(); }
|
||
console.log("[syncCustomerDiff] missing " + diff.length + " friends, fetching brief info");
|
||
var batches = [];
|
||
for (var j = 0; j < diff.length; j += 20) batches.push(diff.slice(j, j + 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;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|
||
return 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: normalizeStr(info.phoneNumList)
|
||
};
|
||
});
|
||
Core.api("advicer/customer/batch_add", { list: records })
|
||
.then(function () { console.log("[syncCustomerDiff] batch_add " + records.length + " new friends"); })
|
||
.catch(function (e) { console.warn("[syncCustomerDiff] batch_add failed:", e); });
|
||
state.syncing = false;
|
||
renderContacts();
|
||
});
|
||
}
|
||
|
||
if (forceRefresh) {
|
||
// 刷新流程:获取所有已有好友的最新 brief,与 DB 比对,只更新有变化的
|
||
var existingNames = existingList.map(function (e) { return e.user_name; }).filter(Boolean);
|
||
if (!existingNames.length) { addNewFriends(); return; }
|
||
var existingBatches = [];
|
||
for (var i = 0; i < existingNames.length; i += 20) existingBatches.push(existingNames.slice(i, i + 20));
|
||
var existingChain = Promise.resolve();
|
||
var latestInfos = [];
|
||
existingBatches.forEach(function (batch) {
|
||
existingChain = existingChain.then(function () {
|
||
return getBriefInfoWithRetry(batch)
|
||
.then(function (list) {
|
||
(list || []).forEach(function (info) {
|
||
if (info && info.userName) {
|
||
latestInfos.push(info);
|
||
state.brief[info.userName] = info;
|
||
}
|
||
});
|
||
})
|
||
.catch(function () {});
|
||
});
|
||
});
|
||
existingChain.then(function () {
|
||
// 与 DB 比对,找出有变化的记录
|
||
var dbMap = {};
|
||
existingList.forEach(function (e) { dbMap[e.user_name] = e; });
|
||
var changedRecords = [];
|
||
latestInfos.forEach(function (info) {
|
||
var db = dbMap[info.userName];
|
||
if (!db) return;
|
||
if ((info.labelList || "") !== (db.label_list || "") ||
|
||
(info.nickName || "") !== (db.nick_name || "") ||
|
||
(info.remark || "") !== (db.remark || "") ||
|
||
(info.bigHeadImgUrl || "") !== (db.big_head_img_url || "") ||
|
||
(info.smallHeadImgUrl || "") !== (db.small_head_img_url || "") ||
|
||
(info.sex || 0) !== (db.sex || 0)) {
|
||
changedRecords.push({
|
||
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: normalizeStr(info.phoneNumList)
|
||
});
|
||
}
|
||
});
|
||
if (changedRecords.length) {
|
||
console.log("[syncCustomerDiff] batch_update " + changedRecords.length + " changed friends (total " + latestInfos.length + ")");
|
||
Core.api("advicer/customer/batch_update", { list: changedRecords })
|
||
.catch(function (e) { console.warn("[syncCustomerDiff] batch_update failed:", e); });
|
||
} else {
|
||
console.log("[syncCustomerDiff] no changes detected in " + latestInfos.length + " existing friends");
|
||
}
|
||
renderContacts();
|
||
// 然后新增缺失好友
|
||
addNewFriends();
|
||
});
|
||
} else {
|
||
// 正常流程:只新增缺失好友
|
||
addNewFriends();
|
||
}
|
||
})
|
||
.catch(function (e) { state.syncing = false; 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 || "" };
|
||
}
|
||
|
||
/** 获取好友的标签 HTML(小标签展示,全部标签) */
|
||
function contactLabelsHtml(wxid) {
|
||
var info = state.brief[wxid];
|
||
if (!info || !info.labelList) return "";
|
||
var ids = String(info.labelList).split(",").filter(Boolean);
|
||
if (!ids.length) return "";
|
||
var tags = [];
|
||
ids.forEach(function (id) {
|
||
var name = state.labelMap[id];
|
||
if (name) tags.push(name);
|
||
});
|
||
if (!tags.length) return "";
|
||
return '<div class="ci-labels">' + tags.map(function (t) {
|
||
return '<span class="ci-label">' + Core.esc(t) + '</span>';
|
||
}).join("") + '</div>';
|
||
}
|
||
|
||
/** 获取好友的已托管标签 HTML(仅显示 hosting=1 的标签) */
|
||
function hostingLabelsHtml(wxid) {
|
||
var info = state.brief[wxid];
|
||
if (!info || !info.labelList) return "";
|
||
var ids = String(info.labelList).split(",").filter(Boolean);
|
||
if (!ids.length) return "";
|
||
var tags = [];
|
||
ids.forEach(function (id) {
|
||
// 查找该标签是否为托管标签
|
||
for (var i = 0; i < state.labels.length; i++) {
|
||
if (String(state.labels[i].labelId) === id && state.labels[i].hosting) {
|
||
tags.push(state.labels[i].labelName);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
if (!tags.length) return "";
|
||
return '<div class="ci-labels">' + tags.map(function (t) {
|
||
return '<span class="ci-label ci-label-hosting">' + Core.esc(t) + '</span>';
|
||
}).join("") + '</div>';
|
||
}
|
||
|
||
/** 获取好友所属的托管标签 ID 列表 */
|
||
function getHostingLabelIds(wxid) {
|
||
var info = state.brief[wxid];
|
||
if (!info || !info.labelList) return [];
|
||
var ids = String(info.labelList).split(",").filter(Boolean);
|
||
var hostingIds = [];
|
||
ids.forEach(function (id) {
|
||
for (var i = 0; i < state.labels.length; i++) {
|
||
if (String(state.labels[i].labelId) === id && state.labels[i].hosting) {
|
||
hostingIds.push(id);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
return hostingIds;
|
||
}
|
||
|
||
function renderContacts() {
|
||
if (WxN.hideContactDetail) WxN.hideContactDetail();
|
||
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 === "labels" ? "active" : "") + '" data-tab="labels">标签 ' + state.labels.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>' : '') +
|
||
hostingLabelsHtml(c.wxid) +
|
||
'<span class="ci-time">' + Core.esc(timeStr) + "</span></div>" +
|
||
'<div class="ci-sub">' + Core.esc(dirIcon + " " + (c.lastMsg || "")) + '</div></div>' +
|
||
(!c.isGroup ? '<button class="ci-edit-label-btn" data-wxid="' + Core.esc(c.wxid) + '" title="编辑标签">标签</button>' : '') +
|
||
'</div>';
|
||
}).join("");
|
||
}
|
||
} else if (state.tab === "labels") {
|
||
// 标签列表:每个标签一行,点击展开显示该标签下的好友
|
||
var labelList = state.labels;
|
||
if (kw) {
|
||
labelList = labelList.filter(function (l) {
|
||
return (l.labelName || "").toLowerCase().indexOf(kw) >= 0;
|
||
});
|
||
}
|
||
if (!labelList.length) {
|
||
items = '<div class="empty" style="padding:30px 14px"><div class="big">🏷</div>' + (kw ? "无匹配标签" : "暂无标签") + "</div>";
|
||
} else {
|
||
items = labelList.map(function (l) {
|
||
var lid = String(l.labelId);
|
||
var expanded = !!state.expandedLabels[lid];
|
||
var arrow = expanded ? "▾" : "▸";
|
||
var friends = state.labelFriends[lid] || [];
|
||
var count = state.labelCounts[lid];
|
||
var countSpan = (count != null)
|
||
? ' <span class="c-dim fs-11">(' + count + ')</span>'
|
||
: ' <span class="c-dim fs-11">…</span>';
|
||
var hostingCls = l.hosting ? ' hosting' : '';
|
||
var hostingBtn = '<button class="label-hosting-btn' + hostingCls + '" data-label-id="' + Core.esc(lid) + '" data-hosting="' + (l.hosting ? 0 : 1) + '" title="' + (l.hosting ? '取消托管' : '开启托管') + '">' + (l.hosting ? '托管中' : '托管') + '</button>';
|
||
var html = '<div class="label-row" data-label-id="' + Core.esc(lid) + '">' +
|
||
'<span class="label-arrow">' + arrow + '</span>' +
|
||
'<span class="label-name">' + Core.esc(l.labelName) + '</span>' +
|
||
countSpan +
|
||
'<span class="spacer"></span>' +
|
||
hostingBtn + '</div>';
|
||
if (expanded) {
|
||
var isLoading = !!state.loadingLabels[lid];
|
||
if (isLoading) {
|
||
html += '<div class="label-loading fs-12 c-dim" style="padding:8px 16px"><span class="spin"></span> 加载中…</div>';
|
||
} else if (friends.length) {
|
||
html += friends.map(function (f) {
|
||
var d = contactDisplay(f.user_name);
|
||
if (!d.avatar && f.big_head_img_url) d.avatar = f.big_head_img_url;
|
||
if (!d.name || d.name === f.user_name) d.name = f.nick_name || f.remark || f.user_name;
|
||
return '<div class="contact-item label-friend" data-wxid="' + Core.esc(f.user_name) + '">' +
|
||
(d.avatar
|
||
? '<img class="wx-avatar" style="width:32px;height:32px" src="' + Core.esc(d.avatar) + '" alt="">'
|
||
: '<div class="wx-avatar" style="width:32px;height:32px">' + 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(f.user_name) + '</div></div></div>';
|
||
}).join("");
|
||
} else {
|
||
html += '<div class="label-loading fs-12 c-dim" style="padding:8px 16px">加载中…</div>';
|
||
}
|
||
}
|
||
return html;
|
||
}).join("");
|
||
}
|
||
} else {
|
||
// 好友列表
|
||
var list = state.contacts.friends;
|
||
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-row"><span class="ci-name">' + Core.esc(d.name) + '</span>' +
|
||
hostingLabelsHtml(w) + '</div>' +
|
||
'<div class="ci-sub mono">' + Core.esc(w) + "</div></div>" +
|
||
'<button class="ci-edit-label-btn" data-wxid="' + Core.esc(w) + '" title="编辑标签">标签</button>' +
|
||
'</div>';
|
||
}).join("");
|
||
}
|
||
}
|
||
// 加载中进度提示
|
||
var progressHint = '';
|
||
if (state.syncing) {
|
||
progressHint = '<div class="fs-11 c-dim" style="text-align:center;padding:4px 0"><span class="spin"></span> 正在同步好友信息…</div>';
|
||
} else 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();
|
||
if (state.tab === "labels") loadLabelCounts();
|
||
renderContacts();
|
||
};
|
||
});
|
||
// 标签行点击展开/收起
|
||
$$(".label-row", box).forEach(function (row) {
|
||
row.onclick = function (e) {
|
||
// 点击托管按钮不触发展开/收起
|
||
if (e.target.classList.contains('label-hosting-btn')) return;
|
||
var lid = row.dataset.labelId;
|
||
if (state.expandedLabels[lid]) {
|
||
// 收起
|
||
delete state.expandedLabels[lid];
|
||
renderContacts();
|
||
} else {
|
||
// 展开
|
||
state.expandedLabels[lid] = true;
|
||
state.labelFriends[lid] = [];
|
||
renderContacts();
|
||
loadLabelFriends(lid);
|
||
}
|
||
};
|
||
});
|
||
// 托管按钮点击
|
||
$$(".label-hosting-btn", box).forEach(function (btn) {
|
||
btn.onclick = function (e) {
|
||
e.stopPropagation();
|
||
var lid = Number(btn.dataset.labelId);
|
||
var newHosting = Number(btn.dataset.hosting);
|
||
var selfWxid = state.app && state.app.wxid;
|
||
if (!selfWxid) return;
|
||
btn.disabled = true;
|
||
Core.api("advicer/label/hosting", { selfWxid: selfWxid, labelId: lid, hosting: newHosting })
|
||
.then(function () {
|
||
// 更新 state.labels 中对应标签的 hosting 状态
|
||
state.labels.forEach(function (l) {
|
||
if (l.labelId === lid) l.hosting = newHosting;
|
||
});
|
||
Core.toast(newHosting ? "已开启托管" : "已取消托管", "ok");
|
||
renderContacts();
|
||
})
|
||
.catch(function (e) {
|
||
Core.toast("操作失败:" + e.message, "err");
|
||
btn.disabled = false;
|
||
});
|
||
};
|
||
});
|
||
$$(".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);
|
||
}
|
||
};
|
||
// 悬浮显示详情气泡(好友 / 会话 / 标签内好友)
|
||
if (state.tab === "friends" || state.tab === "conversations" || c.classList.contains("label-friend")) {
|
||
c.onmouseenter = function (e) { WxN.showContactDetail(c.dataset.wxid, c, e); };
|
||
c.onmouseleave = function () { WxN.hideContactDetail(); };
|
||
}
|
||
});
|
||
// 编辑标签按钮点击
|
||
$$(".ci-edit-label-btn", box).forEach(function (btn) {
|
||
btn.onclick = function (e) {
|
||
e.stopPropagation();
|
||
openLabelEditor(btn.dataset.wxid, btn);
|
||
};
|
||
});
|
||
}
|
||
|
||
/* ==================== 标签编辑弹窗 ==================== */
|
||
var _labelEditorPopup = null;
|
||
|
||
function openLabelEditor(wxid, anchorEl) {
|
||
closeLabelEditor();
|
||
var s = state;
|
||
var info = s.brief[wxid] || {};
|
||
var currentLabels = (info.labelList || "").split(",").filter(Boolean);
|
||
var allLabels = s.labels;
|
||
if (!allLabels.length) { Core.toast("暂无标签,请先在标签 tab 同步", "warn"); return; }
|
||
|
||
var popup = document.createElement('div');
|
||
popup.className = 'wx-label-editor-popup';
|
||
popup.style.position = 'fixed';
|
||
popup.style.zIndex = '9999';
|
||
|
||
// 视口边界检测
|
||
var popupWidth = 260;
|
||
var rect = anchorEl.getBoundingClientRect();
|
||
var leftPos = rect.right + 6;
|
||
if (leftPos + popupWidth > window.innerWidth - 10) {
|
||
leftPos = rect.left - popupWidth - 6;
|
||
}
|
||
if (leftPos < 10) leftPos = 10;
|
||
var topPos = rect.top;
|
||
if (topPos + 350 > window.innerHeight) {
|
||
topPos = Math.max(10, window.innerHeight - 360);
|
||
}
|
||
popup.style.left = leftPos + 'px';
|
||
popup.style.top = topPos + 'px';
|
||
|
||
// 渲染标签列表
|
||
var html = '<div class="wx-le-head">编辑标签</div>';
|
||
html += '<div class="wx-le-body">';
|
||
allLabels.forEach(function (l) {
|
||
var lid = String(l.labelId);
|
||
var checked = currentLabels.indexOf(lid) >= 0 ? ' checked' : '';
|
||
var checkedCls = checked ? ' wx-le-checked' : '';
|
||
html += '<label class="wx-le-item' + checkedCls + '" data-label-id="' + Core.esc(lid) + '">' +
|
||
'<input type="checkbox" value="' + Core.esc(lid) + '"' + checked + '>' +
|
||
'<span class="wx-le-name">' + Core.esc(l.labelName) + '</span>' +
|
||
(l.hosting ? '<span class="wx-le-hosting-tag">托管</span>' : '') +
|
||
'</label>';
|
||
});
|
||
html += '</div>';
|
||
html += '<div class="wx-le-foot">' +
|
||
'<button class="btn ghost sm wx-le-cancel">取消</button>' +
|
||
'<button class="btn primary sm wx-le-save">保存</button>' +
|
||
'</div>';
|
||
popup.innerHTML = html;
|
||
document.body.appendChild(popup);
|
||
_labelEditorPopup = popup;
|
||
|
||
// checkbox 变化时更新样式
|
||
$$(".wx-le-item input", popup).forEach(function (cb) {
|
||
cb.onchange = function () {
|
||
var label = cb.closest('.wx-le-item');
|
||
if (cb.checked) label.classList.add('wx-le-checked');
|
||
else label.classList.remove('wx-le-checked');
|
||
};
|
||
});
|
||
|
||
// 取消
|
||
$(".wx-le-cancel", popup).onclick = function () { closeLabelEditor(); };
|
||
|
||
// 保存
|
||
$(".wx-le-save", popup).onclick = function () {
|
||
var newLabelIds = [];
|
||
$$(".wx-le-item input:checked", popup).forEach(function (cb) {
|
||
newLabelIds.push(cb.value);
|
||
});
|
||
var selfWxid = s.app && s.app.wxid;
|
||
if (!selfWxid) return;
|
||
|
||
var proj = Core.store(Core.KEYS.project);
|
||
if (!proj || !proj.projectId) { Core.toast("请先选择项目", "err"); return; }
|
||
|
||
var saveBtn = $(".wx-le-save", popup);
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = "保存中…";
|
||
|
||
// 一次调用后端接口:内部完成 wx API 调用 + DB 更新
|
||
Core.api("advicer/customer/update_labels", {
|
||
projectId: proj.projectId,
|
||
appId: s.app.appId,
|
||
selfWxid: selfWxid,
|
||
userName: wxid,
|
||
labelList: newLabelIds.join(",")
|
||
}).then(function () {
|
||
// 更新本地 brief 中的 labelList
|
||
if (s.brief[wxid]) {
|
||
s.brief[wxid].labelList = newLabelIds.join(",");
|
||
}
|
||
Core.toast("标签已更新", "ok");
|
||
closeLabelEditor();
|
||
renderContacts();
|
||
}).catch(function (e) {
|
||
Core.toast("保存失败:" + e.message, "err");
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = "保存";
|
||
});
|
||
};
|
||
|
||
// 点击外部关闭
|
||
setTimeout(function () {
|
||
document.addEventListener('mousedown', _labelEditorOutsideClick, true);
|
||
}, 0);
|
||
}
|
||
|
||
function _labelEditorOutsideClick(e) {
|
||
if (_labelEditorPopup && !_labelEditorPopup.contains(e.target) && !e.target.classList.contains('ci-edit-label-btn')) {
|
||
closeLabelEditor();
|
||
}
|
||
}
|
||
|
||
function closeLabelEditor() {
|
||
document.removeEventListener('mousedown', _labelEditorOutsideClick, true);
|
||
if (_labelEditorPopup && _labelEditorPopup.parentNode) {
|
||
_labelEditorPopup.parentNode.removeChild(_labelEditorPopup);
|
||
}
|
||
_labelEditorPopup = null;
|
||
}
|
||
|
||
/* ==================== WebSocket 实时通信 ==================== */
|
||
|
||
function connectWs() {
|
||
if (!state || !state.app || !state.app.appId) {
|
||
console.warn("[WS] connectWs skipped: appId missing");
|
||
return;
|
||
}
|
||
console.log("[WS] connecting, appId=" + state.app.appId);
|
||
disconnectWs(); // 先断开已有连接
|
||
|
||
var token = Core.store(Core.KEYS.token);
|
||
if (!token) { console.warn("[WS] connectWs skipped: token missing"); 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 () {
|
||
console.log("[WS] connected, subscribing appId=" + state.app.appId);
|
||
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);
|
||
console.log("[WS] received event:", msg.event, msg.data);
|
||
handleWsEvent(msg);
|
||
} catch (err) { console.warn("[WS] parse error:", err); }
|
||
};
|
||
|
||
wsConn.onclose = function () {
|
||
console.log("[WS] connection closed");
|
||
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();
|
||
loadClients();
|
||
|
||
$("#ai-advicer", m.root).onchange = function () {
|
||
loadVersions(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() {
|
||
Core.api("skill/list", { projectId: projectId }).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.loadLabels = loadLabels;
|
||
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;
|
||
WxN.closeLabelEditor = closeLabelEditor;
|
||
|
||
global.Wx = { mount: mount, unmount: unmount };
|
||
})(window);
|