Files
YG_FT/frontend/src/views/data-process/DataProcessCreateView.vue
2026-07-25 22:41:06 +08:00

998 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ElMessage, type UploadFile } from 'element-plus'
import { storeToRefs } from 'pinia'
import AppConfirmDialog from '@/components/AppConfirmDialog.vue'
import TaskSetupStep from './create/TaskSetupStep.vue'
import ModelSelectionStep from './create/ModelSelectionStep.vue'
import SourceUploadStep from './create/SourceUploadStep.vue'
import PreviewCompareStep from './create/PreviewCompareStep.vue'
import GenerationStep from './create/GenerationStep.vue'
import ResultEditorStep from './create/ResultEditorStep.vue'
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,
validateSourceFileSelection,
} from './create/useDataProcessSourceUpload'
import { useModelsStore } from '@/stores/models'
import {
createDataProcessPreview,
createDataProcessTask,
deleteDataProcessPreview,
deleteDataProcessSourceFile,
getDataProcessPreview,
getDataProcessSourceContent,
pullDataProcessExternalSource,
testDataProcessExternalSource,
updateDataProcessPreview,
updateDataProcessTask,
type DataProcessExternalSourcePayload,
type DataProcessPreviewItem,
} from '@/api/modules/dataProcess'
import type { DataProcessConfig } from '@/types/dataProcess'
import type {
ExternalDataSource,
GenerationControlOptions,
PreviewItem,
ProcessType,
StepId,
StructuredProcessOptions,
UnstructuredProcessOptions,
UploadedDataFile,
} from './create/types'
const router = useRouter()
const modelsStore = useModelsStore()
const { list: modelList, loaded: modelsLoaded } = storeToRefs(modelsStore)
// 候选范围与模型管理保持一致,不在数据处理页面重复定义模型过滤规则。
const generationModels = computed(() => modelList.value)
const taskSetupRef = ref<InstanceType<typeof TaskSetupStep>>()
const modelSelectionRef = ref<InstanceType<typeof ModelSelectionStep>>()
const confirmDialogRef = ref<InstanceType<typeof AppConfirmDialog>>()
const PREVIEW_MODEL_VERSION = 'backend-pipeline-v4'
const WIZARD_STEPS = [
{ id: 'create', title: '创建任务', desc: '填写任务信息与处理配置' },
{ id: 'model', title: '大模型选择', desc: '选择生成模型并设置输出要求' },
{ id: 'upload', title: '上传文件', desc: '上传或接入待处理的源数据' },
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
const currentStep = ref(0)
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')
const task = reactive({ name: '', description: '' })
const taskId = ref<string | null>(null)
const processType = ref<ProcessType>('structured')
const structuredOptions = ref<StructuredProcessOptions>(createDefaultStructuredOptions())
const unstructuredOptions = ref<UnstructuredProcessOptions>(createDefaultUnstructuredOptions())
const modelSelectionOptions = computed<GenerationControlOptions>(() => (
processType.value === 'unstructured' ? unstructuredOptions.value : structuredOptions.value
))
const uploadedFiles = ref<UploadedDataFile[]>([])
const previewBuilding = ref(false)
const { buildPreviewsByFile } = useDataProcessPreviewBuild()
const externalSource = reactive<ExternalDataSource>({
type: 'postgresql',
url: '',
authMode: 'none',
username: '',
password: '',
limit: 1000,
query: '',
fileName: 'external-data.jsonl',
})
const externalPulling = ref(false)
const externalConnected = ref(false)
const fileName = computed(() => uploadedFiles.value.map(f => f.name).join(', '))
const previewSignature = ref('')
const previewItems = ref<PreviewItem[]>([])
const selectedPreviewFileId = ref<string | null>(null)
const selectedPreviewId = ref<string | null>(null)
const selectedPreviewIdsByFile = ref<Record<string, string>>({})
const dirty = ref(false)
const modelSubmitLoading = ref(false)
let allowLeave = false
const {
generation,
results,
selectedResultId,
persistResultChanges,
resetDownstream,
restoreResult,
startGeneration,
stopGeneration,
stopGenerationTimer,
updateResultField,
validateResults,
} = useDataProcessGeneration({
taskId,
dirty,
beforeGenerate: syncPreviewChanges,
})
const { enqueueSourceUpload, sourceUploading } = useDataProcessSourceUpload({
taskId,
uploadedFiles,
onUploaded(file) {
previewSignature.value = ''
resetDownstream()
dirty.value = true
ElMessage.success(`文件 ${file.name} 上传成功,等待切分`)
},
})
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<string, PreviewItem[]>()
for (const item of previewItems.value) {
const items = grouped.get(item.sourceFileId) || []
items.push(item)
grouped.set(item.sourceFileId, items)
}
return grouped
})
const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value))
const activePreviewItems = computed(() => previewItemsByFile.value.get(selectedPreviewFileId.value || '') || [])
const activeSourceText = computed(() => activePreviewFile.value?.content ?? '')
const previewFiles = computed(() => uploadedFiles.value.map((file) => {
const items = previewItemsByFile.value.get(String(file.uid)) || []
return {
id: String(file.uid),
name: file.name,
count: items.length,
modifiedCount: items.filter((item) => item.status !== 'original').length,
}
}))
const primaryActionLabel = computed(() => {
if (currentStepId.value === 'create') return '继续:选择大模型'
if (currentStepId.value === 'model') return '继续:上传文件'
if (currentStepId.value === 'upload') {
if (sourceUploading.value) return '正在上传'
return previewBuilding.value ? '正在切分' : '继续:数据预览'
}
if (currentStepId.value === 'preview') return '确认预览并继续'
if (currentStepId.value === 'results') return '保存任务'
if (generation.status === 'running') return '正在生成'
if (generation.status === 'success') return '查看生成结果'
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'
return 'fa-arrow-right'
})
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
}
function updateModelSelectionOptions(value: GenerationControlOptions) {
if (processType.value === 'unstructured') {
unstructuredOptions.value = { ...unstructuredOptions.value, ...value }
return
}
structuredOptions.value = { ...structuredOptions.value, ...value }
}
function toBackendConfig(): DataProcessConfig {
const options = processType.value === 'unstructured'
? unstructuredOptions.value
: structuredOptions.value
const common = {
preprocess_options: [...options.preprocessOptions],
semantic_enrichment: options.semanticEnrichment,
dataset_split: { ...options.datasetSplit },
generation_model_id: options.generationModelId,
generation_prompt: options.generationPrompt,
temperature: options.temperature,
max_tokens: options.maxTokens,
json_mode: options.jsonMode,
quality_filter_enabled: options.qualityFilterEnabled,
filter_low_quality: options.filterLowQuality,
filter_short_content: options.filterShortContent,
min_output_length: options.minOutputLength,
}
if (processType.value === 'unstructured') {
return {
...common,
chunk_method: unstructuredOptions.value.chunkMethod,
chunk_size: unstructuredOptions.value.chunkSize,
chunk_overlap: unstructuredOptions.value.chunkOverlap,
min_chunk_size: unstructuredOptions.value.minChunkSize,
semantic_breakpoint_percentile: unstructuredOptions.value.semanticBreakpointPercentile,
preserve_tables: unstructuredOptions.value.preserveTables,
preserve_code_blocks: unstructuredOptions.value.preserveCodeBlocks,
preserve_lists: unstructuredOptions.value.preserveLists,
qa_pairs_per_chunk: unstructuredOptions.value.qaPairsPerChunk,
}
}
return {
...common,
qa_pairs_per_row: structuredOptions.value.qaPairsPerRow,
}
}
function taskPayload() {
return {
name: task.name.trim(),
description: task.description.trim(),
process_type: originalProcessType.value || processType.value,
config: toBackendConfig(),
}
}
function externalPayload(): DataProcessExternalSourcePayload {
return {
type: 'postgresql',
url: externalSource.url.trim(),
auth_mode: externalSource.authMode,
username: externalSource.username || undefined,
password: externalSource.password || undefined,
limit: externalSource.limit,
query: externalSource.query?.trim() || undefined,
file_name: externalSource.fileName || 'external-data.jsonl',
}
}
function mapPreviewItem(item: DataProcessPreviewItem): PreviewItem {
return {
id: String(item.id),
sourceFileId: String(item.source_file_id),
originalContent: item.original_content,
editedContent: item.edited_content,
sourceStart: item.source_start,
sourceEnd: item.source_end,
sourceStartLine: item.source_start_line,
sourceEndLine: item.source_end_line,
tokenCount: item.token_count,
status: item.status,
sourcePages: Array.isArray(item.quality_score?.source_pages)
? item.quality_score.source_pages.filter((value): value is number => typeof value === 'number')
: [],
updatedAt: item.updated_at,
}
}
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,
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],
() => {
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()
})
function scrollToStepTop() {
document.querySelector<HTMLElement>('.layout-content')?.scrollTo({ top: 0, behavior: 'smooth' })
}
watch(currentStep, () => nextTick(scrollToStepTop))
function handleFileChange(uploadFile: UploadFile) {
const raw = uploadFile.raw
if (!raw) return
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回模型选择步骤后重试')
return
}
const validation = validateSourceFileSelection(raw, processType.value, uploadedFiles.value)
if (!validation.valid) {
ElMessage[validation.severity](validation.message)
return
}
// 必须先同步插入文件行,再交给队列;这样选择完成后页面会立即展示全部文件。
const localUid = `local-${uploadFile.uid}-${Date.now()}-${uploadedFiles.value.length}`
uploadedFiles.value.push({
uid: localUid,
rawFile: raw,
name: raw.name,
size: raw.size,
count: 0,
content: '',
fileFormat: validation.extension,
status: 'queued',
uploadProgress: 0,
previewStatus: 'waiting',
previewProgress: 0,
})
dirty.value = true
enqueueSourceUpload({ uid: localUid, file: raw, extension: validation.extension })
}
async function useSampleFile() {
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回模型选择步骤后重试')
return
}
const sample = new File([DEFAULT_SOURCE_TEXT], 'finance_qa.jsonl', { type: 'application/x-ndjson' })
handleFileChange({ raw: sample, uid: Date.now(), name: sample.name } as UploadFile)
}
function updateExternalSource(value: ExternalDataSource) {
Object.assign(externalSource, value)
externalConnected.value = false
}
async function handleTestConnection() {
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回模型选择步骤后重试')
return
}
if (!externalSource.url.trim()) {
ElMessage.warning('请先填写数据源地址')
return
}
externalPulling.value = true
try {
const result = await testDataProcessExternalSource(taskId.value, externalPayload())
externalConnected.value = result.connected
if (result.connected) ElMessage.success(result.message || '数据源连接测试成功')
else ElMessage.warning(result.message || '数据源连接失败')
} catch {
externalConnected.value = false
} finally {
externalPulling.value = false
}
}
async function handlePullData() {
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回模型选择步骤后重试')
return
}
if (!externalSource.url.trim()) {
ElMessage.warning('请先填写数据源地址')
return
}
if (!externalSource.query?.trim()) {
ElMessage.warning('请先填写只读 SELECT 查询语句')
return
}
externalPulling.value = true
try {
const response = await pullDataProcessExternalSource(taskId.value, externalPayload())
const newFiles: UploadedDataFile[] = []
for (const file of response.files) {
const source = await getDataProcessSourceContent(taskId.value, file.id, {
start_line: 1,
line_count: 5000,
})
newFiles.push(mapDataProcessSourceFile(file, source.content))
}
uploadedFiles.value.push(...newFiles)
externalConnected.value = true
previewSignature.value = ''
resetDownstream()
dirty.value = true
ElMessage.success(`已成功登记 ${newFiles.length} 个外部源文件`)
} catch {
externalConnected.value = false
} finally {
externalPulling.value = false
}
}
async function handleRemoveFile(uid: string | number) {
const index = uploadedFiles.value.findIndex(f => f.uid === uid)
if (index < 0 || !taskId.value) return
const file = uploadedFiles.value[index]
if (!file) return
if (file.status === 'uploading') {
ElMessage.warning('当前文件正在上传,请等待上传结束后再删除')
return
}
if (file.sourceFileId) {
try {
await deleteDataProcessSourceFile(taskId.value, file.sourceFileId)
} catch {
return
}
}
uploadedFiles.value.splice(index, 1)
previewSignature.value = ''
previewItems.value = []
selectedPreviewId.value = null
resetDownstream()
dirty.value = true
}
function resetSourceDataForProcessTypeChange() {
uploadedFiles.value = []
previewSignature.value = ''
previewItems.value = []
selectedPreviewFileId.value = null
selectedPreviewId.value = null
selectedPreviewIdsByFile.value = {}
externalConnected.value = false
resetDownstream()
}
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() {
if (modelSubmitLoading.value) return
modelSubmitLoading.value = true
try {
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
}
}
async function nextFromUpload() {
if (previewBuilding.value || sourceUploading.value) return
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回模型选择步骤后重试')
return
}
if (uploadedFiles.value.length === 0) {
ElMessage.warning(processType.value === 'external' ? '请先拉取至少一个数据源' : '请上传至少一个源数据文件')
return
}
const failedUploads = uploadedFiles.value.filter((file) => file.status === 'failed')
if (failedUploads.length) {
ElMessage.warning(`${failedUploads.length} 个文件上传失败,请删除后重新选择`)
return
}
if (hasUnfinishedUploads.value) {
ElMessage.warning('请等待所有文件上传完成后再开始切分')
return
}
const signature = buildPreviewSignature()
const configSignature = buildPreviewConfigSignature()
const allFilesSucceeded = () => uploadedFiles.value.every((file) => (
file.previewStatus === 'success' && file.previewConfigSignature === configSignature
))
if (signature === previewSignature.value && previewItems.value.length && allFilesSucceeded()) {
goToStep('preview')
return
}
for (const file of uploadedFiles.value) {
if (file.previewConfigSignature === configSignature) continue
file.previewStatus = 'waiting'
file.previewProgress = 0
file.previewError = undefined
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
}
}
function selectPreviewFile(fileId: string) {
if (selectedPreviewFileId.value && selectedPreviewId.value) {
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
}
selectedPreviewFileId.value = fileId
selectedPreviewId.value = selectedPreviewIdsByFile.value[fileId]
|| activePreviewItems.value[0]?.id
|| null
}
function selectPreviewItem(id: string) {
selectedPreviewId.value = id
if (selectedPreviewFileId.value) selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = id
}
function updatePreviewContent(id: string, value: string) {
const item = previewItems.value.find((entry) => entry.id === id)
if (!item) return
item.editedContent = value
item.tokenCount = estimateTokenCount(value)
item.status = value === item.originalContent ? 'original' : item.sourceStart == null ? 'manual' : 'modified'
resetDownstream()
dirty.value = true
}
async function syncPreviewChanges() {
if (!taskId.value) throw new Error('任务尚未创建')
const changedItems = previewItems.value.filter((item) => item.status === 'modified' || item.status === 'manual')
for (const item of changedItems) {
const saved = await updateDataProcessPreview(taskId.value, item.id, {
edited_content: item.editedContent,
expected_updated_at: item.updatedAt,
})
const index = previewItems.value.findIndex((entry) => entry.id === item.id)
if (index >= 0) previewItems.value[index] = mapPreviewItem(saved)
}
}
function restorePreviewItem(id: string) {
const item = previewItems.value.find((entry) => entry.id === id)
if (!item || item.sourceStart == null) return
item.editedContent = item.originalContent
item.tokenCount = estimateTokenCount(item.originalContent)
item.status = 'original'
resetDownstream()
dirty.value = true
}
async function addPreviewItem() {
if (!selectedPreviewFileId.value || !taskId.value) return
try {
const created = await createDataProcessPreview(taskId.value, {
source_file_id: selectedPreviewFileId.value,
edited_content: '',
})
const item = mapPreviewItem(created)
previewItems.value.push(item)
selectPreviewItem(item.id)
resetDownstream()
dirty.value = true
} catch {
// 请求层已展示错误。
}
}
async function removePreviewItem(id: string) {
const confirmed = await confirmDialogRef.value?.open({
title: '删除预览内容?',
message: '删除只影响本次处理,不会修改源文件。删除后可重新从源文件生成预览。',
confirmText: '删除',
cancelText: '取消',
tone: 'danger',
})
if (!confirmed) return
const index = previewItems.value.findIndex((item) => item.id === id)
if (index < 0) return
if (!taskId.value) return
try {
await deleteDataProcessPreview(taskId.value, id)
} catch {
return
}
previewItems.value.splice(index, 1)
selectedPreviewId.value = activePreviewItems.value[Math.min(index, activePreviewItems.value.length - 1)]?.id ?? null
if (selectedPreviewFileId.value && selectedPreviewId.value) {
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
}
resetDownstream()
dirty.value = true
}
async function handlePrimaryAction() {
if (currentStepId.value === 'create') {
await nextFromCreate()
return
}
if (currentStepId.value === 'model') {
await nextFromModel()
return
}
if (currentStepId.value === 'upload') {
await nextFromUpload()
return
}
if (currentStepId.value === 'preview') {
if (!previewItems.value.length) {
ElMessage.warning('当前没有可生成的预览内容')
return
}
goToStep('generate')
return
}
if (currentStepId.value === 'generate') {
if (generation.status === 'success') {
goToStep('results')
} else if (generation.status !== 'running') {
await startGeneration()
}
return
}
await saveTask()
}
function handleBack() {
if (sourceUploading.value) {
ElMessage.warning('请等待当前文件上传完成')
return
}
if (previewBuilding.value) {
ElMessage.warning('请等待当前文件切分完成')
return
}
if (generation.status === 'running') {
ElMessage.warning('请先停止当前生成任务')
return
}
if (currentStep.value > 0) currentStep.value -= 1
}
async function saveTask() {
if (!validateResults()) {
ElMessage.warning('请先修正校验失败的结果')
return
}
try {
await persistResultChanges()
} catch {
return
}
if (!validateResults()) {
ElMessage.warning('仍有结果未通过后端质量校验,请继续修正')
return
}
dirty.value = false
allowLeave = true
ElMessage.success('数据处理任务已保存')
await router.push('/data-process')
}
async function handleCancel() {
if (!dirty.value) {
allowLeave = true
router.back()
return
}
const confirmed = await confirmDialogRef.value?.open({
title: '确认离开当前页面?',
message: leaveWarningMessage.value,
confirmText: leaveConfirmText.value,
cancelText: '继续编辑',
tone: 'danger',
})
if (!confirmed) return
allowLeave = true
router.back()
}
onBeforeRouteLeave(async () => {
if (sourceUploading.value) {
ElMessage.warning('请等待当前文件上传完成后再离开页面')
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)
})
onBeforeUnmount(() => {
stopGenerationTimer()
})
onMounted(() => {
localStorage.removeItem('yg-data-process-create-draft')
void modelsStore.load(true)
if (isRegeneration.value) void loadRegenerationSource()
})
</script>
<template>
<div class="create-wizard-layout">
<main class="wizard-main">
<div class="wizard-main-inner">
<div class="wizard-steps-container">
<div class="custom-wizard-steps">
<template v-for="(step, index) in WIZARD_STEPS" :key="step.id">
<div
v-if="index !== 0"
class="step-connector"
:class="{ 'is-active': currentStep >= index }"
></div>
<div
class="step-item"
:class="{
'is-active': currentStep === index,
'is-completed': currentStep > index
}"
:aria-current="currentStep === index ? 'step' : undefined"
>
<div class="step-node">
<div class="step-icon">
<i v-if="currentStep > index" class="fa fa-check" />
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-text">
<div class="step-title">{{ step.title }}</div>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="wizard-content" v-loading="hydrating">
<div v-if="initializationError" class="initialization-error" role="alert">
<el-alert type="error" :closable="false" :title="initializationError" show-icon />
<el-button type="primary" :loading="hydrating" @click="loadRegenerationSource">重试加载原任务</el-button>
</div>
<TaskSetupStep
v-else-if="currentStepId === 'create'"
ref="taskSetupRef"
v-model:name="task.name"
v-model:description="task.description"
v-model:process-type="processType"
v-model:structured-options="structuredOptions"
v-model:unstructured-options="unstructuredOptions"
:process-type-locked="isRegeneration"
/>
<ModelSelectionStep
v-else-if="currentStepId === 'model'"
ref="modelSelectionRef"
:options="modelSelectionOptions"
:models="generationModels"
@update:options="updateModelSelectionOptions"
/>
<SourceUploadStep
v-else-if="currentStepId === 'upload'"
:process-type="processType"
:uploaded-files="uploadedFiles"
:external-source="externalSource"
:external-pulling="externalPulling"
:external-connected="externalConnected"
:preview-building="previewBuilding"
:source-uploading="sourceUploading"
@update:external-source="updateExternalSource"
@file-change="handleFileChange"
@remove-file="handleRemoveFile"
@use-sample="useSampleFile"
@test-connection="handleTestConnection"
@pull-data="handlePullData"
/>
<PreviewCompareStep
v-else-if="currentStepId === 'preview'"
:selected-id="selectedPreviewId"
:selected-file-id="selectedPreviewFileId"
:source-text="activeSourceText"
:items="activePreviewItems"
:process-type="processType"
:file-name="activePreviewFile?.name ?? ''"
:task-id="taskId"
:source-file-id="activePreviewFile?.sourceFileId ?? activePreviewFile?.uid ?? null"
:files="previewFiles"
@update:selected-id="selectPreviewItem"
@update:selected-file-id="selectPreviewFile"
@update:item-content="updatePreviewContent"
@restore:item="restorePreviewItem"
@add:item="addPreviewItem"
@remove:item="removePreviewItem"
/>
<GenerationStep
v-else-if="currentStepId === 'generate'"
:task-name="task.name"
:process-type="processType"
:file-name="fileName"
:preview-count="previewItems.length"
:modified-count="modifiedPreviewCount"
:generation="generation"
@stop="stopGeneration"
@retry="startGeneration"
/>
<ResultEditorStep
v-else-if="currentStepId === 'results'"
v-model:selected-id="selectedResultId"
:items="results"
:preview-items="previewItems"
@update:field="updateResultField"
@restore:item="restoreResult"
/>
</div>
</div>
</main>
<footer class="wizard-footer">
<div class="footer-left">
<el-button v-if="currentStep > 0" :disabled="previewBuilding || sourceUploading" @click="handleBack">
<i class="fa fa-arrow-left" style="margin-right: 6px;" /> 返回{{ previousStepLabel }}
</el-button>
<el-button v-else @click="handleCancel">取消</el-button>
</div>
<div class="footer-center">
</div>
<div class="footer-right">
<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)"
@click="handlePrimaryAction"
>
{{ primaryActionLabel }} <i class="fa" :class="primaryActionIcon" style="margin-left: 6px;" />
</el-button>
</div>
</footer>
</div>
<AppConfirmDialog ref="confirmDialogRef" />
</template>
<style scoped lang="scss" src="./create/data-process-create.scss"></style>