refactor: 数据集预览组件化
拆分 DatasetVersionBar、DatasetRecordTable、DatasetRawPreview、DatasetRecordEditorDialog 子组件,DatasetPreviewView 聚焦编排,预览类型独立到 preview/types.ts,回归脚本适配。
This commit is contained in:
@@ -4,12 +4,24 @@ import ts from 'typescript'
|
|||||||
|
|
||||||
const root = process.cwd()
|
const root = process.cwd()
|
||||||
const previewPath = path.join(root, 'src/views/dataset/DatasetPreviewView.vue')
|
const previewPath = path.join(root, 'src/views/dataset/DatasetPreviewView.vue')
|
||||||
|
const previewComponentsDir = path.join(root, 'src/views/dataset/preview')
|
||||||
|
const previewComponentPaths = [
|
||||||
|
'DatasetVersionBar.vue',
|
||||||
|
'DatasetRecordTable.vue',
|
||||||
|
'DatasetRecordEditorDialog.vue',
|
||||||
|
'DatasetRawPreview.vue',
|
||||||
|
].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')
|
||||||
const mockPath = path.join(root, 'src/mock/data.ts')
|
const mockPath = path.join(root, 'src/mock/data.ts')
|
||||||
const recordsPath = path.join(root, 'src/views/dataset/datasetRecords.ts')
|
const recordsPath = path.join(root, 'src/views/dataset/datasetRecords.ts')
|
||||||
const versionsPath = path.join(root, 'src/mock/datasetVersions.ts')
|
const versionsPath = path.join(root, 'src/mock/datasetVersions.ts')
|
||||||
const source = fs.readFileSync(previewPath, 'utf8')
|
const source = fs.readFileSync(previewPath, 'utf8')
|
||||||
|
const previewComponentSources = previewComponentPaths.map((componentPath) => {
|
||||||
|
expect(fs.existsSync(componentPath), `缺少数据集预览叶子组件:${path.basename(componentPath)}`)
|
||||||
|
return fs.readFileSync(componentPath, 'utf8')
|
||||||
|
})
|
||||||
|
const previewSurfaceSource = [source, ...previewComponentSources].join('\n')
|
||||||
const apiSource = fs.readFileSync(apiPath, 'utf8')
|
const apiSource = fs.readFileSync(apiPath, 'utf8')
|
||||||
const adapterSource = fs.readFileSync(adapterPath, 'utf8')
|
const adapterSource = fs.readFileSync(adapterPath, 'utf8')
|
||||||
const mockSource = fs.readFileSync(mockPath, 'utf8')
|
const mockSource = fs.readFileSync(mockPath, 'utf8')
|
||||||
@@ -35,33 +47,55 @@ function expect(condition, message) {
|
|||||||
if (!condition) throw new Error(message)
|
if (!condition) throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(source.split('\n').length < 800, 'DatasetPreviewView.vue 应拆分到 800 行以内')
|
||||||
|
for (const componentName of ['DatasetVersionBar', 'DatasetRecordTable', 'DatasetRecordEditorDialog', 'DatasetRawPreview']) {
|
||||||
|
expect(source.includes(`import ${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(!source.includes('class="file-pane"'), '单文件数据集详情不应展示文件选择侧栏')
|
||||||
expect(source.includes('class="code-line"'), '内容查看器应提供逐行展示')
|
expect(previewSurfaceSource.includes('class="code-line"'), '内容查看器应提供逐行展示')
|
||||||
expect(source.includes('class="line-number"'), '内容查看器应显示行号')
|
expect(previewSurfaceSource.includes('class="line-number"'), '内容查看器应显示行号')
|
||||||
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
|
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
|
||||||
expect(source.includes('previewError'), '内容加载失败时应提供错误状态')
|
expect(source.includes('previewError'), '内容加载失败时应提供错误状态')
|
||||||
expect(source.includes('class="records-viewer"'), '详情页应将结构化文件展示为样本列表')
|
expect(previewSurfaceSource.includes('class="records-viewer"'), '详情页应将结构化文件展示为样本列表')
|
||||||
expect(source.includes('class="record-table"'), '样本数据应使用企业级表格展示')
|
expect(previewSurfaceSource.includes('class="record-table"'), '样本数据应使用企业级表格展示')
|
||||||
expect(source.includes('fixed="right"'), '逐条编辑操作列应固定在表格右侧')
|
expect(previewSurfaceSource.includes('fixed="right"'), '逐条编辑操作列应固定在表格右侧')
|
||||||
expect(source.includes('v-for="fieldKey in tableFieldKeys"'), '企业表格应根据数据结构动态生成字段列')
|
expect(previewSurfaceSource.includes('v-for="fieldKey in tableFieldKeys"'), '企业表格应根据数据结构动态生成字段列')
|
||||||
expect(source.includes('openRecordEditor(asDatasetRecord(row))'), '每条样本应提供独立编辑入口')
|
expect(previewSurfaceSource.includes("emit('edit', asDatasetRecord(row))"), '每条样本应向主页面发送编辑事件')
|
||||||
expect(source.includes('暂存修改'), '单条编辑应先暂存到当前页面')
|
expect(previewSurfaceSource.includes('暂存修改'), '单条编辑应先暂存到当前页面')
|
||||||
expect(source.includes('hasPendingVersionChanges && isViewingActiveVersion'), '存在暂存修改时才应显示保存版本按钮')
|
expect(source.includes('hasPendingVersionChanges && isViewingActiveVersion'), '存在暂存修改时才应显示保存版本按钮')
|
||||||
expect(source.includes('保存版本'), '文件工具栏应提供保存版本按钮')
|
expect(source.includes('保存版本'), '文件工具栏应提供保存版本按钮')
|
||||||
expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本')
|
expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本')
|
||||||
expect(source.includes('class="version-control-bar"'), '详情页应提供独立版本控制区')
|
expect(previewSurfaceSource.includes('class="version-control-bar"'), '详情页应提供独立版本控制区')
|
||||||
expect(source.includes('设为当前版本'), '历史版本应支持显式切换为当前版本')
|
expect(previewSurfaceSource.includes('设为当前版本'), '历史版本应支持显式切换为当前版本')
|
||||||
expect(source.includes('历史版本(只读)'), '历史版本应明确展示只读状态')
|
expect(previewSurfaceSource.includes('历史版本(只读)'), '历史版本应明确展示只读状态')
|
||||||
expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本')
|
expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本')
|
||||||
expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容')
|
expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容')
|
||||||
expect(source.includes('v-model:current-page="currentPage"'), '样本列表应支持分页浏览')
|
expect(previewSurfaceSource.includes('v-model:current-page="currentPage"'), '样本列表应支持分页浏览')
|
||||||
expect(!source.includes('class="content-editor"'), '详情页不应继续提供整文件编辑器')
|
expect(!source.includes('class="content-editor"'), '详情页不应继续提供整文件编辑器')
|
||||||
expect(source.includes('saveRecord'), '逐条编辑器应提供单条保存动作')
|
expect(source.includes('saveRecord'), '逐条编辑器应提供单条保存动作')
|
||||||
expect(source.includes('hasUnsavedChanges'), '在线编辑器应跟踪未保存修改')
|
expect(source.includes('hasUnsavedChanges'), '在线编辑器应跟踪未保存修改')
|
||||||
expect(source.includes('onBeforeRouteLeave'), '离开页面时应保护未保存修改')
|
expect(source.includes('onBeforeRouteLeave'), '离开页面时应保护未保存修改')
|
||||||
expect(source.includes("event.key.toLowerCase() === 's'"), '在线编辑器应支持快捷键保存')
|
expect(previewSurfaceSource.includes("event.key.toLowerCase() === 's'"), '在线编辑器应支持快捷键保存')
|
||||||
expect(source.includes('loadVersions(selectedFile)'), '版本内容加载失败后应支持重新加载')
|
expect(source.includes('loadVersions(selectedFile)'), '版本内容加载失败后应支持重新加载')
|
||||||
|
expect(source.includes('function resetRecordEditor()'), '主页面应提供统一的编辑器状态清理函数')
|
||||||
|
const resetEditorStart = source.indexOf('function resetRecordEditor()')
|
||||||
|
const updateFieldStart = source.indexOf('function updateEditField', resetEditorStart)
|
||||||
|
const resetEditorSource = source.slice(resetEditorStart, updateFieldStart)
|
||||||
|
for (const marker of [
|
||||||
|
'editorVisible.value = false',
|
||||||
|
'editingRecord.value = null',
|
||||||
|
'editFields.value = []',
|
||||||
|
"rawDraft.value = ''",
|
||||||
|
"originalDraft.value = ''",
|
||||||
|
]) {
|
||||||
|
expect(resetEditorSource.includes(marker), `编辑器状态清理不完整:${marker}`)
|
||||||
|
}
|
||||||
|
const versionChangeStart = source.indexOf('async function handleViewedVersionChange')
|
||||||
|
const activateVersionStart = source.indexOf('async function activateViewedVersion', versionChangeStart)
|
||||||
|
const versionChangeSource = source.slice(versionChangeStart, activateVersionStart)
|
||||||
|
expect(versionChangeSource.includes('resetRecordEditor()'), '成功切换版本后必须关闭并清空旧编辑器状态')
|
||||||
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
|
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
|
||||||
expect(!source.includes('handleDelete'), '页面不应保留删除逻辑')
|
expect(!source.includes('handleDelete'), '页面不应保留删除逻辑')
|
||||||
expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑')
|
expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑')
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ import {
|
|||||||
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
|
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
|
||||||
import { parseDatasetRecords, updateDatasetRecord } from './datasetRecords'
|
import { parseDatasetRecords, updateDatasetRecord } from './datasetRecords'
|
||||||
import type { DatasetRecord } 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'
|
import type { DatasetFile, DatasetItem, DatasetVersion } from '@/types'
|
||||||
|
|
||||||
interface EditField {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
isJson: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const datasetId = route.params.id as string
|
const datasetId = route.params.id as string
|
||||||
|
|
||||||
@@ -32,9 +31,6 @@ const selectedFileId = ref('')
|
|||||||
const previewContent = ref('')
|
const previewContent = ref('')
|
||||||
const baseVersionContent = ref('')
|
const baseVersionContent = ref('')
|
||||||
const previewError = ref('')
|
const previewError = ref('')
|
||||||
const searchText = ref('')
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = 10
|
|
||||||
const editorVisible = ref(false)
|
const editorVisible = ref(false)
|
||||||
const editingRecord = ref<DatasetRecord | null>(null)
|
const editingRecord = ref<DatasetRecord | null>(null)
|
||||||
const editFields = ref<EditField[]>([])
|
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 recordResult = computed(() => parseDatasetRecords(previewContent.value, selectedFile.value?.name || ''))
|
||||||
const isRecordFile = computed(() => recordResult.value.supported)
|
const isRecordFile = computed(() => recordResult.value.supported)
|
||||||
const records = computed(() => recordResult.value.records)
|
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 viewedVersion = computed(() => versions.value.find((item) => item.id === loadedVersionId.value))
|
||||||
const isViewingActiveVersion = computed(() => Boolean(loadedVersionId.value) && loadedVersionId.value === activeVersionId.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 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'
|
const currentDraft = computed(() => editingRecord.value?.kind === 'object'
|
||||||
? JSON.stringify(editFields.value)
|
? JSON.stringify(editFields.value)
|
||||||
: rawDraft.value)
|
: rawDraft.value)
|
||||||
@@ -105,47 +73,10 @@ const createdAt = computed(() => dataset.value?.create_time
|
|||||||
? new Date(dataset.value.create_time).toLocaleString('zh-CN')
|
? 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) {
|
function fileKey(file: DatasetFile) {
|
||||||
return String(file.id || file.name)
|
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() {
|
async function loadDataset() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -174,6 +105,7 @@ async function loadVersions(file: DatasetFile) {
|
|||||||
loadedVersionId.value = result.active_version_id
|
loadedVersionId.value = result.active_version_id
|
||||||
previewContent.value = activeContent.content
|
previewContent.value = activeContent.content
|
||||||
baseVersionContent.value = activeContent.content
|
baseVersionContent.value = activeContent.content
|
||||||
|
resetRecordEditor()
|
||||||
} catch {
|
} catch {
|
||||||
if (requestId === versionRequestId) previewError.value = '版本内容加载失败,请稍后重试'
|
if (requestId === versionRequestId) previewError.value = '版本内容加载失败,请稍后重试'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -197,8 +129,7 @@ async function handleViewedVersionChange(nextVersionId: string) {
|
|||||||
previewContent.value = result.content
|
previewContent.value = result.content
|
||||||
baseVersionContent.value = result.content
|
baseVersionContent.value = result.content
|
||||||
loadedVersionId.value = result.version.id
|
loadedVersionId.value = result.version.id
|
||||||
currentPage.value = 1
|
resetRecordEditor()
|
||||||
searchText.value = ''
|
|
||||||
} catch {
|
} catch {
|
||||||
if (requestId === versionRequestId) selectedVersionId.value = previousVersionId
|
if (requestId === versionRequestId) selectedVersionId.value = previousVersionId
|
||||||
} finally {
|
} finally {
|
||||||
@@ -259,6 +190,19 @@ function openRecordEditor(record: DatasetRecord) {
|
|||||||
originalDraft.value = record.kind === 'object' ? JSON.stringify(editFields.value) : rawDraft.value
|
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 = '当前存在尚未保存为版本的修改,放弃后将无法恢复。') {
|
async function confirmDiscardChanges(message = '当前存在尚未保存为版本的修改,放弃后将无法恢复。') {
|
||||||
if (!hasUnsavedChanges.value) return true
|
if (!hasUnsavedChanges.value) return true
|
||||||
try {
|
try {
|
||||||
@@ -287,18 +231,11 @@ async function confirmEditorDiscardChanges() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeRecordEditor() {
|
async function closeRecordEditor(done?: () => void) {
|
||||||
if (savingRecord.value) return
|
if (savingRecord.value) return
|
||||||
if (!await confirmEditorDiscardChanges()) return
|
if (!await confirmEditorDiscardChanges()) return
|
||||||
editorVisible.value = false
|
resetRecordEditor()
|
||||||
editingRecord.value = null
|
done?.()
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDialogBeforeClose(done: () => void) {
|
|
||||||
if (savingRecord.value) return
|
|
||||||
if (!await confirmEditorDiscardChanges()) return
|
|
||||||
editingRecord.value = null
|
|
||||||
done()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNextRecordValue() {
|
function buildNextRecordValue() {
|
||||||
@@ -330,9 +267,7 @@ async function saveRecord() {
|
|||||||
nextValue,
|
nextValue,
|
||||||
)
|
)
|
||||||
previewContent.value = nextContent
|
previewContent.value = nextContent
|
||||||
originalDraft.value = currentDraft.value
|
resetRecordEditor()
|
||||||
editorVisible.value = false
|
|
||||||
editingRecord.value = null
|
|
||||||
ElMessage.success(`第 ${record.displayIndex} 条修改已暂存`)
|
ElMessage.success(`第 ${record.displayIndex} 条修改已暂存`)
|
||||||
} finally {
|
} finally {
|
||||||
savingRecord.value = false
|
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) {
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
if (!hasUnsavedChanges.value) return
|
if (!hasUnsavedChanges.value) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -419,46 +347,19 @@ 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>
|
||||||
|
|
||||||
<section class="version-control-bar" aria-label="数据集版本控制">
|
<DatasetVersionBar
|
||||||
<div class="version-selector-group">
|
v-model="selectedVersionId"
|
||||||
<label for="dataset-version-select">数据版本</label>
|
:versions="versions"
|
||||||
<el-select
|
:viewed-version="viewedVersion"
|
||||||
id="dataset-version-select"
|
:active-version-id="activeVersionId"
|
||||||
v-model="selectedVersionId"
|
:is-viewing-active-version="isViewingActiveVersion"
|
||||||
class="version-select"
|
:has-pending-version-changes="hasPendingVersionChanges"
|
||||||
:loading="versionLoading"
|
:version-loading="versionLoading"
|
||||||
:disabled="versionLoading || savingRecord"
|
:saving-record="savingRecord"
|
||||||
aria-label="选择要查看的数据集版本"
|
:activating-version="activatingVersion"
|
||||||
@change="handleViewedVersionChange"
|
@change="handleViewedVersionChange"
|
||||||
>
|
@activate="activateViewedVersion"
|
||||||
<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>
|
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="viewedVersion && !isViewingActiveVersion"
|
v-if="viewedVersion && !isViewingActiveVersion"
|
||||||
@@ -508,78 +409,19 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
</div>
|
</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="!previewContent" class="viewer-state"><i class="fa fa-file-o" aria-hidden="true" /><span>当前文件暂无可预览内容</span></div>
|
||||||
|
|
||||||
<div v-else-if="isRecordFile" class="records-viewer">
|
<DatasetRecordTable
|
||||||
<div class="records-toolbar">
|
v-else-if="isRecordFile"
|
||||||
<div><strong>样本数据</strong><span>共 {{ records.length.toLocaleString() }} 条</span><el-tag v-if="invalidRecordCount" size="small" type="danger">{{ invalidRecordCount }} 条格式错误</el-tag></div>
|
:records="records"
|
||||||
<el-input v-model="searchText" class="record-search" clearable placeholder="搜索样本内容" aria-label="搜索样本内容" @input="currentPage = 1">
|
:is-viewing-active-version="isViewingActiveVersion"
|
||||||
<template #prefix><i class="fa fa-search" /></template>
|
:version-id="loadedVersionId"
|
||||||
</el-input>
|
@edit="openRecordEditor"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div v-if="!filteredRecords.length" class="viewer-state record-empty-state"><i class="fa fa-search" aria-hidden="true" /><span>没有找到匹配的样本</span></div>
|
<DatasetRawPreview
|
||||||
<div v-else class="record-table-shell" aria-label="数据样本表格">
|
v-else
|
||||||
<el-table
|
:content="previewContent"
|
||||||
:data="pagedRecords"
|
:file-name="selectedFile?.name"
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
<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>
|
<span class="empty-icon" aria-hidden="true"><i class="fa fa-folder-open-o" /></span><h2>暂无数据文件</h2><p>该数据集还没有可供预览的文件。</p>
|
||||||
</section>
|
</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>
|
<DatasetRecordEditorDialog
|
||||||
<div v-if="editingRecord" class="record-editor" @keydown="handleEditorKeydown">
|
:model-value="editorVisible"
|
||||||
<div class="editor-hint">
|
:record="editingRecord"
|
||||||
<i class="fa fa-info-circle" aria-hidden="true" />
|
:edit-fields="editFields"
|
||||||
当前修改会先暂存;完成多条修改后,点击文件工具栏中的“保存版本”创建 V{{ nextVersionNumber }}。
|
:raw-draft="rawDraft"
|
||||||
</div>
|
:saving-record="savingRecord"
|
||||||
<el-form v-if="editingRecord.kind === 'object'" label-position="top">
|
:has-draft-changes="hasEditorDraftChanges"
|
||||||
<el-form-item v-for="field in editFields" :key="field.key" :label="field.key">
|
:next-version-number="nextVersionNumber"
|
||||||
<el-input v-model="field.value" type="textarea" :rows="field.isJson ? 5 : 3" :aria-label="`编辑字段 ${field.key}`" :placeholder="field.isJson ? '请输入合法 JSON' : `请输入 ${field.key}`" />
|
@close="closeRecordEditor"
|
||||||
<small v-if="field.isJson" class="field-helper">该字段为对象、数组或数值,请保持合法 JSON 格式。</small>
|
@save="saveRecord"
|
||||||
</el-form-item>
|
@update:field="updateEditField"
|
||||||
</el-form>
|
@update:raw-draft="rawDraft = $event"
|
||||||
<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>
|
|
||||||
</PageCard>
|
</PageCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -715,61 +546,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
white-space: nowrap;
|
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 {
|
.historical-version-alert {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -868,153 +644,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
background: #fbfcfe;
|
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 {
|
.viewer-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1057,47 +686,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
margin-right: 5px;
|
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 {
|
.empty-files {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1149,18 +737,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.overview-item {
|
||||||
padding: 0 0 12px;
|
padding: 0 0 12px;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
@@ -1176,22 +752,6 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
align-items: flex-start;
|
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 {
|
.download-button {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
padding: 8px;
|
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>
|
</style>
|
||||||
|
|||||||
63
frontend/src/views/dataset/preview/DatasetRawPreview.vue
Normal file
63
frontend/src/views/dataset/preview/DatasetRawPreview.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string
|
||||||
|
fileName?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const lines = computed(() => props.content.split('\n').slice(0, 100))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="code-viewer" tabindex="0" :aria-label="`${fileName || ''} 文件内容`">
|
||||||
|
<div v-for="(line, index) in lines" :key="index" class="code-line">
|
||||||
|
<span class="line-number" aria-hidden="true">{{ index + 1 }}</span>
|
||||||
|
<code>{{ line || ' ' }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
155
frontend/src/views/dataset/preview/DatasetRecordEditorDialog.vue
Normal file
155
frontend/src/views/dataset/preview/DatasetRecordEditorDialog.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DatasetRecord } from '../datasetRecords'
|
||||||
|
import type { EditField } from './types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
record: DatasetRecord | null
|
||||||
|
editFields: EditField[]
|
||||||
|
rawDraft: string
|
||||||
|
savingRecord: boolean
|
||||||
|
hasDraftChanges: boolean
|
||||||
|
nextVersionNumber: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [done?: () => void]
|
||||||
|
save: []
|
||||||
|
'update:field': [index: number, value: string]
|
||||||
|
'update:rawDraft': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleBeforeClose(done: () => void) {
|
||||||
|
emit('close', done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('save')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
class="record-editor-dialog"
|
||||||
|
width="min(720px, 92vw)"
|
||||||
|
:title="record ? `编辑第 ${record.displayIndex} 条数据` : '编辑数据'"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="!savingRecord"
|
||||||
|
:show-close="!savingRecord"
|
||||||
|
:before-close="handleBeforeClose"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-if="record" class="record-editor" @keydown="handleKeydown">
|
||||||
|
<div class="editor-hint">
|
||||||
|
<i class="fa fa-info-circle" aria-hidden="true" />
|
||||||
|
当前修改会先暂存;完成多条修改后,点击文件工具栏中的“保存版本”创建
|
||||||
|
V{{ nextVersionNumber }}。
|
||||||
|
</div>
|
||||||
|
<el-form v-if="record.kind === 'object'" label-position="top">
|
||||||
|
<el-form-item v-for="(field, index) in editFields" :key="field.key" :label="field.key">
|
||||||
|
<el-input
|
||||||
|
:model-value="field.value"
|
||||||
|
type="textarea"
|
||||||
|
:rows="field.isJson ? 5 : 3"
|
||||||
|
:aria-label="`编辑字段 ${field.key}`"
|
||||||
|
:placeholder="field.isJson ? '请输入合法 JSON' : `请输入 ${field.key}`"
|
||||||
|
@update:model-value="emit('update:field', index, $event)"
|
||||||
|
/>
|
||||||
|
<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">
|
||||||
|
{{ record.kind === 'invalid' ? '修复 JSON 内容' : 'JSON 内容' }}
|
||||||
|
</label>
|
||||||
|
<el-input
|
||||||
|
id="raw-record-draft"
|
||||||
|
:model-value="rawDraft"
|
||||||
|
type="textarea"
|
||||||
|
:rows="10"
|
||||||
|
aria-label="编辑当前条 JSON 内容"
|
||||||
|
placeholder="请输入合法 JSON"
|
||||||
|
@update:model-value="emit('update:rawDraft', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="record-editor-actions">
|
||||||
|
<span>{{ hasDraftChanges ? '有未暂存修改' : '尚未修改' }} · Ctrl/⌘ + S 暂存</span>
|
||||||
|
<div>
|
||||||
|
<el-button :disabled="savingRecord" @click="emit('close')">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="savingRecord"
|
||||||
|
:disabled="!hasDraftChanges"
|
||||||
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
暂存修改
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.record-editor-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-editor-actions > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
335
frontend/src/views/dataset/preview/DatasetRecordTable.vue
Normal file
335
frontend/src/views/dataset/preview/DatasetRecordTable.vue
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { DatasetRecord } from '../datasetRecords'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
records: DatasetRecord[]
|
||||||
|
isViewingActiveVersion: boolean
|
||||||
|
versionId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [record: DatasetRecord]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const pageSize = 10
|
||||||
|
const searchText = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
const allTableFieldKeys = computed(() => {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
props.records.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 invalidRecordCount = computed(() => props.records.filter((record) => record.kind === 'invalid').length)
|
||||||
|
const hasExtraTableFields = computed(() => allTableFieldKeys.value.length > tableFieldKeys.value.length)
|
||||||
|
const filteredRecords = computed(() => {
|
||||||
|
const query = searchText.value.trim().toLowerCase()
|
||||||
|
if (!query) return props.records
|
||||||
|
return props.records.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)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.versionId,
|
||||||
|
() => {
|
||||||
|
searchText.value = ''
|
||||||
|
currentPage.value = 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function asDatasetRecord(row: unknown) {
|
||||||
|
return row as DatasetRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFieldValue(value: unknown) {
|
||||||
|
if (typeof value === 'string') return value || '(空)'
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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' : ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div 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>
|
||||||
|
|
||||||
|
<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="emit('edit', 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
color: #8b96a8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-state > i {
|
||||||
|
color: #aeb7c5;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-empty-state {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.records-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.record-table :deep(.el-table__row) { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
154
frontend/src/views/dataset/preview/DatasetVersionBar.vue
Normal file
154
frontend/src/views/dataset/preview/DatasetVersionBar.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DatasetVersion } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
versions: DatasetVersion[]
|
||||||
|
viewedVersion?: DatasetVersion
|
||||||
|
activeVersionId: string
|
||||||
|
isViewingActiveVersion: boolean
|
||||||
|
hasPendingVersionChanges: boolean
|
||||||
|
versionLoading: boolean
|
||||||
|
savingRecord: boolean
|
||||||
|
activatingVersion: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
change: [versionId: string]
|
||||||
|
activate: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function formatVersionTime(value: string) {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionOptionLabel(version: DatasetVersion) {
|
||||||
|
const status = version.id === props.activeVersionId ? '当前版本' : '历史版本'
|
||||||
|
return `V${version.version} · ${status} · ${formatVersionTime(version.create_time)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVersionChange(versionId: string) {
|
||||||
|
emit('update:modelValue', versionId)
|
||||||
|
emit('change', versionId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="version-control-bar" aria-label="数据集版本控制">
|
||||||
|
<div class="version-selector-group">
|
||||||
|
<label for="dataset-version-select">数据版本</label>
|
||||||
|
<el-select
|
||||||
|
id="dataset-version-select"
|
||||||
|
:model-value="modelValue"
|
||||||
|
class="version-select"
|
||||||
|
:loading="versionLoading"
|
||||||
|
:disabled="versionLoading || savingRecord"
|
||||||
|
aria-label="选择要查看的数据集版本"
|
||||||
|
@change="handleVersionChange"
|
||||||
|
>
|
||||||
|
<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="emit('activate')"
|
||||||
|
>
|
||||||
|
<i class="fa fa-check-circle" />
|
||||||
|
设为当前版本
|
||||||
|
</el-button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.version-control-bar,
|
||||||
|
.version-selector-group,
|
||||||
|
.version-status {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-select,
|
||||||
|
.version-control-bar > .el-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
frontend/src/views/dataset/preview/types.ts
Normal file
5
frontend/src/views/dataset/preview/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface EditField {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
isJson: boolean
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user