ai-c/pages/character-detail/character-detail.js
2026-02-02 18:21:32 +08:00

658 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

// 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' })
}
})