fix(dataset): 展示并切换三路数据切分
This commit is contained in:
@@ -10,6 +10,7 @@ const previewComponentPaths = [
|
|||||||
'DatasetRecordTable.vue',
|
'DatasetRecordTable.vue',
|
||||||
'DatasetRecordEditorDialog.vue',
|
'DatasetRecordEditorDialog.vue',
|
||||||
'DatasetRawPreview.vue',
|
'DatasetRawPreview.vue',
|
||||||
|
'DatasetSplitNavigator.vue',
|
||||||
].map((name) => path.join(previewComponentsDir, name))
|
].map((name) => path.join(previewComponentsDir, name))
|
||||||
const apiPath = path.join(root, 'src/api/modules/dataset.ts')
|
const apiPath = path.join(root, 'src/api/modules/dataset.ts')
|
||||||
const adapterPath = path.join(root, 'src/mock/adapter.ts')
|
const adapterPath = path.join(root, 'src/mock/adapter.ts')
|
||||||
@@ -50,12 +51,16 @@ function expect(condition, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(previewLogicSource.split('\n').length < 520, 'DatasetPreviewView.vue 的逻辑与模板应保持在 520 行以内')
|
expect(previewLogicSource.split('\n').length < 520, 'DatasetPreviewView.vue 的逻辑与模板应保持在 520 行以内')
|
||||||
for (const componentName of ['DatasetVersionBar', 'DatasetRecordTable', 'DatasetRecordEditorDialog', 'DatasetRawPreview']) {
|
for (const componentName of ['DatasetVersionBar', 'DatasetRecordTable', 'DatasetRecordEditorDialog', 'DatasetRawPreview', 'DatasetSplitNavigator']) {
|
||||||
expect(source.includes(`import ${componentName}`), `主页面未导入叶子组件:${componentName}`)
|
expect(source.includes(`import ${componentName}`), `主页面未导入叶子组件:${componentName}`)
|
||||||
expect(source.includes(`<${componentName}`), `主页面未挂载叶子组件:${componentName}`)
|
expect(source.includes(`<${componentName}`), `主页面未挂载叶子组件:${componentName}`)
|
||||||
}
|
}
|
||||||
expect(source.includes('class="preview-workspace"'), '详情页应提供数据文件工作区')
|
expect(source.includes('class="preview-workspace"'), '详情页应提供数据文件工作区')
|
||||||
expect(!source.includes('class="file-pane"'), '单文件数据集详情不应展示文件选择侧栏')
|
expect(previewSurfaceSource.includes('class="split-file-tabs"'), '多切分数据集应提供训练/验证/测试文件切换')
|
||||||
|
expect(source.includes('@select="handleFileChange"'), '切换数据切分时应加载对应文件版本')
|
||||||
|
expect(previewSurfaceSource.includes('splitCounts.train'), '数据集概览应显示训练集数量')
|
||||||
|
expect(previewSurfaceSource.includes('splitCounts.validation'), '数据集概览应显示验证集数量')
|
||||||
|
expect(previewSurfaceSource.includes('splitCounts.test'), '数据集概览应显示测试集数量')
|
||||||
expect(previewSurfaceSource.includes('class="code-line"'), '内容查看器应提供逐行展示')
|
expect(previewSurfaceSource.includes('class="code-line"'), '内容查看器应提供逐行展示')
|
||||||
expect(previewSurfaceSource.includes('class="line-number"'), '内容查看器应显示行号')
|
expect(previewSurfaceSource.includes('class="line-number"'), '内容查看器应显示行号')
|
||||||
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
|
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface DatasetFile {
|
|||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
size?: string | number
|
size?: string | number
|
||||||
|
record_count?: number
|
||||||
|
split?: 'train' | 'validation' | 'test' | null
|
||||||
[k: string]: any
|
[k: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ export interface DatasetItem {
|
|||||||
description?: string
|
description?: string
|
||||||
create_time?: string
|
create_time?: string
|
||||||
files?: DatasetFile[]
|
files?: DatasetFile[]
|
||||||
|
split_counts?: { train: number; validation: number; test: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatasetVersion {
|
export interface DatasetVersion {
|
||||||
|
|||||||
@@ -448,6 +448,33 @@ async function publishDataset() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resyncPublishedDataset() {
|
||||||
|
if (!detail.value || !outputDatasetId.value || publishing.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'将按当前 8:1:1 配置重新生成训练、验证、测试三个切分文件,数据集 ID 保持不变。',
|
||||||
|
'重新同步数据切分?',
|
||||||
|
{ type: 'warning', confirmButtonText: '重新同步', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
publishing.value = true
|
||||||
|
try {
|
||||||
|
const published = await publishDataProcess(taskId.value, {
|
||||||
|
...publishForm,
|
||||||
|
dataset_name: outputDatasetName.value || `${detail.value.name}-数据集`,
|
||||||
|
split: configuredSplit(),
|
||||||
|
})
|
||||||
|
ElMessage.success('训练集、验证集和测试集已重新同步')
|
||||||
|
const datasetId = published.dataset?.id || outputDatasetId.value
|
||||||
|
await router.push(`/dataset/${datasetId}/preview`)
|
||||||
|
} finally {
|
||||||
|
publishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch([currentPage, pageSize], () => void loadResults())
|
watch([currentPage, pageSize], () => void loadResults())
|
||||||
|
|
||||||
onMounted(loadPage)
|
onMounted(loadPage)
|
||||||
@@ -472,6 +499,13 @@ onBeforeUnmount(() => {
|
|||||||
<i class="fa" :class="outputDatasetId ? 'fa-external-link' : 'fa-database'" />
|
<i class="fa" :class="outputDatasetId ? 'fa-external-link' : 'fa-database'" />
|
||||||
{{ outputDatasetId ? '查看输出数据集' : '发布为数据集' }}
|
{{ outputDatasetId ? '查看输出数据集' : '发布为数据集' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="detail.status === 'completed' && outputDatasetId"
|
||||||
|
:loading="publishing"
|
||||||
|
@click="resyncPublishedDataset"
|
||||||
|
>
|
||||||
|
<i class="fa fa-refresh" />重新同步切分
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ detail.description || '暂无任务描述' }}</p>
|
<p>{{ detail.description || '暂无任务描述' }}</p>
|
||||||
<dl class="heading-meta">
|
<dl class="heading-meta">
|
||||||
@@ -717,7 +751,7 @@ onBeforeUnmount(() => {
|
|||||||
<el-alert
|
<el-alert
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
:title="`数据集划分:训练集 ${publishForm.split.train}% / 验证集 ${publishForm.split.validation}% / 测试集 ${publishForm.split.test}%`"
|
:title="`将生成三个独立文件:训练集 ${publishForm.split.train}% / 验证集 ${publishForm.split.validation}% / 测试集 ${publishForm.split.test}%`"
|
||||||
/>
|
/>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ const filteredDataList = computed(() => {
|
|||||||
return dataList.value.filter((item) => item.source !== 'task')
|
return dataList.value.filter((item) => item.source !== 'task')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const splitLabels = [
|
||||||
|
{ key: 'train', label: '训练', type: 'primary' },
|
||||||
|
{ key: 'validation', label: '验证', type: 'warning' },
|
||||||
|
{ key: 'test', label: '测试', type: 'success' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function hasSplitCounts(row: Partial<DatasetItem>) {
|
||||||
|
return row.source === 'task'
|
||||||
|
&& Object.values(row.split_counts || {}).some((value) => Number(value) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -87,9 +98,17 @@ onMounted(loadData)
|
|||||||
<el-table-column label="数据集名称" prop="name" align="center" />
|
<el-table-column label="数据集名称" prop="name" align="center" />
|
||||||
<el-table-column label="数据类型" align="center" width="110">
|
<el-table-column label="数据类型" align="center" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag type="primary" size="small">
|
<div v-if="hasSplitCounts(row)" class="split-tags">
|
||||||
{{ DATASET_TYPE_MAP[String(row.type).toLowerCase()] || row.type || '-' }}
|
<el-tag
|
||||||
|
v-for="item in splitLabels"
|
||||||
|
:key="item.key"
|
||||||
|
:type="item.type"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ item.label }} {{ row.split_counts?.[item.key] || 0 }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-tag v-else type="primary" size="small">{{ DATASET_TYPE_MAP[String(row.type).toLowerCase()] || row.type || '-' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="存储位置" align="center" width="110">
|
<el-table-column label="存储位置" align="center" width="110">
|
||||||
@@ -167,4 +186,11 @@ onMounted(loadData)
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import DatasetVersionBar from './preview/DatasetVersionBar.vue'
|
|||||||
import DatasetRecordTable from './preview/DatasetRecordTable.vue'
|
import DatasetRecordTable from './preview/DatasetRecordTable.vue'
|
||||||
import DatasetRecordEditorDialog from './preview/DatasetRecordEditorDialog.vue'
|
import DatasetRecordEditorDialog from './preview/DatasetRecordEditorDialog.vue'
|
||||||
import DatasetRawPreview from './preview/DatasetRawPreview.vue'
|
import DatasetRawPreview from './preview/DatasetRawPreview.vue'
|
||||||
|
import DatasetSplitNavigator from './preview/DatasetSplitNavigator.vue'
|
||||||
import type { EditField } from './preview/types'
|
import type { EditField } from './preview/types'
|
||||||
import type { DatasetFile, DatasetItem, DatasetVersion } from '@/types'
|
import type { DatasetFile, DatasetItem, DatasetVersion } from '@/types'
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ let versionRequestId = 0
|
|||||||
|
|
||||||
const files = computed<DatasetFile[]>(() => dataset.value?.files || [])
|
const files = computed<DatasetFile[]>(() => dataset.value?.files || [])
|
||||||
const selectedFile = computed(() => files.value.find((file) => fileKey(file) === selectedFileId.value))
|
const selectedFile = computed(() => files.value.find((file) => fileKey(file) === selectedFileId.value))
|
||||||
|
const splitCounts = computed(() => dataset.value?.split_counts || { train: 0, validation: 0, test: 0 })
|
||||||
const totalLines = computed(() => previewContent.value ? previewContent.value.split('\n').length : 0)
|
const totalLines = computed(() => previewContent.value ? previewContent.value.split('\n').length : 0)
|
||||||
const recordResult = computed(() => parseDatasetRecords(previewContent.value, selectedFile.value?.name || ''))
|
const recordResult = computed(() => parseDatasetRecords(previewContent.value, selectedFile.value?.name || ''))
|
||||||
const isRecordFile = computed(() => recordResult.value.supported)
|
const isRecordFile = computed(() => recordResult.value.supported)
|
||||||
@@ -81,6 +83,13 @@ function fileKey(file: DatasetFile) {
|
|||||||
return String(file.id || file.name)
|
return String(file.id || file.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleFileChange(file: DatasetFile) {
|
||||||
|
if (fileKey(file) === selectedFileId.value) return
|
||||||
|
if (!await confirmDiscardChanges('切换数据切分后,当前样本的未保存修改将丢失。')) return
|
||||||
|
selectedFileId.value = fileKey(file)
|
||||||
|
await loadVersions(file)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDataset() {
|
async function loadDataset() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -400,6 +409,12 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
<div class="overview-item overview-item-wide"><span>创建时间</span><strong>{{ createdAt }}</strong></div>
|
<div class="overview-item overview-item-wide"><span>创建时间</span><strong>{{ createdAt }}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DatasetSplitNavigator
|
||||||
|
v-if="Object.values(splitCounts).some((value) => value > 0)"
|
||||||
|
:files="files" :selected-file-id="selectedFileId" :split-counts="splitCounts"
|
||||||
|
@select="handleFileChange"
|
||||||
|
/>
|
||||||
|
|
||||||
<DatasetVersionBar
|
<DatasetVersionBar
|
||||||
v-model="selectedVersionId"
|
v-model="selectedVersionId"
|
||||||
:versions="versions"
|
:versions="versions"
|
||||||
@@ -488,10 +503,7 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DatasetRecordEditorDialog
|
<DatasetRecordEditorDialog
|
||||||
:model-value="editorVisible"
|
:model-value="editorVisible" :record="editingRecord" :edit-fields="editFields" :raw-draft="rawDraft"
|
||||||
:record="editingRecord"
|
|
||||||
:edit-fields="editFields"
|
|
||||||
:raw-draft="rawDraft"
|
|
||||||
:saving-record="savingRecord"
|
:saving-record="savingRecord"
|
||||||
:has-draft-changes="hasEditorDraftChanges"
|
:has-draft-changes="hasEditorDraftChanges"
|
||||||
:next-version-number="nextVersionNumber"
|
:next-version-number="nextVersionNumber"
|
||||||
@@ -500,7 +512,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
@update:field="updateEditField"
|
@update:field="updateEditField"
|
||||||
@update:raw-draft="rawDraft = $event"
|
@update:raw-draft="rawDraft = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppConfirmDialog ref="confirmDialogRef" />
|
<AppConfirmDialog ref="confirmDialogRef" />
|
||||||
</PageCard>
|
</PageCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
103
frontend/src/views/dataset/preview/DatasetSplitNavigator.vue
Normal file
103
frontend/src/views/dataset/preview/DatasetSplitNavigator.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DatasetFile } from '@/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
files: DatasetFile[]
|
||||||
|
selectedFileId: string
|
||||||
|
splitCounts: { train: number; validation: number; test: number }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ select: [file: DatasetFile] }>()
|
||||||
|
|
||||||
|
function fileKey(file: DatasetFile) {
|
||||||
|
return String(file.id || file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLabel(split?: DatasetFile['split']) {
|
||||||
|
if (!split) return '数据文件'
|
||||||
|
return { train: '训练集', validation: '验证集', test: '测试集' }[split]
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTagType(split?: DatasetFile['split']) {
|
||||||
|
if (!split) return 'info'
|
||||||
|
return ({ train: 'primary', validation: 'warning', test: 'success' } as const)[split]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="split-summary" aria-label="数据切分统计">
|
||||||
|
<div class="split-stat train"><span>训练集</span><strong>{{ splitCounts.train.toLocaleString() }}</strong></div>
|
||||||
|
<div class="split-stat validation"><span>验证集</span><strong>{{ splitCounts.validation.toLocaleString() }}</strong></div>
|
||||||
|
<div class="split-stat test"><span>测试集</span><strong>{{ splitCounts.test.toLocaleString() }}</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav v-if="files.length > 1" class="split-file-tabs" aria-label="数据切分文件">
|
||||||
|
<button
|
||||||
|
v-for="file in files"
|
||||||
|
:key="fileKey(file)"
|
||||||
|
type="button"
|
||||||
|
class="split-file-tab"
|
||||||
|
:class="{ active: fileKey(file) === selectedFileId }"
|
||||||
|
@click="emit('select', file)"
|
||||||
|
>
|
||||||
|
<el-tag :type="splitTagType(file.split)" size="small">{{ splitLabel(file.split) }}</el-tag>
|
||||||
|
<strong>{{ (file.record_count || 0).toLocaleString() }} 条</strong>
|
||||||
|
<span>{{ file.name }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.split-summary,
|
||||||
|
.split-file-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #e5eaf2;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-stat strong { font-size: 18px; }
|
||||||
|
.split-stat.train strong { color: #4f46e5; }
|
||||||
|
.split-stat.validation strong { color: #d97706; }
|
||||||
|
.split-stat.test strong { color: #059669; }
|
||||||
|
|
||||||
|
.split-file-tab {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e5eaf2;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #344054;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-file-tab:hover,
|
||||||
|
.split-file-tab.active {
|
||||||
|
border-color: #818cf8;
|
||||||
|
background: #f7f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-file-tab span:last-child {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user