feat: 增强数据集预览功能

重构数据集预览页支持多文件切换与内容高亮,Mock 新增预览数据集与按文件 ID 路由的内容返回,并补充回归测试脚本与 npm 脚本注册。
This commit is contained in:
caoxiaozhu
2026-07-11 10:39:26 +08:00
parent bc3eddfe84
commit 1455ee7f97
5 changed files with 771 additions and 115 deletions

View File

@@ -11,6 +11,7 @@
"test:data-process-list": "node scripts/regression-data-process-list.mjs", "test:data-process-list": "node scripts/regression-data-process-list.mjs",
"test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs", "test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs",
"test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs", "test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs",
"test:dataset-preview": "node scripts/regression-dataset-preview.mjs",
"test:model-manage": "node scripts/regression-model-manage.mjs", "test:model-manage": "node scripts/regression-model-manage.mjs",
"test:training-log-layout": "node scripts/regression-training-log-layout.mjs", "test:training-log-layout": "node scripts/regression-training-log-layout.mjs",
"test:page-surface": "node scripts/regression-page-surface.mjs" "test:page-surface": "node scripts/regression-page-surface.mjs"

View File

@@ -0,0 +1,29 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const previewPath = path.join(root, 'src/views/dataset/DatasetPreviewView.vue')
const mockPath = path.join(root, 'src/mock/data.ts')
const source = fs.readFileSync(previewPath, 'utf8')
const mockSource = fs.readFileSync(mockPath, 'utf8')
function expect(condition, message) {
if (!condition) throw new Error(message)
}
expect(source.includes('class="preview-workspace"'), '预览页应使用文件与内容双栏工作区')
expect(source.includes('class="code-line"'), '内容查看器应提供逐行展示')
expect(source.includes('class="line-number"'), '内容查看器应显示行号')
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
expect(source.includes('previewError'), '内容加载失败时应提供错误状态')
expect(source.includes('aria-current'), '文件选中态应向辅助技术暴露')
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
expect(!source.includes('handleDelete'), '页面不应保留删除逻辑')
expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑')
expect(mockSource.includes('files: ['), 'Mock 数据集应包含可验收的文件列表')
expect(mockSource.includes('mockDatasetPreviews'), 'Mock 数据应包含文件预览内容')
expect(mockSource.includes("'mock-jsonl'"), 'Mock 数据应为通用数据集提供模拟样本')
expect(mockSource.includes("'mock-readme'"), 'Mock 数据应为通用数据集提供模拟说明')
expect(mockSource.includes('.map((dataset)'), '所有 Mock 数据集都应自动补齐预览文件')
console.log('dataset preview regression checks passed')

View File

@@ -12,6 +12,7 @@ import {
mockTrainedModels, mockTrainedModels,
mockLocalModels, mockLocalModels,
mockDatasets, mockDatasets,
mockDatasetPreviews,
mockFineTuneList, mockFineTuneList,
mockCompareList, mockCompareList,
mockEvalList, mockEvalList,
@@ -129,7 +130,12 @@ async function handleMock(config: AxiosRequestConfig) {
m = url.match(/^\/dataset-manage\/upload\/([^/]+)$/) m = url.match(/^\/dataset-manage\/upload\/([^/]+)$/)
if (m && method === 'post') return ok({ uploaded: true }) if (m && method === 'post') return ok({ uploaded: true })
m = url.match(/^\/dataset-manage\/preview\/([^/]+)$/) m = url.match(/^\/dataset-manage\/preview\/([^/]+)$/)
if (m && method === 'get') return ok(mockLogContent) if (m && method === 'get') {
const fileId = decodeURIComponent(m[1])
const fallbackKey = fileId.endsWith('-readme') ? 'mock-readme' : 'mock-jsonl'
const content = mockDatasetPreviews[fileId] ?? mockDatasetPreviews[fallbackKey]
return ok({ content })
}
// ==================== 训练任务 ==================== // ==================== 训练任务 ====================
if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList) if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)

