feat: 实现业务视图页面

登录、模型调优、评测、推理、对比、模型管理、数据集、数据处理、工具、系统(硬件/日志/训练日志)等全部业务页面视图。
This commit is contained in:
caoxiaozhu
2026-07-10 16:45:55 +08:00
parent c1893cf82c
commit 6f6609dae5
30 changed files with 9383 additions and 0 deletions

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
import {
getDataset,
createDataset,
updateDataset,
uploadDatasetFiles,
} from '@/api/modules/dataset'
import type { DatasetItem } from '@/types'
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const submitting = ref(false)
const isEdit = computed(() => !!route.params.id)
const editId = computed(() => route.params.id as string | undefined)
/** 允许的文件类型 */
const acceptTypes = '.json,.jsonl'
const form = reactive({
name: '',
description: '',
dataset_type: 'train' as 'train' | 'eval',
storage: 'local' as 'local' | 'cloud' | 'minio',
// MinIO 配置
minio_endpoint: '',
minio_bucket: '',
minio_access_key: '',
minio_secret_key: '',
minio_ssl: false,
})
const files = ref<File[]>([])
const fileCount = ref(0)
const formatValid = ref<boolean | null>(null)
const formatMessage = ref('')
const rules: FormRules = {
name: [
{ required: true, message: '请输入数据集名称', trigger: 'blur' },
{ max: 20, message: '不超过 20 字符', trigger: 'blur' },
],
description: [{ max: 50, message: '不超过 50 字符', trigger: 'blur' }],
}
/** 处理文件选择(替换模式:新文件覆盖旧文件) */
async function handleFileChange(uploadFile: UploadFile) {
const raw = uploadFile.raw
if (!raw) return
const ext = raw.name.split('.').pop()?.toLowerCase()
if (ext !== 'json' && ext !== 'jsonl') {
ElMessage.warning('仅支持 JSON/JSONL 格式')
return
}
if (raw.size > 200 * 1024 * 1024) {
ElMessage.warning('单文件不能超过 200MB')
return
}
files.value = [raw] // 替换模式
await analyzeFile(raw)
}
/** 前端解析文件统计条数并校验 Alpaca 格式 */
async function analyzeFile(file: File) {
try {
const text = await file.text()
const lines = text.trim().split('\n').filter(Boolean)
fileCount.value = lines.length
// Alpaca 格式校验:每行 JSON 须含 instruction 字段
let validCount = 0
for (const line of lines) {
try {
const obj = JSON.parse(line)
if (obj.instruction !== undefined) validCount++
} catch {
// 非 JSON 行(如纯 JSONL 多行结构)
}
}
if (validCount > 0 && validCount === lines.length) {
formatValid.value = true
formatMessage.value = `符合 Alpaca 格式(含 instruction 字段)`
} else if (validCount > 0) {
formatValid.value = true
formatMessage.value = `部分符合 Alpaca 格式(${validCount}/${lines.length}`
} else {
formatValid.value = false
formatMessage.value = '未检测到标准 Alpaca 格式(缺少 instruction 字段),仍可上传'
}
} catch {
fileCount.value = 0
formatValid.value = null
formatMessage.value = '文件解析失败'
}
}
/** 自定义上传(阻止自动上传,仅收集文件) */
function customUpload() {
return Promise.resolve()
}
function handleRemove() {
files.value = []
fileCount.value = 0
formatValid.value = null
formatMessage.value = ''
}
async function loadEditData() {
if (!editId.value) return
loading.value = true
try {
const ds: any = await getDataset(editId.value)
Object.assign(form, {
name: ds.name || '',
description: ds.description || '',
dataset_type: ds.type === 'eval' ? 'eval' : 'train',
storage: ds.storage_type || 'local',
})
fileCount.value = ds.count || 0
} catch {
// ignore
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (!isEdit.value && files.value.length === 0) {
ElMessage.warning('请至少上传一个数据集文件')
return
}
submitting.value = true
try {
const baseData: Partial<DatasetItem> = {
name: form.name,
type: form.dataset_type,
storage_type: form.storage,
description: form.description,
count: fileCount.value,
}
// MinIO 配置
if (form.storage === 'minio') {
;(baseData as any).minio_config = {
endpoint: form.minio_endpoint,
bucket: form.minio_bucket,
access_key: form.minio_access_key,
secret_key: form.minio_secret_key,
ssl: form.minio_ssl,
}
}
if (isEdit.value && editId.value) {
// 编辑:更新记录 + 可选上传新文件
await updateDataset(editId.value, baseData)
if (files.value.length > 0) {
await uploadDatasetFiles(editId.value, files.value)
}
ElMessage.success('更新成功')
} else {
// 新建:创建记录 → 上传文件 → 更新 count
const res: any = await createDataset(baseData)
const newId = res?.id || res
if (files.value.length > 0) {
await uploadDatasetFiles(newId, files.value)
await updateDataset(newId, { count: fileCount.value })
}
ElMessage.success('上传成功')
}
router.push('/dataset')
} catch {
// ignore
} finally {
submitting.value = false
}
})
}
function handleCancel() {
router.back()
}
onMounted(loadEditData)
</script>
<template>
<PageCard :title="isEdit ? '编辑数据集' : '上传数据集'" v-loading="loading">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
style="max-width: 640px"
>
<el-form-item label="数据集名称" prop="name">
<el-input v-model="form.name" placeholder="请输入数据集名称" maxlength="20" show-word-limit />
</el-form-item>
<el-form-item label="数据集描述">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
maxlength="50"
show-word-limit
placeholder="选填"
/>
</el-form-item>
<el-form-item label="数据集类型">
<el-radio-group v-model="form.dataset_type">
<el-radio value="train">训练集</el-radio>
<el-radio value="eval">评测集</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="存储位置">
<el-radio-group v-model="form.storage">
<el-radio value="local">本地</el-radio>
<el-radio value="cloud">云平台</el-radio>
<el-radio value="minio">MinIO</el-radio>
</el-radio-group>
</el-form-item>
<!-- MinIO 配置 -->
<template v-if="form.storage === 'minio'">
<el-form-item label="Endpoint">
<el-input v-model="form.minio_endpoint" placeholder="如http://minio:9000" />
</el-form-item>
<el-form-item label="Bucket">
<el-input v-model="form.minio_bucket" placeholder="请输入 Bucket 名称" />
</el-form-item>
<el-form-item label="Access Key">
<el-input v-model="form.minio_access_key" placeholder="请输入 Access Key" />
</el-form-item>
<el-form-item label="Secret Key">
<el-input v-model="form.minio_secret_key" type="password" show-password placeholder="请输入 Secret Key" />
</el-form-item>
<el-form-item label="启用 SSL">
<el-switch v-model="form.minio_ssl" />
</el-form-item>
</template>
<!-- 文件上传仅本地存储 -->
<el-form-item v-if="form.storage === 'local'" label="上传文件">
<div style="width: 100%">
<el-upload
drag
:accept="acceptTypes"
:auto-upload="false"
:show-file-list="false"
:http-request="customUpload"
:on-change="handleFileChange"
>
<i class="fa fa-cloud-upload" style="font-size: 32px; color: #1890ff" />
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">仅支持 JSON/JSONL 格式单文件不超过 200MB</div>
</template>
</el-upload>
<!-- 已选文件列表 -->
<div v-for="f in files" :key="f.name" class="file-item">
<div class="file-info">
<i class="fa fa-file-code-o" />
<span class="file-name">{{ f.name }}</span>
<span class="file-size">{{ (f.size / 1024).toFixed(1) }} KB</span>
</div>
<el-button link type="danger" size="small" @click="handleRemove">
<i class="fa fa-times" />
</el-button>
</div>
<!-- 格式校验提示 -->
<el-alert
v-if="formatMessage"
:title="formatMessage"
:type="formatValid ? 'success' : 'warning'"
:closable="false"
show-icon
style="margin-top: 8px"
/>
<div v-if="fileCount" class="record-count">解析到 {{ fileCount }} 条记录</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? '保存' : '上传' }}
</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</PageCard>
</template>
<style scoped>
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid #ebeef5;
border-radius: 4px;
margin-top: 8px;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
}
.file-name {
color: #303133;
}
.file-size {
color: #909399;
font-size: 12px;
}
.record-count {
margin-top: 8px;
font-size: 13px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import { getDatasetList, deleteDataset, downloadDatasetUrl } from '@/api/modules/dataset'
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
import type { DatasetItem } from '@/types'
const router = useRouter()
const loading = ref(false)
const dataList = ref<DatasetItem[]>([])
const activeTab = ref('upload')
const filteredDataList = computed(() => {
if (activeTab.value === 'upload') {
return dataList.value
} else {
// 假设数据任务产生的数据集可以通过某个字段区分,目前 mock 数据没有该字段,所以暂为空
return []
}
})
async function loadData() {
loading.value = true
try {
dataList.value = (await getDatasetList()) || []
} catch {
// ignore
} finally {
loading.value = false
}
}
async function handleDelete(row: any) {
await deleteDataset(row.id)
ElMessage.success('删除成功')
}
function handlePreview(row: any) {
router.push(`/dataset/${row.id}/preview`)
}
function handleDownload(row: any) {
window.open(downloadDatasetUrl(row.id), '_blank')
}
onMounted(loadData)
</script>
<template>
<DataTablePage
title="数据集管理"
:data="filteredDataList"
:loading="loading"
searchable
:search-fields="['name', 'description']"
:create-text="activeTab === 'upload' ? '上传数据集' : ''"
create-to="/dataset/create"
:delete-fn="handleDelete"
row-key="id"
@refresh="loadData"
>
<template #title>
<div class="capsule-tabs">
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'upload' }"
@click="activeTab = 'upload'"
>
本地上传
</button>
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'task' }"
@click="activeTab = 'task'"
>
数据任务
</button>
</div>
</template>
<template #columns>
<el-table-column label="数据集名称" prop="name" align="center" />
<el-table-column label="数据类型" align="center" width="110">
<template #default="{ row }">
<el-tag type="primary" size="small">
{{ DATASET_TYPE_MAP[String(row.type).toLowerCase()] || row.type || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="存储位置" align="center" width="110">
<template #default="{ row }">
<el-tag type="success" size="small">
{{ STORAGE_MAP[row.storage_type] || row.storage_type || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" align="center" width="100">
<template #default="{ row }">
{{ row.size && row.size !== '0 B' && row.size !== '0' ? row.size : '-' }}
</template>
</el-table-column>
<el-table-column label="数据条数" align="center" width="100">
<template #default="{ row }">{{ row.count || 0 }}</template>
</el-table-column>
<el-table-column label="描述" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" align="center" width="180">
<template #default="{ row }">
{{ row.create_time ? new Date(row.create_time).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
</template>
<template #actions="{ row }">
<div class="action-buttons">
<el-button type="primary" link size="small" @click="handlePreview(row)">
<i class="fa fa-eye" style="margin-right: 4px" />预览
</el-button>
<el-button type="success" link size="small" @click="handleDownload(row)">
<i class="fa fa-download" style="margin-right: 4px" />下载
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
</el-button>
</div>
</template>
</DataTablePage>
</template>
<style scoped lang="scss">
/* 胶囊切换栏样式 */
.capsule-tabs {
display: flex;
background: #f1f5f9;
padding: 3px;
border-radius: 8px;
gap: 2px;
border: 1px solid #e2e8f0;
}
.capsule-tab-item {
border: 0;
background: transparent;
padding: 6px 20px;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
outline: none;
&:hover {
color: #1e293b;
}
&.active {
background: #fff;
color: #4f46e5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
import {
getDataset,
previewDatasetFile,
deleteDataset,
downloadDatasetUrl,
downloadFileUrl,
} from '@/api/modules/dataset'
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
import type { DatasetItem, DatasetFile } from '@/types'
const route = useRoute()
const router = useRouter()
const datasetId = route.params.id as string
const loading = ref(false)
const dataset = ref<DatasetItem | null>(null)
const selectedFileId = ref<string>('')
const previewContent = ref('')
const files = computed<DatasetFile[]>(() => dataset.value?.files || [])
const previewLines = computed(() => previewContent.value.split('\n').slice(0, 100))
const totalLines = computed(() => previewContent.value.split('\n').length)
async function loadDataset() {
loading.value = true
try {
dataset.value = await getDataset(datasetId)
if (files.value.length > 0) {
selectedFileId.value = String(files.value[0].id || files.value[0].name)
loadPreview()
}
} catch {
// ignore
} finally {
loading.value = false
}
}
async function loadPreview() {
if (!selectedFileId.value) return
try {
const res = await previewDatasetFile(selectedFileId.value)
previewContent.value = res.content || ''
} catch {
previewContent.value = '加载失败'
}
}
function handleDownloadFile(file: any) {
const fid = file.id || file.name
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)
</script>
<template>
<PageCard title="数据集预览" v-loading="loading">
<template #extra>
<el-button @click="handleDownloadAll"><i class="fa fa-download" /> 打包下载</el-button>
<el-button type="danger" @click="handleDelete"><i class="fa fa-trash" /> 删除</el-button>
<el-button @click="router.back()">返回</el-button>
</template>
<!-- 数据集信息 -->
<el-descriptions :column="3" border style="margin-bottom: 20px">
<el-descriptions-item label="名称">{{ dataset?.name }}</el-descriptions-item>
<el-descriptions-item label="类型">
{{ 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>
</PageCard>
</template>
<style scoped>
.section-title {
font-size: 14px;
font-weight: 600;
color: #606266;
margin: 0 0 12px;
}
.file-section {
margin-bottom: 24px;
}
.preview-pre {
margin: 0;
padding: 12px 16px;
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
line-height: 1.6;
max-height: 500px;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.preview-footer {
margin-top: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
</style>