tim-manager.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import TIM from 'tim-wx-sdk';
  2. class TIMManager {
  3. constructor() {
  4. this.tim = null;
  5. this.isLogin = false;
  6. this.userId = null;
  7. this.messageCallbacks = [];
  8. this.messageReadCallbacks = [];
  9. // 新增:缓存 store 实例,避免重复获取
  10. this.store = null;
  11. }
  12. /**
  13. * 初始化 TIM SDK
  14. * @param {Number} sdkAppID - 腾讯云 IM 的 SDKAppID
  15. */
  16. init(sdkAppID) {
  17. // 创建 SDK 实例
  18. this.tim = TIM.create({
  19. SDKAppID: sdkAppID
  20. });
  21. // 设置日志级别(开发时设为 0,生产设为 1)
  22. this.tim.setLogLevel(0);
  23. // 注册监听事件
  24. this.registerEvents();
  25. // 初始化时提前尝试获取 store(提升后续获取成功率)
  26. this.initStore();
  27. }
  28. /**
  29. * 初始化 store 实例(核心修复:提前获取+缓存)
  30. */
  31. initStore() {
  32. try {
  33. // 方式1:从全局 App 实例获取
  34. const app = getApp();
  35. if (app && app.$store) {
  36. this.store = app.$store;
  37. console.log('✅ 从全局 App 获取 store 成功');
  38. return;
  39. }
  40. // 方式2:直接导入 store(备用方案,注意路径正确性)
  41. const storeModule = require('../store/index.js');
  42. if (storeModule && storeModule.default) {
  43. this.store = storeModule.default;
  44. console.log('✅ 直接导入 store 成功');
  45. return;
  46. }
  47. console.warn('⚠️ 初始化时未获取到 store,将在需要时重试');
  48. } catch (error) {
  49. console.warn('⚠️ 初始化 store 失败:', error.message);
  50. }
  51. }
  52. /**
  53. * 安全获取 store 实例(带重试逻辑)
  54. * @returns {Promise<Object>} store 实例
  55. */
  56. async getStoreSafely() {
  57. // 缓存中有则直接返回
  58. if (this.store) {
  59. return this.store;
  60. }
  61. // 无缓存时,重试获取(最多等 3 秒,每 100ms 检查一次)
  62. return new Promise((resolve, reject) => {
  63. let retryCount = 0;
  64. const maxRetry = 30; // 30 * 100ms = 3 秒
  65. const timer = setInterval(() => {
  66. retryCount++;
  67. // 尝试从全局 App 获取
  68. const app = getApp();
  69. if (app && app.$store) {
  70. clearInterval(timer);
  71. this.store = app.$store;
  72. resolve(this.store);
  73. return;
  74. }
  75. // 尝试直接导入
  76. try {
  77. const storeModule = require('../store/index.js');
  78. if (storeModule && storeModule.default) {
  79. clearInterval(timer);
  80. this.store = storeModule.default;
  81. resolve(this.store);
  82. return;
  83. }
  84. } catch (e) {}
  85. // 超过重试次数
  86. if (retryCount >= maxRetry) {
  87. clearInterval(timer);
  88. reject(new Error('获取 store 超时(3秒)'));
  89. }
  90. }, 100);
  91. });
  92. }
  93. /**
  94. * 注册事件监听
  95. */
  96. registerEvents() {
  97. // SDK 进入 ready 状态
  98. this.tim.on(TIM.EVENT.SDK_READY, this.onSdkReady.bind(this));
  99. // SDK 未 ready
  100. this.tim.on(TIM.EVENT.SDK_NOT_READY, this.onSdkNotReady.bind(this));
  101. // 收到新消息
  102. this.tim.on(TIM.EVENT.MESSAGE_RECEIVED, this.onMessageReceived.bind(this));
  103. // 会话列表更新(核心修复)
  104. this.tim.on(TIM.EVENT.CONVERSATION_LIST_UPDATED, this.onConversationListUpdated.bind(this));
  105. // 消息已读回执(对方已读我发送的消息)
  106. this.tim.on(TIM.EVENT.MESSAGE_READ_BY_PEER, this.onMessageReadByPeer.bind(this));
  107. // 被踢下线
  108. this.tim.on(TIM.EVENT.KICKED_OUT, this.onKickedOut.bind(this));
  109. // 网络状态变化
  110. this.tim.on(TIM.EVENT.NET_STATE_CHANGE, this.onNetStateChange.bind(this));
  111. }
  112. /**
  113. * SDK Ready
  114. */
  115. onSdkReady() {
  116. this.isLogin = true;
  117. }
  118. /**
  119. * SDK Not Ready
  120. */
  121. onSdkNotReady() {
  122. this.isLogin = false;
  123. }
  124. /**
  125. * 收到新消息
  126. */
  127. onMessageReceived(event) {
  128. const messageList = event.data;
  129. // 同步接收到的消息到MySQL(双重保障)
  130. messageList.forEach(message => {
  131. this.syncReceivedMessageToMySQL(message);
  132. });
  133. // 触发回调
  134. this.messageCallbacks.forEach(callback => {
  135. callback(messageList);
  136. });
  137. }
  138. /**
  139. * 同步接收到的消息到MySQL
  140. */
  141. async syncReceivedMessageToMySQL(timMessage) {
  142. try {
  143. // 获取消息类型
  144. const getMessageType = (msg) => {
  145. const typeMap = {
  146. 'TIMTextElem': 1,
  147. 'TIMImageElem': 2,
  148. 'TIMSoundElem': 3,
  149. 'TIMVideoFileElem': 4,
  150. 'TIMFileElem': 5
  151. };
  152. // 处理自定义消息(我们的语音消息)
  153. if (msg.type === 'TIMCustomElem' && msg.payload) {
  154. try {
  155. const customData = JSON.parse(msg.payload.data);
  156. if (customData.type === 'voice') {
  157. return 3; // 语音消息
  158. }
  159. } catch (e) {
  160. console.error('解析自定义消息类型失败:', e);
  161. }
  162. }
  163. return typeMap[msg.type] || 1;
  164. };
  165. // 获取消息内容
  166. const getMessageContent = (msg) => {
  167. switch (msg.type) {
  168. case 'TIMTextElem':
  169. return msg.payload.text || '';
  170. case 'TIMImageElem':
  171. return '[图片]';
  172. case 'TIMSoundElem':
  173. return '[语音]';
  174. case 'TIMVideoFileElem':
  175. return '[视频]';
  176. case 'TIMFileElem':
  177. return '[文件]';
  178. case 'TIMCustomElem':
  179. // 处理自定义消息
  180. try {
  181. const customData = JSON.parse(msg.payload.data);
  182. if (customData.type === 'voice') {
  183. return '[语音]';
  184. }
  185. } catch (e) {
  186. console.error('解析自定义消息内容失败:', e);
  187. }
  188. return '[自定义消息]';
  189. default:
  190. return '[未知消息]';
  191. }
  192. };
  193. // 使用V2接口,支持红娘消息(TIM用户ID可能是 m_xxx 格式)
  194. const syncData = {
  195. messageId: timMessage.ID,
  196. fromTimUserId: String(timMessage.from), // 保持字符串格式,支持 m_xxx
  197. toTimUserId: String(timMessage.to), // 保持字符串格式,支持 m_xxx
  198. messageType: getMessageType(timMessage),
  199. content: getMessageContent(timMessage),
  200. sendTime: timMessage.time
  201. };
  202. // 如果是语音消息,添加语音信息
  203. if (timMessage.type === 'TIMSoundElem' && timMessage.payload) {
  204. syncData.mediaUrl = timMessage.payload.url || timMessage.payload.remoteAudioUrl;
  205. syncData.duration = timMessage.payload.second || 0;
  206. syncData.mediaSize = timMessage.payload.size || 0;
  207. }
  208. // 如果是自定义消息(我们的语音消息)
  209. if (timMessage.type === 'TIMCustomElem' && timMessage.payload) {
  210. try {
  211. const customData = JSON.parse(timMessage.payload.data);
  212. if (customData.type === 'voice') {
  213. syncData.mediaUrl = customData.url;
  214. syncData.duration = customData.duration;
  215. syncData.mediaSize = customData.size || 0;
  216. }
  217. } catch (e) {
  218. console.error('解析自定义消息失败:', e);
  219. }
  220. }
  221. // 如果是图片消息,添加图片信息
  222. if (timMessage.type === 'TIMImageElem' && timMessage.payload.imageInfoArray) {
  223. const imageInfo = timMessage.payload.imageInfoArray[0];
  224. syncData.mediaUrl = imageInfo.imageUrl;
  225. syncData.thumbnailUrl = imageInfo.imageUrl;
  226. }
  227. // 调用后端同步接口(V2版本,支持红娘消息)
  228. const res = await uni.request({
  229. url: 'https://api.zhongruanke.cn/api/chat/syncTIMMessageV2',
  230. method: 'POST',
  231. data: syncData,
  232. header: {
  233. 'Content-Type': 'application/json'
  234. }
  235. });
  236. if (res[1].data.code === 200) {
  237. const msgType = res[1].data.type === 'matchmaker' ? '红娘消息' : '用户消息';
  238. }
  239. } catch (error) {
  240. console.error('❌ 同步接收消息失败:', error);
  241. // 同步失败不影响主流程
  242. }
  243. }
  244. /**
  245. * 消息已读回执(对方已读我发送的消息)
  246. */
  247. onMessageReadByPeer(event) {
  248. // 触发所有已读回执回调
  249. this.messageReadCallbacks.forEach(callback => {
  250. try {
  251. callback(event);
  252. } catch (error) {
  253. console.error('❌ 已读回执回调执行失败:', error);
  254. }
  255. });
  256. }
  257. /**
  258. * 会话列表更新(核心修复:异步安全获取 store)
  259. */
  260. async onConversationListUpdated(event) {
  261. // 计算总未读数
  262. const conversationList = event.data;
  263. let totalUnread = 0;
  264. conversationList.forEach(conversation => {
  265. totalUnread += conversation.unreadCount || 0;
  266. });
  267. try {
  268. // 核心修复:异步安全获取 store
  269. const store = await this.getStoreSafely();
  270. if (store) {
  271. await store.dispatch('updateTotalUnread', totalUnread);
  272. console.log('✅ 全局未读数更新成功:', totalUnread);
  273. }
  274. // 兜底方案:触发自定义事件,确保页面能收到更新
  275. uni.$emit('conversationUnreadUpdate', totalUnread);
  276. } catch (error) {
  277. console.error('❌ 更新全局未读数失败:', error.message);
  278. // 即使失败,仍触发自定义事件
  279. uni.$emit('conversationUnreadUpdate', totalUnread);
  280. }
  281. }
  282. /**
  283. * 被踢下线
  284. */
  285. onKickedOut(event) {
  286. uni.showModal({
  287. title: '下线通知',
  288. content: '您的账号在其他设备登录',
  289. showCancel: false
  290. });
  291. }
  292. /**
  293. * 网络状态变化
  294. */
  295. onNetStateChange(event) {}
  296. /**
  297. * 登录
  298. */
  299. async login(userID, userSig) {
  300. try {
  301. const res = await this.tim.login({
  302. userID: String(userID),
  303. userSig: userSig
  304. });
  305. this.userId = userID;
  306. return res;
  307. } catch (error) {
  308. console.error('❌ TIM 登录失败:', error);
  309. throw error;
  310. }
  311. }
  312. /**
  313. * 登出
  314. */
  315. async logout() {
  316. try {
  317. await this.tim.logout();
  318. this.isLogin = false;
  319. this.userId = null;
  320. } catch (error) {
  321. console.error('❌ TIM 登出失败:', error);
  322. }
  323. }
  324. /**
  325. * 发送文本消息
  326. */
  327. async sendTextMessage(toUserId, text) {
  328. try {
  329. // 验证参数
  330. if (!toUserId || toUserId === 'undefined' || toUserId === 'null') {
  331. console.error('❌ 接收者ID无效:', toUserId);
  332. throw new Error('接收者ID无效: ' + toUserId);
  333. }
  334. if (!this.userId || this.userId === 'undefined' || this.userId === 'null') {
  335. console.error('❌ 发送者ID无效:', this.userId);
  336. throw new Error('发送者ID未登录或无效');
  337. }
  338. const toUserIdStr = String(toUserId);
  339. // 创建文本消息
  340. const message = this.tim.createTextMessage({
  341. to: toUserIdStr,
  342. conversationType: TIM.TYPES.CONV_C2C, // 单聊
  343. payload: {
  344. text: text
  345. }
  346. });
  347. // 发送消息
  348. const res = await this.tim.sendMessage(message);
  349. return res.data.message;
  350. } catch (error) {
  351. console.error(' - 错误代码:', error.code || 'N/A');
  352. throw error;
  353. }
  354. }
  355. /**
  356. * 发送图片消息
  357. */
  358. async sendImageMessage(toUserId, filePath) {
  359. try {
  360. const message = this.tim.createImageMessage({
  361. to: String(toUserId),
  362. conversationType: TIM.TYPES.CONV_C2C,
  363. payload: {
  364. file: filePath
  365. },
  366. onProgress: (percent) => {}
  367. });
  368. const res = await this.tim.sendMessage(message);
  369. return res.data.message;
  370. } catch (error) {
  371. console.error('❌ 图片发送失败:', error);
  372. throw error;
  373. }
  374. }
  375. /**
  376. * 发送语音消息(通过自定义消息携带MinIO URL)
  377. * @param {String} toUserId 接收者ID
  378. * @param {String} voiceUrl MinIO语音文件URL
  379. * @param {Number} duration 语音时长(秒)
  380. * @param {Number} fileSize 文件大小(字节)
  381. */
  382. async sendVoiceMessage(toUserId, voiceUrl, duration, fileSize) {
  383. try {
  384. // 使用自定义消息发送语音信息
  385. const message = this.tim.createCustomMessage({
  386. to: String(toUserId),
  387. conversationType: TIM.TYPES.CONV_C2C,
  388. payload: {
  389. data: JSON.stringify({
  390. type: 'voice',
  391. url: voiceUrl,
  392. duration: duration,
  393. size: fileSize
  394. }),
  395. description: '[语音]',
  396. extension: voiceUrl
  397. }
  398. });
  399. const res = await this.tim.sendMessage(message);
  400. return res.data.message;
  401. } catch (error) {
  402. console.error('❌ 语音发送失败:', error);
  403. throw error;
  404. }
  405. }
  406. /**
  407. * 获取会话列表
  408. */
  409. async getConversationList() {
  410. try {
  411. const res = await this.tim.getConversationList();
  412. return res.data.conversationList;
  413. } catch (error) {
  414. console.error('❌ 获取会话列表失败:', error);
  415. throw error;
  416. }
  417. }
  418. /**
  419. * 获取聊天记录
  420. */
  421. async getMessageList(conversationID, count = 15) {
  422. try {
  423. const res = await this.tim.getMessageList({
  424. conversationID: conversationID,
  425. count: count
  426. });
  427. return res.data.messageList;
  428. } catch (error) {
  429. console.error('❌ 获取聊天记录失败:', error);
  430. throw error;
  431. }
  432. }
  433. /**
  434. * 将消息设为已读
  435. */
  436. async setMessageRead(conversationID) {
  437. try {
  438. await this.tim.setMessageRead({ conversationID });
  439. } catch (error) {
  440. console.error('❌ 设置已读失败:', error);
  441. }
  442. }
  443. /**
  444. * 撤回消息
  445. */
  446. async revokeMessage(message) {
  447. try {
  448. await this.tim.revokeMessage(message);
  449. } catch (error) {
  450. console.error('❌ 撤回失败:', error);
  451. throw error;
  452. }
  453. }
  454. /**
  455. * 监听消息
  456. */
  457. onMessage(callback) {
  458. this.messageCallbacks.push(callback);
  459. }
  460. /**
  461. * 移除监听
  462. */
  463. offMessage(callback) {
  464. const index = this.messageCallbacks.indexOf(callback);
  465. if (index > -1) {
  466. this.messageCallbacks.splice(index, 1);
  467. }
  468. }
  469. /**
  470. * 监听已读回执
  471. */
  472. onMessageRead(callback) {
  473. this.messageReadCallbacks.push(callback);
  474. }
  475. /**
  476. * 移除已读回执监听
  477. */
  478. offMessageRead(callback) {
  479. const index = this.messageReadCallbacks.indexOf(callback);
  480. if (index > -1) {
  481. this.messageReadCallbacks.splice(index, 1);
  482. }
  483. }
  484. /**
  485. * 获取 TIM 实例
  486. */
  487. getTim() {
  488. return this.tim;
  489. }
  490. /**
  491. * 获取当前登录的 IM 用户ID
  492. */
  493. getCurrentUserId() {
  494. return this.userId ? String(this.userId) : null;
  495. }
  496. }
  497. // 导出单例
  498. const timManager = new TIMManager();
  499. export default timManager;