findme-miniprogram-frontend/pages/message/chat/chat.js
2025-12-27 17:16:03 +08:00

3222 lines
103 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 聊天页面逻辑
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'
});
}
});
}
}
});
},
});