Compare commits

...

2 Commits

Author SHA1 Message Date
868fd0658c feat: add video file size validation with 100MB limit in digital human store
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
Add MAX_VIDEO_SIZE constant and implement file size validation for both upload and selection workflows to comply with 302.ai Kling API limitations. Display error toast when files exceed the 100MB threshold before processing.
2026-04-05 17:30:47 +08:00
f391a8c0d0 feat: 优化 2026-04-05 17:27:31 +08:00
9 changed files with 489 additions and 743 deletions

View File

@@ -1,8 +1,8 @@
<template>
<div class="voice-selector">
<div class="w-full">
<!-- 空状态 -->
<div v-if="userVoiceCards.length === 0" class="empty-voices">
<div class="empty-icon">
<div v-if="userVoiceCards.length === 0" class="py-10 px-6 bg-muted border border-dashed border-border rounded-lg text-center">
<div class="mb-3">
<Icon icon="lucide:mic-off" class="size-10 text-muted-foreground/40" />
</div>
<p class="text-muted-foreground mb-4">还没有配音</p>
@@ -11,68 +11,71 @@
</Button>
</div>
<div v-else class="voice-selector-wrapper">
<div v-else class="flex flex-col gap-4">
<!-- 标题栏 -->
<div class="selector-header">
<div class="header-left">
<span class="header-title">选择音色</span>
<span class="voice-count">{{ userVoiceCards.length }} 个配音</span>
<div class="h-12 flex items-center justify-between pb-3 border-b border-border">
<div class="flex items-center gap-2.5">
<span class="text-base font-semibold text-foreground">选择音色</span>
<span class="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{{ userVoiceCards.length }} 个配音</span>
</div>
<Button
v-if="selectedVoiceId"
class="synthesize-btn"
:disabled="isPlayerInitializing"
:loading="previewLoadingVoiceId === selectedVoiceId"
v-show="selectedVoiceId"
class="h-9 shadow-sm transition-all hover:-translate-y-px hover:shadow-md"
:disabled="isPlayerInitializing || isSynthesizing"
@click="handleSynthesize"
>
<Icon icon="lucide:volume-2" class="size-4" />
合成试听
<Icon v-if="isSynthesizing" icon="lucide:loader-2" class="size-4 animate-spin" />
<Icon v-else icon="lucide:volume-2" class="size-4" />
{{ isSynthesizing ? '合成中...' : '合成试听' }}
</Button>
</div>
<!-- 卡片网格 -->
<div class="voice-grid" :class="{ 'has-many': userVoiceCards.length > 4 }">
<!-- 卡片列表 -->
<div
class="flex flex-col gap-1.5"
:class="userVoiceCards.length > 6 && 'max-h-[260px] overflow-y-auto pr-1 voice-grid-scroll'"
>
<button
v-for="voice in userVoiceCards"
:key="voice.id"
class="voice-card"
:class="{ 'selected': selectedVoiceId === voice.id }"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all text-left border border-transparent hover:bg-muted"
:class="selectedVoiceId === voice.id ? 'bg-primary/10 border-primary/30' : ''"
@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 class="shrink-0 w-8 h-8 rounded-full flex items-center justify-center"
:class="selectedVoiceId === voice.id ? 'bg-primary text-primary-foreground' : 'bg-primary/15 text-primary'"
>
<Icon icon="lucide:audio-waveform" class="size-4" />
</div>
<!-- 信息区域 -->
<div class="card-info">
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.description || '我的配音' }}</div>
<!-- 名称 -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate"
:class="selectedVoiceId === voice.id ? 'text-primary' : 'text-foreground'"
>{{ voice.name }}</div>
<div v-if="voice.description" class="text-xs text-muted-foreground truncate">{{ voice.description }}</div>
</div>
<!-- 选中标记 -->
<Icon v-if="selectedVoiceId === voice.id" icon="lucide:check-circle-2" class="size-4 text-primary shrink-0" />
</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">
<div v-if="audioUrl" class="bg-muted rounded-lg p-3.5 border border-border">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
<div class="text-primary">
<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 class="text-base font-semibold text-foreground">{{ currentVoiceName }}</div>
<div class="text-xs text-muted-foreground">合成预览</div>
</div>
</div>
<Button variant="ghost" size="sm" @click="downloadAudio" class="download-btn">
<Button variant="ghost" size="sm" @click="downloadAudio" class="text-muted-foreground text-xs hover:text-primary hover:bg-primary/[0.08]">
<Icon icon="lucide:download" class="size-4" />
下载
</Button>
@@ -114,6 +117,7 @@ const playerContainer = ref(null)
const audioUrl = ref('')
const currentVoiceName = ref('')
const isPlayerInitializing = ref(false)
const isSynthesizing = ref(false)
// 默认封面图片(音频波形图标)
const defaultCover = `data:image/svg+xml;base64,${btoa(`
@@ -184,12 +188,13 @@ const handleVoiceSelect = (voice) => {
}
const handleSynthesize = () => {
if (!selectedVoiceId.value || isPlayerInitializing.value) return
if (!selectedVoiceId.value || isPlayerInitializing.value || isSynthesizing.value) return
const voice = userVoiceCards.value.find((v) => v.id === selectedVoiceId.value)
if (!voice) return
currentVoiceName.value = voice.name
isSynthesizing.value = true
handlePlayVoiceSample(voice)
}
@@ -215,12 +220,13 @@ const handlePlayVoiceSample = (voice) => {
playVoiceSample(
voice,
(data) => {
isSynthesizing.value = false
const url = data.audioUrl || data.objectUrl
if (!url) return
initPlayer(url)
},
() => {
/* 错误已在 useTTS 中处理 */
isSynthesizing.value = false
},
{ autoPlay: false },
)
@@ -331,274 +337,22 @@ onBeforeUnmount(() => {
</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);
/* 自定义滚动条 */
.voice-grid-scroll {
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 2px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
}
/* APlayer 深层样式覆盖 */
.aplayer-container {
:deep(.aplayer) {
border-radius: var(--radius);
@@ -612,19 +366,21 @@ onBeforeUnmount(() => {
}
/* 动画 */
@keyframes scaleIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
.slide-fade-enter-active {
transition: all var(--duration-base) ease-out;
transition: all 0.2s ease-out;
}
.slide-fade-leave-active {
transition: all var(--duration-fast) ease-in;
transition: all 0.15s ease-in;
}
.slide-fade-enter-from {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;

View File

@@ -53,7 +53,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
class="select-none group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"

View File

@@ -1,18 +1,12 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import dayjs from 'dayjs'
import { MaterialService } from '@/api/material'
import { VoiceService } from '@/api/voice'
import { useUpload } from '@/composables/useUpload'
import useVoiceText from '@/hooks/web/useVoiceText'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import {
Table,
@@ -22,13 +16,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
@@ -40,39 +27,18 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Progress } from '@/components/ui/progress'
import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.vue'
// ========== 常量 ==========
const MAX_FILE_SIZE = 5 * 1024 * 1024
const VALID_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const VALID_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
const DEFAULT_FORM_DATA = {
id: null,
name: '',
fileId: null,
autoTranscribe: true,
language: 'zh-CN',
gender: 'female',
note: '',
text: '',
fileUrl: ''
}
import VoiceCopyDialog from './VoiceCopyDialog.vue'
// ========== 响应式数据 ==========
const loading = ref(false)
const submitting = ref(false)
const voiceList = ref([])
const modalVisible = ref(false)
const deleteDialogVisible = ref(false)
const deleteTarget = ref(null)
const formMode = ref('create')
const dialogVisible = ref(false)
const dialogMode = ref('create')
const dialogRecord = ref(null)
const audioPlayer = ref(null)
const fileList = ref([])
const extractingText = ref(false)
const fileInputRef = ref(null)
const isDragging = ref(false)
const searchParams = reactive({
name: '',
@@ -87,22 +53,6 @@ const pagination = reactive({
showSizeChanger: true,
})
const formData = reactive({ ...DEFAULT_FORM_DATA })
// ========== Hooks ==========
const { state: uploadState, upload } = useUpload()
const { getVoiceText } = useVoiceText()
// ========== 计算属性 ==========
const isCreateMode = computed(() => formMode.value === 'create')
const isSubmitDisabled = computed(() => {
if (!isCreateMode.value) return false
if (extractingText.value) return true
if (formData.fileId && !formData.text) return true
return false
})
// ========== 工具函数 ==========
const formatDateTime = (value) => {
if (!value) return '-'
@@ -151,35 +101,15 @@ function handlePageChange(page) {
// ========== CRUD 操作 ==========
function handleCreate() {
formMode.value = 'create'
resetForm()
modalVisible.value = true
dialogMode.value = 'create'
dialogRecord.value = null
dialogVisible.value = true
}
async function handleEdit(record) {
formMode.value = 'edit'
try {
const res = await VoiceService.get(record.id)
if (res.code === 0 && res.data) {
Object.assign(formData, {
id: res.data.id || null,
name: res.data.name || '',
fileId: res.data.fileId || null,
language: res.data.language || 'zh-CN',
gender: res.data.gender || 'female',
note: res.data.note || ''
})
}
} catch (error) {
console.error('获取配音详情失败:', error)
Object.assign(formData, {
id: record.id,
name: record.name || '',
fileId: record.fileId || null,
note: record.note || ''
})
}
modalVisible.value = true
function handleEdit(record) {
dialogMode.value = 'edit'
dialogRecord.value = record
dialogVisible.value = true
}
function handleDelete(record) {
@@ -216,154 +146,6 @@ function handlePlayAudio(record) {
}
}
// ========== 文件上传 ==========
function triggerFileInput() {
fileInputRef.value?.click()
}
function handleFileSelect(event) {
const file = event.target.files?.[0]
if (file) processFile(file)
}
function handleDragOver(e) {
e.preventDefault()
isDragging.value = true
}
function handleDragLeave() {
isDragging.value = false
}
function handleDrop(event) {
event.preventDefault()
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file) processFile(file)
}
function validateFile(file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('文件大小不能超过 5MB')
return false
}
const fileName = file.name.toLowerCase()
const isValid = VALID_AUDIO_TYPES.some(t => file.type.includes(t)) ||
VALID_AUDIO_EXTENSIONS.some(ext => fileName.endsWith(ext))
if (!isValid) {
toast.error('请上传音频文件MP3、WAV、AAC 等)')
return false
}
return true
}
async function processFile(file) {
if (!validateFile(file)) return
try {
await upload(file, {
fileCategory: 'voice',
groupId: null,
onSuccess: async (id, fileUrl) => {
formData.fileId = id
formData.fileUrl = fileUrl
fileList.value = [file]
await fetchAudioTextById(id)
},
onError: (error) => {
toast.error(error.message || '上传失败')
}
})
} catch (error) {
console.error('上传失败:', error)
}
}
async function fetchAudioTextById(fileId) {
if (!fileId) return
extractingText.value = true
try {
const res = await MaterialService.getAudioPlayUrl(fileId)
if (res.code === 0 && res.data) {
const results = await getVoiceText([{ audio_url: res.data }])
if (results?.length > 0) {
formData.text = results[0].value || ''
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
toast.error('语音识别失败')
} finally {
extractingText.value = false
}
}
function handleRemoveFile() {
formData.fileId = null
formData.text = ''
formData.fileUrl = ''
fileList.value = []
}
// ========== 表单操作 ==========
async function handleSubmit() {
if (!formData.name.trim()) {
toast.warning('请输入配音名称')
return
}
if (isCreateMode.value && !formData.fileId) {
toast.warning('请上传音频文件')
return
}
submitting.value = true
const params = isCreateMode.value
? {
name: formData.name,
fileId: formData.fileId,
autoTranscribe: formData.autoTranscribe,
language: formData.language,
gender: formData.gender,
note: formData.note,
text: formData.text
}
: {
id: formData.id,
name: formData.name,
language: formData.language,
gender: formData.gender,
note: formData.note
}
try {
const res = isCreateMode.value
? await VoiceService.create(params)
: await VoiceService.update(params)
if (res.code !== 0) {
toast.error(res.msg || '操作失败')
return
}
toast.success(isCreateMode.value ? '创建成功' : '更新成功')
modalVisible.value = false
loadVoiceList()
} catch (error) {
console.error('提交失败:', error)
toast.error('操作失败,请稍后重试')
} finally {
submitting.value = false
}
}
function handleCancel() {
modalVisible.value = false
resetForm()
}
function resetForm() {
Object.assign(formData, { ...DEFAULT_FORM_DATA })
fileList.value = []
}
// ========== 生命周期 ==========
onMounted(() => loadVoiceList())
</script>
@@ -467,102 +249,12 @@ onMounted(() => loadVoiceList())
<!-- 弹窗 -->
<template #modals>
<!-- 新建/编辑弹窗 -->
<Dialog :open="modalVisible" @update:open="(v) => modalVisible = v">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ isCreateMode ? '新建配音' : '编辑配音' }}</DialogTitle>
</DialogHeader>
<div class="space-y-5 py-2">
<!-- 名称 -->
<div class="space-y-2">
<Label for="name">配音名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="formData.name" placeholder="请输入配音名称" />
</div>
<!-- 上传区域 -->
<div v-if="isCreateMode" class="space-y-2">
<Label>音频文件 <span class="text-destructive">*</span></Label>
<!-- 未上传状态 -->
<div
v-if="fileList.length === 0 && !uploadState.uploading && !extractingText"
class="upload-zone"
:class="{ 'upload-zone--dragging': isDragging }"
@click="triggerFileInput"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<div class="upload-zone__icon">
<Icon icon="lucide:cloud-upload" />
</div>
<p class="upload-zone__title">点击或拖拽音频文件到此区域</p>
<p class="upload-zone__hint">支持 MP3WAVAACM4AFLACOGG最大 5MB</p>
</div>
<!-- 上传中 -->
<div v-else-if="uploadState.uploading" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在上传...</p>
</div>
<!-- 识别中 -->
<div v-else-if="extractingText" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在识别语音...</p>
</div>
<!-- 已上传 -->
<div v-else class="upload-preview">
<div class="upload-preview__icon">
<Icon icon="lucide:file-audio" />
</div>
<div class="upload-preview__info">
<span class="upload-preview__name">{{ fileList[0]?.name || '音频文件' }}</span>
<Badge v-if="formData.text" variant="secondary" class="gap-1">
<Icon icon="lucide:check-circle" class="size-3" />
已识别语音
</Badge>
<Badge v-else variant="outline" class="gap-1 text-amber-600">
<Icon icon="lucide:alert-circle" class="size-3" />
未识别到语音
</Badge>
</div>
<Button variant="ghost" size="sm" class="text-destructive" @click="handleRemoveFile">
<Icon icon="lucide:x" class="size-4" />
</Button>
</div>
<input
ref="fileInputRef"
type="file"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
class="hidden"
@change="handleFileSelect"
/>
</div>
<!-- 备注 -->
<div class="space-y-2">
<Label for="note">备注</Label>
<Textarea
id="note"
v-model="formData.note"
placeholder="备注信息(选填)"
:rows="2"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="isSubmitDisabled" :loading="submitting" @click="handleSubmit">
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<VoiceCopyDialog
v-model:open="dialogVisible"
:mode="dialogMode"
:record="dialogRecord"
@success="loadVoiceList"
/>
<!-- 删除确认 -->
<AlertDialog :open="deleteDialogVisible" @update:open="(v) => deleteDialogVisible = v">
@@ -588,98 +280,5 @@ onMounted(() => loadVoiceList())
</TaskPageLayout>
</div>
<audio ref="audioPlayer" class="hidden" />
</template>
<style scoped lang="less">
// 上传区域
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-4);
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
background: var(--muted);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
background: oklch(0.97 0.01 254.604);
}
&--dragging {
border-color: var(--primary);
background: oklch(0.95 0.02 254.604);
}
&__icon {
font-size: 36px;
color: var(--primary);
margin-bottom: var(--space-3);
opacity: 0.8;
}
&__title {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--foreground);
margin-bottom: var(--space-1);
}
&__hint {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}
}
.upload-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-4);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
background: var(--muted);
}
.upload-preview {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid oklch(0.92 0.02 145);
border-radius: var(--radius-lg);
background: oklch(0.98 0.01 145);
&__icon {
font-size: 28px;
color: var(--primary);
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
&__name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--foreground);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.hidden {
display: none;
}
</style>

View File

@@ -0,0 +1,372 @@
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { useUpload } from '@/composables/useUpload'
import useVoiceText from '@/hooks/web/useVoiceText'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
// ========== 常量 ==========
const MAX_FILE_SIZE = 5 * 1024 * 1024
const MAX_DURATION = 30 // 最大音频时长(秒)
const VALID_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const VALID_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
const DEFAULT_FORM_DATA = {
id: null,
name: '',
fileId: null,
autoTranscribe: true,
language: 'zh-CN',
gender: 'female',
note: '',
text: '',
fileUrl: ''
}
// ========== Props & Emits ==========
const props = defineProps({
open: Boolean,
mode: { type: String, default: 'create' },
record: { type: Object, default: null }
})
const emit = defineEmits(['update:open', 'success'])
// ========== 响应式数据 ==========
const submitting = ref(false)
const audioPlayer = ref(null)
const fileList = ref([])
const extractingText = ref(false)
const fileInputRef = ref(null)
const isDragging = ref(false)
const formData = reactive({ ...DEFAULT_FORM_DATA })
// ========== Hooks ==========
const { state: uploadState, upload } = useUpload()
const { getVoiceText } = useVoiceText()
// ========== 计算属性 ==========
const isCreateMode = computed(() => props.mode === 'create')
const isSubmitDisabled = computed(() => {
if (!isCreateMode.value) return false
if (extractingText.value) return true
if (formData.fileId && !formData.text) return true
return false
})
// ========== Watch ==========
watch(() => props.open, (val) => {
if (val) {
if (isCreateMode.value) {
resetForm()
} else {
loadDetail()
}
}
})
// ========== 数据加载 ==========
async function loadDetail() {
if (!props.record) return
try {
const res = await VoiceService.get(props.record.id)
if (res.code === 0 && res.data) {
Object.assign(formData, {
id: res.data.id || null,
name: res.data.name || '',
fileId: res.data.fileId || null,
language: res.data.language || 'zh-CN',
gender: res.data.gender || 'female',
note: res.data.note || ''
})
}
} catch (error) {
console.error('获取配音详情失败:', error)
Object.assign(formData, {
id: props.record.id,
name: props.record.name || '',
fileId: props.record.fileId || null,
note: props.record.note || ''
})
}
}
// ========== 音频时长校验 ==========
function getAudioDuration(file) {
return new Promise((resolve) => {
const url = URL.createObjectURL(file)
const audio = new Audio()
audio.onloadedmetadata = () => {
URL.revokeObjectURL(url)
resolve(audio.duration)
}
audio.onerror = () => {
URL.revokeObjectURL(url)
resolve(0)
}
audio.src = url
})
}
// ========== 文件上传 ==========
function triggerFileInput() {
fileInputRef.value?.click()
}
function handleFileSelect(event) {
const file = event.target.files?.[0]
if (file) processFile(file)
}
function handleDragOver(e) {
e.preventDefault()
isDragging.value = true
}
function handleDragLeave() {
isDragging.value = false
}
function handleDrop(event) {
event.preventDefault()
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file) processFile(file)
}
function validateFile(file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('文件大小不能超过 5MB')
return false
}
const fileName = file.name.toLowerCase()
const isValid = VALID_AUDIO_TYPES.some(t => file.type.includes(t)) ||
VALID_AUDIO_EXTENSIONS.some(ext => fileName.endsWith(ext))
if (!isValid) {
toast.error('请上传音频文件MP3、WAV、AAC 等)')
return false
}
return true
}
async function processFile(file) {
if (!validateFile(file)) return
// 校验音频时长
const duration = await getAudioDuration(file)
if (duration > MAX_DURATION) {
toast.error(`音频时长不能超过 ${MAX_DURATION} 秒(当前 ${Math.ceil(duration)} 秒)`)
return
}
try {
await upload(file, {
fileCategory: 'voice',
groupId: null,
onSuccess: async (id, fileUrl) => {
formData.fileId = id
formData.fileUrl = fileUrl
fileList.value = [file]
await fetchAudioTextById(id)
},
onError: (error) => {
toast.error(error.message || '上传失败')
}
})
} catch (error) {
console.error('上传失败:', error)
}
}
async function fetchAudioTextById(fileId) {
if (!fileId) return
extractingText.value = true
try {
const res = await MaterialService.getAudioPlayUrl(fileId)
if (res.code === 0 && res.data) {
const results = await getVoiceText([{ audio_url: res.data }])
if (results?.length > 0) {
formData.text = results[0].value || ''
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
toast.error('语音识别失败')
} finally {
extractingText.value = false
}
}
function handleRemoveFile() {
formData.fileId = null
formData.text = ''
formData.fileUrl = ''
fileList.value = []
}
// ========== 表单操作 ==========
async function handleSubmit() {
if (!formData.name.trim()) {
toast.warning('请输入配音名称')
return
}
if (isCreateMode.value && !formData.fileId) {
toast.warning('请上传音频文件')
return
}
submitting.value = true
const params = isCreateMode.value
? {
name: formData.name,
fileId: formData.fileId,
autoTranscribe: formData.autoTranscribe,
language: formData.language,
gender: formData.gender,
note: formData.note,
text: formData.text
}
: {
id: formData.id,
name: formData.name,
language: formData.language,
gender: formData.gender,
note: formData.note
}
try {
const res = isCreateMode.value
? await VoiceService.create(params)
: await VoiceService.update(params)
if (res.code !== 0) {
toast.error(res.msg || '操作失败')
return
}
toast.success(isCreateMode.value ? '创建成功' : '更新成功')
emit('update:open', false)
emit('success')
} catch (error) {
console.error('提交失败:', error)
toast.error('操作失败,请稍后重试')
} finally {
submitting.value = false
}
}
function handleCancel() {
emit('update:open', false)
resetForm()
}
function resetForm() {
Object.assign(formData, { ...DEFAULT_FORM_DATA })
fileList.value = []
}
</script>
<template>
<Dialog :open="open" @update:open="(v) => emit('update:open', v)">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ isCreateMode ? '新建配音' : '编辑配音' }}</DialogTitle>
</DialogHeader>
<div class="space-y-5 py-2">
<!-- 名称 -->
<div class="space-y-2">
<Label for="name">配音名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="formData.name" placeholder="请输入配音名称" />
</div>
<!-- 上传区域 -->
<div v-if="isCreateMode" class="space-y-2">
<Label>音频文件 <span class="text-destructive">*</span></Label>
<!-- 未上传状态 -->
<div
v-if="fileList.length === 0 && !uploadState.uploading && !extractingText"
class="flex flex-col items-center justify-center p-8 px-4 border-2 border-dashed border-border rounded-lg bg-muted cursor-pointer transition-all duration-200 hover:border-primary hover:bg-primary/5"
:class="{ '!border-primary !bg-primary/10': isDragging }"
@click="triggerFileInput"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<Icon icon="lucide:cloud-upload" class="text-4xl text-primary mb-3 opacity-80" />
<p class="text-sm font-medium text-foreground mb-1">点击或拖拽音频文件到此区域</p>
<p class="text-xs text-muted-foreground">支持 MP3WAVAACM4AFLACOGG最大 5MB时长不超过 1 分钟</p>
</div>
<!-- 上传中 -->
<div v-else-if="uploadState.uploading" class="flex flex-col items-center justify-center p-8 px-4 border-2 border-solid border-border rounded-lg bg-muted">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在上传...</p>
</div>
<!-- 识别中 -->
<div v-else-if="extractingText" class="flex flex-col items-center justify-center p-8 px-4 border-2 border-solid border-border rounded-lg bg-muted">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在识别语音...</p>
</div>
<!-- 已上传 -->
<div v-else class="flex items-center gap-3 p-4 border rounded-lg border-emerald-200 bg-emerald-50">
<Icon icon="lucide:file-audio" class="text-[28px] text-primary shrink-0" />
<div class="flex-1 flex flex-col gap-1 min-w-0">
<span class="text-sm font-medium text-foreground max-w-[220px] truncate">{{ fileList[0]?.name || '音频文件' }}</span>
<Badge v-if="formData.text" variant="secondary" class="gap-1 w-fit">
<Icon icon="lucide:check-circle" class="size-3" />
已识别语音
</Badge>
<Badge v-else variant="outline" class="gap-1 text-amber-600 w-fit">
<Icon icon="lucide:alert-circle" class="size-3" />
未识别到语音
</Badge>
</div>
<Button variant="ghost" size="sm" class="text-destructive shrink-0" @click="handleRemoveFile">
<Icon icon="lucide:x" class="size-4" />
</Button>
</div>
<input
ref="fileInputRef"
type="file"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
class="hidden"
@change="handleFileSelect"
/>
</div>
<!-- 备注 -->
<div class="space-y-2">
<Label for="note">备注</Label>
<Textarea
id="note"
v-model="formData.note"
placeholder="备注信息(选填)"
:rows="2"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="isSubmitDisabled" :loading="submitting" @click="handleSubmit">
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -392,6 +392,7 @@ onMounted(async () => {
transition: all 0.3s ease;
display: flex;
flex-direction: column;
min-width: 0;
&:hover {
box-shadow: var(--shadow-lg);

View File

@@ -36,7 +36,8 @@
<label class="section-label">语速调节</label>
<div class="rate-control">
<Slider
v-model="store.speechRate"
:model-value="[store.speechRate]"
@update:model-value="store.speechRate = $event?.[0] ?? store.speechRate"
:min="0.5"
:max="2.0"
:step="0.1"
@@ -135,13 +136,6 @@ const placeholder = computed(() => {
const canGenerateAudio = computed(() => {
return store.text.trim() && store.voice && store.isVideoReady
})
const rateMarks = {
0.5: '0.5x',
1.0: '1.0x',
1.5: '1.5x',
2.0: '2.0x',
}
</script>
<style scoped lang="less">

View File

@@ -7,7 +7,7 @@
* 3. 时间轴可视化:实时对比视频和音频时长
*/
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { toast } from 'vue-sonner'
import { VoiceService } from '@/api/voice'
@@ -17,6 +17,11 @@ import { useUpload } from '@/composables/useUpload'
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep, TimelineData } from '../types/identify-face'
// ========== 常量 ==========
/** 视频文件大小上限 100MB302.ai Kling 接口限制) */
const MAX_VIDEO_SIZE = 100 * 1024 * 1024
// ========== 内部类型定义 ==========
/** 音频数据 */
@@ -43,8 +48,12 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 文案内容 */
const text = ref('')
/** 语速 */
const speechRate = ref(1.0)
/** 语速(持久化到本地) */
const speechRate = ref(Number(localStorage.getItem('digitalHuman:speechRate')) || 1.0)
watch(speechRate, (val) => {
localStorage.setItem('digitalHuman:speechRate', String(val))
})
/** 选中的音色 */
const voice = ref<VoiceMeta | null>(null)
@@ -231,6 +240,11 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
return
}
if (file.size > MAX_VIDEO_SIZE) {
toast.error('视频文件不能超过 100MB')
return
}
// 释放旧的 blob URL
if (videoPreviewUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(videoPreviewUrl.value)
@@ -247,6 +261,12 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 从素材库选择视频(选择后自动识别) */
async function selectVideo(video: Video) {
// 校验视频大小
if (video.fileSize && video.fileSize > MAX_VIDEO_SIZE) {
toast.error('视频文件不能超过 100MB')
return
}
selectedVideo.value = video
videoFile.value = null
videoSource.value = 'select'

View File

@@ -27,6 +27,7 @@ import cn.iocoder.yudao.module.tik.muye.aiserviceconfig.service.AiServiceConfigS
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*;
@Tag(name = "管理后台 - AI第三方服务配置")
@@ -75,6 +76,7 @@ public class AiServiceConfigController {
@Operation(summary = "获得AI服务配置")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<AiServiceConfigRespVO> getAiServiceConfig(@RequestParam("id") Long id) {
AiServiceConfigDO aiServiceConfig = aiServiceConfigService.getAiServiceConfig(id);
return success(BeanUtils.toBean(aiServiceConfig, AiServiceConfigRespVO.class));
@@ -83,6 +85,7 @@ public class AiServiceConfigController {
@GetMapping("/page")
@Operation(summary = "获得AI服务配置分页")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<PageResult<AiServiceConfigRespVO>> getAiServiceConfigPage(@Valid AiServiceConfigPageReqVO pageReqVO) {
PageResult<AiServiceConfigDO> pageResult = aiServiceConfigService.getAiServiceConfigPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, AiServiceConfigRespVO.class));
@@ -104,6 +107,7 @@ public class AiServiceConfigController {
@GetMapping("/list-enabled")
@Operation(summary = "获取所有启用的服务配置列表(前端积分显示用)")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<Map<String, List<AiServiceConfigService.ServiceConfigSimpleVO>>> getEnabledServiceConfigList() {
return success(aiServiceConfigService.getEnabledServiceConfigList());
}