| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628 |
- <template>
- <view class="chat-page">
- <view class="chat-header">
- <view class="chat-header-main">
- <view class="back" @click="goBack">
- <image class="header-icon-img" src="/static/icons/back.svg" mode="aspectFit" />
- </view>
- <view class="chat-title-wrap" @click="onTitleClick">
- <view class="chat-title-texts">
- <text class="chat-title">{{ contactTitle }}</text>
- </view>
- </view>
- <view class="header-actions">
- <view class="header-icon" @click="onMore">
- <image class="header-icon-img" src="/static/icons/more.svg" mode="aspectFit" />
- </view>
- </view>
- </view>
- </view>
- <scroll-view
- class="message-list"
- scroll-y
- :scroll-into-view="scrollIntoView"
- scroll-with-animation
- @scrolltoupper="onLoadMore"
- >
- <view v-if="loadingMoreForContact" class="load-more">加载更多...</view>
- <view v-else-if="!messageList.length" class="empty-messages">
- <text class="empty-text">暂无消息,发一句打个招呼吧</text>
- </view>
- <view
- v-for="(msg, index) in messageList"
- :key="msg.id || msg.tempId || index"
- :id="'msg-' + (msg.id || msg.tempId)"
- >
- <PrivateMessageBubble
- v-if="msg.type === 'MESSAGE' || msg.type === 'PRIVATE' || !msg.type"
- :msg="msg"
- :sender-name="getSenderName(msg)"
- :sender-id="otherUserId"
- :sender-avatar="contactAvatar"
- :me-name="currentUserName"
- :me-id="currentUserId"
- :me-avatar="currentUserAvatar"
- :show-date-label="shouldShowDateLabel(index)"
- :remind-loading="remindingNotificationId === String(msg.id)"
- @preview-image="previewImage"
- @open-notification-url="openNotificationUrl"
- @retry="onRetry"
- @remind-user-notification="onRemindUserNotification"
- />
- <NotificationBubble
- v-else
- :msg="msg"
- :sender-name="getSenderName(msg)"
- :show-date-label="shouldShowDateLabel(index)"
- @open-notification-url="openNotificationUrl"
- />
- </view>
- </scroll-view>
- <view class="input-bar">
- <view class="input-row">
- <view class="input-wrap">
- <input
- v-model="inputValue"
- class="input"
- :placeholder="'发送给 ' + contactTitle"
- confirm-type="send"
- @confirm="onSend"
- @focus="onInputFocus"
- />
- </view>
- <view class="input-icon input-icon-plus" @click="onPlus">
- <image class="input-plus-img" src="/static/icons/add.svg" mode="aspectFit" />
- </view>
- </view>
- <view v-if="showPlusPanel" class="plus-panel">
- <view class="plus-item" @click="onChooseImage">
- <view class="plus-icon">
- <image class="plus-inner-icon" src="/static/icons/image.svg" mode="aspectFit" />
- </view>
- <text class="plus-label-title">图片</text>
- </view>
- <view class="plus-item" @click="onChooseVideo">
- <view class="plus-icon">
- <image class="plus-inner-icon" src="/static/icons/video.svg" mode="aspectFit" />
- </view>
- <text class="plus-label-title">视频</text>
- </view>
- <view class="plus-item" @click="onChooseFile">
- <view class="plus-icon">
- <image class="plus-inner-icon" src="/static/icons/file.svg" mode="aspectFit" />
- </view>
- <text class="plus-label-title">文件</text>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup>
- import { ref, computed, watch, nextTick } from 'vue'
- import { onLoad, onUnload, onShow } from '@dcloudio/uni-app'
- import PrivateMessageBubble from '../../components/chat/PrivateMessageBubble.vue'
- import NotificationBubble from '../../components/chat/NotificationBubble.vue'
- import { useMessages } from '../../composables/useMessages'
- import { useContacts } from '../../composables/useContacts'
- import { chatStore } from '../../store/chat'
- import { getMessageCallbackUrl, getToken } from '../../utils/api'
- const otherUserId = ref('')
- const contactTitle = ref('会话')
- const fallbackContactName = ref('')
- const inputValue = ref('')
- const scrollIntoView = ref('')
- /** 从消息搜索进入时滚动到指定消息 id,定位后清空 */
- const scrollToMessageId = ref('')
- /** 用于区分「底部最后一条是否变化」:加载更多只 prepend 时不变,避免误滚到底 */
- const lastBottomMsgKey = ref('')
- const showPlusPanel = ref(false)
- /** 正在对哪条 USER_NOTIFICATION 执行「再次提醒」(用于按钮 loading,并防并发连点) */
- const remindingNotificationId = ref('')
- const { messages, loadingMore, fetchMessages, fetchMoreMessages, sendMessage, retrySendMessage, sendFileMessage, retrySendFileMessage, remindUserNotification } = useMessages()
- const { fetchContacts } = useContacts()
- function syncContactTitle() {
- const contact = (chatStore.contacts || []).find((c) => String(c.user_id || c.id) === String(otherUserId.value))
- if (contact) {
- contactTitle.value = (contact.app_name || contact.title || '会话')
- return
- }
- // 若会话列表未命中(例如从联系人详情进入,此时 chatStore.contacts 未包含该用户)
- contactTitle.value = fallbackContactName.value || '会话'
- }
- const messageList = computed(() => {
- const id = String(otherUserId.value)
- return (messages[id] || [])
- })
- const loadingMoreForContact = computed(() => !!loadingMore[String(otherUserId.value)])
- /** 滚到列表底部;先清空 scroll-into-view 再设锚点,避免同 id 时不滚动 */
- function scrollToBottom() {
- if (scrollToMessageId.value) return
- const list = messageList.value
- if (!list.length) return
- const last = list[list.length - 1]
- const anchor = 'msg-' + (last.id || last.tempId)
- scrollIntoView.value = ''
- nextTick(() => {
- scrollIntoView.value = anchor
- setTimeout(() => {
- scrollIntoView.value = anchor
- }, 50)
- })
- }
- onLoad((options) => {
- lastBottomMsgKey.value = ''
- otherUserId.value = String(
- (options && (options.otherUserId || options.userId || options.contactId)) || '0'
- )
- // 用联系人详情传参兜底:保证从联系人详情进来仍能显示名字
- try {
- const raw = options && options.contactName != null ? String(options.contactName) : ''
- const s = raw
- // 若未自动解码,可能是 %E5...,这里做一次安全解码
- if (/%[0-9A-Fa-f]{2}/.test(s)) fallbackContactName.value = decodeURIComponent(s)
- else fallbackContactName.value = s
- } catch (e) {
- fallbackContactName.value = ''
- }
- try {
- const mid = options && (options.scrollToMessageId || options.messageId)
- if (mid != null && String(mid).trim() !== '') scrollToMessageId.value = String(mid).trim()
- } catch (e) {
- scrollToMessageId.value = ''
- }
- chatStore.setActiveContact(otherUserId.value)
- chatStore.clearUnread(otherUserId.value)
- chatStore.updateTabBarUnreadBadge()
- syncContactTitle()
- try {
- const u = uni.getStorageSync('current_user')
- if (u && typeof u === 'object') {
- if (u.name) currentUserName.value = u.name
- currentUserId.value = String(u.id ?? u.user_id ?? '')
- currentUserAvatar.value = u.avatar || u.avatar_url || ''
- }
- } catch (e) {}
- // 进入聊天时,确保会话列表已就绪:联系人详情页跳转时可能还没加载 chatStore.contacts
- if (!chatStore.contacts || !chatStore.contacts.length) {
- Promise.resolve()
- .then(() => fetchContacts())
- .then(() => syncContactTitle())
- .catch(() => {})
- } else {
- // contacts 已有数据时也做一次兜底(例如 otherUserId 刚好没命中但后续会更新)
- syncContactTitle()
- }
- })
- /** 每次显示页面都拉最新一页(含从子页返回),并在数据就绪后滚到底 */
- onShow(async () => {
- const id = otherUserId.value
- if (!id || id === '0') return
- await fetchMessages(id)
- scrollToBottom()
- })
- onUnload(() => {
- chatStore.setActiveContact('')
- })
- watch(messageList, (list) => {
- if (!list.length) return
- const target = scrollToMessageId.value
- if (target) {
- const hit = list.find((m) => String(m.id) === String(target) || String(m.tempId) === String(target))
- if (hit) {
- const anchor = 'msg-' + (hit.id || hit.tempId)
- scrollIntoView.value = ''
- nextTick(() => {
- scrollIntoView.value = anchor
- })
- scrollToMessageId.value = ''
- return
- }
- return
- }
- const last = list[list.length - 1]
- const key = String(last.id || last.tempId)
- if (lastBottomMsgKey.value === key) return
- lastBottomMsgKey.value = key
- scrollToBottom()
- }, { deep: true })
- // contacts 更新后同步聊天标题(避免从联系人详情进入仍显示“会话”)
- watch(
- () => chatStore.contacts,
- () => {
- if (otherUserId.value) syncContactTitle()
- },
- { deep: true }
- )
- function goBack() {
- uni.navigateBack()
- }
- function onSend() {
- const text = inputValue.value.trim()
- if (!text) return
- sendMessage(otherUserId.value, text)
- inputValue.value = ''
- showPlusPanel.value = false
- }
- function onLoadMore() {
- fetchMoreMessages(otherUserId.value)
- }
- function basenameFromPath(p) {
- if (!p) return ''
- const s = String(p).replace(/\\/g, '/')
- const i = s.lastIndexOf('/')
- return i >= 0 ? s.slice(i + 1) : s
- }
- function onRetry(msg) {
- if (!msg || msg.status !== 'failed') return
- if (msg.contentType === 'TEXT') {
- retrySendMessage(otherUserId.value, msg)
- return
- }
- retrySendFileMessage(otherUserId.value, msg)
- }
- async function onRemindUserNotification(msg) {
- if (remindingNotificationId.value) return
- if (!msg || msg.tempId) return
- remindingNotificationId.value = String(msg.id)
- try {
- await remindUserNotification(otherUserId.value, msg)
- } finally {
- remindingNotificationId.value = ''
- }
- }
- function getSenderName(msg) {
- return msg.isMe ? (currentUserName.value || '我') : contactTitle.value
- }
- function shouldShowDateLabel(index) {
- const list = messageList.value
- if (index <= 0) return true
- const prev = list[index - 1]
- const curr = list[index]
- if (!prev || !curr || !prev.createdAt || !curr.createdAt) return true
- const prevDay = new Date(prev.createdAt).toDateString()
- const currDay = new Date(curr.createdAt).toDateString()
- return prevDay !== currDay
- }
- const currentUserName = ref('')
- const currentUserId = ref('')
- const currentUserAvatar = ref('')
- const contactAvatar = computed(() => {
- const contact = (chatStore.contacts || []).find((c) => String(c.user_id || c.id) === String(otherUserId.value))
- return (contact && contact.avatar) ? contact.avatar : ''
- })
- function previewImage(url) {
- if (!url) return
- uni.previewImage({ urls: [url] })
- }
- async function openNotificationUrl(msg) {
- const token = getToken()
- try {
- const res = await getMessageCallbackUrl(token, msg.id)
- const url = res.callback_url || res.callbackUrl || msg.actionUrl
- if (url) {
- const pageUrl =
- '/pages/webview/index?url=' +
- encodeURIComponent(url) +
- '&title=' +
- encodeURIComponent(msg.title || '详情')
- uni.navigateTo({ url: pageUrl })
- }
- } catch (e) {
- if (msg.actionUrl) {
- const pageUrl =
- '/pages/webview/index?url=' +
- encodeURIComponent(msg.actionUrl) +
- '&title=' +
- encodeURIComponent(msg.title || '详情')
- uni.navigateTo({ url: pageUrl })
- } else {
- uni.showToast({ title: '打开失败', icon: 'none' })
- }
- }
- }
- function onTitleClick() {
- // 可扩展:进入联系人详情或下拉菜单
- }
- function onMore() {
- uni.showActionSheet({
- itemList: ['聊天信息', '查找聊天内容', '清空聊天记录'],
- success: (res) => {}
- })
- }
- function onInputFocus() {
- showPlusPanel.value = false
- }
- function onChooseImage() {
- uni.hideKeyboard()
- uni.chooseImage({
- count: 1,
- success: (res) => {
- const path = res.tempFilePaths[0]
- const name = basenameFromPath(path) || 'image.jpg'
- sendFileMessage(otherUserId.value, path, 'IMAGE', name)
- }
- })
- }
- function onChooseVideo() {
- uni.hideKeyboard()
- uni.chooseVideo({
- sourceType: ['album', 'camera'],
- success: (res) => {
- const path = res.tempFilePath || (res.tempFilePaths && res.tempFilePaths[0])
- if (path) {
- const name = basenameFromPath(path) || 'video.mp4'
- sendFileMessage(otherUserId.value, path, 'VIDEO', name)
- }
- }
- })
- }
- function onChooseFile() {
- uni.hideKeyboard()
- const pick = (path, name) => {
- const n = (name && String(name).trim()) || basenameFromPath(path) || '文件'
- sendFileMessage(otherUserId.value, path, 'FILE', n)
- }
- if (typeof uni.chooseMessageFile === 'function') {
- uni.chooseMessageFile({
- count: 1,
- type: 'file',
- success: (res) => {
- const file = res.tempFiles && res.tempFiles[0]
- if (file && file.path) pick(file.path, file.name)
- }
- })
- } else if (typeof uni.chooseFile === 'function') {
- uni.chooseFile({
- count: 1,
- success: (res) => {
- const path = (res.tempFilePaths && res.tempFilePaths[0]) || ''
- if (path) pick(path)
- }
- })
- } else {
- uni.showToast({ title: '当前端暂不支持选文件', icon: 'none' })
- }
- }
- function onPlus() {
- uni.hideKeyboard()
- showPlusPanel.value = !showPlusPanel.value
- }
- </script>
- <style scoped>
- .chat-page {
- height: 100vh;
- display: flex;
- flex-direction: column;
- background: #f3f4f6;
- overflow-x: hidden;
- /* 顶部安全区由 header 自己处理 */
- padding-bottom: constant(safe-area-inset-bottom);
- padding-bottom: env(safe-area-inset-bottom);
- }
- .chat-header {
- /* 只负责安全区和背景,与会话列表页 custom-header 对齐 */
- padding: 0 24rpx 24rpx 32rpx;
- padding-top: 88rpx;
- padding-top: max(88rpx, calc(24rpx + constant(safe-area-inset-top)));
- padding-top: max(88rpx, calc(24rpx + env(safe-area-inset-top)));
- background: #ffffff;
- box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
- }
- .chat-header-main {
- height: 88rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- position: relative;
- }
- .back {
- width: 64rpx;
- height: 64rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 8rpx;
- }
- .header-icon-img {
- width: 44rpx;
- height: 44rpx;
- opacity: 0.9;
- }
- .chat-title-wrap {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .chat-title-texts {
- display: flex;
- flex-direction: column;
- justify-content: center;
- }
- .chat-title {
- font-size: 34rpx;
- font-weight: 600;
- color: #111827;
- }
- .header-actions {
- display: flex;
- align-items: center;
- gap: 16rpx;
- }
- .header-icon {
- width: 56rpx;
- height: 56rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .message-list {
- flex: 1;
- min-height: 0;
- height: 0;
- /* 去掉顶部内边距,使第一条消息与列表页首行对齐 */
- padding: 0 24rpx 16rpx;
- }
- .load-more {
- text-align: center;
- padding: 16rpx;
- font-size: 24rpx;
- color: #999;
- }
- .empty-messages {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 200rpx;
- padding: 48rpx;
- }
- .empty-text {
- font-size: 28rpx;
- color: #999;
- }
- .input-bar {
- display: flex;
- flex-direction: column;
- gap: 16rpx;
- padding: 10rpx 16rpx;
- background: #f5f5f7;
- border-top: 1rpx solid #e5e7eb;
- padding-bottom: max(10rpx, constant(safe-area-inset-bottom));
- padding-bottom: max(10rpx, env(safe-area-inset-bottom));
- }
- .input-row {
- display: flex;
- align-items: center;
- }
- .input-wrap {
- position: relative;
- min-height: 60rpx;
- max-height: 140rpx;
- padding: 6rpx 18rpx;
- background: #ffffff;
- border-radius: 999rpx;
- flex: 1;
- display: flex;
- align-items: center;
- }
- .input {
- min-height: 40rpx;
- font-size: 26rpx;
- height: 44rpx;
- line-height: 44rpx;
- padding: 0;
- width: 100%;
- box-sizing: border-box;
- }
- .input-icons {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 24rpx;
- }
- .input-icon {
- width: 64rpx;
- height: 64rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .input-icon .icon-emoji,
- .input-icon .icon-at,
- .input-icon .icon-mic,
- .input-icon .icon-pic,
- .input-icon .icon-aa {
- font-size: 40rpx;
- color: #6b7280;
- }
- .input-icon-plus {
- margin-left: 12rpx;
- border-radius: 999rpx;
- border: 2rpx solid #111827;
- background: #ffffff;
- }
- .input-plus-img {
- width: 36rpx;
- height: 36rpx;
- opacity: 0.9;
- }
- .plus-panel {
- margin-top: 12rpx;
- padding: 24rpx 16rpx 12rpx;
- background: #f5f5f7;
- border-radius: 24rpx;
- display: flex;
- justify-content: space-between;
- }
- .plus-item {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-start;
- }
- .plus-icon {
- width: 120rpx;
- height: 120rpx;
- border-radius: 32rpx;
- background: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 8rpx;
- }
- .plus-inner-icon {
- width: 56rpx;
- height: 56rpx;
- }
- .plus-label-title {
- font-size: 26rpx;
- color: #111111;
- }
- .plus-label-desc {
- margin-top: 2rpx;
- font-size: 22rpx;
- color: #999999;
- }
- </style>
|