feat(data-process): 完善后台生成与失败重试

This commit is contained in:
caoxiaozhu
2026-07-28 10:56:05 +08:00
parent 8a6a6574bb
commit 01d2e6c76a
178 changed files with 4472 additions and 595 deletions

View File

@@ -27,6 +27,7 @@ import {
} from './create/useDataProcessSourceUpload'
import { useModelsStore } from '@/stores/models'
import {
confirmDataProcessResults,
createDataProcessPreview,
createDataProcessTask,
deleteDataProcessPreview,
@@ -37,10 +38,16 @@ import {
testDataProcessExternalSource,
updateDataProcessPreview,
updateDataProcessTask,
updateDataProcessWorkflowStep,
type DataProcessExternalSourcePayload,
type DataProcessPreviewItem,
} from '@/api/modules/dataProcess'
import type { DataProcessConfig } from '@/types/dataProcess'
import type {
DataProcessConfig,
DataProcessPreviewProgress,
DataProcessTask,
DataProcessWorkflowStep,
} from '@/types/dataProcess'
import type {
ExternalDataSource,
GenerationControlOptions,
@@ -81,7 +88,11 @@ const modelSelectionOptions = computed<GenerationControlOptions>(() => (
))
const uploadedFiles = ref<UploadedDataFile[]>([])
const previewBuilding = ref(false)
const { buildPreviewsByFile } = useDataProcessPreviewBuild()
const {
resumePreviewBuild,
startPreviewBuild,
stopPreviewPolling,
} = useDataProcessPreviewBuild()
const externalSource = reactive<ExternalDataSource>({
type: 'postgresql',
url: '',
@@ -104,14 +115,18 @@ const dirty = ref(false)
const modelSubmitLoading = ref(false)
let allowLeave = false
const {
bulkRegeneration,
generation,
regeneratingResultId,
resultRegenerationBusy,
results,
selectedResultId,
persistResultChanges,
resetDownstream,
restoreResult,
regenerateAllResults,
regenerateResult,
resumeGeneration,
startGeneration,
stopGeneration,
stopGenerationTimer,
updateResultField,
validateResults,
@@ -241,6 +256,26 @@ function taskPayload() {
}
}
async function persistWorkflowStep(step: StepId) {
if (!taskId.value) return
await updateDataProcessWorkflowStep(taskId.value, step as DataProcessWorkflowStep)
}
async function saveTaskConfiguration() {
if (isRegeneration.value) {
const regenerated = await prepareRegeneration(taskPayload())
taskId.value = String(regenerated.task.id)
dirty.value = false
return regenerated.task
}
const saved = taskId.value
? await updateDataProcessTask(taskId.value, taskPayload())
: await createDataProcessTask(taskPayload())
taskId.value = String(saved.id)
dirty.value = false
return saved
}
function externalPayload(): DataProcessExternalSourcePayload {
return {
type: 'postgresql',
@@ -260,6 +295,7 @@ function mapPreviewItem(item: DataProcessPreviewItem): PreviewItem {
sourceFileId: String(item.source_file_id),
originalContent: item.original_content,
editedContent: item.edited_content,
savedEditedContent: item.edited_content,
sourceStart: item.source_start,
sourceEnd: item.source_end,
sourceStartLine: item.source_start_line,
@@ -273,6 +309,18 @@ function mapPreviewItem(item: DataProcessPreviewItem): PreviewItem {
}
}
async function loadAllPreviewItems() {
if (!taskId.value) return []
const first = await getDataProcessPreview(taskId.value, { page: 1, page_size: 500 })
const items = [...first.items]
const pages = Math.ceil(first.total / first.page_size)
for (let page = 2; page <= pages; page += 1) {
const next = await getDataProcessPreview(taskId.value, { page, page_size: 500 })
items.push(...next.items)
}
return items.map(mapPreviewItem)
}
function previewAffectingOptions() {
return previewAffectingOptionsFor(processType.value, structuredOptions.value, unstructuredOptions.value)
}
@@ -291,6 +339,7 @@ function buildPreviewSignature() {
}
const {
isRegeneration,
isWorkflowResume,
originalProcessType,
regenerationPrepared,
hydrating,
@@ -299,8 +348,6 @@ const {
confirmPreviewConfigChange,
prepareRegeneration,
confirmStartGeneration,
returnToDetail,
leaveWarning,
} = useDataProcessRegeneration({
task,
processType,
@@ -324,8 +371,6 @@ async function beforeStartGeneration() {
syncPreviewChanges,
)
}
const leaveWarningMessage = computed(() => leaveWarning(generation.status))
const leaveConfirmText = computed(() => isRegeneration.value ? '返回详情' : '放弃修改')
watch(
[() => task.name, () => task.description, processType, structuredOptions, unstructuredOptions, externalSource],
() => {
@@ -504,6 +549,14 @@ async function nextFromCreate() {
confirmDialogRef.value?.open(options) ?? Promise.resolve(false)
))
if (!confirmed) return
if (!isRegeneration.value) {
try {
await saveTaskConfiguration()
await persistWorkflowStep('model')
} catch {
return
}
}
goToStep('model')
}
@@ -527,18 +580,17 @@ async function nextFromModel() {
if (isRegeneration.value) {
const regenerated = await prepareRegeneration(taskPayload())
taskId.value = String(regenerated.task.id)
dirty.value = false
if (regenerated.preview_invalidated) {
ElMessage.info('切分配置已保存,后续将按新配置重新切分;点击“开始生成”前,原生成结果和已发布数据保持不变')
} else if (regenerated.published_outputs_preserved) {
ElMessage.info('重新生成配置已保存;点击“开始生成”前,原生成结果和已发布数据保持不变')
}
} else {
const saved = taskId.value
? await updateDataProcessTask(taskId.value, taskPayload())
: await createDataProcessTask(taskPayload())
taskId.value = String(saved.id)
await saveTaskConfiguration()
}
dirty.value = true
await persistWorkflowStep('upload')
dirty.value = false
goToStep('upload')
} catch {
// 请求层已展示名称冲突或配置非法等具体原因。
@@ -547,6 +599,76 @@ async function nextFromModel() {
}
}
function applyPreviewProgress(progress: DataProcessPreviewProgress) {
const active = progress.preview_status === 'queued' || progress.preview_status === 'running'
previewBuilding.value = active
for (const file of uploadedFiles.value) {
if (!file.sourceFileId || file.previewStatus === 'success') continue
if (active) {
file.previewStatus = 'processing'
file.previewProgress = Math.max(0, Math.min(99, Number(progress.preview_progress) || 0))
file.previewError = undefined
} else if (progress.preview_status === 'failed' || progress.preview_status === 'cancelled') {
file.previewStatus = 'failed'
file.previewProgress = 0
file.previewError = progress.preview_failure_reason || '切分失败,请重试'
}
}
}
async function completePreviewWorkspace() {
previewItems.value = await loadAllPreviewItems()
const configSignature = buildPreviewConfigSignature()
const previewCounts = new Map<string, number>()
for (const item of previewItems.value) {
previewCounts.set(item.sourceFileId, (previewCounts.get(item.sourceFileId) || 0) + 1)
}
for (const file of uploadedFiles.value) {
const count = previewCounts.get(String(file.sourceFileId)) || 0
file.previewCount = count
file.previewStatus = count > 0 ? 'success' : 'failed'
file.previewProgress = count > 0 ? 100 : 0
file.previewError = count > 0 ? undefined : '该文件没有生成可用的预览条目'
file.previewConfigSignature = count > 0 ? configSignature : undefined
}
const failedCount = uploadedFiles.value.filter((file) => file.previewStatus === 'failed').length
if (failedCount || !previewItems.value.length) {
ElMessage.warning(`${failedCount || 1} 个文件切分失败;修正问题后可重新切分`)
return false
}
selectedPreviewFileId.value = String(uploadedFiles.value[0]?.uid ?? '') || null
selectedPreviewId.value = activePreviewItems.value[0]?.id ?? null
selectedPreviewIdsByFile.value = selectedPreviewId.value && selectedPreviewFileId.value
? { [selectedPreviewFileId.value]: selectedPreviewId.value }
: {}
previewSignature.value = buildPreviewSignature()
resetDownstream()
await persistWorkflowStep('preview')
dirty.value = false
goToStep('preview')
return true
}
async function monitorPreviewBuild(sourceFileIds: string[], resume = false) {
if (!taskId.value) return
previewBuilding.value = true
try {
const finalProgress = resume
? await resumePreviewBuild(taskId.value, applyPreviewProgress)
: await startPreviewBuild(taskId.value, sourceFileIds, applyPreviewProgress)
applyPreviewProgress(finalProgress)
if (finalProgress.preview_status === 'completed') {
await completePreviewWorkspace()
} else if (finalProgress.preview_status === 'failed') {
ElMessage.warning(finalProgress.preview_failure_reason || '文件切分失败,请修正后重试')
}
} catch {
// 请求层已经展示错误;后端运行状态可在重新进入任务后继续查询。
} finally {
previewBuilding.value = false
}
}
async function nextFromUpload() {
if (previewBuilding.value || sourceUploading.value) return
if (!taskId.value) {
@@ -586,59 +708,12 @@ async function nextFromUpload() {
file.previewCount = undefined
}
previewBuilding.value = true
try {
const pendingFileIds = uploadedFiles.value
.filter((file) => file.previewStatus !== 'success' || file.previewConfigSignature !== configSignature)
.map((file) => file.sourceFileId)
.filter((fileId): fileId is string => Boolean(fileId))
// 构建接口是同步响应:请求中只展示真实的“处理中”,响应成功后才记为 100%。
await buildPreviewsByFile(taskId.value, pendingFileIds, (progress) => {
const file = uploadedFiles.value.find((item) => (
String(item.sourceFileId) === String(progress.source_file_id)
))
if (!file) return
file.previewStatus = progress.status
file.previewProgress = progress.progress
file.previewCount = progress.preview_count
file.previewError = progress.error
if (progress.status === 'success') file.previewConfigSignature = configSignature
})
const failedCount = uploadedFiles.value.filter((file) => file.previewStatus === 'failed').length
if (failedCount || !allFilesSucceeded()) {
ElMessage.warning(`${failedCount || 1} 个文件切分失败;修正问题后点击“继续:数据预览”即可重试`)
return
}
try {
const first = await getDataProcessPreview(taskId.value, { page: 1, page_size: 500 })
const items = [...first.items]
const pages = Math.ceil(first.total / first.page_size)
for (let page = 2; page <= pages; page += 1) {
const next = await getDataProcessPreview(taskId.value, { page, page_size: 500 })
items.push(...next.items)
}
previewItems.value = items.map(mapPreviewItem)
} catch {
ElMessage.warning('文件切分已经完成,但预览加载失败;请再次点击继续重试加载')
return
}
if (!previewItems.value.length) {
ElMessage.warning('源文件没有生成可用的预览条目,请检查文件内容和预处理配置')
return
}
selectedPreviewFileId.value = String(uploadedFiles.value[0]?.uid ?? '') || null
selectedPreviewId.value = activePreviewItems.value[0]?.id ?? null
selectedPreviewIdsByFile.value = selectedPreviewId.value && selectedPreviewFileId.value
? { [selectedPreviewFileId.value]: selectedPreviewId.value }
: {}
previewSignature.value = signature
resetDownstream()
goToStep('preview')
} finally {
previewBuilding.value = false
}
const pendingFileIds = uploadedFiles.value
.filter((file) => file.previewStatus !== 'success' || file.previewConfigSignature !== configSignature)
.map((file) => file.sourceFileId)
.filter((fileId): fileId is string => Boolean(fileId))
await persistWorkflowStep('upload')
await monitorPreviewBuild(pendingFileIds)
}
function selectPreviewFile(fileId: string) {
@@ -668,7 +743,9 @@ function updatePreviewContent(id: string, value: string) {
async function syncPreviewChanges() {
if (!taskId.value) throw new Error('任务尚未创建')
const changedItems = previewItems.value.filter((item) => item.status === 'modified' || item.status === 'manual')
const changedItems = previewItems.value.filter((item) => (
item.editedContent !== item.savedEditedContent
))
for (const item of changedItems) {
const saved = await updateDataProcessPreview(taskId.value, item.id, {
edited_content: item.editedContent,
@@ -751,21 +828,67 @@ async function handlePrimaryAction() {
ElMessage.warning('当前没有可生成的预览内容')
return
}
try {
await syncPreviewChanges()
await persistWorkflowStep('generate')
} catch {
return
}
dirty.value = false
goToStep('generate')
return
}
if (currentStepId.value === 'generate') {
if (generation.status === 'success') {
try {
await persistWorkflowStep('results')
} catch {
return
}
dirty.value = false
goToStep('results')
} else if (generation.status !== 'running') {
await startGeneration()
await handleStartGeneration()
}
return
}
await saveTask()
}
function handleBack() {
async function handleStartGeneration() {
try {
await persistWorkflowStep('generate')
} catch {
return
}
const started = await startGeneration()
if (!started) return
dirty.value = false
}
async function persistWorkspaceForStep(targetStep: StepId) {
if (isRegeneration.value && !dirty.value) return
if (currentStepId.value === 'create') {
if (!taskId.value && !task.name.trim()) return
const valid = await taskSetupRef.value?.validate()
if (!valid) throw new Error('请先完善任务信息')
await saveTaskConfiguration()
} else if (currentStepId.value === 'model' && dirty.value) {
await saveTaskConfiguration()
} else if (currentStepId.value === 'preview') {
await syncPreviewChanges()
} else if (currentStepId.value === 'results') {
await persistResultChanges()
}
if (taskId.value) await persistWorkflowStep(targetStep)
dirty.value = false
}
async function persistWorkspaceBeforeLeave() {
await persistWorkspaceForStep(currentStepId.value)
}
async function handleBack() {
if (sourceUploading.value) {
ElMessage.warning('请等待当前文件上传完成')
return
@@ -774,15 +897,25 @@ function handleBack() {
ElMessage.warning('请等待当前文件切分完成')
return
}
if (generation.status === 'running') {
ElMessage.warning('请先停止当前生成任务')
return
if (currentStepId.value === 'generate') return
if (currentStep.value > 0) {
const targetStep = WIZARD_STEPS[currentStep.value - 1]?.id
if (!targetStep) return
try {
await persistWorkspaceForStep(targetStep)
} catch {
return
}
currentStep.value -= 1
}
if (currentStep.value > 0) currentStep.value -= 1
}
async function saveTask() {
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回上一步重试')
return
}
if (!validateResults()) {
ElMessage.warning('请先修正校验失败的结果')
return
@@ -796,58 +929,103 @@ async function saveTask() {
ElMessage.warning('仍有结果未通过后端质量校验,请继续修正')
return
}
try {
await persistWorkflowStep('results')
await confirmDataProcessResults(taskId.value)
} catch {
return
}
dirty.value = false
allowLeave = true
ElMessage.success('数据处理任务已保存')
await router.push('/data-process')
ElMessage.success('生成结果已确认')
await router.replace({ name: 'data-process-detail', params: { id: taskId.value } })
}
async function handleCancel() {
if (!dirty.value) {
await returnToPreviousPage()
return
}
const confirmed = await confirmDialogRef.value?.open({
title: '确认离开当前页面?',
message: leaveWarningMessage.value,
confirmText: leaveConfirmText.value,
cancelText: '继续编辑',
tone: 'danger',
})
if (!confirmed) return
await returnToPreviousPage()
}
async function returnToPreviousPage() {
allowLeave = true
if (isRegeneration.value && await returnToDetail()) return
router.back()
await router.push({ name: 'data-process' })
}
onBeforeRouteLeave(async () => {
if (sourceUploading.value) {
ElMessage.warning('请等待当前文件上传完成后再离开页面')
onBeforeRouteLeave(async (to) => {
if (allowLeave) return true
if (to.name === 'data-process' && taskId.value) {
try {
await persistWorkspaceBeforeLeave()
allowLeave = true
return true
} catch {
ElMessage.error('当前步骤自动保存失败,已停留在本页,请重试')
return false
}
}
try {
await persistWorkspaceBeforeLeave()
allowLeave = true
return true
} catch {
ElMessage.error('当前步骤自动保存失败,已停留在本页,请重试')
return false
}
if (allowLeave || !dirty.value) return true
const confirmed = await confirmDialogRef.value?.open({
title: '确认离开当前页面?',
message: leaveWarningMessage.value,
confirmText: leaveConfirmText.value,
cancelText: '继续编辑',
tone: 'danger',
})
if (confirmed) allowLeave = true
return Boolean(confirmed)
})
function inferWorkflowStep(sourceTask: DataProcessTask): StepId {
if (sourceTask.status === 'running' || sourceTask.status === 'failed' || sourceTask.status === 'stopped') {
return 'generate'
}
if (sourceTask.status === 'completed' && sourceTask.results_confirmed === false) return 'generate'
if (Number(sourceTask.preview_count || previewItems.value.length) > 0) return 'preview'
if ((sourceTask.source_files || []).length > 0) return 'upload'
return 'model'
}
async function initializeExistingWorkflow() {
const sourceTask = await loadRegenerationSource()
if (!sourceTask) return
taskId.value = String(sourceTask.id)
if (!isWorkflowResume.value) return
if (sourceTask.status === 'completed' && sourceTask.results_confirmed !== false) {
allowLeave = true
await router.replace({ name: 'data-process-detail', params: { id: sourceTask.id } })
return
}
if (sourceTask.preview_status === 'queued' || sourceTask.preview_status === 'running') {
goToStep('upload')
applyPreviewProgress({
task_id: sourceTask.id,
workflow_step: 'upload',
preview_status: sourceTask.preview_status,
preview_progress: Number(sourceTask.preview_progress) || 0,
preview_run_id: sourceTask.preview_run_id,
preview_failure_reason: sourceTask.preview_failure_reason,
preview_total_files: sourceTask.preview_total_files,
preview_completed_files: sourceTask.preview_completed_files,
})
const sourceFileIds = uploadedFiles.value
.map((file) => file.sourceFileId)
.filter((fileId): fileId is string => Boolean(fileId))
void monitorPreviewBuild(sourceFileIds, true)
return
}
let resumeStep = (sourceTask.workflow_step || inferWorkflowStep(sourceTask)) as StepId
if (sourceTask.preview_status === 'failed') resumeStep = 'upload'
if (sourceTask.status === 'running') resumeStep = 'generate'
if (resumeStep === 'preview' && !previewItems.value.length) resumeStep = 'upload'
if (resumeStep === 'results' && sourceTask.status !== 'completed') resumeStep = 'generate'
goToStep(resumeStep)
if (resumeStep === 'generate' || resumeStep === 'results') await resumeGeneration()
}
onBeforeUnmount(() => {
stopPreviewPolling()
stopGenerationTimer()
})
onMounted(() => {
localStorage.removeItem('yg-data-process-create-draft')
void modelsStore.load(true)
if (isRegeneration.value) void loadRegenerationSource()
if (isRegeneration.value || isWorkflowResume.value) void initializeExistingWorkflow()
})
</script>
@@ -954,8 +1132,7 @@ onMounted(() => {
:preview-count="previewItems.length"
:modified-count="modifiedPreviewCount"
:generation="generation"
@stop="stopGeneration"
@retry="startGeneration"
@retry="handleStartGeneration"
/>
<ResultEditorStep
@@ -963,8 +1140,11 @@ onMounted(() => {
v-model:selected-id="selectedResultId"
:items="results"
:preview-items="previewItems"
:regenerating-result-id="regeneratingResultId"
:bulk-regeneration="bulkRegeneration"
@update:field="updateResultField"
@restore:item="restoreResult"
@regenerate:all="regenerateAllResults"
@regenerate:item="regenerateResult"
/>
</div>
</div>
@@ -972,7 +1152,11 @@ onMounted(() => {
<footer class="wizard-footer">
<div class="footer-left">
<el-button v-if="currentStep > 0" :disabled="previewBuilding || sourceUploading" @click="handleBack">
<el-button
v-if="currentStep > 0"
:disabled="currentStepId === 'generate' || previewBuilding || sourceUploading"
@click="handleBack"
>
<i class="fa fa-arrow-left" style="margin-right: 6px;" /> 返回{{ previousStepLabel }}
</el-button>
<el-button v-else @click="handleCancel">{{ isRegeneration ? '返回详情' : '取消' }}</el-button>
@@ -983,8 +1167,8 @@ onMounted(() => {
<el-button
class="wizard-primary-action"
type="primary"
:loading="modelSubmitLoading || generation.status === 'running' || (currentStepId === 'upload' && (sourceUploading || previewBuilding))"
:disabled="hydrating || modelSubmitLoading || Boolean(initializationError) || (currentStepId === 'generate' && generation.status === 'running') || previewBuilding || sourceUploading || (currentStepId === 'upload' && hasUnfinishedUploads)"
:loading="modelSubmitLoading || generation.status === 'running' || resultRegenerationBusy || (currentStepId === 'upload' && (sourceUploading || previewBuilding))"
:disabled="hydrating || modelSubmitLoading || Boolean(initializationError) || resultRegenerationBusy || (currentStepId === 'generate' && generation.status === 'running') || previewBuilding || sourceUploading || (currentStepId === 'upload' && hasUnfinishedUploads)"
@click="handlePrimaryAction"
>
{{ primaryActionLabel }} <i class="fa" :class="primaryActionIcon" style="margin-left: 6px;" />