前端
This commit is contained in:
25
frontend/app/web-gold/src/views/dh/Avatar.vue
Normal file
25
frontend/app/web-gold/src/views/dh/Avatar.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold">生成数字人</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-1">
|
||||
<div class="space-y-3">
|
||||
<div class="text-gray-600 text-sm">形象、背景、脚本、分辨率、字幕等配置。</div>
|
||||
<button class="px-4 py-2 bg-purple-600 text-white rounded">生成视频</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-2">
|
||||
<div class="text-gray-500">视频预览、任务队列、渲染进度</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
313
frontend/app/web-gold/src/views/dh/VoiceCopy.vue
Normal file
313
frontend/app/web-gold/src/views/dh/VoiceCopy.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||||
import useVoiceText from '@gold/hooks/web/useVoiceText'
|
||||
|
||||
const store = useVoiceCopyStore()
|
||||
const profiles = computed(() => store.profiles)
|
||||
const activeId = computed(() => store.activeId)
|
||||
const { getVoiceText } = useVoiceText()
|
||||
|
||||
const form = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
language: 'zh-CN', // 简体中文
|
||||
gender: 'female',
|
||||
referenceAudio: '', // dataURL 或外链
|
||||
sampleText: '今天天气很好,我们一起去公园散步吧。',
|
||||
enhancement: 50, // 增强强度 0-100(去噪/清晰度)
|
||||
noiseReduction: true,
|
||||
note: '',
|
||||
originalText: '', // 原语音文本
|
||||
})
|
||||
|
||||
const isSavingAs = ref(false)
|
||||
const saveAsName = ref('')
|
||||
|
||||
// function generateId() {
|
||||
// return `${Date.now()}_${Math.floor(Math.random()*1e6)}`
|
||||
// }
|
||||
|
||||
async function loadProfiles() { await store.load(); if (store.activeProfile) Object.assign(form, { ...store.activeProfile }) }
|
||||
|
||||
function toDataURL(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function onUploadBefore(file) {
|
||||
try {
|
||||
const dataUrl = await toDataURL(file)
|
||||
form.referenceAudio = dataUrl
|
||||
message.success('音频已就绪(本地预处理)')
|
||||
} catch {
|
||||
message.error('读取音频失败,请重试')
|
||||
}
|
||||
return false // 阻止 antd 自动上传
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
id: '',
|
||||
name: '',
|
||||
language: 'zh-CN',
|
||||
gender: 'female',
|
||||
referenceAudio: '',
|
||||
sampleText: '今天天气很好,我们一起去公园散步吧。',
|
||||
enhancement: 50,
|
||||
noiseReduction: true,
|
||||
note: '',
|
||||
originalText: '',
|
||||
})
|
||||
}
|
||||
|
||||
function validate() {
|
||||
if (!form.referenceAudio) {
|
||||
message.warning('请先上传或粘贴一段参考音频')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function transcribeOriginal() {
|
||||
const url = (form.referenceAudio || '').trim()
|
||||
if (!url) { message.warning('请先提供参考音频'); return }
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
message.info('当前仅支持网络音频链接一键转写,请粘贴 http(s) 链接')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const list = await getVoiceText([{ audio_url: url }])
|
||||
const text = Array.isArray(list) && list[0]?.value ? list[0].value : ''
|
||||
if (text) {
|
||||
form.originalText = text
|
||||
message.success('已获取原语音文本')
|
||||
} else {
|
||||
message.warning('未获取到可用文本,请稍后重试')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error('转写失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!validate()) return
|
||||
if (!form.id) {
|
||||
const created = await store.add({ ...form, id: '' })
|
||||
Object.assign(form, { ...created })
|
||||
message.success('已保存到本地')
|
||||
} else {
|
||||
const updated = await store.update({ ...form })
|
||||
Object.assign(form, { ...updated })
|
||||
message.success('已更新')
|
||||
}
|
||||
}
|
||||
|
||||
function openSaveAs() {
|
||||
if (!validate()) return
|
||||
saveAsName.value = form.name ? `${form.name}-副本` : ''
|
||||
isSavingAs.value = true
|
||||
}
|
||||
|
||||
async function confirmSaveAs() {
|
||||
const name = (saveAsName.value || '').trim()
|
||||
if (!name) {
|
||||
message.warning('请输入名称')
|
||||
return
|
||||
}
|
||||
const created = await store.duplicate({ ...form }, name)
|
||||
Object.assign(form, { ...created })
|
||||
isSavingAs.value = false
|
||||
message.success('已另存为')
|
||||
}
|
||||
|
||||
function selectProfile(p) {
|
||||
if (!p) return
|
||||
store.select(p.id)
|
||||
Object.assign(form, { ...p })
|
||||
}
|
||||
|
||||
function requestDelete(p) {
|
||||
Modal.confirm({
|
||||
title: '删除克隆声音',
|
||||
content: `确定删除「${p.name || '未命名'}」吗?此操作不可恢复。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await store.remove(p.id)
|
||||
if (!store.activeProfile) { resetForm() } else { Object.assign(form, { ...store.activeProfile }) }
|
||||
message.success('已删除')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadProfiles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vc-page">
|
||||
<div class="vc-grid">
|
||||
<!-- 左侧:Cosy Voice 表单 -->
|
||||
<section class="vc-left">
|
||||
<div class="vc-title">语音克隆</div>
|
||||
<div class="vc-steps">
|
||||
<span class="step">1. 上传音频</span>
|
||||
<span class="step">2. 填文本</span>
|
||||
<span class="step">3. 保存</span>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="名称(可选)">
|
||||
<a-input v-model:value="form.name" placeholder="给你的克隆声音起个名字" allow-clear />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="语言">
|
||||
<a-select v-model:value="form.language" :options="[
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁體中文' },
|
||||
{ value: 'en-US', label: 'English' }
|
||||
]" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="性别/音色">
|
||||
<a-radio-group v-model:value="form.gender">
|
||||
<a-radio value="female">女声(更柔和)</a-radio>
|
||||
<a-radio value="male">男声(更低沉)</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="参考音频">
|
||||
<a-upload :before-upload="onUploadBefore" :show-upload-list="false" accept="audio/*">
|
||||
<a-button type="default">选择音频文件</a-button>
|
||||
</a-upload>
|
||||
<a-input v-model:value="form.referenceAudio" placeholder="或粘贴音频外链" style="margin-top:8px" />
|
||||
<div v-if="form.referenceAudio" class="vc-audio-preview">
|
||||
<audio :src="form.referenceAudio" controls preload="metadata" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="示例文本(可读一段样例,提升效果)">
|
||||
<a-textarea v-model:value="form.sampleText" :rows="3" placeholder="示例:今天天气很好,我们一起去公园散步吧。" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="原语音文本">
|
||||
<a-textarea v-model:value="form.originalText" :rows="4" placeholder="填写参考音频中的发音文本" />
|
||||
<div class="vc-row" style="margin-top:8px">
|
||||
<a-button @click="transcribeOriginal">一键转写</a-button>
|
||||
<div class="hint" style="margin-left:12px">仅支持网络链接</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<div class="vc-row">
|
||||
<a-form-item label="去噪优化" style="flex:1">
|
||||
<a-switch v-model:checked="form.noiseReduction" />
|
||||
</a-form-item>
|
||||
<a-form-item label="增强强度" style="flex:2">
|
||||
<a-slider v-model:value="form.enhancement" :min="0" :max="100" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<a-form-item label="备注(可选)">
|
||||
<a-textarea v-model:value="form.note" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<div class="vc-actions">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="saveProfile">保存</a-button>
|
||||
<a-button @click="openSaveAs">另存为</a-button>
|
||||
<a-button @click="resetForm">重置</a-button>
|
||||
</a-space>
|
||||
<div class="hint" style="margin-top:6px">保存后可在右侧管理</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:已保存的克隆声音列表 -->
|
||||
<section class="vc-right">
|
||||
<div class="vc-title">已保存的克隆声音</div>
|
||||
<div v-if="!profiles.length" class="vc-empty">暂无记录,保存后会出现在这里</div>
|
||||
<ul v-else class="vc-list">
|
||||
<li v-for="p in profiles" :key="p.id" class="vc-item" :class="{ active: p.id === activeId }" @click="selectProfile(p)">
|
||||
<div class="vc-item-main">
|
||||
<div class="vc-item-name">{{ p.name || '未命名' }}</div>
|
||||
<div class="vc-item-sub">{{ p.language }} · {{ p.gender === 'female' ? '女声' : '男声' }}</div>
|
||||
</div>
|
||||
<button class="vc-item-del" @click.stop="requestDelete(p)" aria-label="删除">×</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="isSavingAs" title="另存为" :maskClosable="false" @ok="confirmSaveAs" @cancel="() => (isSavingAs = false)">
|
||||
<a-input v-model:value="saveAsName" placeholder="输入名称,如:小红-普通话-温柔女声" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vc-page { color: var(--color-text); }
|
||||
.vc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.vc-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
.vc-left, .vc-right {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-inset-card);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.vc-title { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 8px; }
|
||||
.vc-steps { display:flex; flex-wrap: wrap; gap:8px; margin-bottom: 8px; }
|
||||
.vc-steps .step { font-size: 12px; color: var(--color-text-secondary); background: #161616; border: 1px solid var(--color-border); padding: 4px 8px; border-radius: 999px; }
|
||||
|
||||
.vc-row { display: flex; gap: 16px; }
|
||||
|
||||
.vc-actions { margin-top: 8px; }
|
||||
|
||||
.hint { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
|
||||
|
||||
.vc-audio-preview { margin-top: 8px; }
|
||||
.vc-audio-preview audio { width: 100%; }
|
||||
|
||||
.vc-empty { color: var(--color-text-secondary); padding: 12px; }
|
||||
|
||||
.vc-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.vc-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 10px 12px; cursor: pointer;
|
||||
transition: background .15s ease, border-color .15s ease, transform .1s ease;
|
||||
}
|
||||
.vc-item:hover { background: #161616; }
|
||||
.vc-item.active { border-color: var(--color-primary); }
|
||||
|
||||
.vc-item-main { display: flex; flex-direction: column; }
|
||||
.vc-item-name { font-size: 14px; color: var(--color-text); font-weight: 600; }
|
||||
.vc-item-sub { font-size: 12px; color: var(--color-text-secondary); }
|
||||
|
||||
.vc-item-del {
|
||||
visibility: hidden;
|
||||
width: 24px; height: 24px; border-radius: 6px;
|
||||
background: #2a2a2a; color: #fff; border: 1px solid var(--color-border);
|
||||
}
|
||||
.vc-item:hover .vc-item-del { visibility: visible; }
|
||||
.vc-item-del:hover { background: #3a3a3a; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
146
frontend/app/web-gold/src/views/dh/VoiceGenerate.vue
Normal file
146
frontend/app/web-gold/src/views/dh/VoiceGenerate.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { ref, computed, h, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||||
|
||||
const store = useVoiceCopyStore()
|
||||
|
||||
const selectedVoiceId = ref(store.activeId)
|
||||
const text = ref('')
|
||||
const speed = ref(1.0)
|
||||
const emotion = ref('neutral')
|
||||
|
||||
const records = ref([]) // 生成记录:{ id, voiceId, voiceName, text, status, url, createdAt }
|
||||
|
||||
const voiceOptions = computed(() => store.profiles.map(p => ({ value: p.id, label: p.name || '未命名' })))
|
||||
const selectedVoice = computed(() => store.profiles.find(p => p.id === selectedVoiceId.value) || null)
|
||||
|
||||
// 监听 store.activeId 变化,同步到 selectedVoiceId
|
||||
watch(() => store.activeId, (newId) => {
|
||||
if (newId && store.profiles.find(p => p.id === newId)) {
|
||||
selectedVoiceId.value = newId
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function ensureReady() {
|
||||
if (!selectedVoiceId.value) { message.warning('请选择克隆声音'); return false }
|
||||
if (!text.value.trim()) { message.warning('请输入文案'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
function simulateGenerate() {
|
||||
// 本地模拟:新增记录 → 排队 → 处理中 → 完成
|
||||
const id = `${Date.now()}_${Math.floor(Math.random()*1e5)}`
|
||||
const now = Date.now()
|
||||
const rec = {
|
||||
id,
|
||||
voiceId: selectedVoiceId.value,
|
||||
voiceName: selectedVoice.value?.name || '未命名',
|
||||
text: text.value.trim(),
|
||||
status: 'queued',
|
||||
url: '',
|
||||
createdAt: now,
|
||||
}
|
||||
records.value = [rec, ...records.value]
|
||||
|
||||
setTimeout(() => {
|
||||
updateRecord(id, { status: 'processing' })
|
||||
}, 600)
|
||||
setTimeout(() => {
|
||||
// 模拟成功产出
|
||||
const blob = new Blob([new Uint8Array([1,2,3])], { type: 'audio/mp3' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
updateRecord(id, { status: 'done', url })
|
||||
message.success('生成完成(模拟)')
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
function updateRecord(id, patch) {
|
||||
const idx = records.value.findIndex(r => r.id === id)
|
||||
if (idx !== -1) records.value[idx] = { ...records.value[idx], ...patch }
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts)
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function onGenerate() {
|
||||
if (!ensureReady()) return
|
||||
simulateGenerate()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vg-page">
|
||||
<div class="vg-grid">
|
||||
<!-- 左侧:配置区 -->
|
||||
<section class="vg-left">
|
||||
<div class="vg-title">配音生成</div>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="克隆声音">
|
||||
<a-select v-model:value="selectedVoiceId" :options="voiceOptions" placeholder="请选择已保存的克隆声音" />
|
||||
</a-form-item>
|
||||
<a-form-item label="文案">
|
||||
<a-textarea v-model:value="text" :rows="5" placeholder="输入要合成的文案" />
|
||||
</a-form-item>
|
||||
<div class="vg-row">
|
||||
<a-form-item label="语速" style="flex:1">
|
||||
<a-slider v-model:value="speed" :min="0.5" :max="2" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="情感" style="flex:1">
|
||||
<a-select v-model:value="emotion" :options="[
|
||||
{ value: 'neutral', label: '中性' },
|
||||
{ value: 'happy', label: '开心' },
|
||||
{ value: 'sad', label: '悲伤' },
|
||||
{ value: 'angry', label: '愤怒' }
|
||||
]" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="onGenerate">生成</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:结果/预览与记录 -->
|
||||
<section class="vg-right">
|
||||
<div class="vg-title">生成记录</div>
|
||||
<template v-if="records.length">
|
||||
<a-table :dataSource="records" :pagination="false" rowKey="id">
|
||||
<a-table-column key="createdAt" title="时间" :customRender="({ record }) => formatTime(record.createdAt)" />
|
||||
<a-table-column key="voiceName" title="声音" dataIndex="voiceName" />
|
||||
<a-table-column key="text" title="文案" :customRender="({ record }) => record.text?.slice(0, 40) + (record.text?.length>40?'...':'')" />
|
||||
<a-table-column key="status" title="状态" :customRender="({ record }) => record.status" />
|
||||
<a-table-column key="action" title="操作"
|
||||
:customRender="({ record }) => record.url ? h('a', { href: record.url, target: '_blank' }, '预览') : h('span', {}, '-')" />
|
||||
</a-table>
|
||||
</template>
|
||||
<a-empty v-else class="vg-empty">
|
||||
<template #description>
|
||||
<div>暂无生成记录,选择声音并输入文案后点击“生成”</div>
|
||||
</template>
|
||||
</a-empty>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.vg-page { color: var(--color-text); }
|
||||
.vg-grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
|
||||
@media (min-width: 1024px) { .vg-grid { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.vg-left, .vg-right {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-inset-card);
|
||||
padding: 16px;
|
||||
}
|
||||
.vg-title { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 8px; }
|
||||
.vg-row { display: flex; gap: 16px; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user