412 lines
No EOL
18 KiB
Text
412 lines
No EOL
18 KiB
Text
<!--聊天页面-->
|
||
<view class="chat-container {{themeClass}} {{isThemeTransitioning ? 'theme-transitioning' : ''}} {{showEmojiPanel ? 'with-emoji' : ''}}">
|
||
<!-- 加载状态指示器 -->
|
||
<view wx:if="{{isLoading}}" class="loading-container">
|
||
<view class="loading-spinner"></view>
|
||
<text class="loading-text">正在加载聊天...</text>
|
||
</view>
|
||
|
||
<!-- 消息列表 -->
|
||
<scroll-view
|
||
class="message-list"
|
||
style="height:100%;"
|
||
scroll-y="{{true}}"
|
||
scroll-top="{{scrollTop}}"
|
||
scroll-into-view="{{scrollIntoView}}"
|
||
enhanced="{{true}}"
|
||
show-scrollbar="{{false}}"
|
||
bindscrolltoupper="loadMoreMessages"
|
||
bindscroll="onScroll"
|
||
upper-threshold="50"
|
||
>
|
||
<!-- 🔥 顶部加载更多提示 -->
|
||
<view wx:if="{{hasMore}}" class="load-more-top" bindtap="loadMoreMessages">
|
||
<text class="load-more-text">{{loadingMessages ? '加载中...' : '上拉加载更多消息'}}</text>
|
||
</view>
|
||
|
||
<view wx:for="{{messages}}" wx:key="messageId" class="message-item" id="msg-{{item.messageId}}">
|
||
<!-- 日期分隔线(仅日期) -->
|
||
<view wx:if="{{item.showDateDivider}}" class="time-divider">
|
||
<text class="time-text">{{item.dateText}}</text>
|
||
</view>
|
||
|
||
<!-- 消息内容:根据是否为自己,交换头像与消息体的渲染顺序以确保两侧显示正确 -->
|
||
<view class="message-wrapper {{item.isSelf ? 'self' : 'other'}}">
|
||
<!-- 自己的消息:先渲染消息体,再渲染头像(头像在右侧) -->
|
||
<block wx:if="{{item.isSelf}}">
|
||
<view class="message-body">
|
||
<!-- 自己的消息不显示昵称 -->
|
||
|
||
<!-- 单行(右侧):气泡内显示时间/状态 -->
|
||
<view class="message-line self">
|
||
<view class="message-content {{item.msgType}} {{item.isRecalled ? 'recalled' : ''}}" bindlongpress="showMessageMenu" data-message="{{item}}">
|
||
<!-- 撤回消息显示 -->
|
||
<view wx:if="{{item.isRecalled}}" class="recalled-content">
|
||
<text class="recalled-icon">↩️</text>
|
||
<text class="recalled-text">{{item.content}}</text>
|
||
</view>
|
||
|
||
<!-- 普通消息显示 -->
|
||
<text wx:elif="{{item.msgType === 'text'}}" class="text-content">{{item.content}}</text>
|
||
|
||
<view wx:elif="{{item.msgType === 'image'}}">
|
||
<view wx:if="{{item.content.type === 'error'}}" class="error-content">
|
||
<text class="error-text">❌ 图片加载失败</text>
|
||
<text class="error-detail">{{item.content.error}}</text>
|
||
<text class="error-original">原始内容: {{item.content.originalContent}}</text>
|
||
</view>
|
||
<image wx:else
|
||
class="image-content"
|
||
src="{{item.content.url || item.content}}"
|
||
mode="aspectFit"
|
||
bindtap="previewImage"
|
||
data-url="{{item.content.url || item.content}}"
|
||
data-type="{{item.content.type}}"
|
||
binderror="onImageError">
|
||
</image>
|
||
</view>
|
||
|
||
<voice-message wx:elif="{{item.msgType === 'audio' || item.msgType === 'voice'}}"
|
||
voice-data="{{item.content}}"
|
||
is-self="{{item.isSelf}}"
|
||
message-id="{{item.messageId}}">
|
||
</voice-message>
|
||
|
||
<video wx:elif="{{item.msgType === 'video'}}"
|
||
class="video-content"
|
||
src="{{item.content}}"
|
||
poster="{{item.thumbnail}}"
|
||
controls>
|
||
</video>
|
||
|
||
<view wx:elif="{{item.msgType === 'location'}}" class="location-content" bindtap="showLocation" data-message="{{item}}">
|
||
<text class="location-icon">📍</text>
|
||
<view class="location-info">
|
||
<text class="location-name">{{item.locationName || '位置信息'}}</text>
|
||
<text class="location-address">{{item.locationAddress}}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view wx:elif="{{item.msgType === 'file'}}" class="file-content" bindtap="downloadFile" data-message="{{item}}">
|
||
<text class="file-icon">📄</text>
|
||
<view class="file-info">
|
||
<text class="file-name">{{item.fileName || '文件'}}</text>
|
||
<text class="file-size">{{item.fileSize || '未知大小'}}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<image wx:elif="{{item.msgType === 'sticker'}}"
|
||
class="sticker-content"
|
||
src="{{item.content.url || item.content}}"
|
||
mode="aspectFit">
|
||
</image>
|
||
|
||
<view wx:elif="{{item.msgType === 'card'}}" class="card-content" bindtap="viewCard" data-message="{{item}}">
|
||
<text class="card-icon">👤</text>
|
||
<view class="card-info">
|
||
<text class="card-name">{{item.cardName || '联系人'}}</text>
|
||
<text class="card-phone">{{item.cardPhone || ''}}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view wx:elif="{{item.msgType === 'system'}}" class="system-content">
|
||
<text class="system-text">{{item.content}}</text>
|
||
</view>
|
||
|
||
<text wx:else class="unknown-content">[不支持的消息类型]</text>
|
||
|
||
<!-- 气泡内时间与状态 -->
|
||
<view wx:if="{{item.msgType !== 'system'}}" class="bubble-meta">
|
||
<text class="bubble-time">{{item.bubbleTime}}</text>
|
||
<text wx:if="{{item.deliveryStatus === 'read'}}" class="meta-status meta-read">✓✓</text>
|
||
<text wx:elif="{{item.deliveryStatus === 'delivered' || item.deliveryStatus === 'sent'}}" class="meta-status meta-delivered">✓</text>
|
||
<text wx:elif="{{item.deliveryStatus === 'sending'}}" class="meta-status meta-sending">●</text>
|
||
<text wx:elif="{{item.deliveryStatus === 'failed'}}" class="meta-status meta-failed">!</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 发送失败的“重发”操作(仅自己消息显示,出现在气泡下方靠右) -->
|
||
<view wx:if="{{item.isSelf && item.deliveryStatus === 'failed' && !item.isRecalled && item.msgType !== 'system'}}" class="resend-row">
|
||
<view class="resend-action" bindtap="resendMessage" data-message="{{item}}">
|
||
<text class="resend-icon">↻</text>
|
||
<text class="resend-text">重新发送</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 自己的头像(右侧) -->
|
||
<view class="avatar">
|
||
<view wx:if="{{userAvatar && userAvatar.length > 0}}" class="avatar-image">
|
||
<image src="{{userAvatar}}" mode="aspectFill" bindload="onAvatarLoad" binderror="onAvatarError" />
|
||
</view>
|
||
<view wx:else class="avatar-placeholder">
|
||
<text>👤</text>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
|
||
<!-- 对方的消息:头像在左,消息体在右 -->
|
||
<block wx:else>
|
||
<view class="avatar">
|
||
<view wx:if="{{item.senderAvatar}}" class="avatar-image">
|
||
<image src="{{item.senderAvatar}}" mode="aspectFill" bindload="onAvatarLoad" binderror="onAvatarError" />
|
||
</view>
|
||
<view wx:else class="avatar-placeholder">
|
||
<text>👥</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="message-body">
|
||
<view class="sender-name" wx:if="{{!item.isSelf && chatType === 1}}">
|
||
{{item.senderName}}
|
||
</view>
|
||
|
||
<!-- 单行(左侧):气泡内显示时间(仅时间) -->
|
||
<view class="message-line other">
|
||
<view class="message-content {{item.msgType}} {{item.isRecalled ? 'recalled' : ''}}" bindlongpress="showMessageMenu" data-message="{{item}}">
|
||
<!-- 撤回消息显示 -->
|
||
<view wx:if="{{item.isRecalled}}" class="recalled-content">
|
||
<text class="recalled-icon">↩️</text>
|
||
<text class="recalled-text">{{item.content}}</text>
|
||
</view>
|
||
|
||
<!-- 普通消息显示 -->
|
||
<text wx:elif="{{item.msgType === 'text'}}" class="text-content">{{item.content}}</text>
|
||
|
||
<view wx:elif="{{item.msgType === 'image'}}">
|
||
<view wx:if="{{item.content.type === 'error'}}" class="error-content">
|
||
<text class="error-text">❌ 图片加载失败</text>
|
||
<text class="error-detail">{{item.content.error}}</text>
|
||
<text class="error-original">原始内容: {{item.content.originalContent}}</text>
|
||
</view>
|
||
<image wx:else
|
||
class="image-content"
|
||
src="{{item.content.url || item.content}}"
|
||
mode="aspectFit"
|
||
bindtap="previewImage"
|
||
data-url="{{item.content.url || item.content}}"
|
||
data-type="{{item.content.type}}"
|
||
binderror="onImageError">
|
||
</image>
|
||
</view>
|
||
|
||
<voice-message wx:elif="{{item.msgType === 'audio' || item.msgType === 'voice'}}"
|
||
voice-data="{{item.content}}"
|
||
is-self="{{item.isSelf}}"
|
||
message-id="{{item.messageId}}">
|
||
</voice-message>
|
||
|
||
<video wx:elif="{{item.msgType === 'video'}}"
|
||
class="video-content"
|
||
src="{{item.content}}"
|
||
poster="{{item.thumbnail}}"
|
||
controls>
|
||
</video>
|
||
|
||
<view wx:elif="{{item.msgType === 'location'}}" class="location-content" bindtap="showLocation" data-message="{{item}}">
|
||
<text class="location-icon">📍</text>
|
||
<view class="location-info">
|
||
<text class="location-name">{{item.locationName || '位置信息'}}</text>
|
||
<text class="location-address">{{item.locationAddress}}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view wx:elif="{{item.msgType === 'file'}}" class="file-content" bindtap="downloadFile" data-message="{{item}}">
|
||
<text class="file-icon">📄</text>
|
||
<view class="file-info">
|
||
<text class="file-name">{{item.fileName || '文件'}}</text>
|
||
<text class="file-size">{{item.fileSize || '未知大小'}}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<image wx:elif="{{item.msgType === 'sticker'}}"
|
||
class="sticker-content"
|
||
src="{{item.content.url || item.content}}"
|
||
mode="aspectFit">
|
||
</image>
|
||
|
||
<view wx:elif="{{item.msgType === 'card'}}" class="card-content" bindtap="viewCard" data-message="{{item}}">
|
||
<text class="card-icon">👤</text>
|
||
<view class="card-info">
|
||
<text class="card-name">{{item.cardName || '联系人'}}</text>
|
||
<text class="card-phone">{{item.cardPhone || ''}}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view wx:elif="{{item.msgType === 'system'}}" class="system-content">
|
||
<text class="system-text">{{item.content}}</text>
|
||
</view>
|
||
|
||
<text wx:else class="unknown-content">[不支持的消息类型]</text>
|
||
|
||
<!-- 气泡内时间(对方仅时间,无状态) -->
|
||
<view wx:if="{{item.msgType !== 'system'}}" class="bubble-meta">
|
||
<text class="bubble-time">{{item.bubbleTime}}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
</view>
|
||
</block>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 回到最新消息按钮 -->
|
||
<view class="scroll-to-bottom-btn {{showScrollToBottom ? 'show' : ''}}"
|
||
bindtap="scrollToBottom"
|
||
wx:if="{{showScrollToBottom}}">
|
||
<view class="btn-icon">↓</view>
|
||
</view>
|
||
|
||
<!-- 输入框区域 -->
|
||
<view class="input-area" style="padding-bottom: {{safeAreaBottom}}px;">
|
||
<view class="input-row">
|
||
<!-- 语音按钮 -->
|
||
<view class="tool-btn" bindtap="toggleInputType">
|
||
<text class="{{inputType === 'text' ? 'icon-mic' : 'icon-keyboard'}}">{{inputType === 'text' ? '🎤' : '⌨️'}}</text>
|
||
</view>
|
||
|
||
<!-- 文本输入 -->
|
||
<view wx:if="{{inputType === 'text'}}" class="text-input-wrapper">
|
||
<textarea
|
||
class="text-input"
|
||
placeholder="请输入消息..."
|
||
value="{{inputText}}"
|
||
bindinput="onInputChange"
|
||
bindconfirm="sendTextMessage"
|
||
bindcompositionstart="onCompositionStart"
|
||
bindcompositionend="onCompositionEnd"
|
||
bindlinechange="onLineChange"
|
||
focus="{{inputFocus}}"
|
||
maxlength="500"
|
||
confirm-type="send"
|
||
adjust-position="{{true}}"
|
||
hold-keyboard="{{true}}"
|
||
auto-height="{{true}}"
|
||
show-confirm-bar="{{false}}"
|
||
fixed="{{false}}"
|
||
cursor-spacing="0"
|
||
disable-default-padding="{{true}}"
|
||
/>
|
||
</view>
|
||
|
||
<!-- 语音录制 -->
|
||
<view wx:else class="voice-input-wrapper">
|
||
<button
|
||
class="voice-btn {{recording ? 'recording' : ''}}"
|
||
bindtap="openVoiceRecorder"
|
||
>
|
||
🎤 录制语音
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 表情按钮 -->
|
||
<view class="tool-btn" bindtap="toggleEmojiPanel">
|
||
<text class="icon-emoji">😊</text>
|
||
</view>
|
||
|
||
<!-- 更多按钮 -->
|
||
<view class="tool-btn" bindtap="toggleMorePanel">
|
||
<text class="icon-more">➕</text>
|
||
</view>
|
||
|
||
<!-- 发送按钮 -->
|
||
<view wx:if="{{inputText.length > 0}}" class="send-btn" bindtap="sendTextMessage">
|
||
<text>发送</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 表情面板 -->
|
||
<view wx:if="{{showEmojiPanel}}" class="emoji-panel">
|
||
<scroll-view class="emoji-scroll" scroll-y="{{true}}" show-scrollbar="{{false}}">
|
||
<view class="emoji-list">
|
||
<text wx:for="{{commonEmojis}}" wx:key="*this" class="emoji-item" bindtap="selectEmoji" data-emoji="{{item}}">{{item}}</text>
|
||
</view>
|
||
<!-- 底部安全区占位,避免最后一排被遮挡,保证能继续滚动到最底部 -->
|
||
<view class="emoji-bottom-spacer" style="height: {{safeAreaBottom}}px;"></view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 更多功能面板:移到 input-area 外,避免被其 overflow 裁切 -->
|
||
<view wx:if="{{showMorePanel}}" class="more-panel-overlay" bindtap="toggleMorePanel">
|
||
<view class="more-panel" catchtap="stopPropagation">
|
||
<view class="more-panel-header">
|
||
<text class="more-panel-title">选择功能</text>
|
||
<view class="more-panel-close" bindtap="toggleMorePanel">✕</view>
|
||
</view>
|
||
<view class="more-list">
|
||
<view class="more-item" bindtap="showMediaPicker">
|
||
<text class="more-icon">📷</text>
|
||
<text class="more-text">拍照/录像</text>
|
||
</view>
|
||
<view class="more-item" bindtap="chooseImageFromAlbum">
|
||
<text class="more-icon">🖼️</text>
|
||
<text class="more-text">相册</text>
|
||
</view>
|
||
<view class="more-item" bindtap="selectFile">
|
||
<text class="more-icon">📄</text>
|
||
<text class="more-text">文件</text>
|
||
</view>
|
||
<view class="more-item" bindtap="selectLocation">
|
||
<text class="more-icon">📍</text>
|
||
<text class="more-text">位置</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 录音提示 -->
|
||
<view wx:if="{{recording}}" class="recording-tips">
|
||
<text>正在录音...</text>
|
||
</view>
|
||
|
||
<!-- 语音录制组件 -->
|
||
<voice-recorder
|
||
visible="{{showVoiceRecorder}}"
|
||
max-duration="{{60000}}"
|
||
min-duration="{{1000}}"
|
||
bind:send="onVoiceSend"
|
||
bind:close="closeVoiceRecorder">
|
||
</voice-recorder>
|
||
|
||
<!-- 媒体预览组件 -->
|
||
<media-preview
|
||
visible="{{mediaPreviewVisible}}"
|
||
media-list="{{mediaPreviewList}}"
|
||
current-index="{{currentMediaIndex}}"
|
||
bind:close="closeMediaPreview"
|
||
bind:indexchange="onMediaIndexChange">
|
||
</media-preview>
|
||
|
||
<!-- 消息操作菜单组件 -->
|
||
<message-action-menu
|
||
visible="{{messageActionVisible}}"
|
||
message="{{currentMessage}}"
|
||
is-own-message="{{isOwnMessage}}"
|
||
bind:close="closeMessageAction"
|
||
bind:action="onMessageAction">
|
||
</message-action-menu>
|
||
|
||
<!-- @提醒选择器组件 -->
|
||
<mention-selector
|
||
visible="{{mentionSelectorVisible}}"
|
||
group-id="{{targetId}}"
|
||
current-user-id="{{userInfo.user.customId}}"
|
||
bind:close="closeMentionSelector"
|
||
bind:mention="onMentionSelect">
|
||
</mention-selector>
|
||
|
||
<!-- 主题切换悬浮按钮 -->
|
||
<view class="theme-toggle" bindtap="toggleTheme" style="position:fixed; left:24rpx; top: {{statusBarHeight+20}}rpx; width:64rpx; height:64rpx; border-radius:32rpx; background:rgba(255,255,255,0.04); display:flex; align-items:center; justify-content:center; z-index:800; color:var(--text-primary);">
|
||
<text>{{themeClass === 'theme-dark' ? '🌙' : '☀️'}}</text>
|
||
</view>
|
||
|
||
<!-- 主题切换径向遮罩动画 -->
|
||
<view wx:if="{{showThemeOverlay}}" class="theme-overlay">
|
||
<view class="theme-overlay-circle {{overlayPlaying ? 'play' : ''}}" style="width: {{overlayDiameter}}px; height: {{overlayDiameter}}px; left: {{overlayLeft}}px; top: {{overlayTop}}px;"></view>
|
||
</view>
|
||
|
||
</view> |