Ai_GirlFriend/xuniYou/pages/chat/phone.vue
2026-02-28 09:40:18 +08:00

1143 lines
34 KiB
Vue
Raw 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.

<template>
<view>
<view class="body">
<uni-nav-bar fixed statusBar left-icon="left" background-color="transparent" :border="false" @clickLeft="back"
color="#ffffff"></uni-nav-bar>
<image class="back"
:src="loverBasicList.image_url ? loverBasicList.image_url : 'https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/39c6f8899c15f60fc59207835f95e07a.png'"
mode="aspectFill"></image>
</view>
<!-- 麦克风权限开关移到右上角 -->
<view class="mic-permission-switch" @click="toggleMicPermission">
<text class="mic-emoji">{{ micEnabled ? '🎤' : '🔇' }}</text>
</view>
<view class="header">
<view class="header_content">
<view class="header_time">{{ isVip ? 'VIP 无限通话' : '通话剩余时间' }}</view>
<view class="header_module fa sb" v-if="!isVip">
<view class="header_title faj">{{ formatTime(remainingTime).hours }}</view>
<view class="header_title faj">{{ formatTime(remainingTime).minutes }}</view>
<view class="header_title faj">{{ formatTime(remainingTime).seconds }}</view>
</view>
<view class="header_recharge faj" @click="goRecharge">充值<image src="/static/images/phone_more.png" mode="widthFix"></image>
</view>
</view>
</view>
<!-- 替换为动态脑电波效果 -->
<view class="dynamic-wave">
<view class="wave-bar" v-for="n in 15" :key="n" :style="{ 'animation-delay': n * 0.1 + 's' }"></view>
</view>
<view class="opt fa">
<view @click="hangUp()" class="opt_item">
<image class="opt_image" src="/static/images/phone_a2.png" mode="widthFix"></image>
<view class="opt_name">挂断</view>
</view>
<!-- 按住说话按钮 -->
<view class="opt_item mic-button"
@touchstart="startTalking"
@touchend="stopTalking"
@touchcancel="stopTalking"
:class="{ 'talking': isTalking }">
<image class="opt_image" src="/static/images/phone_a1.png" mode="widthFix"></image>
<view class="opt_name">{{ isTalking ? '松开结束' : '按住说话' }}</view>
</view>
</view>
<view class="black"></view>
</view>
</template>
<script>
// #ifdef APP
const recorder = uni.requireNativePlugin('AudioRecode')
// const recorder = uni.requireNativePlugin('LcPrinter')
console.log('recorder123456::', recorder)
// #endif
// let socketTask = null
// socketTask = uni.connectSocket({
// url: 'wss://lovers.shandonghuixing.com/voice/call',//'wss://<host>/voice/call',
// header:{
// Authorization: 'Bearer ' + uni.getStorageSync("token") || "" //'Bearer <token>'
// }
// })
// console.log('socketTask:',socketTask)
import {
} from '@/utils/api.js'
import notHave from '@/components/not-have.vue';
import { baseURLPy } from '@/utils/request.js'
import topSafety from '@/components/top-safety.vue';
// 在组件外部初始化 recorderManager
let recorderManager = null;
export default {
components: {
notHave,
topSafety,
},
beforeCreate() {
console.log('beforeCreate', )
// const domModule = uni.requireNativePlugin('io.dcolud.audio.recode')
// console.log('beforeCreate',domModule)
},
data() {
return {
loverBasicList: uni.getStorageSync('loverBasicList'),
socketTask: null,
isRecording: false,
status: 'Ready',
audioContext: null,
audioData: [],
isApp: false, // 是否为 App 端
totalDuration: 300000, // 默认 5 分钟
remainingTime: 300000,
timer: null,
isVip: false,
isTalking: false, // 是否正在说话
micEnabled: true // 麦克风是否开启
}
},
onLoad() {
// 检测平台
const systemInfo = uni.getSystemInfoSync()
console.log('systemInfo', systemInfo)
// console.log('plus', plus)
this.isApp = systemInfo.uniPlatform === 'app'
// 初始化 recorderManager非 App 端)
if (!this.isApp) {
recorderManager = uni.getRecorderManager();
}
this.getCallDuration()
this.initAudio()
},
onUnload() {
this.stopCall()
if (this.timer) {
clearInterval(this.timer)
}
},
methods: {
getCallDuration() {
uni.request({
url: baseURLPy + '/voice/call/duration',
method: 'GET',
header: {
'Authorization': 'Bearer ' + uni.getStorageSync("token")
},
success: (res) => {
console.log('通话时长配置:', res.data)
if (res.data.code === 1 && res.data.data) {
const duration = res.data.data.duration
this.isVip = res.data.data.is_vip
if (duration > 0) {
this.totalDuration = duration
this.remainingTime = duration
} else {
// VIP 用户无限制,设置为 24 小时
this.totalDuration = 24 * 60 * 60 * 1000
this.remainingTime = 24 * 60 * 60 * 1000
}
}
this.connectWebSocket()
},
fail: (err) => {
console.error('获取通话时长失败:', err)
// 失败时使用默认值
this.connectWebSocket()
}
})
},
formatTime(ms) {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return {
hours: String(hours).padStart(2, '0'),
minutes: String(minutes).padStart(2, '0'),
seconds: String(seconds).padStart(2, '0')
}
},
startTimer() {
if (this.timer) {
clearInterval(this.timer)
}
this.timer = setInterval(() => {
this.remainingTime -= 1000
if (this.remainingTime <= 0) {
clearInterval(this.timer)
uni.showToast({
title: '通话时间已用完',
icon: 'none'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else if (this.remainingTime === 60000) {
// 剩余 1 分钟提示
uni.showToast({
title: '剩余 1 分钟',
icon: 'none',
duration: 2000
})
}
}, 1000)
},
hangUp() {
uni.showModal({
title: '提示',
content: '挂断改通话',
success: function(res) {
if (res.confirm) {
uni.navigateBack();
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
},
initAudio() {
// 销毁旧的音频上下文(如果存在)
if (this.audioContext) {
this.audioContext.destroy();
}
// 创建新的音频上下文
this.audioContext = uni.createInnerAudioContext()
this.audioContext.volume = 1
// 设置音频源类型
this.audioContext.srcType = 'auto'
// 添加音频事件监听
this.audioContext.onError((err) => {
console.error('音频播放失败:', err)
console.error('错误详情:', JSON.stringify(err))
uni.showToast({
title: '音频播放失败',
icon: 'none'
})
})
this.audioContext.onPlay(() => {
console.log('音频开始播放')
})
this.audioContext.onEnded(() => {
console.log('音频播放结束')
})
this.audioContext.onCanplay(() => {
console.log('音频可以播放了')
})
},
connectWebSocket() {
// 根据 baseURLPy 构建 WebSocket URL
let wsUrl = baseURLPy.replace('http://', 'ws://').replace('https://', 'wss://') + '/voice/call'
console.log('WebSocket URL:', wsUrl)
this.socketTask = uni.connectSocket({
url: wsUrl,
header: {
"content-type": "application/json",
'Authorization': 'Bearer ' + uni.getStorageSync("token") || ""
},
success: () => console.log('WS 连接成功')
});
this.socketTask.onOpen((res) => {
console.log('WebSocket onOpen:', res)
// 不要在这里自动开始录音,等用户按住按钮时再开始
// this.startRecording();
this.startTimer();
});
this.socketTask.onMessage((res) => {
console.log('onMessage:', res.data)
this.handleServerMessage(res.data);
});
this.socketTask.onError((err) => {
console.error('WS 错误', err);
});
this.socketTask.onClose((res) => {
console.log('WebSocket 关闭:', res)
if (this.isApp && this.isRecording) {
console.log('关闭录音')
recorder.stop()
}
})
},
// 开始录制
async startRecording() {
console.log('=== startRecording 被调用 ===')
console.log('isRecording:', this.isRecording)
console.log('isApp:', this.isApp)
console.log('socketTask 状态:', this.socketTask ? this.socketTask.readyState : 'null')
if (this.isRecording) {
console.log('录音已在进行中,跳过')
return;
}
this.isRecording = true;
this.status = 'Call Started';
if (this.isApp) {
console.log('App 端:启动原生录音')
this.startRecord()
} else {
// 小程序和 H5 端支持 onFrameRecorded
if (!recorderManager) {
console.error('recorderManager 未初始化')
uni.showToast({
title: '录音功能初始化失败',
icon: 'none'
})
this.isRecording = false
return
}
console.log('小程序/H5 端:设置录音监听器')
// 监听录音开始
recorderManager.onStart(() => {
console.log('✅ 录音已开始')
})
// 监听录音错误
recorderManager.onError((err) => {
console.error('❌ 录音错误:', err)
uni.showToast({
title: '录音失败: ' + (err.errMsg || '未知错误'),
icon: 'none'
})
this.isRecording = false
})
// 监听录音停止
recorderManager.onStop((res) => {
console.log('录音已停止:', res)
})
// 监听音频帧
recorderManager.onFrameRecorded((res) => {
const {
frameBuffer,
isLastFrame
} = res;
console.log('收到音频帧, isTalking:', this.isTalking, 'frameBuffer size:', frameBuffer.byteLength)
// 只有在说话状态下才发送音频数据
if (this.isTalking && this.socketTask && this.socketTask.readyState === 1) {
console.log('✅ 发送音频数据到服务器')
this.socketTask.send({
data: frameBuffer,
success: () => {
console.log('音频数据发送成功')
},
fail: (err) => {
console.error('音频数据发送失败:', err)
}
});
} else {
console.log('⏸️ 不发送音频数据 - isTalking:', this.isTalking, 'socketTask.readyState:', this.socketTask ? this.socketTask.readyState : 'null')
}
});
console.log('启动 recorderManager')
try {
recorderManager.start({
duration: this.totalDuration,
format: 'pcm', // ⚠️ 必须用 PCMParaformer 实时版只吃 PCM
sampleRate: 16000, // ⚠️ 必须 16000Hz这是 ASR 的标准
numberOfChannels: 1, // 单声道
frameSize: 2, // 单位是 KB。设置小一点(2-4KB)延迟低,设置太大延迟高
audioSource: 'voice_communication'
});
console.log('recorderManager.start 已调用')
} catch (err) {
console.error('启动录音失败:', err)
this.isRecording = false
uni.showToast({
title: '启动录音失败',
icon: 'none'
})
}
}
},
startRecord() {
console.log('=== startRecord (App原生) 被调用 ===')
const doStart = () => {
console.log('开始启动原生录音器')
recorder.start({
sampleRate: 16000,
frameSize: 640,
source: 'mic'
}, (res) => {
if (res.type === 'frame') {
const ab = uni.base64ToArrayBuffer(res.data)
console.log('收到原生音频帧, isTalking:', this.isTalking, 'buffer size:', ab.byteLength)
// 只有在说话状态下才发送音频数据
if (this.isTalking && this.socketTask && this.socketTask.readyState === 1) {
console.log('发送原生音频数据到服务器')
this.socketTask.send({
data: ab
})
} else {
console.log('不发送原生音频数据 - isTalking:', this.isTalking)
}
}
})
console.log('原生录音器已启动')
}
if (uni.getSystemInfoSync().platform !== 'android') {
doStart()
return
}
if (typeof plus === 'undefined') {
// 还没到 plusready
console.log('等待 plusready')
document.addEventListener('plusready', () => {
plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e) => {
if (e.granted && e.granted.length) {
console.log('录音权限已授予')
doStart()
} else {
console.error('录音权限被拒绝')
uni.showModal({
title: '权限不足',
content: '请允许麦克风权限'
})
}
})
})
return
}
console.log('请求录音权限')
plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e) => {
console.log('权限请求结果:', e)
if (e.granted && e.granted.length) {
console.log('录音权限已授予')
doStart()
} else {
console.error('录音权限被拒绝')
uni.showModal({
title: '权限不足',
content: '请允许麦克风权限'
})
}
})
},
// 切换麦克风权限开关
toggleMicPermission() {
this.micEnabled = !this.micEnabled
if (this.micEnabled) {
uni.showToast({
title: '麦克风已开启',
icon: 'none'
})
} else {
uni.showToast({
title: '麦克风已关闭',
icon: 'none'
})
// 如果正在说话,停止录音
if (this.isTalking) {
this.stopTalking()
}
}
},
// 开始说话(按住)
startTalking() {
console.log('startTalking 被调用, micEnabled:', this.micEnabled, 'isRecording:', this.isRecording)
if (!this.micEnabled) {
uni.showToast({
title: '请先开启麦克风权限',
icon: 'none'
})
return
}
this.isTalking = true
console.log('开始说话, isTalking 设置为:', this.isTalking)
// 如果录音还没开始,先启动录音
if (!this.isRecording) {
console.log('录音未启动,开始启动录音')
this.startRecording()
} else {
console.log('录音已在运行')
}
},
// 停止说话(松开)
stopTalking() {
this.isTalking = false
console.log('停止说话, isTalking 设置为:', this.isTalking)
// 注意:不要停止录音,只是停止发送数据
// 录音会持续进行,但数据不会被发送到服务器
},
openMicPermission() {
plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e) => {
console.log('e.granted', e)
if (e.granted && e.granted.length) {
uni.showToast({
title: '麦克风权限已开启',
icon: 'none'
})
} else {
uni.showModal({
title: '权限不足',
content: '请允许麦克风权限'
})
}
})
},
stopCall() {
this.isRecording = false;
if (recorderManager) {
recorderManager.stop();
}
if (this.socketTask) {
this.socketTask.close();
}
if (this.audioContext) {
this.audioContext.pause();
this.audioContext.destroy();
}
if (this.timer) {
clearInterval(this.timer);
}
},
// 接收消息
async handleServerMessage(data) {
console.log('接受到的信息:', data, typeof(data))
console.log('接受到的信息:', JSON.stringify(data))
console.log('接受到的信息:', Object.prototype.toString.call(data))
if (data && typeof(data) == 'object') {
//处理数据流
uni.hideLoading()
this.audioData.push(data);
console.log('this.audioData:', this.audioData)
} else {
//处理非数据流
let dataInfo = JSON.parse(data)
console.log('dataInfo:', dataInfo.type)
if (dataInfo.type == 'reply_end') {
if(this.isApp){
uni.showLoading({
title: '思考中',
mask: true
});
console.log('reply_endreply_endreply_end',)
// 创建一个完整的 ArrayBuffer 来存储所有音频数据
const totalLength = this.audioData.reduce((acc, buffer) => acc + buffer.byteLength, 0)
console.log('totalLength:',totalLength)
const mergedArrayBuffer = new Uint8Array(totalLength)
// 将音频数据填充到mergedArrayBuffer中修复之前这里没有填充数据
let offset = 0;
for (let i = 0; i < this.audioData.length; i++) {
const buffer = new Uint8Array(this.audioData[i]);
mergedArrayBuffer.set(buffer, offset);
offset += buffer.byteLength;
}
// console.log('mergedArrayBuffer填充完成长度:', mergedArrayBuffer)
const base64Audio = uni.arrayBufferToBase64(mergedArrayBuffer.buffer);
const base64WithPrefix = `data:audio/mp3;base64,${base64Audio}`;
// console.log('base64WithPrefix:',base64WithPrefix)
const filePath = await new Promise((resolve) => {
console.log('this:',this)
const fileName = `_doc/${Date.now()}_numberPerson.mp3`;
this.base64ToFile(base64WithPrefix, fileName, (path) => {
console.log('pathpathpath',path)
resolve(path);
});
});
console.log('pathpathpathfilePath',filePath)
// 使用filePath播放音频
// const audioCtx = uni.createInnerAudioContext();
this.audioContext.src = filePath;
try {
uni.hideLoading()
console.log('尝试延迟播放...');
this.audioContext.play();
// 清空音频数据
this.audioData = [];
} catch (delayError) {
console.error('延迟播放也失败:', delayError);
}
}else{
this.mergeAudioData()
}
}
if (dataInfo.type == 'reply_text') {
// #ifdef APP
uni.showLoading({
title: '思考中',
mask: true
});
// #endif
// #ifdef MP
uni.showLoading({
title: '思考中',
mask: true
});
// #endif
}
if (dataInfo.type == 'interrupt') {
this.audioContext.pause();
// uni.showToast({
// icon: "none",
// duration: 2000,
// title: "被打断"
// })
}
if (dataInfo.type == 'ready') {
// 准备好的
}
}
},
base64ToFile(base64Str, fileName, callback) {
// https://blog.csdn.net/qq_45225600/article/details/149859602
var index = base64Str.indexOf(',');
var base64Str = base64Str.slice(index + 1, base64Str.length);
plus.io.requestFileSystem(plus.io.PRIVATE_DOC, function(fs) {
fs.root.getFile(fileName, { create: true }, function(entry) {
var fullPath = entry.fullPath;
let platform = uni.getSystemInfoSync().platform;
if (platform == 'android') {
// Android实现
var Base64 = plus.android.importClass("android.util.Base64");
var FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
try {
var out = new FileOutputStream(fullPath);
var bytes = Base64.decode(base64Str, Base64.DEFAULT);
out.write(bytes);
out.close();
callback && callback(entry.toLocalURL());
} catch (e) {
console.log(e.message);
}
} else if (platform == 'ios') {
// iOS实现
var NSData = plus.ios.importClass('NSData');
var nsData = new NSData();
nsData = nsData.initWithBase64EncodedStringoptions(base64Str, 0);
if (nsData) {
nsData.plusCallMethod({ writeToFile: fullPath, atomically: true });
plus.ios.deleteObject(nsData);
}
callback && callback(entry.toLocalURL());
}
});
});
},
// 合并音频数据并播放
async mergeAudioData() {
console.log('this.audioData:',this.audioData)
if (this.audioData.length === 0) {
console.log('没有音频数据')
return null
}
try {
// 创建一个完整的 ArrayBuffer 来存储所有音频数据
const totalLength = this.audioData.reduce((acc, buffer) => acc + buffer.byteLength, 0)
console.log('totalLength:',totalLength)
const mergedArrayBuffer = new Uint8Array(totalLength)
console.log('mergedArrayBuffer:',mergedArrayBuffer)
let offset = 0
for (let i = 0; i < this.audioData.length; i++) {
const buffer = new Uint8Array(this.audioData[i])
mergedArrayBuffer.set(buffer, offset)
offset += buffer.byteLength
}
// 将合并后的 ArrayBuffer 保存为文件并播放
const fileName = `recording_${Date.now()}.mp3`
let filePath;
if (this.isApp) {
// App端音频播放处理
try {
// 重新初始化音频上下文,确保状态正确
this.initAudio();
// 先检查存储权限
if (typeof plus !== 'undefined' && plus.android) {
const Context = plus.android.importClass('android.content.Context');
const Environment = plus.android.importClass('android.os.Environment');
const permissions = ['android.permission.READ_EXTERNAL_STORAGE', 'android.permission.WRITE_EXTERNAL_STORAGE'];
plus.android.requestPermissions(permissions, (res) => {
if (res.deniedAlways.length > 0) {
console.error('存储权限被永久拒绝');
}
});
}
// 方案1尝试使用Blob URL直接播放如果支持
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
console.log('尝试使用Blob URL播放...');
const blob = new Blob([mergedArrayBuffer.buffer], { type: 'audio/mpeg' });
const blobURL = URL.createObjectURL(blob);
console.log('Blob URL:', blobURL);
this.audioContext.src = blobURL;
} else {
// 方案2写入文件后播放
console.log('尝试使用文件方式播放...');
filePath = `${plus.io.PUBLIC_DOWNLOADS}/${fileName}`;
const fileURL = await this.writeFileApp(mergedArrayBuffer.buffer, filePath);
console.log('文件播放路径:', fileURL);
// 尝试多种路径格式
let finalURL = fileURL;
// 如果fileURL是file://格式,尝试去除协议
if (fileURL.startsWith('file://')) {
const pathWithoutProtocol = fileURL.substring(7);
console.log('尝试无协议路径:', pathWithoutProtocol);
}
// 尝试使用plus.io.convertLocalFileSystemURL转换
const convertedURL = plus.io.convertLocalFileSystemURL(filePath);
console.log('转换后的路径:', convertedURL);
this.audioContext.src = finalURL;
}
} catch (error) {
console.error('App端音频播放准备失败:', error);
// 回退到简单的文件写入方式
filePath = `${plus.io.PUBLIC_DOWNLOADS}/${fileName}`;
const fileURL = await this.writeFileApp(mergedArrayBuffer.buffer, filePath);
this.audioContext.src = fileURL;
}
} else {
// 小程序端使用uni API写入文件
filePath = `${uni.env.USER_DATA_PATH}/${fileName}`;
await this.writeFileMiniProgram(mergedArrayBuffer.buffer, filePath);
this.audioContext.src = filePath;
}
console.log('最终音频源:', this.audioContext.src)
// 播放音频
try {
console.log('开始播放...');
// 确保音频上下文已正确创建
if (!this.audioContext) {
console.error('音频上下文未初始化');
this.initAudio();
this.audioContext.src = filePath;
}
// 先设置src再播放
this.audioContext.play();
// 清空音频数据
this.audioData = [];
} catch (playError) {
console.error('音频播放异常:', playError);
console.error('异常详情:', JSON.stringify(playError));
// 尝试延迟播放
setTimeout(() => {
try {
console.log('尝试延迟播放...');
this.audioContext.play();
} catch (delayError) {
console.error('延迟播放也失败:', delayError);
}
}, 500);
this.audioData = [];
}
} catch (error) {
console.error('合并音频数据失败:', error)
return null
}
},
// App端文件写入方法
writeFileApp(buffer, filePath) {
console.log('App端文件写入方法:',buffer, filePath)
return new Promise((resolve, reject) => {
console.log('plus.io.requestFileSystem',plus.io.requestFileSystem)
console.log('plus.io.PUBLIC_DOWNLOADS',plus.io.PUBLIC_DOWNLOADS)
plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => {
console.log('fs.root:',fs.root)
const fileName = filePath.split('/').pop();
fs.root.getFile(fileName, {create: true, exclusive: false}, (fileEntry) => {
console.log('fileEntry',fileEntry)
fileEntry.createWriter((writer) => {
console.log('writer:',writer)
console.log('writer 的方法:', Object.keys(writer))
console.log('buffer type:', typeof buffer, 'buffer instanceof ArrayBuffer:', buffer instanceof ArrayBuffer)
console.log('Blob 是否存在:', typeof Blob !== 'undefined')
console.log('准备进入 try 块...')
try {
console.log('已进入 try 块')
// 检查 Blob 是否可用,如果不可用则尝试直接写入 ArrayBuffer
let writeData;
if (typeof Blob !== 'undefined') {
// 先将 ArrayBuffer 转换为 Blob再写入
console.log('开始创建 Blob...')
writeData = new Blob([buffer], { type: 'audio/mpeg' });
console.log('Blob 创建成功:', writeData, 'size:', writeData.size)
} else {
// Blob 不可用,直接使用 ArrayBuffer
console.log('Blob 不可用,直接使用 ArrayBuffer')
writeData = buffer;
}
// 先设置所有事件监听器
console.log('开始设置事件监听器...')
writer.onerror = (err) => {
console.error('App端文件写入失败:', err)
reject(err)
}
writer.onwriteend = (e) => {
console.log('App端文件写入完成', e)
// 获取文件信息验证
fileEntry.file((file) => {
console.log('文件信息:', {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
})
// 验证文件大小是否大于0
if (file.size === 0) {
console.error('警告: 文件大小为0可能写入失败')
}
}, (err) => {
console.error('获取文件信息失败:', err)
})
// 获取文件路径的多种方式
const toURL = fileEntry.toURL();
const fullPath = fileEntry.fullPath;
console.log('toURL():', toURL)
console.log('fullPath:', fullPath)
let fileURL;
// 优先使用 toURL()如果返回的是有效路径file://开头)
if (toURL && toURL.startsWith('file://')) {
fileURL = toURL;
console.log('使用 toURL() 路径:', fileURL)
} else if (fullPath && fullPath.startsWith('/')) {
// fullPath 是绝对路径(如 /storage/emulated/0/...
// 直接添加 file:// 前缀
fileURL = 'file://' + fullPath;
console.log('使用 fullPath 并添加 file:// 前缀:', fileURL)
} else if (toURL) {
// toURL() 返回相对路径(如 _downloads/xxx.mp3尝试转换
fileURL = plus.io.convertLocalFileSystemURL(toURL);
console.log('使用 toURL() 相对路径转换:', fileURL)
// 如果转换后仍不是 file:// 开头,手动添加
if (!fileURL || !fileURL.startsWith('file://')) {
// 尝试用 fullPath如果可用
if (fullPath && fullPath.startsWith('/')) {
fileURL = 'file://' + fullPath;
console.log('回退到 fullPath 并添加 file:// 前缀:', fileURL)
}
}
}
console.log('App端最终文件URL:', fileURL)
resolve(fileURL)
}
writer.ontruncate = (e) => {
console.log('App端文件清空完成开始写入', e)
console.log('writer.readyState:', writer.readyState)
// truncate 完成后再写入数据
console.log('准备写入数据:', writeData, '类型:', typeof writeData, '是否是ArrayBuffer:', writeData instanceof ArrayBuffer)
// 添加写入开始监听
writer.onwritestart = (e) => {
console.log('文件写入开始', e)
}
// 如果 Blob 不可用ArrayBuffer 需要转换为 Uint8Array 的 buffer
if (writeData instanceof ArrayBuffer) {
console.log('写入 ArrayBuffer大小:', writeData.byteLength, 'bytes')
// FileWriter.write() 应该支持 ArrayBuffer但需要确保格式正确
writer.write(writeData)
} else {
console.log('写入 Blob大小:', writeData.size)
writer.write(writeData)
}
}
console.log('事件监听器设置完成,开始 seek...')
// 清空文件内容(如果文件已存在)
// 先定位到文件开头
writer.seek(0);
console.log('seek 完成,开始 truncate...')
// 然后清空文件,这会触发 ontruncate 事件
writer.truncate(0);
console.log('truncate 调用完成')
} catch (error) {
console.error('写入过程中发生错误:', error)
reject(error)
}
}, (err) => {
console.error('App端创建写入器失败:', err)
reject(err)
})
}, (err) => {
console.error('App端获取文件失败:', err)
reject(err)
})
}, (err) => {
console.error('App端请求文件系统失败:', err)
reject(err)
})
})
},
// 小程序端文件写入方法
writeFileMiniProgram(buffer, filePath) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager()
fs.writeFile({
filePath: filePath,
data: buffer,
encoding: 'binary',
success: (res) => {
console.log('小程序端文件写入成功:', res)
resolve(filePath)
},
fail: (err) => {
console.error('小程序端文件写入失败:', err)
reject(err)
}
})
})
},
back() {
uni.navigateBack({
delta: 1,
});
},
generateClick() {
uni.navigateBack({
delta: 2,
});
},
goRecharge() {
uni.showToast({
title: '充值功能开发中',
icon: 'none'
})
}
}
}
</script>
<style>
page {
/* opacity: 0.7; */
}
</style>
<style>
.body {
position: relative;
}
.back {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.body {
position: relative;
padding: 0 60rpx;
}
.header {
position: absolute;
right: 28rpx;
top: 11%;
z-index: 5;
}
.header_content {
position: relative;
padding: 12rpx 24rpx;
background: linear-gradient(135deg, rgba(159, 71, 255, 0.6) 0%, rgba(0, 83, 250, 0.6) 100%);
border-radius: 12rpx;
text-align: center;
}
.header_time {
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
}
.header_module {
position: relative;
}
.header_title {
margin: 0 8rpx 0 0;
padding: 0 12rpx;
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
background: linear-gradient(135deg, rgba(159, 71, 255, 0.6) 0%, rgba(0, 83, 250, 0.6) 100%);
border-radius: 12rpx;
}
.header_title:nth-child(3) {
margin: 0 0 0 0;
}
.header_recharge {
position: relative;
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
}
.header_recharge image {
margin: 0 0 0 10rpx;
width: 6rpx;
height: 10rpx;
}
/* 删除原来的voice样式添加新的动态波形样式 */
.dynamic-wave {
position: absolute;
left: 0;
right: 0;
bottom: 50%;
width: 300rpx;
/* 增加整体宽度 */
margin: 0 auto;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
height: 150rpx;
/* 增加容器高度 */
}
.wave-bar {
width: 8rpx;
/* 增加单个波形条的宽度 */
height: 40rpx;
/* 增加基础高度 */
margin: 0 4rpx;
/* 增加间距 */
background: linear-gradient(to top, #9f47ff, #0053fa);
border-radius: 4rpx;
/* 调整圆角 */
animation: wave 1.5s infinite ease-in-out;
}
@keyframes wave {
0%,
100% {
height: 40rpx;
/* 调整基础高度 */
opacity: 0.6;
}
50% {
height: 120rpx;
/* 调整最大高度 */
opacity: 1;
}
}
/* 麦克风权限开关样式 */
.mic-permission-switch {
position: absolute;
right: 28rpx;
top: 100rpx;
z-index: 10;
width: 80rpx;
height: 80rpx;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10rpx);
}
.mic-emoji {
font-size: 48rpx;
}
.opt {
position: absolute;
left: 0;
right: 0;
bottom: 15%;
margin: 0 auto;
z-index: 5;
display: flex;
justify-content: center;
gap: 100rpx;
}
.opt_item {
position: relative;
}
.opt_image {
width: 132rpx;
height: 132rpx;
border-radius: 100rpx;
display: block;
}
.opt_name {
margin: 26rpx 0 0 0;
font-weight: 500;
font-size: 32rpx;
color: #FFFFFF;
line-height: 50rpx;
text-align: center;
}
/* 按住说话按钮样式 */
.mic-button {
transition: transform 0.2s;
}
.mic-button.talking {
transform: scale(1.1);
}
.mic-button.talking .opt_image {
box-shadow: 0 0 20rpx rgba(159, 71, 255, 0.8);
}
.black {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 812rpx;
background: linear-gradient(178deg, rgba(0, 0, 0, 0) 0%, #000000 100%);
z-index: 2;
}
</style>