Files
sionrui/frontend/app/web-gold/src/components/VoiceSelector.vue
sion123 52a1094144 feat(kling): add validation error display in timeline panel and update UI components
- Remove unused audio base64 reference and error message in VoiceSelector
- Rename CSS class from 'result-banner' to 'result-inline' and update button styling
- Pass validationError prop from GenerateStep to TimelinePanel
- Add validation error display in TimelinePanel with error state styling
- Update conditional rendering to show either validation error or duration diff
- Add CloseCircleOutlined icon for error status display
2026-03-05 23:43:27 +08:00

546 lines
14 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>
<div class="voice-selector">
<div v-if="userVoiceCards.length === 0" class="empty-voices">
<a-empty :image="simpleImage" description="还没有配音">
</a-empty>
</div>
<div v-else class="voice-selector-wrapper">
<!-- 选择器卡片 -->
<div class="voice-card" :class="{ 'has-audio': audioUrl }">
<div class="voice-card-header">
<div class="header-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14C13.1046 14 14 13.1046 14 12C14 10.8954 13.1046 10 12 10C10.8954 10 10 10.8954 10 12C10 13.1046 10.8954 14 12 14Z" fill="currentColor"/>
<path d="M12 6C14.813 6 17.125 8.156 17.469 10.875C17.5 11.125 17.719 11.313 17.969 11.313H18.031C18.313 11.313 18.531 11.063 18.5 10.781C18.094 7.5 15.344 5 12 5C8.656 5 5.906 7.5 5.5 10.781C5.469 11.063 5.687 11.313 5.969 11.313H6.031C6.281 11.313 6.5 11.125 6.531 10.875C6.875 8.156 9.187 6 12 6Z" fill="currentColor" opacity="0.6"/>
<path d="M12 3C16.5 3 20.188 6.5 20.469 11C20.5 11.25 20.719 11.438 20.969 11.438H21.031C21.313 11.438 21.531 11.188 21.5 10.906C21.156 5.875 17 2 12 2C7 2 2.844 5.875 2.5 10.906C2.469 11.188 2.687 11.438 2.969 11.438H3.031C3.281 11.438 3.5 11.25 3.531 11C3.813 6.5 7.5 3 12 3Z" fill="currentColor" opacity="0.3"/>
</svg>
</div>
<span class="header-title">音色选择</span>
<span v-if="currentVoiceName" class="header-badge">{{ currentVoiceName }}</span>
</div>
<div class="voice-card-body">
<div class="select-wrapper">
<a-select
v-model:value="selectedVoiceId"
placeholder="请选择音色"
class="voice-select"
:options="voiceOptions"
@change="handleVoiceChange"
>
<template #suffixIcon>
<DownOutlined class="select-arrow" />
</template>
</a-select>
</div>
<a-button
class="synthesize-btn"
:class="{ 'btn-active': selectedVoiceId }"
:disabled="!selectedVoiceId || isPlayerInitializing"
:loading="previewLoadingVoiceId === selectedVoiceId"
@click="handleSynthesize"
>
<template #icon>
<SoundOutlined />
</template>
<span class="btn-text">合成试听</span>
</a-button>
</div>
</div>
<!-- 播放器区域 -->
<transition name="slide-fade">
<div v-if="audioUrl" class="player-section">
<div ref="playerContainer" class="aplayer-container"></div>
<div class="player-actions">
<a-button
type="text"
size="small"
@click="downloadAudio"
class="download-btn"
>
<template #icon>
<DownloadOutlined />
</template>
下载音频
</a-button>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { Empty, message } from 'ant-design-vue'
import { SoundOutlined, DownloadOutlined, DownOutlined } from '@ant-design/icons-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
import APlayer from 'aplayer'
const props = defineProps({
synthText: {
type: String,
default: ''
},
speechRate: {
type: Number,
default: 1.0
}
})
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
const voiceStore = useVoiceCopyStore()
const emit = defineEmits(['select', 'audioGenerated'])
let player = null
const playerContainer = ref(null)
const audioUrl = ref('')
const currentVoiceName = ref('')
const isPlayerInitializing = ref(false)
// 默认封面图片(音频波形图标)
const defaultCover = `data:image/svg+xml;base64,${btoa(`
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#1f2937" rx="8"/>
<g fill="#60a5fa">
<rect x="20" y="35" width="4" height="30" rx="2">
<animate attributeName="height" values="30;20;30" dur="0.8s" repeatCount="indefinite"/>
<animate attributeName="y" values="35;40;35" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="30" y="30" width="4" height="40" rx="2">
<animate attributeName="height" values="40;25;40" dur="0.6s" repeatCount="indefinite"/>
<animate attributeName="y" values="30;37.5;30" dur="0.6s" repeatCount="indefinite"/>
</rect>
<rect x="40" y="25" width="4" height="50" rx="2">
<animate attributeName="height" values="50;30;50" dur="0.7s" repeatCount="indefinite"/>
<animate attributeName="y" values="25;35;25" dur="0.7s" repeatCount="indefinite"/>
</rect>
<rect x="50" y="28" width="4" height="44" rx="2">
<animate attributeName="height" values="44;28;44" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="y" values="28;36;28" dur="0.9s" repeatCount="indefinite"/>
</rect>
<rect x="60" y="32" width="4" height="36" rx="2">
<animate attributeName="height" values="36;22;36" dur="0.5s" repeatCount="indefinite"/>
<animate attributeName="y" values="32;39;32" dur="0.5s" repeatCount="indefinite"/>
</rect>
<rect x="70" y="38" width="4" height="24" rx="2">
<animate attributeName="height" values="24;15;24" dur="0.7s" repeatCount="indefinite"/>
<animate attributeName="y" values="38;42.5;38" dur="0.7s" repeatCount="indefinite"/>
</rect>
</g>
<circle cx="50" cy="50" r="18" fill="none" stroke="#60a5fa" stroke-width="2" opacity="0.3"/>
<path d="M44 44 L44 56 L56 50 Z" fill="#60a5fa" opacity="0.5"/>
</svg>
`.trim())}`
// 使用TTS Hook
const {
previewLoadingVoiceId,
playVoiceSample,
setText,
setSpeechRate
} = useTTS({
provider: TTS_PROVIDERS.SILICONFLOW
})
const selectedVoiceId = ref('')
const userVoiceCards = computed(() =>
(voiceStore.profiles || []).map(profile => ({
id: `user-${profile.id}`,
rawId: profile.id,
name: profile.name || '未命名',
category: '',
gender: profile.gender || 'female',
description: profile.note || '我的配音',
fileUrl: profile.fileUrl,
transcription: profile.transcription || '',
source: 'user',
voiceId: profile.voiceId
}))
)
const voiceOptions = computed(() =>
userVoiceCards.value.map(voice => ({
value: voice.id,
label: voice.name,
data: voice
}))
)
const handleVoiceChange = (value, option) => {
const voice = option.data
selectedVoiceId.value = value
currentVoiceName.value = voice.name
emit('select', voice)
}
const handleSynthesize = () => {
if (!selectedVoiceId.value || isPlayerInitializing.value) return
const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value)
if (!voice) return
currentVoiceName.value = voice.name
handlePlayVoiceSample(voice)
}
// 监听 prop 变化,更新 TTS 参数
watch(() => props.synthText, (newText) => {
setText(newText || '')
}, { immediate: true })
watch(() => props.speechRate, (newRate) => {
setSpeechRate(newRate)
}, { immediate: true })
const handlePlayVoiceSample = (voice) => {
currentVoiceName.value = voice.name
playVoiceSample(
voice,
(data) => {
const url = data.audioUrl || data.objectUrl
if (!url) return
initPlayer(url)
},
undefined, // 错误静默处理
{ autoPlay: false }
)
}
/**
* 初始化 APlayer
*/
const initPlayer = (url) => {
// 防止并发初始化
if (isPlayerInitializing.value) {
return
}
isPlayerInitializing.value = true
destroyPlayer()
audioUrl.value = url
nextTick(() => {
try {
// 检查容器是否存在
if (!playerContainer.value) {
message.error('播放器容器未就绪')
isPlayerInitializing.value = false
audioUrl.value = ''
return
}
player = new APlayer({
container: playerContainer.value,
autoplay: true,
theme: '#3b82f6',
volume: 0.7,
loop: 'none',
audio: [{
name: currentVoiceName.value || '语音合成',
artist: '合成',
url: url,
cover: defaultCover
}]
})
player.on('ended', () => {
player.seek(0)
})
player.on('error', (e) => {
console.error('APlayer 播放错误:', e)
})
player.on('canplay', () => {
isPlayerInitializing.value = false
// 发送音频时长和 URL 给父组件
const durationMs = Math.floor(player.audio.duration * 1000)
if (durationMs > 0) {
emit('audioGenerated', {
durationMs,
audioUrl: audioUrl.value // 使用 URL性能优化
})
}
})
} catch (e) {
console.error('APlayer 初始化失败:', e)
message.error('播放器初始化失败,请重试')
isPlayerInitializing.value = false
audioUrl.value = ''
}
})
}
const downloadAudio = () => {
if (!audioUrl.value) return
const link = document.createElement('a')
link.href = audioUrl.value
link.download = `${currentVoiceName.value || '语音合成'}.mp3`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const destroyPlayer = () => {
isPlayerInitializing.value = false
if (player) {
try {
player.pause()
player.destroy()
} catch (e) {
console.error('销毁播放器失败:', e)
}
player = null
}
if (audioUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(audioUrl.value)
}
audioUrl.value = ''
}
onMounted(async () => {
await voiceStore.refresh()
})
onBeforeUnmount(() => {
destroyPlayer()
})
</script>
<style scoped lang="less">
.voice-selector {
width: 100%;
}
.empty-voices {
padding: 24px 0;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: 12px;
}
/* 主容器 */
.voice-selector-wrapper {
display: flex;
flex-direction: column;
gap: 14px;
}
/* 音色卡片 - 柔和风格 */
.voice-card {
background: rgba(59, 130, 246, 0.03);
border: 1px solid rgba(59, 130, 246, 0.1);
border-radius: 10px;
padding: 14px;
transition: all 0.25s ease;
&:hover {
border-color: rgba(59, 130, 246, 0.18);
background: rgba(59, 130, 246, 0.05);
}
&.has-audio {
border-color: rgba(59, 130, 246, 0.2);
background: rgba(59, 130, 246, 0.06);
}
}
/* 卡片头部 */
.voice-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.header-icon {
width: 26px;
height: 26px;
border-radius: 6px;
background: rgba(59, 130, 246, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
svg {
width: 14px;
height: 14px;
}
}
.header-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text);
}
.header-badge {
margin-left: auto;
padding: 2px 8px;
background: rgba(59, 130, 246, 0.08);
border-radius: 10px;
font-size: 11px;
color: #3b82f6;
font-weight: 500;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 卡片主体 */
.voice-card-body {
display: flex;
gap: 10px;
align-items: stretch;
}
.select-wrapper {
flex: 1;
:deep(.ant-select) {
width: 100%;
height: 36px;
.ant-select-selector {
height: 36px !important;
border-radius: 8px !important;
border-color: rgba(59, 130, 246, 0.12) !important;
background: rgba(255, 255, 255, 0.9) !important;
transition: all 0.2s ease !important;
&:hover {
border-color: rgba(59, 130, 246, 0.25) !important;
}
}
&.ant-select-focused .ant-select-selector {
border-color: rgba(59, 130, 246, 0.3) !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.06) !important;
}
.ant-select-selection-item {
line-height: 34px !important;
font-size: 13px;
}
.ant-select-selection-placeholder {
line-height: 34px !important;
font-size: 13px;
}
}
}
.select-arrow {
color: #94a3b8;
transition: transform 0.3s ease;
}
/* 合成按钮 - 柔和风格 */
.synthesize-btn {
height: 36px;
padding: 0 16px;
border-radius: 8px;
border: none;
background: rgba(59, 130, 246, 0.08);
color: #64748b;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.25s ease;
white-space: nowrap;
&:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.12);
color: #475569;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.btn-active {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
&:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.2);
}
}
.btn-text {
font-size: 13px;
}
}
/* 播放器区域 */
.player-section {
background: rgba(59, 130, 246, 0.03);
border-radius: 10px;
padding: 12px;
border: 1px solid rgba(59, 130, 246, 0.08);
}
.aplayer-container {
:deep(.aplayer) {
border-radius: 8px;
box-shadow: none;
border: 1px solid rgba(0, 0, 0, 0.04);
.aplayer-body {
border-radius: 8px;
}
}
}
.player-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.download-btn {
color: #94a3b8;
font-size: 12px;
padding: 4px 8px;
height: auto;
transition: all 0.2s ease;
&:hover {
color: #3b82f6;
background: rgba(59, 130, 246, 0.06);
}
}
/* 动画 */
.slide-fade-enter-active {
transition: all 0.25s ease-out;
}
.slide-fade-leave-active {
transition: all 0.2s ease-in;
}
.slide-fade-enter-from {
transform: translateY(-8px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-8px);
opacity: 0;
}
</style>