feat: 前端优化
This commit is contained in:
@@ -16,6 +16,8 @@ function saveTokens(info) {
|
||||
tokenManager.setTokens({
|
||||
accessToken: info.accessToken || '',
|
||||
refreshToken: info.refreshToken || '',
|
||||
expiresIn: info.expiresTime || 7200, // expiresTime 是秒数
|
||||
tokenType: info.tokenType || 'Bearer'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,6 +65,15 @@ export async function loginByPassword(mobile, password) {
|
||||
const { data } = await api.post(`${SERVER_BASE}/auth/login`, { mobile, password });
|
||||
const info = data || {};
|
||||
saveTokens(info);
|
||||
|
||||
// 清除用户信息缓存,确保登录后获取最新信息
|
||||
try {
|
||||
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
|
||||
clearUserInfoCache()
|
||||
} catch (e) {
|
||||
console.error('清除用户信息缓存失败:', e)
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -109,6 +120,15 @@ export async function loginBySms(mobile, code) {
|
||||
const { data } = await api.post(`${SERVER_BASE}/auth/sms-login`, { mobile, code });
|
||||
const info = data || {};
|
||||
saveTokens(info);
|
||||
|
||||
// 清除用户信息缓存,确保登录后获取最新信息
|
||||
try {
|
||||
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
|
||||
clearUserInfoCache()
|
||||
} catch (e) {
|
||||
console.error('清除用户信息缓存失败:', e)
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -125,6 +145,15 @@ export async function refreshToken() {
|
||||
const { data } = await api.post(`${SERVER_BASE}/auth/refresh-token`, null, { params: { refreshToken: rt } });
|
||||
const info = data || {};
|
||||
saveTokens(info);
|
||||
|
||||
// 清除用户信息缓存,因为 token 刷新后用户信息可能已更新
|
||||
try {
|
||||
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
|
||||
clearUserInfoCache()
|
||||
} catch (e) {
|
||||
console.error('清除用户信息缓存失败:', e)
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,3 +75,13 @@ export function deleteTask(taskId) {
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务输出文件的签名URL
|
||||
*/
|
||||
export function getSignedUrls(taskId) {
|
||||
return request({
|
||||
url: `/webApi/api/tik/digital-human/task/${taskId}/signed-url`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createHttpClient(options = {}) {
|
||||
const httpClient = createClientAxios({
|
||||
baseURL: '/',
|
||||
timeout: 180000,
|
||||
refreshTokenFn: refreshToken, // 传递刷新函数给拦截器
|
||||
on401: async (error) => {
|
||||
// 401:优先使用上层自定义处理
|
||||
if (on401) {
|
||||
|
||||
@@ -26,41 +26,40 @@ const items = computed(() => {
|
||||
{
|
||||
title: '功能',
|
||||
children: [
|
||||
// { path: '/home', label: '首页', icon: 'home' },
|
||||
{ path: '/content-style/benchmark', label: '对标分析', icon: 'grid' },
|
||||
{ path: '/content-style/copywriting', label: '文案创作', icon: 'text' },
|
||||
{ path: '/trends/forecast', label: '热点趋势', icon: 'text' },
|
||||
// { name: '首页', label: '首页', icon: 'home' },
|
||||
{ name: '对标分析', label: '对标分析', icon: 'grid' },
|
||||
{ name: '文案创作', label: '文案创作', icon: 'text' },
|
||||
{ name: '热点预测', label: '热点趋势', icon: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '数字人',
|
||||
children: [
|
||||
{ path: '/digital-human/voice-copy', label: '人声克隆', icon: 'mic' },
|
||||
{ path: "/digital-human/kling", label: "可灵数字人", icon: "user" },
|
||||
// { path: '/digital-human/video', label: '数字人视频', icon: 'video' },
|
||||
{ name: '人声克隆', label: '人声克隆', icon: 'mic' },
|
||||
{ name: '可灵数字人', label: "可灵数字人", icon: "user" },
|
||||
// { name: '数字人视频', label: '数字人视频', icon: 'video' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '素材库',
|
||||
children: [
|
||||
{ path: '/material/list', label: '素材列表', icon: 'grid' },
|
||||
{ path: '/material/mix', label: '智能混剪', icon: 'scissors' },
|
||||
{ path: '/material/mix-task', label: '混剪任务', icon: 'video' },
|
||||
{ path: '/material/group', label: '素材分组', icon: 'folder' },
|
||||
{ name: '素材列表', label: '素材列表', icon: 'grid' },
|
||||
{ name: '智能混剪', label: '智能混剪', icon: 'scissors' },
|
||||
{ name: '素材分组', label: '素材分组', icon: 'folder' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '任务管理',
|
||||
children: [
|
||||
{ name: '任务中心', label: '任务中心', icon: 'video', params: { type: 'mix-task' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
children: [
|
||||
{ path: '/system/style-settings', label: '风格设置', icon: 'text' },
|
||||
{ name: '风格设置', label: '风格设置', icon: 'text' },
|
||||
]
|
||||
},
|
||||
// {
|
||||
// title: '视频',
|
||||
// children: [
|
||||
// { path: '/digital-human/avatar', label: '生成数字人', icon: 'user' },
|
||||
// ]
|
||||
// },
|
||||
}
|
||||
]
|
||||
|
||||
// 如果未登录,过滤掉"系统"菜单组
|
||||
@@ -71,8 +70,12 @@ const items = computed(() => {
|
||||
return allItems
|
||||
})
|
||||
|
||||
function go(p) {
|
||||
router.push(p)
|
||||
function go(item) {
|
||||
if (item.params) {
|
||||
router.push({ name: item.name, params: item.params })
|
||||
} else {
|
||||
router.push({ name: item.name })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,7 +84,7 @@ function go(p) {
|
||||
<nav class="sidebar__nav">
|
||||
<div v-for="group in items" :key="group.title" class="nav-group">
|
||||
<div class="nav-group__title">{{ group.title }}</div>
|
||||
<button v-for="it in group.children" :key="it.path" class="nav-item" :class="{ 'is-active': route.path === it.path }" @click="go(it.path)">
|
||||
<button v-for="it in group.children" :key="it.name" class="nav-item" :class="{ 'is-active': route.name === it.name }" @click="go(it)">
|
||||
<span class="nav-item__icon" aria-hidden="true" v-html="icons[it.icon]"></span>
|
||||
<span class="nav-item__label">{{ it.label }}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const showDropdown = ref(false)
|
||||
|
||||
@@ -61,6 +63,17 @@ function handleMouseEnter() {
|
||||
function handleMouseLeave() {
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
// 处理退出登录
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await userStore.logout()
|
||||
// 跳转到登录页
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,7 +204,7 @@ function handleMouseLeave() {
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<div class="dropdown-footer">
|
||||
<button class="action-btn" @click="userStore.logout">
|
||||
<button class="action-btn" @click="handleLogout">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M5 2H2C1.44772 2 1 2.44772 1 3V11C1 11.5523 1.44772 12 2 12H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M9 10L13 7L9 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
@@ -66,6 +66,11 @@ const routes = [
|
||||
children: [
|
||||
{ path: '', redirect: '/system/style-settings' },
|
||||
{ path: 'style-settings', name: '风格设置', component: () => import('../views/system/StyleSettings.vue') },
|
||||
{
|
||||
path: 'task-management/:type',
|
||||
name: '任务中心',
|
||||
component: () => import('../views/system/task-management/layout/TaskLayout.vue')
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
@@ -81,6 +86,11 @@ const routes = [
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 向后兼容:重定向旧路径到新路径
|
||||
{
|
||||
path: '/material/mix-task',
|
||||
redirect: '/system/task-management/mix-task'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -149,7 +149,15 @@ export const useUserStore = defineStore('user', () => {
|
||||
console.error('清空 token 失败:', e)
|
||||
}
|
||||
|
||||
// 2. 清空用户信息
|
||||
// 2. 清空用户信息缓存
|
||||
try {
|
||||
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
|
||||
clearUserInfoCache()
|
||||
} catch (e) {
|
||||
console.error('清除用户信息缓存失败:', e)
|
||||
}
|
||||
|
||||
// 3. 清空用户信息
|
||||
isLoggedIn.value = false
|
||||
userId.value = ''
|
||||
nickname.value = ''
|
||||
@@ -162,7 +170,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
vipLevel.value = 0
|
||||
credits.value = 0
|
||||
|
||||
// 3. 删除本地存储的用户数据
|
||||
// 4. 删除本地存储的用户数据
|
||||
await remove(STORAGE_KEY)
|
||||
}
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ onMounted(() => {
|
||||
.table-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table-description {
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
|
||||
<!-- 生成数量 -->
|
||||
<a-form-item label="生成数量">
|
||||
<a-radio-group v-model:value="formData.produceCount" button-style="solid">
|
||||
<a-radio-group v-model:value="formData.produceCount" button-style="solid" @change="saveProduceCount">
|
||||
<a-radio-button :value="1">1个</a-radio-button>
|
||||
<a-radio-button :value="2">2个</a-radio-button>
|
||||
<a-radio-button :value="3">3个</a-radio-button>
|
||||
<a-radio-button :value="5">5个</a-radio-button>
|
||||
<a-radio-button :value="10">10个</a-radio-button>
|
||||
<a-radio-button :value="15">15个</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
@@ -270,12 +272,26 @@ const router = useRouter()
|
||||
const formData = ref({
|
||||
groupId: null,
|
||||
title: '',
|
||||
produceCount: 3,
|
||||
produceCount: loadProduceCount(),
|
||||
totalDuration: 15, // 成品总时长 15-30s
|
||||
clipDuration: 3, // 单切片时长 3-5s
|
||||
cropMode: 'center' // 裁剪模式,默认居中裁剪
|
||||
})
|
||||
|
||||
// 本地存储键名
|
||||
const STORAGE_KEY = 'mix-produce-count'
|
||||
|
||||
// 从本地存储加载生成数量
|
||||
function loadProduceCount() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
return saved ? parseInt(saved, 10) : 3
|
||||
}
|
||||
|
||||
// 保存生成数量到本地存储
|
||||
function saveProduceCount() {
|
||||
localStorage.setItem(STORAGE_KEY, formData.value.produceCount.toString())
|
||||
}
|
||||
|
||||
// 状态
|
||||
const loadingGroups = ref(false)
|
||||
const loadingFiles = ref(false)
|
||||
@@ -512,7 +528,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
&__params {
|
||||
width: 320px;
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.ant-card {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<a-space>
|
||||
<!-- 预览按钮 -->
|
||||
<a-button
|
||||
v-if="canPreview"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePreview"
|
||||
>
|
||||
<template #icon>
|
||||
<EyeOutlined />
|
||||
</template>
|
||||
预览
|
||||
</a-button>
|
||||
|
||||
<!-- 下载按钮 -->
|
||||
<a-button
|
||||
v-if="canDownload"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
下载
|
||||
</a-button>
|
||||
|
||||
<!-- 取消按钮 -->
|
||||
<a-button
|
||||
v-if="canCancel"
|
||||
size="small"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</a-button>
|
||||
|
||||
<!-- 重试按钮 -->
|
||||
<a-button
|
||||
v-if="canRetry"
|
||||
size="small"
|
||||
@click="handleRetry"
|
||||
>
|
||||
重试
|
||||
</a-button>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<a-popconfirm
|
||||
v-if="canDelete"
|
||||
title="确定删除这个任务吗?删除后无法恢复。"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<a-button size="small" danger>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
|
||||
<!-- 插槽用于自定义操作 -->
|
||||
<slot name="extra" :task="task"></slot>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
EyeOutlined,
|
||||
DownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// API 对象
|
||||
apis: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 操作成功回调
|
||||
onSuccess: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// 解构 API
|
||||
const { deleteApi, cancelApi, retryApi, getSignedUrlsApi } = props.apis
|
||||
|
||||
// 使用操作 Composable
|
||||
const operations = useTaskOperations(
|
||||
{
|
||||
deleteApi,
|
||||
cancelApi,
|
||||
retryApi
|
||||
},
|
||||
props.onSuccess
|
||||
)
|
||||
|
||||
// 计算属性:是否可预览
|
||||
const canPreview = computed(() => {
|
||||
return props.task.status === 'success' &&
|
||||
props.task.outputUrls &&
|
||||
props.task.outputUrls.length > 0 &&
|
||||
getSignedUrlsApi
|
||||
})
|
||||
|
||||
// 计算属性:是否可下载
|
||||
const canDownload = computed(() => {
|
||||
return props.task.status === 'success' &&
|
||||
props.task.outputUrls &&
|
||||
props.task.outputUrls.length > 0
|
||||
})
|
||||
|
||||
// 计算属性:是否可取消
|
||||
const canCancel = computed(() => {
|
||||
return props.task.status === 'pending' || props.task.status === 'running'
|
||||
})
|
||||
|
||||
// 计算属性:是否可重试
|
||||
const canRetry = computed(() => {
|
||||
return props.task.status === 'failed'
|
||||
})
|
||||
|
||||
// 计算属性:是否可删除
|
||||
const canDelete = computed(() => {
|
||||
return true // 所有任务都可以删除
|
||||
})
|
||||
|
||||
// 处理预览
|
||||
const handlePreview = async () => {
|
||||
if (getSignedUrlsApi) {
|
||||
await operations.handlePreview(getSignedUrlsApi, props.task.id, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理下载
|
||||
const handleDownload = () => {
|
||||
operations.handleBatchDownload(
|
||||
props.task.outputUrls || [],
|
||||
getSignedUrlsApi,
|
||||
props.task.id
|
||||
)
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
operations.handleCancel(props.task.id)
|
||||
}
|
||||
|
||||
// 处理重试
|
||||
const handleRetry = () => {
|
||||
operations.handleRetry(props.task.id)
|
||||
}
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = () => {
|
||||
operations.handleDelete(props.task.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保按钮内的图标和文字对齐 */
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="task-filter-bar">
|
||||
<a-space>
|
||||
<!-- 状态筛选 -->
|
||||
<a-select
|
||||
v-model:value="localFilters.status"
|
||||
style="width: 120px"
|
||||
placeholder="任务状态"
|
||||
allow-clear
|
||||
@change="handleChange"
|
||||
>
|
||||
<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-option value="canceled">已取消</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<!-- 关键词搜索 -->
|
||||
<a-input
|
||||
v-model:value="localFilters.keyword"
|
||||
:placeholder="placeholder"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@press-enter="handleChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<!-- 日期范围选择 -->
|
||||
<a-range-picker
|
||||
v-model:value="localFilters.dateRange"
|
||||
style="width: 300px"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
@change="handleChange"
|
||||
/>
|
||||
|
||||
<!-- 查询按钮 -->
|
||||
<a-button type="primary" @click="handleChange">
|
||||
查询
|
||||
</a-button>
|
||||
|
||||
<!-- 重置按钮 -->
|
||||
<a-button @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
|
||||
<!-- 插槽用于扩展其他筛选条件 -->
|
||||
<slot name="extra"></slot>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, toRefs } from 'vue'
|
||||
import { SearchOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
// 筛选条件
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
status: '',
|
||||
keyword: '',
|
||||
dateRange: null
|
||||
})
|
||||
},
|
||||
// 占位符文本
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '搜索关键词'
|
||||
},
|
||||
// 是否在值变化时立即触发
|
||||
immediate: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:filters', 'change', 'reset'])
|
||||
|
||||
// 内部筛选条件副本
|
||||
const localFilters = ref({
|
||||
status: '',
|
||||
keyword: '',
|
||||
dateRange: null,
|
||||
...props.filters
|
||||
})
|
||||
|
||||
// 监听 props 变化,更新内部副本
|
||||
watch(
|
||||
() => props.filters,
|
||||
(newVal) => {
|
||||
localFilters.value = {
|
||||
...localFilters.value,
|
||||
...newVal
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 处理变化
|
||||
const handleChange = () => {
|
||||
// 更新父组件的 v-model
|
||||
emit('update:filters', { ...localFilters.value })
|
||||
// 触发 change 事件
|
||||
emit('change', { ...localFilters.value })
|
||||
}
|
||||
|
||||
// 处理重置
|
||||
const handleReset = () => {
|
||||
localFilters.value = {
|
||||
status: '',
|
||||
keyword: '',
|
||||
dateRange: null
|
||||
}
|
||||
// 更新父组件的 v-model
|
||||
emit('update:filters', { ...localFilters.value })
|
||||
// 触发重置事件
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-filter-bar {
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-filter-bar .ant-space {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<a-tag :color="color" :class="statusClass">
|
||||
<template v-if="showIcon && (status === 'running' || status === 'RUNNING')">
|
||||
<LoadingOutlined :spin="true" />
|
||||
</template>
|
||||
{{ text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { LoadingOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
// 状态值
|
||||
status: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// 是否显示图标
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 自定义状态映射
|
||||
statusMap: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:状态文本
|
||||
const text = computed(() => {
|
||||
// 使用自定义映射或默认映射(同时支持大小写)
|
||||
const map = {
|
||||
pending: '待处理',
|
||||
running: '处理中',
|
||||
success: '已完成',
|
||||
failed: '失败',
|
||||
canceled: '已取消',
|
||||
// 大写状态支持
|
||||
PENDING: '待处理',
|
||||
RUNNING: '处理中',
|
||||
SUCCESS: '已完成',
|
||||
FAILED: '失败',
|
||||
CANCELED: '已取消',
|
||||
...props.statusMap
|
||||
}
|
||||
return map[props.status] || props.status
|
||||
})
|
||||
|
||||
// 计算属性:状态颜色
|
||||
const color = computed(() => {
|
||||
const colorMap = {
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
canceled: 'warning',
|
||||
// 大写状态支持
|
||||
PENDING: 'default',
|
||||
RUNNING: 'processing',
|
||||
SUCCESS: 'success',
|
||||
FAILED: 'error',
|
||||
CANCELED: 'warning'
|
||||
}
|
||||
return colorMap[props.status] || 'default'
|
||||
})
|
||||
|
||||
// 计算属性:状态样式类
|
||||
const statusClass = computed(() => {
|
||||
// 将状态标准化为小写,用于CSS类名
|
||||
const normalizedStatus = props.status.toLowerCase()
|
||||
return `task-status-tag--${normalizedStatus}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 状态标签动画效果 */
|
||||
.task-status-tag--running {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
/**
|
||||
* 任务列表通用逻辑 Composable
|
||||
* @param {Function} fetchApi - 获取列表数据的 API 函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @returns {Object} 列表状态和方法
|
||||
*/
|
||||
export function useTaskList(fetchApi, options = {}) {
|
||||
// 默认配置
|
||||
const defaultOptions = {
|
||||
pageSize: 10,
|
||||
pollingInterval: 5000, // 5秒轮询间隔
|
||||
...options
|
||||
}
|
||||
|
||||
// 响应式状态
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const current = ref(1)
|
||||
const pageSize = ref(defaultOptions.pageSize)
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
keyword: '',
|
||||
dateRange: null,
|
||||
...options.defaultFilters
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const paginationConfig = reactive({
|
||||
current: 1,
|
||||
pageSize: defaultOptions.pageSize,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (page, size) => {
|
||||
current.value = page
|
||||
pageSize.value = size
|
||||
paginationConfig.current = page
|
||||
paginationConfig.pageSize = size
|
||||
fetchList()
|
||||
},
|
||||
onShowSizeChange: (currentPage, size) => {
|
||||
current.value = 1
|
||||
pageSize.value = size
|
||||
paginationConfig.current = 1
|
||||
paginationConfig.pageSize = size
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
|
||||
// 构建查询参数
|
||||
const buildParams = () => {
|
||||
const params = {
|
||||
pageNo: current.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters.status) {
|
||||
params.status = filters.status
|
||||
}
|
||||
if (filters.keyword) {
|
||||
params.keyword = filters.keyword
|
||||
}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.dateRange && Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
||||
params.createTimeStart = `${filters.dateRange[0]} 00:00:00`
|
||||
params.createTimeEnd = `${filters.dateRange[1]} 23:59:59`
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// 获取任务列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchApi(buildParams())
|
||||
if (res.code === 0) {
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
paginationConfig.total = res.data.total || 0
|
||||
} else {
|
||||
message.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败:', error)
|
||||
message.error('加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = () => {
|
||||
current.value = 1
|
||||
paginationConfig.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
const handleResetFilters = () => {
|
||||
filters.status = ''
|
||||
filters.keyword = ''
|
||||
filters.dateRange = null
|
||||
current.value = 1
|
||||
paginationConfig.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
const refresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag, filters, sorter) => {
|
||||
console.log('表格变化:', pag, filters, sorter)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
list,
|
||||
total,
|
||||
current,
|
||||
pageSize,
|
||||
filters,
|
||||
paginationConfig,
|
||||
|
||||
// 方法
|
||||
fetchList,
|
||||
handleFilterChange,
|
||||
handleResetFilters,
|
||||
refresh,
|
||||
handleTableChange,
|
||||
buildParams
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
|
||||
/**
|
||||
* 任务操作通用逻辑 Composable
|
||||
* @param {Object} apis - API 对象,包含 deleteApi, cancelApi, retryApi
|
||||
* @param {Function} onSuccess - 操作成功后的回调函数
|
||||
* @returns {Object} 操作方法
|
||||
*/
|
||||
export function useTaskOperations(apis, onSuccess) {
|
||||
const { deleteApi, cancelApi, retryApi } = apis
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定删除这个任务吗?删除后无法恢复。',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteApi(id)
|
||||
message.success('删除成功')
|
||||
onSuccess && onSuccess()
|
||||
} catch (error) {
|
||||
console.error('删除任务失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
const handleCancel = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确定要取消这个任务吗?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await cancelApi(id)
|
||||
message.success('已取消任务')
|
||||
onSuccess && onSuccess()
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重试任务
|
||||
const handleRetry = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认重试',
|
||||
content: '确定要重新生成这个任务吗?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await retryApi(id)
|
||||
message.success('已重新提交任务')
|
||||
onSuccess && onSuccess()
|
||||
} catch (error) {
|
||||
console.error('重试任务失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async (ids, deleteApiFn) => {
|
||||
if (!ids || ids.length === 0) {
|
||||
message.warning('请选择要删除的任务')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认批量删除',
|
||||
content: `确定要删除选中的 ${ids.length} 个任务吗?删除后无法恢复。`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const deleteFn = deleteApiFn || deleteApi
|
||||
for (const id of ids) {
|
||||
await deleteFn(id)
|
||||
}
|
||||
message.success(`成功删除 ${ids.length} 个任务`)
|
||||
onSuccess && onSuccess()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
message.error('批量删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下载单个文件
|
||||
const handleDownload = (url, filename = 'download') => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 批量下载
|
||||
const handleBatchDownload = async (urls, getSignedUrlsApi, taskId) => {
|
||||
if (!urls || urls.length === 0) {
|
||||
message.warning('没有可下载的文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
message.loading('正在获取下载链接...', 0)
|
||||
let downloadUrls = urls
|
||||
|
||||
// 如果需要获取签名URL
|
||||
if (getSignedUrlsApi && taskId) {
|
||||
const res = await getSignedUrlsApi(taskId)
|
||||
if (res.code === 0 && res.data) {
|
||||
downloadUrls = res.data
|
||||
} else {
|
||||
message.warning('获取下载链接失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
message.destroy()
|
||||
message.loading('正在准备下载...', 0)
|
||||
|
||||
// 逐个触发下载,避免浏览器阻止多个弹窗
|
||||
downloadUrls.forEach((url, index) => {
|
||||
setTimeout(() => {
|
||||
handleDownload(url)
|
||||
}, index * 500) // 每个下载间隔500ms
|
||||
})
|
||||
|
||||
message.destroy()
|
||||
message.success(`已触发 ${downloadUrls.length} 个文件的下载`)
|
||||
} catch (error) {
|
||||
console.error('批量下载失败:', error)
|
||||
message.destroy()
|
||||
message.error('获取下载链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取签名URL
|
||||
const getSignedUrl = async (getSignedUrlsApi, taskId, index) => {
|
||||
try {
|
||||
const res = await getSignedUrlsApi(taskId)
|
||||
if (res.code === 0 && res.data && res.data[index]) {
|
||||
return res.data[index]
|
||||
} else {
|
||||
message.warning('获取预览链接失败')
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取签名URL失败:', error)
|
||||
message.error('获取预览链接失败')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 预览
|
||||
const handlePreview = async (getSignedUrlsApi, taskId, index) => {
|
||||
try {
|
||||
message.loading('正在获取预览链接...', 0)
|
||||
const url = await getSignedUrl(getSignedUrlsApi, taskId, index)
|
||||
message.destroy()
|
||||
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('预览失败:', error)
|
||||
message.destroy()
|
||||
message.error('预览失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 基本操作
|
||||
handleDelete,
|
||||
handleCancel,
|
||||
handleRetry,
|
||||
|
||||
// 批量操作
|
||||
handleBatchDelete,
|
||||
handleBatchDownload,
|
||||
|
||||
// 下载和预览
|
||||
handleDownload,
|
||||
handlePreview,
|
||||
getSignedUrl
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 任务状态轮询 Composable
|
||||
* @param {Function} fetchApi - 获取任务列表的 API 函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @returns {Object} 轮询控制方法
|
||||
*/
|
||||
export function useTaskPolling(fetchApi, options = {}) {
|
||||
const defaultOptions = {
|
||||
interval: 5000, // 默认5秒轮询间隔
|
||||
filter: (task) => task.status === 'pending' || task.status === 'running',
|
||||
onTaskUpdate: null, // 任务更新回调
|
||||
...options
|
||||
}
|
||||
|
||||
const interval = ref(null)
|
||||
const isPolling = ref(false)
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
// 清除可能存在的旧定时器
|
||||
stopPolling()
|
||||
|
||||
isPolling.value = true
|
||||
interval.value = setInterval(async () => {
|
||||
// 只在页面可见时轮询,避免后台浪费资源
|
||||
if (document.visibilityState === 'visible') {
|
||||
await pollTasks()
|
||||
}
|
||||
}, defaultOptions.interval)
|
||||
}
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (interval.value) {
|
||||
clearInterval(interval.value)
|
||||
interval.value = null
|
||||
}
|
||||
isPolling.value = false
|
||||
}
|
||||
|
||||
// 执行轮询
|
||||
const pollTasks = async () => {
|
||||
try {
|
||||
// 构建查询参数,只获取 pending 和 running 状态的任务
|
||||
const params = {
|
||||
pageNo: 1,
|
||||
pageSize: 1000, // 获取较多数据以检查所有运行中的任务
|
||||
status: 'pending,running' // 只获取这两种状态
|
||||
}
|
||||
|
||||
const res = await fetchApi(params)
|
||||
if (res.code === 0) {
|
||||
const tasks = res.data.list || []
|
||||
const activeTasks = tasks.filter(defaultOptions.filter)
|
||||
|
||||
// 如果没有运行中的任务,停止轮询
|
||||
if (activeTasks.length === 0) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
// 通知任务更新
|
||||
if (defaultOptions.onTaskUpdate) {
|
||||
defaultOptions.onTaskUpdate(tasks)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error)
|
||||
// 轮询失败不停止,继续尝试
|
||||
}
|
||||
}
|
||||
|
||||
// 页面可见性变化处理
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// 页面可见时,恢复轮询
|
||||
if (!isPolling.value) {
|
||||
startPolling()
|
||||
}
|
||||
} else {
|
||||
// 页面隐藏时,暂停轮询
|
||||
if (isPolling.value) {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听页面可见性变化
|
||||
const addVisibilityListener = () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
|
||||
// 移除页面可见性监听
|
||||
const removeVisibilityListener = () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
|
||||
// 手动触发一次轮询
|
||||
const manualPoll = async () => {
|
||||
await pollTasks()
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
addVisibilityListener()
|
||||
// 自动开始轮询
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
removeVisibilityListener()
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isPolling,
|
||||
|
||||
// 方法
|
||||
startPolling,
|
||||
stopPolling,
|
||||
manualPoll,
|
||||
addVisibilityListener,
|
||||
removeVisibilityListener
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务轮询管理 Composable - 用于管理多个任务列表的轮询
|
||||
* @returns {Object} 轮询管理器
|
||||
*/
|
||||
export function useTaskPollingManager() {
|
||||
const pollings = new Map()
|
||||
|
||||
// 注册轮询
|
||||
const registerPolling = (key, polling) => {
|
||||
pollings.set(key, polling)
|
||||
}
|
||||
|
||||
// 取消注册轮询
|
||||
const unregisterPolling = (key) => {
|
||||
const polling = pollings.get(key)
|
||||
if (polling) {
|
||||
polling.stopPolling()
|
||||
pollings.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 停止所有轮询
|
||||
const stopAll = () => {
|
||||
pollings.forEach((polling) => {
|
||||
polling.stopPolling()
|
||||
})
|
||||
}
|
||||
|
||||
// 开始所有轮询
|
||||
const startAll = () => {
|
||||
pollings.forEach((polling) => {
|
||||
polling.startPolling()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registerPolling,
|
||||
unregisterPolling,
|
||||
stopAll,
|
||||
startAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
<template>
|
||||
<div class="digital-human-task-page">
|
||||
<!-- 筛选条件 -->
|
||||
<div class="digital-human-task-page__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-option value="canceled">已取消</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-input
|
||||
v-model:value="filters.keyword"
|
||||
placeholder="搜索任务名称"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@press-enter="handleFilterChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<a-range-picker
|
||||
v-model:value="filters.dateRange"
|
||||
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="digital-human-task-page__content">
|
||||
<!-- 批量操作栏 -->
|
||||
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
|
||||
<a-alert
|
||||
:message="`已选中 ${selectedRowKeys.length} 项`"
|
||||
type="info"
|
||||
show-icon
|
||||
>
|
||||
<template #action>
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleBatchDownload"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
批量下载
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除选中的任务吗?删除后无法恢复。"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<a-button size="small" danger>
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
批量删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<a-table
|
||||
:data-source="list"
|
||||
:columns="columns"
|
||||
:row-key="record => record.id"
|
||||
:pagination="paginationConfig"
|
||||
@change="handleTableChange"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 1000 }"
|
||||
>
|
||||
<!-- 任务名称列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'taskName'">
|
||||
<div class="task-name-cell">
|
||||
<strong>{{ record.taskName }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 音色列 -->
|
||||
<template v-else-if="column.key === 'voiceId'">
|
||||
<span>{{ record.voiceId || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<TaskStatusTag :status="record.status" />
|
||||
</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'">
|
||||
{{ formatDateTime(record.createTime) }}
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handlePreview(record)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlayCircleOutlined />
|
||||
</template>
|
||||
预览
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
size="small"
|
||||
@click="handleDownload(record)"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
下载
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isStatus(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>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<a-modal
|
||||
v-model:open="previewVisible"
|
||||
title="视频预览"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<video
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
controls
|
||||
style="width: 100%; max-height: 600px"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
PlayCircleOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
getDigitalHumanTaskPage,
|
||||
cancelTask,
|
||||
deleteTask
|
||||
} from '@/api/digitalHuman'
|
||||
import { formatDate } from '@/utils/file'
|
||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
||||
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
||||
|
||||
// 使用 Composable
|
||||
const {
|
||||
loading,
|
||||
list,
|
||||
filters,
|
||||
paginationConfig,
|
||||
fetchList,
|
||||
handleFilterChange,
|
||||
handleResetFilters,
|
||||
handleTableChange
|
||||
} = useTaskList(getDigitalHumanTaskPage)
|
||||
|
||||
// 使用任务操作 Composable
|
||||
const {
|
||||
handleDelete,
|
||||
handleCancel,
|
||||
} = useTaskOperations(
|
||||
{
|
||||
deleteApi: deleteTask,
|
||||
cancelApi: cancelTask,
|
||||
},
|
||||
fetchList
|
||||
)
|
||||
|
||||
// 使用轮询 Composable
|
||||
useTaskPolling(getDigitalHumanTaskPage, {
|
||||
onTaskUpdate: () => {
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
|
||||
// 预览相关
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
|
||||
// 表格选择相关
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
// 表格行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
onSelectAll: (selected, selectedRows, changeRows) => {
|
||||
// 全选逻辑
|
||||
console.log('全选状态:', selected, '选中行数:', selectedRows.length, '变化行数:', changeRows.length)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateStr) => {
|
||||
return formatDate(dateStr)
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'taskName',
|
||||
key: 'taskName',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
fixed: 'left'
|
||||
},
|
||||
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 280,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取进度条状态
|
||||
const getProgressStatus = (status) => {
|
||||
const statusMap = {
|
||||
pending: 'normal',
|
||||
running: 'active',
|
||||
success: 'success',
|
||||
failed: 'exception',
|
||||
canceled: 'normal',
|
||||
// 大写状态支持
|
||||
PENDING: 'normal',
|
||||
RUNNING: 'active',
|
||||
SUCCESS: 'success',
|
||||
FAILED: 'exception',
|
||||
CANCELED: 'normal'
|
||||
}
|
||||
return statusMap[status] || 'normal'
|
||||
}
|
||||
|
||||
// 检查状态(同时支持大小写)
|
||||
const isStatus = (status, targetStatus) => {
|
||||
return status === targetStatus || status === targetStatus.toUpperCase()
|
||||
}
|
||||
|
||||
// 预览视频
|
||||
const handlePreview = (record) => {
|
||||
if (!record.resultVideoUrl) {
|
||||
message.warning('该任务暂无视频结果,请稍后再试')
|
||||
return
|
||||
}
|
||||
|
||||
previewUrl.value = record.resultVideoUrl
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
// 下载视频
|
||||
const handleDownload = (record) => {
|
||||
if (!record.resultVideoUrl) {
|
||||
message.warning('该任务暂无视频结果,请稍后再试')
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = record.resultVideoUrl
|
||||
link.download = `数字人视频_${record.id}_${Date.now()}.mp4`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 批量下载视频
|
||||
const handleBatchDownload = () => {
|
||||
// 获取选中的已完成任务
|
||||
const selectedTasks = list.value.filter(task =>
|
||||
selectedRowKeys.value.includes(task.id) &&
|
||||
isStatus(task.status, 'success')
|
||||
)
|
||||
|
||||
if (selectedTasks.length === 0) {
|
||||
message.warning('请选择已完成的任务')
|
||||
return
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
// 逐个直接下载
|
||||
for (const task of selectedTasks) {
|
||||
if (!task.resultVideoUrl) {
|
||||
failCount++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = task.resultVideoUrl
|
||||
link.download = `数字人视频_${task.id}_${Date.now()}.mp4`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`下载任务 ${task.id} 失败:`, error)
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
message.success(`已触发 ${successCount} 个文件的下载`)
|
||||
} else if (successCount === 0) {
|
||||
message.error('所有文件下载失败,请重试')
|
||||
} else {
|
||||
message.warning(`成功下载 ${successCount} 个文件,${failCount} 个文件下载失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除任务
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning('请选择要删除的任务')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 逐个删除选中的任务
|
||||
for (const id of selectedRowKeys.value) {
|
||||
await deleteTask(id)
|
||||
}
|
||||
|
||||
message.success(`成功删除 ${selectedRowKeys.value.length} 个任务`)
|
||||
|
||||
// 清空选择并刷新列表
|
||||
selectedRowKeys.value = []
|
||||
await fetchList()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
message.error('批量删除失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.digital-human-task-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.digital-human-task-page__filters {
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.digital-human-task-page__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 批量操作栏 */
|
||||
.batch-actions {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.batch-actions + .ant-spin {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.batch-actions + .ant-spin :deep(.ant-spin-container) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.batch-actions + .ant-spin :deep(.ant-table) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 任务名称单元格 */
|
||||
.task-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 文本截断 */
|
||||
.text-ellipsis {
|
||||
display: inline-block;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 展开内容 */
|
||||
.expanded-content {
|
||||
padding: 16px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-card);
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-text p {
|
||||
margin: 8px 0 0 0;
|
||||
padding: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-params {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.task-result {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.processing-tip {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 确保按钮内的图标和文字对齐 */
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: var(--color-bg-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="task-layout">
|
||||
<!-- 左侧导航 -->
|
||||
<aside class="task-layout__sidebar">
|
||||
<nav class="task-layout__nav">
|
||||
<div class="task-layout__nav-header">
|
||||
<h2 class="task-layout__nav-title">任务管理</h2>
|
||||
</div>
|
||||
<ul class="task-layout__nav-list">
|
||||
<li
|
||||
v-for="item in navItems"
|
||||
:key="item.type"
|
||||
class="task-layout__nav-item"
|
||||
:class="{
|
||||
'is-active': currentType === item.type
|
||||
}"
|
||||
>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="task-layout__nav-link"
|
||||
@click="navigateTo(item.type)"
|
||||
>
|
||||
<span class="nav-icon" v-html="icons[item.icon]"></span>
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<main class="task-layout__content">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="currentComponent" :key="currentType" />
|
||||
</transition>
|
||||
</main>
|
||||
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<a-button
|
||||
v-if="isMobile"
|
||||
class="task-layout__mobile-btn"
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="large"
|
||||
@click="showMobileNav = !showMobileNav"
|
||||
>
|
||||
<template #icon>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<!-- 移动端遮罩层 -->
|
||||
<a-drawer
|
||||
v-model:open="showMobileNav"
|
||||
placement="left"
|
||||
:width="220"
|
||||
:closable="false"
|
||||
class="task-layout__mobile-drawer"
|
||||
>
|
||||
<div class="task-layout__mobile-nav">
|
||||
<div class="task-layout__nav-header">
|
||||
<h2 class="task-layout__nav-title">任务管理</h2>
|
||||
</div>
|
||||
<ul class="task-layout__nav-list">
|
||||
<li
|
||||
v-for="item in navItems"
|
||||
:key="item.type"
|
||||
class="task-layout__nav-item"
|
||||
:class="{
|
||||
'is-active': currentType === item.type
|
||||
}"
|
||||
>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="task-layout__nav-link"
|
||||
@click="navigateTo(item.type, true)"
|
||||
>
|
||||
<span class="nav-icon" v-html="icons[item.icon]"></span>
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { MenuOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const showMobileNav = ref(false)
|
||||
const windowWidth = ref(window.innerWidth)
|
||||
|
||||
// 单色 SVG 图标(填充 currentColor,可继承文本色)
|
||||
const icons = {
|
||||
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>',
|
||||
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>'
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const isMobile = computed(() => windowWidth.value < 768)
|
||||
const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1200)
|
||||
|
||||
// 当前任务类型 - 使用路由name来激活高亮
|
||||
const currentType = computed(() => {
|
||||
// 使用路由的params.type,如果不存在则默认使用mix-task
|
||||
const type = route.params.type
|
||||
|
||||
// 如果类型无效或为空,默认使用 mix-task
|
||||
if (!type || type === 'task-management') {
|
||||
return 'mix-task'
|
||||
}
|
||||
|
||||
return type
|
||||
})
|
||||
|
||||
// 动态导入组件
|
||||
const MixTaskList = defineAsyncComponent(() => import('../mix-task/index.vue'))
|
||||
const DigitalHumanTaskList = defineAsyncComponent(() => import('../digital-human-task/index.vue'))
|
||||
|
||||
// 导航项配置
|
||||
const navItems = [
|
||||
{
|
||||
type: 'mix-task',
|
||||
label: '混剪视频任务',
|
||||
icon: 'video',
|
||||
component: MixTaskList
|
||||
},
|
||||
{
|
||||
type: 'digital-human-task',
|
||||
label: '数字人视频任务',
|
||||
icon: 'user',
|
||||
component: DigitalHumanTaskList
|
||||
}
|
||||
]
|
||||
|
||||
// 当前组件
|
||||
const currentComponent = computed(() => {
|
||||
const item = navItems.find(item => item.type === currentType.value)
|
||||
if (!item) {
|
||||
return navItems[0].component
|
||||
}
|
||||
return item.component
|
||||
})
|
||||
|
||||
// 导航到指定类型
|
||||
const navigateTo = (type, closeMobile = false) => {
|
||||
router.push(`/system/task-management/${type}`)
|
||||
if (closeMobile) {
|
||||
showMobileNav.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新窗口宽度
|
||||
const updateWindowWidth = () => {
|
||||
windowWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateWindowWidth)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateWindowWidth)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧导航 */
|
||||
.task-layout__sidebar {
|
||||
width: 220px;
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1199px) {
|
||||
.task-layout__sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.task-layout__sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航头部 */
|
||||
.task-layout__nav-header {
|
||||
padding: 24px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.task-layout__nav-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 导航列表 */
|
||||
.task-layout__nav-list {
|
||||
list-style: none;
|
||||
padding: 8px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 导航项 */
|
||||
.task-layout__nav-item {
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.task-layout__nav-item.is-active .task-layout__nav-link {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-layout__nav-item.is-active .nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 导航链接 */
|
||||
.task-layout__nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-card);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-layout__nav-link:hover {
|
||||
background: var(--color-bg-2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.task-layout__nav-item.is-active .task-layout__nav-link:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 导航图标 */
|
||||
.nav-icon {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 12px;
|
||||
color: var(--color-text-3);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.task-layout__nav-item.is-active .nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 导航文本 */
|
||||
.nav-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 右侧内容 */
|
||||
.task-layout__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-bg);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 移动端按钮 */
|
||||
.task-layout__mobile-btn {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.task-layout__mobile-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端抽屉 */
|
||||
.task-layout__mobile-drawer :deep(.ant-drawer-body) {
|
||||
padding: 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.task-layout__mobile-nav {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-layout__mobile-nav .task-layout__nav-header {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.task-layout__mobile-nav .task-layout__nav-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<div class="mix-task-page">
|
||||
<!-- 筛选条件 -->
|
||||
<div class="mix-task-page__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:value="filters.keyword"
|
||||
placeholder="搜索标题"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@press-enter="handleFilterChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<a-range-picker
|
||||
v-model:value="filters.dateRange"
|
||||
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-page__content">
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<a-table
|
||||
:data-source="list"
|
||||
: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="isStatus(record.status, 'success') && record.outputUrls && record.outputUrls.length > 0"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleDownloadAll(record.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
<span>下载</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'running')"
|
||||
size="small"
|
||||
@click="handleCancel(record.id)"
|
||||
>
|
||||
取消
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'failed')"
|
||||
size="small"
|
||||
@click="handleRetry(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
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
@click="handlePreview(record.id, index)"
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
视频 {{ index + 1 }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDownloadSignedUrl(record.id, index)"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</a-button>
|
||||
<span v-else class="processing-tip">
|
||||
视频 {{ index + 1 }} (处理中...)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="record.errorMsg" class="task-error">
|
||||
<a-alert
|
||||
type="error"
|
||||
:message="record.errorMsg"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
PlayCircleOutlined,
|
||||
DownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MixTaskService } from '@/api/mixTask'
|
||||
import { formatDate } from '@/utils/file'
|
||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
||||
|
||||
// 使用 Composable
|
||||
const {
|
||||
loading,
|
||||
list,
|
||||
filters,
|
||||
paginationConfig,
|
||||
fetchList,
|
||||
handleFilterChange,
|
||||
handleResetFilters,
|
||||
handleTableChange,
|
||||
buildParams
|
||||
} = useTaskList(MixTaskService.getTaskPage)
|
||||
|
||||
// 使用任务操作 Composable
|
||||
const {
|
||||
handleDelete,
|
||||
handleCancel,
|
||||
handleRetry,
|
||||
handlePreview,
|
||||
handleBatchDownload
|
||||
} = useTaskOperations(
|
||||
{
|
||||
deleteApi: MixTaskService.deleteTask,
|
||||
cancelApi: MixTaskService.cancelTask,
|
||||
retryApi: MixTaskService.retryTask
|
||||
},
|
||||
fetchList
|
||||
)
|
||||
|
||||
// 使用轮询 Composable
|
||||
useTaskPolling(MixTaskService.getTaskPage, {
|
||||
onTaskUpdate: () => {
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
|
||||
// 扩展行键
|
||||
const expandedRowKeys = ref([])
|
||||
|
||||
// 处理展开行变化
|
||||
const handleExpandedRowsChange = (keys) => {
|
||||
expandedRowKeys.value = keys
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
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 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',
|
||||
// 大写状态支持
|
||||
PENDING: 'normal',
|
||||
RUNNING: 'active',
|
||||
SUCCESS: 'success',
|
||||
FAILED: 'exception'
|
||||
}
|
||||
return statusMap[status] || 'normal'
|
||||
}
|
||||
|
||||
// 检查状态(同时支持大小写)
|
||||
const isStatus = (status, targetStatus) => {
|
||||
return status === targetStatus || status === targetStatus.toUpperCase()
|
||||
}
|
||||
|
||||
// 下载单个视频(使用签名URL)
|
||||
const handleDownloadSignedUrl = async (taskId, index) => {
|
||||
try {
|
||||
const res = await MixTaskService.getSignedUrls(taskId)
|
||||
if (res.code === 0 && res.data && res.data[index]) {
|
||||
const link = document.createElement('a')
|
||||
link.href = res.data[index]
|
||||
link.download = `video_${taskId}_${index + 1}`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
console.warn('获取下载链接失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取下载链接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量下载所有视频
|
||||
const handleDownloadAll = async (taskId) => {
|
||||
handleBatchDownload(
|
||||
[],
|
||||
MixTaskService.getSignedUrls,
|
||||
taskId
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mix-task-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mix-task-page__filters {
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.mix-task-page__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 标题单元格 */
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 展开内容 */
|
||||
.expanded-content {
|
||||
padding: 16px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-card);
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-text p {
|
||||
margin: 8px 0 0 0;
|
||||
padding: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-results {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.processing-tip {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 确保按钮内的图标和文字对齐 */
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: var(--color-bg-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user