This commit is contained in:
2026-03-05 22:58:31 +08:00
parent e046335900
commit 9b132082d2
7 changed files with 1514 additions and 738 deletions

View File

@@ -96,13 +96,14 @@ const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
const voiceStore = useVoiceCopyStore()
const emit = defineEmits(['select'])
const emit = defineEmits(['select', 'audioGenerated'])
let player = null
const playerContainer = ref(null)
const audioUrl = ref('')
const currentVoiceName = ref('')
const isPlayerInitializing = ref(false)
const currentAudioBase64 = ref('') // 保存当前音频的 base64 数据
// 默认封面图片(音频波形图标)
const defaultCover = `data:image/svg+xml;base64,${btoa(`
@@ -182,9 +183,7 @@ const handleVoiceChange = (value, option) => {
}
const handleSynthesize = () => {
if (!selectedVoiceId.value) return
// 防止在播放器初始化过程中重复点击
if (isPlayerInitializing.value) return
if (!selectedVoiceId.value || isPlayerInitializing.value) return
const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value)
if (!voice) return
@@ -209,12 +208,11 @@ const handlePlayVoiceSample = (voice) => {
(data) => {
const url = data.audioUrl || data.objectUrl
if (!url) return
currentAudioBase64.value = data.audioBase64 || ''
initPlayer(url)
},
(error) => {
// 音频播放失败,静默处理
},
{ autoPlay: false } // 禁用自动播放,由 APlayer 控制
undefined, // 错误静默处理
{ autoPlay: false }
)
}
@@ -266,6 +264,14 @@ const initPlayer = (url) => {
player.on('canplay', () => {
isPlayerInitializing.value = false
// 发送音频时长和 base64 数据给父组件
const durationMs = Math.floor(player.audio.duration * 1000)
if (durationMs > 0) {
emit('audioGenerated', {
durationMs,
audioBase64: currentAudioBase64.value
})
}
})
} catch (e) {
console.error('APlayer 初始化失败:', e)
@@ -276,29 +282,21 @@ const initPlayer = (url) => {
})
}
/**
* 下载音频
*/
const downloadAudio = () => {
if (!audioUrl.value) return
const filename = `${currentVoiceName.value || '语音合成'}.mp3`
const link = document.createElement('a')
link.href = audioUrl.value
link.download = filename
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) {
@@ -306,8 +304,7 @@ const destroyPlayer = () => {
}
player = null
}
// 只对 blob URL 调用 revokeObjectURL
if (audioUrl.value && audioUrl.value.startsWith('blob:')) {
if (audioUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(audioUrl.value)
}
audioUrl.value = ''
@@ -338,25 +335,25 @@ onBeforeUnmount(() => {
.voice-selector-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
}
/* 音色卡片 */
/* 音色卡片 - 柔和风格 */
.voice-card {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
border: 1px solid rgba(59, 130, 246, 0.15);
border-radius: 12px;
padding: 16px;
transition: all 0.3s ease;
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.3);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.18);
background: rgba(59, 130, 246, 0.05);
}
&.has-audio {
border-color: rgba(59, 130, 246, 0.4);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
border-color: rgba(59, 130, 246, 0.2);
background: rgba(59, 130, 246, 0.06);
}
}
@@ -365,40 +362,40 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
margin-bottom: 12px;
}
.header-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
width: 26px;
height: 26px;
border-radius: 6px;
background: rgba(59, 130, 246, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: white;
color: #3b82f6;
svg {
width: 16px;
height: 16px;
width: 14px;
height: 14px;
}
}
.header-title {
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: var(--color-text);
}
.header-badge {
margin-left: auto;
padding: 2px 10px;
background: rgba(59, 130, 246, 0.1);
border-radius: 12px;
font-size: 12px;
padding: 2px 8px;
background: rgba(59, 130, 246, 0.08);
border-radius: 10px;
font-size: 11px;
color: #3b82f6;
font-weight: 500;
max-width: 120px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -407,7 +404,7 @@ onBeforeUnmount(() => {
/* 卡片主体 */
.voice-card-body {
display: flex;
gap: 12px;
gap: 10px;
align-items: stretch;
}
@@ -416,73 +413,73 @@ onBeforeUnmount(() => {
:deep(.ant-select) {
width: 100%;
height: 40px;
height: 36px;
.ant-select-selector {
height: 40px !important;
border-radius: 10px !important;
border-color: rgba(59, 130, 246, 0.2) !important;
background: rgba(255, 255, 255, 0.8) !important;
transition: all 0.3s ease !important;
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: #3b82f6 !important;
border-color: rgba(59, 130, 246, 0.25) !important;
}
}
&.ant-select-focused .ant-select-selector {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
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: 38px !important;
line-height: 34px !important;
font-size: 13px;
}
.ant-select-selection-placeholder {
line-height: 38px !important;
line-height: 34px !important;
font-size: 13px;
}
}
}
.select-arrow {
color: #3b82f6;
color: #94a3b8;
transition: transform 0.3s ease;
}
/* 合成按钮 */
/* 合成按钮 - 柔和风格 */
.synthesize-btn {
height: 40px;
padding: 0 20px;
border-radius: 10px;
height: 36px;
padding: 0 16px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
background: rgba(59, 130, 246, 0.08);
color: #64748b;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
transition: all 0.25s ease;
white-space: nowrap;
&:hover:not(:disabled) {
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
transform: translateY(-1px);
background: rgba(59, 130, 246, 0.12);
color: #475569;
}
&:disabled {
opacity: 0.6;
opacity: 0.5;
cursor: not-allowed;
}
&.btn-active {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
&:hover:not(:disabled) {
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
background: rgba(59, 130, 246, 0.2);
}
}
@@ -493,46 +490,46 @@ onBeforeUnmount(() => {
/* 播放器区域 */
.player-section {
background: rgba(255, 255, 255, 0.6);
border-radius: 12px;
padding: 14px;
border: 1px solid rgba(59, 130, 246, 0.1);
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: 10px;
border-radius: 8px;
box-shadow: none;
border: 1px solid rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.04);
.aplayer-body {
border-radius: 10px;
border-radius: 8px;
}
}
}
.player-actions {
margin-top: 10px;
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.download-btn {
color: #3b82f6;
color: #94a3b8;
font-size: 12px;
padding: 4px 8px;
height: auto;
transition: all 0.2s ease;
&:hover {
color: #2563eb;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
background: rgba(59, 130, 246, 0.06);
}
}
/* 动画 */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
transition: all 0.25s ease-out;
}
.slide-fade-leave-active {
@@ -540,12 +537,12 @@ onBeforeUnmount(() => {
}
.slide-fade-enter-from {
transform: translateY(-10px);
transform: translateY(-8px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-10px);
transform: translateY(-8px);
opacity: 0;
}
</style>

View File

@@ -250,7 +250,7 @@ export function useTTS(options = {}) {
// 处理 Base64 音频
if (res.data?.audioBase64) {
const { blob, objectUrl } = decodeBase64Audio(res.data.audioBase64, res.data.format)
const audioData = { blob, objectUrl, format: res.data.format }
const audioData = { blob, objectUrl, format: res.data.format, audioBase64: res.data.audioBase64 }
cacheAudio(cacheKey, audioData)
resetPreviewState()
if (opts.autoPlay !== false) playCachedAudio(audioData)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,515 @@
<template>
<Teleport to="body">
<transition name="popover-fade">
<div
v-if="visible"
class="popover-overlay"
@click.self="handleClose"
@mousedown.self="handleClose"
>
<div
class="popover-card"
:style="popoverStyle"
@click.stop
>
<!-- 头部 -->
<div class="popover-header">
<span class="popover-title">AI 文案生成</span>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
</button>
</div>
<!-- 内容区 -->
<div class="popover-body">
<!-- 智能体选择 -->
<div class="form-item">
<label class="form-label">选择智能体</label>
<a-select
v-model:value="selectedAgentId"
:loading="loadingAgents"
placeholder="请选择智能体"
class="agent-select"
:bordered="false"
size="small"
>
<a-select-option
v-for="agent in agentList"
:key="agent.id"
:value="agent.id"
>
<div class="agent-option">
<img v-if="agent.icon" :src="agent.icon" class="agent-icon" />
<span class="agent-name">{{ agent.agentName }}</span>
</div>
</a-select-option>
</a-select>
</div>
<!-- 主题输入 -->
<div class="form-item">
<label class="form-label">文案主题</label>
<input
v-model="theme"
type="text"
class="theme-input"
placeholder="如:产品介绍、活动推广..."
:disabled="isGenerating"
@keydown.enter="handleGenerate"
/>
</div>
<!-- 生成结果预览 -->
<div v-if="generatedText" class="result-preview">
<div class="result-content">
{{ generatedText }}
<span v-if="isGenerating" class="cursor-blink">|</span>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="popover-footer">
<button class="btn btn-cancel" @click="handleClose">取消</button>
<button
class="btn btn-primary"
:disabled="!canGenerate || isGenerating"
@click="handleGenerate"
>
<LoadingOutlined v-if="isGenerating" class="spin" />
<span>{{ isGenerating ? '生成中...' : '生成' }}</span>
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { getAgentList, sendChatStream } from '@/api/agent'
// Props
const props = defineProps<{
visible: boolean
theme?: string
}>()
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': [text: string]
'error': [msg: string]
}>()
// 状态
const agentList = ref<any[]>([])
const loadingAgents = ref(false)
const selectedAgentId = ref<number | null>(null)
const theme = ref('')
const generatedText = ref('')
const isGenerating = ref(false)
const abortController = ref<AbortController | null>(null)
const popoverStyle = ref<Record<string, string>>({})
// 计算属性
const canGenerate = computed(() => {
return selectedAgentId.value && theme.value.trim().length > 0
})
// 获取智能体列表
const fetchAgents = async () => {
loadingAgents.value = true
try {
const res = await getAgentList()
if (res.code === 0 && res.data) {
agentList.value = res.data
// 默认选中第一个
if (res.data.length > 0 && !selectedAgentId.value) {
selectedAgentId.value = res.data[0].id
}
}
} catch (error) {
console.error('获取智能体列表失败:', error)
} finally {
loadingAgents.value = false
}
}
// 更新气泡位置
const updatePosition = () => {
// 找到触发按钮
const triggerBtn = document.querySelector('.generate-text-btn')
if (triggerBtn) {
const rect = triggerBtn.getBoundingClientRect()
const popoverWidth = 320
const popoverHeight = 280
// 计算位置,确保不超出视口
let left = rect.right - popoverWidth
let top = rect.bottom + 8
// 边界检测
if (left < 16) left = 16
if (left + popoverWidth > window.innerWidth - 16) {
left = window.innerWidth - popoverWidth - 16
}
if (top + popoverHeight > window.innerHeight - 16) {
top = rect.top - popoverHeight - 8
}
popoverStyle.value = {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${popoverWidth}px`,
}
}
}
// 生成文案
const handleGenerate = async () => {
if (!canGenerate.value || isGenerating.value) return
const selectedAgent = agentList.value.find(a => a.id === selectedAgentId.value)
if (!selectedAgent) {
message.warning('请选择智能体')
return
}
isGenerating.value = true
generatedText.value = ''
abortController.value = new AbortController()
const prompt = `请根据以下主题生成一段播报文案,要求:
1. 语言流畅自然,适合口播
2. 内容简洁有吸引力
3. 不要使用markdown格式直接输出纯文本
主题:${theme.value}`
try {
await sendChatStream({
agentId: selectedAgent.id,
content: prompt,
ctrl: abortController.value,
onMessage: (result: { event: string; content?: string; errorMessage?: string }) => {
if (result.event === 'message' && result.content) {
generatedText.value += result.content
} else if (result.event === 'error') {
message.error(result.errorMessage || '生成出错')
isGenerating.value = false
}
},
onError: () => {
message.error('生成失败,请重试')
isGenerating.value = false
},
onClose: () => {
isGenerating.value = false
// 生成完成,触发成功回调
if (generatedText.value) {
emit('success', generatedText.value.trim())
}
}
})
} catch (error: any) {
if (error.name !== 'AbortError') {
message.error('生成失败')
}
isGenerating.value = false
}
}
// 关闭弹窗
const handleClose = () => {
if (isGenerating.value) {
abortController.value?.abort()
isGenerating.value = false
}
generatedText.value = ''
theme.value = ''
emit('update:visible', false)
}
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
fetchAgents()
updatePosition()
// 监听窗口大小变化
window.addEventListener('resize', updatePosition)
} else {
window.removeEventListener('resize', updatePosition)
}
})
// 清理
onUnmounted(() => {
window.removeEventListener('resize', updatePosition)
if (abortController.value) {
abortController.value.abort()
}
})
</script>
<style scoped lang="less">
// Notion 风格配色
@bg-popover: #ffffff;
@bg-hover: #f7f6f3;
@text-primary: #37352f;
@text-secondary: #787774;
@text-tertiary: #b4b4b4;
@border-color: #e9e9e7;
@primary-color: #2e75cc;
@primary-hover: #1f63cb;
// 过渡动画
.popover-fade-enter-active,
.popover-fade-leave-active {
transition: all 0.2s ease;
}
.popover-fade-enter-from,
.popover-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
// 遮罩层(透明,仅用于点击外部关闭)
.popover-overlay {
position: fixed;
inset: 0;
z-index: 1000;
}
// 气泡卡片
.popover-card {
background: @bg-popover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.1);
border: 1px solid @border-color;
overflow: hidden;
z-index: 1001;
}
// 头部
.popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid @border-color;
}
.popover-title {
font-size: 13px;
font-weight: 600;
color: @text-primary;
}
.close-btn {
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: @text-tertiary;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: all 0.15s ease;
&:hover {
background: @bg-hover;
color: @text-secondary;
}
}
// 内容区
.popover-body {
padding: 14px;
}
.form-item {
margin-bottom: 14px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
display: block;
font-size: 12px;
font-weight: 500;
color: @text-secondary;
margin-bottom: 6px;
}
.agent-select {
width: 100%;
:deep(.ant-select-selector) {
background: @bg-hover !important;
border-radius: 6px !important;
padding: 4px 10px !important;
min-height: 32px !important;
display: flex !important;
align-items: center !important;
}
:deep(.ant-select-selection-item) {
font-size: 13px;
color: @text-primary;
line-height: 24px !important;
display: flex !important;
align-items: center !important;
}
}
.agent-option {
display: flex;
align-items: center;
gap: 8px;
}
.agent-icon {
width: 18px;
height: 18px;
border-radius: 4px;
object-fit: cover;
}
.agent-name {
font-size: 13px;
color: @text-primary;
}
.theme-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid @border-color;
border-radius: 6px;
font-size: 13px;
color: @text-primary;
background: @bg-hover;
outline: none;
transition: all 0.15s ease;
&::placeholder {
color: @text-tertiary;
}
&:focus {
border-color: @primary-color;
box-shadow: 0 0 0 2px rgba(46, 117, 204, 0.1);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// 生成结果预览
.result-preview {
margin-top: 12px;
padding: 10px;
background: @bg-hover;
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: @text-tertiary;
border-radius: 2px;
}
}
.result-content {
font-size: 13px;
line-height: 1.5;
color: @text-primary;
white-space: pre-wrap;
word-break: break-word;
}
.cursor-blink {
color: @primary-color;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
// 底部按钮
.popover-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid @border-color;
}
.btn {
height: 30px;
padding: 0 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 4px;
&.btn-cancel {
background: transparent;
border: none;
color: @text-secondary;
&:hover {
background: @bg-hover;
color: @text-primary;
}
}
&.btn-primary {
background: @primary-color;
border: none;
color: #fff;
&:hover:not(:disabled) {
background: @primary-hover;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.spin {
font-size: 12px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -2,10 +2,11 @@
<div class="timeline-panel">
<div class="timeline-header">
<span class="timeline-title">时间轴对比</span>
<span v-if="showDurations" class="duration-info">
人脸: {{ formatDuration(faceDurationMs) }}
<span v-if="showDurations" class="duration-badge">
人脸 {{ formatDuration(faceDurationMs) }}
<template v-if="audioDurationMs > 0">
| 音频: {{ formatDuration(audioDurationMs) }}
<span class="divider"></span>
音频 {{ formatDuration(audioDurationMs) }}
</template>
</span>
</div>
@@ -21,28 +22,42 @@
<span class="ruler-label">{{ mark.label }}</span>
<span class="ruler-tick"></span>
</div>
<!-- 音频结束位置标记 -->
<div
v-if="audioDurationMs > 0 && isExceed"
class="audio-end-marker"
:style="{ left: audioEndPosition + '%' }"
>
<span class="audio-marker-label">{{ (audioDurationMs / 1000).toFixed(1) }}s</span>
<span class="audio-marker-line"></span>
</div>
</div>
<!-- 轨道区域 -->
<div class="timeline-tracks">
<!-- 视频轨道 -->
<div class="track video-track">
<span class="track-icon">📹</span>
<span class="track-label">视频</span>
<div class="track">
<div class="track-info">
<span class="track-icon">📹</span>
<span class="track-label">视频</span>
</div>
<div class="track-bar">
<div
class="track-fill video-fill"
:style="{ width: videoBarWidth + '%' }"
>
<span class="track-time">{{ formatDuration(faceDurationMs) }}</span>
<span v-if="videoBarWidth > 15" class="track-time">{{ formatDuration(faceDurationMs) }}</span>
</div>
</div>
</div>
<!-- 音频轨道 -->
<div class="track audio-track">
<span class="track-icon">🎵</span>
<span class="track-label">音频</span>
<div class="track">
<div class="track-info">
<span class="track-icon">🎙</span>
<span class="track-label">音频</span>
</div>
<div class="track-bar">
<div
v-if="audioDurationMs > 0"
@@ -50,7 +65,7 @@
:class="{ 'audio-exceed': isExceed }"
:style="{ width: audioBarWidth + '%' }"
>
<span class="track-time">{{ formatDuration(audioDurationMs) }}</span>
<span v-if="audioBarWidth > 15" class="track-time">{{ formatDuration(audioDurationMs) }}</span>
</div>
<span v-else class="track-placeholder">等待生成音频</span>
</div>
@@ -61,11 +76,11 @@
<div v-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
<template v-if="diffStatus === 'match'">
<CheckCircleOutlined class="diff-icon" />
<span>时长匹配良好</span>
<span>时长匹配良好可以生成</span>
</template>
<template v-else-if="diffStatus === 'exceed'">
<ExclamationCircleOutlined class="diff-icon" />
<span>音频超出 {{ formatDuration(diffMs) }}缩短文案</span>
<span>音频超出 {{ formatDuration(diffMs) }}建议缩短文案</span>
</template>
<template v-else-if="diffStatus === 'short'">
<InfoCircleOutlined class="diff-icon" />
@@ -105,6 +120,10 @@ const audioBarWidth = computed(() =>
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
)
const audioEndPosition = computed(() =>
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
)
const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
@@ -151,11 +170,25 @@ const formatDuration = formatDurationMs
</script>
<style scoped lang="less">
// 蓝紫主题配色 - 与主页面协调
@text-primary: #1e293b;
@text-secondary: #64748b;
@text-tertiary: #94a3b8;
@bg-subtle: rgba(59, 130, 246, 0.04);
@bg-hover: rgba(59, 130, 246, 0.08);
@border-light: rgba(59, 130, 246, 0.1);
@border-medium: rgba(59, 130, 246, 0.15);
@accent-blue: #3b82f6;
@accent-purple: #8b5cf6;
@accent-green: #10b981;
@accent-red: #ef4444;
@accent-orange: #f59e0b;
.timeline-panel {
background: #f8fafc;
background: @bg-subtle;
border-radius: 10px;
padding: 16px;
margin-top: 16px;
padding: 14px 18px;
margin-top: 8px;
}
.timeline-header {
@@ -163,25 +196,37 @@ const formatDuration = formatDurationMs
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.timeline-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.timeline-title {
font-size: 12px;
font-weight: 600;
color: @text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.duration-info {
font-size: 12px;
color: #64748b;
.duration-badge {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: @text-tertiary;
.divider {
width: 4px;
height: 4px;
background: @border-medium;
border-radius: 50%;
}
}
// 刻度尺
.timeline-ruler {
position: relative;
height: 20px;
margin-bottom: 8px;
margin-left: 70px; // 为图标和标签留空间
height: 18px;
margin-bottom: 10px;
margin-left: 80px;
}
.ruler-mark {
@@ -194,7 +239,7 @@ const formatDuration = formatDurationMs
.ruler-label {
font-size: 10px;
color: #94a3b8;
color: @text-tertiary;
margin-bottom: 2px;
}
@@ -202,7 +247,35 @@ const formatDuration = formatDurationMs
display: block;
width: 1px;
height: 4px;
background: #cbd5e1;
background: @border-medium;
}
// 音频结束位置标记
.audio-end-marker {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
top: 0;
}
.audio-marker-label {
font-size: 9px;
font-weight: 600;
color: @accent-red;
background: rgba(235, 87, 87, 0.1);
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
margin-bottom: 2px;
}
.audio-marker-line {
width: 1px;
height: 6px;
background: @accent-red;
}
// 轨道区域
@@ -215,100 +288,110 @@ const formatDuration = formatDurationMs
.track {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
}
.track-icon {
font-size: 14px;
width: 20px;
text-align: center;
}
.track-info {
display: flex;
align-items: center;
gap: 6px;
width: 68px;
flex-shrink: 0;
}
.track-label {
width: 34px;
font-size: 12px;
color: #64748b;
flex-shrink: 0;
}
.track-icon {
font-size: 14px;
line-height: 1;
}
.track-bar {
flex: 1;
height: 24px;
background: #e2e8f0;
border-radius: 4px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
}
.track-label {
font-size: 12px;
color: @text-secondary;
font-weight: 500;
}
.track-fill {
height: 100%;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.3s ease;
}
.track-bar {
flex: 1;
height: 22px;
background: rgba(55, 53, 47, 0.06);
border-radius: 4px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
}
.track-time {
font-size: 11px;
color: white;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.track-fill {
height: 100%;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.track-placeholder {
font-size: 11px;
color: #94a3b8;
padding-left: 12px;
}
.track-time {
font-size: 10px;
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
letter-spacing: 0.3px;
}
.track-placeholder {
font-size: 11px;
color: @text-tertiary;
padding-left: 14px;
}
.video-fill {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
background: linear-gradient(90deg, @accent-blue 0%, @accent-purple 100%);
}
.audio-fill {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
background: linear-gradient(90deg, @accent-green 0%, #059669 100%);
&.audio-exceed {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
animation: pulse 1.5s ease-in-out infinite;
background: linear-gradient(90deg, @accent-red 0%, #dc2626 100%);
animation: pulse-warning 2s ease-in-out infinite;
}
}
@keyframes pulse {
@keyframes pulse-warning {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
50% { opacity: 0.75; }
}
// 差异提示
.timeline-diff {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
border-radius: 6px;
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
.diff-icon {
font-size: 14px;
flex-shrink: 0;
}
&.match {
background: #dcfce7;
color: #166534;
background: rgba(16, 185, 129, 0.08);
color: @accent-green;
}
&.exceed {
background: #fee2e2;
color: #dc2626;
background: rgba(239, 68, 68, 0.08);
color: @accent-red;
}
&.short {
background: #fef3c7;
color: #92400e;
background: rgba(245, 158, 11, 0.08);
color: @accent-orange;
}
}
</style>

View File

@@ -239,8 +239,20 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
videoFile.value = null
videoSource.value = 'select'
videoSelectorVisible.value = false
// 素材列表返回的 fileUrl 已带签名,直接使用
videoPreviewUrl.value = video.fileUrl
// 获取带签名的视频播放URL
try {
const urlRes = await MaterialService.getVideoPlayUrl(Number(video.id))
if (urlRes.code !== 0 || !urlRes.data) {
throw new Error(urlRes.msg || '获取播放链接失败')
}
videoPreviewUrl.value = urlRes.data
} catch (err: any) {
videoStep.value = 'error'
error.value = err.message || '获取播放链接失败'
message.error(error.value)
return
}
// 自动识别
await recognizeVideo()
@@ -306,8 +318,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 识别已存在的视频 */
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
// 素材列表返回的 fileUrl 已带签名,直接使用
return performFaceRecognition(video.id, video.fileUrl, false)
// 使用已获取的带签名预览URL
return performFaceRecognition(video.id, videoPreviewUrl.value, false)
}
/** 执行人脸识别 */
@@ -421,7 +433,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
const taskRes = await createLipSyncTask({
taskName: `数字人任务_${Date.now()}`,
videoFileId: identifyData.value.fileId,
// 根据视频来源选择传递方式
videoUrl: videoSource.value === 'select' ? videoPreviewUrl.value : undefined,
videoFileId: videoSource.value === 'upload' ? identifyData.value.fileId : undefined,
inputText: text.value,
speechRate: speechRate.value,
volume: 0,
@@ -570,24 +584,23 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
const blob = new Blob([bytes], { type: 'audio/mp3' })
const audio = new Audio()
const objectUrl = URL.createObjectURL(blob)
const timeoutId = setTimeout(() => {
cleanup()
reject(new Error('音频解析超时'))
}, 15000)
let resolved = false
let lastDuration = 0
function cleanup() {
const timeoutId = setTimeout(() => {
if (!resolved) {
cleanup()
reject(new Error('音频解析超时'))
}
}, 15000)
const cleanup = () => {
clearTimeout(timeoutId)
URL.revokeObjectURL(objectUrl)
}
function tryResolve(duration: number, source: string) {
if (resolved) return
if (!isFinite(duration) || duration <= 0) return
const tryResolve = (duration: number, source: string) => {
if (resolved || !isFinite(duration) || duration <= 0) return
lastDuration = duration
if (source === 'canplaythrough') {
@@ -601,15 +614,14 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
audio.oncanplay = () => tryResolve(audio.duration, 'canplay')
audio.oncanplaythrough = () => tryResolve(audio.duration, 'canplaythrough')
audio.onerror = () => {
if (!resolved) {
if (lastDuration > 0) {
resolved = true
cleanup()
resolve(Math.floor(lastDuration * 1000) - 200)
} else {
cleanup()
reject(new Error('音频解析失败'))
}
if (resolved) return
if (lastDuration > 0) {
resolved = true
cleanup()
resolve(Math.floor(lastDuration * 1000) - 200)
} else {
cleanup()
reject(new Error('音频解析失败'))
}
}
@@ -693,6 +705,5 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
// ========== 工具函数 ==========
function extractId(str: string): string {
const match = str.match(/[\w-]+$/)
return match ? match[0] : str
return str.match(/[\w-]+$/)?.[0] ?? str
}

View File

@@ -370,7 +370,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
*/
private void validateUserFile(Long fileId, Long userId, String fileType) {
TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
.eq(TikUserFileDO::getFileId, fileId) // 查询fileId字段指向infra_file.id
.eq(TikUserFileDO::getId, fileId) // 用主键ID查询前端传递的是userFileId
.eq(TikUserFileDO::getUserId, userId));
if (userFile == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.FILE_NOT_EXISTS, fileType + "文件不存在");
@@ -525,9 +525,13 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
// 处理视频文件音频由实时TTS生成无需准备
if (task.getVideoFileId() != null) {
FileDO videoFile = fileMapper.selectById(task.getVideoFileId());
if (videoFile != null) {
task.setVideoUrl(fileApi.presignGetUrl(videoFile.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS));
// 先查询 tik_user_file 获取 infra_file.id再查询 infra_file
TikUserFileDO userFile = userFileMapper.selectById(task.getVideoFileId());
if (userFile != null && userFile.getFileId() != null) {
FileDO videoFile = fileMapper.selectById(userFile.getFileId());
if (videoFile != null) {
task.setVideoUrl(fileApi.presignGetUrl(videoFile.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS));
}
}
}