index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. <template>
  2. <view class="chat-page">
  3. <view class="chat-header">
  4. <view class="chat-header-main">
  5. <view class="back" @click="goBack">
  6. <image class="header-icon-img" src="/static/icons/back.svg" mode="aspectFit" />
  7. </view>
  8. <view class="chat-title-wrap" @click="onTitleClick">
  9. <view class="chat-title-texts">
  10. <text class="chat-title">{{ contactTitle }}</text>
  11. </view>
  12. </view>
  13. <view class="header-actions">
  14. <view class="header-icon" @click="onMore">
  15. <image class="header-icon-img" src="/static/icons/more.svg" mode="aspectFit" />
  16. </view>
  17. </view>
  18. </view>
  19. </view>
  20. <scroll-view
  21. class="message-list"
  22. scroll-y
  23. :scroll-into-view="scrollIntoView"
  24. scroll-with-animation
  25. @scrolltoupper="onLoadMore"
  26. >
  27. <view v-if="loadingMoreForContact" class="load-more">加载更多...</view>
  28. <view v-else-if="!messageList.length" class="empty-messages">
  29. <text class="empty-text">暂无消息,发一句打个招呼吧</text>
  30. </view>
  31. <view
  32. v-for="(msg, index) in messageList"
  33. :key="msg.id || msg.tempId || index"
  34. >
  35. <PrivateMessageBubble
  36. v-if="msg.type === 'MESSAGE' || msg.type === 'PRIVATE' || !msg.type"
  37. :msg="msg"
  38. :sender-name="getSenderName(msg)"
  39. :sender-id="otherUserId"
  40. :sender-avatar="contactAvatar"
  41. :me-name="currentUserName"
  42. :me-id="currentUserId"
  43. :me-avatar="currentUserAvatar"
  44. :show-date-label="shouldShowDateLabel(index)"
  45. :remind-loading="remindingNotificationId === String(msg.id)"
  46. @preview-image="previewImage"
  47. @open-notification-url="openNotificationUrl"
  48. @retry="onRetry"
  49. @remind-user-notification="onRemindUserNotification"
  50. />
  51. <NotificationBubble
  52. v-else
  53. :msg="msg"
  54. :sender-name="getSenderName(msg)"
  55. :show-date-label="shouldShowDateLabel(index)"
  56. @open-notification-url="openNotificationUrl"
  57. />
  58. </view>
  59. </scroll-view>
  60. <view class="input-bar">
  61. <view class="input-row">
  62. <view class="input-wrap">
  63. <input
  64. v-model="inputValue"
  65. class="input"
  66. :placeholder="'发送给 ' + contactTitle"
  67. confirm-type="send"
  68. @confirm="onSend"
  69. @focus="onInputFocus"
  70. />
  71. </view>
  72. <view class="input-icon input-icon-plus" @click="onPlus">
  73. <image class="input-plus-img" src="/static/icons/add.svg" mode="aspectFit" />
  74. </view>
  75. </view>
  76. <view v-if="showPlusPanel" class="plus-panel">
  77. <view class="plus-item" @click="onChooseImage">
  78. <view class="plus-icon">
  79. <image class="plus-inner-icon" src="/static/icons/image.svg" mode="aspectFit" />
  80. </view>
  81. <text class="plus-label-title">图片</text>
  82. </view>
  83. <view class="plus-item" @click="onChooseVideo">
  84. <view class="plus-icon">
  85. <image class="plus-inner-icon" src="/static/icons/video.svg" mode="aspectFit" />
  86. </view>
  87. <text class="plus-label-title">视频</text>
  88. </view>
  89. <view class="plus-item" @click="onChooseFile">
  90. <view class="plus-icon">
  91. <image class="plus-inner-icon" src="/static/icons/file.svg" mode="aspectFit" />
  92. </view>
  93. <text class="plus-label-title">文件</text>
  94. </view>
  95. </view>
  96. </view>
  97. </view>
  98. </template>
  99. <script setup>
  100. import { ref, computed, watch, onMounted } from 'vue'
  101. import { onLoad, onUnload } from '@dcloudio/uni-app'
  102. import PrivateMessageBubble from '../../components/chat/PrivateMessageBubble.vue'
  103. import NotificationBubble from '../../components/chat/NotificationBubble.vue'
  104. import { useMessages } from '../../composables/useMessages'
  105. import { useContacts } from '../../composables/useContacts'
  106. import { chatStore } from '../../store/chat'
  107. import { getMessageCallbackUrl, getToken } from '../../utils/api'
  108. const otherUserId = ref('')
  109. const contactTitle = ref('会话')
  110. const fallbackContactName = ref('')
  111. const inputValue = ref('')
  112. const scrollIntoView = ref('')
  113. const showPlusPanel = ref(false)
  114. /** 正在对哪条 USER_NOTIFICATION 执行「再次提醒」(用于按钮 loading,并防并发连点) */
  115. const remindingNotificationId = ref('')
  116. const { messages, loadingMore, fetchMessages, fetchMoreMessages, sendMessage, retrySendMessage, sendFileMessage, remindUserNotification } = useMessages()
  117. const { fetchContacts } = useContacts()
  118. function syncContactTitle() {
  119. const contact = (chatStore.contacts || []).find((c) => String(c.user_id || c.id) === String(otherUserId.value))
  120. if (contact) {
  121. contactTitle.value = (contact.app_name || contact.title || '会话')
  122. return
  123. }
  124. // 若会话列表未命中(例如从联系人详情进入,此时 chatStore.contacts 未包含该用户)
  125. contactTitle.value = fallbackContactName.value || '会话'
  126. }
  127. const messageList = computed(() => {
  128. const id = String(otherUserId.value)
  129. return (messages[id] || [])
  130. })
  131. const loadingMoreForContact = computed(() => !!loadingMore[String(otherUserId.value)])
  132. onLoad((options) => {
  133. otherUserId.value = String((options && (options.otherUserId || options.userId)) || '0')
  134. // 用联系人详情传参兜底:保证从联系人详情进来仍能显示名字
  135. try {
  136. const raw = options && options.contactName != null ? String(options.contactName) : ''
  137. const s = raw
  138. // 若未自动解码,可能是 %E5...,这里做一次安全解码
  139. if (/%[0-9A-Fa-f]{2}/.test(s)) fallbackContactName.value = decodeURIComponent(s)
  140. else fallbackContactName.value = s
  141. } catch (e) {
  142. fallbackContactName.value = ''
  143. }
  144. chatStore.setActiveContact(otherUserId.value)
  145. chatStore.clearUnread(otherUserId.value)
  146. chatStore.updateTabBarUnreadBadge()
  147. syncContactTitle()
  148. try {
  149. const u = uni.getStorageSync('current_user')
  150. if (u && typeof u === 'object') {
  151. if (u.name) currentUserName.value = u.name
  152. currentUserId.value = String(u.id ?? u.user_id ?? '')
  153. currentUserAvatar.value = u.avatar || u.avatar_url || ''
  154. }
  155. } catch (e) {}
  156. // 进入聊天时,确保会话列表已就绪:联系人详情页跳转时可能还没加载 chatStore.contacts
  157. if (!chatStore.contacts || !chatStore.contacts.length) {
  158. Promise.resolve()
  159. .then(() => fetchContacts())
  160. .then(() => syncContactTitle())
  161. .catch(() => {})
  162. } else {
  163. // contacts 已有数据时也做一次兜底(例如 otherUserId 刚好没命中但后续会更新)
  164. syncContactTitle()
  165. }
  166. fetchMessages(otherUserId.value)
  167. })
  168. onUnload(() => {
  169. chatStore.setActiveContact('')
  170. })
  171. onMounted(() => {
  172. // 滚动到底部
  173. setTimeout(() => {
  174. const list = messageList.value
  175. if (list.length) scrollIntoView.value = 'msg-' + (list[list.length - 1].id || list[list.length - 1].tempId)
  176. }, 300)
  177. })
  178. watch(messageList, (list) => {
  179. if (list.length) scrollIntoView.value = 'msg-' + (list[list.length - 1].id || list[list.length - 1].tempId)
  180. }, { deep: true })
  181. // contacts 更新后同步聊天标题(避免从联系人详情进入仍显示“会话”)
  182. watch(
  183. () => chatStore.contacts,
  184. () => {
  185. if (otherUserId.value) syncContactTitle()
  186. },
  187. { deep: true }
  188. )
  189. function goBack() {
  190. uni.navigateBack()
  191. }
  192. function onSend() {
  193. const text = inputValue.value.trim()
  194. if (!text) return
  195. sendMessage(otherUserId.value, text)
  196. inputValue.value = ''
  197. showPlusPanel.value = false
  198. }
  199. function onLoadMore() {
  200. fetchMoreMessages(otherUserId.value)
  201. }
  202. function onRetry(msg) {
  203. retrySendMessage(otherUserId.value, msg)
  204. }
  205. async function onRemindUserNotification(msg) {
  206. if (remindingNotificationId.value) return
  207. if (!msg || msg.tempId) return
  208. remindingNotificationId.value = String(msg.id)
  209. try {
  210. await remindUserNotification(otherUserId.value, msg)
  211. } finally {
  212. remindingNotificationId.value = ''
  213. }
  214. }
  215. function getSenderName(msg) {
  216. return msg.isMe ? (currentUserName.value || '我') : contactTitle.value
  217. }
  218. function shouldShowDateLabel(index) {
  219. const list = messageList.value
  220. if (index <= 0) return true
  221. const prev = list[index - 1]
  222. const curr = list[index]
  223. if (!prev || !curr || !prev.createdAt || !curr.createdAt) return true
  224. const prevDay = new Date(prev.createdAt).toDateString()
  225. const currDay = new Date(curr.createdAt).toDateString()
  226. return prevDay !== currDay
  227. }
  228. const currentUserName = ref('')
  229. const currentUserId = ref('')
  230. const currentUserAvatar = ref('')
  231. const contactAvatar = computed(() => {
  232. const contact = (chatStore.contacts || []).find((c) => String(c.user_id || c.id) === String(otherUserId.value))
  233. return (contact && contact.avatar) ? contact.avatar : ''
  234. })
  235. function previewImage(url) {
  236. if (!url) return
  237. uni.previewImage({ urls: [url] })
  238. }
  239. async function openNotificationUrl(msg) {
  240. const token = getToken()
  241. try {
  242. const res = await getMessageCallbackUrl(token, msg.id)
  243. const url = res.callback_url || res.callbackUrl || msg.actionUrl
  244. if (url) {
  245. const pageUrl =
  246. '/pages/webview/index?url=' +
  247. encodeURIComponent(url) +
  248. '&title=' +
  249. encodeURIComponent(msg.title || '详情')
  250. uni.navigateTo({ url: pageUrl })
  251. }
  252. } catch (e) {
  253. if (msg.actionUrl) {
  254. const pageUrl =
  255. '/pages/webview/index?url=' +
  256. encodeURIComponent(msg.actionUrl) +
  257. '&title=' +
  258. encodeURIComponent(msg.title || '详情')
  259. uni.navigateTo({ url: pageUrl })
  260. } else {
  261. uni.showToast({ title: '打开失败', icon: 'none' })
  262. }
  263. }
  264. }
  265. function onTitleClick() {
  266. // 可扩展:进入联系人详情或下拉菜单
  267. }
  268. function onMore() {
  269. uni.showActionSheet({
  270. itemList: ['聊天信息', '查找聊天内容', '清空聊天记录'],
  271. success: (res) => {}
  272. })
  273. }
  274. function onInputFocus() {
  275. showPlusPanel.value = false
  276. }
  277. function onChooseImage() {
  278. uni.hideKeyboard()
  279. uni.chooseImage({
  280. count: 1,
  281. success: (res) => {
  282. const path = res.tempFilePaths[0]
  283. sendFileMessage(otherUserId.value, path, 'image')
  284. }
  285. })
  286. }
  287. function onChooseVideo() {
  288. uni.hideKeyboard()
  289. uni.chooseVideo({
  290. sourceType: ['album', 'camera'],
  291. success: (res) => {
  292. const path = res.tempFilePath || (res.tempFilePaths && res.tempFilePaths[0])
  293. if (path) {
  294. sendFileMessage(otherUserId.value, path, 'video')
  295. }
  296. }
  297. })
  298. }
  299. function onChooseFile() {
  300. uni.hideKeyboard()
  301. // 不同平台支持差异较大,如有需要可按端分别处理
  302. if (uni.chooseMessageFile) {
  303. uni.chooseMessageFile({
  304. count: 1,
  305. type: 'file',
  306. success: (res) => {
  307. const file = res.tempFiles && res.tempFiles[0]
  308. if (file && file.path) {
  309. sendFileMessage(otherUserId.value, file.path, 'file')
  310. }
  311. }
  312. })
  313. } else {
  314. uni.showToast({ title: '当前端暂不支持选文件', icon: 'none' })
  315. }
  316. }
  317. function onPlus() {
  318. uni.hideKeyboard()
  319. showPlusPanel.value = !showPlusPanel.value
  320. }
  321. </script>
  322. <style scoped>
  323. .chat-page {
  324. height: 100vh;
  325. display: flex;
  326. flex-direction: column;
  327. background: #f3f4f6;
  328. overflow-x: hidden;
  329. /* 顶部安全区由 header 自己处理 */
  330. padding-bottom: constant(safe-area-inset-bottom);
  331. padding-bottom: env(safe-area-inset-bottom);
  332. }
  333. .chat-header {
  334. /* 只负责安全区和背景,与会话列表页 custom-header 对齐 */
  335. padding: 0 24rpx 24rpx 32rpx;
  336. padding-top: 88rpx;
  337. padding-top: max(88rpx, calc(24rpx + constant(safe-area-inset-top)));
  338. padding-top: max(88rpx, calc(24rpx + env(safe-area-inset-top)));
  339. background: #ffffff;
  340. box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
  341. }
  342. .chat-header-main {
  343. height: 88rpx;
  344. display: flex;
  345. align-items: center;
  346. justify-content: space-between;
  347. position: relative;
  348. }
  349. .back {
  350. width: 64rpx;
  351. height: 64rpx;
  352. display: flex;
  353. align-items: center;
  354. justify-content: center;
  355. margin-right: 8rpx;
  356. }
  357. .header-icon-img {
  358. width: 44rpx;
  359. height: 44rpx;
  360. opacity: 0.9;
  361. }
  362. .chat-title-wrap {
  363. position: absolute;
  364. left: 50%;
  365. transform: translateX(-50%);
  366. display: flex;
  367. align-items: center;
  368. justify-content: center;
  369. }
  370. .chat-title-texts {
  371. display: flex;
  372. flex-direction: column;
  373. justify-content: center;
  374. }
  375. .chat-title {
  376. font-size: 34rpx;
  377. font-weight: 600;
  378. color: #111827;
  379. }
  380. .header-actions {
  381. display: flex;
  382. align-items: center;
  383. gap: 16rpx;
  384. }
  385. .header-icon {
  386. width: 56rpx;
  387. height: 56rpx;
  388. display: flex;
  389. align-items: center;
  390. justify-content: center;
  391. }
  392. .message-list {
  393. flex: 1;
  394. min-height: 0;
  395. height: 0;
  396. /* 去掉顶部内边距,使第一条消息与列表页首行对齐 */
  397. padding: 0 24rpx 16rpx;
  398. }
  399. .load-more {
  400. text-align: center;
  401. padding: 16rpx;
  402. font-size: 24rpx;
  403. color: #999;
  404. }
  405. .empty-messages {
  406. flex: 1;
  407. display: flex;
  408. align-items: center;
  409. justify-content: center;
  410. min-height: 200rpx;
  411. padding: 48rpx;
  412. }
  413. .empty-text {
  414. font-size: 28rpx;
  415. color: #999;
  416. }
  417. .input-bar {
  418. display: flex;
  419. flex-direction: column;
  420. gap: 16rpx;
  421. padding: 10rpx 16rpx;
  422. background: #f5f5f7;
  423. border-top: 1rpx solid #e5e7eb;
  424. padding-bottom: max(10rpx, constant(safe-area-inset-bottom));
  425. padding-bottom: max(10rpx, env(safe-area-inset-bottom));
  426. }
  427. .input-row {
  428. display: flex;
  429. align-items: center;
  430. }
  431. .input-wrap {
  432. position: relative;
  433. min-height: 60rpx;
  434. max-height: 140rpx;
  435. padding: 6rpx 18rpx;
  436. background: #ffffff;
  437. border-radius: 999rpx;
  438. flex: 1;
  439. display: flex;
  440. align-items: center;
  441. }
  442. .input {
  443. min-height: 40rpx;
  444. font-size: 26rpx;
  445. height: 44rpx;
  446. line-height: 44rpx;
  447. padding: 0;
  448. width: 100%;
  449. box-sizing: border-box;
  450. }
  451. .input-icons {
  452. display: flex;
  453. align-items: center;
  454. justify-content: flex-start;
  455. gap: 24rpx;
  456. }
  457. .input-icon {
  458. width: 64rpx;
  459. height: 64rpx;
  460. display: flex;
  461. align-items: center;
  462. justify-content: center;
  463. }
  464. .input-icon .icon-emoji,
  465. .input-icon .icon-at,
  466. .input-icon .icon-mic,
  467. .input-icon .icon-pic,
  468. .input-icon .icon-aa {
  469. font-size: 40rpx;
  470. color: #6b7280;
  471. }
  472. .input-icon-plus {
  473. margin-left: 12rpx;
  474. border-radius: 999rpx;
  475. border: 2rpx solid #111827;
  476. background: #ffffff;
  477. }
  478. .input-plus-img {
  479. width: 36rpx;
  480. height: 36rpx;
  481. opacity: 0.9;
  482. }
  483. .plus-panel {
  484. margin-top: 12rpx;
  485. padding: 24rpx 16rpx 12rpx;
  486. background: #f5f5f7;
  487. border-radius: 24rpx;
  488. display: flex;
  489. justify-content: space-between;
  490. }
  491. .plus-item {
  492. flex: 1;
  493. display: flex;
  494. flex-direction: column;
  495. align-items: center;
  496. justify-content: flex-start;
  497. }
  498. .plus-icon {
  499. width: 120rpx;
  500. height: 120rpx;
  501. border-radius: 32rpx;
  502. background: #ffffff;
  503. display: flex;
  504. align-items: center;
  505. justify-content: center;
  506. margin-bottom: 8rpx;
  507. }
  508. .plus-inner-icon {
  509. width: 56rpx;
  510. height: 56rpx;
  511. }
  512. .plus-label-title {
  513. font-size: 26rpx;
  514. color: #111111;
  515. }
  516. .plus-label-desc {
  517. margin-top: 2rpx;
  518. font-size: 22rpx;
  519. color: #999999;
  520. }
  521. </style>