refactor: 数据集预览组件化

拆分 DatasetVersionBar、DatasetRecordTable、DatasetRawPreview、DatasetRecordEditorDialog 子组件,DatasetPreviewView 聚焦编排,预览类型独立到 preview/types.ts,回归脚本适配。
This commit is contained in:
caoxiaozhu
2026-07-13 15:30:11 +08:00
parent e212de1693
commit 3fabd0c0eb
7 changed files with 821 additions and 518 deletions

View File

@@ -14,14 +14,13 @@ import {
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
import { parseDatasetRecords, updateDatasetRecord } from './datasetRecords'
import type { DatasetRecord } from './datasetRecords'
import DatasetVersionBar from './preview/DatasetVersionBar.vue'
import DatasetRecordTable from './preview/DatasetRecordTable.vue'
import DatasetRecordEditorDialog from './preview/DatasetRecordEditorDialog.vue'
import DatasetRawPreview from './preview/DatasetRawPreview.vue'
import type { EditField } from './preview/types'
import type { DatasetFile, DatasetItem, DatasetVersion } from '@/types'
interface EditField {
key: string
value: string
isJson: boolean
}
const route = useRoute()
const datasetId = route.params.id as string
@@ -32,9 +31,6 @@ const selectedFileId = ref('')
const previewContent = ref('')
const baseVersionContent = ref('')
const previewError = ref('')
const searchText = ref('')
const currentPage = ref(1)
const pageSize = 10
const editorVisible = ref(false)
const editingRecord = ref<DatasetRecord | null>(null)
const editFields = ref<EditField[]>([])
@@ -56,37 +52,9 @@ const totalLines = computed(() => previewContent.value ? previewContent.value.sp
const recordResult = computed(() => parseDatasetRecords(previewContent.value, selectedFile.value?.name || ''))
const isRecordFile = computed(() => recordResult.value.supported)
const records = computed(() => recordResult.value.records)
const invalidRecordCount = computed(() => records.value.filter((record) => record.kind === 'invalid').length)
const allTableFieldKeys = computed(() => {
const keys = new Set<string>()
records.value.forEach((record) => {
if (record.kind !== 'object') return
Object.keys(record.value as Record<string, unknown>).forEach((key) => keys.add(key))
})
const preferred = ['instruction', 'question', 'prompt', 'input', 'context', 'output', 'answer', 'response']
return [...keys].sort((a, b) => {
const rank = (key: string) => {
const index = preferred.indexOf(key)
return index === -1 ? preferred.length : index
}
return rank(a) - rank(b)
})
})
const tableFieldKeys = computed(() => allTableFieldKeys.value.slice(0, 6))
const hasExtraTableFields = computed(() => allTableFieldKeys.value.length > tableFieldKeys.value.length)
const viewedVersion = computed(() => versions.value.find((item) => item.id === loadedVersionId.value))
const isViewingActiveVersion = computed(() => Boolean(loadedVersionId.value) && loadedVersionId.value === activeVersionId.value)
const nextVersionNumber = computed(() => Math.max(0, ...versions.value.map((item) => item.version)) + 1)
const filteredRecords = computed(() => {
const query = searchText.value.trim().toLowerCase()
if (!query) return records.value
return records.value.filter((record) => JSON.stringify(record.value).toLowerCase().includes(query))
})
const pagedRecords = computed(() => {
const start = (currentPage.value - 1) * pageSize
return filteredRecords.value.slice(start, start + pageSize)
})
const rawPreviewLines = computed(() => previewContent.value ? previewContent.value.split('\n').slice(0, 100) : [])
const currentDraft = computed(() => editingRecord.value?.kind === 'object'
? JSON.stringify(editFields.value)
: rawDraft.value)
@@ -105,47 +73,10 @@ const createdAt = computed(() => dataset.value?.create_time
? new Date(dataset.value.create_time).toLocaleString('zh-CN')
: '-')
function formatVersionTime(value: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
function versionOptionLabel(version: DatasetVersion) {
const status = version.id === activeVersionId.value ? '当前版本' : '历史版本'
return `V${version.version} · ${status} · ${formatVersionTime(version.create_time)}`
}
function fileKey(file: DatasetFile) {
return String(file.id || file.name)
}
function formatFieldValue(value: unknown) {
if (typeof value === 'string') return value || '(空)'
return JSON.stringify(value)
}
function asDatasetRecord(row: unknown) {
return row as DatasetRecord
}
function recordFieldValue(record: DatasetRecord, key: string) {
if (record.kind !== 'object') return record.kind === 'invalid' ? record.raw : formatFieldValue(record.value)
const data = record.value as Record<string, unknown>
return Object.prototype.hasOwnProperty.call(data, key) ? formatFieldValue(data[key]) : '-'
}
function extraFieldValue(record: DatasetRecord) {
if (record.kind !== 'object') return '-'
const data = record.value as Record<string, unknown>
return allTableFieldKeys.value
.filter((key) => !tableFieldKeys.value.includes(key) && Object.prototype.hasOwnProperty.call(data, key))
.map((key) => `${key}: ${formatFieldValue(data[key])}`)
.join('') || '-'
}
function recordRowClassName({ row }: { row: unknown }) {
return asDatasetRecord(row).kind === 'invalid' ? 'record-table-row-invalid' : ''
}
async function loadDataset() {
loading.value = true
try {
@@ -174,6 +105,7 @@ async function loadVersions(file: DatasetFile) {
loadedVersionId.value = result.active_version_id
previewContent.value = activeContent.content
baseVersionContent.value = activeContent.content
resetRecordEditor()
} catch {
if (requestId === versionRequestId) previewError.value = '版本内容加载失败,请稍后重试'
} finally {
@@ -197,8 +129,7 @@ async function handleViewedVersionChange(nextVersionId: string) {
previewContent.value = result.content
baseVersionContent.value = result.content
loadedVersionId.value = result.version.id
currentPage.value = 1
searchText.value = ''
resetRecordEditor()
} catch {
if (requestId === versionRequestId) selectedVersionId.value = previousVersionId
} finally {
@@ -259,6 +190,19 @@ function openRecordEditor(record: DatasetRecord) {
originalDraft.value = record.kind === 'object' ? JSON.stringify(editFields.value) : rawDraft.value
}
function resetRecordEditor() {
editorVisible.value = false
editingRecord.value = null
editFields.value = []
rawDraft.value = ''
originalDraft.value = ''
}
function updateEditField(index: number, value: string) {
const field = editFields.value[index]
if (field) field.value = value
}
async function confirmDiscardChanges(message = '当前存在尚未保存为版本的修改,放弃后将无法恢复。') {
if (!hasUnsavedChanges.value) return true
try {
@@ -287,18 +231,11 @@ async function confirmEditorDiscardChanges() {
}
}
async function closeRecordEditor() {
async function closeRecordEditor(done?: () => void) {
if (savingRecord.value) return
if (!await confirmEditorDiscardChanges()) return
editorVisible.value = false
editingRecord.value = null
}
async function handleDialogBeforeClose(done: () => void) {
if (savingRecord.value) return
if (!await confirmEditorDiscardChanges()) return
editingRecord.value = null
done()
resetRecordEditor()
done?.()
}
function buildNextRecordValue() {
@@ -330,9 +267,7 @@ async function saveRecord() {
nextValue,
)
previewContent.value = nextContent
originalDraft.value = currentDraft.value
editorVisible.value = false
editingRecord.value = null
resetRecordEditor()
ElMessage.success(`${record.displayIndex} 条修改已暂存`)
} finally {
savingRecord.value = false
@@ -370,13 +305,6 @@ async function saveVersion() {
}
}
function handleEditorKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
saveRecord()
}
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedChanges.value) return
event.preventDefault()
@@ -419,46 +347,19 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
<div class="overview-item overview-item-wide"><span>创建时间</span><strong>{{ createdAt }}</strong></div>
</section>
<section class="version-control-bar" aria-label="数据集版本控制">
<div class="version-selector-group">
<label for="dataset-version-select">数据版本</label>
<el-select
id="dataset-version-select"
v-model="selectedVersionId"
class="version-select"
:loading="versionLoading"
:disabled="versionLoading || savingRecord"
aria-label="选择要查看的数据集版本"
@change="handleViewedVersionChange"
>
<el-option
v-for="version in versions"
:key="version.id"
:label="versionOptionLabel(version)"
:value="version.id"
/>
</el-select>
</div>
<div class="version-status" role="status" aria-live="polite">
<template v-if="viewedVersion">
<el-tag v-if="isViewingActiveVersion" type="success">V{{ viewedVersion.version }} · 当前版本</el-tag>
<el-tag v-else type="info">V{{ viewedVersion.version }} · 历史版本只读</el-tag>
<el-tag v-if="hasPendingVersionChanges" type="warning">有待保存修改</el-tag>
<span>{{ viewedVersion.description || '无版本说明' }} · {{ formatVersionTime(viewedVersion.create_time) }}</span>
</template>
</div>
<el-button
v-if="viewedVersion && !isViewingActiveVersion"
type="primary"
:loading="activatingVersion"
@click="activateViewedVersion"
>
<i class="fa fa-check-circle" />
设为当前版本
</el-button>
</section>
<DatasetVersionBar
v-model="selectedVersionId"
:versions="versions"
:viewed-version="viewedVersion"
:active-version-id="activeVersionId"
:is-viewing-active-version="isViewingActiveVersion"
:has-pending-version-changes="hasPendingVersionChanges"
:version-loading="versionLoading"
:saving-record="savingRecord"
:activating-version="activatingVersion"
@change="handleViewedVersionChange"
@activate="activateViewedVersion"
/>
<el-alert
v-if="viewedVersion && !isViewingActiveVersion"
@@ -508,78 +409,19 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
</div>
<div v-else-if="!previewContent" class="viewer-state"><i class="fa fa-file-o" aria-hidden="true" /><span>当前文件暂无可预览内容</span></div>
<div v-else-if="isRecordFile" class="records-viewer">
<div class="records-toolbar">
<div><strong>样本数据</strong><span> {{ records.length.toLocaleString() }} </span><el-tag v-if="invalidRecordCount" size="small" type="danger">{{ invalidRecordCount }} 条格式错误</el-tag></div>
<el-input v-model="searchText" class="record-search" clearable placeholder="搜索样本内容" aria-label="搜索样本内容" @input="currentPage = 1">
<template #prefix><i class="fa fa-search" /></template>
</el-input>
</div>
<DatasetRecordTable
v-else-if="isRecordFile"
:records="records"
:is-viewing-active-version="isViewingActiveVersion"
:version-id="loadedVersionId"
@edit="openRecordEditor"
/>
<div v-if="!filteredRecords.length" class="viewer-state record-empty-state"><i class="fa fa-search" aria-hidden="true" /><span>没有找到匹配的样本</span></div>
<div v-else class="record-table-shell" aria-label="数据样本表格">
<el-table
:data="pagedRecords"
row-key="sourceIndex"
stripe
border
class="record-table"
:row-class-name="recordRowClassName"
>
<el-table-column label="序号" width="76" align="center">
<template #default="{ row }">
<div class="table-index">
<strong>{{ asDatasetRecord(row).displayIndex }}</strong>
<small> {{ asDatasetRecord(row).sourceIndex + 1 }} </small>
</div>
</template>
</el-table-column>
<el-table-column v-for="fieldKey in tableFieldKeys" :key="fieldKey" :label="fieldKey" min-width="220">
<template #default="{ row }">
<el-tooltip :content="recordFieldValue(asDatasetRecord(row), fieldKey)" placement="top" :show-after="400">
<div class="table-cell-text">{{ recordFieldValue(asDatasetRecord(row), fieldKey) }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column v-if="hasExtraTableFields" label="其他字段" min-width="240">
<template #default="{ row }">
<el-tooltip :content="extraFieldValue(asDatasetRecord(row))" placement="top" :show-after="400">
<div class="table-cell-text">{{ extraFieldValue(asDatasetRecord(row)) }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag v-if="asDatasetRecord(row).kind === 'invalid'" size="small" type="danger">格式错误</el-tag>
<el-tag v-else-if="asDatasetRecord(row).kind === 'primitive'" size="small" type="warning">非对象</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="96" align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
:disabled="!isViewingActiveVersion"
:title="isViewingActiveVersion ? '' : '历史版本为只读,请先设为当前版本'"
:aria-label="`${asDatasetRecord(row).kind === 'invalid' ? '修复' : '编辑'} ${asDatasetRecord(row).displayIndex} 条数据`"
@click="openRecordEditor(asDatasetRecord(row))"
>
<i :class="asDatasetRecord(row).kind === 'invalid' ? 'fa fa-wrench' : 'fa fa-pencil'" />
{{ asDatasetRecord(row).kind === 'invalid' ? '修复' : '编辑' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="filteredRecords.length > pageSize" class="records-pagination">
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="filteredRecords.length" layout="prev, pager, next" background />
</div>
</div>
<div v-else class="code-viewer" tabindex="0" :aria-label="`${selectedFile?.name || ''} 文件内容`">
<div v-for="(line, index) in rawPreviewLines" :key="index" class="code-line"><span class="line-number" aria-hidden="true">{{ index + 1 }}</span><code>{{ line || ' ' }}</code></div>
</div>
<DatasetRawPreview
v-else
:content="previewContent"
:file-name="selectedFile?.name"
/>
</div>
<div class="viewer-footer"><span v-if="isRecordFile"><i class="fa fa-list-alt" aria-hidden="true" /> 逐条查看与编辑</span><span v-else><i class="fa fa-eye" aria-hidden="true" /> 文本预览</span><span>UTF-8</span></div>
@@ -590,30 +432,19 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
<span class="empty-icon" aria-hidden="true"><i class="fa fa-folder-open-o" /></span><h2>暂无数据文件</h2><p>该数据集还没有可供预览的文件</p>
</section>
<el-dialog v-model="editorVisible" class="record-editor-dialog" width="min(720px, 92vw)" :title="editingRecord ? `编辑第 ${editingRecord.displayIndex} 条数据` : '编辑数据'" :close-on-click-modal="false" :close-on-press-escape="!savingRecord" :show-close="!savingRecord" :before-close="handleDialogBeforeClose" destroy-on-close>
<div v-if="editingRecord" class="record-editor" @keydown="handleEditorKeydown">
<div class="editor-hint">
<i class="fa fa-info-circle" aria-hidden="true" />
当前修改会先暂存完成多条修改后点击文件工具栏中的保存版本创建 V{{ nextVersionNumber }}
</div>
<el-form v-if="editingRecord.kind === 'object'" label-position="top">
<el-form-item v-for="field in editFields" :key="field.key" :label="field.key">
<el-input v-model="field.value" type="textarea" :rows="field.isJson ? 5 : 3" :aria-label="`编辑字段 ${field.key}`" :placeholder="field.isJson ? '请输入合法 JSON' : `请输入 ${field.key}`" />
<small v-if="field.isJson" class="field-helper">该字段为对象数组或数值请保持合法 JSON 格式</small>
</el-form-item>
</el-form>
<div v-else class="raw-record-editor">
<label for="raw-record-draft">{{ editingRecord.kind === 'invalid' ? '修复 JSON 内容' : 'JSON 内容' }}</label>
<el-input id="raw-record-draft" v-model="rawDraft" type="textarea" :rows="10" aria-label="编辑当前条 JSON 内容" placeholder="请输入合法 JSON" />
</div>
</div>
<template #footer>
<div class="record-editor-actions">
<span>{{ hasEditorDraftChanges ? '有未暂存修改' : '尚未修改' }} · Ctrl/ + S 暂存</span>
<div><el-button :disabled="savingRecord" @click="closeRecordEditor">取消</el-button><el-button type="primary" :loading="savingRecord" :disabled="!hasEditorDraftChanges" @click="saveRecord">暂存修改</el-button></div>
</div>
</template>
</el-dialog>
<DatasetRecordEditorDialog
:model-value="editorVisible"
:record="editingRecord"
:edit-fields="editFields"
:raw-draft="rawDraft"
:saving-record="savingRecord"
:has-draft-changes="hasEditorDraftChanges"
:next-version-number="nextVersionNumber"
@close="closeRecordEditor"
@save="saveRecord"
@update:field="updateEditField"
@update:raw-draft="rawDraft = $event"
/>
</PageCard>
</template>
@@ -715,61 +546,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
white-space: nowrap;
}
.version-control-bar {
display: flex;
align-items: center;
gap: 16px;
min-height: 64px;
margin-bottom: 12px;
padding: 10px 14px;
box-sizing: border-box;
border: 1px solid #dfe5ee;
border-radius: 10px;
background: #fff;
}
.version-selector-group {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.version-selector-group label {
color: #344054;
font-size: 13px;
font-weight: 600;
}
.version-select {
width: 300px;
}
.version-status {
display: flex;
align-items: center;
flex: 1;
gap: 10px;
min-width: 0;
}
.version-status > span {
overflow: hidden;
color: #7c8799;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-control-bar > .el-button {
min-height: 40px;
flex: 0 0 auto;
}
.version-control-bar > .el-button i {
margin-right: 6px;
}
.historical-version-alert {
margin-bottom: 12px;
}
@@ -868,153 +644,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
background: #fbfcfe;
}
.records-viewer {
min-height: 468px;
padding: 16px;
box-sizing: border-box;
}
.records-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.records-toolbar > div {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.records-toolbar strong {
color: #253047;
font-size: 14px;
}
.records-toolbar span {
color: #8a94a6;
font-size: 12px;
}
.record-search {
width: 240px;
}
.record-table-shell {
overflow: hidden;
border: 1px solid #e4e9f1;
border-radius: 8px;
}
.record-table {
width: 100%;
}
.record-table :deep(.el-table__header th) {
height: 44px;
background: #f5f7fa;
color: #475467;
font-size: 12px;
font-weight: 600;
}
.record-table :deep(.el-table__cell) {
padding: 10px 0;
}
.record-table :deep(.record-table-row-invalid td) {
background: #fff7f7 !important;
}
.table-index {
display: flex;
align-items: center;
flex-direction: column;
gap: 1px;
color: #667085;
font-variant-numeric: tabular-nums;
}
.table-index strong {
font-size: 13px;
font-weight: 600;
}
.table-index small {
color: #98a2b3;
font-size: 10px;
}
.table-cell-text {
display: -webkit-box;
overflow: hidden;
color: #526076;
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.record-table .el-button i {
margin-right: 5px;
}
.records-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.record-empty-state {
min-height: 320px;
}
.code-viewer {
height: 100%;
max-height: 468px;
overflow: auto;
padding: 12px 0 18px;
outline: none;
}
.code-viewer:focus-visible {
box-shadow: inset 0 0 0 2px var(--el-color-primary-light-5);
}
.code-line {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
min-height: 25px;
color: #344054;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 12px;
line-height: 1.65;
}
.code-line:hover {
background: #f4f6fa;
}
.line-number {
padding-right: 14px;
border-right: 1px solid #edf0f5;
color: #a4adbc;
font-variant-numeric: tabular-nums;
text-align: right;
user-select: none;
}
.code-line code {
display: block;
min-width: max-content;
padding: 0 18px;
white-space: pre;
}
.viewer-state {
display: flex;
align-items: center;
@@ -1057,47 +686,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
margin-right: 5px;
}
.editor-hint {
margin-bottom: 18px;
padding: 10px 12px;
border: 1px solid #dbe2ff;
border-radius: 8px;
background: #f5f7ff;
color: #56627a;
font-size: 13px;
}
.editor-hint i {
margin-right: 6px;
color: var(--primary-color);
}
.field-helper {
margin-top: 5px;
color: #8a94a6;
line-height: 1.5;
}
.raw-record-editor label {
display: block;
margin-bottom: 8px;
color: #344054;
font-size: 13px;
font-weight: 600;
}
.record-editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.record-editor-actions > span {
color: #8a94a6;
font-size: 12px;
}
.empty-files {
display: flex;
align-items: center;
@@ -1149,18 +737,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
grid-template-columns: 1fr;
}
.version-control-bar,
.version-selector-group,
.version-status {
align-items: stretch;
flex-direction: column;
}
.version-select,
.version-control-bar > .el-button {
width: 100%;
}
.overview-item {
padding: 0 0 12px;
border-right: 0;
@@ -1176,22 +752,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
align-items: flex-start;
}
.records-toolbar,
.record-editor-actions {
align-items: stretch;
flex-direction: column;
}
.record-search {
width: 100%;
}
.record-editor-actions > div {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.download-button {
width: 44px;
padding: 8px;
@@ -1204,7 +764,4 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
}
}
@media (prefers-reduced-motion: reduce) {
.record-table :deep(.el-table__row) { transition: none; }
}
</style>