SsqHelp.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import {getApiUrl} from "@api/config";
  2. /**
  3. * SaleSmartly 聊天插件封装(JSSDK: help-en.salesmartly.com)
  4. * 集成模式:hideIcon 隐藏官方气泡;入口/红点/主动消息提示条由 WSupportCenter 自建,
  5. * Live Chat 用 SaleSmartly 官方窗口(chatOpen 唤起,外观/欢迎语/快捷问题在其后台配置)。
  6. * 主动消息触达仅网页内:依赖 onUnRead / onReceiveMessage 回调,登录后须 ensureReady() 预载。
  7. */
  8. class SsqChat{
  9. constructor() {
  10. this.src = "https://plugin-code.salesmartly.com/js/project_607414_626816_1768878382.js";
  11. this.apiUrl = getApiUrl();
  12. this.prefix=this.apiUrl.split(".")[0].split("//")[1]+"_";
  13. this.prefix="uk_";
  14. this.gameId=null;
  15. this.channel=null;
  16. this.labels=[]; // 会话来源标签(建议/投诉),经 setLoginInfo label_names 展示在客服端
  17. this.chatWindowOpen=false; // SaleSmartly 官方窗口开关态(onOpenChat/onCloseChat 维护)
  18. this.unread={num:0,list:[]};
  19. // 已读指纹持久化:SaleSmartly 服务端不因"看过"清 unread,onUnRead 会一直推旧值(刷新后又提示)。
  20. // 故由客户端记账:已读时把当前未读列表的内容指纹存 localStorage;此后 onUnRead 的列表指纹
  21. // 与之相同=还是那批已读消息(拦),不同=真有新消息(亮)。跨刷新有效,不依赖 onReceiveMessage。
  22. this._readSig="";
  23. this._loadPromise=null;
  24. this._listeners={unread:new Set(),message:new Set(),chatOpen:new Set(),chatClose:new Set()};
  25. }
  26. _readSigKey(){ return "SSQ_READ_SIG_"+(this.gameId||"g"); }
  27. /** 由未读列表算内容指纹:优先用消息 id/时间戳,退化到文本+条数 */
  28. _sigOf(list){
  29. try{
  30. const arr=Array.isArray(list)?list:[];
  31. if (!arr.length) return "";
  32. return arr.map(m=>String(
  33. (m&&(m.id||m.message_id||m.msg_id||m.create_time||m.time||m.content||m.text||m.msg))||""
  34. )).join("|")+"#"+arr.length;
  35. }catch(e){ return String((list&&list.length)||0); }
  36. }
  37. _loadReadSig(){
  38. try{ this._readSig=window.localStorage.getItem(this._readSigKey())||""; }catch(e){ this._readSig=""; }
  39. }
  40. _saveReadSig(sig){
  41. this._readSig=sig||"";
  42. try{ window.localStorage.setItem(this._readSigKey(), this._readSig); }catch(e){}
  43. }
  44. setGameId(gameId, channel) {
  45. this.channel=channel;
  46. this.gameId=gameId;
  47. this.gameId==null && window.ssq && window.ssq.push('clearUser');
  48. }
  49. setServicejs(url) {
  50. this.src=url;
  51. }
  52. /** 订阅事件:unread({num,list}) / message(msg) / chatOpen / chatClose;返回取消函数 */
  53. on(type,cb){
  54. this._listeners[type]?.add(cb);
  55. return ()=>this._listeners[type]?.delete(cb);
  56. }
  57. _emit(type,payload){
  58. this._listeners[type]?.forEach(cb=>{ try{ cb(payload); }catch(e){} });
  59. }
  60. async loadScript(){
  61. return new Promise((resolve, reject) => {
  62. const script = document.createElement('script');
  63. script.src = this.src;
  64. script.onload = () => {
  65. window.__ssc = window.__ssc || {};
  66. window.__ssc.setting = { hideIcon: true }; //true为隐藏,false为不隐藏
  67. window.ssq && window.ssq.push('onReady', () => {
  68. this._attachEvents();
  69. resolve();
  70. });
  71. };
  72. script.onerror = () => {
  73. this._loadPromise=null; // 失败允许下次重试
  74. reject(new Error(`Failed to load script`));
  75. };
  76. document.body.appendChild(script);
  77. })
  78. }
  79. /** 幂等预载:登录后即调,否则收不到 onUnRead/onReceiveMessage(主动消息触达失效) */
  80. ensureReady(){
  81. if (this._loadPromise) return this._loadPromise;
  82. this._loadPromise=this.loadScript();
  83. return this._loadPromise;
  84. }
  85. preLoadScript(){
  86. this.ensureReady().then(()=>{}).catch(()=>{});
  87. }
  88. _attachEvents(){
  89. this._loadReadSig(); // 从上次会话恢复已读指纹(跨刷新)
  90. // 未读数与未读内容:自建入口红点 + Tab 角标数据源。
  91. // 指纹守门:SaleSmartly 服务端不因"看过"清 unread,onUnRead 会持续推同一批旧消息
  92. //(表现为快速开关角标复现、刷新后又提示同一批旧消息)。本次列表指纹 == 已存已读指纹
  93. // → 还是那批已读消息,忽略;指纹不同 → 真有新消息,放行点亮。
  94. window.ssq.push('onUnRead',(obj)=>{
  95. const num=Number(obj?.num)||0;
  96. const list=obj?.list||[];
  97. const sig=this._sigOf(list);
  98. if (num>0 && sig && sig===this._readSig) return; // 与已读批次相同=陈旧,忽略
  99. this.unread={num,list};
  100. this._emit('unread',this.unread);
  101. });
  102. // 实时收到消息(含客服主动消息):供自建顶部提示条触达
  103. window.ssq.push('onReceiveMessage',(obj)=>this._emit('message',obj));
  104. window.ssq.push('onOpenChat',()=>{
  105. this.chatWindowOpen=true; this._emit('chatOpen');
  106. // 打开窗口即视为把当前未读全部读完:落已读指纹 + 清零广播
  107. this.clearUnread();
  108. });
  109. window.ssq.push('onCloseChat',()=>{ this.chatWindowOpen=false; this._emit('chatClose'); });
  110. }
  111. packUserInfo(){
  112. let info={
  113. language: global.i18n.getCurrLangKey(),
  114. }
  115. if (this.gameId!=null){
  116. Object.assign(info,{
  117. user_id: this.prefix + this.channel + "_" + this.gameId,
  118. user_name: this.prefix + this.channel + "_" + this.gameId,
  119. });
  120. // 客服工作台右侧用户面板:VIP/品牌/渠道(静态字段;动态深数据走内部 admin)
  121. const u=global.user||{};
  122. const ext={};
  123. if (u.vip!=null) ext["VIP"]=String(u.vip);
  124. if (window.themeConfig?.title) ext["Brand"]=window.themeConfig.title;
  125. ext["Site"]=window.location.hostname;
  126. if (u.Channel!=null) ext["Channel"]=String(u.Channel);
  127. info.custom_fields_ext=ext;
  128. if (this.labels.length) info.label_names=this.labels;
  129. }
  130. return info
  131. }
  132. /** 本地未读清零并广播(打开聊天窗即已读;SaleSmartly 的 onUnRead 不会主动推 0) */
  133. clearUnread(){
  134. // 把"当前这批未读"的内容指纹落为已读水位并持久化:此后 onUnRead 再推同一批即被拦(跨刷新)。
  135. // 打开窗口时当前 unread 可能已被清空,故指纹回退到上次 onUnRead 缓存的 list。
  136. const sig=this._sigOf(this.unread?.list);
  137. if (sig) this._saveReadSig(sig);
  138. this.unread={num:0,list:[]};
  139. this._emit('unread',this.unread);
  140. }
  141. /** 绑定/刷新登录信息(幂等);extraLabels 会并入会话标签 */
  142. bindUser(extraLabels=[]){
  143. extraLabels.forEach(l=>{ if(!this.labels.includes(l)) this.labels.push(l); });
  144. try{ window.ssq && window.ssq.push('setLoginInfo', this.packUserInfo()); }catch(e){ console.error(e); }
  145. }
  146. onOpenChat(callback=null){
  147. try {
  148. window.ssq.push('setLoginInfo', this.packUserInfo());
  149. window.ssq.push('chatOpen');
  150. if (callback!=null)callback();
  151. }
  152. catch (err){
  153. console.error(err);
  154. }
  155. }
  156. /** 是否已登录且拿到 GameID(仅用于控制"登录后才预加载",不再阻止打开会话) */
  157. hasUser(){
  158. return this.gameId != null && this.gameId !== "" && String(this.gameId) !== "0";
  159. }
  160. openChat(callback=null){
  161. // 未登录也可进客服(用 SaleSmartly 默认访客名);已登录则 onOpenChat 里 setLoginInfo 带真实身份
  162. if (window.ssq==null){
  163. let self=this;
  164. this.ensureReady().then(()=>{
  165. self.onOpenChat(callback);
  166. }).catch((error) => {
  167. if (callback!=null)callback();
  168. })
  169. }
  170. else this.onOpenChat(callback);
  171. return true;
  172. }
  173. closeChat(){
  174. try{ window.ssq && window.ssq.push('chatClose'); }catch(e){}
  175. }
  176. /**
  177. * 以访客身份发结构化消息(建议/投诉表单桥接进客服会话队列)并打来源标签。
  178. * 就绪等待带 8s 超时:脚本加载失败/域名未白名单时 onReady 可能永不回调,不能悬挂调用方。
  179. */
  180. async sendSystemContext(text,label=null){
  181. try{
  182. await Promise.race([
  183. this.ensureReady(),
  184. new Promise((_,rej)=>setTimeout(()=>rej(new Error('ssq ready timeout')),8000))
  185. ]);
  186. this.bindUser(label?[label]:[]);
  187. window.ssq.push('sendTextMessage',text);
  188. return true;
  189. }catch(e){
  190. return false;
  191. }
  192. }
  193. }
  194. const SsqHelp = new SsqChat();
  195. export default SsqHelp;