diff --git a/frontend/scripts/regression-data-process-detail.mjs b/frontend/scripts/regression-data-process-detail.mjs index 6e0ea78..e16e45a 100644 --- a/frontend/scripts/regression-data-process-detail.mjs +++ b/frontend/scripts/regression-data-process-detail.mjs @@ -57,6 +57,17 @@ assert.match(detailSource, /结果明细加载失败/, '结果加载失败缺少 assert.match(detailSource, /width:\s*100%/, '详情页没有铺满内容区域') assert.doesNotMatch(detailSource, /^\s*max-width:\s*\d+px/m, '详情页不应使用固定最大宽度') assert.match(detailSource, /!\/\(\?:password\|secret\|token\|api_key\)\/i\.test\(key\)/, '处理配置没有过滤敏感凭据字段') +assert.match(detailSource, /key !== 'generation_model_snapshot'/, '处理配置仍直接展示内部模型快照') +assert.match(detailSource, /preprocessOptionLabelMap/, '处理配置没有把预处理内部枚举转换为中文') +assert.match(detailSource, /const parsed = typeof value === 'number' \? value : Number\(value\)/, '详情页不兼容 PostgreSQL 数字字符串') +assert.match(detailSource, /new Date\(startTime\.value\)\.getTime\(\)/, '详情页没有在后端耗时缺失时按开始、完成时间回算') +assert.match(detailSource, /outputCount \+ numeric\(detail\.value\?\.filtered_count\)/, '结果保留率没有使用输出和过滤结果的同口径分母') +assert.doesNotMatch(detailSource, /numeric\(detail\.value\?\.output_count\) \/ inputCount/, '结果保留率仍在用输出结果除以源文件记录数') +assert.match(detailSource, /:percentage="retentionPercentage"/, '结果保留率进度条仍绑定任务执行进度') +assert.match(detailSource, /preview_count/, '非结构化任务没有展示实际输入切片数') +assert.match(detailSource, /const configExpanded = ref\(false\)/, '处理配置没有默认收起') +assert.match(detailSource, /:aria-expanded="configExpanded"/, '处理配置折叠按钮缺少无障碍状态') +assert.match(detailSource, /[\s\S]*?v-show="configExpanded"/, '处理配置没有折叠过渡或内容状态') assert.doesNotMatch(detailSource, /const (?:detailMap|completedResults)\b|TODO: 接入真实接口/, '详情页仍包含本地 Mock 数据') for (const apiName of [ diff --git a/frontend/scripts/regression-data-process-wizard.mjs b/frontend/scripts/regression-data-process-wizard.mjs index 4029f0e..4dffb35 100644 --- a/frontend/scripts/regression-data-process-wizard.mjs +++ b/frontend/scripts/regression-data-process-wizard.mjs @@ -86,15 +86,17 @@ for (const component of expectedComponents) { const typesPath = path.join(createDir, 'types.ts') const modelPath = path.join(createDir, 'previewModel.ts') const pdfViewerPath = path.join(createDir, 'PdfSourceViewer.vue') +const resultEditorPath = path.join(createDir, 'ResultEditorStep.vue') assert.ok(existsSync(typesPath), '缺少向导类型定义') assert.ok(existsSync(modelPath), '缺少来源映射模型') assert.ok(existsSync(pdfViewerPath), '缺少 PDF 原文件预览组件') -const [typesSource, modelSource, previewSource, pdfViewerSource] = await Promise.all([ +const [typesSource, modelSource, previewSource, pdfViewerSource, resultEditorSource] = await Promise.all([ readFile(typesPath, 'utf8'), readFile(modelPath, 'utf8'), readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'), readFile(pdfViewerPath, 'utf8'), + readFile(resultEditorPath, 'utf8'), ]) for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) { @@ -307,6 +309,14 @@ assert.match(viewSource, /function goToStep\(stepId: StepId\)[\s\S]*?WIZARD_STEP assert.match(viewSource, /currentStepId\.value === 'preview'[\s\S]*?goToStep\('generate'\)/, '数据预览步骤没有进入开始生成') assert.match(viewSource, /generation\.status === 'success'[\s\S]*?goToStep\('results'\)/, '生成成功后没有进入结果编辑与保存') assert.match(viewSource, / item\.id === previewItemId\)/, '结果编辑器没有按 preview_item_id 关联原文') +assert.match(resultEditorSource, /editedContent\.trim\(\)[\s\S]*?originalContent\.trim\(\)/, '原文参照没有优先展示实际用于生成的预处理内容') +assert.match(resultEditorSource, /原文参照/, '结果编辑器缺少原文参照区域') +assert.match(resultEditorSource, /实际送入模型的预处理后原文/, '结果编辑器没有说明原文参照口径') assert.match(viewSource, /watch\(processType,[\s\S]*?resetSourceDataForProcessTypeChange\(\)/, '切换处理类型后没有失效旧源数据') assert.match(viewSource, /function resetSourceDataForProcessTypeChange\(\)[\s\S]*?uploadedFiles\.value = \[\][\s\S]*?selectedPreviewFileId\.value = null/, '旧源数据失效没有同步清理文件与预览选择') diff --git a/frontend/src/types/dataProcess.ts b/frontend/src/types/dataProcess.ts index ab5835d..85dd096 100644 --- a/frontend/src/types/dataProcess.ts +++ b/frontend/src/types/dataProcess.ts @@ -37,6 +37,7 @@ export interface DataProcessTask { output_dataset_name?: string | null output_dataset?: string | null source_file_count?: number + preview_count?: number input_count?: number output_count?: number filtered_count?: number diff --git a/frontend/src/views/data-process/DataProcessCreateView.vue b/frontend/src/views/data-process/DataProcessCreateView.vue index 7f7e2f2..542af93 100644 --- a/frontend/src/views/data-process/DataProcessCreateView.vue +++ b/frontend/src/views/data-process/DataProcessCreateView.vue @@ -950,6 +950,7 @@ onMounted(() => { v-else-if="currentStepId === 'results'" v-model:selected-id="selectedResultId" :items="results" + :preview-items="previewItems" @update:field="updateResultField" @restore:item="restoreResult" /> diff --git a/frontend/src/views/data-process/DataProcessDetailView.vue b/frontend/src/views/data-process/DataProcessDetailView.vue index 918f4a1..b81a371 100644 --- a/frontend/src/views/data-process/DataProcessDetailView.vue +++ b/frontend/src/views/data-process/DataProcessDetailView.vue @@ -42,6 +42,7 @@ const savingResult = ref(false) const restoringResultId = ref(null) const publishDialogVisible = ref(false) const publishing = ref(false) +const configExpanded = ref(false) let resultFilterTimer: ReturnType | null = null const editForm = reactive({ instruction: '', input: '', output: '' }) @@ -90,8 +91,24 @@ const chunkMethodLabelMap: Record = { custom: '自定义分隔符', } -function numeric(value: number | undefined) { - return Number.isFinite(value) ? Number(value) : 0 +const preprocessOptionLabelMap: Record = { + clean_invalid: '清理无效数据', + detect_structure: '识别表格结构', + deduplicate: '重复数据去重', + normalize_format: '数据格式标准化', + filter_anomaly: '异常数据过滤', + desensitize: '敏感信息脱敏', + clean_invalid_content: '清理无效内容', + detect_document_structure: '识别文档结构', + merge_short_content: '合并过短内容', + filter_low_quality: '过滤低质量内容', + deduplicate_content: '重复内容去重', + preserve_context: '保留上下文', +} + +function numeric(value: unknown) { + const parsed = typeof value === 'number' ? value : Number(value) + return Number.isFinite(parsed) ? parsed : 0 } function formatDateTime(value?: string | null) { @@ -101,13 +118,31 @@ function formatDateTime(value?: string | null) { } const retentionRate = computed(() => { - const inputCount = numeric(detail.value?.input_count) - return inputCount - ? Number(((numeric(detail.value?.output_count) / inputCount) * 100).toFixed(1)) + const outputCount = numeric(detail.value?.output_count) + const denominator = outputCount + numeric(detail.value?.filtered_count) + return denominator + ? Number(((outputCount / denominator) * 100).toFixed(1)) : 0 }) -const progressPercentage = computed(() => Math.min(100, Math.max(0, numeric(detail.value?.progress)))) +const retentionDenominator = computed(() => ( + numeric(detail.value?.output_count) + numeric(detail.value?.filtered_count) +)) +const retentionPercentage = computed(() => Math.min(100, Math.max(0, retentionRate.value))) +const progressPercentage = computed(() => ( + detail.value?.status === 'completed' + ? 100 + : Math.min(100, Math.max(0, numeric(detail.value?.progress))) +)) +const isUnstructured = computed(() => detail.value?.process_type === 'unstructured') +const sourceFileCount = computed(() => ( + numeric(detail.value?.source_file_count) || detail.value?.source_files?.length || 0 +)) +const previewCount = computed(() => numeric(detail.value?.preview_count)) +const inputMetricLabel = computed(() => isUnstructured.value ? '输入切片' : '输入记录') +const inputMetricCount = computed(() => ( + isUnstructured.value ? previewCount.value : numeric(detail.value?.input_count) +)) const sourceDatasetName = computed(() => ( detail.value?.source_dataset_name || detail.value?.source_dataset @@ -125,22 +160,44 @@ const completeTime = computed(() => detail.value?.complete_time || detail.value? const durationText = computed(() => { if (detail.value?.duration) return detail.value.duration - const seconds = detail.value?.duration_seconds - if (!Number.isFinite(seconds)) return detail.value?.status === 'running' ? '处理中' : '-' - const safeSeconds = Math.max(0, Math.round(Number(seconds))) + const providedDuration = detail.value?.duration_seconds + let seconds = providedDuration == null ? null : numeric(providedDuration) + if (seconds == null && startTime.value) { + const started = new Date(startTime.value).getTime() + const finished = completeTime.value + ? new Date(completeTime.value).getTime() + : detail.value?.status === 'running' ? Date.now() : Number.NaN + if (Number.isFinite(started) && Number.isFinite(finished) && finished >= started) { + seconds = (finished - started) / 1000 + } + } + if (seconds == null) return detail.value?.status === 'running' ? '处理中' : '-' + const safeSeconds = Math.max(0, Math.round(seconds)) + const hours = Math.floor(safeSeconds / 3600) const minutes = Math.floor(safeSeconds / 60) const restSeconds = safeSeconds % 60 + if (hours) return `${hours} 小时 ${minutes % 60} 分 ${restSeconds} 秒` return minutes ? `${minutes} 分 ${restSeconds} 秒` : `${restSeconds} 秒` }) const configRows = computed(() => Object.entries(detail.value?.config || {}) - .filter(([key]) => !/(?:password|secret|token|api_key)/i.test(key)) + .filter(([key]) => ( + key !== 'generation_model_snapshot' + && !/(?:password|secret|token|api_key)/i.test(key) + )) .map(([key, value]) => ({ label: configLabelMap[key] || key.split('_').join(' '), value: formatConfigValue(key, value), }))) function formatConfigValue(key: string, value: unknown) { + if (key === 'generation_model_id') { + const snapshot = detail.value?.config?.generation_model_snapshot + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { + const model = snapshot as Record + return String(model.name || model.display_name || model.model_name || value || '-') + } + } if (key === 'chunk_method' && typeof value === 'string') { return chunkMethodLabelMap[value] || value } @@ -148,7 +205,14 @@ function formatConfigValue(key: string, value: unknown) { const split = value as Partial return `训练集 ${split.train ?? 0}% / 验证集 ${split.validation ?? 0}% / 测试集 ${split.test ?? 0}%` } - if (Array.isArray(value)) return value.length ? value.join('、') : '-' + if (Array.isArray(value)) { + if (key === 'preprocess_options') { + return value.length + ? value.map((item) => preprocessOptionLabelMap[String(item)] || String(item)).join('、') + : '-' + } + return value.length ? value.join('、') : '-' + } if (typeof value === 'boolean') return value ? '是' : '否' if (value && typeof value === 'object') return JSON.stringify(value) return value == null || value === '' ? '-' : String(value) @@ -436,19 +500,19 @@ onBeforeUnmount(() => { {{ completeTime ? `完成于 ${formatDateTime(completeTime)}` : `当前进度 ${progressPercentage}%` }}
- 输入数据 - {{ numeric(detail.input_count).toLocaleString() }} - 来源:{{ sourceDatasetName }} + {{ inputMetricLabel }} + {{ inputMetricCount.toLocaleString() }} + {{ sourceFileCount ? `源文件 ${sourceFileCount} 个:` : '来源:' }}{{ sourceDatasetName }}
输出结果 {{ numeric(detail.output_count).toLocaleString() }} - {{ outputDatasetName || '尚未生成输出数据集' }} + {{ outputDatasetName || '尚未发布为数据集' }}
- 数据保留率 - {{ numeric(detail.input_count) ? `${retentionRate}%` : '-' }} - + 结果保留率 + {{ retentionDenominator ? `${retentionRate}%` : '-' }} +
@@ -477,7 +541,7 @@ onBeforeUnmount(() => { link @click="router.push(`/dataset/${outputDatasetId}/preview`)" >{{ outputDatasetName }} - {{ outputDatasetName || '尚未生成' }} + {{ outputDatasetName || '尚未发布为数据集' }} @@ -488,7 +552,8 @@ onBeforeUnmount(() => {

处理统计

查看数据清洗、过滤和输出情况

-
原始数据{{ numeric(detail.input_count).toLocaleString() }}
+
源文件{{ sourceFileCount.toLocaleString() }}
+
{{ inputMetricLabel }}{{ inputMetricCount.toLocaleString() }}
成功输出{{ numeric(detail.output_count).toLocaleString() }}
过滤数据{{ numeric(detail.filtered_count).toLocaleString() }}
重复数据{{ numeric(detail.duplicate_count).toLocaleString() }}
@@ -499,13 +564,29 @@ onBeforeUnmount(() => {
-
+

处理配置

任务执行时使用的规则与参数

+
+ 共 {{ configRows.length }} 项 + + {{ configExpanded ? '收起' : '展开' }} + + +
-
-
{{ item.label }}
{{ item.value }}
-
-
暂无处理配置
+ +
+
+
{{ item.label }}
{{ item.value }}
+
+
暂无处理配置
+
+
@@ -698,6 +779,22 @@ onBeforeUnmount(() => { p { margin: 4px 0 0; color: #94a3b8; font-size: 12px; } } +.config-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.config-actions { + display: flex; + align-items: center; + gap: 8px; + + > span { color: #94a3b8; font-size: 12px; } + :deep(.el-button) { gap: 6px; } +} + .info-list { margin: 0; padding: 4px 18px 12px; } .info-list > div { min-height: 44px; border-bottom: 1px solid #f1f5f9; display: grid; grid-template-columns: 100px minmax(0, 1fr); align-items: center; @@ -718,7 +815,7 @@ onBeforeUnmount(() => { .config-grid { margin: 0; padding: 8px 18px 16px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 40px; } .config-grid > div { min-height: 48px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 20px; } .config-grid dt { color: #64748b; font-size: 12px; } -.config-grid dd { margin: 0; color: #334155; font-size: 13px; text-align: right; } +.config-grid dd { max-width: 68%; margin: 0; color: #334155; font-size: 13px; overflow-wrap: anywhere; text-align: right; } .result-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #eef0f3; } .result-toolbar .section-heading { border-bottom: 0; } diff --git a/frontend/src/views/data-process/create/ResultEditorStep.vue b/frontend/src/views/data-process/create/ResultEditorStep.vue index 471da26..2d6c060 100644 --- a/frontend/src/views/data-process/create/ResultEditorStep.vue +++ b/frontend/src/views/data-process/create/ResultEditorStep.vue @@ -1,9 +1,10 @@