Files
sionrui/frontend/app/web-gold/src/components/VoiceSelector.vue
2026-03-17 23:41:49 +08:00

633 lines
15 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">
<div class="empty-icon">
<Icon icon="lucide:mic-off" class="size-10 text-muted-foreground/40" />
</div>
<p class="text-muted-foreground mb-4">还没有配音</p>
<Button @click="$router.push('/voice-copy')">
去创建配音
</Button>
</div>
<div v-else class="voice-selector-wrapper">
<!-- 标题栏 -->
<div class="selector-header">
<div class="header-left">
<span class="header-title">选择音色</span>
<span class="voice-count">{{ userVoiceCards.length }} 个配音</span>
</div>
<Button
v-if="selectedVoiceId"
class="synthesize-btn"
:disabled="isPlayerInitializing"
:loading="previewLoadingVoiceId === selectedVoiceId"
@click="handleSynthesize"
>
<Icon icon="lucide:volume-2" class="size-4" />
合成试听
</Button>
</div>
<!-- 卡片网格 -->
<div class="voice-grid" :class="{ 'has-many': userVoiceCards.length > 4 }">
<button
v-for="voice in userVoiceCards"
:key="voice.id"
class="voice-card"
:class="{ 'selected': selectedVoiceId === voice.id }"
@click="handleVoiceSelect(voice)"
>
<!-- 头像区域 -->
<div class="card-avatar">
<div class="avatar-ring"></div>
<div class="avatar-icon">
<Icon icon="lucide:audio-waveform" class="size-6" />
</div>
<!-- 选中指示器 -->
<div v-if="selectedVoiceId === voice.id" class="selected-indicator">
<Icon icon="lucide:check" class="size-3" />
</div>
</div>
<!-- 信息区域 -->
<div class="card-info">
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.description || '我的配音' }}</div>
</div>
</button>
</div>
<!-- 播放器区域 -->
<transition name="slide-fade">
<div v-if="audioUrl" class="player-section">
<div class="player-header">
<div class="player-info">
<div class="player-icon">
<Icon icon="lucide:play-circle" class="size-8" />
</div>
<div class="player-meta">
<div class="player-title">{{ currentVoiceName }}</div>
<div class="player-label">合成预览</div>
</div>
</div>
<Button variant="ghost" size="sm" @click="downloadAudio" class="download-btn">
<Icon icon="lucide:download" class="size-4" />
下载
</Button>
</div>
<div ref="playerContainer" class="aplayer-container"></div>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
import APlayer from 'aplayer'
import { Button } from '@/components/ui/button'
const props = defineProps({
synthText: {
type: String,
default: '',
},
speechRate: {
type: Number,
default: 1.0,
},
})
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 handleVoiceSelect = (voice) => {
selectedVoiceId.value = voice.id
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)
},
() => {
/* 错误已在 useTTS 中处理 */
},
{ autoPlay: false },
)
}
/**
* 初始化 APlayer
*/
const initPlayer = (url) => {
// 防止并发初始化
if (isPlayerInitializing.value) {
return
}
isPlayerInitializing.value = true
destroyPlayer()
audioUrl.value = url
nextTick(() => {
try {
// 检查容器是否存在
if (!playerContainer.value) {
toast.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)
toast.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: var(--space-10) var(--space-6);
background: var(--muted);
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
text-align: center;
.empty-icon {
margin-bottom: var(--space-3);
}
}
/* 主容器 */
.voice-selector-wrapper {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 标题栏 */
.selector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-2-5);
}
.header-title {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--foreground);
}
.voice-count {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
background: var(--muted);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius);
}
.synthesize-btn {
height: 36px;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
border: none;
background: var(--primary);
color: var(--primary-foreground);
font-weight: 500;
font-size: var(--font-size-sm);
box-shadow: var(--shadow-sm);
transition: all var(--duration-fast);
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&:disabled {
background: var(--muted-foreground);
box-shadow: none;
cursor: not-allowed;
transform: none;
}
}
/* 卡片网格 */
.voice-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
&.has-many {
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 2px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
}
}
/* 音色卡片 */
.voice-card {
position: relative;
background: var(--card);
border: 2px solid var(--muted);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-3);
cursor: pointer;
transition: all var(--duration-base);
overflow: hidden;
text-align: left;
&:hover {
border-color: var(--border);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
&.selected {
border-color: var(--primary);
background: oklch(from var(--primary) l c h / 0.1);
box-shadow: var(--shadow-md);
}
}
/* 头像区域 */
.card-avatar {
position: relative;
width: 48px;
height: 48px;
margin: 0 auto var(--space-2-5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.avatar-ring {
background: oklch(from var(--primary) l c h / 0.2);
}
.avatar-icon {
color: var(--primary);
}
}
.avatar-ring {
position: absolute;
inset: 0;
border-radius: 50%;
}
.avatar-icon {
position: relative;
z-index: 1;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.selected-indicator {
position: absolute;
top: -4px;
right: -4px;
width: 20px;
height: 20px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-foreground);
font-size: var(--font-size-xs);
box-shadow: var(--shadow-sm);
animation: scaleIn var(--duration-fast);
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
/* 信息区域 */
.card-info {
text-align: center;
}
.voice-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--foreground);
margin-bottom: var(--space-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.voice-desc {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 播放器区域 */
.player-section {
background: var(--muted);
border-radius: var(--radius-lg);
padding: var(--space-3-5);
border: 1px solid var(--border);
}
.player-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.player-info {
display: flex;
align-items: center;
gap: var(--space-2-5);
}
.player-icon {
width: 36px;
height: 36px;
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
}
.player-meta {
.player-title {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--foreground);
}
.player-label {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}
}
.download-btn {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
padding: var(--space-2) var(--space-3);
height: auto;
border-radius: var(--radius-sm);
transition: all var(--duration-fast);
&:hover {
color: var(--primary);
background: oklch(from var(--primary) l c h / 0.08);
}
}
.aplayer-container {
:deep(.aplayer) {
border-radius: var(--radius);
box-shadow: none;
border: 1px solid var(--border);
.aplayer-body {
border-radius: var(--radius);
}
}
}
/* 动画 */
.slide-fade-enter-active {
transition: all var(--duration-base) ease-out;
}
.slide-fade-leave-active {
transition: all var(--duration-fast) ease-in;
}
.slide-fade-enter-from {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;
}
</style>