// pages/character-detail/character-detail.js // 角色详情页面 - 对接后端API const app = getApp() const api = require('../../utils/api') const config = require('../../config/index') // 获取静态资源基础URL(去掉/api后缀) const getStaticBaseUrl = () => { const apiUrl = config.API_BASE_URL return apiUrl.replace(/\/api$/, '') } Page({ data: { loading: true, character: null, isLiked: false, isPlaying: false, // 音频播放状态 // 爱心弹窗 showHeartPopup: false, userLovePoints: 0, // 用户爱心值 purchasing: false, unlockHeartsCost: 500 // 默认解锁爱心成本 }, onLoad(options) { const characterId = options.id if (characterId) { this.loadCharacterDetail(characterId) this.loadHeartBalance() this.loadUnlockConfig(characterId) } else { wx.showToast({ title: '参数错误', icon: 'none' }) setTimeout(() => wx.navigateBack(), 1500) } }, /** * 加载解锁配置 */ async loadUnlockConfig(characterId) { try { const res = await api.chat.getQuota(characterId) if (res.success && res.data && res.data.unlock_config) { const cost = res.data.unlock_config.hearts_cost if (typeof cost === 'number') { this.setData({ unlockHeartsCost: cost }) console.log('[character-detail] 已从后端同步解锁成本:', cost) } } } catch (err) { console.log('[character-detail] 加载解锁配置失败,使用默认值', err) } }, onShow() { // 刷新爱心余额 this.loadHeartBalance() }, onUnload() { // 清理音频资源 if (this.audioContext) { this.audioContext.stop() this.audioContext.destroy() this.audioContext = null } }, /** * 加载角色详情 */ async loadCharacterDetail(id) { this.setData({ loading: true }) try { const res = await api.character.getDetail(id) console.log('[character-detail] API返回原始数据:', JSON.stringify(res)) // 兼容两种返回格式 let data = null if (res.code === 0 && res.data) { data = res.data } else if (res.success && res.data) { data = res.data } if (data) { // 打印关键字段 console.log('[character-detail] greetingAudioUrl:', data.greetingAudioUrl) console.log('[character-detail] greeting_audio_url:', data.greeting_audio_url) console.log('[character-detail] audio_url:', data.audio_url) const character = this.transformCharacter(data) console.log('[character-detail] 转换后的audioUrl:', character.audioUrl) this.setData({ character, isLiked: data.is_liked || false, loading: false }) } else { throw new Error(res.message || '加载失败') } } catch (err) { console.error('加载角色详情失败', err) this.setData({ loading: false }) wx.showToast({ title: '加载失败', icon: 'none' }) } }, /** * 转换角色数据格式 */ transformCharacter(data) { // 静态资源基础URL const staticBaseUrl = getStaticBaseUrl() console.log('[character-detail transformCharacter] 原始数据:', { age: data.age, companion_type: data.companion_type, name: data.name }) // 转换照片路径为完整URL const convertPhotoUrl = (url) => { if (!url) return '' // 如果已经是完整URL,直接返回 if (url.startsWith('http://') || url.startsWith('https://')) { return url } // 如果是相对路径,拼接基础URL if (url.startsWith('/characters/')) { return staticBaseUrl + url } return url } // 使用后端返回的短标签格式爱好,或者自己处理 let hobbies = data.hobbiesTags || [] if (hobbies.length === 0 && data.hobbies) { // 如果后端没有返回hobbiesTags,自己处理 if (typeof data.hobbies === 'string') { hobbies = [data.hobbies.substring(0, 4)] } else if (Array.isArray(data.hobbies)) { hobbies = data.hobbies.map(h => String(h).substring(0, 4)).slice(0, 5) } else if (typeof data.hobbies === 'object') { const allHobbies = data.hobbies.adult || Object.values(data.hobbies).flat() hobbies = allHobbies.map(h => { // 提取短标签:去除括号内容,截取前4个字符 let tag = String(h).replace(/[((][^))]*[))]/g, '').trim() const parts = tag.split(/[、,,;;::]/) tag = parts[0].trim() return tag.length > 4 ? tag.substring(0, 4) : tag }).filter(t => t.length > 0).slice(0, 5) } } // 解析性格特点(也提取短标签) let traits = data.traits || [] if (!Array.isArray(traits) || traits.length === 0) { if (data.personalityTraits) { if (Array.isArray(data.personalityTraits)) { traits = data.personalityTraits.map(t => String(t).substring(0, 4)).slice(0, 5) } else if (typeof data.personalityTraits === 'object') { const allTraits = Object.values(data.personalityTraits).flat() traits = allTraits.map(t => String(t).substring(0, 4)).slice(0, 5) } } } // 相册:优先使用后端返回的gallery,转换为完整URL let photos = (data.gallery || []).map(convertPhotoUrl).filter(p => p) if (photos.length === 0) { // 如果没有相册,使用宣传图或头像作为相册 const promoImage = convertPhotoUrl(data.promoImage || data.promo_image) const avatar = convertPhotoUrl(data.avatar || data.image) if (promoImage) { photos = [promoImage] } else if (avatar) { photos = [avatar] } } // 头像:转换为完整URL(用于小头像显示) const avatar = convertPhotoUrl(data.avatar || data.logo || data.image) || '' // 宣传图:转换为完整URL(用于头部大图显示) const promoImage = convertPhotoUrl(data.promoImage || data.promo_image || data.avatar || data.image) || '' // 处理年龄显示:如果已包含"岁"则直接使用,否则添加"岁" let ageDisplay = '' if (data.age) { const ageStr = String(data.age).trim() // 如果age不为空且不是'null'字符串 if (ageStr && ageStr !== 'null' && ageStr !== 'undefined') { ageDisplay = ageStr.includes('岁') ? ageStr : ageStr + '岁' } } // 如果没有年龄,尝试从companion_type中提取 if (!ageDisplay && data.companion_type) { const ageMatch = data.companion_type.match(/(\d+岁)/) if (ageMatch) { ageDisplay = ageMatch[1] } } console.log('[character-detail transformCharacter] 年龄处理结果:', { 原始age: data.age, 最终ageDisplay: ageDisplay }) return { id: data.id, name: data.name, avatar: avatar, promoImage: promoImage, // 宣传图(用于头部大图) job: data.occupation || data.companionType || '', age: data.age || '', ageDisplay: ageDisplay, location: data.location || data.province || '', audioDuration: data.audio_duration || '12"', about: data.about || data.selfIntroduction || data.bio || '', traits: traits.slice(0, 5), hobbies: hobbies.slice(0, 5), photos: photos, voiceId: data.voice_id || data.voiceFeatures, audioUrl: data.greetingAudioUrl || data.greeting_audio_url || data.audio_url || '', // 预录制的开场白音频URL // Edge TTS 配置(用于实时生成语音) edgeTtsVoice: data.edgeTtsVoice || data.edge_tts_voice || '', edgeTtsRate: data.edgeTtsRate || data.edge_tts_rate || '', edgeTtsPitch: data.edgeTtsPitch || data.edge_tts_pitch || '' } }, // 返回上一页 goBack() { wx.navigateBack() }, // 播放音频 async onPlayAudio() { const { character, isPlaying } = this.data // 防止重复点击 if (isPlaying) { // 如果正在播放,点击则停止 if (this.audioContext) { try { this.audioContext.stop() } catch (e) {} } this.setData({ isPlaying: false }) return } // 检查是否有有效的音频URL(非空字符串) const audioUrl = character.audioUrl console.log('[character-detail] audioUrl:', audioUrl, '类型:', typeof audioUrl) if (audioUrl && audioUrl.trim() !== '') { // 处理相对路径,拼接完整URL let fullAudioUrl = audioUrl if (audioUrl.startsWith('/')) { fullAudioUrl = getStaticBaseUrl() + audioUrl } console.log('[character-detail] 完整音频URL:', fullAudioUrl) // 先检查文件是否存在 wx.request({ url: fullAudioUrl, method: 'HEAD', success: (res) => { console.log('[character-detail] HEAD请求结果:', res.statusCode) if (res.statusCode === 200) { this.playAudioUrl(fullAudioUrl) } else { wx.showToast({ title: '音频文件不存在', icon: 'none' }) } }, fail: (err) => { console.log('[character-detail] HEAD请求失败,尝试直接播放:', err) // 有些服务器不支持HEAD,直接尝试播放 this.playAudioUrl(fullAudioUrl) } }) return } // 没有预录制音频,提示用户 wx.showToast({ title: '该角色暂无独白音频', icon: 'none', duration: 2000 }) }, // 播放Base64格式的音频 playBase64Audio(base64Data) { // 将Base64转换为临时文件 const fs = wx.getFileSystemManager() const filePath = `${wx.env.USER_DATA_PATH}/temp_audio_${Date.now()}.mp3` try { // 解码Base64并写入文件 fs.writeFileSync(filePath, base64Data, 'base64') // 播放音频 this.playAudioUrl(filePath) } catch (err) { console.error('写入音频文件失败:', err) wx.showToast({ title: '播放失败', icon: 'none' }) } }, // 播放音频URL playAudioUrl(url) { console.log('[character-detail] playAudioUrl 开始播放:', url) // 如果正在播放,先停止 if (this.audioContext) { try { this.audioContext.stop() this.audioContext.destroy() } catch (e) { console.log('[character-detail] 停止旧音频时出错:', e) } this.audioContext = null } // 显示播放中提示 wx.showToast({ title: '播放独白中...', icon: 'none', duration: 5000 }) // 延迟创建新的音频上下文,避免冲突 setTimeout(() => { // 不使用 useWebAudioImplement,某些情况下可能导致问题 const innerAudioContext = wx.createInnerAudioContext() this.audioContext = innerAudioContext // 设置音量为最大 innerAudioContext.volume = 1.0 innerAudioContext.src = url innerAudioContext.obeyMuteSwitch = false // 不受静音开关影响 innerAudioContext.onCanplay(() => { console.log('[character-detail] 音频可以播放了, duration:', innerAudioContext.duration) }) innerAudioContext.onPlay(() => { console.log('[character-detail] 音频开始播放, volume:', innerAudioContext.volume) this.setData({ isPlaying: true }) }) innerAudioContext.onTimeUpdate(() => { // 每秒打印一次进度,确认音频在播放 const currentTime = Math.floor(innerAudioContext.currentTime) if (currentTime !== this._lastLogTime) { console.log('[character-detail] 播放进度:', currentTime, '/', Math.floor(innerAudioContext.duration || 0)) this._lastLogTime = currentTime } }) innerAudioContext.onError((err) => { console.error('[character-detail] 音频播放错误:', JSON.stringify(err)) this.setData({ isPlaying: false }) wx.hideToast() // 针对不同错误给出提示 let errMsg = '播放失败' if (err.errCode === 10001) { errMsg = '系统错误,请重试' } else if (err.errCode === 10002) { errMsg = '网络错误' } else if (err.errCode === 10003) { errMsg = '音频文件错误' } else if (err.errCode === 10004) { errMsg = '音频格式不支持' } else if (err.errMsg && err.errMsg.includes('interruption')) { errMsg = '播放被中断,请重试' } wx.showToast({ title: errMsg, icon: 'none' }) }) innerAudioContext.onEnded(() => { console.log('[character-detail] 音频播放结束') this.setData({ isPlaying: false }) wx.hideToast() // 清理临时文件 if (url.startsWith(wx.env.USER_DATA_PATH)) { try { wx.getFileSystemManager().unlinkSync(url) } catch (e) { // 忽略删除失败 } } }) // 延迟播放,确保音频上下文准备好 setTimeout(() => { console.log('[character-detail] 调用 play()') innerAudioContext.play() }, 100) }, 50) }, // 查看全部相册 onViewAllPhotos() { const { photos } = this.data.character if (photos && photos.length > 0) { wx.previewImage({ urls: photos, current: photos[0] }) } }, // 预览单张照片 onPreviewPhoto(e) { const index = e.currentTarget.dataset.index const { photos } = this.data.character if (photos && photos.length > 0) { wx.previewImage({ urls: photos, current: photos[index] }) } }, // 不喜欢 - 直接返回上一页 onDislike() { wx.navigateBack() }, // 喜欢/取消喜欢 async onLike() { const { character, isLiked } = this.data // 检查登录 if (app.checkNeedLogin && app.checkNeedLogin()) return try { const res = await api.character.toggleLike(character.id) if (res.success) { const newLiked = !isLiked this.setData({ isLiked: newLiked }) // 静默操作,不显示提示 } } catch (err) { console.error('喜欢操作失败', err) wx.showToast({ title: '操作失败', icon: 'none' }) } }, // 聊天 onChat() { const { character } = this.data wx.navigateTo({ url: `/pages/chat-detail/chat-detail?id=${character.id}&name=${encodeURIComponent(character.name)}` }) }, // ==================== 爱心弹窗相关 ==================== /** * 加载用户爱心值 * 使用 /api/auth/me 接口,该接口从 im_users.grass_balance 读取余额 */ async loadHeartBalance() { try { const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) if (!token) { this.setData({ userLovePoints: 0 }) return } const res = await api.auth.getCurrentUser() if (res.success && res.data) { this.setData({ userLovePoints: res.data.grass_balance || 0 }) console.log('[character-detail] 爱心值加载成功:', res.data.grass_balance) } } catch (err) { console.log('加载爱心值失败', err) } }, /** * 显示爱心弹窗 */ showHeartPopup() { this.setData({ showHeartPopup: true }) }, /** * 关闭爱心弹窗 */ closeHeartPopup() { this.setData({ showHeartPopup: false }) }, /** * 阻止事件冒泡 */ preventBubble() {}, /** * 阻止滚动穿透 */ preventTouchMove() {}, /** * 分享解锁 */ onShareUnlock() { wx.showToast({ title: '分享功能开发中', icon: 'none' }) // TODO: 实现分享解锁逻辑 }, /** * 爱心兑换 */ async onHeartExchange() { const { character, userLovePoints, unlockHeartsCost } = this.data // 检查登录 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 (userLovePoints < unlockHeartsCost) { wx.showModal({ title: '爱心值不足', content: `您的爱心值不足${unlockHeartsCost},是否前往充值?`, confirmText: '去充值', success: (res) => { if (res.confirm) { wx.navigateTo({ url: '/pages/recharge/recharge' }) } } }) return } // 确认兑换 wx.showModal({ title: '确认兑换', content: `使用${unlockHeartsCost}爱心值解锁与${character.name}的聊天?`, confirmText: '确认兑换', success: async (res) => { if (res.confirm) { await this.doHeartExchange() } } }) }, /** * 执行爱心兑换 */ async doHeartExchange() { const { character, unlockHeartsCost, userLovePoints } = this.data this.setData({ purchasing: true }) wx.showLoading({ title: '兑换中...' }) try { // 调用角色解锁API const res = await api.character.unlock({ character_id: character.id, unlock_type: 'hearts' }) wx.hideLoading() if (res.success) { wx.showToast({ title: '解锁成功', icon: 'success' }) this.setData({ showHeartPopup: false }) // 更新本地爱心余额(优先使用后端返回,否则本地计算) const newBalance = res.data?.remaining_hearts ?? (userLovePoints - unlockHeartsCost) this.setData({ userLovePoints: newBalance }) // 延迟后跳转到聊天页面 setTimeout(() => { wx.navigateTo({ url: `/pages/chat-detail/chat-detail?id=${character.id}&name=${encodeURIComponent(character.name)}` }) }, 1000) } else { wx.showToast({ title: res.message || '兑换失败', icon: 'none' }) } } catch (err) { wx.hideLoading() console.error('兑换失败', err) wx.showToast({ title: '网络错误,请重试', icon: 'none' }) } finally { this.setData({ purchasing: false }) } }, /** * 选择爱心套餐(已废弃,保留兼容) */ selectHeartPackage(e) { const index = e.currentTarget.dataset.index this.setData({ selectedHeartPackage: index }) }, /** * 购买并解锁角色聊天(已废弃,保留兼容) * 使用 /api/payment/unified-order 接口 * 测试模式下返回 testMode: true,订单直接完成,无需调用微信支付 */ async buyAndUnlock() { // 调用新的爱心兑换逻辑 await this.onHeartExchange() }, /** * 跳转到用户协议 */ goToUserAgreement() { wx.navigateTo({ url: '/pages/agreement/agreement?code=user-agreement' }) }, /** * 跳转到隐私政策 */ goToPrivacyPolicy() { wx.navigateTo({ url: '/pages/agreement/agreement?code=privacy-policy' }) } })