diff --git a/frontend/scripts/regression-data-process-wizard.mjs b/frontend/scripts/regression-data-process-wizard.mjs index d9d83e2..9d0a078 100644 --- a/frontend/scripts/regression-data-process-wizard.mjs +++ b/frontend/scripts/regression-data-process-wizard.mjs @@ -722,19 +722,28 @@ assert.match(regenerationSource, /getDataProcessPreview\(taskId, \{ page: 1, pag assert.match(viewSource, /if \(hydrating\.value\) return/, '任务水合期间仍可能触发重置副作用') assert.match(regenerationSource, /currentSignature !== originalPreviewConfigSignature\.value[\s\S]*?currentSignature === confirmedPreviewConfigSignature\.value/, '切分变更确认没有按原签名和已确认签名去重') assert.match(regenerationSource, /if \(!bindings\.previewItems\.value\.length\) return true/, '没有现有切片时仍会弹出删除切片警告') -assert.ok(regenerationSource.includes('删除现有切片和生成结果;已发布三数据集暂时保留'), '切分变更警告没有说明完整影响范围') +assert.ok(regenerationSource.includes('点击“开始生成”前,原生成结果和已发布数据会继续保留'), '切分变更警告没有说明开始生成前原结果与发布数据仍保留') +assert.doesNotMatch(regenerationSource, /原切片、原生成结果/, '切分变更警告不应承诺实际重切后仍保留原切片') +assert.doesNotMatch(regenerationSource, /修改预处理或切分配置将删除现有切片和生成结果/, '准备配置阶段仍误称会立即删除原数据') assert.match(regenerationSource, /regenerateDataProcessTask\(sourceTaskId\.value,[\s\S]*?expected_updated_at: originalTaskUpdatedAt\.value/, '第二步没有携带最新乐观并发版本重新生成任务') assert.match(regenerationSource, /originalTaskUpdatedAt\.value = regenerated\.task\.updated_at/, '重新生成成功后没有更新下一次提交的并发版本') assert.match(regenerationSource, /if \(regenerationPrepared\.value\) \{[\s\S]*?getDataProcessTask\(sourceTaskId\.value\)[\s\S]*?originalTaskUpdatedAt\.value = latestTask\.updated_at/, '服务端中间操作后再次提交没有刷新任务并发版本') assert.match(regenerationSource, /regenerationPrepared\.value = true/, '重新生成提交成功后没有记录服务端已变更状态') assert.match(regenerationSource, /hydrateWorkspace\(regeneratedTask, !regenerated\.preview_invalidated\)/, '重新生成没有按 preview_invalidated 决定保留或清空切片') -assert.match(regenerationSource, /任务已进入重新生成状态,但工作区恢复失败/, '重新生成已提交但水合失败时缺少可恢复错误状态') +assert.match(regenerationSource, /重新生成配置已保存,但工作区恢复失败/, '重新生成配置已保存但水合失败时缺少可恢复错误状态') assert.match(regenerationSource, /return chunks\.join\(''\)/, '分页恢复源正文时不应额外插入换行') assert.doesNotMatch(regenerationSource, /binaryDocument[\s\S]*?mapDataProcessSourceFile\(file, ''\)/, '二进制源正文加载失败时不能静默降级为空内容') assert.match(nextFromModelSource, /if \(isRegeneration\.value\) \{[\s\S]*?prepareRegeneration\(taskPayload\(\)\)/, '重新生成每次从模型步骤继续时没有调用专用接口') assert.doesNotMatch(nextFromModelSource, /isRegeneration\.value && !taskId\.value/, '重新生成提交一次后可能错误转为普通任务更新') -assert.ok(viewSource.includes('已提交到服务端的修改不会因离开页面而撤销'), '离开提示没有说明已提交服务端的修改不可撤销') -assert.match(viewSource, /regenerationPrepared\.value \? '离开页面' : '放弃修改'/, '服务端已提交后离开按钮仍使用误导性的放弃修改文案') +assert.ok(regenerationSource.includes('重新生成配置已保存,但尚未开始生成'), '准备完成后离开提示没有区分尚未开始生成') +assert.ok(regenerationSource.includes('返回详情不会替换原生成结果或已发布数据'), '准备完成后离开提示未说明原详情仍保留') +assert.match(viewSource, /const leaveConfirmText = computed\(\(\) => isRegeneration\.value \? '返回详情' : '放弃修改'\)/, '重新生成离开按钮仍使用误导性文案') +assert.match(regenerationSource, /async function returnToDetail\(\)[\s\S]*?router\.replace\(\{ name: 'data-process-detail', params: \{ id: sourceTaskId\.value \} \}\)/, '重新生成退出没有显式返回原任务详情') +assert.match(viewSource, /async function returnToPreviousPage\(\)[\s\S]*?isRegeneration\.value && await returnToDetail\(\)/, '退出按钮没有使用显式返回详情逻辑') +assert.match(viewSource, /\{\{ isRegeneration \? '返回详情' : '取消' \}\}/, '重新生成第一步退出按钮没有明确标识返回详情') +assert.match(regenerationSource, /async function confirmStartGeneration\([\s\S]*?开始重新生成?[\s\S]*?当前生成结果将被替换[\s\S]*?confirmText: '开始生成'[\s\S]*?syncPreviewChanges\(\)/, '开始生成前没有明确破坏性边界或同步预览修改') +assert.match(viewSource, /beforeGenerate: beforeStartGeneration[\s\S]*?async function beforeStartGeneration\(\)[\s\S]*?confirmStartGeneration\([\s\S]*?syncPreviewChanges/, '生成流程没有在真实开始前调用确认边界') +assert.match(generationSource, /const canStart = await bindings\.beforeGenerate\?\.\(\)[\s\S]*?if \(canStart === false\) return[\s\S]*?generateDataProcess\(taskId\)/, '用户取消开始生成时仍可能调用真实生成 API') assert.match(viewSource, /v-if="initializationError"[\s\S]*?@click="loadRegenerationSource"[\s\S]*?重试加载原任务/, '原任务初始化失败后缺少安全重试入口') assert.match(regenerationSource, /previewStatus = preservePreviews && count > 0 \? 'success' : 'waiting'/, '保留切片时没有跳过重切,或切片失效后未回到等待状态') diff --git a/frontend/src/views/data-process/DataProcessCreateView.vue b/frontend/src/views/data-process/DataProcessCreateView.vue index 0e1a369..f869587 100644 --- a/frontend/src/views/data-process/DataProcessCreateView.vue +++ b/frontend/src/views/data-process/DataProcessCreateView.vue @@ -103,7 +103,6 @@ const selectedPreviewIdsByFile = ref>({}) const dirty = ref(false) const modelSubmitLoading = ref(false) let allowLeave = false - const { generation, results, @@ -119,7 +118,7 @@ const { } = useDataProcessGeneration({ taskId, dirty, - beforeGenerate: syncPreviewChanges, + beforeGenerate: beforeStartGeneration, }) const { enqueueSourceUpload, sourceUploading } = useDataProcessSourceUpload({ taskId, @@ -134,7 +133,6 @@ const { enqueueSourceUpload, sourceUploading } = useDataProcessSourceUpload({ const hasUnfinishedUploads = computed(() => uploadedFiles.value.some((file) => ( file.status !== 'ready' || !file.sourceFileId ))) - const modifiedPreviewCount = computed(() => previewItems.value.filter((item) => item.status !== 'original').length) const previewItemsByFile = computed(() => { const grouped = new Map() @@ -171,7 +169,6 @@ const primaryActionLabel = computed(() => { if (generation.status === 'failed') return '重新生成' return '开始生成' }) - const primaryActionIcon = computed(() => { if (currentStepId.value === 'results') return 'fa-check' if (currentStepId.value === 'generate' && generation.status !== 'success') return 'fa-play' @@ -181,7 +178,6 @@ const primaryActionIcon = computed(() => { const previousStepLabel = computed(() => currentStep.value > 0 ? WIZARD_STEPS[currentStep.value - 1].title : '') - function goToStep(stepId: StepId) { const nextStepIndex = WIZARD_STEPS.findIndex((step) => step.id === stepId) if (nextStepIndex >= 0) currentStep.value = nextStepIndex @@ -280,24 +276,19 @@ function mapPreviewItem(item: DataProcessPreviewItem): PreviewItem { function previewAffectingOptions() { return previewAffectingOptionsFor(processType.value, structuredOptions.value, unstructuredOptions.value) } - function generationAffectingOptions() { return generationAffectingOptionsFor(processType.value, structuredOptions.value, unstructuredOptions.value) } - const generationOptionsSignature = computed(() => JSON.stringify(generationAffectingOptions())) - function buildPreviewConfigSignature() { return `${PREVIEW_MODEL_VERSION}:${processType.value}:${JSON.stringify(previewAffectingOptions())}` } - function buildPreviewSignature() { const filesSignature = uploadedFiles.value .map((file) => `${file.uid}:${file.name}:${file.size}:${file.checksumSha256 || file.count}`) .join('|') return `${buildPreviewConfigSignature()}:${filesSignature}` } - const { isRegeneration, originalProcessType, @@ -307,6 +298,9 @@ const { loadSource: loadRegenerationSource, confirmPreviewConfigChange, prepareRegeneration, + confirmStartGeneration, + returnToDetail, + leaveWarning, } = useDataProcessRegeneration({ task, processType, @@ -324,11 +318,14 @@ const { mapPreviewItem, resetDownstream, }) -const leaveWarningMessage = computed(() => regenerationPrepared.value - ? '仍有尚未完成的本地操作;已提交到服务端的修改不会因离开页面而撤销。' - : '当前存在未保存修改,离开后这些修改将不会保留。') -const leaveConfirmText = computed(() => regenerationPrepared.value ? '离开页面' : '放弃修改') - +async function beforeStartGeneration() { + return confirmStartGeneration( + options => confirmDialogRef.value?.open(options) ?? Promise.resolve(false), + syncPreviewChanges, + ) +} +const leaveWarningMessage = computed(() => leaveWarning(generation.status)) +const leaveConfirmText = computed(() => isRegeneration.value ? '返回详情' : '放弃修改') watch( [() => task.name, () => task.description, processType, structuredOptions, unstructuredOptions, externalSource], () => { @@ -531,9 +528,9 @@ async function nextFromModel() { const regenerated = await prepareRegeneration(taskPayload()) taskId.value = String(regenerated.task.id) if (regenerated.preview_invalidated) { - ElMessage.info('切分配置已变化,现有源文件将在下一步按新配置重新切分') + ElMessage.info('切分配置已保存,后续将按新配置重新切分;点击“开始生成”前,原生成结果和已发布数据保持不变') } else if (regenerated.published_outputs_preserved) { - ElMessage.info('已保留原切片;已发布的三个数据集将在重新发布前保持不变') + ElMessage.info('重新生成配置已保存;点击“开始生成”前,原生成结果和已发布数据保持不变') } } else { const saved = taskId.value @@ -807,8 +804,7 @@ async function saveTask() { async function handleCancel() { if (!dirty.value) { - allowLeave = true - router.back() + await returnToPreviousPage() return } const confirmed = await confirmDialogRef.value?.open({ @@ -819,7 +815,12 @@ async function handleCancel() { tone: 'danger', }) if (!confirmed) return + await returnToPreviousPage() +} + +async function returnToPreviousPage() { allowLeave = true + if (isRegeneration.value && await returnToDetail()) return router.back() } @@ -973,7 +974,7 @@ onMounted(() => { 返回:{{ previousStepLabel }} - 取消 + {{ isRegeneration ? '返回详情' : '取消' }} diff --git a/frontend/src/views/data-process/create/useDataProcessGeneration.ts b/frontend/src/views/data-process/create/useDataProcessGeneration.ts index cd92d13..cf0b1bb 100644 --- a/frontend/src/views/data-process/create/useDataProcessGeneration.ts +++ b/frontend/src/views/data-process/create/useDataProcessGeneration.ts @@ -15,7 +15,7 @@ import type { GenerationState, ResultItem } from './types' interface GenerationBindings { taskId: Ref dirty: Ref - beforeGenerate?: () => Promise + beforeGenerate?: () => Promise } const RESULT_PAGE_SIZE = 500 @@ -51,6 +51,7 @@ export function useDataProcessGeneration(bindings: GenerationBindings) { let generationTimer: ReturnType | null = null let generationRun = 0 let pollFailureCount = 0 + let generationStarting = false function stopGenerationTimer() { generationRun += 1 @@ -140,33 +141,39 @@ export function useDataProcessGeneration(bindings: GenerationBindings) { } async function startGeneration() { + if (generationStarting || generation.status === 'running') return const taskId = bindings.taskId.value if (!taskId) { ElMessage.error('任务尚未创建,请返回上一步重试') return } - stopGenerationTimer() - const runId = generationRun - generation.status = 'running' - pollFailureCount = 0 - generation.progress = 0 - generation.message = '正在同步预览修改并启动后端处理,请稍候。' - + generationStarting = true + let runId: number | null = null try { - await bindings.beforeGenerate?.() + const canStart = await bindings.beforeGenerate?.() + if (canStart === false) return + stopGenerationTimer() + const activeRunId = generationRun + runId = activeRunId + generation.status = 'running' + pollFailureCount = 0 + generation.progress = 0 + generation.message = '正在同步预览修改并启动后端处理,请稍候。' const progress = await generateDataProcess(taskId) - if (runId !== generationRun) return + if (activeRunId !== generationRun) return if (progress.status === 'completed' || progress.status === 'failed' || progress.status === 'stopped') { await finishFromProgress(progress) return } applyProgress(progress) - generationTimer = setTimeout(() => void pollGeneration(runId), POLL_INTERVAL_MS) + generationTimer = setTimeout(() => void pollGeneration(activeRunId), POLL_INTERVAL_MS) } catch (error) { - if (runId !== generationRun) return + if (runId !== null && runId !== generationRun) return generation.status = 'failed' generation.message = error instanceof Error ? error.message : '启动数据处理失败,请重试。' + } finally { + generationStarting = false } } diff --git a/frontend/src/views/data-process/create/useDataProcessRegeneration.ts b/frontend/src/views/data-process/create/useDataProcessRegeneration.ts index c5ae4d8..ccb0e8a 100644 --- a/frontend/src/views/data-process/create/useDataProcessRegeneration.ts +++ b/frontend/src/views/data-process/create/useDataProcessRegeneration.ts @@ -1,5 +1,5 @@ import { computed, nextTick, ref, type Reactive, type Ref } from 'vue' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { getDataProcessPreview, getDataProcessSourceContent, @@ -81,6 +81,7 @@ async function loadAllPreviews(taskId: string, mapPreviewItem: RegenerationBindi export function useDataProcessRegeneration(bindings: RegenerationBindings) { const route = useRoute() + const router = useRouter() const sourceTaskId = computed(() => ( route.name === 'data-process-regenerate' ? String(route.params.id || '') : '' )) @@ -169,7 +170,7 @@ export function useDataProcessRegeneration(bindings: RegenerationBindings) { if (!changed || currentSignature === confirmedPreviewConfigSignature.value) return true const confirmed = await openConfirm({ title: '确认修改切分配置?', - message: '修改预处理或切分配置将删除现有切片和生成结果;已发布三数据集暂时保留,直到重新发布后才会更新。', + message: '修改预处理或切分配置后,将按新配置重新切分。在点击“开始生成”前,原生成结果和已发布数据会继续保留。', confirmText: '确认并继续', cancelText: '返回检查', tone: 'warning', @@ -209,13 +210,47 @@ export function useDataProcessRegeneration(bindings: RegenerationBindings) { } } catch (error) { initializationError.value = error instanceof Error - ? `任务已进入重新生成状态,但工作区恢复失败:${error.message}` - : '任务已进入重新生成状态,但工作区恢复失败,请重试加载原任务' + ? `重新生成配置已保存,但工作区恢复失败:${error.message}` + : '重新生成配置已保存,但工作区恢复失败,请重试加载原任务' throw error } return regenerated } + async function confirmStartGeneration( + openConfirm: (options: ConfirmOptions) => Promise, + syncPreviewChanges: () => Promise, + ) { + if (isRegeneration.value) { + const confirmed = await openConfirm({ + title: '开始重新生成?', + message: '点击开始后,当前生成结果将被替换。已发布数据集会继续保留,直到重新发布。', + confirmText: '开始生成', + cancelText: '继续检查', + tone: 'warning', + }) + if (!confirmed) return false + } + await syncPreviewChanges() + return true + } + + async function returnToDetail() { + if (!sourceTaskId.value) return false + await router.replace({ name: 'data-process-detail', params: { id: sourceTaskId.value } }) + return true + } + + function leaveWarning(generationStatus: string) { + if (isRegeneration.value && regenerationPrepared.value && generationStatus === 'idle') { + return '重新生成配置已保存,但尚未开始生成;现在返回详情不会替换原生成结果或已发布数据,可稍后继续。' + } + if (isRegeneration.value && regenerationPrepared.value) { + return '重新生成已开始;离开页面不会停止服务端处理,已提交的修改不会撤销。' + } + return '当前存在未保存修改,离开后这些修改将不会保留。' + } + return { sourceTaskId, isRegeneration, @@ -228,5 +263,8 @@ export function useDataProcessRegeneration(bindings: RegenerationBindings) { loadSource, confirmPreviewConfigChange, prepareRegeneration, + confirmStartGeneration, + returnToDetail, + leaveWarning, } }