feat(kling): add validation error display in timeline panel and update UI components

- Remove unused audio base64 reference and error message in VoiceSelector
- Rename CSS class from 'result-banner' to 'result-inline' and update button styling
- Pass validationError prop from GenerateStep to TimelinePanel
- Add validation error display in TimelinePanel with error state styling
- Update conditional rendering to show either validation error or duration diff
- Add CloseCircleOutlined icon for error status display
This commit is contained in:
2026-03-05 23:43:27 +08:00
parent dff90abbb4
commit 52a1094144
8 changed files with 36 additions and 71 deletions

View File

@@ -103,7 +103,6 @@ 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(`
@@ -208,7 +207,6 @@ const handlePlayVoiceSample = (voice) => {
(data) => {
const url = data.audioUrl || data.objectUrl
if (!url) return
currentAudioBase64.value = data.audioBase64 || ''
initPlayer(url)
},
undefined, // 错误静默处理
@@ -259,7 +257,6 @@ const initPlayer = (url) => {
player.on('error', (e) => {
console.error('APlayer 播放错误:', e)
message.error('音频播放失败,请重试')
})
player.on('canplay', () => {

View File

@@ -850,7 +850,7 @@ onMounted(async () => {
justify-content: flex-end;
}
.result-banner {
.result-inline {
display: flex;
align-items: center;
justify-content: flex-end;
@@ -870,17 +870,7 @@ onMounted(async () => {
color: @accent-red;
}
.banner-icon {
font-size: 16px;
flex-shrink: 0;
}
.banner-text {
flex: 1;
text-align: right;
}
.retry-btn {
.inline-btn {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid currentColor;
@@ -890,9 +880,6 @@ onMounted(async () => {
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
&:hover {
background: rgba(16, 185, 129, 0.1);

View File

@@ -55,6 +55,7 @@
:audio-duration-ms="store.timeline.audioDurationMs"
:face-start-time="store.timeline.faceStartTime"
:face-end-time="store.timeline.faceEndTime"
:validation-error="store.validationError"
/>
<!-- 积分预估 -->

View File

@@ -72,8 +72,13 @@
</div>
</div>
<!-- 校验错误提示 -->
<div v-if="validationError" class="timeline-diff error">
<CloseCircleOutlined class="diff-icon" />
<span>{{ validationError }}</span>
</div>
<!-- 时长差异提示 -->
<div v-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
<div v-else-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
<template v-if="diffStatus === 'match'">
<CheckCircleOutlined class="diff-icon" />
<span>时长匹配良好可以生成</span>
@@ -92,7 +97,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import { formatDurationMs } from '../utils/format'
interface Props {
@@ -100,11 +105,13 @@ interface Props {
audioDurationMs: number
faceStartTime?: number
faceEndTime?: number
validationError?: string | null
}
const props = withDefaults(defineProps<Props>(), {
faceStartTime: 0,
faceEndTime: 0,
validationError: null,
})
const maxDuration = computed(() => {
@@ -393,5 +400,10 @@ const formatDuration = formatDurationMs
background: rgba(245, 158, 11, 0.08);
color: @accent-orange;
}
&.error {
background: rgba(239, 68, 68, 0.12);
color: @accent-red;
}
}
</style>

View File

@@ -56,6 +56,7 @@
:audio-duration-ms="store.timeline.audioDurationMs"
:face-start-time="store.timeline.faceStartTime"
:face-end-time="store.timeline.faceEndTime"
:validation-error="store.validationError"
/>
<!-- 生成音频按钮 -->

View File

@@ -126,10 +126,22 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
const canGenerate = computed(() => {
if (!isVideoReady.value || !isAudioReady.value) return false
if (!timeline.value) return false
// 音频时长不能超过人脸时长
// 1. 人脸区间至少2秒
if (timeline.value.videoDurationMs < 2000) return false
// 2. 音频时长不能超过人脸时长
return timeline.value.audioDurationMs <= timeline.value.videoDurationMs
})
/** 校验失败原因 */
const validationError = computed(() => {
if (!timeline.value) return '请先完成视频识别'
if (!isVideoReady.value) return '请先完成视频识别'
if (!isAudioReady.value) return '请先生成音频'
if (timeline.value.videoDurationMs < 2000) return '人脸区间不足2秒无法生成对口型视频'
if (timeline.value.audioDurationMs > timeline.value.videoDurationMs) return '音频时长超过人脸时长,请缩短文案'
return null
})
/** 时间轴匹配状态 */
const timelineMatch = computed(() => {
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
@@ -658,6 +670,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
isAudioReady,
canGoNext,
canGenerate,
validationError,
timelineMatch,
faceDurationMs,
audioDurationMs,

View File

@@ -101,7 +101,7 @@
type="link"
size="small"
class="action-btn action-btn--primary"
@click="handlePreview(record)"
@click="openVideoUrl(record)"
>
<PlayCircleOutlined /> 预览
</a-button>
@@ -111,7 +111,7 @@
type="link"
size="small"
class="action-btn action-btn--success"
@click="handleDownload(record)"
@click="openVideoUrl(record)"
>
<DownloadOutlined /> 下载
</a-button>
@@ -170,17 +170,8 @@ const rowSelection = {
// 状态判断
const isStatus = (status, target) => status === target || status === target.toUpperCase()
// 预览视频(直接打开链接
const handlePreview = (record) => {
if (!record.resultVideoUrl) {
message.warning('该任务暂无视频结果,请稍后再试')
return
}
window.open(record.resultVideoUrl, '_blank')
}
// 下载视频
const handleDownload = (record) => {
// 打开视频链接(预览/下载共用
const openVideoUrl = (record) => {
if (!record.resultVideoUrl) {
message.warning('该任务暂无视频结果,请稍后再试')
return

View File

@@ -580,43 +580,6 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
StrUtil.blankToDefault(requestFormat, getDefaultFormat()));
}
private String buildFileName(String voiceId, String format) {
String safeVoice = StrUtil.blankToDefault(voiceId, "voice")
.replaceAll("[^a-zA-Z0-9_-]", "");
return safeVoice + "-" + System.currentTimeMillis() + "." + format;
}
private String resolveContentType(String format) {
if (format == null) {
return "audio/mpeg";
}
return switch (format.toLowerCase()) {
case "wav" -> "audio/wav";
case "flac" -> "audio/flac";
default -> "audio/mpeg";
};
}
private String determineSynthesisText(String transcriptionText, String inputText, boolean allowFallback) {
StringBuilder builder = new StringBuilder();
if (StrUtil.isNotBlank(transcriptionText)) {
builder.append(transcriptionText.trim());
}
if (StrUtil.isNotBlank(inputText)) {
if (builder.length() > 0) {
builder.append("\n");
}
builder.append(inputText.trim());
}
if (builder.length() > 0) {
return builder.toString();
}
if (allowFallback) {
return getPreviewText();
}
throw exception(VOICE_TTS_FAILED, "请提供需要合成的文本内容");
}
/**
* 从URL中提取原始URL去除查询参数和锚点
*/