Files
YG_FT/frontend/src/views/data-process/DataProcessDetailView.vue

979 lines
37 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
import { usePolling } from '@/composables/usePolling'
import {
getDataProcessProgress,
getDataProcessResults,
getDataProcessTask,
publishDataProcess,
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 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: '保留上下文',
}
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 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') {
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)
}
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 } })
}
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>
<el-button
v-if="detail.status === 'completed' && !hasCurrentPublishedDataset"
class="publish-button"
type="primary"
@click="openPublishDialog"
>
<i class="fa fa-database" style="margin-right: 4px;" />发布为三个数据集
</el-button>
<el-button
v-if="canRegenerate"
class="publish-button"
type="primary"
@click="startRegeneration"
>
<i class="fa fa-refresh" style="margin-right: 4px;" />重新生成
</el-button>
</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; }
}
.publish-button { margin-left: auto; }
.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; }
.publish-button { width: 100%; margin-left: 0; }
.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>