useMessages.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. /**
  2. * 会话消息:拉取历史、加载更多、发文字、发文件
  3. */
  4. import {
  5. getMessages,
  6. sendMessage as apiSendMessage,
  7. uploadFile,
  8. getToken,
  9. getContentType,
  10. getUserIdFromToken,
  11. normalizeMessageContentType
  12. } from '../utils/api'
  13. import { chatStore } from '../store/chat'
  14. /**
  15. * 归一化单条消息。若后端未返回 is_me,则用 currentUserId 与 sender_id 计算
  16. * @param {object} m - 原始消息
  17. * @param {string} [currentUserId] - 当前登录用户 ID,用于推算 isMe
  18. */
  19. function normalizeMessage(m, currentUserId) {
  20. if (!m) return null
  21. const type = m.type ?? 'MESSAGE'
  22. let isMe = m.is_me ?? m.isMe
  23. if (isMe === undefined && currentUserId != null && currentUserId !== '') {
  24. const senderId = m.sender_id ?? m.senderId
  25. isMe = String(senderId) === String(currentUserId)
  26. }
  27. const rawText = m.content ?? m.text ?? ''
  28. const urlField = m.url ?? m.file_url ?? m.content_url
  29. const content =
  30. urlField && /^https?:\/\//i.test(String(urlField)) ? String(urlField) : String(rawText)
  31. return {
  32. id: String(m.id),
  33. type,
  34. senderId: m.sender_id ?? m.senderId,
  35. receiverId: m.receiver_id ?? m.receiverId,
  36. content,
  37. contentType: normalizeMessageContentType(m.content_type ?? m.contentType ?? 'TEXT'),
  38. title: m.title,
  39. createdAt: m.created_at ?? m.createdAt,
  40. isMe: !!isMe,
  41. actionUrl: m.action_url ?? m.actionUrl,
  42. actionText: m.action_text ?? m.actionText
  43. }
  44. }
  45. function normalizeMessageList(list, currentUserId) {
  46. return (list || []).map((m) => normalizeMessage(m, currentUserId)).filter(Boolean)
  47. }
  48. /**
  49. * 拉取某会话最新一页历史(与聊天页 fetchMessages 同源逻辑)。
  50. * 供 WebSocket 重连后补拉断线期间消息,避免仅依赖推送延迟。
  51. */
  52. export async function fetchMessagesForContact(contactId) {
  53. const token = getToken()
  54. if (!token) return
  55. const currentUserId = getUserIdFromToken(token)
  56. try {
  57. const data = await getMessages(token, contactId, { skip: 0, limit: 50 })
  58. const list = Array.isArray(data) ? data : (data.messages || data.list || data.items || [])
  59. chatStore.setMessagesForContact(contactId, normalizeMessageList(list, currentUserId))
  60. chatStore.markContactLoaded(contactId)
  61. } catch (e) {
  62. chatStore.setMessagesForContact(contactId, [])
  63. }
  64. }
  65. export function useMessages() {
  66. async function fetchMessages(contactId) {
  67. return fetchMessagesForContact(contactId)
  68. }
  69. async function fetchMoreMessages(contactId) {
  70. const list = chatStore.messages[String(contactId)] || []
  71. const skip = list.length
  72. const token = getToken()
  73. if (!token) return
  74. chatStore.loadingMore[String(contactId)] = true
  75. try {
  76. const currentUserId = getUserIdFromToken(token)
  77. const data = await getMessages(token, contactId, { skip, limit: 50 })
  78. const more = Array.isArray(data) ? data : (data.messages || data.list || data.items || [])
  79. if (more.length) chatStore.prependMessages(contactId, normalizeMessageList(more, currentUserId))
  80. } catch (e) {
  81. // 静默失败
  82. } finally {
  83. chatStore.loadingMore[String(contactId)] = false
  84. }
  85. }
  86. async function sendMessage(contactId, content) {
  87. const text = (content || '').trim()
  88. if (!text) return
  89. const token = getToken()
  90. if (!token) {
  91. uni.showToast({ title: '请先登录', icon: 'none' })
  92. return
  93. }
  94. const cid = String(contactId)
  95. const tempId = 'temp-' + Date.now()
  96. const tempMsg = {
  97. id: tempId,
  98. tempId,
  99. senderId: null,
  100. content: text,
  101. contentType: 'TEXT',
  102. createdAt: new Date().toISOString(),
  103. isMe: true,
  104. status: 'sending'
  105. }
  106. chatStore.appendMessage(cid, tempMsg)
  107. try {
  108. const res = await apiSendMessage(token, cid, text, 'TEXT')
  109. const raw = (res && (res.data ?? res.message ?? res)) || res
  110. const currentUserId = getUserIdFromToken(token)
  111. const serverMsg = normalizeMessage(typeof raw === 'object' && raw !== null ? raw : null, currentUserId)
  112. const hasValidId = serverMsg && serverMsg.id && String(serverMsg.id) !== 'undefined'
  113. if (hasValidId) {
  114. const finalMsg = serverMsg.content != null && serverMsg.content !== ''
  115. ? serverMsg
  116. : { ...serverMsg, content: text, contentType: 'TEXT' }
  117. chatStore.replaceTempMessage(cid, tempId, finalMsg)
  118. } else {
  119. chatStore.updateMessage(cid, tempId, { status: 'sent' })
  120. }
  121. } catch (e) {
  122. chatStore.updateMessage(cid, tempId, { status: 'failed' })
  123. const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
  124. uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
  125. }
  126. }
  127. async function retrySendMessage(contactId, msg) {
  128. if (!msg || !msg.tempId || msg.status !== 'failed') return
  129. const text = (msg.content || '').trim()
  130. if (!text) return
  131. const token = getToken()
  132. if (!token) {
  133. uni.showToast({ title: '请先登录', icon: 'none' })
  134. return
  135. }
  136. const cid = String(contactId)
  137. chatStore.updateMessage(cid, msg.tempId, { status: 'sending' })
  138. try {
  139. const res = await apiSendMessage(token, cid, text, 'TEXT')
  140. const raw = (res && (res.data ?? res.message ?? res)) || res
  141. const currentUserId = getUserIdFromToken(token)
  142. const serverMsg = normalizeMessage(typeof raw === 'object' && raw !== null ? raw : null, currentUserId)
  143. const hasValidId = serverMsg && serverMsg.id && String(serverMsg.id) !== 'undefined'
  144. if (hasValidId) {
  145. const finalMsg = serverMsg.content != null && serverMsg.content !== ''
  146. ? serverMsg
  147. : { ...serverMsg, content: text, contentType: 'TEXT' }
  148. chatStore.replaceTempMessage(cid, msg.tempId, finalMsg)
  149. } else {
  150. chatStore.updateMessage(cid, msg.tempId, { status: 'sent' })
  151. }
  152. } catch (e) {
  153. chatStore.updateMessage(cid, msg.tempId, { status: 'failed' })
  154. const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
  155. uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
  156. }
  157. }
  158. async function sendFileMessage(contactId, filePath, contentType, fileName, onProgress) {
  159. const token = getToken()
  160. if (!token) {
  161. uni.showToast({ title: '请先登录', icon: 'none' })
  162. return
  163. }
  164. const cid = String(contactId)
  165. const apiCt = normalizeMessageContentType(contentType)
  166. const displayTitle =
  167. (fileName && String(fileName).trim()) ||
  168. (apiCt === 'IMAGE' ? '图片' : apiCt === 'VIDEO' ? '视频' : '文件')
  169. const tempId = 'temp-' + Date.now()
  170. const tempMsg = {
  171. id: tempId,
  172. tempId,
  173. senderId: null,
  174. content: filePath,
  175. contentType: apiCt,
  176. title: displayTitle,
  177. createdAt: new Date().toISOString(),
  178. isMe: true,
  179. status: 'sending',
  180. localFilePath: filePath,
  181. uploadProgress: 0
  182. }
  183. chatStore.appendMessage(cid, tempMsg)
  184. try {
  185. const uploadResult = await uploadFile(token, filePath, fileName, (p) => {
  186. if (typeof p === 'number' && p >= 0 && p <= 1) {
  187. chatStore.updateMessage(cid, tempId, { uploadProgress: p })
  188. }
  189. if (typeof onProgress === 'function') onProgress(p)
  190. })
  191. const key = uploadResult.key || uploadResult.file_key
  192. const name = (fileName && String(fileName).trim()) || uploadResult.filename || displayTitle
  193. if (!key) throw new Error('上传未返回 key')
  194. const res = await apiSendMessage(token, cid, key, apiCt, name)
  195. const raw = (res && (res.data ?? res.message ?? res)) || res
  196. const currentUserId = getUserIdFromToken(token)
  197. const serverMsg = normalizeMessage(typeof raw === 'object' && raw !== null ? raw : null, currentUserId)
  198. const hasValidId = serverMsg && serverMsg.id && String(serverMsg.id) !== 'undefined'
  199. if (hasValidId) {
  200. const finalMsg =
  201. serverMsg.content != null && String(serverMsg.content) !== ''
  202. ? serverMsg
  203. : { ...serverMsg, content: key, contentType: apiCt, title: name }
  204. chatStore.replaceTempMessage(cid, tempId, finalMsg)
  205. } else {
  206. chatStore.updateMessage(cid, tempId, { status: 'sent', uploadProgress: undefined })
  207. }
  208. } catch (e) {
  209. chatStore.updateMessage(cid, tempId, { status: 'failed', uploadProgress: undefined })
  210. const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
  211. uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
  212. }
  213. }
  214. async function retrySendFileMessage(contactId, msg) {
  215. if (!msg || !msg.tempId || msg.status !== 'failed' || !msg.localFilePath) return
  216. const token = getToken()
  217. if (!token) {
  218. uni.showToast({ title: '请先登录', icon: 'none' })
  219. return
  220. }
  221. const cid = String(contactId)
  222. const tempId = msg.tempId
  223. const apiCt = normalizeMessageContentType(msg.contentType)
  224. const displayTitle = (msg.title && String(msg.title).trim()) || (apiCt === 'IMAGE' ? '图片' : apiCt === 'VIDEO' ? '视频' : '文件')
  225. chatStore.updateMessage(cid, tempId, { status: 'sending', uploadProgress: 0 })
  226. try {
  227. const uploadResult = await uploadFile(token, msg.localFilePath, undefined, (p) => {
  228. if (typeof p === 'number' && p >= 0 && p <= 1) {
  229. chatStore.updateMessage(cid, tempId, { uploadProgress: p })
  230. }
  231. })
  232. const key = uploadResult.key || uploadResult.file_key
  233. const name = uploadResult.filename || displayTitle
  234. if (!key) throw new Error('上传未返回 key')
  235. const res = await apiSendMessage(token, cid, key, apiCt, name)
  236. const raw = (res && (res.data ?? res.message ?? res)) || res
  237. const currentUserId = getUserIdFromToken(token)
  238. const serverMsg = normalizeMessage(typeof raw === 'object' && raw !== null ? raw : null, currentUserId)
  239. const hasValidId = serverMsg && serverMsg.id && String(serverMsg.id) !== 'undefined'
  240. if (hasValidId) {
  241. const finalMsg =
  242. serverMsg.content != null && String(serverMsg.content) !== ''
  243. ? serverMsg
  244. : { ...serverMsg, content: key, contentType: apiCt, title: name }
  245. chatStore.replaceTempMessage(cid, tempId, finalMsg)
  246. } else {
  247. chatStore.updateMessage(cid, tempId, { status: 'sent', uploadProgress: undefined })
  248. }
  249. } catch (e) {
  250. chatStore.updateMessage(cid, tempId, { status: 'failed', uploadProgress: undefined })
  251. const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
  252. uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
  253. }
  254. }
  255. /** 再次提醒:向对方重发一条相同结构的 USER_NOTIFICATION */
  256. async function remindUserNotification(contactId, sourceMsg) {
  257. if (!sourceMsg || sourceMsg.tempId) return
  258. const token = getToken()
  259. if (!token) {
  260. uni.showToast({ title: '请先登录', icon: 'none' })
  261. return
  262. }
  263. const cid = String(contactId)
  264. const tempId = 'temp-' + Date.now()
  265. const content = sourceMsg.content != null ? String(sourceMsg.content) : ''
  266. const title =
  267. sourceMsg.title != null && sourceMsg.title !== '' ? String(sourceMsg.title) : undefined
  268. const tempMsg = {
  269. id: tempId,
  270. tempId,
  271. senderId: null,
  272. type: 'MESSAGE',
  273. content,
  274. contentType: 'USER_NOTIFICATION',
  275. title: sourceMsg.title,
  276. actionUrl: sourceMsg.actionUrl,
  277. actionText: sourceMsg.actionText,
  278. createdAt: new Date().toISOString(),
  279. isMe: true,
  280. status: 'sending'
  281. }
  282. chatStore.appendMessage(cid, tempMsg)
  283. try {
  284. const res = await apiSendMessage(token, cid, content, 'USER_NOTIFICATION', title, {
  285. action_url: sourceMsg.actionUrl,
  286. action_text: sourceMsg.actionText
  287. })
  288. const raw = (res && (res.data ?? res.message ?? res)) || res
  289. const currentUserId = getUserIdFromToken(token)
  290. const serverMsg = normalizeMessage(typeof raw === 'object' && raw !== null ? raw : null, currentUserId)
  291. const hasValidId = serverMsg && serverMsg.id && String(serverMsg.id) !== 'undefined'
  292. if (hasValidId) {
  293. const finalMsg =
  294. serverMsg.contentType === 'USER_NOTIFICATION'
  295. ? serverMsg
  296. : { ...serverMsg, contentType: 'USER_NOTIFICATION', content, title: sourceMsg.title }
  297. chatStore.replaceTempMessage(cid, tempId, finalMsg)
  298. } else {
  299. chatStore.updateMessage(cid, tempId, { status: 'sent' })
  300. }
  301. } catch (e) {
  302. chatStore.removeMessage(cid, tempId)
  303. const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
  304. uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
  305. }
  306. }
  307. return {
  308. messages: chatStore.messages,
  309. loadingMore: chatStore.loadingMore,
  310. fetchMessages,
  311. fetchMoreMessages,
  312. sendMessage,
  313. retrySendMessage,
  314. sendFileMessage,
  315. retrySendFileMessage,
  316. remindUserNotification,
  317. hasContactLoaded: (id) => chatStore.hasContactLoaded(id),
  318. getContentType
  319. }
  320. }