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

928 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, 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 ModelStatusTag from '@/components/ModelStatusTag.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'
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: '生成提示语',
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(() => {
const items = detail.value?.output_datasets || []
if (items.length) return items
if (!detail.value?.output_dataset_id) return []
return [{
id: detail.value.output_dataset_id,
name: detail.value.output_dataset_name || detail.value.output_dataset || '训练集',
type: 'train',
}]
})
const outputDatasetName = computed(() => (
outputDatasets.value.map((item) => item.name).join('、')
))
const outputDatasetId = computed(() => detail.value?.output_dataset_id || null)
const canRegenerate = computed(() => {
const status = detail.value?.status
return status === 'pending'
|| status === 'failed'
|| status === 'stopped'
|| (status === 'completed' && Boolean(outputDatasetId.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(() => Object.entries(detail.value?.config || {})
.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<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 === '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 {
detail.value = await getDataProcessTask(taskId.value)
} 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>
<ModelStatusTag :status="detail.status" />
<el-button
v-if="detail.status === 'completed' && !outputDatasetId"
class="publish-button"
type="primary"
@click="openPublishDialog"
>
<i class="fa fa-database" />发布为三个数据集
</el-button>
<el-button
v-if="canRegenerate"
class="publish-button"
type="primary"
@click="startRegeneration"
>
<i class="fa fa-refresh" />重新生成
</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() }}</strong>
<small>{{ sourceFileCount ? `源文件 ${sourceFileCount} 个:` : '来源:' }}{{ sourceDatasetName }}</small>
</div>
<div class="metric-card">
<span>输出结果</span>
<strong>{{ numeric(detail.output_count).toLocaleString() }}</strong>
<small>{{ outputDatasetName || '尚未发布为数据集' }}</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">
<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>尚未发布为数据集</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() }}</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>查看编辑或恢复处理结果</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 label="操作" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
:disabled="Boolean(outputDatasetId)"
@click="openResultEditor(row as DataProcessResult)"
>编辑</el-button>
<el-button
link
:disabled="Boolean(outputDatasetId)"
: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 || (detail.status === 'completed' ? '没有符合条件的结果' : detail.status === 'failed' ? '任务失败,未生成结果明细' : '处理完成后将在这里展示结果明细') }}</strong>
<span v-if="!resultError">{{ detail.status === 'completed' ? '请调整搜索或筛选条件' : `当前任务状态${detail.status === 'running' ? '运行中' : '等待中'}` }}</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-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>