diff --git a/frontend/app/web-gold/src/views/material/MaterialList.vue b/frontend/app/web-gold/src/views/material/MaterialList.vue
index 2e5e951855..bd733b8dec 100644
--- a/frontend/app/web-gold/src/views/material/MaterialList.vue
+++ b/frontend/app/web-gold/src/views/material/MaterialList.vue
@@ -123,14 +123,18 @@
图片
文件
-
-
-
-
+
+
+
+
+
+
+
+
@@ -249,7 +253,8 @@ import {
SearchOutlined,
FileOutlined,
DeleteOutlined,
- TagsOutlined
+ TagsOutlined,
+ DownloadOutlined
} from '@ant-design/icons-vue'
import { MaterialService, MaterialGroupService } from '@/api/material'
import { MixService } from '@/api/mix'
@@ -425,6 +430,24 @@ const handleDeleteFile = (file) => {
})
}
+// 下载文件
+const handleDownloadFile = (file) => {
+ if (!file?.previewUrl) {
+ message.warning('文件地址无效')
+ return
+ }
+
+ const fileUrl = file.previewUrl
+ const link = document.createElement('a')
+ link.href = fileUrl
+ link.download = file.fileName || 'download'
+ link.target = '_blank'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ message.success('开始下载')
+}
+
// 文件点击
const handleFileClick = (file) => {
const isSelected = selectedFileIds.value.includes(file.id)
@@ -853,7 +876,7 @@ const handleGroupCancel = () => {
.material-item__group {
position: absolute;
bottom: 8px;
- right: 42px; /* 在删除按钮左边 */
+ right: 76px; /* 在下载按钮左边 */
width: 28px;
height: 28px;
background: rgba(24, 144, 255, 0.9);
@@ -868,6 +891,24 @@ const handleGroupCancel = () => {
font-size: 16px;
}
+.material-item__download {
+ position: absolute;
+ bottom: 8px;
+ right: 42px; /* 在删除按钮左边 */
+ width: 28px;
+ height: 28px;
+ background: rgba(82, 196, 26, 0.9);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ cursor: pointer;
+ opacity: 0;
+ transition: all 0.3s;
+ font-size: 16px;
+}
+
.material-item:hover .material-item__delete {
opacity: 1;
}
@@ -876,6 +917,10 @@ const handleGroupCancel = () => {
opacity: 1;
}
+.material-item:hover .material-item__download {
+ opacity: 1;
+}
+
.material-item__delete:hover {
background: rgb(255, 77, 79);
transform: scale(1.1);
@@ -886,6 +931,11 @@ const handleGroupCancel = () => {
transform: scale(1.1);
}
+.material-item__download:hover {
+ background: rgb(82, 196, 26);
+ transform: scale(1.1);
+}
+
.material-item__info {
padding: 12px;
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java
index ac77294b20..e8eed31334 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java
@@ -6,6 +6,8 @@ import cn.hutool.http.HttpResponse;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
+import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
@@ -42,6 +44,7 @@ public class LatentsyncPollingService {
private final RedissonClient redissonClient;
private final TikOssInitService ossInitService;
private final cn.iocoder.yudao.module.infra.api.file.FileApi fileApi;
+ private final TikUserFileMapper userFileMapper;
/**
* Redis键前缀
@@ -213,14 +216,14 @@ public class LatentsyncPollingService {
}
// 保存视频到OSS(异步处理,轻量化逻辑)
- String savedVideoUrl = videoUrl;
+ OssSaveResult saveResult = null;
try {
// 保存视频到OSS,避免临时URL过期
- savedVideoUrl = saveVideoToOss(task, videoUrl);
- log.info("[handleTaskCompleted][任务({})视频已保存到OSS][url={}]", taskId, savedVideoUrl);
+ saveResult = saveVideoToOss(task, videoUrl);
+ log.info("[handleTaskCompleted][任务({})视频已保存到OSS][url={}]", taskId, saveResult.getUrl());
} catch (Exception e) {
log.warn("[handleTaskCompleted][任务({})保存视频失败,使用原URL][error={}]", taskId, e.getMessage());
- savedVideoUrl = videoUrl; // 降级处理
+ saveResult = new OssSaveResult(videoUrl, 0, null); // 降级处理
}
// 更新任务状态为成功
@@ -229,18 +232,21 @@ public class LatentsyncPollingService {
updateObj.setStatus("SUCCESS");
updateObj.setCurrentStep("finishing");
updateObj.setProgress(100);
- updateObj.setResultVideoUrl(savedVideoUrl);
+ updateObj.setResultVideoUrl(saveResult.getUrl());
updateObj.setFinishTime(LocalDateTime.now());
taskMapper.updateById(updateObj);
// 缓存结果到Redis(快速回显)
try {
String resultKey = "digital_human:task:result:" + taskId;
- stringRedisTemplate.opsForValue().set(resultKey, savedVideoUrl, Duration.ofHours(24));
+ stringRedisTemplate.opsForValue().set(resultKey, saveResult.getUrl(), Duration.ofHours(24));
} catch (Exception e) {
log.warn("[handleTaskCompleted][任务({})缓存结果失败]", taskId, e);
}
+ // 保存结果视频到用户文件表(这样用户可以在素材库中查看)
+ saveResultVideoToUserFiles(task, saveResult);
+
// 从轮询队列中移除
removeFromPollingQueue(taskId, requestId);
@@ -355,8 +361,9 @@ public class LatentsyncPollingService {
/**
* 保存视频到OSS - 流式处理优化内存
+ * 返回保存结果,包含URL和文件大小
*/
- private String saveVideoToOss(TikDigitalHumanTaskDO task, String remoteVideoUrl) throws Exception {
+ private OssSaveResult saveVideoToOss(TikDigitalHumanTaskDO task, String remoteVideoUrl) throws Exception {
log.info("[saveVideoToOss][任务({})开始下载并保存视频到OSS][remoteUrl={}]", task.getId(), remoteVideoUrl);
try {
@@ -382,14 +389,44 @@ public class LatentsyncPollingService {
// 6. 移除预签名URL中的签名参数,获取基础URL
String cleanOssUrl = HttpUtils.removeUrlQuery(ossUrl);
+ // 7. 生成文件路径(用于后续删除)
+ String filePath = baseDirectory + "/" + fileName;
+
log.info("[saveVideoToOss][任务({})视频保存到OSS完成][directory={}, fileName={}, ossUrl={}]",
task.getId(), baseDirectory, fileName, cleanOssUrl);
- return cleanOssUrl;
+ return new OssSaveResult(cleanOssUrl, videoBytes.length, filePath);
} catch (Exception e) {
log.error("[saveVideoToOss][任务({})保存视频到OSS失败][remoteUrl={}]", task.getId(), remoteVideoUrl, e);
// 如果保存失败,返回原始URL(降级处理)
- return remoteVideoUrl;
+ return new OssSaveResult(remoteVideoUrl, 0, null);
+ }
+ }
+
+ /**
+ * OSS保存结果
+ */
+ private static class OssSaveResult {
+ private final String url;
+ private final int fileSize;
+ private final String filePath;
+
+ public OssSaveResult(String url, int fileSize, String filePath) {
+ this.url = url;
+ this.fileSize = fileSize;
+ this.filePath = filePath;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public int getFileSize() {
+ return fileSize;
+ }
+
+ public String getFilePath() {
+ return filePath;
}
}
@@ -414,4 +451,48 @@ public class LatentsyncPollingService {
}
}
+ /**
+ * 保存结果视频到用户文件表
+ * 这样用户就可以在素材库中查看和管理生成的文件
+ */
+ private void saveResultVideoToUserFiles(TikDigitalHumanTaskDO task, OssSaveResult saveResult) {
+ try {
+ Long userId = task.getUserId();
+ if (userId == null) {
+ log.warn("[saveResultVideoToUserFiles][任务({})用户ID为空,无法保存文件]", task.getId());
+ return;
+ }
+
+ // 验证结果视频URL
+ String resultVideoUrl = saveResult.getUrl();
+ if (StrUtil.isBlank(resultVideoUrl)) {
+ log.warn("[saveResultVideoToUserFiles][任务({})结果视频URL为空,无法保存文件]", task.getId());
+ return;
+ }
+
+ // 生成文件名(使用任务ID和时间戳)
+ String fileName = String.format("数字人视频_%d_%d.mp4", task.getId(), System.currentTimeMillis());
+
+ // 创建用户文件记录
+ TikUserFileDO userFile = new TikUserFileDO();
+ userFile.setUserId(userId);
+ userFile.setFileName(fileName);
+ userFile.setFileType("video/mp4");
+ userFile.setFileCategory("generate"); // 文件分类:生成文件
+ userFile.setFileUrl(resultVideoUrl);
+ userFile.setFilePath(saveResult.getFilePath()); // 设置文件路径(用于后续删除)
+ userFile.setFileSize((long) saveResult.getFileSize()); // 设置文件大小
+ userFile.setDescription("数字人生成结果视频,任务ID:" + task.getId());
+
+ // 插入到用户文件表
+ userFileMapper.insert(userFile);
+
+ log.info("[saveResultVideoToUserFiles][任务({})结果视频已保存到用户文件表,文件编号({})]",
+ task.getId(), userFile.getId());
+ } catch (Exception e) {
+ // 保存失败不影响主流程,只记录日志
+ log.error("[saveResultVideoToUserFiles][任务({})保存结果视频到用户文件表失败]", task.getId(), e);
+ }
+ }
+
}