609 lines
12 KiB
Vue
609 lines
12 KiB
Vue
|
|
<template>
|
||
|
|
<div class="task-status-page">
|
||
|
|
<div class="page-header">
|
||
|
|
<h1>任务状态监控</h1>
|
||
|
|
<div class="header-actions">
|
||
|
|
<button @click="refreshAll" class="btn-refresh" :disabled="loading">
|
||
|
|
{{ loading ? '刷新中...' : '刷新全部' }}
|
||
|
|
</button>
|
||
|
|
<button @click="triggerPolling" class="btn-poll" v-if="isAdmin">
|
||
|
|
手动轮询
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="stats-cards">
|
||
|
|
<div class="stat-card">
|
||
|
|
<div class="stat-icon pending">⏳</div>
|
||
|
|
<div class="stat-content">
|
||
|
|
<div class="stat-number">{{ stats.pending }}</div>
|
||
|
|
<div class="stat-label">待处理</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="stat-card">
|
||
|
|
<div class="stat-icon processing">🔄</div>
|
||
|
|
<div class="stat-content">
|
||
|
|
<div class="stat-number">{{ stats.processing }}</div>
|
||
|
|
<div class="stat-label">处理中</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="stat-card">
|
||
|
|
<div class="stat-icon completed">✅</div>
|
||
|
|
<div class="stat-content">
|
||
|
|
<div class="stat-number">{{ stats.completed }}</div>
|
||
|
|
<div class="stat-label">已完成</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="stat-card">
|
||
|
|
<div class="stat-icon failed">❌</div>
|
||
|
|
<div class="stat-content">
|
||
|
|
<div class="stat-number">{{ stats.failed }}</div>
|
||
|
|
<div class="stat-label">失败</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="task-list">
|
||
|
|
<div class="list-header">
|
||
|
|
<h2>任务列表</h2>
|
||
|
|
<div class="filter-controls">
|
||
|
|
<select v-model="statusFilter" @change="filterTasks">
|
||
|
|
<option value="">全部状态</option>
|
||
|
|
<option value="PENDING">待处理</option>
|
||
|
|
<option value="PROCESSING">处理中</option>
|
||
|
|
<option value="COMPLETED">已完成</option>
|
||
|
|
<option value="FAILED">失败</option>
|
||
|
|
<option value="CANCELLED">已取消</option>
|
||
|
|
<option value="TIMEOUT">超时</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="task-items">
|
||
|
|
<div
|
||
|
|
v-for="task in filteredTasks"
|
||
|
|
:key="task.taskId"
|
||
|
|
class="task-item"
|
||
|
|
:class="getTaskItemClass(task.status)"
|
||
|
|
>
|
||
|
|
<div class="task-main">
|
||
|
|
<div class="task-info">
|
||
|
|
<div class="task-id">{{ task.taskId }}</div>
|
||
|
|
<div class="task-type">{{ task.taskType?.description || task.taskType }}</div>
|
||
|
|
<div class="task-time">{{ formatDate(task.createdAt) }}</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="task-status">
|
||
|
|
<div class="status-badge" :class="getStatusClass(task.status)">
|
||
|
|
{{ task.statusDescription || task.status }}
|
||
|
|
</div>
|
||
|
|
<div class="progress-info" v-if="task.status === 'PROCESSING'">
|
||
|
|
<div class="progress-bar">
|
||
|
|
<div
|
||
|
|
class="progress-fill"
|
||
|
|
:style="{ width: task.progress + '%' }"
|
||
|
|
></div>
|
||
|
|
</div>
|
||
|
|
<span class="progress-text">{{ task.progress }}%</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="task-actions">
|
||
|
|
<button
|
||
|
|
v-if="task.status === 'PROCESSING'"
|
||
|
|
@click="cancelTask(task.taskId)"
|
||
|
|
class="btn-cancel"
|
||
|
|
>
|
||
|
|
取消
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
v-if="task.resultUrl"
|
||
|
|
@click="viewResult(task.resultUrl)"
|
||
|
|
class="btn-view"
|
||
|
|
>
|
||
|
|
查看结果
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
v-if="['FAILED', 'TIMEOUT'].includes(task.status)"
|
||
|
|
@click="retryTask(task.taskId)"
|
||
|
|
class="btn-retry"
|
||
|
|
>
|
||
|
|
重试
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="filteredTasks.length === 0" class="empty-state">
|
||
|
|
<div class="empty-icon">📋</div>
|
||
|
|
<div class="empty-text">暂无任务</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup>
|
||
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
|
|
import { useUserStore } from '@/stores/user'
|
||
|
|
import { taskStatusApi } from '@/api/taskStatus'
|
||
|
|
import { ElMessage } from 'element-plus'
|
||
|
|
|
||
|
|
const userStore = useUserStore()
|
||
|
|
|
||
|
|
const tasks = ref([])
|
||
|
|
const loading = ref(false)
|
||
|
|
const statusFilter = ref('')
|
||
|
|
const refreshTimer = ref(null)
|
||
|
|
|
||
|
|
// 计算属性
|
||
|
|
const isAdmin = computed(() => userStore.isAdmin)
|
||
|
|
|
||
|
|
const stats = computed(() => {
|
||
|
|
const stats = {
|
||
|
|
pending: 0,
|
||
|
|
processing: 0,
|
||
|
|
completed: 0,
|
||
|
|
failed: 0
|
||
|
|
}
|
||
|
|
|
||
|
|
tasks.value.forEach(task => {
|
||
|
|
switch (task.status) {
|
||
|
|
case 'PENDING':
|
||
|
|
stats.pending++
|
||
|
|
break
|
||
|
|
case 'PROCESSING':
|
||
|
|
stats.processing++
|
||
|
|
break
|
||
|
|
case 'COMPLETED':
|
||
|
|
stats.completed++
|
||
|
|
break
|
||
|
|
case 'FAILED':
|
||
|
|
case 'CANCELLED':
|
||
|
|
case 'TIMEOUT':
|
||
|
|
stats.failed++
|
||
|
|
break
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
return stats
|
||
|
|
})
|
||
|
|
|
||
|
|
const filteredTasks = computed(() => {
|
||
|
|
if (!statusFilter.value) {
|
||
|
|
return tasks.value
|
||
|
|
}
|
||
|
|
return tasks.value.filter(task => task.status === statusFilter.value)
|
||
|
|
})
|
||
|
|
|
||
|
|
// 方法
|
||
|
|
const fetchTasks = async () => {
|
||
|
|
try {
|
||
|
|
loading.value = true
|
||
|
|
const response = await taskStatusApi.getUserTaskStatuses(userStore.user?.username)
|
||
|
|
tasks.value = response.data
|
||
|
|
} catch (error) {
|
||
|
|
console.error('获取任务列表失败:', error)
|
||
|
|
ElMessage.error('获取任务列表失败')
|
||
|
|
} finally {
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const refreshAll = async () => {
|
||
|
|
await fetchTasks()
|
||
|
|
ElMessage.success('任务列表已刷新')
|
||
|
|
}
|
||
|
|
|
||
|
|
const filterTasks = () => {
|
||
|
|
// 过滤逻辑在计算属性中处理
|
||
|
|
}
|
||
|
|
|
||
|
|
const cancelTask = async (taskId) => {
|
||
|
|
try {
|
||
|
|
const response = await taskStatusApi.cancelTask(taskId)
|
||
|
|
if (response.data.success) {
|
||
|
|
ElMessage.success('任务已取消')
|
||
|
|
await fetchTasks()
|
||
|
|
} else {
|
||
|
|
ElMessage.error(response.data.message)
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('取消任务失败:', error)
|
||
|
|
ElMessage.error('取消任务失败')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const viewResult = (resultUrl) => {
|
||
|
|
window.open(resultUrl, '_blank')
|
||
|
|
}
|
||
|
|
|
||
|
|
const retryTask = (taskId) => {
|
||
|
|
// 重试逻辑,可以导航到相应的创建页面
|
||
|
|
ElMessage.info('重试功能开发中')
|
||
|
|
}
|
||
|
|
|
||
|
|
const triggerPolling = async () => {
|
||
|
|
try {
|
||
|
|
const response = await taskStatusApi.triggerPolling()
|
||
|
|
if (response.data.success) {
|
||
|
|
ElMessage.success('轮询已触发')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('触发轮询失败:', error)
|
||
|
|
ElMessage.error('触发轮询失败')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const getTaskItemClass = (status) => {
|
||
|
|
return `task-item-${status.toLowerCase()}`
|
||
|
|
}
|
||
|
|
|
||
|
|
const getStatusClass = (status) => {
|
||
|
|
return `status-${status.toLowerCase()}`
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatDate = (dateString) => {
|
||
|
|
if (!dateString) return '-'
|
||
|
|
return new Date(dateString).toLocaleString('zh-CN')
|
||
|
|
}
|
||
|
|
|
||
|
|
const startAutoRefresh = () => {
|
||
|
|
refreshTimer.value = setInterval(fetchTasks, 30000) // 30秒刷新一次
|
||
|
|
}
|
||
|
|
|
||
|
|
const stopAutoRefresh = () => {
|
||
|
|
if (refreshTimer.value) {
|
||
|
|
clearInterval(refreshTimer.value)
|
||
|
|
refreshTimer.value = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 生命周期
|
||
|
|
onMounted(() => {
|
||
|
|
fetchTasks()
|
||
|
|
startAutoRefresh()
|
||
|
|
})
|
||
|
|
|
||
|
|
onUnmounted(() => {
|
||
|
|
stopAutoRefresh()
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.task-status-page {
|
||
|
|
padding: 24px;
|
||
|
|
background: #0a0a0a;
|
||
|
|
min-height: 100vh;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 32px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-header h1 {
|
||
|
|
color: #fff;
|
||
|
|
font-size: 28px;
|
||
|
|
font-weight: 700;
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-refresh,
|
||
|
|
.btn-poll {
|
||
|
|
padding: 10px 20px;
|
||
|
|
border: none;
|
||
|
|
border-radius: 8px;
|
||
|
|
font-size: 14px;
|
||
|
|
font-weight: 500;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-refresh {
|
||
|
|
background: #3b82f6;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-refresh:hover:not(:disabled) {
|
||
|
|
background: #2563eb;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-refresh:disabled {
|
||
|
|
opacity: 0.5;
|
||
|
|
cursor: not-allowed;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-poll {
|
||
|
|
background: #10b981;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-poll:hover {
|
||
|
|
background: #059669;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stats-cards {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
|
|
gap: 20px;
|
||
|
|
margin-bottom: 32px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-card {
|
||
|
|
background: #1a1a1a;
|
||
|
|
border-radius: 12px;
|
||
|
|
padding: 20px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-icon {
|
||
|
|
font-size: 32px;
|
||
|
|
width: 48px;
|
||
|
|
height: 48px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
border-radius: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-icon.pending {
|
||
|
|
background: #fbbf24;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-icon.processing {
|
||
|
|
background: #3b82f6;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-icon.completed {
|
||
|
|
background: #10b981;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-icon.failed {
|
||
|
|
background: #ef4444;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-content {
|
||
|
|
flex: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-number {
|
||
|
|
font-size: 24px;
|
||
|
|
font-weight: 700;
|
||
|
|
color: #fff;
|
||
|
|
margin-bottom: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-label {
|
||
|
|
font-size: 14px;
|
||
|
|
color: #9ca3af;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-list {
|
||
|
|
background: #1a1a1a;
|
||
|
|
border-radius: 12px;
|
||
|
|
padding: 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.list-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.list-header h2 {
|
||
|
|
color: #fff;
|
||
|
|
font-size: 20px;
|
||
|
|
font-weight: 600;
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-controls select {
|
||
|
|
padding: 8px 12px;
|
||
|
|
border: 1px solid #374151;
|
||
|
|
border-radius: 6px;
|
||
|
|
background: #1a1a1a;
|
||
|
|
color: #fff;
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-items {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-item {
|
||
|
|
background: #0a0a0a;
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 16px;
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
border-left: 4px solid #374151;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-item-pending {
|
||
|
|
border-left-color: #fbbf24;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-item-processing {
|
||
|
|
border-left-color: #3b82f6;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-item-completed {
|
||
|
|
border-left-color: #10b981;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-item-failed,
|
||
|
|
.task-item-cancelled,
|
||
|
|
.task-item-timeout {
|
||
|
|
border-left-color: #ef4444;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-main {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 20px;
|
||
|
|
flex: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-info {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-id {
|
||
|
|
color: #fff;
|
||
|
|
font-weight: 600;
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-type {
|
||
|
|
color: #9ca3af;
|
||
|
|
font-size: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-time {
|
||
|
|
color: #6b7280;
|
||
|
|
font-size: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-status {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-badge {
|
||
|
|
padding: 4px 8px;
|
||
|
|
border-radius: 12px;
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: 500;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-pending {
|
||
|
|
background: #fbbf24;
|
||
|
|
color: #92400e;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-processing {
|
||
|
|
background: #3b82f6;
|
||
|
|
color: #1e40af;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-completed {
|
||
|
|
background: #10b981;
|
||
|
|
color: #064e3b;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-failed,
|
||
|
|
.status-cancelled,
|
||
|
|
.status-timeout {
|
||
|
|
background: #ef4444;
|
||
|
|
color: #7f1d1d;
|
||
|
|
}
|
||
|
|
|
||
|
|
.progress-info {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.progress-bar {
|
||
|
|
width: 100px;
|
||
|
|
height: 4px;
|
||
|
|
background: #374151;
|
||
|
|
border-radius: 2px;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.progress-fill {
|
||
|
|
height: 100%;
|
||
|
|
background: #3b82f6;
|
||
|
|
transition: width 0.3s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.progress-text {
|
||
|
|
color: #9ca3af;
|
||
|
|
font-size: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-cancel,
|
||
|
|
.btn-view,
|
||
|
|
.btn-retry {
|
||
|
|
padding: 6px 12px;
|
||
|
|
border: none;
|
||
|
|
border-radius: 4px;
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: 500;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-cancel {
|
||
|
|
background: #ef4444;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-cancel:hover {
|
||
|
|
background: #dc2626;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-view {
|
||
|
|
background: #3b82f6;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-view:hover {
|
||
|
|
background: #2563eb;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-retry {
|
||
|
|
background: #10b981;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-retry:hover {
|
||
|
|
background: #059669;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-state {
|
||
|
|
text-align: center;
|
||
|
|
padding: 40px;
|
||
|
|
color: #6b7280;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-icon {
|
||
|
|
font-size: 48px;
|
||
|
|
margin-bottom: 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-text {
|
||
|
|
font-size: 16px;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
|
||
|
|
|