feat(kling): 优化时间轴对比组件样式和交互
Some checks failed
Build and Deploy / deploy (push) Has been cancelled

- 重构 TimelinePanel.vue 组件,使用 Tailwind CSS 替代 Less,简化样式代码
- 改进视觉设计:更新颜色方案、间距和图标,提升用户体验
- 移除音频结束位置标记,优化刻度尺和轨道显示逻辑
- 统一时长差异提示的样式和状态显示

feat(infra): 扩展文件预签名接口支持 Content-Type 参数

- 在 FileApi、FileClient、FileService 接口中新增带 Content-Type 参数的 presignGetUrl 方法
- 实现 S3FileClient 对 Content-Type 参数的支持,确保浏览器正确渲染媒体文件
- 在 TikUserFileServiceImpl 中为音视频文件生成预签名 URL 时自动推断 Content-Type
- 支持公开访问和私有访问两种模式下的 Content-Type 参数传递
This commit is contained in:
2026-04-09 01:04:07 +08:00
parent c607316f53
commit 63d3e7eecb
8 changed files with 118 additions and 290 deletions

View File

@@ -1,94 +1,94 @@
<template>
<div class="timeline-panel">
<div class="timeline-header">
<span class="timeline-title">时间轴对比</span>
<span v-if="showDurations" class="duration-badge">
人脸 {{ formatDuration(faceDurationMs) }}
<div class="bg-[#fafbfc] border border-[#e5e7eb] rounded-md px-4 py-3 mt-2">
<div class="flex justify-between items-center mb-2.5">
<span class="text-[13px] font-semibold text-gray-800">时间轴对比</span>
<span v-if="showDurations" class="flex items-center gap-1.5 text-[13px] text-gray-600 tabular-nums">
<span class="flex items-center gap-[5px]">
<span class="w-[7px] h-[7px] rounded-sm bg-blue-500"></span>
视频 {{ formatDuration(faceDurationMs) }}
</span>
<template v-if="audioDurationMs > 0">
<span class="divider"></span>
音频 {{ formatDuration(audioDurationMs) }}
<span class="text-gray-400 text-xs">/</span>
<span class="flex items-center gap-[5px]">
<span class="w-[7px] h-[7px] rounded-sm bg-emerald-500"></span>
音频 {{ formatDuration(audioDurationMs) }}
</span>
</template>
</span>
</div>
<!-- 刻度尺 -->
<div class="timeline-ruler">
<div class="relative h-5 mb-1.5 ml-12">
<div
v-for="mark in rulerMarks"
:key="mark.time"
class="ruler-mark"
class="absolute -translate-x-1/2 flex flex-col items-center"
:style="{ left: mark.position + '%' }"
>
<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>
<span class="text-[11px] text-gray-400 leading-none mb-1">{{ mark.label }}</span>
<span class="block w-px h-1 bg-[#e5e7eb]"></span>
</div>
</div>
<!-- 轨道区域 -->
<div class="timeline-tracks">
<div class="flex flex-col gap-1.5">
<!-- 视频轨道 -->
<div class="track">
<div class="track-info">
<span class="track-icon">📹</span>
<span class="track-label">视频</span>
<div class="flex items-center gap-2">
<div class="w-9 shrink-0">
<span class="text-xs text-gray-600 font-medium">视频</span>
</div>
<div class="track-bar">
<div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
<div
class="track-fill video-fill"
class="h-full rounded flex items-center justify-center transition-[width] duration-300 min-w-[2px] bg-blue-500"
:style="{ width: videoBarWidth + '%' }"
>
<span v-if="videoBarWidth > 15" class="track-time">{{ formatDuration(faceDurationMs) }}</span>
<span v-if="videoBarWidth > 20" class="text-xs text-white font-medium tracking-wide">{{ formatDuration(faceDurationMs) }}</span>
</div>
</div>
</div>
<!-- 音频轨道 -->
<div class="track">
<div class="track-info">
<span class="track-icon">🎙</span>
<span class="track-label">音频</span>
<div class="flex items-center gap-2">
<div class="w-9 shrink-0">
<span class="text-xs text-gray-600 font-medium">音频</span>
</div>
<div class="track-bar">
<div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
<div
v-if="audioDurationMs > 0"
class="track-fill audio-fill"
:class="{ 'audio-exceed': isExceed }"
class="h-full rounded flex items-center justify-center transition-[width] duration-300 min-w-[2px]"
:class="isExceed ? 'bg-red-500' : 'bg-emerald-500'"
:style="{ width: audioBarWidth + '%' }"
>
<span v-if="audioBarWidth > 15" class="track-time">{{ formatDuration(audioDurationMs) }}</span>
<span v-if="audioBarWidth > 20" class="text-xs text-white font-medium tracking-wide">{{ formatDuration(audioDurationMs) }}</span>
</div>
<span v-else class="track-placeholder">等待生成音频</span>
<span v-else class="text-xs text-gray-400 pl-2.5">等待生成音频</span>
</div>
</div>
</div>
<!-- 校验错误提示 -->
<div v-if="validationError" class="timeline-diff error">
<Icon icon="lucide:x-circle" class="diff-icon" />
<div v-if="validationError" class="flex items-center gap-1.5 mt-2.5 px-2.5 py-2 rounded text-xs font-medium text-red-500 bg-red-50 border border-red-200">
<Icon icon="lucide:x-circle" class="text-sm shrink-0" />
<span>{{ validationError }}</span>
</div>
<!-- 时长差异提示 -->
<div v-else-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
<div v-else-if="audioDurationMs > 0" class="flex items-center gap-1.5 mt-2.5 px-2.5 py-2 rounded text-xs font-medium border border-transparent"
:class="{
'bg-[#f0fdf4] border-[#bbf7d0] text-emerald-600': diffStatus === 'match',
'bg-red-50 border-red-200 text-red-500': diffStatus === 'exceed',
'bg-amber-50 border-amber-200 text-amber-500': diffStatus === 'short',
}"
>
<template v-if="diffStatus === 'match'">
<Icon icon="lucide:check-circle" class="diff-icon" />
<Icon icon="lucide:check-circle" class="text-sm shrink-0" />
<span>时长匹配良好可以生成</span>
</template>
<template v-else-if="diffStatus === 'exceed'">
<Icon icon="lucide:alert-circle" class="diff-icon" />
<Icon icon="lucide:alert-circle" class="text-sm shrink-0" />
<span>音频超出 {{ formatDuration(diffMs) }}建议缩短文案</span>
</template>
<template v-else-if="diffStatus === 'short'">
<Icon icon="lucide:info" class="diff-icon" />
<Icon icon="lucide:info" class="text-sm shrink-0" />
<span>音频较短可适当增加文案</span>
</template>
</div>
@@ -127,10 +127,6 @@ 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))
@@ -178,235 +174,3 @@ function calculateInterval(duration: number): number {
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: @bg-subtle;
border-radius: 10px;
padding: 14px 18px;
margin-top: 8px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.timeline-title {
font-size: 12px;
font-weight: 600;
color: @text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.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: 18px;
margin-bottom: 10px;
margin-left: 80px;
}
.ruler-mark {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.ruler-label {
font-size: 10px;
color: @text-tertiary;
margin-bottom: 2px;
}
.ruler-tick {
display: block;
width: 1px;
height: 4px;
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;
}
// 轨道区域
.timeline-tracks {
display: flex;
flex-direction: column;
gap: 8px;
}
.track {
display: flex;
align-items: center;
gap: 12px;
}
.track-info {
display: flex;
align-items: center;
gap: 6px;
width: 68px;
flex-shrink: 0;
}
.track-icon {
font-size: 14px;
line-height: 1;
}
.track-label {
font-size: 12px;
color: @text-secondary;
font-weight: 500;
}
.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-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-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(90deg, @accent-blue 0%, @accent-purple 100%);
}
.audio-fill {
background: linear-gradient(90deg, @accent-green 0%, #059669 100%);
&.audio-exceed {
background: linear-gradient(90deg, @accent-red 0%, #dc2626 100%);
animation: pulse-warning 2s ease-in-out infinite;
}
}
@keyframes pulse-warning {
0%, 100% { opacity: 1; }
50% { opacity: 0.75; }
}
// 差异提示
.timeline-diff {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
.diff-icon {
font-size: 14px;
flex-shrink: 0;
}
&.match {
background: rgba(16, 185, 129, 0.08);
color: @accent-green;
}
&.exceed {
background: rgba(239, 68, 68, 0.08);
color: @accent-red;
}
&.short {
background: rgba(245, 158, 11, 0.08);
color: @accent-orange;
}
&.error {
background: rgba(239, 68, 68, 0.12);
color: @accent-red;
}
}
</style>

View File

@@ -52,6 +52,18 @@ public interface FileApi {
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
Integer expirationSeconds);
/**
* 生成文件预签名地址(带 Content-Type用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @param contentType 响应的 Content-Type为 null 时不设置
* @return 文件预签名地址
*/
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
Integer expirationSeconds,
String contentType);
/**
* 生成文件预签名地址(带 OSS 处理参数),用于读取
* 用于阿里云 OSS 视频截帧等图片处理场景

View File

@@ -31,7 +31,12 @@ public class FileApiImpl implements FileApi {
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
return fileService.presignGetUrl(url, expirationSeconds);
return presignGetUrl(url, expirationSeconds, null);
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
return fileService.presignGetUrl(url, expirationSeconds, contentType);
}
@Override

View File

@@ -71,6 +71,18 @@ public interface FileClient {
* @return 文件预签名地址
*/
default String presignGetUrl(String url, Integer expirationSeconds) {
return presignGetUrl(url, expirationSeconds, null);
}
/**
* 生成文件预签名地址(带 Content-Type用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @param contentType 响应的 Content-Type为 null 时不设置
* @return 文件预签名地址
*/
default String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
throw new UnsupportedOperationException("不支持的操作");
}

View File

@@ -200,12 +200,19 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
return presignGetUrlWithProcess(url, expirationSeconds, null);
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
return presignGetUrlWithProcess(url, expirationSeconds, null, contentType);
}
@Override
public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) {
return presignGetUrlWithProcess(url, expirationSeconds, processParam, null);
}
/**
* 生成文件预签名地址(带 OSS 处理参数和 Content-Type用于读取
*/
private String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam, String contentType) {
// 1. 将 url 转换为 path支持 CDN 域名和 OSS 原始域名)
String path = extractPathFromUrl(url);
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
@@ -213,11 +220,17 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
// 2. 公开访问:无需签名,直接拼接参数
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
String resultUrl = config.getDomain() + "/" + encodedPath;
StringBuilder resultUrl = new StringBuilder(config.getDomain()).append("/").append(encodedPath);
char separator = '?';
if (StrUtil.isNotBlank(processParam)) {
resultUrl = resultUrl + "?x-oss-process=" + processParam;
resultUrl.append(separator).append("x-oss-process=").append(processParam);
separator = '&';
}
return resultUrl;
if (StrUtil.isNotBlank(contentType)) {
resultUrl.append(separator).append("response-content-type=").append(
URLUtil.encode(contentType, StandardCharsets.UTF_8));
}
return resultUrl.toString();
}
// 3. 私有访问:生成预签名 URL需要将处理参数包含在签名中
@@ -229,10 +242,14 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
com.aliyun.oss.model.GeneratePresignedUrlRequest request =
new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), decodedPath, HttpMethod.GET);
request.setExpiration(expirationDate);
// 关键:将 x-oss-process 参数包含在签名中
// 将 x-oss-process 参数包含在签名中
if (StrUtil.isNotBlank(processParam)) {
request.addQueryParameter("x-oss-process", processParam);
}
// 设置 response-content-type确保浏览器能正确渲染
if (StrUtil.isNotBlank(contentType)) {
request.addQueryParameter("response-content-type", contentType);
}
signedUrl = aliyunOssClient.generatePresignedUrl(request).toString();
} else {
// 非阿里云不支持 OSS 处理参数,直接返回普通预签名 URL

View File

@@ -54,6 +54,16 @@ public interface FileService {
*/
String presignGetUrl(String url, Integer expirationSeconds);
/**
* 生成文件预签名地址(带 Content-Type用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @param contentType 响应的 Content-Type为 null 时不设置
* @return 文件预签名地址
*/
String presignGetUrl(String url, Integer expirationSeconds, String contentType);
/**
* 生成文件预签名地址(带 OSS 处理参数),用于读取
* 用于阿里云 OSS 视频截帧等图片处理场景

View File

@@ -142,8 +142,13 @@ public class FileServiceImpl implements FileService {
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
return presignGetUrl(url, expirationSeconds, null);
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
FileClient fileClient = fileConfigService.getMasterFileClient();
return fileClient.presignGetUrl(url, expirationSeconds);
return fileClient.presignGetUrl(url, expirationSeconds, contentType);
}
@Override

View File

@@ -339,7 +339,9 @@ public class TikUserFileServiceImpl implements TikUserFileService {
}
// 视频播放URL不缓存每次都生成新的签名URL使用 filePath 而非 fileUrl避免域名匹配问题
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS);
// 根据文件扩展名推断 Content-Type确保浏览器 <video> 元素能正确渲染
String contentType = java.net.URLConnection.guessContentTypeFromName(file.getFilePath());
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS, contentType);
}
@Override
@@ -354,7 +356,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
}
// 音频播放URL不缓存每次都生成新的签名URL使用 filePath 而非 fileUrl避免域名匹配问题
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS);
String contentType = java.net.URLConnection.guessContentTypeFromName(file.getFilePath());
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS, contentType);
}
@Override