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:
2026-02-26 20:45:51 +08:00
parent 72fa2c63a1
commit 1e5a1d422b
6 changed files with 386 additions and 1223 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>