refactor(TaskStatusTag): replace a-tag with span element and improve status configuration
- Replace a-tag component with semantic span element for better accessibility - Introduce centralized STATUS_CONFIG object for consistent status mapping - Add isRunning computed property for cleaner conditional logic - Remove redundant statusMap handling and normalize status values - Add proper CSS class bindings for styling consistency - Update component structure to use
This commit is contained in:
@@ -1,97 +1,92 @@
|
||||
<template>
|
||||
<a-tag :color="color" :class="statusClass">
|
||||
<template v-if="showIcon && (status === 'running' || status === 'RUNNING')">
|
||||
<LoadingOutlined :spin="true" />
|
||||
<span class="task-status-tag" :class="statusClass">
|
||||
<template v-if="showIcon && isRunning">
|
||||
<LoadingOutlined class="task-status-tag__icon" :spin="true" />
|
||||
</template>
|
||||
{{ text }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</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: () => ({})
|
||||
}
|
||||
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: '已完成',
|
||||
PROCESSING: '处理中',
|
||||
FAILED: '失败',
|
||||
CANCELED: '已取消',
|
||||
...props.statusMap
|
||||
}
|
||||
return map[props.status] || props.status
|
||||
const STATUS_CONFIG = {
|
||||
pending: { text: '待处理', class: 'task-status-tag--pending' },
|
||||
running: { text: '处理中', class: 'task-status-tag--running' },
|
||||
success: { text: '已完成', class: 'task-status-tag--success' },
|
||||
failed: { text: '失败', class: 'task-status-tag--failed' },
|
||||
canceled: { text: '已取消', class: 'task-status-tag--canceled' }
|
||||
}
|
||||
|
||||
const normalizedStatus = computed(() => props.status?.toLowerCase() || '')
|
||||
|
||||
const config = computed(() => {
|
||||
const custom = props.statusMap[props.status]
|
||||
if (custom) return { text: custom, class: `task-status-tag--${normalizedStatus.value}` }
|
||||
return STATUS_CONFIG[normalizedStatus.value] || { text: props.status, class: 'task-status-tag--default' }
|
||||
})
|
||||
|
||||
// 计算属性:状态颜色
|
||||
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}`
|
||||
})
|
||||
const text = computed(() => config.value.text)
|
||||
const statusClass = computed(() => config.value.class)
|
||||
const isRunning = computed(() => normalizedStatus.value === 'running')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 状态标签动画效果 */
|
||||
.task-status-tag--running {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
<style scoped lang="less">
|
||||
.task-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.5;
|
||||
|
||||
&__icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
&--running {
|
||||
background: var(--color-primary-50);
|
||||
color: var(--color-primary-600);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: var(--color-success-50);
|
||||
color: var(--color-success-600);
|
||||
}
|
||||
|
||||
&--failed {
|
||||
background: var(--color-error-50);
|
||||
color: var(--color-error-600);
|
||||
}
|
||||
|
||||
&--canceled {
|
||||
background: var(--color-warning-50);
|
||||
color: var(--color-warning-600);
|
||||
}
|
||||
|
||||
&--default {
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="digital-human-task-page">
|
||||
<div class="task-page">
|
||||
<!-- 筛选条件 -->
|
||||
<div class="digital-human-task-page__filters">
|
||||
<div class="task-page__filters">
|
||||
<a-space :size="16">
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
class="filter-select"
|
||||
placeholder="任务状态"
|
||||
@change="handleFilterChange"
|
||||
allow-clear
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
@@ -25,9 +25,7 @@
|
||||
allow-clear
|
||||
@press-enter="handleFilterChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
|
||||
<a-range-picker
|
||||
@@ -39,39 +37,22 @@
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
|
||||
<a-button type="primary" class="filter-button" @click="handleFilterChange">
|
||||
查询
|
||||
</a-button>
|
||||
<a-button class="filter-button" @click="handleResetFilters">
|
||||
重置
|
||||
</a-button>
|
||||
<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 class="task-page__content">
|
||||
<!-- 批量操作栏 -->
|
||||
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
|
||||
<a-alert
|
||||
:message="`已选中 ${selectedRowKeys.length} 项`"
|
||||
type="info"
|
||||
show-icon
|
||||
>
|
||||
<a-alert :message="`已选中 ${selectedRowKeys.length} 项`" type="info" show-icon>
|
||||
<template #action>
|
||||
<a-space>
|
||||
|
||||
<a-popconfirm
|
||||
title="确定要删除选中的任务吗?删除后无法恢复。"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<a-button size="small" danger>
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
批量删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
<a-popconfirm title="确定要删除选中的任务吗?" @confirm="handleBatchDelete">
|
||||
<a-button size="small" danger>
|
||||
<DeleteOutlined /> 批量删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
@@ -82,21 +63,14 @@
|
||||
:columns="columns"
|
||||
:row-key="record => record.id"
|
||||
:pagination="paginationConfig"
|
||||
@change="handleTableChange"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 1000 }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 任务名称列 -->
|
||||
<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>
|
||||
<strong>{{ record.taskName }}</strong>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
@@ -106,10 +80,10 @@
|
||||
|
||||
<!-- 进度列 -->
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<div style="min-width: 100px">
|
||||
<div class="progress-cell">
|
||||
<a-progress
|
||||
:percent="record.progress"
|
||||
:status="getProgressStatus(record.status)"
|
||||
:status="PROGRESS_STATUS[record.status]"
|
||||
size="small"
|
||||
:show-info="false"
|
||||
/>
|
||||
@@ -118,27 +92,22 @@
|
||||
|
||||
<!-- 创建时间列 -->
|
||||
<template v-else-if="column.key === 'createTime'">
|
||||
{{ formatDateTime(record.createTime) }}
|
||||
{{ formatDate(record.createTime) }}
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
|
||||
<!-- 下载按钮 -->
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
size="small"
|
||||
class="action-btn action-btn--success"
|
||||
@click="handleDownload(record)"
|
||||
class="action-btn-download"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
下载
|
||||
<DownloadOutlined /> 下载
|
||||
</a-button>
|
||||
<!-- 取消按钮 -->
|
||||
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'running')"
|
||||
type="link"
|
||||
@@ -148,11 +117,8 @@
|
||||
取消
|
||||
</a-button>
|
||||
|
||||
<a-popconfirm
|
||||
title="确定删除这个任务吗?删除后无法恢复。"
|
||||
@confirm="() => handleDelete(record.id)"
|
||||
>
|
||||
<a-button size="small" type="link" class="action-btn-delete">删除</a-button>
|
||||
<a-popconfirm title="确定删除?删除后无法恢复。" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" class="action-btn action-btn--danger">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
@@ -166,364 +132,145 @@
|
||||
<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 { SearchOutlined, 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)
|
||||
// 进度状态映射
|
||||
const PROGRESS_STATUS = {
|
||||
pending: 'normal', running: 'active', success: 'success', failed: 'exception', canceled: 'normal',
|
||||
PENDING: 'normal', RUNNING: 'active', SUCCESS: 'success', FAILED: 'exception', CANCELED: 'normal'
|
||||
}
|
||||
|
||||
// 使用任务操作 Composable
|
||||
const {
|
||||
handleDelete,
|
||||
handleCancel,
|
||||
} = useTaskOperations(
|
||||
{
|
||||
deleteApi: deleteTask,
|
||||
cancelApi: cancelTask,
|
||||
},
|
||||
fetchList
|
||||
)
|
||||
// Composables
|
||||
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(getDigitalHumanTaskPage)
|
||||
const { handleDelete, handleCancel } = useTaskOperations({ deleteApi: deleteTask, cancelApi: cancelTask }, fetchList)
|
||||
useTaskPolling(getDigitalHumanTaskPage, { onTaskUpdate: fetchList })
|
||||
|
||||
// 使用轮询 Composable
|
||||
useTaskPolling(getDigitalHumanTaskPage, {
|
||||
onTaskUpdate: () => {
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
|
||||
// 表格选择相关
|
||||
// 表格选择
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
// 表格行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
onSelectAll: (selected, selectedRows, changeRows) => {
|
||||
// 全选逻辑
|
||||
console.log('全选状态:', selected, '选中行数:', selectedRows.length, '变化行数:', changeRows.length)
|
||||
}
|
||||
onChange: (keys) => { selectedRowKeys.value = keys }
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateStr) => {
|
||||
return formatDate(dateStr)
|
||||
}
|
||||
// 状态判断
|
||||
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||
|
||||
// 表格列定义
|
||||
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: 240,
|
||||
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 handleDownload = (record) => {
|
||||
console.log(record)
|
||||
if (!record.resultVideoUrl) {
|
||||
message.warning('该任务暂无视频结果,请稍后再试')
|
||||
return
|
||||
}
|
||||
|
||||
window.open(record.resultVideoUrl, '_blank')
|
||||
}
|
||||
|
||||
// 批量删除任务
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning('请选择要删除的任务')
|
||||
return
|
||||
}
|
||||
if (!selectedRowKeys.value.length) return
|
||||
|
||||
try {
|
||||
// 逐个删除选中的任务
|
||||
for (const id of selectedRowKeys.value) {
|
||||
await deleteTask(id)
|
||||
}
|
||||
|
||||
for (const id of selectedRowKeys.value) await deleteTask(id)
|
||||
message.success(`成功删除 ${selectedRowKeys.value.length} 个任务`)
|
||||
|
||||
// 清空选择并刷新列表
|
||||
selectedRowKeys.value = []
|
||||
await fetchList()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
} catch (e) {
|
||||
console.error('批量删除失败:', e)
|
||||
message.error('批量删除失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
// 表格列定义
|
||||
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: 200, fixed: 'right' }
|
||||
]
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.digital-human-task-page {
|
||||
padding: 0 var(--space-3);
|
||||
.task-page {
|
||||
padding: var(--space-4);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
&__filters {
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filter-date-picker {
|
||||
width: 280px;
|
||||
}
|
||||
.task-page__filters {
|
||||
padding: var(--space-4);
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
.filter-date-picker {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量操作栏 */
|
||||
.task-page__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
margin-bottom: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
+ .ant-spin {
|
||||
+ :deep(.ant-spin) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.ant-spin-container) {
|
||||
.ant-spin-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
.ant-table {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 任务名称单元格 */
|
||||
.task-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
.progress-cell {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* 操作按钮样式 */
|
||||
.action-btn-preview {
|
||||
color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-hover, var(--color-blue-600));
|
||||
}
|
||||
.action-btn {
|
||||
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
|
||||
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
|
||||
}
|
||||
|
||||
.action-btn-download {
|
||||
color: var(--color-success);
|
||||
|
||||
&:hover {
|
||||
color: #059669;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-delete {
|
||||
color: var(--color-error);
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
/* 文本截断 */
|
||||
.text-ellipsis {
|
||||
display: inline-block;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 展开内容 */
|
||||
.expanded-content {
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-card);
|
||||
margin: var(--space-2);
|
||||
}
|
||||
|
||||
.task-text {
|
||||
margin-bottom: var(--space-3);
|
||||
|
||||
p {
|
||||
margin: var(--space-2) 0 0 0;
|
||||
padding: var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.task-params {
|
||||
margin-bottom: var(--space-3);
|
||||
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-weight: 600;
|
||||
margin-right: var(--space-2);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-result {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.processing-tip {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* 确保按钮内的图标和文字对齐 */
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 8px;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: var(--color-bg-2);
|
||||
font-weight: 600;
|
||||
background: var(--color-gray-50);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* 桌面端样式优化 */
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,22 +8,13 @@
|
||||
</div>
|
||||
<ul class="task-layout__nav-list">
|
||||
<li
|
||||
v-for="item in navItems"
|
||||
v-for="item in NAV_ITEMS"
|
||||
:key="item.type"
|
||||
class="task-layout__nav-item"
|
||||
:class="{
|
||||
'is-active': currentType === item.type
|
||||
}"
|
||||
:class="{ 'is-active': currentType === item.type }"
|
||||
>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="task-layout__nav-link"
|
||||
@click="navigateTo(item.type)"
|
||||
>
|
||||
<span class="nav-icon">
|
||||
<VideoCameraOutlined v-if="item.icon === 'video'" />
|
||||
<UserOutlined v-else-if="item.icon === 'user'" />
|
||||
</span>
|
||||
<a class="task-layout__nav-link" @click="navigateTo(item.type)">
|
||||
<component :is="item.icon" class="nav-icon" />
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -41,55 +32,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent, markRaw } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { VideoCameraOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 当前任务类型
|
||||
const currentType = computed(() => {
|
||||
const type = route.params.type
|
||||
|
||||
if (!type || type === 'task-management') {
|
||||
return 'mix-task'
|
||||
}
|
||||
|
||||
return type
|
||||
const { type } = route.params
|
||||
return !type || type === 'task-management' ? 'mix-task' : type
|
||||
})
|
||||
|
||||
// 动态导入组件
|
||||
const MixTaskList = defineAsyncComponent(() => import('../mix-task/index.vue'))
|
||||
const DigitalHumanTaskList = defineAsyncComponent(() => import('../digital-human-task/index.vue'))
|
||||
|
||||
// 导航项配置
|
||||
const navItems = [
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
type: 'mix-task',
|
||||
label: '混剪视频任务',
|
||||
icon: 'video',
|
||||
component: MixTaskList
|
||||
icon: VideoCameraOutlined,
|
||||
component: markRaw(defineAsyncComponent(() => import('../mix-task/index.vue')))
|
||||
},
|
||||
{
|
||||
type: 'digital-human-task',
|
||||
label: '数字人视频任务',
|
||||
icon: 'user',
|
||||
component: DigitalHumanTaskList
|
||||
icon: UserOutlined,
|
||||
component: markRaw(defineAsyncComponent(() => import('../digital-human-task/index.vue')))
|
||||
}
|
||||
]
|
||||
|
||||
// 当前组件
|
||||
const currentComponent = computed(() => {
|
||||
const item = navItems.find(item => item.type === currentType.value)
|
||||
if (!item) {
|
||||
return navItems[0].component
|
||||
}
|
||||
return item.component
|
||||
return NAV_ITEMS.find(item => item.type === currentType.value)?.component ?? NAV_ITEMS[0].component
|
||||
})
|
||||
|
||||
// 导航到指定类型
|
||||
const navigateTo = (type) => {
|
||||
router.push(`/system/task-management/${type}`)
|
||||
}
|
||||
@@ -99,141 +72,96 @@ const navigateTo = (type) => {
|
||||
.task-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* 左侧导航 */
|
||||
.task-layout__sidebar {
|
||||
width: 220px;
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--color-gray-200);
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.is-mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航头部 */
|
||||
.task-layout__nav-header {
|
||||
height: 64px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--space-6);
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
}
|
||||
|
||||
.task-layout__nav-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-gray-800);
|
||||
}
|
||||
|
||||
/* 导航列表 */
|
||||
.task-layout__nav-list {
|
||||
list-style: none;
|
||||
padding: var(--space-1) 0;
|
||||
padding: var(--space-2) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 导航项 */
|
||||
.task-layout__nav-item {
|
||||
margin: 4px var(--space-2);
|
||||
margin: var(--space-1) var(--space-3);
|
||||
|
||||
&.is-active {
|
||||
.task-layout__nav-link {
|
||||
background: var(--color-primary);
|
||||
&.is-active .task-layout__nav-link {
|
||||
background: var(--color-primary-500);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
.nav-icon {
|
||||
color: #fff;
|
||||
|
||||
.nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航链接 */
|
||||
.task-layout__nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border-radius: var(--radius-card);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gray-600);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-primary-500);
|
||||
}
|
||||
|
||||
.is-active & {
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航图标 */
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: var(--space-2);
|
||||
color: var(--color-text-3);
|
||||
transition: color 0.2s;
|
||||
|
||||
.task-layout__nav-item.is-active & {
|
||||
.is-active &:hover {
|
||||
background: var(--color-primary-600);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航文本 */
|
||||
.nav-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: var(--space-2);
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* 右侧内容 */
|
||||
.task-layout__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-bg);
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
padding: 0;
|
||||
}
|
||||
background: var(--color-bg-page);
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
transition: opacity var(--duration-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="mix-task-page">
|
||||
<div class="task-page">
|
||||
<!-- 筛选条件 -->
|
||||
<div class="mix-task-page__filters">
|
||||
<div class="task-page__filters">
|
||||
<a-space :size="16">
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
class="filter-select"
|
||||
placeholder="任务状态"
|
||||
@change="handleFilterChange"
|
||||
allow-clear
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
@@ -24,9 +24,7 @@
|
||||
allow-clear
|
||||
@press-enter="handleFilterChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
|
||||
<a-range-picker
|
||||
@@ -38,177 +36,138 @@
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
|
||||
<a-button type="primary" class="filter-button" @click="handleFilterChange">
|
||||
查询
|
||||
</a-button>
|
||||
<a-button class="filter-button" @click="handleResetFilters">
|
||||
重置
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleFilterChange">查询</a-button>
|
||||
<a-button @click="handleResetFilters">重置</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<div class="mix-task-page__content">
|
||||
<div class="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: 'max-content' }"
|
||||
@change="handleTableChange"
|
||||
@expandedRowsChange="handleExpandedRowsChange"
|
||||
>
|
||||
<!-- 标题列 -->
|
||||
<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>
|
||||
<a-tag v-if="record.text" size="small">有文案</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)" class="status-tag">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
<TaskStatusTag :status="record.status" />
|
||||
</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>
|
||||
<a-tag v-if="record.outputUrls?.length" color="success">
|
||||
{{ record.outputUrls.length }} 个视频
|
||||
</a-tag>
|
||||
<span v-else class="text-muted">-</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"
|
||||
v-if="canOperate(record, 'preview')"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="openPreviewModal(record)"
|
||||
class="action-btn-preview"
|
||||
class="action-btn action-btn--primary"
|
||||
@click="openPreview(record)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlayCircleOutlined />
|
||||
</template>
|
||||
预览
|
||||
<PlayCircleOutlined /> 预览
|
||||
</a-button>
|
||||
|
||||
<!-- 下载按钮 -->
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success') && record.outputUrls && record.outputUrls.length > 0"
|
||||
v-if="canOperate(record, 'download')"
|
||||
type="link"
|
||||
size="small"
|
||||
class="action-btn action-btn--success"
|
||||
@click="handleDownload(record)"
|
||||
class="action-btn-download"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
下载
|
||||
<DownloadOutlined /> 下载
|
||||
</a-button>
|
||||
|
||||
<!-- 取消按钮 -->
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'running')"
|
||||
v-if="canOperate(record, 'cancel')"
|
||||
size="small"
|
||||
@click="handleCancel(record.id)"
|
||||
>
|
||||
取消
|
||||
</a-button>
|
||||
|
||||
<!-- 重试按钮 -->
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'failed')"
|
||||
v-if="canOperate(record, 'retry')"
|
||||
size="small"
|
||||
@click="handleRetry(record.id)"
|
||||
>
|
||||
重试
|
||||
</a-button>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<a-popconfirm
|
||||
title="确定删除这个任务吗?删除后无法恢复。"
|
||||
@confirm="() => handleDelete(record.id)"
|
||||
>
|
||||
<a-button size="small" type="link" class="action-btn-delete">删除</a-button>
|
||||
<a-popconfirm title="确定删除?删除后无法恢复。" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" class="action-btn action-btn--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">
|
||||
<div v-if="record.outputUrls?.length" class="task-results">
|
||||
<div class="result-header">
|
||||
<strong>生成结果:</strong>
|
||||
<span class="result-count">{{ record.outputUrls.length }} 个视频</span>
|
||||
</div>
|
||||
<div class="result-list">
|
||||
<div
|
||||
v-for="(url, index) in record.outputUrls"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
>
|
||||
<div v-for="(_, index) in record.outputUrls" :key="index" class="result-item">
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePreviewSingle(record, index)"
|
||||
class="result-preview-btn"
|
||||
@click="previewVideo(record, index)"
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
视频 {{ index + 1 }}
|
||||
<PlayCircleOutlined /> 视频 {{ index + 1 }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDownloadSingle(record.id, index)"
|
||||
class="result-download-btn"
|
||||
@click="downloadVideo(record.id, index)"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</a-button>
|
||||
<span v-else class="processing-tip">
|
||||
视频 {{ index + 1 }} (处理中...)
|
||||
</span>
|
||||
<span v-else class="text-muted">视频 {{ index + 1 }} (处理中...)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="record.errorMsg" class="task-error">
|
||||
<a-alert
|
||||
type="error"
|
||||
:message="record.errorMsg"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
<a-alert v-if="record.errorMsg" type="error" :message="record.errorMsg" show-icon />
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -216,21 +175,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 预览模态框 -->
|
||||
<a-modal
|
||||
v-model:open="previewVisible"
|
||||
:title="previewTitle"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
:centered="true"
|
||||
class="preview-modal"
|
||||
>
|
||||
<div v-if="previewUrl" class="preview-container">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
controls
|
||||
autoplay
|
||||
style="width: 100%; max-height: 600px; border-radius: 8px;"
|
||||
>
|
||||
<a-modal v-model:open="preview.visible" :title="preview.title" width="800px" :footer="null" centered>
|
||||
<div v-if="preview.url" class="preview-container">
|
||||
<video :src="preview.url" controls autoplay class="preview-video">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
@@ -242,76 +189,64 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
PlayCircleOutlined,
|
||||
DownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ref, reactive, 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'
|
||||
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
||||
|
||||
// 使用 Composable
|
||||
const {
|
||||
loading,
|
||||
list,
|
||||
filters,
|
||||
paginationConfig,
|
||||
fetchList,
|
||||
handleFilterChange,
|
||||
handleResetFilters,
|
||||
handleTableChange,
|
||||
buildParams
|
||||
} = useTaskList(MixTaskService.getTaskPage)
|
||||
|
||||
// 使用任务操作 Composable
|
||||
const {
|
||||
handleDelete,
|
||||
handleCancel,
|
||||
handleRetry,
|
||||
handleBatchDownload
|
||||
} = useTaskOperations(
|
||||
{
|
||||
deleteApi: MixTaskService.deleteTask,
|
||||
cancelApi: MixTaskService.cancelTask,
|
||||
retryApi: MixTaskService.retryTask,
|
||||
getSignedUrlsApi: MixTaskService.getSignedUrls
|
||||
},
|
||||
// Composables
|
||||
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(MixTaskService.getTaskPage)
|
||||
const { handleDelete, handleCancel, handleRetry, handleBatchDownload } = useTaskOperations(
|
||||
{ deleteApi: MixTaskService.deleteTask, cancelApi: MixTaskService.cancelTask, retryApi: MixTaskService.retryTask, getSignedUrlsApi: MixTaskService.getSignedUrls },
|
||||
fetchList
|
||||
)
|
||||
useTaskPolling(MixTaskService.getTaskPage, { onTaskUpdate: fetchList })
|
||||
|
||||
// 预览相关状态
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('')
|
||||
// 展开行
|
||||
const expandedRowKeys = ref([])
|
||||
const handleExpandedRowsChange = (keys) => { expandedRowKeys.value = keys }
|
||||
|
||||
// 预览单个视频
|
||||
const handlePreviewSingle = async (record, index) => {
|
||||
// 预览状态
|
||||
const preview = reactive({ visible: false, title: '', url: '' })
|
||||
|
||||
// 状态判断
|
||||
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||
const canOperate = (record, action) => {
|
||||
const isSuccess = isStatus(record.status, 'success')
|
||||
const hasUrls = record.outputUrls?.length > 0
|
||||
const actions = {
|
||||
preview: isSuccess && hasUrls,
|
||||
download: isSuccess && hasUrls,
|
||||
cancel: isStatus(record.status, 'running'),
|
||||
retry: isStatus(record.status, 'failed')
|
||||
}
|
||||
return actions[action]
|
||||
}
|
||||
|
||||
// 预览视频
|
||||
const previewVideo = async (record, index) => {
|
||||
preview.title = `${record.title} - 视频 ${index + 1}`
|
||||
preview.visible = true
|
||||
preview.url = ''
|
||||
try {
|
||||
previewTitle.value = `${record.title} - 视频 ${index + 1}`
|
||||
previewVisible.value = true
|
||||
previewUrl.value = ''
|
||||
|
||||
// 获取签名URL
|
||||
const res = await MixTaskService.getSignedUrls(record.id)
|
||||
if (res.code === 0 && res.data && res.data[index]) {
|
||||
previewUrl.value = res.data[index]
|
||||
} else {
|
||||
console.warn('获取预览链接失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预览链接失败:', error)
|
||||
if (res.code === 0 && res.data?.[index]) preview.url = res.data[index]
|
||||
} catch (e) {
|
||||
console.error('获取预览链接失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载单个视频
|
||||
const handleDownloadSingle = async (taskId, index) => {
|
||||
const openPreview = (record) => previewVideo(record, 0)
|
||||
|
||||
// 下载视频
|
||||
const downloadVideo = async (taskId, index) => {
|
||||
try {
|
||||
const res = await MixTaskService.getSignedUrls(taskId)
|
||||
if (res.code === 0 && res.data && res.data[index]) {
|
||||
if (res.code === 0 && res.data?.[index]) {
|
||||
const link = document.createElement('a')
|
||||
link.href = res.data[index]
|
||||
link.download = `video_${taskId}_${index + 1}.mp4`
|
||||
@@ -319,242 +254,112 @@ const handleDownloadSingle = async (taskId, index) => {
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
console.warn('获取下载链接失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取下载链接失败:', error)
|
||||
} catch (e) {
|
||||
console.error('获取下载链接失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 预览任务(主列表)
|
||||
const openPreviewModal = async (record) => {
|
||||
await handlePreviewSingle(record, 0)
|
||||
}
|
||||
|
||||
// 下载任务
|
||||
const handleDownload = async (record) => {
|
||||
if (record.outputUrls && record.outputUrls.length > 0) {
|
||||
await handleBatchDownload(
|
||||
[],
|
||||
MixTaskService.getSignedUrls,
|
||||
record.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用轮询 Composable
|
||||
useTaskPolling(MixTaskService.getTaskPage, {
|
||||
onTaskUpdate: () => {
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
|
||||
// 扩展行键
|
||||
const expandedRowKeys = ref([])
|
||||
|
||||
// 处理展开行变化
|
||||
const handleExpandedRowsChange = (keys) => {
|
||||
expandedRowKeys.value = keys
|
||||
const handleDownload = (record) => {
|
||||
if (record.outputUrls?.length) handleBatchDownload([], MixTaskService.getSignedUrls, record.id)
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 70,
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90
|
||||
},
|
||||
{
|
||||
title: '生成结果',
|
||||
dataIndex: 'outputUrls',
|
||||
key: 'outputUrls',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 160
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'finishTime',
|
||||
key: 'finishTime',
|
||||
width: 160
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
fixed: 'right'
|
||||
}
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70, fixed: 'left' },
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
|
||||
{ title: '生成结果', dataIndex: 'outputUrls', key: 'outputUrls', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 160 },
|
||||
{ title: '完成时间', dataIndex: 'finishTime', key: 'finishTime', width: 160 },
|
||||
{ title: '操作', key: 'actions', width: 240, 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()
|
||||
}
|
||||
|
||||
// 删除未使用的方法
|
||||
// handleDownloadSignedUrl 和 handleDownloadAll 已被移除
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.mix-task-page {
|
||||
padding: 0 var(--space-3);
|
||||
.task-page {
|
||||
padding: var(--space-4);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
&__filters {
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filter-date-picker {
|
||||
width: 280px;
|
||||
}
|
||||
.task-page__filters {
|
||||
padding: var(--space-4);
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-3);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
.filter-date-picker {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标题单元格 */
|
||||
.task-page__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* 操作按钮样式 */
|
||||
.action-btn-preview {
|
||||
color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-hover, var(--color-blue-600));
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
.action-btn-download {
|
||||
color: var(--color-success);
|
||||
|
||||
&:hover {
|
||||
color: #059669;
|
||||
}
|
||||
.action-btn {
|
||||
&--primary { color: var(--color-primary-500); &:hover { color: var(--color-primary-600); } }
|
||||
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
|
||||
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
|
||||
}
|
||||
|
||||
.action-btn-delete {
|
||||
color: var(--color-error);
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
/* 展开内容 */
|
||||
.expanded-content {
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
margin: var(--space-2);
|
||||
}
|
||||
|
||||
.task-text {
|
||||
margin-bottom: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
p {
|
||||
margin: var(--space-2) 0 0 0;
|
||||
padding: var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
line-height: 1.6;
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--color-gray-100);
|
||||
border-radius: var(--radius-md);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
}
|
||||
|
||||
.task-results {
|
||||
margin-bottom: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3, #8c8c8c);
|
||||
}
|
||||
.result-count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
.result-list {
|
||||
@@ -562,72 +367,20 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.result-preview-btn {
|
||||
color: var(--color-primary);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-hover, var(--color-blue-600));
|
||||
}
|
||||
}
|
||||
|
||||
.result-download-btn {
|
||||
color: var(--color-success);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
color: #059669;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.processing-tip {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-gray-100);
|
||||
border-radius: var(--radius-md);
|
||||
transition: box-shadow var(--duration-fast) var(--ease-out);
|
||||
|
||||
.task-error {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* 确保按钮内的图标和文字对齐 */
|
||||
: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;
|
||||
}
|
||||
|
||||
/* 预览模态框样式 */
|
||||
.preview-modal {
|
||||
:deep(.ant-modal-body) {
|
||||
padding: var(--space-3);
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +391,12 @@ onMounted(() => {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -645,5 +404,16 @@ onMounted(() => {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 桌面端样式优化 */
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: var(--color-gray-50);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -216,7 +216,7 @@ onMounted(async () => {
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">剩余积分</div>
|
||||
<div class="stat-value">{{ formatCredits(userStore.remainingPoints) }}</div>
|
||||
<div class="stat-desc">用于AI生成消耗</div>
|
||||
<div class="stat-desc">用于生成消耗</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
Reference in New Issue
Block a user