View File

@@ -99,7 +99,22 @@ export const mockLocalModels = {
// ============ 数据集 ============ // ============ 数据集 ============
export const mockDatasets: DatasetItem[] = [ export const mockDatasets: DatasetItem[] = [
{ id: 1, name: '金融问答-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '128 MB', count: 8560, description: '金融领域问答对', create_time: '2025-12-20T08:00:00Z' }, {
id: 1,
name: '金融问答-训练集',
type: 'train',
storage_type: 'local',
source: 'upload',
size: '128 MB',
count: 8560,
description: '面向金融领域问答场景的高质量指令微调数据集',
create_time: '2025-12-20T08:00:00Z',
files: [
{ id: 'finance-train-jsonl', name: 'finance_train.jsonl', size: '86.4 MB' },
{ id: 'finance-validation-jsonl', name: 'finance_validation.jsonl', size: '21.8 MB' },
{ id: 'dataset-readme', name: 'README.md', size: '4.2 KB' },
],
},
{ id: 2, name: '法律文书-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '256 MB', count: 15230, description: '法律文书数据集', create_time: '2025-12-25T10:30:00Z' }, { id: 2, name: '法律文书-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '256 MB', count: 15230, description: '法律文书数据集', create_time: '2025-12-25T10:30:00Z' },
{ id: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', source: 'upload', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' }, { id: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', source: 'upload', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' },
{ id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', source: 'upload', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' }, { id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', source: 'upload', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' },
@@ -109,16 +124,81 @@ export const mockDatasets: DatasetItem[] = [
{ id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' }, { id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' },
{ id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' }, { id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' },
{ id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' }, { id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' },
] ].map((dataset) => ({
...dataset,
files: dataset.files?.length
? dataset.files
: [
{ id: `dataset-${dataset.id}-samples`, name: 'dataset_samples.jsonl', size: dataset.size || '12.8 MB' },
{ id: `dataset-${dataset.id}-readme`, name: 'README.md', size: '3.8 KB' },
],
}))
export const mockDatasetPreviews: Record<string, string> = {
'finance-train-jsonl': [
'{"instruction":"什么是净资产收益率?","input":"","output":"净资产收益率ROE用于衡量企业运用自有资本获得收益的能力。"}',
'{"instruction":"解释市盈率的含义","input":"某公司股价为 36 元,每股收益为 3 元。","output":"市盈率为股价除以每股收益,本例中市盈率为 12 倍。"}',
'{"instruction":"央行降准通常会带来什么影响?","input":"","output":"降准通常会释放银行体系流动性,降低资金成本,并增强信贷投放能力。"}',
'{"instruction":"如何理解债券久期?","input":"","output":"久期衡量债券价格对利率变化的敏感程度,久期越长,价格波动通常越大。"}',
'{"instruction":"基金定投适合怎样的投资者?","input":"","output":"基金定投适合希望分散择时风险、进行中长期纪律性投资的投资者。"}',
'{"instruction":"资产负债率过高意味着什么?","input":"","output":"可能意味着企业偿债压力较大、财务风险较高,但仍需结合行业特点判断。"}',
'{"instruction":"通货膨胀如何影响实际收益率?","input":"","output":"实际收益率约等于名义收益率减去通货膨胀率。"}',
'{"instruction":"什么是流动比率?","input":"","output":"流动比率等于流动资产除以流动负债,用于衡量短期偿债能力。"}',
'{"instruction":"股票分红是否等于投资者获得额外收益?","input":"","output":"不完全等同,除息后股价通常会相应调整,应结合总回报判断。"}',
'{"instruction":"如何区分系统性风险与非系统性风险?","input":"","output":"系统性风险影响整个市场,非系统性风险主要与单个公司或行业有关。"}',
'{"instruction":"什么是复利?","input":"","output":"复利是将本金和此前产生的收益一并计入下一期收益计算。"}',
'{"instruction":"现金流量表主要反映什么?","input":"","output":"现金流量表反映企业经营、投资和筹资活动产生的现金流入与流出。"}',
].join('\n'),
'finance-validation-jsonl': [
'{"instruction":"计算简单收益率","input":"买入价 100 元,卖出价 108 元,不考虑费用。","output":"简单收益率为 8%。"}',
'{"instruction":"什么是信用利差?","input":"","output":"信用利差是信用债收益率相对无风险基准收益率的差额。"}',
'{"instruction":"解释最大回撤","input":"","output":"最大回撤描述资产净值从历史高点到随后低点的最大跌幅。"}',
'{"instruction":"什么是风险溢价?","input":"","output":"风险溢价是投资者因承担额外风险而要求的超额预期收益。"}',
].join('\n'),
'dataset-readme': [
'# 金融问答训练集',
'',
'本数据集用于金融领域指令微调与基础能力评测。',
'',
'## 文件说明',
'',
'- `finance_train.jsonl`:训练样本',
'- `finance_validation.jsonl`:验证样本',
'',
'文本编码UTF-8',
].join('\n'),
'mock-jsonl': [
'{"instruction":"请概括以下文本的核心观点","input":"人工智能正在提升企业的数据处理效率。","output":"人工智能能够帮助企业提升数据处理效率。"}',
'{"instruction":"将用户问题改写为更清晰的表达","input":"这个功能咋用?","output":"请说明该功能的具体使用步骤。"}',
'{"instruction":"判断文本情感倾向","input":"这次服务响应很及时,问题也解决了。","output":"正向"}',
'{"instruction":"提取文本中的关键实体","input":"远光软件于周一发布了新的模型管理平台。","output":["远光软件","周一","模型管理平台"]}',
'{"instruction":"生成简短回复","input":"您好,我想了解数据集上传支持哪些格式?","output":"您好,目前支持 JSON、JSONL、CSV、TXT 等常见格式。"}',
'{"instruction":"对以下内容进行分类","input":"如何重置账户密码?","output":"账户与安全"}',
'{"instruction":"找出句子中的时间信息","input":"系统将在 7 月 15 日凌晨 2 点进行升级。","output":"7 月 15 日凌晨 2 点"}',
'{"instruction":"补全客服回复","input":"用户反馈页面加载缓慢。","output":"已收到您的反馈,我们正在排查页面加载问题,请稍后重试。"}',
].join('\n'),
'mock-readme': [
'# 数据集说明',
'',
'当前内容为前端 Mock 数据,用于预览页面布局与交互效果。',
'',
'## 文件结构',
'',
'- `dataset_samples.jsonl`:模拟的数据样本',
'- `README.md`:数据集使用说明',
'',
'文本编码UTF-8',
].join('\n'),
}
// ============ 训练任务 ============ // ============ 训练任务 ============
export const mockFineTuneList: FineTuneTask[] = [ export const mockFineTuneList: FineTuneTask[] = [
{ id: 1, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' }, { id: 103942, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' },
{ id: 2, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' }, { id: 349102, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' },
{ id: 3, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' }, { id: 849301, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' },
{ id: 4, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' }, { id: 593021, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' },
{ id: 5, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' }, { id: 201948, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' },
{ id: 6, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' }, { id: 940212, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' },
] ]
// ============ 模型推理/对比 ============ // ============ 模型推理/对比 ============

View File

@@ -1,157 +1,697 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageCard from '@/components/PageCard.vue' import PageCard from '@/components/PageCard.vue'
import { import { downloadFileUrl, getDataset, previewDatasetFile } from '@/api/modules/dataset'
getDataset,
previewDatasetFile,
deleteDataset,
downloadDatasetUrl,
downloadFileUrl,
} from '@/api/modules/dataset'
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants' import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
import type { DatasetItem, DatasetFile } from '@/types' import type { DatasetFile, DatasetItem } from '@/types'
const route = useRoute() const route = useRoute()
const router = useRouter()
const datasetId = route.params.id as string const datasetId = route.params.id as string
const loading = ref(false) const loading = ref(false)
const previewLoading = ref(false)
const dataset = ref<DatasetItem | null>(null) const dataset = ref<DatasetItem | null>(null)
const selectedFileId = ref<string>('') const selectedFileId = ref('')
const previewContent = ref('') const previewContent = ref('')
const previewError = ref('')
let previewRequestId = 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 previewLines = computed(() => previewContent.value ? previewContent.value.split('\n').slice(0, 100) : [])
const totalLines = computed(() => previewContent.value ? previewContent.value.split('\n').length : 0)
const datasetType = computed(() => {
const value = String(dataset.value?.type || '').toLowerCase()
return DATASET_TYPE_MAP[value] || dataset.value?.type || '-'
})
const storageType = computed(() => {
const value = dataset.value?.storage_type || ''
return STORAGE_MAP[value] || value || '-'
})
const createdAt = computed(() => dataset.value?.create_time
? new Date(dataset.value.create_time).toLocaleString('zh-CN')
: '-')
const previewLines = computed(() => previewContent.value.split('\n').slice(0, 100)) function fileKey(file: DatasetFile) {
const totalLines = computed(() => previewContent.value.split('\n').length) return String(file.id || file.name)
}
function fileExtension(fileName = '') {
return fileName.includes('.') ? fileName.split('.').pop()?.toUpperCase() : 'FILE'
}
async function loadDataset() { async function loadDataset() {
loading.value = true loading.value = true
try { try {
dataset.value = await getDataset(datasetId) dataset.value = await getDataset(datasetId)
if (files.value.length > 0) { const firstFile = files.value[0]
selectedFileId.value = String(files.value[0].id || files.value[0].name) if (firstFile) await selectFile(firstFile)
loadPreview()
}
} catch {
// ignore
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadPreview() { async function selectFile(file: DatasetFile) {
if (!selectedFileId.value) return const nextFileId = fileKey(file)
selectedFileId.value = nextFileId
previewContent.value = ''
previewError.value = ''
previewLoading.value = true
const requestId = ++previewRequestId
try { try {
const res = await previewDatasetFile(selectedFileId.value) const res = await previewDatasetFile(nextFileId)
previewContent.value = res.content || '' if (requestId === previewRequestId) previewContent.value = res.content || ''
} catch { } catch {
previewContent.value = '加载失败' if (requestId === previewRequestId) previewError.value = '内容加载失败,请稍后重试'
} finally {
if (requestId === previewRequestId) previewLoading.value = false
} }
} }
function handleDownloadFile(file: any) { function handleDownloadFile(file: DatasetFile) {
const fid = file.id || file.name window.open(downloadFileUrl(datasetId, fileKey(file)), '_blank')
window.open(downloadFileUrl(datasetId, fid), '_blank')
}
function handleDownloadAll() {
window.open(downloadDatasetUrl(datasetId), '_blank')
}
async function handleDelete() {
await ElMessageBox.confirm('确定要删除这个数据集吗?', '确认删除', { type: 'warning' })
await deleteDataset(datasetId)
ElMessage.success('删除成功')
router.push('/dataset')
} }
onMounted(loadDataset) onMounted(loadDataset)
</script> </script>
<template> <template>
<PageCard title="数据集预览" v-loading="loading"> <PageCard class="dataset-preview-page" v-loading="loading">
<template #extra> <template #header>
<el-button @click="handleDownloadAll"><i class="fa fa-download" /> 打包下载</el-button> <div class="page-heading">
<el-button type="danger" @click="handleDelete"><i class="fa fa-trash" /> 删除</el-button> <div class="heading-icon" aria-hidden="true">
<el-button @click="router.back()">返回</el-button> <i class="fa fa-database" />
</div>
<div>
<div class="heading-row">
<h1>{{ dataset?.name || '数据集预览' }}</h1>
<el-tag size="small" type="primary">{{ datasetType }}</el-tag>
<el-tag size="small" type="success">{{ storageType }}</el-tag>
</div>
<p>{{ dataset?.description || '查看数据集文件及内容样本' }}</p>
</div>
</div>
</template> </template>
<!-- 数据集信息 --> <section class="dataset-overview" aria-label="数据集概览">
<el-descriptions :column="3" border style="margin-bottom: 20px"> <div class="overview-item">
<el-descriptions-item label="名称">{{ dataset?.name }}</el-descriptions-item> <span>数据条数</span>
<el-descriptions-item label="类型"> <strong>{{ (dataset?.count || 0).toLocaleString() }}</strong>
{{ DATASET_TYPE_MAP[String(dataset?.type).toLowerCase()] || dataset?.type }}
</el-descriptions-item>
<el-descriptions-item label="存储位置">
{{ STORAGE_MAP[dataset?.storage_type || ''] || dataset?.storage_type }}
</el-descriptions-item>
<el-descriptions-item label="数据条数">{{ dataset?.count || 0 }}</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ dataset?.create_time ? new Date(dataset.create_time).toLocaleString('zh-CN') : '-' }}
</el-descriptions-item>
<el-descriptions-item label="描述">{{ dataset?.description || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 文件列表 -->
<div class="file-section">
<h3 class="section-title">文件列表</h3>
<el-table :data="files" style="width: 100%">
<el-table-column label="文件名" prop="name" />
<el-table-column label="大小" prop="size" width="120" />
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="selectedFileId = String(row.id || row.name); loadPreview()">预览</el-button>
<el-button link type="success" @click="handleDownloadFile(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 预览内容 -->
<div class="preview-section">
<h3 class="section-title">内容预览</h3>
<pre class="preview-pre">{{ previewLines.join('\n') }}</pre>
<div v-if="totalLines > 100" class="preview-footer">
... {{ totalLines }} 条记录已显示前 100 ...
</div> </div>
</div> <div class="overview-item">
<span>数据大小</span>
<strong>{{ dataset?.size || '-' }}</strong>
</div>
<div class="overview-item">
<span>文件数量</span>
<strong>{{ files.length }}</strong>
</div>
<div class="overview-item overview-item-wide">
<span>创建时间</span>
<strong>{{ createdAt }}</strong>
</div>
</section>
<section v-if="files.length" class="preview-workspace" aria-label="数据集文件预览">
<aside class="file-pane">
<div class="pane-heading">
<div>
<span class="pane-eyebrow">FILES</span>
<h2>数据文件</h2>
</div>
<span class="file-count">{{ files.length }}</span>
</div>
<nav class="file-list" aria-label="数据文件列表">
<button
v-for="file in files"
:key="fileKey(file)"
type="button"
class="file-item"
:class="{ 'is-active': fileKey(file) === selectedFileId }"
:aria-current="fileKey(file) === selectedFileId ? 'true' : undefined"
@click="selectFile(file)"
>
<span class="file-type" aria-hidden="true">{{ fileExtension(file.name) }}</span>
<span class="file-copy">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.size || '未知大小' }}</small>
</span>
<i v-if="fileKey(file) === selectedFileId" class="fa fa-angle-right" aria-hidden="true" />
</button>
</nav>
</aside>
<div class="content-pane">
<div class="content-toolbar">
<div class="content-title">
<span class="file-icon" aria-hidden="true"><i class="fa fa-file-text-o" /></span>
<div>
<h2>{{ selectedFile?.name }}</h2>
<p>
{{ selectedFile?.size || '未知大小' }}
<span aria-hidden="true">·</span>
{{ totalLines.toLocaleString() }}
<span v-if="totalLines > 100" aria-hidden="true">·</span>
<span v-if="totalLines > 100">显示前 100 </span>
</p>
</div>
</div>
<el-tooltip content="下载当前文件" placement="top">
<el-button
class="download-button"
aria-label="下载当前文件"
:disabled="!selectedFile"
@click="selectedFile && handleDownloadFile(selectedFile)"
>
<i class="fa fa-download" />
下载文件
</el-button>
</el-tooltip>
</div>
<div class="viewer-shell" :aria-busy="previewLoading">
<div v-if="previewLoading" class="viewer-state">
<i class="fa fa-circle-o-notch fa-spin" aria-hidden="true" />
<span>正在加载文件内容</span>
</div>
<div v-else-if="previewError" class="viewer-state viewer-state-error" role="alert">
<i class="fa fa-exclamation-circle" aria-hidden="true" />
<strong>{{ previewError }}</strong>
<el-button size="small" @click="selectedFile && selectFile(selectedFile)">重新加载</el-button>
</div>
<div v-else-if="!previewLines.length" class="viewer-state">
<i class="fa fa-file-o" aria-hidden="true" />
<span>当前文件暂无可预览内容</span>
</div>
<div v-else class="code-viewer" tabindex="0" :aria-label="`${selectedFile?.name || ''} 文件内容`">
<div v-for="(line, index) in previewLines" :key="index" class="code-line">
<span class="line-number" aria-hidden="true">{{ index + 1 }}</span>
<code>{{ line || ' ' }}</code>
</div>
</div>
</div>
<div class="viewer-footer">
<span><i class="fa fa-eye" aria-hidden="true" /> 只读预览</span>
<span>UTF-8</span>
</div>
</div>
</section>
<section v-else-if="!loading" class="empty-files" aria-label="空文件列表">
<span class="empty-icon" aria-hidden="true"><i class="fa fa-folder-open-o" /></span>
<h2>暂无数据文件</h2>
<p>该数据集还没有可供预览的文件</p>
</section>
</PageCard> </PageCard>
</template> </template>
<style scoped> <style scoped lang="scss">
.section-title { .dataset-preview-page {
min-width: 0;
}
.page-heading {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.heading-icon {
display: grid;
flex: 0 0 44px;
width: 44px;
height: 44px;
place-items: center;
border-radius: 12px;
background: linear-gradient(145deg, #eef2ff, #e0e7ff);
color: var(--primary-color);
font-size: 18px;
}
.heading-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.heading-row h1 {
margin: 0 4px 0 0;
color: #172033;
font-size: 20px;
font-weight: 650;
line-height: 1.35;
}
.page-heading p {
margin: 4px 0 0;
color: #7c8799;
font-size: 13px;
line-height: 1.5;
}
.dataset-overview {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 0.7fr)) minmax(220px, 1.2fr);
margin-bottom: 20px;
padding: 16px 20px;
border: 1px solid #edf0f5;
border-radius: 12px;
background: #fbfcfe;
}
.overview-item {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
padding: 0 20px;
border-right: 1px solid #e9edf4;
}
.overview-item:first-child {
padding-left: 0;
}
.overview-item:last-child {
padding-right: 0;
border-right: 0;
}
.overview-item span {
color: #8a94a6;
font-size: 12px;
}
.overview-item strong {
overflow: hidden;
color: #283347;
font-size: 15px;
font-variant-numeric: tabular-nums;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-workspace {
display: grid;
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr);
min-height: 520px;
overflow: hidden;
border: 1px solid #e5eaf2;
border-radius: 12px;
background: #fff;
}
.file-pane {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #e5eaf2;
background: #f8fafc;
}
.pane-heading {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 68px;
padding: 12px 16px;
border-bottom: 1px solid #e5eaf2;
}
.pane-eyebrow {
display: block;
margin-bottom: 2px;
color: #9aa4b5;
font-size: 10px;
font-weight: 700;
letter-spacing: 1.1px;
}
.pane-heading h2,
.content-title h2 {
margin: 0;
color: #263146;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #606266;
margin: 0 0 12px;
} }
.file-section { .file-count {
margin-bottom: 24px; display: grid;
} min-width: 26px;
height: 26px;
.preview-pre { place-items: center;
margin: 0; border-radius: 8px;
padding: 12px 16px; background: #e9edf4;
background: #f5f7fa; color: #596579;
border: 1px solid #ebeef5;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 12px; font-size: 12px;
line-height: 1.6; font-variant-numeric: tabular-nums;
max-height: 500px; font-weight: 600;
}
.file-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
.file-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 58px;
margin: 0 0 4px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: #526076;
cursor: pointer;
text-align: left;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.file-item:hover {
border-color: #dce2eb;
background: #fff;
}
.file-item:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 1px;
}
.file-item.is-active {
border-color: #cfd5ff;
background: #fff;
color: var(--primary-color);
box-shadow: 0 5px 16px rgba(79, 70, 229, 0.08);
}
.file-type {
display: grid;
flex: 0 0 42px;
height: 32px;
place-items: center;
border-radius: 7px;
background: #e9edf4;
color: #68758b;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.35px;
}
.file-item.is-active .file-type {
background: #eef2ff;
color: var(--primary-color);
}
.file-copy {
display: flex;
flex: 1;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.file-copy strong {
overflow: hidden;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-copy small {
color: #9aa4b5;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.content-pane {
display: flex;
flex-direction: column;
min-width: 0;
background: #fff;
}
.content-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 68px;
padding: 10px 16px;
border-bottom: 1px solid #e5eaf2;
}
.content-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.content-title > div {
min-width: 0;
}
.content-title h2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-title p {
display: flex;
gap: 6px;
margin: 3px 0 0;
color: #8a94a6;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.file-icon {
display: grid;
flex: 0 0 34px;
height: 34px;
place-items: center;
border-radius: 8px;
background: #f1f5f9;
color: #69768b;
}
.download-button {
min-height: 36px;
}
.download-button i {
margin-right: 6px;
}
.viewer-shell {
position: relative;
flex: 1;
min-height: 400px;
overflow: hidden;
background: #fbfcfe;
}
.code-viewer {
height: 100%;
max-height: 468px;
overflow: auto; overflow: auto;
white-space: pre-wrap; padding: 12px 0 18px;
word-wrap: break-word; outline: none;
} }
.preview-footer { .code-viewer:focus-visible {
margin-top: 8px; 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; font-size: 12px;
color: #909399; 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;
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;
}
.viewer-state-error > i,
.viewer-state-error strong {
color: #d14343;
}
.viewer-state-error strong {
font-weight: 500;
}
.viewer-footer {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 34px;
padding: 6px 14px;
border-top: 1px solid #e5eaf2;
background: #f8fafc;
color: #8a94a6;
font-size: 11px;
}
.viewer-footer i {
margin-right: 5px;
}
.empty-files {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-height: 420px;
border: 1px dashed #dce2eb;
border-radius: 12px;
background: #fbfcfe;
text-align: center; text-align: center;
} }
.empty-icon {
display: grid;
width: 54px;
height: 54px;
margin-bottom: 14px;
place-items: center;
border-radius: 14px;
background: #eef2f7;
color: #8a96a8;
font-size: 22px;
}
.empty-files h2 {
margin: 0;
color: #354056;
font-size: 15px;
}
.empty-files p {
margin: 6px 0 0;
color: #929daf;
font-size: 13px;
}
@media (max-width: 900px) {
.dataset-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 0;
}
.overview-item:nth-child(2) {
border-right: 0;
}
.overview-item:nth-child(3) {
padding-left: 0;
}
.preview-workspace {
grid-template-columns: 220px minmax(0, 1fr);
}
}
@media (max-width: 680px) {
.dataset-overview {
grid-template-columns: 1fr;
}
.overview-item {
padding: 0 0 12px;
border-right: 0;
border-bottom: 1px solid #e9edf4;
}
.overview-item:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.preview-workspace {
grid-template-columns: 1fr;
}
.file-pane {
max-height: 240px;
border-right: 0;
border-bottom: 1px solid #e5eaf2;
}
.content-toolbar {
align-items: flex-start;
}
.download-button {
width: 44px;
padding: 8px;
font-size: 0;
}
.download-button i {
margin: 0;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.file-item {
transition: none;
}
}
</style> </style>