feat: 优化

This commit is contained in:
2026-02-02 22:36:20 +08:00
parent 33b748915d
commit 5cee704132
5 changed files with 108 additions and 16 deletions

View File

@@ -23,6 +23,7 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"ai": "^6.0.39", "ai": "^6.0.39",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"aplayer": "^1.10.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"path-to-regexp": "^6.3.0", "path-to-regexp": "^6.3.0",

View File

@@ -27,19 +27,32 @@
试听 试听
</a-button> </a-button>
</div> </div>
<!-- APlayer 播放器容器 -->
<div v-if="audioUrl" ref="playerContainer" class="aplayer-container"></div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy' import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS' import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
import APlayer from 'aplayer'
const voiceStore = useVoiceCopyStore() const voiceStore = useVoiceCopyStore()
const emit = defineEmits(['select']) const emit = defineEmits(['select'])
// 使用TTS Hook默认使用Qwen供应商 // APlayer 实例
let player = null
const playerContainer = ref(null)
const audioUrl = ref('')
const currentVoiceName = ref('')
// 默认封面图片Base64 SVG
const defaultCover = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIGZpbGw9IiMzY4MmY2IiBmaWxsLW9wYWNpdHk9IjAuMSIvPjxwL3N2Zz4='
// 使用TTS Hook
const { const {
previewLoadingVoiceId, previewLoadingVoiceId,
playingPreviewVoiceId, playingPreviewVoiceId,
@@ -87,6 +100,7 @@ const voiceOptions = computed(() =>
const handleVoiceChange = (value, option) => { const handleVoiceChange = (value, option) => {
const voice = option.data const voice = option.data
selectedVoiceId.value = value selectedVoiceId.value = value
currentVoiceName.value = voice.name
emit('select', voice) emit('select', voice)
} }
@@ -97,27 +111,90 @@ const handlePreviewCurrentVoice = () => {
const voice = displayedVoices.value.find(v => v.id === selectedVoiceId.value) const voice = displayedVoices.value.find(v => v.id === selectedVoiceId.value)
if (!voice) return if (!voice) return
currentVoiceName.value = voice.name
handlePlayVoiceSample(voice) handlePlayVoiceSample(voice)
} }
/** /**
* 处理音色试听 * 处理音色试听
* 使用Hook提供的playVoiceSample方法
*/ */
const handlePlayVoiceSample = (voice) => { const handlePlayVoiceSample = (voice) => {
currentVoiceName.value = voice.name
playVoiceSample( playVoiceSample(
voice, voice,
(audioData) => { (data) => {
// 成功回调 // 提取音频 URL
console.log('音频播放成功', audioData) const url = data.audioUrl || data.objectUrl
if (!url) {
console.error('无效的音频数据格式', data)
return
}
initPlayer(url)
}, },
(error) => { (error) => {
// 错误回调
console.error('音频播放失败', error) console.error('音频播放失败', error)
} }
) )
} }
/**
* 初始化 APlayer
*/
const initPlayer = (url) => {
destroyPlayer()
audioUrl.value = url
nextTick(() => {
if (!playerContainer.value) return
player = new APlayer({
container: playerContainer.value,
autoplay: true,
theme: '#3b82f6',
preload: 'auto',
volume: 0.7,
audio: [{
name: currentVoiceName.value || '语音试听',
artist: '试听',
url: url,
cover: defaultCover
}],
options: {
showDownload: true
}
})
player.on('ended', () => {
if (audioUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(audioUrl.value)
}
audioUrl.value = ''
})
player.on('error', (e) => {
console.error('APlayer 播放错误:', e)
})
})
}
/**
* 销毁播放器
*/
const destroyPlayer = () => {
if (player) {
try {
player.destroy()
} catch (e) {
console.error('销毁播放器失败:', e)
}
player = null
}
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
/** /**
* 设置要试听的文本(供父组件调用) * 设置要试听的文本(供父组件调用)
* @param {string} text 要试听的文本 * @param {string} text 要试听的文本
@@ -142,6 +219,10 @@ defineExpose({
onMounted(async () => { onMounted(async () => {
await voiceStore.refresh() await voiceStore.refresh()
}) })
onBeforeUnmount(() => {
destroyPlayer()
})
</script> </script>
<style scoped> <style scoped>
@@ -176,4 +257,9 @@ onMounted(async () => {
height: 32px; height: 32px;
white-space: nowrap; white-space: nowrap;
} }
/* APlayer 容器样式 */
.aplayer-container {
margin-top: 12px;
}
</style> </style>

View File

@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import Antd from 'ant-design-vue' import Antd from 'ant-design-vue'
import 'normalize.css' import 'normalize.css'
import 'ant-design-vue/dist/reset.css' import 'ant-design-vue/dist/reset.css'
import 'aplayer/dist/APlayer.min.css'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';

View File

@@ -12,6 +12,7 @@
"description": "", "description": "",
"dependencies": { "dependencies": {
"@types/node": "^25.0.6", "@types/node": "^25.0.6",
"aplayer": "^1.10.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
"localforage": "^1.10.0", "localforage": "^1.10.0",

View File

@@ -499,9 +499,9 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
log.info("[previewVoice][试听voiceConfigId={}, voiceId={}, userId={}]", log.info("[previewVoice][试听voiceConfigId={}, voiceId={}, userId={}]",
voiceConfigId, reqVO.getVoiceId(), userId); voiceConfigId, reqVO.getVoiceId(), userId);
String voiceId = null; String voiceId;
String fileUrl = null; String fileUrl;
String referenceText = null; String referenceText;
// 1. 通过语音URL合成 // 1. 通过语音URL合成
if (StrUtil.isNotBlank(reqVO.getFileUrl()) && StrUtil.isNotBlank(reqVO.getTranscriptionText())) { if (StrUtil.isNotBlank(reqVO.getFileUrl()) && StrUtil.isNotBlank(reqVO.getTranscriptionText())) {
@@ -510,6 +510,7 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
? reqVO.getFileUrl() ? reqVO.getFileUrl()
: fileApi.presignGetUrl(rawFileUrl, PRESIGN_URL_EXPIRATION_SECONDS); : fileApi.presignGetUrl(rawFileUrl, PRESIGN_URL_EXPIRATION_SECONDS);
referenceText = reqVO.getTranscriptionText(); referenceText = reqVO.getTranscriptionText();
voiceId = null;
} }
// 2. 用户配音 // 2. 用户配音
else if (voiceConfigId != null) { else if (voiceConfigId != null) {
@@ -518,8 +519,10 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
throw exception(VOICE_NOT_EXISTS, "配音不存在"); throw exception(VOICE_NOT_EXISTS, "配音不存在");
} }
if (StrUtil.isNotBlank(voice.getVoiceId())) {
voiceId = voice.getVoiceId(); voiceId = voice.getVoiceId();
if (StrUtil.isNotBlank(voiceId)) {
fileUrl = null;
referenceText = null;
} else { } else {
FileDO fileDO = fileMapper.selectById(voice.getFileId()); FileDO fileDO = fileMapper.selectById(voice.getFileId());
if (fileDO == null) { if (fileDO == null) {
@@ -538,14 +541,14 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
if (StrUtil.isBlank(voiceId)) { if (StrUtil.isBlank(voiceId)) {
throw exception(VOICE_NOT_EXISTS, "系统配音音色ID不能为空"); throw exception(VOICE_NOT_EXISTS, "系统配音音色ID不能为空");
} }
fileUrl = null;
referenceText = null;
} }
// 统一处理:使用前端传入的 inputText否则使用默认试听文本
String finalText = StrUtil.blankToDefault(reqVO.getInputText(), getPreviewText()); String finalText = StrUtil.blankToDefault(reqVO.getInputText(), getPreviewText());
String instruction = reqVO.getInstruction(); String instruction = reqVO.getInstruction();
Float speechRate = reqVO.getSpeechRate() != null ? reqVO.getSpeechRate() : 1.0f; Float speechRate = ObjectUtil.defaultIfNull(reqVO.getSpeechRate(), 1.0f);
Float volume = reqVO.getVolume() != null ? reqVO.getVolume() : 0f; Float volume = ObjectUtil.defaultIfNull(reqVO.getVolume(), 0f);
String audioFormat = StrUtil.blankToDefault(reqVO.getAudioFormat(), "mp3"); String audioFormat = StrUtil.blankToDefault(reqVO.getAudioFormat(), "mp3");
// 缓存 // 缓存