Files
YG_FT/frontend/src/views/data-process/DataProcessDetailView.vue
2026-07-30 16:54:00 +08:00

1098 lines
41 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, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
import { usePolling } from '@/composables/usePolling'
import {
getDataProcessProgress,
getDataProcessResults,
getDataProcessTask,
publishDataProcess,
repeatDataProcessTask,
restoreDataProcessResult,
updateDataProcessResult,
} from '@/api/modules/dataProcess'
import type {
DataProcessDatasetSplit,
DataProcessPublishPayload,
DataProcessResult,
DataProcessResultStatus,
DataProcessTask,
DataProcessType,
} from '@/types/dataProcess'
import { dataProcessDisplayStatus } from '@/utils/dataProcessStatus.js'
const route = useRoute()
const router = useRouter()
const taskId = computed(() => String(route.params.id || ''))
const detail = ref<DataProcessTask | null>(null)
const loading = ref(true)
const loadError = ref('')
const results = ref<DataProcessResult[]>([])
const resultTotal = ref(0)
const resultLoading = ref(false)
const resultError = ref('')
const keyword = ref('')
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const editingResult = ref<DataProcessResult | null>(null)
const editDialogVisible = ref(false)
const savingResult = ref(false)
const restoringResultId = ref<string | number | null>(null)
const publishDialogVisible = ref(false)
const publishing = ref(false)
const repeatGenerating = ref(false)
const repeatRequestId = ref('')
const configExpanded = ref(false)
const resultCellTooltipOptions = {
popperClass: 'data-process-result-tooltip',
placement: 'top',
showAfter: 300,
} as const
let resultFilterTimer: ReturnType<typeof setTimeout> | null = null
const editForm = reactive({ instruction: '', input: '', output: '' })
const publishForm = reactive<DataProcessPublishPayload>({
dataset_name: '',
dataset_type: 'train',
storage_type: 'local',
split: { train: 80, validation: 10, test: 10 },
format: 'alpaca_jsonl',
})
const processTypeMap: Record<DataProcessType, string> = {
structured: '结构化数据',
unstructured: '非结构化数据',
external: '外来数据源拉取',
}
const configLabelMap: Record<string, string> = {
preprocess_options: '预处理规则',
dataset_split: '数据集划分',
generation_model_id: '数据生成模型',
generation_prompt: '生成提示语',
output_type: '输出类型',
reasoning_detail: '推理详细程度',
temperature: '生成温度',
max_tokens: '最大输出长度',
json_mode: 'JSON 输出',
quality_filter_enabled: '质量筛选',
filter_low_quality: '过滤低质量内容',
filter_short_content: '过滤过短内容',
min_output_length: '最少输出字数',
semantic_enrichment: '语义增强',
qa_pairs_per_row: '每行生成数量',
qa_pairs_per_chunk: '每切片生成数量',
chunk_method: '切分方式',
chunk_size: '切片长度',
chunk_overlap: '重叠长度',
min_chunk_size: '最小切片长度',
semantic_breakpoint_percentile: '语义断点百分位',
preserve_tables: '保留表格',
preserve_code_blocks: '保留代码块',
preserve_lists: '保留列表',
}
const chunkMethodLabelMap: Record<string, string> = {
layout_hybrid: '版面结构混合切分',
semantic: '语义切分',
fixed: '固定 Token',
}
const preprocessOptionLabelMap: Record<string, string> = {
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: '保留上下文',
}
const structuredPreprocessOptionKeys = new Set([
'clean_invalid',
'deduplicate',
'detect_structure',
'normalize_format',
'desensitize',
'filter_anomaly',
])
function formatStructuredPreprocessOptions(value: unknown[]) {
const options = [...new Set(value.map((item) => String(item)))]
const selected = new Set(options)
const consumed = new Set<string>()
const labels: string[] = []
function appendGroup(values: string[], groupLabel: string) {
const selectedValues = values.filter((item) => selected.has(item))
selectedValues.forEach((item) => consumed.add(item))
if (selectedValues.length === values.length) {
labels.push(groupLabel)
return
}
selectedValues.forEach((item) => {
labels.push(`${preprocessOptionLabelMap[item] || item}(历史部分配置)`)
})
}
appendGroup(['clean_invalid', 'deduplicate'], '数据清洗')
appendGroup(['detect_structure', 'normalize_format'], '结构标准化')
if (selected.has('desensitize')) {
consumed.add('desensitize')
labels.push('敏感信息脱敏')
}
if (selected.has('filter_anomaly')) {
consumed.add('filter_anomaly')
labels.push('异常数据过滤(历史规则)')
}
options.forEach((item) => {
if (!consumed.has(item)) labels.push(preprocessOptionLabelMap[item] || item)
})
return labels.length ? labels.join('、') : '-'
}
function numeric(value: unknown) {
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function formatDateTime(value?: string | null) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
const retentionRate = computed(() => {
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 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
|| detail.value?.source_files?.map((file) => file.name).join('、')
|| '源文件上传'
))
const outputDatasets = computed(() => {
return detail.value?.output_datasets || []
})
const outputDatasetName = computed(() => (
outputDatasets.value.map((item) => item.name).join('、')
))
const outputDatasetId = computed(() => detail.value?.output_dataset_id || null)
const hasPublishedOutputs = computed(() => outputDatasets.value.length > 0)
const hasCurrentPublishedDataset = computed(() => (
Boolean(outputDatasetId.value)
&& outputDatasets.value.some((dataset) => String(dataset.id) === String(outputDatasetId.value))
))
const displayStatus = computed(() => dataProcessDisplayStatus({
status: detail.value?.status || 'pending',
output_dataset_id: hasCurrentPublishedDataset.value ? outputDatasetId.value : null,
}))
const inputMetricUnit = computed(() => isUnstructured.value ? '个' : '条')
const outputSummary = computed(() => {
if (hasCurrentPublishedDataset.value) return outputDatasetName.value || '当前轮已发布'
if (outputDatasetId.value) return '已发布数据集不可用,请重新发布'
if (hasPublishedOutputs.value) {
return `当前轮尚未发布;上次已发布 ${outputDatasets.value.length} 个数据集,仍可正常使用`
}
return detail.value?.status === 'completed' ? '当前轮尚未发布' : '尚未发布为数据集'
})
const resultEmptyTitle = computed(() => {
if (detail.value?.status === 'completed') return '没有符合条件的结果'
if (detail.value?.status === 'failed') return '任务生成失败,未生成结果明细'
if (detail.value?.status === 'stopped') return '任务已停止,暂无生成结果'
return '处理完成后将在这里展示结果明细'
})
const resultEmptyDescription = computed(() => (
detail.value?.status === 'completed'
? '请调整搜索或筛选条件'
: `当前任务状态:${displayStatus.value.label}`
))
const canRegenerate = computed(() => {
const status = detail.value?.status
return status === 'pending'
|| status === 'failed'
|| status === 'stopped'
|| (status === 'completed' && (Boolean(outputDatasetId.value) || hasPublishedOutputs.value))
})
const canRepeatGeneration = computed(() => (
detail.value?.status === 'completed'
&& detail.value.results_confirmed !== false
&& previewCount.value > 0
))
const creatorName = computed(() => detail.value?.creator_name || detail.value?.creator || '-')
const createTime = computed(() => detail.value?.create_time || detail.value?.created_at)
const startTime = computed(() => detail.value?.start_time || detail.value?.started_at)
const completeTime = computed(() => detail.value?.complete_time || detail.value?.completed_at)
const durationText = computed(() => {
if (detail.value?.duration) return detail.value.duration
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(() => {
const config = detail.value?.config || {}
return Object.entries(config)
.filter(([key]) => (
key !== 'generation_model_snapshot'
&& (key !== 'reasoning_detail' || config.output_type === 'reasoning')
&& !/(?: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<string, unknown>
return String(model.name || model.display_name || model.model_name || value || '-')
}
}
if (key === 'chunk_method' && typeof value === 'string') {
return chunkMethodLabelMap[value] || value
}
if (key === 'output_type') {
return value === 'reasoning' ? '思维链回答' : '标准回答'
}
if (key === 'reasoning_detail') {
return value === 'detailed' ? '详细推理' : '普通推理'
}
if (key === 'dataset_split' && value && typeof value === 'object') {
const split = value as Partial<DataProcessDatasetSplit>
return `训练集 ${split.train ?? 0}% / 验证集 ${split.validation ?? 0}% / 测试集 ${split.test ?? 0}%`
}
if (Array.isArray(value)) {
if (key === 'preprocess_options') {
const containsStructuredOption = value.some((item) => (
structuredPreprocessOptionKeys.has(String(item))
))
return containsStructuredOption
? formatStructuredPreprocessOptions(value)
: 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)
}
function processTypeLabel(value?: DataProcessType) {
return value ? processTypeMap[value] || value : '-'
}
function isActiveStatus(status?: DataProcessTask['status']) {
return status === 'running'
}
async function loadTask(silent = false) {
if (!silent) loading.value = true
loadError.value = ''
try {
const task = await getDataProcessTask(taskId.value)
if (task.status !== 'completed' || task.results_confirmed === false) {
await router.replace({ name: 'data-process-workflow', params: { id: task.id } })
return
}
detail.value = task
} catch {
detail.value = null
loadError.value = '数据处理任务加载失败,任务可能已删除或当前无权访问。'
} finally {
if (!silent) loading.value = false
}
}
async function loadResults() {
resultLoading.value = true
resultError.value = ''
try {
const response = await getDataProcessResults(taskId.value, {
page: currentPage.value,
page_size: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value || undefined,
})
results.value = response.items
resultTotal.value = response.total
} catch {
results.value = []
resultTotal.value = 0
resultError.value = '结果明细加载失败,请稍后重试。'
} finally {
resultLoading.value = false
}
}
async function loadPage() {
loading.value = true
await loadTask(true)
if (detail.value) {
await loadResults()
} else {
results.value = []
resultTotal.value = 0
resultError.value = ''
}
loading.value = false
if (isActiveStatus(detail.value?.status)) startPolling()
else stopPolling()
}
async function refreshRuntime() {
if (!detail.value || !isActiveStatus(detail.value.status)) {
stopPolling()
return
}
const progress = await getDataProcessProgress(taskId.value)
detail.value = {
...detail.value,
status: progress.status,
progress: progress.progress,
input_count: progress.input_count ?? detail.value.input_count,
output_count: progress.output_count ?? detail.value.output_count,
filtered_count: progress.filtered_count ?? detail.value.filtered_count,
duplicate_count: progress.duplicate_count ?? detail.value.duplicate_count,
error_count: progress.error_count ?? detail.value.error_count,
failure_reason: progress.failure_reason ?? detail.value.failure_reason,
}
if (!isActiveStatus(progress.status)) {
stopPolling()
await Promise.all([loadTask(true), loadResults()])
}
}
const { start: startPolling, stop: stopPolling } = usePolling(refreshRuntime, 3000, {
immediate: false,
})
function scheduleResultReload() {
if (currentPage.value !== 1) {
currentPage.value = 1
return
}
if (resultFilterTimer) clearTimeout(resultFilterTimer)
resultFilterTimer = setTimeout(() => void loadResults(), 300)
}
function resultStatusLabel(status: DataProcessResultStatus) {
return status === 'valid' ? '有效' : status === 'modified' ? '已修改' : '无效'
}
function resultStatusType(status: DataProcessResultStatus) {
return status === 'valid' ? 'success' : status === 'modified' ? 'warning' : 'danger'
}
function qualityScoreLabel(value: DataProcessResult['quality_score']) {
if (value == null) return '-'
const score = value.overall
return Number.isFinite(score) ? Number(score).toFixed(1) : '-'
}
function qualityFlagsLabel(value: DataProcessResult['quality_score']) {
return value?.flags?.length ? value.flags.join('、') : '未命中质量规则'
}
function replaceResult(updated: DataProcessResult) {
const index = results.value.findIndex((item) => item.id === updated.id)
if (index >= 0) results.value.splice(index, 1, updated)
}
function openResultEditor(result: DataProcessResult) {
editingResult.value = result
Object.assign(editForm, {
instruction: result.instruction,
input: result.input,
output: result.output,
})
editDialogVisible.value = true
}
async function saveResult() {
if (!editingResult.value) return
if (!editForm.instruction.trim() || !editForm.output.trim()) {
ElMessage.warning('Instruction 和 Output 不能为空')
return
}
savingResult.value = true
try {
const updated = await updateDataProcessResult(taskId.value, editingResult.value.id, {
instruction: editForm.instruction,
input: editForm.input,
output: editForm.output,
expected_updated_at: editingResult.value.updated_at,
})
replaceResult(updated)
await loadTask(true)
editDialogVisible.value = false
ElMessage.success('结果已保存')
} catch {
// 统一请求层已展示后端返回的失败原因。
} finally {
savingResult.value = false
}
}
async function restoreResult(result: DataProcessResult) {
try {
await ElMessageBox.confirm('确定恢复为模型最初生成的内容吗?', '恢复生成结果', {
type: 'warning',
confirmButtonText: '恢复',
cancelButtonText: '取消',
})
} catch {
return
}
restoringResultId.value = result.id
try {
const restored = await restoreDataProcessResult(taskId.value, result.id)
replaceResult(restored)
await loadTask(true)
ElMessage.success('已恢复生成结果')
} catch {
// 统一请求层已展示后端返回的失败原因。
} finally {
restoringResultId.value = null
}
}
function configuredSplit(): DataProcessDatasetSplit {
const split = detail.value?.config?.dataset_split
if (!split || typeof split !== 'object') return { train: 80, validation: 10, test: 10 }
const value = split as Partial<DataProcessDatasetSplit>
return {
train: Number(value.train) || 0,
validation: Number(value.validation) || 0,
test: Number(value.test) || 0,
}
}
function openPublishDialog() {
if (!detail.value) return
publishForm.dataset_name = `${detail.value.name}-数据集`
publishForm.split = configuredSplit()
publishDialogVisible.value = true
}
async function publishDataset() {
if (!publishForm.dataset_name.trim()) {
ElMessage.warning('请输入数据集名称')
return
}
publishing.value = true
try {
const published = await publishDataProcess(taskId.value, {
...publishForm,
dataset_name: publishForm.dataset_name.trim(),
split: { ...publishForm.split },
})
const datasets = published.datasets || published.output_datasets || []
ElMessage.success(`已发布 ${datasets.length || 3} 个独立数据集`)
publishDialogVisible.value = false
await router.push({ path: '/dataset', query: { tab: 'task', task_id: taskId.value } })
} catch {
// 统一请求层已展示后端返回的失败原因。
} finally {
publishing.value = false
}
}
function startRegeneration() {
void router.push({ name: 'data-process-regenerate', params: { id: taskId.value } })
}
function createRepeatRequestId() {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID()
}
return `${Date.now()}_${Math.random().toString(36).slice(2, 14)}`
}
async function repeatGeneration() {
if (!detail.value?.updated_at || repeatGenerating.value) return
try {
await ElMessageBox.confirm(
'系统会复制当前配置、源文件和切分结果,创建一个独立的新任务并在后台生成。原任务和原结果不会被修改。',
'按原配置再生成一批?',
{
confirmButtonText: '创建并开始生成',
cancelButtonText: '取消',
type: 'info',
},
)
} catch {
return
}
repeatGenerating.value = true
repeatRequestId.value ||= createRepeatRequestId()
try {
const repeated = await repeatDataProcessTask(taskId.value, {
expected_updated_at: detail.value.updated_at,
request_id: repeatRequestId.value,
})
ElMessage.success(repeated.created ? '已创建新任务,正在后台生成' : '已恢复此前创建的新任务')
await router.push({
name: 'data-process-workflow',
params: { id: repeated.task.id },
})
} catch {
// 保留幂等请求 ID网络超时后再次点击不会重复创建任务。
} finally {
repeatGenerating.value = false
}
}
watch([currentPage, pageSize], () => void loadResults())
onMounted(loadPage)
onBeforeUnmount(() => {
if (resultFilterTimer) clearTimeout(resultFilterTimer)
})
</script>
<template>
<PageCard class="data-process-detail-page" v-loading="loading">
<template v-if="detail" #header>
<div class="detail-heading">
<div class="heading-row">
<h1>{{ detail.name }}</h1>
<el-tag :type="displayStatus.type" size="small" effect="light">
{{ displayStatus.label }}
</el-tag>
<div class="heading-actions">
<el-button
v-if="detail.status === 'completed' && !hasCurrentPublishedDataset"
type="primary"
@click="openPublishDialog"
>
<i class="fa fa-database" style="margin-right: 4px;" />发布为三个数据集
</el-button>
<el-button
v-if="canRepeatGeneration"
type="primary"
:loading="repeatGenerating"
:disabled="repeatGenerating"
@click="repeatGeneration"
>
<i class="fa fa-clone" style="margin-right: 4px;" />按原配置再生成一批
</el-button>
<el-button
v-if="canRegenerate"
type="warning"
plain
@click="startRegeneration"
>
<i class="fa fa-refresh" style="margin-right: 4px;" />覆盖当前任务重新生成
</el-button>
</div>
</div>
<p>{{ detail.description || '暂无任务描述' }}</p>
<dl class="heading-meta">
<div><dt>任务 ID</dt><dd>{{ detail.id }}</dd></div>
<div><dt>处理类型</dt><dd>{{ processTypeLabel(detail.process_type) }}</dd></div>
<div><dt>创建人</dt><dd>{{ creatorName }}</dd></div>
</dl>
</div>
</template>
<div v-if="loadError" class="not-found-state" role="alert">
<i class="fa fa-exclamation-circle" aria-hidden="true" />
<h2>无法加载数据处理任务</h2>
<p>{{ loadError }}</p>
<div class="load-state-actions">
<el-button @click="router.push('/data-process')">返回任务列表</el-button>
<el-button type="primary" @click="loadPage">重新加载</el-button>
</div>
</div>
<template v-else-if="detail">
<section class="metric-grid" aria-label="处理结果概览">
<div class="metric-card">
<span>处理耗时</span>
<strong>{{ durationText }}</strong>
<small>{{ completeTime ? `完成于 ${formatDateTime(completeTime)}` : `当前进度 ${progressPercentage}%` }}</small>
</div>
<div class="metric-card">
<span>{{ inputMetricLabel }}</span>
<strong>{{ inputMetricCount.toLocaleString() }} {{ inputMetricUnit }}</strong>
<small>{{ sourceFileCount ? `源文件 ${sourceFileCount} 个:` : '来源:' }}{{ sourceDatasetName }}</small>
</div>
<div class="metric-card">
<span>输出结果</span>
<strong>{{ numeric(detail.output_count).toLocaleString() }} </strong>
<small>{{ outputSummary }}</small>
</div>
<div class="metric-card is-primary">
<span>结果保留率</span>
<strong>{{ retentionDenominator ? `${retentionRate}%` : '-' }}</strong>
<el-progress :percentage="retentionPercentage" :show-text="false" :stroke-width="5" />
</div>
</section>
<div v-if="detail.failure_reason" class="failure-alert" role="alert">
<i class="fa fa-exclamation-triangle" aria-hidden="true" />
<div><strong>处理任务执行失败</strong><p>{{ detail.failure_reason }}</p></div>
</div>
<div class="detail-grid">
<section class="detail-section" aria-labelledby="runtime-title">
<div class="section-heading">
<div><h2 id="runtime-title">运行信息</h2><p>查看任务执行时间与数据流向</p></div>
</div>
<dl class="info-list">
<div><dt>创建时间</dt><dd>{{ formatDateTime(createTime) }}</dd></div>
<div><dt>开始时间</dt><dd>{{ formatDateTime(startTime) }}</dd></div>
<div><dt>完成时间</dt><dd>{{ formatDateTime(completeTime) }}</dd></div>
<div><dt>处理耗时</dt><dd>{{ durationText }}</dd></div>
<div><dt>源数据集</dt><dd>{{ sourceDatasetName }}</dd></div>
<div class="output-dataset-row">
<dt>输出数据集</dt>
<dd class="output-dataset-cell">
<p
v-if="hasPublishedOutputs && !hasCurrentPublishedDataset"
class="output-dataset-hint"
>当前轮尚未发布以下为上次已发布数据集仍可正常使用</p>
<div v-if="outputDatasets.length" class="output-dataset-links">
<el-button
v-for="dataset in outputDatasets"
:key="String(dataset.id)"
type="primary"
link
@click="router.push(`/dataset/${dataset.id}/preview`)"
>{{ dataset.name }}{{ dataset.count || 0 }} <i class="fa fa-external-link" /></el-button>
</div>
<span v-else-if="outputDatasetId">已发布数据集不可用请重新发布</span>
<span v-else>尚未发布为数据集</span>
</dd>
</div>
</dl>
</section>
<section class="detail-section" aria-labelledby="statistics-title">
<div class="section-heading">
<div><h2 id="statistics-title">处理统计</h2><p>查看数据清洗过滤和输出情况</p></div>
</div>
<div class="statistics-grid">
<div><span>源文件</span><strong>{{ sourceFileCount.toLocaleString() }} </strong></div>
<div><span>{{ inputMetricLabel }}</span><strong>{{ inputMetricCount.toLocaleString() }} {{ inputMetricUnit }}</strong></div>
<div><span>生成结果</span><strong>{{ numeric(detail.output_count).toLocaleString() }} </strong></div>
<div><span>过滤数据</span><strong>{{ numeric(detail.filtered_count).toLocaleString() }} </strong></div>
<div><span>重复数据</span><strong>{{ numeric(detail.duplicate_count).toLocaleString() }} </strong></div>
<div><span>异常数据</span><strong>{{ numeric(detail.error_count).toLocaleString() }} </strong></div>
<div><span>执行进度</span><strong>{{ progressPercentage }}%</strong></div>
</div>
</section>
</div>
<section class="detail-section config-section" aria-labelledby="config-title">
<div class="section-heading config-heading">
<div><h2 id="config-title">处理配置</h2><p>任务执行时使用的规则与参数</p></div>
<div class="config-actions">
<span> {{ configRows.length }} </span>
<el-button
text
:aria-expanded="configExpanded"
aria-controls="config-content"
@click="configExpanded = !configExpanded"
>
{{ configExpanded ? '收起' : '展开' }}
<i class="fa" :class="configExpanded ? 'fa-chevron-up' : 'fa-chevron-down'" />
</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="configExpanded" id="config-content" class="config-content">
<dl v-if="configRows.length" class="config-grid">
<div v-for="item in configRows" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
<div v-else class="compact-empty">暂无处理配置</div>
</div>
</el-collapse-transition>
</section>
<section class="detail-section result-section" aria-labelledby="result-title" v-loading="resultLoading">
<div class="result-toolbar">
<div class="section-heading">
<div>
<h2 id="result-title">结果明细</h2>
<p>{{ hasCurrentPublishedDataset ? '查看生成结果与质量状态,已发布数据请前往数据集详情编辑' : '查看、编辑或恢复待发布结果' }}</p>
</div>
</div>
<div class="result-filters">
<el-input
v-model="keyword"
clearable
placeholder="搜索指令、输入或输出"
@input="scheduleResultReload"
@clear="scheduleResultReload"
>
<template #prefix><i class="fa fa-search" aria-hidden="true" /></template>
</el-input>
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="scheduleResultReload">
<el-option label="有效" value="valid" />
<el-option label="已修改" value="modified" />
<el-option label="无效" value="invalid" />
</el-select>
<el-button :loading="resultLoading" @click="loadResults"><i class="fa fa-refresh" /></el-button>
</div>
</div>
<el-table
v-if="results.length"
:data="results"
:tooltip-options="resultCellTooltipOptions"
row-key="id"
table-layout="fixed"
>
<el-table-column type="index" label="#" width="56" align="center" />
<el-table-column label="指令" min-width="190" show-overflow-tooltip prop="instruction" />
<el-table-column label="输入" min-width="160" show-overflow-tooltip prop="input" />
<el-table-column label="输出" min-width="230" show-overflow-tooltip prop="output" />
<el-table-column label="质量分" width="88" align="center">
<template #default="{ row }">
<el-tooltip :content="qualityFlagsLabel((row as DataProcessResult).quality_score)">
<span>{{ qualityScoreLabel((row as DataProcessResult).quality_score) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="resultStatusType(row.status)" size="small">{{ resultStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!hasCurrentPublishedDataset" label="操作" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openResultEditor(row as DataProcessResult)">编辑</el-button>
<el-button
link
:loading="restoringResultId === row.id"
@click="restoreResult(row as DataProcessResult)"
>恢复</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="result-empty">
<i class="fa" :class="resultError || detail.status === 'failed' ? 'fa-exclamation-circle' : 'fa-hourglass-half'" aria-hidden="true" />
<strong>{{ resultError || resultEmptyTitle }}</strong>
<span v-if="!resultError">{{ resultEmptyDescription }}</span>
<el-button v-else type="primary" link @click="loadResults">重新加载</el-button>
</div>
<div v-if="resultTotal" class="result-pagination">
<span> {{ resultTotal }} 条结果</span>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
layout="sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
:total="resultTotal"
/>
</div>
</section>
</template>
</PageCard>
<el-dialog v-model="editDialogVisible" title="编辑处理结果" width="680px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="Instruction" required>
<el-input v-model="editForm.instruction" type="textarea" :rows="3" maxlength="4000" show-word-limit />
</el-form-item>
<el-form-item label="Input">
<el-input v-model="editForm.input" type="textarea" :rows="3" maxlength="10000" show-word-limit />
</el-form-item>
<el-form-item label="Output" required>
<el-input v-model="editForm.output" type="textarea" :rows="6" maxlength="20000" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingResult" @click="saveResult">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="publishDialogVisible" title="发布为三个数据集" width="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="数据集名称" required>
<el-input v-model="publishForm.dataset_name" maxlength="150" show-word-limit />
</el-form-item>
<div class="publish-form-grid">
<el-form-item label="存储位置">
<el-select v-model="publishForm.storage_type">
<el-option label="平台本地存储" value="local" />
</el-select>
</el-form-item>
<el-form-item label="输出格式">
<el-select v-model="publishForm.format">
<el-option label="Alpaca JSONL" value="alpaca_jsonl" />
<el-option label="JSONL" value="jsonl" />
</el-select>
</el-form-item>
</div>
<el-alert
type="info"
:closable="false"
:title="`将发布三个独立数据集:训练集 ${publishForm.split.train}% / 验证集 ${publishForm.split.validation}% / 测试集 ${publishForm.split.test}%`"
/>
</el-form>
<template #footer>
<el-button @click="publishDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="publishing" @click="publishDataset">发布三个数据集</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.data-process-detail-page { width: 100%; min-width: 0; }
.detail-heading {
width: 100%;
.heading-row { display: flex; align-items: center; gap: 12px; }
h1 { margin: 0; color: #1f2937; font-size: 20px; font-weight: 600; }
> p { margin: 8px 0 0; color: #64748b; font-size: 13px; }
}
.heading-actions {
margin-left: auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
:deep(.el-button + .el-button) { margin-left: 0; }
}
.load-state-actions { display: flex; gap: 10px; }
.compact-empty { padding: 28px 18px; color: #94a3b8; font-size: 13px; text-align: center; }
.publish-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.publish-form-grid :deep(.el-select) { width: 100%; }
.heading-meta {
margin: 16px 0 0; display: flex; flex-wrap: wrap; gap: 10px 30px;
div { display: flex; gap: 7px; font-size: 12px; }
dt { color: #94a3b8; }
dd { margin: 0; color: #475569; font-weight: 500; }
}
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.metric-card {
min-height: 116px; padding: 18px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff;
display: flex; flex-direction: column;
> span { color: #64748b; font-size: 12px; }
> strong { margin-top: 10px; color: #1f2937; font-size: 22px; font-weight: 600; }
> small { margin-top: auto; color: #94a3b8; font-size: 12px; }
:deep(.el-progress) { margin-top: auto; }
&.is-primary { border-color: var(--el-color-primary-light-7); background: var(--el-color-primary-light-9); }
&.is-primary > strong { color: var(--primary-color); }
}
.failure-alert {
margin-top: 16px; padding: 14px 16px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2;
color: #b91c1c; display: flex; gap: 12px;
> i { margin-top: 2px; }
strong { font-size: 13px; }
p { margin: 4px 0 0; color: #7f1d1d; font-size: 12px; }
}
.detail-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.detail-section { border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; overflow: hidden; }
.section-heading {
padding: 16px 18px; border-bottom: 1px solid #eef0f3;
h2 { margin: 0; color: #1f2937; font-size: 15px; font-weight: 600; }
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;
&:last-child { border-bottom: 0; }
dt { color: #64748b; font-size: 12px; }
dd { margin: 0; color: #334155; font-size: 13px; text-align: right; }
:deep(.el-button) { height: auto; padding: 0; }
}
.info-list > .output-dataset-row {
align-items: start;
padding: 10px 0;
dt { padding-top: 2px; line-height: 24px; }
}
.output-dataset-cell { min-width: 0; }
.output-dataset-hint {
margin: 0 0 6px;
color: #d97706;
font-size: 12px;
line-height: 1.5;
text-align: left;
}
.output-dataset-links {
max-width: 100%;
min-width: 0;
display: inline-grid;
grid-template-columns: max-content;
gap: 4px;
text-align: left;
:deep(.el-button) {
width: 100%;
max-width: 100%;
margin-left: 0;
justify-content: flex-start;
line-height: 24px;
white-space: normal;
}
:deep(.el-button > span) {
width: 100%;
display: flex;
align-items: center;
text-align: left;
}
:deep(.el-button i) {
flex: none;
margin-left: auto;
padding-left: 12px;
}
}
.statistics-grid { padding: 18px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.statistics-grid > div {
min-height: 78px; padding: 13px; border-radius: 6px; background: #f8fafc; display: flex; flex-direction: column; gap: 8px;
span { color: #64748b; font-size: 12px; }
strong { color: #1f2937; font-size: 18px; font-weight: 600; }
}
.config-section, .result-section { margin-top: 16px; }
.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 { 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; }
.result-filters { padding-right: 18px; display: flex; gap: 10px; }
.result-filters :deep(.el-input) { width: 250px; }
.result-filters :deep(.el-select) { width: 120px; }
.result-section :deep(.el-table) { border-radius: 0; }
:global(.data-process-result-tooltip) {
box-sizing: border-box;
max-width: min(520px, calc(100vw - 32px));
max-height: 240px;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.6;
}
.result-empty, .not-found-state {
min-height: 220px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8;
> i { margin-bottom: 12px; color: var(--el-color-primary-light-5); font-size: 30px; }
strong, h2 { margin: 0; color: #475569; font-size: 14px; font-weight: 500; }
span, p { margin: 6px 0 16px; font-size: 12px; }
}
.result-pagination { min-height: 56px; padding: 0 18px; border-top: 1px solid #eef0f3; display: flex; align-items: center; justify-content: space-between; }
.result-pagination > span { color: #94a3b8; font-size: 12px; }
.result-pagination :deep(.el-pagination) { margin-top: 0; }
@media (max-width: 1100px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.metric-grid, .config-grid { grid-template-columns: 1fr; }
.detail-heading .heading-row { align-items: flex-start; flex-wrap: wrap; }
.heading-actions { width: 100%; margin-left: 0; }
.heading-actions :deep(.el-button) { width: 100%; }
.publish-form-grid { grid-template-columns: 1fr; gap: 0; }
.result-toolbar { align-items: stretch; flex-direction: column; }
.result-filters { padding: 0 16px 16px; flex-direction: column; }
.result-filters :deep(.el-input), .result-filters :deep(.el-select) { width: 100%; }
.heading-meta { flex-direction: column; gap: 6px; }
}
</style>