feat: 重构 IdentifyFace.vue 为 Hooks 架构

- 新增 hooks/ 目录,包含三个专用 Hook:
  * useVoiceGeneration - 语音生成和校验逻辑
  * useDigitalHumanGeneration - 数字人视频生成逻辑
  * useIdentifyFaceController - 协调两个子 Hook 的控制器

- 新增 types/identify-face.ts 完整类型定义

- 重构 IdentifyFace.vue 使用 hooks 架构:
  * 视图层与业务逻辑分离
  * 状态管理清晰化
  * 模块解耦,逻辑清晰

- 遵循单一职责原则,每个 Hook 只负责一个领域
- 提升代码可测试性和可维护性
- 支持两种视频素材来源:素材库选择和直接上传
- 实现语音生成优先校验的业务规则

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-28 00:19:17 +08:00
parent effbbc694c
commit 36195ea55a
46 changed files with 4258 additions and 3454 deletions

View File

@@ -0,0 +1,179 @@
<template>
<div class="voice-selector">
<div v-if="displayedVoices.length === 0" class="empty-voices">
还没有配音可先在"配音管理"中上传
</div>
<div v-else class="voice-selector-with-preview">
<a-select
v-model:value="selectedVoiceId"
placeholder="请选择音色"
class="voice-select"
:options="voiceOptions"
@change="handleVoiceChange"
style="width: calc(100% - 80px)"
/>
<a-button
class="preview-button"
size="small"
:disabled="!selectedVoiceId"
:loading="previewLoadingVoiceId === selectedVoiceId"
@click="handlePreviewCurrentVoice"
>
<template #icon>
<SoundOutlined />
</template>
试听
</a-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
const voiceStore = useVoiceCopyStore()
const emit = defineEmits(['select'])
// 使用TTS Hook默认使用Qwen供应商
const {
previewLoadingVoiceId,
playingPreviewVoiceId,
ttsText,
speechRate,
playVoiceSample,
setText,
setSpeechRate,
resetPreviewState
} = useTTS({
provider: TTS_PROVIDERS.QWEN
})
// 当前选中的音色ID
const selectedVoiceId = ref('')
// 从store数据构建音色列表
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 displayedVoices = computed(() => userVoiceCards.value)
// 转换为下拉框选项格式
const voiceOptions = computed(() =>
displayedVoices.value.map(voice => ({
value: voice.id,
label: voice.name,
data: voice // 保存完整数据
}))
)
// 音色选择变化处理
const handleVoiceChange = (value, option) => {
const voice = option.data
selectedVoiceId.value = value
emit('select', voice)
}
// 试听当前选中的音色
const handlePreviewCurrentVoice = () => {
if (!selectedVoiceId.value) return
const voice = displayedVoices.value.find(v => v.id === selectedVoiceId.value)
if (!voice) return
handlePlayVoiceSample(voice)
}
/**
* 处理音色试听
* 使用Hook提供的playVoiceSample方法
*/
const handlePlayVoiceSample = (voice) => {
playVoiceSample(
voice,
(audioData) => {
// 成功回调
console.log('音频播放成功', audioData)
},
(error) => {
// 错误回调
console.error('音频播放失败', error)
}
)
}
/**
* 设置要试听的文本(供父组件调用)
* @param {string} text 要试听的文本
*/
const setPreviewText = (text) => {
setText(text)
}
/**
* 设置语速(供父组件调用)
* @param {number} rate 语速倍率
*/
const setPreviewSpeechRate = (rate) => {
setSpeechRate(rate)
}
defineExpose({
setPreviewText,
setPreviewSpeechRate
})
onMounted(async () => {
await voiceStore.refresh()
})
</script>
<style scoped>
.voice-selector {
width: 100%;
}
.empty-voices {
padding: 8px 12px;
font-size: 12px;
color: var(--color-text-secondary);
background: rgba(0, 0, 0, 0.3);
border: 1px dashed rgba(59, 130, 246, 0.3);
border-radius: var(--radius-card);
}
/* 音色选择器和试听按钮的容器 */
.voice-selector-with-preview {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
}
/* 下拉框样式 */
.voice-select {
flex: 1;
}
/* 试听按钮样式 */
.preview-button {
height: 32px;
white-space: nowrap;
}
</style>