658 lines
19 KiB
JavaScript
658 lines
19 KiB
JavaScript
// 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' })
|
||
}
|
||
})
|