feat: 新增批量处理任务管理页面,包含任务概览、列表及创建向导。

This commit is contained in:
sladro 2026-03-03 16:34:28 +08:00
parent 0c17093e59
commit e1f745cf21

View File

@ -18,28 +18,28 @@
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-spinner fa-spin"></i></div>
<div class="stat-info">
<div class="stat-value">0</div>
<div class="stat-value">{{ jobs.filter(j => j.status === 'running').length }}</div>
<div class="stat-label">运行中任务</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<div class="stat-value">0</div>
<div class="stat-value">{{ jobs.filter(j => ['completed', 'completed_with_errors'].includes(j.status)).length }}</div>
<div class="stat-label">已完成</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon warning"><i class="fas fa-clock"></i></div>
<div class="stat-info">
<div class="stat-value">0</div>
<div class="stat-value">{{ jobs.filter(j => j.status === 'pending').length }}</div>
<div class="stat-label">排队中</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon danger"><i class="fas fa-times-circle"></i></div>
<div class="stat-info">
<div class="stat-value">0</div>
<div class="stat-value">{{ jobs.filter(j => j.status === 'failed').length }}</div>
<div class="stat-label">失败/异常</div>
</div>
</div>
@ -52,9 +52,9 @@
<div class="filter-group">
<el-select v-model="statusFilter" placeholder="状态过滤" size="small">
<el-option label="全部" value="all" />
<el-option label="待执行" value="pending" />
<el-option label="运行中" value="running" />
<el-option label="排队中" value="queued" />
<el-option label="已完成" value="succeeded" />
<el-option label="已完成" value="completed" />
<el-option label="失败/异常" value="failed" />
</el-select>
</div>
@ -114,8 +114,8 @@
<el-table-column label="操作" width="160" fixed="right">
<template #default="scope">
<!-- 取消已支持如果不是终态则允许取消 -->
<el-button v-if="['running', 'queued'].includes(scope.row.status)" type="danger" size="small" link @click="cancelJob(scope.row)">取消批次</el-button>
<el-button v-if="['succeeded', 'failed'].includes(scope.row.status)" type="primary" size="small" link @click="viewDetails(scope.row)">详情与报告</el-button>
<el-button v-if="['running', 'pending'].includes(scope.row.status)" type="danger" size="small" link @click="cancelJob(scope.row)">取消批次</el-button>
<el-button v-if="['completed', 'completed_with_errors', 'failed'].includes(scope.row.status)" type="primary" size="small" link @click="viewDetails(scope.row)">详情与报告</el-button>
</template>
</el-table-column>
</el-table>
@ -202,15 +202,16 @@
</template>
<template v-else-if="cadSettings[cadType].strategy === 'shell_execute'">
<el-form-item label="细节级别 (LOD)">
<el-radio-group v-model="cadSettings[cadType].params.lod">
<el-radio label="粗糙">粗糙 (Coarse)</el-radio>
<el-radio label="中等">中等 (Medium)</el-radio>
<el-radio label="精细">精细 (Fine)</el-radio>
</el-radio-group>
<el-form-item label="处理模式">
<el-select v-model="cadSettings[cadType].params.mode" placeholder="请选择模式" style="width: 250px">
<el-option label="保守保留 (Conservative)" value="Conservative" />
<el-option label="标准平衡 (Standard)" value="Standard" />
<el-option label="激进删除 (Aggressive)" value="Aggressive" />
<el-option label="仅保留外壳 (EnvelopeOnly)" value="EnvelopeOnly" />
</el-select>
</el-form-item>
<el-form-item label="移除内部构件">
<el-switch v-model="cadSettings[cadType].params.removeInternal" active-text="" inactive-text="" />
<el-form-item label="备份原文件">
<el-switch v-model="cadSettings[cadType].params.backupOriginal" active-text="" inactive-text="" />
</el-form-item>
</template>
@ -279,7 +280,7 @@ const cadSettings = ref({
},
revit: {
strategy: 'shell_execute',
params: { lod: '粗糙', removeInternal: true }
params: { mode: 'EnvelopeOnly', backupOriginal: false }
},
pdms: {
strategy: 'pdms_simplify',
@ -306,7 +307,7 @@ const detectedCadTypes = computed(() => {
const newModelPath = ref('')
// - queued -> dispatching -> waiting_callback -> succeeded/failed
//
const jobs = ref([])
const filteredJobs = computed(() => {
@ -315,15 +316,129 @@ const filteredJobs = computed(() => {
})
// === WebSocket ===
// WebSocket submit_batch_tasks / batch_item_update
const activeBatchIds = new Set()
let pollTimer = null
const handleWsMessage = (msg) => {
if (msg.type === 'batch_created' || msg.type === 'batch_completed') {
if (msg.data && msg.data.batch) {
updateBatchInJobs(msg.data.batch, msg.data.items || [])
}
} else if (msg.type === 'batch_item_update') {
if (msg.data && msg.data.batch && msg.data.item) {
mergeBatchItemUpdate(msg.data.batch, msg.data.item)
}
} else if (msg.type === 'info' && msg.data && msg.data.batch) {
// Check if backend uses generic info or batch_status_response for updates
updateBatchInJobs(msg.data.batch, msg.data.items || [])
}
}
const isTerminalBatchStatus = (status) => {
return status === 'completed' || status === 'completed_with_errors' || status === 'failed'
}
const mapItemData = (it) => {
return {
id: it.id,
modelPath: it.model_path,
taskType: it.task_type,
status: it.status,
retry: `${it.retry_count || 0}/${it.max_retries || 1}`,
errorMessage: it.error_message || '',
finishedAt: it.finished_at || it.started_at || it.created_at || '-'
}
}
const updateBatchInJobs = (batchData, itemsData) => {
const existingJob = jobs.value.find(j => j.batchId === batchData.id)
const mappedItems = itemsData.map(mapItemData).sort((a,b) => {
const aItem = itemsData.find(i => i.id === a.id)
const bItem = itemsData.find(i => i.id === b.id)
return (aItem?.sequence || 0) - (bItem?.sequence || 0)
})
const progressCalc = batchData.total_count ? Math.round(((batchData.succeeded_count + batchData.failed_count) / batchData.total_count) * 100) : 0
if (existingJob) {
existingJob.status = batchData.status
existingJob.completedCount = batchData.succeeded_count + batchData.failed_count
existingJob.totalCount = batchData.total_count
existingJob.progress = progressCalc
existingJob.items = mappedItems
} else {
jobs.value.unshift({
batchId: batchData.id,
strategy: batchData.metadata?.strategy || batchData.name || '批量处理',
priority: batchData.metadata?.priority || '普通',
progress: progressCalc,
status: batchData.status,
totalCount: batchData.total_count || 0,
completedCount: (batchData.succeeded_count || 0) + (batchData.failed_count || 0),
createdAt: new Date(batchData.created_at).toLocaleString(),
items: mappedItems,
rawBatch: batchData
})
}
managePolling(batchData)
}
const mergeBatchItemUpdate = (batchData, itemData) => {
const existingJob = jobs.value.find(j => j.batchId === batchData.id)
if (!existingJob) {
// batch_created
websocketService.send({ type: 'get_batch_status', batch_id: batchData.id })
return
}
existingJob.status = batchData.status
existingJob.completedCount = batchData.succeeded_count + batchData.failed_count
existingJob.totalCount = batchData.total_count
existingJob.progress = batchData.total_count ? Math.round((existingJob.completedCount / batchData.total_count) * 100) : 0
const mappedItem = mapItemData(itemData)
const itemIndex = existingJob.items.findIndex(it => it.id === itemData.id)
if (itemIndex > -1) {
existingJob.items.splice(itemIndex, 1, mappedItem)
} else {
existingJob.items.push(mappedItem)
}
managePolling(batchData)
}
const managePolling = (batchData) => {
if (!isTerminalBatchStatus(batchData.status)) {
activeBatchIds.add(batchData.id)
} else {
activeBatchIds.delete(batchData.id)
}
if (activeBatchIds.size > 0 && !pollTimer) {
pollTimer = window.setInterval(() => {
activeBatchIds.forEach(id => {
websocketService.send({ type: 'get_batch_status', batch_id: id })
})
}, 4000)
} else if (activeBatchIds.size === 0 && pollTimer) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(() => {
console.log('BatchJobManager mounted')
// websocketService.on(...)
websocketService.on('onMessage', handleWsMessage)
})
onUnmounted(() => {
console.log('BatchJobManager unmounted')
// websocketService.off(...)
websocketService.off('onMessage', handleWsMessage)
if (pollTimer) {
window.clearInterval(pollTimer)
pollTimer = null
}
})
//
@ -340,7 +455,7 @@ const openWizard = () => {
},
revit: {
strategy: 'shell_execute',
params: { lod: '粗糙', removeInternal: true }
params: { mode: 'EnvelopeOnly', backupOriginal: false }
},
pdms: {
strategy: 'pdms_simplify',
@ -354,7 +469,7 @@ const onStrategyChange = (cadType, val) => {
if (val === 'creo_shrinkwrap') {
cadSettings.value[cadType].params = { quality: 5, exportFormat: 'step', ignoreSkeletons: true, ignoreQuilts: false, fillHoles: false }
} else if (val === 'shell_execute') {
cadSettings.value[cadType].params = { lod: '粗糙', removeInternal: true }
cadSettings.value[cadType].params = { mode: 'EnvelopeOnly', backupOriginal: false }
} else if (val.includes('pdms')) {
cadSettings.value[cadType].params = { levelFilter: '', removeAttachments: true }
} else {
@ -369,7 +484,8 @@ const submitTask = () => {
return
}
const batchId = `B-${Date.now().toString().slice(-8)}`
const batchName = `B-${Date.now().toString().slice(-8)}`
const strategiesUsed = detectedCadTypes.value.map(t => cadSettings.value[t].strategy).join(', ') || '混合组合策略'
// submit_batch_tasks WebSocket
const items = taskForm.value.modelPaths.map(p => {
@ -380,43 +496,31 @@ const submitTask = () => {
strategy = cadSettings.value[type].strategy;
params = cadSettings.value[type].params;
}
// Revit
if (type === 'revit' && (!strategy || strategy === 'revit_shell')) {
strategy = 'shell_execute';
}
return {
model_path: p,
task_type: strategy,
params: params
task_params: params // task_params params
}
})
websocketService.send({
type: 'submit_batch_tasks',
batch_name: batchId,
batch_name: batchName,
metadata: {
strategy: strategiesUsed,
priority: taskForm.value.priority
},
items: items
})
// Display logic config
const strategiesUsed = detectedCadTypes.value.map(t => cadSettings.value[t].strategy).join(', ') || '混合组合策略'
ElMessage.success('批量任务已成功提交至全局串行队列!')
ElMessage.success('批量任务已成功提交至全局串行队列,等待状态同步...')
wizardVisible.value = false
// ( batch_created )
jobs.value.unshift({
batchId: batchId,
strategy: strategiesUsed,
priority: taskForm.value.priority,
params: { }, // mix params
progress: 0,
status: 'queued',
totalCount: taskForm.value.modelPaths.length,
completedCount: 0,
createdAt: new Date().toLocaleString(),
items: taskForm.value.modelPaths.map(p => ({
modelPath: p,
status: 'queued',
errorMessage: '',
finishedAt: '-'
}))
})
}
const addModelPath = () => {
@ -425,7 +529,7 @@ const addModelPath = () => {
//
const lowerPath = path.toLowerCase();
const validExtensions = ['.prt', '.asm', '.rvt', '.rfa', '.ifc', 'sam', 'mdb']
const validExtensions = ['.prt', '.asm', '.rvt', '.rfa', '.ifc', '.sam', '.mdb']
const isValid = validExtensions.some(ext => lowerPath.endsWith(ext) || lowerPath.includes(ext))
if (!isValid) {
@ -447,11 +551,11 @@ const removeModelPath = (index) => {
//
const getStatusLabel = (status) => {
const map = { running: '执行中', succeeded: '已完成', queued: '排队中', failed: '失败/异常' }
const map = { pending: '待执行', running: '执行中', completed: '已完成', completed_with_errors: '部分失败', queued: '排队中', failed: '失败/异常' }
return map[status] || status
}
const getStatusType = (status) => {
const map = { running: 'primary', succeeded: 'success', queued: 'info', failed: 'danger' }
const map = { pending: 'info', running: 'primary', completed: 'success', completed_with_errors: 'warning', queued: 'info', failed: 'danger' }
return map[status] || 'info'
}
const getPriorityType = (priority) => {
@ -459,7 +563,8 @@ const getPriorityType = (priority) => {
return map[priority] || 'info'
}
const getProgressStatus = (status) => {
if (status === 'succeeded') return 'success'
if (status === 'completed') return 'success'
if (status === 'completed_with_errors') return 'warning'
if (status === 'failed') return 'exception'
return '' /* running/queued 无特殊状态效果 */
}
@ -468,8 +573,8 @@ const getProgressStatus = (status) => {
const getItemStatusLabel = (status) => {
const map = {
queued: '排队中',
dispatching: '正在下发',
waiting_callback: '执行中(等待回调)',
dispatching: '处理中',
waiting_callback: '等待插件回调',
succeeded: '成功',
failed: '失败'
}