2096 lines
58 KiB
JavaScript
2096 lines
58 KiB
JavaScript
// 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
|
||
}
|
||
})
|