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-card">
<div class="stat-icon"><i class="fas fa-spinner fa-spin"></i></div> <div class="stat-icon"><i class="fas fa-spinner fa-spin"></i></div>
<div class="stat-info"> <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 class="stat-label">运行中任务</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon success"><i class="fas fa-check-circle"></i></div> <div class="stat-icon success"><i class="fas fa-check-circle"></i></div>
<div class="stat-info"> <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 class="stat-label">已完成</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon warning"><i class="fas fa-clock"></i></div> <div class="stat-icon warning"><i class="fas fa-clock"></i></div>
<div class="stat-info"> <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 class="stat-label">排队中</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon danger"><i class="fas fa-times-circle"></i></div> <div class="stat-icon danger"><i class="fas fa-times-circle"></i></div>
<div class="stat-info"> <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 class="stat-label">失败/异常</div>
</div> </div>
</div> </div>
@ -52,9 +52,9 @@
<div class="filter-group"> <div class="filter-group">
<el-select v-model="statusFilter" placeholder="状态过滤" size="small"> <el-select v-model="statusFilter" placeholder="状态过滤" size="small">
<el-option label="全部" value="all" /> <el-option label="全部" value="all" />
<el-option label="待执行" value="pending" />
<el-option label="运行中" value="running" /> <el-option label="运行中" value="running" />
<el-option label="排队中" value="queued" /> <el-option label="已完成" value="completed" />
<el-option label="已完成" value="succeeded" />
<el-option label="失败/异常" value="failed" /> <el-option label="失败/异常" value="failed" />
</el-select> </el-select>
</div> </div>
@ -114,8 +114,8 @@
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="scope"> <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="['running', 'pending'].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="['completed', 'completed_with_errors', 'failed'].includes(scope.row.status)" type="primary" size="small" link @click="viewDetails(scope.row)">详情与报告</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -202,15 +202,16 @@
</template> </template>
<template v-else-if="cadSettings[cadType].strategy === 'shell_execute'"> <template v-else-if="cadSettings[cadType].strategy === 'shell_execute'">
<el-form-item label="细节级别 (LOD)"> <el-form-item label="处理模式">
<el-radio-group v-model="cadSettings[cadType].params.lod"> <el-select v-model="cadSettings[cadType].params.mode" placeholder="请选择模式" style="width: 250px">
<el-radio label="粗糙">粗糙 (Coarse)</el-radio> <el-option label="保守保留 (Conservative)" value="Conservative" />
<el-radio label="中等">中等 (Medium)</el-radio> <el-option label="标准平衡 (Standard)" value="Standard" />
<el-radio label="精细">精细 (Fine)</el-radio> <el-option label="激进删除 (Aggressive)" value="Aggressive" />
</el-radio-group> <el-option label="仅保留外壳 (EnvelopeOnly)" value="EnvelopeOnly" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="移除内部构件"> <el-form-item label="备份原文件">
<el-switch v-model="cadSettings[cadType].params.removeInternal" active-text="" inactive-text="" /> <el-switch v-model="cadSettings[cadType].params.backupOriginal" active-text="" inactive-text="" />
</el-form-item> </el-form-item>
</template> </template>
@ -279,7 +280,7 @@ const cadSettings = ref({
}, },
revit: { revit: {
strategy: 'shell_execute', strategy: 'shell_execute',
params: { lod: '粗糙', removeInternal: true } params: { mode: 'EnvelopeOnly', backupOriginal: false }
}, },
pdms: { pdms: {
strategy: 'pdms_simplify', strategy: 'pdms_simplify',
@ -306,7 +307,7 @@ const detectedCadTypes = computed(() => {
const newModelPath = ref('') const newModelPath = ref('')
// - queued -> dispatching -> waiting_callback -> succeeded/failed //
const jobs = ref([]) const jobs = ref([])
const filteredJobs = computed(() => { const filteredJobs = computed(() => {
@ -315,15 +316,129 @@ const filteredJobs = computed(() => {
}) })
// === WebSocket === // === 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(() => { onMounted(() => {
console.log('BatchJobManager mounted') websocketService.on('onMessage', handleWsMessage)
// websocketService.on(...)
}) })
onUnmounted(() => { onUnmounted(() => {
console.log('BatchJobManager unmounted') websocketService.off('onMessage', handleWsMessage)
// websocketService.off(...) if (pollTimer) {
window.clearInterval(pollTimer)
pollTimer = null
}
}) })
// //
@ -340,7 +455,7 @@ const openWizard = () => {
}, },
revit: { revit: {
strategy: 'shell_execute', strategy: 'shell_execute',
params: { lod: '粗糙', removeInternal: true } params: { mode: 'EnvelopeOnly', backupOriginal: false }
}, },
pdms: { pdms: {
strategy: 'pdms_simplify', strategy: 'pdms_simplify',
@ -354,7 +469,7 @@ const onStrategyChange = (cadType, val) => {
if (val === 'creo_shrinkwrap') { if (val === 'creo_shrinkwrap') {
cadSettings.value[cadType].params = { quality: 5, exportFormat: 'step', ignoreSkeletons: true, ignoreQuilts: false, fillHoles: false } cadSettings.value[cadType].params = { quality: 5, exportFormat: 'step', ignoreSkeletons: true, ignoreQuilts: false, fillHoles: false }
} else if (val === 'shell_execute') { } 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')) { } else if (val.includes('pdms')) {
cadSettings.value[cadType].params = { levelFilter: '', removeAttachments: true } cadSettings.value[cadType].params = { levelFilter: '', removeAttachments: true }
} else { } else {
@ -369,7 +484,8 @@ const submitTask = () => {
return 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 // submit_batch_tasks WebSocket
const items = taskForm.value.modelPaths.map(p => { const items = taskForm.value.modelPaths.map(p => {
@ -380,43 +496,31 @@ const submitTask = () => {
strategy = cadSettings.value[type].strategy; strategy = cadSettings.value[type].strategy;
params = cadSettings.value[type].params; params = cadSettings.value[type].params;
} }
// Revit
if (type === 'revit' && (!strategy || strategy === 'revit_shell')) {
strategy = 'shell_execute';
}
return { return {
model_path: p, model_path: p,
task_type: strategy, task_type: strategy,
params: params task_params: params // task_params params
} }
}) })
websocketService.send({ websocketService.send({
type: 'submit_batch_tasks', type: 'submit_batch_tasks',
batch_name: batchId, batch_name: batchName,
metadata: {
strategy: strategiesUsed,
priority: taskForm.value.priority
},
items: items items: items
}) })
// Display logic config ElMessage.success('批量任务已成功提交至全局串行队列,等待状态同步...')
const strategiesUsed = detectedCadTypes.value.map(t => cadSettings.value[t].strategy).join(', ') || '混合组合策略'
ElMessage.success('批量任务已成功提交至全局串行队列!')
wizardVisible.value = false 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 = () => { const addModelPath = () => {
@ -425,7 +529,7 @@ const addModelPath = () => {
// //
const lowerPath = path.toLowerCase(); 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)) const isValid = validExtensions.some(ext => lowerPath.endsWith(ext) || lowerPath.includes(ext))
if (!isValid) { if (!isValid) {
@ -447,11 +551,11 @@ const removeModelPath = (index) => {
// //
const getStatusLabel = (status) => { const getStatusLabel = (status) => {
const map = { running: '执行中', succeeded: '已完成', queued: '排队中', failed: '失败/异常' } const map = { pending: '待执行', running: '执行中', completed: '已完成', completed_with_errors: '部分失败', queued: '排队中', failed: '失败/异常' }
return map[status] || status return map[status] || status
} }
const getStatusType = (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' return map[status] || 'info'
} }
const getPriorityType = (priority) => { const getPriorityType = (priority) => {
@ -459,7 +563,8 @@ const getPriorityType = (priority) => {
return map[priority] || 'info' return map[priority] || 'info'
} }
const getProgressStatus = (status) => { 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' if (status === 'failed') return 'exception'
return '' /* running/queued 无特殊状态效果 */ return '' /* running/queued 无特殊状态效果 */
} }
@ -468,8 +573,8 @@ const getProgressStatus = (status) => {
const getItemStatusLabel = (status) => { const getItemStatusLabel = (status) => {
const map = { const map = {
queued: '排队中', queued: '排队中',
dispatching: '正在下发', dispatching: '处理中',
waiting_callback: '执行中(等待回调)', waiting_callback: '等待插件回调',
succeeded: '成功', succeeded: '成功',
failed: '失败' failed: '失败'
} }