Files
YG_FT/frontend/src/views/data-process/DataProcessCreateView.vue
caoxiaozhu 81c2f85c3a feat(data_process): 问答对数据评测体系与质量分雷达图
- 三层评测:规则层沿用原五维规则分,语义层用本地 BGE 向量算问答/来源
  相关性,评审层复用生成模型按 rubric 打分(忠实度/正确性/清晰度等,
  区分 standard/reasoning/dpo 输出类型),任一层失败自动降级
- 组合分 = 规则 35% + 语义 20% + 评审 45%,缺层自动重归一
- 新增 results/evaluate-batch 批量评测接口,镜像批量重生成的并发、
  乐观锁与部分成功语义;生成阶段不再展示质量分
- 详情页与结果编辑页新增"数据评测"按钮和批量进度;质量分列悬停弹出
  雷达图浮窗(评审 5 维 + 语义 2 维、三层分项、评审理由)
- 手动编辑/恢复后重算规则与语义层并丢弃过期评审分,雷达图不再展示
  失效数据
2026-08-19 14:22:27 +08:00

1215 lines
43 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, isManualPreviewItem } from './create/previewModel'
import {
createDefaultStructuredOptions,
createDefaultUnstructuredOptions,
generationAffectingOptionsFor,
previewAffectingOptionsFor,
} from './create/dataProcessCreateState'
import { useDataProcessGeneration } from './create/useDataProcessGeneration'
import { useDataProcessEvaluation } from './create/useDataProcessEvaluation'
import { useDataProcessPreviewBuild } from './create/useDataProcessPreviewBuild'
import { useDataProcessRegeneration } from './create/useDataProcessRegeneration'
import { createDefaultExternalSource, externalSourcePayload, restoreExternalSourceConfig, sourceConfigForBackend } from './create/externalSourceConfig'
import {
loadCanonicalSourceContent,
mapDataProcessSourceFile,
useDataProcessSourceUpload,
validateSourceFileSelection,
} from './create/useDataProcessSourceUpload'
import { useModelsStore } from '@/stores/models'
import {
confirmDataProcessResults,
createDataProcessPreview,
createDataProcessTask,
deleteDataProcessPreview,
deleteDataProcessSourceFile,
getDataProcessPreview,
pullDataProcessExternalSource,
testDataProcessExternalSource,
updateDataProcessPreview,
updateDataProcessTask,
updateDataProcessWorkflowStep,
type DataProcessPreviewItem,
} from '@/api/modules/dataProcess'
import type { DataProcessConfig, DataProcessPreviewProgress, DataProcessTask, DataProcessWorkflowStep } from '@/types/dataProcess'
import type {
ExternalDataSource,
GenerationControlOptions,
PreviewItem,
ProcessType,
SourceMode,
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 sourceMode = ref<SourceMode>('local')
const structuredOptions = ref<StructuredProcessOptions>(createDefaultStructuredOptions())
const unstructuredOptions = ref<UnstructuredProcessOptions>(createDefaultUnstructuredOptions())
const modelSelectionOptions = computed<GenerationControlOptions>(() => (
processType.value === 'unstructured' ? unstructuredOptions.value : structuredOptions.value
))
const activeOutputType = computed(() => modelSelectionOptions.value.outputType)
const uploadedFiles = ref<UploadedDataFile[]>([])
const previewBuilding = ref(false)
const {
resumePreviewBuild,
startPreviewBuild,
stopPreviewPolling,
} = useDataProcessPreviewBuild()
const externalSource = reactive<ExternalDataSource>(createDefaultExternalSource())
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 {
bulkRegeneration,
canReturnFromGeneration,
generation,
generationStarting,
regeneratingResultId,
resultRegenerationBusy,
results,
selectedResultId,
persistResultChanges,
resetDownstream,
regenerateAllResults,
regenerateResult,
resumeGeneration,
startGeneration,
stopGenerationTimer,
updateResultField,
validateResults,
} = useDataProcessGeneration({
taskId,
dirty,
outputType: activeOutputType,
beforeGenerate: beforeStartGeneration,
})
const {
evaluation,
evaluateAllResults,
resetEvaluation,
} = useDataProcessEvaluation({
taskId,
results,
selectedResultId,
})
// 生成结果被重置(重新切分/上传/重新生成配置)时同步清空评测进度。
watch(results, (items) => {
if (!items.length) resetEvaluation()
})
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 = {
...sourceConfigForBackend(sourceMode.value, externalSource),
preprocess_options: [...options.preprocessOptions],
semantic_enrichment: options.semanticEnrichment,
dataset_split: { ...options.datasetSplit },
generation_model_id: options.generationModelId,
generation_prompt: options.generationPrompt,
output_type: options.outputType,
reasoning_detail: options.reasoningDetail,
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(),
}
}
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 mapPreviewItem(item: DataProcessPreviewItem): PreviewItem {
const sourceLocator = item.quality_score?.source_locator
return {
id: String(item.id),
sourceFileId: String(item.source_file_id),
originalContent: item.original_content,
editedContent: item.edited_content,
savedEditedContent: item.edited_content,
sourceStart: item.source_start ?? sourceLocator?.source_start ?? null,
sourceEnd: item.source_end ?? sourceLocator?.source_end ?? null,
sourceStartLine: item.source_start_line ?? sourceLocator?.start_line ?? null,
sourceEndLine: item.source_end_line ?? sourceLocator?.end_line ?? null,
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')
: [],
sourceLocator,
headingPath: Array.isArray(item.quality_score?.heading_path)
? item.quality_score.heading_path.filter((value): value is string => typeof value === 'string')
: [],
updatedAt: item.updated_at,
}
}
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)
}
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,
isWorkflowResume,
originalProcessType,
regenerationPrepared,
hydrating,
initializationError,
loadSource: loadRegenerationSource,
confirmPreviewConfigChange,
prepareRegeneration,
confirmStartGeneration,
} = useDataProcessRegeneration({
task,
processType,
structuredOptions,
unstructuredOptions,
uploadedFiles,
previewItems,
selectedPreviewFileId,
selectedPreviewId,
selectedPreviewIdsByFile,
previewSignature,
dirty,
buildPreviewConfigSignature,
buildPreviewSignature,
mapPreviewItem,
resetDownstream,
})
async function beforeStartGeneration() {
return confirmStartGeneration(
options => confirmDialogRef.value?.open(options) ?? Promise.resolve(false),
syncPreviewChanges,
)
}
watch(
[() => task.name, () => task.description, processType, sourceMode, 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(processType, (nextType) => {
if (nextType === 'unstructured' && sourceMode.value === 'external') {
sourceMode.value = 'local'
externalConnected.value = false
}
})
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,
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 })
}
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 updateSourceMode(value: SourceMode) {
const previous = sourceMode.value
sourceMode.value = value
externalConnected.value = false
dirty.value = true
externalPulling.value = true
try { await saveTaskConfiguration() } catch { sourceMode.value = previous }
finally { externalPulling.value = false }
}
async function handleTestConnection() {
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回模型选择步骤后重试')
return
}
if (!externalSource.url.trim()) {
ElMessage.warning('请先填写数据源地址')
return
}
externalPulling.value = true
try {
await saveTaskConfiguration()
const result = await testDataProcessExternalSource(taskId.value, externalSourcePayload(externalSource))
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 {
await saveTaskConfiguration()
const response = await pullDataProcessExternalSource(taskId.value, externalSourcePayload(externalSource))
const newFiles: UploadedDataFile[] = []
for (const file of response.files) {
const content = await loadCanonicalSourceContent(taskId.value, file.id)
newFiles.push(mapDataProcessSourceFile(file, 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
if (!isRegeneration.value) {
try {
await saveTaskConfiguration()
await persistWorkflowStep('model')
} catch {
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)
dirty.value = false
if (regenerated.preview_invalidated) {
ElMessage.info('切分配置已保存,后续将按新配置重新切分;点击“开始生成”前,原生成结果和已发布数据保持不变')
} else if (regenerated.published_outputs_preserved) {
ElMessage.info('重新生成配置已保存;点击“开始生成”前,原生成结果和已发布数据保持不变')
}
} else {
await saveTaskConfiguration()
}
await persistWorkflowStep('upload')
dirty.value = false
goToStep('upload')
} catch {
// 请求层已展示名称冲突或配置非法等具体原因。
} finally {
modelSubmitLoading.value = false
}
}
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()
for (const file of uploadedFiles.value) {
if (!file.content && file.sourceFileId && file.fileFormat?.replace('.', '') === 'pdf') {
file.content = await loadCanonicalSourceContent(taskId.value as string, file.sourceFileId)
.catch(() => file.content)
}
}
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) {
ElMessage.error('任务尚未创建,请返回模型选择步骤后重试')
return
}
if (uploadedFiles.value.length === 0) {
ElMessage.warning(sourceMode.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
}
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) {
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
const isManual = isManualPreviewItem(item)
item.editedContent = value
item.tokenCount = estimateTokenCount(value)
item.status = !value.trim()
? 'invalid'
: value === item.originalContent
? 'original'
: isManual ? 'manual' : 'modified'
resetDownstream()
dirty.value = true
}
async function syncPreviewChanges() {
if (!taskId.value) throw new Error('任务尚未创建')
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,
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 || isManualPreviewItem(item)) 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
}
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 handleStartGeneration()
}
return
}
await saveTask()
}
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
}
if (previewBuilding.value) {
ElMessage.warning('请等待当前文件切分完成')
return
}
if (currentStepId.value === 'generate' && !canReturnFromGeneration.value) 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
}
}
async function saveTask() {
if (!taskId.value) {
ElMessage.error('任务尚未创建,请返回上一步重试')
return
}
if (!validateResults()) {
ElMessage.warning('请先修正校验失败的结果')
return
}
try {
await persistResultChanges()
} catch {
return
}
if (!validateResults()) {
ElMessage.warning('仍有结果未通过后端质量校验,请继续修正')
return
}
try {
await persistWorkflowStep('results')
await confirmDataProcessResults(taskId.value)
} catch {
return
}
dirty.value = false
allowLeave = true
ElMessage.success('生成结果已确认')
await router.replace({ name: 'data-process-detail', params: { id: taskId.value } })
}
async function handleCancel() {
await returnToPreviousPage()
}
async function returnToPreviousPage() {
await router.push({ name: 'data-process' })
}
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
}
})
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)
const restoredSource = restoreExternalSourceConfig(sourceTask.config || {})
sourceMode.value = restoredSource.mode
Object.assign(externalSource, restoredSource.source)
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'
if (resumeStep === 'generate' || resumeStep === 'results') {
const resume = resumeGeneration()
goToStep(resumeStep)
await resume
return
}
goToStep(resumeStep)
}
onBeforeUnmount(() => {
stopPreviewPolling()
stopGenerationTimer()
})
onMounted(() => {
localStorage.removeItem('yg-data-process-create-draft')
void modelsStore.load(true)
if (isRegeneration.value || isWorkflowResume.value) void initializeExistingWorkflow()
})
</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"
:source-mode="sourceMode"
:uploaded-files="uploadedFiles"
:external-source="externalSource"
:external-pulling="externalPulling"
:external-connected="externalConnected"
:preview-building="previewBuilding"
:source-uploading="sourceUploading"
@update:external-source="updateExternalSource"
@update:source-mode="updateSourceMode"
@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 ?? ''"
:file-format="activePreviewFile?.fileFormat"
: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"
@retry="handleStartGeneration"
/>
<ResultEditorStep
v-else-if="currentStepId === 'results'"
v-model:selected-id="selectedResultId"
:items="results"
:preview-items="previewItems"
:regenerating-result-id="regeneratingResultId"
:bulk-regeneration="bulkRegeneration"
:evaluation="evaluation"
:output-type="activeOutputType"
@update:field="updateResultField"
@regenerate:all="regenerateAllResults"
@regenerate:item="regenerateResult"
@evaluate:all="evaluateAllResults"
/>
</div>
</div>
</main>
<footer class="wizard-footer">
<div class="footer-left">
<el-button
v-if="currentStep > 0"
:disabled="(currentStepId === 'generate' && !canReturnFromGeneration) || 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>
</div>
<div class="footer-center">
</div>
<div class="footer-right">
<el-button
class="wizard-primary-action"
type="primary"
:loading="externalPulling || modelSubmitLoading || generationStarting || generation.status === 'running' || resultRegenerationBusy || (currentStepId === 'upload' && (sourceUploading || previewBuilding))"
:disabled="externalPulling || hydrating || modelSubmitLoading || generationStarting || 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;" />
</el-button>
</div>
</footer>
</div>
<AppConfirmDialog ref="confirmDialogRef" />
</template>
<style scoped lang="scss" src="./create/data-process-create.scss"></style>