From 06e7455630294e8486c594bce2337fed5e76e1bb Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Sat, 25 Jul 2026 22:41:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(data-process):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=E9=85=8D=E7=BD=AE=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regression-data-process-detail.mjs | 7 +- .../regression-data-process-wizard.mjs | 78 +++++- frontend/src/api/modules/dataProcess.ts | 12 + frontend/src/router/index.ts | 6 + frontend/src/stores/models.ts | 3 +- frontend/src/types/dataProcess.ts | 11 + .../data-process/DataProcessCreateView.vue | 187 +++++++------- .../data-process/DataProcessDetailView.vue | 58 ++--- .../data-process/create/TaskSetupStep.vue | 20 +- .../create/data-process-create.scss | 6 + .../create/dataProcessCreateState.ts | 173 ++++++++++++- .../create/useDataProcessRegeneration.ts | 232 ++++++++++++++++++ 12 files changed, 648 insertions(+), 145 deletions(-) create mode 100644 frontend/src/views/data-process/create/useDataProcessRegeneration.ts diff --git a/frontend/scripts/regression-data-process-detail.mjs b/frontend/scripts/regression-data-process-detail.mjs index 8a93518..e32ae03 100644 --- a/frontend/scripts/regression-data-process-detail.mjs +++ b/frontend/scripts/regression-data-process-detail.mjs @@ -22,6 +22,7 @@ assert.match(routerSource, /path:\s*['"]data-process\/:id['"]/, '缺少数据处 assert.match(routerSource, /name:\s*['"]data-process-detail['"]/, '数据处理详情路由缺少名称') assert.match(routerSource, /DataProcessDetailView\.vue/, '数据处理详情路由未加载详情页面') assert.match(routerSource, /title:\s*['"]数据处理详情['"]/, '数据处理详情路由标题不正确') +assert.match(routerSource, /path:\s*['"]data-process\/:id\/regenerate['"][\s\S]*?name:\s*['"]data-process-regenerate['"][\s\S]*?DataProcessCreateView\.vue/, '重新生成路由未复用创建向导') assert.match(listSource, /name:\s*['"]data-process-detail['"]/, '列表详情按钮未使用详情命名路由') assert.match(listSource, /params:\s*\{\s*id:\s*taskId\s*\}/, '列表详情按钮未传递任务 ID') @@ -62,7 +63,11 @@ assert.match(detailSource, /\.output-dataset-links[\s\S]*?\.el-button[\s\S]*?wid assert.match(detailSource, /\.el-button\s*>\s*span[\s\S]*?width:\s*100%[\s\S]*?display:\s*flex/, '输出数据集按钮内容没有撑满统一宽度') assert.match(detailSource, /\.el-button i[\s\S]*?margin-left:\s*auto/, '输出数据集跳转图标没有统一右对齐') assert.match(detailSource, /将发布三个独立数据集/, '发布说明仍未明确生成三个独立数据集') -assert.match(detailSource, /outputDatasetBaseName\.value/, '重新同步时未使用单一基础名称') +assert.match(detailSource, /function startRegeneration\(\)[\s\S]*?name: 'data-process-regenerate'[\s\S]*?params: \{ id: taskId\.value \}/, '重新生成按钮没有携带原任务 ID 进入命名路由') +assert.match(detailSource, /const canRegenerate = computed\(\(\) => \{[\s\S]*?status === 'pending'[\s\S]*?status === 'failed'[\s\S]*?status === 'stopped'[\s\S]*?status === 'completed'[\s\S]*?outputDatasetId\.value/, '详情页没有覆盖可恢复的重新生成任务状态') +assert.match(detailSource, /v-if="canRegenerate"[\s\S]*?@click="startRegeneration"[\s\S]*?重新生成/, '可恢复任务没有收敛为单一重新生成入口') +assert.match(detailSource, /v-if="detail\.status === 'completed' && !outputDatasetId"[\s\S]*?@click="openPublishDialog"[\s\S]*?发布为三个数据集/, '未发布完成任务没有保留首次发布入口') +assert.doesNotMatch(detailSource, /查看三个数据集|重新同步数据集|resyncPublishedDataset/, '已发布任务仍保留旧查看或同步按钮') assert.doesNotMatch(detailSource, /label="数据集类型"/, '发布三个数据集时不应再选择单一数据集类型') assert.match(detailSource, /无法加载数据处理任务/, '未知任务或加载失败缺少明确错误状态') assert.match(detailSource, /结果明细加载失败/, '结果加载失败缺少明确错误状态') diff --git a/frontend/scripts/regression-data-process-wizard.mjs b/frontend/scripts/regression-data-process-wizard.mjs index f6be3c5..4e778c5 100644 --- a/frontend/scripts/regression-data-process-wizard.mjs +++ b/frontend/scripts/regression-data-process-wizard.mjs @@ -14,24 +14,28 @@ const apiPath = path.resolve(scriptDir, '../src/api/modules/dataProcess.ts') const contractTypesPath = path.resolve(scriptDir, '../src/types/dataProcess.ts') const requestPath = path.resolve(scriptDir, '../src/api/request.ts') const authStorePath = path.resolve(scriptDir, '../src/stores/auth.ts') +const modelsStorePath = path.resolve(scriptDir, '../src/stores/models.ts') const routerPath = path.resolve(scriptDir, '../src/router/index.ts') const sessionActivityPath = path.resolve(scriptDir, '../src/utils/sessionActivity.ts') const sourceUploadWorkerPath = path.join(createDir, 'useDataProcessSourceUpload.ts') +const regenerationPath = path.join(createDir, 'useDataProcessRegeneration.ts') const viewSource = await readFile(viewPath, 'utf8') const layoutSource = await readFile(layoutPath, 'utf8') -const [stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource, viewStyleSource, apiSource, contractTypesSource] = await Promise.all([ +const [stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource, regenerationSource, viewStyleSource, apiSource, contractTypesSource] = await Promise.all([ readFile(path.join(createDir, 'dataProcessCreateState.ts'), 'utf8'), readFile(path.join(createDir, 'useDataProcessGeneration.ts'), 'utf8'), readFile(path.join(createDir, 'useDataProcessPreviewBuild.ts'), 'utf8'), readFile(sourceUploadWorkerPath, 'utf8'), + readFile(regenerationPath, 'utf8'), readFile(path.join(createDir, 'data-process-create.scss'), 'utf8'), readFile(apiPath, 'utf8'), readFile(contractTypesPath, 'utf8'), ]) -const implementationSource = [viewSource, stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource].join('\n') -const [requestSource, authStoreSource, routerSource, sessionActivitySource] = await Promise.all([ +const implementationSource = [viewSource, stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource, regenerationSource].join('\n') +const [requestSource, authStoreSource, modelsStoreSource, routerSource, sessionActivitySource] = await Promise.all([ readFile(requestPath, 'utf8'), readFile(authStorePath, 'utf8'), + readFile(modelsStorePath, 'utf8'), readFile(routerPath, 'utf8'), readFile(sessionActivityPath, 'utf8'), ]) @@ -60,6 +64,9 @@ assert.match(viewSource, /import AppConfirmDialog from '@\/components\/AppConfir assert.match(viewSource, //, '路由离开确认没有改为异步公共弹窗流程') assert.doesNotMatch(viewSource, /window\.confirm|ElMessageBox/, '创建页仍在使用系统或 Element Plus 确认框') +assert.match(routerSource, /path:\s*'data-process\/:id\/regenerate'[\s\S]*?name:\s*'data-process-regenerate'[\s\S]*?DataProcessCreateView\.vue/, '重新生成路由没有复用创建向导') +assert.match(regenerationSource, /route\.name === 'data-process-regenerate'[\s\S]*?route\.params\.id/, '创建向导没有从路由参数识别重新生成来源任务') +assert.match(viewSource, /const currentStep = ref\(0\)/, '重新生成必须从向导第一步开始') assert.match(viewSource, /const WIZARD_STEPS = \[/, '向导步骤尚未改为固定常量') for (const title of ['创建任务', '大模型选择', '上传文件', '数据预览', '开始生成', '结果编辑与保存']) { @@ -257,6 +264,12 @@ assert.doesNotMatch(nextFromCreateSource, /uploadedFiles|buildDataProcessPreview assert.match(nextFromModelSource, /modelSelectionRef\.value\?\.validate\(\)/, '大模型选择步骤继续前没有校验模型配置') assert.match(nextFromModelSource, /createDataProcessTask\(taskPayload\(\)\)/, '大模型选择完成后没有通过真实 API 创建任务') assert.match(nextFromModelSource, /goToStep\('upload'\)/, '大模型选择完成后没有进入上传文件') +assert.match(nextFromModelSource, /await modelsStore\.load\(true\)/, '提交任务前没有等待最新模型列表') +assert.ok(nextFromModelSource.indexOf('await modelsStore.load(true)') < nextFromModelSource.indexOf('prepareRegeneration(taskPayload())'), '重新生成可能在模型列表校验前修改服务端') +assert.ok(nextFromModelSource.indexOf('await modelsStore.load(true)') < nextFromModelSource.indexOf('updateDataProcessTask'), '普通任务可能在模型列表校验前修改服务端') +assert.ok(nextFromModelSource.includes('模型列表加载失败,未提交任何修改'), '模型列表加载失败没有明确阻断提交') +assert.ok(nextFromModelSource.includes('原任务使用的模型已删除'), '原任务模型已删除没有要求重新选择') +assert.match(modelsStoreSource, /catch \{[\s\S]*?list\.value = \[\][\s\S]*?loaded\.value = false/, '模型列表请求失败后 loaded 状态仍可能错误保留') assert.match(nextFromUploadSource, /uploadedFiles\.value\.length === 0/, '上传步骤继续前没有校验源数据') assert.match( nextFromUploadSource, @@ -383,6 +396,7 @@ assert.doesNotMatch(generationSource, /createResults\(/, '生成 composable 仍 for (const apiName of [ 'createDataProcessTask', + 'regenerateDataProcessTask', 'uploadDataProcessSourceFiles', 'buildDataProcessPreview', 'getDataProcessPreview', @@ -428,6 +442,9 @@ assert.match(sourceUploadWorkerSource, /pending\.status = 'ready'[\s\S]*?pending assert.match(viewSource, /failedUploads[\s\S]*?hasUnfinishedUploads[\s\S]*?buildPreviewsByFile/, '上传失败或未完成时没有阻断切分') assert.doesNotMatch(viewSource, /file\.sourceFileId \|\| file\.uid/, '切分或删除仍可能把本地临时 UID 当成后端文件 ID') assert.match(contractTypesSource, /expected_updated_at\?: string/, '编辑契约缺少乐观并发版本字段') +assert.match(contractTypesSource, /export interface DataProcessRegeneratePayload extends DataProcessTaskCreatePayload[\s\S]*?expected_updated_at: string/, '重新生成 payload 的原任务并发版本必须为必填') +assert.match(contractTypesSource, /export interface DataProcessRegenerateResult[\s\S]*?preview_invalidated: boolean[\s\S]*?published_outputs_preserved: boolean/, '重新生成响应缺少预览失效或已发布数据保留状态') +assert.match(apiSource, /regenerateDataProcessTask[\s\S]*?\/regenerate`/, '重新生成 API 路径未接入') for (const field of [ 'generationModelId', @@ -510,6 +527,10 @@ for (const removedField of ['contextScope', 'generationTypes', 'skipUnanswerable } assert.match(taskSetupSource, /v-if="processType === 'unstructured'"/, '非结构化配置必须仅在非结构化数据类型下显示') +assert.match(taskSetupSource, /processTypeLocked\?: boolean/, '任务配置缺少处理类型锁定状态') +assert.match(taskSetupSource, /:disabled="processTypeLocked"/, '重新生成时处理类型选项没有禁用') +assert.ok(taskSetupSource.includes('重新生成沿用原任务处理类型,不可修改'), '处理类型锁定缺少明确说明') +assert.match(viewSource, /:process-type-locked="isRegeneration"/, '创建向导没有向第一步传递重新生成锁定状态') assert.ok(unstructuredOptionsSource.includes('智能预处理'), '简化后缺少智能预处理总开关') assert.ok(unstructuredOptionsSource.includes('敏感信息脱敏'), '简化后缺少脱敏开关') const expectedSmartPreprocessOptions = [ @@ -668,14 +689,51 @@ assert.ok( const taskPayloadStart = viewSource.indexOf('function taskPayload()') const taskPayloadEnd = viewSource.indexOf('function externalPayload()', taskPayloadStart) const taskPayloadSource = viewSource.slice(taskPayloadStart, taskPayloadEnd) -for (const marker of ['name: task.name.trim()', 'description: task.description.trim()', 'process_type: processType.value', 'config: toBackendConfig()']) { +for (const marker of ['name: task.name.trim()', 'description: task.description.trim()', 'process_type: originalProcessType.value || processType.value', 'config: toBackendConfig()']) { assert.ok(taskPayloadSource.includes(marker), `任务创建 payload 缺少:${marker}`) } +assert.match(stateSource, /Object\.prototype\.hasOwnProperty\.call\(config, key\)/, '配置反向映射没有区分缺失值与 false/0') +assert.match(stateSource, /Number\.isFinite\(value\) \? value : fallback/, '配置反向映射没有保留合法数字 0') +assert.match(stateSource, /createStructuredOptionsFromConfig/, '结构化配置缺少后端到表单的反向映射') +assert.match(stateSource, /createUnstructuredOptionsFromConfig/, '非结构化配置缺少后端到表单的反向映射') +assert.match(stateSource, /configValue\(config, 'preprocess_options', \[\]\)/, '历史任务缺少预处理配置时必须按后端空列表语义回填') +for (const [field, fallback] of [ + ['preserve_tables', 'preserveTables'], + ['preserve_code_blocks', 'preserveCodeBlocks'], + ['preserve_lists', 'preserveLists'], +]) { + assert.match( + stateSource, + new RegExp(`configValue\\(\\s*config,\\s*'${field}',[\\s\\S]*?defaults\\.${fallback}`), + `历史任务缺少 ${field} 时必须沿用表单默认值,同时保留显式 false`, + ) +} +assert.match(regenerationSource, /getDataProcessTask\(sourceTaskId\.value\)/, '重新生成没有加载原任务') +assert.match(regenerationSource, /while \(true\)[\s\S]*?getDataProcessSourceContent[\s\S]*?has_more/, '重新生成没有分页加载完整源正文') +assert.match(regenerationSource, /getDataProcessPreview\(taskId, \{ page: 1, page_size: 500 \}\)[\s\S]*?for \(let page = 2; page <= pages;/, '重新生成没有分页加载全部现有切片') +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.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, /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.match(viewSource, /v-if="initializationError"[\s\S]*?@click="loadRegenerationSource"[\s\S]*?重试加载原任务/, '原任务初始化失败后缺少安全重试入口') +assert.match(regenerationSource, /previewStatus = preservePreviews && count > 0 \? 'success' : 'waiting'/, '保留切片时没有跳过重切,或切片失效后未回到等待状态') -const previewOptionsStart = viewSource.indexOf('function previewAffectingOptions()') -const previewOptionsEnd = viewSource.indexOf('function generationAffectingOptions()', previewOptionsStart) +const previewOptionsStart = stateSource.indexOf('export function previewAffectingOptionsFor(') +const previewOptionsEnd = stateSource.indexOf('export function generationAffectingOptionsFor(', previewOptionsStart) assert.ok(previewOptionsStart >= 0 && previewOptionsEnd > previewOptionsStart, '缺少预览影响配置签名函数') -const previewOptionsSource = viewSource.slice(previewOptionsStart, previewOptionsEnd) +const previewOptionsSource = stateSource.slice(previewOptionsStart, previewOptionsEnd) for (const field of [ 'preprocessOptions', 'chunkMethod', @@ -693,10 +751,10 @@ for (const field of ['semanticEnrichment', 'qaPairsPerChunk', 'datasetSplit']) { assert.ok(!previewOptionsSource.includes(field), `生成字段 ${field} 不应导致预览重建并丢失编辑`) } -const generationOptionsStart = viewSource.indexOf('function generationAffectingOptions()') -const generationOptionsEnd = viewSource.indexOf('const generationOptionsSignature', generationOptionsStart) +const generationOptionsStart = stateSource.indexOf('export function generationAffectingOptionsFor(') +const generationOptionsEnd = stateSource.length assert.ok(generationOptionsStart >= 0 && generationOptionsEnd > generationOptionsStart, '缺少生成影响配置签名函数') -const generationOptionsSource = viewSource.slice(generationOptionsStart, generationOptionsEnd) +const generationOptionsSource = stateSource.slice(generationOptionsStart, generationOptionsEnd) for (const field of [ 'semanticEnrichment', 'qaPairsPerChunk', diff --git a/frontend/src/api/modules/dataProcess.ts b/frontend/src/api/modules/dataProcess.ts index 14f38d7..3fff8c5 100644 --- a/frontend/src/api/modules/dataProcess.ts +++ b/frontend/src/api/modules/dataProcess.ts @@ -10,6 +10,8 @@ import type { DataProcessPreviewItem, DataProcessPreviewUpdatePayload, DataProcessProgress, + DataProcessRegeneratePayload, + DataProcessRegenerateResult, DataProcessPublishPayload, DataProcessPublishResult, DataProcessQualityScore, @@ -39,6 +41,8 @@ export type { DataProcessPreviewItem, DataProcessPreviewUpdatePayload, DataProcessProgress, + DataProcessRegeneratePayload, + DataProcessRegenerateResult, DataProcessPublishPayload, DataProcessPublishResult, DataProcessQualityScore, @@ -74,6 +78,14 @@ export const createDataProcessTask = (payload: DataProcessTaskCreatePayload) => export const updateDataProcessTask = (taskId: string | number, payload: DataProcessTaskUpdatePayload) => put(`/data-process/${encodeURIComponent(taskId)}`, payload) +export const regenerateDataProcessTask = ( + taskId: string | number, + payload: DataProcessRegeneratePayload, +) => post( + `/data-process/${encodeURIComponent(taskId)}/regenerate`, + payload, +) + export const deleteDataProcessTask = (taskId: string | number) => del<{ deleted: string | number }>(`/data-process/${encodeURIComponent(taskId)}`) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 741501c..ae45f29 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -140,6 +140,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/data-process/DataProcessCreateView.vue'), meta: { title: '新建数据处理任务', pageSurface: 'self' }, }, + { + path: 'data-process/:id/regenerate', + name: 'data-process-regenerate', + component: () => import('@/views/data-process/DataProcessCreateView.vue'), + meta: { title: '重新生成数据处理任务', pageSurface: 'self' }, + }, { path: 'data-process/:id', name: 'data-process-detail', diff --git a/frontend/src/stores/models.ts b/frontend/src/stores/models.ts index aa866b6..ddd2153 100644 --- a/frontend/src/stores/models.ts +++ b/frontend/src/stores/models.ts @@ -14,7 +14,7 @@ export const useModelsStore = defineStore('models', () => { async function load(force = false) { if (loaded.value && !force) return - if (pendingLoad && !force) return pendingLoad + if (pendingLoad) return pendingLoad pendingLoad = (async () => { try { @@ -22,6 +22,7 @@ export const useModelsStore = defineStore('models', () => { loaded.value = true } catch { list.value = [] + loaded.value = false } finally { pendingLoad = null } diff --git a/frontend/src/types/dataProcess.ts b/frontend/src/types/dataProcess.ts index 15b6a9c..573e5c8 100644 --- a/frontend/src/types/dataProcess.ts +++ b/frontend/src/types/dataProcess.ts @@ -53,6 +53,7 @@ export interface DataProcessTask { started_at?: string | null complete_time?: string | null completed_at?: string | null + updated_at: string duration?: string | null duration_seconds?: number | null failure_reason?: string | null @@ -74,6 +75,16 @@ export interface DataProcessTaskCreatePayload { config: DataProcessConfig } +export interface DataProcessRegeneratePayload extends DataProcessTaskCreatePayload { + expected_updated_at: string +} + +export interface DataProcessRegenerateResult { + task: DataProcessTask + preview_invalidated: boolean + published_outputs_preserved: boolean +} + export type DataProcessTaskUpdatePayload = Partial export interface DataProcessSourceFile { diff --git a/frontend/src/views/data-process/DataProcessCreateView.vue b/frontend/src/views/data-process/DataProcessCreateView.vue index c627147..0e1a369 100644 --- a/frontend/src/views/data-process/DataProcessCreateView.vue +++ b/frontend/src/views/data-process/DataProcessCreateView.vue @@ -14,9 +14,12 @@ import { DEFAULT_SOURCE_TEXT, estimateTokenCount } from './create/previewModel' import { createDefaultStructuredOptions, createDefaultUnstructuredOptions, + generationAffectingOptionsFor, + previewAffectingOptionsFor, } from './create/dataProcessCreateState' import { useDataProcessGeneration } from './create/useDataProcessGeneration' import { useDataProcessPreviewBuild } from './create/useDataProcessPreviewBuild' +import { useDataProcessRegeneration } from './create/useDataProcessRegeneration' import { mapDataProcessSourceFile, useDataProcessSourceUpload, @@ -51,7 +54,7 @@ import type { const router = useRouter() const modelsStore = useModelsStore() -const { list: modelList } = storeToRefs(modelsStore) +const { list: modelList, loaded: modelsLoaded } = storeToRefs(modelsStore) // 候选范围与模型管理保持一致,不在数据处理页面重复定义模型过滤规则。 const generationModels = computed(() => modelList.value) const taskSetupRef = ref>() @@ -98,6 +101,7 @@ const selectedPreviewFileId = ref(null) const selectedPreviewId = ref(null) const selectedPreviewIdsByFile = ref>({}) const dirty = ref(false) +const modelSubmitLoading = ref(false) let allowLeave = false const { @@ -236,7 +240,7 @@ function taskPayload() { return { name: task.name.trim(), description: task.description.trim(), - process_type: processType.value, + process_type: originalProcessType.value || processType.value, config: toBackendConfig(), } } @@ -274,82 +278,11 @@ function mapPreviewItem(item: DataProcessPreviewItem): PreviewItem { } function previewAffectingOptions() { - if (processType.value === 'structured') { - return { - preprocessOptions: structuredOptions.value.preprocessOptions, - } - } - if (processType.value === 'unstructured') { - const { - preprocessOptions, - chunkMethod, - chunkSize, - chunkOverlap, - minChunkSize, - semanticBreakpointPercentile, - preserveTables, - preserveCodeBlocks, - preserveLists, - } = unstructuredOptions.value - return { - preprocessOptions, - chunkMethod, - chunkSize, - chunkOverlap, - minChunkSize, - semanticBreakpointPercentile, - preserveTables, - preserveCodeBlocks, - preserveLists, - } - } - return null + return previewAffectingOptionsFor(processType.value, structuredOptions.value, unstructuredOptions.value) } function generationAffectingOptions() { - if (processType.value === 'structured') { - const { - semanticEnrichment, qaPairsPerRow, datasetSplit, generationModelId, generationPrompt, - temperature, maxTokens, jsonMode, - qualityFilterEnabled, filterLowQuality, filterShortContent, minOutputLength, - } = structuredOptions.value - return { - semanticEnrichment, qaPairsPerRow, datasetSplit, generationModelId, generationPrompt, - temperature, maxTokens, jsonMode, - qualityFilterEnabled, filterLowQuality, filterShortContent, minOutputLength, - } - } - if (processType.value === 'unstructured') { - const { - semanticEnrichment, - qaPairsPerChunk, - datasetSplit, - generationModelId, - generationPrompt, - temperature, - maxTokens, - jsonMode, - qualityFilterEnabled, - filterLowQuality, - filterShortContent, - minOutputLength, - } = unstructuredOptions.value - return { - semanticEnrichment, - qaPairsPerChunk, - datasetSplit, - generationModelId, - generationPrompt, - temperature, - maxTokens, - jsonMode, - qualityFilterEnabled, - filterLowQuality, - filterShortContent, - minOutputLength, - } - } - return null + return generationAffectingOptionsFor(processType.value, structuredOptions.value, unstructuredOptions.value) } const generationOptionsSignature = computed(() => JSON.stringify(generationAffectingOptions())) @@ -365,19 +298,58 @@ function buildPreviewSignature() { return `${buildPreviewConfigSignature()}:${filesSignature}` } +const { + isRegeneration, + originalProcessType, + regenerationPrepared, + hydrating, + initializationError, + loadSource: loadRegenerationSource, + confirmPreviewConfigChange, + prepareRegeneration, +} = useDataProcessRegeneration({ + task, + processType, + structuredOptions, + unstructuredOptions, + uploadedFiles, + previewItems, + selectedPreviewFileId, + selectedPreviewId, + selectedPreviewIdsByFile, + previewSignature, + dirty, + buildPreviewConfigSignature, + buildPreviewSignature, + mapPreviewItem, + resetDownstream, +}) +const leaveWarningMessage = computed(() => regenerationPrepared.value + ? '仍有尚未完成的本地操作;已提交到服务端的修改不会因离开页面而撤销。' + : '当前存在未保存修改,离开后这些修改将不会保留。') +const leaveConfirmText = computed(() => regenerationPrepared.value ? '离开页面' : '放弃修改') + watch( [() => task.name, () => task.description, processType, structuredOptions, unstructuredOptions, externalSource], - () => { dirty.value = true }, + () => { + if (!hydrating.value) dirty.value = true + }, { deep: true }, ) watch(processType, (nextType, previousType) => { + if (hydrating.value) return + if (isRegeneration.value && originalProcessType.value && nextType !== originalProcessType.value) { + processType.value = originalProcessType.value + return + } if (nextType === previousType || uploadedFiles.value.length === 0) return resetSourceDataForProcessTypeChange() ElMessage.info('处理类型已变更,请重新上传或拉取匹配的源数据') }) watch(generationOptionsSignature, (currentSignature, previousSignature) => { + if (hydrating.value) return if (currentSignature === previousSignature) return resetDownstream() }) @@ -531,21 +503,50 @@ function resetSourceDataForProcessTypeChange() { async function nextFromCreate() { const valid = await taskSetupRef.value?.validate() if (!valid) return + const confirmed = await confirmPreviewConfigChange((options) => ( + confirmDialogRef.value?.open(options) ?? Promise.resolve(false) + )) + if (!confirmed) return goToStep('model') } async function nextFromModel() { - const valid = await modelSelectionRef.value?.validate() - if (!valid) return + if (modelSubmitLoading.value) return + modelSubmitLoading.value = true try { - const saved = taskId.value - ? await updateDataProcessTask(taskId.value, taskPayload()) - : await createDataProcessTask(taskPayload()) - taskId.value = String(saved.id) + await modelsStore.load(true) + if (!modelsLoaded.value) { + ElMessage.error('模型列表加载失败,未提交任何修改,请稍后重试') + return + } + const modelId = modelSelectionOptions.value.generationModelId + const modelExists = generationModels.value.some(model => String(model.id) === String(modelId)) + if (modelId !== '' && !modelExists) { + ElMessage.error(isRegeneration.value ? '原任务使用的模型已删除,请重新选择模型' : '所选模型已删除,请重新选择') + return + } + const valid = await modelSelectionRef.value?.validate() + if (!valid) return + if (isRegeneration.value) { + const regenerated = await prepareRegeneration(taskPayload()) + taskId.value = String(regenerated.task.id) + 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) + } dirty.value = true goToStep('upload') } catch { // 请求层已展示名称冲突或配置非法等具体原因。 + } finally { + modelSubmitLoading.value = false } } @@ -812,8 +813,8 @@ async function handleCancel() { } const confirmed = await confirmDialogRef.value?.open({ title: '确认离开当前页面?', - message: '当前存在未保存修改,离开后这些修改将不会保留。', - confirmText: '放弃修改', + message: leaveWarningMessage.value, + confirmText: leaveConfirmText.value, cancelText: '继续编辑', tone: 'danger', }) @@ -830,8 +831,8 @@ onBeforeRouteLeave(async () => { if (allowLeave || !dirty.value) return true const confirmed = await confirmDialogRef.value?.open({ title: '确认离开当前页面?', - message: '当前存在未保存修改,离开后这些修改将不会保留。', - confirmText: '放弃修改', + message: leaveWarningMessage.value, + confirmText: leaveConfirmText.value, cancelText: '继续编辑', tone: 'danger', }) @@ -845,6 +846,7 @@ onBeforeUnmount(() => { onMounted(() => { localStorage.removeItem('yg-data-process-create-draft') void modelsStore.load(true) + if (isRegeneration.value) void loadRegenerationSource() }) @@ -882,15 +884,20 @@ onMounted(() => { -
+
+ { {{ primaryActionLabel }} diff --git a/frontend/src/views/data-process/DataProcessDetailView.vue b/frontend/src/views/data-process/DataProcessDetailView.vue index 3d625a1..4289845 100644 --- a/frontend/src/views/data-process/DataProcessDetailView.vue +++ b/frontend/src/views/data-process/DataProcessDetailView.vue @@ -1,7 +1,7 @@