3085 lines
No EOL
98 KiB
JavaScript
3085 lines
No EOL
98 KiB
JavaScript
// 聊天页面逻辑
|
||
const app = getApp();
|
||
const apiClient = require('../../../utils/api-client.js');
|
||
const wsManager = require('../../../utils/websocket-manager-v2.js');
|
||
const wsdiagnostic = require('../../../utils/websocket-diagnostic.js');
|
||
const mediaPicker = require('../../../utils/media-picker.js');
|
||
const voiceMessageManager = require('../../../utils/voice-message-manager.js');
|
||
const { initPageSystemInfo } = require('../../../utils/system-info-modern.js');
|
||
const imageCacheManager = require('../../../utils/image-cache-manager.js');
|
||
|
||
Page({
|
||
data: {
|
||
// 聊天基本信息
|
||
conversationId: '',
|
||
targetId: '',
|
||
chatName: '',
|
||
chatType: 0, // 0: 单聊, 1: 群聊
|
||
isOnline: false,
|
||
isNewConversation: false, // 是否为新会话
|
||
|
||
// 消息数据
|
||
messages: [],
|
||
hasMore: true,
|
||
lastMessageId: '',
|
||
unreadCount: 0,
|
||
loadingMessages: false, // 🔥 添加加载状态
|
||
|
||
// 输入相关
|
||
inputType: 'text', // 'text' 或 'voice'
|
||
inputText: '',
|
||
inputFocus: false,
|
||
recording: false,
|
||
isComposing: false, // 中文输入法状态
|
||
|
||
// 面板状态
|
||
showEmojiPanel: false,
|
||
showMorePanel: false,
|
||
showVoiceRecorder: false,
|
||
|
||
// 滚动相关
|
||
scrollTop: 0,
|
||
scrollIntoView: '',
|
||
showScrollToBottom: false, // 🔥 控制滚动到底部按钮显示
|
||
|
||
// 常用表情
|
||
commonEmojis: [
|
||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
|
||
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙',
|
||
'😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔',
|
||
'🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
|
||
'😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮',
|
||
'🤧', '🥵', '🥶', '🥴', '😵', '🤯', '🤠', '🥳', '🥸', '😎',
|
||
'🤓', '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', '😳',
|
||
'🥺', '😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖',
|
||
'😣', '😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬',
|
||
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞',
|
||
'🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍',
|
||
'👎', '👊', '✊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝'
|
||
],
|
||
|
||
// 用户信息
|
||
userInfo: null,
|
||
userAvatar: '',
|
||
targetUserInfo: null, // 对方用户信息
|
||
targetAvatar: '', // 对方头像
|
||
|
||
// 消息相关
|
||
messages: [],
|
||
hasMore: true,
|
||
lastMessageId: '',
|
||
loadingMessages: false, // 🔥 添加加载状态字段
|
||
|
||
// 系统适配信息
|
||
systemInfo: {},
|
||
statusBarHeight: 0,
|
||
menuButtonHeight: 0,
|
||
menuButtonTop: 0,
|
||
navBarHeight: 0,
|
||
windowHeight: 0,
|
||
safeAreaBottom: 0,
|
||
|
||
// 表情数据
|
||
emojis: [
|
||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
|
||
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙',
|
||
'😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔'
|
||
],
|
||
|
||
// 🔥 新增:组件状态(不影响现有功能)
|
||
// 媒体预览组件
|
||
mediaPreviewVisible: false,
|
||
mediaPreviewList: [],
|
||
currentMediaIndex: 0,
|
||
|
||
// 消息操作菜单组件
|
||
messageActionVisible: false,
|
||
currentMessage: null,
|
||
isOwnMessage: false,
|
||
|
||
// @提醒选择器组件
|
||
mentionSelectorVisible: false,
|
||
|
||
// 加载状态
|
||
isLoading: false,
|
||
|
||
// 主题相关
|
||
themeClass: 'theme-dark',
|
||
isThemeTransitioning: false,
|
||
// 主题切换径向遮罩
|
||
showThemeOverlay: false,
|
||
overlayPlaying: false,
|
||
overlayTo: 'theme-light',
|
||
overlayX: 0,
|
||
overlayY: 0,
|
||
overlayDiameter: 0,
|
||
overlayLeft: 0,
|
||
overlayTop: 0
|
||
},
|
||
|
||
async onLoad(options) {
|
||
console.log('聊天页面加载:', options);
|
||
|
||
// 显示加载状态
|
||
this.setData({ isLoading: true });
|
||
|
||
// 初始化系统信息(同步,立即完成)
|
||
this.initSystemInfo();
|
||
|
||
// 🔥 并行执行用户信息初始化和聊天信息初始化
|
||
const userInfoPromise = this.initUserInfo();
|
||
const chatInfoPromise = this.initChatInfo(options);
|
||
|
||
// 等待用户信息完成(消息加载需要用户信息)
|
||
await userInfoPromise;
|
||
|
||
// 初始化WebSocket(不等待)
|
||
this.initWebSocket();
|
||
|
||
// 等待聊天信息初始化完成
|
||
await chatInfoPromise;
|
||
|
||
// 隐藏加载状态
|
||
this.setData({ isLoading: false });
|
||
},
|
||
|
||
onShow: function () {
|
||
// 初始化主题
|
||
const savedTheme = wx.getStorageSync('theme') || 'theme-dark';
|
||
this.setData({ themeClass: savedTheme });
|
||
|
||
// 页面显示时连接WebSocket
|
||
if (wsManager && !wsManager.isConnected) {
|
||
wsManager.connect();
|
||
}
|
||
|
||
// 🔥 进入聊天页面时自动标记所有消息已读
|
||
this.markAllMessagesRead();
|
||
},
|
||
|
||
onHide: function () {
|
||
// 页面隐藏时可以保持WebSocket连接,因为其他页面可能需要
|
||
},
|
||
|
||
onUnload: function () {
|
||
// 页面卸载时清理WebSocket监听器
|
||
if (wsManager) {
|
||
wsManager.off('new_message');
|
||
wsManager.off('message_status');
|
||
wsManager.off('message_recalled');
|
||
}
|
||
},
|
||
|
||
// 初始化系统信息
|
||
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
|
||
});
|
||
},
|
||
|
||
// 初始化聊天信息 - 修正:从API获取正确的conversationId
|
||
async initChatInfo(options) {
|
||
const inputConversationId = options.conversationId || '';
|
||
const targetId = options.targetId || '';
|
||
const chatName = decodeURIComponent(options.name || '聊天');
|
||
const chatType = parseInt(options.chatType || 0);
|
||
|
||
// 临时设置基本信息
|
||
this.setData({
|
||
targetId,
|
||
chatName,
|
||
chatType,
|
||
conversationId: inputConversationId // 临时设置,等API返回正确的
|
||
});
|
||
|
||
// 🔥 动态设置导航栏标题为用户昵称
|
||
wx.setNavigationBarTitle({
|
||
title: chatName
|
||
});
|
||
|
||
console.log('聊天信息初始化:', {
|
||
inputConversationId,
|
||
targetId,
|
||
chatName,
|
||
chatType
|
||
});
|
||
|
||
// 🔥 从API获取正确的conversationId
|
||
await this.getCorrectConversationId(targetId, chatType);
|
||
},
|
||
|
||
// 从API获取正确的conversationId
|
||
async getCorrectConversationId(targetId, chatType) {
|
||
try {
|
||
console.log('🔍 从API获取正确的conversationId...');
|
||
|
||
// 调用conversations接口获取会话列表
|
||
const response = await apiClient.getConversations();
|
||
|
||
if (response && response.code === 0) {
|
||
const conversations = response.data || [];
|
||
console.log('✅ 获取到会话列表:', conversations.length, '个');
|
||
|
||
// 🔥 调试:显示所有会话信息
|
||
console.log('🔍 调试会话查找:');
|
||
console.log('- 目标targetId:', targetId);
|
||
console.log('- 聊天类型chatType:', chatType);
|
||
console.log('- 会话列表:', conversations.map(conv => ({
|
||
id: conv.id || conv.conversationId,
|
||
targetId: conv.targetId,
|
||
type: conv.type,
|
||
targetName: conv.targetName
|
||
})));
|
||
|
||
// 查找与目标用户的会话
|
||
let targetConversation = null;
|
||
|
||
if (chatType === 0) {
|
||
// 单聊:查找targetId匹配的会话
|
||
targetConversation = conversations.find(conv =>
|
||
conv.type === 0 && conv.targetId === targetId
|
||
);
|
||
console.log('🔍 单聊查找结果:', targetConversation);
|
||
} else {
|
||
// 群聊:查找群ID匹配的会话
|
||
targetConversation = conversations.find(conv =>
|
||
conv.type === 1 && conv.targetId === targetId
|
||
);
|
||
console.log('🔍 群聊查找结果:', targetConversation);
|
||
}
|
||
|
||
// 🔥 检查会话ID字段
|
||
const conversationId = targetConversation?.id || targetConversation?.conversationId;
|
||
console.log('🔍 会话ID检查:', {
|
||
hasConversation: !!targetConversation,
|
||
id: targetConversation?.id,
|
||
conversationId: targetConversation?.conversationId,
|
||
finalId: conversationId
|
||
});
|
||
|
||
if (targetConversation && conversationId) {
|
||
// 🔥 找到了现有会话,使用API返回的会话ID
|
||
console.log('✅ 找到现有会话:', conversationId);
|
||
|
||
this.setData({
|
||
conversationId: conversationId,
|
||
// 同时更新其他会话信息
|
||
chatName: targetConversation.targetName || this.data.chatName,
|
||
unreadCount: targetConversation.unreadCount || 0,
|
||
isNewConversation: false
|
||
});
|
||
|
||
// 🔥 并行获取对方用户信息和加载消息
|
||
if (chatType === 0) {
|
||
// 不等待头像加载完成,立即开始加载消息
|
||
this.loadTargetUserInfo(targetId).catch(error => {
|
||
console.error('获取对方用户信息失败:', error);
|
||
});
|
||
}
|
||
|
||
// 立即加载消息,不等待头像
|
||
this.loadMessages();
|
||
|
||
} else {
|
||
// 🔥 没有找到现有会话,这是新会话,不设置conversationId
|
||
// 等发送第一条消息时,后端会创建会话并返回会话ID
|
||
console.log('⚠️ 未找到现有会话,这是新会话');
|
||
|
||
this.setData({
|
||
conversationId: '', // 🔥 新会话不设置ID
|
||
isNewConversation: true,
|
||
messages: [],
|
||
hasMore: false
|
||
});
|
||
|
||
// 🔥 获取对方用户信息(包括头像)- 异步执行
|
||
if (chatType === 0) {
|
||
this.loadTargetUserInfo(targetId).catch(error => {
|
||
console.error('获取对方用户信息失败:', error);
|
||
});
|
||
}
|
||
|
||
console.log('🆕 新会话,等待发送消息时创建');
|
||
}
|
||
|
||
} else {
|
||
throw new Error(response?.message || '获取会话列表失败');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ 获取conversationId失败:', error);
|
||
|
||
// 🔥 对于新会话,不设置conversationId,等后端创建
|
||
this.setData({
|
||
conversationId: '',
|
||
isNewConversation: true,
|
||
messages: [],
|
||
hasMore: false
|
||
});
|
||
|
||
// 🔥 获取对方用户信息(包括头像)
|
||
if (this.data.chatType === 0) {
|
||
await this.loadTargetUserInfo(this.data.targetId);
|
||
}
|
||
|
||
console.log('🔄 降级处理:新会话模式');
|
||
}
|
||
},
|
||
|
||
// 🔥 新增:获取对方用户信息(包括头像)
|
||
async loadTargetUserInfo(targetId) {
|
||
try {
|
||
console.log('👤 开始获取对方用户信息:', targetId);
|
||
|
||
// 调用好友详情API获取用户信息
|
||
const response = await apiClient.getFriendDetail(targetId);
|
||
|
||
if (response && response.code === 0) {
|
||
const userInfo = response.data;
|
||
console.log('✅ 获取到对方用户信息:', userInfo);
|
||
|
||
// 设置对方头像
|
||
const targetAvatar = userInfo.avatar || '';
|
||
console.log('👤 设置对方头像:', targetAvatar);
|
||
|
||
// 🔥 先设置用户信息,头像缓存异步执行
|
||
this.setData({
|
||
targetUserInfo: userInfo,
|
||
targetAvatar: targetAvatar // 先使用原始URL
|
||
});
|
||
|
||
// 🔥 异步缓存头像,不阻塞UI
|
||
imageCacheManager.cacheAvatar(targetAvatar).then(cachedAvatar => {
|
||
this.setData({
|
||
targetAvatar: cachedAvatar
|
||
});
|
||
console.log('✅ 对方头像缓存完成');
|
||
}).catch(error => {
|
||
console.error('❌ 对方头像缓存失败:', error);
|
||
});
|
||
|
||
} else {
|
||
console.warn('⚠️ 获取对方用户信息失败:', response?.message);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ 获取对方用户信息异常:', 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;
|
||
console.log('从本地存储恢复用户信息');
|
||
}
|
||
} catch (error) {
|
||
console.error('从本地存储获取用户信息失败:', error);
|
||
}
|
||
}
|
||
|
||
// 确保有用户信息
|
||
if (userInfo && userInfo.token) {
|
||
// 正确获取用户头像
|
||
const userAvatar = userInfo.user?.avatar || userInfo.avatar || '';
|
||
console.log('👤 设置用户头像:', userAvatar);
|
||
console.log('👤 用户信息结构:', userInfo);
|
||
|
||
// 🔥 先设置用户信息,头像缓存异步执行
|
||
this.setData({
|
||
userInfo: userInfo,
|
||
userAvatar: userAvatar // 先使用原始URL
|
||
});
|
||
|
||
// 🔥 异步缓存头像,不阻塞UI
|
||
imageCacheManager.cacheAvatar(userAvatar).then(cachedAvatar => {
|
||
this.setData({
|
||
userAvatar: cachedAvatar
|
||
});
|
||
console.log('✅ 用户头像缓存完成');
|
||
}).catch(error => {
|
||
console.error('❌ 用户头像缓存失败:', error);
|
||
});
|
||
console.log('用户信息初始化成功:', userInfo.user?.customId || userInfo.customId);
|
||
} else {
|
||
console.error('用户信息初始化失败,跳转到登录页');
|
||
wx.reLaunch({
|
||
url: '/pages/login/login'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 初始化WebSocket
|
||
initWebSocket() {
|
||
console.log('🔌 初始化WebSocket连接...');
|
||
|
||
// 🔥 禁用WebSocket诊断,避免创建额外连接
|
||
// this.runWebSocketDiagnostic();
|
||
|
||
// 获取用户token
|
||
const token = this.data.userInfo?.token || wx.getStorageSync('token');
|
||
if (!token) {
|
||
console.error('❌ 用户token不存在,无法连接WebSocket');
|
||
return;
|
||
}
|
||
|
||
// 设置WebSocket管理器的token
|
||
wsManager.setToken(token);
|
||
|
||
// 连接WebSocket
|
||
wsManager.connect().then(() => {
|
||
console.log('✅ WebSocket连接成功');
|
||
|
||
// 监听新消息
|
||
wsManager.on('new_message', (messageData) => {
|
||
console.log('📨 收到新消息:', messageData);
|
||
this.handleNewMessage(messageData);
|
||
});
|
||
|
||
// 监听消息状态更新(兼容 data 包裹与直传两种格式)
|
||
wsManager.on('message_status', (statusMsg) => {
|
||
const payload = statusMsg && statusMsg.data ? statusMsg.data : statusMsg;
|
||
console.log('📊 消息状态更新:', payload);
|
||
this.updateMessageStatus(payload);
|
||
});
|
||
|
||
// 监听服务端发送回执,使用clientTempId替换临时消息
|
||
wsManager.on('message_sent', async (sentData) => {
|
||
try {
|
||
const srv = sentData?.data || {};
|
||
const formatted = await this.formatMessage(srv);
|
||
|
||
let replaced = false;
|
||
const newMessages = this.data.messages.map(msg => {
|
||
if (!replaced && msg.isSelf && (msg.deliveryStatus === 'sending' || msg.deliveryStatus === 'uploading')) {
|
||
const matchByTempId = srv.clientTempId && msg.messageId === srv.clientTempId;
|
||
if (matchByTempId) {
|
||
replaced = true;
|
||
return { ...formatted, deliveryStatus: 'sent' };
|
||
}
|
||
}
|
||
return msg;
|
||
});
|
||
|
||
this.setMessagesWithDate(replaced ? newMessages : this.data.messages.concat([{ ...formatted, deliveryStatus: 'sent' }]));
|
||
setTimeout(() => this.scrollToBottom(), 100);
|
||
} catch (e) {
|
||
console.error('❌ 处理message_sent回执失败:', e);
|
||
}
|
||
});
|
||
|
||
// 监听服务端发送回执,用服务端消息替换临时消息,避免重复
|
||
wsManager.on('message_sent', async (sentData) => {
|
||
try {
|
||
// sentData.data 内应包含服务端消息详情
|
||
const srv = sentData?.data || {};
|
||
const formatted = await this.formatMessage(srv);
|
||
|
||
// 查找最近一条同类型、自己发送、且仍为sending/上传完成的临时消息进行替换
|
||
let replaced = false;
|
||
const newMessages = this.data.messages.map(msg => {
|
||
if (!replaced && msg.isSelf && (msg.msgType === formatted.msgType) && (msg.deliveryStatus === 'sending' || msg.deliveryStatus === 'uploading')) {
|
||
// 文件/图片等有可能通过content.url对齐
|
||
const sameUrl = (typeof msg.content === 'object' ? msg.content.url : msg.content) === (typeof formatted.content === 'object' ? formatted.content.url : formatted.content);
|
||
// 近5分钟内的临时消息
|
||
const closeTime = Math.abs((msg.timestamp || 0) - (formatted.timestamp || 0)) < 5 * 60 * 1000;
|
||
if (sameUrl || closeTime) {
|
||
replaced = true;
|
||
return { ...formatted, deliveryStatus: 'sent' };
|
||
}
|
||
}
|
||
return msg;
|
||
});
|
||
|
||
// 如果未替换,直接追加(兜底)
|
||
this.setMessagesWithDate(replaced ? newMessages : newMessages.concat([{ ...formatted, deliveryStatus: 'sent' }]));
|
||
|
||
// 滚动到底
|
||
setTimeout(() => this.scrollToBottom(), 100);
|
||
} catch (e) {
|
||
console.error('❌ 处理message_sent回执失败:', e);
|
||
}
|
||
});
|
||
|
||
// 监听消息撤回
|
||
wsManager.on('message_recalled', (recallData) => {
|
||
console.log('↩️ 收到消息撤回通知:', recallData);
|
||
this.handleMessageRecalled(recallData);
|
||
});
|
||
|
||
// 监听连接状态变化
|
||
wsManager.on('connection_status', (status) => {
|
||
console.log('🔌 WebSocket连接状态变化:', status);
|
||
this.setData({ wsConnected: status.connected });
|
||
});
|
||
|
||
}).catch((error) => {
|
||
console.error('❌ WebSocket连接失败:', error);
|
||
this.setData({ wsConnected: false });
|
||
});
|
||
},
|
||
|
||
// 🔥 运行WebSocket连接全面诊断
|
||
async runWebSocketDiagnostic() {
|
||
console.log('🔍 开始WebSocket连接全面诊断...');
|
||
|
||
try {
|
||
const results = await wsdiagnostic.runFullDiagnostic();
|
||
|
||
// 检查诊断结果
|
||
const wsResult = results.find(r => r.test === 'WebSocket连接');
|
||
if (wsResult && wsResult.status === 'success') {
|
||
console.log('✅ WebSocket连接诊断通过,可以正常连接');
|
||
} else {
|
||
console.error('❌ WebSocket连接诊断失败,请查看上方的详细报告');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ WebSocket诊断异常:', error);
|
||
}
|
||
},
|
||
|
||
// 处理新消息
|
||
async handleNewMessage(messageData) {
|
||
console.log('🔍 收到新消息,开始处理:', messageData);
|
||
console.log('🔍 当前会话ID:', this.data.conversationId);
|
||
console.log('🔍 消息会话ID:', messageData.conversationId);
|
||
|
||
// 检查是否是当前会话的消息
|
||
if (messageData.conversationId !== this.data.conversationId) {
|
||
console.log('⚠️ 消息会话ID不匹配,跳过处理');
|
||
console.log('⚠️ 期望:', this.data.conversationId);
|
||
console.log('⚠️ 实际:', messageData.conversationId);
|
||
return;
|
||
}
|
||
|
||
console.log('✅ 消息会话ID匹配,开始格式化消息');
|
||
const newMessage = await this.formatMessage(messageData);
|
||
console.log('✅ 消息格式化完成:', newMessage);
|
||
|
||
const messages = [...this.data.messages, newMessage];
|
||
this.setMessagesWithDate(messages);
|
||
|
||
console.log('✅ 消息已添加到列表,当前消息数:', messages.length);
|
||
|
||
// 🔥 确保新消息后滚动到底部
|
||
setTimeout(() => {
|
||
this.scrollToBottom();
|
||
}, 100);
|
||
|
||
// 发送已读回执
|
||
this.sendReadReceipt(messageData.messageId);
|
||
},
|
||
|
||
// 更新消息状态(兼容多格式payload)
|
||
updateMessageStatus(statusData) {
|
||
try {
|
||
const payload = statusData || {};
|
||
const messageId = payload.messageId || payload.id || payload.msgId;
|
||
// 可能是 data.status 或 payload.status 或 payload.state
|
||
const rawStatus = payload.status !== undefined ? payload.status : (payload.state !== undefined ? payload.state : payload.deliveryStatus);
|
||
|
||
// 将后端状态统一映射为前端 deliveryStatus
|
||
let mapped = this.convertStatus(rawStatus);
|
||
// 显式错误态
|
||
if (rawStatus === 'error' || rawStatus === 'fail' || rawStatus === 'failed') {
|
||
mapped = 'failed';
|
||
}
|
||
|
||
if (!messageId) {
|
||
console.warn('⚠️ message_status 缺少 messageId,忽略:', payload);
|
||
return;
|
||
}
|
||
|
||
const messages = this.data.messages.map(msg => {
|
||
if (msg.messageId === messageId) {
|
||
// 仅对自己发送的消息展示状态;他人消息不显示 deliveryStatus
|
||
if (msg.isSelf) {
|
||
return { ...msg, deliveryStatus: mapped };
|
||
}
|
||
}
|
||
return msg;
|
||
});
|
||
|
||
this.setData({ messages });
|
||
} catch (e) {
|
||
console.error('❌ 更新消息状态失败:', e, statusData);
|
||
}
|
||
},
|
||
|
||
// 处理收到的消息撤回通知
|
||
handleMessageRecalled(recallData) {
|
||
const { messageId, senderName } = recallData;
|
||
const messages = this.data.messages.map(msg => {
|
||
if (msg.messageId === messageId) {
|
||
return {
|
||
...msg,
|
||
isRecalled: true,
|
||
content: msg.isSelf ? '你撤回了一条消息' : `${senderName || '对方'}撤回了一条消息`,
|
||
originalContent: msg.content, // 保存原始内容
|
||
recallTime: Date.now(),
|
||
msgType: 'system' // 改为系统消息样式
|
||
};
|
||
}
|
||
return msg;
|
||
});
|
||
this.setMessagesWithDate(messages);
|
||
},
|
||
|
||
// 加载消息 - 使用正确的API接口和分页逻辑
|
||
async loadMessages(isLoadMore = false) {
|
||
try {
|
||
console.log('📨 加载聊天消息:', {
|
||
isLoadMore,
|
||
targetId: this.data.targetId,
|
||
chatType: this.data.chatType,
|
||
conversationId: this.data.conversationId,
|
||
lastMessageId: this.data.lastMessageId
|
||
});
|
||
|
||
// 检查必要参数
|
||
if (!this.data.targetId) {
|
||
console.log('⚠️ 缺少targetId,无法加载消息');
|
||
return;
|
||
}
|
||
|
||
// 🔥 避免重复请求
|
||
if (this.data.loadingMessages) {
|
||
console.log('⚠️ 消息正在加载中,跳过重复请求');
|
||
console.log('🔍 当前loadingMessages状态:', this.data.loadingMessages);
|
||
return;
|
||
}
|
||
|
||
// 设置加载状态
|
||
this.setData({ loadingMessages: true });
|
||
console.log('🔧 loadMessages设置loadingMessages: true');
|
||
|
||
// 🔥 使用正确的API接口:GET /api/v1/chat/history
|
||
const params = {
|
||
receiverId: this.data.targetId,
|
||
chatType: this.data.chatType,
|
||
limit: 20,
|
||
direction: 'before'
|
||
};
|
||
|
||
// 如果是加载更多,添加lastMsgId参数
|
||
if (isLoadMore && this.data.lastMessageId) {
|
||
params.lastMsgId = this.data.lastMessageId;
|
||
}
|
||
|
||
console.log('📡 API请求参数:', params);
|
||
|
||
// 调用API
|
||
const response = await apiClient.get(`/api/v1/chat/history`, params);
|
||
|
||
if (response && response.code === 0) {
|
||
const newMessages = response.data || [];
|
||
console.log('✅ 获取到消息:', newMessages.length, '条');
|
||
|
||
// 🔥 异步格式化消息(包括头像缓存)
|
||
const formattedMessages = await Promise.all(newMessages.map(msg => this.formatMessage(msg)));
|
||
|
||
// 🔥 判断是否是首次加载
|
||
const isFirstLoad = this.data.messages.length === 0;
|
||
|
||
// 🔥 修正hasMore逻辑:如果返回的消息数等于请求数,说明可能还有更多
|
||
const hasMoreMessages = newMessages.length === 20;
|
||
console.log('📊 hasMore逻辑:', {
|
||
returnedCount: newMessages.length,
|
||
requestedLimit: 20,
|
||
hasMoreMessages,
|
||
isFirstLoad
|
||
});
|
||
|
||
// 🔥 去重处理:避免重复消息
|
||
const existingIds = new Set(this.data.messages.map(msg => msg.messageId));
|
||
const uniqueNewMessages = formattedMessages.filter(msg => !existingIds.has(msg.messageId));
|
||
console.log('🔄 去重后新消息数量:', uniqueNewMessages.length, '原数量:', formattedMessages.length);
|
||
|
||
// 🔥 修复消息排序:确保消息按时间正序排列(旧消息在前,新消息在后)
|
||
let sortedMessages;
|
||
if (isFirstLoad) {
|
||
// 首次加载:直接使用API返回的消息,按时间排序
|
||
sortedMessages = uniqueNewMessages.sort((a, b) => a.timestamp - b.timestamp);
|
||
console.log('📅 首次加载,按时间排序消息');
|
||
} else {
|
||
// 加载更多历史消息:新加载的历史消息应该插入到现有消息前面
|
||
const allMessages = uniqueNewMessages.concat(this.data.messages);
|
||
sortedMessages = allMessages.sort((a, b) => a.timestamp - b.timestamp);
|
||
console.log('📅 加载更多消息,重新排序所有消息');
|
||
}
|
||
|
||
// 🔥 更新lastMessageId:使用最早的消息ID(用于下次分页)
|
||
const earliestMessage = newMessages.length > 0 ? newMessages[newMessages.length - 1] : null;
|
||
const newLastMessageId = earliestMessage ? (earliestMessage.id || earliestMessage.messageId) : '';
|
||
|
||
const annotated = this.recomputeDateDividers(sortedMessages);
|
||
this.setData({
|
||
messages: annotated,
|
||
hasMore: hasMoreMessages,
|
||
lastMessageId: newLastMessageId,
|
||
loadingMessages: false
|
||
});
|
||
console.log('🔧 重置loadingMessages: false');
|
||
|
||
console.log('✅ 消息加载完成:', {
|
||
totalMessages: sortedMessages.length,
|
||
hasMore: hasMoreMessages,
|
||
lastMessageId: newLastMessageId
|
||
});
|
||
|
||
// 🔥 首次加载时自动滚动到底部
|
||
if (isFirstLoad && this.data.messages.length > 0) {
|
||
console.log('📍 首次加载,自动滚动到最新消息');
|
||
setTimeout(() => {
|
||
this.scrollToBottom();
|
||
}, 100);
|
||
}
|
||
} else {
|
||
console.warn('⚠️ 获取消息响应异常:', response);
|
||
this.setData({
|
||
hasMore: false,
|
||
loadingMessages: false
|
||
});
|
||
console.log('🔧 异常情况重置loadingMessages: false');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 加载消息失败:', error);
|
||
this.setData({
|
||
hasMore: false,
|
||
loadingMessages: false
|
||
});
|
||
console.log('🔧 错误情况重置loadingMessages: false');
|
||
wx.showToast({
|
||
title: '加载消息失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 格式化消息 - 适配接口文档的数据结构,特殊处理图片消息
|
||
async formatMessage(msgData) {
|
||
// 🔍 调试:查看消息数据中的头像字段
|
||
console.log('🔍 消息数据字段检查:', {
|
||
senderAvatar: msgData.senderAvatar,
|
||
avatar: msgData.avatar,
|
||
userAvatar: msgData.userAvatar,
|
||
targetAvatar: msgData.targetAvatar,
|
||
fromAvatar: msgData.fromAvatar,
|
||
allFields: Object.keys(msgData)
|
||
});
|
||
|
||
// 🔥 多重获取当前用户ID,确保能正确获取
|
||
let currentUserId = this.data.userInfo?.user?.customId || this.data.userInfo?.customId || '';
|
||
|
||
// 🔥 如果从data中获取不到,尝试从全局获取
|
||
if (!currentUserId) {
|
||
const app = getApp();
|
||
const globalUserInfo = app.globalData.userInfo;
|
||
currentUserId = globalUserInfo?.user?.customId || globalUserInfo?.customId || '';
|
||
console.log('🔍 从全局获取用户ID:', currentUserId);
|
||
}
|
||
|
||
// 🔥 如果还是获取不到,尝试从本地存储获取
|
||
if (!currentUserId) {
|
||
try {
|
||
const storedUserInfo = wx.getStorageSync('userInfo');
|
||
currentUserId = storedUserInfo?.user?.customId || storedUserInfo?.customId || '';
|
||
console.log('🔍 从本地存储获取用户ID:', currentUserId);
|
||
} catch (error) {
|
||
console.error('从本地存储获取用户ID失败:', error);
|
||
}
|
||
}
|
||
|
||
// 适配接口文档中的字段名
|
||
const senderId = msgData.senderId || msgData.senderCustomId;
|
||
const messageId = msgData.id || msgData.messageId;
|
||
const isSelf = senderId === currentUserId;
|
||
|
||
// 🔥 调试:检查发送者判断逻辑
|
||
console.log('🔍 发送者判断调试:', {
|
||
currentUserId,
|
||
senderId,
|
||
messageId,
|
||
isSelf,
|
||
userInfo: this.data.userInfo,
|
||
msgData: {
|
||
senderId: msgData.senderId,
|
||
senderCustomId: msgData.senderCustomId,
|
||
id: msgData.id,
|
||
messageId: msgData.messageId
|
||
}
|
||
});
|
||
|
||
// 🔥 智能消息类型识别:如果msgType是1但内容是文字,自动识别为文字消息
|
||
let msgType = this.convertMsgType(msgData.msgType);
|
||
if (msgType === 'image' && typeof msgData.content === 'string' &&
|
||
!msgData.content.startsWith('http') && !msgData.content.startsWith('data:')) {
|
||
console.log('🔧 检测到错误的图片类型,自动修正为文字消息');
|
||
msgType = 'text';
|
||
}
|
||
|
||
// 🔥 特殊处理媒体消息内容
|
||
let processedContent = msgData.content;
|
||
if (msgType === 'image') {
|
||
processedContent = await this.parseImageContent(msgData.content);
|
||
console.log('🖼️ 图片消息处理结果:', processedContent);
|
||
} else if (msgType === 'voice') {
|
||
processedContent = this.parseVoiceContent(msgData.content);
|
||
console.log('🎤 语音消息处理结果:', processedContent);
|
||
} else if (msgType === 'file') {
|
||
processedContent = this.parseFileContent(msgData.content);
|
||
console.log('📄 文件消息处理结果:', processedContent);
|
||
} else if (msgType === 'location') {
|
||
processedContent = this.parseLocationContent(msgData.content);
|
||
console.log('📍 位置消息处理结果:', processedContent);
|
||
}
|
||
|
||
// 🔥 统一时间戳字段,确保排序正确
|
||
const timestamp = new Date(msgData.sendTime).getTime();
|
||
|
||
// 🔥 先使用默认头像,异步更新
|
||
let senderAvatar = '';
|
||
if (isSelf) {
|
||
senderAvatar = this.data.userAvatar || '';
|
||
} else {
|
||
senderAvatar = this.data.targetAvatar || '';
|
||
}
|
||
|
||
// 🔥 异步提取和缓存头像
|
||
this.extractSenderAvatar(msgData, isSelf).then(cachedAvatar => {
|
||
// 更新对应消息的头像
|
||
const messages = this.data.messages.map(msg => {
|
||
if (msg.messageId === messageId) {
|
||
return { ...msg, senderAvatar: cachedAvatar };
|
||
}
|
||
return msg;
|
||
});
|
||
this.setData({ messages });
|
||
}).catch(error => {
|
||
console.error('提取头像失败:', error);
|
||
});
|
||
|
||
const baseMessage = {
|
||
messageId: messageId,
|
||
senderId: senderId,
|
||
targetId: msgData.receiverId || msgData.targetId,
|
||
content: processedContent, // 使用处理后的内容
|
||
msgType: msgType,
|
||
sendTime: timestamp, // 确保是时间戳格式
|
||
timestamp: timestamp, // 🔥 添加timestamp字段用于排序
|
||
isSelf: isSelf,
|
||
senderName: msgData.senderName || (isSelf ? '我' : '对方'),
|
||
senderAvatar: senderAvatar, // 🔥 使用异步提取的头像
|
||
deliveryStatus: this.convertStatus(msgData.status), // 转换状态
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
// 文件消息补充文件展示字段,便于WXML直接使用
|
||
if (msgType === 'file') {
|
||
return {
|
||
...baseMessage,
|
||
fileName: processedContent.fileName || msgData.fileName || '',
|
||
fileSize: this.formatFileSize(processedContent.fileSize || msgData.fileSize || 0),
|
||
fileType: processedContent.fileType || msgData.fileType || ''
|
||
};
|
||
}
|
||
|
||
// 🔥 位置消息:为兼容旧渲染逻辑,将名称/地址/经纬度同步到根级字段,避免显示默认“位置”
|
||
if (msgType === 'location') {
|
||
return {
|
||
...baseMessage,
|
||
locationName: processedContent?.locationName || msgData.locationName || '位置',
|
||
locationAddress: processedContent?.address || msgData.locationAddress || '',
|
||
latitude: processedContent?.latitude,
|
||
longitude: processedContent?.longitude
|
||
};
|
||
}
|
||
|
||
return baseMessage;
|
||
},
|
||
|
||
// 🔥 解析语音内容:兼容字符串URL、JSON字符串、对象三种情况
|
||
parseVoiceContent(content) {
|
||
try {
|
||
if (!content) return { url: '' };
|
||
|
||
// 如果已经是对象且包含url,直接返回
|
||
if (typeof content === 'object' && content !== null) {
|
||
if (content.url) return content;
|
||
// 兼容部分后端字段
|
||
if (content.voiceUrl) return { url: content.voiceUrl, duration: content.duration };
|
||
// 无法识别,返回空URL,避免崩溃
|
||
return { url: '' };
|
||
}
|
||
|
||
// 如果是字符串,尝试JSON解析
|
||
if (typeof content === 'string') {
|
||
const trimmed = content.trim();
|
||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||
try {
|
||
const obj = JSON.parse(trimmed);
|
||
if (obj && obj.url) return obj;
|
||
// 兼容 file_url 字段
|
||
if (obj && obj.file_url) return { url: obj.file_url, duration: obj.duration };
|
||
} catch (e) {
|
||
// 继续按URL兜底
|
||
}
|
||
}
|
||
// 直接当作URL使用
|
||
return { url: trimmed };
|
||
}
|
||
|
||
return { url: '' };
|
||
} catch (error) {
|
||
console.error('❌ 解析语音内容失败:', error, content);
|
||
return { url: '' };
|
||
}
|
||
},
|
||
|
||
// 🔥 解析文件内容:兼容URL字符串、JSON字符串、对象
|
||
parseFileContent(content) {
|
||
try {
|
||
if (!content) return { url: '', fileName: '', fileSize: 0 };
|
||
|
||
// 对象格式
|
||
if (typeof content === 'object' && content !== null) {
|
||
const url = content.url || content.file_url || content.fileUrl || '';
|
||
const fileName = content.fileName || content.file_name || '';
|
||
const fileSize = content.fileSize || content.file_size || 0;
|
||
const fileType = content.fileType || content.file_type || '';
|
||
return { url, fileName, fileSize, fileType };
|
||
}
|
||
|
||
// 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.file_url || obj.fileUrl || '';
|
||
const fileName = obj.fileName || obj.file_name || '';
|
||
const fileSize = obj.fileSize || obj.file_size || 0;
|
||
const fileType = 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 };
|
||
}
|
||
},
|
||
|
||
// 🔥 解析位置内容:兼容JSON字符串或对象,统一为 {latitude, longitude, address, locationName}
|
||
parseLocationContent(content) {
|
||
try {
|
||
if (!content) return null;
|
||
let obj = content;
|
||
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;
|
||
}
|
||
}
|
||
const lat = obj.latitude ?? obj.lat;
|
||
const lng = obj.longitude ?? obj.lng;
|
||
const address = obj.address || '';
|
||
const locationName = obj.locationName || obj.name || '位置';
|
||
if (lat == null || lng == null) return null;
|
||
return { latitude: Number(lat), longitude: Number(lng), address, locationName };
|
||
} catch (err) {
|
||
console.error('❌ 解析位置内容失败:', err, content);
|
||
return null;
|
||
}
|
||
},
|
||
|
||
// 🔥 提取发送者头像 - 兼容多种字段名,支持缓存
|
||
async extractSenderAvatar(msgData, isSelf) {
|
||
console.log('🔍 开始提取头像,isSelf:', isSelf, 'msgData:', msgData);
|
||
|
||
// 如果是自己的消息,使用当前用户头像(已缓存)
|
||
if (isSelf) {
|
||
const userAvatar = this.data.userAvatar || '';
|
||
console.log('👤 使用当前用户头像:', userAvatar);
|
||
return userAvatar;
|
||
}
|
||
|
||
// 🔥 如果是对方的消息,优先使用获取到的对方头像(已缓存)
|
||
const targetAvatar = this.data.targetAvatar || '';
|
||
if (targetAvatar) {
|
||
console.log('👥 使用获取到的对方头像:', targetAvatar);
|
||
return targetAvatar;
|
||
}
|
||
|
||
// 🔥 尝试多种可能的头像字段名,兼容不同API返回格式
|
||
const possibleAvatarFields = [
|
||
'senderAvatar', // 标准字段
|
||
'avatar', // 简化字段
|
||
'userAvatar', // 用户头像
|
||
'targetAvatar', // 目标头像(与消息列表一致)
|
||
'fromAvatar', // 发送者头像
|
||
'user.avatar', // 嵌套用户对象
|
||
'sender.avatar' // 嵌套发送者对象
|
||
];
|
||
|
||
console.log('🔍 尝试查找头像字段,可能的字段:', possibleAvatarFields);
|
||
|
||
for (const field of possibleAvatarFields) {
|
||
let value;
|
||
|
||
// 处理嵌套字段
|
||
if (field.includes('.')) {
|
||
const parts = field.split('.');
|
||
value = msgData;
|
||
for (const part of parts) {
|
||
value = value?.[part];
|
||
if (!value) break;
|
||
}
|
||
} else {
|
||
value = msgData[field];
|
||
}
|
||
|
||
console.log(`🔍 检查字段 ${field}:`, value);
|
||
|
||
// 如果找到有效的头像URL,缓存并返回
|
||
if (value && typeof value === 'string' && value.length > 0) {
|
||
console.log(`🎯 找到头像字段 ${field}:`, value);
|
||
// 🔥 缓存头像
|
||
const cachedAvatar = await imageCacheManager.cacheAvatar(value);
|
||
return cachedAvatar;
|
||
}
|
||
}
|
||
|
||
console.log('⚠️ 未找到有效的头像字段,消息数据:', msgData);
|
||
console.log('⚠️ 消息数据的所有字段:', Object.keys(msgData));
|
||
return ''; // 没有找到头像,返回空字符串
|
||
},
|
||
|
||
// 转换消息类型(根据后端实际常量定义)
|
||
convertMsgType(apiMsgType) {
|
||
// 🔥 根据API文档修正映射
|
||
const typeMap = {
|
||
0: 'text', // 文字消息
|
||
1: 'image', // 图片消息
|
||
2: 'voice', // 语音消息
|
||
3: 'video', // 视频消息
|
||
4: 'file', // 文件消息
|
||
5: 'location', // 位置消息
|
||
6: 'emoji', // 表情消息
|
||
99: 'system' // 系统消息
|
||
};
|
||
|
||
console.log('🔄 转换msgType:', apiMsgType, '->', typeMap[apiMsgType] || 'text');
|
||
return typeMap[apiMsgType] || 'text';
|
||
},
|
||
|
||
// 转换消息状态(映射为前端deliveryStatus:sending/sent/delivered/read/...)
|
||
convertStatus(apiStatus) {
|
||
// 后端状态(根据文档):0=未读(unread),1=已读(read),2=撤回(recalled),3=删除(deleted)
|
||
// 前端 deliveryStatus 需要的是 sent/delivered/read 等
|
||
const normalize = (val) => {
|
||
if (val === undefined || val === null) return 'sent';
|
||
// 数字状态
|
||
if (typeof val === 'number') {
|
||
if (val === 1) return 'read';
|
||
if (val === 0) return 'delivered';
|
||
if (val === 2) return 'recalled';
|
||
if (val === 3) return 'deleted';
|
||
return 'sent';
|
||
}
|
||
// 字符串状态
|
||
const s = String(val).toLowerCase();
|
||
if (s === 'read') return 'read';
|
||
if (s === 'unread') return 'delivered';
|
||
if (s === 'delivered' || s === 'sent') return s;
|
||
if (s === 'recalled' || s === 'deleted' || s === 'failed') return s;
|
||
return 'sent';
|
||
};
|
||
|
||
const mapped = normalize(apiStatus);
|
||
console.log('🔄 状态映射 convertStatus:', apiStatus, '->', mapped);
|
||
return mapped;
|
||
},
|
||
|
||
// 🔥 新增:解析图片内容(支持OSS URL和Base64两种格式),支持缓存
|
||
async parseImageContent(content) {
|
||
console.log('🖼️ 解析图片内容 - 完整内容:', content);
|
||
console.log('🖼️ 内容类型:', typeof content);
|
||
console.log('🖼️ 内容长度:', content?.length);
|
||
|
||
if (!content) {
|
||
console.log('⚠️ 图片内容为空');
|
||
return { type: 'url', url: '' };
|
||
}
|
||
|
||
// 🔥 检查是否是错误的文本内容(应该是图片URL)
|
||
if (typeof content === 'string' && !content.startsWith('http') && !content.startsWith('data:')) {
|
||
console.error('❌ 图片消息包含无效内容,可能是后端数据错误:', content);
|
||
return {
|
||
type: 'error',
|
||
url: '',
|
||
error: '图片内容无效',
|
||
originalContent: content
|
||
};
|
||
}
|
||
|
||
// 检查是否是Base64格式
|
||
if (content.startsWith('data:image/')) {
|
||
console.log('📷 检测到Base64图片格式');
|
||
return {
|
||
type: 'base64',
|
||
url: content // Base64数据可以直接作为src使用
|
||
};
|
||
}
|
||
|
||
// 检查是否是OSS URL格式
|
||
if (content.startsWith('http://') || content.startsWith('https://')) {
|
||
console.log('🌐 检测到OSS URL图片格式');
|
||
// 🔥 缓存聊天图片
|
||
const cachedUrl = await imageCacheManager.preloadImage(content);
|
||
return {
|
||
type: 'url',
|
||
url: cachedUrl
|
||
};
|
||
}
|
||
|
||
// 尝试解析JSON格式(可能包含文件信息)
|
||
try {
|
||
const parsed = JSON.parse(content);
|
||
if (parsed.file_url) {
|
||
console.log('📄 检测到JSON图片格式');
|
||
// 🔥 缓存聊天图片
|
||
const cachedUrl = await imageCacheManager.preloadImage(parsed.file_url);
|
||
return {
|
||
type: 'url',
|
||
url: cachedUrl,
|
||
fileName: parsed.file_name,
|
||
fileSize: parsed.file_size,
|
||
fileType: parsed.file_type
|
||
};
|
||
}
|
||
} catch (e) {
|
||
console.log('⚠️ JSON解析失败,当作普通URL处理');
|
||
}
|
||
|
||
// 默认当作URL处理
|
||
return {
|
||
type: 'url',
|
||
url: content
|
||
};
|
||
},
|
||
|
||
// 判断是否显示时间
|
||
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}`;
|
||
},
|
||
|
||
// 重新计算日期分隔(仅按日期),返回新数组
|
||
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 annotated = this.recomputeDateDividers(messages);
|
||
this.setData({ messages: annotated });
|
||
},
|
||
|
||
// 输入变化
|
||
onInputChange(e) {
|
||
const inputText = e.detail.value;
|
||
this.setData({ inputText });
|
||
|
||
// 如果正在输入中文,不要发送输入状态
|
||
if (!this.data.isComposing) {
|
||
// 发送输入状态
|
||
if (inputText.length > 0) {
|
||
this.sendTypingStatus(true);
|
||
} else {
|
||
this.sendTypingStatus(false);
|
||
}
|
||
}
|
||
},
|
||
|
||
// 处理中文输入法开始
|
||
onCompositionStart(e) {
|
||
this.setData({ isComposing: true });
|
||
},
|
||
|
||
// 处理中文输入法结束
|
||
onCompositionEnd(e) {
|
||
this.setData({ isComposing: false });
|
||
|
||
// 输入法结束后,更新输入状态
|
||
const inputText = this.data.inputText;
|
||
if (inputText.length > 0) {
|
||
this.sendTypingStatus(true);
|
||
} else {
|
||
this.sendTypingStatus(false);
|
||
}
|
||
},
|
||
|
||
// 处理文本框行数变化
|
||
onLineChange(e) {
|
||
console.log('📝 文本框行数变化:', e.detail);
|
||
// 可以在这里处理输入框高度变化的逻辑
|
||
// 例如调整页面布局或滚动位置
|
||
},
|
||
|
||
// 🔥 ===== 媒体选择功能 =====
|
||
|
||
// 显示媒体选择菜单
|
||
showMediaPicker() {
|
||
console.log('📱 显示媒体选择菜单');
|
||
|
||
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() {
|
||
console.log('📸 开始拍照');
|
||
|
||
try {
|
||
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() {
|
||
console.log('🖼️ 打开相册选择菜单');
|
||
|
||
try {
|
||
// 在相册内二次分流:图片 或 视频
|
||
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) {
|
||
for (const image of result.files) {
|
||
await this.sendImageMessage(image);
|
||
}
|
||
}
|
||
} else if (tapIndex === 1) {
|
||
// 选择视频
|
||
const result = await mediaPicker.chooseVideo({
|
||
sourceType: ['album'],
|
||
maxDuration: 60
|
||
});
|
||
if (result.success && Array.isArray(result.files) && result.files.length > 0) {
|
||
await this.sendVideoMessage(result.files[0]);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 处理相册选择发生异常:', error);
|
||
}
|
||
},
|
||
|
||
// 拍摄视频
|
||
async recordVideo() {
|
||
console.log('🎥 开始拍摄视频');
|
||
|
||
try {
|
||
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'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 从相册选择视频
|
||
async chooseVideoFromAlbum() {
|
||
console.log('🎞️ 从相册选择视频');
|
||
try {
|
||
const result = await mediaPicker.chooseVideo({
|
||
sourceType: ['album'],
|
||
maxDuration: 60
|
||
});
|
||
if (result.success && Array.isArray(result.files) && 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);
|
||
console.log('❌ 失败的头像URL:', e.currentTarget.src);
|
||
console.log('❌ 头像元素信息:', {
|
||
src: e.currentTarget.src,
|
||
dataset: e.currentTarget.dataset,
|
||
id: e.currentTarget.id
|
||
});
|
||
|
||
// 🔥 尝试从dataset中获取更多信息
|
||
const dataset = e.currentTarget.dataset;
|
||
console.log('❌ 头像dataset信息:', dataset);
|
||
},
|
||
|
||
// 🔥 头像加载成功处理
|
||
onAvatarLoad(e) {
|
||
console.log('✅ 头像加载成功:', e.currentTarget.src);
|
||
},
|
||
|
||
// 🔥 ===== 媒体消息发送功能 =====
|
||
|
||
// 发送图片消息 - 兼容媒体选择器
|
||
async sendImageMessage(imageFile) {
|
||
console.log('📸 发送图片消息:', imageFile);
|
||
// 预生成临时ID,便于在任何失败路径更新状态
|
||
const clientTempId = 'temp_' + Date.now();
|
||
|
||
try {
|
||
// 如果传入的是字符串路径,转换为文件对象格式
|
||
if (typeof imageFile === 'string') {
|
||
await this.uploadAndSendImage(imageFile);
|
||
return;
|
||
}
|
||
|
||
// 检查用户信息
|
||
if (!this.data.userInfo) {
|
||
console.error('❌ 用户信息不存在,无法发送图片');
|
||
return;
|
||
}
|
||
|
||
// 检查文件大小
|
||
if (imageFile.size > 10 * 1024 * 1024) { // 10MB限制
|
||
wx.showToast({
|
||
title: '图片大小不能超过10MB',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 获取用户ID
|
||
const currentUserId = this.data.userInfo.user?.customId || this.data.userInfo.customId || '';
|
||
if (!currentUserId) {
|
||
console.error('无法获取用户ID');
|
||
wx.showToast({
|
||
title: '用户ID错误',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 创建临时图片消息
|
||
const timestamp = Date.now();
|
||
const tempMessage = {
|
||
messageId: clientTempId,
|
||
senderId: currentUserId,
|
||
targetId: this.data.targetId,
|
||
content: {
|
||
type: 'url',
|
||
url: imageFile.path // 先使用本地路径显示
|
||
},
|
||
msgType: 'image',
|
||
sendTime: timestamp,
|
||
timestamp: timestamp,
|
||
isSelf: true,
|
||
senderName: '我',
|
||
senderAvatar: this.data.userAvatar || '',
|
||
deliveryStatus: 'sending',
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
// 立即添加到消息列表
|
||
const messages = this.data.messages.concat([tempMessage]);
|
||
this.setMessagesWithDate(messages);
|
||
|
||
// 滚动到底部
|
||
setTimeout(() => {
|
||
this.scrollToBottom();
|
||
}, 100);
|
||
|
||
wx.showLoading({ title: '发送中...' });
|
||
|
||
// 上传图片到服务器
|
||
const uploadResult = await apiClient.uploadFile(imageFile.path, 'image', 'message');
|
||
|
||
if (uploadResult && uploadResult.code === 0) {
|
||
const imageUrl = uploadResult.data.file_url || uploadResult.data.url;
|
||
console.log('📸 获取到图片URL:', imageUrl);
|
||
|
||
if (!imageUrl) {
|
||
throw new Error('上传成功但未获取到图片URL');
|
||
}
|
||
|
||
// 更新临时消息的图片URL
|
||
const updatedMessages = this.data.messages.map(msg => {
|
||
if (msg.messageId === tempMessage.messageId) {
|
||
return {
|
||
...msg,
|
||
content: {
|
||
type: 'url',
|
||
url: imageUrl
|
||
},
|
||
// 上传成功,仅标记为sending,待WebSocket回执更新为sent
|
||
deliveryStatus: 'sending'
|
||
};
|
||
}
|
||
return msg;
|
||
});
|
||
this.setData({ messages: updatedMessages });
|
||
|
||
// 🔥 发送图片消息 - 只使用WebSocket
|
||
const success = wsManager.sendChatMessage(
|
||
this.data.targetId,
|
||
imageUrl,
|
||
'image',
|
||
this.data.chatType
|
||
);
|
||
|
||
if (!success) {
|
||
this.updateMessageDeliveryStatus(tempMessage.messageId, 'failed');
|
||
throw new Error('WebSocket连接异常,图片发送失败');
|
||
}
|
||
|
||
wx.hideLoading();
|
||
} else {
|
||
this.updateMessageDeliveryStatus(tempMessage.messageId, 'failed');
|
||
throw new Error(uploadResult?.message || '图片上传失败');
|
||
}
|
||
|
||
} catch (error) {
|
||
wx.hideLoading();
|
||
console.error('❌ 发送图片消息失败:', error);
|
||
// 标记为失败
|
||
this.updateMessageDeliveryStatus(clientTempId, 'failed');
|
||
wx.showToast({
|
||
title: '图片发送失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 发送视频消息 - 兼容媒体选择器
|
||
async sendVideoMessage(videoFile) {
|
||
console.log('🎥 发送视频消息:', videoFile);
|
||
const clientTempId = 'temp_' + Date.now();
|
||
|
||
try {
|
||
// 如果传入的是字符串路径,使用原有方法
|
||
if (typeof videoFile === 'string') {
|
||
await this.uploadAndSendVideo(videoFile);
|
||
return;
|
||
}
|
||
|
||
// 检查用户信息
|
||
if (!this.data.userInfo) {
|
||
console.error('❌ 用户信息不存在,无法发送视频');
|
||
return;
|
||
}
|
||
|
||
// 检查文件大小
|
||
if (videoFile.size > 100 * 1024 * 1024) { // 100MB限制
|
||
wx.showToast({
|
||
title: '视频大小不能超过100MB',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 获取用户ID
|
||
const currentUserId = this.data.userInfo.user?.customId || this.data.userInfo.customId || '';
|
||
if (!currentUserId) {
|
||
console.error('无法获取用户ID');
|
||
wx.showToast({
|
||
title: '用户ID错误',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 创建临时视频消息
|
||
const timestamp = Date.now();
|
||
const tempMessage = {
|
||
messageId: clientTempId,
|
||
senderId: currentUserId,
|
||
targetId: this.data.targetId,
|
||
content: videoFile.path, // 先使用本地路径显示
|
||
msgType: 'video',
|
||
sendTime: timestamp,
|
||
timestamp: timestamp,
|
||
isSelf: true,
|
||
senderName: '我',
|
||
senderAvatar: this.data.userAvatar || '',
|
||
deliveryStatus: 'sending',
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
// 立即添加到消息列表
|
||
const messages = this.data.messages.concat([tempMessage]);
|
||
this.setMessagesWithDate(messages);
|
||
|
||
// 滚动到底部
|
||
setTimeout(() => {
|
||
this.scrollToBottom();
|
||
}, 100);
|
||
|
||
wx.showLoading({ title: '发送中...' });
|
||
|
||
// 上传视频到服务器
|
||
const uploadResult = await apiClient.uploadFile(videoFile.path, 'video', 'message');
|
||
|
||
if (uploadResult && uploadResult.code === 0) {
|
||
const videoUrl = uploadResult.data.file_url || uploadResult.data.url;
|
||
console.log('🎥 获取到视频URL:', videoUrl);
|
||
|
||
if (!videoUrl) {
|
||
throw new Error('上传成功但未获取到视频URL');
|
||
}
|
||
|
||
// 更新临时消息的视频URL
|
||
const updatedMessages = this.data.messages.map(msg => {
|
||
if (msg.messageId === tempMessage.messageId) {
|
||
return {
|
||
...msg,
|
||
content: videoUrl,
|
||
// 上传成功,等待WS回执
|
||
deliveryStatus: 'sending'
|
||
};
|
||
}
|
||
return msg;
|
||
});
|
||
this.setData({ messages: updatedMessages });
|
||
|
||
// 🔥 发送视频消息 - 只使用WebSocket
|
||
const success = wsManager.sendChatMessage(
|
||
this.data.targetId,
|
||
videoUrl,
|
||
'video',
|
||
this.data.chatType
|
||
);
|
||
|
||
if (!success) {
|
||
this.updateMessageDeliveryStatus(tempMessage.messageId, 'failed');
|
||
throw new Error('WebSocket连接异常,视频发送失败');
|
||
}
|
||
|
||
wx.hideLoading();
|
||
} else {
|
||
throw new Error(uploadResult?.message || '视频上传失败');
|
||
}
|
||
|
||
} catch (error) {
|
||
wx.hideLoading();
|
||
console.error('❌ 发送视频消息失败:', error);
|
||
this.updateMessageDeliveryStatus(clientTempId, 'failed');
|
||
wx.showToast({
|
||
title: '视频发送失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 🔥 ===== 消息发送功能 =====
|
||
|
||
// 发送文本消息 - 优化新会话处理
|
||
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,
|
||
showMorePanel: false
|
||
});
|
||
|
||
// 停止输入状态
|
||
this.sendTypingStatus(false);
|
||
|
||
// 获取用户ID
|
||
const currentUserId = this.data.userInfo.user?.customId || this.data.userInfo.customId || '';
|
||
if (!currentUserId) {
|
||
console.error('无法获取用户ID');
|
||
wx.showToast({
|
||
title: '用户ID错误',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 创建临时消息
|
||
const timestamp = Date.now();
|
||
const tempMessage = {
|
||
messageId: 'temp_' + timestamp,
|
||
senderId: currentUserId,
|
||
targetId: this.data.targetId,
|
||
content: content,
|
||
msgType: 'text',
|
||
sendTime: timestamp,
|
||
timestamp: timestamp, // 🔥 添加timestamp字段确保排序正确
|
||
isSelf: true,
|
||
senderName: '我',
|
||
senderAvatar: this.data.userAvatar || '', // 🔥 使用用户头像,便于WXML判断
|
||
deliveryStatus: 'sending',
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
// 添加到消息列表
|
||
const messages = this.data.messages.concat([tempMessage]);
|
||
this.setMessagesWithDate(messages);
|
||
|
||
// 🔥 确保消息添加后立即滚动到底部
|
||
setTimeout(() => {
|
||
this.scrollToBottom();
|
||
}, 100);
|
||
|
||
try {
|
||
// 🔥 检查WebSocket连接状态
|
||
console.log('📡 WebSocket连接状态:', wsManager.isConnected);
|
||
console.log('📤 准备发送消息:', {
|
||
targetId: this.data.targetId,
|
||
content: content,
|
||
msgType: 'text',
|
||
chatType: this.data.chatType
|
||
});
|
||
|
||
if (!wsManager.isConnected) {
|
||
console.warn('⚠️ WebSocket未连接,尝试重新连接...');
|
||
|
||
// 🔥 使用事件监听方式等待连接
|
||
const connectPromise = new Promise((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
wsManager.off('connected', onConnected);
|
||
wsManager.off('error', onError);
|
||
reject(new Error('WebSocket连接超时(15秒)'));
|
||
}, 15000); // 增加到15秒超时
|
||
|
||
const onConnected = () => {
|
||
clearTimeout(timeout);
|
||
wsManager.off('connected', onConnected);
|
||
wsManager.off('error', onError);
|
||
console.log('✅ WebSocket连接成功');
|
||
resolve();
|
||
};
|
||
|
||
const onError = (error) => {
|
||
clearTimeout(timeout);
|
||
wsManager.off('connected', onConnected);
|
||
wsManager.off('error', onError);
|
||
console.error('❌ WebSocket连接失败:', error);
|
||
reject(new Error('WebSocket连接失败'));
|
||
};
|
||
|
||
// 监听连接事件
|
||
wsManager.on('connected', onConnected);
|
||
wsManager.on('error', onError);
|
||
|
||
// 如果已经连接,直接resolve
|
||
if (wsManager.isConnected) {
|
||
clearTimeout(timeout);
|
||
resolve();
|
||
} else {
|
||
// 开始连接
|
||
wsManager.connect();
|
||
}
|
||
});
|
||
|
||
await connectPromise;
|
||
}
|
||
|
||
// 🔥 发送消息 - 只使用WebSocket,根据API文档要求
|
||
const success = wsManager.sendChatMessage(
|
||
this.data.targetId,
|
||
content,
|
||
'text',
|
||
this.data.chatType
|
||
);
|
||
|
||
if (!success) {
|
||
// WebSocket发送失败,抛出错误
|
||
throw new Error('WebSocket发送消息失败');
|
||
}
|
||
|
||
// 更新消息状态为已发送
|
||
this.updateMessageDeliveryStatus(tempMessage.messageId, 'sent');
|
||
|
||
// 🔥 如果是新会话,发送第一条消息后刷新会话列表获取正确ID
|
||
if (this.data.isNewConversation) {
|
||
console.log('🔄 新会话发送消息后,重新获取正确的conversationId...');
|
||
setTimeout(() => {
|
||
this.refreshConversationId();
|
||
}, 1000); // 延迟1秒确保后端已处理完成
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error);
|
||
// 更新消息状态为失败
|
||
this.updateMessageDeliveryStatus(tempMessage.messageId, 'failed');
|
||
|
||
wx.showToast({
|
||
title: '发送失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 刷新conversationId(用于新会话发送消息后)
|
||
async refreshConversationId() {
|
||
try {
|
||
const response = await apiClient.getConversations();
|
||
if (response && response.code === 0) {
|
||
const conversations = response.data || [];
|
||
|
||
// 查找与当前targetId的会话
|
||
const targetConversation = conversations.find(conv =>
|
||
conv.type === this.data.chatType && conv.targetId === this.data.targetId
|
||
);
|
||
|
||
if (targetConversation && targetConversation.id !== this.data.conversationId) {
|
||
console.log('✅ 更新会话ID:', this.data.conversationId, '->', targetConversation.id);
|
||
this.setData({
|
||
conversationId: targetConversation.id,
|
||
isNewConversation: false
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 刷新conversationId失败:', error);
|
||
}
|
||
},
|
||
|
||
// 更新消息发送状态
|
||
updateMessageDeliveryStatus(messageId, status) {
|
||
const messages = this.data.messages.map(msg => {
|
||
if (msg.messageId === messageId) {
|
||
return { ...msg, deliveryStatus: status };
|
||
}
|
||
return msg;
|
||
});
|
||
this.setData({ messages });
|
||
},
|
||
|
||
// 发送输入状态
|
||
sendTypingStatus(isTyping) {
|
||
wsManager.sendTypingStatus(this.data.conversationId, isTyping);
|
||
},
|
||
|
||
// 发送已读回执
|
||
sendReadReceipt(messageId) {
|
||
wsManager.sendReadReceipt(messageId);
|
||
},
|
||
|
||
// 标记所有消息已读(进入聊天页面时调用)
|
||
async markAllMessagesRead() {
|
||
try {
|
||
if (!this.data.conversationId || this.data.conversationId.trim() === '') {
|
||
console.log('⚠️ 会话ID为空,跳过标记已读(可能是新会话)');
|
||
return;
|
||
}
|
||
|
||
console.log('📖 标记会话所有消息已读:', this.data.conversationId);
|
||
|
||
// 🔥 使用正确的API方法名
|
||
const response = await apiClient.markAllRead(this.data.conversationId);
|
||
|
||
// 🔥 兼容后端业务码:可能为 0 或 200 都表示成功
|
||
if (response && (response.code === 0 || response.code === 200)) {
|
||
console.log('✅ 消息已全部标记已读');
|
||
|
||
// 更新本地消息状态
|
||
const updatedMessages = this.data.messages.map(msg => ({
|
||
...msg,
|
||
deliveryStatus: msg.isSelf ? msg.deliveryStatus : 'read'
|
||
}));
|
||
|
||
this.setData({
|
||
messages: updatedMessages
|
||
});
|
||
} else {
|
||
console.warn('⚠️ 标记已读响应异常:', response);
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 标记消息已读失败:', error);
|
||
// 🔥 不显示错误提示,避免影响用户体验
|
||
}
|
||
},
|
||
|
||
// 标记消息已读(接收到新消息时调用)
|
||
markMessagesRead() {
|
||
if (this.data.messages.length > 0) {
|
||
const lastMessageId = this.data.messages[this.data.messages.length - 1].messageId;
|
||
this.sendReadReceipt(lastMessageId);
|
||
}
|
||
},
|
||
|
||
// 切换输入类型
|
||
toggleInputType() {
|
||
const inputType = this.data.inputType === 'text' ? 'voice' : 'text';
|
||
this.setData({
|
||
inputType,
|
||
showEmojiPanel: false,
|
||
showMorePanel: false
|
||
});
|
||
},
|
||
|
||
// 切换表情面板
|
||
toggleEmojiPanel() {
|
||
this.setData({
|
||
showEmojiPanel: !this.data.showEmojiPanel,
|
||
showMorePanel: false
|
||
});
|
||
},
|
||
|
||
// 切换更多面板
|
||
toggleMorePanel() {
|
||
this.setData({
|
||
showMorePanel: !this.data.showMorePanel,
|
||
showEmojiPanel: false
|
||
});
|
||
},
|
||
|
||
// 阻止事件冒泡
|
||
stopPropagation() {
|
||
// 阻止事件冒泡,防止点击面板内容时关闭面板
|
||
},
|
||
|
||
// 🔥 新增:组件方法(不影响现有功能)
|
||
|
||
// 媒体预览组件方法
|
||
closeMediaPreview() {
|
||
this.setData({
|
||
mediaPreviewVisible: false,
|
||
mediaPreviewList: [],
|
||
currentMediaIndex: 0
|
||
});
|
||
},
|
||
|
||
onMediaIndexChange(e) {
|
||
const { currentIndex } = e.detail;
|
||
this.setData({
|
||
currentMediaIndex: currentIndex
|
||
});
|
||
},
|
||
|
||
// 消息操作菜单组件方法
|
||
closeMessageAction() {
|
||
this.setData({
|
||
messageActionVisible: false,
|
||
currentMessage: null,
|
||
isOwnMessage: false
|
||
});
|
||
},
|
||
|
||
onMessageAction(e) {
|
||
const { action, data } = e.detail;
|
||
console.log('消息操作:', action, data);
|
||
|
||
// 根据操作类型执行相应功能
|
||
switch (action) {
|
||
case 'copy':
|
||
this.copyMessage(this.data.currentMessage);
|
||
break;
|
||
case 'delete':
|
||
this.deleteMessage(this.data.currentMessage);
|
||
break;
|
||
case 'recall':
|
||
this.recallMessage(this.data.currentMessage);
|
||
break;
|
||
case 'reaction':
|
||
// 处理表情回应
|
||
console.log('表情回应:', data);
|
||
break;
|
||
default:
|
||
console.log('未处理的操作:', action);
|
||
}
|
||
|
||
this.closeMessageAction();
|
||
},
|
||
|
||
// @提醒选择器组件方法
|
||
closeMentionSelector() {
|
||
this.setData({
|
||
mentionSelectorVisible: false
|
||
});
|
||
},
|
||
|
||
onMentionSelect(e) {
|
||
const { type, text, userIds } = e.detail;
|
||
console.log('@提醒选择:', type, text, userIds);
|
||
|
||
// 将@信息添加到输入框
|
||
if (type === 'all') {
|
||
this.setData({
|
||
inputText: this.data.inputText + '@所有人 '
|
||
});
|
||
} else {
|
||
this.setData({
|
||
inputText: this.data.inputText + `@${text} `
|
||
});
|
||
}
|
||
|
||
this.closeMentionSelector();
|
||
},
|
||
|
||
// 🎤 ===== 语音消息功能 =====
|
||
|
||
// 显示语音录制界面(重命名避免与 data 字段同名造成绑定混淆)
|
||
openVoiceRecorder() {
|
||
console.log('🎤 显示语音录制界面');
|
||
|
||
this.setData({
|
||
showVoiceRecorder: true,
|
||
showEmojiPanel: false,
|
||
showMorePanel: false
|
||
});
|
||
},
|
||
|
||
// 关闭语音录制界面
|
||
closeVoiceRecorder() {
|
||
console.log('🎤 关闭语音录制界面');
|
||
|
||
this.setData({
|
||
showVoiceRecorder: false
|
||
});
|
||
},
|
||
|
||
// 语音发送事件处理
|
||
onVoiceSend(e) {
|
||
const voiceData = e.detail;
|
||
console.log('🎤 发送语音消息:', voiceData);
|
||
|
||
// 发送语音消息
|
||
this.sendVoiceMessage(voiceData);
|
||
},
|
||
|
||
// 发送语音消息
|
||
async sendVoiceMessage(voiceData) {
|
||
// 预分配临时ID,便于错误时标记
|
||
const clientTempId = 'temp_' + Date.now();
|
||
try {
|
||
console.log('📤 发送语音消息:', voiceData);
|
||
|
||
// 与文本/图片一致的临时消息结构
|
||
const timestamp = Date.now();
|
||
const currentUserId = this.data.userInfo?.user?.customId || this.data.userInfo?.customId || this.data.userInfo?.id || 'unknown';
|
||
|
||
const voiceMessage = {
|
||
messageId: clientTempId,
|
||
conversationId: this.data.conversationId,
|
||
senderId: currentUserId,
|
||
senderName: this.data.userInfo?.nickname || '我',
|
||
senderAvatar: this.data.userAvatar || '',
|
||
targetId: this.data.targetId,
|
||
msgType: 'voice',
|
||
content: {
|
||
url: voiceData.url,
|
||
duration: voiceData.duration,
|
||
size: voiceData.size,
|
||
tempFilePath: voiceData.tempFilePath
|
||
},
|
||
sendTime: timestamp,
|
||
timestamp: timestamp,
|
||
isSelf: true,
|
||
deliveryStatus: 'sending',
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
// 添加到消息列表并滚动到底
|
||
const messages = this.data.messages.concat([voiceMessage]);
|
||
this.setMessagesWithDate(messages);
|
||
setTimeout(() => { this.scrollToBottom(); }, 100);
|
||
|
||
// 🔥 使用正确的WebSocket消息格式发送语音消息
|
||
const success = wsManager.sendChatMessage(
|
||
this.data.targetId,
|
||
JSON.stringify(voiceMessage.content),
|
||
'voice',
|
||
this.data.chatType
|
||
);
|
||
|
||
if (success) {
|
||
this.updateMessageStatus({ messageId: voiceMessage.messageId, status: 'sent' });
|
||
console.log('✅ 语音消息发送成功');
|
||
} else {
|
||
this.updateMessageStatus({ messageId: voiceMessage.messageId, status: 'failed' });
|
||
console.error('❌ 语音消息发送失败');
|
||
wx.showToast({ title: '发送失败', icon: 'none' });
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ 发送语音消息失败:', error);
|
||
// 标记失败
|
||
this.updateMessageDeliveryStatus(clientTempId, 'failed');
|
||
wx.showToast({ title: '发送失败', icon: 'none' });
|
||
}
|
||
},
|
||
|
||
// 选择表情
|
||
selectEmoji(e) {
|
||
const emoji = e.currentTarget.dataset.emoji;
|
||
const inputText = this.data.inputText + emoji;
|
||
this.setData({ inputText });
|
||
},
|
||
|
||
// 开始录音
|
||
startRecording(e) {
|
||
console.log('开始录音');
|
||
this.setData({ recording: true });
|
||
|
||
// TODO: 实现录音功能
|
||
wx.showToast({
|
||
title: '录音功能开发中',
|
||
icon: 'none'
|
||
});
|
||
},
|
||
|
||
// 停止录音
|
||
stopRecording(e) {
|
||
console.log('停止录音');
|
||
this.setData({ recording: false });
|
||
|
||
// TODO: 处理录音结果
|
||
},
|
||
|
||
// 取消录音
|
||
cancelRecording(e) {
|
||
console.log('取消录音');
|
||
this.setData({ recording: false });
|
||
},
|
||
|
||
// 选择图片
|
||
selectImage() {
|
||
wx.chooseImage({
|
||
count: 9,
|
||
sizeType: ['original', 'compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
success: (res) => {
|
||
console.log('选择图片:', res);
|
||
this.uploadAndSendImage(res.tempFilePaths[0]);
|
||
}
|
||
});
|
||
|
||
this.setData({ showMorePanel: false });
|
||
},
|
||
|
||
// 上传并发送图片
|
||
async uploadAndSendImage(imagePath) {
|
||
try {
|
||
// 获取用户ID
|
||
const currentUserId = this.data.userInfo?.user?.customId || this.data.userInfo?.customId || '';
|
||
if (!currentUserId) {
|
||
console.error('无法获取用户ID');
|
||
wx.showToast({
|
||
title: '用户ID错误',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 创建临时图片消息
|
||
const timestamp = Date.now();
|
||
const tempMessage = {
|
||
messageId: 'temp_' + timestamp,
|
||
senderId: currentUserId,
|
||
targetId: this.data.targetId,
|
||
content: {
|
||
type: 'url',
|
||
url: imagePath // 先使用本地路径显示
|
||
},
|
||
msgType: 'image',
|
||
sendTime: timestamp,
|
||
timestamp: timestamp,
|
||
isSelf: true,
|
||
senderName: '我',
|
||
senderAvatar: this.data.userAvatar || '',
|
||
deliveryStatus: 'sending',
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
// 立即添加到消息列表
|
||
const messages = this.data.messages.concat([tempMessage]);
|
||
this.setMessagesWithDate(messages);
|
||
|
||
// 滚动到底部
|
||
setTimeout(() => {
|
||
this.scrollToBottom();
|
||
}, 100);
|
||
|
||
wx.showLoading({ title: '发送中...' });
|
||
|
||
// 上传图片
|
||
const uploadResult = await apiClient.uploadFile(imagePath, 'image', 'message');
|
||
|
||
if (uploadResult && uploadResult.code === 0) {
|
||
const imageUrl = uploadResult.data.file_url || uploadResult.data.url;
|
||
console.log('📸 获取到图片URL:', imageUrl);
|
||
|
||
if (!imageUrl) {
|
||
throw new Error('上传成功但未获取到图片URL');
|
||
}
|
||
|
||
// 更新临时消息的图片URL
|
||
const updatedMessages = this.data.messages.map(msg => {
|
||
if (msg.messageId === tempMessage.messageId) {
|
||
return {
|
||
...msg,
|
||
content: {
|
||
type: 'url',
|
||
url: imageUrl
|
||
},
|
||
deliveryStatus: 'sending'
|
||
};
|
||
}
|
||
return msg;
|
||
});
|
||
this.setData({ messages: updatedMessages });
|
||
|
||
// 🔥 发送图片消息 - 只使用WebSocket
|
||
const success = wsManager.sendChatMessage(
|
||
this.data.targetId,
|
||
imageUrl,
|
||
'image',
|
||
this.data.chatType
|
||
);
|
||
|
||
if (!success) {
|
||
this.updateMessageDeliveryStatus(tempMessage.messageId, 'failed');
|
||
throw new Error('WebSocket连接异常,图片发送失败');
|
||
}
|
||
|
||
wx.hideLoading();
|
||
} else {
|
||
throw new Error(uploadResult?.message || '图片上传失败');
|
||
}
|
||
} catch (error) {
|
||
wx.hideLoading();
|
||
console.error('发送图片失败:', error);
|
||
// 兜底:尝试将最后一条sending的自发图片置为failed
|
||
const lastIndex = [...this.data.messages].reverse().findIndex(m => m.isSelf && m.msgType === 'image' && m.deliveryStatus === 'sending');
|
||
if (lastIndex !== -1) {
|
||
const idx = this.data.messages.length - 1 - lastIndex;
|
||
const failed = this.data.messages.map((m, i) => i === idx ? { ...m, deliveryStatus: 'failed' } : m);
|
||
this.setData({ messages: failed });
|
||
}
|
||
wx.showToast({
|
||
title: '发送失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 选择视频
|
||
selectVideo() {
|
||
wx.chooseVideo({
|
||
sourceType: ['album', 'camera'],
|
||
maxDuration: 60,
|
||
compressed: true,
|
||
success: (res) => {
|
||
console.log('选择视频:', res);
|
||
// TODO: 实现视频上传和发送
|
||
wx.showToast({
|
||
title: '视频功能开发中',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
});
|
||
|
||
this.setData({ showMorePanel: false });
|
||
},
|
||
|
||
// 选择文件
|
||
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);
|
||
|
||
// 可选:限制大小 100MB
|
||
if (fileSize > 100 * 1024 * 1024) {
|
||
wx.showToast({ title: '文件不能超过100MB', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
// 获取当前用户ID
|
||
const currentUserId = this.data.userInfo?.user?.customId || this.data.userInfo?.customId || '';
|
||
if (!currentUserId) {
|
||
wx.showToast({ title: '用户信息错误', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
// 创建临时消息
|
||
const timestamp = Date.now();
|
||
const clientTempId = 'temp_' + timestamp;
|
||
const tempMessage = {
|
||
messageId: clientTempId,
|
||
senderId: currentUserId,
|
||
targetId: this.data.targetId,
|
||
content: { url: '' },
|
||
msgType: 'file',
|
||
fileName: fileName,
|
||
fileSize: readableSize,
|
||
sendTime: timestamp,
|
||
timestamp: timestamp,
|
||
isSelf: true,
|
||
senderName: '我',
|
||
senderAvatar: this.data.userAvatar || '',
|
||
deliveryStatus: 'sending',
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
this.setMessagesWithDate(this.data.messages.concat([tempMessage]));
|
||
setTimeout(() => this.scrollToBottom(), 100);
|
||
|
||
wx.showLoading({ title: '发送中...' });
|
||
|
||
try {
|
||
// 上传文件
|
||
const uploadResult = await apiClient.uploadFile(filePath, 'file', 'message');
|
||
if (uploadResult && uploadResult.code === 0) {
|
||
const fileUrl = uploadResult.data.file_url || uploadResult.data.url;
|
||
if (!fileUrl) throw new Error('上传成功但未返回URL');
|
||
|
||
// 更新临时消息为已发送并写入URL
|
||
const updatedMessages = this.data.messages.map(msg => {
|
||
if (msg.messageId === tempMessage.messageId) {
|
||
return {
|
||
...msg,
|
||
content: { url: fileUrl },
|
||
deliveryStatus: 'sending'
|
||
};
|
||
}
|
||
return msg;
|
||
});
|
||
this.setData({ messages: updatedMessages });
|
||
|
||
// 通过WebSocket发送
|
||
const payload = { url: fileUrl, file_name: fileName, file_size: fileSize, clientTempId };
|
||
const ok = wsManager.sendChatMessage(
|
||
this.data.targetId,
|
||
JSON.stringify(payload),
|
||
'file',
|
||
this.data.chatType
|
||
);
|
||
if (!ok) throw new Error('WebSocket连接异常,文件发送失败');
|
||
|
||
wx.hideLoading();
|
||
// 状态改为等待服务端回执后再置为sent
|
||
} else {
|
||
throw new Error(uploadResult?.message || '文件上传失败');
|
||
}
|
||
} catch (err) {
|
||
console.error('❌ 发送文件失败:', err);
|
||
// 更新为失败
|
||
const failedMessages = this.data.messages.map(msg => {
|
||
if (msg.messageId === tempMessage.messageId) {
|
||
return { ...msg, deliveryStatus: '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 {
|
||
console.log('📍 选择位置:', res);
|
||
const { name, address, latitude, longitude } = res || {};
|
||
|
||
// 基本校验
|
||
if (latitude == null || longitude == null) {
|
||
wx.showToast({ title: '位置信息无效', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
// 当前用户
|
||
const currentUserId = this.data.userInfo?.user?.customId || this.data.userInfo?.customId || '';
|
||
if (!currentUserId) {
|
||
wx.showToast({ title: '用户信息错误', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
// 构造临时消息
|
||
const timestamp = Date.now();
|
||
const clientTempId = 'temp_' + timestamp;
|
||
const tempMsg = {
|
||
messageId: clientTempId,
|
||
senderId: currentUserId,
|
||
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: 'sending',
|
||
bubbleTime: this.formatBubbleTime(timestamp)
|
||
};
|
||
|
||
this.setMessagesWithDate(this.data.messages.concat([tempMsg]));
|
||
setTimeout(() => this.scrollToBottom(), 100);
|
||
|
||
// 通过 WebSocket 发送位置消息
|
||
const ok = wsManager.sendLocationMessage(
|
||
this.data.targetId,
|
||
latitude,
|
||
longitude,
|
||
address || '',
|
||
name || '位置',
|
||
this.data.chatType,
|
||
{ extra: JSON.stringify({ clientTempId }) }
|
||
);
|
||
|
||
if (!ok) {
|
||
this.updateMessageDeliveryStatus(clientTempId, 'failed');
|
||
throw new Error('WebSocket连接异常,位置发送失败');
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error('❌ 发送位置失败:', err);
|
||
// 标记失败
|
||
if (clientTempId) this.updateMessageDeliveryStatus(clientTempId, '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;
|
||
|
||
console.log('🖼️ 预览图片:', { url, 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;
|
||
console.log('播放音频:', message);
|
||
// TODO: 实现音频播放
|
||
wx.showToast({
|
||
title: '音频播放功能开发中',
|
||
icon: 'none'
|
||
});
|
||
},
|
||
|
||
// 显示位置
|
||
showLocation(e) {
|
||
const message = e.currentTarget.dataset.message;
|
||
console.log('📍 打开位置:', 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) {
|
||
itemList.push('删除');
|
||
} else {
|
||
// 普通消息的菜单选项
|
||
if (message.msgType === 'text') {
|
||
itemList.push('复制');
|
||
}
|
||
itemList.push('删除');
|
||
|
||
if (message.isSelf) {
|
||
// 检查是否可以撤回(2分钟内)
|
||
const canRecall = this.canRecallMessage(message);
|
||
if (canRecall) {
|
||
itemList.push('撤回');
|
||
}
|
||
}
|
||
}
|
||
|
||
wx.showActionSheet({
|
||
itemList: itemList,
|
||
success: (res) => {
|
||
const action = itemList[res.tapIndex];
|
||
switch (action) {
|
||
case '复制':
|
||
this.copyMessage(message);
|
||
break;
|
||
case '删除':
|
||
this.deleteMessage(message);
|
||
break;
|
||
case '撤回':
|
||
this.recallMessage(message);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 复制消息
|
||
copyMessage(message) {
|
||
if (message.msgType === 'text') {
|
||
wx.setClipboardData({
|
||
data: message.content,
|
||
success: () => {
|
||
wx.showToast({
|
||
title: '已复制',
|
||
icon: 'success'
|
||
});
|
||
}
|
||
});
|
||
}
|
||
},
|
||
|
||
// 删除消息
|
||
deleteMessage(message) {
|
||
wx.showModal({
|
||
title: '确认删除',
|
||
content: '确定要删除这条消息吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
const messages = this.data.messages.filter(msg => msg.messageId !== message.messageId);
|
||
this.setMessagesWithDate(messages);
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 撤回消息
|
||
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
|
||
});
|
||
|
||
// 检查WebSocket连接状态
|
||
if (!wsManager || !wsManager.isConnected) {
|
||
throw new Error('网络连接异常,请检查网络后重试');
|
||
}
|
||
|
||
// 通过WebSocket发送撤回消息
|
||
const result = await wsManager.recallMessage(message.messageId);
|
||
|
||
// 撤回成功,本地立即更新消息状态为已撤回
|
||
this.updateMessageToRecalled(message.messageId);
|
||
|
||
wx.showToast({
|
||
title: '已撤回',
|
||
icon: 'success'
|
||
});
|
||
} 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) {
|
||
return {
|
||
...msg,
|
||
isRecalled: true,
|
||
content: '你撤回了一条消息',
|
||
originalContent: msg.content, // 保存原始内容用于调试
|
||
recallTime: Date.now()
|
||
};
|
||
}
|
||
return msg;
|
||
});
|
||
this.setData({ messages });
|
||
},
|
||
|
||
// 重发消息
|
||
async resendMessage(e) {
|
||
const message = e.currentTarget.dataset.message;
|
||
if (!message) return;
|
||
console.log('🔁 重发消息:', message);
|
||
|
||
// 标记为发送中
|
||
this.updateMessageDeliveryStatus(message.messageId, 'sending');
|
||
|
||
try {
|
||
// 确保WS已连接,必要时尝试快速重连
|
||
if (!wsManager || !wsManager.isConnected) {
|
||
console.warn('⚠️ WebSocket未连接,尝试重连以重新发送...');
|
||
const tryConnect = new Promise((resolve, reject) => {
|
||
let done = false;
|
||
const to = setTimeout(() => { if (!done) { done = true; cleanup(); reject(new Error('WS重连超时')); } }, 8000);
|
||
const onConn = () => { if (!done) { done = true; cleanup(); resolve(); } };
|
||
const onErr = (err) => { if (!done) { done = true; cleanup(); reject(err || new Error('WS重连失败')); } };
|
||
const cleanup = () => { try { wsManager.off('connected', onConn); wsManager.off('error', onErr); clearTimeout(to); } catch (e) {} };
|
||
try { wsManager.on('connected', onConn); wsManager.on('error', onErr); wsManager.connect(); } catch (e) { cleanup(); reject(e); }
|
||
});
|
||
await tryConnect;
|
||
}
|
||
|
||
let ok = false;
|
||
switch (message.msgType) {
|
||
case 'text':
|
||
ok = wsManager.sendChatMessage(this.data.targetId, message.content, 'text', this.data.chatType);
|
||
break;
|
||
case 'image': {
|
||
const url = message.content?.url || message.content;
|
||
ok = wsManager.sendChatMessage(this.data.targetId, url, 'image', this.data.chatType);
|
||
break;
|
||
}
|
||
case 'video': {
|
||
const url = message.content?.url || message.content;
|
||
ok = wsManager.sendChatMessage(this.data.targetId, url, 'video', this.data.chatType);
|
||
break;
|
||
}
|
||
case 'voice': {
|
||
const payload = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || {});
|
||
ok = wsManager.sendChatMessage(this.data.targetId, payload, 'voice', this.data.chatType);
|
||
break;
|
||
}
|
||
case 'file': {
|
||
const payload = JSON.stringify({ url: message.content?.url || '', file_name: message.fileName, file_size: message.fileSize });
|
||
ok = wsManager.sendChatMessage(this.data.targetId, payload, 'file', this.data.chatType);
|
||
break;
|
||
}
|
||
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 || '位置';
|
||
ok = wsManager.sendLocationMessage(this.data.targetId, lat, lng, address, name, this.data.chatType);
|
||
break;
|
||
}
|
||
default:
|
||
ok = false;
|
||
}
|
||
|
||
if (!ok) throw new Error('网络异常,发送失败');
|
||
// 成功由服务端回执最终置为sent,这里不强行改
|
||
} catch (err) {
|
||
console.error('❌ 重发失败:', err);
|
||
this.updateMessageDeliveryStatus(message.messageId, 'failed');
|
||
wx.showToast({ title: '发送失败', icon: 'none' });
|
||
}
|
||
},
|
||
|
||
// 将消息置为失败并可选记录原因
|
||
markMessageFailed(messageId, reason) {
|
||
const messages = this.data.messages.map(m => m.messageId === messageId ? { ...m, deliveryStatus: 'failed', errorReason: reason || '' } : m);
|
||
this.setData({ messages });
|
||
},
|
||
|
||
// 🔥 加载更多消息 - 仅在用户向上滚动时触发
|
||
loadMoreMessages() {
|
||
console.log('🔄 用户向上滚动,触发加载更多历史消息');
|
||
console.log('🔍 当前状态检查:', {
|
||
hasMore: this.data.hasMore,
|
||
loadingMessages: this.data.loadingMessages,
|
||
lastMessageId: this.data.lastMessageId,
|
||
messagesCount: this.data.messages.length
|
||
});
|
||
|
||
// 🔥 检查是否还有更多消息
|
||
if (!this.data.hasMore) {
|
||
console.log('⚠️ 没有更多历史消息了');
|
||
return;
|
||
}
|
||
|
||
// 🔥 检查是否正在加载中
|
||
if (this.data.loadingMessages) {
|
||
console.log('⚠️ 正在加载中,忽略重复请求');
|
||
return;
|
||
}
|
||
|
||
// 🔥 检查是否有lastMessageId用于分页
|
||
if (!this.data.lastMessageId) {
|
||
console.log('⚠️ 缺少lastMessageId,无法加载更多');
|
||
this.setData({ hasMore: false });
|
||
return;
|
||
}
|
||
|
||
console.log('✅ 开始加载更多历史消息,lastMessageId:', this.data.lastMessageId);
|
||
|
||
// 🔥 直接调用loadMessages,不在这里设置loadingMessages状态
|
||
// loadMessages方法内部会处理状态管理
|
||
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;
|
||
|
||
console.log('📍 自动滚动到最新消息,消息ID:', lastMessage.messageId);
|
||
|
||
// 🔥 参考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);
|
||
},
|
||
|
||
// 显示聊天菜单
|
||
showChatMenu() {
|
||
const itemList = ['清空聊天记录', '查找聊天记录'];
|
||
|
||
if (this.data.chatType === 0) {
|
||
itemList.push('视频通话', '语音通话');
|
||
} else {
|
||
itemList.push('群聊信息');
|
||
}
|
||
|
||
wx.showActionSheet({
|
||
itemList: itemList,
|
||
success: (res) => {
|
||
console.log('选择菜单项:', res.tapIndex);
|
||
wx.showToast({
|
||
title: '功能开发中',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
// 下载文件
|
||
downloadFile(e) {
|
||
const message = e.currentTarget.dataset.message;
|
||
console.log('📄 下载文件:', 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: () => {
|
||
console.log('✅ 文件打开成功');
|
||
},
|
||
fail: (err) => {
|
||
console.error('❌ 文件打开失败:', err);
|
||
wx.showToast({
|
||
title: '文件打开失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
});
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
console.error('❌ 文件下载失败:', err);
|
||
wx.showToast({
|
||
title: '文件下载失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 查看名片
|
||
viewCard(e) {
|
||
const message = e.currentTarget.dataset.message;
|
||
console.log('👤 查看名片:', message);
|
||
|
||
wx.showModal({
|
||
title: '联系人名片',
|
||
content: `姓名: ${message.cardName || '未知'}\n电话: ${message.cardPhone || '未知'}`,
|
||
showCancel: true,
|
||
cancelText: '关闭',
|
||
confirmText: '添加联系人',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
// TODO: 实现添加联系人功能
|
||
wx.showToast({
|
||
title: '添加联系人功能开发中',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 切换主题
|
||
toggleTheme() {
|
||
const next = this.data.themeClass === 'theme-dark' ? 'theme-light' : 'theme-dark';
|
||
const query = wx.createSelectorQuery();
|
||
query.select('.theme-toggle').boundingClientRect();
|
||
query.selectViewport().boundingClientRect();
|
||
query.exec((res) => {
|
||
const btnRect = res && res[0];
|
||
const vpRect = res && res[1];
|
||
// 兜底:左上角区域,和按钮在左侧的位置一致
|
||
let cx = 24;
|
||
let cy = (this.data.statusBarHeight || 0) + 16;
|
||
if (btnRect) {
|
||
cx = btnRect.left + btnRect.width / 2;
|
||
cy = btnRect.top + btnRect.height / 2;
|
||
}
|
||
// 计算到四角的最大距离,得到覆盖半径
|
||
const w = vpRect ? vpRect.width : (this.data.systemInfo?.windowWidth || 375);
|
||
const h = vpRect ? vpRect.height : (this.data.systemInfo?.windowHeight || this.data.windowHeight || 667);
|
||
const dx = Math.max(cx, w - cx);
|
||
const dy = Math.max(cy, h - cy);
|
||
const radius = Math.ceil(Math.sqrt(dx * dx + dy * dy)) + 12;
|
||
const diameter = radius * 2;
|
||
const left = Math.round(cx - radius);
|
||
const top = Math.round(cy - radius);
|
||
|
||
// 开启过渡与遮罩
|
||
this.setData({
|
||
isThemeTransitioning: true,
|
||
showThemeOverlay: true,
|
||
overlayPlaying: false,
|
||
overlayTo: next,
|
||
overlayX: cx,
|
||
overlayY: cy,
|
||
overlayDiameter: diameter,
|
||
overlayLeft: left,
|
||
overlayTop: top
|
||
});
|
||
|
||
// 下一帧启动遮罩动画,并切换主题变量
|
||
setTimeout(() => {
|
||
this.setData({ overlayPlaying: true, themeClass: next });
|
||
try { wx.setStorageSync('theme', next); } catch (e) { console.warn('保存主题失败', e); }
|
||
}, 16);
|
||
|
||
// 动画结束后收尾
|
||
setTimeout(() => {
|
||
this.setData({ showThemeOverlay: false, overlayPlaying: false, isThemeTransitioning: false });
|
||
}, 420);
|
||
});
|
||
},
|
||
}); |