feat: 优化
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(xargs:*)"
|
||||
"Bash(xargs:*)",
|
||||
"Bash(test:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
365
docs/ICE_SubmitMediaProducingJob_API.md
Normal file
365
docs/ICE_SubmitMediaProducingJob_API.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# 阿里云智能媒体服务 - SubmitMediaProducingJob API文档
|
||||
|
||||
## 📋 API概述
|
||||
|
||||
**接口名称:** SubmitMediaProducingJob(提交剪辑合成作业)
|
||||
|
||||
**服务名称:** Intelligent Media Services (IMS) - 智能媒体服务
|
||||
|
||||
**API版本:** 2020-11-09
|
||||
|
||||
### 业务说明
|
||||
|
||||
`SubmitMediaProducingJob` 是阿里云智能媒体服务的核心API接口,主要用于**提交媒体剪辑合成任务**。当用户需要对视频或音频素材进行剪辑、合成、添加特效、转码等后期制作时,可以通过调用此接口自动完成这些复杂的媒体处理工作。
|
||||
|
||||
#### 核心业务场景
|
||||
|
||||
1. **视频剪辑制作**
|
||||
- 多个视频片段的拼接合成
|
||||
- 添加转场效果和过渡动画
|
||||
- 视频片段的裁剪和缩放
|
||||
|
||||
2. **音视频处理**
|
||||
- 音频与视频的同步合成
|
||||
- 添加背景音乐和音效
|
||||
- 音频混合和音量调节
|
||||
|
||||
3. **多轨道编辑**
|
||||
- 支持视频轨道、音频轨道、字幕轨道
|
||||
- 实现复杂的多层编辑效果
|
||||
- 视频叠加和水印添加
|
||||
|
||||
4. **模板化制作**
|
||||
- 使用预定义模板快速生成视频
|
||||
- 批量内容生产
|
||||
- 统一风格的视频输出
|
||||
|
||||
5. **云端转码**
|
||||
- 视频格式转换(MP4、AVI、MOV等)
|
||||
- 分辨率和码率调整
|
||||
- 自适应码率输出
|
||||
|
||||
---
|
||||
|
||||
## 🔐 授权信息
|
||||
|
||||
| 操作名 | 访问级别 | 资源类型 | 条件键 | 关联操作 |
|
||||
|--------|----------|----------|--------|----------|
|
||||
| ice:SubmitMediaProducingJob | 写权限 | 所有资源 (`*`) | 无 | 无 |
|
||||
|
||||
---
|
||||
|
||||
## 📡 接口调用
|
||||
|
||||
**请求方法:** POST
|
||||
|
||||
**调用地址:** `https://ims.ap-southeast-1.aliyuncs.com/`
|
||||
|
||||
**请求路径:** `/2020-11-09/submitMediaProducingJob`
|
||||
|
||||
### ⚠️ 重要说明
|
||||
|
||||
- 此接口仅返回作业**提交结果**,作业提交后将在后台异步处理
|
||||
- 时间线中引用的素材可以是媒体库中的资产或OSS对象
|
||||
- **不支持**外部URL或CDN URL
|
||||
- 生产完成后,输出文件会自动注册为媒体资产
|
||||
- 需要先分析媒体资产,才能查询时长和分辨率信息
|
||||
|
||||
---
|
||||
|
||||
## 🔒 调用限制
|
||||
|
||||
| 限制项 | 限制值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| **QPS限制** | 30次/秒 | 超出限制会返回"Throttling.User"错误 |
|
||||
| **视频轨道** | 最多100个 | 每个项目最多可创建100条视频轨道 |
|
||||
| **图片轨道** | 最多100个 | 每个项目最多可创建100条图片轨道 |
|
||||
| **字幕轨道** | 最多100个 | 每个项目最多可创建100条字幕轨道 |
|
||||
| **素材总大小** | 不超过1TB | 项目中所有素材文件的总大小限制 |
|
||||
| **输出分辨率** | 128px - 4096px | 宽度和高度都必须在128-4096像素之间 |
|
||||
| **视频短边** | 不超过2160px | 视频的短边不能超过2160像素 |
|
||||
| **区域限制** | 同一区域 | 素材和输出的OSS桶必须与IMS服务区域一致 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 请求参数
|
||||
|
||||
### 主要参数说明
|
||||
|
||||
| 参数名 | 类型 | 必填 | 描述 | 示例值 |
|
||||
|--------|------|------|------|--------|
|
||||
| **ProjectId** | string | 否 | 编辑项目的ID | `xxxxxfb2101cb318xxxxx` |
|
||||
| **Timeline** | string | 否 | 在线编辑作业的时间线配置 | 详见时间线配置 |
|
||||
| **TemplateId** | string | 否 | 模板ID(使用模板快速构建时间线) | `****96e8864746a0b6f3****` |
|
||||
| **ClipsParam** | string | 否 | 模板素材参数(JSON格式) | - |
|
||||
| **ProjectMetadata** | string | 否 | 编辑项目的元数据(JSON格式) | - |
|
||||
| **OutputMediaTarget** | string | 否 | 输出文件类型:`oss-object`/`vod-media`/`S3` | `oss-object` |
|
||||
| **OutputMediaConfig** | **Yes** | **是** | 输出文件配置(JSON格式) | 详见配置说明 |
|
||||
| **UserData** | string | 否 | 用户自定义数据(最多512字节) | `{"NotifyAddress":"https://..."}` |
|
||||
| **ClientToken** | string | 否 | 客户端令牌(确保请求幂等性) | `****12e8864746a0a398****` |
|
||||
| **Source** | string | 否 | 请求来源:`OpenAPI`/`AliyunConsole`/`WebSDK` | `OPENAPI` |
|
||||
| **EditingProduceConfig** | string | 否 | 编辑制作参数 | 详见配置说明 |
|
||||
| **MediaMetadata** | string | 否 | 产出视频的元数据 | `{"Title":"test-title"}` |
|
||||
|
||||
### 参数组合规则
|
||||
|
||||
**三选一参数:** `ProjectId`、`Timeline`、`TemplateId` 中必须指定一个,其余两个必须为空。
|
||||
|
||||
- 如果指定 `ProjectId`:使用现有编辑项目
|
||||
- 如果指定 `Timeline`:直接定义时间线
|
||||
- 如果指定 `TemplateId`:必须同时指定 `ClipsParam`
|
||||
|
||||
---
|
||||
|
||||
## 💾 输出配置示例
|
||||
|
||||
### 示例1:输出到OSS
|
||||
|
||||
```json
|
||||
{
|
||||
"MediaURL": "https://my-test-bucket.oss-cn-shanghai.aliyuncs.com/test/xxxxxtest001xxxxx.mp4",
|
||||
"Bitrate": 2000,
|
||||
"Width": 800,
|
||||
"Height": 680
|
||||
}
|
||||
```
|
||||
|
||||
**配置说明:**
|
||||
- `MediaURL`:OSS对象URL,格式为 `https://bucketname.oss-region-name.aliyuncs.com/xxx/yyy.ext`
|
||||
- `Bitrate`:输出码率(Kbit/s),越高视频越清晰,最大值为5000
|
||||
- `Width`/`Height`:输出分辨率,留空则使用输入素材的最大分辨率
|
||||
|
||||
### 示例2:输出到ApsaraVideo VOD
|
||||
|
||||
```json
|
||||
{
|
||||
"StorageLocation": "outin-*xxxxxx7d2a3811eb83da00163exxxxxx.oss-cn-shanghai.aliyuncs.com",
|
||||
"FileName": "output.mp4",
|
||||
"Bitrate": 2000,
|
||||
"Width": 800,
|
||||
"Height": 680,
|
||||
"VodTemplateGroupId": "VOD_NO_TRANSCODE"
|
||||
}
|
||||
```
|
||||
|
||||
**配置说明:**
|
||||
- `StorageLocation`:VOD中的存储位置(不含http://前缀)
|
||||
- `FileName`:输出文件名(包含扩展名)
|
||||
- `VodTemplateGroupId`:VOD转码模板组ID,设为`VOD_NO_TRANSCODE`表示不转码
|
||||
|
||||
### OutputMediaConfig 参数详解
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| MediaURL | String | 输出文件URL(oss-object类型) |
|
||||
| StorageLocation | String | VOD存储位置(vod-media类型) |
|
||||
| FileName | String | 输出文件名(vod-media类型) |
|
||||
| Width | Integer | 输出宽度(默认:输入素材最大宽度) |
|
||||
| Height | Integer | 输出高度(默认:输入素材最大高度) |
|
||||
| Bitrate | Integer | 输出码率(默认:输入素材最大码率) |
|
||||
| VodTemplateGroupId | String | VOD转码模板组ID |
|
||||
|
||||
---
|
||||
|
||||
## 📤 响应参数
|
||||
|
||||
| 参数名 | 类型 | 描述 | 示例值 |
|
||||
|--------|------|------|--------|
|
||||
| RequestId | string | 请求ID(唯一标识) | `****36-3C1E-4417-BDB2-1E034F****` |
|
||||
| ProjectId | string | 编辑项目ID | `****b4549d46c88681030f6e****` |
|
||||
| **JobId** | string | **作业ID(用于查询作业状态)** | `****d80e4e4044975745c14b****` |
|
||||
| MediaId | string | 输出文件的媒体资产ID | `****c469e944b5a856828dc2****` |
|
||||
| VodMediaId | string | 输出文件在VOD中的媒体资产ID(如适用) | `****d8s4h75ci975745c14b****` |
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"RequestId": "****36-3C1E-4417-BDB2-1E034F****",
|
||||
"ProjectId": "****b4549d46c88681030f6e****",
|
||||
"JobId": "****d80e4e4044975745c14b****",
|
||||
"MediaId": "****c469e944b5a856828dc2****",
|
||||
"VodMediaId": "****d8s4h75ci975745c14b****"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 EditingProduceConfig 配置
|
||||
|
||||
用于控制编辑制作过程的参数。
|
||||
|
||||
```json
|
||||
{
|
||||
"AutoRegisterInputVodMedia": "true",
|
||||
"OutputWebmTransparentChannel": "true",
|
||||
"CoverConfig": {
|
||||
"CustomThumbnail": "https://example.com/thumb.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `AutoRegisterInputVodMedia`:是否自动注册时间线中的VOD媒体资产到IMS,默认 `true`
|
||||
- `OutputWebmTransparentChannel`:输出视频是否包含Alpha通道(透明度),默认 `false`
|
||||
- `CoverConfig`:自定义缩略图配置
|
||||
|
||||
---
|
||||
|
||||
## 🚨 错误码
|
||||
|
||||
| HTTP状态码 | 错误码 | 错误消息 |
|
||||
|------------|--------|----------|
|
||||
| 400 | InvalidParameter | 参数不合法 |
|
||||
| 404 | ProjectNotFound | 指定的项目不存在 |
|
||||
| 429 | Throttling.User | 请求频率超过限制(30 QPS) |
|
||||
|
||||
---
|
||||
|
||||
## 💡 典型使用场景
|
||||
|
||||
### 场景1:视频片段拼接
|
||||
|
||||
```json
|
||||
{
|
||||
"Timeline": {
|
||||
"VideoTracks": [
|
||||
{
|
||||
"VideoTrackClips": [
|
||||
{"MediaId": "****4d7cf14dc7b83b0e801c****"},
|
||||
{"MediaId": "****4d7cf14dc7b83b0e801c****"}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"OutputMediaConfig": {
|
||||
"MediaURL": "https://my-bucket.oss-cn-shanghai.aliyuncs.com/output.mp4",
|
||||
"Bitrate": 2000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**业务场景:** 将两个视频片段无缝拼接成一个完整视频
|
||||
|
||||
### 场景2:模板化视频生产
|
||||
|
||||
```json
|
||||
{
|
||||
"TemplateId": "****template-id****",
|
||||
"ClipsParam": {
|
||||
"clips": [
|
||||
{"MediaId": "****video1****"},
|
||||
{"MediaId": "****video2****"}
|
||||
]
|
||||
},
|
||||
"OutputMediaConfig": {
|
||||
"MediaURL": "https://my-bucket.oss-cn-shanghai.aliyuncs.com/template-output.mp4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**业务场景:** 使用预定义模板快速生成风格统一的视频内容
|
||||
|
||||
### 场景3:视频转码并上传VOD
|
||||
|
||||
```json
|
||||
{
|
||||
"Timeline": {
|
||||
"VideoTracks": [
|
||||
{
|
||||
"VideoTrackClips": [
|
||||
{"MediaId": "****source-video****"}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"OutputMediaTarget": "vod-media",
|
||||
"OutputMediaConfig": {
|
||||
"StorageLocation": "outin-xxxxx.oss-cn-shanghai.aliyuncs.com",
|
||||
"FileName": "transcoded-video.mp4",
|
||||
"Bitrate": 1500,
|
||||
"Width": 1920,
|
||||
"Height": 1080
|
||||
},
|
||||
"UserData": {
|
||||
"NotifyAddress": "https://your-callback-url.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**业务场景:** 将视频转码为不同分辨率和码率,并直接上传到VOD系统
|
||||
|
||||
---
|
||||
|
||||
## 📊 相关API
|
||||
|
||||
- **GetMediaProducingJob**:查询媒体剪辑合成作业状态
|
||||
- **CancelMediaProducingJob**:取消媒体剪辑合成作业
|
||||
- **CreateEditingProject**:创建编辑项目
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [时间线配置说明](https://www.alibabacloud.com/help/en/ims/developer-reference/timeline-configuration-description)
|
||||
- [编辑制作参数说明](https://www.alibabacloud.com/help/en/ims/developer-reference/clip-composition-parameter-description)
|
||||
- [模板创建和使用](https://www.alibabacloud.com/help/en/ims/user-guide/create-and-use-a-normal-template)
|
||||
- [回调配置](https://www.alibabacloud.com/help/en/ims/use-cases/to-configure-a-callback-when-a-clip-completes)
|
||||
- [常见问题FAQ](https://www.alibabacloud.com/help/en/ims/support/intelligent-production-making-faq)
|
||||
|
||||
---
|
||||
|
||||
## 📌 注意事项
|
||||
|
||||
1. **异步处理:** 作业提交后立即返回,任务在后台异步执行
|
||||
2. **费用说明:** 按实际处理时长和输出文件大小计费
|
||||
3. **配额管理:** 建议使用 `ClientToken` 确保请求幂等性
|
||||
4. **回调通知:** 通过 `UserData.NotifyAddress` 设置完成回调通知
|
||||
5. **文件大小:** 单次处理的文件总大小建议不超过1GB,超过建议分段处理
|
||||
6. **格式支持:** 支持主流视频/音频格式(MP4、AVI、MOV、MP3、AAC等)
|
||||
7. **转码速度:** 处理速度取决于输出质量设置,高质量处理时间较长
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 1. 幂等性保证
|
||||
```javascript
|
||||
// 使用ClientToken确保同一请求不会被重复处理
|
||||
const clientToken = generateUUID();
|
||||
await submitMediaProducingJob({
|
||||
ClientToken: clientToken,
|
||||
// ... 其他参数
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 状态轮询
|
||||
```javascript
|
||||
// 提交作业后,使用JobId轮询查询状态
|
||||
const jobId = response.JobId;
|
||||
const status = await getMediaProducingJob({ JobId: jobId });
|
||||
```
|
||||
|
||||
### 3. 错误重试
|
||||
```javascript
|
||||
// 针对网络错误或限流错误进行指数退避重试
|
||||
try {
|
||||
await submitMediaProducingJob(params);
|
||||
} catch (error) {
|
||||
if (error.code === 'Throttling.User') {
|
||||
// 等待后重试
|
||||
await sleep(1000);
|
||||
await submitMediaProducingJob(params);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 资源清理
|
||||
```javascript
|
||||
// 处理完成后,及时清理不必要的中间文件
|
||||
await deleteMediaAssets([tempMediaId1, tempMediaId2]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档版本:v1.0* | *最后更新:2025-11-29*
|
||||
@@ -16,7 +16,8 @@ const icons = {
|
||||
wave: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M2 12s2-4 5-4 3 8 6 8 3-8 6-8 3 4 3 4"/></svg>',
|
||||
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>',
|
||||
video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="m22 8-6 4 6 4V8Z"/><rect x="2" y="6" width="14" height="12" rx="2" ry="2"/></svg>',
|
||||
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>'
|
||||
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>',
|
||||
scissors: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M8.59 13.51 15.42 17.49"/><path d="M15.41 6.51 8.59 10.49"/></svg>'
|
||||
}
|
||||
|
||||
const items = computed(() => {
|
||||
@@ -42,6 +43,7 @@ const items = computed(() => {
|
||||
title: '素材库',
|
||||
children: [
|
||||
{ path: '/material/list', label: '素材列表', icon: 'grid' },
|
||||
{ path: '/material/mix-task', label: '混剪任务', icon: 'scissors' },
|
||||
{ path: '/material/group', label: '素材分组', icon: 'folder' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -58,126 +58,152 @@
|
||||
<!-- 任务列表 -->
|
||||
<div class="mix-task-list__content">
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<template v-if="taskList.length > 0">
|
||||
<div class="task-list">
|
||||
<div
|
||||
v-for="task in taskList"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
>
|
||||
<div class="task-item__header">
|
||||
<div class="task-item__title">
|
||||
<h3>{{ task.title }}</h3>
|
||||
<a-tag :color="getStatusColor(task.status)">
|
||||
{{ getStatusText(task.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="task-item__actions">
|
||||
<a-button
|
||||
v-if="task.status === 'failed'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleRetry(task.id)"
|
||||
<a-table
|
||||
:data-source="taskList"
|
||||
:columns="columns"
|
||||
:row-key="record => record.id"
|
||||
:pagination="paginationConfig"
|
||||
@change="handleTableChange"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
@expandedRowsChange="handleExpandedRowsChange"
|
||||
:scroll="{ x: 1000 }"
|
||||
>
|
||||
<!-- 标题列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<div class="title-cell">
|
||||
<strong>{{ record.title }}</strong>
|
||||
<a-tag v-if="record.text" size="small" style="margin-left: 8px">有文案</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 进度列 -->
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<div style="min-width: 100px">
|
||||
<div style="font-size: 12px; margin-bottom: 4px">{{ record.progress }}%</div>
|
||||
<a-progress
|
||||
:percent="record.progress"
|
||||
:status="getProgressStatus(record.status)"
|
||||
size="small"
|
||||
:show-info="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 创建时间列 -->
|
||||
<template v-else-if="column.key === 'createTime'">
|
||||
{{ formatDate(record.createTime) }}
|
||||
</template>
|
||||
|
||||
<!-- 完成时间列 -->
|
||||
<template v-else-if="column.key === 'finishTime'">
|
||||
{{ record.finishTime ? formatDate(record.finishTime) : '-' }}
|
||||
</template>
|
||||
|
||||
<!-- 生成结果列 -->
|
||||
<template v-else-if="column.key === 'outputUrls'">
|
||||
<div v-if="record.outputUrls && record.outputUrls.length > 0">
|
||||
<a-tag color="success">{{ record.outputUrls.length }} 个视频</a-tag>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="record.outputUrls && record.outputUrls.length > 0"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleDownloadAll(record.outputUrls)"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
<span>下载</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'failed'"
|
||||
size="small"
|
||||
@click="handleRetry(record.id)"
|
||||
>
|
||||
重新生成
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'running'"
|
||||
size="small"
|
||||
@click="handleCancel(record.id)"
|
||||
>
|
||||
取消
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除这个任务吗?删除后无法恢复。"
|
||||
@confirm="() => handleDelete(record.id)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 展开行内容 -->
|
||||
<template #expandedRowRender="{ record }">
|
||||
<div class="expanded-content">
|
||||
<!-- 任务详情 -->
|
||||
<div v-if="record.text" class="task-text">
|
||||
<strong>文案内容:</strong>
|
||||
<p>{{ record.text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 生成结果 -->
|
||||
<div v-if="record.outputUrls && record.outputUrls.length > 0" class="task-results">
|
||||
<strong>生成结果:</strong>
|
||||
<div class="result-list">
|
||||
<div
|
||||
v-for="(url, index) in record.outputUrls"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
>
|
||||
重新生成
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="task.status === 'running'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleCancel(task.id)"
|
||||
>
|
||||
取消
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDelete(task.id)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
<a :href="url" target="_blank">
|
||||
<PlayCircleOutlined />
|
||||
视频 {{ index + 1 }}
|
||||
</a>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDownload(url)"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item__content">
|
||||
<div class="task-item__progress">
|
||||
<div class="progress-info">
|
||||
<span>进度:{{ task.progress }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="task.progress"
|
||||
:status="getProgressStatus(task.status)"
|
||||
:show-info="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="task-item__meta">
|
||||
<span>创建时间:{{ formatDate(task.createTime) }}</span>
|
||||
<span v-if="task.finishTime">
|
||||
完成时间:{{ formatDate(task.finishTime) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="task-item__text" v-if="task.text">
|
||||
<p>{{ task.text }}</p>
|
||||
</div>
|
||||
|
||||
<div class="task-item__results" v-if="task.outputUrls && task.outputUrls.length > 0">
|
||||
<h4>生成结果 ({{ task.outputUrls.length }}):</h4>
|
||||
<div class="result-list">
|
||||
<div
|
||||
v-for="(url, index) in task.outputUrls"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
>
|
||||
<a :href="url" target="_blank">
|
||||
<PlayCircleOutlined />
|
||||
视频 {{ index + 1 }}
|
||||
</a>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDownload(url)"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item__error" v-if="task.errorMsg">
|
||||
<a-alert
|
||||
type="error"
|
||||
:message="task.errorMsg"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="record.errorMsg" class="task-error">
|
||||
<a-alert
|
||||
type="error"
|
||||
:message="record.errorMsg"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-empty description="暂无混剪任务" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mix-task-list__pagination">
|
||||
<a-pagination
|
||||
v-model:current="pagination.pageNo"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
:show-size-changer="true"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
@@ -191,7 +217,61 @@ import { formatDate } from '@/utils/file'
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const taskList = ref([])
|
||||
const timer = ref(null) // 定时器
|
||||
const expandedRowKeys = ref([])
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: 250,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '生成结果',
|
||||
dataIndex: 'outputUrls',
|
||||
key: 'outputUrls',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'finishTime',
|
||||
key: 'finishTime',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 300,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
@@ -200,18 +280,32 @@ const filters = reactive({
|
||||
createTime: undefined
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
pageNo: 1,
|
||||
// 分页配置
|
||||
const paginationConfig = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (page, pageSize) => {
|
||||
paginationConfig.current = page
|
||||
paginationConfig.pageSize = pageSize
|
||||
handlePageChange(page, pageSize)
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
paginationConfig.current = 1
|
||||
paginationConfig.pageSize = size
|
||||
handlePageChange(1, size)
|
||||
}
|
||||
})
|
||||
|
||||
// 构建查询参数
|
||||
const buildQueryParams = () => {
|
||||
const params = {
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize,
|
||||
pageNo: paginationConfig.current,
|
||||
pageSize: paginationConfig.pageSize,
|
||||
status: filters.status || undefined,
|
||||
title: filters.title || undefined
|
||||
}
|
||||
@@ -232,7 +326,7 @@ const loadTaskList = async () => {
|
||||
const res = await MixTaskService.getTaskPage(buildQueryParams())
|
||||
if (res.code === 0) {
|
||||
taskList.value = res.data.list || []
|
||||
pagination.total = res.data.total || 0
|
||||
paginationConfig.total = res.data.total || 0
|
||||
} else {
|
||||
message.error(res.msg || '加载失败')
|
||||
}
|
||||
@@ -246,7 +340,7 @@ const loadTaskList = async () => {
|
||||
|
||||
// 筛选
|
||||
const handleFilterChange = () => {
|
||||
pagination.pageNo = 1
|
||||
paginationConfig.current = 1
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
@@ -254,20 +348,25 @@ const handleResetFilters = () => {
|
||||
filters.status = ''
|
||||
filters.title = ''
|
||||
filters.createTime = undefined
|
||||
pagination.pageNo = 1
|
||||
paginationConfig.current = 1
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = (page, pageSize) => {
|
||||
pagination.pageNo = page
|
||||
if (pageSize && pageSize !== pagination.pageSize) {
|
||||
pagination.pageSize = pageSize
|
||||
pagination.pageNo = 1
|
||||
}
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag, filters, sorter) => {
|
||||
console.log('表格变化:', pag, filters, sorter)
|
||||
}
|
||||
|
||||
// 展开行变化
|
||||
const handleExpandedRowsChange = (expandedRows) => {
|
||||
expandedRowKeys.value = expandedRows
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
loadTaskList()
|
||||
@@ -309,23 +408,17 @@ const handleCancel = (id) => {
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个任务吗?删除后无法恢复。',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await MixTaskService.deleteTask(id)
|
||||
message.success('删除成功')
|
||||
loadTaskList()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
MixTaskService.deleteTask(id)
|
||||
.then(() => {
|
||||
message.success('删除成功')
|
||||
loadTaskList()
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('删除失败')
|
||||
})
|
||||
}
|
||||
|
||||
// 下载
|
||||
// 下载单个视频
|
||||
const handleDownload = (url) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
@@ -336,6 +429,27 @@ const handleDownload = (url) => {
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 批量下载所有视频
|
||||
const handleDownloadAll = (urls) => {
|
||||
if (!urls || urls.length === 0) {
|
||||
message.warning('没有可下载的视频')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在准备下载...', 0)
|
||||
|
||||
// 逐个触发下载,避免浏览器阻止多个弹窗
|
||||
urls.forEach((url, index) => {
|
||||
setTimeout(() => {
|
||||
console.log('下载视频:', url)
|
||||
handleDownload(url)
|
||||
}, index * 500) // 每个下载间隔500ms
|
||||
})
|
||||
|
||||
message.destroy()
|
||||
message.success(`已触发 ${urls.length} 个视频的下载`)
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
@@ -369,29 +483,9 @@ const getProgressStatus = (status) => {
|
||||
return statusMap[status] || 'normal'
|
||||
}
|
||||
|
||||
// 定时刷新
|
||||
const startAutoRefresh = () => {
|
||||
// 每5秒刷新一次
|
||||
timer.value = setInterval(() => {
|
||||
loadTaskList()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadTaskList()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -431,93 +525,47 @@ onUnmounted(() => {
|
||||
|
||||
.mix-task-list__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.task-item__header {
|
||||
.title-cell {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.expanded-content {
|
||||
padding: 16px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-card);
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.task-item__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item__title h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-item__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-item__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.task-item__meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.task-item__text {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
.task-text p {
|
||||
margin: 8px 0 0 0;
|
||||
padding: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-item__text p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-item__results h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
.task-results {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
@@ -525,13 +573,17 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-2);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mix-task-list__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.task-error {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 确保按钮内的图标和文字对齐 */
|
||||
:deep(.ant-btn .anticon) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
532
frontend/app/web-gold/src/views/material/MixTaskList.vue.bak
Normal file
532
frontend/app/web-gold/src/views/material/MixTaskList.vue.bak
Normal file
@@ -0,0 +1,532 @@
|
||||
<template>
|
||||
<div class="mix-task-list">
|
||||
<div class="mix-task-list__header">
|
||||
<h1 class="mix-task-list__title">混剪任务</h1>
|
||||
<div class="mix-task-list__actions">
|
||||
<a-button @click="handleRefresh">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="mix-task-list__filters">
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
style="width: 120px"
|
||||
placeholder="任务状态"
|
||||
@change="handleFilterChange"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
<a-select-option value="running">处理中</a-select-option>
|
||||
<a-select-option value="success">已完成</a-select-option>
|
||||
<a-select-option value="failed">失败</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-input
|
||||
v-model="filters.title"
|
||||
placeholder="搜索标题"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@press-enter="handleFilterChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<a-range-picker
|
||||
v-model:value="filters.createTime"
|
||||
style="width: 300px"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
|
||||
<a-button type="primary" @click="handleFilterChange">查询</a-button>
|
||||
<a-button @click="handleResetFilters">重置</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<div class="mix-task-list__content">
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<template v-if="taskList.length > 0">
|
||||
<div class="task-list">
|
||||
<div
|
||||
v-for="task in taskList"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
>
|
||||
<div class="task-item__header">
|
||||
<div class="task-item__title">
|
||||
<h3>{{ task.title }}</h3>
|
||||
<a-tag :color="getStatusColor(task.status)">
|
||||
{{ getStatusText(task.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item__content">
|
||||
<div class="task-item__progress">
|
||||
<div class="progress-info">
|
||||
<span>进度:{{ task.progress }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="task.progress"
|
||||
:status="getProgressStatus(task.status)"
|
||||
:show-info="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="task-item__meta">
|
||||
<span>创建时间:{{ formatDate(task.createTime) }}</span>
|
||||
<span v-if="task.finishTime">
|
||||
完成时间:{{ formatDate(task.finishTime) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="task-item__text" v-if="task.text">
|
||||
<p>{{ task.text }}</p>
|
||||
</div>
|
||||
|
||||
<div class="task-item__results" v-if="task.outputUrls && task.outputUrls.length > 0">
|
||||
<h4>生成结果 ({{ task.outputUrls.length }}):</h4>
|
||||
<div class="result-list">
|
||||
<div
|
||||
v-for="(url, index) in task.outputUrls"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
>
|
||||
<a :href="url" target="_blank">
|
||||
<PlayCircleOutlined />
|
||||
视频 {{ index + 1 }}
|
||||
</a>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDownload(url)"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item__error" v-if="task.errorMsg">
|
||||
<a-alert
|
||||
type="error"
|
||||
:message="task.errorMsg"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-empty description="暂无混剪任务" />
|
||||
</template>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mix-task-list__pagination">
|
||||
<a-pagination
|
||||
v-model:current="pagination.pageNo"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
:show-size-changer="true"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
PlayCircleOutlined,
|
||||
DownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MixTaskService } from '@/api/mixTask'
|
||||
import { formatDate } from '@/utils/file'
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const taskList = ref([])
|
||||
const timer = ref(null) // 定时器
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
title: '',
|
||||
createTime: undefined
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 构建查询参数
|
||||
const buildQueryParams = () => {
|
||||
const params = {
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize,
|
||||
status: filters.status || undefined,
|
||||
title: filters.title || undefined
|
||||
}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
|
||||
params.createTimeStart = `${filters.createTime[0]} 00:00:00`
|
||||
params.createTimeEnd = `${filters.createTime[1]} 23:59:59`
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// 加载任务列表
|
||||
const loadTaskList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await MixTaskService.getTaskPage(buildQueryParams())
|
||||
if (res.code === 0) {
|
||||
taskList.value = res.data.list || []
|
||||
pagination.total = res.data.total || 0
|
||||
} else {
|
||||
message.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败:', error)
|
||||
message.error('加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选
|
||||
const handleFilterChange = () => {
|
||||
pagination.pageNo = 1
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
filters.status = ''
|
||||
filters.title = ''
|
||||
filters.createTime = undefined
|
||||
pagination.pageNo = 1
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = (page, pageSize) => {
|
||||
pagination.pageNo = page
|
||||
if (pageSize && pageSize !== pagination.pageSize) {
|
||||
pagination.pageSize = pageSize
|
||||
pagination.pageNo = 1
|
||||
}
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const handleRetry = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认重新生成',
|
||||
content: '确定要重新生成这个任务吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await MixTaskService.retryTask(id)
|
||||
message.success('已重新提交任务')
|
||||
loadTaskList()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
const handleCancel = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确定要取消这个任务吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await MixTaskService.cancelTask(id)
|
||||
message.success('已取消任务')
|
||||
loadTaskList()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个任务吗?删除后无法恢复。',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await MixTaskService.deleteTask(id)
|
||||
message.success('删除成功')
|
||||
loadTaskList()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下载单个视频
|
||||
const handleDownload = (url) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'video'
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 批量下载所有视频
|
||||
const handleDownloadAll = (urls) => {
|
||||
if (!urls || urls.length === 0) {
|
||||
message.warning('没有可下载的视频')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在准备下载...', 0)
|
||||
|
||||
// 逐个触发下载,避免浏览器阻止多个弹窗
|
||||
urls.forEach((url, index) => {
|
||||
setTimeout(() => {
|
||||
handleDownload(url)
|
||||
}, index * 500) // 每个下载间隔500ms
|
||||
})
|
||||
|
||||
message.destroy()
|
||||
message.success(`已触发 ${urls.length} 个视频的下载`)
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
pending: '待处理',
|
||||
running: '处理中',
|
||||
success: '已完成',
|
||||
failed: '失败'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取进度条状态
|
||||
const getProgressStatus = (status) => {
|
||||
const statusMap = {
|
||||
pending: 'normal',
|
||||
running: 'active',
|
||||
success: 'success',
|
||||
failed: 'exception'
|
||||
}
|
||||
return statusMap[status] || 'normal'
|
||||
}
|
||||
|
||||
// 定时刷新
|
||||
const startAutoRefresh = () => {
|
||||
// 每5秒刷新一次
|
||||
timer.value = setInterval(() => {
|
||||
loadTaskList()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadTaskList()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mix-task-list {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mix-task-list__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mix-task-list__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mix-task-list__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mix-task-list__filters {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.mix-task-list__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.task-item__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.task-item__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item__title h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-item__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-item__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.task-item__meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.task-item__text {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-item__text p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-item__results h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mix-task-list__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.tik.media;
|
||||
|
||||
import cn.iocoder.yudao.module.infra.api.file.FileApi;
|
||||
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
@@ -34,6 +35,7 @@ import java.util.*;
|
||||
public class BatchProduceAlignment {
|
||||
|
||||
private final IceProperties properties;
|
||||
private final FileApi fileApi;
|
||||
private Client iceClient;
|
||||
|
||||
public void initClient() throws Exception {
|
||||
@@ -105,8 +107,9 @@ public class BatchProduceAlignment {
|
||||
initClient();
|
||||
}
|
||||
|
||||
// 纯画面模式:仅拼接视频片段,添加空的音频轨道
|
||||
// 纯画面模式:仅拼接视频片段,为每个视频添加静音音频轨道
|
||||
JSONArray videoClipArray = new JSONArray();
|
||||
JSONArray audioClipArray = new JSONArray();
|
||||
|
||||
// 按顺序拼接视频片段(不随机打乱)
|
||||
for (int i = 0; i < videoArray.length; i++) {
|
||||
@@ -120,21 +123,39 @@ public class BatchProduceAlignment {
|
||||
}
|
||||
|
||||
log.debug("[ICE][添加视频片段][{}: {}]", i + 1, videoUrl);
|
||||
// 每个视频片段添加静音效果(Volume: 0)
|
||||
String videoClip = "{\"MediaURL\":\""+videoUrl+"\",\"ReferenceClipId\":\""+clipId+"\",\"Volume\":0,\"Effects\":[{\"Type\":\"Background\",\"SubType\":\"Blur\",\"Radius\":0.1}]}";
|
||||
// 使用标准的 MediaURL 参数(符合 ICE API 文档规范)
|
||||
String videoClip = "{\"MediaURL\":\"" + videoUrl + "\"}";
|
||||
videoClipArray.add(JSONObject.parseObject(videoClip));
|
||||
|
||||
// 为每个视频片段添加静音的音频轨道
|
||||
JSONObject audioClip = new JSONObject();
|
||||
audioClip.put("MediaURL", videoUrl);
|
||||
// 添加静音效果
|
||||
JSONObject volumeEffect = new JSONObject();
|
||||
volumeEffect.put("Type", "Volume");
|
||||
volumeEffect.put("Gain", 0); // 0 表示静音
|
||||
JSONArray effects = new JSONArray();
|
||||
effects.add(volumeEffect);
|
||||
audioClip.put("Effects", effects);
|
||||
audioClipArray.add(audioClip);
|
||||
}
|
||||
|
||||
// 添加空的音频轨道(避免ICE报错)
|
||||
JSONArray emptyAudioClipArray = new JSONArray();
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+emptyAudioClipArray.toJSONString()+"}]}";
|
||||
// 构建时间线,包含视频轨道和音频轨道
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"}]}";
|
||||
|
||||
//
|
||||
String targetFileName = UUID.randomUUID().toString().replace("-", "");
|
||||
String outputMediaUrl = "http://" + properties.getBucket() + ".oss-" + properties.getRegionId() + ".aliyuncs.com/ice_output/" + targetFileName + ".mp4";
|
||||
String bucketEndpoint = "https://" + properties.getBucket() + ".oss-" + properties.getRegionId() + ".aliyuncs.com";
|
||||
String outputMediaPath = "/ice_output/" + targetFileName + ".mp4";
|
||||
String outputMediaUrl = bucketEndpoint + outputMediaPath;
|
||||
|
||||
// 生成签名URL(有效期24小时)使用公共API
|
||||
String signedUrl = fileApi.presignGetUrl(outputMediaUrl, 24 * 3600);
|
||||
|
||||
int width = 720;
|
||||
int height = 1280;
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":"+width+",\"Height\":"+height+"}";
|
||||
int bitrate = 2000; // 输出码率 2000 Kbit/s(符合 ICE API 文档推荐)
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + signedUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.iocoder.yudao.module.tik.mix.config;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 混剪任务设计优化
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
public class MixTaskDataOptimization implements CommandLineRunner {
|
||||
|
||||
@Override
|
||||
public void run(String... args) throws Exception {
|
||||
// 统一通过 setOutputUrlList/getOutputUrlList 方法访问
|
||||
// 禁止直接操作 outputUrls 字段
|
||||
// 设计原则:封装优于修复
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package cn.iocoder.yudao.module.tik.mix.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@@ -43,13 +41,13 @@ public class MixTaskDO extends TenantBaseDO {
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* 视频素材URL列表(JSON)
|
||||
* 视频素材URL列表(逗号分隔)
|
||||
*/
|
||||
@TableField("video_urls")
|
||||
private String videoUrls;
|
||||
|
||||
/**
|
||||
* 背景音乐URL列表(JSON)
|
||||
* 背景音乐URL列表(逗号分隔)
|
||||
*/
|
||||
@TableField("bg_music_urls")
|
||||
private String bgMusicUrls;
|
||||
@@ -61,13 +59,13 @@ public class MixTaskDO extends TenantBaseDO {
|
||||
private Integer produceCount;
|
||||
|
||||
/**
|
||||
* 任务ID列表(JSON)
|
||||
* 任务ID列表(逗号分隔)
|
||||
*/
|
||||
@TableField("job_ids")
|
||||
private String jobIds;
|
||||
|
||||
/**
|
||||
* 输出文件URL列表(JSON)
|
||||
* 输出文件URL列表(逗号分隔)
|
||||
*/
|
||||
@TableField("output_urls")
|
||||
private String outputUrls;
|
||||
@@ -101,55 +99,67 @@ public class MixTaskDO extends TenantBaseDO {
|
||||
* 获取视频URL列表
|
||||
*/
|
||||
public List<String> getVideoUrlList() {
|
||||
return JsonUtils.parseObject(videoUrls, new TypeReference<List<String>>() {});
|
||||
if (videoUrls == null || videoUrls.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return List.of(videoUrls.split(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视频URL列表
|
||||
*/
|
||||
public void setVideoUrlList(List<String> videoUrls) {
|
||||
this.videoUrls = JsonUtils.toJsonString(videoUrls);
|
||||
this.videoUrls = videoUrls == null || videoUrls.isEmpty() ? null : String.join(",", videoUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取背景音乐URL列表
|
||||
*/
|
||||
public List<String> getBgMusicUrlList() {
|
||||
return JsonUtils.parseObject(bgMusicUrls, new TypeReference<List<String>>() {});
|
||||
if (bgMusicUrls == null || bgMusicUrls.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return List.of(bgMusicUrls.split(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置背景音乐URL列表
|
||||
*/
|
||||
public void setBgMusicUrlList(List<String> bgMusicUrls) {
|
||||
this.bgMusicUrls = JsonUtils.toJsonString(bgMusicUrls);
|
||||
this.bgMusicUrls = bgMusicUrls == null || bgMusicUrls.isEmpty() ? null : String.join(",", bgMusicUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务ID列表
|
||||
*/
|
||||
public List<String> getJobIdList() {
|
||||
return JsonUtils.parseObject(jobIds, new TypeReference<List<String>>() {});
|
||||
if (jobIds == null || jobIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return List.of(jobIds.split(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务ID列表
|
||||
*/
|
||||
public void setJobIdList(List<String> jobIds) {
|
||||
this.jobIds = JsonUtils.toJsonString(jobIds);
|
||||
this.jobIds = jobIds == null || jobIds.isEmpty() ? null : String.join(",", jobIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输出URL列表
|
||||
*/
|
||||
public List<String> getOutputUrlList() {
|
||||
return JsonUtils.parseObject(outputUrls, new TypeReference<List<String>>() {});
|
||||
if (outputUrls == null || outputUrls.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return List.of(outputUrls.split(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置输出URL列表
|
||||
*/
|
||||
public void setOutputUrlList(List<String> outputUrls) {
|
||||
this.outputUrls = JsonUtils.toJsonString(outputUrls);
|
||||
this.outputUrls = outputUrls == null || outputUrls.isEmpty() ? null : String.join(",", outputUrls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 混剪任务 Service 实现
|
||||
@@ -36,21 +39,21 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createMixTask(MixTaskSaveReqVO createReqVO, Long userId) {
|
||||
log.info("开始创建混剪任务,用户ID: {}, 标题: {}", userId, createReqVO.getTitle());
|
||||
log.info("[MixTask][创建任务] userId={}, title={}, videoCount={}, produceCount={}",
|
||||
userId, createReqVO.getTitle(), createReqVO.getVideoUrls().size(), createReqVO.getProduceCount());
|
||||
|
||||
// 1. 创建初始任务对象
|
||||
MixTaskDO task = MixTaskUtils.createInitialTask(createReqVO, userId);
|
||||
|
||||
// 2. 保存到数据库
|
||||
mixTaskMapper.insert(task);
|
||||
log.info("任务已创建,任务ID: {}", task.getId());
|
||||
|
||||
// 3. 异步提交到阿里云 ICE
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
submitToICE(task.getId(), createReqVO);
|
||||
} catch (Exception e) {
|
||||
log.error("提交任务到ICE失败,任务ID: {}", task.getId(), e);
|
||||
log.error("[MixTask][提交ICE失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
|
||||
updateTaskError(task.getId(), "提交任务失败: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
@@ -61,12 +64,10 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateMixTask(MixTaskUpdateReqVO updateReqVO) {
|
||||
log.info("更新混剪任务,任务ID: {}", updateReqVO.getId());
|
||||
|
||||
// 1. 检查任务是否存在
|
||||
MixTaskDO existTask = mixTaskMapper.selectById(updateReqVO.getId());
|
||||
if (existTask == null) {
|
||||
log.error("任务不存在,任务ID: {}", updateReqVO.getId());
|
||||
log.warn("[MixTask][任务不存在] taskId={}", updateReqVO.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,68 +76,58 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
|
||||
// 3. 执行更新
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
log.info("任务更新成功,任务ID: {}", updateReqVO.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteMixTask(Long id) {
|
||||
log.info("删除混剪任务,任务ID: {}", id);
|
||||
|
||||
// 1. 检查任务是否存在
|
||||
MixTaskDO existTask = mixTaskMapper.selectById(id);
|
||||
if (existTask == null) {
|
||||
log.error("任务不存在,任务ID: {}", id);
|
||||
log.warn("[MixTask][任务不存在] taskId={}", id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行删除
|
||||
mixTaskMapper.deleteById(id);
|
||||
log.info("任务删除成功,任务ID: {}", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MixTaskRespVO getMixTask(Long id) {
|
||||
log.debug("查询混剪任务,任务ID: {}", id);
|
||||
|
||||
MixTaskDO task = mixTaskMapper.selectById(id);
|
||||
return BeanUtils.toBean(task, MixTaskRespVO.class);
|
||||
MixTaskRespVO respVO = BeanUtils.toBean(task, MixTaskRespVO.class);
|
||||
// 手动设置 outputUrls,因为 DO 中是逗号分隔字符串,VO 中是 List
|
||||
if (respVO != null) {
|
||||
respVO.setOutputUrls(task.getOutputUrlList());
|
||||
}
|
||||
return respVO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<MixTaskRespVO> getMixTaskPage(MixTaskPageReqVO pageReqVO, Long userId) {
|
||||
log.debug("分页查询混剪任务,用户ID: {}, 页码: {}, 页大小: {}",
|
||||
userId, pageReqVO.getPageNo(), pageReqVO.getPageSize());
|
||||
|
||||
PageResult<MixTaskDO> pageResult = mixTaskMapper.selectPage(pageReqVO);
|
||||
return BeanUtils.toBean(pageResult, MixTaskRespVO.class);
|
||||
return convertToRespVO(pageResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<MixTaskRespVO> getUserMixTaskPage(MixTaskPageReqVO pageReqVO, Long userId) {
|
||||
log.debug("分页查询用户混剪任务,用户ID: {}, 页码: {}, 页大小: {}",
|
||||
userId, pageReqVO.getPageNo(), pageReqVO.getPageSize());
|
||||
|
||||
// 使用用户ID过滤查询
|
||||
PageResult<MixTaskDO> pageResult = mixTaskMapper.selectPageByUserId(pageReqVO, userId);
|
||||
return BeanUtils.toBean(pageResult, MixTaskRespVO.class);
|
||||
return convertToRespVO(pageResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MixTaskRespVO getTaskStatus(Long id) {
|
||||
log.debug("查询任务状态,任务ID: {}", id);
|
||||
return getMixTask(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void retryTask(Long id) {
|
||||
log.info("重新生成失败任务,任务ID: {}", id);
|
||||
|
||||
// 1. 查询原任务
|
||||
MixTaskDO existTask = mixTaskMapper.selectById(id);
|
||||
if (existTask == null) {
|
||||
log.error("任务不存在,任务ID: {}", id);
|
||||
log.warn("[MixTask][任务不存在] taskId={}", id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,8 +137,8 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
updateTask.setStatus(MixTaskConstants.STATUS_PENDING);
|
||||
updateTask.setProgress(0);
|
||||
updateTask.setErrorMsg(null);
|
||||
updateTask.setJobIds(null);
|
||||
updateTask.setOutputUrls(null);
|
||||
updateTask.setJobIdList(null);
|
||||
updateTask.setOutputUrlList(null);
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
|
||||
// 3. 重新提交到ICE
|
||||
@@ -160,29 +151,26 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
saveReqVO.setProduceCount(existTask.getProduceCount());
|
||||
submitToICE(id, saveReqVO);
|
||||
} catch (Exception e) {
|
||||
log.error("重新提交任务失败,任务ID: {}", id, e);
|
||||
log.error("[MixTask][重新提交失败] taskId={}, error={}", id, e.getMessage(), e);
|
||||
updateTaskError(id, "重新提交失败: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
log.info("任务重新提交成功,任务ID: {}", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cancelTask(Long id) {
|
||||
log.info("取消任务,任务ID: {}", id);
|
||||
|
||||
// 1. 查询任务
|
||||
MixTaskDO existTask = mixTaskMapper.selectById(id);
|
||||
if (existTask == null) {
|
||||
log.error("任务不存在,任务ID: {}", id);
|
||||
log.warn("[MixTask][任务不存在] taskId={}", id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查任务状态
|
||||
if (!MixTaskConstants.STATUS_RUNNING.equals(existTask.getStatus())) {
|
||||
log.warn("任务非运行状态,无法取消,任务ID: {}, 状态: {}", id, existTask.getStatus());
|
||||
log.warn("[MixTask][任务状态不允许取消] taskId={}, currentStatus={}, requiredStatus={}",
|
||||
id, existTask.getStatus(), MixTaskConstants.STATUS_RUNNING);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,14 +183,10 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
|
||||
updateTask.setErrorMsg("用户主动取消任务");
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
|
||||
log.info("任务取消成功,任务ID: {}", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkTaskStatusBatch() {
|
||||
log.debug("开始批量检查任务状态");
|
||||
|
||||
// 性能优化点:
|
||||
// 1. 时间范围限制:只检查最近6小时内的任务(避免检查历史任务)
|
||||
// 2. 数量限制:每次最多检查50个任务(避免单次查询过多)
|
||||
@@ -220,14 +204,11 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
);
|
||||
|
||||
if (runningTasks.isEmpty()) {
|
||||
log.debug("没有最近{}小时内运行中的任务,跳过检查", MixTaskConstants.CHECK_HOURS_LIMIT);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("发现 {} 个最近{}小时内的运行中任务,开始检查状态(最多检查{}个)",
|
||||
runningTasks.size(), MixTaskConstants.CHECK_HOURS_LIMIT, MixTaskConstants.CHECK_BATCH_SIZE);
|
||||
|
||||
// 逐个检查任务状态
|
||||
int failureCount = 0;
|
||||
for (MixTaskDO task : runningTasks) {
|
||||
try {
|
||||
List<String> jobIds = task.getJobIdList();
|
||||
@@ -237,18 +218,15 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
syncTaskStatus(task.getId(), jobId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("检查任务状态失败,任务ID: {}", task.getId(), e);
|
||||
log.error("[MixTask][单个任务检查失败] taskId={}, error={}", task.getId(), e.getMessage(), e);
|
||||
failureCount++;
|
||||
// 单个任务失败不影响其他任务
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("批量检查任务状态完成");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncTaskStatus(Long taskId, String jobId) {
|
||||
log.debug("同步任务状态,任务ID: {}, jobId: {}", taskId, jobId);
|
||||
|
||||
try {
|
||||
// 检查任务是否超时(超过12小时则标记为失败)
|
||||
MixTaskDO task = mixTaskMapper.selectById(taskId);
|
||||
@@ -257,7 +235,7 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
if (createTime != null) {
|
||||
long hoursPassed = ChronoUnit.HOURS.between(createTime, LocalDateTime.now());
|
||||
if (hoursPassed > 12) {
|
||||
log.warn("[ICE][任务超时,自动标记为失败][taskId={}, 已运行{}小时]", taskId, hoursPassed);
|
||||
log.warn("[MixTask][任务超时自动失败] taskId={}, runHours={}, threshold=12", taskId, hoursPassed);
|
||||
updateTaskError(taskId, "任务执行超时(超过12小时)");
|
||||
return;
|
||||
}
|
||||
@@ -266,12 +244,10 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
|
||||
// 调用阿里云 ICE API 查询任务状态
|
||||
String status = iceClient.getMediaProducingJobStatus(jobId);
|
||||
log.debug("[ICE][查询到任务状态][taskId={}, jobId={}, status={}]", taskId, jobId, status);
|
||||
|
||||
// 根据ICE状态更新任务
|
||||
if ("Success".equalsIgnoreCase(status) || "success".equalsIgnoreCase(status)) {
|
||||
// 任务成功完成,更新为100%
|
||||
log.info("[ICE][任务执行成功][taskId={}, jobId={}]", taskId, jobId);
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_SUCCESS, MixTaskConstants.PROGRESS_COMPLETED);
|
||||
} else if ("Failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(status) || "Failure".equalsIgnoreCase(status)) {
|
||||
// 任务失败 - 获取详细错误信息
|
||||
@@ -280,25 +256,24 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
// 尝试获取更详细的失败信息(如果ICE API支持)
|
||||
errorMsg = "ICE任务执行失败,状态: " + status;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[ICE][获取详细失败信息失败][taskId={}]", taskId, ex);
|
||||
log.warn("[MixTask][获取详细失败信息失败] taskId={}", taskId, ex);
|
||||
}
|
||||
log.error("[ICE][任务执行失败][taskId={}, jobId={}, status={}]", taskId, jobId, status);
|
||||
log.error("[MixTask][ICE任务执行失败] taskId={}, jobId={}, iceStatus={}, errorMsg={}",
|
||||
taskId, jobId, status, errorMsg);
|
||||
updateTaskError(taskId, errorMsg);
|
||||
} else if ("Running".equalsIgnoreCase(status) || "running".equalsIgnoreCase(status) || "Processing".equalsIgnoreCase(status)) {
|
||||
// 任务仍在运行,更新进度为70%
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 70);
|
||||
log.debug("[ICE][任务执行中][taskId={}, jobId={}, progress=70%]", taskId, jobId);
|
||||
} else if ("Pending".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status)) {
|
||||
// 任务等待中,更新进度为60%
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 60);
|
||||
log.debug("[ICE][任务等待中][taskId={}, jobId={}, progress=60%]", taskId, jobId);
|
||||
} else {
|
||||
// 未知状态,记录日志但不更新
|
||||
log.warn("[ICE][未知任务状态][taskId={}, jobId={}, status={}]", taskId, jobId, status);
|
||||
log.warn("[MixTask][未知ICE状态] taskId={}, jobId={}, iceStatus={}", taskId, jobId, status);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步任务状态失败,任务ID: {}, jobId: {}", taskId, jobId, e);
|
||||
log.error("[MixTask][状态同步异常] taskId={}, jobId={}, error={}", taskId, jobId, e.getMessage(), e);
|
||||
updateTaskError(taskId, "查询任务状态失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -306,8 +281,6 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void saveTaskResult(Long taskId, List<String> outputUrls) {
|
||||
log.info("保存任务结果,任务ID: {}, 结果数量: {}", taskId, outputUrls.size());
|
||||
|
||||
// 1. 更新任务输出URL
|
||||
MixTaskDO updateTask = new MixTaskDO();
|
||||
updateTask.setId(taskId);
|
||||
@@ -316,16 +289,12 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
|
||||
updateTask.setFinishTime(java.time.LocalDateTime.now());
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
|
||||
log.info("任务结果保存成功,任务ID: {}", taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交任务到阿里云 ICE
|
||||
*/
|
||||
private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO) {
|
||||
log.info("提交任务到ICE,任务ID: {}", taskId);
|
||||
|
||||
try {
|
||||
// 1. 更新任务状态为运行中,进度10%
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
|
||||
@@ -347,10 +316,8 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
updateTaskWithResults(taskId, jobIdUrlPair.getJobIds(), jobIdUrlPair.getOutputUrls(),
|
||||
MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_UPLOADED);
|
||||
|
||||
log.info("任务提交到ICE成功,任务ID: {}, jobId数量: {}", taskId, jobIdUrlPair.getJobIds().size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("提交任务到ICE失败,任务ID: {}", taskId, e);
|
||||
log.error("[MixTask][ICE提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
|
||||
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
|
||||
// 注意:异步线程中不抛出异常,避免未处理异常
|
||||
}
|
||||
@@ -360,8 +327,6 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
* 提交任务到阿里云 ICE(新版本,使用 IceClient)
|
||||
*/
|
||||
private void submitToIceV2(Long taskId, MixTaskSaveReqVO createReqVO) {
|
||||
log.info("[V2][提交任务到ICE][taskId={}]", taskId);
|
||||
|
||||
try {
|
||||
// 1. 更新任务状态
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_SUBMITTED);
|
||||
@@ -374,10 +339,8 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
// TODO: 这里需要将 BatchProduceAlignment 的逻辑移到 IceClient 中
|
||||
// String jobId = iceClient.submitMediaProducingJob(timeline, outputMediaConfig);
|
||||
|
||||
log.info("[V2][任务提交成功][taskId={}]", taskId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[V2][提交任务失败][taskId={}]", taskId, e);
|
||||
log.error("[MixTask][V2提交失败] taskId={}, error={}", taskId, e.getMessage(), e);
|
||||
updateTaskError(taskId, "ICE处理失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -434,7 +397,29 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
updateTask.setErrorMsg(errorMsg);
|
||||
updateTask.setFinishTime(java.time.LocalDateTime.now());
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
}
|
||||
|
||||
log.error("任务执行失败,任务ID: {}, 错误信息: {}", taskId, errorMsg);
|
||||
/**
|
||||
* 将DO分页结果转换为VO分页结果,消除冗余代码
|
||||
* 优化点:
|
||||
* 1. 使用Map查找替代O(n)流操作,提升性能
|
||||
* 2. 统一outputUrls转换逻辑,避免代码重复
|
||||
*/
|
||||
private PageResult<MixTaskRespVO> convertToRespVO(PageResult<MixTaskDO> pageResult) {
|
||||
if (pageResult == null || pageResult.getList().isEmpty()) {
|
||||
return PageResult.empty(pageResult.getTotal());
|
||||
}
|
||||
|
||||
// 创建Map用于高效查找(O(1)复杂度)替代流操作的O(n)复杂度
|
||||
Map<Long, MixTaskDO> taskMap = pageResult.getList().stream()
|
||||
.collect(Collectors.toMap(MixTaskDO::getId, Function.identity()));
|
||||
|
||||
return BeanUtils.toBean(pageResult, MixTaskRespVO.class, vo -> {
|
||||
MixTaskDO task = taskMap.get(vo.getId());
|
||||
if (task != null) {
|
||||
// 手动设置 outputUrls,因为 DO 中是逗号分隔字符串,VO 中是 List
|
||||
vo.setOutputUrls(task.getOutputUrlList());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user