// pages/chat-detail/chat-detail.js // 聊天详情页面 - 与AI角色聊天 const app = getApp() const api = require('../../utils/api') const util = require('../../utils/util') const proactiveMessage = require('../../utils/proactiveMessage') const imageUrl = require('../../utils/imageUrl') const config = require('../../config/index') // 常用表情 const EMOJIS = [ "😊", "😀", "😁", "😃", "😂", "🤣", "😅", "😆", "😉", "😋", "😎", "😍", "😘", "🥰", "😗", "😙", "🙂", "🤗", "🤩", "🤔", "😐", "😑", "😶", "🙄", "😏", "😣", "😥", "😮", "😯", "😪", "😫", "😴", "😌", "😛", "😜", "😝", "😒", "😓", "😔", "😕", "🙃", "😲", "😖", "😞", "😟", "😤", "😢", "😭", "😨", "😩", "😬", "😰", "😱", "😳", "😵", "😡", "😠", "😷", "🤒", "🤕", "😇", "🥳", "🥺", "👋", "👌", "✌️", "🤞", "👍", "👎", "👏", "🙌", "🤝", "🙏", "💪", "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "💕", "💖", "💗", "💘", "💝", "🌹", "🌺", "🌻", "🌼", "🌷", "🎉", "🎊", "🎁" ] Page({ data: { statusBarHeight: 44, navHeight: 96, // 角色信息 characterId: '', conversationId: '', character: { id: '', name: '加载中...', avatar: '', isOnline: true, job: '', location: '', gender: '', age: '', education: '', serviceCount: 0, returnCount: 0, rating: '4.9', motto: '', qualification: '', skills: [], introduction: '' }, // 用户头像 - 从用户信息获取,使用默认头像 myAvatar: '/images/default-avatar.svg', // 消息列表 messages: [], // 输入状态 inputText: '', inputFocus: false, isVoiceMode: false, isRecording: false, showEmoji: false, recordingDuration: 0, voiceCancelHint: false, // AI状态 isTyping: false, isSending: false, playingVoiceId: null, // 滚动控制 scrollIntoView: '', scrollTop: 0, // 当前滚动位置 // 加载状态 loading: true, loadingMore: false, hasMore: true, page: 1, pageSize: 20, // 每页加载20条消息 isFirstLoad: true, // 是否首次加载 // 人物介绍弹窗 showProfilePopup: false, // 查看评价弹窗 showReviewPopup: false, reviews: [], // 更多功能面板 showMorePanel: false, // 常用语列表 quickReplies: [ '你好,很高兴认识你~', '最近怎么样?', '有什么想聊的吗?', '今天心情如何?', '晚安,好梦~', '早安,新的一天开始了!' ], showQuickReplyPopup: false, // 表情列表 emojis: EMOJIS, // 约时间弹窗 showSchedulePopup: false, scheduleDate: '', scheduleTime: '', // 礼物相关 showGiftPopup: false, giftList: [], selectedGift: null, userFlowers: 0, // 聊天配额相关 remainingCount: 10, // 剩余免费次数(已废弃,保留兼容) maxCount: 10, // 每日最大免费次数(已废弃,保留兼容) isUnlocked: false, // 是否已解锁该角色 isVip: false, // 是否为VIP用户 showUnlockPopup: false, // 显示解锁弹窗 todayCharacterId: '', // 今天已聊天的角色ID heartCount: 0, // 用户爱心余额 unlockHeartsCost: 500, // 默认解锁爱心成本 // 免费畅聊相关 freeTime: null, countdownText: '' }, onLoad(options) { const { statusBarHeight, navHeight } = app.globalData // 初始化消息处理相关变量 this.pendingMessages = [] this.messageTimer = null this.isProcessing = false // 获取参数 const characterId = options.id || '' const conversationId = options.conversationId || '' const characterName = decodeURIComponent(options.name || '') // 设置用户头像 const userInfo = app.globalData.userInfo const myAvatar = imageUrl.getAvatarUrl(userInfo?.avatar) this.setData({ statusBarHeight, navHeight, characterId, conversationId, myAvatar, 'character.name': characterName || '加载中...' }) // 进入聊天详情页时,调用标记已读接口清除未读数 if (conversationId) { this.markConversationAsRead(conversationId) } // 加载角色信息和聊天历史 this.initChat() }, onShow() { // 每次显示页面时,刷新一次配额状态,确保免费畅聊时间等状态是最新的 if (!this.data.loading) { this.loadQuotaStatus() } }, onUnload() { // 页面卸载时清理 // 清除消息处理定时器 if (this.messageTimer) { clearTimeout(this.messageTimer) this.messageTimer = null } // 清除图片回复定时器 if (this.imageReplyTimers && this.imageReplyTimers.length > 0) { this.imageReplyTimers.forEach(timer => clearTimeout(timer)) this.imageReplyTimers = [] } // 清空待处理消息队列 this.pendingMessages = [] this.isProcessing = false // 离开页面时标记该角色的主动推送消息为已读 if (this.data.characterId) { proactiveMessage.markAsRead(this.data.characterId) } }, /** * 标记会话已读 * 进入聊天详情页时调用,清除未读数 * @param {string} conversationId - 会话ID */ async markConversationAsRead(conversationId) { if (!conversationId) { console.log('[chat-detail] 没有conversationId,跳过标记已读') return } console.log('[chat-detail] 开始调用标记已读接口,conversationId:', conversationId) try { const res = await api.chat.markAsRead(conversationId) console.log('[chat-detail] 标记已读API响应:', JSON.stringify(res)) if (res.code === 0 || res.success) { console.log('[chat-detail] 标记已读成功') } else { console.log('[chat-detail] 标记已读失败,响应:', res.message || res.error) } } catch (err) { console.error('[chat-detail] 标记已读请求异常:', err) } }, /** * 初始化聊天 */ async initChat() { this.setData({ loading: true }) try { // 检查登录状态和Token const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) const userId = wx.getStorageSync(config.STORAGE_KEYS.USER_ID) console.log('[chat-detail] ========== 初始化聊天 ==========') console.log('[chat-detail] Token存在:', !!token) console.log('[chat-detail] Token长度:', token ? token.length : 0) console.log('[chat-detail] UserId:', userId) console.log('[chat-detail] CharacterId:', this.data.characterId) // 先加载配额状态 await this.loadQuotaStatus() // 并行加载角色信息和聊天历史 const [characterRes, historyRes] = await Promise.all([ this.loadCharacterInfo(), this.loadChatHistory() ]) this.setData({ loading: false }) // 如果没有会话ID,创建新会话 if (!this.data.conversationId && this.data.characterId) { await this.createConversation() } } catch (err) { console.error('初始化聊天失败', err) this.setData({ loading: false }) util.showError('加载失败') } }, /** * 加载聊天配额状态 */ async loadQuotaStatus() { const { characterId } = this.data if (!characterId) { console.log('[chat-detail] 没有角色ID,使用默认配额') return } try { const res = await api.chat.getQuota(characterId) console.log('[chat-detail] 配额API响应:', JSON.stringify(res)) if (res && res.success && res.data) { const quota = res.data const isUnlocked = quota.is_unlocked || false const isVip = quota.isVip || (quota.free_chat_time && quota.free_chat_time.isVip) || false const freeChatTime = quota.free_chat_time || null const canChatByVip = !!isVip const canChatByFreeTime = !!(freeChatTime && freeChatTime.isActive) const canChat = isUnlocked || canChatByVip || canChatByFreeTime console.log('[chat-detail] 解析权限状态:', { isUnlocked, isVip, canChat, canChatByFreeTime }) this.setData({ remainingCount: -1, maxCount: 0, isUnlocked: !!isUnlocked, isVip: !!isVip, todayCharacterId: quota.today_character_id || '', freeTime: freeChatTime, unlockHeartsCost: quota.unlock_config?.hearts_cost || 500 }) // 处理免费畅聊倒计时 if (freeChatTime && freeChatTime.isActive && freeChatTime.remainingSeconds > 0) { this.startCountdown(freeChatTime.remainingSeconds) } else { this.stopCountdown() } // 如果不能聊天且未解锁,显示解锁弹窗 if (!canChat && !isUnlocked) { console.log('[chat-detail] 无聊天权限,显示解锁弹窗') this.setData({ showUnlockPopup: true }) } else { console.log('[chat-detail] 拥有聊天权限,确保解锁弹窗关闭') this.setData({ showUnlockPopup: false }) } } } catch (err) { console.log('[chat-detail] 加载权限状态失败', err) this.setData({ remainingCount: -1, maxCount: 0, isUnlocked: false }) } // 同时加载用户爱心值 await this.loadHeartBalance() }, /** * 开始倒计时 */ startCountdown(seconds) { this.stopCountdown() let remaining = seconds this.setData({ countdownText: this.formatSeconds(remaining) }) this.countdownTimer = setInterval(() => { remaining-- if (remaining <= 0) { this.stopCountdown() this.setData({ 'freeTime.isActive': false, 'freeTime.remainingSeconds': 0 }) } else { this.setData({ countdownText: this.formatSeconds(remaining) }) } }, 1000) }, /** * 停止倒计时 */ stopCountdown() { if (this.countdownTimer) { clearInterval(this.countdownTimer) this.countdownTimer = null } this.setData({ countdownText: '' }) }, /** * 格式化秒数为 MM:SS */ formatSeconds(s) { const m = Math.floor(s / 60) const rs = s % 60 return `${m}:${rs < 10 ? '0' : ''}${rs}` }, /** * 加载用户爱心值 * 使用 /api/auth/me 接口,该接口从 im_users.grass_balance 读取余额 */ async loadHeartBalance() { try { const res = await api.auth.getCurrentUser() if (res.success && res.data) { this.setData({ heartCount: res.data.grass_balance || 0 }) console.log('[chat-detail] 爱心值加载成功:', res.data.grass_balance) } } catch (err) { console.log('加载爱心值失败', err) } }, /** * 加载角色信息 */ async loadCharacterInfo() { if (!this.data.characterId) return try { const res = await api.character.getDetail(this.data.characterId) if (res.success && res.data) { // 处理头像URL - 后端已返回完整URL,前端只需兜底处理 const avatarUrl = imageUrl.getCharacterAvatarUrl(res.data.avatar || res.data.logo) // 解析擅长领域 let skills = res.data.hobbiesTags || res.data.traits || [] if (!Array.isArray(skills) || skills.length === 0) { skills = ['情感困惑', '职业压力', '成长创伤'] } // 使用API返回的真实统计数据 const serviceCount = res.data.serviceCount || 0 const returnCount = res.data.returnCount || 0 const avgRating = res.data.avgRating || 4.9 this.setData({ character: { id: res.data.id, name: res.data.name, avatar: avatarUrl, isOnline: true, voiceId: res.data.voice_id, job: res.data.occupation || res.data.companionType || '心理咨询师', location: res.data.location || res.data.province || '北京', gender: res.data.gender === 'male' ? '男' : '女', age: res.data.age || '90后', education: '本科', serviceCount: serviceCount, returnCount: returnCount, rating: avgRating.toFixed(2), motto: res.data.openingLine || '每一次倾诉,都是心灵的释放', qualification: '国家二级心理咨询师 | 情感咨询专家认证', skills: skills.slice(0, 3), introduction: res.data.selfIntroduction || res.data.about || '专业心理咨询培训' } }) } } catch (err) { console.log('加载角色信息失败', err) } }, /** * 加载聊天历史(首次加载最近20条) */ async loadChatHistory() { const { characterId, pageSize } = this.data if (!characterId) { const welcomeMsg = { id: 'welcome', text: `你好!我是${this.data.character.name},很高兴认识你~`, isMe: false, time: util.formatTime(new Date(), 'HH:mm'), type: 'text' } this.setData({ messages: [welcomeMsg], isFirstLoad: false, hasMore: false }) return } try { console.log('[chat-detail] 开始加载聊天历史, characterId:', characterId) // 首次只加载最近20条消息 const res = await api.chat.getChatHistoryByCharacter(characterId, { limit: pageSize, page: 1 }) console.log('[chat-detail] API响应:', JSON.stringify(res).substring(0, 200)) if (res.success && res.data && res.data.length > 0) { console.log('[chat-detail] 收到历史消息数量:', res.data.length) const messages = res.data.map(msg => this.transformMessage(msg)) this.setData({ messages, hasMore: res.data.length >= pageSize, page: 1, isFirstLoad: false }) console.log('[chat-detail] 消息已设置, 当前数量:', this.data.messages.length) console.log('[chat-detail] 首次加载完成,不自动滚动到底部') } else { console.log('[chat-detail] 没有历史记录,显示欢迎消息') const welcomeMsg = { id: 'welcome', text: `你好!我是${this.data.character.name},很高兴认识你~`, isMe: false, time: util.formatTime(new Date(), 'HH:mm'), type: 'text' } this.setData({ messages: [welcomeMsg], isFirstLoad: false, hasMore: false }) } } catch (err) { console.log('加载聊天历史失败:', err) const welcomeMsg = { id: 'welcome', text: `你好!我是${this.data.character.name},很高兴认识你~`, isMe: false, time: util.formatTime(new Date(), 'HH:mm'), type: 'text' } this.setData({ messages: [welcomeMsg], isFirstLoad: false, hasMore: false }) } }, /** * 加载更多历史消息(向上翻页) */ async loadMoreHistory() { const { characterId, loadingMore, hasMore, page, pageSize, messages } = this.data if (loadingMore || !hasMore || !characterId) { return } console.log('[chat-detail] 开始加载更多历史消息, page:', page + 1) this.setData({ loadingMore: true }) try { const res = await api.chat.getChatHistoryByCharacter(characterId, { limit: pageSize, page: page + 1 }) if (res.success && res.data && res.data.length > 0) { console.log('[chat-detail] 加载到更多消息:', res.data.length, '条') const newMessages = res.data.map(msg => this.transformMessage(msg)) // 将新消息插入到列表开头(历史消息在前) this.setData({ messages: [...newMessages, ...messages], hasMore: res.data.length >= pageSize, page: page + 1, loadingMore: false }) console.log('[chat-detail] 历史消息加载完成,总消息数:', this.data.messages.length) } else { console.log('[chat-detail] 没有更多历史消息了') this.setData({ hasMore: false, loadingMore: false }) } } catch (err) { console.error('[chat-detail] 加载更多历史消息失败:', err) this.setData({ loadingMore: false }) } }, /** * 滚动事件监听(检测是否滚动到顶部) */ onScroll(e) { const { scrollTop } = e.detail // 滚动到顶部时加载更多历史消息 if (scrollTop < 50 && !this.data.loadingMore && this.data.hasMore) { console.log('[chat-detail] 滚动到顶部,触发加载更多') this.loadMoreHistory() } }, /** * 转换消息格式 */ transformMessage(msg) { const baseMessage = { id: msg.id, text: msg.content, isMe: msg.role === 'user', time: util.formatTime(msg.created_at || msg.timestamp, 'HH:mm'), type: msg.message_type || 'text' } // 根据消息类型添加额外字段 if (msg.message_type === 'image' && msg.image_url) { baseMessage.imageUrl = msg.image_url } else if (msg.message_type === 'voice' && msg.voice_url) { baseMessage.audioUrl = msg.voice_url baseMessage.duration = msg.voice_duration } else if (msg.message_type === 'gift' && msg.gift_info) { baseMessage.giftInfo = typeof msg.gift_info === 'string' ? JSON.parse(msg.gift_info) : msg.gift_info } return baseMessage }, /** * 创建新会话 * 注意:后端通过 Token 验证用户身份,不再需要传递 userId */ async createConversation() { try { const res = await api.chat.createConversation(this.data.characterId) if (res.code === 0 && res.data) { this.setData({ conversationId: res.data.id }) } else if (res.success && res.data) { this.setData({ conversationId: res.data.id }) } } catch (err) { console.log('创建会话失败', err) } }, /** * 返回上一页 */ onBack() { wx.navigateBack() }, /** * 跳转到角色详情页 */ onGoToCharacterDetail() { const { characterId } = this.data if (characterId) { wx.navigateTo({ url: `/pages/character-detail/character-detail?id=${characterId}` }) } }, /** * 用户头像加载失败时的处理 */ onAvatarError() { this.setData({ myAvatar: '/images/default-avatar.svg' }) }, /** * 更多操作 */ onMore() { wx.showActionSheet({ itemList: ['查看资料', '清空聊天记录', '举报'], success: (res) => { if (res.tapIndex === 0) { // 查看资料 wx.navigateTo({ url: `/pages/character-detail/character-detail?id=${this.data.characterId}` }) } else if (res.tapIndex === 1) { this.clearMessages() } else if (res.tapIndex === 2) { util.showSuccess('举报已提交') } } }) }, /** * 清空消息 * 只清空聊天记录,不删除会话 * 会话仍然显示在消息列表中 */ async clearMessages() { const confirmed = await util.showConfirm({ title: '清空记录', content: '确定要清空聊天记录吗?此操作不可恢复。' }) if (!confirmed) return const { characterId } = this.data // 如果没有角色ID,只清空本地消息 if (!characterId) { this.setData({ messages: [] }) util.showSuccess('已清空') return } wx.showLoading({ title: '清空中...' }) try { // 调用后端API清空聊天记录(使用角色ID) const res = await api.chat.clearChatHistory(characterId) if (res.success || res.code === 0) { // 清空本地消息列表 this.setData({ messages: [] }) util.showSuccess('已清空') } else { throw new Error(res.message || '清空失败') } } catch (err) { console.error('清空聊天记录失败:', err) // 即使API失败,也清空本地消息 this.setData({ messages: [] }) util.showSuccess('已清空') } finally { wx.hideLoading() } }, /** * 输入文字 */ onInput(e) { this.setData({ inputText: e.detail.value }) }, /** * 发送消息 */ async onSend() { const { inputText, characterId, conversationId, character, isUnlocked, isVip, freeTime, remainingCount } = this.data // 只检查输入是否为空 if (!inputText.trim()) return // 检查登录 if (app.checkNeedLogin()) return // 检查聊天权限 // 1. 如果已解锁或VIP,直接放行 // 2. 如果未解锁且非VIP,检查免费畅聊时间 const canChatByFreeTime = !!(freeTime && freeTime.isActive) const canChatByVip = !!isVip if (!isUnlocked && !canChatByVip && !canChatByFreeTime) { console.log('[chat-detail] 无聊天权限,显示解锁弹窗', { isUnlocked, isVip, canChatByFreeTime }) this.setData({ showUnlockPopup: true }) return } const messageText = inputText.trim() const newId = util.generateId() // 添加用户消息到列表 const userMessage = { id: newId, text: messageText, isMe: true, time: util.formatTime(new Date(), 'HH:mm'), type: 'text' // 标记为文字消息 } // 立即清空输入框,允许用户继续输入 this.setData({ messages: [...this.data.messages, userMessage], inputText: '' }, () => { // 发送消息后立即滚动到底部 this.scrollToBottom() }) console.log('[chat-detail] 发送消息') // 将消息加入待处理队列 this.pendingMessages.push(messageText) // 如果没有正在等待的定时器,启动延迟处理 if (!this.messageTimer) { this.startMessageTimer(characterId, conversationId, character, isUnlocked, remainingCount) } }, /** * 待处理消息队列 */ pendingMessages: [], messageTimer: null, isProcessing: false, /** * 启动消息处理定时器 * 等待随机 2-8 秒,期间收集用户发送的所有消息 */ startMessageTimer(characterId, conversationId, character, isUnlocked, remainingCount) { // 随机延迟 2-4 秒 const randomDelay = Math.floor(Math.random() * 2000) + 2000 console.log('[chat-detail] 启动消息收集定时器,延迟:', randomDelay, 'ms') this.messageTimer = setTimeout(() => { this.messageTimer = null this.processPendingMessages(characterId, conversationId, character, isUnlocked, remainingCount) }, randomDelay) }, /** * 处理待处理的消息队列 * 将多条消息合并后发送给 AI */ async processPendingMessages(characterId, conversationId, character, isUnlocked, remainingCount) { if (this.pendingMessages.length === 0) return // 如果正在处理中,延迟重试而不是直接丢弃消息 if (this.isProcessing) { console.log('[chat-detail] 消息处理中,延迟500ms后重试') setTimeout(() => { this.processPendingMessages(characterId, conversationId, character, isUnlocked, remainingCount) }, 500) return } this.isProcessing = true // 取出所有待处理消息并清空队列 const messagesToProcess = [...this.pendingMessages] this.pendingMessages = [] // 合并多条消息为一条(用换行分隔) const combinedMessage = messagesToProcess.join('\n') console.log('[chat-detail] 合并处理消息:', messagesToProcess.length, '条') console.log('[chat-detail] 合并后内容:', combinedMessage) // 显示AI正在输入 this.setData({ isTyping: true }) try { // 构建对话历史(最近10条消息,只包含文字消息) // 过滤掉图片消息,因为后端不需要处理图片内容 const conversationHistory = this.data.messages .slice(-10) .filter(msg => msg.type !== 'image' && msg.text) // 只保留有文字内容的消息 .map(msg => ({ role: msg.isMe ? 'user' : 'assistant', content: msg.text })) // 发送合并后的消息到后端 const res = await api.chat.sendMessage({ character_id: characterId, conversation_id: this.data.conversationId || conversationId, message: combinedMessage, conversationHistory: conversationHistory }) this.setData({ isTyping: false }) // 检查是否需要解锁 if (!res.success && (res.error === 'FREE_CHAT_TIME_EXPIRED' || res.error === 'FREE_CHAT_TIME_NOT_CLAIMED')) { this.setData({ showUnlockPopup: true, 'freeTime.isActive': false }) if (res.error === 'FREE_CHAT_TIME_NOT_CLAIMED') { wx.showModal({ title: '领取免费畅聊', content: '领取100爱心值即可获得60分钟免费畅聊时间,是否现在去领取?', confirmText: '去领取', success: (modalRes) => { if (modalRes.confirm) { wx.switchTab({ url: '/pages/profile/profile' }) } } }) } this.isProcessing = false return } // 检查是否切换了角色 if (!res.success && res.error === 'DIFFERENT_CHARACTER') { this.setData({ showUnlockPopup: true }) wx.showToast({ title: res.message || '今天已与其他角色聊天', icon: 'none' }) this.isProcessing = false return } if (res.success && res.data) { // 更新会话ID(如果是新会话) if (res.data.conversation_id && !this.data.conversationId) { this.setData({ conversationId: res.data.conversation_id }) } // 更新解锁状态(从后端返回的quota字段) if (res.data.quota) { const newIsUnlocked = res.data.quota.is_unlocked console.log('[chat-detail] 后端返回解锁状态:', { newIsUnlocked }) this.setData({ isUnlocked: newIsUnlocked || this.data.isUnlocked }) } // 添加AI回复 const aiMessage = { id: res.data.id || util.generateId(), text: res.data.content || res.data.message, isMe: false, time: util.formatTime(new Date(), 'HH:mm'), audioUrl: res.data.audio_url, type: 'text' // 标记为文字消息 } this.setData({ messages: [...this.data.messages, aiMessage] }, () => { // AI回复后滚动到底部 this.scrollToBottom() }) } else { throw new Error(res.error || res.message || '发送失败') } } catch (err) { console.error('发送消息失败', err) // 开发模式下使用模拟AI回复 const config = require('../../config/index') if (config.DEBUG) { console.log('[DEV] 使用模拟AI回复') const mockResponse = this.getMockAIResponse(combinedMessage, character.name) const aiMessage = { id: util.generateId(), text: mockResponse, isMe: false, time: util.formatTime(new Date(), 'HH:mm'), type: 'text' // 标记为文字消息 } this.setData({ messages: [...this.data.messages, aiMessage], isTyping: false }, () => { this.scrollToBottom() }) this.isProcessing = false return } this.setData({ isTyping: false }) util.showError(err.message || '发送失败,请重试') } this.isProcessing = false }, /** * 获取模拟AI回复(开发模式) */ getMockAIResponse(userMessage, characterName) { const responses = [ `嗯,我明白你的意思~`, `这个问题很有趣呢,让我想想...`, `谢谢你愿意和我分享这些~`, `我觉得你说得很有道理!`, `哈哈,你真的很有趣~`, `我一直都在这里陪着你哦~`, `能和你聊天真的很开心!`, `你今天心情怎么样呀?`, `我很喜欢和你聊天的感觉~`, `嗯嗯,我在认真听你说呢~` ] // 根据用户消息内容选择合适的回复 if (userMessage.includes('你好') || userMessage.includes('嗨') || userMessage.includes('hi')) { return `你好呀!我是${characterName},很高兴认识你~今天想聊点什么呢?` } if (userMessage.includes('名字') || userMessage.includes('叫什么')) { return `我叫${characterName}呀,你可以这样叫我~` } if (userMessage.includes('喜欢')) { return `我也很喜欢和你聊天呢!你喜欢什么呀?` } if (userMessage.includes('开心') || userMessage.includes('高兴')) { return `看到你开心我也很开心呢!希望你每天都这么快乐~` } if (userMessage.includes('难过') || userMessage.includes('伤心') || userMessage.includes('不开心')) { return `抱抱你~有什么不开心的事情可以和我说说,我会一直陪着你的。` } // 随机选择一个回复 const randomIndex = Math.floor(Math.random() * responses.length) return responses[randomIndex] }, /** * 滚动到底部(仅在发送/接收新消息时调用) * 使用 scroll-into-view 属性,自动滚动到最后一条消息 */ scrollToBottom() { const messages = this.data.messages if (messages && messages.length > 0) { // 使用 setTimeout 确保 DOM 已更新 setTimeout(() => { this.setData({ scrollIntoView: `msg-${messages.length - 1}` }) }, 100) } }, /** * 滚动到顶部(加载更多历史消息后保持位置) */ scrollToTop() { this.setData({ scrollTop: 0 }) }, /** * 切换语音模式 */ onVoiceMode() { this.setData({ isVoiceMode: !this.data.isVoiceMode, showEmoji: false }) }, /** * 开始录音 */ onVoiceStart() { this.setData({ isRecording: true }) wx.showToast({ title: '正在录音...', icon: 'none', duration: 60000 }) // 开始录音 const recorderManager = wx.getRecorderManager() recorderManager.start({ duration: 60000, format: 'mp3' }) this.recorderManager = recorderManager }, /** * 结束录音 */ onVoiceEnd() { this.setData({ isRecording: false }) wx.hideToast() if (this.recorderManager) { this.recorderManager.stop() this.recorderManager.onStop((res) => { // 发送语音消息(暂时转为文字) const newId = util.generateId() const voiceMessage = { id: newId, text: '[语音消息]', isMe: true, time: util.formatTime(new Date(), 'HH:mm'), type: 'voice', audioUrl: res.tempFilePath } this.setData({ messages: [...this.data.messages, voiceMessage] }) this.scrollToBottom() }) } }, /** * 切换表情面板 */ onEmojiToggle() { // 收起键盘 this.setData({ inputFocus: false }) // 延迟一点再显示面板,确保键盘先收起 setTimeout(() => { this.setData({ showEmoji: !this.data.showEmoji, showMorePanel: false, isVoiceMode: false }) }, 50) }, /** * 点击+号,显示更多功能面板 */ onAddMore() { // 收起键盘 this.setData({ inputFocus: false }) // 延迟一点再显示面板,确保键盘先收起 setTimeout(() => { this.setData({ showMorePanel: !this.data.showMorePanel, showEmoji: false, isVoiceMode: false }) }, 50) }, /** * 关闭所有面板 */ onClosePanels() { this.setData({ showEmoji: false, showMorePanel: false }) }, /** * 点击聊天区域关闭面板 */ onTapChatArea() { if (this.data.showEmoji || this.data.showMorePanel) { this.setData({ showEmoji: false, showMorePanel: false }) } }, /** * 选择表情 */ onEmojiSelect(e) { const emoji = e.currentTarget.dataset.emoji this.setData({ inputText: this.data.inputText + emoji }) }, /** * 播放语音消息 */ onPlayVoice(e) { const { id, url } = e.currentTarget.dataset if (!url) { util.showError('语音不可用') return } const innerAudioContext = wx.createInnerAudioContext() innerAudioContext.src = url innerAudioContext.play() innerAudioContext.onEnded(() => { innerAudioContext.destroy() }) }, /** * 播放AI语音 */ async onPlayAIVoice(e) { const { id, text } = e.currentTarget.dataset const { character } = this.data if (!character.voiceId) { util.showError('该角色暂不支持语音') return } util.showLoading('生成语音中...') try { const res = await api.tts.synthesize({ text: text, voice_id: character.voiceId, character_id: character.id }) util.hideLoading() if (res.success && res.data && res.data.audio_url) { const innerAudioContext = wx.createInnerAudioContext() innerAudioContext.src = res.data.audio_url innerAudioContext.play() } else { util.showError('语音生成失败') } } catch (err) { util.hideLoading() util.showError('语音生成失败') } }, /** * 开始聊天(免费倾诉) */ onStartChat() { // 聚焦输入框 this.setData({ showEmoji: false }) }, /** * 显示人物介绍弹窗 */ onShowProfile() { this.setData({ showProfilePopup: true }) }, /** * 关闭人物介绍弹窗 */ onCloseProfile() { this.setData({ showProfilePopup: false }) }, /** * 查看评价 */ onShowReviews() { // 生成模拟评价数据 const mockReviews = [ { id: 1, phone: '138****6172', date: '2024-12-15 14:32', content: '老师很有耐心,倾听我的问题后给出了很中肯的建议。咨询后感觉心里轻松了很多,对未来也有了新的规划...', tags: ['专业', '耐心', '有效果'], reply: '谢谢您的信任,很高兴能够帮助到您。希望您能继续保持积极的心态,有任何问题随时可以来找我交流。', likes: 23 }, { id: 2, phone: '186****3298', date: '2024-12-10 09:15', content: '第一次尝试心理咨询,老师非常专业,让我感觉很放松。通过几次咨询,我对自己的情绪有了更好的认识...', tags: ['专业', '温暖', '有帮助'], reply: '能够陪伴您成长是我的荣幸,继续加油!', likes: 15 }, { id: 3, phone: '159****7721', date: '2024-12-05 20:48', content: '老师的声音很温柔,聊天的过程中感觉很舒服。虽然问题还在,但是心态好了很多,会继续找老师咨询的...', tags: ['温柔', '善于倾听'], reply: '', likes: 8 }, { id: 4, phone: '177****4532', date: '2024-11-28 16:22', content: '咨询师很专业,能够快速理解我的问题并给出建议。性价比很高,会推荐给朋友...', tags: ['专业', '高效'], reply: '', likes: 12 }, { id: 5, phone: '133****8965', date: '2024-11-20 11:05', content: '非常好的一次体验,老师很有同理心,让我感受到了被理解和支持...', tags: ['有同理心', '支持'], reply: '感谢您的认可,祝您生活愉快!', likes: 6 } ] this.setData({ showReviewPopup: true, reviews: mockReviews }) }, /** * 关闭评价弹窗 */ onCloseReviews() { this.setData({ showReviewPopup: false }) }, /** * 阻止弹窗滚动穿透 */ preventMove() { return false }, /** * 关闭解锁弹窗 */ closeUnlockPopup() { this.setData({ showUnlockPopup: false }) }, /** * 跳转到个人中心(去领取奖励) */ onGoToProfile() { wx.switchTab({ url: '/pages/profile/profile' }) }, /** * 爱心兑换解锁 */ async onExchangeHearts() { const { character, heartCount, unlockHeartsCost } = this.data const config = require('../../config/index') // 检查登录 const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) if (!token) { wx.showToast({ title: '请先登录', icon: 'none' }) setTimeout(() => { wx.navigateTo({ url: '/pages/login/login' }) }, 1500) return } // 检查爱心值,不足时提示并跳转充值页面 if (heartCount < unlockHeartsCost) { wx.showToast({ title: '爱心值不足,去充值', icon: 'none' }) setTimeout(() => { this.setData({ showUnlockPopup: false }) wx.navigateTo({ url: '/pages/recharge/recharge' }) }, 1500) return } wx.showLoading({ title: '兑换中...' }) try { const res = await api.character.unlock({ character_id: character.id, unlock_type: 'hearts' }) wx.hideLoading() if (res.success || res.code === 0) { wx.showToast({ title: '解锁成功', icon: 'success' }) // 更新状态,使用后端返回的剩余爱心数 const remainingHearts = res.data?.remaining_hearts ?? (heartCount - unlockHeartsCost) this.setData({ heartCount: remainingHearts, isUnlocked: true, remainingCount: -1, // -1 表示无限 showUnlockPopup: false }) } else { wx.showToast({ title: res.message || '兑换失败', icon: 'none' }) } } catch (err) { wx.hideLoading() console.error('爱心兑换失败', err) wx.showToast({ title: '网络错误,请重试', icon: 'none' }) } }, /** * 直接购买解锁(9.9元) * 使用 /api/payment/unified-order 接口 * 测试模式下返回 testMode: true,订单直接完成,无需调用微信支付 */ async onPurchaseDirect() { const { character } = this.data const config = require('../../config/index') // 检查登录 const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) if (!token) { wx.showToast({ title: '请先登录', icon: 'none' }) setTimeout(() => { wx.navigateTo({ url: '/pages/login/login' }) }, 1500) return } wx.showLoading({ title: '创建订单中...' }) try { // 调用统一支付订单接口 const res = await api.payment.createUnifiedOrder({ type: 'character_unlock', character_id: character.id, amount: 9.9 }) wx.hideLoading() console.log('[chat-detail onPurchaseDirect] API返回:', JSON.stringify(res)) if (res.success) { // 检查是否为测试模式(兼容多种判断方式) const isTestMode = res.testMode || res.test_mode || res.data?.testMode || res.data?.test_mode || // 如果 payParams.package 包含 mock_prepay,也认为是测试模式 (res.payParams?.package && res.payParams.package.includes('mock_prepay')) if (isTestMode) { // 测试模式:订单已直接完成,无需调用微信支付 wx.showToast({ title: '购买成功', icon: 'success' }) // 重新加载配额状态 await this.loadQuotaStatus() // 更新状态 this.setData({ isUnlocked: true, remainingCount: -1, showUnlockPopup: false }) } else { // 正式模式:调用微信支付 const payParams = res.payParams || res.pay_params || res.data?.payParams || res.data?.pay_params if (!payParams || !payParams.timeStamp) { wx.showToast({ title: '支付参数错误', icon: 'none' }) return } wx.requestPayment({ timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType || 'RSA', paySign: payParams.paySign, success: async () => { wx.showToast({ title: '解锁成功', icon: 'success' }) // 重新加载配额状态 await this.loadQuotaStatus() // 更新状态 this.setData({ isUnlocked: true, remainingCount: -1, showUnlockPopup: false }) }, fail: (err) => { if (err.errMsg !== 'requestPayment:fail cancel') { wx.showToast({ title: '支付失败', icon: 'none' }) } } }) } } else { wx.showToast({ title: res.message || '创建订单失败', icon: 'none' }) } } catch (err) { wx.hideLoading() console.error('购买解锁失败', err) wx.showToast({ title: '网络错误,请重试', icon: 'none' }) } }, /** * 拍照 */ onTakePhoto() { this.setData({ showMorePanel: false }) wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera'], camera: 'back', success: (res) => { const tempFilePath = res.tempFiles[0].tempFilePath this.sendImageMessage(tempFilePath) }, fail: (err) => { if (err.errMsg !== 'chooseMedia:fail cancel') { util.showError('拍照失败') } } }) }, /** * 从相册选择图片 */ onChooseImage() { this.setData({ showMorePanel: false }) wx.chooseMedia({ count: 9, mediaType: ['image'], sourceType: ['album'], success: (res) => { res.tempFiles.forEach(file => { this.sendImageMessage(file.tempFilePath) }) }, fail: (err) => { if (err.errMsg !== 'chooseMedia:fail cancel') { util.showError('选择图片失败') } } }) }, /** * 发送图片消息 * 发送图片后,先上传到服务器,然后保存到数据库,最后AI返回预设的图片回复话术 */ async sendImageMessage(tempFilePath) { const newId = util.generateId() // 先添加本地消息(显示上传中状态) const imageMessage = { id: newId, type: 'image', imageUrl: tempFilePath, isMe: true, time: util.formatTime(new Date(), 'HH:mm'), uploading: true // 标记为上传中 } this.setData({ messages: [...this.data.messages, imageMessage] }, () => { this.scrollToBottom() }) try { // 1. 上传图片到服务器 console.log('[chat-detail] 开始上传图片:', tempFilePath) // 检查登录状态 const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) console.log('[chat-detail] Token存在:', !!token) console.log('[chat-detail] Token长度:', token ? token.length : 0) const uploadRes = await api.uploadFile(tempFilePath, 'uploads') if (!uploadRes || !uploadRes.success || !uploadRes.data || !uploadRes.data.url) { throw new Error('图片上传失败') } const imageUrl = uploadRes.data.url console.log('[chat-detail] 图片上传成功:', imageUrl) // 2. 更新本地消息,移除上传中状态 const messages = this.data.messages.map(msg => { if (msg.id === newId) { return { ...msg, imageUrl: imageUrl, uploading: false } } return msg }) this.setData({ messages }) // 3. 保存图片消息到数据库 try { await api.chat.sendImage({ character_id: this.data.characterId, conversation_id: this.data.conversationId, image_url: imageUrl }) console.log('[chat-detail] 图片消息已保存到数据库') } catch (err) { console.error('[chat-detail] 保存图片消息失败:', err) // 保存失败不影响显示,继续执行 } // 4. 使用独立的图片回复定时器,避免与文字消息冲突 // 随机延迟 2-4 秒后显示AI回复 const randomDelay = Math.floor(Math.random() * 2000) + 2000 // 存储定时器ID,用于页面卸载时清理 const imageReplyTimer = setTimeout(async () => { // 检查页面是否还存在 if (!this.data) return // 标记图片回复正在处理中 this.isImageReplyProcessing = true // 只有在没有正在输入状态时才显示(避免覆盖文字消息的输入状态) const shouldShowTyping = !this.data.isTyping if (shouldShowTyping) { this.setData({ isTyping: true }) } try { // 调用图片回复话术API const res = await api.imageReply.getRandom() // 检查页面是否还存在 if (!this.data) { this.isImageReplyProcessing = false return } // 只有当前是图片回复触发的isTyping时才关闭 if (shouldShowTyping && !this.isProcessing) { this.setData({ isTyping: false }) } if (res && res.success && res.data && res.data.content) { // 添加AI回复 const aiMessage = { id: util.generateId(), text: res.data.content, isMe: false, time: util.formatTime(new Date(), 'HH:mm'), type: 'text' // 标记为文字消息 } this.setData({ messages: [...this.data.messages, aiMessage] }, () => { this.scrollToBottom() }) } else { // API返回失败,使用默认回复 this.showDefaultImageReply() } } catch (err) { console.error('[chat-detail] 获取图片回复话术失败:', err) if (this.data) { // 只有当前是图片回复触发的isTyping时才关闭 if (shouldShowTyping && !this.isProcessing) { this.setData({ isTyping: false }) } // 使用默认回复 this.showDefaultImageReply() } } finally { // 清除图片回复处理标记 this.isImageReplyProcessing = false } }, randomDelay) // 保存定时器引用,用于清理 if (!this.imageReplyTimers) { this.imageReplyTimers = [] } this.imageReplyTimers.push(imageReplyTimer) } catch (err) { console.error('[chat-detail] 图片上传失败:', err) // 更新消息状态为失败 const messages = this.data.messages.map(msg => { if (msg.id === newId) { return { ...msg, uploading: false, uploadFailed: true } } return msg }) this.setData({ messages }) util.showError('图片发送失败') } }, /** * 显示默认图片回复(API失败时的兜底) */ showDefaultImageReply() { const defaultReplies = [ '哇,这张图片真好看!', '谢谢你分享这张图片给我~', '这张图片很有意思呢!', '收到你的图片啦,真棒!' ] const randomIndex = Math.floor(Math.random() * defaultReplies.length) const aiMessage = { id: util.generateId(), text: defaultReplies[randomIndex], isMe: false, time: util.formatTime(new Date(), 'HH:mm'), type: 'text' // 标记为文字消息 } this.setData({ messages: [...this.data.messages, aiMessage] }, () => { this.scrollToBottom() }) }, /** * 预览图片 */ onPreviewImage(e) { const url = e.currentTarget.dataset.url const urls = this.data.messages .filter(msg => msg.type === 'image') .map(msg => msg.imageUrl) wx.previewImage({ current: url, urls: urls }) }, /** * 发送礼物 */ onSendGift() { this.setData({ showMorePanel: false }) // 加载礼物列表 this.loadGiftList() this.setData({ showGiftPopup: true }) }, /** * 加载礼物列表 */ async loadGiftList() { // 模拟礼物数据 const giftList = [ { id: 1, name: '玫瑰花', price: 10, image: '/images/gift-rose.png' }, { id: 2, name: '爱心', price: 20, image: '/images/gift-heart.png' }, { id: 3, name: '蛋糕', price: 50, image: '/images/gift-cake.png' }, { id: 4, name: '钻戒', price: 100, image: '/images/gift-ring.png' }, { id: 5, name: '跑车', price: 500, image: '/images/gift-car.png' }, { id: 6, name: '城堡', price: 1000, image: '/images/gift-castle.png' }, { id: 7, name: '火箭', price: 2000, image: '/images/gift-rocket.png' }, { id: 8, name: '皇冠', price: 5000, image: '/images/gift-crown.png' } ] this.setData({ giftList }) }, /** * 选择礼物 */ onSelectGift(e) { const gift = e.currentTarget.dataset.gift this.setData({ selectedGift: gift }) }, /** * 关闭礼物弹窗 */ onCloseGiftPopup() { this.setData({ showGiftPopup: false, selectedGift: null }) }, /** * 确认发送礼物 */ async onConfirmSendGift() { const { selectedGift, userFlowers, character } = this.data if (!selectedGift) { util.showError('请选择礼物') return } if (userFlowers < selectedGift.price) { util.showError('花朵余额不足') // 跳转充值页面 setTimeout(() => { wx.navigateTo({ url: '/pages/recharge/recharge' }) }, 1500) return } // 发送礼物消息 const newId = util.generateId() const giftMessage = { id: newId, type: 'gift', text: `送出了 ${selectedGift.name}`, giftInfo: selectedGift, isMe: true, time: util.formatTime(new Date(), 'HH:mm') } this.setData({ messages: [...this.data.messages, giftMessage], showGiftPopup: false, selectedGift: null, userFlowers: userFlowers - selectedGift.price }, () => { this.scrollToBottom() }) util.showSuccess('礼物已送出') // TODO: 调用后端API发送礼物 }, /** * 语音通话 */ onVoiceCall() { this.setData({ showMorePanel: false }) wx.showModal({ title: '语音通话', content: '语音通话功能即将上线,敬请期待~', showCancel: false, confirmText: '知道了' }) }, /** * 常用语 */ onQuickReply() { this.setData({ showMorePanel: false }) wx.showActionSheet({ itemList: this.data.quickReplies, success: (res) => { const selectedReply = this.data.quickReplies[res.tapIndex] this.setData({ inputText: selectedReply }) } }) }, /** * 约时间 */ onScheduleTime() { this.setData({ showMorePanel: false }) wx.showModal({ title: '约时间', content: '预约功能即将上线,敬请期待~', showCancel: false, confirmText: '知道了' }) }, /** * 抢红包 */ onRedPacket() { this.setData({ showMorePanel: false }) wx.showModal({ title: '抢红包', content: '红包功能即将上线,敬请期待~', showCancel: false, confirmText: '知道了' }) }, /** * 测结果 */ onTestResult() { this.setData({ showMorePanel: false }) // 跳转到测试结果页面 wx.showModal({ title: '测结果', content: '心理测试功能即将上线,敬请期待~', showCancel: false, confirmText: '知道了' }) }, /** * 语音录制相关方法 */ onVoiceTouchStart(e) { this.touchStartY = e.touches[0].clientY this.setData({ isRecording: true, voiceCancelHint: false, recordingDuration: 0 }) // 开始录音 const recorderManager = wx.getRecorderManager() recorderManager.start({ duration: 60000, format: 'mp3' }) this.recorderManager = recorderManager // 录音时长计时 this.recordingTimer = setInterval(() => { this.setData({ recordingDuration: this.data.recordingDuration + 1 }) }, 1000) }, onVoiceTouchMove(e) { const moveY = e.touches[0].clientY const diff = this.touchStartY - moveY // 上滑超过50px显示取消提示 this.setData({ voiceCancelHint: diff > 50 }) }, onVoiceTouchEnd() { clearInterval(this.recordingTimer) const { voiceCancelHint, recordingDuration, characterId, character, isUnlocked, remainingCount } = this.data this.setData({ isRecording: false }) if (this.recorderManager) { this.recorderManager.stop() if (voiceCancelHint) { // 取消发送 util.showToast('已取消') return } if (recordingDuration < 1) { util.showError('录音时间太短') return } this.recorderManager.onStop(async (res) => { console.log('[chat-detail] 录音完成:', res.tempFilePath, '时长:', recordingDuration) // 先显示语音消息(带识别中状态) const newId = util.generateId() const voiceMessage = { id: newId, type: 'voice', audioUrl: res.tempFilePath, duration: recordingDuration, isMe: true, time: util.formatTime(new Date(), 'HH:mm'), recognizing: true, // 识别中状态 recognizedText: '' // 识别出的文字 } this.setData({ messages: [...this.data.messages, voiceMessage] }, () => { this.scrollToBottom() }) // 进行语音识别 try { wx.showLoading({ title: '语音识别中...' }) // 读取音频文件并转换为base64 const fs = wx.getFileSystemManager() const audioData = fs.readFileSync(res.tempFilePath) const audioBase64 = wx.arrayBufferToBase64(audioData) // 调用语音识别API const recognizeRes = await api.speech.recognize({ audio: audioBase64, format: 'mp3' }) wx.hideLoading() let recognizedText = '' if (recognizeRes.success && recognizeRes.data && recognizeRes.data.text) { recognizedText = recognizeRes.data.text console.log('[chat-detail] 语音识别结果:', recognizedText) } else { // 识别失败,使用默认文字 recognizedText = '[语音消息]' console.log('[chat-detail] 语音识别失败,使用默认文字') } // 更新语音消息的识别状态 const messages = this.data.messages.map(msg => { if (msg.id === newId) { return { ...msg, recognizing: false, recognizedText } } return msg }) this.setData({ messages }) // 如果识别出了有效文字,发送给AI if (recognizedText && recognizedText !== '[语音消息]') { // 检查聊天权限 const canChatByFreeTime = !!(this.data.freeTime && this.data.freeTime.isActive) const canChatByVip = !!this.data.isVip if (!isUnlocked && !canChatByVip && !canChatByFreeTime) { console.log('[chat-detail] 语音消息无聊天权限', { isUnlocked, isVip, canChatByFreeTime }) this.setData({ showUnlockPopup: true }) return } // 将识别出的文字加入待处理队列 this.pendingMessages.push(recognizedText) // 如果没有正在等待的定时器,启动延迟处理 if (!this.messageTimer) { this.startMessageTimer(characterId, this.data.conversationId, character, isUnlocked, remainingCount) } } } catch (err) { wx.hideLoading() console.error('[chat-detail] 语音识别失败:', err) // 更新消息状态 const messages = this.data.messages.map(msg => { if (msg.id === newId) { return { ...msg, recognizing: false, recognizedText: '[语音消息]' } } return msg }) this.setData({ messages }) util.showError('语音识别失败') } }) } }, onVoiceTouchCancel() { clearInterval(this.recordingTimer) this.setData({ isRecording: false }) if (this.recorderManager) { this.recorderManager.stop() } }, /** * 消息长按操作 */ onMessageLongPress(e) { const item = e.currentTarget.dataset.item wx.showActionSheet({ itemList: ['复制', '删除'], success: (res) => { if (res.tapIndex === 0) { // 复制 wx.setClipboardData({ data: item.text, success: () => { util.showSuccess('已复制') } }) } else if (res.tapIndex === 1) { // 删除 const messages = this.data.messages.filter(msg => msg.id !== item.id) this.setData({ messages }) } } }) }, /** * 阻止事件冒泡 */ preventBubble() { return }, /** * 阻止触摸穿透 */ preventTouchMove() { return false } })