Files
YG_FT/frontend/src/views/dataset/DatasetPreviewView.vue
caoxiaozhu 1455ee7f97 feat: 增强数据集预览功能
重构数据集预览页支持多文件切换与内容高亮,Mock 新增预览数据集与按文件 ID 路由的内容返回,并补充回归测试脚本与 npm 脚本注册。
2026-07-11 10:39:26 +08:00

698 lines
16 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import PageCard from '@/components/PageCard.vue'
import { downloadFileUrl, getDataset, previewDatasetFile } from '@/api/modules/dataset'
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
import type { DatasetFile, DatasetItem } from '@/types'
const route = useRoute()
const datasetId = route.params.id as string
const loading = ref(false)
const previewLoading = ref(false)
const dataset = ref<DatasetItem | null>(null)
const selectedFileId = ref('')
const previewContent = ref('')
const previewError = ref('')
let previewRequestId = 0
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')
: '-')
function fileKey(file: DatasetFile) {
return String(file.id || file.name)
}
function fileExtension(fileName = '') {
return fileName.includes('.') ? fileName.split('.').pop()?.toUpperCase() : 'FILE'
}
async function loadDataset() {
loading.value = true
try {
dataset.value = await getDataset(datasetId)
const firstFile = files.value[0]
if (firstFile) await selectFile(firstFile)
} finally {
loading.value = false
}
}
async function selectFile(file: DatasetFile) {
const nextFileId = fileKey(file)
selectedFileId.value = nextFileId
previewContent.value = ''
previewError.value = ''
previewLoading.value = true
const requestId = ++previewRequestId
try {
const res = await previewDatasetFile(nextFileId)
if (requestId === previewRequestId) previewContent.value = res.content || ''
} catch {
if (requestId === previewRequestId) previewError.value = '内容加载失败,请稍后重试'
} finally {
if (requestId === previewRequestId) previewLoading.value = false
}
}
function handleDownloadFile(file: DatasetFile) {
window.open(downloadFileUrl(datasetId, fileKey(file)), '_blank')
}
onMounted(loadDataset)
</script>
<template>
<PageCard class="dataset-preview-page" v-loading="loading">
<template #header>
<div class="page-heading">
<div class="heading-icon" aria-hidden="true">
<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>
<section class="dataset-overview" aria-label="数据集概览">
<div class="overview-item">
<span>数据条数</span>
<strong>{{ (dataset?.count || 0).toLocaleString() }}</strong>
</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>
</template>
<style scoped lang="scss">
.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-weight: 600;
}
.file-count {
display: grid;
min-width: 26px;
height: 26px;
place-items: center;
border-radius: 8px;
background: #e9edf4;
color: #596579;
font-size: 12px;
font-variant-numeric: tabular-nums;
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;
padding: 12px 0 18px;
outline: none;
}
.code-viewer:focus-visible {
box-shadow: inset 0 0 0 2px var(--el-color-primary-light-5);
}
.code-line {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
min-height: 25px;
color: #344054;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 12px;
line-height: 1.65;
}
.code-line:hover {
background: #f4f6fa;
}
.line-number {
padding-right: 14px;
border-right: 1px solid #edf0f5;
color: #a4adbc;
font-variant-numeric: tabular-nums;
text-align: right;
user-select: none;
}
.code-line code {
display: block;
min-width: max-content;
padding: 0 18px;
white-space: pre;
}
.viewer-state {
display: flex;
align-items: center;
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;
}
.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>