findme-miniprogram-frontend/pages/message/chat/chat.js

3223 lines
103 KiB
JavaScript
Raw Normal View History

2025-12-27 17:16:03 +08:00
// 聊天页面逻辑
const app = getApp();
const mediaPicker = require('../../../utils/media-picker.js');
const { initPageSystemInfo } = require('../../../utils/system-info-modern.js');
const imageCacheManager = require('../../../utils/image-cache-manager.js');
const nimConversationManager = require('../../../utils/nim-conversation-manager.js');
const nimUserManager = require('../../../utils/nim-user-manager.js');
const friendAPI = require('../../../utils/friend-api.js');
Page({
data: {
// 聊天基本信息
conversationId: '',
targetId: '',
chatName: '',
chatType: 0, // 0: 单聊, 1: 群聊
isOnline: false,
isNewConversation: false, // 是否为新会话
// 消息数据
messages: [],
hasMore: true,
lastMessageTime: 0,
unreadCount: 0,
loadingMessages: false, // 🔥 添加加载状态
// 输入相关
inputType: 'text', // 'text' 或 'voice'
inputText: '',
inputFocus: false,
recording: false,
voiceCancel: false, // 🔥 语音录制取消标识
isComposing: false, // 中文输入法状态
// 面板状态
showEmojiPanel: false,
showMorePanel: false,
showVoiceRecorder: false,
// 滚动相关
scrollTop: 0,
scrollIntoView: '',
showScrollToBottom: false, // 🔥 控制滚动到底部按钮显示
// 常用表情
commonEmojis: [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙',
'😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔',
'🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
'😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮',
'🤧', '🥵', '🥶', '🥴', '😵', '🤯', '🤠', '🥳', '🥸', '😎',
'🤓', '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', '😳',
'🥺', '😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖',
'😣', '😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬',
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞',
'🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍',
'👎', '👊', '✊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝'
],
// 用户信息
userInfo: null,
userAvatar: '',
targetUserInfo: null, // 对方用户信息
targetAvatar: '', // 对方头像
// 系统适配信息
systemInfo: {},
statusBarHeight: 0,
menuButtonHeight: 0,
menuButtonTop: 0,
navBarHeight: 0,
windowHeight: 0,
safeAreaBottom: 0,
keyboardHeight: 0, // 键盘高度
inputAreaHeight: 0, // 输入框区域高度
emojiPanelHeight: 0, // 表情面板高度
// 加载状态
isLoading: false,
},
// 🔍 判断内容是否像图片URL/Base64/JSON中包含图片URL
_looksLikeImageContent(content) {
try {
if (!content) return false;
const isImgUrl = (u) => {
if (typeof u !== 'string') return false;
if (/^data:image\//i.test(u)) return true;
if (!/^https?:\/\//i.test(u)) return false;
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(u);
};
if (typeof content === 'string') {
const s = content.trim();
if (isImgUrl(s)) return true;
if (s.startsWith('{') && s.endsWith('}')) {
try {
const obj = JSON.parse(s);
const u = obj?.file_url || obj?.url;
return isImgUrl(u || '');
} catch (_) { return false; }
}
return false;
}
if (typeof content === 'object') {
const u = content.file_url || content.url;
return isImgUrl(u || '');
}
return false;
} catch (_) {
return false;
}
},
// 🔥 将 NIM SDK V2 的数字类型枚举转换为 WXML 中使用的字符串类型
// V2NIMMessageType: 0=文本, 1=图片, 2=语音, 3=视频, 4=位置, 5=通知, 6=文件, 10=提示, 100=自定义
convertMessageTypeToString(messageType) {
const typeMap = {
0: 'text', // 文本
1: 'image', // 图片
2: 'audio', // 语音WXML中也兼容'voice'
3: 'video', // 视频
4: 'location', // 位置
5: 'notification', // 通知
6: 'file', // 文件
10: 'tip', // 提示
100: 'custom' // 自定义
};
return typeMap[messageType] || 'text';
},
/**
* 点击自己头像 -> 跳转个人资料页
*/
onSelfAvatarTap(e) {
try {
const userId = this.data.userInfo?.user?.customId;
if (!userId) return;
wx.navigateTo({
url: `/subpackages/profile/profile/profile?userId=${userId}`
});
} catch (err) {
console.error('进入个人资料失败', err);
}
},
/**
* 点击对方头像 -> 跳转好友详情页单聊或成员资料群聊可后续拓展
*/
async onPeerAvatarTap(e) {
try {
const senderId = e.currentTarget.dataset.senderId;
if (!senderId) {
console.warn('⚠️ senderId 为空,无法跳转');
return;
}
// // 如果是自己就直接走自己的逻辑
// const selfId = this.data.userInfo?.user?.customId;
// if (senderId === selfId) {
// this.onSelfAvatarTap();
// return;
// }
wx.showLoading({ title: '加载中...', mask: true });
// 调用好友详情接口判断关系
let res = await friendAPI.getFriendDetail(senderId)
wx.hideLoading();
if (res?.code == 0 || res?.code == 200) {
console.log('√ 是好友,跳转好友详情页');
wx.navigateTo({
url: `/subpackages/social/friend-detail/friend-detail?customId=${senderId}`
});
} else {
console.log(' 非好友,跳转陌生人预览页');
wx.navigateTo({
url: `/subpackages/social/user-preview/user-preview?customId=${senderId}`
});
}
} catch (err) {
console.error('× 查询好友关系失败:', err);
wx.hideLoading();
const senderId = e.currentTarget.dataset.senderId;
if (senderId) {
wx.navigateTo({
url: `/subpackages/social/user-preview/user-preview?customId=${senderId}`
});
}
}
},
async onLoad(options) {
// 显示加载状态
this.setData({ isLoading: true });
// 初始化系统信息(同步,立即完成)
this.initSystemInfo();
// 检查 NIM 状态
const appInstance = getApp();
this.nim = appInstance?.globalData?.nim || null;
this._nimOldestMessage = null;
if (!this.nim || !this.nim.V2NIMMessageService || !this.nim.V2NIMConversationService) {
console.error('❌ NIM 服务不可用');
wx.showToast({
title: 'NIM服务不可用',
icon: 'none'
});
return;
}
console.log('✅ NIM消息服务可用启用NIM模式加载消息');
// 并行初始化用户与聊天信息
const userInfoPromise = this.initUserInfo();
const chatInfoPromise = this.initChatInfo(options);
await userInfoPromise;
await chatInfoPromise;
// 隐藏加载状态
this.setData({ isLoading: false });
// 注册 NIM 监听器
this.attachNIMListeners();
},
onShow: function () {
console.log('聊天页面显示');
// 🔥 检查并重连 NIM防止从后台恢复后 NIM 断开)
this.checkAndReconnectNim();
// 进入聊天页面时自动标记所有消息已读
this.markAllMessagesRead();
},
onReady() {
// 页面已就绪,获取输入框区域的实际高度
this.updateInputAreaHeight();
},
// 更新输入框区域高度
updateInputAreaHeight() {
wx.createSelectorQuery()
.select('.input-area')
.boundingClientRect(rect => {
if (rect && rect.height) {
console.log('📏 输入框区域高度:', rect.height);
this.setData({
inputAreaHeight: rect.height
});
}
})
.exec();
},
onHide: function () {
// 页面隐藏时可以保持会话状态
},
onUnload: function () {
// 🔥 清理录音相关定时器
if (this.voiceStartTimer) {
clearTimeout(this.voiceStartTimer);
this.voiceStartTimer = null;
}
// 页面卸载时清理 NIM 监听器
this.detachNIMListeners();
},
// 注册 NIM 事件监听器
attachNIMListeners() {
console.log('📡 注册 NIM 事件监听器');
const nim = getApp().globalData.nim;
if (!nim || !nim.V2NIMMessageService) {
console.warn('⚠️ NIM SDK 不可用,无法注册监听器');
return;
}
// 🔥 使用 bind(this) 确保回调中的 this 上下文正确
// 监听新消息
this._nimMessageListener = this.handleNewNIMMessage.bind(this);
// 🔥 监听消息撤回通知
this._nimMessageRevokeListener = this.handleMessageRevokeNotifications.bind(this);
// 注册监听器根据NIM SDK文档调整
try {
nim.V2NIMMessageService.on('onReceiveMessages', this._nimMessageListener);
nim.V2NIMMessageService.on('onMessageRevokeNotifications', this._nimMessageRevokeListener);
console.log('✅ NIM 监听器注册成功');
} catch (error) {
console.error('❌ 注册 NIM 监听器失败:', error);
}
},
// 清理 NIM 事件监听器
detachNIMListeners() {
console.log('🔌 清理 NIM 事件监听器');
const nim = getApp().globalData.nim;
if (!nim || !nim.V2NIMMessageService) {
return;
}
try {
if (this._nimMessageListener) {
nim.V2NIMMessageService.off('onReceiveMessages', this._nimMessageListener);
this._nimMessageListener = null;
}
if (this._nimMessageRevokeListener) {
nim.V2NIMMessageService.off('onMessageRevokeNotifications', this._nimMessageRevokeListener);
this._nimMessageRevokeListener = null;
}
console.log('✅ NIM 监听器清理成功');
} catch (error) {
console.error('❌ 清理 NIM 监听器失败:', error);
}
},
// 🔥 检查并重连 NIM页面级别
async checkAndReconnectNim() {
try {
const app = getApp();
// 触发全局 NIM 重连检查
if (app && typeof app.checkAndReconnectNim === 'function') {
await app.checkAndReconnectNim();
}
// 检查 NIM 实例是否可用
const nim = app.globalData.nim;
if (!nim || !nim.V2NIMMessageService) {
console.warn('⚠️ NIM 实例不可用');
return;
}
// 重新注册事件监听器(确保监听器有效)
if (!this._nimMessageListener || !this._nimMessageRevokeListener) {
console.log('🔄 重新注册 NIM 事件监听器');
this.detachNIMListeners(); // 先清理旧的
this.attachNIMListeners(); // 重新注册
}
// 检查会话管理器状态
if (!nimConversationManager.getInitStatus()) {
console.log('🔄 重新初始化会话管理器');
nimConversationManager.init(nim);
}
console.log('✅ 聊天页面 NIM 状态检查完成');
} catch (error) {
console.error('❌ 聊天页面 NIM 重连检查失败:', error);
}
},
// 处理 NIM 新消息
async handleNewNIMMessage(messages) {
try {
console.log('📨 处理 NIM 新消息,数量:', messages?.length || 0);
// 🔥 NIM SDK V2 的 onReceiveMessages 回调参数是消息数组
if (!Array.isArray(messages) || messages.length === 0) {
console.warn('⚠️ 收到空消息数组或非数组数据');
return;
}
// 过滤出属于当前会话的消息
const currentConversationMessages = messages.filter(
msg => msg.conversationId === this.data.conversationId
);
if (currentConversationMessages.length === 0) {
console.log('⚠️ 没有属于当前会话的消息,跳过');
return;
}
console.log('✅ 收到当前会话的新消息:', currentConversationMessages.length, '条');
// 格式化所有新消息
const formattedMessages = await Promise.all(
currentConversationMessages.map(msg => this.formatMessage(msg))
);
// 去重处理 - 防止重复显示已存在的消息
const existingIds = new Set(this.data.messages.map(msg => msg.messageId));
const uniqueNewMessages = formattedMessages.filter(
msg => !existingIds.has(msg.messageId)
);
if (uniqueNewMessages.length === 0) {
console.log('⚠️ 所有新消息均已存在,跳过重复添加');
return;
}
console.log('✅ 去重后新消息数量:', uniqueNewMessages.length);
// 添加到消息列表
const updatedMessages = this.data.messages.concat(uniqueNewMessages);
this.setMessagesWithDate(updatedMessages);
// 滚动到底部
setTimeout(() => this.scrollToBottom(), 100);
// 标记已读
await nimConversationManager.markConversationRead(this.data.conversationId);
console.log('✅ 新消息处理完成');
} catch (error) {
console.error('❌ 处理新消息失败:', error);
}
},
// 处理消息撤回通知
handleMessageRevokeNotifications(revokeNotifications) {
try {
if (!Array.isArray(revokeNotifications) || revokeNotifications.length === 0) {
return;
}
// 过滤出属于当前会话的撤回通知
const currentConversationRevokes = revokeNotifications.filter(
revoke => revoke.messageRefer?.conversationId === this.data.conversationId
);
if (currentConversationRevokes.length === 0) {
return;
}
// 更新本地消息状态
currentConversationRevokes.forEach(revoke => {
const messageRefer = revoke.messageRefer;
if (!messageRefer) return;
const messageServerId = messageRefer.messageServerId;
const messageClientId = messageRefer.messageClientId;
// 尝试找到匹配的消息
let foundMessage = null;
// 方式1: 通过 messageServerId 查找
if (messageServerId) {
foundMessage = this.data.messages.find(m =>
m.messageId === messageServerId ||
m._rawMessage?.messageServerId === messageServerId
);
}
// 方式2: 通过 messageClientId 查找
if (!foundMessage && messageClientId) {
foundMessage = this.data.messages.find(m =>
m.messageId === messageClientId ||
m._rawMessage?.messageClientId === messageClientId
);
}
if (foundMessage) {
this.updateMessageToRecalled(foundMessage.messageId);
}
});
} catch (error) {
console.error('处理撤回通知失败:', error);
}
},
// 初始化系统信息
initSystemInfo() {
const pageSystemInfo = initPageSystemInfo();
this.setData({
systemInfo: pageSystemInfo.systemInfo,
statusBarHeight: pageSystemInfo.statusBarHeight,
menuButtonHeight: pageSystemInfo.menuButtonHeight,
menuButtonTop: pageSystemInfo.menuButtonTop,
navBarHeight: pageSystemInfo.navBarHeight,
windowHeight: pageSystemInfo.windowHeight,
safeAreaBottom: pageSystemInfo.safeAreaBottom,
keyboardHeight: 0 // 初始化键盘高度
});
},
// 🔥 监听键盘高度变化
onKeyboardHeightChange(e) {
const { height, duration } = e.detail;
const inputAreaHeight = this.data.inputAreaHeight;
const totalBottom = inputAreaHeight + (height || 0);
console.log('⌨️ 键盘高度变化:', {
keyboardHeight: height,
inputAreaHeight,
totalBottom,
duration
});
// 直接使用微信提供的键盘高度单位px
this.setData({
keyboardHeight: height || 0
}, () => {
// 键盘弹出时,滚动到底部,确保最新消息可见
if (height > 0) {
this.scrollToBottom(true);
}
});
},
// 初始化聊天信息
async initChatInfo(options) {
const inputConversationId = options.conversationId || '';
const targetId = options.targetId || '';
const chatName = decodeURIComponent(options.name || '聊天');
const chatType = parseInt(options.chatType || 0);
const incomingTargetAvatar = options.targetAvatar || '';
const sendMessage = options.sendMessage?.trim() || '';
// 临时设置基本信息
this.setData({
targetId,
chatName,
chatType,
inputText: sendMessage,
conversationId: inputConversationId,
targetAvatar: incomingTargetAvatar || this.data.targetAvatar || ''
});
// 🔥 动态设置导航栏标题为用户昵称
wx.setNavigationBarTitle({
title: chatName
});
// 🔥 从 NIM 获取对方用户信息(头像和昵称)
// 注意:如果 targetId 是会话ID格式如 "p2p-xxx" 或 "16950956|1|33981539"需要提取真实用户ID
if (targetId) {
let realUserId = targetId;
// 🔥 如果是 NIM 会话ID格式包含 | 分隔符提取真实用户ID
// 格式senderId|conversationType|receiverId如 "16950956|1|33981539"
if (targetId.includes('|')) {
const parts = targetId.split('|');
if (parts.length >= 3) {
// 对于单聊type=1receiverId 是对方用户IDparts[2]
realUserId = parts[2];
console.log('🔧 从会话ID提取用户ID:', { conversationId: targetId, userId: realUserId });
}
}
await this.loadTargetUserInfo(realUserId);
}
// 🔥 如果没有传入conversationId先检查会话是否已存在
if (!inputConversationId) {
console.log('🔍 未传入会话ID检查是否已存在该会话');
if (!targetId) {
console.error('❌ 缺少 targetId无法查询会话');
return;
}
// 使用NIM生成会话ID
const conversationId = this.buildNIMConversationId(targetId, chatType);
if (!conversationId) {
console.error('❌ 无法生成会话ID');
return;
}
console.log('🔍 生成的会话ID:', conversationId);
// 🔥 检查会话是否已存在从NIM SDK获取会话列表查询
try {
const nim = getApp().globalData.nim;
if (nim && nim.V2NIMConversationService) {
// 🔥 获取会话列表并查找目标会话
console.log('🔍 从NIM SDK查询会话是否存在...');
const result = await nim.V2NIMConversationService.getConversationList(0, 100);
const existingConv = result?.conversationList?.find(c => c.conversationId === conversationId);
if (existingConv) {
console.log('✅ 会话已存在,使用现有会话:', existingConv);
this.setData({
conversationId,
isNewConversation: false
});
// 加载消息
await this.loadMessages();
return;
} else {
console.log('📝 会话不存在,标记为新会话');
}
}
} catch (error) {
console.warn('⚠️ 查询会话失败,将作为新会话处理:', error);
}
// 🔥 会话不存在,标记为新会话(不加载历史消息)
console.log('✅ 创建新会话ID:', conversationId);
this.setData({
conversationId,
isNewConversation: true,
messages: []
});
return;
}
// 使用传入的conversationId
this.setData({ conversationId: inputConversationId });
// 加载消息
await this.loadMessages();
},
// 🔥 验证并使用传入的会话ID
buildNIMConversationId(targetId, chatType) {
try {
if (!this.nim || !this.nim.V2NIMConversationIdUtil) {
return '';
}
if (!targetId) {
return '';
}
if (Number(chatType) === 1) {
return this.nim.V2NIMConversationIdUtil.teamConversationId(String(targetId));
}
return this.nim.V2NIMConversationIdUtil.p2pConversationId(String(targetId));
} catch (err) {
console.warn('构造 NIM 会话ID 失败:', err);
return '';
}
},
// 🔥 从 NIM SDK 获取对方用户信息(包括头像和昵称)
async loadTargetUserInfo(targetId) {
try {
console.log('📡 从 NIM 获取用户信息:', targetId);
// 🔥 使用 NIM SDK 获取用户信息
const userInfo = await nimUserManager.getUserInfo(targetId);
if (userInfo) {
const targetAvatar = userInfo.avatar || '';
const targetNickname = userInfo.nickname || '';
// 🔥 先设置用户信息,头像缓存异步执行
this.setData({
targetUserInfo: {
avatar: targetAvatar,
nickname: targetNickname
},
targetAvatar: targetAvatar, // 先使用原始URL
chatName: targetNickname || this.data.chatName // 更新聊天名称
});
console.log('✅ 用户信息加载成功(从 NIM:', { targetNickname, targetAvatar });
// 🔥 异步缓存头像不阻塞UI
if (targetAvatar) {
imageCacheManager.cacheAvatar(targetAvatar).then(cachedAvatar => {
this.setData({
targetAvatar: cachedAvatar
});
}).catch(error => {
console.error('❌ 对方头像缓存失败:', error);
});
}
} else {
console.warn('⚠️ 从 NIM 未获取到用户信息');
}
} catch (error) {
console.error('❌ 从 NIM 获取用户信息异常:', error);
}
},
// 初始化用户信息
async initUserInfo() {
// 先尝试从全局获取
let userInfo = app.globalData.userInfo;
// 如果全局没有,尝试从本地存储获取
if (!userInfo || !userInfo.token) {
try {
const storedUserInfo = wx.getStorageSync('userInfo');
if (storedUserInfo && storedUserInfo.token) {
userInfo = storedUserInfo;
// 更新全局数据
app.globalData.userInfo = userInfo;
app.globalData.isLoggedIn = true;
}
} catch (error) {
console.error('从本地存储获取用户信息失败:', error);
}
}
// 确保有用户信息
if (userInfo && userInfo.token) {
// 正确获取用户头像
const userAvatar = userInfo.user?.avatar || userInfo.avatar || '';
// 🔥 先设置用户信息,头像缓存异步执行
this.setData({
userInfo: userInfo,
userAvatar: userAvatar // 先使用原始URL
});
// 🔥 异步缓存头像不阻塞UI
imageCacheManager.cacheAvatar(userAvatar).then(cachedAvatar => {
this.setData({
userAvatar: cachedAvatar
});
}).catch(error => {
console.error('❌ 用户头像缓存失败:', error);
});
} else {
console.error('用户信息初始化失败,跳转到登录页');
wx.reLaunch({
url: '/pages/login/login'
});
}
},
// 加载消息 - 使用 NIM SDK
async loadMessages(isLoadMore = false) {
try {
console.log('📨 加载聊天消息 (NIM SDK):', {
isLoadMore,
conversationId: this.data.conversationId
});
// 检查 conversationId
if (!this.data.conversationId) {
console.log('⚠️ 缺少 conversationId无法加载消息');
return;
}
// 避免重复请求
if (this.data.loadingMessages) {
console.log('⚠️ 消息正在加载中,跳过重复请求');
return;
}
// 设置加载状态
this.setData({ loadingMessages: true });
// 获取 NIM 实例
const nim = getApp().globalData.nim;
if (!nim || !nim.V2NIMMessageService) {
throw new Error('NIM SDK 未初始化');
}
// 使用 NIM SDK 获取历史消息
const limit = 20;
const option = {
conversationId: this.data.conversationId,
endTime: isLoadMore && this.data.lastMessageTime ? this.data.lastMessageTime : Date.now(),
limit: limit,
direction: 0 // 1 表示查询比 endTime 更早的消息
};
console.log('📡 查询参数:', option);
const result = await nim.V2NIMMessageService.getMessageList(option);
if (!result) {
console.log('⚠️ NIM SDK 返回空结果');
this.setData({ loadingMessages: false, hasMore: false });
return;
}
const newMessages = result || [];
console.log('✅ NIM 消息回调:', newMessages);
console.log('✅ 从 NIM 获取到消息:', newMessages.length, '条');
// 格式化消息
const formattedMessages = await Promise.all(
newMessages.map(msg => this.formatMessage(msg))
);
// 判断是否是首次加载
const isFirstLoad = this.data.messages.length === 0;
// 去重处理
const existingIds = new Set(this.data.messages.map(msg => msg.messageId));
const uniqueNewMessages = formattedMessages.filter(
msg => !existingIds.has(msg.messageId)
);
// 合并并排序消息
let sortedMessages;
if (isFirstLoad) {
sortedMessages = uniqueNewMessages.sort((a, b) => a.timestamp - b.timestamp);
} else {
const allMessages = uniqueNewMessages.concat(this.data.messages);
sortedMessages = allMessages.sort((a, b) => a.timestamp - b.timestamp);
}
// 更新 lastMessageTime最早消息的时间戳用于分页
const earliestMessage = newMessages.length > 0
? newMessages[newMessages.length - 1]
: null;
const newLastMessageTime = earliestMessage?.createTime || earliestMessage?.timestamp || 0;
// 添加日期分隔符
const annotated = this.recomputeDateDividers(this._filterDeleted(sortedMessages));
// 更新数据
this.setData({
messages: annotated,
hasMore: newMessages.length === limit,
lastMessageTime: newLastMessageTime,
loadingMessages: false
});
console.log('✅ 消息加载完成:', {
totalMessages: sortedMessages.length,
hasMore: newMessages.length === limit,
lastMessageTime: newLastMessageTime
});
// 首次加载时滚动到底部
if (isFirstLoad && this.data.messages.length > 0) {
setTimeout(() => this.scrollToBottom(), 100);
}
} catch (error) {
console.error('❌ 加载消息失败:', error);
this.setData({
hasMore: false,
loadingMessages: false
});
wx.showToast({
title: '加载消息失败',
icon: 'none'
});
}
},
// 格式化消息 - 适配接口文档的数据结构,特殊处理图片消息
async formatMessage(msgData) {
// 获取当前用户的NIM账号ID用于判断消息是否为自己发送
let currentNimAccountId = this.data.userInfo?.neteaseIMAccid || this.data.userInfo?.user?.neteaseIMAccid || '';
// 如果从data中获取不到尝试从全局获取
if (!currentNimAccountId) {
const app = getApp();
const globalUserInfo = app.globalData.userInfo;
currentNimAccountId = globalUserInfo?.neteaseIMAccid || globalUserInfo?.user?.neteaseIMAccid || '';
}
// 如果还是获取不到,尝试从本地存储获取
if (!currentNimAccountId) {
try {
const storedUserInfo = wx.getStorageSync('userInfo');
currentNimAccountId = storedUserInfo?.neteaseIMAccid || storedUserInfo?.user?.neteaseIMAccid || '';
} catch (error) {
console.error('从本地存储获取NIM账号ID失败:', error);
}
}
// 适配 NIM SDK V2 官方字段名
const senderId = msgData.senderId || msgData.senderAccountId;
const messageId = msgData.messageClientId || msgData.messageServerId || msgData.id || msgData.messageId;
const isSelf = senderId === currentNimAccountId;
// 🔥 直接使用 NIM SDK V2 的 messageType 枚举值(无需转换)
// V2NIMMessageType: 0=文本, 1=图片, 2=语音, 3=视频, 4=位置, 5=通知, 6=文件, 10=提示, 100=自定义
let messageType = msgData.messageType !== undefined ? msgData.messageType : msgData.msgType;
// 🔥 获取消息内容 - NIM SDK V2 使用 text(文本) 或 attachment(附件)
let _rawContent;
if (msgData.text !== undefined && msgData.text !== null) {
_rawContent = msgData.text;
} else if (msgData.attachment !== undefined && msgData.attachment !== null) {
_rawContent = msgData.attachment;
} else if (msgData.content !== undefined && msgData.content !== null) {
_rawContent = msgData.content;
} else {
_rawContent = '';
}
// 标记为图片消息(1)但内容不是可用图片 -> 降级为文本(0)
if (messageType === 1) {
let hasValidImage = false;
try {
if (typeof _rawContent === 'string') {
const s = _rawContent.trim();
if (/^https?:\/\//i.test(s) || /^data:image\//i.test(s)) {
hasValidImage = true;
} else if (s.startsWith('{') && s.endsWith('}')) {
const obj = JSON.parse(s);
const u = obj?.file_url || obj?.url;
hasValidImage = !!u;
}
} else if (typeof _rawContent === 'object' && _rawContent !== null) {
const u = _rawContent.file_url || _rawContent.url;
hasValidImage = !!u;
}
} catch (_) { hasValidImage = false; }
if (!hasValidImage) {
console.log('🔧 检测到图片类型但无有效URL降级为文本');
messageType = 0; // 降级为文本
}
}
// 标记为文本(0)但内容像图片 -> 升级为图片(1)
if (messageType === 0 && this._looksLikeImageContent(_rawContent)) {
console.log('🖼️ 文本消息内容识别为图片,自动升级为图片类型');
messageType = 1;
}
// 🔥 撤回态识别(历史记录/服务端数据)
// 注意:撤回状态应该通过 msgData.isRecalled 字段判断,不应该依赖 sendingState
// sendingState 枚举值: 0=未知, 1=成功, 2=失败, 3=发送中
const recalledFromServer = !!msgData.isRecalled; // 只通过 isRecalled 字段判断是否撤回
// 🔥 特殊处理媒体消息内容
let processedContent = _rawContent;
if (messageType === 1) { // 图片
processedContent = await this.parseImageContent(_rawContent);
console.log('🖼️ 图片消息处理结果:', processedContent);
} else if (messageType === 2) { // 语音
processedContent = this.parseVoiceContent(_rawContent);
console.log('🎤 语音消息处理结果:', processedContent);
} else if (messageType === 6) { // 文件
processedContent = this.parseFileContent(_rawContent);
console.log('📄 文件消息处理结果:', processedContent);
} else if (messageType === 4) { // 位置
processedContent = this.parseLocationContent(_rawContent);
console.log('📍 位置消息处理结果:', processedContent);
} else if (messageType === 3) { // 视频
processedContent = this.parseVideoContent(_rawContent);
console.log('🎬 视频消息处理结果:', processedContent);
} else if (messageType === 0) { // 文本
processedContent = msgData.text || _rawContent;
}
// 🔥 统一时间戳字段 - NIM SDK V2 使用 createTime
let timestamp = 0;
try {
// 优先使用 createTime兼容其他时间字段
if (typeof msgData.createTime === 'number') {
timestamp = msgData.createTime;
} else if (typeof msgData.sendTime === 'number') {
timestamp = msgData.sendTime;
} else if (typeof msgData.sendTime === 'string') {
const t = Date.parse(msgData.sendTime);
timestamp = isNaN(t) ? Date.now() : t;
} else {
timestamp = Number(msgData.timestamp || Date.now());
}
} catch (_) {
timestamp = Date.now();
}
// 🔥 获取发送者头像(已通过 NIM getUserInfo 获取并缓存)
const senderAvatar = isSelf ? (this.data.userAvatar || '') : (this.data.targetAvatar || '');
// 🔥 处理消息发送状态
let deliveryStatus;
if (isSelf) {
// 根据官方文档的正确枚举值处理
if (msgData.sendingState === 3) {
deliveryStatus = 3; // 发送中 (V2NIM_MESSAGE_SENDING_STATE_SENDING)
} else if (msgData.sendingState === 2) {
deliveryStatus = 2; // 发送失败 (V2NIM_MESSAGE_SENDING_STATE_FAILED)
} else {
// 对于历史消息,默认为成功状态
// 包括: sendingState为0(未知)、1(成功)、undefined、null或其他值的情况
deliveryStatus = 1; // V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
console.log('📜 历史消息状态设为成功:', {
messageId: msgData.messageClientId || msgData.messageServerId,
sendingState: msgData.sendingState,
status: msgData.status,
finalStatus: deliveryStatus
});
}
} else {
// 对方消息:成功状态
deliveryStatus = 1;
}
const baseMessage = {
messageId: messageId,
senderId: senderId,
targetId: msgData.receiverId || msgData.targetId,
content: processedContent,
msgType: this.convertMessageTypeToString(messageType), // 🔥 转换为字符串类型 (text/image/audio/video/location/file)
sendTime: timestamp,
timestamp: timestamp,
isSelf: isSelf,
senderName: msgData.senderName || (isSelf ? '我' : '对方'),
senderAvatar: senderAvatar,
deliveryStatus: deliveryStatus,
bubbleTime: this.formatBubbleTime(timestamp),
_rawMessage: msgData // 🔥 保存 NIM SDK 原始消息对象,用于撤回等操作
};
// 🔥 调试:查看消息状态转换结果
console.log('🔍 消息状态转换:', {
isSelf: isSelf,
sendingState: msgData.sendingState,
status: msgData.status,
deliveryStatus: baseMessage.deliveryStatus,
messageId: baseMessage.messageId,
messageType: 'formatMessage调用 - 历史消息处理'
});
// NIM SDK V2 撤回的消息会通过 onMessageRevokeNotifications 事件通知
if (recalledFromServer) {
return {
...baseMessage,
isRecalled: true,
msgType: 'system', // 🔥 系统消息类型使用字符串
content: isSelf ? '你撤回了一条消息' : (msgData.senderName ? `${msgData.senderName}撤回了一条消息` : '对方撤回了一条消息'),
originalContent: processedContent
};
}
// 文件消息补充文件展示字段便于WXML直接使用
if (messageType === 6) { // 文件
return {
...baseMessage,
fileName: processedContent.fileName || msgData.fileName || '',
fileSize: this.formatFileSize(processedContent.fileSize || msgData.fileSize || 0),
fileType: processedContent.fileType || msgData.fileType || ''
};
}
// 🔥 位置消息:为兼容旧渲染逻辑,将名称/地址/经纬度同步到根级字段,避免显示默认“位置”
if (messageType === 4) { // 位置
return {
...baseMessage,
locationName: processedContent?.locationName || msgData.locationName || '位置',
locationAddress: processedContent?.address || msgData.locationAddress || '',
latitude: processedContent?.latitude,
longitude: processedContent?.longitude
};
}
// 🔥 视频消息将URL和缩略图同步到根级字段兼容WXML渲染
if (messageType === 3) { // 视频
return {
...baseMessage,
content: processedContent.url || processedContent, // WXML 中使用 item.content 作为 src
thumbnail: processedContent.thumbnail || '',
duration: processedContent.duration || 0,
videoUrl: processedContent.url || ''
};
}
// 🔥 最终调试输出
console.log('✅ formatMessage 完成:', {
messageId,
msgType: baseMessage.msgType, // 使用转换后的字符串类型
msgTypeNumber: messageType, // 原始数字类型(用于调试)
content: processedContent,
deliveryStatus: baseMessage.deliveryStatus,
isSelf,
timestamp
});
return baseMessage;
},
// 🔥 解析语音内容 - 支持 NIM SDK V2 的 attachment 对象格式
parseVoiceContent(content) {
try {
if (!content) return { url: '', duration: 0 };
// NIM SDK V2 的语音附件对象格式
// attachment 通常包含: url, dur (duration), size 等字段
if (typeof content === 'object' && content !== null) {
const url = content.url || content.path || content.voiceUrl || '';
const duration = content.dur || content.duration || 0; // NIM SDK V2 使用 dur
const size = content.size || content.fileSize || 0;
console.log('🎤 语音附件对象解析:', { url, duration, size });
return { url, duration, size };
}
// 如果是字符串尝试JSON解析
if (typeof content === 'string') {
const trimmed = content.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const obj = JSON.parse(trimmed);
const url = obj.url || obj.path || obj.voiceUrl || '';
const duration = obj.dur || obj.duration || 0;
const size = obj.size || obj.fileSize || 0;
return { url, duration, size };
} catch (e) {
// 继续按URL兜底
}
}
// 直接当作URL使用
return { url: trimmed, duration: 0 };
}
return { url: '', duration: 0 };
} catch (error) {
console.error('❌ 解析语音内容失败:', error, content);
return { url: '', duration: 0 };
}
},
// 🔥 解析文件内容 - 支持 NIM SDK V2 的 attachment 对象格式
parseFileContent(content) {
try {
if (!content) return { url: '', fileName: '', fileSize: 0 };
// NIM SDK V2 的文件附件对象格式
if (typeof content === 'object' && content !== null) {
const url = content.url || content.path || content.file_url || content.fileUrl || '';
const fileName = content.name || content.fileName || content.file_name || '';
const fileSize = content.size || content.fileSize || content.file_size || 0;
const ext = content.ext || content.fileType || content.file_type || '';
console.log('📄 文件附件对象解析:', { url, fileName, fileSize, ext });
return { url, fileName, fileSize, fileType: ext };
}
// JSON字符串
if (typeof content === 'string') {
const trimmed = content.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const obj = JSON.parse(trimmed);
const url = obj.url || obj.path || obj.file_url || obj.fileUrl || '';
const fileName = obj.name || obj.fileName || obj.file_name || '';
const fileSize = obj.size || obj.fileSize || obj.file_size || 0;
const fileType = obj.ext || obj.fileType || obj.file_type || '';
return { url, fileName, fileSize, fileType };
} catch (e) {
// fallthrough to plain URL
}
}
// 纯URL
return { url: trimmed, fileName: '', fileSize: 0 };
}
return { url: '', fileName: '', fileSize: 0 };
} catch (error) {
console.error('❌ 解析文件内容失败:', error, content);
return { url: '', fileName: '', fileSize: 0 };
}
},
// 🔥 解析位置内容 - 支持 NIM SDK V2 的 attachment 对象格式
parseLocationContent(content) {
try {
if (!content) return null;
let obj = content;
// NIM SDK V2 的位置附件对象格式
// attachment 通常包含: lat, lng, title, address 等字段
if (typeof content === 'string') {
const trimmed = content.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
obj = JSON.parse(trimmed);
} catch (e) {
return null;
}
} else {
// 非JSON字符串无法解析
return null;
}
}
// NIM SDK V2 使用 lat/lng兼容 latitude/longitude
const lat = obj.lat ?? obj.latitude;
const lng = obj.lng ?? obj.longitude;
const address = obj.address || obj.addr || '';
const locationName = obj.title || obj.locationName || obj.name || '位置';
console.log('📍 位置附件对象解析:', { lat, lng, address, locationName });
if (lat == null || lng == null) return null;
return { latitude: Number(lat), longitude: Number(lng), address, locationName };
} catch (err) {
console.error('❌ 解析位置内容失败:', err, content);
return null;
}
},
// 🔥 解析视频内容 - 支持 NIM SDK V2 的 attachment 对象格式
parseVideoContent(content) {
try {
if (!content) return { url: '', thumbnail: '' };
// NIM SDK V2 的视频附件对象格式
if (typeof content === 'object' && content !== null) {
const url = content.url || content.path || '';
const thumbnail = content.thumbUrl || content.coverUrl || content.thumbnail || '';
const duration = content.dur || content.duration || 0;
const size = content.size || content.fileSize || 0;
const width = content.width || content.w || 0;
const height = content.height || content.h || 0;
console.log('🎬 视频附件对象解析:', { url, thumbnail, duration, size, width, height });
return { url, thumbnail, duration, size, width, height };
}
// JSON 字符串
if (typeof content === 'string') {
const trimmed = content.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const obj = JSON.parse(trimmed);
const url = obj.url || obj.path || '';
const thumbnail = obj.thumbUrl || obj.coverUrl || obj.thumbnail || '';
const duration = obj.dur || obj.duration || 0;
return { url, thumbnail, duration };
} catch (e) {
// 继续按URL兜底
}
}
// 直接当作URL使用
return { url: trimmed, thumbnail: '' };
}
return { url: '', thumbnail: '' };
} catch (error) {
console.error('❌ 解析视频内容失败:', error, content);
return { url: '', thumbnail: '' };
}
},
// 🔥 根据官方文档NIM SDK V2 的正确枚举值0=未知, 1=成功, 2=失败, 3=发送中)
convertStatus(apiStatus) {
// NIM SDK V2 的 V2NIMMessageSendingState 枚举:
// V2NIM_MESSAGE_SENDING_STATE_UNKNOWN = 0 (未知状态)
// V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED = 1 (发送成功)
// V2NIM_MESSAGE_SENDING_STATE_FAILED = 2 (发送失败)
// V2NIM_MESSAGE_SENDING_STATE_SENDING = 3 (发送中)
console.log('🔄 convertStatus 输入:', {
apiStatus,
type: typeof apiStatus,
isUndefined: apiStatus === undefined,
isNull: apiStatus === null
});
// 🔥 直接返回数字枚举值,不转换为字符串
// undefined/null 默认为成功状态 1 (历史消息通常没有状态字段,说明已成功)
if (apiStatus === undefined || apiStatus === null) {
console.log('🔄 状态映射 convertStatus: undefined/null -> 1 (历史消息默认成功)');
return 1; // V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
}
// 如果已经是数字,直接返回
if (typeof apiStatus === 'number') {
// 验证是否为有效的 sendingState 枚举值 (0, 1, 2, 3)
if (apiStatus === 0 || apiStatus === 1 || apiStatus === 2 || apiStatus === 3) {
console.log('🔄 状态映射 convertStatus:', apiStatus, '(有效枚举值,保持不变)');
return apiStatus;
}
// 对于历史消息,其他数字状态也默认为成功(因为能从服务器获取到说明已发送成功)
console.log('🔄 状态映射 convertStatus:', apiStatus, '-> 1 (历史消息其他数字状态,默认成功)');
return 1;
}
// 字符串状态转换为枚举值
const s = String(apiStatus).toLowerCase();
let result;
if (s === 'succeeded' || s === 'success' || s === 'sent' || s === 'delivered') {
result = 1; // SUCCEEDED
} else if (s === 'failed' || s === 'fail') {
result = 2; // FAILED
} else if (s === 'sending') {
result = 3; // SENDING
} else {
// 对于历史消息,未知状态默认为成功
result = 1; // 默认成功
}
console.log('🔄 状态映射 convertStatus:', apiStatus, '->', result, '(历史消息处理)');
return result;
},
// 🔥 解析图片内容 - 支持 NIM SDK V2 的 attachment 对象格式
async parseImageContent(content) {
try {
if (!content) return { type: 'url', url: '' };
// 1) NIM SDK V2 的 attachment 对象格式
// attachment 通常包含: url, name, size, width, height 等字段
if (typeof content === 'object') {
// NIM SDK V2 图片附件字段
const url = content.url || content.file_url || content.path || '';
const fileName = content.name || content.fileName || content.file_name || '';
const fileSize = content.size || content.fileSize || content.file_size || 0;
const width = content.width || content.w || 0;
const height = content.height || content.h || 0;
console.log('🖼️ 图片附件对象解析:', { url, fileName, fileSize, width, height });
if (/^data:image\//i.test(url)) {
return { type: 'base64', url, fileName, fileSize, width, height };
}
if (url) {
const cachedUrl = await imageCacheManager.preloadImage(url);
return { type: 'url', url: cachedUrl, fileName, fileSize, width, height };
}
// 对象但无 url视为错误
return { type: 'error', url: '', error: '图片内容缺少URL', originalContent: JSON.stringify(content).slice(0, 200) };
}
// 2) 字符串Base64
if (typeof content === 'string' && /^data:image\//i.test(content)) {
return { type: 'base64', url: content };
}
// 3) 字符串URL
if (typeof content === 'string' && /^https?:\/\//i.test(content)) {
const cachedUrl = await imageCacheManager.preloadImage(content);
return { type: 'url', url: cachedUrl };
}
// 4) 字符串:尝试 JSON 解析
if (typeof content === 'string') {
const s = content.trim();
if (s.startsWith('{') && s.endsWith('}')) {
try {
const obj = JSON.parse(s);
const u = obj.url || obj.file_url || obj.path || '';
if (u) {
const cachedUrl = await imageCacheManager.preloadImage(u);
return {
type: /^data:image\//i.test(u) ? 'base64' : 'url',
url: cachedUrl,
fileName: obj.name || obj.file_name || obj.fileName,
fileSize: obj.size || obj.file_size || obj.fileSize,
width: obj.width || obj.w,
height: obj.height || obj.h
};
}
} catch (e) {
// fallthrough
}
}
}
// 5) 其他:无法识别,按错误返回,避免把纯文本当图片
return { type: 'error', url: '', error: '图片内容无效', originalContent: String(content).slice(0, 200) };
} catch (err) {
console.error('❌ 解析图片内容失败:', err, content);
return { type: 'error', url: '', error: '解析失败', originalContent: String(content).slice(0, 200) };
}
},
// 🔥 新增:解析视频内容,统一返回可用于 <video src> 的字符串URL
parseVideoContent(content) {
try {
if (!content) return '';
// 对象格式
if (typeof content === 'object') {
const url = content.url || content.file_url || content.fileUrl || '';
return String(url || '');
}
// 字符串格式可能是JSON或URL
const str = String(content).trim();
if (!str) return '';
if (str.startsWith('{') && str.endsWith('}')) {
try {
const obj = JSON.parse(str);
const url = obj.url || obj.file_url || obj.fileUrl || '';
return String(url || '');
} catch (_) {
// 继续按纯URL处理
}
}
return str;
} catch (e) {
console.error('❌ 解析视频内容失败:', e, content);
return '';
}
},
// 判断是否显示时间
shouldShowTime(timestamp) {
// 简单实现每5分钟显示一次时间
const lastMessage = this.data.messages[this.data.messages.length - 1];
if (!lastMessage) return true;
const diff = timestamp - lastMessage.sendTime;
return diff > 5 * 60 * 1000; // 5分钟
},
// 气泡内时间 HH:mm
formatBubbleTime(timestamp) {
const d = new Date(timestamp);
const hh = d.getHours().toString().padStart(2, '0');
const mm = d.getMinutes().toString().padStart(2, '0');
return `${hh}:${mm}`;
},
// 日期标签:今天/昨天/YYYY/MM/DD
formatDateLabel(timestamp) {
const d = new Date(timestamp);
const today = new Date();
const yst = new Date();
yst.setDate(today.getDate() - 1);
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
if (sameDay(d, today)) return '今天';
if (sameDay(d, yst)) return '昨天';
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
return `${y}/${m}/${day}`;
},
// 🔥 过滤已删除的消息
_filterDeleted(messages) {
if (!Array.isArray(messages)) return [];
return messages.filter(msg => !msg.isDeleted);
},
// 重新计算日期分隔(仅按日期),返回新数组
recomputeDateDividers(list) {
if (!Array.isArray(list)) return [];
let lastDateKey = '';
return list.map((m, idx) => {
const d = new Date(m.timestamp || m.sendTime || 0);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const show = key !== lastDateKey;
const dateText = this.formatDateLabel(d.getTime());
lastDateKey = key;
return { ...m, showDateDivider: show, dateText, bubbleTime: m.bubbleTime || this.formatBubbleTime(m.timestamp || m.sendTime || Date.now()) };
});
},
// 便捷设置消息并自动打日期分隔
setMessagesWithDate(messages) {
const safe = this._filterDeleted(messages);
const annotated = this.recomputeDateDividers(safe);
console.log('📋 setMessagesWithDate 执行:', {
输入消息数: messages.length,
过滤后消息数: safe.length,
标注后消息数: annotated.length,
最后一条消息: annotated[annotated.length - 1]
});
this.setData({ messages: annotated });
},
// 输入变化
onInputChange(e) {
const inputText = e.detail.value;
this.setData({ inputText });
},
// 处理中文输入法开始
onCompositionStart(e) {
this.setData({ isComposing: true });
},
// 处理中文输入法结束
onCompositionEnd(e) {
this.setData({ isComposing: false });
},
// 处理文本框行数变化
onLineChange(e) {
// 可以在这里处理输入框高度变化的逻辑
},
// 显示媒体选择菜单
showMediaPicker() {
// 进入媒体选择时先收起工具栏,避免遮挡
this.setData({
showMorePanel: false,
showEmojiPanel: false,
emojiPanelHeight: 0,
keyboardHeight: 0,
showVoiceRecorder: false,
inputType: 'text'
});
wx.showActionSheet({
itemList: ['拍照', '拍摄视频'],
success: (res) => {
switch (res.tapIndex) {
case 0:
this.takePhoto();
break;
case 1:
this.recordVideo();
break;
}
},
fail: (error) => {
if (error.errMsg !== 'showActionSheet:fail cancel') {
console.error('❌ 显示媒体选择菜单失败:', error);
}
}
});
},
// 拍照
async takePhoto() {
try {
// 开始拍照前关闭工具栏
this.setData({ showMorePanel: false, showEmojiPanel: false, emojiPanelHeight: 0, keyboardHeight: 0, showVoiceRecorder: false, inputType: 'text' });
const result = await mediaPicker.chooseImages({
count: 1,
sourceType: ['camera']
});
if (result.success && result.files.length > 0) {
await this.sendImageMessage(result.files[0]);
}
} catch (error) {
console.error('❌ 拍照失败:', error);
wx.showToast({
title: '拍照失败',
icon: 'none'
});
}
},
// 从相册选择图片
async chooseImageFromAlbum() {
try {
// 打开相册前先收起工具栏
this.setData({ showMorePanel: false, showEmojiPanel: false, emojiPanelHeight: 0, keyboardHeight: 0, showVoiceRecorder: false, inputType: 'text' });
// 在相册内二次分流:图片 或 视频
const tapIndex = await new Promise((resolve) => {
wx.showActionSheet({
itemList: ['从相册选择图片', '从相册选择视频'],
success: (res) => resolve(res.tapIndex),
fail: (err) => {
if (err.errMsg !== 'showActionSheet:fail cancel') {
console.error('❌ 显示相册选择菜单失败:', err);
}
resolve(-1);
}
});
});
if (tapIndex === 0) {
// 选择图片
const result = await mediaPicker.chooseImages({
count: 9,
sourceType: ['album']
});
if (result.success && Array.isArray(result.files) && result.files.length > 0) {
// 保险再截断一次
const files = result.files.slice(0, 9);
if (result.files.length > 9) {
wx.showToast({ title: '最多发送9张图片', icon: 'none' });
}
for (const image of files) {
await this.sendImageMessage(image);
}
}
} else if (tapIndex === 1) {
// 选择视频
const result = await mediaPicker.chooseVideo({
sourceType: ['album'],
maxDuration: 60,
compressed: true
});
if (result.success && Array.isArray(result.files) && result.files.length > 0) {
const video = result.files[0];
if (video.size && video.size > 200 * 1024 * 1024) {
wx.showToast({ title: '视频超过200MB无法发送', icon: 'none' });
return;
}
// 简单压缩提示(如果支持,可扩展自定义压缩逻辑)
if (!video.compressed && video.tempFilePath) {
// 微信 chooseVideo 已有压缩,兜底提示
}
await this.sendVideoMessage(video);
}
}
} catch (error) {
console.error('❌ 处理相册选择发生异常:', error);
}
},
// 拍摄视频
async recordVideo() {
try {
// 开始录制前关闭工具栏
this.setData({ showMorePanel: false, showEmojiPanel: false, emojiPanelHeight: 0, keyboardHeight: 0, showVoiceRecorder: false, inputType: 'text' });
const result = await mediaPicker.chooseVideo({
sourceType: ['camera'],
maxDuration: 60
});
if (result.success && result.files.length > 0) {
await this.sendVideoMessage(result.files[0]);
}
} catch (error) {
console.error('❌ 拍摄视频失败:', error);
wx.showToast({
title: '拍摄视频失败',
icon: 'none'
});
}
},
// 🔥 头像加载错误处理
onAvatarError(e) {
console.error('❌ 头像加载失败:', e.detail);
// 🔥 尝试从dataset中获取更多信息
const dataset = e.currentTarget.dataset;
},
// 🔥 头像加载成功处理
onAvatarLoad(e) {
},
// 🔥 ===== 媒体消息发送功能 =====
// 发送图片消息 - 兼容媒体选择器
async sendImageMessage(imageFile) {
try {
// 如果传入的是字符串路径,转换为文件对象格式
if (typeof imageFile === 'string') {
imageFile = { path: imageFile };
}
// 检查用户信息
if (!this.data.userInfo) {
console.error('❌ 用户信息不存在,无法发送图片');
return;
}
// 检查文件大小
if (imageFile.size && imageFile.size > 10 * 1024 * 1024) { // 10MB限制
wx.showToast({
title: '图片大小不能超过10MB',
icon: 'none'
});
return;
}
// 🔥 发送开始后立即收起工具栏/面板,回到聊天界面
this.setData({
showMorePanel: false,
showEmojiPanel: false,
emojiPanelHeight: 0,
keyboardHeight: 0,
showVoiceRecorder: false,
inputType: 'text'
});
wx.showLoading({ title: '发送中...' });
const imagePath = imageFile.path;
console.log('📸 图片本地路径:', imagePath);
// 🔥 获取当前用户的NIM账号ID
const currentNimAccountId = this.data.userInfo?.neteaseIMAccid || this.data.userInfo?.user?.neteaseIMAccid || '';
if (!currentNimAccountId) {
throw new Error('无法获取NIM账号ID');
}
// 🔥 创建临时图片消息
const timestamp = Date.now();
const tempMessage = {
messageId: 'temp_image_' + timestamp,
senderId: currentNimAccountId,
targetId: this.data.targetId,
content: {
url: imagePath // 临时使用本地路径
},
msgType: 1, // 图片
sendTime: timestamp,
timestamp: timestamp,
isSelf: true,
senderName: '我',
senderAvatar: this.data.userAvatar || '',
deliveryStatus: 3, // 发送中
bubbleTime: this.formatBubbleTime(timestamp)
};
// 添加到消息列表
const messages = this.data.messages.concat([tempMessage]);
this.setMessagesWithDate(messages);
setTimeout(() => this.scrollToBottom(), 100);
// 🔥 使用 NIM SDK 发送图片消息SDK会自动上传
const result = await nimConversationManager.sendImageMessage(
this.data.conversationId,
imagePath
);
wx.hideLoading();
if (result && result.message) {
// 🔥 格式化并替换临时消息
const sentMessage = await this.formatMessage(result.message);
sentMessage.deliveryStatus = 1; // 发送成功 (V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED)
// 替换临时消息
const tempIndex = this.data.messages.findIndex(msg => msg.messageId === tempMessage.messageId);
if (tempIndex !== -1) {
const updatedMessages = [...this.data.messages];
updatedMessages[tempIndex] = sentMessage;
this.setMessagesWithDate(updatedMessages);
console.log('✅ 图片消息UI更新成功');
}
} else {
throw new Error('NIM SDK 发送图片失败');
}
} catch (error) {
wx.hideLoading();
console.error('❌ 发送图片消息失败:', error);
// 更新临时消息状态为失败
const messages = this.data.messages.map(msg => {
if (msg.messageId && msg.messageId.startsWith('temp_image_')) {
return { ...msg, deliveryStatus: 1 }; // 发送失败
}
return msg;
});
this.setMessagesWithDate(messages);
wx.showToast({
title: '图片发送失败',
icon: 'none'
});
}
},
// 发送视频消息 - 兼容媒体选择器
async sendVideoMessage(videoFile) {
try {
// 如果传入的是字符串路径,转换为文件对象格式
if (typeof videoFile === 'string') {
videoFile = { path: videoFile };
}
// 检查用户信息
if (!this.data.userInfo) {
console.error('❌ 用户信息不存在,无法发送视频');
return;
}
// 检查文件大小
if (videoFile.size && videoFile.size > 200 * 1024 * 1024) { // 200MB限制
wx.showToast({
title: '视频不能超过200MB',
icon: 'none'
});
return;
}
// 🔥 发送开始后立即收起工具栏/面板,回到聊天界面
this.setData({
showMorePanel: false,
showEmojiPanel: false,
emojiPanelHeight: 0,
keyboardHeight: 0,
showVoiceRecorder: false,
inputType: 'text'
});
wx.showLoading({ title: '发送中...' });
const videoPath = videoFile.path;
const duration = videoFile.duration || 0; // 视频时长(秒)
console.log('🎥 视频本地路径:', videoPath, '时长:', duration);
// 🔥 获取当前用户的NIM账号ID
const currentNimAccountId = this.data.userInfo?.neteaseIMAccid || this.data.userInfo?.user?.neteaseIMAccid || '';
if (!currentNimAccountId) {
throw new Error('无法获取NIM账号ID');
}
// 🔥 创建临时视频消息
const timestamp = Date.now();
const tempMessage = {
messageId: 'temp_video_' + timestamp,
senderId: currentNimAccountId,
targetId: this.data.targetId,
content: {
url: videoPath, // 临时使用本地路径
duration: duration
},
msgType: 3, // 视频
sendTime: timestamp,
timestamp: timestamp,
isSelf: true,
senderName: '我',
senderAvatar: this.data.userAvatar || '',
deliveryStatus: 3, // 发送中 (V2NIM_MESSAGE_SENDING_STATE_SENDING)
bubbleTime: this.formatBubbleTime(timestamp)
};
// 添加到消息列表
const messages = this.data.messages.concat([tempMessage]);
this.setMessagesWithDate(messages);
setTimeout(() => this.scrollToBottom(), 100);
// 🔥 使用 NIM SDK 发送视频消息SDK会自动上传
const result = await nimConversationManager.sendVideoMessage(
this.data.conversationId,
videoPath,
duration * 1000 // NIM SDK 需要毫秒
);
wx.hideLoading();
if (result && result.message) {
// 🔥 格式化并替换临时消息
const sentMessage = await this.formatMessage(result.message);
sentMessage.deliveryStatus = 1; // 发送成功 (V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED)
// 替换临时消息
const tempIndex = this.data.messages.findIndex(msg => msg.messageId === tempMessage.messageId);
if (tempIndex !== -1) {
const updatedMessages = [...this.data.messages];
updatedMessages[tempIndex] = sentMessage;
this.setMessagesWithDate(updatedMessages);
console.log('✅ 视频消息UI更新成功');
}
} else {
throw new Error('NIM SDK 发送视频失败');
}
} catch (error) {
wx.hideLoading();
console.error('❌ 发送视频消息失败:', error);
// 更新临时消息状态为失败
const messages = this.data.messages.map(msg => {
if (msg.messageId && msg.messageId.startsWith('temp_video_')) {
return { ...msg, deliveryStatus: 1 }; // 发送失败
}
return msg;
});
this.setMessagesWithDate(messages);
wx.showToast({
title: '视频发送失败',
icon: 'none'
});
}
},
// 🔥 ===== 消息发送功能 =====
// 发送文本消息 - 使用 NIM SDK
async sendTextMessage() {
const content = this.data.inputText.trim();
if (!content) return;
// 检查用户信息
if (!this.data.userInfo) {
console.error('用户信息不存在,无法发送消息');
wx.showToast({
title: '用户信息错误',
icon: 'none'
});
return;
}
// 清空输入框
this.setData({
inputText: '',
showEmojiPanel: false,
emojiPanelHeight: 0,
showMorePanel: false
});
// 🔥 获取当前用户的NIM账号ID用于临时消息的senderId保持与实际消息一致
const currentNimAccountId = this.data.userInfo?.neteaseIMAccid || this.data.userInfo?.user?.neteaseIMAccid || '';
if (!currentNimAccountId) {
console.error('无法获取NIM账号ID');
wx.showToast({
title: 'NIM账号错误',
icon: 'none'
});
return;
}
// 创建临时消息使用NIM账号ID作为senderId确保与实际消息格式一致
const timestamp = Date.now();
const tempMessage = {
messageId: 'temp_' + timestamp,
senderId: currentNimAccountId, // 🔥 使用NIM账号ID而不是customId
targetId: this.data.targetId,
content: content,
msgType: 'text', // 文本
sendTime: timestamp,
timestamp: timestamp,
isSelf: true,
senderName: '我',
senderAvatar: this.data.userAvatar || '',
deliveryStatus: 3, // V2NIM_MESSAGE_SENDING_STATE_SENDING
bubbleTime: this.formatBubbleTime(timestamp)
};
// 添加到消息列表
const messages = this.data.messages.concat([tempMessage]);
this.setMessagesWithDate(messages);
// 滚动到底部
setTimeout(() => this.scrollToBottom(), 100);
try {
console.log(' 准备发送文本消息 (NIM SDK):', {
conversationId: this.data.conversationId,
content: content
});
// 使用 NIM SDK 发送文本消息
const result = await nimConversationManager.sendTextMessage(
this.data.conversationId,
content
);
if (result && result.message) {
console.log('✅ 消息发送成功');
const sentMessage = await this.formatMessage(result.message);
sentMessage.deliveryStatus = 1; // V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
console.log('✅ 强制设置消息状态为成功 (deliveryStatus = 1)');
// 🔥 使用临时消息的索引位置来替换,而不是依赖 messageId 匹配
const tempIndex = this.data.messages.findIndex(msg => msg.messageId === tempMessage.messageId);
if (tempIndex !== -1) {
// 替换临时消息
const updatedMessages = [...this.data.messages];
updatedMessages[tempIndex] = sentMessage;
this.setMessagesWithDate(updatedMessages);
console.log('✅ 消息UI更新成功新消息ID:', sentMessage.messageId);
} else {
// 临时消息未找到,直接添加实际消息
console.warn('⚠️ 未找到临时消息,直接添加实际消息');
const updatedMessages = this.data.messages.concat([sentMessage]);
this.setMessagesWithDate(updatedMessages);
}
} else {
throw new Error('NIM SDK 发送消息失败');
}
} catch (error) {
console.error('发送消息失败:', error);
// 更新消息状态为失败
this.updateMessageDeliveryStatus(tempMessage.messageId, 1); // V2NIM_MESSAGE_SENDING_STATE_FAILED
wx.showToast({
title: '发送失败',
icon: 'none'
});
}
},
// 更新消息发送状态
updateMessageDeliveryStatus(messageId, status) {
const messages = this.data.messages.map(msg => {
if (msg.messageId === messageId) {
return { ...msg, deliveryStatus: status };
}
return msg;
});
this.setData({ messages });
},
// 标记所有消息已读(进入聊天页面时调用)
async markAllMessagesRead() {
try {
if (!this.data.conversationId || this.data.conversationId.trim() === '') {
console.log('⚠️ 会话ID为空跳过标记已读');
return;
}
console.log('📖 标记会话所有消息已读 (NIM SDK):', this.data.conversationId);
// 使用 NIM SDK 标记已读
await nimConversationManager.markConversationRead(this.data.conversationId);
console.log('✅ 消息已全部标记已读');
// 更新本地消息状态
const updatedMessages = this.data.messages.map(msg => ({
...msg,
deliveryStatus: msg.isSelf ? msg.deliveryStatus : 'read'
}));
this.setData({
messages: updatedMessages
});
} catch (error) {
console.error('❌ 标记消息已读失败:', error);
// 不显示错误提示,避免影响用户体验
}
},
// 切换输入类型
toggleInputType() {
const inputType = this.data.inputType === 'text' ? 'voice' : 'text';
// 关闭表情面板和更多面板
this.closeEmojiPanel();
this.setData({
inputType,
showMorePanel: false,
keyboardHeight: 0 // 切换输入类型时重置键盘高度
});
},
// 🔥 ===== 语音录制触摸事件处理 =====
// 开始录音(按下)
onVoiceTouchStart(e) {
console.log('🎤 开始录音');
// 🔥 防抖检查:如果上次录音结束时间距离现在太近,忽略本次点击
const now = Date.now();
if (this.lastVoiceEndTime && (now - this.lastVoiceEndTime) < 500) {
console.log('⚠️ 点击过快,忽略本次录音');
wx.showToast({
title: '要点坏了~~~~~',
icon: 'none',
duration: 1000
});
return;
}
// 🔥 如果正在录音中,忽略重复点击
if (this.data.recording) {
console.log('⚠️ 正在录音中,忽略重复点击');
return;
}
// 设置录音状态
this.setData({
recording: true,
voiceCancel: false
});
// 重置取消标记
this.voiceRecordCancelled = false;
// 🔥 延迟开始录音,防止误触
this.voiceStartTimer = setTimeout(() => {
// 再次检查是否仍在录音状态(用户可能已经松开了)
if (this.data.recording && !this.voiceRecordCancelled) {
this.startVoiceRecord();
} else {
console.log('⚠️ 用户已松开,取消录音启动');
this.setData({ recording: false });
}
}, 150); // 延迟150ms开始录音
},
// 手指移动(判断是否取消)
onVoiceTouchMove(e) {
const touch = e.touches[0];
const target = e.currentTarget;
// 获取按钮位置信息
const query = wx.createSelectorQuery();
query.select('.voice-btn').boundingClientRect();
query.exec((res) => {
if (res && res[0]) {
const rect = res[0];
// 判断手指是否移出按钮区域(上滑取消)
const isOutside = touch.clientY < rect.top - 50; // 上移50px以上取消
this.setData({ voiceCancel: isOutside });
if (isOutside) {
console.log('⚠️ 上滑取消录音');
}
}
});
},
// 结束录音(松开)
onVoiceTouchEnd(e) {
console.log('🎤 结束录音');
// 🔥 清除延迟启动定时器
if (this.voiceStartTimer) {
clearTimeout(this.voiceStartTimer);
this.voiceStartTimer = null;
}
const voiceCancel = this.data.voiceCancel;
// 🔥 记录本次录音结束时间,用于防抖
this.lastVoiceEndTime = Date.now();
// 重置录音状态
this.setData({
recording: false,
voiceCancel: false
});
if (voiceCancel) {
// 🔥 设置取消标记防止onStop回调发送消息
this.voiceRecordCancelled = true;
// 取消录音
this.cancelVoiceRecord();
} else {
// 完成录音并发送
this.stopVoiceRecord();
}
},
// 录音被中断(如来电)
onVoiceTouchCancel(e) {
console.log('⚠️ 录音被中断');
// 🔥 清除延迟启动定时器
if (this.voiceStartTimer) {
clearTimeout(this.voiceStartTimer);
this.voiceStartTimer = null;
}
this.setData({
recording: false,
voiceCancel: false
});
// 🔥 设置取消标记
this.voiceRecordCancelled = true;
// 🔥 记录结束时间
this.lastVoiceEndTime = Date.now();
// 取消录音
this.cancelVoiceRecord();
},
// 开始录音
startVoiceRecord() {
try {
const recorderManager = wx.getRecorderManager();
recorderManager.onStart(() => {
console.log('✅ 录音开始');
wx.vibrateShort(); // 震动反馈
});
// 🔥 在这里注册 onStop 回调,只注册一次
recorderManager.onStop((res) => {
console.log('✅ 录音结束 - 原始数据:', res);
// 🔥 检查是否已取消录音
if (this.voiceRecordCancelled) {
console.log('⚠️ 录音已取消,不发送消息');
this.voiceRecordCancelled = false; // 重置标记
return;
}
console.log('📁 tempFilePath 详情:', {
value: res.tempFilePath,
type: typeof res.tempFilePath,
length: res.tempFilePath?.length,
exists: !!res.tempFilePath
});
const { tempFilePath, duration } = res;
// 🔥 严格检查 tempFilePath
if (!tempFilePath || typeof tempFilePath !== 'string' || tempFilePath.trim() === '') {
console.error('❌ 录音文件路径无效:', tempFilePath);
wx.showToast({
title: '录音文件获取失败',
icon: 'none'
});
return;
}
// 🔥 检查录音时长 - 至少需要1秒
if (duration < 1000) {
console.log('⚠️ 录音时间太短,不发送 - 时长:', duration);
wx.showToast({
title: '时间太短了,没录到啊~~~~~~',
icon: 'none',
duration: 1500
});
return;
}
console.log('✅ 准备发送语音,路径:', tempFilePath);
// 🔥 使用微信文件系统获取文件信息验证
wx.getFileSystemManager().getFileInfo({
filePath: tempFilePath,
success: (fileInfo) => {
console.log('✅ 文件信息:', fileInfo);
// 🔥 调用新的语音发送方法
this.sendVoiceMessageWithNIM(tempFilePath, duration);
},
fail: (err) => {
console.error('❌ 获取文件信息失败:', err);
wx.showToast({
title: '录音文件无效',
icon: 'none'
});
}
});
});
recorderManager.onError((err) => {
console.error('❌ 录音错误:', err);
wx.showToast({
title: '录音失败',
icon: 'none'
});
this.setData({ recording: false });
});
// 开始录音
recorderManager.start({
duration: 60000, // 最长60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
});
// 保存录音管理器实例
this.recorderManager = recorderManager;
} catch (error) {
console.error('❌ 启动录音失败:', error);
wx.showToast({
title: '录音功能异常',
icon: 'none'
});
this.setData({ recording: false });
}
},
// 停止录音并发送
stopVoiceRecord() {
if (!this.recorderManager) {
console.warn('⚠️ 录音管理器不存在');
return;
}
try {
// 🔥 直接停止录音onStop回调已在startVoiceRecord中注册
this.recorderManager.stop();
console.log(' 停止录音,等待处理...');
} catch (error) {
console.error('❌ 停止录音失败:', error);
wx.showToast({
title: '录音失败',
icon: 'none'
});
}
},
// 取消录音
cancelVoiceRecord() {
if (!this.recorderManager) {
console.warn('⚠️ 录音管理器不存在');
return;
}
try {
// 🔥 先设置取消标记确保onStop回调不会发送消息
this.voiceRecordCancelled = true;
this.recorderManager.stop();
console.log('✅ 录音已取消');
wx.showToast({
title: '已取消',
icon: 'none',
duration: 1500
});
} catch (error) {
console.error('❌ 取消录音失败:', error);
}
},
// 🔥 发送语音消息使用NIM SDK新录制的语音
async sendVoiceMessageWithNIM(filePath, duration) {
try {
console.log('📤 准备发送语音消息 (NIM直接调用):', {
filePath,
duration,
filePathType: typeof filePath,
filePathLength: filePath?.length,
targetId: this.data.targetId,
chatType: this.data.chatType
});
// 🔥 严格检查参数
if (!filePath || typeof filePath !== 'string' || filePath.trim() === '') {
console.error('❌ 语音文件路径无效:', filePath);
throw new Error('语音文件路径不能为空');
}
// 🔥 确保有会话ID首次进入可能未设置
let conversationId = this.data.conversationId;
if (!conversationId && this.data.targetId) {
conversationId = this.buildNIMConversationId(this.data.targetId, this.data.chatType);
this.setData({ conversationId });
console.log('✅ 自动生成会话ID:', conversationId);
}
if (!conversationId) {
console.error('❌ 会话ID为空');
throw new Error('会话ID不能为空');
}
wx.showLoading({
title: '发送中...'
});
// 🔥 获取当前用户的NIM账号ID
const currentNimAccountId = this.data.userInfo?.neteaseIMAccid || this.data.userInfo?.user?.neteaseIMAccid || '';
if (!currentNimAccountId) {
console.error('❌ 无法获取NIM账号ID');
throw new Error('无法获取当前用户的NIM账号ID');
}
console.log('✅ 参数检查通过,准备创建临时消息');
// 🔥 创建临时语音消息
const timestamp = Date.now();
const tempMessage = {
messageId: 'temp_voice_' + timestamp,
senderId: currentNimAccountId,
targetId: this.data.targetId,
content: {
duration: duration,
url: filePath // 临时使用本地路径
},
msgType: 'voice',
sendTime: timestamp,
timestamp: timestamp,
isSelf: true,
senderName: '我',
senderAvatar: this.data.userAvatar || '',
deliveryStatus: 3, // 发送中 (V2NIM_MESSAGE_SENDING_STATE_SENDING)
bubbleTime: this.formatBubbleTime(timestamp)
};
// 添加到消息列表
const messages = this.data.messages.concat([tempMessage]);
this.setMessagesWithDate(messages);
// 滚动到底部
setTimeout(() => this.scrollToBottom(), 100);
console.log('调用 NIM SDK 发送语音消息:', {
conversationId: conversationId,
targetId: this.data.targetId,
filePath: filePath,
duration: duration,
isGroup: this.data.chatType === 1
});
// 🔥 使用 NIM SDK 发送语音消息(使用 conversationId 而不是 targetId
const result = await nimConversationManager.sendAudioMessage(
conversationId, // 🔥 修复:使用 conversationId 而不是 targetId
filePath,
duration, // 已经是毫秒
{
isGroup: this.data.chatType === 1
}
);
wx.hideLoading();
console.log('✅ 语音消息发送成功:', result);
// 🔥 格式化并替换临时消息
if (result && result.message) {
const sentMessage = await this.formatMessage(result.message);
sentMessage.deliveryStatus = 1; // 发送成功 (V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED)
// 替换临时消息
const tempIndex = this.data.messages.findIndex(msg => msg.messageId === tempMessage.messageId);
if (tempIndex !== -1) {
const updatedMessages = [...this.data.messages];
updatedMessages[tempIndex] = sentMessage;
this.setMessagesWithDate(updatedMessages);
console.log('✅ 语音消息UI更新成功');
}
}
wx.showToast({
title: '发送成功',
icon: 'success'
});
} catch (error) {
wx.hideLoading();
console.error('❌ 发送语音消息失败:', error);
// 更新临时消息状态为失败
const messages = this.data.messages.map(msg => {
if (msg.messageId.startsWith('temp_voice_')) {
return { ...msg, deliveryStatus: 1 }; // 发送失败
}
return msg;
});
this.setMessagesWithDate(messages);
wx.showToast({
title: '发送失败: ' + error.message,
icon: 'none'
});
}
},
// 关闭表情面板的辅助方法
closeEmojiPanel() {
if (this.data.showEmojiPanel) {
this.setData({
showEmojiPanel: false,
emojiPanelHeight: 0
});
}
},
// 收起输入法并重置相关状态
hideInputMethod() {
try { wx.hideKeyboard(); } catch (e) { /* 忽略异常 */ }
this.setData({
keyboardHeight: 0,
inputFocus: false,
showEmojiPanel: false,
showMorePanel: false,
emojiPanelHeight: 0
});
},
// 切换表情面板
toggleEmojiPanel() {
const willShow = !this.data.showEmojiPanel;
// 调用通用方法收起输入法
this.hideInputMethod();
this.setData({
showEmojiPanel: willShow,
showMorePanel: false
}, () => {
if (willShow) {
// 表情面板展开后,测量其高度
wx.createSelectorQuery()
.select('.emoji-panel')
.boundingClientRect(rect => {
if (rect && rect.height) {
console.log('😀 表情面板高度:', rect.height, rect.height - 10);
this.setData({
emojiPanelHeight: rect.height - 10 // 减去一些间距
}, () => {
// 滚动到底部,确保最后的消息可见
this.scrollToBottom(true);
});
}
})
.exec();
} else {
// 表情面板关闭
this.setData({
emojiPanelHeight: 0
});
}
});
},
// 切换更多面板
toggleMorePanel() {
const willShow = !this.data.showMorePanel;
// 关闭表情面板
this.closeEmojiPanel();
// 调用通用方法收起输入法
this.hideInputMethod();
this.setData({
showMorePanel: willShow
});
},
// 阻止事件冒泡
stopPropagation() {
// 阻止事件冒泡,防止点击面板内容时关闭面板
},
// 选择表情
selectEmoji(e) {
const emoji = e.currentTarget.dataset.emoji;
const inputText = this.data.inputText + emoji;
this.setData({ inputText });
},
// 选择文件
selectFile() {
// 先收起面板
this.setData({ showMorePanel: false });
try {
wx.chooseMessageFile({
count: 1,
type: 'file',
success: async (res) => {
if (!res || !res.tempFiles || res.tempFiles.length === 0) {
wx.showToast({ title: '未选择文件', icon: 'none' });
return;
}
const file = res.tempFiles[0];
const filePath = file.path || file.tempFilePath;
const fileName = file.name || '文件';
const fileSize = file.size || 0;
const readableSize = this.formatFileSize(fileSize);
// 限制大小 200MB
if (fileSize > 200 * 1024 * 1024) {
wx.showToast({ title: '文件不能超过200MB', icon: 'none' });
return;
}
// 获取当前用户的NIM账号ID
const currentNimAccountId = this.data.userInfo?.neteaseIMAccid || this.data.userInfo?.user?.neteaseIMAccid || '';
if (!currentNimAccountId) {
wx.showToast({ title: 'NIM账号错误', icon: 'none' });
return;
}
// 创建临时消息
const timestamp = Date.now();
const clientTempId = 'temp_' + timestamp;
const tempMessage = {
messageId: clientTempId,
senderId: currentNimAccountId, // 🔥 使用NIM账号ID
targetId: this.data.targetId,
content: { url: '' },
msgType: 'file',
fileName: fileName,
fileSize: readableSize,
sendTime: timestamp,
timestamp: timestamp,
isSelf: true,
senderName: '我',
senderAvatar: this.data.userAvatar || '',
deliveryStatus: 3, // V2NIM_MESSAGE_SENDING_STATE_SENDING
bubbleTime: this.formatBubbleTime(timestamp)
};
this.setMessagesWithDate(this.data.messages.concat([tempMessage]));
setTimeout(() => this.scrollToBottom(), 100);
wx.showLoading({ title: '发送中...' });
try {
// 🔥 使用 NIM SDK 发送文件消息SDK会自动上传
const result = await nimConversationManager.sendFileMessage(
this.data.conversationId,
filePath
);
wx.hideLoading();
if (result && result.message) {
// 🔥 格式化并替换临时消息
const sentMessage = await this.formatMessage(result.message);
sentMessage.deliveryStatus = 1; // 发送成功 (V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED)
const updatedMessages = this.data.messages.map(msg =>
msg.messageId === tempMessage.messageId ? sentMessage : msg
);
this.setMessagesWithDate(updatedMessages);
console.log('✅ 文件消息UI更新成功');
} else {
throw new Error('NIM SDK 发送文件失败');
}
} catch (err) {
wx.hideLoading();
console.error('❌ 发送文件失败:', err);
// 更新为失败
const failedMessages = this.data.messages.map(msg => {
if (msg.messageId === tempMessage.messageId) {
return { ...msg, deliveryStatus: 1 }; // V2NIM_MESSAGE_SENDING_STATE_FAILED
}
return msg;
});
this.setData({ messages: failedMessages });
wx.hideLoading();
wx.showToast({ title: '发送失败', icon: 'none' });
}
},
fail: (error) => {
if (error && error.errMsg && !/cancel/.test(error.errMsg)) {
console.error('❌ 选择文件失败:', error);
wx.showToast({ title: '选择文件失败', icon: 'none' });
}
}
});
} catch (e) {
console.error('❌ 文件选择异常:', e);
wx.showToast({ title: '选择文件异常', icon: 'none' });
}
},
// 将字节大小格式化成人类可读
formatFileSize(bytes) {
try {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[i]}`;
} catch (e) {
return `${bytes} B`;
}
},
// 选择位置
selectLocation() {
wx.chooseLocation({
success: async (res) => {
try {
const { name, address, latitude, longitude } = res || {};
// 基本校验
if (latitude == null || longitude == null) {
wx.showToast({ title: '位置信息无效', icon: 'none' });
return;
}
// 获取当前用户的NIM账号ID
const currentNimAccountId = this.data.userInfo?.neteaseIMAccid || this.data.userInfo?.user?.neteaseIMAccid || '';
if (!currentNimAccountId) {
wx.showToast({ title: 'NIM账号错误', icon: 'none' });
return;
}
// 构造临时消息
const timestamp = Date.now();
const clientTempId = 'temp_' + timestamp;
const tempMsg = {
messageId: clientTempId,
senderId: currentNimAccountId, // 🔥 使用NIM账号ID
targetId: this.data.targetId,
msgType: 'location',
content: { latitude, longitude, address, locationName: name || '位置' },
locationName: name || '位置',
locationAddress: address || '',
latitude,
longitude,
sendTime: timestamp,
timestamp,
isSelf: true,
senderName: '我',
senderAvatar: this.data.userAvatar || '',
deliveryStatus: 3, // V2NIM_MESSAGE_SENDING_STATE_SENDING
bubbleTime: this.formatBubbleTime(timestamp)
};
this.setMessagesWithDate(this.data.messages.concat([tempMsg]));
setTimeout(() => this.scrollToBottom(), 100);
// 使用 NIM SDK 发送位置消息
try {
const result = await nimConversationManager.sendLocationMessage(
this.data.conversationId,
latitude,
longitude,
address || '',
name || '位置'
);
if (result && result.message) {
// 🔥 替换临时消息为正式消息
console.log('✅ 位置消息发送成功准备更新UI');
const sentMessage = await this.formatMessage(result.message);
// 🔥 强制设置为成功状态
sentMessage.deliveryStatus = 1; // V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
// 🔥 使用临时消息的索引位置来替换
const tempIndex = this.data.messages.findIndex(msg => msg.messageId === clientTempId);
if (tempIndex !== -1) {
const updatedMessages = [...this.data.messages];
updatedMessages[tempIndex] = sentMessage;
this.setMessagesWithDate(updatedMessages);
} else {
const updatedMessages = this.data.messages.concat([sentMessage]);
this.setMessagesWithDate(updatedMessages);
}
} else {
throw new Error('NIM SDK 发送位置失败');
}
} catch (sendErr) {
this.updateMessageDeliveryStatus(clientTempId, 1); // V2NIM_MESSAGE_SENDING_STATE_FAILED
throw sendErr;
}
} catch (err) {
console.error('❌ 发送位置失败:', err);
// 标记失败
if (clientTempId) this.updateMessageDeliveryStatus(clientTempId, 1); // V2NIM_MESSAGE_SENDING_STATE_FAILED
wx.showToast({ title: '位置发送失败', icon: 'none' });
}
},
fail: (e) => {
if (e && e.errMsg && !/cancel/.test(e.errMsg)) {
console.error('❌ 选择位置失败:', e);
wx.showToast({ title: '选择位置失败', icon: 'none' });
}
},
complete: () => {
this.setData({ showMorePanel: false });
}
});
},
// 图片加载错误处理
onImageError(e) {
console.error('❌ 图片加载失败:', e.detail);
const url = e.currentTarget.dataset.url;
wx.showToast({
title: '图片加载失败',
icon: 'none',
duration: 2000
});
},
// 预览图片 - 支持新的图片格式
previewImage(e) {
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
if (!url) {
wx.showToast({
title: '图片地址无效',
icon: 'none'
});
return;
}
// Base64格式需要特殊处理
if (type === 'base64' || url.startsWith('data:image/')) {
// 小程序的previewImage可以直接处理Base64
wx.previewImage({
current: url,
urls: [url]
});
} else {
// 普通URL格式
wx.previewImage({
current: url,
urls: [url]
});
}
},
// 播放音频
playAudio(e) {
const message = e.currentTarget.dataset.message;
// TODO: 实现音频播放
wx.showToast({
title: '音频播放功能开发中',
icon: 'none'
});
},
// 显示位置
showLocation(e) {
const message = e.currentTarget.dataset.message;
try {
// 统一解析位置内容content 可能是 JSON 字符串或对象)
const parsed = this.parseLocationContent(message.content) || {};
const lat = message.latitude ?? parsed.latitude;
const lng = message.longitude ?? parsed.longitude;
const name = message.locationName || parsed.locationName || '位置';
const address = message.locationAddress || parsed.address || '';
if (lat == null || lng == null) {
wx.showToast({ title: '无效位置', icon: 'none' });
return;
}
wx.openLocation({
latitude: Number(lat),
longitude: Number(lng),
name: name,
address: address,
scale: 16
});
} catch (err) {
console.error('❌ 打开位置失败:', err);
wx.showToast({ title: '打开位置失败', icon: 'none' });
}
},
// 显示消息菜单
showMessageMenu(e) {
const message = e.currentTarget.dataset.message;
const itemList = [];
// 撤回的消息不显示菜单
if (message.isRecalled) {
return;
}
// 普通消息的菜单选项
if (message.msgType === 'text') {
itemList.push('复制');
}
if (message.isSelf) {
// 检查是否可以撤回2分钟内
const canRecall = this.canRecallMessage(message);
if (canRecall) {
itemList.push('撤回');
}
}
// 如果没有可用操作,直接返回
if (itemList.length === 0) {
return;
}
wx.showActionSheet({
itemList: itemList,
success: (res) => {
const action = itemList[res.tapIndex];
switch (action) {
case '复制':
this.copyMessage(message);
break;
case '撤回':
this.recallMessage(message);
break;
}
}
});
},
// 复制消息
copyMessage(message) {
if (message.msgType === 'text') {
wx.setClipboardData({
data: message.content,
success: () => {
wx.showToast({
title: '已复制',
icon: 'success'
});
}
});
}
},
// 撤回消息
async recallMessage(message) {
// 检查撤回时间限制
if (!this.canRecallMessage(message)) {
wx.showToast({
title: '超过2分钟无法撤回',
icon: 'none'
});
return;
}
wx.showModal({
title: '确认撤回',
content: '确定要撤回这条消息吗?',
success: async (res) => {
if (res.confirm) {
try {
// 显示加载状态
wx.showLoading({
title: '撤回中...',
mask: true
});
// message._rawMessage 是保存的 NIM SDK 原始消息对象
const rawMessage = message._rawMessage;
if (!rawMessage) {
throw new Error('消息对象不完整,无法撤回');
}
const result = await nimConversationManager.recallMessage(
this.data.conversationId,
rawMessage // 传入 NIM SDK 原始消息对象
);
if (result) {
// 撤回成功,本地立即更新消息状态为已撤回
this.updateMessageToRecalled(message.messageId);
wx.showToast({
title: '已撤回',
icon: 'success'
});
} else {
throw new Error('撤回失败');
}
} catch (error) {
console.error('撤回消息失败:', error);
wx.showToast({
title: error.message || '撤回失败',
icon: 'none'
});
} finally {
wx.hideLoading();
}
}
}
});
},
// 检查是否可以撤回消息
canRecallMessage(message) {
if (!message.isSelf) return false;
if (message.msgType === 'system' || message.isRecalled) return false;
// 检查时间限制2分钟 = 120秒
const now = Date.now();
const messageTime = message.timestamp || message.createTime || now;
const timeDiff = (now - messageTime) / 1000; // 转换为秒
return timeDiff <= 120; // 2分钟内可撤回
},
// 更新消息为已撤回状态
updateMessageToRecalled(messageId) {
const messages = this.data.messages.map(msg => {
if (msg.messageId === messageId) {
const recallText = msg.isSelf ? '你撤回了一条消息' : '对方撤回了一条消息';
return {
...msg,
isRecalled: true,
content: recallText,
originalContent: msg.content,
recallTime: Date.now(),
msgType: 'text'
};
}
return msg;
});
this.setMessagesWithDate(messages);
// 同步撤回提示到本地,供会话列表页读取
try {
const convId = this.data.conversationId;
if (convId) {
const hints = wx.getStorageSync('recallHints') || {};
// 使用实际的撤回消息文本,而不是固定的"你撤回了一条消息"
const revokedMsg = messages.find(m => m.messageId === messageId);
const previewText = revokedMsg?.content || '撤回了一条消息';
hints[convId] = { preview: previewText, updatedAt: Date.now() };
wx.setStorageSync('recallHints', hints);
}
} catch (e) {
console.warn('写入撤回提示失败', e);
}
},
// 重发消息
async resendMessage(e) {
const message = e.currentTarget.dataset.message;
if (!message) return;
console.log('🔁 重发消息 (NIM SDK):', message);
// 标记为发送中
this.updateMessageDeliveryStatus(message.messageId, 2); // V2NIM_MESSAGE_SENDING_STATE_SENDING
try {
let result = null;
const conversationId = this.data.conversationId;
console.log('🔁 重发消息使用会话ID:', conversationId);
switch (message.msgType) {
case 'text':
result = await nimConversationManager.sendTextMessage(
conversationId,
message.content
);
break;
case 'image':
case 'video':
case 'voice':
case 'file':
// ⚠️ 图片/视频/语音/文件消息重发需要本地文件路径
// 但发送失败的消息已经没有本地路径,无法重发
wx.showToast({
title: '媒体消息暂不支持重发',
icon: 'none'
});
this.updateMessageDeliveryStatus(message.messageId, 1);
return;
case 'location': {
const lat = message.latitude ?? message.content?.latitude;
const lng = message.longitude ?? message.content?.longitude;
const address = message.locationAddress || message.content?.address || '';
const name = message.locationName || message.content?.locationName || '位置';
result = await nimConversationManager.sendLocationMessage(
conversationId,
lat,
lng,
address,
name
);
break;
}
default:
throw new Error('不支持的消息类型');
}
if (result && result.message) {
// 替换为新消息 - 使用索引定位避免ID不匹配
const sentMessage = await this.formatMessage(result.message);
// 🔥 强制设置为成功状态
sentMessage.deliveryStatus = 1; // V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
const msgIndex = this.data.messages.findIndex(msg => msg.messageId === message.messageId);
if (msgIndex !== -1) {
const updatedMessages = [...this.data.messages];
updatedMessages[msgIndex] = sentMessage;
console.log('✅ 重发消息替换成功:', {
原消息ID: message.messageId,
新消息ID: sentMessage.messageId,
索引位置: msgIndex
});
this.setMessagesWithDate(updatedMessages);
} else {
console.warn('⚠️ 未找到原消息,直接添加重发消息');
this.setMessagesWithDate([...this.data.messages, sentMessage]);
}
} else {
throw new Error('发送失败');
}
} catch (err) {
console.error('❌ 重发失败:', err);
this.updateMessageDeliveryStatus(message.messageId, 1); // V2NIM_MESSAGE_SENDING_STATE_FAILED
wx.showToast({ title: '发送失败', icon: 'none' });
}
},
// 将消息置为失败并可选记录原因
markMessageFailed(messageId, reason) {
const messages = this.data.messages.map(m => m.messageId === messageId ? { ...m, deliveryStatus: 1, errorReason: reason || '' } : m); // V2NIM_MESSAGE_SENDING_STATE_FAILED
this.setData({ messages });
},
// 🔥 加载更多消息 - 仅在用户向上滚动时触发
loadMoreMessages() {
console.log('🔄 用户向上滚动,触发加载更多历史消息');
console.log('🔍 当前状态检查:', {
hasMore: this.data.hasMore,
loadingMessages: this.data.loadingMessages,
lastMessageTime: this.data.lastMessageTime,
messagesCount: this.data.messages.length
});
// 🔥 检查是否还有更多消息
if (!this.data.hasMore) {
return;
}
// 🔥 检查是否正在加载中
if (this.data.loadingMessages) {
return;
}
// 🔥 检查是否有lastMessageTime用于分页
if (!this.data.lastMessageTime) {
console.log('⚠️ 缺少lastMessageTime无法加载更多');
this.setData({ hasMore: false });
return;
}
console.log('✅ 开始加载更多历史消息');
this.loadMessages(true); // 传递true表示是加载更多
},
// 滚动事件处理
onScroll(e) {
const { scrollTop, scrollHeight } = e.detail;
const windowHeight = this.data.windowHeight - 200; // 减去输入框高度
// 计算距离底部的距离
const distanceFromBottom = scrollHeight - scrollTop - windowHeight;
// 如果距离底部超过一个屏幕高度,显示滚动到底部按钮
const shouldShow = distanceFromBottom > windowHeight;
if (this.data.showScrollToBottom !== shouldShow) {
this.setData({
showScrollToBottom: shouldShow
});
}
},
// 🔥 滚动到底部 - 参考Flutter app的实现
scrollToBottom() {
if (this.data.messages.length === 0) return;
const lastMessage = this.data.messages[this.data.messages.length - 1];
if (!lastMessage) return;
// 🔥 参考Flutter的animateTo(maxScrollExtent)实现
// 方法1使用scrollIntoView精确定位到最后一条消息
const scrollIntoViewId = 'msg-' + lastMessage.messageId;
this.setData({
scrollIntoView: scrollIntoViewId,
scrollTop: 999999, // 🔥 同时设置一个大值确保滚动到底部
showScrollToBottom: false // 🔥 滚动到底部后隐藏按钮
});
// 🔥 延迟再次滚动确保DOM完全渲染模仿Flutter的延迟机制
setTimeout(() => {
this.setData({
scrollIntoView: scrollIntoViewId,
scrollTop: 999999
});
}, 100);
// 🔥 最后的保障:强制滚动到底部
setTimeout(() => {
this.setData({
scrollTop: 999999
});
}, 300);
},
// 下载文件
downloadFile(e) {
const message = e.currentTarget.dataset.message;
if (!message.content || !message.content.url) {
wx.showToast({
title: '文件地址无效',
icon: 'none'
});
return;
}
wx.showModal({
title: '下载文件',
content: `确定要下载文件 "${message.fileName || '未知文件'}" 吗?`,
success: (res) => {
if (res.confirm) {
wx.downloadFile({
url: message.content.url,
success: (downloadRes) => {
if (downloadRes.statusCode === 200) {
wx.openDocument({
filePath: downloadRes.tempFilePath,
success: () => {
},
fail: (err) => {
console.error('❌ 文件打开失败:', err);
wx.showToast({
title: '文件打开失败',
icon: 'none'
});
}
});
}
},
fail: (err) => {
console.error('❌ 文件下载失败:', err);
wx.showToast({
title: '文件下载失败',
icon: 'none'
});
}
});
}
}
});
},
});